Compare commits

...
8 Commits
Author SHA1 Message Date
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
dnorberg e511e7c10b Add Writing Desk symbol catalog and paper transcription
Build / build (push) Canceled after 0s
2026-09-24 14:08:12 -04:00
dnorberg 05a06a4dc8 added reference images of the writing desk gui
Build / build (push) Canceled after 0s
2026-09-24 05:42:57 -04:00
dnorberg 1dcd3a178e Add Writing Desk paper copying and ink meter
Build / build (push) Canceled after 0s
2026-09-24 05:38:51 -04:00
dnorberg 385741027a Show Writing Desk page previews like Legacy
Build / build (push) Canceled after 0s
2026-09-24 05:17:31 -04:00
dnorberg 972aeaaed6 Add Writing Desk storage and menu foundation
Build / build (push) Canceled after 0s
2026-09-24 05:13:35 -04:00
dnorberg 5066e71897 Match Legacy notebook sparse portrait pages
Build / build (push) Canceled after 0s
2026-09-24 05:02:58 -04:00
41 changed files with 1597 additions and 80 deletions

No files matched your search

+8
View File
@@ -8,6 +8,14 @@ Mystcraft Legacy copyright: XCompWiz and contributors
Mystcraft Legacy license: GNU Lesser General Public License version 3
The animated ink-vial item texture in this project is copied from Mystcraft
Legacy's assets/mystcraft/textures/items/ink_vial.png under that license.
The Notebook page atlas is copied from Legacy's assets/mystcraft/gui/bookui_pagel.png
under the same license.
The Writing Desk item texture is copied from Legacy's
assets/mystcraft/textures/items/writingdesk.png under the same license.
The Writing Desk block texture is copied from Legacy's
assets/mystcraft/textures/entity/desk.png under the same license.
The Writing Desk GUI atlas is copied from Legacy's assets/mystcraft/gui/writingdesk.png
under the same license; desk wood and inlay textures are cropped from desk.png.
The initial build layout is derived from the official NeoForge 1.21.1
ModDevGradle MDK: https://github.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle
+21 -2
View File
@@ -14,12 +14,31 @@ have not been ported yet.
Milestone 4 now includes stackable blank Pages plus server-created symbol,
unresolved-symbol, and baseline link-panel states. Page visuals follow the
legacy runtime-composition rules. Craft an Ink Vial from two black dye, a glass
bottle, and a water bucket; the bucket is returned. Hold a blank Page in one
bottle, and a water bucket; the bucket is returned. Hold vanilla paper in one
hand and use the Ink Vial in the other to prepare a baseline Link Panel Page.
Each vial holds 16 ink units, and the default link-panel cost is two units.
The spent vial returns a glass bottle. Server owners can adjust the symbol-copy
and link-panel costs in the `writing` section of `rivencraft-server.toml`.
Symbol-page transcription will become available through the Writing Desk.
The Writing Desk can copy a selected written page from its collection tabs.
The Writing Desk is now craftable from planks, a glass bottle, and a feather.
It occupies two adjacent blocks, with one shared inventory; loaded paper and
the selected page appear on its Legacy-inspired model.
Desks placed before 0.3.6.1 keep their old one-block footprint until broken
and placed again; leave an adjacent space when replacing one.
Its four collection tabs, paper input, ink vial input, empty glass-bottle return,
16-unit ink meter, and inventory persist
through world saves; breaking it drops stored contents. The desk screen is a
functional foundation styled after Legacy's left tabs, central writing surface,
and right inventory. Put vanilla paper in the
upper-left slot and Ink Vials in the upper-right; a copy appears in the
lower-left result slot when the selected page is valid. Taking it consumes one
paper and, by default, one ink. An empty meter consumes a vial to refill and
returns a vanilla glass bottle to the lower-right slot. The `AZ` control sorts
visible pages by name; `ALL` shows pages held across every desk tab. Search and
category filtering narrow the view without granting new pages, and cross-tab
copy selection is checked by the server.
Selecting a stored page on the desk surface shows its Legacy-proportioned
portrait preview and name above the player inventory.
## Linking Book
Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

+5
View File
@@ -7,6 +7,11 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
| Rivencraft asset | Upstream asset | Upstream revision | Changes |
| --- | --- | --- | --- |
| `assets/rivencraft/textures/item/page.png` | `assets/mystcraft/textures/items/page_background.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/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/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; solid GUI backing sampled from Legacy atlas |
| `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_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 |
+16 -6
View File
@@ -53,8 +53,8 @@ unresolved symbol, and baseline link panel. Symbol and unresolved-symbol pages
retain their namespaced symbol reference; unresolved pages can be inspected and
removed but are rejected as usable writing until their data pack returns.
Only server-side writing operations may turn a blank page into a known symbol
page or link panel. The shared `PageItem` creation and validation gates reject
Only server-side writing operations may create a known symbol
page or link panel from vanilla paper. The shared `PageItem` creation and validation gates reject
new pages for identifiers absent from the server catalog. Later Writing Desk
and Book Binder packets must call these gates rather than accepting page
contents supplied by a client.
@@ -65,6 +65,13 @@ composition from its page background and symbol-component sheet. Data-pack
symbols without a bundled resource-pack model use the legacy question-mark
fallback while keeping their real identifier and display metadata.
The Notebook surface displays pages by their persistent slot index instead of
packing occupied pages together. Empty positions through the last placed page
appear darkened. While carrying a Page, all available positions are shown for
placement. The surface draws the native 30×40 portrait page region from
Legacy's book UI atlas; symbols are layered at a uniform scale, while the
square item models remain unchanged in inventories.
## Ink and baseline link panels
An Ink Vial is crafted from two black dye, a glass bottle, and a water bucket.
@@ -75,9 +82,12 @@ used to craft another. The animated item texture comes from Mystcraft Legacy.
The server config sets ink costs independently for symbol copying (default one
unit) and baseline link-panel preparation (default two units). To prepare a
panel, hold a blank Page in one hand and use an Ink Vial in the other. The
server checks the held items, blank state, and sufficient ink before consuming
anything. A stack of blank Pages yields exactly one Link Panel Page per use;
panel, hold vanilla paper in one hand and use an Ink Vial in the other. The
server checks the held items and sufficient ink before consuming
anything. A stack of paper yields exactly one Link Panel Page per use;
if the inventory is full, the result is dropped instead of deleted. Symbol
copying will be implemented by the Writing Desk. Link-property modifiers are
copying is implemented by the Writing Desk: it previews a copy of the selected
resolved page, then spends paper and ink only when that copy is taken. The
desk's 16-unit ink meter refills from an Ink Vial when needed and returns a
vanilla glass bottle. Link-property modifiers are
deferred beyond this baseline flow.
+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.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.3.4.1
mod_version=0.3.6.2
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.
# This should match the base package used for the mod sources.
@@ -0,0 +1,92 @@
package net.dnorberg.rivencraft.block;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.DirectionProperty;
import net.minecraft.world.level.block.state.properties.BooleanProperty;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.entity.LivingEntity;
import org.jetbrains.annotations.Nullable;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.phys.BlockHitResult;
public final class WritingDeskBlock extends BaseEntityBlock {
public static final MapCodec<WritingDeskBlock> CODEC = simpleCodec(WritingDeskBlock::new);
public static final DirectionProperty FACING = BlockStateProperties.HORIZONTAL_FACING;
public static final BooleanProperty EXTENSION = BooleanProperty.create("extension");
public WritingDeskBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(EXTENSION, false));
}
@Override public MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> builder) {
builder.add(FACING, EXTENSION);
}
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
Direction facing = context.getHorizontalDirection().getOpposite();
BlockPos extension = context.getClickedPos().relative(facing.getClockWise());
return context.getLevel().getBlockState(extension).canBeReplaced(context)
? defaultBlockState().setValue(FACING, facing) : null;
}
@Override public void setPlacedBy(Level level, BlockPos pos, BlockState state,
@Nullable LivingEntity placer, ItemStack stack) {
super.setPlacedBy(level, pos, state, placer, stack);
level.setBlock(pos.relative(state.getValue(FACING).getClockWise()),
state.setValue(EXTENSION, true), 3);
}
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return state.getValue(EXTENSION) ? null : new WritingDeskBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
BlockPos controller = state.getValue(EXTENSION)
? pos.relative(state.getValue(FACING).getCounterClockWise()) : pos;
if (!level.isClientSide && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(controller) instanceof WritingDeskBlockEntity desk) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new WritingDeskMenu(id, inventory, desk),
Component.translatable("container.rivencraft.writing_desk")),
buffer -> {
buffer.writeBlockPos(controller);
WritingDeskCatalog.write(buffer, WritingDeskCatalog.available(desk));
});
}
return InteractionResult.sidedSuccess(level.isClientSide);
}
@Override protected void onRemove(BlockState state, Level level, BlockPos pos, BlockState replacement, boolean moving) {
if (!state.is(replacement.getBlock())) {
if (!level.isClientSide && !state.getValue(EXTENSION)
&& level.getBlockEntity(pos) instanceof WritingDeskBlockEntity desk) {
Containers.dropContents(level, pos, desk);
level.updateNeighbourForOutputSignal(pos, this);
}
BlockPos other = pos.relative(state.getValue(FACING).getClockWise(),
state.getValue(EXTENSION) ? -1 : 1);
BlockState otherState = level.getBlockState(other);
if (otherState.is(this) && otherState.getValue(EXTENSION) != state.getValue(EXTENSION)) {
level.setBlock(other, net.minecraft.world.level.block.Blocks.AIR.defaultBlockState(), 35);
}
}
super.onRemove(state, level, pos, replacement, moving);
}
}
@@ -0,0 +1,158 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.writing.WritingInkService;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.NonNullList;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/** Four collection tabs, paper, ink vials and empty-vial return are owned by the block. */
public final class WritingDeskBlockEntity extends BlockEntity implements Container {
public static final int COLLECTIONS = 4;
public static final int INK = 4;
public static final int RETURN = 5;
public static final int PAPER = 6;
/** Unused by new desks; retained so old saves cannot silently lose slot contents. */
public static final int LEGACY_OUTPUT = 7;
public static final int SIZE = 8;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private int activeCollection;
private int inkRemaining;
private ItemStack displayPage = ItemStack.EMPTY;
public WritingDeskBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.WRITING_DESK.get(), pos, state);
}
public int activeCollection() { return activeCollection; }
public int inkRemaining() { return inkRemaining; }
public ItemStack displayPage() { return displayPage; }
public void setDisplayPage(ItemStack page) {
ItemStack next = page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1);
if (!ItemStack.matches(displayPage, next)) {
displayPage = next;
setChanged();
}
}
public void setActiveCollection(int index) {
if (index >= 0 && index < COLLECTIONS && activeCollection != index) {
activeCollection = index;
setChanged();
}
}
@Override public int getContainerSize() { return SIZE; }
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) {
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
setChanged();
}
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); }
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack);
if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize());
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
setChanged();
}
@Override public boolean stillValid(Player player) {
return level != null && level.isLoaded(worldPosition)
&& level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
if (slot < COLLECTIONS) return stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.PAGE.get());
return switch (slot) {
case INK -> stack.is(ModItems.INK_VIAL.get());
case RETURN -> false;
case PAPER -> stack.is(Items.PAPER);
case LEGACY_OUTPUT -> false;
default -> false;
};
}
/** The output is virtual; inputs are committed only after a result can be given to a player. */
public boolean canConsumeCopyMaterials() {
ItemStack paper = items.get(PAPER);
if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false;
int cost = WritingInkService.symbolCopyCost();
if (inkRemaining >= cost) return true;
ItemStack vial = items.get(INK);
ItemStack returned = items.get(RETURN);
return vial.is(ModItems.INK_VIAL.get()) && inkRemaining + InkVialItem.remaining(vial) >= cost
&& (returned.isEmpty() || returned.is(Items.GLASS_BOTTLE)
&& returned.getCount() < returned.getMaxStackSize());
}
public boolean consumeCopyMaterials() {
if (!canConsumeCopyMaterials()) return false;
int cost = WritingInkService.symbolCopyCost();
if (inkRemaining < cost) {
ItemStack vial = items.get(INK);
inkRemaining += Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY);
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);
}
inkRemaining -= cost;
removeItem(PAPER, 1);
setChanged();
return true;
}
@Override public void clearContent() {
items.clear();
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
inkRemaining = 0;
displayPage = ItemStack.EMPTY;
setChanged();
}
@Override protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.loadAdditional(tag, registries);
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
ContainerHelper.loadAllItems(tag, items, registries);
activeCollection = Math.clamp(tag.getInt("ActiveCollection"), 0, COLLECTIONS - 1);
inkRemaining = Math.clamp(tag.getInt("InkRemaining"), 0, InkVialItem.CAPACITY);
displayPage = tag.contains("DisplayPage", CompoundTag.TAG_COMPOUND)
? ItemStack.parse(registries, tag.getCompound("DisplayPage")).orElse(ItemStack.EMPTY)
: ItemStack.EMPTY;
}
@Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries);
ContainerHelper.saveAllItems(tag, items, registries);
tag.putInt("ActiveCollection", activeCollection);
tag.putInt("InkRemaining", inkRemaining);
if (!displayPage.isEmpty()) tag.put("DisplayPage", displayPage.save(registries));
}
@Override public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
return saveWithoutMetadata(registries);
}
@Override public void setChanged() {
super.setChanged();
if (level != null && !level.isClientSide) {
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
}
}
}
@@ -14,6 +14,7 @@ import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.client.event.EntityRenderersEvent;
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
import net.dnorberg.rivencraft.client.screen.NotebookScreen;
import net.dnorberg.rivencraft.client.screen.WritingDeskScreen;
import net.dnorberg.rivencraft.registry.ModMenus;
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
@@ -24,11 +25,13 @@ public final class ClientEvents {
@SubscribeEvent
public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) {
event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new);
event.registerBlockEntityRenderer(ModBlockEntities.WRITING_DESK.get(), WritingDeskRenderer::new);
}
@SubscribeEvent
public static void registerScreens(RegisterMenuScreensEvent event) {
event.register(ModMenus.NOTEBOOK.get(), NotebookScreen::new);
event.register(ModMenus.WRITING_DESK.get(), WritingDeskScreen::new);
}
@SubscribeEvent
@@ -0,0 +1,20 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.network.WritingDeskCatalogPayload;
import net.minecraft.client.Minecraft;
import net.neoforged.neoforge.network.handling.IPayloadContext;
public final class ClientWritingDeskCatalog {
private ClientWritingDeskCatalog() {}
public static void handle(WritingDeskCatalogPayload payload, IPayloadContext context) {
context.enqueueWork(() -> {
var player = Minecraft.getInstance().player;
if (player != null && player.containerMenu instanceof WritingDeskMenu menu
&& menu.containerId == payload.containerId()) {
menu.setCatalog(payload.symbols());
}
});
}
}
@@ -0,0 +1,55 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.world.item.ItemDisplayContext;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Visible selected page and loaded paper, as on the Legacy desk. */
public final class WritingDeskRenderer implements BlockEntityRenderer<WritingDeskBlockEntity> {
public WritingDeskRenderer(BlockEntityRendererProvider.Context context) {}
@Override public void render(WritingDeskBlockEntity desk, float partialTick, PoseStack pose,
MultiBufferSource buffers, int light, int overlay) {
int paper = desk.getItem(WritingDeskBlockEntity.PAPER).getCount();
ItemStack page = desk.displayPage();
if (paper == 0 && page.isEmpty()) return;
pose.pushPose();
pose.translate(0.5, 0, 0.5);
pose.mulPose(Axis.YP.rotationDegrees(
180.0F - desk.getBlockState().getValue(WritingDeskBlock.FACING).toYRot()));
if (paper > 0) {
ItemStack sheet = new ItemStack(Items.PAPER);
int layers = Math.min(4, 1 + paper / 16);
for (int i = 0; i < layers; i++) {
drawFlat(desk, sheet, pose, buffers, light, overlay, 1.22, 0.27 + i * 0.008, -0.14, 0.55F);
}
}
if (!page.isEmpty()) {
drawFlat(desk, page, pose, buffers, light, overlay, 1.0, 0.572, 0.0, 0.8F);
}
pose.popPose();
}
private static void drawFlat(WritingDeskBlockEntity desk, ItemStack stack, PoseStack pose,
MultiBufferSource buffers, int light, int overlay,
double x, double y, double z, float scale) {
pose.pushPose();
pose.translate(x, y, z);
pose.mulPose(Axis.XP.rotationDegrees(90));
pose.scale(scale, scale, scale);
Minecraft.getInstance().getItemRenderer().renderStatic(stack, ItemDisplayContext.FIXED,
light, overlay, pose, buffers, desk.getLevel(), 0);
pose.popPose();
}
@Override public boolean shouldRenderOffScreen(WritingDeskBlockEntity blockEntity) { return true; }
@Override public int getViewDistance() { return 64; }
}
@@ -1,8 +1,8 @@
package net.dnorberg.rivencraft.client.screen;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.client.gui.GuiGraphics;
@@ -10,6 +10,7 @@ import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.inventory.ClickType;
import net.minecraft.world.item.ItemStack;
@@ -24,6 +25,15 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
private static final int PAGE_STEP_X = 31;
private static final int PAGE_STEP_Y = 41;
private static final int PAGE_COLUMNS = 5;
private static final ResourceLocation PAGE_ATLAS = texture("gui/notebook_pages");
private static final ResourceLocation GENERIC_SYMBOL = texture("item/page_symbol");
private static final ResourceLocation UNRESOLVED_SYMBOL = texture("item/page_unresolved");
private static final ResourceLocation STANDARD_TERRAIN = texture("item/page_symbol_terrain_standard");
private static final ResourceLocation NATIVE_BIOMES = texture("item/page_symbol_biome_native");
private static final ResourceLocation NORMAL_LIGHTING = texture("item/page_symbol_lighting_normal");
private static final ResourceLocation NORMAL_WEATHER = texture("item/page_symbol_weather_normal");
private static final ResourceLocation NORMAL_TIME = texture("item/page_symbol_celestial_normal_time");
private static final ResourceLocation CAVES = texture("item/page_symbol_feature_caves");
private EditBox textInput;
private boolean draggingScrollbar;
private boolean surfaceClickPendingRelease;
@@ -59,25 +69,25 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
super.render(graphics, mouseX, mouseY, partialTick);
List<Integer> pages = visiblePages();
scroll = Math.min(scroll, maxScroll(pages.size()));
int pageSlots = displayedPageSlots();
scroll = Math.min(scroll, maxScroll(pageSlots));
graphics.enableScissor(leftPos, topPos + SURFACE_TOP, leftPos + SURFACE_RIGHT,
topPos + SURFACE_BOTTOM);
for (int position = 0; position < pages.size(); position++) {
int x = leftPos + (position % PAGE_COLUMNS) * PAGE_STEP_X;
int y = topPos + SURFACE_TOP + (position / PAGE_COLUMNS) * PAGE_STEP_Y - scroll;
for (int index = 0; index < pageSlots; index++) {
int x = leftPos + (index % PAGE_COLUMNS) * PAGE_STEP_X;
int y = topPos + SURFACE_TOP + (index / PAGE_COLUMNS) * PAGE_STEP_Y - scroll;
if (y + PAGE_HEIGHT > topPos + SURFACE_TOP && y < topPos + SURFACE_BOTTOM) {
drawPage(graphics, pages.get(position), x, y);
drawPage(graphics, index, x, y);
}
}
graphics.disableScissor();
drawScrollbar(graphics, pages.size());
int hovered = pageAt(mouseX, mouseY, pages);
drawScrollbar(graphics, pageSlots);
int hovered = pageAt(mouseX, mouseY);
if (!menu.getCarried().isEmpty() && inSurface(mouseX, mouseY)) {
graphics.renderItem(menu.getCarried(), mouseX - 8, mouseY - 8);
} else if (hovered >= 0) {
} else if (hovered >= 0 && !menu.getSlot(hovered).getItem().isEmpty()) {
ItemStack page = menu.getSlot(hovered).getItem();
if (!page.isEmpty() && matchesSearch(page)) graphics.renderTooltip(font, page, mouseX, mouseY);
if (matchesSearch(page)) graphics.renderTooltip(font, page, mouseX, mouseY);
} else {
renderTooltip(graphics, mouseX, mouseY);
}
@@ -103,13 +113,16 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (inScrollbar(mouseX, mouseY) && button == 0) {
draggingScrollbar = true;
setScrollFromMouse(mouseY, visiblePages().size());
setScrollFromMouse(mouseY, displayedPageSlots());
return true;
}
int index = pageAt(mouseX, mouseY, visiblePages());
int index = pageAt(mouseX, mouseY);
if (index >= 0 && (button == 0 || button == 1)) {
ItemStack page = menu.getSlot(index).getItem();
if (!page.isEmpty() && !matchesSearch(page)) return true;
if ((!page.isEmpty() && !matchesSearch(page))
|| (page.isEmpty() && !menu.getCarried().is(ModItems.PAGE.get()))) {
return true;
}
setFocused(null);
textInput.setFocused(false);
selectedSlot = index;
@@ -123,7 +136,7 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double dragX, double dragY) {
if (draggingScrollbar && button == 0) {
setScrollFromMouse(mouseY, visiblePages().size());
setScrollFromMouse(mouseY, displayedPageSlots());
return true;
}
return super.mouseDragged(mouseX, mouseY, button, dragX, dragY);
@@ -144,7 +157,7 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (inSurface(mouseX, mouseY) || inScrollbar(mouseX, mouseY)) {
scroll = Math.clamp(scroll - (int)Math.signum(scrollY) * 20,
0, maxScroll(visiblePages().size()));
0, maxScroll(displayedPageSlots()));
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
@@ -170,21 +183,16 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
return super.keyPressed(keyCode, scanCode, modifiers);
}
private List<Integer> visiblePages() {
List<Integer> pages = new ArrayList<>();
int firstEmpty = -1;
for (int index = 0; index < NotebookMenu.NOTEBOOK_SLOTS; index++) {
ItemStack stack = menu.getSlot(index).getItem();
if (stack.isEmpty()) {
if (firstEmpty < 0) firstEmpty = index;
} else {
pages.add(index);
private int displayedPageSlots() {
if (menu.getCarried().is(ModItems.PAGE.get())) {
return NotebookMenu.NOTEBOOK_SLOTS;
}
for (int index = NotebookMenu.NOTEBOOK_SLOTS - 1; index >= 0; index--) {
if (!menu.getSlot(index).getItem().isEmpty()) {
return index + 1;
}
}
if (firstEmpty >= 0 && menu.getCarried().is(ModItems.PAGE.get())) {
pages.add(firstEmpty);
}
return pages;
return 0;
}
private boolean matchesSearch(ItemStack page) {
@@ -193,15 +201,15 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
.contains(textInput.getValue().toLowerCase(Locale.ROOT));
}
private int pageAt(double mouseX, double mouseY, List<Integer> pages) {
private int pageAt(double mouseX, double mouseY) {
if (!inSurface(mouseX, mouseY)) return -1;
int localX = (int)mouseX - leftPos;
int localY = (int)mouseY - topPos - SURFACE_TOP + scroll;
int column = localX / PAGE_STEP_X;
int position = (localY / PAGE_STEP_Y) * PAGE_COLUMNS + column;
int index = (localY / PAGE_STEP_Y) * PAGE_COLUMNS + column;
return column < PAGE_COLUMNS && localX % PAGE_STEP_X < PAGE_WIDTH
&& localY % PAGE_STEP_Y < PAGE_HEIGHT && position < pages.size()
? pages.get(position) : -1;
&& localY % PAGE_STEP_Y < PAGE_HEIGHT && index < displayedPageSlots()
? index : -1;
}
private boolean inSurface(double mouseX, double mouseY) {
@@ -243,20 +251,48 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
private void drawPage(GuiGraphics graphics, int index, int x, int y) {
ItemStack page = menu.getSlot(index).getItem();
boolean visible = !page.isEmpty() && matchesSearch(page);
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT,
visible ? 0xFFE5D4A7 : 0x704B4945);
graphics.fill(x + 2, y + 2, x + PAGE_WIDTH - 2, y + PAGE_HEIGHT - 2,
visible ? 0xFFF4E5C3 : 0x603B3934);
graphics.renderOutline(x, y, PAGE_WIDTH, PAGE_HEIGHT,
index == selectedSlot ? 0xFFFFD061 : 0xFF564A34);
if (visible) {
graphics.pose().pushPose();
graphics.pose().translate(x + 3, y + 8, 0);
graphics.pose().scale(1.5F, 1.5F, 1.0F);
graphics.renderItem(page, 0, 0);
graphics.pose().popPose();
boolean dimmed = page.isEmpty() || !matchesSearch(page);
// Legacy's Notebook draws the atlas' native 30x40 page, not a stretched item icon.
graphics.blit(PAGE_ATLAS, x, y, PAGE_WIDTH, PAGE_HEIGHT,
156, 0, PAGE_WIDTH, PAGE_HEIGHT, 256, 256);
if (!page.isEmpty()) {
switch (PageItem.content(page).kind()) {
case BLANK -> { }
case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 25, y + 20, 0xFF000000);
case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(symbolTexture(page), x + 1, y + 6,
28, 28, 16, 16, 96, 96, 128, 128);
}
}
if (dimmed) {
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0xB8000000);
}
if (index == selectedSlot && !page.isEmpty()) {
graphics.renderOutline(x, y, PAGE_WIDTH, PAGE_HEIGHT, 0xFFFFD061);
}
}
static ResourceLocation symbolTexture(ItemStack page) {
var content = PageItem.content(page);
if (content.kind() == net.dnorberg.rivencraft.writing.PageContent.Kind.UNRESOLVED_SYMBOL) {
return UNRESOLVED_SYMBOL;
}
return symbolTexture(content.symbol().orElseThrow().id());
}
static ResourceLocation symbolTexture(ResourceLocation id) {
return switch (id.toString()) {
case "rivencraft:terrain/standard" -> STANDARD_TERRAIN;
case "rivencraft:biome/native" -> NATIVE_BIOMES;
case "rivencraft:lighting/normal" -> NORMAL_LIGHTING;
case "rivencraft:weather/normal" -> NORMAL_WEATHER;
case "rivencraft:celestial/normal_time" -> NORMAL_TIME;
case "rivencraft:feature/caves" -> CAVES;
default -> GENERIC_SYMBOL;
};
}
private static ResourceLocation texture(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/" + path + ".png");
}
private void moveSelected(boolean next) {
@@ -0,0 +1,327 @@
package net.dnorberg.rivencraft.client.screen;
import java.util.List;
import java.util.Locale;
import java.util.ArrayList;
import java.util.Comparator;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
/** Functional storage surface; full Legacy transcription controls are a later Milestone 4 slice. */
public final class WritingDeskScreen extends AbstractContainerScreen<WritingDeskMenu> {
private static final ResourceLocation PAGE_ATLAS = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/notebook_pages.png");
private static final ResourceLocation LEGACY_DESK = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/writing_desk_legacy.png");
private int lastCollection = -1;
private int selectedPage;
private int scrollRows;
private boolean allMode;
private boolean sortAlphabetically;
private int selectedCollection = -1;
private ResourceLocation selectedCategory;
private EditBox search;
private Button categoryButton;
private Button allButton;
private Button sortButton;
private boolean draggingScrollbar;
private record DisplayPage(int collection, int index, ItemStack stack) {}
public WritingDeskScreen(WritingDeskMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title);
imageWidth = 398;
imageHeight = 176;
titleLabelX = 222;
}
@Override protected void init() {
super.init();
sortButton = addRenderableWidget(Button.builder(Component.literal("AZ"), button -> {
sortAlphabetically = !sortAlphabetically;
scrollRows = 0;
}).bounds(leftPos + 58, topPos - 18, 20, 18).build());
allButton = addRenderableWidget(Button.builder(Component.literal("ALL"), button -> {
allMode = !allMode;
scrollRows = 0;
if (!allMode) {
selectedCollection = -1;
selectedPage = 0;
if (minecraft != null && minecraft.gameMode != null)
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, menu.activeCollection());
}
}).bounds(leftPos + 80, topPos - 18, 28, 18).build());
search = new EditBox(font, leftPos + 110, topPos - 18, 86, 18,
Component.translatable("screen.rivencraft.writing_desk.search"));
search.setMaxLength(64);
search.setHint(Component.translatable("screen.rivencraft.writing_desk.search"));
search.setResponder(value -> scrollRows = 0);
addRenderableWidget(search);
categoryButton = addRenderableWidget(Button.builder(Component.literal("C"), button -> {
List<ResourceLocation> categories = menu.catalog().stream()
.map(SymbolDisplayMetadata::category).distinct().sorted().toList();
int next = selectedCategory == null ? 0 : categories.indexOf(selectedCategory) + 1;
selectedCategory = next >= categories.size() ? null : categories.get(next);
categoryButton.setMessage(Component.literal(selectedCategory == null ? "C" : "C*"));
scrollRows = 0;
}).bounds(leftPos + 198, topPos - 18, 18, 18).build());
}
private List<DisplayPage> filteredPages() {
String query = search == null ? "" : search.getValue().trim().toLowerCase(Locale.ROOT);
List<DisplayPage> entries = new ArrayList<>();
int from = allMode ? 0 : menu.activeCollection();
int until = allMode ? 4 : from + 1;
for (int collection = from; collection < until; collection++) {
List<ItemStack> pages = pages(collection);
int last = -1;
for (int i = 0; i < pages.size(); i++) if (!pages.get(i).isEmpty()) last = i;
for (int i = 0; i <= last; i++) {
ItemStack page = pages.get(i);
if (page.isEmpty() && (allMode || sortAlphabetically || !query.isEmpty() || selectedCategory != null)) continue;
if (!page.isEmpty() && !query.isEmpty()
&& !page.getHoverName().getString().toLowerCase(Locale.ROOT).contains(query)
&& !PageItem.content(page).symbol().map(symbol -> symbol.id().toString()
.toLowerCase(Locale.ROOT).contains(query)).orElse(false)) continue;
if (!page.isEmpty() && selectedCategory != null) {
ResourceLocation id = PageItem.content(page).symbol().map(symbol -> symbol.id()).orElse(null);
if (menu.catalog().stream().noneMatch(metadata -> metadata.id().equals(id)
&& metadata.category().equals(selectedCategory))) continue;
}
entries.add(new DisplayPage(collection, i, page));
}
}
if (sortAlphabetically) entries.sort(Comparator
.comparing((DisplayPage entry) -> entry.stack().getHoverName().getString(), String.CASE_INSENSITIVE_ORDER)
.thenComparingInt(DisplayPage::collection).thenComparingInt(DisplayPage::index));
return entries;
}
@Override public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
// Keep the world visible, but still draw our GUI backing. The vanilla
// container implementation normally calls renderBg from this method.
renderBg(graphics, partialTick, mouseX, mouseY);
}
@Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
int x = leftPos;
int y = topPos;
graphics.fill(x + 56, y + 14, x + 206, y + 168, 0xAA201B18);
graphics.blit(LEGACY_DESK, x, y + 5, 56, 9, 0, 166, 56, 9, 256, 256);
graphics.blit(LEGACY_DESK, x, y + 166, 56, 9, 0, 215, 56, 9, 256, 256);
graphics.blit(LEGACY_DESK, x + 218, y + 4, 178, 170, 64, 8, 16, 16, 256, 256);
for (int i = 0; i < 4; i++) {
graphics.blit(LEGACY_DESK, x, y + 16 + i * 37,
56, 36, 0, 166, 56, 54, 256, 256);
if (i == menu.activeCollection() && !allMode)
graphics.fill(x + 2, y + 18 + i * 37, x + 54, y + 46 + i * 37, 0x885858CC);
graphics.drawString(font, new String[]{"I", "II", "III", "IV"}[i],
x + 8, y + 25 + i * 37, 0xFF181818, false);
slotFrame(graphics, 36, 20 + i * 37);
}
graphics.blit(LEGACY_DESK, x + 206, y + 14, 10, 130, 177, 0, 12, 166, 256, 256);
int totalRows = (filteredPages().size() + 4) / 5;
int maxScroll = Math.max(0, totalRows - 3);
scrollRows = Math.clamp(scrollRows, 0, maxScroll);
int thumbHeight = Math.max(14, 126 * 3 / Math.max(3, totalRows));
int thumbY = y + 16 + (maxScroll == 0 ? 0 : scrollRows * (126 - thumbHeight) / maxScroll);
graphics.fill(x + 207, thumbY, x + 215, thumbY + thumbHeight, 0xFFB7B7B7);
graphics.renderOutline(x + 207, thumbY, 8, thumbHeight, 0xFF424242);
slotFrame(graphics, 370, 9);
slotFrame(graphics, 228, 9);
slotFrame(graphics, 228, 57);
slotFrame(graphics, 370, 57);
graphics.fill(x + 350, y + 9, x + 364, y + 64, 0xFF303035);
graphics.fill(x + 352, y + 11, x + 362, y + 62, 0xFF15151B);
int filled = 51 * menu.inkRemaining() / 16;
if (filled > 0) graphics.fill(x + 352, y + 62 - filled, x + 362, y + 62, 0xFF6666AA);
graphics.renderOutline(x + 350, y + 9, 14, 55, 0xFF484848);
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) slotFrame(graphics, 224 + column * 18, 79 + row * 18);
}
for (int column = 0; column < 9; column++) slotFrame(graphics, 224 + column * 18, 137);
}
private void slotFrame(GuiGraphics graphics, int x, int y) {
graphics.fill(leftPos + x - 1, topPos + y - 1,
leftPos + x + 17, topPos + y + 17, 0xFF4D4D4D);
graphics.fill(leftPos + x, topPos + y,
leftPos + x + 16, topPos + y + 16, 0xFF858585);
}
@Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
// Legacy leaves the inventory grid unlabeled.
}
@Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
super.render(graphics, mouseX, mouseY, partialTick);
if (sortAlphabetically) graphics.renderOutline(leftPos + 58, topPos - 18, 20, 18, 0xFFFFD061);
if (allMode) graphics.renderOutline(leftPos + 80, topPos - 18, 28, 18, 0xFFFFD061);
renderCollection(graphics);
renderTooltip(graphics, mouseX, mouseY);
if (mouseX >= leftPos + 350 && mouseX < leftPos + 364
&& mouseY >= topPos + 9 && mouseY < topPos + 64) {
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.ink",
menu.inkRemaining(), 16), mouseX, mouseY);
}
if (mouseX >= leftPos + 198 && mouseX < leftPos + 216
&& mouseY >= topPos - 18 && mouseY < topPos) {
graphics.renderTooltip(font, Component.literal(selectedCategory == null
? "All categories" : selectedCategory.toString()), mouseX, mouseY);
}
}
private List<ItemStack> pages(int collectionIndex) {
ItemStack collection = menu.getSlot(collectionIndex).getItem();
if (collection.is(ModItems.PAGE.get())) return List.of(collection);
if (collection.is(ModItems.NOTEBOOK.get())) {
return collection.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
}
return List.of();
}
private void renderCollection(GuiGraphics graphics) {
if (lastCollection != menu.activeCollection()) {
lastCollection = menu.activeCollection();
scrollRows = 0;
selectedPage = 0;
selectedCollection = -1;
}
List<DisplayPage> entries = filteredPages();
for (int i = scrollRows * 5; i < entries.size() && i < scrollRows * 5 + 15; i++) {
int column = i % 5;
int row = i / 5 - scrollRows;
DisplayPage entry = entries.get(i);
ItemStack page = entry.stack();
int x = leftPos + 58 + column * 29;
int y = topPos + 18 + row * 41;
drawPage(graphics, page, x, y);
if (page.isEmpty()) graphics.fill(x, y, x + 30, y + 40, 0xB8000000);
int active = selectedCollection >= 0 ? selectedCollection : menu.activeCollection();
if (entry.collection() == active && entry.index() == selectedPage && !page.isEmpty())
graphics.renderOutline(x, y, 30, 40, 0xFFFFD061);
}
int active = selectedCollection >= 0 ? selectedCollection : menu.activeCollection();
List<ItemStack> source = pages(active);
if (selectedPage >= source.size() || source.get(selectedPage).isEmpty()) return;
ItemStack selected = source.get(selectedPage);
drawPage(graphics, selected, leftPos + 253, topPos + 10);
String title = font.plainSubstrByWidth(selected.getHoverName().getString(), 84);
graphics.fill(leftPos + 251, topPos + 58, leftPos + 342, topPos + 73, 0xFF202020);
graphics.drawString(font, title, leftPos + 254, topPos + 61, 0xFFE0E0E0, false);
}
private void drawPage(GuiGraphics graphics, ItemStack page, int x, int y) {
graphics.blit(PAGE_ATLAS, x, y, 30, 40, 156, 0, 30, 40, 256, 256);
if (page.isEmpty()) return;
switch (PageItem.content(page).kind()) {
case BLANK -> { }
case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 25, y + 20, 0xFF000000);
case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(NotebookScreen.symbolTexture(page), x + 1, y + 6,
28, 28, 16, 16, 96, 96, 128, 128);
}
}
@Override public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (button == 0) {
if (mouseX >= leftPos + 206 && mouseX < leftPos + 217
&& mouseY >= topPos + 14 && mouseY < topPos + 144) {
draggingScrollbar = true;
setScrollFromMouse(mouseY);
return true;
}
int x = (int) mouseX - leftPos - 58;
int y = (int) mouseY - topPos - 18;
if (x >= 0 && x < 145 && y >= 0 && y < 123) {
int column = x / 29;
int row = y / 41;
int index = (row + scrollRows) * 5 + column;
List<DisplayPage> entries = filteredPages();
if (y % 41 < 40 && index < entries.size()) {
DisplayPage entry = entries.get(index);
if (!entry.stack().isEmpty()) {
selectedCollection = entry.collection();
selectedPage = entry.index();
if (minecraft != null && minecraft.gameMode != null) {
int id = allMode ? 1000 + entry.collection() * NotebookContents.MAX_PAGES + entry.index()
: 16 + entry.index();
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, id);
}
}
}
return true;
}
}
if (button == 0 && mouseX >= leftPos && mouseX < leftPos + 35) {
for (int i = 0; i < 4; i++) {
int top = topPos + 16 + i * 37;
if (mouseY >= top && mouseY < top + 36) {
allMode = false;
selectedCollection = -1;
if (minecraft != null && minecraft.gameMode != null) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, i);
}
return true;
}
}
}
return super.mouseClicked(mouseX, mouseY, button);
}
@Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (mouseX >= leftPos + 58 && mouseX < leftPos + 217
&& mouseY >= topPos + 18 && mouseY < topPos + 141) {
int rows = (filteredPages().size() + 4) / 5;
scrollRows = Math.clamp(scrollRows - (int) Math.signum(scrollY), 0, Math.max(0, rows - 3));
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
private void setScrollFromMouse(double mouseY) {
int rows = (filteredPages().size() + 4) / 5;
int max = Math.max(0, rows - 3);
scrollRows = Math.clamp((int) Math.round((mouseY - topPos - 16) * max / 126.0), 0, max);
}
@Override public boolean mouseDragged(double mouseX, double mouseY, int button, double dragX, double dragY) {
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 && draggingScrollbar) {
draggingScrollbar = false;
return true;
}
return super.mouseReleased(mouseX, mouseY, button);
}
@Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (search != null && search.isFocused()) {
if (keyCode == 256) {
setFocused(null);
search.setFocused(false);
} else {
search.keyPressed(keyCode, scanCode, modifiers);
}
return true;
}
return super.keyPressed(keyCode, scanCode, modifiers);
}
}
@@ -203,6 +203,29 @@ public final class WritingDataGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void notebookMenuPreservesSparsePagePositions(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(ModItems.NOTEBOOK.get()));
NotebookMenu menu = new NotebookMenu(9, player.getInventory(), InteractionHand.MAIN_HAND);
ItemStack page = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
menu.setCarried(page);
menu.clicked(9, 0, ClickType.PICKUP, player);
helper.assertTrue(menu.getCarried().isEmpty(),
"Clicking a distant empty Notebook slot must place the carried Page");
NotebookContents saved = player.getMainHandItem().get(ModDataComponents.NOTEBOOK_CONTENTS.get());
helper.assertTrue(saved != null && saved.pages().size() > 9,
"Notebook persistence must extend through the highest occupied position");
for (int index = 0; index < 9; index++) {
helper.assertTrue(saved.pages().get(index).isEmpty(),
"Earlier empty page positions must remain empty rather than being packed together");
}
helper.assertValueEqual(PageItem.content(saved.pages().get(9)).kind(), PageContent.Kind.SYMBOL,
"The Page must remain in the selected distant position");
helper.succeed();
}
@GameTest(template = "empty")
public static void inkVialsPersistAndOnlyStackWithMatchingContents(GameTestHelper helper) {
ItemStack vial = new ItemStack(ModItems.INK_VIAL.get());
@@ -221,14 +244,14 @@ public final class WritingDataGameTests {
@GameTest(template = "empty")
public static void linkPanelPreparationConsumesOnePageAndConfiguredInkOnlyOnSuccess(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
ItemStack pages = new ItemStack(ModItems.PAGE.get(), 2);
ItemStack pages = new ItemStack(Items.PAPER, 2);
ItemStack vial = new ItemStack(ModItems.INK_VIAL.get());
player.setItemInHand(InteractionHand.MAIN_HAND, vial);
player.setItemInHand(InteractionHand.OFF_HAND, pages);
helper.assertTrue(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
"A blank Page and sufficient ink must prepare one link panel");
helper.assertValueEqual(pages.getCount(), 1, "Preparation must consume exactly one blank Page");
"Paper and sufficient ink must prepare one link panel");
helper.assertValueEqual(pages.getCount(), 1, "Preparation must consume exactly one paper");
helper.assertValueEqual(InkVialItem.remaining(player.getMainHandItem()),
InkVialItem.CAPACITY - WritingInkService.linkPanelCost(),
"Preparation must consume exactly the server-configured ink cost");
@@ -251,14 +274,14 @@ public final class WritingDataGameTests {
ItemStack lowInk = new ItemStack(ModItems.INK_VIAL.get());
lowInk.set(ModDataComponents.INK_REMAINING.get(), WritingInkService.linkPanelCost() - 1);
player.setItemInHand(InteractionHand.MAIN_HAND, lowInk);
player.setItemInHand(InteractionHand.OFF_HAND, new ItemStack(ModItems.PAGE.get()));
player.setItemInHand(InteractionHand.OFF_HAND, new ItemStack(Items.PAPER));
helper.assertFalse(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
"Insufficient ink must reject link-panel preparation");
helper.assertValueEqual(InkVialItem.remaining(player.getMainHandItem()),
WritingInkService.linkPanelCost() - 1,
"Insufficient ink must remain untouched");
helper.assertValueEqual(PageItem.content(player.getOffhandItem()).kind(), PageContent.Kind.BLANK,
"Insufficient ink must leave the Page blank");
helper.assertTrue(player.getOffhandItem().is(Items.PAPER),
"Insufficient ink must leave paper untouched");
lowInk.set(ModDataComponents.INK_REMAINING.get(), WritingInkService.linkPanelCost());
helper.assertTrue(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
@@ -267,7 +290,7 @@ public final class WritingDataGameTests {
"A depleted Ink Vial must return a reusable glass bottle");
helper.assertValueEqual(PageItem.content(player.getOffhandItem()).kind(),
PageContent.Kind.LINK_PANEL,
"A single blank Page must be replaced by one Link Panel Page");
"A single paper must be replaced by one Link Panel Page");
helper.succeed();
}
@@ -275,7 +298,7 @@ public final class WritingDataGameTests {
public static void stackedInkVialsSplitWithoutChangingTheOtherVials(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(ModItems.INK_VIAL.get(), 2));
player.setItemInHand(InteractionHand.OFF_HAND, new ItemStack(ModItems.PAGE.get()));
player.setItemInHand(InteractionHand.OFF_HAND, new ItemStack(Items.PAPER));
helper.assertTrue(WritingInkService.prepareLinkPanel(player, InteractionHand.MAIN_HAND),
"A stack of full vials must support preparing one panel");
@@ -0,0 +1,353 @@
package net.dnorberg.rivencraft.gametest;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.inventory.ClickType;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class WritingDeskGameTests {
private WritingDeskGameTests() {}
@GameTest(template = "empty")
public static void inventoryAndActiveCollectionPersist(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
var original = new WritingDeskBlockEntity(pos, ModBlocks.WRITING_DESK.get().defaultBlockState());
original.setItem(0, new ItemStack(ModItems.NOTEBOOK.get()));
original.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
original.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 12));
original.setActiveCollection(2);
var saved = original.saveWithoutMetadata(helper.getLevel().registryAccess());
var restored = new WritingDeskBlockEntity(pos, ModBlocks.WRITING_DESK.get().defaultBlockState());
restored.loadWithComponents(saved, helper.getLevel().registryAccess());
helper.assertTrue(restored.getItem(0).is(ModItems.NOTEBOOK.get()), "Notebook lost on save/load");
helper.assertTrue(restored.getItem(WritingDeskBlockEntity.INK).is(ModItems.INK_VIAL.get()), "Ink lost on save/load");
helper.assertValueEqual(restored.getItem(WritingDeskBlockEntity.PAPER).getCount(), 12, "Pages lost on save/load");
helper.assertValueEqual(restored.activeCollection(), 2, "Active collection lost on save/load");
helper.succeed();
}
@GameTest(template = "empty")
public static void breakingDeskDropsItsStoredItemsOnce(GameTestHelper helper) {
BlockPos relative = new BlockPos(1, 1, 1);
BlockPos pos = helper.absolutePos(relative);
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
desk.setItem(0, new ItemStack(ModItems.NOTEBOOK.get()));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 12));
helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(pos).inflate(2));
int notebooks = drops.stream().filter(e -> e.getItem().is(ModItems.NOTEBOOK.get())).mapToInt(e -> e.getItem().getCount()).sum();
int ink = drops.stream().filter(e -> e.getItem().is(ModItems.INK_VIAL.get())).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(ink, 1, "Ink must drop exactly once");
helper.assertValueEqual(pages, 12, "Pages must drop without loss");
helper.succeed();
}
@GameTest(template = "empty")
public static void menuTransfersWithoutDuplicationAndClosesWhenReplaced(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
player.getInventory().setItem(9, new ItemStack(Items.PAPER, 12));
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
helper.assertTrue(menu.stillValid(player), "Placed desk must keep its menu valid");
helper.assertTrue(menu.clickMenuButton(player, 3), "Active collection control must accept a valid tab");
helper.assertValueEqual(desk.activeCollection(), 3, "Selected tab must be stored on the desk");
menu.quickMoveStack(player, 9);
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 12, "Shift-click must deposit all pages");
helper.assertTrue(player.getInventory().getItem(9).isEmpty(), "Shift-click must consume player source stack");
menu.quickMoveStack(player, WritingDeskBlockEntity.PAPER);
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(), "Shift-click must empty desk source slot");
int total = player.getInventory().items.stream().filter(stack -> stack.is(Items.PAPER))
.mapToInt(ItemStack::getCount).sum();
helper.assertValueEqual(total, 12, "Round-trip must not lose or duplicate pages");
helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
helper.assertFalse(menu.stillValid(player), "Removed desk must invalidate menu");
helper.succeed();
}
@GameTest(template = "empty")
public static void takingCopySpendsPaperAndOneInkThenReturnsGlassBottle(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
ItemStack source = PageItem.createLinkPanelPage();
desk.setItem(0, source.copy());
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
menu.broadcastChanges();
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().is(ModItems.PAGE.get()),
"A source page with paper and ink must preview one copy");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 2,
"Previewing must not consume paper");
helper.assertValueEqual(desk.inkRemaining(), 0, "Previewing must not consume ink");
menu.clicked(WritingDeskMenu.COPY_RESULT, 0, ClickType.PICKUP, player);
helper.assertTrue(menu.getCarried().is(ModItems.PAGE.get()), "Taking result must give the copied page");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
"Taking a copy must consume one paper");
helper.assertValueEqual(desk.inkRemaining(), 15, "First copy must refill to 16 and consume one ink");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.INK).isEmpty(), "Refill must consume one vial");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.RETURN).is(Items.GLASS_BOTTLE),
"Refill must return a vanilla glass bottle");
helper.assertTrue(desk.getItem(0).is(ModItems.PAGE.get()), "Source page must remain untouched");
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).is(ModItems.PAGE.get()),
"A second copy must remain available from the same ink fill");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(),
"Two copies must consume exactly two paper");
helper.assertValueEqual(desk.inkRemaining(), 14, "Two copies must spend exactly two of sixteen ink");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.RETURN).getCount(), 1,
"A second copy must not consume another vial");
var saved = desk.saveWithoutMetadata(helper.getLevel().registryAccess());
var restored = new WritingDeskBlockEntity(pos, ModBlocks.WRITING_DESK.get().defaultBlockState());
restored.loadWithComponents(saved, helper.getLevel().registryAccess());
helper.assertValueEqual(restored.inkRemaining(), 14, "Ink meter must persist across saves");
helper.succeed();
}
@GameTest(template = "empty")
public static void shiftClickCopiesOnlyWhenResourcesAndBottleSpaceExist(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
desk.setItem(0, PageItem.createLinkPanelPage());
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
desk.setItem(WritingDeskBlockEntity.RETURN, new ItemStack(Items.GLASS_BOTTLE, 64));
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty(),
"Full bottle return must block refill without consuming supplies");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 2,
"Blocked copy must leave paper untouched");
desk.removeItem(WritingDeskBlockEntity.RETURN, 1);
ItemStack copied = menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT);
helper.assertTrue(copied.is(ModItems.PAGE.get()), "Shift-click must transfer one copy");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
"Shift-click must consume one paper");
helper.assertValueEqual(desk.inkRemaining(), 15, "Shift-click must consume one ink");
int copies = player.getInventory().items.stream().filter(stack -> stack.is(ModItems.PAGE.get()))
.mapToInt(ItemStack::getCount).sum();
helper.assertValueEqual(copies, 1, "Shift-click must give exactly one page");
helper.succeed();
}
@GameTest(template = "empty")
public static void selectedNotebookPageControlsCopyPreview(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(),
new NotebookContents(List.of(ItemStack.EMPTY, PageItem.createLinkPanelPage())));
desk.setItem(0, notebook);
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
menu.broadcastChanges();
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().isEmpty(),
"An empty selected position must not create a copy");
helper.assertTrue(menu.clickMenuButton(player, 17), "Second page selection must be accepted");
menu.broadcastChanges();
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().is(ModItems.PAGE.get()),
"Selecting the populated page must show a copy");
helper.succeed();
}
@GameTest(template = "empty")
public static void twoViewersCannotTakeTheSameLastSheet(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
desk.setItem(0, PageItem.createLinkPanelPage());
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var first = helper.makeMockServerPlayerInLevel();
var second = helper.makeMockServerPlayerInLevel();
first.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
second.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
WritingDeskMenu firstMenu = new WritingDeskMenu(1, first.getInventory(), desk);
WritingDeskMenu secondMenu = new WritingDeskMenu(2, second.getInventory(), desk);
firstMenu.broadcastChanges();
secondMenu.broadcastChanges();
helper.assertTrue(firstMenu.quickMoveStack(first, WritingDeskMenu.COPY_RESULT).is(ModItems.PAGE.get()),
"First viewer should take the last available copy");
helper.assertTrue(secondMenu.quickMoveStack(second, WritingDeskMenu.COPY_RESULT).isEmpty(),
"Second viewer must not copy the already-consumed paper");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.PAPER).isEmpty(),
"Shared desk must spend paper only once");
helper.succeed();
}
@GameTest(template = "empty")
public static void catalogAggregatesKnownSymbolsAcrossCollections(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
ResourceLocation known = ResourceLocation.parse("rivencraft:terrain/standard");
ItemStack page = PageItem.createSymbolPage(new SymbolReference(known));
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(page.copy())));
desk.setItem(0, page);
desk.setItem(1, notebook);
helper.assertValueEqual(WritingDeskCatalog.available(desk).size(), 1,
"Duplicate symbols across loose pages and notebooks must appear once");
helper.assertValueEqual(WritingDeskCatalog.available(desk).getFirst().id(), known,
"Catalog must retain the authoritative symbol ID");
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
helper.assertFalse(menu.clickMenuButton(player, 101), "Out-of-range catalog index must be rejected");
helper.assertTrue(menu.clickMenuButton(player, 100), "Known catalog symbol must be selectable");
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
menu.broadcastChanges();
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().is(ModItems.PAGE.get()),
"Catalog selection must preview a copy of the known symbol");
desk.setItem(0, ItemStack.EMPTY);
desk.setItem(1, ItemStack.EMPTY);
menu.broadcastChanges();
helper.assertTrue(menu.catalog().isEmpty(), "Removed sources must invalidate catalog for the viewer");
helper.assertTrue(menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().isEmpty(),
"Removed sources must invalidate the copy output");
helper.succeed();
}
@GameTest(template = "empty")
public static void fullInventoryCannotConsumeCopyMaterials(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
WritingDeskBlockEntity desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
desk.setItem(0, PageItem.createLinkPanelPage());
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
for (int i = 0; i < 36; i++) player.getInventory().setItem(i, new ItemStack(Items.COBBLESTONE, 64));
WritingDeskMenu menu = new WritingDeskMenu(1, player.getInventory(), desk);
helper.assertTrue(menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty(),
"Full inventory must reject shift-click copy");
helper.assertValueEqual(desk.getItem(WritingDeskBlockEntity.PAPER).getCount(), 1,
"Rejected copy must preserve paper");
helper.assertValueEqual(desk.inkRemaining(), 0, "Rejected copy must preserve ink meter");
helper.assertTrue(desk.getItem(WritingDeskBlockEntity.INK).is(ModItems.INK_VIAL.get()),
"Rejected copy must preserve ink vial");
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, 1000 + 2 * NotebookContents.MAX_PAGES),
"ALL view must reject empty cross-tab positions");
helper.assertTrue(menu.clickMenuButton(player, 1000 + 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();
}
}
@@ -0,0 +1,257 @@
package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.dnorberg.rivencraft.network.WritingDeskCatalogPayload;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.neoforge.network.PacketDistributor;
import java.util.List;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.world.Container;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.DataSlot;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
public final class WritingDeskMenu extends AbstractContainerMenu {
public static final int COPY_RESULT = WritingDeskBlockEntity.SIZE;
private static final int PLAYER_START = COPY_RESULT + 1;
private final Container desk;
private final WritingDeskBlockEntity blockEntity;
private final SimpleContainer result = new SimpleContainer(1);
private final boolean clientSide;
private int syncedActiveCollection;
private int syncedInkRemaining;
private int selectedPage;
private int selectedCollection = -1;
private ResourceLocation selectedSymbol;
private List<SymbolDisplayMetadata> catalog = List.of();
private final ServerPlayer serverViewer;
public WritingDeskMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
instanceof WritingDeskBlockEntity found ? found : null);
catalog = WritingDeskCatalog.read(data);
}
public WritingDeskMenu(int id, Inventory inventory, WritingDeskBlockEntity blockEntity) {
super(ModMenus.WRITING_DESK.get(), id);
this.blockEntity = blockEntity;
this.desk = blockEntity == null ? new SimpleContainer(WritingDeskBlockEntity.SIZE) : blockEntity;
this.clientSide = inventory.player.level().isClientSide;
this.serverViewer = inventory.player instanceof ServerPlayer serverPlayer ? serverPlayer : null;
if (blockEntity != null && !clientSide) catalog = WritingDeskCatalog.available(blockEntity);
addDataSlot(new DataSlot() {
@Override public int get() { return WritingDeskMenu.this.blockEntity == null
? syncedActiveCollection : WritingDeskMenu.this.blockEntity.activeCollection(); }
@Override public void set(int value) { syncedActiveCollection = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() { return WritingDeskMenu.this.blockEntity == null
? syncedInkRemaining : WritingDeskMenu.this.blockEntity.inkRemaining(); }
@Override public void set(int value) { syncedInkRemaining = value; }
});
for (int i = 0; i < WritingDeskBlockEntity.COLLECTIONS; i++) {
addSlot(new DeskSlot(desk, i, 36, 20 + i * 37, 1));
}
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.INK, 370, 9, 16));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.RETURN, 370, 57, 64));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.PAPER, 228, 9, 64));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.LEGACY_OUTPUT, -1000, -1000, 64));
addSlot(new CopySlot(result, 0, 228, 57));
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
addSlot(new Slot(inventory, column + row * 9 + 9, 224 + column * 18, 79 + row * 18));
}
}
for (int column = 0; column < 9; column++) {
addSlot(new Slot(inventory, column, 224 + column * 18, 137));
}
}
public int activeCollection() { return syncedActiveCollection; }
public int inkRemaining() { return syncedInkRemaining; }
public List<SymbolDisplayMetadata> catalog() { return catalog; }
public void setCatalog(List<SymbolDisplayMetadata> symbols) { catalog = List.copyOf(symbols); }
@Override public boolean clickMenuButton(Player player, int id) {
if (blockEntity == null || !stillValid(player)) return false;
if (id >= 0 && id < WritingDeskBlockEntity.COLLECTIONS) {
blockEntity.setActiveCollection(id);
selectedPage = 0;
selectedCollection = -1;
selectedSymbol = null;
} else if (id >= 16 && id < 16 + NotebookContents.MAX_PAGES) {
selectedPage = id - 16;
selectedCollection = -1;
selectedSymbol = null;
} else if (id >= 1000 && id < 1000 + WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES) {
int offset = id - 1000;
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 >= 100 && id < 100 + catalog.size()) {
if (!catalog.equals(WritingDeskCatalog.available(blockEntity))) {
refreshCatalog();
return false;
}
ResourceLocation requested = catalog.get(id - 100).id();
if (!WritingDeskCatalog.available(blockEntity).stream().anyMatch(entry -> entry.id().equals(requested))) {
return false;
}
selectedSymbol = requested;
} else return false;
refreshOutput();
return true;
}
@Override public boolean stillValid(Player player) {
return blockEntity != null && blockEntity.stillValid(player);
}
@Override public ItemStack quickMoveStack(Player player, int index) {
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
if (index == COPY_RESULT) {
refreshOutput();
if (!canCopy()) return ItemStack.EMPTY;
ItemStack copy = selectedSource().copyWithCount(1);
ItemStack movingCopy = copy.copy();
if (!moveItemStackTo(movingCopy, PLAYER_START, slots.size(), true) || !movingCopy.isEmpty()) {
return ItemStack.EMPTY;
}
blockEntity.consumeCopyMaterials();
refreshOutput();
return copy;
}
Slot slot = slots.get(index);
if (!slot.hasItem()) return ItemStack.EMPTY;
ItemStack moving = slot.getItem();
ItemStack original = moving.copy();
if (index < COPY_RESULT) {
if (!moveItemStackTo(moving, PLAYER_START, slots.size(), true)) return ItemStack.EMPTY;
} else if (!moveIntoDesk(moving)) {
return ItemStack.EMPTY;
}
if (moving.isEmpty()) slot.setByPlayer(ItemStack.EMPTY);
else slot.setChanged();
return original;
}
private boolean moveIntoDesk(ItemStack moving) {
if (moving.is(ModItems.INK_VIAL.get())) return moveItemStackTo(moving, 4, 5, false);
if (moving.is(ModItems.NOTEBOOK.get())) return moveItemStackTo(moving, 0, 4, false);
if (moving.is(Items.PAPER))
return moveItemStackTo(moving, WritingDeskBlockEntity.PAPER,
WritingDeskBlockEntity.PAPER + 1, false);
if (moving.is(ModItems.PAGE.get())) return moveItemStackTo(moving, 0, 4, false);
return false;
}
private ItemStack selectedSource() {
if (selectedSymbol != null) {
SymbolReference reference = new SymbolReference(selectedSymbol);
return SymbolCatalog.isKnown(reference)
&& blockEntity != null
&& WritingDeskCatalog.available(blockEntity).stream()
.anyMatch(entry -> entry.id().equals(selectedSymbol))
? PageItem.createSymbolPage(reference) : ItemStack.EMPTY;
}
int collectionIndex = selectedCollection >= 0 ? selectedCollection
: blockEntity == null ? syncedActiveCollection : blockEntity.activeCollection();
return pageAt(collectionIndex, selectedPage);
}
private ItemStack pageAt(int collectionIndex, int pageIndex) {
if (collectionIndex < 0 || collectionIndex >= WritingDeskBlockEntity.COLLECTIONS
|| pageIndex < 0 || pageIndex >= NotebookContents.MAX_PAGES) return ItemStack.EMPTY;
ItemStack collection = desk.getItem(collectionIndex);
if (collection.is(ModItems.PAGE.get())) return pageIndex == 0 ? collection : ItemStack.EMPTY;
if (collection.is(ModItems.NOTEBOOK.get())) {
List<ItemStack> pages = collection.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
return pageIndex < pages.size() ? pages.get(pageIndex) : ItemStack.EMPTY;
}
return ItemStack.EMPTY;
}
private boolean canCopy() {
ItemStack source = selectedSource();
return blockEntity != null && source.is(ModItems.PAGE.get())
&& PageItem.content(source).kind() != PageContent.Kind.BLANK
&& PageItem.isUsableWriting(source) && blockEntity.canConsumeCopyMaterials();
}
private void refreshOutput() {
if (clientSide) return;
if (blockEntity != null) blockEntity.setDisplayPage(selectedSource());
ItemStack preview = canCopy() ? selectedSource().copyWithCount(1) : ItemStack.EMPTY;
if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview);
}
private void refreshCatalog() {
if (clientSide || blockEntity == null) return;
List<SymbolDisplayMetadata> current = WritingDeskCatalog.available(blockEntity);
if (!current.equals(catalog)) {
catalog = current;
if (serverViewer != null && serverViewer.containerMenu == this) {
PacketDistributor.sendToPlayer(serverViewer, new WritingDeskCatalogPayload(containerId, catalog));
}
}
}
@Override public void broadcastChanges() {
refreshCatalog();
refreshOutput();
super.broadcastChanges();
}
@Override public void clicked(int slotId, int button, net.minecraft.world.inventory.ClickType clickType,
Player player) {
if (!stillValid(player)) {
player.closeContainer();
return;
}
refreshCatalog();
refreshOutput();
super.clicked(slotId, button, clickType, player);
}
private final class CopySlot extends Slot {
private CopySlot(Container container, int index, int x, int y) { super(container, index, x, y); }
@Override public boolean mayPlace(ItemStack stack) { return false; }
@Override public boolean mayPickup(Player player) {
return clientSide ? hasItem() : WritingDeskMenu.this.stillValid(player) && canCopy();
}
@Override public void onTake(Player player, ItemStack stack) {
if (!clientSide && blockEntity.consumeCopyMaterials()) refreshOutput();
super.onTake(player, stack);
}
}
private static final class DeskSlot extends Slot {
private final int limit;
private DeskSlot(Container container, int index, int x, int y, int limit) {
super(container, index, x, y);
this.limit = limit;
}
@Override public boolean mayPlace(ItemStack stack) { return container.canPlaceItem(getContainerSlot(), stack); }
@Override public int getMaxStackSize() { return limit; }
}
}
@@ -9,6 +9,7 @@ import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.client.ClientLinkPreviewHandler;
import net.dnorberg.rivencraft.client.ClientBookInteractionHandler;
import net.dnorberg.rivencraft.client.ClientAgeCelestialAngles;
import net.dnorberg.rivencraft.client.ClientWritingDeskCatalog;
import net.dnorberg.rivencraft.event.AgeCelestialAngleSync;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
@@ -68,6 +69,8 @@ public final class ModNetworking {
ClientLinkPreviewHandler::handle);
registrar.playToClient(AgeCelestialAnglePayload.TYPE, AgeCelestialAnglePayload.STREAM_CODEC,
ClientAgeCelestialAngles::handle);
registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC,
ClientWritingDeskCatalog::handle);
}
private static void requestDescriptiveBookScreen(RequestDescriptiveBookScreenPayload payload, IPayloadContext context) {
@@ -0,0 +1,26 @@
package net.dnorberg.rivencraft.network;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.ResourceLocation;
/** Current server-approved symbols from this open Writing Desk menu. */
public record WritingDeskCatalogPayload(int containerId, List<SymbolDisplayMetadata> symbols)
implements CustomPacketPayload {
public static final Type<WritingDeskCatalogPayload> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "writing_desk_catalog"));
public static final StreamCodec<RegistryFriendlyByteBuf, WritingDeskCatalogPayload> STREAM_CODEC =
StreamCodec.of(
(buffer, payload) -> {
buffer.writeVarInt(payload.containerId());
WritingDeskCatalog.write(buffer, payload.symbols());
},
buffer -> new WritingDeskCatalogPayload(buffer.readVarInt(), WritingDeskCatalog.read(buffer)));
@Override public Type<? extends CustomPacketPayload> type() { return TYPE; }
}
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.neoforged.bus.api.IEventBus;
@@ -16,6 +17,9 @@ public final class ModBlockEntities {
BLOCK_ENTITIES.register("book_stand", () -> BlockEntityType.Builder
.of(BookStandBlockEntity::new, ModBlocks.BOOK_STAND.get())
.build(null));
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<WritingDeskBlockEntity>> WRITING_DESK =
BLOCK_ENTITIES.register("writing_desk", () -> BlockEntityType.Builder
.of(WritingDeskBlockEntity::new, ModBlocks.WRITING_DESK.get()).build(null));
private ModBlockEntities() {
}
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.neoforged.bus.api.IEventBus;
@@ -16,6 +17,10 @@ public final class ModBlocks {
BookStandBlock::new,
BlockBehaviour.Properties.ofFullCopy(Blocks.LECTERN)
);
public static final DeferredBlock<WritingDeskBlock> WRITING_DESK = BLOCKS.registerBlock(
"writing_desk", WritingDeskBlock::new,
BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion()
);
private ModBlocks() {
}
@@ -22,6 +22,7 @@ public final class ModCreativeTabs {
output.accept(ModItems.LINKING_BOOK.get());
output.accept(ModItems.NOTEBOOK.get());
output.accept(ModItems.INK_VIAL.get());
output.accept(ModItems.WRITING_DESK.get());
})
.build());
@@ -30,7 +31,6 @@ public final class ModCreativeTabs {
.title(Component.translatable("itemGroup.rivencraft.pages"))
.icon(PageItem::createLinkPanelPage)
.displayItems((parameters, output) -> {
output.accept(ModItems.PAGE.get());
output.accept(PageItem.createLinkPanelPage());
})
.build());
@@ -7,6 +7,7 @@ import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.NotebookItem;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.BlockItem;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredItem;
import net.neoforged.neoforge.registries.DeferredRegister;
@@ -39,6 +40,7 @@ public final class ModItems {
InkVialItem::new,
new Item.Properties().stacksTo(16)
);
public static final DeferredItem<BlockItem> WRITING_DESK = ITEMS.registerSimpleBlockItem(ModBlocks.WRITING_DESK);
private ModItems() {
}
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.inventory.MenuType;
import net.neoforged.bus.api.IEventBus;
@@ -15,6 +16,8 @@ public final class ModMenus {
public static final DeferredHolder<MenuType<?>, MenuType<NotebookMenu>> NOTEBOOK =
MENUS.register("notebook", () -> IMenuTypeExtension.create(NotebookMenu::new));
public static final DeferredHolder<MenuType<?>, MenuType<WritingDeskMenu>> WRITING_DESK =
MENUS.register("writing_desk", () -> IMenuTypeExtension.create(WritingDeskMenu::new));
private ModMenus() {
}
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.writing;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
/** A bounded, server-derived view of symbols physically present in the desk. */
public final class WritingDeskCatalog {
private static final int MAX_ENTRIES = WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES;
private WritingDeskCatalog() {}
public static List<SymbolDisplayMetadata> available(WritingDeskBlockEntity desk) {
Map<ResourceLocation, SymbolDisplayMetadata> unique = new TreeMap<>();
for (int slot = 0; slot < WritingDeskBlockEntity.COLLECTIONS; slot++) {
ItemStack collection = desk.getItem(slot);
if (collection.is(ModItems.PAGE.get())) add(unique, collection);
if (collection.is(ModItems.NOTEBOOK.get())) {
List<ItemStack> pages = collection.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
pages.forEach(page -> add(unique, page));
}
}
return unique.values().stream()
.sorted(Comparator.comparing((SymbolDisplayMetadata symbol) -> symbol.category().toString())
.thenComparing(symbol -> symbol.id().toString()))
.toList();
}
private static void add(Map<ResourceLocation, SymbolDisplayMetadata> unique, ItemStack page) {
if (!page.is(ModItems.PAGE.get())) return;
PageContent content = PageItem.content(page).resolve();
if (content.kind() != PageContent.Kind.SYMBOL) return;
SymbolReference reference = content.symbol().orElseThrow();
SymbolCatalog.find(reference).ifPresent(definition ->
unique.put(reference.id(), definition.display(reference.id())));
}
public static void write(RegistryFriendlyByteBuf buffer, List<SymbolDisplayMetadata> entries) {
if (entries.size() > MAX_ENTRIES) throw new IllegalArgumentException("Desk symbol catalog exceeds limit");
buffer.writeVarInt(entries.size());
entries.forEach(entry -> SymbolDisplayMetadata.STREAM_CODEC.encode(buffer, entry));
}
public static List<SymbolDisplayMetadata> read(RegistryFriendlyByteBuf buffer) {
int count = buffer.readVarInt();
if (count < 0 || count > MAX_ENTRIES) throw new IllegalArgumentException("Invalid desk symbol count " + count);
List<SymbolDisplayMetadata> entries = new ArrayList<>(count);
for (int index = 0; index < count; index++) {
entries.add(SymbolDisplayMetadata.STREAM_CODEC.decode(buffer));
}
return List.copyOf(entries);
}
}
@@ -29,7 +29,7 @@ public final class WritingInkService {
int remaining = InkVialItem.remaining(vial);
return vial.is(ModItems.INK_VIAL.get()) && remaining >= linkPanelCost()
&& remaining <= InkVialItem.CAPACITY
&& page.is(ModItems.PAGE.get()) && PageItem.content(page).kind() == PageContent.Kind.BLANK;
&& page.is(Items.PAPER);
}
public static boolean prepareLinkPanel(Player player, InteractionHand vialHand) {
@@ -0,0 +1,12 @@
{
"variants": {
"facing=north,extension=false": {"model":"rivencraft:block/writing_desk"},
"facing=east,extension=false": {"model":"rivencraft:block/writing_desk","y":90},
"facing=south,extension=false": {"model":"rivencraft:block/writing_desk","y":180},
"facing=west,extension=false": {"model":"rivencraft:block/writing_desk","y":270},
"facing=north,extension=true": {"model":"rivencraft:block/writing_desk_extension"},
"facing=east,extension=true": {"model":"rivencraft:block/writing_desk_extension","y":90},
"facing=south,extension=true": {"model":"rivencraft:block/writing_desk_extension","y":180},
"facing=west,extension=true": {"model":"rivencraft:block/writing_desk_extension","y":270}
}
}
@@ -16,8 +16,13 @@
"item.rivencraft.notebook.pages": "Pages: %s/%s",
"item.rivencraft.notebook.unresolved": "Unresolved pages: %s",
"item.rivencraft.ink_vial": "Ink Vial",
"block.rivencraft.writing_desk": "Writing Desk",
"container.rivencraft.writing_desk": "Writing Desk",
"screen.rivencraft.writing_desk.ink": "Ink: %s/%s",
"screen.rivencraft.writing_desk.search": "Search",
"screen.rivencraft.writing_desk.no_symbols": "No available symbols",
"item.rivencraft.ink_vial.remaining": "Ink: %s/%s",
"item.rivencraft.ink_vial.usage": "Use with a blank Page in the other hand to prepare a Link Panel.",
"item.rivencraft.ink_vial.usage": "Use with paper in the other hand to prepare a Link Panel.",
"screen.rivencraft.notebook.search": "Search pages",
"screen.rivencraft.notebook.move_previous": "Move selected page left",
"screen.rivencraft.notebook.move_next": "Move selected page right",
@@ -0,0 +1,11 @@
{
"parent": "minecraft:block/block",
"textures": {"wood":"rivencraft:block/writing_desk_wood","inlay":"rivencraft:block/writing_desk_inlay","particle":"rivencraft:block/writing_desk_wood"},
"elements": [
{"from":[0,0,1],"to":[16,1,15],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}},
{"from":[0,8,0],"to":[16,9,16],"faces":{"up":{"texture":"#inlay"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}},
{"from":[0,0,1],"to":[1,8,15],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}},
{"from":[1,3,1],"to":[16,4,15],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}},
{"from":[0,9,15],"to":[16,15,16],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}}
]
}
@@ -0,0 +1,11 @@
{
"parent": "minecraft:block/block",
"textures": {"wood":"rivencraft:block/writing_desk_wood","inlay":"rivencraft:block/writing_desk_inlay","particle":"rivencraft:block/writing_desk_wood"},
"elements": [
{"from":[0,0,1],"to":[16,1,15],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}},
{"from":[0,8,0],"to":[16,9,16],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}},
{"from":[15,0,1],"to":[16,8,15],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}},
{"from":[0,3,1],"to":[15,4,15],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}},
{"from":[0,9,15],"to":[16,15,16],"faces":{"up":{"texture":"#wood"},"down":{"texture":"#wood"},"north":{"texture":"#wood"},"south":{"texture":"#wood"},"east":{"texture":"#wood"},"west":{"texture":"#wood"}}}
]
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": {"layer0": "rivencraft:item/writing_desk"}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

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.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

@@ -0,0 +1,4 @@
{
"type": "minecraft:block",
"pools": [{ "rolls": 1, "entries": [{ "type": "minecraft:item", "name": "rivencraft:writing_desk" }] }]
}
@@ -1,13 +0,0 @@
{
"type": "minecraft:crafting_shapeless",
"category": "misc",
"ingredients": [
{
"item": "minecraft:paper"
}
],
"result": {
"count": 1,
"id": "rivencraft:page"
}
}
@@ -0,0 +1,11 @@
{
"type": "minecraft:crafting_shaped",
"category": "misc",
"pattern": ["V F", "PPP", "P P"],
"key": {
"V": { "item": "minecraft:glass_bottle" },
"F": { "item": "minecraft:feather" },
"P": { "tag": "minecraft:planks" }
},
"result": { "id": "rivencraft:writing_desk", "count": 1 }
}