Compare commits

...
4 Commits
Author SHA1 Message Date
dnorberg 85da1f047d Add Legacy-style Book Binder
Build / build (push) Canceled after 0s
2026-09-29 09:06:34 -04:00
dnorberg 6587a924e9 worked on getting writing desk model to match legacy
Build / build (push) Canceled after 0s
2026-09-25 08:27:41 -04:00
dnorberg a359ce7835 Refine Writing Desk collection and ink refill behavior
Build / build (push) Canceled after 0s
2026-09-25 06:07:44 -04:00
dnorberg e2e348d5c5 Refine Writing Desk GUI to match Legacy layout
Build / build (push) Canceled after 0s
2026-09-24 14:54:42 -04:00
44 changed files with 2448 additions and 221 deletions

No files matched your search

+125
View File
@@ -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.
+43 -7
View File
@@ -25,20 +25,56 @@ It occupies two adjacent blocks, with one shared inventory; loaded paper and
the selected page appear on its Legacy-inspired model. the selected page appear on its Legacy-inspired model.
Desks placed before 0.3.6.1 keep their old one-block footprint until broken 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. and placed again; leave an adjacent space when replacing one.
Its four collection tabs, paper input, ink vial input, empty glass-bottle return, Its 88 collection tabs, paper input, ink vial input, empty glass-bottle return,
16-unit ink meter, and inventory persist 16-unit ink meter, and inventory persist
through world saves; breaking it drops stored contents. The desk screen is a through world saves; breaking it drops stored contents. The desk screen is a
functional foundation styled after Legacy's left tabs, central writing surface, functional foundation using Legacy's original desk and scrollbar textures.
and right inventory. Put vanilla paper in the 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 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 lower-left result slot when the selected page is valid. Taking it consumes one
paper and, by default, one ink. An empty meter consumes a vial to refill and paper and, by default, one ink. An available vial refills an empty ink meter
returns a vanilla glass bottle to the lower-right slot. The ALL browser searches when inserted or when copying uses its last ink, and returns a vanilla glass
and filters symbols held in the desk's loose pages and Notebooks; selection bottle to the lower-right slot. Removing a bottle also retries a blocked refill.
is validated against the server's current symbol catalog. 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 Selecting a stored page on the desk surface shows its Legacy-proportioned
portrait preview and name above the player inventory. portrait preview and name above the player inventory.
## Book Binder
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; Milestone 5 will interpret additional symbols during Age generation.
## Linking Book ## Linking Book
Craft a Linking Book from a vanilla book and an ender pearl, then use it in the Craft a Linking Book from a vanilla book and an ender pearl, then use it in the
+41
View File
@@ -78,6 +78,9 @@ 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"
// 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', project.mod_id systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
} }
@@ -116,6 +119,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
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

+5 -2
View File
@@ -10,8 +10,11 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
| `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/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/item/writing_desk.png` | `assets/mystcraft/textures/items/writingdesk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only | | `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/block/writing_desk.png` | `assets/mystcraft/textures/entity/desk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/block/writing_desk_wood.png` and `writing_desk_inlay.png` | `assets/mystcraft/textures/entity/desk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Cropped opaque wood and red inlay regions for the two-block model | | `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/writing_desk_legacy.png` | `assets/mystcraft/gui/writingdesk.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; solid GUI backing sampled from Legacy atlas | | `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/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 |
+1 -1
View File
@@ -35,7 +35,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.6.1 mod_version=0.3.7.2
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.
@@ -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,258 @@
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) || 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;
boolean link = false;
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 (link || !items.get(LINK_PANEL).isEmpty()) return ItemStack.EMPTY;
link = true;
} else if (validPage(page)) writing.add(page);
else return ItemStack.EMPTY;
}
if (end + writing.size() > PAGE_COUNT || writing.isEmpty() && !link) return ItemStack.EMPTY;
if (link) {
for (ItemStack page : pages) {
if (!page.isEmpty() && PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL) {
items.set(LINK_PANEL, page.copy());
break;
}
}
}
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);
}
}
}
@@ -16,18 +16,19 @@ import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
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.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.BlockState;
/** Four collection tabs, paper, ink vials and empty-vial return are owned by the block. */ /** Collection tabs, paper, ink vials and empty-vial return are owned by the block. */
public final class WritingDeskBlockEntity extends BlockEntity implements Container { public final class WritingDeskBlockEntity extends BlockEntity implements Container {
public static final int COLLECTIONS = 4; public static final int COLLECTIONS = 88;
public static final int INK = 4; public static final int INK = COLLECTIONS;
public static final int RETURN = 5; public static final int RETURN = INK + 1;
public static final int PAPER = 6; public static final int PAPER = INK + 2;
/** Unused by new desks; retained so old saves cannot silently lose slot contents. */ /** Unused by new desks; retained so old saves cannot silently lose slot contents. */
public static final int LEGACY_OUTPUT = 7; public static final int LEGACY_OUTPUT = INK + 3;
public static final int SIZE = 8; public static final int SIZE = INK + 4;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY); private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private int activeCollection; private int activeCollection;
private int inkRemaining; private int inkRemaining;
@@ -62,6 +63,7 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
if (!removed.isEmpty()) { if (!removed.isEmpty()) {
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY; if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
setChanged(); setChanged();
if (slot == RETURN) refillEmptyInkBar();
} }
return removed; return removed;
} }
@@ -71,6 +73,7 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize()); if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize());
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY; if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
setChanged(); setChanged();
if (slot == INK || slot == RETURN) refillEmptyInkBar();
} }
@Override public boolean stillValid(Player player) { @Override public boolean stillValid(Player player) {
return level != null && level.isLoaded(worldPosition) return level != null && level.isLoaded(worldPosition)
@@ -89,6 +92,28 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
}; };
} }
private boolean hasBottleSpace() {
ItemStack returned = items.get(RETURN);
return returned.isEmpty() || returned.is(Items.GLASS_BOTTLE)
&& returned.getCount() < returned.getMaxStackSize();
}
/** Consume one vial only when the empty meter can return its glass bottle. */
private void refillEmptyInkBar() {
if (level == null || level.isClientSide || inkRemaining != 0 || !hasBottleSpace()) return;
ItemStack vial = items.get(INK);
if (!vial.is(ModItems.INK_VIAL.get())) return;
int ink = Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY);
if (ink == 0) return;
inkRemaining = ink;
vial.shrink(1);
if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY);
ItemStack returned = items.get(RETURN);
if (returned.isEmpty()) items.set(RETURN, new ItemStack(Items.GLASS_BOTTLE));
else returned.grow(1);
setChanged();
}
/** The output is virtual; inputs are committed only after a result can be given to a player. */ /** The output is virtual; inputs are committed only after a result can be given to a player. */
public boolean canConsumeCopyMaterials() { public boolean canConsumeCopyMaterials() {
ItemStack paper = items.get(PAPER); ItemStack paper = items.get(PAPER);
@@ -96,10 +121,8 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
int cost = WritingInkService.symbolCopyCost(); int cost = WritingInkService.symbolCopyCost();
if (inkRemaining >= cost) return true; if (inkRemaining >= cost) return true;
ItemStack vial = items.get(INK); ItemStack vial = items.get(INK);
ItemStack returned = items.get(RETURN);
return vial.is(ModItems.INK_VIAL.get()) && inkRemaining + InkVialItem.remaining(vial) >= cost return vial.is(ModItems.INK_VIAL.get()) && inkRemaining + InkVialItem.remaining(vial) >= cost
&& (returned.isEmpty() || returned.is(Items.GLASS_BOTTLE) && hasBottleSpace();
&& returned.getCount() < returned.getMaxStackSize());
} }
public boolean consumeCopyMaterials() { public boolean consumeCopyMaterials() {
@@ -117,8 +140,13 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
inkRemaining -= cost; inkRemaining -= cost;
removeItem(PAPER, 1); removeItem(PAPER, 1);
setChanged(); setChanged();
if (inkRemaining == 0) refillEmptyInkBar();
return true; return true;
} }
@Override public void setLevel(Level level) {
super.setLevel(level);
refillEmptyInkBar();
}
@Override public void clearContent() { @Override public void clearContent() {
items.clear(); items.clear();
items = NonNullList.withSize(SIZE, ItemStack.EMPTY); items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
@@ -130,15 +158,25 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
super.loadAdditional(tag, registries); super.loadAdditional(tag, registries);
items = NonNullList.withSize(SIZE, ItemStack.EMPTY); items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
ContainerHelper.loadAllItems(tag, items, registries); 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); activeCollection = Math.clamp(tag.getInt("ActiveCollection"), 0, COLLECTIONS - 1);
inkRemaining = Math.clamp(tag.getInt("InkRemaining"), 0, InkVialItem.CAPACITY); inkRemaining = Math.clamp(tag.getInt("InkRemaining"), 0, InkVialItem.CAPACITY);
displayPage = tag.contains("DisplayPage", CompoundTag.TAG_COMPOUND) displayPage = tag.contains("DisplayPage", CompoundTag.TAG_COMPOUND)
? ItemStack.parse(registries, tag.getCompound("DisplayPage")).orElse(ItemStack.EMPTY) ? ItemStack.parse(registries, tag.getCompound("DisplayPage")).orElse(ItemStack.EMPTY)
: ItemStack.EMPTY; : ItemStack.EMPTY;
refillEmptyInkBar();
} }
@Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { @Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries); super.saveAdditional(tag, registries);
ContainerHelper.saveAllItems(tag, items, registries); ContainerHelper.saveAllItems(tag, items, registries);
tag.putInt("InventoryVersion", 1);
tag.putInt("ActiveCollection", activeCollection); tag.putInt("ActiveCollection", activeCollection);
tag.putInt("InkRemaining", inkRemaining); tag.putInt("InkRemaining", inkRemaining);
if (!displayPage.isEmpty()) tag.put("DisplayPage", displayPage.save(registries)); if (!displayPage.isEmpty()) tag.put("DisplayPage", displayPage.save(registries));
@@ -15,6 +15,7 @@ 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.WritingDeskScreen;
import net.dnorberg.rivencraft.client.screen.BookBinderScreen;
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)
@@ -32,6 +33,7 @@ public final class ClientEvents {
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.WRITING_DESK.get(), WritingDeskScreen::new);
event.register(ModMenus.BOOK_BINDER.get(), BookBinderScreen::new);
} }
@SubscribeEvent @SubscribeEvent
@@ -0,0 +1,215 @@
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;
/** 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 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 (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;
}
return super.keyPressed(keyCode, scanCode, modifiers);
}
}
@@ -2,10 +2,12 @@ package net.dnorberg.rivencraft.client.screen;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.ArrayList;
import java.util.Comparator;
import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.WritingDeskMenu; import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
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.NotebookContents; import net.dnorberg.rivencraft.writing.NotebookContents;
@@ -17,68 +19,109 @@ 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.Slot;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
/** Functional storage surface; full Legacy transcription controls are a later Milestone 4 slice. */ /** Legacy desk atlas layout, with four visible tabs out of the desk's 88 collections. */
public final class WritingDeskScreen extends AbstractContainerScreen<WritingDeskMenu> { public final class WritingDeskScreen extends AbstractContainerScreen<WritingDeskMenu> {
private static final ResourceLocation PAGE_ATLAS = ResourceLocation.fromNamespaceAndPath( private static final ResourceLocation PAGE_ATLAS = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/notebook_pages.png"); Rivencraft.MOD_ID, "textures/gui/notebook_pages.png");
private static final ResourceLocation LEGACY_DESK = ResourceLocation.fromNamespaceAndPath( private static final ResourceLocation LEGACY_DESK = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/writing_desk_legacy.png"); 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 lastCollection = -1;
private int selectedPage; private int selectedPage;
private int scrollRows; private int scrollRows;
private boolean allMode; private boolean allMode;
private ResourceLocation selectedSymbol; private boolean sortAlphabetically;
private int selectedCollection = -1;
private ResourceLocation selectedCategory; private ResourceLocation selectedCategory;
private EditBox search; private EditBox search;
private Button categoryButton; 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) { public WritingDeskScreen(WritingDeskMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title); super(menu, inventory, title);
imageWidth = 398; imageWidth = 409;
imageHeight = 176; imageHeight = 186;
titleLabelX = 222; titleLabelX = 222;
inventoryLabelX = 224;
inventoryLabelY = 79;
} }
@Override protected void init() { @Override protected void init() {
super.init(); super.init();
addRenderableWidget(Button.builder(Component.literal("ALL"), button -> { sortButton = addRenderableWidget(Button.builder(Component.literal("AZ"), button -> {
allMode = true; sortAlphabetically = !sortAlphabetically;
scrollRows = 0; scrollRows = 0;
}).bounds(leftPos + 48, topPos - 18, 29, 18).build()); }).bounds(leftPos + 58, topPos, 20, 18).build());
categoryButton = addRenderableWidget(Button.builder(Component.literal("All types"), button -> { 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).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() List<ResourceLocation> categories = menu.catalog().stream()
.map(SymbolDisplayMetadata::category).distinct().sorted().toList(); .map(SymbolDisplayMetadata::category).distinct().sorted().toList();
int next = selectedCategory == null ? 0 : categories.indexOf(selectedCategory) + 1; int next = selectedCategory == null ? 0 : categories.indexOf(selectedCategory) + 1;
selectedCategory = next >= categories.size() ? null : categories.get(next); selectedCategory = next >= categories.size() ? null : categories.get(next);
categoryButton.setMessage(Component.literal(selectedCategory == null categoryButton.setMessage(Component.literal(selectedCategory == null ? "C" : "C*"));
? "All types" : selectedCategory.getPath()));
allMode = true;
scrollRows = 0; scrollRows = 0;
}).bounds(leftPos + 79, topPos - 18, 66, 18).build()); }).bounds(leftPos + 213, topPos, 18, 18).build());
search = new EditBox(font, leftPos + 147, topPos - 18, 69, 18,
Component.translatable("screen.rivencraft.writing_desk.search"));
search.setMaxLength(64);
search.setHint(Component.translatable("screen.rivencraft.writing_desk.search"));
search.setResponder(value -> {
allMode = true;
scrollRows = 0;
});
addRenderableWidget(search);
} }
private List<SymbolDisplayMetadata> filteredCatalog() { private List<DisplayPage> filteredPages() {
String query = search == null ? "" : search.getValue().trim().toLowerCase(Locale.ROOT); String query = search == null ? "" : search.getValue().trim().toLowerCase(Locale.ROOT);
return menu.catalog().stream() List<DisplayPage> entries = new ArrayList<>();
.filter(entry -> selectedCategory == null || selectedCategory.equals(entry.category())) int from = allMode ? 0 : menu.activeCollection();
.filter(entry -> query.isEmpty() int until = allMode ? WritingDeskBlockEntity.COLLECTIONS : from + 1;
|| entry.id().toString().toLowerCase(Locale.ROOT).contains(query) for (int collection = from; collection < until; collection++) {
|| Component.translatable(entry.nameTranslationKey()).getString() List<ItemStack> pages = pages(collection);
.toLowerCase(Locale.ROOT).contains(query)) int last = -1;
.toList(); 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) { @Override public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
@@ -90,54 +133,83 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
@Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) { @Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
int x = leftPos; int x = leftPos;
int y = topPos; int y = topPos;
// Use textured backing: GUI fills may depth-test behind the world with some renderers. // Legacy GuiWritingDesk draws the entire 176x166 panel, not a stretched swatch.
graphics.blit(LEGACY_DESK, x + 218, y + 4, 178, 170, 64, 8, 16, 16, 256, 256); 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++) { for (int i = 0; i < 4; i++) {
graphics.blit(LEGACY_DESK, x + 6, y + 16 + i * 37, int tab = menu.firstVisibleTab() + i;
34, 32, 0, 166, 56, 54, 256, 256); int tabY = y + 29 + i * 37;
if (i == menu.activeCollection()) if (tab == menu.activeCollection() && !allMode) graphics.setColor(0.5F, 0.5F, 1F, 1F);
graphics.renderOutline(x + 6, y + 16 + i * 37, 34, 32, 0xFF7777B6); graphics.blit(LEGACY_DESK, x, tabY, 0, 166, 58, 37);
slotFrame(graphics, 12, 20 + i * 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();
}
} }
slotFrame(graphics, 370, 20); drawTabControl(graphics, 177, 212, menu.firstVisibleTab() < WritingDeskBlockEntity.COLLECTIONS - 4);
slotFrame(graphics, 228, 20); // GuiElementVSlider: 4-pixel caps, repeated 22-pixel track, 12x15 thumb.
slotFrame(graphics, 228, 57); graphics.blit(SCROLLBAR, x + 213, y + 20, 0, 0, 20, 4, 32, 32);
slotFrame(graphics, 370, 57); for (int offset = 4; offset < 162; offset += 22)
graphics.fill(x + 350, y + 20, x + 364, y + 75, 0xFF303035); graphics.blit(SCROLLBAR, x + 213, y + 20 + offset, 0, 4, 20, Math.min(22, 162 - offset), 32, 32);
graphics.fill(x + 352, y + 22, x + 362, y + 73, 0xFF15151B); graphics.blit(SCROLLBAR, x + 213, y + 182, 0, 26, 20, 4, 32, 32);
int filled = 51 * menu.inkRemaining() / 16; int totalRows = (filteredPages().size() + 4) / 5;
if (filled > 0) graphics.fill(x + 352, y + 73 - filled, x + 362, y + 73, 0xFF6666AA); int maxScroll = Math.max(0, totalRows - 4);
graphics.renderOutline(x + 350, y + 20, 14, 55, 0xFF484848); scrollRows = Math.clamp(scrollRows, 0, maxScroll);
for (int row = 0; row < 3; row++) { int thumbY = y + 24 + (maxScroll == 0 ? 0 : scrollRows * 143 / maxScroll);
for (int column = 0; column < 9; column++) slotFrame(graphics, 224 + column * 18, 91 + row * 18); 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);
for (int column = 0; column < 9; column++) slotFrame(graphics, 224 + column * 18, 149); 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 slotFrame(GuiGraphics graphics, int x, int y) { private void drawTabControl(GuiGraphics graphics, int y, int textureY, boolean enabled) {
graphics.fill(leftPos + x - 1, topPos + y - 1, if (!enabled) graphics.setColor(0.4F, 0.4F, 0.4F, 1F);
leftPos + x + 17, topPos + y + 17, 0xFF4D4D4D); graphics.blit(LEGACY_DESK, leftPos, topPos + y, 0, textureY, 58, 9);
graphics.fill(leftPos + x, topPos + y, graphics.setColor(1F, 1F, 1F, 1F);
leftPos + x + 16, topPos + y + 16, 0xFF858585);
} }
@Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { @Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false); // Legacy leaves the inventory grid unlabeled.
} }
@Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { @Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
super.render(graphics, mouseX, mouseY, 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); 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); renderTooltip(graphics, mouseX, mouseY);
if (mouseX >= leftPos + 350 && mouseX < leftPos + 364 if (mouseX >= leftPos + 365 && mouseX < leftPos + 381
&& mouseY >= topPos + 20 && mouseY < topPos + 75) { && mouseY >= topPos + 27 && mouseY < topPos + 97) {
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.ink", graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.ink",
menu.inkRemaining(), 16), mouseX, mouseY); menu.inkRemaining(), 16), mouseX, mouseY);
} }
if (mouseX >= leftPos + 213 && mouseX < leftPos + 231
&& mouseY >= topPos && mouseY < topPos + 18) {
graphics.renderTooltip(font, Component.literal(selectedCategory == null
? "All categories" : selectedCategory.toString()), mouseX, mouseY);
}
} }
private List<ItemStack> pages() { private List<ItemStack> pages(int collectionIndex) {
ItemStack collection = menu.getSlot(menu.activeCollection()).getItem(); ItemStack collection = menu.getSlot(collectionIndex).getItem();
if (collection.is(ModItems.PAGE.get())) return List.of(collection); if (collection.is(ModItems.PAGE.get())) return List.of(collection);
if (collection.is(ModItems.NOTEBOOK.get())) { if (collection.is(ModItems.NOTEBOOK.get())) {
return collection.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages(); return collection.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
@@ -146,73 +218,34 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
} }
private void renderCollection(GuiGraphics graphics) { private void renderCollection(GuiGraphics graphics) {
if (allMode) {
renderCatalog(graphics);
return;
}
List<ItemStack> pages = pages();
if (lastCollection != menu.activeCollection()) { if (lastCollection != menu.activeCollection()) {
lastCollection = menu.activeCollection(); lastCollection = menu.activeCollection();
scrollRows = 0; scrollRows = 0;
selectedPage = 0; selectedPage = 0;
selectedCollection = -1;
} }
int last = -1; List<DisplayPage> entries = filteredPages();
for (int i = 0; i < pages.size(); i++) if (!pages.get(i).isEmpty()) last = i; for (int i = scrollRows * 5; i < entries.size() && i < scrollRows * 5 + 20; i++) {
for (int i = scrollRows * 5; i <= last && i < scrollRows * 5 + 15; i++) {
int column = i % 5; int column = i % 5;
int row = i / 5 - scrollRows; int row = i / 5 - scrollRows;
ItemStack page = pages.get(i); DisplayPage entry = entries.get(i);
int x = leftPos + 48 + column * 31; ItemStack page = entry.stack();
int y = topPos + 18 + row * 41; int x = leftPos + 58 + column * 31;
int y = topPos + 20 + row * 41;
drawPage(graphics, page, x, y); drawPage(graphics, page, x, y);
if (page.isEmpty()) graphics.fill(x, y, x + 30, y + 40, 0xB8000000); if (page.isEmpty()) graphics.fill(x, y, x + 30, y + 40, 0xB8000000);
if (i == selectedPage && !page.isEmpty()) graphics.renderOutline(x, y, 30, 40, 0xFFFFD061); int active = selectedCollection >= 0 ? selectedCollection : menu.activeCollection();
} if (entry.collection() == active && entry.index() == selectedPage && !page.isEmpty())
if (selectedPage >= pages.size() || pages.get(selectedPage).isEmpty()) return;
ItemStack selected = pages.get(selectedPage);
graphics.pose().pushPose();
graphics.pose().translate(leftPos + 294, topPos + 12, 0);
graphics.pose().scale(1.35F, 1.35F, 1.0F);
drawPage(graphics, selected, 0, 0);
graphics.pose().popPose();
String title = font.plainSubstrByWidth(selected.getHoverName().getString(), 112);
graphics.fill(leftPos + 251, topPos + 64, leftPos + 365, topPos + 77, 0xFF202020);
graphics.drawString(font, title, leftPos + 254, topPos + 67, 0xFFE0E0E0, false);
}
private void renderCatalog(GuiGraphics graphics) {
List<SymbolDisplayMetadata> filtered = filteredCatalog();
if (filtered.isEmpty()) {
graphics.drawString(font, Component.translatable("screen.rivencraft.writing_desk.no_symbols"),
leftPos + 52, topPos + 24, 0xFFC4B8A3, false);
}
for (int i = scrollRows * 5; i < filtered.size() && i < scrollRows * 5 + 15; i++) {
SymbolDisplayMetadata entry = filtered.get(i);
int x = leftPos + 48 + (i % 5) * 31;
int y = topPos + 18 + (i / 5 - scrollRows) * 41;
drawSymbolPage(graphics, entry.id(), x, y);
if (Objects.equals(entry.id(), selectedSymbol)) {
graphics.renderOutline(x, y, 30, 40, 0xFFFFD061); graphics.renderOutline(x, y, 30, 40, 0xFFFFD061);
}
} }
SymbolDisplayMetadata selected = menu.catalog().stream() int active = selectedCollection >= 0 ? selectedCollection : menu.activeCollection();
.filter(entry -> Objects.equals(entry.id(), selectedSymbol)).findFirst().orElse(null); List<ItemStack> source = pages(active);
if (selected == null) return; if (selectedPage >= source.size() || source.get(selectedPage).isEmpty()) return;
graphics.pose().pushPose(); ItemStack selected = source.get(selectedPage);
graphics.pose().translate(leftPos + 294, topPos + 12, 0); drawPage(graphics, selected, leftPos + 265, topPos + 26);
graphics.pose().scale(1.35F, 1.35F, 1.0F); String title = font.plainSubstrByWidth(selected.getHoverName().getString(), 95);
drawSymbolPage(graphics, selected.id(), 0, 0); graphics.fill(leftPos + 261, topPos + 81, leftPos + 360, topPos + 95, 0xFF202020);
graphics.pose().popPose(); graphics.drawString(font, title, leftPos + 263, topPos + 84, 0xFFE0E0E0, false);
String title = font.plainSubstrByWidth(
Component.translatable(selected.nameTranslationKey()).getString(), 112);
graphics.fill(leftPos + 251, topPos + 64, leftPos + 365, topPos + 77, 0xFF202020);
graphics.drawString(font, title, leftPos + 254, topPos + 67, 0xFFE0E0E0, false);
}
private void drawSymbolPage(GuiGraphics graphics, ResourceLocation id, int x, int y) {
graphics.blit(PAGE_ATLAS, x, y, 30, 40, 156, 0, 30, 40, 256, 256);
graphics.blit(NotebookScreen.symbolTexture(id), x + 1, y + 6,
28, 28, 16, 16, 96, 96, 128, 128);
} }
private void drawPage(GuiGraphics graphics, ItemStack page, int x, int y) { private void drawPage(GuiGraphics graphics, ItemStack page, int x, int y) {
@@ -226,45 +259,116 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
} }
} }
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) { @Override public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (button == 0) { if (button == 0) {
int x = (int) mouseX - leftPos - 48; pageDragSource = null;
int y = (int) mouseY - topPos - 18; draggingPage = false;
if (x >= 0 && x < 155 && y >= 0 && y < 123) { }
int column = x / 31; int surfaceX = (int) Math.floor(mouseX - leftPos - 58);
int row = y / 41; int surfaceY = (int) Math.floor(mouseY - topPos - 20);
int index = (row + scrollRows) * 5 + column; if ((button == 0 || button == 1) && !menu.getCarried().isEmpty()
if (allMode) { && surfaceX >= 0 && surfaceX < 155 && surfaceY >= 0 && surfaceY < 164) {
List<SymbolDisplayMetadata> filtered = filteredCatalog(); if (menu.getCarried().is(ModItems.PAGE.get()) && surfaceX % 31 < 30 && surfaceY % 41 < 40) {
if (x % 31 < 30 && y % 41 < 40 && index < filtered.size()) { int index = (surfaceY / 41 + scrollRows) * 5 + surfaceX / 31;
SymbolDisplayMetadata entry = filtered.get(index); List<DisplayPage> entries = filteredPages();
selectedSymbol = entry.id(); // Only displayed positions are addressable. Filtering never turns
int serverIndex = menu.catalog().indexOf(entry); // empty screen space into additional storage for a loose-page tab.
if (serverIndex >= 0 && minecraft != null && minecraft.gameMode != null) { int collection = index < entries.size() ? entries.get(index).collection() : menu.activeCollection();
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, 100 + serverIndex); 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;
} }
} else { if (minecraft != null && minecraft.gameMode != null) {
List<ItemStack> pages = pages(); int id = WritingDeskMenu.ALL_PAGE_BUTTON_BASE
if (x % 31 < 30 && y % 41 < 40 && index < pages.size() + entry.collection() * NotebookContents.MAX_PAGES + entry.index();
&& !pages.get(index).isEmpty()) { minecraft.gameMode.handleInventoryButtonClick(menu.containerId, id);
selectedPage = index;
if (minecraft != null && minecraft.gameMode != null) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, 16 + index);
}
} }
} }
return true; return true;
} }
} }
if (button == 0 && mouseX >= leftPos + 29 && mouseX < leftPos + 40) { if (button == 0 && mouseX >= leftPos && mouseX < leftPos + 58) {
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
int top = topPos + 16 + i * 37; int top = topPos + 29 + i * 37;
if (mouseY >= top && mouseY < top + 32) { boolean overSlot = mouseX >= leftPos + 36 && mouseX < leftPos + 54
&& mouseY >= top + 4 && mouseY < top + 22;
if (mouseY >= top && mouseY < top + 37 && !overSlot) {
allMode = false; allMode = false;
selectedSymbol = null; selectedCollection = -1;
if (minecraft != null && minecraft.gameMode != null) { if (minecraft != null && minecraft.gameMode != null) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, i); minecraft.gameMode.handleInventoryButtonClick(menu.containerId, menu.firstVisibleTab() + i);
} }
return true; return true;
} }
@@ -274,15 +378,63 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
} }
@Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { @Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (mouseX >= leftPos + 48 && mouseX < leftPos + 203 if (mouseX >= leftPos + 58 && mouseX < leftPos + 233
&& mouseY >= topPos + 18 && mouseY < topPos + 141) { && mouseY >= topPos + 20 && mouseY < topPos + 186) {
int rows = ((allMode ? filteredCatalog().size() : pages().size()) + 4) / 5; int rows = (filteredPages().size() + 4) / 5;
scrollRows = Math.clamp(scrollRows - (int) Math.signum(scrollY), 0, Math.max(0, rows - 3)); scrollRows = Math.clamp(scrollRows - (int) Math.signum(scrollY), 0, Math.max(0, rows - 4));
return true; return true;
} }
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); 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) { @Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (search != null && search.isFocused()) { if (search != null && search.isFocused()) {
if (keyCode == 256) { if (keyCode == 256) {
@@ -0,0 +1,284 @@
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.MOD_ID)
@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();
}
@GameTest(template = "empty")
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.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");
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();
}
@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();
}
}
@@ -61,10 +61,10 @@ public final class WritingDeskGameTests {
helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3); helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(pos).inflate(2)); 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 notebooks = drops.stream().filter(e -> e.getItem().is(ModItems.NOTEBOOK.get())).mapToInt(e -> e.getItem().getCount()).sum();
int ink = drops.stream().filter(e -> e.getItem().is(ModItems.INK_VIAL.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(); 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(notebooks, 1, "Notebook must drop exactly once");
helper.assertValueEqual(ink, 1, "Ink 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.assertValueEqual(pages, 12, "Pages must drop without loss");
helper.succeed(); helper.succeed();
} }
@@ -81,7 +81,7 @@ public final class WritingDeskGameTests {
helper.assertTrue(menu.stillValid(player), "Placed desk must keep its menu valid"); 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.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"); helper.assertValueEqual(desk.activeCollection(), 3, "Selected tab must be stored on the desk");
menu.quickMoveStack(player, 9); menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT + 1);
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 12, "Shift-click must deposit all pages"); 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"); helper.assertTrue(player.getInventory().getItem(9).isEmpty(), "Shift-click must consume player source stack");
menu.quickMoveStack(player, WritingDeskBlockEntity.PAPER); menu.quickMoveStack(player, WritingDeskBlockEntity.PAPER);
@@ -111,12 +111,12 @@ public final class WritingDeskGameTests {
"A source page with paper and ink must preview one copy"); "A source page with paper and ink must preview one copy");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 2, helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 2,
"Previewing must not consume paper"); "Previewing must not consume paper");
helper.assertValueEqual(desk.inkRemaining(), 0, "Previewing must not consume ink"); helper.assertValueEqual(desk.inkRemaining(), 16, "Inserting a vial must fill the empty bar before copying");
menu.clicked(WritingDeskMenu.COPY_RESULT, 0, ClickType.PICKUP, player); 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.assertTrue(menu.getCarried().is(ModItems.PAGE.get()), "Taking result must give the copied page");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1, helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
"Taking a copy must consume one paper"); "Taking a copy must consume one paper");
helper.assertValueEqual(desk.inkRemaining(), 15, "First copy must refill to 16 and consume one ink"); 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.INK).isEmpty(), "Refill must consume one vial");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.RETURN).is(Items.GLASS_BOTTLE), helper.assertTrue(desk.getItem(WritingDeskBlockEntity.RETURN).is(Items.GLASS_BOTTLE),
"Refill must return a vanilla glass bottle"); "Refill must return a vanilla glass bottle");
@@ -135,6 +135,48 @@ public final class WritingDeskGameTests {
helper.succeed(); 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") @GameTest(template = "empty")
public static void shiftClickCopiesOnlyWhenResourcesAndBottleSpaceExist(GameTestHelper helper) { public static void shiftClickCopiesOnlyWhenResourcesAndBottleSpaceExist(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1)); BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
@@ -142,8 +184,8 @@ public final class WritingDeskGameTests {
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos); WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
desk.setItem(0, PageItem.createLinkPanelPage()); desk.setItem(0, PageItem.createLinkPanelPage());
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 2)); desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
desk.setItem(WritingDeskBlockEntity.RETURN, new ItemStack(Items.GLASS_BOTTLE, 64)); desk.setItem(WritingDeskBlockEntity.RETURN, new ItemStack(Items.GLASS_BOTTLE, 64));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var player = helper.makeMockServerPlayerInLevel(); var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5); player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk); WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
@@ -180,7 +222,7 @@ public final class WritingDeskGameTests {
menu.broadcastChanges(); menu.broadcastChanges();
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().isEmpty(), helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().isEmpty(),
"An empty selected position must not create a copy"); "An empty selected position must not create a copy");
helper.assertTrue(menu.clickMenuButton(player, 17), "Second page selection must be accepted"); helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.PAGE_BUTTON_BASE + 1), "Second page selection must be accepted");
menu.broadcastChanges(); menu.broadcastChanges();
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().is(ModItems.PAGE.get()), helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().is(ModItems.PAGE.get()),
"Selecting the populated page must show a copy"); "Selecting the populated page must show a copy");
@@ -230,8 +272,8 @@ public final class WritingDeskGameTests {
var player = helper.makeMockServerPlayerInLevel(); var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5); player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk); WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
helper.assertFalse(menu.clickMenuButton(player, 101), "Out-of-range catalog index must be rejected"); helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.CATALOG_BUTTON_BASE + 1), "Out-of-range catalog index must be rejected");
helper.assertTrue(menu.clickMenuButton(player, 100), "Known catalog symbol must be selectable"); 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.PAPER, new ItemStack(Items.PAPER));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get())); desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
menu.broadcastChanges(); menu.broadcastChanges();
@@ -262,9 +304,9 @@ public final class WritingDeskGameTests {
"Full inventory must reject shift-click copy"); "Full inventory must reject shift-click copy");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1, helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
"Rejected copy must preserve paper"); "Rejected copy must preserve paper");
helper.assertValueEqual(desk.inkRemaining(), 0, "Rejected copy must preserve ink meter"); helper.assertValueEqual(desk.inkRemaining(), 16, "Rejected copy must preserve the filled ink meter");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.INK).is(ModItems.INK_VIAL.get()), helper.assertTrue(desk.getItem(WritingDeskBlockEntity.INK).isEmpty(),
"Rejected copy must preserve ink vial"); "The vial should already have refilled the empty meter");
helper.succeed(); helper.succeed();
} }
@@ -321,4 +363,260 @@ public final class WritingDeskGameTests {
"Open shelves must not cull terrain behind the desk"); "Open shelves must not cull terrain behind the desk");
helper.succeed(); 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();
}
@GameTest(template = "empty")
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,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);
}
}
}
@@ -29,14 +29,27 @@ import net.minecraft.world.item.Items;
public final class WritingDeskMenu extends AbstractContainerMenu { public final class WritingDeskMenu extends AbstractContainerMenu {
public static final int COPY_RESULT = WritingDeskBlockEntity.SIZE; 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 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 Container desk;
private final WritingDeskBlockEntity blockEntity; private final WritingDeskBlockEntity blockEntity;
private final SimpleContainer result = new SimpleContainer(1); private final SimpleContainer result = new SimpleContainer(1);
private final boolean clientSide; private final boolean clientSide;
private int syncedActiveCollection; private int syncedActiveCollection;
private int syncedInkRemaining; private int syncedInkRemaining;
private int firstVisibleTab;
private int selectedPage; private int selectedPage;
private int selectedCollection = -1;
private ResourceLocation selectedSymbol; private ResourceLocation selectedSymbol;
private List<SymbolDisplayMetadata> catalog = List.of(); private List<SymbolDisplayMetadata> catalog = List.of();
private final ServerPlayer serverViewer; private final ServerPlayer serverViewer;
@@ -54,6 +67,11 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
this.clientSide = inventory.player.level().isClientSide; this.clientSide = inventory.player.level().isClientSide;
this.serverViewer = inventory.player instanceof ServerPlayer serverPlayer ? serverPlayer : null; this.serverViewer = inventory.player instanceof ServerPlayer serverPlayer ? serverPlayer : null;
if (blockEntity != null && !clientSide) catalog = WritingDeskCatalog.available(blockEntity); 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() { addDataSlot(new DataSlot() {
@Override public int get() { return WritingDeskMenu.this.blockEntity == null @Override public int get() { return WritingDeskMenu.this.blockEntity == null
? syncedActiveCollection : WritingDeskMenu.this.blockEntity.activeCollection(); } ? syncedActiveCollection : WritingDeskMenu.this.blockEntity.activeCollection(); }
@@ -65,43 +83,94 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
@Override public void set(int value) { syncedInkRemaining = value; } @Override public void set(int value) { syncedInkRemaining = value; }
}); });
for (int i = 0; i < WritingDeskBlockEntity.COLLECTIONS; i++) { for (int i = 0; i < WritingDeskBlockEntity.COLLECTIONS; i++) {
addSlot(new DeskSlot(desk, i, 12, 20 + i * 37, 1)); addSlot(new DeskSlot(desk, i, 37, 34 + (i % VISIBLE_TABS) * 37, 1));
} }
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.INK, 370, 20, 16)); addSlot(new DeskSlot(desk, WritingDeskBlockEntity.INK, 385, 28, 16));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.RETURN, 370, 57, 64)); addSlot(new DeskSlot(desk, WritingDeskBlockEntity.RETURN, 385, 80, 64));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.PAPER, 228, 20, 64)); addSlot(new DeskSlot(desk, WritingDeskBlockEntity.PAPER, 241, 28, 64));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.LEGACY_OUTPUT, -1000, -1000, 64)); addSlot(new DeskSlot(desk, WritingDeskBlockEntity.LEGACY_OUTPUT, -1000, -1000, 64));
addSlot(new CopySlot(result, 0, 228, 57)); addSlot(new CopySlot(result, 0, 241, 80));
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 Slot(inventory, column + row * 9 + 9, 224 + column * 18, 91 + row * 18)); addSlot(new Slot(inventory, column + row * 9 + 9, 241 + column * 18, 104 + row * 18));
} }
} }
for (int column = 0; column < 9; column++) { for (int column = 0; column < 9; column++) {
addSlot(new Slot(inventory, column, 224 + column * 18, 149)); addSlot(new Slot(inventory, column, 241 + column * 18, 162));
} }
} }
public int activeCollection() { return syncedActiveCollection; } public int activeCollection() { return syncedActiveCollection; }
public int firstVisibleTab() { return firstVisibleTab; }
public int inkRemaining() { return syncedInkRemaining; } 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 List<SymbolDisplayMetadata> catalog() { return catalog; }
public void setCatalog(List<SymbolDisplayMetadata> symbols) { catalog = List.copyOf(symbols); } 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) { @Override public boolean clickMenuButton(Player player, int id) {
if (blockEntity == null || !stillValid(player)) return false; if (blockEntity == null || !stillValid(player)) return false;
if (id >= 0 && id < WritingDeskBlockEntity.COLLECTIONS) { 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); blockEntity.setActiveCollection(id);
selectedPage = 0; selectedPage = 0;
selectedCollection = -1;
selectedSymbol = null; selectedSymbol = null;
} else if (id >= 16 && id < 16 + NotebookContents.MAX_PAGES) { } else if (id >= EXTRACT_PAGE_BUTTON_BASE
selectedPage = id - 16; && 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; selectedSymbol = null;
} else if (id >= 100 && id < 100 + catalog.size()) { } 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()) {
if (!catalog.equals(WritingDeskCatalog.available(blockEntity))) { if (!catalog.equals(WritingDeskCatalog.available(blockEntity))) {
refreshCatalog(); refreshCatalog();
return false; return false;
} }
ResourceLocation requested = catalog.get(id - 100).id(); ResourceLocation requested = catalog.get(id - CATALOG_BUTTON_BASE).id();
if (!WritingDeskCatalog.available(blockEntity).stream().anyMatch(entry -> entry.id().equals(requested))) { if (!WritingDeskCatalog.available(blockEntity).stream().anyMatch(entry -> entry.id().equals(requested))) {
return false; return false;
} }
@@ -111,6 +180,129 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
return true; 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) { @Override public boolean stillValid(Player player) {
return blockEntity != null && blockEntity.stillValid(player); return blockEntity != null && blockEntity.stillValid(player);
} }
@@ -130,6 +322,7 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
return copy; return copy;
} }
Slot slot = slots.get(index); Slot slot = slots.get(index);
if (!slot.isActive()) return ItemStack.EMPTY;
if (!slot.hasItem()) return ItemStack.EMPTY; if (!slot.hasItem()) return ItemStack.EMPTY;
ItemStack moving = slot.getItem(); ItemStack moving = slot.getItem();
ItemStack original = moving.copy(); ItemStack original = moving.copy();
@@ -144,12 +337,13 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
} }
private boolean moveIntoDesk(ItemStack moving) { private boolean moveIntoDesk(ItemStack moving) {
if (moving.is(ModItems.INK_VIAL.get())) return moveItemStackTo(moving, 4, 5, false); if (moving.is(ModItems.INK_VIAL.get())) return moveItemStackTo(moving,
if (moving.is(ModItems.NOTEBOOK.get())) return moveItemStackTo(moving, 0, 4, false); 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)) if (moving.is(Items.PAPER))
return moveItemStackTo(moving, WritingDeskBlockEntity.PAPER, return moveItemStackTo(moving, WritingDeskBlockEntity.PAPER,
WritingDeskBlockEntity.PAPER + 1, false); WritingDeskBlockEntity.PAPER + 1, false);
if (moving.is(ModItems.PAGE.get())) return moveItemStackTo(moving, 0, 4, false); if (moving.is(ModItems.PAGE.get())) return moveItemStackTo(moving, firstVisibleTab, firstVisibleTab + VISIBLE_TABS, false);
return false; return false;
} }
@@ -162,12 +356,20 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
.anyMatch(entry -> entry.id().equals(selectedSymbol)) .anyMatch(entry -> entry.id().equals(selectedSymbol))
? PageItem.createSymbolPage(reference) : ItemStack.EMPTY; ? PageItem.createSymbolPage(reference) : ItemStack.EMPTY;
} }
ItemStack collection = desk.getItem(blockEntity == null ? syncedActiveCollection : blockEntity.activeCollection()); int collectionIndex = selectedCollection >= 0 ? selectedCollection
if (collection.is(ModItems.PAGE.get())) return selectedPage == 0 ? collection : ItemStack.EMPTY; : 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())) { if (collection.is(ModItems.NOTEBOOK.get())) {
List<ItemStack> pages = collection.getOrDefault( List<ItemStack> pages = collection.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages(); ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
return selectedPage < pages.size() ? pages.get(selectedPage) : ItemStack.EMPTY; return pageIndex < pages.size() ? pages.get(pageIndex) : ItemStack.EMPTY;
} }
return ItemStack.EMPTY; return ItemStack.EMPTY;
} }
@@ -211,6 +413,7 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
} }
refreshCatalog(); refreshCatalog();
refreshOutput(); refreshOutput();
if (slotId >= 0 && slotId < slots.size() && !slots.get(slotId).isActive()) return;
super.clicked(slotId, button, clickType, player); super.clicked(slotId, button, clickType, player);
} }
@@ -226,13 +429,19 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
} }
} }
private static final class DeskSlot extends Slot { private final class DeskSlot extends Slot {
private final int limit; private final int limit;
private DeskSlot(Container container, int index, int x, int y, int limit) { private DeskSlot(Container container, int index, int x, int y, int limit) {
super(container, index, x, y); super(container, index, x, y);
this.limit = limit; this.limit = limit;
} }
@Override public boolean mayPlace(ItemStack stack) { return container.canPlaceItem(getContainerSlot(), stack); } @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; } @Override public int getMaxStackSize() { return limit; }
} }
} }
@@ -13,6 +13,7 @@ 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.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.minecraft.core.component.DataComponents; import net.minecraft.core.component.DataComponents;
@@ -49,7 +50,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("12");
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,6 +72,16 @@ public final class ModNetworking {
ClientAgeCelestialAngles::handle); ClientAgeCelestialAngles::handle);
registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC, registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC,
ClientWritingDeskCatalog::handle); ClientWritingDeskCatalog::handle);
registrar.playToServer(SetBookBinderTitlePayload.TYPE, SetBookBinderTitlePayload.STREAM_CODEC,
ModNetworking::setBookBinderTitle);
}
private static void setBookBinderTitle(SetBookBinderTitlePayload payload, IPayloadContext context) {
if (context.player() instanceof ServerPlayer player
&& player.containerMenu instanceof BookBinderMenu menu
&& menu.containerId == payload.containerId()) {
menu.setTitle(player, payload.title());
}
} }
private static void requestDescriptiveBookScreen(RequestDescriptiveBookScreenPayload payload, IPayloadContext context) { private static void requestDescriptiveBookScreen(RequestDescriptiveBookScreenPayload payload, IPayloadContext context) {
@@ -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; }
}
@@ -2,6 +2,7 @@ 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.WritingDeskBlockEntity;
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;
@@ -20,6 +21,9 @@ public final class ModBlockEntities {
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<WritingDeskBlockEntity>> WRITING_DESK = public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<WritingDeskBlockEntity>> WRITING_DESK =
BLOCK_ENTITIES.register("writing_desk", () -> BlockEntityType.Builder BLOCK_ENTITIES.register("writing_desk", () -> BlockEntityType.Builder
.of(WritingDeskBlockEntity::new, ModBlocks.WRITING_DESK.get()).build(null)); .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));
private ModBlockEntities() { private ModBlockEntities() {
} }
@@ -2,6 +2,7 @@ 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.WritingDeskBlock;
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;
@@ -21,6 +22,10 @@ public final class ModBlocks {
"writing_desk", WritingDeskBlock::new, "writing_desk", WritingDeskBlock::new,
BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion() 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()
);
private ModBlocks() { private ModBlocks() {
} }
@@ -23,6 +23,7 @@ public final class ModCreativeTabs {
output.accept(ModItems.NOTEBOOK.get()); output.accept(ModItems.NOTEBOOK.get());
output.accept(ModItems.INK_VIAL.get()); output.accept(ModItems.INK_VIAL.get());
output.accept(ModItems.WRITING_DESK.get()); output.accept(ModItems.WRITING_DESK.get());
output.accept(ModItems.BOOK_BINDER.get());
}) })
.build()); .build());
@@ -5,6 +5,7 @@ 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 com.mojang.serialization.Codec;
import net.minecraft.core.component.DataComponentType; import net.minecraft.core.component.DataComponentType;
@@ -61,6 +62,12 @@ 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 = public static final DeferredHolder<DataComponentType<?>, DataComponentType<Integer>> INK_REMAINING =
DATA_COMPONENTS.register("ink_remaining", () -> DataComponentType.<Integer>builder() DATA_COMPONENTS.register("ink_remaining", () -> DataComponentType.<Integer>builder()
.persistent(Codec.intRange(0, 16)) .persistent(Codec.intRange(0, 16))
@@ -41,6 +41,7 @@ public final class ModItems {
new Item.Properties().stacksTo(16) new Item.Properties().stacksTo(16)
); );
public static final DeferredItem<BlockItem> WRITING_DESK = ITEMS.registerSimpleBlockItem(ModBlocks.WRITING_DESK); public static final DeferredItem<BlockItem> WRITING_DESK = ITEMS.registerSimpleBlockItem(ModBlocks.WRITING_DESK);
public static final DeferredItem<BlockItem> BOOK_BINDER = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_BINDER);
private ModItems() { private ModItems() {
} }
@@ -3,6 +3,7 @@ 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.WritingDeskMenu;
import net.dnorberg.rivencraft.menu.BookBinderMenu;
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;
@@ -18,6 +19,8 @@ public final class ModMenus {
MENUS.register("notebook", () -> IMenuTypeExtension.create(NotebookMenu::new)); MENUS.register("notebook", () -> IMenuTypeExtension.create(NotebookMenu::new));
public static final DeferredHolder<MenuType<?>, MenuType<WritingDeskMenu>> WRITING_DESK = public static final DeferredHolder<MenuType<?>, MenuType<WritingDeskMenu>> WRITING_DESK =
MENUS.register("writing_desk", () -> IMenuTypeExtension.create(WritingDeskMenu::new)); 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));
private ModMenus() { private ModMenus() {
} }
@@ -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}
}
}
@@ -18,6 +18,17 @@
"item.rivencraft.ink_vial": "Ink Vial", "item.rivencraft.ink_vial": "Ink Vial",
"block.rivencraft.writing_desk": "Writing Desk", "block.rivencraft.writing_desk": "Writing Desk",
"container.rivencraft.writing_desk": "Writing Desk", "container.rivencraft.writing_desk": "Writing Desk",
"block.rivencraft.book_binder": "Book Binder",
"container.rivencraft.book_binder": "Book Binder",
"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.select_hint": "Right-click page to select",
"screen.rivencraft.writing_desk.ink": "Ink: %s/%s", "screen.rivencraft.writing_desk.ink": "Ink: %s/%s",
"screen.rivencraft.writing_desk.search": "Search", "screen.rivencraft.writing_desk.search": "Search",
"screen.rivencraft.writing_desk.no_symbols": "No available symbols", "screen.rivencraft.writing_desk.no_symbols": "No available symbols",
@@ -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]}
}
}
]
}
@@ -1,11 +1,62 @@
{ {
"parent": "minecraft:block/block", "format_version": "26.3",
"textures": {"wood":"rivencraft:block/writing_desk_wood","inlay":"rivencraft:block/writing_desk_inlay","particle":"rivencraft:block/writing_desk_wood"}, "credit": "Made with Blockbench",
"elements": [ "parent": "minecraft:block/block",
{"from":[0,0,1],"to":[16,1,15],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}}, "textures": {
{"from":[0,8,0],"to":[16,9,16],"faces":{"up":{"texture":"#inlay"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}}, "2": "rivencraft:block/writing_desk",
{"from":[0,0,1],"to":[1,8,15],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}}, "particle": "rivencraft:block/writing_desk"
{"from":[1,3,1],"to":[16,4,15],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}}, },
{"from":[0,9,15],"to":[16,15,16],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}} "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"}
}
}
]
} }
@@ -1,11 +1,88 @@
{ {
"parent": "minecraft:block/block", "format_version": "26.3",
"textures": {"wood":"rivencraft:block/writing_desk_wood","inlay":"rivencraft:block/writing_desk_inlay","particle":"rivencraft:block/writing_desk_wood"}, "credit": "Made with Blockbench",
"elements": [ "parent": "minecraft:block/block",
{"from":[0,0,1],"to":[16,1,15],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}}, "textures": {
{"from":[0,8,0],"to":[16,9,16],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}}, "2": "rivencraft:block/writing_desk",
{"from":[15,0,1],"to":[16,8,15],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}}, "particle": "rivencraft:block/writing_desk"
{"from":[0,3,1],"to":[15,4,15],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}}, },
{"from":[0,9,15],"to":[16,15,16],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}} "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"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 441 B

After

Width:  |  Height:  |  Size: 449 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 489 B

After

Width:  |  Height:  |  Size: 507 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

@@ -0,0 +1,4 @@
{
"type": "minecraft:block",
"pools": [{"rolls":1,"entries":[{"type":"minecraft:item","name":"rivencraft:book_binder"}]}]
}
@@ -0,0 +1,10 @@
{
"type": "minecraft:crafting_shaped",
"category": "misc",
"pattern": ["III", "PPP", "P P"],
"key": {
"I": {"item":"minecraft:iron_ingot"},
"P": {"tag":"minecraft:planks"}
},
"result": {"id":"rivencraft:book_binder","count":1}
}