Compare commits

...
4 Commits
Author SHA1 Message Date
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
dnorberg 7f9cf2a4a5 Fix Writing Desk GUI and two-block rendering
Build / build (push) Canceled after 0s
2026-09-24 14:36:49 -04:00
27 changed files with 1387 additions and 287 deletions

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
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.
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.
+2
View File
@@ -14,6 +14,8 @@ The Writing Desk item texture is copied from Legacy's
assets/mystcraft/textures/items/writingdesk.png under the same license. assets/mystcraft/textures/items/writingdesk.png under the same license.
The Writing Desk block texture is copied from Legacy's The Writing Desk block texture is copied from Legacy's
assets/mystcraft/textures/entity/desk.png under the same license. assets/mystcraft/textures/entity/desk.png under the same license.
The Writing Desk GUI atlas is copied from Legacy's assets/mystcraft/gui/writingdesk.png
under the same license; desk wood and inlay textures are cropped from desk.png.
The initial build layout is derived from the official NeoForge 1.21.1 The initial build layout is derived from the official NeoForge 1.21.1
ModDevGradle MDK: https://github.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle ModDevGradle MDK: https://github.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle
+31 -7
View File
@@ -21,17 +21,41 @@ The spent vial returns a glass bottle. Server owners can adjust the symbol-copy
and link-panel costs in the `writing` section of `rivencraft-server.toml`. and link-panel costs in the `writing` section of `rivencraft-server.toml`.
The Writing Desk can copy a selected written page from its collection tabs. The Writing Desk can copy a selected written page from its collection tabs.
The Writing Desk is now craftable from planks, a glass bottle, and a feather. The Writing Desk is now craftable from planks, a glass bottle, and a feather.
Its four collection tabs, paper input, ink vial input, empty glass-bottle return, It occupies two adjacent blocks, with one shared inventory; loaded paper and
the selected page appear on its Legacy-inspired model.
Desks placed before 0.3.6.1 keep their old one-block footprint until broken
and placed again; leave an adjacent space when replacing one.
Its 88 collection tabs, paper input, ink vial input, empty glass-bottle return,
16-unit ink meter, and inventory persist 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

+4 -1
View File
@@ -9,7 +9,10 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
| `assets/rivencraft/textures/item/page.png` | `assets/mystcraft/textures/items/page_background.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only | | `assets/rivencraft/textures/item/page.png` | `assets/mystcraft/textures/items/page_background.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/gui/notebook_pages.png` | `assets/mystcraft/gui/bookui_pagel.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Notebook draws the native 30×40 page at atlas coordinates `(156, 0)` | | `assets/rivencraft/textures/gui/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; single-block model adapts the desk geometry and UV regions from `ModelWritingDesk` | | `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/scrollbar.png` | `assets/mystcraft/gui/scrollbar.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; tiled track and normal/pressed thumb |
| `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.0 mod_version=0.3.6.9
immersive_portals_version=6.0.7 immersive_portals_version=6.0.7
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources. # This should match the base package used for the mod sources.
@@ -5,6 +5,7 @@ import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.menu.WritingDeskMenu; import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog; import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Containers; import net.minecraft.world.Containers;
@@ -19,48 +20,72 @@ import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition; 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.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.DirectionProperty; import net.minecraft.world.level.block.state.properties.DirectionProperty;
import net.minecraft.world.level.block.state.properties.BooleanProperty;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.entity.LivingEntity;
import org.jetbrains.annotations.Nullable;
import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.phys.BlockHitResult; import net.minecraft.world.phys.BlockHitResult;
public final class WritingDeskBlock extends BaseEntityBlock { public final class WritingDeskBlock extends BaseEntityBlock {
public static final MapCodec<WritingDeskBlock> CODEC = simpleCodec(WritingDeskBlock::new); public static final MapCodec<WritingDeskBlock> CODEC = simpleCodec(WritingDeskBlock::new);
public static final DirectionProperty FACING = BlockStateProperties.HORIZONTAL_FACING; public static final DirectionProperty FACING = BlockStateProperties.HORIZONTAL_FACING;
public static final BooleanProperty EXTENSION = BooleanProperty.create("extension");
public WritingDeskBlock(Properties properties) { public WritingDeskBlock(Properties properties) {
super(properties); super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, net.minecraft.core.Direction.NORTH)); registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(EXTENSION, false));
} }
@Override public MapCodec<? extends BaseEntityBlock> codec() { return CODEC; } @Override public MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> builder) { @Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> builder) {
builder.add(FACING); builder.add(FACING, EXTENSION);
} }
@Override public BlockState getStateForPlacement(BlockPlaceContext context) { @Override public BlockState getStateForPlacement(BlockPlaceContext context) {
return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite()); Direction facing = context.getHorizontalDirection().getOpposite();
BlockPos extension = context.getClickedPos().relative(facing.getClockWise());
return context.getLevel().getBlockState(extension).canBeReplaced(context)
? defaultBlockState().setValue(FACING, facing) : null;
}
@Override public void setPlacedBy(Level level, BlockPos pos, BlockState state,
@Nullable LivingEntity placer, ItemStack stack) {
super.setPlacedBy(level, pos, state, placer, stack);
level.setBlock(pos.relative(state.getValue(FACING).getClockWise()),
state.setValue(EXTENSION, true), 3);
} }
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; } @Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { @Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new WritingDeskBlockEntity(pos, state); return state.getValue(EXTENSION) ? null : new WritingDeskBlockEntity(pos, state);
} }
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, @Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) { Player player, BlockHitResult hit) {
BlockPos controller = state.getValue(EXTENSION)
? pos.relative(state.getValue(FACING).getCounterClockWise()) : pos;
if (!level.isClientSide && player instanceof ServerPlayer serverPlayer if (!level.isClientSide && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(pos) instanceof WritingDeskBlockEntity desk) { && level.getBlockEntity(controller) instanceof WritingDeskBlockEntity desk) {
serverPlayer.openMenu(new SimpleMenuProvider( serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new WritingDeskMenu(id, inventory, desk), (id, inventory, ignored) -> new WritingDeskMenu(id, inventory, desk),
Component.translatable("container.rivencraft.writing_desk")), Component.translatable("container.rivencraft.writing_desk")),
buffer -> { buffer -> {
buffer.writeBlockPos(pos); buffer.writeBlockPos(controller);
WritingDeskCatalog.write(buffer, WritingDeskCatalog.available(desk)); WritingDeskCatalog.write(buffer, WritingDeskCatalog.available(desk));
}); });
} }
return InteractionResult.sidedSuccess(level.isClientSide); return InteractionResult.sidedSuccess(level.isClientSide);
} }
@Override protected void onRemove(BlockState state, Level level, BlockPos pos, BlockState replacement, boolean moving) { @Override protected void onRemove(BlockState state, Level level, BlockPos pos, BlockState replacement, boolean moving) {
if (!state.is(replacement.getBlock()) && !level.isClientSide if (!state.is(replacement.getBlock())) {
&& level.getBlockEntity(pos) instanceof WritingDeskBlockEntity desk) { if (!level.isClientSide && !state.getValue(EXTENSION)
Containers.dropContents(level, pos, desk); && level.getBlockEntity(pos) instanceof WritingDeskBlockEntity desk) {
level.updateNeighbourForOutputSignal(pos, this); Containers.dropContents(level, pos, desk);
level.updateNeighbourForOutputSignal(pos, this);
}
BlockPos other = pos.relative(state.getValue(FACING).getClockWise(),
state.getValue(EXTENSION) ? -1 : 1);
BlockState otherState = level.getBlockState(other);
if (otherState.is(this) && otherState.getValue(EXTENSION) != state.getValue(EXTENSION)) {
level.setBlock(other, net.minecraft.world.level.block.Blocks.AIR.defaultBlockState(), 35);
}
} }
super.onRemove(state, level, pos, replacement, moving); super.onRemove(state, level, pos, replacement, moving);
} }
@@ -8,26 +8,31 @@ import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup; import net.minecraft.core.HolderLookup;
import net.minecraft.core.NonNullList; import net.minecraft.core.NonNullList;
import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.Container; import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper; 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;
private ItemStack displayPage = ItemStack.EMPTY;
public WritingDeskBlockEntity(BlockPos pos, BlockState state) { public WritingDeskBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.WRITING_DESK.get(), pos, state); super(ModBlockEntities.WRITING_DESK.get(), pos, state);
@@ -35,6 +40,14 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
public int activeCollection() { return activeCollection; } public int activeCollection() { return activeCollection; }
public int inkRemaining() { return inkRemaining; } public int inkRemaining() { return inkRemaining; }
public ItemStack displayPage() { return displayPage; }
public void setDisplayPage(ItemStack page) {
ItemStack next = page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1);
if (!ItemStack.matches(displayPage, next)) {
displayPage = next;
setChanged();
}
}
public void setActiveCollection(int index) { public void setActiveCollection(int index) {
if (index >= 0 && index < COLLECTIONS && activeCollection != index) { if (index >= 0 && index < COLLECTIONS && activeCollection != index) {
activeCollection = index; activeCollection = index;
@@ -47,14 +60,20 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
@Override public ItemStack getItem(int slot) { return items.get(slot); } @Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) { @Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount); ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) setChanged(); if (!removed.isEmpty()) {
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
setChanged();
if (slot == RETURN) refillEmptyInkBar();
}
return removed; return removed;
} }
@Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); } @Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); }
@Override public void setItem(int slot, ItemStack stack) { @Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack); items.set(slot, stack);
if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize()); if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize());
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)
@@ -73,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);
@@ -80,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() {
@@ -101,25 +140,57 @@ 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);
inkRemaining = 0; inkRemaining = 0;
displayPage = ItemStack.EMPTY;
setChanged(); setChanged();
} }
@Override protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) { @Override protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
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)
? ItemStack.parse(registries, tag.getCompound("DisplayPage")).orElse(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));
}
@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);
}
} }
} }
@@ -25,6 +25,7 @@ public final class ClientEvents {
@SubscribeEvent @SubscribeEvent
public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) { public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) {
event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new); event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new);
event.registerBlockEntityRenderer(ModBlockEntities.WRITING_DESK.get(), WritingDeskRenderer::new);
} }
@SubscribeEvent @SubscribeEvent
@@ -0,0 +1,55 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.world.item.ItemDisplayContext;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Visible selected page and loaded paper, as on the Legacy desk. */
public final class WritingDeskRenderer implements BlockEntityRenderer<WritingDeskBlockEntity> {
public WritingDeskRenderer(BlockEntityRendererProvider.Context context) {}
@Override public void render(WritingDeskBlockEntity desk, float partialTick, PoseStack pose,
MultiBufferSource buffers, int light, int overlay) {
int paper = desk.getItem(WritingDeskBlockEntity.PAPER).getCount();
ItemStack page = desk.displayPage();
if (paper == 0 && page.isEmpty()) return;
pose.pushPose();
pose.translate(0.5, 0, 0.5);
pose.mulPose(Axis.YP.rotationDegrees(
180.0F - desk.getBlockState().getValue(WritingDeskBlock.FACING).toYRot()));
if (paper > 0) {
ItemStack sheet = new ItemStack(Items.PAPER);
int layers = Math.min(4, 1 + paper / 16);
for (int i = 0; i < layers; i++) {
drawFlat(desk, sheet, pose, buffers, light, overlay, 1.22, 0.27 + i * 0.008, -0.14, 0.55F);
}
}
if (!page.isEmpty()) {
drawFlat(desk, page, pose, buffers, light, overlay, 1.0, 0.572, 0.0, 0.8F);
}
pose.popPose();
}
private static void drawFlat(WritingDeskBlockEntity desk, ItemStack stack, PoseStack pose,
MultiBufferSource buffers, int light, int overlay,
double x, double y, double z, float scale) {
pose.pushPose();
pose.translate(x, y, z);
pose.mulPose(Axis.XP.rotationDegrees(90));
pose.scale(scale, scale, scale);
Minecraft.getInstance().getItemRenderer().renderStatic(stack, ItemDisplayContext.FIXED,
light, overlay, pose, buffers, desk.getLevel(), 0);
pose.popPose();
}
@Override public boolean shouldRenderOffScreen(WritingDeskBlockEntity blockEntity) { return true; }
@Override public int getViewDistance() { return 64; }
}
@@ -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,123 +19,197 @@ 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(
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) {
// Legacy leaves the world visible around the desk instead of dimming it. // Keep the world visible, but still draw our GUI backing. The vanilla
// container implementation normally calls renderBg from this method.
renderBg(graphics, partialTick, mouseX, mouseY);
} }
@Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) { @Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
int x = leftPos; int x = leftPos;
int y = topPos; int y = topPos;
graphics.fill(x + 4, y + 8, x + 216, y + 174, 0xFF383230); // Legacy GuiWritingDesk draws the entire 176x166 panel, not a stretched swatch.
graphics.fill(x + 44, y + 14, x + 210, y + 170, 0xFF221B1A); graphics.blit(LEGACY_DESK, x + 233, y + 20, 0, 0, 176, 166);
graphics.fill(x + 218, y + 4, x + 396, y + 174, 0xFF555454); drawTabControl(graphics, 20, 203, menu.firstVisibleTab() > 0);
graphics.fill(x + 220, y + 6, x + 394, y + 172, 0xFFB7B7B7);
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
graphics.fill(x + 6, y + 16 + i * 37, x + 40, y + 48 + i * 37, int tab = menu.firstVisibleTab() + i;
i == menu.activeCollection() ? 0xFF7777B6 : 0xFF999999); int tabY = y + 29 + i * 37;
slotFrame(graphics, 12, 20 + i * 37); if (tab == menu.activeCollection() && !allMode) graphics.setColor(0.5F, 0.5F, 1F, 1F);
graphics.blit(LEGACY_DESK, x, tabY, 0, 166, 58, 37);
graphics.setColor(1F, 1F, 1F, 1F);
graphics.drawString(font, Integer.toString(tab + 1), x + 8, tabY + 8, 0xFF181818, false);
ItemStack collection = menu.getSlot(tab).getItem();
if (!collection.isEmpty()) {
String name = collection.getHoverName().getString();
float scale = Math.min(1F, 50F / Math.max(1, font.width(name)));
graphics.pose().pushPose();
graphics.pose().translate(x + 4, tabY + 25, 0);
graphics.pose().scale(scale, scale, 1);
graphics.drawString(font, name, 0, 0, 0xFF404040, false);
graphics.pose().popPose();
}
} }
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();
@@ -142,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) {
@@ -222,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;
} }
@@ -270,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) {
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.gametest;
import java.util.List; import java.util.List;
import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity; import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.menu.WritingDeskMenu; import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModBlocks; import net.dnorberg.rivencraft.registry.ModBlocks;
@@ -12,6 +13,7 @@ import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.SymbolReference; import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog; import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
import net.minecraft.gametest.framework.GameTest; import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper; import net.minecraft.gametest.framework.GameTestHelper;
@@ -59,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();
} }
@@ -79,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);
@@ -109,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");
@@ -133,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));
@@ -140,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);
@@ -178,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");
@@ -228,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();
@@ -260,9 +304,319 @@ 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();
}
@GameTest(template = "empty")
public static void twoBlockDeskSharesOneInventoryAndBreaksTogether(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
var block = ModBlocks.WRITING_DESK.get();
var state = block.defaultBlockState().setValue(WritingDeskBlock.FACING, Direction.NORTH);
helper.getLevel().setBlock(pos, state, 3);
block.setPlacedBy(helper.getLevel(), pos, state, null, new ItemStack(block));
BlockPos extension = pos.relative(Direction.EAST);
helper.assertTrue(helper.getLevel().getBlockState(extension).is(block),
"Placing a desk must reserve the adjacent second block");
helper.assertTrue(helper.getLevel().getBlockState(extension).getValue(WritingDeskBlock.EXTENSION),
"Adjacent half must be marked as an extension");
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
helper.assertTrue(helper.getLevel().getBlockEntity(extension) == null,
"Extension must not duplicate the inventory");
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 7));
helper.getLevel().destroyBlock(extension, true);
helper.assertTrue(helper.getLevel().getBlockState(pos).isAir(),
"Breaking the extension must remove the controller");
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(pos).inflate(3));
int desks = drops.stream().filter(e -> e.getItem().is(block.asItem()))
.mapToInt(e -> e.getItem().getCount()).sum();
int paper = drops.stream().filter(e -> e.getItem().is(Items.PAPER))
.mapToInt(e -> e.getItem().getCount()).sum();
helper.assertValueEqual(desks, 1, "Breaking either half must drop one desk");
helper.assertValueEqual(paper, 7, "Breaking either half must drop stored paper once");
helper.succeed();
}
@GameTest(template = "empty")
public static void breakingControllerRemovesExtensionWithoutDuplicateDesk(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
var block = ModBlocks.WRITING_DESK.get();
var state = block.defaultBlockState().setValue(WritingDeskBlock.FACING, Direction.NORTH);
helper.getLevel().setBlock(pos, state, 3);
block.setPlacedBy(helper.getLevel(), pos, state, null, new ItemStack(block));
BlockPos extension = pos.relative(Direction.EAST);
helper.getLevel().destroyBlock(pos, true);
helper.assertTrue(helper.getLevel().getBlockState(extension).isAir(),
"Breaking the controller must remove the extension");
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(pos).inflate(3));
int desks = drops.stream().filter(e -> e.getItem().is(block.asItem()))
.mapToInt(e -> e.getItem().getCount()).sum();
helper.assertValueEqual(desks, 1, "Controller break must drop exactly one desk");
helper.succeed();
}
@GameTest(template = "empty")
public static void openDeskDoesNotOccludeTerrain(GameTestHelper helper) {
helper.assertFalse(ModBlocks.WRITING_DESK.get().defaultBlockState().canOcclude(),
"Open shelves must not cull terrain behind the desk");
helper.succeed();
}
@GameTest(template = "empty")
public static void allPagesSelectionUsesTheRequestedPhysicalTab(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
ItemStack first = PageItem.createLinkPanelPage();
ItemStack second = PageItem.createSymbolPage(new SymbolReference(
ResourceLocation.parse("rivencraft:terrain/standard")));
desk.setItem(0, first);
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(),
new NotebookContents(List.of(ItemStack.EMPTY, second)));
desk.setItem(1, notebook);
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
helper.assertFalse(menu.clickMenuButton(player, WritingDeskMenu.ALL_PAGE_BUTTON_BASE + 2 * NotebookContents.MAX_PAGES),
"ALL view must reject empty cross-tab positions");
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.ALL_PAGE_BUTTON_BASE + NotebookContents.MAX_PAGES + 1),
"ALL view must accept the populated second tab page");
helper.assertTrue(ItemStack.matches(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem(), second),
"Copy preview must match the physical page selected from the other tab");
helper.assertValueEqual(desk.activeCollection(), 0,
"Selecting from ALL must not silently change the active tab");
helper.succeed();
}
@GameTest(template = "empty")
public static void tabGroupsCycleByFourAndProtectHiddenSlots(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
var desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
var otherViewer = new WritingDeskMenu(2, player.getInventory(), desk);
menu.clickMenuButton(player, WritingDeskMenu.PREVIOUS_TABS);
helper.assertValueEqual(menu.firstVisibleTab(), 0, "Previous must stop at tabs 1-4");
menu.clickMenuButton(player, WritingDeskMenu.NEXT_TABS);
helper.assertValueEqual(menu.firstVisibleTab(), 4, "Next must show tabs 5-8");
helper.assertValueEqual(otherViewer.firstVisibleTab(), 0, "Cycling must not move another viewer's slots");
helper.assertFalse(menu.getSlot(0).isActive(), "Earlier tab slots must be hidden");
helper.assertTrue(menu.getSlot(4).isActive(), "New group's slots must be interactive");
desk.setItem(0, new ItemStack(ModItems.NOTEBOOK.get()));
menu.clicked(0, 0, ClickType.PICKUP, player);
helper.assertTrue(menu.getCarried().isEmpty(), "Hidden slot clicks must not take items");
helper.assertTrue(menu.quickMoveStack(player, 0).isEmpty(), "Hidden shift-click must be rejected");
player.getInventory().setItem(9, new ItemStack(ModItems.NOTEBOOK.get()));
menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT + 1);
helper.assertTrue(desk.getItem(4).is(ModItems.NOTEBOOK.get()), "Shift-click must use the visible group");
for (int i = 0; i < 30; i++) menu.clickMenuButton(player, WritingDeskMenu.NEXT_TABS);
helper.assertValueEqual(menu.firstVisibleTab(), 84, "Last group must be tabs 85-88");
helper.assertTrue(menu.clickMenuButton(player, 87), "Tab 88 must be selectable without page-ID collisions");
helper.assertValueEqual(desk.activeCollection(), 87, "Tab 88 must become active");
helper.assertFalse(menu.clickMenuButton(player, 88), "Tab 89 must not exist");
desk.setItem(87, PageItem.createLinkPanelPage());
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
helper.assertTrue(menu.clickMenuButton(player, WritingDeskMenu.ALL_PAGE_BUTTON_BASE + 87 * NotebookContents.MAX_PAGES),
"ALL must select a physical page from tab 88");
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).is(ModItems.PAGE.get()),
"Tab 88's page must be copyable");
menu.clickMenuButton(player, WritingDeskMenu.PREVIOUS_TABS);
helper.assertValueEqual(menu.firstVisibleTab(), 80, "Previous must move back four tabs");
helper.succeed();
}
@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(); helper.succeed();
} }
} }
@@ -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;
} }
@@ -181,6 +383,7 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
private void refreshOutput() { private void refreshOutput() {
if (clientSide) return; if (clientSide) return;
if (blockEntity != null) blockEntity.setDisplayPage(selectedSource());
ItemStack preview = canCopy() ? selectedSource().copyWithCount(1) : ItemStack.EMPTY; ItemStack preview = canCopy() ? selectedSource().copyWithCount(1) : ItemStack.EMPTY;
if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview); if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview);
} }
@@ -210,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);
} }
@@ -225,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; }
} }
} }
@@ -49,7 +49,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("11");
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,
@@ -19,7 +19,7 @@ public final class ModBlocks {
); );
public static final DeferredBlock<WritingDeskBlock> WRITING_DESK = BLOCKS.registerBlock( public static final DeferredBlock<WritingDeskBlock> WRITING_DESK = BLOCKS.registerBlock(
"writing_desk", WritingDeskBlock::new, "writing_desk", WritingDeskBlock::new,
BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE) BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion()
); );
private ModBlocks() { private ModBlocks() {
@@ -1,8 +1,12 @@
{ {
"variants": { "variants": {
"facing=north": { "model": "rivencraft:block/writing_desk" }, "facing=north,extension=false": {"model":"rivencraft:block/writing_desk"},
"facing=east": { "model": "rivencraft:block/writing_desk", "y": 90 }, "facing=east,extension=false": {"model":"rivencraft:block/writing_desk","y":90},
"facing=south": { "model": "rivencraft:block/writing_desk", "y": 180 }, "facing=south,extension=false": {"model":"rivencraft:block/writing_desk","y":180},
"facing=west": { "model": "rivencraft:block/writing_desk", "y": 270 } "facing=west,extension=false": {"model":"rivencraft:block/writing_desk","y":270},
"facing=north,extension=true": {"model":"rivencraft:block/writing_desk_extension"},
"facing=east,extension=true": {"model":"rivencraft:block/writing_desk_extension","y":90},
"facing=south,extension=true": {"model":"rivencraft:block/writing_desk_extension","y":180},
"facing=west,extension=true": {"model":"rivencraft:block/writing_desk_extension","y":270}
} }
} }
@@ -1,69 +1,62 @@
{ {
"parent": "minecraft:block/block", "format_version": "26.3",
"ambientocclusion": false, "credit": "Made with Blockbench",
"textures": { "parent": "minecraft:block/block",
"particle": "rivencraft:block/writing_desk", "textures": {
"desk": "rivencraft:block/writing_desk" "2": "rivencraft:block/writing_desk",
}, "particle": "rivencraft:block/writing_desk"
"elements": [ },
{ "elements": [
"from": [0, 8, 0], "to": [16, 9, 16], {
"faces": { "from": [0, 11, 0],
"up": {"uv": [1, 0, 3, 2], "texture": "#desk"}, "to": [16, 12, 16],
"down": {"uv": [1, 2, 3, 4], "texture": "#desk"}, "rotation": {"angle": 0, "axis": "y", "origin": [0, 3, 0]},
"north": {"uv": [1, 0, 3, 0.125], "texture": "#desk"}, "faces": {
"south": {"uv": [1, 0, 3, 0.125], "texture": "#desk"}, "north": {"uv": [1, 2, 2, 2.125], "texture": "#2"},
"east": {"uv": [1, 0, 3, 0.125], "texture": "#desk"}, "east": {"uv": [2.82031, 4.01563, 3.82031, 4.125], "texture": "#2"},
"west": {"uv": [1, 0, 3, 0.125], "texture": "#desk"} "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": [2, 8, 15], }
"faces": { },
"north": {"uv": [5.625, 0.125, 5.875, 2], "texture": "#desk"}, {
"south": {"uv": [5.625, 0.125, 5.875, 2], "texture": "#desk"}, "from": [0, 0, 1],
"east": {"uv": [5.625, 0.125, 5.875, 2], "texture": "#desk"}, "to": [1, 11, 15],
"west": {"uv": [5.625, 0.125, 5.875, 2], "texture": "#desk"} "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"},
"from": [14, 0, 1], "to": [16, 8, 15], "west": {"uv": [5.67969, 2.625, 6.6875, 4], "texture": "#2"},
"faces": { "up": {"uv": [0.08594, 10.36719, 1.14063, 11.83594], "texture": "#2"},
"north": {"uv": [5.625, 0.125, 5.875, 2], "texture": "#desk"}, "down": {"uv": [5.75, 5.25, 5.8125, 7.10938], "texture": "#2"}
"south": {"uv": [5.625, 0.125, 5.875, 2], "texture": "#desk"}, }
"east": {"uv": [5.625, 0.125, 5.875, 2], "texture": "#desk"}, },
"west": {"uv": [5.625, 0.125, 5.875, 2], "texture": "#desk"} {
} "from": [1, 5, 1],
}, "to": [16, 6, 15],
{ "rotation": {"angle": 0, "axis": "y", "origin": [0, 2, 0]},
"from": [1, 3, 2], "to": [15, 4, 14], "faces": {
"faces": { "north": {"uv": [3, 2, 3.9375, 2.125], "texture": "#2"},
"up": {"uv": [0, 4.25, 3.75, 6.125], "texture": "#desk"}, "east": {"uv": [2.8125, 4, 3.75, 4.125], "texture": "#2"},
"down": {"uv": [0, 4.25, 3.75, 6.125], "texture": "#desk"}, "south": {"uv": [1.35938, 11.71875, 2.44531, 12.71875], "texture": "#2"},
"north": {"uv": [0, 4.25, 3.75, 4.5], "texture": "#desk"}, "west": {"uv": [0.03125, 11.66406, 0.99219, 12.66406], "texture": "#2"},
"south": {"uv": [0, 4.25, 3.75, 4.5], "texture": "#desk"}, "up": {"uv": [1.875, 4, 2.8125, 2.125], "texture": "#2"},
"east": {"uv": [0, 4.25, 3.75, 4.5], "texture": "#desk"}, "down": {"uv": [0.9375, 2.125, 1.875, 4], "texture": "#2"}
"west": {"uv": [0, 4.25, 3.75, 4.5], "texture": "#desk"} }
} },
}, {
{ "from": [0, 0, 15],
"from": [1, 9, 14], "to": [15, 15, 16], "to": [16, 11, 16],
"faces": { "rotation": {"angle": 0, "axis": "y", "origin": [0, -9, 0]},
"north": {"uv": [8, 0, 11.75, 2], "texture": "#desk"}, "faces": {
"south": {"uv": [8, 0, 11.75, 2], "texture": "#desk"}, "north": {"uv": [8.0625, 0, 9.0625, 1.375], "texture": "#2"},
"east": {"uv": [8, 0, 8.25, 2], "texture": "#desk"}, "east": {"uv": [6.625, 0.125, 6.6875, 1.5], "texture": "#2"},
"west": {"uv": [8, 0, 8.25, 2], "texture": "#desk"}, "south": {"uv": [5.625, 2.625, 6.625, 4], "texture": "#2"},
"up": {"uv": [8, 0, 11.75, 0.25], "texture": "#desk"} "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"}
{ }
"from": [1, 12, 9], "to": [15, 13, 15], }
"faces": { ]
"up": {"uv": [8, 3.625, 11.75, 4.375], "texture": "#desk"},
"down": {"uv": [8, 3.625, 11.75, 4.375], "texture": "#desk"},
"north": {"uv": [8, 3.625, 11.75, 3.75], "texture": "#desk"},
"south": {"uv": [8, 3.625, 11.75, 3.75], "texture": "#desk"}
}
}
]
} }
@@ -0,0 +1,88 @@
{
"format_version": "26.3",
"credit": "Made with Blockbench",
"parent": "minecraft:block/block",
"textures": {
"2": "rivencraft:block/writing_desk",
"particle": "rivencraft:block/writing_desk"
},
"elements": [
{
"from": [1, 0, 1],
"to": [15, 1, 15],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 0, 0]},
"faces": {
"north": {"uv": [1.8125, 4.125, 2.8125, 4.25], "texture": "#2"},
"east": {"uv": [0.21875, 11.88281, 1.04688, 12.88281], "texture": "#2"},
"south": {"uv": [1.53906, 10.5625, 2.63281, 11.5625], "texture": "#2"},
"west": {"uv": [0.14063, 10.5625, 1.17969, 11.5625], "texture": "#2"},
"up": {"uv": [1.8125, 4.125, 2.8125, 2.125], "texture": "#2"},
"down": {"uv": [4, 0, 5, 2], "texture": "#2"}
}
},
{
"from": [0, 11, 0],
"to": [16, 12, 16],
"rotation": {"angle": 0, "axis": "y", "origin": [0, 3, 0]},
"faces": {
"north": {"uv": [2, 2, 3, 2.125], "texture": "#2"},
"east": {"uv": [1, 0, 1.0625, 2], "rotation": 90, "texture": "#2"},
"south": {"uv": [3, 2, 4, 2.125], "texture": "#2"},
"west": {"uv": [2.8125, 4, 3.8125, 4.125], "texture": "#2"},
"up": {"uv": [2, 0, 3, 2], "texture": "#2"},
"down": {"uv": [9, 0, 10, 2], "texture": "#2"}
}
},
{
"from": [15, 0, 1],
"to": [16, 11, 15],
"faces": {
"north": {"uv": [7.6875, 2.625, 7.75, 4], "texture": "#2"},
"east": {"uv": [10.00156, 0.625, 11.00156, 2], "texture": "#2"},
"south": {"uv": [1.99219, 10.77344, 2.99219, 12.02344], "texture": "#2"},
"west": {"uv": [6.6875, 2.625, 7.6875, 4], "texture": "#2"},
"up": {"uv": [0.49219, 10.96094, 1.49219, 12.08594], "texture": "#2"},
"down": {"uv": [5.75313, 5.36719, 5.8125, 7.125], "texture": "#2"}
}
},
{
"from": [0, 0, 1],
"to": [1, 11, 15],
"rotation": {"angle": 0, "axis": "y", "origin": [-15, 0, 0]},
"faces": {
"north": {"uv": [7.68828, 2.625, 7.75078, 4], "texture": "#2"},
"east": {"uv": [7.6875, 2.62539, 6.6875, 4.00039], "texture": "#2"},
"south": {"uv": [0.05469, 12.22656, 1.05469, 13.32031], "texture": "#2"},
"west": {"uv": [3, 0, 4, 1.375], "texture": "#2"},
"up": {"uv": [0.15625, 10.83594, 1.15625, 11.97656], "texture": "#2"},
"down": {"uv": [7, 4.60156, 7.0625, 6.375], "texture": "#2"}
}
},
{
"from": [1, 5, 1],
"to": [15, 6, 15],
"rotation": {"angle": 0, "axis": "y", "origin": [1, 2, 0]},
"faces": {
"north": {"uv": [1.8125, 4.25, 2.8125, 4.125], "texture": "#2"},
"east": {"uv": [1.86719, 11.82813, 2.79688, 12.82813], "texture": "#2"},
"south": {"uv": [0.32813, 11.80469, 1.28906, 12.80469], "texture": "#2"},
"west": {"uv": [0.27344, 10.5625, 1.53125, 11.5625], "texture": "#2"},
"up": {"uv": [1.8125, 4.125, 2.8125, 2.125], "texture": "#2"},
"down": {"uv": [9, 0, 10, 2], "texture": "#2"}
}
},
{
"from": [0, 0, 15],
"to": [16, 11, 16],
"rotation": {"angle": 0, "axis": "y", "origin": [0, -9, 0]},
"faces": {
"north": {"uv": [9, 0.125, 10, 1.5], "texture": "#2"},
"east": {"uv": [10.8125, 0.625, 10.875, 2], "texture": "#2"},
"south": {"uv": [10.875, 0.625, 11.875, 2], "texture": "#2"},
"west": {"uv": [6.6875, 0.125, 6.75, 1.5], "texture": "#2"},
"up": {"uv": [0, 15, 16, 16], "texture": "#2"},
"down": {"uv": [2.8125, 4, 3.8125, 4.125], "texture": "#2"}
}
}
]
}
@@ -1,3 +1,4 @@
{ {
"parent": "rivencraft:block/writing_desk" "parent": "minecraft:item/generated",
"textures": {"layer0": "rivencraft:item/writing_desk"}
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

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: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB