Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c900634c9 | ||
|
|
8cce41fd71 | ||
|
|
114a6ee21e | ||
|
|
8246640f49 | ||
|
|
ba2faace15 | ||
|
|
88355ee276 | ||
|
|
568490e625 | ||
|
|
85da1f047d | ||
|
|
6587a924e9 | ||
|
|
a359ce7835 | ||
|
|
e2e348d5c5 | ||
|
|
7f9cf2a4a5 | ||
|
|
e511e7c10b | ||
|
|
05a06a4dc8 | ||
|
|
1dcd3a178e | ||
|
|
385741027a | ||
|
|
972aeaaed6 | ||
|
|
5066e71897 | ||
|
|
02901c9cbc | ||
|
|
d6fd32994f | ||
|
|
143da0a940 | ||
|
|
dc5157f973 | ||
|
|
956b91c462 | ||
|
|
c980366278 |
No files matched your search
@@ -0,0 +1,125 @@
|
|||||||
|
Rivencraft Build Instructions
|
||||||
|
============================
|
||||||
|
|
||||||
|
These instructions apply when source code, models, textures, data, or other
|
||||||
|
resources have been changed.
|
||||||
|
|
||||||
|
Requirements
|
||||||
|
------------
|
||||||
|
|
||||||
|
- Java 21 JDK
|
||||||
|
- A working Gradle wrapper
|
||||||
|
- The project dependencies available in the Gradle cache, or network access
|
||||||
|
for Gradle to download them
|
||||||
|
|
||||||
|
The project targets Minecraft 1.21.1 and NeoForge 21.1.251.
|
||||||
|
|
||||||
|
1. Open the project directory
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
cd /path/to/rivencraft
|
||||||
|
|
||||||
|
2. Check the changed files
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
Confirm that model, texture, and other resource files are inside
|
||||||
|
`src/main/resources`. Java source files belong under `src/main/java`.
|
||||||
|
|
||||||
|
For example, block models are normally stored under:
|
||||||
|
|
||||||
|
src/main/resources/assets/rivencraft/models/block/
|
||||||
|
|
||||||
|
Resource files placed under `src/main/resources` are automatically included in
|
||||||
|
the finished mod JAR by Gradle.
|
||||||
|
|
||||||
|
3. Update the version when appropriate
|
||||||
|
---------------------------------------
|
||||||
|
|
||||||
|
Rivencraft uses the version format `x.y.z.b`:
|
||||||
|
|
||||||
|
- `x`: major release
|
||||||
|
- `y`: milestone completion
|
||||||
|
- `z`: release slice
|
||||||
|
- `b`: bug fixes and additional work within that slice
|
||||||
|
|
||||||
|
Edit `mod_version` in `gradle.properties` before creating a new release. Do
|
||||||
|
not reuse a version that has already been published.
|
||||||
|
|
||||||
|
4. Build the mod
|
||||||
|
----------------
|
||||||
|
|
||||||
|
From the project directory, run:
|
||||||
|
|
||||||
|
./gradlew build --offline
|
||||||
|
|
||||||
|
If the required dependencies are not cached locally, remove `--offline`:
|
||||||
|
|
||||||
|
./gradlew build
|
||||||
|
|
||||||
|
On systems using mise-managed Java and Gradle, use:
|
||||||
|
|
||||||
|
mise exec -- ./gradlew build --offline
|
||||||
|
|
||||||
|
5. Run the automated tests
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
To build and run the GameTest suite:
|
||||||
|
|
||||||
|
./gradlew build runGameTestServer --offline
|
||||||
|
|
||||||
|
Or, with mise:
|
||||||
|
|
||||||
|
mise exec -- ./gradlew build runGameTestServer --offline
|
||||||
|
|
||||||
|
All required GameTests should pass before distributing a build.
|
||||||
|
|
||||||
|
GameTests use `build/gametest-run/world`, separate from the normal development
|
||||||
|
world. Before each run, Gradle removes that test world if it exceeds 512 MiB.
|
||||||
|
Override the limit with `-PgameTestMaxWorldMiB=NUMBER`. A caller-supplied
|
||||||
|
`-PgameTestRunDir` is never removed automatically.
|
||||||
|
|
||||||
|
To run the suite in a fresh temporary directory instead, use:
|
||||||
|
|
||||||
|
test_dir=$(mktemp -d /tmp/rivencraft-gametest.XXXXXX)
|
||||||
|
./gradlew runGameTestServer --offline -PgameTestRunDir="$test_dir"
|
||||||
|
|
||||||
|
With mise, prefix the second command with `mise exec --`.
|
||||||
|
|
||||||
|
6. Locate and inspect the JAR
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Build outputs are written to:
|
||||||
|
|
||||||
|
build/libs/
|
||||||
|
|
||||||
|
The filename uses the configured version, for example:
|
||||||
|
|
||||||
|
build/libs/rivencraft-0.3.6.9.jar
|
||||||
|
|
||||||
|
To verify that changed resource files are packaged:
|
||||||
|
|
||||||
|
unzip -l build/libs/rivencraft-VERSION.jar | rg 'models|textures|data'
|
||||||
|
|
||||||
|
Replace `VERSION` with the actual version number.
|
||||||
|
|
||||||
|
To create a checksum for distribution:
|
||||||
|
|
||||||
|
sha256sum build/libs/rivencraft-VERSION.jar
|
||||||
|
|
||||||
|
7. Stop the Gradle daemon when finished
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
This is optional, but useful after a long test run:
|
||||||
|
|
||||||
|
./gradlew --stop
|
||||||
|
|
||||||
|
Release checklist
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
- Confirm the version was incremented when required.
|
||||||
|
- Confirm the build completed successfully.
|
||||||
|
- Confirm all required GameTests passed.
|
||||||
|
- Confirm changed resources are present in the JAR.
|
||||||
|
- Generate and record the SHA-256 checksum.
|
||||||
|
- Test the JAR in the target Minecraft 1.21.1 NeoForge instance.
|
||||||
|
- Publish the JAR only after client-side testing is complete.
|
||||||
@@ -6,6 +6,19 @@ It is not an official release of Mystcraft.
|
|||||||
Mystcraft Legacy source: https://github.com/Mystcraft/Mystcraft-Legacy
|
Mystcraft Legacy source: https://github.com/Mystcraft/Mystcraft-Legacy
|
||||||
Mystcraft Legacy copyright: XCompWiz and contributors
|
Mystcraft Legacy copyright: XCompWiz and contributors
|
||||||
Mystcraft Legacy license: GNU Lesser General Public License version 3
|
Mystcraft Legacy license: GNU Lesser General Public License version 3
|
||||||
|
The animated ink-vial item texture in this project is copied from Mystcraft
|
||||||
|
Legacy's assets/mystcraft/textures/items/ink_vial.png under that license.
|
||||||
|
The Notebook page atlas is copied from Legacy's assets/mystcraft/gui/bookui_pagel.png
|
||||||
|
under the same license.
|
||||||
|
The Writing Desk item texture is copied from Legacy's
|
||||||
|
assets/mystcraft/textures/items/writingdesk.png under the same license.
|
||||||
|
The Writing Desk block texture is copied from Legacy's
|
||||||
|
assets/mystcraft/textures/entity/desk.png under the same license.
|
||||||
|
The Writing Desk GUI atlas is copied from Legacy's assets/mystcraft/gui/writingdesk.png
|
||||||
|
under the same license; desk wood and inlay textures are cropped from desk.png.
|
||||||
|
The Ink Mixer GUI atlas and block textures are copied from Legacy's
|
||||||
|
assets/mystcraft/gui/inkmixer.png and textures/blocks/inkmixer_*.png under
|
||||||
|
the same license. Rivencraft's block model is original and uses those textures.
|
||||||
|
|
||||||
The initial build layout is derived from the official NeoForge 1.21.1
|
The initial build layout is derived from the official NeoForge 1.21.1
|
||||||
ModDevGradle MDK: https://github.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle
|
ModDevGradle MDK: https://github.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle
|
||||||
@@ -4,8 +4,8 @@ Rivencraft is a clean, incremental port of the ideas and gameplay of
|
|||||||
[Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft
|
[Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft
|
||||||
1.21.1 on NeoForge.
|
1.21.1 on NeoForge.
|
||||||
|
|
||||||
The bootstrap, books/linking, and Ages milestones are complete. Milestone 4 is
|
The bootstrap, books/linking, Ages, and writing-system milestones are complete.
|
||||||
building the writing system on a data-pack symbol catalog and persistent
|
The writing system uses a data-pack symbol catalog and persistent
|
||||||
ordered-writing model. The project currently provides functional Linking Books
|
ordered-writing model. The project currently provides functional Linking Books
|
||||||
and randomly seeded, persistent vanilla-like Ages created from page-free
|
and randomly seeded, persistent vanilla-like Ages created from page-free
|
||||||
Descriptive Books. Instability and full symbol-to-world grammar interpretation
|
Descriptive Books. Instability and full symbol-to-world grammar interpretation
|
||||||
@@ -13,18 +13,107 @@ have not been ported yet.
|
|||||||
|
|
||||||
Milestone 4 now includes stackable blank Pages plus server-created symbol,
|
Milestone 4 now includes stackable blank Pages plus server-created symbol,
|
||||||
unresolved-symbol, and baseline link-panel states. Page visuals follow the
|
unresolved-symbol, and baseline link-panel states. Page visuals follow the
|
||||||
legacy runtime-composition rules; written-page creation will become available
|
legacy runtime-composition rules. Craft an Ink Vial from two black dye, a glass
|
||||||
through the Writing Desk work that follows.
|
bottle, and a water bucket; the bucket is returned. Hold vanilla paper in one
|
||||||
|
hand and use the Ink Vial in the other to prepare a baseline Link Panel Page.
|
||||||
|
Each vial holds 16 ink units, and the default link-panel cost is two units.
|
||||||
|
The spent vial returns a glass bottle. Server owners can adjust the symbol-copy
|
||||||
|
and link-panel costs in the `writing` section of `rivencraft-server.toml`.
|
||||||
|
The Ink Mixer now offers a Legacy-style station for producing Link Panels:
|
||||||
|
insert paper and Ink Vials, then take a Link Panel from the result slot. Each
|
||||||
|
vial fills its 16-unit basin and returns a glass bottle; no server tick is
|
||||||
|
needed. The earlier paper-in-one-hand method remains available. Ink modifier
|
||||||
|
ingredients and link properties are planned for Milestone 5.
|
||||||
|
The Writing Desk can copy a selected written page from its collection tabs.
|
||||||
|
The Writing Desk is now craftable from planks, a glass bottle, and a feather.
|
||||||
|
It occupies two adjacent blocks, with one shared inventory; loaded paper and
|
||||||
|
the selected page appear on its Legacy-inspired model.
|
||||||
|
Desks placed before 0.3.6.1 keep their old one-block footprint until broken
|
||||||
|
and placed again; leave an adjacent space when replacing one.
|
||||||
|
Its 88 collection tabs, paper input, ink vial input, empty glass-bottle return,
|
||||||
|
16-unit ink meter, and inventory persist
|
||||||
|
through world saves; breaking it drops stored contents. The desk screen is a
|
||||||
|
functional foundation using Legacy's original desk and scrollbar textures.
|
||||||
|
Four tabs are visible at once; the controls above and below them move by four
|
||||||
|
(1–4, 5–8, through 85–88). Each viewer can browse a different group. Existing
|
||||||
|
four-tab desks retain their collections and supplies when upgraded.
|
||||||
|
With a notebook selected, pick up a Page from your inventory and click an empty
|
||||||
|
position on the left surface to store one page there. Clear search/filter/sort
|
||||||
|
and turn off `ALL` to place pages at specific positions; occupied positions are
|
||||||
|
never overwritten. A notebook tab exposes all 27 notebook positions (scroll to
|
||||||
|
reach the rest); a loose-page tab exposes only its one occupied position.
|
||||||
|
Empty tabs have no page storage until a notebook or page is put in the tab slot.
|
||||||
|
Click a page on the desk surface to select it for copying. Drag a page from one
|
||||||
|
notebook position to an empty position to move it, or onto another page to swap
|
||||||
|
their positions. This also works between notebooks shown in the `ALL` view.
|
||||||
|
Drag a page out of the panel to turn it back into an item. Drop it over a player
|
||||||
|
inventory slot or desk tab to place it there; a tab with a notebook adds it to
|
||||||
|
the first open position. Dropping elsewhere leaves the page on the cursor for
|
||||||
|
normal inventory placement. The source notebook or tab loses the page only after
|
||||||
|
a valid drop.
|
||||||
|
Put vanilla paper in the
|
||||||
|
upper-left slot and Ink Vials in the upper-right; a copy appears in the
|
||||||
|
lower-left result slot when the selected page is valid. Taking it consumes one
|
||||||
|
paper and, by default, one ink. An available vial refills an empty ink meter
|
||||||
|
when inserted or when copying uses its last ink, and returns a vanilla glass
|
||||||
|
bottle to the lower-right slot. Removing a bottle also retries a blocked refill.
|
||||||
|
Refilling waits if that slot is full or contains another item. The `AZ` control sorts
|
||||||
|
visible pages by name; `ALL` shows pages held across every desk tab. Search and
|
||||||
|
category filtering narrow the view without granting new pages, and cross-tab
|
||||||
|
copy selection is checked by the server.
|
||||||
|
Selecting a stored page on the desk surface shows its Legacy-proportioned
|
||||||
|
portrait preview and name above the player inventory.
|
||||||
|
|
||||||
|
## Book Binder
|
||||||
|
|
||||||
|
For a new survival Age: craft an Ink Vial, an Ink Mixer, a Writing Desk, and a Book Binder.
|
||||||
|
Put vanilla paper and an Ink Vial in the Ink Mixer to make a Link Panel
|
||||||
|
Page. Place leather (or an empty Notebook) and the Link Panel in the Book Binder,
|
||||||
|
enter an Age name, and take the finished Descriptive Book. Written symbol Pages
|
||||||
|
are optional; without them, the book creates a random Age. To duplicate a Page
|
||||||
|
you already own, store it in a Writing Desk tab, select it, supply paper and ink,
|
||||||
|
and take the copied Page from the output. A Notebook can hold and transfer a
|
||||||
|
larger ordered collection of Pages between desks and the binder.
|
||||||
|
|
||||||
|
With JEI installed, Rivencraft displays the Ink Mixer operation in its own
|
||||||
|
category and the hand-use, copying, binding, and Linking Book crafting flows in a Writing category.
|
||||||
|
JEI is optional; vanilla crafting
|
||||||
|
recipes and all writing interactions work without it. For a development client
|
||||||
|
with JEI, run `./gradlew runClient -PwithJei=true`.
|
||||||
|
|
||||||
|
Craft a Book Binder from three iron ingots and five planks. Put leather or an
|
||||||
|
empty Notebook in the left cover slot. Enter a title, then place a Link Panel
|
||||||
|
Page first in the horizontal page strip. Click a page in the strip with a
|
||||||
|
written Page on the cursor to insert it before that position; click it with an
|
||||||
|
empty cursor to take it out. The arrows or mouse wheel scroll through up to
|
||||||
|
27 written Pages. Clicking the strip with a filled Notebook imports its Pages
|
||||||
|
at that position and leaves the emptied Notebook on the cursor. The right slot
|
||||||
|
outputs a Descriptive Book once the title, cover, and Link Panel are present.
|
||||||
|
Inputs are consumed only when the result is taken.
|
||||||
|
|
||||||
|
The completed book receives its Age identity when assembled. With only a Link
|
||||||
|
Panel, it describes a randomly generated Age. Its writing is stored on the
|
||||||
|
book; the Descriptive Book screen's turned pages show ordered symbols and
|
||||||
|
unresolved IDs. Until Milestone 5, symbols do not affect generation and written
|
||||||
|
books use the same random Age fallback. The Descriptive Book screen follows
|
||||||
|
Legacy's book spread: the title and Age details are on the left, the destination
|
||||||
|
preview is on the right, and clicks in the bottom corners turn pages. Ordered
|
||||||
|
symbol pages appear before the final blank spread; their pictograms await
|
||||||
|
Milestone 5. Link-panel-only books show their details in the enchantment-table
|
||||||
|
font.
|
||||||
|
|
||||||
## Linking Book
|
## Linking Book
|
||||||
|
|
||||||
Craft a Linking Book from a vanilla book and an ender pearl, then use it in the
|
Craft an unwritten Linking Book from one Link Panel Page and one leather in any
|
||||||
air. An unbound book opens a screen where it can be named and bound to the
|
two crafting-grid positions. An unbound book opens a screen where it can be named and bound to the
|
||||||
current location. A bound book opens its destination screen and travels when
|
current location. A bound book opens its destination screen and travels when
|
||||||
its preview panel is clicked. The destination is stored on the item and
|
its preview panel is clicked. The destination is stored on the item and
|
||||||
survives inventory and world saves. Travel only succeeds if the target
|
survives inventory and world saves. Travel only succeeds if the target
|
||||||
dimension is loaded and a non-fluid, passable landing position can be found
|
dimension is loaded and a non-fluid, passable landing position can be found
|
||||||
nearby.
|
nearby.
|
||||||
|
The Linking Book uses the same Legacy page layout and bottom-corner page turns
|
||||||
|
as the Descriptive Book, but without the Descriptive Book's gold cover edges.
|
||||||
|
Both screens play Minecraft's book-page-turn sound when a page is turned.
|
||||||
|
|
||||||
Successful book travel plays Mystcraft Legacy's original linking sound at both
|
Successful book travel plays Mystcraft Legacy's original linking sound at both
|
||||||
the departure and arrival locations.
|
the departure and arrival locations.
|
||||||
@@ -100,6 +189,10 @@ known upstream limits are documented in
|
|||||||
```
|
```
|
||||||
|
|
||||||
The built mod is written to `build/libs/`.
|
The built mod is written to `build/libs/`.
|
||||||
|
The default dedicated-server GameTest run is deliberately small. Use
|
||||||
|
`./gradlew runGameTestServer -PwithArchivedGameTests=true` for the complete
|
||||||
|
regression suite before a release or when investigating a related issue. See
|
||||||
|
[docs/GAMETESTS.md](docs/GAMETESTS.md).
|
||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ tasks.named('wrapper', Wrapper).configure {
|
|||||||
version = mod_version
|
version = mod_version
|
||||||
group = mod_group_id
|
group = mod_group_id
|
||||||
|
|
||||||
|
List<String> archivedGameTestSuites = ['linking', 'writing', 'desk', 'binder', 'mixer', 'recipe']
|
||||||
|
List<String> selectedArchivedGameTestSuites = providers.gradleProperty('withArchivedGameTests')
|
||||||
|
.map { it.toBoolean() }.orElse(false).get() ? archivedGameTestSuites
|
||||||
|
: providers.gradleProperty('archivedGameTestSuites').orElse('').get()
|
||||||
|
.split(',').collect { it.trim() }.findAll { !it.isEmpty() }
|
||||||
|
if (!archivedGameTestSuites.containsAll(selectedArchivedGameTestSuites)) {
|
||||||
|
throw new GradleException("Unknown archived GameTest suite. Choose from: ${archivedGameTestSuites.join(', ')}")
|
||||||
|
}
|
||||||
|
String enabledGameTestNamespaces = ([mod_id] + selectedArchivedGameTestSuites.collect {
|
||||||
|
"rivencraft_archive_${it}"
|
||||||
|
}).join(',')
|
||||||
|
|
||||||
sourceSets.main.resources {
|
sourceSets.main.resources {
|
||||||
// Include resources generated by data generators.
|
// Include resources generated by data generators.
|
||||||
srcDir('src/generated/resources')
|
srcDir('src/generated/resources')
|
||||||
@@ -26,7 +38,24 @@ sourceSets.main.resources {
|
|||||||
exclude("src/generated/**/.cache") // datagen cache files
|
exclude("src/generated/**/.cache") // datagen cache files
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Archived suites use separate namespaces but share the same empty structure.
|
||||||
|
tasks.named('processResources', ProcessResources).configure {
|
||||||
|
archivedGameTestSuites.each { suite ->
|
||||||
|
from('src/main/resources/data/rivencraft/structure/empty.nbt') {
|
||||||
|
into "data/rivencraft_archive_${suite}/structure"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
|
maven {
|
||||||
|
name = 'JaredsMaven'
|
||||||
|
url = 'https://maven.blamejared.com'
|
||||||
|
content {
|
||||||
|
includeGroup 'mezz.jei'
|
||||||
|
includeGroup 'net.mezzdev.config'
|
||||||
|
}
|
||||||
|
}
|
||||||
maven { url = 'https://maven.createmod.net' }
|
maven { url = 'https://maven.createmod.net' }
|
||||||
maven {
|
maven {
|
||||||
url = 'https://www.cursemaven.com'
|
url = 'https://www.cursemaven.com'
|
||||||
@@ -78,7 +107,10 @@ neoForge {
|
|||||||
// The gametest system is also enabled by default for other run configs under the /test command.
|
// The gametest system is also enabled by default for other run configs under the /test command.
|
||||||
gameTestServer {
|
gameTestServer {
|
||||||
type = "gameTestServer"
|
type = "gameTestServer"
|
||||||
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
// Keep generated test Ages separate from worlds used by the dev client/server.
|
||||||
|
gameDirectory = project.file(project.findProperty('gameTestRunDir')
|
||||||
|
?: layout.buildDirectory.dir('gametest-run').get().asFile)
|
||||||
|
systemProperty 'neoforge.enabledGameTestNamespaces', enabledGameTestNamespaces
|
||||||
}
|
}
|
||||||
|
|
||||||
data {
|
data {
|
||||||
@@ -116,6 +148,44 @@ neoForge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GameTests generate Ages, so their dedicated world can grow quickly across runs.
|
||||||
|
// Never auto-delete a caller-supplied gameTestRunDir or a normal dev world.
|
||||||
|
long gameTestWorldLimitMiB = providers.gradleProperty('gameTestMaxWorldMiB').orElse('512').get().toLong()
|
||||||
|
if (gameTestWorldLimitMiB <= 0) {
|
||||||
|
throw new GradleException('gameTestMaxWorldMiB must be a positive number')
|
||||||
|
}
|
||||||
|
boolean customGameTestRunDir = providers.gradleProperty('gameTestRunDir').isPresent()
|
||||||
|
File dedicatedGameTestDir = layout.buildDirectory.dir('gametest-run').get().asFile.canonicalFile
|
||||||
|
File dedicatedGameTestWorld = new File(dedicatedGameTestDir, 'world')
|
||||||
|
def cleanOversizedGameTestWorld = tasks.register('cleanOversizedGameTestWorld') {
|
||||||
|
doLast {
|
||||||
|
if (customGameTestRunDir) {
|
||||||
|
getLogger().lifecycle('Skipping automatic GameTest world cleanup for custom gameTestRunDir')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
def worldDir = dedicatedGameTestWorld
|
||||||
|
if (!worldDir.isDirectory() || java.nio.file.Files.isSymbolicLink(worldDir.toPath())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
def paths = java.nio.file.Files.walk(worldDir.toPath()).withCloseable { it.toList() }
|
||||||
|
long bytes = paths.sum(0L) { path ->
|
||||||
|
java.nio.file.Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)
|
||||||
|
? java.nio.file.Files.size(path) : 0L
|
||||||
|
}
|
||||||
|
long limit = gameTestWorldLimitMiB * 1024L * 1024L
|
||||||
|
if (bytes > limit) {
|
||||||
|
getLogger().lifecycle('Removing oversized GameTest world {} ({} MiB; limit {} MiB)',
|
||||||
|
worldDir, Math.ceil(bytes / 1048576d) as long, gameTestWorldLimitMiB)
|
||||||
|
paths.toSorted(java.util.Comparator.reverseOrder()).each { java.nio.file.Files.delete(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tasks.named('runGameTestServer').configure {
|
||||||
|
dependsOn(cleanOversizedGameTestWorld)
|
||||||
|
}
|
||||||
|
|
||||||
// Sets up a dependency configuration called 'localRuntime'.
|
// Sets up a dependency configuration called 'localRuntime'.
|
||||||
// This configuration should be used instead of 'runtimeOnly' to declare
|
// This configuration should be used instead of 'runtimeOnly' to declare
|
||||||
// a dependency that will be present for runtime testing but that is
|
// a dependency that will be present for runtime testing but that is
|
||||||
@@ -131,12 +201,12 @@ dependencies {
|
|||||||
compileOnly "maven.modrinth:immersive-portals-neoforge:${immersive_portals_version}"
|
compileOnly "maven.modrinth:immersive-portals-neoforge:${immersive_portals_version}"
|
||||||
implementation "curse.maven:dynamicdimension-ar-edition-1469680:${dynamic_dimensions_file_id}"
|
implementation "curse.maven:dynamicdimension-ar-edition-1469680:${dynamic_dimensions_file_id}"
|
||||||
implementation "lol.bai:badpackets:neo-${badpackets_version}"
|
implementation "lol.bai:badpackets:neo-${badpackets_version}"
|
||||||
// Example optional mod dependency with JEI
|
// JEI integration is isolated in its plugin and absent from the published runtime dependencies.
|
||||||
// The JEI API is declared for compile time use, while the full JEI artifact is used at runtime
|
compileOnly "mezz.jei:jei-${minecraft_version}-neoforge-api:${jei_version}"
|
||||||
// compileOnly "mezz.jei:jei-${mc_version}-common-api:${jei_version}"
|
// Opt in to JEI for a dev client with -PwithJei=true.
|
||||||
// compileOnly "mezz.jei:jei-${mc_version}-neoforge-api:${jei_version}"
|
if (providers.gradleProperty('withJei').map { it.toBoolean() }.orElse(false).get()) {
|
||||||
// We add the full version to localRuntime, not runtimeOnly, so that we do not publish a dependency on it
|
localRuntime "mezz.jei:jei-${minecraft_version}-neoforge:${jei_version}"
|
||||||
// localRuntime "mezz.jei:jei-${mc_version}-neoforge:${jei_version}"
|
}
|
||||||
|
|
||||||
// Example mod dependency using a mod jar from ./libs with a flat dir repository
|
// Example mod dependency using a mod jar from ./libs with a flat dir repository
|
||||||
// This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar
|
// This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 553 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -7,6 +7,20 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
|
|||||||
| Rivencraft asset | Upstream asset | Upstream revision | Changes |
|
| Rivencraft asset | Upstream asset | Upstream revision | Changes |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `assets/rivencraft/textures/item/page.png` | `assets/mystcraft/textures/items/page_background.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
|
| `assets/rivencraft/textures/item/page.png` | `assets/mystcraft/textures/items/page_background.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
|
||||||
|
| `assets/rivencraft/textures/gui/notebook_pages.png` | `assets/mystcraft/gui/bookui_pagel.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Notebook draws the native 30×40 page at atlas coordinates `(156, 0)` |
|
||||||
|
| `assets/rivencraft/textures/gui/bookui_cover.png` | `assets/mystcraft/gui/bookui_cover.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; shared book cover, with gold edges drawn only for Descriptive Books |
|
||||||
|
| `assets/rivencraft/textures/gui/bookui_pagel.png` | `assets/mystcraft/gui/bookui_pagel.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; turned left page |
|
||||||
|
| `assets/rivencraft/textures/gui/bookui_pager.png` | `assets/mystcraft/gui/bookui_pager.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; link-panel frame on the first right page |
|
||||||
|
| `assets/rivencraft/textures/gui/bookui_rpage_full.png` | `assets/mystcraft/gui/bookui_rpage_full.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; full right written page |
|
||||||
|
| `assets/rivencraft/textures/item/writing_desk.png` | `assets/mystcraft/textures/items/writingdesk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
|
||||||
|
| `assets/rivencraft/textures/block/writing_desk.png` | `assets/mystcraft/textures/entity/desk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
|
||||||
|
| `assets/rivencraft/textures/gui/writing_desk_legacy.png` | `assets/mystcraft/gui/writingdesk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; full desk panel, tabs, and cycling controls at original atlas coordinates |
|
||||||
|
| `assets/rivencraft/textures/gui/scrollbar.png` | `assets/mystcraft/gui/scrollbar.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; tiled track and normal/pressed thumb |
|
||||||
|
| `assets/rivencraft/textures/block/book_binder.png` | `assets/mystcraft/textures/entity/bookbinder/bookbinder.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder model uses this body texture |
|
||||||
|
| `assets/rivencraft/textures/block/book_binder_press.png` | `assets/mystcraft/textures/entity/bookbinder/press_part.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder model uses this press texture |
|
||||||
|
| `assets/rivencraft/textures/gui/book_binder_legacy.png` | `assets/mystcraft/gui/pagebinder.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder GUI uses the original 176×181 panel |
|
||||||
|
| `assets/rivencraft/textures/gui/ink_mixer_legacy.png` | `assets/mystcraft/gui/inkmixer.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Ink Mixer GUI uses the original 176×181 panel |
|
||||||
|
| `assets/rivencraft/textures/block/ink_mixer_top.png`, `ink_mixer_side.png`, `ink_mixer_bottom.png` | `assets/mystcraft/textures/blocks/inkmixer_top.png`, `inkmixer_side.png`, `inkmixer_bottom.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Rivencraft supplies its own block model |
|
||||||
| `assets/rivencraft/textures/item/linking_book_unwritten.png` | `assets/mystcraft/textures/items/unlinked.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
|
| `assets/rivencraft/textures/item/linking_book_unwritten.png` | `assets/mystcraft/textures/items/unlinked.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
|
||||||
| `assets/rivencraft/textures/item/page_link_panel.png` | `page_background.png` and `PageBuilder.BasicPageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Reproduces the legacy 160 px scale, black panel at `(25,30)`–`(135,75)`, and 128 px resample |
|
| `assets/rivencraft/textures/item/page_link_panel.png` | `page_background.png` and `PageBuilder.BasicPageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Reproduces the legacy 160 px scale, black panel at `(25,30)`–`(135,75)`, and 128 px resample |
|
||||||
| `assets/rivencraft/textures/item/page_symbol*.png` and `page_unresolved.png` | `page_background.png`, `textures/symbolcomponents.png`, `WordData`, `ModSymbols`, and `PageBuilder.PageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Static 1.21.1 renditions of the legacy runtime-composed four-word symbol pages; `normal_time` uses the legacy Full Length poem |
|
| `assets/rivencraft/textures/item/page_symbol*.png` and `page_unresolved.png` | `page_background.png`, `textures/symbolcomponents.png`, `WordData`, `ModSymbols`, and `PageBuilder.PageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Static 1.21.1 renditions of the legacy runtime-composed four-word symbol pages; `normal_time` uses the legacy Full Length poem |
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# GameTest suites
|
||||||
|
|
||||||
|
The default dedicated-server run is a small smoke suite: 17 Rivencraft tests
|
||||||
|
cover core linking, deterministic Age definition, writing persistence, legacy
|
||||||
|
data migration, workstation transactions, and forged/replayed menu actions.
|
||||||
|
NeoForge currently adds two of its own tests, so the runner reports 19.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
./gradlew runGameTestServer
|
||||||
|
```
|
||||||
|
|
||||||
|
The remaining 47 Rivencraft tests are archived, not deleted. They cover
|
||||||
|
completed milestone details such as every Age terrain profile, Notebook and
|
||||||
|
Desk surface interactions, refill edge cases, book assembly controls, and
|
||||||
|
workstation break/drop behavior. Run only the relevant archive alongside the
|
||||||
|
core tests when investigating an issue:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
./gradlew runGameTestServer -ParchivedGameTestSuites=desk
|
||||||
|
./gradlew runGameTestServer -ParchivedGameTestSuites=linking,binder
|
||||||
|
```
|
||||||
|
|
||||||
|
Available suite names are `linking`, `writing`, `desk`, `binder`, `mixer`, and
|
||||||
|
`recipe`. Run all 64 Rivencraft tests (66 including NeoForge's two) before a
|
||||||
|
release or after broad cross-system changes:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
./gradlew runGameTestServer -PwithArchivedGameTests=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Each archive is selected by its `rivencraft_archive_<suite>` GameTest namespace.
|
||||||
|
The suites share the normal empty test structure and do not need Create, Ponder, or
|
||||||
|
Immersive Portals. The core suite delegates to the original regression methods
|
||||||
|
for its selected cases, so the two suites do not maintain duplicate test logic.
|
||||||
|
|
||||||
|
GameTest Ages are created in `build/gametest-run/world`, not in a development
|
||||||
|
client save. The Gradle task removes that **dedicated** world automatically if
|
||||||
|
it exceeds 512 MiB; override the threshold with `-PgameTestMaxWorldMiB=128`,
|
||||||
|
for example. Cleanup never targets a custom `-PgameTestRunDir` or a player
|
||||||
|
world. A fresh test world can also help diagnose a slow or stalled worldgen
|
||||||
|
run. The full suite should still be run before shipping changes in the
|
||||||
|
archived systems.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Mystcraft Legacy Age grammar audit (milestone 5, issue #28)
|
||||||
|
|
||||||
|
Reference: [Mystcraft-Legacy commit `9bc8ddc`](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae), whose [build properties](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/build.properties) identify version 0.13.7.06. The user-provided local Minecraft 1.12.2 extraction (`mystcraft-1.12.2-0.13.7.06`) has the same version in `mcmod.info`; its `assets/mystcraft/textures/symbolcomponents.png` is byte-identical to the source checkout. That extraction contains compiled classes and assets, but no shipped `.cfg` or `.java` files. Source-linked behavioral claims below use the corresponding GitHub Java source. This is a behavior specification, not a code port. Any later adapted code needs its own copyright/provenance record under [PORTING.md](PORTING.md).
|
||||||
|
|
||||||
|
## What Legacy actually does
|
||||||
|
|
||||||
|
Legacy's [root rule](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/GrammarRules.java#L62) produces, in order: terrain, biome controller, weather, lighting, optional spawning, suns, moons, starfields, doodads, visuals, small/medium/large features, and effects. Each symbol declares the rule it can satisfy and any preceding modifier tokens it needs in [SymbolRules](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/SymbolRules.java). The written page order therefore matters: modifiers appear before their consumer, while missing grammar branches can be generated. The [grammar tree](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/grammar/GrammarTree.java#L158-L222) connects authored terminals and inserts missing branches; [AgeData](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/agedata/AgeData.java#L230-L258) does this on first link with an Age-seeded RNG. Weighted rule *ranks* choose generated symbols; a `null` rank is not randomly generated, though such a rule can connect authored pages ([GrammarGenerator](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/grammar/GrammarGenerator.java#L28-L112)).
|
||||||
|
|
||||||
|
Legacy does **not** reject a book just because it omits terrain, biome, weather, or lighting. It fills the gaps. It also has separate instability accounting for dangling/excess symbols; this audit records those as diagnostics, not as milestone 5 generation effects ([AgeController](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/AgeController.java#L196-L247)).
|
||||||
|
|
||||||
|
## Symbol-to-Rivencraft mapping
|
||||||
|
|
||||||
|
| Legacy category and symbols | Existing Rivencraft path | Milestone 5 disposition |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `TerrainNormal`, `TerrainAmplified`, `TerrainFlat`, `TerrainVoid`, `TerrainNether`, `TerrainEnd`; terrain and sea block modifiers | `AgeProfile` selects noise/flat/void and registered noise settings; random profiles already persist per Age | Compile supported terrain shapes into profile/resources. Nether/End and material modifiers require explicit compatible data-pack resources; do not claim exact 1.12 terrain parity. |
|
||||||
|
| `BioConNative`, `Single`, `Tiled`, `Grid`, `Tiny`, `Small`, `Medium`, `Large`, `Huge`, plus biome pages | `AgeTraits.BiomeDistribution` has all nine modes; `AgeDimensionService` has sources for these modes | Map modes, consume authored biome IDs in order, then deterministically fill required minimums. Native uses the profile's modern biome preset, not the old `BiomeProvider`. |
|
||||||
|
| `LightingNormal`, `Dark`, `Bright` | `AgeTraits.Lighting`, dimension type construction | Compile the choice to a stable dimension type/light behavior. Exact Legacy 1.12 brightness-table rendering is not guaranteed by NeoForge's dimension-type fields. |
|
||||||
|
| `WeatherNorm`, `Fast`, `Slow`, `Off`, `On`, `Cloudy`, `Rain`, `Snow`, `Storm` | `AgeTraits.Weather` has matching names except Legacy `On` maps to `ALWAYS` | Compile weather semantics separately from biome selection. `Snow` must remain deferred until optional season integration; do not silently substitute another weather mode. |
|
||||||
|
| Suns, moons, starfields; angle, phase, period, color/gradient/sunset modifiers | One `celestialPeriod`, `celestialAngle`, `celestialPhase` on `AgeTraits` | Compile only the representable single-cycle subset first. Multiple celestial bodies, independent periods, custom stars, gradients and sunsets need a richer definition and renderer, not flattening into one value. |
|
||||||
|
| Sky/fog/cloud/grass/foliage/water colors, natural variants, horizon, rainbow | Existing sky/dimension visuals and preview | Require an explicit modern client/biome-color design; metadata-only symbol pages must not pretend to change rendering. |
|
||||||
|
| Caves, ores, huge trees, lakes, floating islands, tendrils, skylands, spheres, spikes, crystals; vanilla villages, strongholds, mineshafts, dungeons, fortresses, obelisks, fissures | Current cave profile and vanilla data-pack generation | Compile only features for which registered 1.21.1 placed features/structures exist. Defer bespoke structures and polish to milestone 7. |
|
||||||
|
| `EnvAccel`, explosions, lightning, meteors, scorching; PvP and dummy suppression pages | No equivalent Age-definition effect | Defer environmental hazards/instability effects to milestone 6, PvP rule to a separately scoped server policy; retain authored IDs without fake behavior. |
|
||||||
|
|
||||||
|
The Legacy symbol list and page names come from [ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L129-L240) and [SymbolRules](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/SymbolRules.java#L19-L137). Rivencraft's [catalog](../src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java) currently holds display metadata only; its built-in set is just standard terrain, native biomes, normal weather, normal lighting, normal time, and caves. [OrderedWriting](../src/main/java/net/dnorberg/rivencraft/writing/OrderedWriting.java) preserves page order, but no compiler yet connects it to [AgeTraits](../src/main/java/net/dnorberg/rivencraft/book/AgeTraits.java) or [Age profiles](AGE_PROFILES.md).
|
||||||
|
|
||||||
|
## Symbol-page artwork: `symbolcomponents.png`
|
||||||
|
|
||||||
|
Legacy does not allocate one image tile to each complete Age symbol. The [default word image source](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/api/java/com/xcompwiz/mystcraft/api/word/DrawableWord.java#L24-L75) is `mystcraft:textures/symbolcomponents.png`, a 512×512 RGBA sheet. It has an 8×8 grid of 64×64 component cells. For zero-based component index `n`, source pixels begin at `x = (n % 8) * 64`, `y = (n / 8) * 64`. [GuiUtils.drawComponent](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/client/gui/GuiUtils.java#L131-L152) uses that mapping for GUI drawing; [PageBuilder.PageSprite.stitchWord](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/client/render/PageBuilder.java#L287-L331) uses it when composing an item sprite. Cell `0` is the missing-word/question-mark fallback. Each component may be tinted and its alpha layered with the other components of a word.
|
||||||
|
|
||||||
|
The complete lookup chain for a unique page is:
|
||||||
|
|
||||||
|
1. [WordData.init](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/api/java/com/xcompwiz/mystcraft/api/word/WordData.java#L96-L166) registers a list of component indices for each named word (for example, `Nature` uses `5, 6, 8, 10, 11, 12, 15, 16, 17, 22`). [DrawableWord](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/api/java/com/xcompwiz/mystcraft/api/word/DrawableWord.java#L26-L75) also permits per-component colors and an alternate image source. Unregistered words get a deterministic component list from their lowercase name hash ([DrawableWordManager](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/words/DrawableWordManager.java#L34-L56)).
|
||||||
|
2. [ModSymbols.registerSymbol](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L105-L116) assigns four poem words to each Age symbol. For example, `LightingNormal` is `Ethereal, Dynamic, Cycle, Balance` ([registration](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L184-L186)). The poem, not the symbol ID, selects the component layers.
|
||||||
|
3. [PageBuilder.PageSprite](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/client/render/PageBuilder.java#L232-L279) draws those words above, right, below, and left of the page center, respectively. Their target rectangles on its 160×160 working image are `(48,0)`, `(96,48)`, `(48,96)`, `(0,48)`, each 64×64 ([getPageTarget](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/client/render/PageBuilder.java#L360-L378)). It scales the composed image to a 128×128 item sprite. [GuiUtils.drawSymbol](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/client/gui/GuiUtils.java#L74-L96) renders the same four-word arrangement directly in book/workstation GUIs.
|
||||||
|
|
||||||
|
For Rivencraft, keep **symbol semantics** (`SymbolReference`/catalog ID) separate from **page artwork** (four ordered word names, their component indices/colors, and the shared sheet). Existing built-in page images are static renditions of this process ([asset provenance](ASSET_PROVENANCE.md)); future symbol pages can compose it at build time or in a client-only renderer. Unknown words and missing symbols need distinct fallbacks, and data-pack-only symbols must remain usable without silently borrowing another symbol's image. Do not copy the Legacy renderer wholesale: its texture-atlas and GUI APIs target 1.12.2.
|
||||||
|
|
||||||
|
## Verified numeric defaults and bounds
|
||||||
|
|
||||||
|
These values are from code, **not** a supposed shipped `.cfg`: the inspected Legacy repository contains no default configuration file with numeric min/max fields for age grammar. [MystConfig](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/config/MystConfig.java) supplies typed config helpers, while symbol enablement uses a Boolean `<symbol>.enabled` option ([SymbolManager](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/SymbolManager.java#L91-L107)). Do not label Rivencraft's present numeric range as a Legacy config range.
|
||||||
|
|
||||||
|
| Parameter | Legacy source value/rule | Implementation consequence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Period pages | `Zero=0`, `Half=0.5`, `Full=1`, `Double=2` ([ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L194-L201)); repeated period modifiers average pairwise ([SymbolLength](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/modifiers/SymbolLength.java#L25-L37)) | Authored basic values have min 0 and max 2; no arbitrary continuous written range is established. A sun without a period uses `0.8 + 0.4 * nextDouble()`—`[0.8, 1.2)`—before conversion to ticks ([SymbolSunNormal](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolSunNormal.java#L47-L73)). Rivencraft's random nonzero `[0.2, 2.0)` is its own existing policy. |
|
||||||
|
| Angle pages | North/East/South/West = `0/90/180/270` degrees ([ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L187-L190)); repeated values use shortest-arc averaging ([ModifierUtils](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/api/java/com/xcompwiz/mystcraft/api/symbol/ModifierUtils.java#L54-L73)) | Canonical angle is `[0, 360)`; consumers may seed a random angle if omitted. |
|
||||||
|
| Phase pages | Nadir/Rising/Zenith/Setting = `0/90/180/270` degrees ([ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L198-L201)); the sun divides by 360 ([SymbolSunNormal](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolSunNormal.java#L65-L73)) | Written phase is `[0, 360)` degrees, represented by Rivencraft as `[0, 1)` turns. |
|
||||||
|
| Biome count | Grammar requires one page for `Single`, `Tiled`, `Grid`; `Tiny`–`Huge` have two explicit biome slots plus a biome-list branch ([SymbolRules](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/SymbolRules.java#L91-L99)). `Single` fills to 1 ([source](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolBiomeControllerSingle.java#L22-L37)), `Tiled`/`Grid` to 2 ([source](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolBiomeControllerTiled.java#L25-L38)), and `Tiny`–`Huge` to 3 ([source](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolBiomeControllerLarge.java#L61-L84)) | These are **minimums**, not maximums. `Native` uses vanilla's provider and has no authored biome-list requirement ([source](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolBiomeControllerNative.java#L34-L44)). Preserve additional authored biomes where the chosen controller can accept them. |
|
||||||
|
| Weather normal | Rain duration/base `12000/12000`, cooldown/base `168000/12000`; thunder duration/base `12000/3600`, cooldown/base `168000/12000` ticks ([SymbolWeatherNormal](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/symbol/symbols/SymbolWeatherNormal.java#L25-L36)) | These are source constants, not configurable min/max bounds. Other weather controllers need individual review before parity claims. |
|
||||||
|
|
||||||
|
## Compilation and fallback contract for the next issues
|
||||||
|
|
||||||
|
1. A Link-Panel-only book, a legacy page-free book, and any book with no effective supported Age symbols retain the current seed-derived random Age path. Preserve the saved writing untouched.
|
||||||
|
2. A supported partial sequence is **not** invalid: complete missing required branches deterministically from the Age seed, then compile the result. Authored symbols take precedence over generated fillers. This follows Legacy's grammar tree, rather than requiring a full hand-written list.
|
||||||
|
3. Unknown/unresolved IDs, unsupported-but-known effects, misplaced modifiers, duplicates, and conflicts must be reported distinctly. Do not silently apply a different effect or discard an authored page. A first compiler may use the existing random Age path for combinations it cannot represent, with an explicit diagnostic; it must never claim such a book was successfully compiled.
|
||||||
|
4. Grammar diagnostics are not instability. Milestone 5 decides recognition, ordering, completion, and whether a supported combination can compile. Milestone 6 may use dangling/excess/conflict diagnostics for instability effects. Milestone 7 owns bespoke structures and polish.
|
||||||
|
5. Freeze the completed, effective generation definition with the Age identity before creating chunks. Keep page order and original IDs for display/migration, and keep seeded completion deterministic across reloads. New symbol behavior belongs in versioned server data, not the schema-1 display catalog.
|
||||||
|
|
||||||
|
The mapping is intentionally narrower than all Legacy symbols. The exact rule and compiler schema are follow-up issues #29–#31; terrain, biome, environment, and feature implementations are #32–#35. No generation behavior or release version changes in this audit.
|
||||||
@@ -33,11 +33,14 @@ acceptance issue and all required implementation issues are complete.
|
|||||||
lifecycle management, data-pack integration, and optional destination
|
lifecycle management, data-pack integration, and optional destination
|
||||||
renderer compatibility. **Complete.** See
|
renderer compatibility. **Complete.** See
|
||||||
[Milestone 3](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/8).
|
[Milestone 3](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/8).
|
||||||
4. **Writing system** — pages, symbols, notebooks, desks, and book assembly.
|
4. **Writing system** — pages, notebooks, desks, book assembly, and the baseline
|
||||||
**In progress.**
|
Ink Mixer for Link Panels. Symbol effects are deferred until Milestone 5.
|
||||||
|
**Complete.** See [Milestone 4](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/9)
|
||||||
|
and [writing-system data and migration](WRITING_DATA.md).
|
||||||
5. **Age grammar** — translate symbol combinations into modern biome, noise,
|
5. **Age grammar** — translate symbol combinations into modern biome, noise,
|
||||||
dimension-type, and world-generation configuration. Optional integrations
|
dimension-type, and world-generation configuration. Optional integrations
|
||||||
may add composable biome regions and seasonal behavior.
|
may add composable biome regions and seasonal behavior. See the
|
||||||
|
[Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md).
|
||||||
6. **Instability** — deterministic validation and server-safe effects.
|
6. **Instability** — deterministic validation and server-safe effects.
|
||||||
7. **Structures and polish** — libraries, crystals, portals, recipes, models,
|
7. **Structures and polish** — libraries, crystals, portals, recipes, models,
|
||||||
sounds, advancements, compatibility, and migration tests.
|
sounds, advancements, compatibility, and migration tests.
|
||||||
@@ -62,10 +65,13 @@ The writing system and Age grammar will build on the existing page-free Age
|
|||||||
path. Page-free books remain a useful deterministic fallback and test fixture
|
path. Page-free books remain a useful deterministic fallback and test fixture
|
||||||
after written symbol combinations become available.
|
after written symbol combinations become available.
|
||||||
|
|
||||||
Milestone 4 begins with a versioned, data-pack-defined symbol catalog and an
|
Milestone 4 delivered a versioned, data-pack-defined symbol catalog and an
|
||||||
immutable ordered-writing model. See [WRITING_DATA.md](WRITING_DATA.md). These
|
immutable ordered-writing model. See [WRITING_DATA.md](WRITING_DATA.md). These
|
||||||
contracts preserve unknown symbol identifiers and store author intent; they do
|
contracts preserve unknown symbol identifiers and store author intent; they do
|
||||||
not interpret symbols into Age generation, which remains Milestone 5 work.
|
not interpret symbols into Age generation, which remains Milestone 5 work.
|
||||||
|
The Ink Mixer is included in Milestone 4 after book assembly; it
|
||||||
|
uses paper and Ink Vials to make baseline Link Panels. Adding modifier
|
||||||
|
ingredients and symbol-driven link properties belongs to Milestone 5.
|
||||||
|
|
||||||
## Provenance rules
|
## Provenance rules
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ unstable in a particular graphics or mod configuration.
|
|||||||
|
|
||||||
For preview-related releases:
|
For preview-related releases:
|
||||||
|
|
||||||
1. Run `./gradlew runGameTestServer` to verify the complete selection matrix,
|
1. Run `./gradlew runGameTestServer -PwithArchivedGameTests=true` to verify the complete selection matrix,
|
||||||
core Age creation, persistence, and travel without optional renderers.
|
core Age creation, persistence, and travel without optional renderers.
|
||||||
2. Open held Linking and Descriptive Books once with each available backend.
|
2. Open held Linking and Descriptive Books once with each available backend.
|
||||||
3. Open both book types from a Book Stand and confirm Explore is disabled.
|
3. Open both book types from a Book Stand and confirm Explore is disabled.
|
||||||
@@ -75,4 +75,3 @@ For preview-related releases:
|
|||||||
preview loader remains active.
|
preview loader remains active.
|
||||||
5. With Ponder, reopen different destinations and confirm the previous
|
5. With Ponder, reopen different destinations and confirm the previous
|
||||||
snapshot does not bleed into the next one.
|
snapshot does not bleed into the next one.
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Server-authoritative symbol semantics (schema v2)
|
||||||
|
|
||||||
|
Milestone 5 issue #29 adds typed meaning to the existing data-pack symbol catalog. Put definitions at `data/<namespace>/rivencraft/symbols/<path>.json`; the file ID becomes the stable symbol ID. For example, `data/example/rivencraft/symbols/terrain/highlands.json` defines `example:terrain/highlands`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 2,
|
||||||
|
"name": "symbol.example.terrain.highlands",
|
||||||
|
"description": "symbol.example.terrain.highlands.description",
|
||||||
|
"category": "example:terrain",
|
||||||
|
"semantics": {
|
||||||
|
"role": "terrain",
|
||||||
|
"value": "example:highlands"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`name` and `description` are translation keys. `category` is for display and sorting; it does not determine grammar. `semantics.role` and its typed `value` are server-owned. The terrain value above names an Age runtime profile that a later compiler must resolve and validate. A syntactically valid resource ID alone does not guarantee the profile or feature exists.
|
||||||
|
|
||||||
|
| Role | Value type and allowed values |
|
||||||
|
| --- | --- |
|
||||||
|
| `terrain` | Canonical namespaced Age profile ID. |
|
||||||
|
| `biome_controller` | `native`, `single`, `tiled`, `grid`, `tiny`, `small`, `medium`, `large`, or `huge`. |
|
||||||
|
| `biome` | Canonical namespaced biome ID. |
|
||||||
|
| `lighting` | `normal`, `dark`, or `bright`. |
|
||||||
|
| `weather` | `normal`, `fast`, `slow`, `off`, `always`, `cloudy`, `rain`, `snow`, or `storm`. |
|
||||||
|
| `celestial` | `normal_time` (the first representable single-cycle behavior). |
|
||||||
|
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. |
|
||||||
|
| `angle_modifier` | JSON number in `[0, 360)` degrees. |
|
||||||
|
| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler will convert to turns. |
|
||||||
|
| `feature` | Canonical namespaced feature-effect ID; future generation mapping must recognize it. |
|
||||||
|
|
||||||
|
These are schema bounds, not claims that Legacy shipped numeric config ranges. See the [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md) for the source values and biome-count rules. Roles outside this table—including multiple suns, gradients, special structures, and hazards—are intentionally unsupported by schema v2. Their meaning requires a later versioned extension, not an unvalidated free-form field.
|
||||||
|
|
||||||
|
The server rejects a v2 definition with an unknown top-level or semantics field, missing/incorrectly typed field, unknown role or choice, malformed/non-canonical resource ID, or out-of-range/non-finite number. An invalid definition is logged with its file ID and excluded from the active catalog. Saved pages and books still retain that ID, allowing the player to recover them if the data pack is corrected. A reload atomically replaces the catalog rather than mutating saved writing.
|
||||||
|
|
||||||
|
Schema v1 definitions remain readable as **display-only** symbols; existing packs need not be rewritten merely to show or copy pages. A v1 definition cannot add a `semantics` field. To make one eligible for future Age compilation, publish a v2 definition with the **same file path and ID**, adding a valid role and value. Do not rename the page ID or reorder a book's `OrderedWriting`. The client receives only `SymbolDisplayMetadata` (ID, translation keys, category, resolved flag); semantic definitions are not serialized to it. This slice defines and validates meaning but does **not yet apply** written symbols to Age generation; issues #30 and #31 implement parsing and compilation.
|
||||||
|
|
||||||
|
The six built-in definitions demonstrate terrain, biome-controller, lighting, weather, celestial, and feature roles. A third-party pack may add its own definitions under its namespace. Use an explicit semantic role even when the display category has the same name; the two fields serve different purposes.
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
# Writing and symbol data
|
# Writing and symbol data
|
||||||
|
|
||||||
Milestone 4 stores what a player wrote without deciding what that writing does
|
Milestone 4 stores what a player wrote without deciding what that writing does
|
||||||
to an Age. Milestone 5 owns grammar validation, symbol interaction, instability,
|
to an Age. Milestone 5 owns grammar validation and symbol interaction,
|
||||||
and translation into biome, noise, dimension-type, and other world-generation
|
and translation into biome, noise, dimension-type, and other world-generation
|
||||||
configuration.
|
configuration. Milestone 6 owns instability effects. See the
|
||||||
|
[Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md) for the source-backed
|
||||||
|
Milestone 5 contract.
|
||||||
|
|
||||||
## Symbol catalog
|
## Symbol catalog
|
||||||
|
|
||||||
@@ -25,13 +27,17 @@ Schema version 1 contains display metadata only:
|
|||||||
|
|
||||||
`name` and `description` are translation keys, not arbitrary formatted text or
|
`name` and `description` are translation keys, not arbitrary formatted text or
|
||||||
executable behavior. `category` is a namespaced organizational identifier.
|
executable behavior. `category` is a namespaced organizational identifier.
|
||||||
|
Milestone 5 adds [server-authoritative schema v2 semantics](SYMBOL_SEMANTICS.md)
|
||||||
|
with a typed role and bounded value. Existing v1 definitions remain readable
|
||||||
|
but display-only; updating an ID to v2 never rewrites saved pages or books.
|
||||||
Invalid definitions are logged and excluded by the server. Menus and future
|
Invalid definitions are logged and excluded by the server. Menus and future
|
||||||
page synchronization must obtain resolved display metadata from the server;
|
page synchronization must obtain resolved display metadata from the server;
|
||||||
clients never make a symbol valid by naming it.
|
clients never make a symbol valid by naming it.
|
||||||
|
|
||||||
Rivencraft ships a small catalog covering terrain, biomes, lighting, weather,
|
Rivencraft ships a small catalog covering terrain, biomes, lighting, weather,
|
||||||
celestial behavior, and features. It exists to exercise the complete writing
|
celestial behavior, and features. Its v2 semantics prepare for the full
|
||||||
workflow before Milestone 5 implements the full legacy grammar.
|
Legacy-inspired grammar; writing still uses random Age fallback until the
|
||||||
|
parser and compiler are implemented.
|
||||||
|
|
||||||
## Persistent writing
|
## Persistent writing
|
||||||
|
|
||||||
@@ -40,6 +46,18 @@ a symbol reference or a baseline link-panel marker. `OrderedWriting` retains up
|
|||||||
to 256 entries in exact player-authored order. All three contracts have both
|
to 256 entries in exact player-authored order. All three contracts have both
|
||||||
persistent codecs and bounded network codecs.
|
persistent codecs and bounded network codecs.
|
||||||
|
|
||||||
|
Assembled Descriptive Books store `OrderedWriting` schema version 1 alongside
|
||||||
|
their Age reference. Books assembled in 0.3.7.2 saved writing as a bare list;
|
||||||
|
that form still loads as version 1 without reordering or dropping unknown IDs.
|
||||||
|
Older page-free books without writing continue to work unchanged. Copying a
|
||||||
|
book copies its writing and Age reference; merely copying it never creates a
|
||||||
|
new Age. The Book Binder creates the Age when its finished output is taken.
|
||||||
|
The Descriptive Book screen can inspect the ordered pages by turning them from
|
||||||
|
the bottom corners and identifies unresolved IDs using server-provided symbol
|
||||||
|
metadata. Until Milestone 5, the
|
||||||
|
saved symbols are informational only: Age creation uses the existing random
|
||||||
|
fallback and does not apply them to generation.
|
||||||
|
|
||||||
Removing a data pack never removes its IDs from saved writing. A missing ID is
|
Removing a data pack never removes its IDs from saved writing. A missing ID is
|
||||||
resolved as client-safe **Unresolved Symbol** display metadata while retaining
|
resolved as client-safe **Unresolved Symbol** display metadata while retaining
|
||||||
the original namespaced ID for diagnostics and later restoration. This is
|
the original namespaced ID for diagnostics and later restoration. This is
|
||||||
@@ -53,14 +71,93 @@ unresolved symbol, and baseline link panel. Symbol and unresolved-symbol pages
|
|||||||
retain their namespaced symbol reference; unresolved pages can be inspected and
|
retain their namespaced symbol reference; unresolved pages can be inspected and
|
||||||
removed but are rejected as usable writing until their data pack returns.
|
removed but are rejected as usable writing until their data pack returns.
|
||||||
|
|
||||||
Only server-side writing operations may turn a blank page into a known symbol
|
Only server-side writing operations may create a known symbol page or link
|
||||||
page or link panel. The shared `PageItem` creation and validation gates reject
|
panel from vanilla paper. The shared `PageItem` creation and validation gates
|
||||||
new pages for identifiers absent from the server catalog. Later Writing Desk
|
reject new pages for identifiers absent from the server catalog. The Writing
|
||||||
and Book Binder packets must call these gates rather than accepting page
|
Desk refuses to copy an unresolved page, and the Book Binder keeps such a page
|
||||||
contents supplied by a client.
|
removable but will not assemble a book from it until its symbol resolves again.
|
||||||
|
|
||||||
The item model uses server-assigned custom-model data derived from the symbol
|
The item model uses server-assigned custom-model data derived from the symbol
|
||||||
identifier. Built-in pages reproduce Mystcraft Legacy's four-word poem
|
identifier. Built-in pages reproduce Mystcraft Legacy's four-word poem
|
||||||
composition from its page background and symbol-component sheet. Data-pack
|
composition from its page background and symbol-component sheet. Data-pack
|
||||||
symbols without a bundled resource-pack model use the legacy question-mark
|
symbols without a bundled resource-pack model use the legacy question-mark
|
||||||
fallback while keeping their real identifier and display metadata.
|
fallback while keeping their real identifier and display metadata.
|
||||||
|
|
||||||
|
The Notebook surface displays pages by their persistent slot index instead of
|
||||||
|
packing occupied pages together. Empty positions through the last placed page
|
||||||
|
appear darkened. While carrying a Page, all available positions are shown for
|
||||||
|
placement. The surface draws the native 30×40 portrait page region from
|
||||||
|
Legacy's book UI atlas; symbols are layered at a uniform scale, while the
|
||||||
|
square item models remain unchanged in inventories.
|
||||||
|
|
||||||
|
A Notebook holds 27 ordered positions. Its pages, names, gaps, and unresolved
|
||||||
|
IDs are saved on the item, so moving the Notebook between an inventory, Desk,
|
||||||
|
and Binder does not rearrange them. A loose Page in a Desk tab exposes just one
|
||||||
|
position; a Notebook exposes all 27. The Desk has 88 persistent collection
|
||||||
|
tabs, shown four at a time. Use the up/down tab controls to move by four, put
|
||||||
|
paper and Ink Vials in their input slots, select a stored page, and take the
|
||||||
|
copy from the virtual output. Taking it spends one paper and the configured
|
||||||
|
symbol-copy ink cost. The `AZ`, `ALL`, search, and category controls only change
|
||||||
|
which stored pages are displayed; the server rechecks the actual source before
|
||||||
|
copying. Moving a page within the Desk or dragging it into the inventory removes
|
||||||
|
the original only when the destination accepts it.
|
||||||
|
|
||||||
|
The Book Binder takes a leather or empty-Notebook cover, an Age title, one Link
|
||||||
|
Panel, and optionally up to 27 ordered written Pages. A filled Notebook can be
|
||||||
|
imported at a chosen strip position; the empty Notebook remains recoverable.
|
||||||
|
The preview result is virtual. Taking the completed Descriptive Book commits the
|
||||||
|
inputs once, creates its Age identity, and saves schema-versioned
|
||||||
|
`OrderedWriting` plus the Age reference on the book. Link-Panel-only books
|
||||||
|
create random Ages. A written symbol does **not** yet change that generation.
|
||||||
|
Inputs, title, and pending Age identity survive Binder save/load; breaking the
|
||||||
|
Binder returns stored inputs. The completed book retains both its writing and
|
||||||
|
Age identity through item save/load and copying.
|
||||||
|
|
||||||
|
## Ink and baseline link panels
|
||||||
|
|
||||||
|
An Ink Vial is crafted from two black dye, a glass bottle, and a water bucket.
|
||||||
|
The bucket remains after crafting. Each vial holds 16 ink units, stored in a
|
||||||
|
persistent, network-synchronized component; only vials with identical remaining
|
||||||
|
ink stack together. When depleted, a vial returns a glass bottle that can be
|
||||||
|
used to craft another. The animated item texture comes from Mystcraft Legacy.
|
||||||
|
|
||||||
|
The server config sets ink costs independently for symbol copying (default one
|
||||||
|
unit) and baseline link-panel preparation (default two units). To prepare a
|
||||||
|
panel, hold vanilla paper in one hand and use an Ink Vial in the other. The
|
||||||
|
server checks the held items and sufficient ink before consuming
|
||||||
|
anything. A stack of paper yields exactly one Link Panel Page per use;
|
||||||
|
if the inventory is full, the result is dropped instead of deleted. Symbol
|
||||||
|
copying is implemented by the Writing Desk: it previews a copy of the selected
|
||||||
|
resolved page, then spends paper and ink only when that copy is taken. The
|
||||||
|
desk's 16-unit ink meter refills from an Ink Vial when needed and returns a
|
||||||
|
vanilla glass bottle. Link-property modifiers are
|
||||||
|
deferred beyond this baseline flow.
|
||||||
|
|
||||||
|
The Ink Mixer is the station version of Link Panel preparation. Put paper and
|
||||||
|
Ink Vials in its inputs and take the Link Panel from its virtual output. Its
|
||||||
|
16-unit basin refills only when needed and only if the glass-bottle output has
|
||||||
|
space. The basin and inputs persist through save/load; breaking the Mixer
|
||||||
|
returns stored items. A Link Panel plus leather also crafts an unwritten
|
||||||
|
Linking Book. JEI, when installed, displays the Mixer operation and the
|
||||||
|
writing, binding, and Linking Book crafting flows; it is not required to play.
|
||||||
|
|
||||||
|
## Migration and validation
|
||||||
|
|
||||||
|
The 0.3.7.2 bare-list `OrderedWriting` form reads as schema version 1 without
|
||||||
|
changing entry order. Descriptive Books with no writing component keep their
|
||||||
|
existing Age behavior. Old eight-slot Desks migrate their four collections,
|
||||||
|
ink supply, paper, bottle return, and legacy output into the 88-tab layout;
|
||||||
|
saved inputs are never silently discarded. Missing symbol IDs survive page,
|
||||||
|
Notebook, Binder, and assembled-book persistence instead of being substituted
|
||||||
|
or erased.
|
||||||
|
|
||||||
|
The core and archived dedicated-server [GameTests](GAMETESTS.md) together
|
||||||
|
exercise codec and item round-trips, legacy data,
|
||||||
|
workstation NBT reload, break drops, full-inventory refusal, forged menu IDs,
|
||||||
|
and replayed output requests. World saves and chunk unloads use the same block
|
||||||
|
entity NBT persistence path as those reload tests. This does not replace a
|
||||||
|
manual smoke test of loading an older world before updating a production save.
|
||||||
|
|
||||||
|
Milestone 4 stops at preserving author intent and offering a playable random
|
||||||
|
Age fallback. Milestone 5 will define symbol grammar and validation, translate
|
||||||
|
valid writing into actual Age generation, and introduce symbol and ink modifiers.
|
||||||
@@ -22,6 +22,7 @@ neo_version=21.1.251
|
|||||||
ponder_version=1.0.82
|
ponder_version=1.0.82
|
||||||
dynamic_dimensions_file_id=7707789
|
dynamic_dimensions_file_id=7707789
|
||||||
badpackets_version=0.8.2
|
badpackets_version=0.8.2
|
||||||
|
jei_version=19.57.0.449
|
||||||
# The loader version range can only use the major version of FML as bounds
|
# The loader version range can only use the major version of FML as bounds
|
||||||
loader_version_range=[1,)
|
loader_version_range=[1,)
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ mod_name=Rivencraft
|
|||||||
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
||||||
mod_license=LGPL-3.0-or-later
|
mod_license=LGPL-3.0-or-later
|
||||||
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
|
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
|
||||||
mod_version=0.3.3.0
|
mod_version=0.4.1.0
|
||||||
immersive_portals_version=6.0.7
|
immersive_portals_version=6.0.7
|
||||||
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
|
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
|
||||||
# This should match the base package used for the mod sources.
|
# This should match the base package used for the mod sources.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import net.dnorberg.rivencraft.registry.ModCreativeTabs;
|
|||||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
import net.dnorberg.rivencraft.registry.ModItems;
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
import net.dnorberg.rivencraft.registry.ModMenus;
|
import net.dnorberg.rivencraft.registry.ModMenus;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModRecipeSerializers;
|
||||||
import net.dnorberg.rivencraft.registry.ModSounds;
|
import net.dnorberg.rivencraft.registry.ModSounds;
|
||||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||||
import net.dnorberg.rivencraft.registry.ModBlockEntities;
|
import net.dnorberg.rivencraft.registry.ModBlockEntities;
|
||||||
@@ -43,6 +44,7 @@ public final class Rivencraft {
|
|||||||
ModBlockEntities.register(modEventBus);
|
ModBlockEntities.register(modEventBus);
|
||||||
ModItems.register(modEventBus);
|
ModItems.register(modEventBus);
|
||||||
ModMenus.register(modEventBus);
|
ModMenus.register(modEventBus);
|
||||||
|
ModRecipeSerializers.register(modEventBus);
|
||||||
ModSounds.register(modEventBus);
|
ModSounds.register(modEventBus);
|
||||||
ModCreativeTabs.register(modEventBus);
|
ModCreativeTabs.register(modEventBus);
|
||||||
NeoForge.EVENT_BUS.addListener(BookStandConversionHandler::onRightClickBlock);
|
NeoForge.EVENT_BUS.addListener(BookStandConversionHandler::onRightClickBlock);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package net.dnorberg.rivencraft.block;
|
||||||
|
|
||||||
|
import com.mojang.serialization.MapCodec;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.menu.BookBinderMenu;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.Containers;
|
||||||
|
import net.minecraft.world.InteractionResult;
|
||||||
|
import net.minecraft.world.SimpleMenuProvider;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.block.BaseEntityBlock;
|
||||||
|
import net.minecraft.world.level.block.RenderShape;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.level.block.state.StateDefinition;
|
||||||
|
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||||
|
import net.minecraft.world.level.block.state.properties.DirectionProperty;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.phys.BlockHitResult;
|
||||||
|
|
||||||
|
public final class BookBinderBlock extends BaseEntityBlock {
|
||||||
|
public static final MapCodec<BookBinderBlock> CODEC = simpleCodec(BookBinderBlock::new);
|
||||||
|
public static final DirectionProperty FACING = BlockStateProperties.HORIZONTAL_FACING;
|
||||||
|
|
||||||
|
public BookBinderBlock(Properties properties) {
|
||||||
|
super(properties);
|
||||||
|
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
|
||||||
|
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> builder) {
|
||||||
|
builder.add(FACING);
|
||||||
|
}
|
||||||
|
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
|
||||||
|
Direction facing = context.getHorizontalDirection().getOpposite();
|
||||||
|
return defaultBlockState().setValue(FACING, facing);
|
||||||
|
}
|
||||||
|
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
|
||||||
|
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
return new BookBinderBlockEntity(pos, state);
|
||||||
|
}
|
||||||
|
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
|
||||||
|
Player player, BlockHitResult hit) {
|
||||||
|
if (!level.isClientSide && player instanceof ServerPlayer serverPlayer
|
||||||
|
&& level.getBlockEntity(pos) instanceof BookBinderBlockEntity binder) {
|
||||||
|
serverPlayer.openMenu(new SimpleMenuProvider(
|
||||||
|
(id, inventory, ignored) -> new BookBinderMenu(id, inventory, binder),
|
||||||
|
Component.translatable("container.rivencraft.book_binder")),
|
||||||
|
buffer -> buffer.writeBlockPos(pos));
|
||||||
|
}
|
||||||
|
return InteractionResult.sidedSuccess(level.isClientSide);
|
||||||
|
}
|
||||||
|
@Override protected void onRemove(BlockState state, Level level, BlockPos pos,
|
||||||
|
BlockState replacement, boolean moving) {
|
||||||
|
if (!state.is(replacement.getBlock()) && !level.isClientSide
|
||||||
|
&& level.getBlockEntity(pos) instanceof BookBinderBlockEntity binder) {
|
||||||
|
Containers.dropContents(level, pos, binder);
|
||||||
|
level.updateNeighbourForOutputSignal(pos, this);
|
||||||
|
}
|
||||||
|
super.onRemove(state, level, pos, replacement, moving);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package net.dnorberg.rivencraft.block;
|
||||||
|
|
||||||
|
import com.mojang.serialization.MapCodec;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.menu.InkMixerMenu;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.Containers;
|
||||||
|
import net.minecraft.world.InteractionResult;
|
||||||
|
import net.minecraft.world.SimpleMenuProvider;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.block.BaseEntityBlock;
|
||||||
|
import net.minecraft.world.level.block.RenderShape;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.phys.BlockHitResult;
|
||||||
|
|
||||||
|
public final class InkMixerBlock extends BaseEntityBlock {
|
||||||
|
public static final MapCodec<InkMixerBlock> CODEC = simpleCodec(InkMixerBlock::new);
|
||||||
|
|
||||||
|
public InkMixerBlock(Properties properties) { super(properties); }
|
||||||
|
|
||||||
|
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
|
||||||
|
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
|
||||||
|
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
return new InkMixerBlockEntity(pos, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
|
||||||
|
Player player, BlockHitResult hit) {
|
||||||
|
if (!level.isClientSide && player instanceof ServerPlayer serverPlayer
|
||||||
|
&& level.getBlockEntity(pos) instanceof InkMixerBlockEntity mixer) {
|
||||||
|
serverPlayer.openMenu(new SimpleMenuProvider(
|
||||||
|
(id, inventory, ignored) -> new InkMixerMenu(id, inventory, mixer),
|
||||||
|
Component.translatable("container.rivencraft.ink_mixer")),
|
||||||
|
buffer -> buffer.writeBlockPos(pos));
|
||||||
|
}
|
||||||
|
return InteractionResult.sidedSuccess(level.isClientSide);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected void onRemove(BlockState state, Level level, BlockPos pos,
|
||||||
|
BlockState replacement, boolean moving) {
|
||||||
|
if (!state.is(replacement.getBlock()) && !level.isClientSide
|
||||||
|
&& level.getBlockEntity(pos) instanceof InkMixerBlockEntity mixer) {
|
||||||
|
Containers.dropContents(level, pos, mixer);
|
||||||
|
level.updateNeighbourForOutputSignal(pos, this);
|
||||||
|
}
|
||||||
|
super.onRemove(state, level, pos, replacement, moving);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package net.dnorberg.rivencraft.block;
|
||||||
|
|
||||||
|
import com.mojang.serialization.MapCodec;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.Containers;
|
||||||
|
import net.minecraft.world.InteractionResult;
|
||||||
|
import net.minecraft.world.SimpleMenuProvider;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.block.BaseEntityBlock;
|
||||||
|
import net.minecraft.world.level.block.RenderShape;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.level.block.state.StateDefinition;
|
||||||
|
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||||
|
import net.minecraft.world.level.block.state.properties.DirectionProperty;
|
||||||
|
import net.minecraft.world.level.block.state.properties.BooleanProperty;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.entity.LivingEntity;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.phys.BlockHitResult;
|
||||||
|
|
||||||
|
public final class WritingDeskBlock extends BaseEntityBlock {
|
||||||
|
public static final MapCodec<WritingDeskBlock> CODEC = simpleCodec(WritingDeskBlock::new);
|
||||||
|
public static final DirectionProperty FACING = BlockStateProperties.HORIZONTAL_FACING;
|
||||||
|
public static final BooleanProperty EXTENSION = BooleanProperty.create("extension");
|
||||||
|
|
||||||
|
public WritingDeskBlock(Properties properties) {
|
||||||
|
super(properties);
|
||||||
|
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(EXTENSION, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
|
||||||
|
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> builder) {
|
||||||
|
builder.add(FACING, EXTENSION);
|
||||||
|
}
|
||||||
|
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
|
||||||
|
Direction facing = context.getHorizontalDirection().getOpposite();
|
||||||
|
BlockPos extension = context.getClickedPos().relative(facing.getClockWise());
|
||||||
|
return context.getLevel().getBlockState(extension).canBeReplaced(context)
|
||||||
|
? defaultBlockState().setValue(FACING, facing) : null;
|
||||||
|
}
|
||||||
|
@Override public void setPlacedBy(Level level, BlockPos pos, BlockState state,
|
||||||
|
@Nullable LivingEntity placer, ItemStack stack) {
|
||||||
|
super.setPlacedBy(level, pos, state, placer, stack);
|
||||||
|
level.setBlock(pos.relative(state.getValue(FACING).getClockWise()),
|
||||||
|
state.setValue(EXTENSION, true), 3);
|
||||||
|
}
|
||||||
|
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
|
||||||
|
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
return state.getValue(EXTENSION) ? null : new WritingDeskBlockEntity(pos, state);
|
||||||
|
}
|
||||||
|
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
|
||||||
|
Player player, BlockHitResult hit) {
|
||||||
|
BlockPos controller = state.getValue(EXTENSION)
|
||||||
|
? pos.relative(state.getValue(FACING).getCounterClockWise()) : pos;
|
||||||
|
if (!level.isClientSide && player instanceof ServerPlayer serverPlayer
|
||||||
|
&& level.getBlockEntity(controller) instanceof WritingDeskBlockEntity desk) {
|
||||||
|
serverPlayer.openMenu(new SimpleMenuProvider(
|
||||||
|
(id, inventory, ignored) -> new WritingDeskMenu(id, inventory, desk),
|
||||||
|
Component.translatable("container.rivencraft.writing_desk")),
|
||||||
|
buffer -> {
|
||||||
|
buffer.writeBlockPos(controller);
|
||||||
|
WritingDeskCatalog.write(buffer, WritingDeskCatalog.available(desk));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return InteractionResult.sidedSuccess(level.isClientSide);
|
||||||
|
}
|
||||||
|
@Override protected void onRemove(BlockState state, Level level, BlockPos pos, BlockState replacement, boolean moving) {
|
||||||
|
if (!state.is(replacement.getBlock())) {
|
||||||
|
if (!level.isClientSide && !state.getValue(EXTENSION)
|
||||||
|
&& level.getBlockEntity(pos) instanceof WritingDeskBlockEntity desk) {
|
||||||
|
Containers.dropContents(level, pos, desk);
|
||||||
|
level.updateNeighbourForOutputSignal(pos, this);
|
||||||
|
}
|
||||||
|
BlockPos other = pos.relative(state.getValue(FACING).getClockWise(),
|
||||||
|
state.getValue(EXTENSION) ? -1 : 1);
|
||||||
|
BlockState otherState = level.getBlockState(other);
|
||||||
|
if (otherState.is(this) && otherState.getValue(EXTENSION) != state.getValue(EXTENSION)) {
|
||||||
|
level.setBlock(other, net.minecraft.world.level.block.Blocks.AIR.defaultBlockState(), 35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.onRemove(state, level, pos, replacement, moving);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package net.dnorberg.rivencraft.block.entity;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeReference;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModBlockEntities;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||||
|
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||||
|
import net.dnorberg.rivencraft.writing.PageContent;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.HolderLookup;
|
||||||
|
import net.minecraft.core.NonNullList;
|
||||||
|
import net.minecraft.core.component.DataComponents;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.network.protocol.Packet;
|
||||||
|
import net.minecraft.network.protocol.game.ClientGamePacketListener;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
|
||||||
|
import net.minecraft.world.Container;
|
||||||
|
import net.minecraft.world.ContainerHelper;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
|
||||||
|
/** Persistent inputs for a binder; the assembled output is never stored here. */
|
||||||
|
public final class BookBinderBlockEntity extends BlockEntity implements Container {
|
||||||
|
public static final int COVER = 0;
|
||||||
|
public static final int LINK_PANEL = 1;
|
||||||
|
public static final int NOTEBOOK = 2;
|
||||||
|
public static final int FIRST_PAGE = 3;
|
||||||
|
public static final int PAGE_COUNT = NotebookContents.MAX_PAGES;
|
||||||
|
public static final int SIZE = FIRST_PAGE + PAGE_COUNT;
|
||||||
|
public static final int MAX_TITLE_LENGTH = 32;
|
||||||
|
|
||||||
|
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||||
|
private String title = "";
|
||||||
|
private UUID pendingAgeId;
|
||||||
|
|
||||||
|
public BookBinderBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
super(ModBlockEntities.BOOK_BINDER.get(), pos, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String title() { return title; }
|
||||||
|
|
||||||
|
public void setTitle(String value) {
|
||||||
|
String normalized = value == null ? "" : value.strip();
|
||||||
|
if (normalized.length() > MAX_TITLE_LENGTH) normalized = normalized.substring(0, MAX_TITLE_LENGTH);
|
||||||
|
if (!normalized.equals(title)) {
|
||||||
|
title = normalized;
|
||||||
|
setChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean validCover(ItemStack stack) {
|
||||||
|
return stack.is(Items.LEATHER) || stack.is(ModItems.NOTEBOOK.get())
|
||||||
|
&& stack.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pageCount() == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean validPage(ItemStack stack) {
|
||||||
|
return stack.is(ModItems.PAGE.get()) && PageItem.content(stack).kind() != PageContent.Kind.LINK_PANEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public int getContainerSize() { return SIZE; }
|
||||||
|
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
|
||||||
|
@Override public ItemStack getItem(int slot) { return items.get(slot); }
|
||||||
|
@Override public ItemStack removeItem(int slot, int amount) {
|
||||||
|
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
|
||||||
|
if (!removed.isEmpty()) setChanged();
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
@Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); }
|
||||||
|
@Override public void setItem(int slot, ItemStack stack) {
|
||||||
|
items.set(slot, stack);
|
||||||
|
if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize());
|
||||||
|
setChanged();
|
||||||
|
}
|
||||||
|
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
|
||||||
|
return switch (slot) {
|
||||||
|
case COVER -> validCover(stack);
|
||||||
|
case LINK_PANEL -> stack.is(ModItems.PAGE.get())
|
||||||
|
&& PageItem.content(stack).kind() == PageContent.Kind.LINK_PANEL;
|
||||||
|
case NOTEBOOK -> stack.is(ModItems.NOTEBOOK.get());
|
||||||
|
default -> slot >= FIRST_PAGE && slot < SIZE && validPage(stack);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@Override public boolean stillValid(Player player) {
|
||||||
|
return level != null && level.isLoaded(worldPosition) && level.getBlockEntity(worldPosition) == this
|
||||||
|
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
|
||||||
|
worldPosition.getZ() + 0.5) <= 64.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All checks precede any mutation so a failed assembly leaves every input intact. */
|
||||||
|
public boolean canAssemble() {
|
||||||
|
if (title.isBlank() || !validCover(items.get(COVER))
|
||||||
|
|| !canPlaceItem(LINK_PANEL, items.get(LINK_PANEL))
|
||||||
|
|| items.get(LINK_PANEL).getCount() != 1) return false;
|
||||||
|
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
|
||||||
|
ItemStack page = items.get(slot);
|
||||||
|
if (!page.isEmpty() && (!validPage(page) || !PageItem.isUsableWriting(page) || page.getCount() != 1
|
||||||
|
|| PageItem.content(page).kind() == PageContent.Kind.BLANK)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ItemStack previewBook() {
|
||||||
|
if (!canAssemble()) return ItemStack.EMPTY;
|
||||||
|
if (pendingAgeId == null) {
|
||||||
|
pendingAgeId = UUID.randomUUID();
|
||||||
|
setChanged();
|
||||||
|
}
|
||||||
|
List<WritingEntry> entries = new ArrayList<>();
|
||||||
|
entries.add(WritingEntry.linkPanel());
|
||||||
|
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
|
||||||
|
ItemStack page = items.get(slot);
|
||||||
|
if (!page.isEmpty()) entries.add(WritingEntry.symbol(PageItem.content(page).symbol().orElseThrow()));
|
||||||
|
}
|
||||||
|
ItemStack book = new ItemStack(ModItems.DESCRIPTIVE_BOOK.get());
|
||||||
|
book.set(ModDataComponents.AGE_REFERENCE.get(), new AgeReference(pendingAgeId, title));
|
||||||
|
book.set(ModDataComponents.BOOK_WRITING.get(), new OrderedWriting(entries));
|
||||||
|
book.set(DataComponents.CUSTOM_NAME, Component.literal(title));
|
||||||
|
return book;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called once, on the server, after the output can be delivered. */
|
||||||
|
public boolean consumeAssembly() {
|
||||||
|
if (!canAssemble()) return false;
|
||||||
|
if (pendingAgeId == null) pendingAgeId = UUID.randomUUID();
|
||||||
|
if (level instanceof ServerLevel serverLevel)
|
||||||
|
AgeRegistrySavedData.resolveOrCreate(serverLevel, new AgeReference(pendingAgeId, title));
|
||||||
|
items.get(COVER).shrink(1);
|
||||||
|
if (items.get(COVER).isEmpty()) items.set(COVER, ItemStack.EMPTY);
|
||||||
|
items.get(LINK_PANEL).shrink(1);
|
||||||
|
if (items.get(LINK_PANEL).isEmpty()) items.set(LINK_PANEL, ItemStack.EMPTY);
|
||||||
|
for (int slot = FIRST_PAGE; slot < SIZE; slot++) items.set(slot, ItemStack.EMPTY);
|
||||||
|
title = "";
|
||||||
|
pendingAgeId = null;
|
||||||
|
setChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int nextPageIndex() {
|
||||||
|
for (int index = PAGE_COUNT - 1; index >= 0; index--) {
|
||||||
|
if (!items.get(FIRST_PAGE + index).isEmpty()) return index + 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert before a displayed page, shifting later pages as Legacy's strip does. */
|
||||||
|
public boolean insertPage(int index, ItemStack page) {
|
||||||
|
int end = nextPageIndex();
|
||||||
|
if (index < 0 || index > end || end >= PAGE_COUNT || page.getCount() != 1
|
||||||
|
|| !validPage(page)) return false;
|
||||||
|
for (int current = end; current > index; current--)
|
||||||
|
items.set(FIRST_PAGE + current, items.get(FIRST_PAGE + current - 1));
|
||||||
|
items.set(FIRST_PAGE + index, page.copy());
|
||||||
|
setChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Take a page back to the cursor, closing the gap in the displayed order. */
|
||||||
|
public ItemStack takePage(int index) {
|
||||||
|
int end = nextPageIndex();
|
||||||
|
if (index < 0 || index >= end || items.get(FIRST_PAGE + index).isEmpty()) return ItemStack.EMPTY;
|
||||||
|
ItemStack removed = items.get(FIRST_PAGE + index);
|
||||||
|
for (int current = index; current < end - 1; current++)
|
||||||
|
items.set(FIRST_PAGE + current, items.get(FIRST_PAGE + current + 1));
|
||||||
|
items.set(FIRST_PAGE + end - 1, ItemStack.EMPTY);
|
||||||
|
setChanged();
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move every Notebook page as one transaction, leaving its empty cover in the input slot. */
|
||||||
|
public boolean importNotebook() {
|
||||||
|
ItemStack notebook = items.get(NOTEBOOK);
|
||||||
|
ItemStack emptied = importNotebookFromStack(notebook, nextPageIndex());
|
||||||
|
if (emptied.isEmpty()) return false;
|
||||||
|
items.set(NOTEBOOK, emptied);
|
||||||
|
setChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import a Notebook from either its stored slot or the player's cursor. */
|
||||||
|
public ItemStack importNotebookFromStack(ItemStack notebook, int index) {
|
||||||
|
if (!notebook.is(ModItems.NOTEBOOK.get()) || notebook.getCount() != 1) return ItemStack.EMPTY;
|
||||||
|
List<ItemStack> pages = notebook.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
||||||
|
if (pages.isEmpty()) return ItemStack.EMPTY;
|
||||||
|
int end = nextPageIndex();
|
||||||
|
if (index < 0 || index > end) return ItemStack.EMPTY;
|
||||||
|
ItemStack linkPanel = ItemStack.EMPTY;
|
||||||
|
List<ItemStack> writing = new ArrayList<>();
|
||||||
|
for (ItemStack page : pages) {
|
||||||
|
if (page.isEmpty()) continue;
|
||||||
|
if (!page.is(ModItems.PAGE.get()) || page.getCount() != 1) return ItemStack.EMPTY;
|
||||||
|
if (PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL) {
|
||||||
|
if (!linkPanel.isEmpty() || !items.get(LINK_PANEL).isEmpty()) return ItemStack.EMPTY;
|
||||||
|
linkPanel = page;
|
||||||
|
} else if (validPage(page)) writing.add(page);
|
||||||
|
else return ItemStack.EMPTY;
|
||||||
|
}
|
||||||
|
if (end + writing.size() > PAGE_COUNT || writing.isEmpty() && linkPanel.isEmpty()) return ItemStack.EMPTY;
|
||||||
|
if (!linkPanel.isEmpty()) items.set(LINK_PANEL, linkPanel.copy());
|
||||||
|
for (int current = end - 1; current >= index; current--)
|
||||||
|
items.set(FIRST_PAGE + current + writing.size(), items.get(FIRST_PAGE + current));
|
||||||
|
for (ItemStack page : writing) items.set(FIRST_PAGE + index++, page.copy());
|
||||||
|
ItemStack emptyNotebook = notebook.copy();
|
||||||
|
emptyNotebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
|
||||||
|
setChanged();
|
||||||
|
return emptyNotebook;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void clearContent() {
|
||||||
|
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||||
|
title = "";
|
||||||
|
pendingAgeId = null;
|
||||||
|
setChanged();
|
||||||
|
}
|
||||||
|
@Override protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
|
||||||
|
super.loadAdditional(tag, registries);
|
||||||
|
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||||
|
ContainerHelper.loadAllItems(tag, items, registries);
|
||||||
|
title = tag.getString("Title");
|
||||||
|
pendingAgeId = tag.hasUUID("PendingAgeId") ? tag.getUUID("PendingAgeId") : null;
|
||||||
|
}
|
||||||
|
@Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
|
||||||
|
super.saveAdditional(tag, registries);
|
||||||
|
ContainerHelper.saveAllItems(tag, items, registries);
|
||||||
|
tag.putString("Title", title);
|
||||||
|
if (pendingAgeId != null) tag.putUUID("PendingAgeId", pendingAgeId);
|
||||||
|
}
|
||||||
|
@Override public Packet<ClientGamePacketListener> getUpdatePacket() {
|
||||||
|
return ClientboundBlockEntityDataPacket.create(this);
|
||||||
|
}
|
||||||
|
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
|
||||||
|
return saveWithoutMetadata(registries);
|
||||||
|
}
|
||||||
|
@Override public void setChanged() {
|
||||||
|
super.setChanged();
|
||||||
|
if (level != null && !level.isClientSide) {
|
||||||
|
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package net.dnorberg.rivencraft.block.entity;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||||
|
import net.dnorberg.rivencraft.writing.InkVialExchange;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModBlockEntities;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingInkService;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.HolderLookup;
|
||||||
|
import net.minecraft.core.NonNullList;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.world.Container;
|
||||||
|
import net.minecraft.world.ContainerHelper;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
|
||||||
|
/** Baseline Link Panel mixer. Modifier ingredients belong to the symbol/ink-effects milestone. */
|
||||||
|
public final class InkMixerBlockEntity extends BlockEntity implements Container {
|
||||||
|
public static final int INK = 0;
|
||||||
|
public static final int PAPER = 1;
|
||||||
|
public static final int BOTTLES = 2;
|
||||||
|
public static final int SIZE = 3;
|
||||||
|
|
||||||
|
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||||
|
private int inkRemaining;
|
||||||
|
|
||||||
|
public InkMixerBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
super(ModBlockEntities.INK_MIXER.get(), pos, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int inkRemaining() { return inkRemaining; }
|
||||||
|
|
||||||
|
@Override public int getContainerSize() { return SIZE; }
|
||||||
|
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
|
||||||
|
@Override public ItemStack getItem(int slot) { return items.get(slot); }
|
||||||
|
@Override public ItemStack removeItem(int slot, int amount) {
|
||||||
|
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
|
||||||
|
if (!removed.isEmpty()) {
|
||||||
|
setChanged();
|
||||||
|
if (slot == BOTTLES) refillEmptyBasin();
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
@Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); }
|
||||||
|
@Override public void setItem(int slot, ItemStack stack) {
|
||||||
|
items.set(slot, stack);
|
||||||
|
if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize());
|
||||||
|
setChanged();
|
||||||
|
if (slot == INK || slot == BOTTLES) refillEmptyBasin();
|
||||||
|
}
|
||||||
|
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
|
||||||
|
return switch (slot) {
|
||||||
|
case INK -> stack.is(ModItems.INK_VIAL.get());
|
||||||
|
case PAPER -> stack.is(Items.PAPER);
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@Override public boolean stillValid(Player player) {
|
||||||
|
return level != null && level.isLoaded(worldPosition) && level.getBlockEntity(worldPosition) == this
|
||||||
|
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
|
||||||
|
worldPosition.getZ() + 0.5) <= 64.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No server tick: refill only when ink, bottle space, or consumption changes. */
|
||||||
|
private void refillEmptyBasin() {
|
||||||
|
if (level != null && !level.isClientSide && inkRemaining == 0) {
|
||||||
|
int addedInk = InkVialExchange.exchange(items, INK, BOTTLES);
|
||||||
|
if (addedInk > 0) {
|
||||||
|
inkRemaining = addedInk;
|
||||||
|
setChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canCreateLinkPanel() {
|
||||||
|
if (!items.get(PAPER).is(Items.PAPER)) return false;
|
||||||
|
int cost = WritingInkService.linkPanelCost();
|
||||||
|
return InkVialExchange.canPay(items, INK, BOTTLES, inkRemaining, cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commit inputs only when a virtual output is actually taken. */
|
||||||
|
public boolean consumeLinkPanelMaterials() {
|
||||||
|
if (!canCreateLinkPanel()) return false;
|
||||||
|
int cost = WritingInkService.linkPanelCost();
|
||||||
|
if (inkRemaining < cost) {
|
||||||
|
inkRemaining += InkVialExchange.exchange(items, INK, BOTTLES);
|
||||||
|
}
|
||||||
|
inkRemaining -= cost;
|
||||||
|
items.get(PAPER).shrink(1);
|
||||||
|
if (items.get(PAPER).isEmpty()) items.set(PAPER, ItemStack.EMPTY);
|
||||||
|
setChanged();
|
||||||
|
if (inkRemaining == 0) refillEmptyBasin();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void setLevel(Level level) {
|
||||||
|
super.setLevel(level);
|
||||||
|
refillEmptyBasin();
|
||||||
|
}
|
||||||
|
@Override public void clearContent() {
|
||||||
|
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||||
|
inkRemaining = 0;
|
||||||
|
setChanged();
|
||||||
|
}
|
||||||
|
@Override protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
|
||||||
|
super.loadAdditional(tag, registries);
|
||||||
|
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||||
|
ContainerHelper.loadAllItems(tag, items, registries);
|
||||||
|
inkRemaining = Math.clamp(tag.getInt("InkRemaining"), 0, InkVialItem.CAPACITY);
|
||||||
|
refillEmptyBasin();
|
||||||
|
}
|
||||||
|
@Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
|
||||||
|
super.saveAdditional(tag, registries);
|
||||||
|
ContainerHelper.saveAllItems(tag, items, registries);
|
||||||
|
tag.putInt("InkRemaining", inkRemaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package net.dnorberg.rivencraft.block.entity;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||||
|
import net.dnorberg.rivencraft.writing.InkVialExchange;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingInkService;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModBlockEntities;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.HolderLookup;
|
||||||
|
import net.minecraft.core.NonNullList;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.network.protocol.Packet;
|
||||||
|
import net.minecraft.network.protocol.game.ClientGamePacketListener;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
|
||||||
|
import net.minecraft.world.Container;
|
||||||
|
import net.minecraft.world.ContainerHelper;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
|
||||||
|
/** Collection tabs, paper, ink vials and empty-vial return are owned by the block. */
|
||||||
|
public final class WritingDeskBlockEntity extends BlockEntity implements Container {
|
||||||
|
public static final int COLLECTIONS = 88;
|
||||||
|
public static final int INK = COLLECTIONS;
|
||||||
|
public static final int RETURN = INK + 1;
|
||||||
|
public static final int PAPER = INK + 2;
|
||||||
|
/** Unused by new desks; retained so old saves cannot silently lose slot contents. */
|
||||||
|
public static final int LEGACY_OUTPUT = INK + 3;
|
||||||
|
public static final int SIZE = INK + 4;
|
||||||
|
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||||
|
private int activeCollection;
|
||||||
|
private int inkRemaining;
|
||||||
|
private ItemStack displayPage = ItemStack.EMPTY;
|
||||||
|
|
||||||
|
public WritingDeskBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
super(ModBlockEntities.WRITING_DESK.get(), pos, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int activeCollection() { return activeCollection; }
|
||||||
|
public int inkRemaining() { return inkRemaining; }
|
||||||
|
public ItemStack displayPage() { return displayPage; }
|
||||||
|
public void setDisplayPage(ItemStack page) {
|
||||||
|
ItemStack next = page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1);
|
||||||
|
if (!ItemStack.matches(displayPage, next)) {
|
||||||
|
displayPage = next;
|
||||||
|
setChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public void setActiveCollection(int index) {
|
||||||
|
if (index >= 0 && index < COLLECTIONS && activeCollection != index) {
|
||||||
|
activeCollection = index;
|
||||||
|
setChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public int getContainerSize() { return SIZE; }
|
||||||
|
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
|
||||||
|
@Override public ItemStack getItem(int slot) { return items.get(slot); }
|
||||||
|
@Override public ItemStack removeItem(int slot, int amount) {
|
||||||
|
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
|
||||||
|
if (!removed.isEmpty()) {
|
||||||
|
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
|
||||||
|
setChanged();
|
||||||
|
if (slot == RETURN) refillEmptyInkBar();
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
@Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); }
|
||||||
|
@Override public void setItem(int slot, ItemStack stack) {
|
||||||
|
items.set(slot, stack);
|
||||||
|
if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize());
|
||||||
|
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
|
||||||
|
setChanged();
|
||||||
|
if (slot == INK || slot == RETURN) refillEmptyInkBar();
|
||||||
|
}
|
||||||
|
@Override public boolean stillValid(Player player) {
|
||||||
|
return level != null && level.isLoaded(worldPosition)
|
||||||
|
&& level.getBlockEntity(worldPosition) == this
|
||||||
|
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
|
||||||
|
worldPosition.getZ() + 0.5) <= 64.0;
|
||||||
|
}
|
||||||
|
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
|
||||||
|
if (slot < COLLECTIONS) return stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.PAGE.get());
|
||||||
|
return switch (slot) {
|
||||||
|
case INK -> stack.is(ModItems.INK_VIAL.get());
|
||||||
|
case RETURN -> false;
|
||||||
|
case PAPER -> stack.is(Items.PAPER);
|
||||||
|
case LEGACY_OUTPUT -> false;
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consume one vial only when the empty meter can return its glass bottle. */
|
||||||
|
private void refillEmptyInkBar() {
|
||||||
|
if (level != null && !level.isClientSide && inkRemaining == 0) {
|
||||||
|
int addedInk = InkVialExchange.exchange(items, INK, RETURN);
|
||||||
|
if (addedInk > 0) {
|
||||||
|
inkRemaining = addedInk;
|
||||||
|
setChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The output is virtual; inputs are committed only after a result can be given to a player. */
|
||||||
|
public boolean canConsumeCopyMaterials() {
|
||||||
|
ItemStack paper = items.get(PAPER);
|
||||||
|
if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false;
|
||||||
|
int cost = WritingInkService.symbolCopyCost();
|
||||||
|
return InkVialExchange.canPay(items, INK, RETURN, inkRemaining, cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean consumeCopyMaterials() {
|
||||||
|
if (!canConsumeCopyMaterials()) return false;
|
||||||
|
int cost = WritingInkService.symbolCopyCost();
|
||||||
|
if (inkRemaining < cost) {
|
||||||
|
inkRemaining += InkVialExchange.exchange(items, INK, RETURN);
|
||||||
|
}
|
||||||
|
inkRemaining -= cost;
|
||||||
|
removeItem(PAPER, 1);
|
||||||
|
setChanged();
|
||||||
|
if (inkRemaining == 0) refillEmptyInkBar();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
@Override public void setLevel(Level level) {
|
||||||
|
super.setLevel(level);
|
||||||
|
refillEmptyInkBar();
|
||||||
|
}
|
||||||
|
@Override public void clearContent() {
|
||||||
|
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||||
|
inkRemaining = 0;
|
||||||
|
displayPage = ItemStack.EMPTY;
|
||||||
|
setChanged();
|
||||||
|
}
|
||||||
|
@Override protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
|
||||||
|
super.loadAdditional(tag, registries);
|
||||||
|
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||||
|
ContainerHelper.loadAllItems(tag, items, registries);
|
||||||
|
// Pre-88-tab desks stored materials in slots 4..7. Move them once;
|
||||||
|
// never interpret those stacks as newly added collection tabs.
|
||||||
|
if (tag.getInt("InventoryVersion") < 1) {
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
items.set(INK + i, items.get(4 + i));
|
||||||
|
items.set(4 + i, ItemStack.EMPTY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeCollection = Math.clamp(tag.getInt("ActiveCollection"), 0, COLLECTIONS - 1);
|
||||||
|
inkRemaining = Math.clamp(tag.getInt("InkRemaining"), 0, InkVialItem.CAPACITY);
|
||||||
|
displayPage = tag.contains("DisplayPage", CompoundTag.TAG_COMPOUND)
|
||||||
|
? ItemStack.parse(registries, tag.getCompound("DisplayPage")).orElse(ItemStack.EMPTY)
|
||||||
|
: ItemStack.EMPTY;
|
||||||
|
refillEmptyInkBar();
|
||||||
|
}
|
||||||
|
@Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
|
||||||
|
super.saveAdditional(tag, registries);
|
||||||
|
ContainerHelper.saveAllItems(tag, items, registries);
|
||||||
|
tag.putInt("InventoryVersion", 1);
|
||||||
|
tag.putInt("ActiveCollection", activeCollection);
|
||||||
|
tag.putInt("InkRemaining", inkRemaining);
|
||||||
|
if (!displayPage.isEmpty()) tag.put("DisplayPage", displayPage.save(registries));
|
||||||
|
}
|
||||||
|
@Override public Packet<ClientGamePacketListener> getUpdatePacket() {
|
||||||
|
return ClientboundBlockEntityDataPacket.create(this);
|
||||||
|
}
|
||||||
|
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
|
||||||
|
return saveWithoutMetadata(registries);
|
||||||
|
}
|
||||||
|
@Override public void setChanged() {
|
||||||
|
super.setChanged();
|
||||||
|
if (level != null && !level.isClientSide) {
|
||||||
|
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,8 +92,8 @@ public final class ClientBookInteractionHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
minecraft.setScreen(payload.fromStand()
|
minecraft.setScreen(payload.fromStand()
|
||||||
? new DescriptiveBookScreen(payload.source().pos(), payload.book(), payload.traits())
|
? new DescriptiveBookScreen(payload.source().pos(), payload.book(), payload.traits(), payload.writingSymbols())
|
||||||
: new DescriptiveBookScreen(hand, payload.book(), payload.traits()));
|
: new DescriptiveBookScreen(hand, payload.book(), payload.traits(), payload.writingSymbols()));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,9 @@ import net.neoforged.neoforge.common.NeoForge;
|
|||||||
import net.neoforged.neoforge.client.event.EntityRenderersEvent;
|
import net.neoforged.neoforge.client.event.EntityRenderersEvent;
|
||||||
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
|
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
|
||||||
import net.dnorberg.rivencraft.client.screen.NotebookScreen;
|
import net.dnorberg.rivencraft.client.screen.NotebookScreen;
|
||||||
|
import net.dnorberg.rivencraft.client.screen.WritingDeskScreen;
|
||||||
|
import net.dnorberg.rivencraft.client.screen.BookBinderScreen;
|
||||||
|
import net.dnorberg.rivencraft.client.screen.InkMixerScreen;
|
||||||
import net.dnorberg.rivencraft.registry.ModMenus;
|
import net.dnorberg.rivencraft.registry.ModMenus;
|
||||||
|
|
||||||
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
|
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
|
||||||
@@ -24,11 +27,15 @@ public final class ClientEvents {
|
|||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) {
|
public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) {
|
||||||
event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new);
|
event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new);
|
||||||
|
event.registerBlockEntityRenderer(ModBlockEntities.WRITING_DESK.get(), WritingDeskRenderer::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
public static void registerScreens(RegisterMenuScreensEvent event) {
|
public static void registerScreens(RegisterMenuScreensEvent event) {
|
||||||
event.register(ModMenus.NOTEBOOK.get(), NotebookScreen::new);
|
event.register(ModMenus.NOTEBOOK.get(), NotebookScreen::new);
|
||||||
|
event.register(ModMenus.WRITING_DESK.get(), WritingDeskScreen::new);
|
||||||
|
event.register(ModMenus.BOOK_BINDER.get(), BookBinderScreen::new);
|
||||||
|
event.register(ModMenus.INK_MIXER.get(), InkMixerScreen::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package net.dnorberg.rivencraft.client;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
|
||||||
|
import net.dnorberg.rivencraft.network.WritingDeskCatalogPayload;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.neoforged.neoforge.network.handling.IPayloadContext;
|
||||||
|
|
||||||
|
public final class ClientWritingDeskCatalog {
|
||||||
|
private ClientWritingDeskCatalog() {}
|
||||||
|
|
||||||
|
public static void handle(WritingDeskCatalogPayload payload, IPayloadContext context) {
|
||||||
|
context.enqueueWork(() -> {
|
||||||
|
var player = Minecraft.getInstance().player;
|
||||||
|
if (player != null && player.containerMenu instanceof WritingDeskMenu menu
|
||||||
|
&& menu.containerId == payload.containerId()) {
|
||||||
|
menu.setCatalog(payload.symbols());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package net.dnorberg.rivencraft.client;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import com.mojang.math.Axis;
|
||||||
|
import net.dnorberg.rivencraft.block.WritingDeskBlock;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.renderer.MultiBufferSource;
|
||||||
|
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
|
||||||
|
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
|
||||||
|
import net.minecraft.world.item.ItemDisplayContext;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
|
||||||
|
/** Visible selected page and loaded paper, as on the Legacy desk. */
|
||||||
|
public final class WritingDeskRenderer implements BlockEntityRenderer<WritingDeskBlockEntity> {
|
||||||
|
public WritingDeskRenderer(BlockEntityRendererProvider.Context context) {}
|
||||||
|
|
||||||
|
@Override public void render(WritingDeskBlockEntity desk, float partialTick, PoseStack pose,
|
||||||
|
MultiBufferSource buffers, int light, int overlay) {
|
||||||
|
int paper = desk.getItem(WritingDeskBlockEntity.PAPER).getCount();
|
||||||
|
ItemStack page = desk.displayPage();
|
||||||
|
if (paper == 0 && page.isEmpty()) return;
|
||||||
|
pose.pushPose();
|
||||||
|
pose.translate(0.5, 0, 0.5);
|
||||||
|
pose.mulPose(Axis.YP.rotationDegrees(
|
||||||
|
180.0F - desk.getBlockState().getValue(WritingDeskBlock.FACING).toYRot()));
|
||||||
|
if (paper > 0) {
|
||||||
|
ItemStack sheet = new ItemStack(Items.PAPER);
|
||||||
|
int layers = Math.min(4, 1 + paper / 16);
|
||||||
|
for (int i = 0; i < layers; i++) {
|
||||||
|
drawFlat(desk, sheet, pose, buffers, light, overlay, 1.22, 0.27 + i * 0.008, -0.14, 0.55F);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!page.isEmpty()) {
|
||||||
|
drawFlat(desk, page, pose, buffers, light, overlay, 1.0, 0.572, 0.0, 0.8F);
|
||||||
|
}
|
||||||
|
pose.popPose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void drawFlat(WritingDeskBlockEntity desk, ItemStack stack, PoseStack pose,
|
||||||
|
MultiBufferSource buffers, int light, int overlay,
|
||||||
|
double x, double y, double z, float scale) {
|
||||||
|
pose.pushPose();
|
||||||
|
pose.translate(x, y, z);
|
||||||
|
pose.mulPose(Axis.XP.rotationDegrees(90));
|
||||||
|
pose.scale(scale, scale, scale);
|
||||||
|
Minecraft.getInstance().getItemRenderer().renderStatic(stack, ItemDisplayContext.FIXED,
|
||||||
|
light, overlay, pose, buffers, desk.getLevel(), 0);
|
||||||
|
pose.popPose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean shouldRenderOffScreen(WritingDeskBlockEntity blockEntity) { return true; }
|
||||||
|
@Override public int getViewDistance() { return 64; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package net.dnorberg.rivencraft.client.screen;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.menu.BookBinderMenu;
|
||||||
|
import net.dnorberg.rivencraft.network.SetBookBinderTitlePayload;
|
||||||
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
|
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.entity.player.Inventory;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.neoforged.neoforge.network.PacketDistributor;
|
||||||
|
import org.lwjgl.glfw.GLFW;
|
||||||
|
|
||||||
|
/** Legacy-sized binder: cover and result flank a scrolling strip of full page thumbnails. */
|
||||||
|
public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMenu> {
|
||||||
|
private static final ResourceLocation BACKGROUND = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
Rivencraft.MOD_ID, "textures/gui/book_binder_legacy.png");
|
||||||
|
private static final ResourceLocation PAGE_ATLAS = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
Rivencraft.MOD_ID, "textures/gui/notebook_pages.png");
|
||||||
|
private static final int STRIP_X = 7;
|
||||||
|
private static final int STRIP_Y = 45;
|
||||||
|
private static final int STRIP_WIDTH = 162;
|
||||||
|
private static final int STRIP_HEIGHT = 40;
|
||||||
|
private static final int PAGE_WIDTH = 26;
|
||||||
|
private static final int PAGE_HEIGHT = 34;
|
||||||
|
private static final int PAGE_STEP = 28;
|
||||||
|
private static final int VISIBLE_PAGES = 5;
|
||||||
|
|
||||||
|
private EditBox titleInput;
|
||||||
|
private int firstVisible;
|
||||||
|
private boolean stripClickPendingRelease;
|
||||||
|
|
||||||
|
public BookBinderScreen(BookBinderMenu menu, Inventory inventory, Component title) {
|
||||||
|
super(menu, inventory, title);
|
||||||
|
imageWidth = 176;
|
||||||
|
imageHeight = 181;
|
||||||
|
inventoryLabelX = 8;
|
||||||
|
inventoryLabelY = 87;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean isPauseScreen() { return false; }
|
||||||
|
|
||||||
|
@Override protected void init() {
|
||||||
|
super.init();
|
||||||
|
titleInput = new EditBox(font, leftPos + 9, topPos + 10, 112, 12,
|
||||||
|
Component.translatable("screen.rivencraft.book_binder.title"));
|
||||||
|
titleInput.setBordered(false);
|
||||||
|
titleInput.setTextColor(0xFFFFFFFF);
|
||||||
|
titleInput.setMaxLength(BookBinderBlockEntity.MAX_TITLE_LENGTH);
|
||||||
|
titleInput.setValue(menu.title());
|
||||||
|
titleInput.setHint(Component.translatable("screen.rivencraft.book_binder.title_hint"));
|
||||||
|
titleInput.setResponder(value -> PacketDistributor.sendToServer(
|
||||||
|
new SetBookBinderTitlePayload(menu.containerId, value)));
|
||||||
|
addRenderableWidget(titleInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||||
|
renderBg(graphics, partialTick, mouseX, mouseY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
|
||||||
|
graphics.blit(BACKGROUND, leftPos, topPos, 0, 0, imageWidth, imageHeight, 256, 256);
|
||||||
|
if (menu.getSlot(BookBinderBlockEntity.LINK_PANEL).getItem().isEmpty()) {
|
||||||
|
float fade = (System.currentTimeMillis() % 4000L) / 2000F;
|
||||||
|
if (fade > 1F) fade = 2F - fade;
|
||||||
|
graphics.blit(BACKGROUND, leftPos + 27, topPos + 26,
|
||||||
|
18, 18, 176, 0, 30, 40, 256, 256);
|
||||||
|
graphics.setColor(1F, 0.3F, 0.3F, 0.8F * fade);
|
||||||
|
graphics.blit(BACKGROUND, leftPos + 27, topPos + 26,
|
||||||
|
18, 18, 176, 0, 30, 40, 256, 256);
|
||||||
|
graphics.setColor(1F, 1F, 1F, 1F);
|
||||||
|
}
|
||||||
|
graphics.fill(leftPos + 7, topPos + 9, leftPos + 123, topPos + 23, 0xFF000000);
|
||||||
|
graphics.renderOutline(leftPos + 7, topPos + 9, 116, 14,
|
||||||
|
titleInput == null || titleInput.getValue().isBlank() ? 0xFFFF3030 : 0xFFA0A0A0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
|
||||||
|
graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||||
|
super.render(graphics, mouseX, mouseY, partialTick);
|
||||||
|
firstVisible = Math.min(firstVisible, maxFirstVisible());
|
||||||
|
graphics.enableScissor(leftPos + STRIP_X + 8, topPos + STRIP_Y,
|
||||||
|
leftPos + STRIP_X + STRIP_WIDTH - 8, topPos + STRIP_Y + STRIP_HEIGHT);
|
||||||
|
int count = visiblePageCount();
|
||||||
|
for (int visible = 0; visible < VISIBLE_PAGES; visible++) {
|
||||||
|
int index = firstVisible + visible;
|
||||||
|
if (index >= count) break;
|
||||||
|
drawPage(graphics, pageAtIndex(index), index,
|
||||||
|
leftPos + 16 + visible * PAGE_STEP, topPos + STRIP_Y + 3);
|
||||||
|
}
|
||||||
|
graphics.disableScissor();
|
||||||
|
drawScrollEnds(graphics);
|
||||||
|
if (mouseY >= topPos + STRIP_Y && mouseY < topPos + STRIP_Y + STRIP_HEIGHT) {
|
||||||
|
if (firstVisible > 0 && mouseX >= leftPos + STRIP_X && mouseX < leftPos + STRIP_X + 8)
|
||||||
|
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book_binder.previous_pages"), mouseX, mouseY);
|
||||||
|
else if (firstVisible < maxFirstVisible() && mouseX >= leftPos + STRIP_X + STRIP_WIDTH - 8
|
||||||
|
&& mouseX < leftPos + STRIP_X + STRIP_WIDTH)
|
||||||
|
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book_binder.next_pages"), mouseX, mouseY);
|
||||||
|
}
|
||||||
|
if (pageAtIndex(0).isEmpty() && mouseX >= leftPos + 27 && mouseX < leftPos + 45
|
||||||
|
&& mouseY >= topPos + 26 && mouseY < topPos + 44) {
|
||||||
|
graphics.renderTooltip(font,
|
||||||
|
Component.translatable("screen.rivencraft.book_binder.missing_panel"), mouseX, mouseY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int hovered = pageIndexAt(mouseX, mouseY);
|
||||||
|
if (hovered >= 0 && hovered < displayedPages() && !pageAtIndex(hovered).isEmpty())
|
||||||
|
graphics.renderTooltip(font, pageAtIndex(hovered), mouseX, mouseY);
|
||||||
|
else renderTooltip(graphics, mouseX, mouseY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ItemStack pageAtIndex(int index) {
|
||||||
|
return menu.getSlot(index == 0 ? BookBinderBlockEntity.LINK_PANEL
|
||||||
|
: BookBinderBlockEntity.FIRST_PAGE + index - 1).getItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int displayedPages() {
|
||||||
|
for (int index = BookBinderBlockEntity.PAGE_COUNT; index >= 1; index--)
|
||||||
|
if (!pageAtIndex(index).isEmpty()) return index + 1;
|
||||||
|
return pageAtIndex(0).isEmpty() ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int maxFirstVisible() {
|
||||||
|
return Math.max(0, visiblePageCount() - VISIBLE_PAGES);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int visiblePageCount() {
|
||||||
|
return Math.min(BookBinderBlockEntity.PAGE_COUNT + 1,
|
||||||
|
displayedPages() + (menu.getCarried().isEmpty() ? 0 : 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawPage(GuiGraphics graphics, ItemStack page, int index, int x, int y) {
|
||||||
|
graphics.blit(PAGE_ATLAS, x, y, PAGE_WIDTH, PAGE_HEIGHT, 156, 0, 30, 40, 256, 256);
|
||||||
|
if (page.isEmpty()) {
|
||||||
|
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0xAA000000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (PageItem.content(page).kind()) {
|
||||||
|
case BLANK -> { }
|
||||||
|
case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 22, y + 17, 0xFF000000);
|
||||||
|
case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(NotebookScreen.symbolTexture(page),
|
||||||
|
x + 1, y + 5, 24, 24, 16, 16, 96, 96, 128, 128);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawScrollEnds(GuiGraphics graphics) {
|
||||||
|
int x = leftPos + STRIP_X;
|
||||||
|
int y = topPos + STRIP_Y;
|
||||||
|
graphics.fill(x, y, x + 8, y + STRIP_HEIGHT,
|
||||||
|
firstVisible == 0 ? 0x33000000 : 0xAA000000);
|
||||||
|
graphics.fill(x + STRIP_WIDTH - 8, y, x + STRIP_WIDTH, y + STRIP_HEIGHT,
|
||||||
|
firstVisible >= maxFirstVisible() ? 0x33000000 : 0xAA000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int pageIndexAt(double mouseX, double mouseY) {
|
||||||
|
int localX = (int) mouseX - leftPos;
|
||||||
|
int localY = (int) mouseY - topPos;
|
||||||
|
if (localX < STRIP_X + 8 || localX >= STRIP_X + STRIP_WIDTH - 8
|
||||||
|
|| localY < STRIP_Y || localY >= STRIP_Y + STRIP_HEIGHT) return -1;
|
||||||
|
int visible = (localX - 16) / PAGE_STEP;
|
||||||
|
if (visible < 0 || visible >= VISIBLE_PAGES) return -1;
|
||||||
|
return Math.min(firstVisible + visible, BookBinderBlockEntity.PAGE_COUNT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||||
|
int localX = (int) mouseX - leftPos;
|
||||||
|
int localY = (int) mouseY - topPos;
|
||||||
|
if ((button == 0 || button == 1) && localY >= STRIP_Y && localY < STRIP_Y + STRIP_HEIGHT
|
||||||
|
&& localX >= STRIP_X && localX < STRIP_X + STRIP_WIDTH) {
|
||||||
|
stripClickPendingRelease = true;
|
||||||
|
if (localX < STRIP_X + 8) firstVisible = Math.max(0, firstVisible - 1);
|
||||||
|
else if (localX >= STRIP_X + STRIP_WIDTH - 8)
|
||||||
|
firstVisible = Math.min(maxFirstVisible(), firstVisible + 1);
|
||||||
|
else {
|
||||||
|
int index = pageIndexAt(mouseX, mouseY);
|
||||||
|
if (index >= 0 && index <= displayedPages()
|
||||||
|
&& (index < displayedPages() || !menu.getCarried().isEmpty()))
|
||||||
|
clickStrip(index);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.mouseClicked(mouseX, mouseY, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickStrip(int index) {
|
||||||
|
if (minecraft != null && minecraft.gameMode != null)
|
||||||
|
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
|
||||||
|
BookBinderMenu.STRIP_CLICK_BASE + index);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean mouseReleased(double mouseX, double mouseY, int button) {
|
||||||
|
if (stripClickPendingRelease) {
|
||||||
|
stripClickPendingRelease = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.mouseReleased(mouseX, mouseY, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
|
||||||
|
if (mouseX >= leftPos + STRIP_X && mouseX < leftPos + STRIP_X + STRIP_WIDTH
|
||||||
|
&& mouseY >= topPos + STRIP_Y && mouseY < topPos + STRIP_Y + STRIP_HEIGHT) {
|
||||||
|
firstVisible = Math.clamp(firstVisible - (int) Math.signum(scrollY), 0, maxFirstVisible());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
|
||||||
|
if (titleInput != null && titleInput.isFocused()) {
|
||||||
|
if (keyCode == 256) {
|
||||||
|
setFocused(null);
|
||||||
|
titleInput.setFocused(false);
|
||||||
|
} else titleInput.keyPressed(keyCode, scanCode, modifiers);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (keyCode == GLFW.GLFW_KEY_LEFT && firstVisible > 0) {
|
||||||
|
firstVisible--;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (keyCode == GLFW.GLFW_KEY_RIGHT && firstVisible < maxFirstVisible()) {
|
||||||
|
firstVisible++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.keyPressed(keyCode, scanCode, modifiers);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package net.dnorberg.rivencraft.client.screen;
|
package net.dnorberg.rivencraft.client.screen;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import net.dnorberg.rivencraft.book.AgeReference;
|
import net.dnorberg.rivencraft.book.AgeReference;
|
||||||
import net.dnorberg.rivencraft.book.AgeTraits;
|
import net.dnorberg.rivencraft.book.AgeTraits;
|
||||||
import net.dnorberg.rivencraft.client.ClientBookExploreOverlay;
|
import net.dnorberg.rivencraft.client.ClientBookExploreOverlay;
|
||||||
@@ -10,62 +12,78 @@ import net.dnorberg.rivencraft.network.RequestLinkPreviewPayload;
|
|||||||
import net.dnorberg.rivencraft.network.LinkPreviewPayload;
|
import net.dnorberg.rivencraft.network.LinkPreviewPayload;
|
||||||
import net.dnorberg.rivencraft.network.ReleaseLinkPreviewPayload;
|
import net.dnorberg.rivencraft.network.ReleaseLinkPreviewPayload;
|
||||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
|
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||||
import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderer;
|
import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderer;
|
||||||
import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderers;
|
import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderers;
|
||||||
import net.minecraft.core.BlockPos;
|
import net.minecraft.core.BlockPos;
|
||||||
import net.minecraft.client.gui.GuiGraphics;
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
import net.minecraft.client.gui.screens.Screen;
|
import net.minecraft.client.gui.screens.Screen;
|
||||||
|
import net.minecraft.client.resources.sounds.SimpleSoundInstance;
|
||||||
import net.minecraft.client.gui.components.Button;
|
import net.minecraft.client.gui.components.Button;
|
||||||
import net.minecraft.client.gui.components.EditBox;
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
|
import net.minecraft.client.gui.components.Tooltip;
|
||||||
import net.minecraft.network.chat.Component;
|
import net.minecraft.network.chat.Component;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.sounds.SoundEvents;
|
||||||
import net.minecraft.util.FormattedCharSequence;
|
import net.minecraft.util.FormattedCharSequence;
|
||||||
import net.minecraft.world.item.ItemStack;
|
import net.minecraft.world.item.ItemStack;
|
||||||
import net.minecraft.world.InteractionHand;
|
import net.minecraft.world.InteractionHand;
|
||||||
import net.neoforged.neoforge.network.PacketDistributor;
|
import net.neoforged.neoforge.network.PacketDistributor;
|
||||||
|
import org.lwjgl.glfw.GLFW;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
public final class DescriptiveBookScreen extends Screen implements ExplorableBookScreen {
|
public final class DescriptiveBookScreen extends Screen implements ExplorableBookScreen {
|
||||||
private static final int BOOK_TEXT_COLOR = 0xFF3B281B;
|
private static final int BOOK_TEXT_COLOR = 0xFF3B281B;
|
||||||
private static final ResourceLocation BOOK_TEXTURE = ResourceLocation.fromNamespaceAndPath(
|
private static final int UNRESOLVED_TEXT_COLOR = 0xFF9B302C;
|
||||||
"rivencraft", "textures/gui/linking_book.png"
|
private static final ResourceLocation ENCHANTMENT_FONT = ResourceLocation.withDefaultNamespace("alt");
|
||||||
);
|
|
||||||
private static final int BOOK_WIDTH = 327;
|
private static final int BOOK_WIDTH = 327;
|
||||||
private static final int BOOK_HEIGHT = 199;
|
private static final int BOOK_HEIGHT = 199;
|
||||||
private static final int TEXTURE_WIDTH = 512;
|
private static final int LINK_PANEL_X = 173;
|
||||||
private static final int TEXTURE_HEIGHT = 312;
|
private static final int LINK_PANEL_Y = 20;
|
||||||
private static final int LINK_PANEL_X = 28;
|
private static final int LINK_PANEL_WIDTH = 132;
|
||||||
private static final int LINK_PANEL_Y = 35;
|
private static final int LINK_PANEL_HEIGHT = 83;
|
||||||
private static final int LINK_PANEL_WIDTH = 126;
|
|
||||||
private static final int LINK_PANEL_HEIGHT = 86;
|
|
||||||
private static final Component EXPLORE_ICON = Component.literal("👁");
|
private static final Component EXPLORE_ICON = Component.literal("👁");
|
||||||
|
|
||||||
private final AgeReference age;
|
private final AgeReference age;
|
||||||
private final ResourceLocation generationProfile;
|
private final ResourceLocation generationProfile;
|
||||||
private final AgeTraits traits;
|
private final AgeTraits traits;
|
||||||
|
private final OrderedWriting writing;
|
||||||
|
private final List<SymbolDisplayMetadata> writingSymbols;
|
||||||
|
private final boolean pageFree;
|
||||||
private final InteractionHand hand;
|
private final InteractionHand hand;
|
||||||
private final BlockPos bookStandPos;
|
private final BlockPos bookStandPos;
|
||||||
private DestinationPreviewRenderer preview;
|
private DestinationPreviewRenderer preview;
|
||||||
private EditBox titleInput;
|
private EditBox titleInput;
|
||||||
private Button exploreButton;
|
private Button exploreButton;
|
||||||
|
private Button createButton;
|
||||||
|
private Button cancelButton;
|
||||||
|
private int pageIndex;
|
||||||
private boolean movingToExploreOverlay;
|
private boolean movingToExploreOverlay;
|
||||||
|
|
||||||
public DescriptiveBookScreen(InteractionHand hand, ItemStack book, @Nullable AgeTraits traits) {
|
public DescriptiveBookScreen(InteractionHand hand, ItemStack book, @Nullable AgeTraits traits,
|
||||||
|
List<SymbolDisplayMetadata> writingSymbols) {
|
||||||
super(book.getHoverName());
|
super(book.getHoverName());
|
||||||
age = book.get(ModDataComponents.AGE_REFERENCE.get());
|
age = book.get(ModDataComponents.AGE_REFERENCE.get());
|
||||||
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
|
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
|
||||||
this.traits = traits;
|
this.traits = traits;
|
||||||
|
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
|
||||||
|
this.writingSymbols = List.copyOf(writingSymbols);
|
||||||
|
pageFree = writing.entries().stream().noneMatch(entry -> entry.kind() == WritingEntry.Kind.SYMBOL);
|
||||||
this.hand = hand;
|
this.hand = hand;
|
||||||
bookStandPos = null;
|
bookStandPos = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DescriptiveBookScreen(BlockPos bookStandPos, ItemStack book, @Nullable AgeTraits traits) {
|
public DescriptiveBookScreen(BlockPos bookStandPos, ItemStack book, @Nullable AgeTraits traits,
|
||||||
|
List<SymbolDisplayMetadata> writingSymbols) {
|
||||||
super(book.getHoverName());
|
super(book.getHoverName());
|
||||||
age = book.get(ModDataComponents.AGE_REFERENCE.get());
|
age = book.get(ModDataComponents.AGE_REFERENCE.get());
|
||||||
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
|
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
|
||||||
this.traits = traits;
|
this.traits = traits;
|
||||||
|
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
|
||||||
|
this.writingSymbols = List.copyOf(writingSymbols);
|
||||||
|
pageFree = writing.entries().stream().noneMatch(entry -> entry.kind() == WritingEntry.Kind.SYMBOL);
|
||||||
hand = InteractionHand.MAIN_HAND;
|
hand = InteractionHand.MAIN_HAND;
|
||||||
this.bookStandPos = bookStandPos.immutable();
|
this.bookStandPos = bookStandPos.immutable();
|
||||||
}
|
}
|
||||||
@@ -75,19 +93,22 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
|||||||
int left = (width - BOOK_WIDTH) / 2;
|
int left = (width - BOOK_WIDTH) / 2;
|
||||||
int top = (height - BOOK_HEIGHT) / 2;
|
int top = (height - BOOK_HEIGHT) / 2;
|
||||||
if (age == null) {
|
if (age == null) {
|
||||||
int rightPage = left + 177;
|
int leftPage = left + 40;
|
||||||
titleInput = new EditBox(font, rightPage, top + 69, 124, 20,
|
titleInput = new EditBox(font, leftPage, top + 69, 110, 20,
|
||||||
Component.translatable("screen.rivencraft.age.title"));
|
Component.translatable("screen.rivencraft.age.title"));
|
||||||
titleInput.setMaxLength(ModNetworking.MAX_BOOK_TITLE_LENGTH);
|
titleInput.setMaxLength(ModNetworking.MAX_BOOK_TITLE_LENGTH);
|
||||||
titleInput.setHint(Component.translatable("screen.rivencraft.age.title_hint"));
|
titleInput.setHint(Component.translatable("screen.rivencraft.age.title_hint"));
|
||||||
addRenderableWidget(titleInput);
|
addRenderableWidget(titleInput);
|
||||||
setInitialFocus(titleInput);
|
setInitialFocus(titleInput);
|
||||||
addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.age.create"), button -> createAge())
|
createButton = addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.age.create"), button -> createAge())
|
||||||
.bounds(rightPage, top + 100, 59, 20)
|
.bounds(leftPage, top + 100, 53, 20)
|
||||||
.build());
|
.build());
|
||||||
addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
|
cancelButton = addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
|
||||||
.bounds(rightPage + 65, top + 100, 59, 20)
|
.bounds(leftPage + 57, top + 100, 53, 20)
|
||||||
.build());
|
.build());
|
||||||
|
titleInput.visible = pageIndex == 0;
|
||||||
|
createButton.visible = pageIndex == 0;
|
||||||
|
cancelButton.visible = pageIndex == 0;
|
||||||
} else {
|
} else {
|
||||||
boolean requestPreview = preview == null;
|
boolean requestPreview = preview == null;
|
||||||
if (requestPreview) {
|
if (requestPreview) {
|
||||||
@@ -96,8 +117,10 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
|||||||
if (preview.supportsExploration()) {
|
if (preview.supportsExploration()) {
|
||||||
exploreButton = addRenderableWidget(Button.builder(
|
exploreButton = addRenderableWidget(Button.builder(
|
||||||
EXPLORE_ICON, button -> enterExploreOverlay())
|
EXPLORE_ICON, button -> enterExploreOverlay())
|
||||||
.bounds(left + 81, top + 145, 20, 20).build());
|
.bounds(left + 229, top + 145, 20, 20)
|
||||||
|
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.book.explore"))).build());
|
||||||
exploreButton.active = bookStandPos == null;
|
exploreButton.active = bookStandPos == null;
|
||||||
|
exploreButton.visible = pageIndex == 0;
|
||||||
}
|
}
|
||||||
if (requestPreview) {
|
if (requestPreview) {
|
||||||
PacketDistributor.sendToServer(new RequestLinkPreviewPayload(
|
PacketDistributor.sendToServer(new RequestLinkPreviewPayload(
|
||||||
@@ -132,50 +155,111 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
|||||||
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||||
renderBook(graphics, partialTick);
|
renderBook(graphics, partialTick);
|
||||||
super.render(graphics, mouseX, mouseY, partialTick);
|
super.render(graphics, mouseX, mouseY, partialTick);
|
||||||
|
int left = (width - BOOK_WIDTH) / 2;
|
||||||
|
int top = (height - BOOK_HEIGHT) / 2;
|
||||||
|
if (mouseY >= top + 164 && mouseY < top + 195) {
|
||||||
|
if (pageIndex > 0 && mouseX >= left + 7 && mouseX < left + 35)
|
||||||
|
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book.previous_page"), mouseX, mouseY);
|
||||||
|
else if (pageIndex < pageCount() && mouseX >= left + 293 && mouseX < left + 320)
|
||||||
|
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book.next_page"), mouseX, mouseY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void renderBook(GuiGraphics graphics, float partialTick) {
|
private void renderBook(GuiGraphics graphics, float partialTick) {
|
||||||
int left = (width - BOOK_WIDTH) / 2;
|
int left = (width - BOOK_WIDTH) / 2;
|
||||||
int top = (height - BOOK_HEIGHT) / 2;
|
int top = (height - BOOK_HEIGHT) / 2;
|
||||||
graphics.blit(BOOK_TEXTURE, left, top, BOOK_WIDTH, BOOK_HEIGHT, 0, 0,
|
LegacyBookArtwork.drawCover(graphics, left, top, true);
|
||||||
TEXTURE_WIDTH, TEXTURE_HEIGHT, TEXTURE_WIDTH, TEXTURE_HEIGHT);
|
|
||||||
|
|
||||||
|
if (pageIndex > 0) {
|
||||||
|
LegacyBookArtwork.drawLeftPage(graphics, left, top);
|
||||||
|
}
|
||||||
|
if (pageIndex == 0) {
|
||||||
|
if (age != null && preview != null) {
|
||||||
|
preview.render(graphics, left + LINK_PANEL_X, top + LINK_PANEL_Y,
|
||||||
|
LINK_PANEL_WIDTH, LINK_PANEL_HEIGHT, partialTick);
|
||||||
|
}
|
||||||
|
LegacyBookArtwork.drawPanelPage(graphics, left, top);
|
||||||
|
graphics.enableScissor(left + 38, top + 38, left + 154, top + 174);
|
||||||
|
renderFirstPageText(graphics, left, top);
|
||||||
|
graphics.disableScissor();
|
||||||
|
} else if (pageIndex < pageCount()) {
|
||||||
|
LegacyBookArtwork.drawFullPage(graphics, left, top);
|
||||||
|
renderWrittenPage(graphics, left, top);
|
||||||
|
}
|
||||||
|
String counter = pageIndex + "/" + pageCount();
|
||||||
|
graphics.drawString(font, counter, left + 165 - font.width(counter) / 2,
|
||||||
|
top + 185, 0xFF000000, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int pageCount() {
|
||||||
|
// Older page-free Descriptive Books do not have a BOOK_WRITING component.
|
||||||
|
return Math.max(1, writing.entries().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void renderFirstPageText(GuiGraphics graphics, int left, int top) {
|
||||||
|
int x = left + 40;
|
||||||
|
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.title"),
|
||||||
|
x, top + 40, 110);
|
||||||
if (age == null) {
|
if (age == null) {
|
||||||
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.name_prompt"),
|
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.name_prompt"),
|
||||||
left + 177, top + 45, 124);
|
x, top + 52, 110);
|
||||||
} else {
|
return;
|
||||||
preview.render(graphics, left + LINK_PANEL_X, top + LINK_PANEL_Y, LINK_PANEL_WIDTH, LINK_PANEL_HEIGHT, partialTick);
|
|
||||||
int detailsY = drawWrappedWithoutShadow(
|
|
||||||
graphics, Component.literal(age.name()), left + 177, top + 43, 124) + 3;
|
|
||||||
detailsY = Math.max(detailsY, top + 72);
|
|
||||||
if (generationProfile != null) {
|
|
||||||
Component profileName = generationProfile.getNamespace().equals("rivencraft")
|
|
||||||
? Component.translatable("generator.rivencraft." + generationProfile.getPath())
|
|
||||||
: Component.literal(generationProfile.toString());
|
|
||||||
detailsY = drawWrappedWithoutShadow(graphics,
|
|
||||||
Component.translatable("screen.rivencraft.age.generator", profileName),
|
|
||||||
left + 177, detailsY, 124) + 2;
|
|
||||||
}
|
|
||||||
if (traits != null) {
|
|
||||||
detailsY = drawTrait(graphics, "biomes", traitName("biome_distribution", traits.biomeDistribution()),
|
|
||||||
left + 177, detailsY);
|
|
||||||
detailsY = drawTrait(graphics, "lighting", traitName("lighting", traits.lighting()),
|
|
||||||
left + 177, detailsY);
|
|
||||||
detailsY = drawTrait(graphics, "weather", traitName("weather", traits.weather()),
|
|
||||||
left + 177, detailsY);
|
|
||||||
Component dayLength = traits.hasStoppedCelestialCycle()
|
|
||||||
? Component.translatable("trait.rivencraft.day_length.stopped")
|
|
||||||
: Component.literal(String.format(Locale.ROOT, "%.2fx", traits.celestialPeriod()));
|
|
||||||
detailsY = drawTrait(graphics, "day_length", dayLength, left + 177, detailsY);
|
|
||||||
detailsY = drawTrait(graphics, "celestial_angle",
|
|
||||||
Component.literal(String.format(Locale.ROOT, "%.0f°", traits.celestialAngle())),
|
|
||||||
left + 177, detailsY);
|
|
||||||
drawTrait(graphics, "celestial_phase",
|
|
||||||
Component.literal(String.format(Locale.ROOT, "%.0f%%", traits.celestialPhase() * 100.0F)),
|
|
||||||
left + 177, detailsY);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
int detailsY = drawWrappedWithoutShadow(graphics, Component.literal(age.name()),
|
||||||
|
x + 10, top + 52, 100) + 10;
|
||||||
|
if (generationProfile != null) {
|
||||||
|
Component profileName = generationProfile.getNamespace().equals("rivencraft")
|
||||||
|
? Component.translatable("generator.rivencraft." + generationProfile.getPath())
|
||||||
|
: Component.literal(generationProfile.toString());
|
||||||
|
detailsY = drawWrappedWithoutShadow(graphics,
|
||||||
|
Component.translatable("screen.rivencraft.age.generator", profileName),
|
||||||
|
x, detailsY, 110) + 2;
|
||||||
|
}
|
||||||
|
if (traits != null) {
|
||||||
|
detailsY = drawTrait(graphics, "biomes", traitName("biome_distribution", traits.biomeDistribution()), x, detailsY);
|
||||||
|
detailsY = drawTrait(graphics, "lighting", traitName("lighting", traits.lighting()), x, detailsY);
|
||||||
|
detailsY = drawTrait(graphics, "weather", traitName("weather", traits.weather()), x, detailsY);
|
||||||
|
Component dayLength = traits.hasStoppedCelestialCycle()
|
||||||
|
? Component.translatable("trait.rivencraft.day_length.stopped")
|
||||||
|
: Component.literal(String.format(Locale.ROOT, "%.2fx", traits.celestialPeriod()));
|
||||||
|
detailsY = drawTrait(graphics, "day_length", dayLength, x, detailsY);
|
||||||
|
detailsY = drawTrait(graphics, "celestial_angle",
|
||||||
|
Component.literal(String.format(Locale.ROOT, "%.0f°", traits.celestialAngle())), x, detailsY);
|
||||||
|
drawTrait(graphics, "celestial_phase",
|
||||||
|
Component.literal(String.format(Locale.ROOT, "%.0f%%", traits.celestialPhase() * 100.0F)), x, detailsY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void renderWrittenPage(GuiGraphics graphics, int left, int top) {
|
||||||
|
WritingEntry entry = writing.entries().get(pageIndex);
|
||||||
|
int x = left + 176;
|
||||||
|
if (entry.kind() == WritingEntry.Kind.LINK_PANEL) {
|
||||||
|
drawWrappedWithoutShadow(graphics, Component.translatable("item.rivencraft.page.link_panel"),
|
||||||
|
x, top + 39, 126);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int symbolIndex = (int) writing.entries().subList(0, pageIndex + 1).stream()
|
||||||
|
.filter(candidate -> candidate.kind() == WritingEntry.Kind.SYMBOL).count() - 1;
|
||||||
|
SymbolDisplayMetadata display = symbolIndex < writingSymbols.size()
|
||||||
|
? writingSymbols.get(symbolIndex) : SymbolDisplayMetadata.unresolved(entry.symbol().orElseThrow());
|
||||||
|
int color = display.resolved() ? BOOK_TEXT_COLOR : UNRESOLVED_TEXT_COLOR;
|
||||||
|
drawWrappedWithoutShadow(graphics, Component.translatable(display.nameTranslationKey()),
|
||||||
|
x, top + 39, 126, color);
|
||||||
|
drawWrappedWithoutShadow(graphics, Component.literal(display.id().toString()),
|
||||||
|
x, top + 64, 126, color);
|
||||||
|
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.pages_not_applied"),
|
||||||
|
x, top + 137, 126);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void turnPage(int direction) {
|
||||||
|
int nextPage = Math.max(0, Math.min(pageCount(), pageIndex + direction));
|
||||||
|
if (nextPage == pageIndex) return;
|
||||||
|
pageIndex = nextPage;
|
||||||
|
minecraft.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.BOOK_PAGE_TURN, 1.0F, 0.65F));
|
||||||
|
if (titleInput != null) titleInput.visible = pageIndex == 0;
|
||||||
|
if (createButton != null) createButton.visible = pageIndex == 0;
|
||||||
|
if (cancelButton != null) cancelButton.visible = pageIndex == 0;
|
||||||
|
if (exploreButton != null) exploreButton.visible = pageIndex == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -201,14 +285,25 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
|||||||
resetExploration();
|
resetExploration();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (age != null && button == 0) {
|
if (button == 0) {
|
||||||
int panelLeft = (width - BOOK_WIDTH) / 2 + LINK_PANEL_X;
|
int left = (width - BOOK_WIDTH) / 2;
|
||||||
int panelTop = (height - BOOK_HEIGHT) / 2 + LINK_PANEL_Y;
|
int top = (height - BOOK_HEIGHT) / 2;
|
||||||
if (mouseX >= panelLeft && mouseX < panelLeft + LINK_PANEL_WIDTH
|
if (pageIndex == 0 && age != null
|
||||||
&& mouseY >= panelTop && mouseY < panelTop + LINK_PANEL_HEIGHT) {
|
&& mouseX >= left + LINK_PANEL_X && mouseX < left + LINK_PANEL_X + LINK_PANEL_WIDTH
|
||||||
|
&& mouseY >= top + LINK_PANEL_Y && mouseY < top + LINK_PANEL_Y + LINK_PANEL_HEIGHT) {
|
||||||
link();
|
link();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (mouseY >= top + 164 && mouseY < top + 195) {
|
||||||
|
if (pageIndex > 0 && mouseX >= left + 7 && mouseX < left + 35) {
|
||||||
|
turnPage(-1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (pageIndex < pageCount() && mouseX >= left + 293 && mouseX < left + 320) {
|
||||||
|
turnPage(1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return super.mouseClicked(mouseX, mouseY, button);
|
return super.mouseClicked(mouseX, mouseY, button);
|
||||||
}
|
}
|
||||||
@@ -224,6 +319,10 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
|
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
|
||||||
|
if (titleInput == null || !titleInput.isFocused()) {
|
||||||
|
if (keyCode == GLFW.GLFW_KEY_LEFT && pageIndex > 0) { turnPage(-1); return true; }
|
||||||
|
if (keyCode == GLFW.GLFW_KEY_RIGHT && pageIndex < pageCount()) { turnPage(1); return true; }
|
||||||
|
}
|
||||||
return preview != null && preview.keyPressed(keyCode, scanCode)
|
return preview != null && preview.keyPressed(keyCode, scanCode)
|
||||||
|| super.keyPressed(keyCode, scanCode, modifiers);
|
|| super.keyPressed(keyCode, scanCode, modifiers);
|
||||||
}
|
}
|
||||||
@@ -268,7 +367,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
|||||||
int top = (height - BOOK_HEIGHT) / 2;
|
int top = (height - BOOK_HEIGHT) / 2;
|
||||||
graphics.drawCenteredString(font,
|
graphics.drawCenteredString(font,
|
||||||
Component.translatable("screen.rivencraft.book.return_from_explore"),
|
Component.translatable("screen.rivencraft.book.return_from_explore"),
|
||||||
left + 91, top + 154, BOOK_TEXT_COLOR);
|
left + 239, top + 154, BOOK_TEXT_COLOR);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -317,7 +416,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
|||||||
|
|
||||||
private int drawTrait(GuiGraphics graphics, String key, Component value, int x, int y) {
|
private int drawTrait(GuiGraphics graphics, String key, Component value, int x, int y) {
|
||||||
return drawWrappedWithoutShadow(graphics,
|
return drawWrappedWithoutShadow(graphics,
|
||||||
Component.translatable("screen.rivencraft.age.trait." + key, value), x, y, 124) + 2;
|
Component.translatable("screen.rivencraft.age.trait." + key, value), x, y, 110) + 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Component traitName(String group, Enum<?> value) {
|
private static Component traitName(String group, Enum<?> value) {
|
||||||
@@ -326,8 +425,14 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
|||||||
}
|
}
|
||||||
|
|
||||||
private int drawWrappedWithoutShadow(GuiGraphics graphics, Component text, int x, int y, int maxWidth) {
|
private int drawWrappedWithoutShadow(GuiGraphics graphics, Component text, int x, int y, int maxWidth) {
|
||||||
for (FormattedCharSequence line : font.split(text, maxWidth)) {
|
return drawWrappedWithoutShadow(graphics, text, x, y, maxWidth, BOOK_TEXT_COLOR);
|
||||||
graphics.drawString(font, line, x, y, BOOK_TEXT_COLOR, false);
|
}
|
||||||
|
|
||||||
|
private int drawWrappedWithoutShadow(GuiGraphics graphics, Component text, int x, int y, int maxWidth, int color) {
|
||||||
|
Component displayed = pageFree && age != null && pageIndex == 0
|
||||||
|
? text.copy().withStyle(style -> style.withFont(ENCHANTMENT_FONT)) : text;
|
||||||
|
for (FormattedCharSequence line : font.split(displayed, maxWidth)) {
|
||||||
|
graphics.drawString(font, line, x, y, color, false);
|
||||||
y += font.lineHeight;
|
y += font.lineHeight;
|
||||||
}
|
}
|
||||||
return y;
|
return y;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package net.dnorberg.rivencraft.client.screen;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||||
|
import net.dnorberg.rivencraft.menu.InkMixerMenu;
|
||||||
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
|
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.entity.player.Inventory;
|
||||||
|
|
||||||
|
/** Legacy Ink Mixer atlas with a server-synced, sixteen-use basin. */
|
||||||
|
public final class InkMixerScreen extends AbstractContainerScreen<InkMixerMenu> {
|
||||||
|
private static final ResourceLocation BACKGROUND = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
Rivencraft.MOD_ID, "textures/gui/ink_mixer_legacy.png");
|
||||||
|
|
||||||
|
public InkMixerScreen(InkMixerMenu menu, Inventory inventory, Component title) {
|
||||||
|
super(menu, inventory, title);
|
||||||
|
imageWidth = 176;
|
||||||
|
imageHeight = 181;
|
||||||
|
inventoryLabelY = 87;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean isPauseScreen() { return false; }
|
||||||
|
@Override public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||||
|
renderBg(graphics, partialTick, mouseX, mouseY);
|
||||||
|
}
|
||||||
|
@Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
|
||||||
|
graphics.blit(BACKGROUND, leftPos, topPos, 0, 0, imageWidth, imageHeight, 256, 256);
|
||||||
|
int centerX = leftPos + 88;
|
||||||
|
int centerY = topPos + 49;
|
||||||
|
int fillTop = centerY + 26 - Math.round(menu.inkRemaining() / (float) InkVialItem.CAPACITY * 52);
|
||||||
|
if (menu.inkRemaining() > 0) {
|
||||||
|
for (int y = Math.max(centerY - 26, fillTop); y <= centerY + 26; y++) {
|
||||||
|
int halfWidth = (int) Math.sqrt(26 * 26 - (y - centerY) * (y - centerY));
|
||||||
|
graphics.fill(centerX - halfWidth, y, centerX + halfWidth + 1, y + 1, 0xFF241228);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
|
||||||
|
graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false);
|
||||||
|
}
|
||||||
|
@Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||||
|
super.render(graphics, mouseX, mouseY, partialTick);
|
||||||
|
renderTooltip(graphics, mouseX, mouseY);
|
||||||
|
int x = mouseX - (leftPos + 88);
|
||||||
|
int y = mouseY - (topPos + 49);
|
||||||
|
if (x * x + y * y <= 26 * 26)
|
||||||
|
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.ink_mixer.ink",
|
||||||
|
menu.inkRemaining(), InkVialItem.CAPACITY), mouseX, mouseY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package net.dnorberg.rivencraft.client.screen;
|
||||||
|
|
||||||
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
|
||||||
|
/** Original Mystcraft Legacy book atlases and page composition (LGPL-3.0,
|
||||||
|
* copyright XCompWiz, revision 9bc8ddc061845df0cd5ea47f4fdef773cff786ae). */
|
||||||
|
final class LegacyBookArtwork {
|
||||||
|
private static final int ATLAS_SIZE = 256;
|
||||||
|
private static final ResourceLocation COVER = texture("bookui_cover.png");
|
||||||
|
private static final ResourceLocation LEFT_PAGE = texture("bookui_pagel.png");
|
||||||
|
private static final ResourceLocation RIGHT_PANEL = texture("bookui_pager.png");
|
||||||
|
private static final ResourceLocation RIGHT_FULL = texture("bookui_rpage_full.png");
|
||||||
|
|
||||||
|
private LegacyBookArtwork() {}
|
||||||
|
|
||||||
|
static void drawCover(GuiGraphics graphics, int left, int top, boolean gilded) {
|
||||||
|
blit(graphics, COVER, left, top + 7, 34, 192, 152, 0);
|
||||||
|
blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0);
|
||||||
|
blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0);
|
||||||
|
blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0);
|
||||||
|
if (gilded) {
|
||||||
|
blit(graphics, COVER, left, top + 7, 34, 192, 186, 0);
|
||||||
|
blit(graphics, COVER, left + 293, top + 7, 34, 192, 186, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void drawLeftPage(GuiGraphics graphics, int left, int top) {
|
||||||
|
blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void drawPanelPage(GuiGraphics graphics, int left, int top) {
|
||||||
|
blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void drawFullPage(GuiGraphics graphics, int left, int top) {
|
||||||
|
blit(graphics, RIGHT_FULL, left + 163, top, 156, 195, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResourceLocation texture(String filename) {
|
||||||
|
return ResourceLocation.fromNamespaceAndPath("rivencraft", "textures/gui/" + filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void blit(GuiGraphics graphics, ResourceLocation texture, int x, int y,
|
||||||
|
int width, int height, int u, int v) {
|
||||||
|
graphics.blit(texture, x, y, width, height, u, v, width, height, ATLAS_SIZE, ATLAS_SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,28 +16,28 @@ import net.dnorberg.rivencraft.item.LinkingBookItem;
|
|||||||
import net.minecraft.client.gui.GuiGraphics;
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
import net.minecraft.client.gui.components.Button;
|
import net.minecraft.client.gui.components.Button;
|
||||||
import net.minecraft.client.gui.components.EditBox;
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
|
import net.minecraft.client.gui.components.Tooltip;
|
||||||
import net.minecraft.client.gui.screens.Screen;
|
import net.minecraft.client.gui.screens.Screen;
|
||||||
|
import net.minecraft.client.resources.sounds.SimpleSoundInstance;
|
||||||
import net.minecraft.core.BlockPos;
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.component.DataComponents;
|
||||||
import net.minecraft.network.chat.Component;
|
import net.minecraft.network.chat.Component;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||||
|
import net.minecraft.sounds.SoundEvents;
|
||||||
import net.minecraft.util.FormattedCharSequence;
|
import net.minecraft.util.FormattedCharSequence;
|
||||||
import net.minecraft.world.InteractionHand;
|
import net.minecraft.world.InteractionHand;
|
||||||
import net.minecraft.world.item.ItemStack;
|
import net.minecraft.world.item.ItemStack;
|
||||||
import net.neoforged.neoforge.network.PacketDistributor;
|
import net.neoforged.neoforge.network.PacketDistributor;
|
||||||
|
import org.lwjgl.glfw.GLFW;
|
||||||
|
|
||||||
public final class LinkingBookScreen extends Screen implements ExplorableBookScreen {
|
public final class LinkingBookScreen extends Screen implements ExplorableBookScreen {
|
||||||
private static final int BOOK_TEXT_COLOR = 0xFF3B281B;
|
private static final int BOOK_TEXT_COLOR = 0xFF3B281B;
|
||||||
private static final ResourceLocation BOOK_TEXTURE = ResourceLocation.fromNamespaceAndPath(
|
|
||||||
"rivencraft", "textures/gui/linking_book.png"
|
|
||||||
);
|
|
||||||
private static final int BOOK_WIDTH = 327;
|
private static final int BOOK_WIDTH = 327;
|
||||||
private static final int BOOK_HEIGHT = 199;
|
private static final int BOOK_HEIGHT = 199;
|
||||||
private static final int TEXTURE_WIDTH = 512;
|
private static final int PREVIEW_X = 173;
|
||||||
private static final int TEXTURE_HEIGHT = 312;
|
private static final int PREVIEW_Y = 20;
|
||||||
private static final int PREVIEW_X = 28;
|
private static final int PREVIEW_WIDTH = 132;
|
||||||
private static final int PREVIEW_Y = 35;
|
private static final int PREVIEW_HEIGHT = 83;
|
||||||
private static final int PREVIEW_WIDTH = 126;
|
|
||||||
private static final int PREVIEW_HEIGHT = 86;
|
|
||||||
private static final Component EXPLORE_ICON = Component.literal("👁");
|
private static final Component EXPLORE_ICON = Component.literal("👁");
|
||||||
|
|
||||||
private final InteractionHand hand;
|
private final InteractionHand hand;
|
||||||
@@ -47,6 +47,9 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
|
|||||||
private DestinationPreviewRenderer preview;
|
private DestinationPreviewRenderer preview;
|
||||||
private EditBox titleInput;
|
private EditBox titleInput;
|
||||||
private Button exploreButton;
|
private Button exploreButton;
|
||||||
|
private Button bindButton;
|
||||||
|
private Button cancelButton;
|
||||||
|
private int pageIndex;
|
||||||
private boolean movingToExploreOverlay;
|
private boolean movingToExploreOverlay;
|
||||||
|
|
||||||
public LinkingBookScreen(InteractionHand hand, ItemStack book) {
|
public LinkingBookScreen(InteractionHand hand, ItemStack book) {
|
||||||
@@ -70,19 +73,22 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
|
|||||||
int left = (width - BOOK_WIDTH) / 2;
|
int left = (width - BOOK_WIDTH) / 2;
|
||||||
int top = (height - BOOK_HEIGHT) / 2;
|
int top = (height - BOOK_HEIGHT) / 2;
|
||||||
if (target == null) {
|
if (target == null) {
|
||||||
int rightPage = left + 177;
|
int leftPage = left + 40;
|
||||||
titleInput = new EditBox(font, rightPage, top + 69, 124, 20,
|
titleInput = new EditBox(font, leftPage, top + 69, 110, 20,
|
||||||
Component.translatable("screen.rivencraft.book.title"));
|
Component.translatable("screen.rivencraft.book.title"));
|
||||||
titleInput.setMaxLength(ModNetworking.MAX_BOOK_TITLE_LENGTH);
|
titleInput.setMaxLength(ModNetworking.MAX_BOOK_TITLE_LENGTH);
|
||||||
titleInput.setHint(Component.translatable("screen.rivencraft.book.title_hint"));
|
titleInput.setHint(Component.translatable("screen.rivencraft.book.title_hint"));
|
||||||
addRenderableWidget(titleInput);
|
addRenderableWidget(titleInput);
|
||||||
setInitialFocus(titleInput);
|
setInitialFocus(titleInput);
|
||||||
addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.book.bind"), button -> bind())
|
bindButton = addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.book.bind"), button -> bind())
|
||||||
.bounds(rightPage, top + 100, 59, 20)
|
.bounds(leftPage, top + 100, 53, 20)
|
||||||
.build());
|
.build());
|
||||||
addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
|
cancelButton = addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
|
||||||
.bounds(rightPage + 65, top + 100, 59, 20)
|
.bounds(leftPage + 57, top + 100, 53, 20)
|
||||||
.build());
|
.build());
|
||||||
|
titleInput.visible = pageIndex == 0;
|
||||||
|
bindButton.visible = pageIndex == 0;
|
||||||
|
cancelButton.visible = pageIndex == 0;
|
||||||
} else {
|
} else {
|
||||||
boolean requestPreview = preview == null;
|
boolean requestPreview = preview == null;
|
||||||
if (requestPreview) {
|
if (requestPreview) {
|
||||||
@@ -91,8 +97,10 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
|
|||||||
if (preview.supportsExploration()) {
|
if (preview.supportsExploration()) {
|
||||||
exploreButton = addRenderableWidget(Button.builder(
|
exploreButton = addRenderableWidget(Button.builder(
|
||||||
EXPLORE_ICON, button -> enterExploreOverlay())
|
EXPLORE_ICON, button -> enterExploreOverlay())
|
||||||
.bounds(left + 81, top + 145, 20, 20).build());
|
.bounds(left + 229, top + 145, 20, 20)
|
||||||
|
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.book.explore"))).build());
|
||||||
exploreButton.active = bookStandPos == null;
|
exploreButton.active = bookStandPos == null;
|
||||||
|
exploreButton.visible = pageIndex == 0;
|
||||||
}
|
}
|
||||||
if (requestPreview) {
|
if (requestPreview) {
|
||||||
PacketDistributor.sendToServer(new RequestLinkPreviewPayload(
|
PacketDistributor.sendToServer(new RequestLinkPreviewPayload(
|
||||||
@@ -128,33 +136,55 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
|
|||||||
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||||
renderBook(graphics, partialTick);
|
renderBook(graphics, partialTick);
|
||||||
super.render(graphics, mouseX, mouseY, partialTick);
|
super.render(graphics, mouseX, mouseY, partialTick);
|
||||||
|
int left = (width - BOOK_WIDTH) / 2;
|
||||||
|
int top = (height - BOOK_HEIGHT) / 2;
|
||||||
|
if (mouseY >= top + 164 && mouseY < top + 195) {
|
||||||
|
if (pageIndex > 0 && mouseX >= left + 7 && mouseX < left + 35)
|
||||||
|
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book.previous_page"), mouseX, mouseY);
|
||||||
|
else if (pageIndex == 0 && mouseX >= left + 293 && mouseX < left + 320)
|
||||||
|
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book.next_page"), mouseX, mouseY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void renderBook(GuiGraphics graphics, float partialTick) {
|
private void renderBook(GuiGraphics graphics, float partialTick) {
|
||||||
int left = (width - BOOK_WIDTH) / 2;
|
int left = (width - BOOK_WIDTH) / 2;
|
||||||
int top = (height - BOOK_HEIGHT) / 2;
|
int top = (height - BOOK_HEIGHT) / 2;
|
||||||
graphics.blit(BOOK_TEXTURE, left, top, BOOK_WIDTH, BOOK_HEIGHT, 0, 0,
|
LegacyBookArtwork.drawCover(graphics, left, top, false);
|
||||||
TEXTURE_WIDTH, TEXTURE_HEIGHT, TEXTURE_WIDTH, TEXTURE_HEIGHT);
|
if (pageIndex > 0) {
|
||||||
|
LegacyBookArtwork.drawLeftPage(graphics, left, top);
|
||||||
if (target == null) {
|
|
||||||
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.book.name_prompt"),
|
|
||||||
left + 177, top + 45, 124);
|
|
||||||
} else {
|
} else {
|
||||||
int previewLeft = left + PREVIEW_X;
|
if (target != null && preview != null) {
|
||||||
int previewTop = top + PREVIEW_Y;
|
preview.render(graphics, left + PREVIEW_X, top + PREVIEW_Y,
|
||||||
preview.render(graphics, previewLeft, previewTop, PREVIEW_WIDTH, PREVIEW_HEIGHT, partialTick);
|
PREVIEW_WIDTH, PREVIEW_HEIGHT, partialTick);
|
||||||
|
}
|
||||||
int rightCenter = left + 239;
|
LegacyBookArtwork.drawPanelPage(graphics, left, top);
|
||||||
drawWrappedWithoutShadow(graphics, title, left + 177, top + 43, 124);
|
graphics.enableScissor(left + 38, top + 38, left + 154, top + 174);
|
||||||
BlockPos pos = target.location().pos();
|
if (target == null) {
|
||||||
drawCenteredWithoutShadow(graphics,
|
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.book.name_prompt"),
|
||||||
LinkingBookItem.destinationName(book, target), rightCenter, top + 76);
|
left + 40, top + 43, 110);
|
||||||
drawCenteredWithoutShadow(graphics,
|
} else {
|
||||||
Component.literal(pos.getX() + ", " + pos.getY() + ", " + pos.getZ()), rightCenter, top + 89);
|
int x = left + 40;
|
||||||
drawCenteredWithoutShadow(graphics,
|
Component customName = book.get(DataComponents.CUSTOM_NAME);
|
||||||
Component.translatable("screen.rivencraft.book.click_to_link"), rightCenter, top + 119);
|
boolean returnLink = customName != null
|
||||||
|
&& customName.getContents() instanceof TranslatableContents translated
|
||||||
|
&& translated.getKey().equals("item.rivencraft.linking_book.return");
|
||||||
|
if (!returnLink) {
|
||||||
|
drawWrappedWithoutShadow(graphics, title, x, top + 40, 110);
|
||||||
|
}
|
||||||
|
int destinationY = returnLink ? top + 40 : top + 58;
|
||||||
|
drawWrappedWithoutShadow(graphics, LinkingBookItem.destinationName(book, target),
|
||||||
|
x + 10, destinationY, 100);
|
||||||
|
BlockPos pos = target.location().pos();
|
||||||
|
drawWrappedWithoutShadow(graphics,
|
||||||
|
Component.literal(pos.getX() + ", " + pos.getY() + ", " + pos.getZ()),
|
||||||
|
x, destinationY + 28, 110);
|
||||||
|
}
|
||||||
|
graphics.disableScissor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String counter = pageIndex + "/1";
|
||||||
|
graphics.drawString(font, counter, left + 165 - font.width(counter) / 2,
|
||||||
|
top + 185, 0xFF000000, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -163,18 +193,39 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
|
|||||||
resetExploration();
|
resetExploration();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (target != null && button == 0) {
|
if (button == 0) {
|
||||||
int left = (width - BOOK_WIDTH) / 2 + PREVIEW_X;
|
int left = (width - BOOK_WIDTH) / 2;
|
||||||
int top = (height - BOOK_HEIGHT) / 2 + PREVIEW_Y;
|
int top = (height - BOOK_HEIGHT) / 2;
|
||||||
if (mouseX >= left && mouseX < left + PREVIEW_WIDTH
|
if (pageIndex == 0 && target != null
|
||||||
&& mouseY >= top && mouseY < top + PREVIEW_HEIGHT) {
|
&& mouseX >= left + PREVIEW_X && mouseX < left + PREVIEW_X + PREVIEW_WIDTH
|
||||||
|
&& mouseY >= top + PREVIEW_Y && mouseY < top + PREVIEW_Y + PREVIEW_HEIGHT) {
|
||||||
link();
|
link();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (mouseY >= top + 164 && mouseY < top + 195) {
|
||||||
|
if (pageIndex > 0 && mouseX >= left + 7 && mouseX < left + 35) {
|
||||||
|
turnPage(0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (pageIndex == 0 && mouseX >= left + 293 && mouseX < left + 320) {
|
||||||
|
turnPage(1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return super.mouseClicked(mouseX, mouseY, button);
|
return super.mouseClicked(mouseX, mouseY, button);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void turnPage(int page) {
|
||||||
|
if (pageIndex == page) return;
|
||||||
|
pageIndex = page;
|
||||||
|
minecraft.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.BOOK_PAGE_TURN, 1.0F, 0.65F));
|
||||||
|
if (titleInput != null) titleInput.visible = pageIndex == 0;
|
||||||
|
if (bindButton != null) bindButton.visible = pageIndex == 0;
|
||||||
|
if (cancelButton != null) cancelButton.visible = pageIndex == 0;
|
||||||
|
if (exploreButton != null) exploreButton.visible = pageIndex == 0;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void removed() {
|
public void removed() {
|
||||||
if (preview != null && !movingToExploreOverlay) {
|
if (preview != null && !movingToExploreOverlay) {
|
||||||
@@ -197,6 +248,10 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
|
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
|
||||||
|
if (titleInput == null || !titleInput.isFocused()) {
|
||||||
|
if (keyCode == GLFW.GLFW_KEY_LEFT && pageIndex > 0) { turnPage(0); return true; }
|
||||||
|
if (keyCode == GLFW.GLFW_KEY_RIGHT && pageIndex == 0) { turnPage(1); return true; }
|
||||||
|
}
|
||||||
return preview != null && preview.keyPressed(keyCode, scanCode)
|
return preview != null && preview.keyPressed(keyCode, scanCode)
|
||||||
|| super.keyPressed(keyCode, scanCode, modifiers);
|
|| super.keyPressed(keyCode, scanCode, modifiers);
|
||||||
}
|
}
|
||||||
@@ -247,7 +302,7 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
|
|||||||
int top = (height - BOOK_HEIGHT) / 2;
|
int top = (height - BOOK_HEIGHT) / 2;
|
||||||
drawCenteredWithoutShadow(graphics,
|
drawCenteredWithoutShadow(graphics,
|
||||||
Component.translatable("screen.rivencraft.book.return_from_explore"),
|
Component.translatable("screen.rivencraft.book.return_from_explore"),
|
||||||
left + 91, top + 154);
|
left + 239, top + 154);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -1,156 +1,326 @@
|
|||||||
package net.dnorberg.rivencraft.client.screen;
|
package net.dnorberg.rivencraft.client.screen;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
import net.dnorberg.rivencraft.Rivencraft;
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
import net.dnorberg.rivencraft.menu.NotebookMenu;
|
import net.dnorberg.rivencraft.menu.NotebookMenu;
|
||||||
import net.dnorberg.rivencraft.network.RenameNotebookPagePayload;
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
import net.minecraft.client.gui.GuiGraphics;
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
import net.minecraft.client.gui.components.Button;
|
import net.minecraft.client.gui.components.Button;
|
||||||
import net.minecraft.client.gui.components.EditBox;
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
import net.minecraft.client.gui.components.Tooltip;
|
|
||||||
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||||
import net.minecraft.network.chat.Component;
|
import net.minecraft.network.chat.Component;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
import net.minecraft.world.entity.player.Inventory;
|
import net.minecraft.world.entity.player.Inventory;
|
||||||
import net.minecraft.world.inventory.ClickType;
|
import net.minecraft.world.inventory.ClickType;
|
||||||
import net.minecraft.world.inventory.Slot;
|
|
||||||
import net.minecraft.world.item.ItemStack;
|
import net.minecraft.world.item.ItemStack;
|
||||||
import net.neoforged.neoforge.network.PacketDistributor;
|
|
||||||
|
|
||||||
|
/** A page surface modelled on Legacy's folder GUI, backed by the Notebook menu. */
|
||||||
public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu> {
|
public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu> {
|
||||||
private static final ResourceLocation NOTEBOOK_BACKGROUND = ResourceLocation.fromNamespaceAndPath(
|
private static final int SURFACE_TOP = 20;
|
||||||
Rivencraft.MOD_ID, "textures/gui/notebook.png");
|
private static final int SURFACE_BOTTOM = 132;
|
||||||
private static final ResourceLocation INVENTORY_BACKGROUND = ResourceLocation.withDefaultNamespace(
|
private static final int SURFACE_RIGHT = 156;
|
||||||
"textures/gui/container/generic_54.png");
|
private static final int PAGE_WIDTH = 30;
|
||||||
private EditBox nameInput;
|
private static final int PAGE_HEIGHT = 40;
|
||||||
private Button previousButton;
|
private static final int PAGE_STEP_X = 31;
|
||||||
private Button nextButton;
|
private static final int PAGE_STEP_Y = 41;
|
||||||
|
private static final int PAGE_COLUMNS = 5;
|
||||||
|
private static final ResourceLocation PAGE_ATLAS = texture("gui/notebook_pages");
|
||||||
|
private static final ResourceLocation GENERIC_SYMBOL = texture("item/page_symbol");
|
||||||
|
private static final ResourceLocation UNRESOLVED_SYMBOL = texture("item/page_unresolved");
|
||||||
|
private static final ResourceLocation STANDARD_TERRAIN = texture("item/page_symbol_terrain_standard");
|
||||||
|
private static final ResourceLocation NATIVE_BIOMES = texture("item/page_symbol_biome_native");
|
||||||
|
private static final ResourceLocation NORMAL_LIGHTING = texture("item/page_symbol_lighting_normal");
|
||||||
|
private static final ResourceLocation NORMAL_WEATHER = texture("item/page_symbol_weather_normal");
|
||||||
|
private static final ResourceLocation NORMAL_TIME = texture("item/page_symbol_celestial_normal_time");
|
||||||
|
private static final ResourceLocation CAVES = texture("item/page_symbol_feature_caves");
|
||||||
|
private EditBox textInput;
|
||||||
|
private boolean draggingScrollbar;
|
||||||
|
private boolean surfaceClickPendingRelease;
|
||||||
private int selectedSlot = -1;
|
private int selectedSlot = -1;
|
||||||
|
private int scroll;
|
||||||
|
|
||||||
public NotebookScreen(NotebookMenu menu, Inventory inventory, Component title) {
|
public NotebookScreen(NotebookMenu menu, Inventory inventory, Component title) {
|
||||||
super(menu, inventory, title);
|
super(menu, inventory, title);
|
||||||
imageWidth = 176;
|
imageWidth = 176;
|
||||||
imageHeight = 183;
|
imageHeight = 213;
|
||||||
inventoryLabelY = 89;
|
inventoryLabelY = 123;
|
||||||
titleLabelY = 7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override public boolean isPauseScreen() { return false; }
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void init() {
|
protected void init() {
|
||||||
super.init();
|
super.init();
|
||||||
nameInput = new EditBox(font, leftPos + 47, topPos + 7, 86, 18,
|
Button sortButton = Button.builder(Component.literal("AZ"), button -> {})
|
||||||
Component.translatable("screen.rivencraft.notebook.rename"));
|
.bounds(leftPos, topPos, 18, 18).build();
|
||||||
nameInput.setMaxLength(32);
|
Button allButton = Button.builder(Component.literal("ALL"), button -> {})
|
||||||
nameInput.setResponder(ignored -> updateButtons());
|
.bounds(leftPos + 20, topPos, 26, 18).build();
|
||||||
addRenderableWidget(nameInput);
|
sortButton.active = false;
|
||||||
|
allButton.active = false;
|
||||||
previousButton = Button.builder(Component.literal("←"), ignored -> moveSelected(false))
|
addRenderableWidget(sortButton);
|
||||||
.bounds(leftPos + 136, topPos + 7, 16, 18)
|
addRenderableWidget(allButton);
|
||||||
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.notebook.move_previous")))
|
textInput = new EditBox(font, leftPos + 48, topPos, 128, 18,
|
||||||
.build();
|
Component.translatable("screen.rivencraft.notebook.search"));
|
||||||
nextButton = Button.builder(Component.literal("→"), ignored -> moveSelected(true))
|
textInput.setMaxLength(32);
|
||||||
.bounds(leftPos + 153, topPos + 7, 16, 18)
|
textInput.setHint(Component.translatable("screen.rivencraft.notebook.search"));
|
||||||
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.notebook.move_next")))
|
textInput.setResponder(value -> scroll = 0);
|
||||||
.build();
|
addRenderableWidget(textInput);
|
||||||
addRenderableWidget(previousButton);
|
|
||||||
addRenderableWidget(nextButton);
|
|
||||||
updateButtons();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||||
super.render(graphics, mouseX, mouseY, partialTick);
|
super.render(graphics, mouseX, mouseY, partialTick);
|
||||||
if (selectedSlot >= 0) {
|
int pageSlots = displayedPageSlots();
|
||||||
Slot slot = menu.slots.get(selectedSlot);
|
scroll = Math.min(scroll, maxScroll(pageSlots));
|
||||||
graphics.renderOutline(leftPos + slot.x - 1, topPos + slot.y - 1, 18, 18, 0xFFE0C36A);
|
graphics.enableScissor(leftPos, topPos + SURFACE_TOP, leftPos + SURFACE_RIGHT,
|
||||||
|
topPos + SURFACE_BOTTOM);
|
||||||
|
for (int index = 0; index < pageSlots; index++) {
|
||||||
|
int x = leftPos + (index % PAGE_COLUMNS) * PAGE_STEP_X;
|
||||||
|
int y = topPos + SURFACE_TOP + (index / PAGE_COLUMNS) * PAGE_STEP_Y - scroll;
|
||||||
|
if (y + PAGE_HEIGHT > topPos + SURFACE_TOP && y < topPos + SURFACE_BOTTOM) {
|
||||||
|
drawPage(graphics, index, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
graphics.disableScissor();
|
||||||
|
drawScrollbar(graphics, pageSlots);
|
||||||
|
int hovered = pageAt(mouseX, mouseY);
|
||||||
|
if (!menu.getCarried().isEmpty() && inSurface(mouseX, mouseY)) {
|
||||||
|
graphics.renderItem(menu.getCarried(), mouseX - 8, mouseY - 8);
|
||||||
|
} else if (hovered >= 0 && !menu.getSlot(hovered).getItem().isEmpty()) {
|
||||||
|
ItemStack page = menu.getSlot(hovered).getItem();
|
||||||
|
if (matchesSearch(page)) graphics.renderTooltip(font, page, mouseX, mouseY);
|
||||||
|
} else {
|
||||||
|
renderTooltip(graphics, mouseX, mouseY);
|
||||||
}
|
}
|
||||||
renderTooltip(graphics, mouseX, mouseY);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
|
protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
|
||||||
graphics.blit(NOTEBOOK_BACKGROUND, leftPos, topPos, 0, 0, imageWidth, 88);
|
graphics.fill(leftPos, topPos + SURFACE_TOP, leftPos + SURFACE_RIGHT,
|
||||||
graphics.blit(INVENTORY_BACKGROUND, leftPos, topPos + 87, 0, 126, imageWidth, 96);
|
topPos + SURFACE_BOTTOM, 0xB0000000);
|
||||||
|
drawBeveledPanel(graphics, leftPos, topPos + 132, imageWidth, 81);
|
||||||
|
for (int row = 0; row < 3; row++) {
|
||||||
|
for (int column = 0; column < 9; column++) drawSlot(graphics, 7 + column * 18, 134 + row * 18);
|
||||||
|
}
|
||||||
|
for (int column = 0; column < 9; column++) drawSlot(graphics, 7 + column * 18, 192);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
|
protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
|
||||||
graphics.drawString(font, title, titleLabelX, titleLabelY, 0x3F2A18, false);
|
|
||||||
graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false);
|
graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void slotClicked(Slot slot, int slotId, int mouseButton, ClickType type) {
|
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||||
if (slotId >= 0 && slotId < NotebookMenu.NOTEBOOK_SLOTS) {
|
if (inScrollbar(mouseX, mouseY) && button == 0) {
|
||||||
selectedSlot = slotId;
|
draggingScrollbar = true;
|
||||||
|
setScrollFromMouse(mouseY, displayedPageSlots());
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
super.slotClicked(slot, slotId, mouseButton, type);
|
int index = pageAt(mouseX, mouseY);
|
||||||
refreshName();
|
if (index >= 0 && (button == 0 || button == 1)) {
|
||||||
|
ItemStack page = menu.getSlot(index).getItem();
|
||||||
|
if ((!page.isEmpty() && !matchesSearch(page))
|
||||||
|
|| (page.isEmpty() && !menu.getCarried().is(ModItems.PAGE.get()))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
setFocused(null);
|
||||||
|
textInput.setFocused(false);
|
||||||
|
selectedSlot = index;
|
||||||
|
slotClicked(menu.getSlot(index), index, button, ClickType.PICKUP);
|
||||||
|
surfaceClickPendingRelease = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.mouseClicked(mouseX, mouseY, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean mouseDragged(double mouseX, double mouseY, int button, double dragX, double dragY) {
|
||||||
|
if (draggingScrollbar && button == 0) {
|
||||||
|
setScrollFromMouse(mouseY, displayedPageSlots());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.mouseDragged(mouseX, mouseY, button, dragX, dragY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean mouseReleased(double mouseX, double mouseY, int button) {
|
||||||
|
if (draggingScrollbar || surfaceClickPendingRelease) {
|
||||||
|
draggingScrollbar = false;
|
||||||
|
surfaceClickPendingRelease = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
draggingScrollbar = false;
|
||||||
|
return super.mouseReleased(mouseX, mouseY, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
|
||||||
|
if (inSurface(mouseX, mouseY) || inScrollbar(mouseX, mouseY)) {
|
||||||
|
scroll = Math.clamp(scroll - (int)Math.signum(scrollY) * 20,
|
||||||
|
0, maxScroll(displayedPageSlots()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
|
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
|
||||||
if (nameInput.isFocused() && (keyCode == 257 || keyCode == 335)) {
|
if (textInput.isFocused()) {
|
||||||
renameSelected();
|
if (keyCode == 256) {
|
||||||
|
setFocused(null);
|
||||||
|
textInput.setFocused(false);
|
||||||
|
} else {
|
||||||
|
textInput.keyPressed(keyCode, scanCode, modifiers);
|
||||||
|
}
|
||||||
|
// EditBox does not consume ordinary letter key presses; the matching charTyped
|
||||||
|
// event handles text input. Do not pass them to the container's inventory keys.
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!nameInput.isFocused() && keyCode == 263) {
|
if (!textInput.isFocused() && selectedSlot >= 0 && (keyCode == 263 || keyCode == 262)) {
|
||||||
moveSelected(false);
|
moveSelected(keyCode == 262);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!nameInput.isFocused() && keyCode == 262) {
|
|
||||||
moveSelected(true);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return super.keyPressed(keyCode, scanCode, modifiers);
|
return super.keyPressed(keyCode, scanCode, modifiers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private int displayedPageSlots() {
|
||||||
public void onClose() {
|
if (menu.getCarried().is(ModItems.PAGE.get())) {
|
||||||
renameSelected();
|
return NotebookMenu.NOTEBOOK_SLOTS;
|
||||||
super.onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void refreshName() {
|
|
||||||
ItemStack selected = selectedPage();
|
|
||||||
nameInput.setValue(selected.isEmpty() ? "" : selected.getHoverName().getString());
|
|
||||||
updateButtons();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void renameSelected() {
|
|
||||||
if (selectedSlot < 0 || selectedPage().isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
PacketDistributor.sendToServer(new RenameNotebookPagePayload(
|
for (int index = NotebookMenu.NOTEBOOK_SLOTS - 1; index >= 0; index--) {
|
||||||
menu.containerId, selectedSlot, nameInput.getValue()));
|
if (!menu.getSlot(index).getItem().isEmpty()) {
|
||||||
|
return index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchesSearch(ItemStack page) {
|
||||||
|
return textInput.getValue().isBlank()
|
||||||
|
|| page.getHoverName().getString().toLowerCase(Locale.ROOT)
|
||||||
|
.contains(textInput.getValue().toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int pageAt(double mouseX, double mouseY) {
|
||||||
|
if (!inSurface(mouseX, mouseY)) return -1;
|
||||||
|
int localX = (int)mouseX - leftPos;
|
||||||
|
int localY = (int)mouseY - topPos - SURFACE_TOP + scroll;
|
||||||
|
int column = localX / PAGE_STEP_X;
|
||||||
|
int index = (localY / PAGE_STEP_Y) * PAGE_COLUMNS + column;
|
||||||
|
return column < PAGE_COLUMNS && localX % PAGE_STEP_X < PAGE_WIDTH
|
||||||
|
&& localY % PAGE_STEP_Y < PAGE_HEIGHT && index < displayedPageSlots()
|
||||||
|
? index : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean inSurface(double mouseX, double mouseY) {
|
||||||
|
return mouseX >= leftPos && mouseX < leftPos + SURFACE_RIGHT
|
||||||
|
&& mouseY >= topPos + SURFACE_TOP && mouseY < topPos + SURFACE_BOTTOM;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean inScrollbar(double mouseX, double mouseY) {
|
||||||
|
return mouseX >= leftPos + SURFACE_RIGHT && mouseX < leftPos + imageWidth
|
||||||
|
&& mouseY >= topPos + SURFACE_TOP && mouseY < topPos + SURFACE_BOTTOM;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int maxScroll(int pageCount) {
|
||||||
|
int rows = (pageCount + PAGE_COLUMNS - 1) / PAGE_COLUMNS;
|
||||||
|
return Math.max(0, rows * PAGE_STEP_Y - (SURFACE_BOTTOM - SURFACE_TOP));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setScrollFromMouse(double mouseY, int pageCount) {
|
||||||
|
int maximum = maxScroll(pageCount);
|
||||||
|
double fraction = (mouseY - topPos - SURFACE_TOP - 10.0)
|
||||||
|
/ (SURFACE_BOTTOM - SURFACE_TOP - 20.0);
|
||||||
|
scroll = Math.clamp((int)(Math.clamp(fraction, 0.0, 1.0) * maximum), 0, maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawScrollbar(GuiGraphics graphics, int pageCount) {
|
||||||
|
int x = leftPos + SURFACE_RIGHT;
|
||||||
|
int y = topPos + SURFACE_TOP;
|
||||||
|
int height = SURFACE_BOTTOM - SURFACE_TOP;
|
||||||
|
graphics.fill(x, y, x + 20, y + height, 0xFF858585);
|
||||||
|
graphics.renderOutline(x, y, 20, height, 0xFF262626);
|
||||||
|
for (int line = y + 5; line < y + height - 4; line += 5) {
|
||||||
|
graphics.fill(x + 4, line, x + 16, line + 1, 0xFFB7B7B7);
|
||||||
|
}
|
||||||
|
int maximum = maxScroll(pageCount);
|
||||||
|
int thumbY = y + 4 + (maximum == 0 ? 0 : scroll * (height - 23) / maximum);
|
||||||
|
graphics.fill(x + 4, thumbY, x + 16, thumbY + 15, 0xFFC9C9C9);
|
||||||
|
graphics.renderOutline(x + 4, thumbY, 12, 15, 0xFF414141);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawPage(GuiGraphics graphics, int index, int x, int y) {
|
||||||
|
ItemStack page = menu.getSlot(index).getItem();
|
||||||
|
boolean dimmed = page.isEmpty() || !matchesSearch(page);
|
||||||
|
// Legacy's Notebook draws the atlas' native 30x40 page, not a stretched item icon.
|
||||||
|
graphics.blit(PAGE_ATLAS, x, y, PAGE_WIDTH, PAGE_HEIGHT,
|
||||||
|
156, 0, PAGE_WIDTH, PAGE_HEIGHT, 256, 256);
|
||||||
|
if (!page.isEmpty()) {
|
||||||
|
switch (PageItem.content(page).kind()) {
|
||||||
|
case BLANK -> { }
|
||||||
|
case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 25, y + 20, 0xFF000000);
|
||||||
|
case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(symbolTexture(page), x + 1, y + 6,
|
||||||
|
28, 28, 16, 16, 96, 96, 128, 128);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dimmed) {
|
||||||
|
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0xB8000000);
|
||||||
|
}
|
||||||
|
if (index == selectedSlot && !page.isEmpty()) {
|
||||||
|
graphics.renderOutline(x, y, PAGE_WIDTH, PAGE_HEIGHT, 0xFFFFD061);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static ResourceLocation symbolTexture(ItemStack page) {
|
||||||
|
var content = PageItem.content(page);
|
||||||
|
if (content.kind() == net.dnorberg.rivencraft.writing.PageContent.Kind.UNRESOLVED_SYMBOL) {
|
||||||
|
return UNRESOLVED_SYMBOL;
|
||||||
|
}
|
||||||
|
return symbolTexture(content.symbol().orElseThrow().id());
|
||||||
|
}
|
||||||
|
|
||||||
|
static ResourceLocation symbolTexture(ResourceLocation id) {
|
||||||
|
return switch (id.toString()) {
|
||||||
|
case "rivencraft:terrain/standard" -> STANDARD_TERRAIN;
|
||||||
|
case "rivencraft:biome/native" -> NATIVE_BIOMES;
|
||||||
|
case "rivencraft:lighting/normal" -> NORMAL_LIGHTING;
|
||||||
|
case "rivencraft:weather/normal" -> NORMAL_WEATHER;
|
||||||
|
case "rivencraft:celestial/normal_time" -> NORMAL_TIME;
|
||||||
|
case "rivencraft:feature/caves" -> CAVES;
|
||||||
|
default -> GENERIC_SYMBOL;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResourceLocation texture(String path) {
|
||||||
|
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/" + path + ".png");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void moveSelected(boolean next) {
|
private void moveSelected(boolean next) {
|
||||||
int target = selectedSlot + (next ? 1 : -1);
|
int target = selectedSlot + (next ? 1 : -1);
|
||||||
if (selectedSlot < 0 || selectedPage().isEmpty()
|
if (selectedSlot < 0 || target < 0 || target >= NotebookMenu.NOTEBOOK_SLOTS
|
||||||
|| target < 0 || target >= NotebookMenu.NOTEBOOK_SLOTS) {
|
|| menu.getSlot(selectedSlot).getItem().isEmpty()) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
renameSelected();
|
|
||||||
int buttonId = (next ? NotebookMenu.MOVE_NEXT_BASE : NotebookMenu.MOVE_PREVIOUS_BASE) + selectedSlot;
|
int buttonId = (next ? NotebookMenu.MOVE_NEXT_BASE : NotebookMenu.MOVE_PREVIOUS_BASE) + selectedSlot;
|
||||||
if (menu.clickMenuButton(minecraft.player, buttonId)) {
|
if (menu.clickMenuButton(minecraft.player, buttonId)) {
|
||||||
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, buttonId);
|
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, buttonId);
|
||||||
selectedSlot = target;
|
selectedSlot = target;
|
||||||
refreshName();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ItemStack selectedPage() {
|
private static void drawBeveledPanel(GuiGraphics graphics, int x, int y, int width, int height) {
|
||||||
return selectedSlot >= 0 && selectedSlot < NotebookMenu.NOTEBOOK_SLOTS
|
graphics.fill(x, y, x + width, y + height, 0xFFC6C6C6);
|
||||||
? menu.getSlot(selectedSlot).getItem()
|
graphics.fill(x, y, x + width, y + 2, 0xFFFFFFFF);
|
||||||
: ItemStack.EMPTY;
|
graphics.fill(x, y, x + 2, y + height, 0xFFFFFFFF);
|
||||||
|
graphics.fill(x, y + height - 2, x + width, y + height, 0xFF555555);
|
||||||
|
graphics.fill(x + width - 2, y, x + width, y + height, 0xFF555555);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateButtons() {
|
private void drawSlot(GuiGraphics graphics, int x, int y) {
|
||||||
if (previousButton == null || nextButton == null) {
|
graphics.fill(leftPos + x, topPos + y, leftPos + x + 18, topPos + y + 18, 0xFF8B8B8B);
|
||||||
return;
|
graphics.fill(leftPos + x, topPos + y, leftPos + x + 18, topPos + y + 1, 0xFF373737);
|
||||||
}
|
graphics.fill(leftPos + x, topPos + y, leftPos + x + 1, topPos + y + 18, 0xFF373737);
|
||||||
boolean hasPage = !selectedPage().isEmpty();
|
graphics.fill(leftPos + x + 17, topPos + y, leftPos + x + 18, topPos + y + 18, 0xFFFFFFFF);
|
||||||
previousButton.active = hasPage && selectedSlot > 0;
|
graphics.fill(leftPos + x, topPos + y + 17, leftPos + x + 18, topPos + y + 18, 0xFFFFFFFF);
|
||||||
nextButton.active = hasPage && selectedSlot >= 0 && selectedSlot < NotebookMenu.NOTEBOOK_SLOTS - 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
package net.dnorberg.rivencraft.client.screen;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
|
||||||
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
|
import net.minecraft.client.gui.components.Button;
|
||||||
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
|
import net.minecraft.client.gui.components.Tooltip;
|
||||||
|
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.entity.player.Inventory;
|
||||||
|
import net.minecraft.world.inventory.Slot;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import org.lwjgl.glfw.GLFW;
|
||||||
|
|
||||||
|
/** Legacy desk atlas layout, with four visible tabs out of the desk's 88 collections. */
|
||||||
|
public final class WritingDeskScreen extends AbstractContainerScreen<WritingDeskMenu> {
|
||||||
|
private static final ResourceLocation PAGE_ATLAS = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
Rivencraft.MOD_ID, "textures/gui/notebook_pages.png");
|
||||||
|
private static final ResourceLocation LEGACY_DESK = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
Rivencraft.MOD_ID, "textures/gui/writing_desk_legacy.png");
|
||||||
|
private static final ResourceLocation SCROLLBAR = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
Rivencraft.MOD_ID, "textures/gui/scrollbar.png");
|
||||||
|
private int lastCollection = -1;
|
||||||
|
private int selectedPage;
|
||||||
|
private int scrollRows;
|
||||||
|
private boolean allMode;
|
||||||
|
private boolean sortAlphabetically;
|
||||||
|
private int selectedCollection = -1;
|
||||||
|
private ResourceLocation selectedCategory;
|
||||||
|
private EditBox search;
|
||||||
|
private Button categoryButton;
|
||||||
|
private Button allButton;
|
||||||
|
private Button sortButton;
|
||||||
|
private boolean draggingScrollbar;
|
||||||
|
private record DisplayPage(int collection, int index, ItemStack stack) {}
|
||||||
|
private DisplayPage pageDragSource;
|
||||||
|
private double pageDragStartX;
|
||||||
|
private double pageDragStartY;
|
||||||
|
private boolean draggingPage;
|
||||||
|
|
||||||
|
private boolean isPhysicalPageView() {
|
||||||
|
return !allMode && !sortAlphabetically && selectedCategory == null
|
||||||
|
&& (search == null || search.getValue().isBlank());
|
||||||
|
}
|
||||||
|
|
||||||
|
public WritingDeskScreen(WritingDeskMenu menu, Inventory inventory, Component title) {
|
||||||
|
super(menu, inventory, title);
|
||||||
|
imageWidth = 409;
|
||||||
|
imageHeight = 186;
|
||||||
|
titleLabelX = 222;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean isPauseScreen() { return false; }
|
||||||
|
|
||||||
|
@Override protected void init() {
|
||||||
|
super.init();
|
||||||
|
sortButton = addRenderableWidget(Button.builder(Component.literal("AZ"), button -> {
|
||||||
|
sortAlphabetically = !sortAlphabetically;
|
||||||
|
scrollRows = 0;
|
||||||
|
}).bounds(leftPos + 58, topPos, 20, 18)
|
||||||
|
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.writing_desk.sort"))).build());
|
||||||
|
allButton = addRenderableWidget(Button.builder(Component.literal("ALL"), button -> {
|
||||||
|
allMode = !allMode;
|
||||||
|
scrollRows = 0;
|
||||||
|
if (!allMode) {
|
||||||
|
selectedCollection = -1;
|
||||||
|
selectedPage = 0;
|
||||||
|
if (minecraft != null && minecraft.gameMode != null)
|
||||||
|
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, WritingDeskMenu.PAGE_BUTTON_BASE);
|
||||||
|
}
|
||||||
|
}).bounds(leftPos + 80, topPos, 28, 18)
|
||||||
|
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.writing_desk.all"))).build());
|
||||||
|
search = new EditBox(font, leftPos + 110, topPos, 101, 18,
|
||||||
|
Component.translatable("screen.rivencraft.writing_desk.search"));
|
||||||
|
search.setMaxLength(64);
|
||||||
|
search.setHint(Component.translatable("screen.rivencraft.writing_desk.search"));
|
||||||
|
search.setResponder(value -> scrollRows = 0);
|
||||||
|
addRenderableWidget(search);
|
||||||
|
categoryButton = addRenderableWidget(Button.builder(Component.literal("C"), button -> {
|
||||||
|
List<ResourceLocation> categories = menu.catalog().stream()
|
||||||
|
.map(SymbolDisplayMetadata::category).distinct().sorted().toList();
|
||||||
|
int next = selectedCategory == null ? 0 : categories.indexOf(selectedCategory) + 1;
|
||||||
|
selectedCategory = next >= categories.size() ? null : categories.get(next);
|
||||||
|
categoryButton.setMessage(Component.literal(selectedCategory == null ? "C" : "C*"));
|
||||||
|
scrollRows = 0;
|
||||||
|
}).bounds(leftPos + 213, topPos, 18, 18).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<DisplayPage> filteredPages() {
|
||||||
|
String query = search == null ? "" : search.getValue().trim().toLowerCase(Locale.ROOT);
|
||||||
|
List<DisplayPage> entries = new ArrayList<>();
|
||||||
|
int from = allMode ? 0 : menu.activeCollection();
|
||||||
|
int until = allMode ? WritingDeskBlockEntity.COLLECTIONS : from + 1;
|
||||||
|
for (int collection = from; collection < until; collection++) {
|
||||||
|
List<ItemStack> pages = pages(collection);
|
||||||
|
int last = -1;
|
||||||
|
for (int i = 0; i < pages.size(); i++) if (!pages.get(i).isEmpty()) last = i;
|
||||||
|
// A notebook has exactly its normal GUI capacity; a loose page has one position.
|
||||||
|
if (isPhysicalPageView()) last = menu.pageCapacity(collection) - 1;
|
||||||
|
for (int i = 0; i <= last; i++) {
|
||||||
|
ItemStack page = i < pages.size() ? pages.get(i) : ItemStack.EMPTY;
|
||||||
|
if (page.isEmpty() && (allMode || sortAlphabetically || !query.isEmpty() || selectedCategory != null)) continue;
|
||||||
|
if (!page.isEmpty() && !query.isEmpty()
|
||||||
|
&& !page.getHoverName().getString().toLowerCase(Locale.ROOT).contains(query)
|
||||||
|
&& !PageItem.content(page).symbol().map(symbol -> symbol.id().toString()
|
||||||
|
.toLowerCase(Locale.ROOT).contains(query)).orElse(false)) continue;
|
||||||
|
if (!page.isEmpty() && selectedCategory != null) {
|
||||||
|
ResourceLocation id = PageItem.content(page).symbol().map(symbol -> symbol.id()).orElse(null);
|
||||||
|
if (menu.catalog().stream().noneMatch(metadata -> metadata.id().equals(id)
|
||||||
|
&& metadata.category().equals(selectedCategory))) continue;
|
||||||
|
}
|
||||||
|
entries.add(new DisplayPage(collection, i, page));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sortAlphabetically) entries.sort(Comparator
|
||||||
|
.comparing((DisplayPage entry) -> entry.stack().getHoverName().getString(), String.CASE_INSENSITIVE_ORDER)
|
||||||
|
.thenComparingInt(DisplayPage::collection).thenComparingInt(DisplayPage::index));
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||||
|
// Keep the world visible, but still draw our GUI backing. The vanilla
|
||||||
|
// container implementation normally calls renderBg from this method.
|
||||||
|
renderBg(graphics, partialTick, mouseX, mouseY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
|
||||||
|
int x = leftPos;
|
||||||
|
int y = topPos;
|
||||||
|
// Legacy GuiWritingDesk draws the entire 176x166 panel, not a stretched swatch.
|
||||||
|
graphics.blit(LEGACY_DESK, x + 233, y + 20, 0, 0, 176, 166);
|
||||||
|
drawTabControl(graphics, 20, 203, menu.firstVisibleTab() > 0);
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
int tab = menu.firstVisibleTab() + i;
|
||||||
|
int tabY = y + 29 + i * 37;
|
||||||
|
if (tab == menu.activeCollection() && !allMode) graphics.setColor(0.5F, 0.5F, 1F, 1F);
|
||||||
|
graphics.blit(LEGACY_DESK, x, tabY, 0, 166, 58, 37);
|
||||||
|
graphics.setColor(1F, 1F, 1F, 1F);
|
||||||
|
graphics.drawString(font, Integer.toString(tab + 1), x + 8, tabY + 8, 0xFF181818, false);
|
||||||
|
ItemStack collection = menu.getSlot(tab).getItem();
|
||||||
|
if (!collection.isEmpty()) {
|
||||||
|
String name = collection.getHoverName().getString();
|
||||||
|
float scale = Math.min(1F, 50F / Math.max(1, font.width(name)));
|
||||||
|
graphics.pose().pushPose();
|
||||||
|
graphics.pose().translate(x + 4, tabY + 25, 0);
|
||||||
|
graphics.pose().scale(scale, scale, 1);
|
||||||
|
graphics.drawString(font, name, 0, 0, 0xFF404040, false);
|
||||||
|
graphics.pose().popPose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drawTabControl(graphics, 177, 212, menu.firstVisibleTab() < WritingDeskBlockEntity.COLLECTIONS - 4);
|
||||||
|
// GuiElementVSlider: 4-pixel caps, repeated 22-pixel track, 12x15 thumb.
|
||||||
|
graphics.blit(SCROLLBAR, x + 213, y + 20, 0, 0, 20, 4, 32, 32);
|
||||||
|
for (int offset = 4; offset < 162; offset += 22)
|
||||||
|
graphics.blit(SCROLLBAR, x + 213, y + 20 + offset, 0, 4, 20, Math.min(22, 162 - offset), 32, 32);
|
||||||
|
graphics.blit(SCROLLBAR, x + 213, y + 182, 0, 26, 20, 4, 32, 32);
|
||||||
|
int totalRows = (filteredPages().size() + 4) / 5;
|
||||||
|
int maxScroll = Math.max(0, totalRows - 4);
|
||||||
|
scrollRows = Math.clamp(scrollRows, 0, maxScroll);
|
||||||
|
int thumbY = y + 24 + (maxScroll == 0 ? 0 : scrollRows * 143 / maxScroll);
|
||||||
|
graphics.blit(SCROLLBAR, x + 217, thumbY, 20, draggingScrollbar ? 15 : 0, 12, 15, 32, 32);
|
||||||
|
graphics.fill(x + 365, y + 27, x + 381, y + 97, 0xFF303035);
|
||||||
|
graphics.fill(x + 366, y + 28, x + 380, y + 96, 0xFF6666AA);
|
||||||
|
int filled = 68 * menu.inkRemaining() / 16;
|
||||||
|
if (filled > 0) graphics.fill(x + 366, y + 96 - filled, x + 380, y + 96, 0xFF15151B);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawTabControl(GuiGraphics graphics, int y, int textureY, boolean enabled) {
|
||||||
|
if (!enabled) graphics.setColor(0.4F, 0.4F, 0.4F, 1F);
|
||||||
|
graphics.blit(LEGACY_DESK, leftPos, topPos + y, 0, textureY, 58, 9);
|
||||||
|
graphics.setColor(1F, 1F, 1F, 1F);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
|
||||||
|
// Legacy leaves the inventory grid unlabeled.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||||
|
super.render(graphics, mouseX, mouseY, partialTick);
|
||||||
|
if (sortAlphabetically) graphics.renderOutline(leftPos + 58, topPos, 20, 18, 0xFFFFD061);
|
||||||
|
if (allMode) graphics.renderOutline(leftPos + 80, topPos, 28, 18, 0xFFFFD061);
|
||||||
|
renderCollection(graphics);
|
||||||
|
if (draggingPage && pageDragSource != null) {
|
||||||
|
DisplayPage target = pageAtMouse(mouseX, mouseY);
|
||||||
|
if (target != null && canMovePage(pageDragSource, target)) {
|
||||||
|
int x = leftPos + 58 + ((int) mouseX - leftPos - 58) / 31 * 31;
|
||||||
|
int y = topPos + 20 + ((int) mouseY - topPos - 20) / 41 * 41;
|
||||||
|
graphics.renderOutline(x, y, 30, 40, 0xFF57D478);
|
||||||
|
}
|
||||||
|
graphics.renderItem(pageDragSource.stack(), mouseX - 8, mouseY - 8);
|
||||||
|
}
|
||||||
|
renderTooltip(graphics, mouseX, mouseY);
|
||||||
|
if (mouseX >= leftPos + 365 && mouseX < leftPos + 381
|
||||||
|
&& mouseY >= topPos + 27 && mouseY < topPos + 97) {
|
||||||
|
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.ink",
|
||||||
|
menu.inkRemaining(), 16), mouseX, mouseY);
|
||||||
|
}
|
||||||
|
if (mouseX >= leftPos + 213 && mouseX < leftPos + 231
|
||||||
|
&& mouseY >= topPos && mouseY < topPos + 18) {
|
||||||
|
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.category",
|
||||||
|
selectedCategory == null
|
||||||
|
? Component.translatable("screen.rivencraft.writing_desk.all_categories")
|
||||||
|
: Component.literal(selectedCategory.toString())), mouseX, mouseY);
|
||||||
|
}
|
||||||
|
if (mouseX >= leftPos && mouseX < leftPos + 58) {
|
||||||
|
int localY = mouseY - topPos;
|
||||||
|
if (localY >= 20 && localY < 29)
|
||||||
|
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.previous_tabs"), mouseX, mouseY);
|
||||||
|
else if (localY >= 177 && localY < 186)
|
||||||
|
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.next_tabs"), mouseX, mouseY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ItemStack> pages(int collectionIndex) {
|
||||||
|
ItemStack collection = menu.getSlot(collectionIndex).getItem();
|
||||||
|
if (collection.is(ModItems.PAGE.get())) return List.of(collection);
|
||||||
|
if (collection.is(ModItems.NOTEBOOK.get())) {
|
||||||
|
return collection.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void renderCollection(GuiGraphics graphics) {
|
||||||
|
if (lastCollection != menu.activeCollection()) {
|
||||||
|
lastCollection = menu.activeCollection();
|
||||||
|
scrollRows = 0;
|
||||||
|
selectedPage = 0;
|
||||||
|
selectedCollection = -1;
|
||||||
|
}
|
||||||
|
List<DisplayPage> entries = filteredPages();
|
||||||
|
for (int i = scrollRows * 5; i < entries.size() && i < scrollRows * 5 + 20; i++) {
|
||||||
|
int column = i % 5;
|
||||||
|
int row = i / 5 - scrollRows;
|
||||||
|
DisplayPage entry = entries.get(i);
|
||||||
|
ItemStack page = entry.stack();
|
||||||
|
int x = leftPos + 58 + column * 31;
|
||||||
|
int y = topPos + 20 + row * 41;
|
||||||
|
drawPage(graphics, page, x, y);
|
||||||
|
if (page.isEmpty()) graphics.fill(x, y, x + 30, y + 40, 0xB8000000);
|
||||||
|
int active = selectedCollection >= 0 ? selectedCollection : menu.activeCollection();
|
||||||
|
if (entry.collection() == active && entry.index() == selectedPage && !page.isEmpty())
|
||||||
|
graphics.renderOutline(x, y, 30, 40, 0xFFFFD061);
|
||||||
|
}
|
||||||
|
int active = selectedCollection >= 0 ? selectedCollection : menu.activeCollection();
|
||||||
|
List<ItemStack> source = pages(active);
|
||||||
|
if (selectedPage >= source.size() || source.get(selectedPage).isEmpty()) return;
|
||||||
|
ItemStack selected = source.get(selectedPage);
|
||||||
|
drawPage(graphics, selected, leftPos + 265, topPos + 26);
|
||||||
|
String title = font.plainSubstrByWidth(selected.getHoverName().getString(), 95);
|
||||||
|
graphics.fill(leftPos + 261, topPos + 81, leftPos + 360, topPos + 95, 0xFF202020);
|
||||||
|
graphics.drawString(font, title, leftPos + 263, topPos + 84, 0xFFE0E0E0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawPage(GuiGraphics graphics, ItemStack page, int x, int y) {
|
||||||
|
graphics.blit(PAGE_ATLAS, x, y, 30, 40, 156, 0, 30, 40, 256, 256);
|
||||||
|
if (page.isEmpty()) return;
|
||||||
|
switch (PageItem.content(page).kind()) {
|
||||||
|
case BLANK -> { }
|
||||||
|
case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 25, y + 20, 0xFF000000);
|
||||||
|
case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(NotebookScreen.symbolTexture(page), x + 1, y + 6,
|
||||||
|
28, 28, 16, 16, 96, 96, 128, 128);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DisplayPage pageAtMouse(double mouseX, double mouseY) {
|
||||||
|
int x = (int) Math.floor(mouseX - leftPos - 58);
|
||||||
|
int y = (int) Math.floor(mouseY - topPos - 20);
|
||||||
|
if (x < 0 || x >= 155 || y < 0 || y >= 164 || x % 31 >= 30 || y % 41 >= 40) return null;
|
||||||
|
int index = (y / 41 + scrollRows) * 5 + x / 31;
|
||||||
|
List<DisplayPage> entries = filteredPages();
|
||||||
|
return index < entries.size() ? entries.get(index) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean canMovePage(DisplayPage source, DisplayPage target) {
|
||||||
|
return !source.stack().isEmpty()
|
||||||
|
&& (menu.getSlot(source.collection()).getItem().is(ModItems.NOTEBOOK.get())
|
||||||
|
|| menu.getSlot(source.collection()).getItem().is(ModItems.PAGE.get()))
|
||||||
|
&& (menu.getSlot(target.collection()).getItem().is(ModItems.NOTEBOOK.get())
|
||||||
|
|| menu.getSlot(target.collection()).getItem().is(ModItems.PAGE.get()))
|
||||||
|
&& (source.collection() != target.collection() || source.index() != target.index());
|
||||||
|
}
|
||||||
|
|
||||||
|
private int extractTargetSlot(double mouseX, double mouseY) {
|
||||||
|
for (Slot slot : menu.slots) {
|
||||||
|
int index = slot.index;
|
||||||
|
if (index >= WritingDeskBlockEntity.COLLECTIONS && index <= WritingDeskMenu.COPY_RESULT) continue;
|
||||||
|
if (!slot.isActive() || !isHovering(slot.x, slot.y, 16, 16, mouseX, mouseY)) continue;
|
||||||
|
ItemStack held = pageDragSource.stack();
|
||||||
|
if (!slot.mayPlace(held)) return -1;
|
||||||
|
ItemStack current = slot.getItem();
|
||||||
|
if (current.isEmpty()) return index;
|
||||||
|
if (index < WritingDeskBlockEntity.COLLECTIONS && current.is(ModItems.NOTEBOOK.get())) {
|
||||||
|
List<ItemStack> pages = current.getOrDefault(
|
||||||
|
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
||||||
|
if (pages.size() < NotebookContents.MAX_PAGES || pages.stream().anyMatch(ItemStack::isEmpty))
|
||||||
|
return index;
|
||||||
|
} else if (index > WritingDeskMenu.COPY_RESULT && ItemStack.isSameItemSameComponents(current, held)
|
||||||
|
&& current.getCount() < slot.getMaxStackSize(held)) return index;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||||
|
if (button == 0) {
|
||||||
|
pageDragSource = null;
|
||||||
|
draggingPage = false;
|
||||||
|
}
|
||||||
|
int surfaceX = (int) Math.floor(mouseX - leftPos - 58);
|
||||||
|
int surfaceY = (int) Math.floor(mouseY - topPos - 20);
|
||||||
|
if ((button == 0 || button == 1) && !menu.getCarried().isEmpty()
|
||||||
|
&& surfaceX >= 0 && surfaceX < 155 && surfaceY >= 0 && surfaceY < 164) {
|
||||||
|
if (menu.getCarried().is(ModItems.PAGE.get()) && surfaceX % 31 < 30 && surfaceY % 41 < 40) {
|
||||||
|
int index = (surfaceY / 41 + scrollRows) * 5 + surfaceX / 31;
|
||||||
|
List<DisplayPage> entries = filteredPages();
|
||||||
|
// Only displayed positions are addressable. Filtering never turns
|
||||||
|
// empty screen space into additional storage for a loose-page tab.
|
||||||
|
int collection = index < entries.size() ? entries.get(index).collection() : menu.activeCollection();
|
||||||
|
int page = index < entries.size() ? entries.get(index).index() : index;
|
||||||
|
if (index < entries.size() && page < menu.pageCapacity(collection)
|
||||||
|
&& minecraft != null && minecraft.gameMode != null) {
|
||||||
|
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
|
||||||
|
WritingDeskMenu.PLACE_PAGE_BUTTON_BASE + collection * NotebookContents.MAX_PAGES + page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (button == 0) {
|
||||||
|
if (mouseX >= leftPos && mouseX < leftPos + 58) {
|
||||||
|
int y = (int) mouseY - topPos;
|
||||||
|
if (y >= 20 && y < 29 || y >= 177 && y < 186) {
|
||||||
|
if (minecraft != null && minecraft.gameMode != null)
|
||||||
|
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
|
||||||
|
y < 29 ? WritingDeskMenu.PREVIOUS_TABS : WritingDeskMenu.NEXT_TABS);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mouseX >= leftPos + 213 && mouseX < leftPos + 233
|
||||||
|
&& mouseY >= topPos + 20 && mouseY < topPos + 186) {
|
||||||
|
draggingScrollbar = true;
|
||||||
|
setScrollFromMouse(mouseY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
int x = (int) Math.floor(mouseX - leftPos - 58);
|
||||||
|
int y = (int) Math.floor(mouseY - topPos - 20);
|
||||||
|
if (x >= 0 && x < 155 && y >= 0 && y < 164) {
|
||||||
|
DisplayPage entry = pageAtMouse(mouseX, mouseY);
|
||||||
|
if (entry != null && !entry.stack().isEmpty()) {
|
||||||
|
selectedCollection = entry.collection();
|
||||||
|
selectedPage = entry.index();
|
||||||
|
if (menu.getCarried().isEmpty()) {
|
||||||
|
pageDragSource = entry;
|
||||||
|
pageDragStartX = mouseX;
|
||||||
|
pageDragStartY = mouseY;
|
||||||
|
}
|
||||||
|
if (minecraft != null && minecraft.gameMode != null) {
|
||||||
|
int id = WritingDeskMenu.ALL_PAGE_BUTTON_BASE
|
||||||
|
+ entry.collection() * NotebookContents.MAX_PAGES + entry.index();
|
||||||
|
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (button == 0 && mouseX >= leftPos && mouseX < leftPos + 58) {
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
int top = topPos + 29 + i * 37;
|
||||||
|
boolean overSlot = mouseX >= leftPos + 36 && mouseX < leftPos + 54
|
||||||
|
&& mouseY >= top + 4 && mouseY < top + 22;
|
||||||
|
if (mouseY >= top && mouseY < top + 37 && !overSlot) {
|
||||||
|
allMode = false;
|
||||||
|
selectedCollection = -1;
|
||||||
|
if (minecraft != null && minecraft.gameMode != null) {
|
||||||
|
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, menu.firstVisibleTab() + i);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super.mouseClicked(mouseX, mouseY, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
|
||||||
|
if (mouseX >= leftPos + 58 && mouseX < leftPos + 233
|
||||||
|
&& mouseY >= topPos + 20 && mouseY < topPos + 186) {
|
||||||
|
int rows = (filteredPages().size() + 4) / 5;
|
||||||
|
scrollRows = Math.clamp(scrollRows - (int) Math.signum(scrollY), 0, Math.max(0, rows - 4));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setScrollFromMouse(double mouseY) {
|
||||||
|
int rows = (filteredPages().size() + 4) / 5;
|
||||||
|
int max = Math.max(0, rows - 4);
|
||||||
|
scrollRows = Math.clamp((int) Math.round((mouseY - topPos - 31.5) * max / 143.0), 0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean mouseDragged(double mouseX, double mouseY, int button, double dragX, double dragY) {
|
||||||
|
if (button == 0 && pageDragSource != null) {
|
||||||
|
if (Math.abs(mouseX - pageDragStartX) >= 4 || Math.abs(mouseY - pageDragStartY) >= 4)
|
||||||
|
draggingPage = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (button == 0 && draggingScrollbar) {
|
||||||
|
setScrollFromMouse(mouseY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.mouseDragged(mouseX, mouseY, button, dragX, dragY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean mouseReleased(double mouseX, double mouseY, int button) {
|
||||||
|
if (button == 0 && pageDragSource != null) {
|
||||||
|
if (draggingPage) {
|
||||||
|
DisplayPage target = pageAtMouse(mouseX, mouseY);
|
||||||
|
if (target != null && canMovePage(pageDragSource, target)
|
||||||
|
&& minecraft != null && minecraft.gameMode != null) {
|
||||||
|
selectedCollection = target.collection();
|
||||||
|
selectedPage = target.index();
|
||||||
|
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
|
||||||
|
WritingDeskMenu.movePageButtonId(pageDragSource.collection(), pageDragSource.index(),
|
||||||
|
target.collection(), target.index()));
|
||||||
|
} else if (target == null && minecraft != null && minecraft.gameMode != null) {
|
||||||
|
int targetSlot = extractTargetSlot(mouseX, mouseY);
|
||||||
|
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
|
||||||
|
WritingDeskMenu.extractPageButtonId(pageDragSource.collection(), pageDragSource.index(),
|
||||||
|
targetSlot));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pageDragSource = null;
|
||||||
|
draggingPage = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (button == 0 && draggingScrollbar) {
|
||||||
|
draggingScrollbar = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.mouseReleased(mouseX, mouseY, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
|
||||||
|
if (search != null && search.isFocused()) {
|
||||||
|
if (keyCode == 256) {
|
||||||
|
setFocused(null);
|
||||||
|
search.setFocused(false);
|
||||||
|
} else {
|
||||||
|
search.keyPressed(keyCode, scanCode, modifiers);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (minecraft != null && minecraft.gameMode != null) {
|
||||||
|
if (keyCode == GLFW.GLFW_KEY_PAGE_UP) {
|
||||||
|
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, WritingDeskMenu.PREVIOUS_TABS);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (keyCode == GLFW.GLFW_KEY_PAGE_DOWN) {
|
||||||
|
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, WritingDeskMenu.NEXT_TABS);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super.keyPressed(keyCode, scanCode, modifiers);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package net.dnorberg.rivencraft.compat.jei;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import mezz.jei.api.IModPlugin;
|
||||||
|
import mezz.jei.api.JeiPlugin;
|
||||||
|
import mezz.jei.api.constants.VanillaTypes;
|
||||||
|
import mezz.jei.api.gui.builder.IRecipeLayoutBuilder;
|
||||||
|
import mezz.jei.api.gui.ingredient.IRecipeSlotsView;
|
||||||
|
import mezz.jei.api.helpers.IGuiHelper;
|
||||||
|
import mezz.jei.api.recipe.IFocusGroup;
|
||||||
|
import mezz.jei.api.recipe.RecipeType;
|
||||||
|
import mezz.jei.api.recipe.category.AbstractRecipeCategory;
|
||||||
|
import mezz.jei.api.registration.IRecipeCatalystRegistration;
|
||||||
|
import mezz.jei.api.registration.IRecipeCategoryRegistration;
|
||||||
|
import mezz.jei.api.registration.IRecipeRegistration;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
|
||||||
|
/** Loaded by JEI's plugin scanner only when JEI is installed. No common mod class references this type. */
|
||||||
|
@JeiPlugin
|
||||||
|
public final class RivencraftJeiPlugin implements IModPlugin {
|
||||||
|
private static final RecipeType<WritingGuide> WRITING =
|
||||||
|
RecipeType.create(Rivencraft.MOD_ID, "writing", WritingGuide.class);
|
||||||
|
private static final RecipeType<InkMixerGuide> INK_MIXER =
|
||||||
|
RecipeType.create(Rivencraft.MOD_ID, "ink_mixer", InkMixerGuide.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResourceLocation getPluginUid() {
|
||||||
|
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "jei_writing");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerCategories(IRecipeCategoryRegistration registration) {
|
||||||
|
IGuiHelper guiHelper = registration.getJeiHelpers().getGuiHelper();
|
||||||
|
registration.addRecipeCategories(new WritingCategory(guiHelper), new InkMixerCategory(guiHelper));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerRecipes(IRecipeRegistration registration) {
|
||||||
|
ItemStack panel = PageItem.createLinkPanelPage();
|
||||||
|
registration.addRecipes(WRITING, List.of(
|
||||||
|
new WritingGuide(List.of(new ItemStack(Items.PAPER), new ItemStack(ModItems.INK_VIAL.get())),
|
||||||
|
panel, "jei.rivencraft.writing.link_panel_hand"),
|
||||||
|
new WritingGuide(List.of(panel, new ItemStack(Items.PAPER), new ItemStack(ModItems.INK_VIAL.get())),
|
||||||
|
panel, "jei.rivencraft.writing.copy"),
|
||||||
|
new WritingGuide(List.of(new ItemStack(Items.LEATHER), panel),
|
||||||
|
new ItemStack(ModItems.DESCRIPTIVE_BOOK.get()), "jei.rivencraft.writing.bind"),
|
||||||
|
new WritingGuide(List.of(panel, new ItemStack(Items.LEATHER)),
|
||||||
|
new ItemStack(ModItems.LINKING_BOOK.get()), "jei.rivencraft.writing.linking_book")
|
||||||
|
));
|
||||||
|
registration.addRecipes(INK_MIXER, List.of(new InkMixerGuide(panel)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerRecipeCatalysts(IRecipeCatalystRegistration registration) {
|
||||||
|
registration.addRecipeCatalysts(WRITING, ModItems.INK_VIAL.get(),
|
||||||
|
ModItems.WRITING_DESK.get(), ModItems.BOOK_BINDER.get());
|
||||||
|
registration.addRecipeCatalysts(INK_MIXER, ModItems.INK_MIXER.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
private record WritingGuide(List<ItemStack> inputs, ItemStack output, String instructionKey) {}
|
||||||
|
private record InkMixerGuide(ItemStack panel) {}
|
||||||
|
|
||||||
|
private static final class InkMixerCategory extends AbstractRecipeCategory<InkMixerGuide> {
|
||||||
|
InkMixerCategory(IGuiHelper guiHelper) {
|
||||||
|
super(INK_MIXER, Component.translatable("jei.rivencraft.ink_mixer"),
|
||||||
|
guiHelper.createDrawableIngredient(VanillaTypes.ITEM_STACK,
|
||||||
|
new ItemStack(ModItems.INK_MIXER.get())), 138, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setRecipe(IRecipeLayoutBuilder builder, InkMixerGuide recipe, IFocusGroup focuses) {
|
||||||
|
builder.addInputSlot(2, 3).setStandardSlotBackground().addItemStack(new ItemStack(Items.PAPER));
|
||||||
|
builder.addInputSlot(23, 3).setStandardSlotBackground()
|
||||||
|
.addItemStack(new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
builder.addOutputSlot(112, 3).setOutputSlotBackground().addItemStack(recipe.panel());
|
||||||
|
builder.addOutputSlot(112, 46).setStandardSlotBackground()
|
||||||
|
.addItemStack(new ItemStack(Items.GLASS_BOTTLE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void draw(InkMixerGuide recipe, IRecipeSlotsView slots, GuiGraphics graphics,
|
||||||
|
double mouseX, double mouseY) {
|
||||||
|
graphics.drawWordWrap(Minecraft.getInstance().font,
|
||||||
|
Component.translatable("jei.rivencraft.ink_mixer.usage"), 2, 27, 104, 0x404040);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class WritingCategory extends AbstractRecipeCategory<WritingGuide> {
|
||||||
|
WritingCategory(IGuiHelper guiHelper) {
|
||||||
|
super(WRITING, Component.translatable("jei.rivencraft.writing"),
|
||||||
|
guiHelper.createDrawableIngredient(VanillaTypes.ITEM_STACK,
|
||||||
|
new ItemStack(ModItems.WRITING_DESK.get())), 138, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setRecipe(IRecipeLayoutBuilder builder, WritingGuide recipe, IFocusGroup focuses) {
|
||||||
|
for (int index = 0; index < recipe.inputs().size(); index++) {
|
||||||
|
builder.addInputSlot(2 + index * 21, 3).setStandardSlotBackground()
|
||||||
|
.addItemStack(recipe.inputs().get(index));
|
||||||
|
}
|
||||||
|
builder.addOutputSlot(112, 3).setOutputSlotBackground().addItemStack(recipe.output());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void draw(WritingGuide recipe, IRecipeSlotsView slots, GuiGraphics graphics,
|
||||||
|
double mouseX, double mouseY) {
|
||||||
|
graphics.drawWordWrap(Minecraft.getInstance().font,
|
||||||
|
Component.translatable(recipe.instructionKey()), 2, 27, 134, 0x404040);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ public final class RivencraftConfig {
|
|||||||
|
|
||||||
public static final ModConfigSpec SPEC;
|
public static final ModConfigSpec SPEC;
|
||||||
private static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
|
private static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
|
||||||
|
private static final ModConfigSpec.IntValue SYMBOL_INK_COST;
|
||||||
|
private static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
|
||||||
|
|
||||||
static {
|
static {
|
||||||
ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
|
ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
|
||||||
@@ -25,6 +27,12 @@ public final class RivencraftConfig {
|
|||||||
)
|
)
|
||||||
.defineEnum("return_link_mode", ReturnLinkMode.ON_CREATION);
|
.defineEnum("return_link_mode", ReturnLinkMode.ON_CREATION);
|
||||||
builder.pop();
|
builder.pop();
|
||||||
|
builder.push("writing");
|
||||||
|
SYMBOL_INK_COST = builder.comment("Ink units consumed when the Writing Desk copies one symbol page.")
|
||||||
|
.defineInRange("symbol_ink_cost", 1, 1, 16);
|
||||||
|
LINK_PANEL_INK_COST = builder.comment("Ink units consumed when preparing one baseline link-panel page.")
|
||||||
|
.defineInRange("link_panel_ink_cost", 2, 1, 16);
|
||||||
|
builder.pop();
|
||||||
SPEC = builder.build();
|
SPEC = builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,4 +42,12 @@ public final class RivencraftConfig {
|
|||||||
public static ReturnLinkMode returnLinkMode() {
|
public static ReturnLinkMode returnLinkMode() {
|
||||||
return RETURN_LINK_MODE.get();
|
return RETURN_LINK_MODE.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static int symbolInkCost() {
|
||||||
|
return SYMBOL_INK_COST.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int linkPanelInkCost() {
|
||||||
|
return LINK_PANEL_INK_COST.get();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
package net.dnorberg.rivencraft.gametest;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeReference;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeDimensionService;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.menu.BookBinderMenu;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||||
|
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.entity.item.ItemEntity;
|
||||||
|
import net.minecraft.world.inventory.ClickType;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.level.block.Blocks;
|
||||||
|
import net.minecraft.world.phys.AABB;
|
||||||
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
|
@GameTestHolder("rivencraft_archive_binder")
|
||||||
|
@PrefixGameTestTemplate(false)
|
||||||
|
public final class BookBinderGameTests {
|
||||||
|
private BookBinderGameTests() {}
|
||||||
|
|
||||||
|
private static ItemStack terrainPage() {
|
||||||
|
return PageItem.createSymbolPage(new SymbolReference(
|
||||||
|
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "terrain/standard")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ItemStack weatherPage() {
|
||||||
|
return PageItem.createSymbolPage(new SymbolReference(
|
||||||
|
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "weather/normal")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BookBinderBlockEntity place(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.BOOK_BINDER.get().defaultBlockState(), 3);
|
||||||
|
return (BookBinderBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void binderInputsAndTitleSurviveSaveLoad(GameTestHelper helper) {
|
||||||
|
BookBinderBlockEntity binder = place(helper);
|
||||||
|
binder.setTitle("Ordered Age");
|
||||||
|
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
|
||||||
|
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
|
||||||
|
binder.setItem(BookBinderBlockEntity.FIRST_PAGE, terrainPage());
|
||||||
|
var saved = binder.saveWithoutMetadata(helper.getLevel().registryAccess());
|
||||||
|
var restored = new BookBinderBlockEntity(binder.getBlockPos(), binder.getBlockState());
|
||||||
|
restored.loadWithComponents(saved, helper.getLevel().registryAccess());
|
||||||
|
helper.assertValueEqual(restored.title(), "Ordered Age", "Title must persist");
|
||||||
|
helper.assertTrue(restored.getItem(BookBinderBlockEntity.COVER).is(Items.LEATHER), "Cover must persist");
|
||||||
|
helper.assertTrue(restored.getItem(BookBinderBlockEntity.LINK_PANEL).is(ModItems.PAGE.get()),
|
||||||
|
"Link Panel must persist");
|
||||||
|
helper.assertTrue(ItemStack.matches(restored.getItem(BookBinderBlockEntity.FIRST_PAGE), terrainPage()),
|
||||||
|
"Ordered page must persist");
|
||||||
|
helper.assertTrue(restored.canAssemble(), "Restored binder must remain ready to assemble");
|
||||||
|
AgeReference beforeSave = binder.previewBook().get(ModDataComponents.AGE_REFERENCE.get());
|
||||||
|
var previewSaved = binder.saveWithoutMetadata(helper.getLevel().registryAccess());
|
||||||
|
restored.loadWithComponents(previewSaved, helper.getLevel().registryAccess());
|
||||||
|
helper.assertValueEqual(restored.previewBook().get(ModDataComponents.AGE_REFERENCE.get()), beforeSave,
|
||||||
|
"Pending Age identity must survive a world restart");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void missingSymbolPageSurvivesReloadButCannotBeAssembled(GameTestHelper helper) {
|
||||||
|
BookBinderBlockEntity binder = place(helper);
|
||||||
|
binder.setTitle("Recoverable Writing");
|
||||||
|
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
|
||||||
|
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
|
||||||
|
SymbolReference missing = new SymbolReference(ResourceLocation.parse("example:removed_symbol"));
|
||||||
|
binder.setItem(BookBinderBlockEntity.FIRST_PAGE, PageItem.createRecoveredPage(missing));
|
||||||
|
var saved = binder.saveWithoutMetadata(helper.getLevel().registryAccess());
|
||||||
|
var restored = new BookBinderBlockEntity(binder.getBlockPos(), binder.getBlockState());
|
||||||
|
restored.loadWithComponents(saved, helper.getLevel().registryAccess());
|
||||||
|
helper.assertValueEqual(PageItem.content(restored.getItem(BookBinderBlockEntity.FIRST_PAGE))
|
||||||
|
.symbol().orElseThrow(), missing, "Reload must preserve the removed data-pack symbol ID");
|
||||||
|
helper.assertFalse(restored.canAssemble(), "Missing symbol cannot create a new written Age");
|
||||||
|
helper.assertTrue(restored.getItem(BookBinderBlockEntity.FIRST_PAGE).is(ModItems.PAGE.get()),
|
||||||
|
"Rejected writing must remain recoverable as a Page");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void assemblyIsAtomicAndRetainsPageOrder(GameTestHelper helper) {
|
||||||
|
BookBinderBlockEntity binder = place(helper);
|
||||||
|
binder.setTitle("A New Age");
|
||||||
|
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
|
||||||
|
binder.setItem(BookBinderBlockEntity.FIRST_PAGE, terrainPage());
|
||||||
|
binder.setItem(BookBinderBlockEntity.FIRST_PAGE + 1, weatherPage());
|
||||||
|
helper.assertFalse(binder.canAssemble(), "Missing Link Panel must prevent assembly");
|
||||||
|
helper.assertFalse(binder.consumeAssembly(), "Failure must consume nothing");
|
||||||
|
helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).is(Items.LEATHER),
|
||||||
|
"Rejected assembly must preserve cover");
|
||||||
|
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5,
|
||||||
|
binder.getBlockPos().getZ() + 0.5);
|
||||||
|
BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder);
|
||||||
|
menu.broadcastChanges();
|
||||||
|
helper.assertTrue(menu.getSlot(BookBinderMenu.RESULT).hasItem(), "Valid binder must show output");
|
||||||
|
menu.clicked(BookBinderMenu.RESULT, 0, ClickType.PICKUP, player);
|
||||||
|
ItemStack book = menu.getCarried();
|
||||||
|
helper.assertTrue(book.is(ModItems.DESCRIPTIVE_BOOK.get()), "Result must be a Descriptive Book");
|
||||||
|
AgeReference age = book.get(ModDataComponents.AGE_REFERENCE.get());
|
||||||
|
helper.assertTrue(age != null, "Assembled book must already be a written Age book");
|
||||||
|
OrderedWriting writing = book.get(ModDataComponents.BOOK_WRITING.get());
|
||||||
|
helper.assertTrue(writing != null, "Assembled book must keep its writing");
|
||||||
|
helper.assertValueEqual(writing.schemaVersion(), OrderedWriting.CURRENT_SCHEMA_VERSION,
|
||||||
|
"Assembled writing must use the current schema");
|
||||||
|
helper.assertValueEqual(writing.entries().size(), 3, "Link Panel and two pages must be present");
|
||||||
|
helper.assertValueEqual(writing.entries().get(0).kind(), WritingEntry.Kind.LINK_PANEL,
|
||||||
|
"Link Panel must be first");
|
||||||
|
helper.assertValueEqual(writing.entries().get(1).symbol().orElseThrow().id().toString(),
|
||||||
|
"rivencraft:terrain/standard", "Terrain page order must be preserved");
|
||||||
|
helper.assertValueEqual(writing.entries().get(2).symbol().orElseThrow().id().toString(),
|
||||||
|
"rivencraft:weather/normal", "Weather page order must be preserved");
|
||||||
|
helper.assertTrue(AgeRegistrySavedData.get(helper.getLevel()).find(age.id()).isPresent(),
|
||||||
|
"Taking the completed book must register its random Age definition");
|
||||||
|
ItemStack copy = book.copy();
|
||||||
|
helper.assertValueEqual(copy.get(ModDataComponents.BOOK_WRITING.get()), writing,
|
||||||
|
"A book copy must retain identical ordered writing");
|
||||||
|
helper.assertValueEqual(copy.get(ModDataComponents.AGE_REFERENCE.get()), age,
|
||||||
|
"A book copy must retain the same Age rather than assigning a new one");
|
||||||
|
var savedBook = (net.minecraft.nbt.CompoundTag) copy.saveOptional(helper.getLevel().registryAccess());
|
||||||
|
ItemStack restoredBook = ItemStack.parseOptional(helper.getLevel().registryAccess(), savedBook);
|
||||||
|
helper.assertValueEqual(restoredBook.get(ModDataComponents.BOOK_WRITING.get()), writing,
|
||||||
|
"Versioned ordered writing must survive full book save/load");
|
||||||
|
helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).isEmpty(), "Cover must be consumed once");
|
||||||
|
helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).isEmpty(), "Link Panel must be consumed once");
|
||||||
|
helper.assertTrue(binder.getItem(BookBinderBlockEntity.FIRST_PAGE).isEmpty(), "Pages must be consumed once");
|
||||||
|
menu.broadcastChanges();
|
||||||
|
helper.assertTrue(menu.getSlot(BookBinderMenu.RESULT).getItem().isEmpty(), "No duplicate output may remain");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void linkPanelOnlyCreatesCompletedRandomAgeBook(GameTestHelper helper) {
|
||||||
|
BookBinderBlockEntity binder = place(helper);
|
||||||
|
binder.setTitle("Random Binder Age");
|
||||||
|
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
|
||||||
|
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5,
|
||||||
|
binder.getBlockPos().getZ() + 0.5);
|
||||||
|
BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder);
|
||||||
|
menu.clicked(BookBinderMenu.RESULT, 0, ClickType.PICKUP, player);
|
||||||
|
ItemStack book = menu.getCarried();
|
||||||
|
AgeReference age = book.get(ModDataComponents.AGE_REFERENCE.get());
|
||||||
|
helper.assertTrue(age != null, "Link-Panel-only output must be written, not unwritten");
|
||||||
|
helper.assertValueEqual(age.name(), "Random Binder Age", "The assigned title must name the Age");
|
||||||
|
helper.assertValueEqual(book.get(ModDataComponents.BOOK_WRITING.get()).entries().size(), 1,
|
||||||
|
"A random Age book must contain only its Link Panel when no symbols are supplied");
|
||||||
|
helper.assertTrue(AgeRegistrySavedData.get(helper.getLevel()).find(age.id()).isPresent(),
|
||||||
|
"Taking the book must register its random Age definition");
|
||||||
|
helper.assertTrue(AgeDimensionService.resolve(player, age).isPresent(),
|
||||||
|
"The completed book must resolve to a usable random Age");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void notebookImportPreservesOrderAndRejectsOverflow(GameTestHelper helper) {
|
||||||
|
BookBinderBlockEntity binder = place(helper);
|
||||||
|
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
|
||||||
|
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(
|
||||||
|
PageItem.createLinkPanelPage(), terrainPage(), weatherPage())));
|
||||||
|
binder.setItem(BookBinderBlockEntity.NOTEBOOK, notebook);
|
||||||
|
helper.assertTrue(binder.importNotebook(), "Notebook pages must import together");
|
||||||
|
helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).is(ModItems.PAGE.get()),
|
||||||
|
"Imported Link Panel must use its dedicated slot");
|
||||||
|
helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE), terrainPage()),
|
||||||
|
"First Notebook symbol must stay first");
|
||||||
|
helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 1), weatherPage()),
|
||||||
|
"Second Notebook symbol must stay second");
|
||||||
|
helper.assertValueEqual(binder.getItem(BookBinderBlockEntity.NOTEBOOK)
|
||||||
|
.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pageCount(), 0,
|
||||||
|
"Empty Notebook cover must remain recoverable");
|
||||||
|
for (int slot = BookBinderBlockEntity.FIRST_PAGE + 2; slot < BookBinderBlockEntity.SIZE; slot++)
|
||||||
|
binder.setItem(slot, terrainPage());
|
||||||
|
ItemStack overflow = new ItemStack(ModItems.NOTEBOOK.get());
|
||||||
|
overflow.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(weatherPage())));
|
||||||
|
binder.setItem(BookBinderBlockEntity.NOTEBOOK, overflow);
|
||||||
|
helper.assertFalse(binder.importNotebook(), "Full binder must reject Notebook import");
|
||||||
|
helper.assertValueEqual(binder.getItem(BookBinderBlockEntity.NOTEBOOK)
|
||||||
|
.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pageCount(), 1,
|
||||||
|
"Rejected import must preserve the Notebook page");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void pageControlsAndBreakingPreserveStoredInputs(GameTestHelper helper) {
|
||||||
|
BookBinderBlockEntity binder = place(helper);
|
||||||
|
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
|
||||||
|
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
|
||||||
|
binder.setItem(BookBinderBlockEntity.FIRST_PAGE, terrainPage());
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5,
|
||||||
|
binder.getBlockPos().getZ() + 0.5);
|
||||||
|
BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder);
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, BookBinderMenu.MOVE_PREVIOUS),
|
||||||
|
"Move without selection must be rejected");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.SELECT_BASE), "Page selection must succeed");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.MOVE_NEXT), "Page move must succeed");
|
||||||
|
helper.assertTrue(binder.getItem(BookBinderBlockEntity.FIRST_PAGE).isEmpty(), "Old position must be empty");
|
||||||
|
helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 1), terrainPage()),
|
||||||
|
"Moved page must keep its data");
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, 999), "Unknown action must be rejected");
|
||||||
|
BlockPos pos = binder.getBlockPos();
|
||||||
|
helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
|
||||||
|
helper.assertFalse(menu.stillValid(player), "Removed binder must invalidate the menu");
|
||||||
|
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(pos).inflate(2));
|
||||||
|
int leather = drops.stream().filter(entity -> entity.getItem().is(Items.LEATHER))
|
||||||
|
.mapToInt(entity -> entity.getItem().getCount()).sum();
|
||||||
|
int pages = drops.stream().filter(entity -> entity.getItem().is(ModItems.PAGE.get()))
|
||||||
|
.mapToInt(entity -> entity.getItem().getCount()).sum();
|
||||||
|
helper.assertValueEqual(leather, 1, "Cover must drop once");
|
||||||
|
helper.assertValueEqual(pages, 2, "Link Panel and writing page must drop once each");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void fullInventoryCannotConsumeBinderInputs(GameTestHelper helper) {
|
||||||
|
BookBinderBlockEntity binder = place(helper);
|
||||||
|
binder.setTitle("Full Inventory");
|
||||||
|
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
|
||||||
|
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5,
|
||||||
|
binder.getBlockPos().getZ() + 0.5);
|
||||||
|
for (int slot = 0; slot < 36; slot++)
|
||||||
|
player.getInventory().setItem(slot, new ItemStack(Items.COBBLESTONE, 64));
|
||||||
|
BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder);
|
||||||
|
helper.assertTrue(menu.quickMoveStack(player, BookBinderMenu.RESULT).isEmpty(),
|
||||||
|
"Full inventory must reject output transfer");
|
||||||
|
helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).is(Items.LEATHER),
|
||||||
|
"Rejected transfer must preserve cover");
|
||||||
|
helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).is(ModItems.PAGE.get()),
|
||||||
|
"Rejected transfer must preserve Link Panel");
|
||||||
|
helper.assertTrue(menu.getSlot(BookBinderMenu.RESULT).hasItem(),
|
||||||
|
"Result must remain available after inventory space is freed");
|
||||||
|
helper.assertFalse(menu.setTitle(player, "A title that is much too long for the binder slot"),
|
||||||
|
"Oversized title packet must be rejected");
|
||||||
|
helper.assertValueEqual(binder.title(), "Full Inventory", "Rejected title must preserve existing title");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void forgedBinderActionsAndReplayedOutputCannotDuplicateBook(GameTestHelper helper) {
|
||||||
|
BookBinderBlockEntity binder = place(helper);
|
||||||
|
binder.setTitle("Single Assembly");
|
||||||
|
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
|
||||||
|
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
|
||||||
|
var first = helper.makeMockServerPlayerInLevel();
|
||||||
|
var second = helper.makeMockServerPlayerInLevel();
|
||||||
|
for (var player : List.of(first, second)) player.moveTo(binder.getBlockPos().getX() + 0.5,
|
||||||
|
binder.getBlockPos().getY() + 0.5, binder.getBlockPos().getZ() + 0.5);
|
||||||
|
BookBinderMenu firstMenu = new BookBinderMenu(1, first.getInventory(), binder);
|
||||||
|
BookBinderMenu secondMenu = new BookBinderMenu(2, second.getInventory(), binder);
|
||||||
|
helper.assertFalse(firstMenu.clickMenuButton(first, BookBinderMenu.SELECT_BASE + BookBinderBlockEntity.PAGE_COUNT),
|
||||||
|
"A fabricated page selection must be rejected");
|
||||||
|
helper.assertFalse(firstMenu.clickMenuButton(first, BookBinderMenu.STRIP_CLICK_BASE
|
||||||
|
+ BookBinderBlockEntity.PAGE_COUNT + 1), "A fabricated strip address must be rejected");
|
||||||
|
helper.assertTrue(firstMenu.quickMoveStack(first, BookBinderMenu.RESULT).is(ModItems.DESCRIPTIVE_BOOK.get()),
|
||||||
|
"The first viewer must receive the single assembled book");
|
||||||
|
helper.assertTrue(secondMenu.quickMoveStack(second, BookBinderMenu.RESULT).isEmpty(),
|
||||||
|
"Another viewer must not take a stale result");
|
||||||
|
helper.assertTrue(firstMenu.quickMoveStack(first, BookBinderMenu.RESULT).isEmpty(),
|
||||||
|
"Replaying the output request must not create a second book");
|
||||||
|
int books = first.getInventory().items.stream().filter(stack -> stack.is(ModItems.DESCRIPTIVE_BOOK.get()))
|
||||||
|
.mapToInt(ItemStack::getCount).sum();
|
||||||
|
helper.assertValueEqual(books, 1, "Only one completed book may be emitted");
|
||||||
|
helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).isEmpty(), "Cover must be consumed once");
|
||||||
|
helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).isEmpty(), "Panel must be consumed once");
|
||||||
|
helper.getLevel().setBlock(binder.getBlockPos(), Blocks.AIR.defaultBlockState(), 3);
|
||||||
|
helper.assertFalse(firstMenu.clickMenuButton(first, BookBinderMenu.IMPORT_NOTEBOOK),
|
||||||
|
"A stale binder menu must reject packets after block removal");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void legacyStripInsertsAndRemovesPagesInOrder(GameTestHelper helper) {
|
||||||
|
BookBinderBlockEntity binder = place(helper);
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5,
|
||||||
|
binder.getBlockPos().getZ() + 0.5);
|
||||||
|
BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder);
|
||||||
|
menu.setCarried(PageItem.createLinkPanelPage());
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE),
|
||||||
|
"The first thumbnail must accept a Link Panel");
|
||||||
|
helper.assertTrue(menu.getCarried().isEmpty(), "Inserted panel must leave the cursor");
|
||||||
|
menu.setCarried(terrainPage());
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE + 1),
|
||||||
|
"The strip must accept a written Page");
|
||||||
|
menu.setCarried(weatherPage());
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE + 1),
|
||||||
|
"Inserting before a Page must shift it right");
|
||||||
|
helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE), weatherPage()),
|
||||||
|
"Inserted Page must be first");
|
||||||
|
helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 1), terrainPage()),
|
||||||
|
"Existing Page must move right");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE + 1),
|
||||||
|
"Clicking a thumbnail with an empty cursor must take the Page");
|
||||||
|
helper.assertTrue(ItemStack.matches(menu.getCarried(), weatherPage()),
|
||||||
|
"Removed Page must return to the cursor");
|
||||||
|
helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE), terrainPage()),
|
||||||
|
"Removal must close the gap");
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE),
|
||||||
|
"A symbol Page must not replace the Link Panel");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void legacyStripImportsNotebookAtClickedPosition(GameTestHelper helper) {
|
||||||
|
BookBinderBlockEntity binder = place(helper);
|
||||||
|
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
|
||||||
|
binder.setItem(BookBinderBlockEntity.FIRST_PAGE, terrainPage());
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5,
|
||||||
|
binder.getBlockPos().getZ() + 0.5);
|
||||||
|
BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder);
|
||||||
|
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
|
||||||
|
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(weatherPage())));
|
||||||
|
menu.setCarried(notebook);
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE + 1),
|
||||||
|
"Notebook pages must import where clicked");
|
||||||
|
helper.assertValueEqual(menu.getCarried().getOrDefault(
|
||||||
|
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pageCount(), 0,
|
||||||
|
"The empty Notebook must remain on the cursor");
|
||||||
|
helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE), weatherPage()),
|
||||||
|
"Imported Page must occupy the clicked position");
|
||||||
|
helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 1), terrainPage()),
|
||||||
|
"Existing Page must shift right");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package net.dnorberg.rivencraft.gametest;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
|
/** Small default smoke suite; the detailed milestone regressions are opt-in. */
|
||||||
|
@GameTestHolder(Rivencraft.MOD_ID)
|
||||||
|
@PrefixGameTestTemplate(false)
|
||||||
|
public final class CoreGameTests {
|
||||||
|
private CoreGameTests() {}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void previewBackendSelection(GameTestHelper helper) {
|
||||||
|
LinkingGameTests.previewBackendSelectionCoversOptionalDependencyMatrix(helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void linkTargetPersistence(GameTestHelper helper) {
|
||||||
|
LinkingGameTests.linkTargetSurvivesItemSerialization(helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void deterministicAgeDefinition(GameTestHelper helper) {
|
||||||
|
LinkingGameTests.ageDefinitionIsCreatedOnceWithDeterministicData(helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void blockedLinkDestination(GameTestHelper helper) {
|
||||||
|
LinkingGameTests.travelRejectsBlockedDestination(helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void legacyWritingAndPageFreeBooks(GameTestHelper helper) {
|
||||||
|
WritingDataGameTests.oldBareListWritingAndPageFreeBooksRemainReadable(helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void pagePersistenceAndUnknownSymbols(GameTestHelper helper) {
|
||||||
|
WritingDataGameTests.pageItemPreservesContentAndRejectsForgedUnknownSymbols(helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void semanticSymbolSchema(GameTestHelper helper) {
|
||||||
|
WritingDataGameTests.semanticSchemaIsServerOnlyBoundedAndV1Compatible(helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void notebookPersistence(GameTestHelper helper) {
|
||||||
|
WritingDataGameTests.notebookContentsPreserveOrderGapsNamesAndUnknownPages(helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void deskCopyTransaction(GameTestHelper helper) {
|
||||||
|
WritingDeskGameTests.takingCopySpendsPaperAndOneInkThenReturnsGlassBottle(helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void deskRejectsForgedAndStaleActions(GameTestHelper helper) {
|
||||||
|
WritingDeskGameTests.forgedDeskSelectionsAndStaleMenuCannotCopy(helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void twoBlockDeskDropsOnce(GameTestHelper helper) {
|
||||||
|
WritingDeskGameTests.twoBlockDeskSharesOneInventoryAndBreaksTogether(helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void oldDeskMigration(GameTestHelper helper) {
|
||||||
|
WritingDeskGameTests.oldDeskInventoryMigratesAndAll88TabsPersist(helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void binderKeepsMissingSymbolsRecoverable(GameTestHelper helper) {
|
||||||
|
BookBinderGameTests.missingSymbolPageSurvivesReloadButCannotBeAssembled(helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void binderAssemblyTransaction(GameTestHelper helper) {
|
||||||
|
BookBinderGameTests.assemblyIsAtomicAndRetainsPageOrder(helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void binderRejectsForgedAndReplayedActions(GameTestHelper helper) {
|
||||||
|
BookBinderGameTests.forgedBinderActionsAndReplayedOutputCannotDuplicateBook(helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void mixerOutputTransaction(GameTestHelper helper) {
|
||||||
|
InkMixerGameTests.linkPanelRequiresTakingOutputAndPersistsInk(helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void linkingBookRecipe(GameTestHelper helper) {
|
||||||
|
LinkingBookRecipeGameTests.legacyRecipeRequiresExactlyLeatherAndLinkPanel(helper);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package net.dnorberg.rivencraft.gametest;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.menu.InkMixerMenu;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingInkService;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.world.inventory.ClickType;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.entity.item.ItemEntity;
|
||||||
|
import net.minecraft.world.level.block.Blocks;
|
||||||
|
import net.minecraft.world.phys.AABB;
|
||||||
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
|
@GameTestHolder("rivencraft_archive_mixer")
|
||||||
|
@PrefixGameTestTemplate(false)
|
||||||
|
public final class InkMixerGameTests {
|
||||||
|
private InkMixerGameTests() {}
|
||||||
|
|
||||||
|
private static InkMixerBlockEntity place(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.INK_MIXER.get().defaultBlockState(), 3);
|
||||||
|
return (InkMixerBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void linkPanelRequiresTakingOutputAndPersistsInk(GameTestHelper helper) {
|
||||||
|
InkMixerBlockEntity mixer = place(helper);
|
||||||
|
mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
|
||||||
|
mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(mixer.getBlockPos().getX() + 0.5, mixer.getBlockPos().getY() + 0.5,
|
||||||
|
mixer.getBlockPos().getZ() + 0.5);
|
||||||
|
InkMixerMenu menu = new InkMixerMenu(1, player.getInventory(), mixer);
|
||||||
|
menu.broadcastChanges();
|
||||||
|
helper.assertTrue(menu.getSlot(InkMixerMenu.RESULT).getItem().is(ModItems.PAGE.get()),
|
||||||
|
"Paper and filled basin must preview a Link Panel");
|
||||||
|
helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.PAPER).getCount(), 2,
|
||||||
|
"Preview must not consume paper");
|
||||||
|
helper.assertValueEqual(mixer.inkRemaining(), 16, "Vial must fill the basin");
|
||||||
|
helper.assertTrue(mixer.getItem(InkMixerBlockEntity.BOTTLES).is(Items.GLASS_BOTTLE),
|
||||||
|
"Consumed vial must return a vanilla glass bottle");
|
||||||
|
menu.clicked(InkMixerMenu.RESULT, 0, ClickType.PICKUP, player);
|
||||||
|
helper.assertTrue(menu.getCarried().is(ModItems.PAGE.get()), "Taking result must give a Link Panel");
|
||||||
|
helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.PAPER).getCount(), 1,
|
||||||
|
"Taking result must use exactly one paper");
|
||||||
|
helper.assertValueEqual(mixer.inkRemaining(), 16 - WritingInkService.linkPanelCost(),
|
||||||
|
"Taking result must spend the configured ink cost");
|
||||||
|
var saved = mixer.saveWithoutMetadata(helper.getLevel().registryAccess());
|
||||||
|
var restored = new InkMixerBlockEntity(mixer.getBlockPos(), ModBlocks.INK_MIXER.get().defaultBlockState());
|
||||||
|
restored.loadWithComponents(saved, helper.getLevel().registryAccess());
|
||||||
|
helper.assertValueEqual(restored.inkRemaining(), mixer.inkRemaining(), "Ink basin must persist");
|
||||||
|
helper.assertValueEqual(restored.getItem(InkMixerBlockEntity.PAPER).getCount(), 1,
|
||||||
|
"Paper input must persist");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void bottleCapacityBlocksAndThenPermitsRefill(GameTestHelper helper) {
|
||||||
|
InkMixerBlockEntity mixer = place(helper);
|
||||||
|
mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER));
|
||||||
|
mixer.setItem(InkMixerBlockEntity.BOTTLES, new ItemStack(Items.GLASS_BOTTLE, 64));
|
||||||
|
mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
helper.assertValueEqual(mixer.inkRemaining(), 0, "Full bottle output must block refill");
|
||||||
|
helper.assertTrue(mixer.getItem(InkMixerBlockEntity.INK).is(ModItems.INK_VIAL.get()),
|
||||||
|
"Blocked refill must preserve vial");
|
||||||
|
helper.assertFalse(mixer.canCreateLinkPanel(), "Blocked refill must suppress output");
|
||||||
|
mixer.removeItem(InkMixerBlockEntity.BOTTLES, 1);
|
||||||
|
helper.assertValueEqual(mixer.inkRemaining(), 16, "Making bottle space must immediately refill");
|
||||||
|
helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.BOTTLES).getCount(), 64,
|
||||||
|
"One bottle must be returned after refill");
|
||||||
|
helper.assertTrue(mixer.canCreateLinkPanel(), "Refill must enable Link Panel output");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void shiftClickNeedsInventorySpaceAndBreakingReturnsInputs(GameTestHelper helper) {
|
||||||
|
InkMixerBlockEntity mixer = place(helper);
|
||||||
|
mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
|
||||||
|
mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(mixer.getBlockPos().getX() + 0.5, mixer.getBlockPos().getY() + 0.5,
|
||||||
|
mixer.getBlockPos().getZ() + 0.5);
|
||||||
|
InkMixerMenu menu = new InkMixerMenu(1, player.getInventory(), mixer);
|
||||||
|
for (int slot = 0; slot < 36; slot++) player.getInventory().setItem(slot, new ItemStack(Items.STONE, 64));
|
||||||
|
helper.assertTrue(menu.quickMoveStack(player, InkMixerMenu.RESULT).isEmpty(),
|
||||||
|
"Full inventory must not consume mixer output");
|
||||||
|
helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.PAPER).getCount(), 2,
|
||||||
|
"Full inventory must preserve paper");
|
||||||
|
player.getInventory().setItem(0, ItemStack.EMPTY);
|
||||||
|
helper.assertTrue(menu.quickMoveStack(player, InkMixerMenu.RESULT).is(ModItems.PAGE.get()),
|
||||||
|
"Free inventory space must accept the Link Panel");
|
||||||
|
helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.PAPER).getCount(), 1,
|
||||||
|
"Shift-click must consume only one paper");
|
||||||
|
helper.getLevel().setBlock(mixer.getBlockPos(), Blocks.AIR.defaultBlockState(), 3);
|
||||||
|
helper.assertFalse(menu.stillValid(player), "Breaking the mixer must invalidate its menu");
|
||||||
|
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class,
|
||||||
|
new AABB(mixer.getBlockPos()).inflate(2));
|
||||||
|
int paper = drops.stream().filter(entity -> entity.getItem().is(Items.PAPER))
|
||||||
|
.mapToInt(entity -> entity.getItem().getCount()).sum();
|
||||||
|
int bottles = drops.stream().filter(entity -> entity.getItem().is(Items.GLASS_BOTTLE))
|
||||||
|
.mapToInt(entity -> entity.getItem().getCount()).sum();
|
||||||
|
helper.assertValueEqual(paper, 1, "Breaking the mixer must return unused paper once");
|
||||||
|
helper.assertValueEqual(bottles, 1, "Breaking the mixer must return the consumed vial's bottle once");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package net.dnorberg.rivencraft.gametest;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.writing.LinkingBookRecipe;
|
||||||
|
import net.minecraft.core.component.DataComponents;
|
||||||
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.item.crafting.CraftingBookCategory;
|
||||||
|
import net.minecraft.world.item.crafting.CraftingInput;
|
||||||
|
import net.minecraft.world.item.crafting.RecipeType;
|
||||||
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
|
@GameTestHolder("rivencraft_archive_recipe")
|
||||||
|
@PrefixGameTestTemplate(false)
|
||||||
|
public final class LinkingBookRecipeGameTests {
|
||||||
|
private LinkingBookRecipeGameTests() {}
|
||||||
|
|
||||||
|
public static void legacyRecipeRequiresExactlyLeatherAndLinkPanel(GameTestHelper helper) {
|
||||||
|
LinkingBookRecipe recipe = new LinkingBookRecipe(CraftingBookCategory.MISC);
|
||||||
|
ItemStack panel = PageItem.createLinkPanelPage();
|
||||||
|
CraftingInput valid = CraftingInput.of(2, 1, List.of(panel, new ItemStack(Items.LEATHER)));
|
||||||
|
helper.assertTrue(recipe.matches(valid, helper.getLevel()), "Link Panel and leather must match");
|
||||||
|
helper.assertTrue(recipe.matches(CraftingInput.of(2, 1,
|
||||||
|
List.of(new ItemStack(Items.LEATHER), panel)), helper.getLevel()),
|
||||||
|
"The Legacy recipe must be shapeless");
|
||||||
|
helper.assertFalse(recipe.matches(CraftingInput.of(2, 1,
|
||||||
|
List.of(new ItemStack(Items.BOOK), new ItemStack(Items.ENDER_PEARL))), helper.getLevel()),
|
||||||
|
"The old placeholder recipe must no longer match");
|
||||||
|
helper.assertFalse(recipe.matches(CraftingInput.of(2, 1,
|
||||||
|
List.of(new ItemStack(ModItems.PAGE.get()), new ItemStack(Items.LEATHER))), helper.getLevel()),
|
||||||
|
"A blank Page must not substitute for a Link Panel");
|
||||||
|
helper.assertFalse(recipe.matches(CraftingInput.of(3, 1,
|
||||||
|
List.of(panel, new ItemStack(Items.LEATHER), new ItemStack(Items.PAPER))), helper.getLevel()),
|
||||||
|
"An extra ingredient must invalidate the recipe");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void outputIsUnwrittenAndRegisteredRecipeLoads(GameTestHelper helper) {
|
||||||
|
ItemStack panel = PageItem.createLinkPanelPage();
|
||||||
|
panel.set(DataComponents.CUSTOM_NAME, Component.literal("Home Panel"));
|
||||||
|
CraftingInput input = CraftingInput.of(2, 1, List.of(panel, new ItemStack(Items.LEATHER)));
|
||||||
|
var holder = helper.getLevel().getRecipeManager()
|
||||||
|
.getRecipeFor(RecipeType.CRAFTING, input, helper.getLevel()).orElseThrow();
|
||||||
|
helper.assertValueEqual(holder.id(), ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "linking_book"),
|
||||||
|
"The registered recipe must replace the old placeholder");
|
||||||
|
ItemStack result = holder.value().assemble(input, helper.getLevel().registryAccess());
|
||||||
|
helper.assertTrue(result.is(ModItems.LINKING_BOOK.get()), "Crafting must produce a Linking Book");
|
||||||
|
helper.assertValueEqual(result.getCount(), 1, "Crafting must produce one book");
|
||||||
|
helper.assertFalse(result.has(ModDataComponents.LINK_TARGET.get()), "The book must be unwritten/unbound");
|
||||||
|
helper.assertFalse(result.has(ModDataComponents.PAGE_CONTENT.get()), "Page identity must not leak onto the book");
|
||||||
|
helper.assertValueEqual(result.get(DataComponents.CUSTOM_NAME), Component.literal("Home Panel"),
|
||||||
|
"The panel's custom name must survive crafting");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ import net.neoforged.neoforge.gametest.GameTestHolder;
|
|||||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
/** Server-side regression coverage for persistent linking-book data and travel safety. */
|
/** Server-side regression coverage for persistent linking-book data and travel safety. */
|
||||||
@GameTestHolder(Rivencraft.MOD_ID)
|
@GameTestHolder("rivencraft_archive_linking")
|
||||||
@PrefixGameTestTemplate(false)
|
@PrefixGameTestTemplate(false)
|
||||||
public final class LinkingGameTests {
|
public final class LinkingGameTests {
|
||||||
private static final BlockPos ORIGIN = new BlockPos(0, 1, 0);
|
private static final BlockPos ORIGIN = new BlockPos(0, 1, 0);
|
||||||
@@ -42,7 +42,6 @@ public final class LinkingGameTests {
|
|||||||
private LinkingGameTests() {
|
private LinkingGameTests() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@GameTest(template = "empty")
|
|
||||||
public static void previewBackendSelectionCoversOptionalDependencyMatrix(GameTestHelper helper) {
|
public static void previewBackendSelectionCoversOptionalDependencyMatrix(GameTestHelper helper) {
|
||||||
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.AUTO, true, true,
|
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.AUTO, true, true,
|
||||||
PreviewBackendSelector.Backend.IMMERSIVE_PORTALS);
|
PreviewBackendSelector.Backend.IMMERSIVE_PORTALS);
|
||||||
@@ -127,7 +126,6 @@ public final class LinkingGameTests {
|
|||||||
helper.succeed();
|
helper.succeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GameTest(template = "empty")
|
|
||||||
public static void linkTargetSurvivesItemSerialization(GameTestHelper helper) {
|
public static void linkTargetSurvivesItemSerialization(GameTestHelper helper) {
|
||||||
LinkTarget expected = new LinkTarget(
|
LinkTarget expected = new LinkTarget(
|
||||||
GlobalPos.of(helper.getLevel().dimension(), helper.absolutePos(ORIGIN)),
|
GlobalPos.of(helper.getLevel().dimension(), helper.absolutePos(ORIGIN)),
|
||||||
@@ -181,7 +179,6 @@ public final class LinkingGameTests {
|
|||||||
helper.succeed();
|
helper.succeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GameTest(template = "empty")
|
|
||||||
public static void ageDefinitionIsCreatedOnceWithDeterministicData(GameTestHelper helper) {
|
public static void ageDefinitionIsCreatedOnceWithDeterministicData(GameTestHelper helper) {
|
||||||
UUID ageId = UUID.randomUUID();
|
UUID ageId = UUID.randomUUID();
|
||||||
String requestedName = "Test Age " + ageId;
|
String requestedName = "Test Age " + ageId;
|
||||||
@@ -666,7 +663,6 @@ public final class LinkingGameTests {
|
|||||||
helper.succeed();
|
helper.succeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GameTest(template = "empty")
|
|
||||||
public static void travelRejectsBlockedDestination(GameTestHelper helper) {
|
public static void travelRejectsBlockedDestination(GameTestHelper helper) {
|
||||||
BlockPos absoluteOrigin = helper.absolutePos(ORIGIN);
|
BlockPos absoluteOrigin = helper.absolutePos(ORIGIN);
|
||||||
for (BlockPos position : BlockPos.betweenClosed(
|
for (BlockPos position : BlockPos.betweenClosed(
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
package net.dnorberg.rivencraft.gametest;
|
package net.dnorberg.rivencraft.gametest;
|
||||||
|
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
import com.mojang.serialization.JsonOps;
|
import com.mojang.serialization.JsonOps;
|
||||||
import io.netty.buffer.Unpooled;
|
import io.netty.buffer.Unpooled;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import net.dnorberg.rivencraft.Rivencraft;
|
import java.util.UUID;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeReference;
|
||||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||||
import net.dnorberg.rivencraft.writing.PageContent;
|
import net.dnorberg.rivencraft.writing.PageContent;
|
||||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolDefinition;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||||
import net.dnorberg.rivencraft.writing.WritingEntry;
|
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||||
import net.dnorberg.rivencraft.item.PageItem;
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingInkService;
|
||||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
import net.dnorberg.rivencraft.registry.ModItems;
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
import net.dnorberg.rivencraft.menu.NotebookMenu;
|
import net.dnorberg.rivencraft.menu.NotebookMenu;
|
||||||
@@ -22,11 +28,12 @@ import net.minecraft.resources.ResourceLocation;
|
|||||||
import net.minecraft.world.item.ItemStack;
|
import net.minecraft.world.item.ItemStack;
|
||||||
import net.minecraft.world.item.Items;
|
import net.minecraft.world.item.Items;
|
||||||
import net.minecraft.world.InteractionHand;
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.inventory.ClickType;
|
||||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
/** Acceptance coverage for Milestone 4's persistent writing contracts. */
|
/** Acceptance coverage for Milestone 4's persistent writing contracts. */
|
||||||
@GameTestHolder(Rivencraft.MOD_ID)
|
@GameTestHolder("rivencraft_archive_writing")
|
||||||
@PrefixGameTestTemplate(false)
|
@PrefixGameTestTemplate(false)
|
||||||
public final class WritingDataGameTests {
|
public final class WritingDataGameTests {
|
||||||
private WritingDataGameTests() {
|
private WritingDataGameTests() {
|
||||||
@@ -43,6 +50,8 @@ public final class WritingDataGameTests {
|
|||||||
));
|
));
|
||||||
|
|
||||||
var json = OrderedWriting.CODEC.encodeStart(JsonOps.INSTANCE, original).result().orElseThrow();
|
var json = OrderedWriting.CODEC.encodeStart(JsonOps.INSTANCE, original).result().orElseThrow();
|
||||||
|
helper.assertValueEqual(json.getAsJsonObject().get("schema_version").getAsInt(),
|
||||||
|
OrderedWriting.CURRENT_SCHEMA_VERSION, "Writing must save an explicit schema version");
|
||||||
OrderedWriting persistentCopy = OrderedWriting.CODEC.parse(JsonOps.INSTANCE, json).result().orElseThrow();
|
OrderedWriting persistentCopy = OrderedWriting.CODEC.parse(JsonOps.INSTANCE, json).result().orElseThrow();
|
||||||
helper.assertValueEqual(persistentCopy, original,
|
helper.assertValueEqual(persistentCopy, original,
|
||||||
"Persistent writing codec must retain entry order and unknown symbol IDs");
|
"Persistent writing codec must retain entry order and unknown symbol IDs");
|
||||||
@@ -55,6 +64,27 @@ public final class WritingDataGameTests {
|
|||||||
helper.succeed();
|
helper.succeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void oldBareListWritingAndPageFreeBooksRemainReadable(GameTestHelper helper) {
|
||||||
|
OrderedWriting writing = new OrderedWriting(List.of(
|
||||||
|
WritingEntry.linkPanel(), WritingEntry.symbol(reference("example:removed_symbol"))));
|
||||||
|
var oldJson = WritingEntry.CODEC.listOf().encodeStart(JsonOps.INSTANCE, writing.entries())
|
||||||
|
.result().orElseThrow();
|
||||||
|
OrderedWriting migrated = OrderedWriting.CODEC.parse(JsonOps.INSTANCE, oldJson).result().orElseThrow();
|
||||||
|
helper.assertValueEqual(migrated, writing,
|
||||||
|
"0.3.7.2 bare-list writing must load into schema version 1 intact");
|
||||||
|
|
||||||
|
ItemStack oldPageFreeBook = new ItemStack(ModItems.DESCRIPTIVE_BOOK.get());
|
||||||
|
AgeReference originalAge = new AgeReference(UUID.randomUUID(), "Legacy Page-Free Age");
|
||||||
|
oldPageFreeBook.set(ModDataComponents.AGE_REFERENCE.get(), originalAge);
|
||||||
|
CompoundTag saved = (CompoundTag) oldPageFreeBook.saveOptional(helper.getLevel().registryAccess());
|
||||||
|
ItemStack restored = ItemStack.parseOptional(helper.getLevel().registryAccess(), saved);
|
||||||
|
helper.assertValueEqual(restored.get(ModDataComponents.AGE_REFERENCE.get()), originalAge,
|
||||||
|
"A page-free book must keep its existing Age identity");
|
||||||
|
helper.assertValueEqual(restored.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY),
|
||||||
|
OrderedWriting.EMPTY, "Older page-free books must not require writing migration");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
@GameTest(template = "empty")
|
@GameTest(template = "empty")
|
||||||
public static void serverCatalogResolvesBuiltinsAndKeepsUnknownReferencesReadable(GameTestHelper helper) {
|
public static void serverCatalogResolvesBuiltinsAndKeepsUnknownReferencesReadable(GameTestHelper helper) {
|
||||||
SymbolReference known = reference("rivencraft:terrain/standard");
|
SymbolReference known = reference("rivencraft:terrain/standard");
|
||||||
@@ -73,6 +103,62 @@ public final class WritingDataGameTests {
|
|||||||
helper.succeed();
|
helper.succeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void semanticSchemaIsServerOnlyBoundedAndV1Compatible(GameTestHelper helper) {
|
||||||
|
var builtIn = SymbolCatalog.find(reference("rivencraft:terrain/standard")).orElseThrow();
|
||||||
|
helper.assertValueEqual(builtIn.schemaVersion(), 2, "Built-in symbol must use semantic schema v2");
|
||||||
|
helper.assertValueEqual(builtIn.semantics().orElseThrow().role(), SymbolSemantics.Role.TERRAIN,
|
||||||
|
"Built-in terrain symbol must declare its grammar role");
|
||||||
|
|
||||||
|
String display = "\"name\":\"symbol.example.test\","
|
||||||
|
+ "\"description\":\"symbol.example.test.description\","
|
||||||
|
+ "\"category\":\"example:terrain\"";
|
||||||
|
SymbolDefinition legacy = SymbolDefinition.parse(JsonParser.parseString(
|
||||||
|
"{\"schema_version\":1," + display + "}"));
|
||||||
|
helper.assertTrue(legacy.semantics().isEmpty(), "Existing v1 definitions must remain display-only");
|
||||||
|
SymbolDefinition oldCategory = SymbolDefinition.parse(JsonParser.parseString(
|
||||||
|
"{\"schema_version\":1,\"name\":\"symbol.example.test\","
|
||||||
|
+ "\"description\":\"symbol.example.test.description\","
|
||||||
|
+ "\"category\":\"terrain\"}"));
|
||||||
|
helper.assertValueEqual(oldCategory.category().toString(), "minecraft:terrain",
|
||||||
|
"A v1 category without a namespace must retain the old ResourceLocation behavior");
|
||||||
|
helper.assertValueEqual(legacy.display(reference("example:test").id()).id(),
|
||||||
|
reference("example:test").id(), "Display metadata must preserve the symbol ID");
|
||||||
|
|
||||||
|
SymbolDefinition period = SymbolDefinition.parse(JsonParser.parseString(
|
||||||
|
"{\"schema_version\":2," + display
|
||||||
|
+ ",\"semantics\":{\"role\":\"period_modifier\",\"value\":0}}"));
|
||||||
|
helper.assertValueEqual(period.semantics().orElseThrow().role(), SymbolSemantics.Role.PERIOD_MODIFIER,
|
||||||
|
"Zero-length modifier must be accepted");
|
||||||
|
rejectDefinition(helper, "{\"schema_version\":2," + display
|
||||||
|
+ ",\"semantics\":{\"role\":\"unknown\",\"value\":\"normal\"}}", "role");
|
||||||
|
rejectDefinition(helper, "{\"schema_version\":2," + display
|
||||||
|
+ ",\"semantics\":{\"role\":\"weather\",\"value\":\"blizzard\"}}", "value");
|
||||||
|
rejectDefinition(helper, "{\"schema_version\":2," + display
|
||||||
|
+ ",\"semantics\":{\"role\":\"period_modifier\",\"value\":2.01}}", "value");
|
||||||
|
rejectDefinition(helper, "{\"schema_version\":2," + display
|
||||||
|
+ ",\"semantics\":{\"role\":\"terrain\",\"value\":\"minecraft:overworld\",\"extra\":1}}", "extra");
|
||||||
|
rejectDefinition(helper, "{\"schema_version\":1," + display
|
||||||
|
+ ",\"semantics\":{\"role\":\"weather\",\"value\":\"normal\"}}", "v1");
|
||||||
|
|
||||||
|
var clientJson = builtIn.display(reference("rivencraft:terrain/standard").id());
|
||||||
|
var encoded = net.dnorberg.rivencraft.writing.SymbolDisplayMetadata.CODEC
|
||||||
|
.encodeStart(JsonOps.INSTANCE, clientJson).result().orElseThrow();
|
||||||
|
helper.assertFalse(encoded.getAsJsonObject().has("semantics"),
|
||||||
|
"Client display payload must never contain generation semantics");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void rejectDefinition(GameTestHelper helper, String json, String diagnostic) {
|
||||||
|
boolean rejected = false;
|
||||||
|
try {
|
||||||
|
SymbolDefinition.parse(JsonParser.parseString(json));
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
rejected = exception.getMessage().contains(diagnostic);
|
||||||
|
}
|
||||||
|
helper.assertTrue(rejected, "Invalid symbol must be rejected with diagnostic containing '" + diagnostic + "'");
|
||||||
|
}
|
||||||
|
|
||||||
@GameTest(template = "empty")
|
@GameTest(template = "empty")
|
||||||
public static void pageStatesPersistAndNetworkRoundTrip(GameTestHelper helper) {
|
public static void pageStatesPersistAndNetworkRoundTrip(GameTestHelper helper) {
|
||||||
SymbolReference known = reference("rivencraft:terrain/standard");
|
SymbolReference known = reference("rivencraft:terrain/standard");
|
||||||
@@ -97,7 +183,6 @@ public final class WritingDataGameTests {
|
|||||||
helper.succeed();
|
helper.succeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GameTest(template = "empty")
|
|
||||||
public static void pageItemPreservesContentAndRejectsForgedUnknownSymbols(GameTestHelper helper) {
|
public static void pageItemPreservesContentAndRejectsForgedUnknownSymbols(GameTestHelper helper) {
|
||||||
SymbolReference known = reference("rivencraft:terrain/standard");
|
SymbolReference known = reference("rivencraft:terrain/standard");
|
||||||
SymbolReference unknown = reference("example:forged_symbol");
|
SymbolReference unknown = reference("example:forged_symbol");
|
||||||
@@ -113,6 +198,10 @@ public final class WritingDataGameTests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
ItemStack recovered = PageItem.createRecoveredPage(unknown);
|
ItemStack recovered = PageItem.createRecoveredPage(unknown);
|
||||||
|
CompoundTag recoveredTag = (CompoundTag) recovered.saveOptional(helper.getLevel().registryAccess());
|
||||||
|
ItemStack restoredRecovered = ItemStack.parseOptional(helper.getLevel().registryAccess(), recoveredTag);
|
||||||
|
helper.assertValueEqual(PageItem.content(restoredRecovered).symbol().orElseThrow(), unknown,
|
||||||
|
"A missing symbol must survive a full Page item reload");
|
||||||
helper.assertFalse(PageItem.isUsableWriting(recovered),
|
helper.assertFalse(PageItem.isUsableWriting(recovered),
|
||||||
"An unresolved page must remain recoverable without becoming valid writing");
|
"An unresolved page must remain recoverable without becoming valid writing");
|
||||||
try {
|
try {
|
||||||
@@ -123,7 +212,6 @@ public final class WritingDataGameTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GameTest(template = "empty")
|
|
||||||
public static void notebookContentsPreserveOrderGapsNamesAndUnknownPages(GameTestHelper helper) {
|
public static void notebookContentsPreserveOrderGapsNamesAndUnknownPages(GameTestHelper helper) {
|
||||||
ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
|
ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
|
||||||
first.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME,
|
first.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME,
|
||||||
@@ -150,32 +238,168 @@ public final class WritingDataGameTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GameTest(template = "empty")
|
@GameTest(template = "empty")
|
||||||
public static void notebookMenuValidatesInsertionAndPersistsReorderAndRename(GameTestHelper helper) {
|
public static void notebookMenuValidatesInsertionAndPersistsReorder(GameTestHelper helper) {
|
||||||
var player = helper.makeMockServerPlayerInLevel();
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
|
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
|
||||||
player.setItemInHand(InteractionHand.MAIN_HAND, notebook);
|
player.setItemInHand(InteractionHand.MAIN_HAND, notebook);
|
||||||
NotebookMenu menu = new NotebookMenu(7, player.getInventory(), InteractionHand.MAIN_HAND);
|
NotebookMenu menu = new NotebookMenu(7, player.getInventory(), InteractionHand.MAIN_HAND);
|
||||||
ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
|
ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
|
||||||
|
first.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME,
|
||||||
|
net.minecraft.network.chat.Component.literal("Terrain Base"));
|
||||||
ItemStack second = PageItem.createRecoveredPage(reference("example:removed_symbol"));
|
ItemStack second = PageItem.createRecoveredPage(reference("example:removed_symbol"));
|
||||||
|
|
||||||
helper.assertFalse(menu.getSlot(0).mayPlace(new ItemStack(Items.DIRT)),
|
helper.assertFalse(menu.getSlot(0).mayPlace(new ItemStack(Items.DIRT)),
|
||||||
"Notebook slots must reject non-Page items");
|
"Notebook slots must reject non-Page items");
|
||||||
menu.getSlot(0).set(first);
|
menu.getSlot(0).set(first);
|
||||||
menu.getSlot(1).set(second);
|
menu.getSlot(1).set(second);
|
||||||
menu.renamePage(0, "Terrain Base");
|
|
||||||
helper.assertTrue(menu.clickMenuButton(player, NotebookMenu.MOVE_NEXT_BASE),
|
helper.assertTrue(menu.clickMenuButton(player, NotebookMenu.MOVE_NEXT_BASE),
|
||||||
"The server menu must accept a valid adjacent reorder");
|
"The server menu must accept a valid adjacent reorder");
|
||||||
|
|
||||||
NotebookContents saved = notebook.get(ModDataComponents.NOTEBOOK_CONTENTS.get());
|
NotebookContents saved = notebook.get(ModDataComponents.NOTEBOOK_CONTENTS.get());
|
||||||
helper.assertTrue(saved != null, "Menu changes must persist immediately to the Notebook item");
|
helper.assertTrue(saved != null, "Menu changes must persist immediately to the Notebook item");
|
||||||
helper.assertValueEqual(saved.pages().get(1).getHoverName().getString(), "Terrain Base",
|
helper.assertValueEqual(saved.pages().get(1).getHoverName().getString(), "Terrain Base",
|
||||||
"Reordering must preserve the renamed Page stack");
|
"Reordering must preserve existing Page item data");
|
||||||
helper.assertValueEqual(PageItem.content(saved.pages().get(0)).kind(),
|
helper.assertValueEqual(PageItem.content(saved.pages().get(0)).kind(),
|
||||||
PageContent.Kind.UNRESOLVED_SYMBOL,
|
PageContent.Kind.UNRESOLVED_SYMBOL,
|
||||||
"Reordering must preserve unresolved pages");
|
"Reordering must preserve unresolved pages");
|
||||||
helper.succeed();
|
helper.succeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void notebookMenuSurvivesInventoryStackReplacementAndClicks(GameTestHelper helper) {
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(ModItems.NOTEBOOK.get()));
|
||||||
|
NotebookMenu menu = new NotebookMenu(8, player.getInventory(), InteractionHand.MAIN_HAND);
|
||||||
|
|
||||||
|
// Slot synchronization may replace the ItemStack object while the Notebook remains held.
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, player.getMainHandItem().copy());
|
||||||
|
helper.assertTrue(menu.stillValid(player),
|
||||||
|
"Replacing the held stack object must not close an open Notebook menu");
|
||||||
|
menu.getSlot(0).set(PageItem.createSymbolPage(reference("rivencraft:terrain/standard")));
|
||||||
|
helper.assertTrue(player.getMainHandItem().has(ModDataComponents.NOTEBOOK_CONTENTS.get()),
|
||||||
|
"Page edits must persist to the current held Notebook stack");
|
||||||
|
|
||||||
|
player.getInventory().setItem(9, new ItemStack(Items.DIRT));
|
||||||
|
menu.clicked(NotebookMenu.NOTEBOOK_SLOTS, 0, ClickType.PICKUP, player);
|
||||||
|
helper.assertTrue(menu.getCarried().is(Items.DIRT),
|
||||||
|
"Clicking a player inventory item must pick it up without closing the Notebook");
|
||||||
|
helper.assertTrue(menu.stillValid(player),
|
||||||
|
"Normal inventory clicks must leave the Notebook menu valid");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void notebookMenuPreservesSparsePagePositions(GameTestHelper helper) {
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(ModItems.NOTEBOOK.get()));
|
||||||
|
NotebookMenu menu = new NotebookMenu(9, player.getInventory(), InteractionHand.MAIN_HAND);
|
||||||
|
ItemStack page = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
|
||||||
|
menu.setCarried(page);
|
||||||
|
|
||||||
|
menu.clicked(9, 0, ClickType.PICKUP, player);
|
||||||
|
helper.assertTrue(menu.getCarried().isEmpty(),
|
||||||
|
"Clicking a distant empty Notebook slot must place the carried Page");
|
||||||
|
NotebookContents saved = player.getMainHandItem().get(ModDataComponents.NOTEBOOK_CONTENTS.get());
|
||||||
|
helper.assertTrue(saved != null && saved.pages().size() > 9,
|
||||||
|
"Notebook persistence must extend through the highest occupied position");
|
||||||
|
for (int index = 0; index < 9; index++) {
|
||||||
|
helper.assertTrue(saved.pages().get(index).isEmpty(),
|
||||||
|
"Earlier empty page positions must remain empty rather than being packed together");
|
||||||
|
}
|
||||||
|
helper.assertValueEqual(PageItem.content(saved.pages().get(9)).kind(), PageContent.Kind.SYMBOL,
|
||||||
|
"The Page must remain in the selected distant position");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void inkVialsPersistAndOnlyStackWithMatchingContents(GameTestHelper helper) {
|
||||||
|
ItemStack vial = new ItemStack(ModItems.INK_VIAL.get());
|
||||||
|
vial.set(ModDataComponents.INK_REMAINING.get(), 7);
|
||||||
|
CompoundTag serialized = (CompoundTag) vial.saveOptional(helper.getLevel().registryAccess());
|
||||||
|
ItemStack restored = ItemStack.parseOptional(helper.getLevel().registryAccess(), serialized);
|
||||||
|
helper.assertValueEqual(InkVialItem.remaining(restored), 7,
|
||||||
|
"Remaining ink must survive ItemStack persistence");
|
||||||
|
helper.assertTrue(ItemStack.isSameItemSameComponents(vial, restored),
|
||||||
|
"Vials with equal ink contents must be eligible to stack");
|
||||||
|
helper.assertFalse(ItemStack.isSameItemSameComponents(vial, new ItemStack(ModItems.INK_VIAL.get())),
|
||||||
|
"Vials with different ink contents must not stack");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void linkPanelPreparationConsumesOnePageAndConfiguredInkOnlyOnSuccess(GameTestHelper helper) {
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
ItemStack pages = new ItemStack(Items.PAPER, 2);
|
||||||
|
ItemStack vial = new ItemStack(ModItems.INK_VIAL.get());
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, vial);
|
||||||
|
player.setItemInHand(InteractionHand.OFF_HAND, pages);
|
||||||
|
|
||||||
|
helper.assertTrue(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
|
||||||
|
"Paper and sufficient ink must prepare one link panel");
|
||||||
|
helper.assertValueEqual(pages.getCount(), 1, "Preparation must consume exactly one paper");
|
||||||
|
helper.assertValueEqual(InkVialItem.remaining(player.getMainHandItem()),
|
||||||
|
InkVialItem.CAPACITY - WritingInkService.linkPanelCost(),
|
||||||
|
"Preparation must consume exactly the server-configured ink cost");
|
||||||
|
long panels = player.getInventory().items.stream()
|
||||||
|
.filter(stack -> stack.is(ModItems.PAGE.get())
|
||||||
|
&& PageItem.content(stack).kind() == PageContent.Kind.LINK_PANEL)
|
||||||
|
.mapToInt(ItemStack::getCount).sum();
|
||||||
|
helper.assertValueEqual(panels, 1L, "Preparation must output exactly one link panel");
|
||||||
|
|
||||||
|
ItemStack written = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
|
||||||
|
player.setItemInHand(InteractionHand.OFF_HAND, written);
|
||||||
|
int inkBefore = InkVialItem.remaining(player.getMainHandItem());
|
||||||
|
helper.assertFalse(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
|
||||||
|
"A written Page must not be overwritten as a link panel");
|
||||||
|
helper.assertValueEqual(InkVialItem.remaining(player.getMainHandItem()), inkBefore,
|
||||||
|
"Rejected writing must not consume ink");
|
||||||
|
helper.assertValueEqual(PageItem.content(written).kind(), PageContent.Kind.SYMBOL,
|
||||||
|
"Rejected writing must not modify the input Page");
|
||||||
|
|
||||||
|
ItemStack lowInk = new ItemStack(ModItems.INK_VIAL.get());
|
||||||
|
lowInk.set(ModDataComponents.INK_REMAINING.get(), WritingInkService.linkPanelCost() - 1);
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, lowInk);
|
||||||
|
player.setItemInHand(InteractionHand.OFF_HAND, new ItemStack(Items.PAPER));
|
||||||
|
helper.assertFalse(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
|
||||||
|
"Insufficient ink must reject link-panel preparation");
|
||||||
|
helper.assertValueEqual(InkVialItem.remaining(player.getMainHandItem()),
|
||||||
|
WritingInkService.linkPanelCost() - 1,
|
||||||
|
"Insufficient ink must remain untouched");
|
||||||
|
helper.assertTrue(player.getOffhandItem().is(Items.PAPER),
|
||||||
|
"Insufficient ink must leave paper untouched");
|
||||||
|
|
||||||
|
lowInk.set(ModDataComponents.INK_REMAINING.get(), WritingInkService.linkPanelCost());
|
||||||
|
helper.assertTrue(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
|
||||||
|
"Exact remaining ink must be usable");
|
||||||
|
helper.assertTrue(player.getMainHandItem().is(Items.GLASS_BOTTLE),
|
||||||
|
"A depleted Ink Vial must return a reusable glass bottle");
|
||||||
|
helper.assertValueEqual(PageItem.content(player.getOffhandItem()).kind(),
|
||||||
|
PageContent.Kind.LINK_PANEL,
|
||||||
|
"A single paper must be replaced by one Link Panel Page");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void stackedInkVialsSplitWithoutChangingTheOtherVials(GameTestHelper helper) {
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(ModItems.INK_VIAL.get(), 2));
|
||||||
|
player.setItemInHand(InteractionHand.OFF_HAND, new ItemStack(Items.PAPER));
|
||||||
|
|
||||||
|
helper.assertTrue(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
|
||||||
|
"A stack of full vials must support preparing one panel");
|
||||||
|
helper.assertValueEqual(player.getMainHandItem().getCount(), 1,
|
||||||
|
"Using one vial must leave the other full vial in hand");
|
||||||
|
helper.assertValueEqual(InkVialItem.remaining(player.getMainHandItem()), InkVialItem.CAPACITY,
|
||||||
|
"The unconsumed vial must remain full");
|
||||||
|
long partialVials = player.getInventory().items.stream()
|
||||||
|
.filter(stack -> stack.is(ModItems.INK_VIAL.get())
|
||||||
|
&& InkVialItem.remaining(stack) == InkVialItem.CAPACITY - WritingInkService.linkPanelCost())
|
||||||
|
.mapToInt(ItemStack::getCount).sum();
|
||||||
|
helper.assertValueEqual(partialVials, 1L,
|
||||||
|
"The used vial must reappear with only its own ink reduced");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
private static SymbolReference reference(String id) {
|
private static SymbolReference reference(String id) {
|
||||||
return new SymbolReference(ResourceLocation.parse(id));
|
return new SymbolReference(ResourceLocation.parse(id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,646 @@
|
|||||||
|
package net.dnorberg.rivencraft.gametest;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.block.WritingDeskBlock;
|
||||||
|
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.world.entity.item.ItemEntity;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.inventory.ClickType;
|
||||||
|
import net.minecraft.world.level.block.Blocks;
|
||||||
|
import net.minecraft.world.phys.AABB;
|
||||||
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
|
@GameTestHolder("rivencraft_archive_desk")
|
||||||
|
@PrefixGameTestTemplate(false)
|
||||||
|
public final class WritingDeskGameTests {
|
||||||
|
private WritingDeskGameTests() {}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void inventoryAndActiveCollectionPersist(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
var original = new WritingDeskBlockEntity(pos, ModBlocks.WRITING_DESK.get().defaultBlockState());
|
||||||
|
original.setItem(0, new ItemStack(ModItems.NOTEBOOK.get()));
|
||||||
|
original.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
original.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 12));
|
||||||
|
original.setActiveCollection(2);
|
||||||
|
var saved = original.saveWithoutMetadata(helper.getLevel().registryAccess());
|
||||||
|
var restored = new WritingDeskBlockEntity(pos, ModBlocks.WRITING_DESK.get().defaultBlockState());
|
||||||
|
restored.loadWithComponents(saved, helper.getLevel().registryAccess());
|
||||||
|
helper.assertTrue(restored.getItem(0).is(ModItems.NOTEBOOK.get()), "Notebook lost on save/load");
|
||||||
|
helper.assertTrue(restored.getItem(WritingDeskBlockEntity.INK).is(ModItems.INK_VIAL.get()), "Ink lost on save/load");
|
||||||
|
helper.assertValueEqual(restored.getItem(WritingDeskBlockEntity.PAPER).getCount(), 12, "Pages lost on save/load");
|
||||||
|
helper.assertValueEqual(restored.activeCollection(), 2, "Active collection lost on save/load");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void breakingDeskDropsItsStoredItemsOnce(GameTestHelper helper) {
|
||||||
|
BlockPos relative = new BlockPos(1, 1, 1);
|
||||||
|
BlockPos pos = helper.absolutePos(relative);
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
desk.setItem(0, new ItemStack(ModItems.NOTEBOOK.get()));
|
||||||
|
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 12));
|
||||||
|
helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
|
||||||
|
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(pos).inflate(2));
|
||||||
|
int notebooks = drops.stream().filter(e -> e.getItem().is(ModItems.NOTEBOOK.get())).mapToInt(e -> e.getItem().getCount()).sum();
|
||||||
|
int bottles = drops.stream().filter(e -> e.getItem().is(Items.GLASS_BOTTLE)).mapToInt(e -> e.getItem().getCount()).sum();
|
||||||
|
int pages = drops.stream().filter(e -> e.getItem().is(Items.PAPER)).mapToInt(e -> e.getItem().getCount()).sum();
|
||||||
|
helper.assertValueEqual(notebooks, 1, "Notebook must drop exactly once");
|
||||||
|
helper.assertValueEqual(bottles, 1, "Consumed vial must return one glass bottle on break");
|
||||||
|
helper.assertValueEqual(pages, 12, "Pages must drop without loss");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void menuTransfersWithoutDuplicationAndClosesWhenReplaced(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||||
|
player.getInventory().setItem(9, new ItemStack(Items.PAPER, 12));
|
||||||
|
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
|
||||||
|
helper.assertTrue(menu.stillValid(player), "Placed desk must keep its menu valid");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, 3), "Active collection control must accept a valid tab");
|
||||||
|
helper.assertValueEqual(desk.activeCollection(), 3, "Selected tab must be stored on the desk");
|
||||||
|
menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT + 1);
|
||||||
|
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 12, "Shift-click must deposit all pages");
|
||||||
|
helper.assertTrue(player.getInventory().getItem(9).isEmpty(), "Shift-click must consume player source stack");
|
||||||
|
menu.quickMoveStack(player, WritingDeskBlockEntity.PAPER);
|
||||||
|
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(), "Shift-click must empty desk source slot");
|
||||||
|
int total = player.getInventory().items.stream().filter(stack -> stack.is(Items.PAPER))
|
||||||
|
.mapToInt(ItemStack::getCount).sum();
|
||||||
|
helper.assertValueEqual(total, 12, "Round-trip must not lose or duplicate pages");
|
||||||
|
helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
|
||||||
|
helper.assertFalse(menu.stillValid(player), "Removed desk must invalidate menu");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void takingCopySpendsPaperAndOneInkThenReturnsGlassBottle(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
ItemStack source = PageItem.createLinkPanelPage();
|
||||||
|
desk.setItem(0, source.copy());
|
||||||
|
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
|
||||||
|
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||||
|
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
|
||||||
|
menu.broadcastChanges();
|
||||||
|
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().is(ModItems.PAGE.get()),
|
||||||
|
"A source page with paper and ink must preview one copy");
|
||||||
|
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 2,
|
||||||
|
"Previewing must not consume paper");
|
||||||
|
helper.assertValueEqual(desk.inkRemaining(), 16, "Inserting a vial must fill the empty bar before copying");
|
||||||
|
menu.clicked(WritingDeskMenu.COPY_RESULT, 0, ClickType.PICKUP, player);
|
||||||
|
helper.assertTrue(menu.getCarried().is(ModItems.PAGE.get()), "Taking result must give the copied page");
|
||||||
|
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
|
||||||
|
"Taking a copy must consume one paper");
|
||||||
|
helper.assertValueEqual(desk.inkRemaining(), 15, "First copy must consume one ink");
|
||||||
|
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.INK).isEmpty(), "Refill must consume one vial");
|
||||||
|
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.RETURN).is(Items.GLASS_BOTTLE),
|
||||||
|
"Refill must return a vanilla glass bottle");
|
||||||
|
helper.assertTrue(desk.getItem(0).is(ModItems.PAGE.get()), "Source page must remain untouched");
|
||||||
|
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).is(ModItems.PAGE.get()),
|
||||||
|
"A second copy must remain available from the same ink fill");
|
||||||
|
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(),
|
||||||
|
"Two copies must consume exactly two paper");
|
||||||
|
helper.assertValueEqual(desk.inkRemaining(), 14, "Two copies must spend exactly two of sixteen ink");
|
||||||
|
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.RETURN).getCount(), 1,
|
||||||
|
"A second copy must not consume another vial");
|
||||||
|
var saved = desk.saveWithoutMetadata(helper.getLevel().registryAccess());
|
||||||
|
var restored = new WritingDeskBlockEntity(pos, ModBlocks.WRITING_DESK.get().defaultBlockState());
|
||||||
|
restored.loadWithComponents(saved, helper.getLevel().registryAccess());
|
||||||
|
helper.assertValueEqual(restored.inkRemaining(), 14, "Ink meter must persist across saves");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void emptyDeskInkBarRefillsWithoutCopying(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get(), 2));
|
||||||
|
helper.assertValueEqual(desk.inkRemaining(), 16, "Inserting a vial must refill immediately");
|
||||||
|
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.INK).getCount(), 1,
|
||||||
|
"Refill must consume exactly one vial");
|
||||||
|
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.RETURN).is(Items.GLASS_BOTTLE),
|
||||||
|
"Refill must put a vanilla glass bottle in the return slot");
|
||||||
|
helper.runAfterDelay(2, () -> {
|
||||||
|
helper.assertValueEqual(desk.inkRemaining(), 16, "Full bar must not refill again");
|
||||||
|
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.INK).getCount(), 1,
|
||||||
|
"Full bar must leave the second vial untouched");
|
||||||
|
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.RETURN).getCount(), 1,
|
||||||
|
"Full bar must not create another bottle");
|
||||||
|
helper.succeed();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void emptyDeskInkBarWaitsForBottleSpace(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
desk.setItem(WritingDeskBlockEntity.RETURN, new ItemStack(Items.GLASS_BOTTLE, 64));
|
||||||
|
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
helper.assertValueEqual(desk.inkRemaining(), 0, "Full bottle slot must block automatic refill");
|
||||||
|
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.INK).is(ModItems.INK_VIAL.get()),
|
||||||
|
"Blocked refill must preserve the vial");
|
||||||
|
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.RETURN).getCount(), 64,
|
||||||
|
"Blocked refill must preserve the bottle stack");
|
||||||
|
desk.removeItem(WritingDeskBlockEntity.RETURN, 1);
|
||||||
|
helper.assertValueEqual(desk.inkRemaining(), 16, "Removing a bottle must immediately permit refill");
|
||||||
|
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.INK).isEmpty(),
|
||||||
|
"Refill must consume the waiting vial");
|
||||||
|
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.RETURN).getCount(), 64,
|
||||||
|
"Refill must return one glass bottle to the available space");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void shiftClickCopiesOnlyWhenResourcesAndBottleSpaceExist(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
desk.setItem(0, PageItem.createLinkPanelPage());
|
||||||
|
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
|
||||||
|
desk.setItem(WritingDeskBlockEntity.RETURN, new ItemStack(Items.GLASS_BOTTLE, 64));
|
||||||
|
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||||
|
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
|
||||||
|
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty(),
|
||||||
|
"Full bottle return must block refill without consuming supplies");
|
||||||
|
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 2,
|
||||||
|
"Blocked copy must leave paper untouched");
|
||||||
|
desk.removeItem(WritingDeskBlockEntity.RETURN, 1);
|
||||||
|
ItemStack copied = menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT);
|
||||||
|
helper.assertTrue(copied.is(ModItems.PAGE.get()), "Shift-click must transfer one copy");
|
||||||
|
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
|
||||||
|
"Shift-click must consume one paper");
|
||||||
|
helper.assertValueEqual(desk.inkRemaining(), 15, "Shift-click must consume one ink");
|
||||||
|
int copies = player.getInventory().items.stream().filter(stack -> stack.is(ModItems.PAGE.get()))
|
||||||
|
.mapToInt(ItemStack::getCount).sum();
|
||||||
|
helper.assertValueEqual(copies, 1, "Shift-click must give exactly one page");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void selectedNotebookPageControlsCopyPreview(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
|
||||||
|
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(),
|
||||||
|
new NotebookContents(List.of(ItemStack.EMPTY, PageItem.createLinkPanelPage())));
|
||||||
|
desk.setItem(0, notebook);
|
||||||
|
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
|
||||||
|
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||||
|
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
|
||||||
|
menu.broadcastChanges();
|
||||||
|
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().isEmpty(),
|
||||||
|
"An empty selected position must not create a copy");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.PAGE_BUTTON_BASE + 1), "Second page selection must be accepted");
|
||||||
|
menu.broadcastChanges();
|
||||||
|
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().is(ModItems.PAGE.get()),
|
||||||
|
"Selecting the populated page must show a copy");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void twoViewersCannotTakeTheSameLastSheet(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
desk.setItem(0, PageItem.createLinkPanelPage());
|
||||||
|
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
|
||||||
|
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
var first = helper.makeMockServerPlayerInLevel();
|
||||||
|
var second = helper.makeMockServerPlayerInLevel();
|
||||||
|
first.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||||
|
second.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||||
|
WritingDeskMenu firstMenu = new WritingDeskMenu(1, first.getInventory(), desk);
|
||||||
|
WritingDeskMenu secondMenu = new WritingDeskMenu(2, second.getInventory(), desk);
|
||||||
|
firstMenu.broadcastChanges();
|
||||||
|
secondMenu.broadcastChanges();
|
||||||
|
helper.assertTrue(firstMenu.quickMoveStack(first, WritingDeskMenu.COPY_RESULT).is(ModItems.PAGE.get()),
|
||||||
|
"First viewer should take the last available copy");
|
||||||
|
helper.assertTrue(secondMenu.quickMoveStack(second, WritingDeskMenu.COPY_RESULT).isEmpty(),
|
||||||
|
"Second viewer must not copy the already-consumed paper");
|
||||||
|
helper.assertTrue(firstMenu.quickMoveStack(first, WritingDeskMenu.COPY_RESULT).isEmpty(),
|
||||||
|
"Replaying the first viewer's output request must not make another page");
|
||||||
|
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(),
|
||||||
|
"Shared desk must spend paper only once");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void forgedDeskSelectionsAndStaleMenuCannotCopy(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
desk.setItem(0, PageItem.createLinkPanelPage());
|
||||||
|
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
|
||||||
|
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||||
|
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.CATALOG_BUTTON_BASE + 999),
|
||||||
|
"A fabricated catalog index must be rejected");
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.ALL_PAGE_BUTTON_BASE + 27),
|
||||||
|
"A fabricated page address must be rejected");
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 0,
|
||||||
|
WritingDeskBlockEntity.PAPER)), "A page cannot be extracted into a material slot");
|
||||||
|
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
|
||||||
|
"Forged actions must not spend paper");
|
||||||
|
helper.assertTrue(desk.getItem(0).is(ModItems.PAGE.get()), "Forged actions must not remove the source");
|
||||||
|
helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, 0), "A stale menu must reject selection packets");
|
||||||
|
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty(),
|
||||||
|
"A stale menu must reject output packets");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void catalogAggregatesKnownSymbolsAcrossCollections(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
ResourceLocation known = ResourceLocation.parse("rivencraft:terrain/standard");
|
||||||
|
ItemStack page = PageItem.createSymbolPage(new SymbolReference(known));
|
||||||
|
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
|
||||||
|
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(page.copy())));
|
||||||
|
desk.setItem(0, page);
|
||||||
|
desk.setItem(1, notebook);
|
||||||
|
helper.assertValueEqual(WritingDeskCatalog.available(desk).size(), 1,
|
||||||
|
"Duplicate symbols across loose pages and notebooks must appear once");
|
||||||
|
helper.assertValueEqual(WritingDeskCatalog.available(desk).getFirst().id(), known,
|
||||||
|
"Catalog must retain the authoritative symbol ID");
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||||
|
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.CATALOG_BUTTON_BASE + 1), "Out-of-range catalog index must be rejected");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.CATALOG_BUTTON_BASE), "Known catalog symbol must be selectable");
|
||||||
|
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
|
||||||
|
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
menu.broadcastChanges();
|
||||||
|
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().is(ModItems.PAGE.get()),
|
||||||
|
"Catalog selection must preview a copy of the known symbol");
|
||||||
|
desk.setItem(0, ItemStack.EMPTY);
|
||||||
|
desk.setItem(1, ItemStack.EMPTY);
|
||||||
|
menu.broadcastChanges();
|
||||||
|
helper.assertTrue(menu.catalog().isEmpty(), "Removed sources must invalidate catalog for the viewer");
|
||||||
|
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().isEmpty(),
|
||||||
|
"Removed sources must invalidate the copy output");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void fullInventoryCannotConsumeCopyMaterials(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
desk.setItem(0, PageItem.createLinkPanelPage());
|
||||||
|
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
|
||||||
|
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||||
|
for (int i = 0; i < 36; i++) player.getInventory().setItem(i, new ItemStack(Items.COBBLESTONE, 64));
|
||||||
|
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
|
||||||
|
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty(),
|
||||||
|
"Full inventory must reject shift-click copy");
|
||||||
|
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
|
||||||
|
"Rejected copy must preserve paper");
|
||||||
|
helper.assertValueEqual(desk.inkRemaining(), 16, "Rejected copy must preserve the filled ink meter");
|
||||||
|
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.INK).isEmpty(),
|
||||||
|
"The vial should already have refilled the empty meter");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void twoBlockDeskSharesOneInventoryAndBreaksTogether(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
var block = ModBlocks.WRITING_DESK.get();
|
||||||
|
var state = block.defaultBlockState().setValue(WritingDeskBlock.FACING, Direction.NORTH);
|
||||||
|
helper.getLevel().setBlock(pos, state, 3);
|
||||||
|
block.setPlacedBy(helper.getLevel(), pos, state, null, new ItemStack(block));
|
||||||
|
BlockPos extension = pos.relative(Direction.EAST);
|
||||||
|
helper.assertTrue(helper.getLevel().getBlockState(extension).is(block),
|
||||||
|
"Placing a desk must reserve the adjacent second block");
|
||||||
|
helper.assertTrue(helper.getLevel().getBlockState(extension).getValue(WritingDeskBlock.EXTENSION),
|
||||||
|
"Adjacent half must be marked as an extension");
|
||||||
|
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
helper.assertTrue(helper.getLevel().getBlockEntity(extension) == null,
|
||||||
|
"Extension must not duplicate the inventory");
|
||||||
|
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 7));
|
||||||
|
helper.getLevel().destroyBlock(extension, true);
|
||||||
|
helper.assertTrue(helper.getLevel().getBlockState(pos).isAir(),
|
||||||
|
"Breaking the extension must remove the controller");
|
||||||
|
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(pos).inflate(3));
|
||||||
|
int desks = drops.stream().filter(e -> e.getItem().is(block.asItem()))
|
||||||
|
.mapToInt(e -> e.getItem().getCount()).sum();
|
||||||
|
int paper = drops.stream().filter(e -> e.getItem().is(Items.PAPER))
|
||||||
|
.mapToInt(e -> e.getItem().getCount()).sum();
|
||||||
|
helper.assertValueEqual(desks, 1, "Breaking either half must drop one desk");
|
||||||
|
helper.assertValueEqual(paper, 7, "Breaking either half must drop stored paper once");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void breakingControllerRemovesExtensionWithoutDuplicateDesk(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
var block = ModBlocks.WRITING_DESK.get();
|
||||||
|
var state = block.defaultBlockState().setValue(WritingDeskBlock.FACING, Direction.NORTH);
|
||||||
|
helper.getLevel().setBlock(pos, state, 3);
|
||||||
|
block.setPlacedBy(helper.getLevel(), pos, state, null, new ItemStack(block));
|
||||||
|
BlockPos extension = pos.relative(Direction.EAST);
|
||||||
|
helper.getLevel().destroyBlock(pos, true);
|
||||||
|
helper.assertTrue(helper.getLevel().getBlockState(extension).isAir(),
|
||||||
|
"Breaking the controller must remove the extension");
|
||||||
|
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(pos).inflate(3));
|
||||||
|
int desks = drops.stream().filter(e -> e.getItem().is(block.asItem()))
|
||||||
|
.mapToInt(e -> e.getItem().getCount()).sum();
|
||||||
|
helper.assertValueEqual(desks, 1, "Controller break must drop exactly one desk");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void openDeskDoesNotOccludeTerrain(GameTestHelper helper) {
|
||||||
|
helper.assertFalse(ModBlocks.WRITING_DESK.get().defaultBlockState().canOcclude(),
|
||||||
|
"Open shelves must not cull terrain behind the desk");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void allPagesSelectionUsesTheRequestedPhysicalTab(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
ItemStack first = PageItem.createLinkPanelPage();
|
||||||
|
ItemStack second = PageItem.createSymbolPage(new SymbolReference(
|
||||||
|
ResourceLocation.parse("rivencraft:terrain/standard")));
|
||||||
|
desk.setItem(0, first);
|
||||||
|
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
|
||||||
|
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(),
|
||||||
|
new NotebookContents(List.of(ItemStack.EMPTY, second)));
|
||||||
|
desk.setItem(1, notebook);
|
||||||
|
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
|
||||||
|
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||||
|
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.ALL_PAGE_BUTTON_BASE + 2 * NotebookContents.MAX_PAGES),
|
||||||
|
"ALL view must reject empty cross-tab positions");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.ALL_PAGE_BUTTON_BASE + NotebookContents.MAX_PAGES + 1),
|
||||||
|
"ALL view must accept the populated second tab page");
|
||||||
|
helper.assertTrue(ItemStack.matches(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem(), second),
|
||||||
|
"Copy preview must match the physical page selected from the other tab");
|
||||||
|
helper.assertValueEqual(desk.activeCollection(), 0,
|
||||||
|
"Selecting from ALL must not silently change the active tab");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void tabGroupsCycleByFourAndProtectHiddenSlots(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
var desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||||
|
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
|
||||||
|
var otherViewer = new WritingDeskMenu(2, player.getInventory(), desk);
|
||||||
|
menu.clickMenuButton(player, WritingDeskMenu.PREVIOUS_TABS);
|
||||||
|
helper.assertValueEqual(menu.firstVisibleTab(), 0, "Previous must stop at tabs 1-4");
|
||||||
|
menu.clickMenuButton(player, WritingDeskMenu.NEXT_TABS);
|
||||||
|
helper.assertValueEqual(menu.firstVisibleTab(), 4, "Next must show tabs 5-8");
|
||||||
|
helper.assertValueEqual(otherViewer.firstVisibleTab(), 0, "Cycling must not move another viewer's slots");
|
||||||
|
helper.assertFalse(menu.getSlot(0).isActive(), "Earlier tab slots must be hidden");
|
||||||
|
helper.assertTrue(menu.getSlot(4).isActive(), "New group's slots must be interactive");
|
||||||
|
desk.setItem(0, new ItemStack(ModItems.NOTEBOOK.get()));
|
||||||
|
menu.clicked(0, 0, ClickType.PICKUP, player);
|
||||||
|
helper.assertTrue(menu.getCarried().isEmpty(), "Hidden slot clicks must not take items");
|
||||||
|
helper.assertTrue(menu.quickMoveStack(player, 0).isEmpty(), "Hidden shift-click must be rejected");
|
||||||
|
player.getInventory().setItem(9, new ItemStack(ModItems.NOTEBOOK.get()));
|
||||||
|
menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT + 1);
|
||||||
|
helper.assertTrue(desk.getItem(4).is(ModItems.NOTEBOOK.get()), "Shift-click must use the visible group");
|
||||||
|
for (int i = 0; i < 30; i++) menu.clickMenuButton(player, WritingDeskMenu.NEXT_TABS);
|
||||||
|
helper.assertValueEqual(menu.firstVisibleTab(), 84, "Last group must be tabs 85-88");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, 87), "Tab 88 must be selectable without page-ID collisions");
|
||||||
|
helper.assertValueEqual(desk.activeCollection(), 87, "Tab 88 must become active");
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, 88), "Tab 89 must not exist");
|
||||||
|
desk.setItem(87, PageItem.createLinkPanelPage());
|
||||||
|
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
|
||||||
|
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.ALL_PAGE_BUTTON_BASE + 87 * NotebookContents.MAX_PAGES),
|
||||||
|
"ALL must select a physical page from tab 88");
|
||||||
|
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).is(ModItems.PAGE.get()),
|
||||||
|
"Tab 88's page must be copyable");
|
||||||
|
menu.clickMenuButton(player, WritingDeskMenu.PREVIOUS_TABS);
|
||||||
|
helper.assertValueEqual(menu.firstVisibleTab(), 80, "Previous must move back four tabs");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void oldDeskInventoryMigratesAndAll88TabsPersist(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
var registries = helper.getLevel().registryAccess();
|
||||||
|
var oldItems = net.minecraft.core.NonNullList.withSize(8, ItemStack.EMPTY);
|
||||||
|
oldItems.set(0, new ItemStack(ModItems.NOTEBOOK.get()));
|
||||||
|
oldItems.set(3, PageItem.createLinkPanelPage());
|
||||||
|
oldItems.set(4, new ItemStack(ModItems.INK_VIAL.get(), 2));
|
||||||
|
oldItems.set(5, new ItemStack(Items.GLASS_BOTTLE, 3));
|
||||||
|
oldItems.set(6, new ItemStack(Items.PAPER, 12));
|
||||||
|
oldItems.set(7, PageItem.createLinkPanelPage());
|
||||||
|
var oldTag = new net.minecraft.nbt.CompoundTag();
|
||||||
|
net.minecraft.world.ContainerHelper.saveAllItems(oldTag, oldItems, registries);
|
||||||
|
oldTag.putInt("InkRemaining", 7);
|
||||||
|
oldTag.putInt("ActiveCollection", 3);
|
||||||
|
var desk = new WritingDeskBlockEntity(pos, ModBlocks.WRITING_DESK.get().defaultBlockState());
|
||||||
|
desk.loadWithComponents(oldTag, registries);
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
helper.assertTrue(ItemStack.matches(desk.getItem(i), oldItems.get(i)), "Original tab must survive migration");
|
||||||
|
helper.assertTrue(ItemStack.matches(desk.getItem(WritingDeskBlockEntity.INK + i), oldItems.get(4 + i)),
|
||||||
|
"Original supplies and legacy output must survive migration");
|
||||||
|
helper.assertTrue(desk.getItem(4 + i).isEmpty(), "Supplies must not be duplicated into new tabs");
|
||||||
|
}
|
||||||
|
helper.assertValueEqual(desk.inkRemaining(), 7, "Migration must preserve ink meter");
|
||||||
|
helper.assertValueEqual(desk.activeCollection(), 3, "Migration must preserve active tab");
|
||||||
|
for (int i = 0; i < WritingDeskBlockEntity.COLLECTIONS; i++) desk.setItem(i, PageItem.createLinkPanelPage());
|
||||||
|
desk.setActiveCollection(87);
|
||||||
|
var restored = new WritingDeskBlockEntity(pos, ModBlocks.WRITING_DESK.get().defaultBlockState());
|
||||||
|
restored.loadWithComponents(desk.saveWithoutMetadata(registries), registries);
|
||||||
|
for (int i = 0; i < WritingDeskBlockEntity.COLLECTIONS; i++)
|
||||||
|
helper.assertTrue(restored.getItem(i).is(ModItems.PAGE.get()), "All 88 tab contents must survive save/load");
|
||||||
|
helper.assertValueEqual(restored.activeCollection(), 87, "Tab 88 selection must survive save/load");
|
||||||
|
helper.assertValueEqual(restored.getItem(WritingDeskBlockEntity.PAPER).getCount(), 12, "Reload must not repeat migration");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void surfacePlacesCarriedPagesWithoutLossOrOverwrite(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
var desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
desk.setItem(87, new ItemStack(ModItems.NOTEBOOK.get()));
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||||
|
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
|
||||||
|
ItemStack held = PageItem.createLinkPanelPage().copyWithCount(3);
|
||||||
|
ItemStack expectedPage = held.copyWithCount(1);
|
||||||
|
player.getInventory().setItem(9, held);
|
||||||
|
menu.clicked(WritingDeskMenu.COPY_RESULT + 1, 0, ClickType.PICKUP, player);
|
||||||
|
int lastPosition = WritingDeskMenu.PLACE_PAGE_BUTTON_BASE + 87 * NotebookContents.MAX_PAGES + 26;
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, lastPosition), "Surface must place into final notebook position");
|
||||||
|
helper.assertValueEqual(menu.getCarried().getCount(), 2, "Placing must consume exactly one held page");
|
||||||
|
var pages = desk.getItem(87).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pages();
|
||||||
|
helper.assertValueEqual(pages.size(), 27, "Placement must preserve addressed position");
|
||||||
|
helper.assertTrue(pages.get(0).isEmpty() && pages.get(25).isEmpty(), "Placement must preserve preceding gaps");
|
||||||
|
helper.assertTrue(ItemStack.matches(pages.get(26), expectedPage), "Stored page must preserve components");
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, lastPosition), "Occupied position must not be overwritten");
|
||||||
|
helper.assertValueEqual(menu.getCarried().getCount(), 2, "Rejected placement must preserve cursor");
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.PLACE_PAGE_BUTTON_BASE + 88 * NotebookContents.MAX_PAGES),
|
||||||
|
"Out-of-range placement must be rejected");
|
||||||
|
helper.assertValueEqual(menu.pageCapacity(87), NotebookContents.MAX_PAGES, "Notebook tab must expose normal notebook capacity");
|
||||||
|
helper.assertValueEqual(menu.pageCapacity(0), 0, "Empty tab has no page storage");
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.PLACE_PAGE_BUTTON_BASE), "Empty tab must require a collection first");
|
||||||
|
desk.setItem(0, PageItem.createLinkPanelPage());
|
||||||
|
helper.assertValueEqual(menu.pageCapacity(0), 1, "Loose page tab must expose exactly one position");
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.PLACE_PAGE_BUTTON_BASE), "Loose page must not be replaced");
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.PLACE_PAGE_BUTTON_BASE + 1), "Loose page tab must not grow extra positions");
|
||||||
|
helper.assertValueEqual(menu.getCarried().getCount(), 2, "Rejected placements must preserve cursor");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, lastPosition - 1), "A second empty notebook position must accept a page");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, lastPosition - 2), "A third empty notebook position must accept the last page");
|
||||||
|
helper.assertTrue(menu.getCarried().isEmpty(), "Final placement must clear cursor");
|
||||||
|
menu.setCarried(new ItemStack(Items.DIAMOND));
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, lastPosition - 3), "Non-page items must be rejected");
|
||||||
|
helper.assertTrue(menu.getCarried().is(Items.DIAMOND), "Rejected item must remain held");
|
||||||
|
var restored = new WritingDeskBlockEntity(pos, ModBlocks.WRITING_DESK.get().defaultBlockState());
|
||||||
|
restored.loadWithComponents(desk.saveWithoutMetadata(helper.getLevel().registryAccess()), helper.getLevel().registryAccess());
|
||||||
|
helper.assertValueEqual(restored.getItem(87).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pageCount(), 3,
|
||||||
|
"Placed pages must persist in the notebook");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void deskSurfaceDragMovesAndSwapsNotebookPages(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
var desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
ItemStack linkPanel = PageItem.createLinkPanelPage();
|
||||||
|
ItemStack terrain = PageItem.createSymbolPage(new SymbolReference(
|
||||||
|
ResourceLocation.parse("rivencraft:terrain/standard")));
|
||||||
|
ItemStack firstBook = new ItemStack(ModItems.NOTEBOOK.get());
|
||||||
|
firstBook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(linkPanel, terrain)));
|
||||||
|
desk.setItem(0, firstBook);
|
||||||
|
desk.setItem(1, new ItemStack(ModItems.NOTEBOOK.get()));
|
||||||
|
desk.setItem(2, PageItem.createLinkPanelPage());
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||||
|
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
|
||||||
|
int moveToEmpty = WritingDeskMenu.movePageButtonId(0, 0, 0, 26);
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, moveToEmpty), "Drag must move to a distant empty notebook position");
|
||||||
|
var pages = desk.getItem(0).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pages();
|
||||||
|
helper.assertTrue(pages.get(0).isEmpty(), "Moving must clear the source position");
|
||||||
|
helper.assertTrue(ItemStack.matches(pages.get(26), linkPanel), "Moving must preserve page components");
|
||||||
|
helper.assertValueEqual(desk.getItem(0).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pageCount(), 2,
|
||||||
|
"Moving must preserve total page count");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(0, 26, 0, 1)),
|
||||||
|
"Drag onto an occupied position must swap pages");
|
||||||
|
pages = desk.getItem(0).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pages();
|
||||||
|
helper.assertTrue(ItemStack.matches(pages.get(1), linkPanel) && ItemStack.matches(pages.get(26), terrain),
|
||||||
|
"Swap must preserve both pages");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(0, 1, 1, 5)),
|
||||||
|
"Drag into another notebook must work");
|
||||||
|
var otherPages = desk.getItem(1).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pages();
|
||||||
|
helper.assertTrue(ItemStack.matches(otherPages.get(5), linkPanel)
|
||||||
|
&& desk.getItem(0).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pages().get(1).isEmpty(),
|
||||||
|
"Cross notebook move must have one destination and no old source");
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(0, 1, 1, 6)),
|
||||||
|
"Dragging an empty source must be rejected");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(2, 0, 1, 6)),
|
||||||
|
"A loose page should move into a notebook position");
|
||||||
|
helper.assertTrue(desk.getItem(2).isEmpty()
|
||||||
|
&& desk.getItem(1).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pages().get(6).is(ModItems.PAGE.get()),
|
||||||
|
"Moving a loose page must empty its tab without losing the item");
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(1, 5, 1, 5)),
|
||||||
|
"A no-op drag must be rejected");
|
||||||
|
menu.setCarried(new ItemStack(Items.DIAMOND));
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(1, 5, 1, 6)),
|
||||||
|
"Held inventory items must not be moved by a panel drag");
|
||||||
|
helper.assertTrue(ItemStack.matches(desk.getItem(1).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pages().get(5), linkPanel),
|
||||||
|
"Rejected drags must leave notebook data intact");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void deskSurfaceDragExtractsPagesToInventoryAndTabs(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
var desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
ItemStack page = PageItem.createLinkPanelPage();
|
||||||
|
ItemStack source = new ItemStack(ModItems.NOTEBOOK.get());
|
||||||
|
source.set(ModDataComponents.NOTEBOOK_CONTENTS.get(),
|
||||||
|
new NotebookContents(List.of(page, page, page, page, page, page)));
|
||||||
|
desk.setItem(0, source);
|
||||||
|
ItemStack otherBook = new ItemStack(ModItems.NOTEBOOK.get());
|
||||||
|
ItemStack terrain = PageItem.createSymbolPage(new SymbolReference(
|
||||||
|
ResourceLocation.parse("rivencraft:terrain/standard")));
|
||||||
|
otherBook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(terrain)));
|
||||||
|
desk.setItem(2, otherBook);
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||||
|
player.getInventory().setItem(10, page.copyWithCount(2));
|
||||||
|
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
|
||||||
|
int inventoryStart = WritingDeskMenu.COPY_RESULT + 1;
|
||||||
|
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 0, -1)),
|
||||||
|
"Dropping away from a slot must return the page to the cursor");
|
||||||
|
helper.assertTrue(ItemStack.matches(menu.getCarried(), page), "Cursor must hold the original page item");
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 1, inventoryStart)),
|
||||||
|
"Extraction must not replace an occupied cursor");
|
||||||
|
menu.setCarried(ItemStack.EMPTY);
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 1, inventoryStart)),
|
||||||
|
"Dropping on player inventory must transfer a page");
|
||||||
|
helper.assertTrue(ItemStack.matches(player.getInventory().getItem(9), page),
|
||||||
|
"Transferred page must retain its components");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 2, 1)),
|
||||||
|
"Dropping on an empty tab must store a loose page");
|
||||||
|
helper.assertTrue(ItemStack.matches(desk.getItem(1), page), "Tab must hold the extracted page");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 3, 2)),
|
||||||
|
"Dropping on a notebook tab must insert the page");
|
||||||
|
var otherPages = desk.getItem(2).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pages();
|
||||||
|
helper.assertTrue(ItemStack.matches(otherPages.get(0), terrain)
|
||||||
|
&& ItemStack.matches(otherPages.get(1), page), "Notebook drop must preserve existing pages");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 4, inventoryStart + 1)),
|
||||||
|
"Dropping on a matching inventory stack must merge one page");
|
||||||
|
helper.assertValueEqual(player.getInventory().getItem(10).getCount(), 3, "Merge must add exactly one page");
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 5, 1)),
|
||||||
|
"Occupied loose-page tabs must reject a drop");
|
||||||
|
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(0, 5, WritingDeskBlockEntity.INK)),
|
||||||
|
"Ink slot must not accept an extracted page");
|
||||||
|
helper.assertValueEqual(desk.getItem(0).get(ModDataComponents.NOTEBOOK_CONTENTS.get()).pageCount(), 1,
|
||||||
|
"Rejected drops must leave the remaining source page in the notebook");
|
||||||
|
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(1, 0, -1)),
|
||||||
|
"A loose page shown on the panel must be draggable back to the cursor");
|
||||||
|
helper.assertTrue(desk.getItem(1).isEmpty() && ItemStack.matches(menu.getCarried(), page),
|
||||||
|
"Extracting a loose page must empty its tab and return the same item");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package net.dnorberg.rivencraft.item;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingInkService;
|
||||||
|
import net.minecraft.ChatFormatting;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.InteractionResultHolder;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.Item;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.TooltipFlag;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
|
||||||
|
/** A stackable vial; the ink component is per vial, not shared across a stack. */
|
||||||
|
public final class InkVialItem extends Item {
|
||||||
|
public static final int CAPACITY = 16;
|
||||||
|
|
||||||
|
public InkVialItem(Properties properties) {
|
||||||
|
super(properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int remaining(ItemStack vial) {
|
||||||
|
return vial.getOrDefault(ModDataComponents.INK_REMAINING.get(), CAPACITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InteractionResultHolder<ItemStack> use(Level level, Player player, InteractionHand hand) {
|
||||||
|
if (!WritingInkService.canPrepareLinkPanel(player, hand)) {
|
||||||
|
return InteractionResultHolder.pass(player.getItemInHand(hand));
|
||||||
|
}
|
||||||
|
if (!level.isClientSide) {
|
||||||
|
WritingInkService.prepareLinkPanel(player, hand);
|
||||||
|
}
|
||||||
|
return InteractionResultHolder.sidedSuccess(player.getItemInHand(hand), level.isClientSide);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void appendHoverText(ItemStack stack, TooltipContext context,
|
||||||
|
List<Component> tooltip, TooltipFlag flag) {
|
||||||
|
tooltip.add(Component.translatable("item.rivencraft.ink_vial.remaining",
|
||||||
|
remaining(stack), CAPACITY).withStyle(ChatFormatting.GRAY));
|
||||||
|
tooltip.add(Component.translatable("item.rivencraft.ink_vial.usage")
|
||||||
|
.withStyle(ChatFormatting.GRAY));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package net.dnorberg.rivencraft.item;
|
package net.dnorberg.rivencraft.item;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
import net.dnorberg.rivencraft.registry.ModItems;
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
import net.dnorberg.rivencraft.writing.PageContent;
|
import net.dnorberg.rivencraft.writing.PageContent;
|
||||||
@@ -28,6 +29,23 @@ public final class PageItem extends Item {
|
|||||||
return create(PageContent.symbol(reference));
|
return create(PageContent.symbol(reference));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fixed starter examples for the creative tab; the server still validates catalog membership on use. */
|
||||||
|
public static ItemStack createCreativeStarterPage(String path) {
|
||||||
|
if (!List.of("terrain/standard", "biome/native", "lighting/normal", "weather/normal",
|
||||||
|
"celestial/normal_time", "feature/caves").contains(path)) {
|
||||||
|
throw new IllegalArgumentException("Not a starter symbol: " + path);
|
||||||
|
}
|
||||||
|
SymbolReference reference = new SymbolReference(
|
||||||
|
net.minecraft.resources.ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path));
|
||||||
|
ItemStack page = create(PageContent.symbol(reference));
|
||||||
|
String key = "symbol.rivencraft." + path.replace('/', '.');
|
||||||
|
page.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), new SymbolDisplayMetadata(
|
||||||
|
reference.id(), key, key + ".description",
|
||||||
|
net.minecraft.resources.ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path.split("/")[0]),
|
||||||
|
true));
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
public static ItemStack createLinkPanelPage() {
|
public static ItemStack createLinkPanelPage() {
|
||||||
return create(PageContent.LINK_PANEL);
|
return create(PageContent.LINK_PANEL);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package net.dnorberg.rivencraft.menu;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModMenus;
|
||||||
|
import net.dnorberg.rivencraft.writing.PageContent;
|
||||||
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
|
import net.minecraft.world.Container;
|
||||||
|
import net.minecraft.world.SimpleContainer;
|
||||||
|
import net.minecraft.world.entity.player.Inventory;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||||
|
import net.minecraft.world.inventory.ClickType;
|
||||||
|
import net.minecraft.world.inventory.DataSlot;
|
||||||
|
import net.minecraft.world.inventory.Slot;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
|
||||||
|
/** Fixed, ordered binder slots and a server-owned virtual result. */
|
||||||
|
public final class BookBinderMenu extends AbstractContainerMenu {
|
||||||
|
public static final int RESULT = BookBinderBlockEntity.SIZE;
|
||||||
|
public static final int SELECT_BASE = 100;
|
||||||
|
public static final int MOVE_PREVIOUS = 200;
|
||||||
|
public static final int MOVE_NEXT = 201;
|
||||||
|
public static final int IMPORT_NOTEBOOK = 202;
|
||||||
|
public static final int STRIP_CLICK_BASE = 300;
|
||||||
|
private static final int PLAYER_START = RESULT + 1;
|
||||||
|
|
||||||
|
private final BookBinderBlockEntity binder;
|
||||||
|
private final Container inputs;
|
||||||
|
private final SimpleContainer result = new SimpleContainer(1);
|
||||||
|
private final boolean clientSide;
|
||||||
|
private int selectedPage = -1;
|
||||||
|
|
||||||
|
public BookBinderMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
|
||||||
|
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
|
||||||
|
instanceof BookBinderBlockEntity found ? found : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BookBinderMenu(int id, Inventory inventory, BookBinderBlockEntity binder) {
|
||||||
|
super(ModMenus.BOOK_BINDER.get(), id);
|
||||||
|
this.binder = binder;
|
||||||
|
inputs = binder == null ? new SimpleContainer(BookBinderBlockEntity.SIZE) : binder;
|
||||||
|
clientSide = inventory.player.level().isClientSide;
|
||||||
|
addDataSlot(new DataSlot() {
|
||||||
|
@Override public int get() { return selectedPage; }
|
||||||
|
@Override public void set(int value) { selectedPage = value; }
|
||||||
|
});
|
||||||
|
addSlot(new BinderSlot(BookBinderBlockEntity.COVER, 8, 27, 64));
|
||||||
|
addSlot(new BinderSlot(BookBinderBlockEntity.LINK_PANEL, -1000, -1000, 1));
|
||||||
|
addSlot(new BinderSlot(BookBinderBlockEntity.NOTEBOOK, -1000, -1000, 1));
|
||||||
|
for (int page = 0; page < BookBinderBlockEntity.PAGE_COUNT; page++) {
|
||||||
|
addSlot(new BinderSlot(BookBinderBlockEntity.FIRST_PAGE + page, -1000, -1000, 1));
|
||||||
|
}
|
||||||
|
addSlot(new ResultSlot(result, 0, 152, 27));
|
||||||
|
for (int row = 0; row < 3; row++) {
|
||||||
|
for (int column = 0; column < 9; column++) {
|
||||||
|
addSlot(new Slot(inventory, column + row * 9 + 9, 8 + column * 18, 99 + row * 18));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int column = 0; column < 9; column++) {
|
||||||
|
addSlot(new Slot(inventory, column, 8 + column * 18, 157));
|
||||||
|
}
|
||||||
|
refreshResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int selectedPage() { return selectedPage; }
|
||||||
|
public String title() { return binder == null ? "" : binder.title(); }
|
||||||
|
public boolean setTitle(Player player, String title) {
|
||||||
|
if (clientSide || !stillValid(player) || title == null
|
||||||
|
|| title.length() > BookBinderBlockEntity.MAX_TITLE_LENGTH) return false;
|
||||||
|
binder.setTitle(title);
|
||||||
|
refreshResult();
|
||||||
|
broadcastChanges();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean stillValid(Player player) {
|
||||||
|
return binder != null && binder.stillValid(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean clickMenuButton(Player player, int id) {
|
||||||
|
if (clientSide || !stillValid(player)) return false;
|
||||||
|
if (id >= STRIP_CLICK_BASE && id <= STRIP_CLICK_BASE + BookBinderBlockEntity.PAGE_COUNT) {
|
||||||
|
if (!clickStrip(id - STRIP_CLICK_BASE)) return false;
|
||||||
|
} else if (id >= SELECT_BASE && id < SELECT_BASE + BookBinderBlockEntity.PAGE_COUNT) {
|
||||||
|
selectedPage = id - SELECT_BASE;
|
||||||
|
} else if (id == MOVE_PREVIOUS || id == MOVE_NEXT) {
|
||||||
|
int target = selectedPage + (id == MOVE_PREVIOUS ? -1 : 1);
|
||||||
|
if (selectedPage < 0 || target < 0 || target >= BookBinderBlockEntity.PAGE_COUNT
|
||||||
|
|| binder.getItem(BookBinderBlockEntity.FIRST_PAGE + selectedPage).isEmpty()
|
||||||
|
|| !getCarried().isEmpty()) return false;
|
||||||
|
int fromSlot = BookBinderBlockEntity.FIRST_PAGE + selectedPage;
|
||||||
|
int toSlot = BookBinderBlockEntity.FIRST_PAGE + target;
|
||||||
|
ItemStack from = binder.getItem(fromSlot);
|
||||||
|
ItemStack to = binder.getItem(toSlot);
|
||||||
|
binder.setItem(fromSlot, to);
|
||||||
|
binder.setItem(toSlot, from);
|
||||||
|
selectedPage = target;
|
||||||
|
} else if (id == IMPORT_NOTEBOOK) {
|
||||||
|
if (!binder.importNotebook()) return false;
|
||||||
|
} else return false;
|
||||||
|
refreshResult();
|
||||||
|
broadcastChanges();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean clickStrip(int index) {
|
||||||
|
ItemStack carried = getCarried();
|
||||||
|
if (carried.is(ModItems.NOTEBOOK.get())) {
|
||||||
|
ItemStack emptied = binder.importNotebookFromStack(carried, Math.min(index == 0 ? 0 : index - 1,
|
||||||
|
binder.nextPageIndex()));
|
||||||
|
if (emptied.isEmpty()) return false;
|
||||||
|
setCarried(emptied);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!carried.isEmpty()) {
|
||||||
|
if (index == 0) {
|
||||||
|
if (!binder.getItem(BookBinderBlockEntity.LINK_PANEL).isEmpty()
|
||||||
|
|| !binder.canPlaceItem(BookBinderBlockEntity.LINK_PANEL, carried)) return false;
|
||||||
|
binder.setItem(BookBinderBlockEntity.LINK_PANEL, carried.copyWithCount(1));
|
||||||
|
} else if (!binder.insertPage(index - 1, carried.copyWithCount(1))) return false;
|
||||||
|
ItemStack remainder = carried.copy();
|
||||||
|
remainder.shrink(1);
|
||||||
|
setCarried(remainder);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ItemStack removed;
|
||||||
|
if (index == 0) {
|
||||||
|
removed = binder.removeItem(BookBinderBlockEntity.LINK_PANEL, 1);
|
||||||
|
} else {
|
||||||
|
removed = binder.takePage(index - 1);
|
||||||
|
}
|
||||||
|
if (removed.isEmpty()) return false;
|
||||||
|
setCarried(removed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public ItemStack quickMoveStack(Player player, int index) {
|
||||||
|
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
|
||||||
|
if (index == RESULT) {
|
||||||
|
refreshResult();
|
||||||
|
if (!binder.canAssemble()) return ItemStack.EMPTY;
|
||||||
|
ItemStack book = binder.previewBook();
|
||||||
|
ItemStack remainder = book.copy();
|
||||||
|
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty()) {
|
||||||
|
return ItemStack.EMPTY;
|
||||||
|
}
|
||||||
|
binder.consumeAssembly();
|
||||||
|
refreshResult();
|
||||||
|
return book;
|
||||||
|
}
|
||||||
|
Slot sourceSlot = slots.get(index);
|
||||||
|
if (!sourceSlot.hasItem()) return ItemStack.EMPTY;
|
||||||
|
ItemStack source = sourceSlot.getItem();
|
||||||
|
ItemStack original = source.copy();
|
||||||
|
boolean moved;
|
||||||
|
if (index < RESULT) {
|
||||||
|
moved = moveItemStackTo(source, PLAYER_START, slots.size(), true);
|
||||||
|
} else if (source.is(Items.LEATHER) || BookBinderBlockEntity.validCover(source)) {
|
||||||
|
moved = moveItemStackTo(source, BookBinderBlockEntity.COVER, BookBinderBlockEntity.COVER + 1, false);
|
||||||
|
} else if (source.is(ModItems.NOTEBOOK.get())) {
|
||||||
|
ItemStack emptied = binder.importNotebookFromStack(source, binder.nextPageIndex());
|
||||||
|
if (emptied.isEmpty()) return ItemStack.EMPTY;
|
||||||
|
sourceSlot.setByPlayer(emptied);
|
||||||
|
refreshResult();
|
||||||
|
return original;
|
||||||
|
} else if (source.is(ModItems.PAGE.get())
|
||||||
|
&& PageItem.content(source).kind() == PageContent.Kind.LINK_PANEL) {
|
||||||
|
moved = moveItemStackTo(source, BookBinderBlockEntity.LINK_PANEL,
|
||||||
|
BookBinderBlockEntity.LINK_PANEL + 1, false);
|
||||||
|
} else if (source.is(ModItems.PAGE.get())) {
|
||||||
|
moved = moveItemStackTo(source, BookBinderBlockEntity.FIRST_PAGE, RESULT, false);
|
||||||
|
} else moved = false;
|
||||||
|
if (!moved) return ItemStack.EMPTY;
|
||||||
|
if (source.isEmpty()) sourceSlot.setByPlayer(ItemStack.EMPTY);
|
||||||
|
else sourceSlot.setChanged();
|
||||||
|
refreshResult();
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void clicked(int slotId, int button, ClickType clickType, Player player) {
|
||||||
|
if (!stillValid(player)) {
|
||||||
|
player.closeContainer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refreshResult();
|
||||||
|
super.clicked(slotId, button, clickType, player);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void broadcastChanges() {
|
||||||
|
refreshResult();
|
||||||
|
super.broadcastChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshResult() {
|
||||||
|
if (clientSide) return;
|
||||||
|
ItemStack preview = binder == null ? ItemStack.EMPTY : binder.previewBook();
|
||||||
|
if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class BinderSlot extends Slot {
|
||||||
|
private final int capacity;
|
||||||
|
private BinderSlot(int index, int x, int y, int capacity) {
|
||||||
|
super(inputs, index, x, y);
|
||||||
|
this.capacity = capacity;
|
||||||
|
}
|
||||||
|
@Override public boolean mayPlace(ItemStack stack) { return inputs.canPlaceItem(getContainerSlot(), stack); }
|
||||||
|
@Override public int getMaxStackSize() { return capacity; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ResultSlot extends Slot {
|
||||||
|
private ResultSlot(Container container, int index, int x, int y) { super(container, index, x, y); }
|
||||||
|
@Override public boolean mayPlace(ItemStack stack) { return false; }
|
||||||
|
@Override public boolean mayPickup(Player player) {
|
||||||
|
return clientSide ? hasItem() : stillValid(player) && binder.canAssemble();
|
||||||
|
}
|
||||||
|
@Override public void onTake(Player player, ItemStack stack) {
|
||||||
|
if (!clientSide && binder.consumeAssembly()) refreshResult();
|
||||||
|
super.onTake(player, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package net.dnorberg.rivencraft.menu;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModMenus;
|
||||||
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
|
import net.minecraft.world.Container;
|
||||||
|
import net.minecraft.world.SimpleContainer;
|
||||||
|
import net.minecraft.world.entity.player.Inventory;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||||
|
import net.minecraft.world.inventory.DataSlot;
|
||||||
|
import net.minecraft.world.inventory.Slot;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
|
||||||
|
/** Server-owned virtual output; taking it spends paper and the configured ink cost once. */
|
||||||
|
public final class InkMixerMenu extends AbstractContainerMenu {
|
||||||
|
public static final int RESULT = InkMixerBlockEntity.SIZE;
|
||||||
|
private static final int PLAYER_START = RESULT + 1;
|
||||||
|
private final InkMixerBlockEntity mixer;
|
||||||
|
private final Container inputs;
|
||||||
|
private final SimpleContainer result = new SimpleContainer(1);
|
||||||
|
private final boolean clientSide;
|
||||||
|
private int inkRemaining;
|
||||||
|
|
||||||
|
public InkMixerMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
|
||||||
|
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
|
||||||
|
instanceof InkMixerBlockEntity found ? found : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public InkMixerMenu(int id, Inventory inventory, InkMixerBlockEntity mixer) {
|
||||||
|
super(ModMenus.INK_MIXER.get(), id);
|
||||||
|
this.mixer = mixer;
|
||||||
|
inputs = mixer == null ? new SimpleContainer(InkMixerBlockEntity.SIZE) : mixer;
|
||||||
|
clientSide = inventory.player.level().isClientSide;
|
||||||
|
addDataSlot(new DataSlot() {
|
||||||
|
@Override public int get() { return clientSide ? inkRemaining : mixer.inkRemaining(); }
|
||||||
|
@Override public void set(int value) { inkRemaining = value; }
|
||||||
|
});
|
||||||
|
addSlot(new MixerSlot(InkMixerBlockEntity.INK, 8, 27));
|
||||||
|
addSlot(new MixerSlot(InkMixerBlockEntity.PAPER, 8, 48));
|
||||||
|
addSlot(new MixerSlot(InkMixerBlockEntity.BOTTLES, 152, 27));
|
||||||
|
addSlot(new ResultSlot(152, 48));
|
||||||
|
for (int row = 0; row < 3; row++)
|
||||||
|
for (int column = 0; column < 9; column++)
|
||||||
|
addSlot(new Slot(inventory, column + row * 9 + 9, 8 + column * 18, 99 + row * 18));
|
||||||
|
for (int column = 0; column < 9; column++)
|
||||||
|
addSlot(new Slot(inventory, column, 8 + column * 18, 157));
|
||||||
|
refreshResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int inkRemaining() { return inkRemaining; }
|
||||||
|
|
||||||
|
@Override public boolean stillValid(Player player) {
|
||||||
|
return mixer != null && mixer.stillValid(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void broadcastChanges() {
|
||||||
|
refreshResult();
|
||||||
|
super.broadcastChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshResult() {
|
||||||
|
if (clientSide) return;
|
||||||
|
ItemStack preview = mixer != null && mixer.canCreateLinkPanel()
|
||||||
|
? PageItem.createLinkPanelPage() : ItemStack.EMPTY;
|
||||||
|
if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public ItemStack quickMoveStack(Player player, int index) {
|
||||||
|
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
|
||||||
|
if (index == RESULT) {
|
||||||
|
refreshResult();
|
||||||
|
if (!mixer.canCreateLinkPanel()) return ItemStack.EMPTY;
|
||||||
|
ItemStack output = result.getItem(0).copy();
|
||||||
|
ItemStack remainder = output.copy();
|
||||||
|
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty())
|
||||||
|
return ItemStack.EMPTY;
|
||||||
|
if (!mixer.consumeLinkPanelMaterials()) return ItemStack.EMPTY;
|
||||||
|
refreshResult();
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
Slot slot = slots.get(index);
|
||||||
|
if (!slot.hasItem()) return ItemStack.EMPTY;
|
||||||
|
ItemStack source = slot.getItem();
|
||||||
|
ItemStack original = source.copy();
|
||||||
|
boolean moved;
|
||||||
|
if (index < RESULT) moved = moveItemStackTo(source, PLAYER_START, slots.size(), true);
|
||||||
|
else if (source.is(ModItems.INK_VIAL.get()))
|
||||||
|
moved = moveItemStackTo(source, InkMixerBlockEntity.INK, InkMixerBlockEntity.INK + 1, false);
|
||||||
|
else if (source.is(Items.PAPER))
|
||||||
|
moved = moveItemStackTo(source, InkMixerBlockEntity.PAPER, InkMixerBlockEntity.PAPER + 1, false);
|
||||||
|
else moved = false;
|
||||||
|
if (!moved) return ItemStack.EMPTY;
|
||||||
|
if (source.isEmpty()) slot.setByPlayer(ItemStack.EMPTY);
|
||||||
|
else slot.setChanged();
|
||||||
|
refreshResult();
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MixerSlot extends Slot {
|
||||||
|
MixerSlot(int index, int x, int y) { super(inputs, index, x, y); }
|
||||||
|
@Override public boolean mayPlace(ItemStack stack) { return inputs.canPlaceItem(getContainerSlot(), stack); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ResultSlot extends Slot {
|
||||||
|
ResultSlot(int x, int y) { super(result, 0, x, y); }
|
||||||
|
@Override public boolean mayPlace(ItemStack stack) { return false; }
|
||||||
|
@Override public boolean mayPickup(Player player) {
|
||||||
|
return clientSide ? hasItem() : stillValid(player) && mixer.canCreateLinkPanel();
|
||||||
|
}
|
||||||
|
@Override public void onTake(Player player, ItemStack stack) {
|
||||||
|
if (!clientSide && mixer.consumeLinkPanelMaterials()) refreshResult();
|
||||||
|
super.onTake(player, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,7 @@ import net.dnorberg.rivencraft.registry.ModItems;
|
|||||||
import net.dnorberg.rivencraft.registry.ModMenus;
|
import net.dnorberg.rivencraft.registry.ModMenus;
|
||||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||||
import net.minecraft.core.NonNullList;
|
import net.minecraft.core.NonNullList;
|
||||||
import net.minecraft.core.component.DataComponents;
|
|
||||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
import net.minecraft.network.chat.Component;
|
|
||||||
import net.minecraft.world.Container;
|
import net.minecraft.world.Container;
|
||||||
import net.minecraft.world.InteractionHand;
|
import net.minecraft.world.InteractionHand;
|
||||||
import net.minecraft.world.SimpleContainer;
|
import net.minecraft.world.SimpleContainer;
|
||||||
@@ -26,7 +24,7 @@ public final class NotebookMenu extends AbstractContainerMenu {
|
|||||||
private static final int PLAYER_INVENTORY_START = NOTEBOOK_SLOTS;
|
private static final int PLAYER_INVENTORY_START = NOTEBOOK_SLOTS;
|
||||||
private final Inventory playerInventory;
|
private final Inventory playerInventory;
|
||||||
private final InteractionHand hand;
|
private final InteractionHand hand;
|
||||||
private final ItemStack notebook;
|
private final int openedHotbarSlot;
|
||||||
private final NotebookContainer contents;
|
private final NotebookContainer contents;
|
||||||
|
|
||||||
public NotebookMenu(int containerId, Inventory inventory, RegistryFriendlyByteBuf data) {
|
public NotebookMenu(int containerId, Inventory inventory, RegistryFriendlyByteBuf data) {
|
||||||
@@ -37,34 +35,37 @@ public final class NotebookMenu extends AbstractContainerMenu {
|
|||||||
super(ModMenus.NOTEBOOK.get(), containerId);
|
super(ModMenus.NOTEBOOK.get(), containerId);
|
||||||
this.playerInventory = inventory;
|
this.playerInventory = inventory;
|
||||||
this.hand = hand;
|
this.hand = hand;
|
||||||
this.notebook = inventory.player.getItemInHand(hand);
|
this.openedHotbarSlot = inventory.selected;
|
||||||
this.contents = new NotebookContainer(notebook);
|
this.contents = new NotebookContainer(inventory, hand);
|
||||||
|
|
||||||
for (int row = 0; row < 3; row++) {
|
for (int row = 0; row < 3; row++) {
|
||||||
for (int column = 0; column < 9; column++) {
|
for (int column = 0; column < 9; column++) {
|
||||||
addSlot(new PageSlot(contents, column + row * 9, 8 + column * 18, 32 + row * 18));
|
// The client draws these pages on a filtered, scrollable surface instead.
|
||||||
|
addSlot(new PageSlot(contents, column + row * 9, -1000, -1000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (int row = 0; row < 3; row++) {
|
for (int row = 0; row < 3; row++) {
|
||||||
for (int column = 0; column < 9; column++) {
|
for (int column = 0; column < 9; column++) {
|
||||||
int inventoryIndex = column + row * 9 + 9;
|
int inventoryIndex = column + row * 9 + 9;
|
||||||
addSlot(playerSlot(inventoryIndex, 8 + column * 18, 101 + row * 18));
|
addSlot(playerSlot(inventoryIndex, 8 + column * 18, 135 + row * 18));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (int column = 0; column < 9; column++) {
|
for (int column = 0; column < 9; column++) {
|
||||||
addSlot(playerSlot(column, 8 + column * 18, 159));
|
addSlot(playerSlot(column, 8 + column * 18, 193));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Slot playerSlot(int inventoryIndex, int x, int y) {
|
private Slot playerSlot(int inventoryIndex, int x, int y) {
|
||||||
boolean locked = hand == InteractionHand.MAIN_HAND && inventoryIndex == playerInventory.selected;
|
boolean locked = hand == InteractionHand.MAIN_HAND && inventoryIndex == openedHotbarSlot;
|
||||||
return locked ? new LockedSlot(playerInventory, inventoryIndex, x, y)
|
return locked ? new LockedSlot(playerInventory, inventoryIndex, x, y)
|
||||||
: new Slot(playerInventory, inventoryIndex, x, y);
|
: new Slot(playerInventory, inventoryIndex, x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean stillValid(Player player) {
|
public boolean stillValid(Player player) {
|
||||||
return player.isAlive() && player.getItemInHand(hand) == notebook && notebook.is(ModItems.NOTEBOOK.get());
|
return player.isAlive()
|
||||||
|
&& (hand == InteractionHand.OFF_HAND || player.getInventory().selected == openedHotbarSlot)
|
||||||
|
&& player.getItemInHand(hand).is(ModItems.NOTEBOOK.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -115,29 +116,18 @@ public final class NotebookMenu extends AbstractContainerMenu {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void renamePage(int slot, String name) {
|
|
||||||
if (slot < 0 || slot >= NOTEBOOK_SLOTS) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ItemStack page = contents.getItem(slot);
|
|
||||||
if (page.isEmpty() || !page.is(ModItems.PAGE.get())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (name.isBlank()) {
|
|
||||||
page.remove(DataComponents.CUSTOM_NAME);
|
|
||||||
} else {
|
|
||||||
page.set(DataComponents.CUSTOM_NAME, Component.literal(name));
|
|
||||||
}
|
|
||||||
contents.setChanged();
|
|
||||||
broadcastChanges();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void clicked(int slotId, int button, ClickType clickType, Player player) {
|
public void clicked(int slotId, int button, ClickType clickType, Player player) {
|
||||||
super.clicked(slotId, button, clickType, player);
|
|
||||||
if (!stillValid(player)) {
|
if (!stillValid(player)) {
|
||||||
player.closeContainer();
|
player.closeContainer();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (clickType == ClickType.SWAP
|
||||||
|
&& (button == openedHotbarSlot && hand == InteractionHand.MAIN_HAND
|
||||||
|
|| button == 40 && hand == InteractionHand.OFF_HAND)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super.clicked(slotId, button, clickType, player);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class PageSlot extends Slot {
|
private static final class PageSlot extends Slot {
|
||||||
@@ -159,12 +149,15 @@ public final class NotebookMenu extends AbstractContainerMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static final class NotebookContainer extends SimpleContainer {
|
private static final class NotebookContainer extends SimpleContainer {
|
||||||
private final ItemStack notebook;
|
private final Inventory inventory;
|
||||||
|
private final InteractionHand hand;
|
||||||
private boolean loading;
|
private boolean loading;
|
||||||
|
|
||||||
private NotebookContainer(ItemStack notebook) {
|
private NotebookContainer(Inventory inventory, InteractionHand hand) {
|
||||||
super(NOTEBOOK_SLOTS);
|
super(NOTEBOOK_SLOTS);
|
||||||
this.notebook = notebook;
|
this.inventory = inventory;
|
||||||
|
this.hand = hand;
|
||||||
|
ItemStack notebook = inventory.player.getItemInHand(hand);
|
||||||
loading = true;
|
loading = true;
|
||||||
List<ItemStack> saved = notebook.getOrDefault(
|
List<ItemStack> saved = notebook.getOrDefault(
|
||||||
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
||||||
@@ -186,7 +179,8 @@ public final class NotebookMenu extends AbstractContainerMenu {
|
|||||||
@Override
|
@Override
|
||||||
public void setChanged() {
|
public void setChanged() {
|
||||||
super.setChanged();
|
super.setChanged();
|
||||||
if (loading || notebook.isEmpty()) {
|
ItemStack notebook = inventory.player.getItemInHand(hand);
|
||||||
|
if (loading || !notebook.is(ModItems.NOTEBOOK.get())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
NonNullList<ItemStack> saved = NonNullList.withSize(NOTEBOOK_SLOTS, ItemStack.EMPTY);
|
NonNullList<ItemStack> saved = NonNullList.withSize(NOTEBOOK_SLOTS, ItemStack.EMPTY);
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
package net.dnorberg.rivencraft.menu;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModMenus;
|
||||||
|
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||||
|
import net.dnorberg.rivencraft.writing.PageContent;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
|
||||||
|
import net.dnorberg.rivencraft.network.WritingDeskCatalogPayload;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.neoforged.neoforge.network.PacketDistributor;
|
||||||
|
import java.util.List;
|
||||||
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
|
import net.minecraft.world.Container;
|
||||||
|
import net.minecraft.world.SimpleContainer;
|
||||||
|
import net.minecraft.world.entity.player.Inventory;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||||
|
import net.minecraft.world.inventory.DataSlot;
|
||||||
|
import net.minecraft.world.inventory.Slot;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
|
||||||
|
public final class WritingDeskMenu extends AbstractContainerMenu {
|
||||||
|
public static final int COPY_RESULT = WritingDeskBlockEntity.SIZE;
|
||||||
|
public static final int VISIBLE_TABS = 4;
|
||||||
|
public static final int PREVIOUS_TABS = 90;
|
||||||
|
public static final int NEXT_TABS = 91;
|
||||||
|
public static final int PAGE_BUTTON_BASE = 128;
|
||||||
|
public static final int CATALOG_BUTTON_BASE = 512;
|
||||||
|
public static final int ALL_PAGE_BUTTON_BASE = 32768;
|
||||||
|
public static final int PLACE_PAGE_BUTTON_BASE = 65536;
|
||||||
|
private static final int PAGE_ADDRESS_COUNT = WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES;
|
||||||
|
public static final int MOVE_PAGE_BUTTON_BASE = PLACE_PAGE_BUTTON_BASE + PAGE_ADDRESS_COUNT;
|
||||||
|
private static final int PLAYER_START = COPY_RESULT + 1;
|
||||||
|
private static final int EXTRACT_TARGET_COUNT = PLAYER_START + 36 + 1;
|
||||||
|
public static final int EXTRACT_PAGE_BUTTON_BASE = MOVE_PAGE_BUTTON_BASE + PAGE_ADDRESS_COUNT * PAGE_ADDRESS_COUNT;
|
||||||
|
private final Container desk;
|
||||||
|
private final WritingDeskBlockEntity blockEntity;
|
||||||
|
private final SimpleContainer result = new SimpleContainer(1);
|
||||||
|
private final boolean clientSide;
|
||||||
|
private int syncedActiveCollection;
|
||||||
|
private int syncedInkRemaining;
|
||||||
|
private int firstVisibleTab;
|
||||||
|
private int selectedPage;
|
||||||
|
private int selectedCollection = -1;
|
||||||
|
private ResourceLocation selectedSymbol;
|
||||||
|
private List<SymbolDisplayMetadata> catalog = List.of();
|
||||||
|
private final ServerPlayer serverViewer;
|
||||||
|
|
||||||
|
public WritingDeskMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
|
||||||
|
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
|
||||||
|
instanceof WritingDeskBlockEntity found ? found : null);
|
||||||
|
catalog = WritingDeskCatalog.read(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public WritingDeskMenu(int id, Inventory inventory, WritingDeskBlockEntity blockEntity) {
|
||||||
|
super(ModMenus.WRITING_DESK.get(), id);
|
||||||
|
this.blockEntity = blockEntity;
|
||||||
|
this.desk = blockEntity == null ? new SimpleContainer(WritingDeskBlockEntity.SIZE) : blockEntity;
|
||||||
|
this.clientSide = inventory.player.level().isClientSide;
|
||||||
|
this.serverViewer = inventory.player instanceof ServerPlayer serverPlayer ? serverPlayer : null;
|
||||||
|
if (blockEntity != null && !clientSide) catalog = WritingDeskCatalog.available(blockEntity);
|
||||||
|
firstVisibleTab = blockEntity == null ? 0 : blockEntity.activeCollection() / VISIBLE_TABS * VISIBLE_TABS;
|
||||||
|
addDataSlot(new DataSlot() {
|
||||||
|
@Override public int get() { return firstVisibleTab; }
|
||||||
|
@Override public void set(int value) { firstVisibleTab = value; }
|
||||||
|
});
|
||||||
|
addDataSlot(new DataSlot() {
|
||||||
|
@Override public int get() { return WritingDeskMenu.this.blockEntity == null
|
||||||
|
? syncedActiveCollection : WritingDeskMenu.this.blockEntity.activeCollection(); }
|
||||||
|
@Override public void set(int value) { syncedActiveCollection = value; }
|
||||||
|
});
|
||||||
|
addDataSlot(new DataSlot() {
|
||||||
|
@Override public int get() { return WritingDeskMenu.this.blockEntity == null
|
||||||
|
? syncedInkRemaining : WritingDeskMenu.this.blockEntity.inkRemaining(); }
|
||||||
|
@Override public void set(int value) { syncedInkRemaining = value; }
|
||||||
|
});
|
||||||
|
for (int i = 0; i < WritingDeskBlockEntity.COLLECTIONS; i++) {
|
||||||
|
addSlot(new DeskSlot(desk, i, 37, 34 + (i % VISIBLE_TABS) * 37, 1));
|
||||||
|
}
|
||||||
|
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.INK, 385, 28, 16));
|
||||||
|
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.RETURN, 385, 80, 64));
|
||||||
|
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.PAPER, 241, 28, 64));
|
||||||
|
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.LEGACY_OUTPUT, -1000, -1000, 64));
|
||||||
|
addSlot(new CopySlot(result, 0, 241, 80));
|
||||||
|
for (int row = 0; row < 3; row++) {
|
||||||
|
for (int column = 0; column < 9; column++) {
|
||||||
|
addSlot(new Slot(inventory, column + row * 9 + 9, 241 + column * 18, 104 + row * 18));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int column = 0; column < 9; column++) {
|
||||||
|
addSlot(new Slot(inventory, column, 241 + column * 18, 162));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int activeCollection() { return syncedActiveCollection; }
|
||||||
|
public int firstVisibleTab() { return firstVisibleTab; }
|
||||||
|
public int inkRemaining() { return syncedInkRemaining; }
|
||||||
|
public int pageCapacity(int collectionIndex) {
|
||||||
|
if (collectionIndex < 0 || collectionIndex >= WritingDeskBlockEntity.COLLECTIONS) return 0;
|
||||||
|
ItemStack collection = desk.getItem(collectionIndex);
|
||||||
|
return collection.is(ModItems.NOTEBOOK.get()) ? NotebookContents.MAX_PAGES
|
||||||
|
: collection.is(ModItems.PAGE.get()) ? 1 : 0;
|
||||||
|
}
|
||||||
|
public List<SymbolDisplayMetadata> catalog() { return catalog; }
|
||||||
|
public void setCatalog(List<SymbolDisplayMetadata> symbols) { catalog = List.copyOf(symbols); }
|
||||||
|
|
||||||
|
public static int movePageButtonId(int sourceCollection, int sourcePage, int targetCollection, int targetPage) {
|
||||||
|
int source = sourceCollection * NotebookContents.MAX_PAGES + sourcePage;
|
||||||
|
int target = targetCollection * NotebookContents.MAX_PAGES + targetPage;
|
||||||
|
return MOVE_PAGE_BUTTON_BASE + source * PAGE_ADDRESS_COUNT + target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** targetSlot is -1 for the cursor, otherwise a visible player or desk-tab slot. */
|
||||||
|
public static int extractPageButtonId(int sourceCollection, int sourcePage, int targetSlot) {
|
||||||
|
int source = sourceCollection * NotebookContents.MAX_PAGES + sourcePage;
|
||||||
|
return EXTRACT_PAGE_BUTTON_BASE + source * EXTRACT_TARGET_COUNT + targetSlot + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean clickMenuButton(Player player, int id) {
|
||||||
|
if (blockEntity == null || !stillValid(player)) return false;
|
||||||
|
if (id == PREVIOUS_TABS || id == NEXT_TABS) {
|
||||||
|
firstVisibleTab = Math.clamp(firstVisibleTab + (id == NEXT_TABS ? VISIBLE_TABS : -VISIBLE_TABS),
|
||||||
|
0, WritingDeskBlockEntity.COLLECTIONS - VISIBLE_TABS);
|
||||||
|
return true;
|
||||||
|
} else if (id >= 0 && id < WritingDeskBlockEntity.COLLECTIONS) {
|
||||||
|
if (id < firstVisibleTab || id >= firstVisibleTab + VISIBLE_TABS) return false;
|
||||||
|
blockEntity.setActiveCollection(id);
|
||||||
|
selectedPage = 0;
|
||||||
|
selectedCollection = -1;
|
||||||
|
selectedSymbol = null;
|
||||||
|
} else if (id >= EXTRACT_PAGE_BUTTON_BASE
|
||||||
|
&& id < EXTRACT_PAGE_BUTTON_BASE + PAGE_ADDRESS_COUNT * EXTRACT_TARGET_COUNT) {
|
||||||
|
int offset = id - EXTRACT_PAGE_BUTTON_BASE;
|
||||||
|
int source = offset / EXTRACT_TARGET_COUNT;
|
||||||
|
int targetSlot = offset % EXTRACT_TARGET_COUNT - 1;
|
||||||
|
if (!extractPage(source / NotebookContents.MAX_PAGES, source % NotebookContents.MAX_PAGES,
|
||||||
|
targetSlot)) return false;
|
||||||
|
} else if (id >= MOVE_PAGE_BUTTON_BASE && id < MOVE_PAGE_BUTTON_BASE + PAGE_ADDRESS_COUNT * PAGE_ADDRESS_COUNT) {
|
||||||
|
int offset = id - MOVE_PAGE_BUTTON_BASE;
|
||||||
|
int source = offset / PAGE_ADDRESS_COUNT;
|
||||||
|
int target = offset % PAGE_ADDRESS_COUNT;
|
||||||
|
if (!movePage(source / NotebookContents.MAX_PAGES, source % NotebookContents.MAX_PAGES,
|
||||||
|
target / NotebookContents.MAX_PAGES, target % NotebookContents.MAX_PAGES)) return false;
|
||||||
|
} else if (id >= PLACE_PAGE_BUTTON_BASE
|
||||||
|
&& id < PLACE_PAGE_BUTTON_BASE + WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES) {
|
||||||
|
int offset = id - PLACE_PAGE_BUTTON_BASE;
|
||||||
|
if (!placeCarriedPage(offset / NotebookContents.MAX_PAGES, offset % NotebookContents.MAX_PAGES)) return false;
|
||||||
|
} else if (id >= PAGE_BUTTON_BASE && id < PAGE_BUTTON_BASE + NotebookContents.MAX_PAGES) {
|
||||||
|
selectedPage = id - PAGE_BUTTON_BASE;
|
||||||
|
selectedCollection = -1;
|
||||||
|
selectedSymbol = null;
|
||||||
|
} else if (id >= ALL_PAGE_BUTTON_BASE && id < ALL_PAGE_BUTTON_BASE + WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES) {
|
||||||
|
int offset = id - ALL_PAGE_BUTTON_BASE;
|
||||||
|
int collection = offset / NotebookContents.MAX_PAGES;
|
||||||
|
int page = offset % NotebookContents.MAX_PAGES;
|
||||||
|
if (pageAt(collection, page).isEmpty()) return false;
|
||||||
|
selectedCollection = collection;
|
||||||
|
selectedPage = page;
|
||||||
|
selectedSymbol = null;
|
||||||
|
} else if (id >= CATALOG_BUTTON_BASE && id < CATALOG_BUTTON_BASE + catalog.size()) {
|
||||||
|
List<SymbolDisplayMetadata> available = WritingDeskCatalog.available(blockEntity);
|
||||||
|
if (!catalog.equals(available)) {
|
||||||
|
refreshCatalog(available);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
selectedSymbol = available.get(id - CATALOG_BUTTON_BASE).id();
|
||||||
|
} else return false;
|
||||||
|
refreshOutput();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean placeCarriedPage(int collectionIndex, int pageIndex) {
|
||||||
|
// Resolve the actual storage position on the server, never a filtered list index.
|
||||||
|
ItemStack carried = getCarried();
|
||||||
|
if (clientSide || !carried.is(ModItems.PAGE.get())) return false;
|
||||||
|
ItemStack collection = desk.getItem(collectionIndex);
|
||||||
|
if (collection.is(ModItems.NOTEBOOK.get())) {
|
||||||
|
var pages = new java.util.ArrayList<>(collection.getOrDefault(
|
||||||
|
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages());
|
||||||
|
while (pages.size() <= pageIndex) pages.add(ItemStack.EMPTY);
|
||||||
|
if (!pages.get(pageIndex).isEmpty()) return false;
|
||||||
|
pages.set(pageIndex, carried.copyWithCount(1));
|
||||||
|
ItemStack updated = collection.copy();
|
||||||
|
updated.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(pages));
|
||||||
|
desk.setItem(collectionIndex, updated);
|
||||||
|
} else return false;
|
||||||
|
carried.shrink(1);
|
||||||
|
setCarried(carried.isEmpty() ? ItemStack.EMPTY : carried);
|
||||||
|
selectedCollection = collectionIndex;
|
||||||
|
selectedPage = pageIndex;
|
||||||
|
selectedSymbol = null;
|
||||||
|
refreshCatalog();
|
||||||
|
broadcastChanges();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean movePage(int sourceCollection, int sourcePage, int targetCollection, int targetPage) {
|
||||||
|
if (!getCarried().isEmpty() || sourceCollection == targetCollection && sourcePage == targetPage) return false;
|
||||||
|
ItemStack sourceBook = desk.getItem(sourceCollection);
|
||||||
|
ItemStack targetBook = desk.getItem(targetCollection);
|
||||||
|
boolean sourceNotebook = sourceBook.is(ModItems.NOTEBOOK.get());
|
||||||
|
boolean targetNotebook = targetBook.is(ModItems.NOTEBOOK.get());
|
||||||
|
if ((!sourceNotebook && (!sourceBook.is(ModItems.PAGE.get()) || sourcePage != 0))
|
||||||
|
|| (!targetNotebook && (!targetBook.is(ModItems.PAGE.get()) || targetPage != 0))) return false;
|
||||||
|
var sourcePages = sourceNotebook ? new java.util.ArrayList<>(sourceBook.getOrDefault(
|
||||||
|
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()) : null;
|
||||||
|
if (sourceNotebook && (sourcePage >= sourcePages.size() || sourcePages.get(sourcePage).isEmpty())) return false;
|
||||||
|
ItemStack moving = sourceNotebook ? sourcePages.get(sourcePage) : sourceBook;
|
||||||
|
if (moving.getCount() != 1) return false;
|
||||||
|
if (!targetNotebook && targetBook.getCount() != 1) return false;
|
||||||
|
var targetPages = targetNotebook ? sourceCollection == targetCollection ? sourcePages
|
||||||
|
: new java.util.ArrayList<>(targetBook.getOrDefault(
|
||||||
|
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()) : null;
|
||||||
|
if (targetNotebook) while (targetPages.size() <= targetPage) targetPages.add(ItemStack.EMPTY);
|
||||||
|
ItemStack displaced = targetNotebook ? targetPages.get(targetPage) : targetBook;
|
||||||
|
if (targetNotebook) targetPages.set(targetPage, moving);
|
||||||
|
if (sourceNotebook) {
|
||||||
|
sourcePages.set(sourcePage, displaced);
|
||||||
|
ItemStack updatedSource = sourceBook.copy();
|
||||||
|
updatedSource.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(sourcePages));
|
||||||
|
desk.setItem(sourceCollection, updatedSource);
|
||||||
|
} else desk.setItem(sourceCollection, displaced.isEmpty() ? ItemStack.EMPTY : displaced.copyWithCount(1));
|
||||||
|
if (targetNotebook && sourceCollection != targetCollection) {
|
||||||
|
ItemStack updatedTarget = targetBook.copy();
|
||||||
|
updatedTarget.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(targetPages));
|
||||||
|
desk.setItem(targetCollection, updatedTarget);
|
||||||
|
} else if (!targetNotebook) desk.setItem(targetCollection, moving.copyWithCount(1));
|
||||||
|
selectedCollection = targetCollection;
|
||||||
|
selectedPage = targetPage;
|
||||||
|
selectedSymbol = null;
|
||||||
|
refreshCatalog();
|
||||||
|
broadcastChanges();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean extractPage(int sourceCollection, int sourcePage, int targetSlot) {
|
||||||
|
if (!getCarried().isEmpty() || targetSlot < -1 || targetSlot >= slots.size()) return false;
|
||||||
|
ItemStack sourceBook = desk.getItem(sourceCollection);
|
||||||
|
boolean sourceNotebook = sourceBook.is(ModItems.NOTEBOOK.get());
|
||||||
|
if (!sourceNotebook && (!sourceBook.is(ModItems.PAGE.get()) || sourcePage != 0)) return false;
|
||||||
|
var sourcePages = sourceNotebook ? new java.util.ArrayList<>(sourceBook.getOrDefault(
|
||||||
|
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()) : null;
|
||||||
|
if (sourceNotebook && (sourcePage >= sourcePages.size() || sourcePages.get(sourcePage).isEmpty())) return false;
|
||||||
|
ItemStack moving = sourceNotebook ? sourcePages.get(sourcePage).copyWithCount(1) : sourceBook.copyWithCount(1);
|
||||||
|
if (!sourceNotebook && sourceBook.getCount() != 1) return false;
|
||||||
|
|
||||||
|
if (targetSlot == -1) {
|
||||||
|
setCarried(moving);
|
||||||
|
} else {
|
||||||
|
if (targetSlot >= WritingDeskBlockEntity.COLLECTIONS && targetSlot < PLAYER_START) return false;
|
||||||
|
Slot target = slots.get(targetSlot);
|
||||||
|
if (!target.isActive() || !target.mayPlace(moving)) return false;
|
||||||
|
ItemStack current = target.getItem();
|
||||||
|
if (targetSlot < WritingDeskBlockEntity.COLLECTIONS && current.is(ModItems.NOTEBOOK.get())) {
|
||||||
|
var targetPages = targetSlot == sourceCollection ? sourcePages
|
||||||
|
: new java.util.ArrayList<>(current.getOrDefault(
|
||||||
|
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages());
|
||||||
|
int position = -1;
|
||||||
|
for (int i = 0; i < NotebookContents.MAX_PAGES; i++) {
|
||||||
|
if (i >= targetPages.size() || targetPages.get(i).isEmpty()) {
|
||||||
|
position = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (position < 0) return false;
|
||||||
|
while (targetPages.size() <= position) targetPages.add(ItemStack.EMPTY);
|
||||||
|
targetPages.set(position, moving);
|
||||||
|
if (targetSlot != sourceCollection) {
|
||||||
|
ItemStack updatedTarget = current.copy();
|
||||||
|
updatedTarget.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(targetPages));
|
||||||
|
target.setByPlayer(updatedTarget);
|
||||||
|
}
|
||||||
|
} else if (current.isEmpty()) {
|
||||||
|
target.setByPlayer(moving);
|
||||||
|
} else if (targetSlot >= PLAYER_START && ItemStack.isSameItemSameComponents(current, moving)
|
||||||
|
&& current.getCount() < target.getMaxStackSize(moving)) {
|
||||||
|
target.setByPlayer(current.copyWithCount(current.getCount() + 1));
|
||||||
|
} else return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceNotebook) {
|
||||||
|
sourcePages.set(sourcePage, ItemStack.EMPTY);
|
||||||
|
ItemStack updatedSource = sourceBook.copy();
|
||||||
|
updatedSource.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(sourcePages));
|
||||||
|
desk.setItem(sourceCollection, updatedSource);
|
||||||
|
} else desk.setItem(sourceCollection, ItemStack.EMPTY);
|
||||||
|
selectedCollection = sourceCollection;
|
||||||
|
selectedPage = sourcePage;
|
||||||
|
selectedSymbol = null;
|
||||||
|
refreshCatalog();
|
||||||
|
broadcastChanges();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean stillValid(Player player) {
|
||||||
|
return blockEntity != null && blockEntity.stillValid(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public ItemStack quickMoveStack(Player player, int index) {
|
||||||
|
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
|
||||||
|
if (index == COPY_RESULT) {
|
||||||
|
refreshOutput();
|
||||||
|
if (!canCopy()) return ItemStack.EMPTY;
|
||||||
|
ItemStack copy = selectedSource().copyWithCount(1);
|
||||||
|
ItemStack movingCopy = copy.copy();
|
||||||
|
if (!moveItemStackTo(movingCopy, PLAYER_START, slots.size(), true) || !movingCopy.isEmpty()) {
|
||||||
|
return ItemStack.EMPTY;
|
||||||
|
}
|
||||||
|
blockEntity.consumeCopyMaterials();
|
||||||
|
refreshOutput();
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
Slot slot = slots.get(index);
|
||||||
|
if (!slot.isActive()) return ItemStack.EMPTY;
|
||||||
|
if (!slot.hasItem()) return ItemStack.EMPTY;
|
||||||
|
ItemStack moving = slot.getItem();
|
||||||
|
ItemStack original = moving.copy();
|
||||||
|
if (index < COPY_RESULT) {
|
||||||
|
if (!moveItemStackTo(moving, PLAYER_START, slots.size(), true)) return ItemStack.EMPTY;
|
||||||
|
} else if (!moveIntoDesk(moving)) {
|
||||||
|
return ItemStack.EMPTY;
|
||||||
|
}
|
||||||
|
if (moving.isEmpty()) slot.setByPlayer(ItemStack.EMPTY);
|
||||||
|
else slot.setChanged();
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean moveIntoDesk(ItemStack moving) {
|
||||||
|
if (moving.is(ModItems.INK_VIAL.get())) return moveItemStackTo(moving,
|
||||||
|
WritingDeskBlockEntity.INK, WritingDeskBlockEntity.INK + 1, false);
|
||||||
|
if (moving.is(ModItems.NOTEBOOK.get())) return moveItemStackTo(moving, firstVisibleTab, firstVisibleTab + VISIBLE_TABS, false);
|
||||||
|
if (moving.is(Items.PAPER))
|
||||||
|
return moveItemStackTo(moving, WritingDeskBlockEntity.PAPER,
|
||||||
|
WritingDeskBlockEntity.PAPER + 1, false);
|
||||||
|
if (moving.is(ModItems.PAGE.get())) return moveItemStackTo(moving, firstVisibleTab, firstVisibleTab + VISIBLE_TABS, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ItemStack selectedSource() {
|
||||||
|
if (selectedSymbol != null) {
|
||||||
|
SymbolReference reference = new SymbolReference(selectedSymbol);
|
||||||
|
return SymbolCatalog.isKnown(reference)
|
||||||
|
&& blockEntity != null
|
||||||
|
&& WritingDeskCatalog.available(blockEntity).stream()
|
||||||
|
.anyMatch(entry -> entry.id().equals(selectedSymbol))
|
||||||
|
? PageItem.createSymbolPage(reference) : ItemStack.EMPTY;
|
||||||
|
}
|
||||||
|
int collectionIndex = selectedCollection >= 0 ? selectedCollection
|
||||||
|
: blockEntity == null ? syncedActiveCollection : blockEntity.activeCollection();
|
||||||
|
return pageAt(collectionIndex, selectedPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ItemStack pageAt(int collectionIndex, int pageIndex) {
|
||||||
|
if (collectionIndex < 0 || collectionIndex >= WritingDeskBlockEntity.COLLECTIONS
|
||||||
|
|| pageIndex < 0 || pageIndex >= NotebookContents.MAX_PAGES) return ItemStack.EMPTY;
|
||||||
|
ItemStack collection = desk.getItem(collectionIndex);
|
||||||
|
if (collection.is(ModItems.PAGE.get())) return pageIndex == 0 ? collection : ItemStack.EMPTY;
|
||||||
|
if (collection.is(ModItems.NOTEBOOK.get())) {
|
||||||
|
List<ItemStack> pages = collection.getOrDefault(
|
||||||
|
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
||||||
|
return pageIndex < pages.size() ? pages.get(pageIndex) : ItemStack.EMPTY;
|
||||||
|
}
|
||||||
|
return ItemStack.EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean canCopy() {
|
||||||
|
ItemStack source = selectedSource();
|
||||||
|
return blockEntity != null && source.is(ModItems.PAGE.get())
|
||||||
|
&& PageItem.content(source).kind() != PageContent.Kind.BLANK
|
||||||
|
&& PageItem.isUsableWriting(source) && blockEntity.canConsumeCopyMaterials();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshOutput() {
|
||||||
|
if (clientSide) return;
|
||||||
|
if (blockEntity != null) blockEntity.setDisplayPage(selectedSource());
|
||||||
|
ItemStack preview = canCopy() ? selectedSource().copyWithCount(1) : ItemStack.EMPTY;
|
||||||
|
if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshCatalog() {
|
||||||
|
if (clientSide || blockEntity == null) return;
|
||||||
|
refreshCatalog(WritingDeskCatalog.available(blockEntity));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshCatalog(List<SymbolDisplayMetadata> current) {
|
||||||
|
if (clientSide || blockEntity == null) return;
|
||||||
|
if (!current.equals(catalog)) {
|
||||||
|
catalog = current;
|
||||||
|
if (serverViewer != null && serverViewer.containerMenu == this) {
|
||||||
|
PacketDistributor.sendToPlayer(serverViewer, new WritingDeskCatalogPayload(containerId, catalog));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void broadcastChanges() {
|
||||||
|
refreshCatalog();
|
||||||
|
refreshOutput();
|
||||||
|
super.broadcastChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void clicked(int slotId, int button, net.minecraft.world.inventory.ClickType clickType,
|
||||||
|
Player player) {
|
||||||
|
if (!stillValid(player)) {
|
||||||
|
player.closeContainer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refreshCatalog();
|
||||||
|
refreshOutput();
|
||||||
|
if (slotId >= 0 && slotId < slots.size() && !slots.get(slotId).isActive()) return;
|
||||||
|
super.clicked(slotId, button, clickType, player);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class CopySlot extends Slot {
|
||||||
|
private CopySlot(Container container, int index, int x, int y) { super(container, index, x, y); }
|
||||||
|
@Override public boolean mayPlace(ItemStack stack) { return false; }
|
||||||
|
@Override public boolean mayPickup(Player player) {
|
||||||
|
return clientSide ? hasItem() : WritingDeskMenu.this.stillValid(player) && canCopy();
|
||||||
|
}
|
||||||
|
@Override public void onTake(Player player, ItemStack stack) {
|
||||||
|
if (!clientSide && blockEntity.consumeCopyMaterials()) refreshOutput();
|
||||||
|
super.onTake(player, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class DeskSlot extends Slot {
|
||||||
|
private final int limit;
|
||||||
|
private DeskSlot(Container container, int index, int x, int y, int limit) {
|
||||||
|
super(container, index, x, y);
|
||||||
|
this.limit = limit;
|
||||||
|
}
|
||||||
|
@Override public boolean isActive() {
|
||||||
|
int slot = getContainerSlot();
|
||||||
|
return slot >= WritingDeskBlockEntity.COLLECTIONS
|
||||||
|
|| slot >= firstVisibleTab && slot < firstVisibleTab + VISIBLE_TABS;
|
||||||
|
}
|
||||||
|
@Override public boolean mayPickup(Player player) { return isActive(); }
|
||||||
|
@Override public boolean mayPlace(ItemStack stack) { return isActive() && container.canPlaceItem(getContainerSlot(), stack); }
|
||||||
|
@Override public int getMaxStackSize() { return limit; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,17 @@ import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
|||||||
import net.dnorberg.rivencraft.client.ClientLinkPreviewHandler;
|
import net.dnorberg.rivencraft.client.ClientLinkPreviewHandler;
|
||||||
import net.dnorberg.rivencraft.client.ClientBookInteractionHandler;
|
import net.dnorberg.rivencraft.client.ClientBookInteractionHandler;
|
||||||
import net.dnorberg.rivencraft.client.ClientAgeCelestialAngles;
|
import net.dnorberg.rivencraft.client.ClientAgeCelestialAngles;
|
||||||
|
import net.dnorberg.rivencraft.client.ClientWritingDeskCatalog;
|
||||||
import net.dnorberg.rivencraft.event.AgeCelestialAngleSync;
|
import net.dnorberg.rivencraft.event.AgeCelestialAngleSync;
|
||||||
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
|
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
|
||||||
import net.dnorberg.rivencraft.item.LinkingBookItem;
|
import net.dnorberg.rivencraft.item.LinkingBookItem;
|
||||||
import net.dnorberg.rivencraft.menu.NotebookMenu;
|
import net.dnorberg.rivencraft.menu.BookBinderMenu;
|
||||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
import net.dnorberg.rivencraft.registry.ModItems;
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||||
import net.minecraft.core.component.DataComponents;
|
import net.minecraft.core.component.DataComponents;
|
||||||
import net.minecraft.core.BlockPos;
|
import net.minecraft.core.BlockPos;
|
||||||
import net.minecraft.core.GlobalPos;
|
import net.minecraft.core.GlobalPos;
|
||||||
@@ -49,9 +54,7 @@ public final class ModNetworking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void register(RegisterPayloadHandlersEvent event) {
|
public static void register(RegisterPayloadHandlersEvent event) {
|
||||||
PayloadRegistrar registrar = event.registrar("10");
|
PayloadRegistrar registrar = event.registrar("13");
|
||||||
registrar.playToServer(RenameNotebookPagePayload.TYPE, RenameNotebookPagePayload.STREAM_CODEC,
|
|
||||||
ModNetworking::renameNotebookPage);
|
|
||||||
registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC,
|
registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC,
|
||||||
ModNetworking::requestDescriptiveBookScreen);
|
ModNetworking::requestDescriptiveBookScreen);
|
||||||
registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC,
|
registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC,
|
||||||
@@ -71,16 +74,18 @@ public final class ModNetworking {
|
|||||||
ClientLinkPreviewHandler::handle);
|
ClientLinkPreviewHandler::handle);
|
||||||
registrar.playToClient(AgeCelestialAnglePayload.TYPE, AgeCelestialAnglePayload.STREAM_CODEC,
|
registrar.playToClient(AgeCelestialAnglePayload.TYPE, AgeCelestialAnglePayload.STREAM_CODEC,
|
||||||
ClientAgeCelestialAngles::handle);
|
ClientAgeCelestialAngles::handle);
|
||||||
|
registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC,
|
||||||
|
ClientWritingDeskCatalog::handle);
|
||||||
|
registrar.playToServer(SetBookBinderTitlePayload.TYPE, SetBookBinderTitlePayload.STREAM_CODEC,
|
||||||
|
ModNetworking::setBookBinderTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void renameNotebookPage(RenameNotebookPagePayload payload, IPayloadContext context) {
|
private static void setBookBinderTitle(SetBookBinderTitlePayload payload, IPayloadContext context) {
|
||||||
if (!(context.player() instanceof ServerPlayer player)
|
if (context.player() instanceof ServerPlayer player
|
||||||
|| !(player.containerMenu instanceof NotebookMenu menu)
|
&& player.containerMenu instanceof BookBinderMenu menu
|
||||||
|| menu.containerId != payload.containerId()
|
&& menu.containerId == payload.containerId()) {
|
||||||
|| payload.name().length() > MAX_BOOK_TITLE_LENGTH) {
|
menu.setTitle(player, payload.title());
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
menu.renamePage(payload.slot(), normalizeTitle(payload.name()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void requestDescriptiveBookScreen(RequestDescriptiveBookScreenPayload payload, IPayloadContext context) {
|
private static void requestDescriptiveBookScreen(RequestDescriptiveBookScreenPayload payload, IPayloadContext context) {
|
||||||
@@ -102,10 +107,15 @@ public final class ModNetworking {
|
|||||||
// Requests run on the same server thread as Age creation. A request queued
|
// Requests run on the same server thread as Age creation. A request queued
|
||||||
// during generation can only open the finished book, never a stale client copy.
|
// during generation can only open the finished book, never a stale client copy.
|
||||||
player.inventoryMenu.broadcastChanges();
|
player.inventoryMenu.broadcastChanges();
|
||||||
|
List<SymbolDisplayMetadata> writingSymbols = access.stack()
|
||||||
|
.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY).entries().stream()
|
||||||
|
.filter(entry -> entry.kind() == WritingEntry.Kind.SYMBOL)
|
||||||
|
.map(entry -> SymbolCatalog.display(entry.symbol().orElseThrow()))
|
||||||
|
.toList();
|
||||||
PacketDistributor.sendToPlayer(player, new OpenDescriptiveBookScreenPayload(
|
PacketDistributor.sendToPlayer(player, new OpenDescriptiveBookScreenPayload(
|
||||||
payload.fromStand(), payload.offhand(),
|
payload.fromStand(), payload.offhand(),
|
||||||
GlobalPos.of(player.level().dimension(), payload.standPos()), access.stack().copy(),
|
GlobalPos.of(player.level().dimension(), payload.standPos()), access.stack().copy(),
|
||||||
definition == null ? null : definition.traits()));
|
definition == null ? null : definition.traits(), writingSymbols));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void bindDescriptiveBook(BindDescriptiveBookPayload payload, IPayloadContext context) {
|
private static void bindDescriptiveBook(BindDescriptiveBookPayload payload, IPayloadContext context) {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package net.dnorberg.rivencraft.network;
|
package net.dnorberg.rivencraft.network;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import net.dnorberg.rivencraft.Rivencraft;
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
import net.dnorberg.rivencraft.book.AgeTraits;
|
import net.dnorberg.rivencraft.book.AgeTraits;
|
||||||
|
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
|
||||||
import net.minecraft.core.GlobalPos;
|
import net.minecraft.core.GlobalPos;
|
||||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
import net.minecraft.network.codec.StreamCodec;
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
@@ -16,7 +20,8 @@ public record OpenDescriptiveBookScreenPayload(
|
|||||||
boolean offhand,
|
boolean offhand,
|
||||||
GlobalPos source,
|
GlobalPos source,
|
||||||
ItemStack book,
|
ItemStack book,
|
||||||
@Nullable AgeTraits traits
|
@Nullable AgeTraits traits,
|
||||||
|
List<SymbolDisplayMetadata> writingSymbols
|
||||||
)
|
)
|
||||||
implements CustomPacketPayload {
|
implements CustomPacketPayload {
|
||||||
public static final Type<OpenDescriptiveBookScreenPayload> TYPE = new Type<>(
|
public static final Type<OpenDescriptiveBookScreenPayload> TYPE = new Type<>(
|
||||||
@@ -40,6 +45,11 @@ public record OpenDescriptiveBookScreenPayload(
|
|||||||
buffer.writeFloat(traits.celestialAngle());
|
buffer.writeFloat(traits.celestialAngle());
|
||||||
buffer.writeFloat(traits.celestialPhase());
|
buffer.writeFloat(traits.celestialPhase());
|
||||||
}
|
}
|
||||||
|
if (payload.writingSymbols().size() > OrderedWriting.MAX_ENTRIES) {
|
||||||
|
throw new IllegalArgumentException("Too many book symbol displays");
|
||||||
|
}
|
||||||
|
buffer.writeVarInt(payload.writingSymbols().size());
|
||||||
|
payload.writingSymbols().forEach(symbol -> SymbolDisplayMetadata.STREAM_CODEC.encode(buffer, symbol));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static OpenDescriptiveBookScreenPayload decode(RegistryFriendlyByteBuf buffer) {
|
private static OpenDescriptiveBookScreenPayload decode(RegistryFriendlyByteBuf buffer) {
|
||||||
@@ -57,7 +67,16 @@ public record OpenDescriptiveBookScreenPayload(
|
|||||||
buffer.readFloat()
|
buffer.readFloat()
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
return new OpenDescriptiveBookScreenPayload(fromStand, offhand, source, book, traits);
|
int count = buffer.readVarInt();
|
||||||
|
if (count < 0 || count > OrderedWriting.MAX_ENTRIES) {
|
||||||
|
throw new IllegalArgumentException("Invalid book symbol display count " + count);
|
||||||
|
}
|
||||||
|
List<SymbolDisplayMetadata> writingSymbols = new ArrayList<>(count);
|
||||||
|
for (int index = 0; index < count; index++) {
|
||||||
|
writingSymbols.add(SymbolDisplayMetadata.STREAM_CODEC.decode(buffer));
|
||||||
|
}
|
||||||
|
return new OpenDescriptiveBookScreenPayload(fromStand, offhand, source, book, traits,
|
||||||
|
List.copyOf(writingSymbols));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
package net.dnorberg.rivencraft.network;
|
|
||||||
|
|
||||||
import io.netty.buffer.ByteBuf;
|
|
||||||
import net.dnorberg.rivencraft.Rivencraft;
|
|
||||||
import net.minecraft.network.codec.ByteBufCodecs;
|
|
||||||
import net.minecraft.network.codec.StreamCodec;
|
|
||||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
|
||||||
import net.minecraft.resources.ResourceLocation;
|
|
||||||
|
|
||||||
public record RenameNotebookPagePayload(int containerId, int slot, String name) implements CustomPacketPayload {
|
|
||||||
public static final Type<RenameNotebookPagePayload> TYPE = new Type<>(
|
|
||||||
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "rename_notebook_page"));
|
|
||||||
public static final StreamCodec<ByteBuf, RenameNotebookPagePayload> STREAM_CODEC = StreamCodec.composite(
|
|
||||||
ByteBufCodecs.VAR_INT, RenameNotebookPagePayload::containerId,
|
|
||||||
ByteBufCodecs.VAR_INT, RenameNotebookPagePayload::slot,
|
|
||||||
ByteBufCodecs.STRING_UTF8, RenameNotebookPagePayload::name,
|
|
||||||
RenameNotebookPagePayload::new);
|
|
||||||
|
|
||||||
@Override public Type<? extends CustomPacketPayload> type() { return TYPE; }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package net.dnorberg.rivencraft.network;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
|
||||||
|
public record SetBookBinderTitlePayload(int containerId, String title) implements CustomPacketPayload {
|
||||||
|
public static final Type<SetBookBinderTitlePayload> TYPE = new Type<>(
|
||||||
|
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "set_book_binder_title"));
|
||||||
|
public static final StreamCodec<RegistryFriendlyByteBuf, SetBookBinderTitlePayload> STREAM_CODEC =
|
||||||
|
StreamCodec.composite(ByteBufCodecs.VAR_INT, SetBookBinderTitlePayload::containerId,
|
||||||
|
ByteBufCodecs.STRING_UTF8, SetBookBinderTitlePayload::title,
|
||||||
|
SetBookBinderTitlePayload::new);
|
||||||
|
|
||||||
|
@Override public Type<? extends CustomPacketPayload> type() { return TYPE; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package net.dnorberg.rivencraft.network;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
|
||||||
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
|
||||||
|
/** Current server-approved symbols from this open Writing Desk menu. */
|
||||||
|
public record WritingDeskCatalogPayload(int containerId, List<SymbolDisplayMetadata> symbols)
|
||||||
|
implements CustomPacketPayload {
|
||||||
|
public static final Type<WritingDeskCatalogPayload> TYPE = new Type<>(
|
||||||
|
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "writing_desk_catalog"));
|
||||||
|
public static final StreamCodec<RegistryFriendlyByteBuf, WritingDeskCatalogPayload> STREAM_CODEC =
|
||||||
|
StreamCodec.of(
|
||||||
|
(buffer, payload) -> {
|
||||||
|
buffer.writeVarInt(payload.containerId());
|
||||||
|
WritingDeskCatalog.write(buffer, payload.symbols());
|
||||||
|
},
|
||||||
|
buffer -> new WritingDeskCatalogPayload(buffer.readVarInt(), WritingDeskCatalog.read(buffer)));
|
||||||
|
|
||||||
|
@Override public Type<? extends CustomPacketPayload> type() { return TYPE; }
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ package net.dnorberg.rivencraft.registry;
|
|||||||
|
|
||||||
import net.dnorberg.rivencraft.Rivencraft;
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
||||||
import net.minecraft.core.registries.Registries;
|
import net.minecraft.core.registries.Registries;
|
||||||
import net.minecraft.world.level.block.entity.BlockEntityType;
|
import net.minecraft.world.level.block.entity.BlockEntityType;
|
||||||
import net.neoforged.bus.api.IEventBus;
|
import net.neoforged.bus.api.IEventBus;
|
||||||
@@ -16,6 +19,15 @@ public final class ModBlockEntities {
|
|||||||
BLOCK_ENTITIES.register("book_stand", () -> BlockEntityType.Builder
|
BLOCK_ENTITIES.register("book_stand", () -> BlockEntityType.Builder
|
||||||
.of(BookStandBlockEntity::new, ModBlocks.BOOK_STAND.get())
|
.of(BookStandBlockEntity::new, ModBlocks.BOOK_STAND.get())
|
||||||
.build(null));
|
.build(null));
|
||||||
|
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<WritingDeskBlockEntity>> WRITING_DESK =
|
||||||
|
BLOCK_ENTITIES.register("writing_desk", () -> BlockEntityType.Builder
|
||||||
|
.of(WritingDeskBlockEntity::new, ModBlocks.WRITING_DESK.get()).build(null));
|
||||||
|
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<BookBinderBlockEntity>> BOOK_BINDER =
|
||||||
|
BLOCK_ENTITIES.register("book_binder", () -> BlockEntityType.Builder
|
||||||
|
.of(BookBinderBlockEntity::new, ModBlocks.BOOK_BINDER.get()).build(null));
|
||||||
|
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<InkMixerBlockEntity>> INK_MIXER =
|
||||||
|
BLOCK_ENTITIES.register("ink_mixer", () -> BlockEntityType.Builder
|
||||||
|
.of(InkMixerBlockEntity::new, ModBlocks.INK_MIXER.get()).build(null));
|
||||||
|
|
||||||
private ModBlockEntities() {
|
private ModBlockEntities() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package net.dnorberg.rivencraft.registry;
|
|||||||
|
|
||||||
import net.dnorberg.rivencraft.Rivencraft;
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
import net.dnorberg.rivencraft.block.BookStandBlock;
|
import net.dnorberg.rivencraft.block.BookStandBlock;
|
||||||
|
import net.dnorberg.rivencraft.block.BookBinderBlock;
|
||||||
|
import net.dnorberg.rivencraft.block.WritingDeskBlock;
|
||||||
|
import net.dnorberg.rivencraft.block.InkMixerBlock;
|
||||||
import net.minecraft.world.level.block.Blocks;
|
import net.minecraft.world.level.block.Blocks;
|
||||||
import net.minecraft.world.level.block.state.BlockBehaviour;
|
import net.minecraft.world.level.block.state.BlockBehaviour;
|
||||||
import net.neoforged.bus.api.IEventBus;
|
import net.neoforged.bus.api.IEventBus;
|
||||||
@@ -16,6 +19,18 @@ public final class ModBlocks {
|
|||||||
BookStandBlock::new,
|
BookStandBlock::new,
|
||||||
BlockBehaviour.Properties.ofFullCopy(Blocks.LECTERN)
|
BlockBehaviour.Properties.ofFullCopy(Blocks.LECTERN)
|
||||||
);
|
);
|
||||||
|
public static final DeferredBlock<WritingDeskBlock> WRITING_DESK = BLOCKS.registerBlock(
|
||||||
|
"writing_desk", WritingDeskBlock::new,
|
||||||
|
BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion()
|
||||||
|
);
|
||||||
|
public static final DeferredBlock<BookBinderBlock> BOOK_BINDER = BLOCKS.registerBlock(
|
||||||
|
"book_binder", BookBinderBlock::new,
|
||||||
|
BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion()
|
||||||
|
);
|
||||||
|
public static final DeferredBlock<InkMixerBlock> INK_MIXER = BLOCKS.registerBlock(
|
||||||
|
"ink_mixer", InkMixerBlock::new,
|
||||||
|
BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion()
|
||||||
|
);
|
||||||
|
|
||||||
private ModBlocks() {
|
private ModBlocks() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package net.dnorberg.rivencraft.registry;
|
package net.dnorberg.rivencraft.registry;
|
||||||
|
|
||||||
import net.dnorberg.rivencraft.Rivencraft;
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
import net.minecraft.core.registries.Registries;
|
import net.minecraft.core.registries.Registries;
|
||||||
import net.minecraft.network.chat.Component;
|
import net.minecraft.network.chat.Component;
|
||||||
import net.minecraft.world.item.CreativeModeTab;
|
import net.minecraft.world.item.CreativeModeTab;
|
||||||
@@ -19,8 +20,25 @@ public final class ModCreativeTabs {
|
|||||||
.displayItems((parameters, output) -> {
|
.displayItems((parameters, output) -> {
|
||||||
output.accept(ModItems.DESCRIPTIVE_BOOK.get());
|
output.accept(ModItems.DESCRIPTIVE_BOOK.get());
|
||||||
output.accept(ModItems.LINKING_BOOK.get());
|
output.accept(ModItems.LINKING_BOOK.get());
|
||||||
output.accept(ModItems.PAGE.get());
|
|
||||||
output.accept(ModItems.NOTEBOOK.get());
|
output.accept(ModItems.NOTEBOOK.get());
|
||||||
|
output.accept(ModItems.INK_VIAL.get());
|
||||||
|
output.accept(ModItems.WRITING_DESK.get());
|
||||||
|
output.accept(ModItems.INK_MIXER.get());
|
||||||
|
output.accept(ModItems.BOOK_BINDER.get());
|
||||||
|
})
|
||||||
|
.build());
|
||||||
|
|
||||||
|
public static final DeferredHolder<CreativeModeTab, CreativeModeTab> PAGES =
|
||||||
|
CREATIVE_TABS.register("rivencraft_pages", () -> CreativeModeTab.builder()
|
||||||
|
.title(Component.translatable("itemGroup.rivencraft.pages"))
|
||||||
|
.icon(PageItem::createLinkPanelPage)
|
||||||
|
.displayItems((parameters, output) -> {
|
||||||
|
output.accept(ModItems.PAGE.get());
|
||||||
|
output.accept(PageItem.createLinkPanelPage());
|
||||||
|
for (String id : new String[] {"terrain/standard", "biome/native", "lighting/normal",
|
||||||
|
"weather/normal", "celestial/normal_time", "feature/caves"}) {
|
||||||
|
output.accept(PageItem.createCreativeStarterPage(id));
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import net.dnorberg.rivencraft.book.LinkTarget;
|
|||||||
import net.dnorberg.rivencraft.book.AgeReference;
|
import net.dnorberg.rivencraft.book.AgeReference;
|
||||||
import net.dnorberg.rivencraft.writing.PageContent;
|
import net.dnorberg.rivencraft.writing.PageContent;
|
||||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||||
|
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||||
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
|
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
|
||||||
|
import com.mojang.serialization.Codec;
|
||||||
import net.minecraft.core.component.DataComponentType;
|
import net.minecraft.core.component.DataComponentType;
|
||||||
import net.minecraft.core.registries.Registries;
|
import net.minecraft.core.registries.Registries;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
@@ -60,6 +62,18 @@ public final class ModDataComponents {
|
|||||||
.networkSynchronized(NotebookContents.STREAM_CODEC)
|
.networkSynchronized(NotebookContents.STREAM_CODEC)
|
||||||
.cacheEncoding()
|
.cacheEncoding()
|
||||||
.build());
|
.build());
|
||||||
|
public static final DeferredHolder<DataComponentType<?>, DataComponentType<OrderedWriting>> BOOK_WRITING =
|
||||||
|
DATA_COMPONENTS.register("book_writing", () -> DataComponentType.<OrderedWriting>builder()
|
||||||
|
.persistent(OrderedWriting.CODEC)
|
||||||
|
.networkSynchronized(OrderedWriting.STREAM_CODEC)
|
||||||
|
.cacheEncoding()
|
||||||
|
.build());
|
||||||
|
public static final DeferredHolder<DataComponentType<?>, DataComponentType<Integer>> INK_REMAINING =
|
||||||
|
DATA_COMPONENTS.register("ink_remaining", () -> DataComponentType.<Integer>builder()
|
||||||
|
.persistent(Codec.intRange(0, 16))
|
||||||
|
.networkSynchronized(ByteBufCodecs.VAR_INT)
|
||||||
|
.cacheEncoding()
|
||||||
|
.build());
|
||||||
|
|
||||||
private ModDataComponents() {
|
private ModDataComponents() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import net.dnorberg.rivencraft.item.DescriptiveBookItem;
|
|||||||
import net.dnorberg.rivencraft.item.LinkingBookItem;
|
import net.dnorberg.rivencraft.item.LinkingBookItem;
|
||||||
import net.dnorberg.rivencraft.item.PageItem;
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
import net.dnorberg.rivencraft.item.NotebookItem;
|
import net.dnorberg.rivencraft.item.NotebookItem;
|
||||||
|
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||||
import net.minecraft.world.item.Item;
|
import net.minecraft.world.item.Item;
|
||||||
|
import net.minecraft.world.item.BlockItem;
|
||||||
import net.neoforged.bus.api.IEventBus;
|
import net.neoforged.bus.api.IEventBus;
|
||||||
import net.neoforged.neoforge.registries.DeferredItem;
|
import net.neoforged.neoforge.registries.DeferredItem;
|
||||||
import net.neoforged.neoforge.registries.DeferredRegister;
|
import net.neoforged.neoforge.registries.DeferredRegister;
|
||||||
@@ -33,6 +35,14 @@ public final class ModItems {
|
|||||||
NotebookItem::new,
|
NotebookItem::new,
|
||||||
new Item.Properties().stacksTo(1)
|
new Item.Properties().stacksTo(1)
|
||||||
);
|
);
|
||||||
|
public static final DeferredItem<InkVialItem> INK_VIAL = ITEMS.registerItem(
|
||||||
|
"ink_vial",
|
||||||
|
InkVialItem::new,
|
||||||
|
new Item.Properties().stacksTo(16)
|
||||||
|
);
|
||||||
|
public static final DeferredItem<BlockItem> WRITING_DESK = ITEMS.registerSimpleBlockItem(ModBlocks.WRITING_DESK);
|
||||||
|
public static final DeferredItem<BlockItem> BOOK_BINDER = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_BINDER);
|
||||||
|
public static final DeferredItem<BlockItem> INK_MIXER = ITEMS.registerSimpleBlockItem(ModBlocks.INK_MIXER);
|
||||||
|
|
||||||
private ModItems() {
|
private ModItems() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package net.dnorberg.rivencraft.registry;
|
|||||||
|
|
||||||
import net.dnorberg.rivencraft.Rivencraft;
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
import net.dnorberg.rivencraft.menu.NotebookMenu;
|
import net.dnorberg.rivencraft.menu.NotebookMenu;
|
||||||
|
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
|
||||||
|
import net.dnorberg.rivencraft.menu.BookBinderMenu;
|
||||||
|
import net.dnorberg.rivencraft.menu.InkMixerMenu;
|
||||||
import net.minecraft.core.registries.Registries;
|
import net.minecraft.core.registries.Registries;
|
||||||
import net.minecraft.world.inventory.MenuType;
|
import net.minecraft.world.inventory.MenuType;
|
||||||
import net.neoforged.bus.api.IEventBus;
|
import net.neoforged.bus.api.IEventBus;
|
||||||
@@ -15,6 +18,12 @@ public final class ModMenus {
|
|||||||
|
|
||||||
public static final DeferredHolder<MenuType<?>, MenuType<NotebookMenu>> NOTEBOOK =
|
public static final DeferredHolder<MenuType<?>, MenuType<NotebookMenu>> NOTEBOOK =
|
||||||
MENUS.register("notebook", () -> IMenuTypeExtension.create(NotebookMenu::new));
|
MENUS.register("notebook", () -> IMenuTypeExtension.create(NotebookMenu::new));
|
||||||
|
public static final DeferredHolder<MenuType<?>, MenuType<WritingDeskMenu>> WRITING_DESK =
|
||||||
|
MENUS.register("writing_desk", () -> IMenuTypeExtension.create(WritingDeskMenu::new));
|
||||||
|
public static final DeferredHolder<MenuType<?>, MenuType<BookBinderMenu>> BOOK_BINDER =
|
||||||
|
MENUS.register("book_binder", () -> IMenuTypeExtension.create(BookBinderMenu::new));
|
||||||
|
public static final DeferredHolder<MenuType<?>, MenuType<InkMixerMenu>> INK_MIXER =
|
||||||
|
MENUS.register("ink_mixer", () -> IMenuTypeExtension.create(InkMixerMenu::new));
|
||||||
|
|
||||||
private ModMenus() {
|
private ModMenus() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package net.dnorberg.rivencraft.registry;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.writing.LinkingBookRecipe;
|
||||||
|
import net.minecraft.core.registries.Registries;
|
||||||
|
import net.minecraft.world.item.crafting.RecipeSerializer;
|
||||||
|
import net.minecraft.world.item.crafting.SimpleCraftingRecipeSerializer;
|
||||||
|
import net.neoforged.bus.api.IEventBus;
|
||||||
|
import net.neoforged.neoforge.registries.DeferredHolder;
|
||||||
|
import net.neoforged.neoforge.registries.DeferredRegister;
|
||||||
|
|
||||||
|
public final class ModRecipeSerializers {
|
||||||
|
private static final DeferredRegister<RecipeSerializer<?>> SERIALIZERS =
|
||||||
|
DeferredRegister.create(Registries.RECIPE_SERIALIZER, Rivencraft.MOD_ID);
|
||||||
|
|
||||||
|
public static final DeferredHolder<RecipeSerializer<?>, RecipeSerializer<LinkingBookRecipe>> LINKING_BOOK =
|
||||||
|
SERIALIZERS.register("linking_book", () -> new SimpleCraftingRecipeSerializer<>(LinkingBookRecipe::new));
|
||||||
|
|
||||||
|
private ModRecipeSerializers() {}
|
||||||
|
|
||||||
|
public static void register(IEventBus modEventBus) {
|
||||||
|
SERIALIZERS.register(modEventBus);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package net.dnorberg.rivencraft.writing;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.minecraft.core.NonNullList;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
|
||||||
|
/** Shared, server-side vial-to-meter exchange for the Desk and Ink Mixer. */
|
||||||
|
public final class InkVialExchange {
|
||||||
|
private InkVialExchange() {}
|
||||||
|
|
||||||
|
public static boolean canPay(NonNullList<ItemStack> items, int inkSlot, int bottleSlot,
|
||||||
|
int storedInk, int cost) {
|
||||||
|
return storedInk >= cost || canExchange(items, inkSlot, bottleSlot)
|
||||||
|
&& storedInk + vialInk(items.get(inkSlot)) >= cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns added ink; zero means no inputs were changed. */
|
||||||
|
public static int exchange(NonNullList<ItemStack> items, int inkSlot, int bottleSlot) {
|
||||||
|
if (!canExchange(items, inkSlot, bottleSlot)) return 0;
|
||||||
|
ItemStack vial = items.get(inkSlot);
|
||||||
|
int addedInk = vialInk(vial);
|
||||||
|
vial.shrink(1);
|
||||||
|
if (vial.isEmpty()) items.set(inkSlot, ItemStack.EMPTY);
|
||||||
|
ItemStack bottles = items.get(bottleSlot);
|
||||||
|
if (bottles.isEmpty()) items.set(bottleSlot, new ItemStack(Items.GLASS_BOTTLE));
|
||||||
|
else bottles.grow(1);
|
||||||
|
return addedInk;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean canExchange(NonNullList<ItemStack> items, int inkSlot, int bottleSlot) {
|
||||||
|
ItemStack vial = items.get(inkSlot);
|
||||||
|
ItemStack bottles = items.get(bottleSlot);
|
||||||
|
return vial.is(ModItems.INK_VIAL.get()) && vialInk(vial) > 0
|
||||||
|
&& (bottles.isEmpty() || bottles.is(Items.GLASS_BOTTLE)
|
||||||
|
&& bottles.getCount() < bottles.getMaxStackSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int vialInk(ItemStack vial) {
|
||||||
|
return Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package net.dnorberg.rivencraft.writing;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModRecipeSerializers;
|
||||||
|
import net.minecraft.core.HolderLookup;
|
||||||
|
import net.minecraft.core.NonNullList;
|
||||||
|
import net.minecraft.core.component.DataComponents;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.item.crafting.CraftingBookCategory;
|
||||||
|
import net.minecraft.world.item.crafting.CraftingInput;
|
||||||
|
import net.minecraft.world.item.crafting.CustomRecipe;
|
||||||
|
import net.minecraft.world.item.crafting.Ingredient;
|
||||||
|
import net.minecraft.world.item.crafting.RecipeSerializer;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.neoforged.neoforge.common.crafting.DataComponentIngredient;
|
||||||
|
|
||||||
|
/** Legacy's shapeless leather + Link Panel recipe, adapted to page components. */
|
||||||
|
public final class LinkingBookRecipe extends CustomRecipe {
|
||||||
|
public LinkingBookRecipe(CraftingBookCategory category) {
|
||||||
|
super(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ItemStack findPanel(CraftingInput input) {
|
||||||
|
if (input.ingredientCount() != 2) return ItemStack.EMPTY;
|
||||||
|
ItemStack panel = ItemStack.EMPTY;
|
||||||
|
boolean leather = false;
|
||||||
|
for (ItemStack stack : input.items()) {
|
||||||
|
if (stack.isEmpty()) continue;
|
||||||
|
if (stack.is(ModItems.PAGE.get()) && PageItem.content(stack).kind() == PageContent.Kind.LINK_PANEL
|
||||||
|
&& panel.isEmpty()) {
|
||||||
|
panel = stack;
|
||||||
|
} else if (stack.is(Items.LEATHER) && !leather) {
|
||||||
|
leather = true;
|
||||||
|
} else {
|
||||||
|
return ItemStack.EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return leather ? panel : ItemStack.EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean matches(CraftingInput input, Level level) {
|
||||||
|
return !findPanel(input).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ItemStack assemble(CraftingInput input, HolderLookup.Provider registries) {
|
||||||
|
ItemStack panel = findPanel(input);
|
||||||
|
if (panel.isEmpty()) return ItemStack.EMPTY;
|
||||||
|
ItemStack book = new ItemStack(ModItems.LINKING_BOOK.get());
|
||||||
|
// Legacy copied the panel's data. Only its custom name is meaningful to a
|
||||||
|
// 1.21.1 Linking Book today; page-only components must not reach the book.
|
||||||
|
if (panel.has(DataComponents.CUSTOM_NAME)) {
|
||||||
|
book.set(DataComponents.CUSTOM_NAME, panel.get(DataComponents.CUSTOM_NAME));
|
||||||
|
}
|
||||||
|
return book;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean canCraftInDimensions(int width, int height) {
|
||||||
|
return width * height >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public NonNullList<Ingredient> getIngredients() {
|
||||||
|
NonNullList<Ingredient> ingredients = NonNullList.create();
|
||||||
|
ingredients.add(DataComponentIngredient.of(false,
|
||||||
|
net.dnorberg.rivencraft.registry.ModDataComponents.PAGE_CONTENT.get(),
|
||||||
|
PageContent.LINK_PANEL, ModItems.PAGE.get()));
|
||||||
|
ingredients.add(Ingredient.of(Items.LEATHER));
|
||||||
|
return ingredients;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RecipeSerializer<?> getSerializer() {
|
||||||
|
return ModRecipeSerializers.LINKING_BOOK.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.writing;
|
|||||||
|
|
||||||
import com.mojang.serialization.Codec;
|
import com.mojang.serialization.Codec;
|
||||||
import com.mojang.serialization.DataResult;
|
import com.mojang.serialization.DataResult;
|
||||||
|
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||||
import io.netty.buffer.ByteBuf;
|
import io.netty.buffer.ByteBuf;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -10,30 +11,50 @@ import net.minecraft.network.codec.ByteBufCodecs;
|
|||||||
import net.minecraft.network.codec.StreamCodec;
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
|
||||||
/** Immutable ordered writing shared by pages, notebooks, desks, and books. */
|
/** Immutable ordered writing shared by pages, notebooks, desks, and books. */
|
||||||
public record OrderedWriting(List<WritingEntry> entries) {
|
public record OrderedWriting(int schemaVersion, List<WritingEntry> entries) {
|
||||||
|
public static final int CURRENT_SCHEMA_VERSION = 1;
|
||||||
public static final int MAX_ENTRIES = 256;
|
public static final int MAX_ENTRIES = 256;
|
||||||
public static final OrderedWriting EMPTY = new OrderedWriting(List.of());
|
public static final OrderedWriting EMPTY = new OrderedWriting(List.of());
|
||||||
public static final Codec<OrderedWriting> CODEC = WritingEntry.CODEC.listOf()
|
private static final Codec<List<WritingEntry>> ENTRIES_CODEC = WritingEntry.CODEC.listOf()
|
||||||
.validate(entries -> entries.size() <= MAX_ENTRIES
|
.validate(entries -> entries.size() <= MAX_ENTRIES
|
||||||
? DataResult.success(entries)
|
? DataResult.success(entries)
|
||||||
: DataResult.error(() -> "Writing exceeds " + MAX_ENTRIES + " entries"))
|
: DataResult.error(() -> "Writing exceeds " + MAX_ENTRIES + " entries"));
|
||||||
.xmap(OrderedWriting::new, OrderedWriting::entries);
|
private static final Codec<OrderedWriting> VERSIONED_CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||||
|
Codec.intRange(CURRENT_SCHEMA_VERSION, CURRENT_SCHEMA_VERSION)
|
||||||
|
.fieldOf("schema_version").forGetter(OrderedWriting::schemaVersion),
|
||||||
|
ENTRIES_CODEC.fieldOf("entries").forGetter(OrderedWriting::entries)
|
||||||
|
).apply(instance, OrderedWriting::new));
|
||||||
|
// 0.3.7.2 stored BOOK_WRITING as a bare list. Decode it without changing its order.
|
||||||
|
public static final Codec<OrderedWriting> CODEC = Codec.withAlternative(
|
||||||
|
VERSIONED_CODEC, ENTRIES_CODEC.xmap(OrderedWriting::new, OrderedWriting::entries));
|
||||||
public static final StreamCodec<ByteBuf, OrderedWriting> STREAM_CODEC = StreamCodec.of(
|
public static final StreamCodec<ByteBuf, OrderedWriting> STREAM_CODEC = StreamCodec.of(
|
||||||
OrderedWriting::encode, OrderedWriting::decode);
|
OrderedWriting::encode, OrderedWriting::decode);
|
||||||
|
|
||||||
public OrderedWriting {
|
public OrderedWriting {
|
||||||
|
if (schemaVersion != CURRENT_SCHEMA_VERSION) {
|
||||||
|
throw new IllegalArgumentException("Unsupported writing schema version " + schemaVersion);
|
||||||
|
}
|
||||||
entries = List.copyOf(Objects.requireNonNull(entries, "entries"));
|
entries = List.copyOf(Objects.requireNonNull(entries, "entries"));
|
||||||
if (entries.size() > MAX_ENTRIES) {
|
if (entries.size() > MAX_ENTRIES) {
|
||||||
throw new IllegalArgumentException("Writing exceeds " + MAX_ENTRIES + " entries");
|
throw new IllegalArgumentException("Writing exceeds " + MAX_ENTRIES + " entries");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OrderedWriting(List<WritingEntry> entries) {
|
||||||
|
this(CURRENT_SCHEMA_VERSION, entries);
|
||||||
|
}
|
||||||
|
|
||||||
private static void encode(ByteBuf buffer, OrderedWriting writing) {
|
private static void encode(ByteBuf buffer, OrderedWriting writing) {
|
||||||
|
ByteBufCodecs.VAR_INT.encode(buffer, writing.schemaVersion);
|
||||||
ByteBufCodecs.VAR_INT.encode(buffer, writing.entries.size());
|
ByteBufCodecs.VAR_INT.encode(buffer, writing.entries.size());
|
||||||
writing.entries.forEach(entry -> WritingEntry.STREAM_CODEC.encode(buffer, entry));
|
writing.entries.forEach(entry -> WritingEntry.STREAM_CODEC.encode(buffer, entry));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static OrderedWriting decode(ByteBuf buffer) {
|
private static OrderedWriting decode(ByteBuf buffer) {
|
||||||
|
int schemaVersion = ByteBufCodecs.VAR_INT.decode(buffer);
|
||||||
|
if (schemaVersion != CURRENT_SCHEMA_VERSION) {
|
||||||
|
throw new IllegalArgumentException("Unsupported writing schema version " + schemaVersion);
|
||||||
|
}
|
||||||
int size = ByteBufCodecs.VAR_INT.decode(buffer);
|
int size = ByteBufCodecs.VAR_INT.decode(buffer);
|
||||||
if (size < 0 || size > MAX_ENTRIES) {
|
if (size < 0 || size > MAX_ENTRIES) {
|
||||||
throw new IllegalArgumentException("Invalid writing entry count " + size);
|
throw new IllegalArgumentException("Invalid writing entry count " + size);
|
||||||
@@ -42,6 +63,6 @@ public record OrderedWriting(List<WritingEntry> entries) {
|
|||||||
for (int index = 0; index < size; index++) {
|
for (int index = 0; index < size; index++) {
|
||||||
entries.add(WritingEntry.STREAM_CODEC.decode(buffer));
|
entries.add(WritingEntry.STREAM_CODEC.decode(buffer));
|
||||||
}
|
}
|
||||||
return new OrderedWriting(entries);
|
return new OrderedWriting(schemaVersion, entries);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@ package net.dnorberg.rivencraft.writing;
|
|||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
import com.google.gson.GsonBuilder;
|
import com.google.gson.GsonBuilder;
|
||||||
import com.google.gson.JsonElement;
|
import com.google.gson.JsonElement;
|
||||||
import com.mojang.serialization.JsonOps;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -56,10 +55,13 @@ public final class SymbolCatalog {
|
|||||||
ResourceManager resourceManager,
|
ResourceManager resourceManager,
|
||||||
ProfilerFiller profiler) {
|
ProfilerFiller profiler) {
|
||||||
Map<ResourceLocation, SymbolDefinition> loaded = new HashMap<>();
|
Map<ResourceLocation, SymbolDefinition> loaded = new HashMap<>();
|
||||||
resources.forEach((id, json) -> SymbolDefinition.CODEC.parse(JsonOps.INSTANCE, json)
|
resources.forEach((id, json) -> {
|
||||||
.resultOrPartial(error -> Rivencraft.LOGGER.error(
|
try {
|
||||||
"Ignoring invalid writing symbol {}: {}", id, error))
|
loaded.put(id, SymbolDefinition.parse(json));
|
||||||
.ifPresent(definition -> loaded.put(id, definition)));
|
} catch (IllegalArgumentException exception) {
|
||||||
|
Rivencraft.LOGGER.error("Ignoring invalid writing symbol {}: {}", id, exception.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
symbols = Map.copyOf(loaded);
|
symbols = Map.copyOf(loaded);
|
||||||
Rivencraft.LOGGER.info("Loaded {} writing symbols", loaded.size());
|
Rivencraft.LOGGER.info("Loaded {} writing symbols", loaded.size());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,120 @@
|
|||||||
package net.dnorberg.rivencraft.writing;
|
package net.dnorberg.rivencraft.writing;
|
||||||
|
|
||||||
import com.mojang.serialization.Codec;
|
import com.google.gson.JsonElement;
|
||||||
import com.mojang.serialization.DataResult;
|
import com.google.gson.JsonObject;
|
||||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
import com.google.gson.JsonPrimitive;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
|
||||||
/** Server-loaded, non-executable display metadata for a writing symbol. */
|
/** Server-only symbol definition. Only display metadata crosses to clients. */
|
||||||
public record SymbolDefinition(
|
public record SymbolDefinition(
|
||||||
int schemaVersion,
|
int schemaVersion,
|
||||||
String nameTranslationKey,
|
String nameTranslationKey,
|
||||||
String descriptionTranslationKey,
|
String descriptionTranslationKey,
|
||||||
ResourceLocation category
|
ResourceLocation category,
|
||||||
|
Optional<SymbolSemantics> semantics
|
||||||
) {
|
) {
|
||||||
public static final int CURRENT_SCHEMA_VERSION = 1;
|
public static final int CURRENT_SCHEMA_VERSION = 2;
|
||||||
private static final Pattern TRANSLATION_KEY = Pattern.compile("[a-z0-9_.-]+");
|
private static final Pattern TRANSLATION_KEY = Pattern.compile("[a-z0-9_.-]+");
|
||||||
private static final Codec<String> TRANSLATION_KEY_CODEC = Codec.STRING.comapFlatMap(
|
private static final Set<String> V2_FIELDS = Set.of(
|
||||||
value -> TRANSLATION_KEY.matcher(value).matches()
|
"schema_version", "name", "description", "category", "semantics");
|
||||||
? DataResult.success(value)
|
|
||||||
: DataResult.error(() -> "Invalid translation key: " + value),
|
|
||||||
value -> value
|
|
||||||
);
|
|
||||||
public static final Codec<SymbolDefinition> CODEC = RecordCodecBuilder.<SymbolDefinition>create(instance -> instance.group(
|
|
||||||
Codec.INT.fieldOf("schema_version").forGetter(SymbolDefinition::schemaVersion),
|
|
||||||
TRANSLATION_KEY_CODEC.fieldOf("name").forGetter(SymbolDefinition::nameTranslationKey),
|
|
||||||
TRANSLATION_KEY_CODEC.fieldOf("description").forGetter(SymbolDefinition::descriptionTranslationKey),
|
|
||||||
ResourceLocation.CODEC.fieldOf("category").forGetter(SymbolDefinition::category)
|
|
||||||
).apply(instance, SymbolDefinition::new)).flatXmap(SymbolDefinition::validate, SymbolDefinition::validate);
|
|
||||||
|
|
||||||
public SymbolDefinition {
|
public SymbolDefinition {
|
||||||
Objects.requireNonNull(nameTranslationKey, "nameTranslationKey");
|
Objects.requireNonNull(nameTranslationKey, "nameTranslationKey");
|
||||||
Objects.requireNonNull(descriptionTranslationKey, "descriptionTranslationKey");
|
Objects.requireNonNull(descriptionTranslationKey, "descriptionTranslationKey");
|
||||||
Objects.requireNonNull(category, "category");
|
Objects.requireNonNull(category, "category");
|
||||||
|
semantics = Objects.requireNonNull(semantics, "semantics");
|
||||||
|
if (schemaVersion != 1 && schemaVersion != CURRENT_SCHEMA_VERSION) {
|
||||||
|
throw new IllegalArgumentException("Unsupported schema_version " + schemaVersion);
|
||||||
|
}
|
||||||
|
if (schemaVersion == 1 && semantics.isPresent()) {
|
||||||
|
throw new IllegalArgumentException("Schema v1 symbols cannot have semantics");
|
||||||
|
}
|
||||||
|
if (schemaVersion == CURRENT_SCHEMA_VERSION && semantics.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Schema v2 symbols require semantics");
|
||||||
|
}
|
||||||
|
validateTranslationKey(nameTranslationKey, "name");
|
||||||
|
validateTranslationKey(descriptionTranslationKey, "description");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v1 remains display-only; v2 has an exact field set and typed semantics. */
|
||||||
|
public static SymbolDefinition parse(JsonElement json) {
|
||||||
|
if (json == null || !json.isJsonObject()) {
|
||||||
|
throw new IllegalArgumentException("Symbol definition must be a JSON object");
|
||||||
|
}
|
||||||
|
JsonObject object = json.getAsJsonObject();
|
||||||
|
int version = requiredInteger(object, "schema_version");
|
||||||
|
if (version != 1 && version != CURRENT_SCHEMA_VERSION) {
|
||||||
|
throw new IllegalArgumentException("Unsupported schema_version " + version);
|
||||||
|
}
|
||||||
|
if (version == CURRENT_SCHEMA_VERSION) {
|
||||||
|
for (String field : object.keySet()) {
|
||||||
|
if (!V2_FIELDS.contains(field)) {
|
||||||
|
throw new IllegalArgumentException("Unknown symbol field '" + field + "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (object.has("semantics")) {
|
||||||
|
throw new IllegalArgumentException("Schema v1 cannot declare semantics; use schema_version 2");
|
||||||
|
}
|
||||||
|
String name = requiredString(object, "name");
|
||||||
|
String description = requiredString(object, "description");
|
||||||
|
ResourceLocation category = requiredResourceLocation(object, "category",
|
||||||
|
version == CURRENT_SCHEMA_VERSION);
|
||||||
|
Optional<SymbolSemantics> semantics = version == CURRENT_SCHEMA_VERSION
|
||||||
|
? Optional.of(SymbolSemantics.parse(object.get("semantics")))
|
||||||
|
: Optional.empty();
|
||||||
|
return new SymbolDefinition(version, name, description, category, semantics);
|
||||||
}
|
}
|
||||||
|
|
||||||
public SymbolDisplayMetadata display(ResourceLocation id) {
|
public SymbolDisplayMetadata display(ResourceLocation id) {
|
||||||
return new SymbolDisplayMetadata(id, nameTranslationKey, descriptionTranslationKey, category, true);
|
return new SymbolDisplayMetadata(id, nameTranslationKey, descriptionTranslationKey, category, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DataResult<SymbolDefinition> validate(SymbolDefinition definition) {
|
static String requiredString(JsonObject object, String field) {
|
||||||
return definition.schemaVersion == CURRENT_SCHEMA_VERSION
|
JsonElement element = object.get(field);
|
||||||
? DataResult.success(definition)
|
if (element == null || !element.isJsonPrimitive()
|
||||||
: DataResult.error(() -> "Unsupported symbol schema_version " + definition.schemaVersion);
|
|| !element.getAsJsonPrimitive().isString()) {
|
||||||
|
throw new IllegalArgumentException("'" + field + "' must be a string");
|
||||||
|
}
|
||||||
|
return element.getAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static ResourceLocation requiredResourceLocation(JsonObject object, String field) {
|
||||||
|
return requiredResourceLocation(object, field, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResourceLocation requiredResourceLocation(JsonObject object, String field, boolean canonical) {
|
||||||
|
String value = requiredString(object, field);
|
||||||
|
ResourceLocation id = ResourceLocation.tryParse(value);
|
||||||
|
if (id == null || (canonical && !id.toString().equals(value))) {
|
||||||
|
throw new IllegalArgumentException("'" + field + "' must be "
|
||||||
|
+ (canonical ? "a canonical namespaced ID" : "a resource location") + ": " + value);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int requiredInteger(JsonObject object, String field) {
|
||||||
|
JsonElement element = object.get(field);
|
||||||
|
if (element == null || !element.isJsonPrimitive()) {
|
||||||
|
throw new IllegalArgumentException("'" + field + "' must be an integer");
|
||||||
|
}
|
||||||
|
JsonPrimitive primitive = element.getAsJsonPrimitive();
|
||||||
|
if (!primitive.isNumber() || !primitive.getAsString().matches("-?\\d+")) {
|
||||||
|
throw new IllegalArgumentException("'" + field + "' must be an integer");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(primitive.getAsString());
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
throw new IllegalArgumentException("'" + field + "' is outside the integer range", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateTranslationKey(String value, String field) {
|
||||||
|
if (!TRANSLATION_KEY.matcher(value).matches()) {
|
||||||
|
throw new IllegalArgumentException("Invalid '" + field + "' translation key: " + value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package net.dnorberg.rivencraft.writing;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonPrimitive;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
|
||||||
|
/** Bounded grammar role and value, retained by the server catalog only. */
|
||||||
|
public record SymbolSemantics(Role role, Value value) {
|
||||||
|
public SymbolSemantics {
|
||||||
|
Objects.requireNonNull(role, "role");
|
||||||
|
Objects.requireNonNull(value, "value");
|
||||||
|
if (!role.accepts(value)) {
|
||||||
|
throw new IllegalArgumentException("Invalid semantics.value for role '" + role.id
|
||||||
|
+ "': " + value + "; expected " + role.expected());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SymbolSemantics parse(JsonElement json) {
|
||||||
|
if (json == null || !json.isJsonObject()) {
|
||||||
|
throw new IllegalArgumentException("'semantics' must be an object");
|
||||||
|
}
|
||||||
|
JsonObject object = json.getAsJsonObject();
|
||||||
|
for (String field : object.keySet()) {
|
||||||
|
if (!field.equals("role") && !field.equals("value")) {
|
||||||
|
throw new IllegalArgumentException("Unknown semantics field '" + field + "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String roleId = SymbolDefinition.requiredString(object, "role");
|
||||||
|
Role role = Arrays.stream(Role.values()).filter(candidate -> candidate.id.equals(roleId))
|
||||||
|
.findFirst().orElseThrow(() -> new IllegalArgumentException("Unknown semantic role '" + roleId + "'"));
|
||||||
|
JsonElement raw = object.get("value");
|
||||||
|
if (raw == null || !raw.isJsonPrimitive()) {
|
||||||
|
throw new IllegalArgumentException("'semantics.value' is required");
|
||||||
|
}
|
||||||
|
JsonPrimitive primitive = raw.getAsJsonPrimitive();
|
||||||
|
Value value;
|
||||||
|
if (role.kind == Kind.RESOURCE) {
|
||||||
|
value = new ResourceValue(SymbolDefinition.requiredResourceLocation(object, "value"));
|
||||||
|
} else if (role.kind == Kind.NUMBER) {
|
||||||
|
if (!primitive.isNumber()) {
|
||||||
|
throw new IllegalArgumentException("'semantics.value' for " + role.id + " must be a number");
|
||||||
|
}
|
||||||
|
value = new NumberValue(primitive.getAsDouble());
|
||||||
|
} else {
|
||||||
|
value = new ChoiceValue(SymbolDefinition.requiredString(object, "value"));
|
||||||
|
}
|
||||||
|
return new SymbolSemantics(role, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed interface Value permits ResourceValue, ChoiceValue, NumberValue {}
|
||||||
|
|
||||||
|
public record ResourceValue(ResourceLocation id) implements Value {
|
||||||
|
public ResourceValue { Objects.requireNonNull(id, "id"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ChoiceValue(String id) implements Value {
|
||||||
|
public ChoiceValue { Objects.requireNonNull(id, "id"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record NumberValue(double amount) implements Value {
|
||||||
|
public NumberValue {
|
||||||
|
if (!Double.isFinite(amount)) {
|
||||||
|
throw new IllegalArgumentException("Semantic numeric value must be finite");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Kind { RESOURCE, CHOICE, NUMBER }
|
||||||
|
|
||||||
|
public enum Role {
|
||||||
|
TERRAIN("terrain", Kind.RESOURCE),
|
||||||
|
BIOME_CONTROLLER("biome_controller", Kind.CHOICE,
|
||||||
|
"native", "single", "tiled", "grid", "tiny", "small", "medium", "large", "huge"),
|
||||||
|
BIOME("biome", Kind.RESOURCE),
|
||||||
|
LIGHTING("lighting", Kind.CHOICE, "normal", "dark", "bright"),
|
||||||
|
WEATHER("weather", Kind.CHOICE,
|
||||||
|
"normal", "fast", "slow", "off", "always", "cloudy", "rain", "snow", "storm"),
|
||||||
|
CELESTIAL("celestial", Kind.CHOICE, "normal_time"),
|
||||||
|
PERIOD_MODIFIER("period_modifier", Kind.NUMBER),
|
||||||
|
ANGLE_MODIFIER("angle_modifier", Kind.NUMBER),
|
||||||
|
PHASE_MODIFIER("phase_modifier", Kind.NUMBER),
|
||||||
|
FEATURE("feature", Kind.RESOURCE);
|
||||||
|
|
||||||
|
private final String id;
|
||||||
|
private final Kind kind;
|
||||||
|
private final Set<String> choices;
|
||||||
|
|
||||||
|
Role(String id, Kind kind, String... choices) {
|
||||||
|
this.id = id;
|
||||||
|
this.kind = kind;
|
||||||
|
this.choices = Set.of(choices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String id() { return id; }
|
||||||
|
|
||||||
|
private boolean accepts(Value value) {
|
||||||
|
if (kind == Kind.RESOURCE) return value instanceof ResourceValue;
|
||||||
|
if (kind == Kind.CHOICE) return value instanceof ChoiceValue choice && choices.contains(choice.id());
|
||||||
|
if (!(value instanceof NumberValue number)) return false;
|
||||||
|
return switch (this) {
|
||||||
|
case PERIOD_MODIFIER -> number.amount() >= 0.0 && number.amount() <= 2.0;
|
||||||
|
case ANGLE_MODIFIER, PHASE_MODIFIER -> number.amount() >= 0.0 && number.amount() < 360.0;
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String expected() {
|
||||||
|
return switch (kind) {
|
||||||
|
case RESOURCE -> "a canonical namespaced ID";
|
||||||
|
case CHOICE -> "one of " + choices.stream().sorted().toList();
|
||||||
|
case NUMBER -> this == PERIOD_MODIFIER ? "a finite number in [0, 2]"
|
||||||
|
: "a finite number in [0, 360) degrees";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package net.dnorberg.rivencraft.writing;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
|
||||||
|
/** A bounded, server-derived view of symbols physically present in the desk. */
|
||||||
|
public final class WritingDeskCatalog {
|
||||||
|
private static final int MAX_ENTRIES = WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES;
|
||||||
|
|
||||||
|
private WritingDeskCatalog() {}
|
||||||
|
|
||||||
|
public static List<SymbolDisplayMetadata> available(WritingDeskBlockEntity desk) {
|
||||||
|
Map<ResourceLocation, SymbolDisplayMetadata> unique = new HashMap<>();
|
||||||
|
for (int slot = 0; slot < WritingDeskBlockEntity.COLLECTIONS; slot++) {
|
||||||
|
ItemStack collection = desk.getItem(slot);
|
||||||
|
if (collection.is(ModItems.PAGE.get())) add(unique, collection);
|
||||||
|
if (collection.is(ModItems.NOTEBOOK.get())) {
|
||||||
|
List<ItemStack> pages = collection.getOrDefault(
|
||||||
|
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
||||||
|
pages.forEach(page -> add(unique, page));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unique.values().stream()
|
||||||
|
.sorted(Comparator.comparing((SymbolDisplayMetadata symbol) -> symbol.category().toString())
|
||||||
|
.thenComparing(symbol -> symbol.id().toString()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void add(Map<ResourceLocation, SymbolDisplayMetadata> unique, ItemStack page) {
|
||||||
|
if (!page.is(ModItems.PAGE.get())) return;
|
||||||
|
PageContent content = PageItem.content(page).resolve();
|
||||||
|
if (content.kind() != PageContent.Kind.SYMBOL) return;
|
||||||
|
SymbolReference reference = content.symbol().orElseThrow();
|
||||||
|
SymbolCatalog.find(reference).ifPresent(definition ->
|
||||||
|
unique.put(reference.id(), definition.display(reference.id())));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void write(RegistryFriendlyByteBuf buffer, List<SymbolDisplayMetadata> entries) {
|
||||||
|
if (entries.size() > MAX_ENTRIES) throw new IllegalArgumentException("Desk symbol catalog exceeds limit");
|
||||||
|
buffer.writeVarInt(entries.size());
|
||||||
|
entries.forEach(entry -> SymbolDisplayMetadata.STREAM_CODEC.encode(buffer, entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<SymbolDisplayMetadata> read(RegistryFriendlyByteBuf buffer) {
|
||||||
|
int count = buffer.readVarInt();
|
||||||
|
if (count < 0 || count > MAX_ENTRIES) throw new IllegalArgumentException("Invalid desk symbol count " + count);
|
||||||
|
List<SymbolDisplayMetadata> entries = new ArrayList<>(count);
|
||||||
|
for (int index = 0; index < count; index++) {
|
||||||
|
entries.add(SymbolDisplayMetadata.STREAM_CODEC.decode(buffer));
|
||||||
|
}
|
||||||
|
return List.copyOf(entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package net.dnorberg.rivencraft.writing;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||||
|
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
|
||||||
|
/** Server-owned costs and the baseline blank-page-to-link-panel transaction. */
|
||||||
|
public final class WritingInkService {
|
||||||
|
private WritingInkService() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int symbolCopyCost() {
|
||||||
|
return RivencraftConfig.symbolInkCost();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int linkPanelCost() {
|
||||||
|
return RivencraftConfig.linkPanelInkCost();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean canPrepareLinkPanel(Player player, InteractionHand vialHand) {
|
||||||
|
ItemStack vial = player.getItemInHand(vialHand);
|
||||||
|
ItemStack page = player.getItemInHand(other(vialHand));
|
||||||
|
int remaining = InkVialItem.remaining(vial);
|
||||||
|
return vial.is(ModItems.INK_VIAL.get()) && remaining >= linkPanelCost()
|
||||||
|
&& remaining <= InkVialItem.CAPACITY
|
||||||
|
&& page.is(Items.PAPER);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean prepareLinkPanel(Player player, InteractionHand vialHand) {
|
||||||
|
if (player.level().isClientSide || !canPrepareLinkPanel(player, vialHand)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
InteractionHand pageHand = other(vialHand);
|
||||||
|
ItemStack page = player.getItemInHand(pageHand);
|
||||||
|
ItemStack vial = player.getItemInHand(vialHand);
|
||||||
|
int newInk = InkVialItem.remaining(vial) - linkPanelCost();
|
||||||
|
ItemStack panel = PageItem.createLinkPanelPage();
|
||||||
|
ItemStack spentVial = newInk == 0
|
||||||
|
? new ItemStack(Items.GLASS_BOTTLE)
|
||||||
|
: new ItemStack(ModItems.INK_VIAL.get());
|
||||||
|
if (newInk > 0) {
|
||||||
|
spentVial.set(ModDataComponents.INK_REMAINING.get(), newInk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All validation and output construction is complete before changing either input.
|
||||||
|
if (page.getCount() == 1) {
|
||||||
|
player.setItemInHand(pageHand, panel);
|
||||||
|
} else {
|
||||||
|
page.shrink(1);
|
||||||
|
giveOrDrop(player, panel);
|
||||||
|
}
|
||||||
|
if (vial.getCount() == 1) {
|
||||||
|
player.setItemInHand(vialHand, spentVial);
|
||||||
|
} else {
|
||||||
|
vial.shrink(1);
|
||||||
|
giveOrDrop(player, spentVial);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InteractionHand other(InteractionHand hand) {
|
||||||
|
return hand == InteractionHand.MAIN_HAND ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void giveOrDrop(Player player, ItemStack stack) {
|
||||||
|
if (!player.getInventory().add(stack)) {
|
||||||
|
player.drop(stack, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"variants": {
|
||||||
|
"facing=north": {"model":"rivencraft:block/book_binder"},
|
||||||
|
"facing=east": {"model":"rivencraft:block/book_binder","y":90},
|
||||||
|
"facing=south": {"model":"rivencraft:block/book_binder","y":180},
|
||||||
|
"facing=west": {"model":"rivencraft:block/book_binder","y":270}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"variants": {
|
||||||
|
"": { "model": "rivencraft:block/ink_mixer" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"variants": {
|
||||||
|
"facing=north,extension=false": {"model":"rivencraft:block/writing_desk"},
|
||||||
|
"facing=east,extension=false": {"model":"rivencraft:block/writing_desk","y":90},
|
||||||
|
"facing=south,extension=false": {"model":"rivencraft:block/writing_desk","y":180},
|
||||||
|
"facing=west,extension=false": {"model":"rivencraft:block/writing_desk","y":270},
|
||||||
|
"facing=north,extension=true": {"model":"rivencraft:block/writing_desk_extension"},
|
||||||
|
"facing=east,extension=true": {"model":"rivencraft:block/writing_desk_extension","y":90},
|
||||||
|
"facing=south,extension=true": {"model":"rivencraft:block/writing_desk_extension","y":180},
|
||||||
|
"facing=west,extension=true": {"model":"rivencraft:block/writing_desk_extension","y":270}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,37 @@
|
|||||||
"item.rivencraft.notebook": "Notebook",
|
"item.rivencraft.notebook": "Notebook",
|
||||||
"item.rivencraft.notebook.pages": "Pages: %s/%s",
|
"item.rivencraft.notebook.pages": "Pages: %s/%s",
|
||||||
"item.rivencraft.notebook.unresolved": "Unresolved pages: %s",
|
"item.rivencraft.notebook.unresolved": "Unresolved pages: %s",
|
||||||
"screen.rivencraft.notebook.rename": "Rename selected page",
|
"item.rivencraft.ink_vial": "Ink Vial",
|
||||||
|
"block.rivencraft.writing_desk": "Writing Desk",
|
||||||
|
"container.rivencraft.writing_desk": "Writing Desk",
|
||||||
|
"block.rivencraft.book_binder": "Book Binder",
|
||||||
|
"container.rivencraft.book_binder": "Book Binder",
|
||||||
|
"block.rivencraft.ink_mixer": "Ink Mixer",
|
||||||
|
"container.rivencraft.ink_mixer": "Ink Mixer",
|
||||||
|
"screen.rivencraft.ink_mixer.ink": "Ink: %s/%s",
|
||||||
|
"screen.rivencraft.book_binder.title": "Book title",
|
||||||
|
"screen.rivencraft.book_binder.title_hint": "Name the book",
|
||||||
|
"screen.rivencraft.book_binder.missing_panel": "Link Panel required as the first page",
|
||||||
|
"screen.rivencraft.book_binder.cover": "Cover · Link Panel · Notebook",
|
||||||
|
"screen.rivencraft.book_binder.import": "Import Notebook",
|
||||||
|
"screen.rivencraft.book_binder.import.tooltip": "Move all Notebook pages into the binder in order",
|
||||||
|
"screen.rivencraft.book_binder.move_previous": "Move selected page left",
|
||||||
|
"screen.rivencraft.book_binder.move_next": "Move selected page right",
|
||||||
|
"screen.rivencraft.book_binder.previous_pages": "Scroll pages left (Left Arrow)",
|
||||||
|
"screen.rivencraft.book_binder.next_pages": "Scroll pages right (Right Arrow)",
|
||||||
|
"screen.rivencraft.book_binder.select_hint": "Right-click page to select",
|
||||||
|
"screen.rivencraft.writing_desk.ink": "Ink: %s/%s",
|
||||||
|
"screen.rivencraft.writing_desk.search": "Search",
|
||||||
|
"screen.rivencraft.writing_desk.sort": "Sort pages by name (A–Z)",
|
||||||
|
"screen.rivencraft.writing_desk.all": "Show pages from all tabs",
|
||||||
|
"screen.rivencraft.writing_desk.category": "Cycle page category: %s",
|
||||||
|
"screen.rivencraft.writing_desk.all_categories": "All categories",
|
||||||
|
"screen.rivencraft.writing_desk.previous_tabs": "Previous four tabs (Page Up)",
|
||||||
|
"screen.rivencraft.writing_desk.next_tabs": "Next four tabs (Page Down)",
|
||||||
|
"screen.rivencraft.writing_desk.no_symbols": "No available symbols",
|
||||||
|
"item.rivencraft.ink_vial.remaining": "Ink: %s/%s",
|
||||||
|
"item.rivencraft.ink_vial.usage": "Use with paper in the other hand to prepare a Link Panel.",
|
||||||
|
"screen.rivencraft.notebook.search": "Search pages",
|
||||||
"screen.rivencraft.notebook.move_previous": "Move selected page left",
|
"screen.rivencraft.notebook.move_previous": "Move selected page left",
|
||||||
"screen.rivencraft.notebook.move_next": "Move selected page right",
|
"screen.rivencraft.notebook.move_next": "Move selected page right",
|
||||||
"message.rivencraft.linking_book.bind_hint": "Sneak-use to bind this book to your current location",
|
"message.rivencraft.linking_book.bind_hint": "Sneak-use to bind this book to your current location",
|
||||||
@@ -41,6 +71,10 @@
|
|||||||
"screen.rivencraft.age.name_prompt": "Name this Age before writing the Descriptive Book",
|
"screen.rivencraft.age.name_prompt": "Name this Age before writing the Descriptive Book",
|
||||||
"screen.rivencraft.age.create": "Create",
|
"screen.rivencraft.age.create": "Create",
|
||||||
"screen.rivencraft.age.generator": "Terrain: %s",
|
"screen.rivencraft.age.generator": "Terrain: %s",
|
||||||
|
"screen.rivencraft.age.pages": "Pages",
|
||||||
|
"screen.rivencraft.age.details": "Details",
|
||||||
|
"screen.rivencraft.age.pages_count": "Writing %s/%s",
|
||||||
|
"screen.rivencraft.age.pages_not_applied": "Symbols not applied yet",
|
||||||
"screen.rivencraft.age.trait.biomes": "Biomes: %s",
|
"screen.rivencraft.age.trait.biomes": "Biomes: %s",
|
||||||
"screen.rivencraft.age.trait.lighting": "Lighting: %s",
|
"screen.rivencraft.age.trait.lighting": "Lighting: %s",
|
||||||
"screen.rivencraft.age.trait.weather": "Weather: %s",
|
"screen.rivencraft.age.trait.weather": "Weather: %s",
|
||||||
@@ -91,9 +125,18 @@
|
|||||||
"generator.rivencraft.void": "Void",
|
"generator.rivencraft.void": "Void",
|
||||||
"generator.rivencraft.random_vanilla": "Standard",
|
"generator.rivencraft.random_vanilla": "Standard",
|
||||||
"screen.rivencraft.book.preview_pending": "Loading destination...",
|
"screen.rivencraft.book.preview_pending": "Loading destination...",
|
||||||
"screen.rivencraft.book.click_to_link": "Click the panel to link",
|
|
||||||
"screen.rivencraft.book.explore": "Explore",
|
"screen.rivencraft.book.explore": "Explore",
|
||||||
"screen.rivencraft.book.exploring": "Exploring",
|
"screen.rivencraft.book.exploring": "Exploring",
|
||||||
"screen.rivencraft.book.return_from_explore": "Right-click to return",
|
"screen.rivencraft.book.return_from_explore": "Right-click to return",
|
||||||
"itemGroup.rivencraft": "Rivencraft"
|
"screen.rivencraft.book.previous_page": "Previous page (Left Arrow)",
|
||||||
|
"screen.rivencraft.book.next_page": "Next page (Right Arrow)",
|
||||||
|
"jei.rivencraft.writing": "Rivencraft Writing",
|
||||||
|
"jei.rivencraft.writing.link_panel_hand": "Hold paper and an Ink Vial in opposite hands; use the vial to make a Link Panel.",
|
||||||
|
"jei.rivencraft.ink_mixer": "Ink Mixer",
|
||||||
|
"jei.rivencraft.ink_mixer.usage": "Paper + ink makes a Link Panel. One vial fills 16 ink; each panel uses the configured ink cost. The empty vial returns as a glass bottle.",
|
||||||
|
"jei.rivencraft.writing.copy": "At a Writing Desk, select a stored Page, add paper and ink, then take the copy. The source Page stays.",
|
||||||
|
"jei.rivencraft.writing.bind": "At a Book Binder, name the Age and add a cover plus Link Panel. Optional symbol Pages follow the panel.",
|
||||||
|
"jei.rivencraft.writing.linking_book": "Craft one Link Panel Page with leather to make an unwritten Linking Book. Open it to name and bind it.",
|
||||||
|
"itemGroup.rivencraft": "Rivencraft",
|
||||||
|
"itemGroup.rivencraft.pages": "Rivencraft Pages"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"parent": "minecraft:block/block",
|
||||||
|
"textures": {
|
||||||
|
"particle": "rivencraft:block/book_binder",
|
||||||
|
"body": "rivencraft:block/book_binder",
|
||||||
|
"press": "rivencraft:block/book_binder_press"
|
||||||
|
},
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"from": [0, 0, 0], "to": [16, 10, 16],
|
||||||
|
"faces": {
|
||||||
|
"north": {"texture":"#body","uv":[0,4,4,6.5]},
|
||||||
|
"east": {"texture":"#body","uv":[4,4,8,6.5]},
|
||||||
|
"south": {"texture":"#body","uv":[8,4,12,6.5]},
|
||||||
|
"west": {"texture":"#body","uv":[12,4,16,6.5]},
|
||||||
|
"up": {"texture":"#body","uv":[4,0,8,4]},
|
||||||
|
"down": {"texture":"#body","uv":[8,0,12,4]}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": [0, 10, 1], "to": [3, 14, 15],
|
||||||
|
"faces": {
|
||||||
|
"north": {"texture":"#press","uv":[0,4,1.5,6]},
|
||||||
|
"east": {"texture":"#press","uv":[0,0,7,2]},
|
||||||
|
"south": {"texture":"#press","uv":[2,4,3.5,6]},
|
||||||
|
"west": {"texture":"#press","uv":[0,2,7,4]},
|
||||||
|
"up": {"texture":"#press","uv":[7,0,8.5,6]},
|
||||||
|
"down": {"texture":"#press","uv":[8.5,0,10,7]}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": [13, 10, 1], "to": [16, 14, 15],
|
||||||
|
"faces": {
|
||||||
|
"north": {"texture":"#press","uv":[0,4,1.5,6]},
|
||||||
|
"east": {"texture":"#press","uv":[0,0,7,2]},
|
||||||
|
"south": {"texture":"#press","uv":[2,4,3.5,6]},
|
||||||
|
"west": {"texture":"#press","uv":[0,2,7,4]},
|
||||||
|
"up": {"texture":"#press","uv":[7,0,8.5,6]},
|
||||||
|
"down": {"texture":"#press","uv":[8.5,0,10,7]}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": [3, 11, 3], "to": [13, 12, 4],
|
||||||
|
"faces": {
|
||||||
|
"north": {"texture":"#press","uv":[1,15.5,6,16]},
|
||||||
|
"south": {"texture":"#press","uv":[7,15.5,12,16]},
|
||||||
|
"up": {"texture":"#press","uv":[0.5,15,5.5,15.5]},
|
||||||
|
"down": {"texture":"#press","uv":[5.5,15,10.5,15.5]}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": [3, 11, 12], "to": [13, 12, 13],
|
||||||
|
"faces": {
|
||||||
|
"north": {"texture":"#press","uv":[1,15.5,6,16]},
|
||||||
|
"south": {"texture":"#press","uv":[7,15.5,12,16]},
|
||||||
|
"up": {"texture":"#press","uv":[0.5,15,5.5,15.5]},
|
||||||
|
"down": {"texture":"#press","uv":[5.5,15,10.5,15.5]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"parent": "minecraft:block/block",
|
||||||
|
"textures": {
|
||||||
|
"particle": "rivencraft:block/ink_mixer_side",
|
||||||
|
"side": "rivencraft:block/ink_mixer_side",
|
||||||
|
"top": "rivencraft:block/ink_mixer_top",
|
||||||
|
"bottom": "rivencraft:block/ink_mixer_bottom"
|
||||||
|
},
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"from": [0, 0, 0],
|
||||||
|
"to": [16, 12, 16],
|
||||||
|
"faces": {
|
||||||
|
"north": { "texture": "#side", "uv": [0, 4, 16, 16] },
|
||||||
|
"east": { "texture": "#side", "uv": [0, 4, 16, 16] },
|
||||||
|
"south": { "texture": "#side", "uv": [0, 4, 16, 16] },
|
||||||
|
"west": { "texture": "#side", "uv": [0, 4, 16, 16] },
|
||||||
|
"up": { "texture": "#top" },
|
||||||
|
"down": { "texture": "#bottom" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"format_version": "26.3",
|
||||||
|
"credit": "Made with Blockbench",
|
||||||
|
"parent": "minecraft:block/block",
|
||||||
|
"textures": {
|
||||||
|
"2": "rivencraft:block/writing_desk",
|
||||||
|
"particle": "rivencraft:block/writing_desk"
|
||||||
|
},
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"from": [0, 11, 0],
|
||||||
|
"to": [16, 12, 16],
|
||||||
|
"rotation": {"angle": 0, "axis": "y", "origin": [0, 3, 0]},
|
||||||
|
"faces": {
|
||||||
|
"north": {"uv": [1, 2, 2, 2.125], "texture": "#2"},
|
||||||
|
"east": {"uv": [2.82031, 4.01563, 3.82031, 4.125], "texture": "#2"},
|
||||||
|
"south": {"uv": [0, 2, 1, 2.125], "texture": "#2"},
|
||||||
|
"west": {"uv": [2.9375, 0.125, 3, 2.125], "rotation": 90, "texture": "#2"},
|
||||||
|
"up": {"uv": [1, 0, 2, 2], "texture": "#2"},
|
||||||
|
"down": {"uv": [3, 0, 3.9375, 2], "texture": "#2"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": [0, 0, 1],
|
||||||
|
"to": [1, 11, 15],
|
||||||
|
"faces": {
|
||||||
|
"north": {"uv": [7.6875, 2.625, 7.75, 4], "texture": "#2"},
|
||||||
|
"east": {"uv": [7.6875, 2.625, 6.6875, 4], "texture": "#2"},
|
||||||
|
"south": {"uv": [1.42188, 10.78125, 2.4375, 11.85938], "texture": "#2"},
|
||||||
|
"west": {"uv": [5.67969, 2.625, 6.6875, 4], "texture": "#2"},
|
||||||
|
"up": {"uv": [0.08594, 10.36719, 1.14063, 11.83594], "texture": "#2"},
|
||||||
|
"down": {"uv": [5.75, 5.25, 5.8125, 7.10938], "texture": "#2"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": [1, 5, 1],
|
||||||
|
"to": [16, 6, 15],
|
||||||
|
"rotation": {"angle": 0, "axis": "y", "origin": [0, 2, 0]},
|
||||||
|
"faces": {
|
||||||
|
"north": {"uv": [3, 2, 3.9375, 2.125], "texture": "#2"},
|
||||||
|
"east": {"uv": [2.8125, 4, 3.75, 4.125], "texture": "#2"},
|
||||||
|
"south": {"uv": [1.35938, 11.71875, 2.44531, 12.71875], "texture": "#2"},
|
||||||
|
"west": {"uv": [0.03125, 11.66406, 0.99219, 12.66406], "texture": "#2"},
|
||||||
|
"up": {"uv": [1.875, 4, 2.8125, 2.125], "texture": "#2"},
|
||||||
|
"down": {"uv": [0.9375, 2.125, 1.875, 4], "texture": "#2"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": [0, 0, 15],
|
||||||
|
"to": [16, 11, 16],
|
||||||
|
"rotation": {"angle": 0, "axis": "y", "origin": [0, -9, 0]},
|
||||||
|
"faces": {
|
||||||
|
"north": {"uv": [8.0625, 0, 9.0625, 1.375], "texture": "#2"},
|
||||||
|
"east": {"uv": [6.625, 0.125, 6.6875, 1.5], "texture": "#2"},
|
||||||
|
"south": {"uv": [5.625, 2.625, 6.625, 4], "texture": "#2"},
|
||||||
|
"west": {"uv": [7.6875, 2.625, 7.75, 4], "texture": "#2"},
|
||||||
|
"up": {"uv": [0, 15, 16, 16], "texture": "#2"},
|
||||||
|
"down": {"uv": [0, 9.17969, 16, 10.17969], "texture": "#2"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"format_version": "26.3",
|
||||||
|
"credit": "Made with Blockbench",
|
||||||
|
"parent": "minecraft:block/block",
|
||||||
|
"textures": {
|
||||||
|
"2": "rivencraft:block/writing_desk",
|
||||||
|
"particle": "rivencraft:block/writing_desk"
|
||||||
|
},
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"from": [1, 0, 1],
|
||||||
|
"to": [15, 1, 15],
|
||||||
|
"rotation": {"angle": 0, "axis": "y", "origin": [1, 0, 0]},
|
||||||
|
"faces": {
|
||||||
|
"north": {"uv": [1.8125, 4.125, 2.8125, 4.25], "texture": "#2"},
|
||||||
|
"east": {"uv": [0.21875, 11.88281, 1.04688, 12.88281], "texture": "#2"},
|
||||||
|
"south": {"uv": [1.53906, 10.5625, 2.63281, 11.5625], "texture": "#2"},
|
||||||
|
"west": {"uv": [0.14063, 10.5625, 1.17969, 11.5625], "texture": "#2"},
|
||||||
|
"up": {"uv": [1.8125, 4.125, 2.8125, 2.125], "texture": "#2"},
|
||||||
|
"down": {"uv": [4, 0, 5, 2], "texture": "#2"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": [0, 11, 0],
|
||||||
|
"to": [16, 12, 16],
|
||||||
|
"rotation": {"angle": 0, "axis": "y", "origin": [0, 3, 0]},
|
||||||
|
"faces": {
|
||||||
|
"north": {"uv": [2, 2, 3, 2.125], "texture": "#2"},
|
||||||
|
"east": {"uv": [1, 0, 1.0625, 2], "rotation": 90, "texture": "#2"},
|
||||||
|
"south": {"uv": [3, 2, 4, 2.125], "texture": "#2"},
|
||||||
|
"west": {"uv": [2.8125, 4, 3.8125, 4.125], "texture": "#2"},
|
||||||
|
"up": {"uv": [2, 0, 3, 2], "texture": "#2"},
|
||||||
|
"down": {"uv": [9, 0, 10, 2], "texture": "#2"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": [15, 0, 1],
|
||||||
|
"to": [16, 11, 15],
|
||||||
|
"faces": {
|
||||||
|
"north": {"uv": [7.6875, 2.625, 7.75, 4], "texture": "#2"},
|
||||||
|
"east": {"uv": [10.00156, 0.625, 11.00156, 2], "texture": "#2"},
|
||||||
|
"south": {"uv": [1.99219, 10.77344, 2.99219, 12.02344], "texture": "#2"},
|
||||||
|
"west": {"uv": [6.6875, 2.625, 7.6875, 4], "texture": "#2"},
|
||||||
|
"up": {"uv": [0.49219, 10.96094, 1.49219, 12.08594], "texture": "#2"},
|
||||||
|
"down": {"uv": [5.75313, 5.36719, 5.8125, 7.125], "texture": "#2"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": [0, 0, 1],
|
||||||
|
"to": [1, 11, 15],
|
||||||
|
"rotation": {"angle": 0, "axis": "y", "origin": [-15, 0, 0]},
|
||||||
|
"faces": {
|
||||||
|
"north": {"uv": [7.68828, 2.625, 7.75078, 4], "texture": "#2"},
|
||||||
|
"east": {"uv": [7.6875, 2.62539, 6.6875, 4.00039], "texture": "#2"},
|
||||||
|
"south": {"uv": [0.05469, 12.22656, 1.05469, 13.32031], "texture": "#2"},
|
||||||
|
"west": {"uv": [3, 0, 4, 1.375], "texture": "#2"},
|
||||||
|
"up": {"uv": [0.15625, 10.83594, 1.15625, 11.97656], "texture": "#2"},
|
||||||
|
"down": {"uv": [7, 4.60156, 7.0625, 6.375], "texture": "#2"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": [1, 5, 1],
|
||||||
|
"to": [15, 6, 15],
|
||||||
|
"rotation": {"angle": 0, "axis": "y", "origin": [1, 2, 0]},
|
||||||
|
"faces": {
|
||||||
|
"north": {"uv": [1.8125, 4.25, 2.8125, 4.125], "texture": "#2"},
|
||||||
|
"east": {"uv": [1.86719, 11.82813, 2.79688, 12.82813], "texture": "#2"},
|
||||||
|
"south": {"uv": [0.32813, 11.80469, 1.28906, 12.80469], "texture": "#2"},
|
||||||
|
"west": {"uv": [0.27344, 10.5625, 1.53125, 11.5625], "texture": "#2"},
|
||||||
|
"up": {"uv": [1.8125, 4.125, 2.8125, 2.125], "texture": "#2"},
|
||||||
|
"down": {"uv": [9, 0, 10, 2], "texture": "#2"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": [0, 0, 15],
|
||||||
|
"to": [16, 11, 16],
|
||||||
|
"rotation": {"angle": 0, "axis": "y", "origin": [0, -9, 0]},
|
||||||
|
"faces": {
|
||||||
|
"north": {"uv": [9, 0.125, 10, 1.5], "texture": "#2"},
|
||||||
|
"east": {"uv": [10.8125, 0.625, 10.875, 2], "texture": "#2"},
|
||||||
|
"south": {"uv": [10.875, 0.625, 11.875, 2], "texture": "#2"},
|
||||||
|
"west": {"uv": [6.6875, 0.125, 6.75, 1.5], "texture": "#2"},
|
||||||
|
"up": {"uv": [0, 15, 16, 16], "texture": "#2"},
|
||||||
|
"down": {"uv": [2.8125, 4, 3.8125, 4.125], "texture": "#2"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"parent": "rivencraft:block/book_binder"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"parent": "rivencraft:block/ink_mixer"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"parent": "minecraft:item/generated",
|
||||||
|
"textures": {
|
||||||
|
"layer0": "rivencraft:item/ink_vial"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"parent": "minecraft:item/generated",
|
||||||
|
"textures": {"layer0": "rivencraft:item/writing_desk"}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 747 B |
|
After Width: | Height: | Size: 616 B |
|
After Width: | Height: | Size: 715 B |
|
After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 441 B After Width: | Height: | Size: 449 B |
|
Before Width: | Height: | Size: 489 B After Width: | Height: | Size: 507 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 340 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |