diff --git a/gradle.properties b/gradle.properties index c8fbf7e..a661d78 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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.2.3 +mod_version=0.3.3.0 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. diff --git a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index 49e3d77..665b99b 100644 --- a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -4,6 +4,7 @@ import com.mojang.logging.LogUtils; import net.dnorberg.rivencraft.registry.ModCreativeTabs; import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModItems; +import net.dnorberg.rivencraft.registry.ModMenus; import net.dnorberg.rivencraft.registry.ModSounds; import net.dnorberg.rivencraft.registry.ModBlocks; import net.dnorberg.rivencraft.registry.ModBlockEntities; @@ -41,6 +42,7 @@ public final class Rivencraft { ModBlocks.register(modEventBus); ModBlockEntities.register(modEventBus); ModItems.register(modEventBus); + ModMenus.register(modEventBus); ModSounds.register(modEventBus); ModCreativeTabs.register(modEventBus); NeoForge.EVENT_BUS.addListener(BookStandConversionHandler::onRightClickBlock); diff --git a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java index 6865926..5f65461 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java +++ b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java @@ -12,6 +12,9 @@ import net.neoforged.fml.common.EventBusSubscriber; import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent; 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.registry.ModMenus; @EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT) public final class ClientEvents { @@ -23,6 +26,11 @@ public final class ClientEvents { event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new); } + @SubscribeEvent + public static void registerScreens(RegisterMenuScreensEvent event) { + event.register(ModMenus.NOTEBOOK.get(), NotebookScreen::new); + } + @SubscribeEvent public static void clientSetup(FMLClientSetupEvent event) { event.enqueueWork(() -> ItemProperties.register( diff --git a/src/main/java/net/dnorberg/rivencraft/client/screen/NotebookScreen.java b/src/main/java/net/dnorberg/rivencraft/client/screen/NotebookScreen.java new file mode 100644 index 0000000..4cf686c --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/client/screen/NotebookScreen.java @@ -0,0 +1,156 @@ +package net.dnorberg.rivencraft.client.screen; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.menu.NotebookMenu; +import net.dnorberg.rivencraft.network.RenameNotebookPagePayload; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.inventory.ClickType; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import net.neoforged.neoforge.network.PacketDistributor; + +public final class NotebookScreen extends AbstractContainerScreen { + private static final ResourceLocation NOTEBOOK_BACKGROUND = ResourceLocation.fromNamespaceAndPath( + Rivencraft.MOD_ID, "textures/gui/notebook.png"); + private static final ResourceLocation INVENTORY_BACKGROUND = ResourceLocation.withDefaultNamespace( + "textures/gui/container/generic_54.png"); + private EditBox nameInput; + private Button previousButton; + private Button nextButton; + private int selectedSlot = -1; + + public NotebookScreen(NotebookMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + imageWidth = 176; + imageHeight = 183; + inventoryLabelY = 89; + titleLabelY = 7; + } + + @Override + protected void init() { + super.init(); + nameInput = new EditBox(font, leftPos + 47, topPos + 7, 86, 18, + Component.translatable("screen.rivencraft.notebook.rename")); + nameInput.setMaxLength(32); + nameInput.setResponder(ignored -> updateButtons()); + addRenderableWidget(nameInput); + + previousButton = Button.builder(Component.literal("←"), ignored -> moveSelected(false)) + .bounds(leftPos + 136, topPos + 7, 16, 18) + .tooltip(Tooltip.create(Component.translatable("screen.rivencraft.notebook.move_previous"))) + .build(); + nextButton = Button.builder(Component.literal("→"), ignored -> moveSelected(true)) + .bounds(leftPos + 153, topPos + 7, 16, 18) + .tooltip(Tooltip.create(Component.translatable("screen.rivencraft.notebook.move_next"))) + .build(); + addRenderableWidget(previousButton); + addRenderableWidget(nextButton); + updateButtons(); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + super.render(graphics, mouseX, mouseY, partialTick); + if (selectedSlot >= 0) { + Slot slot = menu.slots.get(selectedSlot); + graphics.renderOutline(leftPos + slot.x - 1, topPos + slot.y - 1, 18, 18, 0xFFE0C36A); + } + renderTooltip(graphics, mouseX, mouseY); + } + + @Override + protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) { + graphics.blit(NOTEBOOK_BACKGROUND, leftPos, topPos, 0, 0, imageWidth, 88); + graphics.blit(INVENTORY_BACKGROUND, leftPos, topPos + 87, 0, 126, imageWidth, 96); + } + + @Override + protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + graphics.drawString(font, title, titleLabelX, titleLabelY, 0x3F2A18, false); + graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false); + } + + @Override + protected void slotClicked(Slot slot, int slotId, int mouseButton, ClickType type) { + if (slotId >= 0 && slotId < NotebookMenu.NOTEBOOK_SLOTS) { + selectedSlot = slotId; + } + super.slotClicked(slot, slotId, mouseButton, type); + refreshName(); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (nameInput.isFocused() && (keyCode == 257 || keyCode == 335)) { + renameSelected(); + return true; + } + if (!nameInput.isFocused() && keyCode == 263) { + moveSelected(false); + return true; + } + if (!nameInput.isFocused() && keyCode == 262) { + moveSelected(true); + return true; + } + return super.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + public void onClose() { + renameSelected(); + super.onClose(); + } + + private void refreshName() { + ItemStack selected = selectedPage(); + nameInput.setValue(selected.isEmpty() ? "" : selected.getHoverName().getString()); + updateButtons(); + } + + private void renameSelected() { + if (selectedSlot < 0 || selectedPage().isEmpty()) { + return; + } + PacketDistributor.sendToServer(new RenameNotebookPagePayload( + menu.containerId, selectedSlot, nameInput.getValue())); + } + + private void moveSelected(boolean next) { + int target = selectedSlot + (next ? 1 : -1); + if (selectedSlot < 0 || selectedPage().isEmpty() + || target < 0 || target >= NotebookMenu.NOTEBOOK_SLOTS) { + return; + } + renameSelected(); + int buttonId = (next ? NotebookMenu.MOVE_NEXT_BASE : NotebookMenu.MOVE_PREVIOUS_BASE) + selectedSlot; + if (menu.clickMenuButton(minecraft.player, buttonId)) { + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, buttonId); + selectedSlot = target; + refreshName(); + } + } + + private ItemStack selectedPage() { + return selectedSlot >= 0 && selectedSlot < NotebookMenu.NOTEBOOK_SLOTS + ? menu.getSlot(selectedSlot).getItem() + : ItemStack.EMPTY; + } + + private void updateButtons() { + if (previousButton == null || nextButton == null) { + return; + } + boolean hasPage = !selectedPage().isEmpty(); + previousButton.active = hasPage && selectedSlot > 0; + nextButton.active = hasPage && selectedSlot >= 0 && selectedSlot < NotebookMenu.NOTEBOOK_SLOTS - 1; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java index 961ebe2..ae5a3c1 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java @@ -9,14 +9,19 @@ import net.dnorberg.rivencraft.writing.PageContent; import net.dnorberg.rivencraft.writing.SymbolCatalog; import net.dnorberg.rivencraft.writing.SymbolReference; import net.dnorberg.rivencraft.writing.WritingEntry; +import net.dnorberg.rivencraft.writing.NotebookContents; import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.registry.ModDataComponents; +import net.dnorberg.rivencraft.registry.ModItems; +import net.dnorberg.rivencraft.menu.NotebookMenu; import net.minecraft.gametest.framework.GameTest; import net.minecraft.gametest.framework.GameTestHelper; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.nbt.CompoundTag; import net.minecraft.resources.ResourceLocation; import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.InteractionHand; import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; @@ -118,6 +123,59 @@ public final class WritingDataGameTests { } } + @GameTest(template = "empty") + public static void notebookContentsPreserveOrderGapsNamesAndUnknownPages(GameTestHelper helper) { + ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard")); + first.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME, + net.minecraft.network.chat.Component.literal("Foundation")); + ItemStack unknown = PageItem.createRecoveredPage(reference("example:removed_symbol")); + NotebookContents contents = new NotebookContents(List.of(first, ItemStack.EMPTY, unknown)); + ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get()); + notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), contents); + + CompoundTag serialized = (CompoundTag)notebook.saveOptional(helper.getLevel().registryAccess()); + ItemStack restored = ItemStack.parseOptional(helper.getLevel().registryAccess(), serialized); + NotebookContents restoredContents = restored.get(ModDataComponents.NOTEBOOK_CONTENTS.get()); + helper.assertTrue(restoredContents != null, "Notebook contents must survive ItemStack persistence"); + helper.assertValueEqual(restoredContents.pages().size(), 3, + "Notebook persistence must retain ordered positions"); + helper.assertTrue(restoredContents.pages().get(1).isEmpty(), + "Notebook persistence must retain empty positions between pages"); + helper.assertValueEqual(restoredContents.pages().get(0).getHoverName().getString(), "Foundation", + "Renamed contained pages must retain their names"); + helper.assertValueEqual(PageItem.content(restoredContents.pages().get(2)).kind(), + PageContent.Kind.UNRESOLVED_SYMBOL, + "Unknown symbol pages must remain present and removable"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void notebookMenuValidatesInsertionAndPersistsReorderAndRename(GameTestHelper helper) { + var player = helper.makeMockServerPlayerInLevel(); + ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get()); + player.setItemInHand(InteractionHand.MAIN_HAND, notebook); + NotebookMenu menu = new NotebookMenu(7, player.getInventory(), InteractionHand.MAIN_HAND); + ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard")); + ItemStack second = PageItem.createRecoveredPage(reference("example:removed_symbol")); + + helper.assertFalse(menu.getSlot(0).mayPlace(new ItemStack(Items.DIRT)), + "Notebook slots must reject non-Page items"); + menu.getSlot(0).set(first); + menu.getSlot(1).set(second); + menu.renamePage(0, "Terrain Base"); + helper.assertTrue(menu.clickMenuButton(player, NotebookMenu.MOVE_NEXT_BASE), + "The server menu must accept a valid adjacent reorder"); + + NotebookContents saved = notebook.get(ModDataComponents.NOTEBOOK_CONTENTS.get()); + helper.assertTrue(saved != null, "Menu changes must persist immediately to the Notebook item"); + helper.assertValueEqual(saved.pages().get(1).getHoverName().getString(), "Terrain Base", + "Reordering must preserve the renamed Page stack"); + helper.assertValueEqual(PageItem.content(saved.pages().get(0)).kind(), + PageContent.Kind.UNRESOLVED_SYMBOL, + "Reordering must preserve unresolved pages"); + helper.succeed(); + } + private static SymbolReference reference(String id) { return new SymbolReference(ResourceLocation.parse(id)); } diff --git a/src/main/java/net/dnorberg/rivencraft/item/NotebookItem.java b/src/main/java/net/dnorberg/rivencraft/item/NotebookItem.java new file mode 100644 index 0000000..6d8e6ac --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/item/NotebookItem.java @@ -0,0 +1,48 @@ +package net.dnorberg.rivencraft.item; + +import java.util.List; +import net.dnorberg.rivencraft.menu.NotebookMenu; +import net.dnorberg.rivencraft.registry.ModDataComponents; +import net.dnorberg.rivencraft.writing.NotebookContents; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; + +public final class NotebookItem extends Item { + public NotebookItem(Properties properties) { + super(properties); + } + + @Override + public InteractionResultHolder use(Level level, Player player, InteractionHand hand) { + ItemStack notebook = player.getItemInHand(hand); + if (!level.isClientSide && player instanceof ServerPlayer serverPlayer) { + serverPlayer.openMenu(new SimpleMenuProvider( + (containerId, inventory, ignored) -> new NotebookMenu(containerId, inventory, hand), + notebook.getHoverName()), + buffer -> buffer.writeBoolean(hand == InteractionHand.OFF_HAND)); + } + return InteractionResultHolder.sidedSuccess(notebook, level.isClientSide); + } + + @Override + public void appendHoverText(ItemStack stack, TooltipContext context, + List tooltip, TooltipFlag flag) { + NotebookContents contents = stack.getOrDefault( + ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY); + tooltip.add(Component.translatable("item.rivencraft.notebook.pages", + contents.pageCount(), NotebookContents.MAX_PAGES).withStyle(ChatFormatting.GRAY)); + if (contents.unresolvedCount() > 0) { + tooltip.add(Component.translatable("item.rivencraft.notebook.unresolved", + contents.unresolvedCount()).withStyle(ChatFormatting.RED)); + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/item/PageItem.java b/src/main/java/net/dnorberg/rivencraft/item/PageItem.java index 7ff7fa3..a0c990f 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/PageItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/PageItem.java @@ -55,6 +55,10 @@ public final class PageItem extends Item { @Override public Component getName(ItemStack stack) { + Component customName = stack.get(DataComponents.CUSTOM_NAME); + if (customName != null) { + return customName; + } PageContent content = content(stack); return switch (content.kind()) { case BLANK -> Component.translatable("item.rivencraft.page.blank"); diff --git a/src/main/java/net/dnorberg/rivencraft/menu/NotebookMenu.java b/src/main/java/net/dnorberg/rivencraft/menu/NotebookMenu.java new file mode 100644 index 0000000..0ebcedb --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/menu/NotebookMenu.java @@ -0,0 +1,200 @@ +package net.dnorberg.rivencraft.menu; + +import java.util.List; +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.minecraft.core.NonNullList; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.world.Container; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ClickType; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; + +public final class NotebookMenu extends AbstractContainerMenu { + public static final int NOTEBOOK_SLOTS = NotebookContents.MAX_PAGES; + public static final int MOVE_PREVIOUS_BASE = 0; + public static final int MOVE_NEXT_BASE = NOTEBOOK_SLOTS; + private static final int PLAYER_INVENTORY_START = NOTEBOOK_SLOTS; + private final Inventory playerInventory; + private final InteractionHand hand; + private final ItemStack notebook; + private final NotebookContainer contents; + + public NotebookMenu(int containerId, Inventory inventory, RegistryFriendlyByteBuf data) { + this(containerId, inventory, data.readBoolean() ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND); + } + + public NotebookMenu(int containerId, Inventory inventory, InteractionHand hand) { + super(ModMenus.NOTEBOOK.get(), containerId); + this.playerInventory = inventory; + this.hand = hand; + this.notebook = inventory.player.getItemInHand(hand); + this.contents = new NotebookContainer(notebook); + + for (int row = 0; row < 3; row++) { + for (int column = 0; column < 9; column++) { + addSlot(new PageSlot(contents, column + row * 9, 8 + column * 18, 32 + row * 18)); + } + } + for (int row = 0; row < 3; row++) { + for (int column = 0; column < 9; column++) { + int inventoryIndex = column + row * 9 + 9; + addSlot(playerSlot(inventoryIndex, 8 + column * 18, 101 + row * 18)); + } + } + for (int column = 0; column < 9; column++) { + addSlot(playerSlot(column, 8 + column * 18, 159)); + } + } + + private Slot playerSlot(int inventoryIndex, int x, int y) { + boolean locked = hand == InteractionHand.MAIN_HAND && inventoryIndex == playerInventory.selected; + return locked ? new LockedSlot(playerInventory, inventoryIndex, x, y) + : new Slot(playerInventory, inventoryIndex, x, y); + } + + @Override + public boolean stillValid(Player player) { + return player.isAlive() && player.getItemInHand(hand) == notebook && notebook.is(ModItems.NOTEBOOK.get()); + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack source = slot.getItem(); + ItemStack original = source.copy(); + if (index < PLAYER_INVENTORY_START) { + if (!moveItemStackTo(source, PLAYER_INVENTORY_START, slots.size(), true)) { + return ItemStack.EMPTY; + } + } else { + if (!source.is(ModItems.PAGE.get()) + || !moveItemStackTo(source, 0, PLAYER_INVENTORY_START, false)) { + return ItemStack.EMPTY; + } + } + if (source.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return original; + } + + @Override + public boolean clickMenuButton(Player player, int id) { + int source; + int target; + if (id >= MOVE_PREVIOUS_BASE && id < MOVE_NEXT_BASE) { + source = id; + target = source - 1; + } else if (id >= MOVE_NEXT_BASE && id < MOVE_NEXT_BASE + NOTEBOOK_SLOTS) { + source = id - MOVE_NEXT_BASE; + target = source + 1; + } else { + return false; + } + if (source < 0 || source >= NOTEBOOK_SLOTS || target < 0 || target >= NOTEBOOK_SLOTS + || contents.getItem(source).isEmpty()) { + return false; + } + contents.swap(source, target); + broadcastChanges(); + return true; + } + + public void renamePage(int slot, String name) { + if (slot < 0 || slot >= NOTEBOOK_SLOTS) { + return; + } + ItemStack page = contents.getItem(slot); + if (page.isEmpty() || !page.is(ModItems.PAGE.get())) { + return; + } + if (name.isBlank()) { + page.remove(DataComponents.CUSTOM_NAME); + } else { + page.set(DataComponents.CUSTOM_NAME, Component.literal(name)); + } + contents.setChanged(); + broadcastChanges(); + } + + @Override + public void clicked(int slotId, int button, ClickType clickType, Player player) { + super.clicked(slotId, button, clickType, player); + if (!stillValid(player)) { + player.closeContainer(); + } + } + + private static final class PageSlot extends Slot { + private PageSlot(Container container, int slot, int x, int y) { + super(container, slot, x, y); + } + + @Override public boolean mayPlace(ItemStack stack) { return stack.is(ModItems.PAGE.get()); } + @Override public int getMaxStackSize() { return 1; } + } + + private static final class LockedSlot extends Slot { + private LockedSlot(Container container, int slot, int x, int y) { + super(container, slot, x, y); + } + + @Override public boolean mayPickup(Player player) { return false; } + @Override public boolean mayPlace(ItemStack stack) { return false; } + } + + private static final class NotebookContainer extends SimpleContainer { + private final ItemStack notebook; + private boolean loading; + + private NotebookContainer(ItemStack notebook) { + super(NOTEBOOK_SLOTS); + this.notebook = notebook; + loading = true; + List saved = notebook.getOrDefault( + ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages(); + for (int index = 0; index < saved.size(); index++) { + super.setItem(index, saved.get(index).copy()); + } + loading = false; + } + + private void swap(int first, int second) { + loading = true; + ItemStack held = getItem(first); + setItem(first, getItem(second)); + setItem(second, held); + loading = false; + setChanged(); + } + + @Override + public void setChanged() { + super.setChanged(); + if (loading || notebook.isEmpty()) { + return; + } + NonNullList saved = NonNullList.withSize(NOTEBOOK_SLOTS, ItemStack.EMPTY); + for (int index = 0; index < NOTEBOOK_SLOTS; index++) { + ItemStack page = getItem(index); + saved.set(index, page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1)); + } + notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(saved)); + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java b/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java index 76aa1bf..69d9f9e 100644 --- a/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java +++ b/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java @@ -12,6 +12,7 @@ import net.dnorberg.rivencraft.client.ClientAgeCelestialAngles; import net.dnorberg.rivencraft.event.AgeCelestialAngleSync; import net.dnorberg.rivencraft.item.DescriptiveBookItem; import net.dnorberg.rivencraft.item.LinkingBookItem; +import net.dnorberg.rivencraft.menu.NotebookMenu; import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModItems; import net.minecraft.core.component.DataComponents; @@ -48,7 +49,9 @@ public final class ModNetworking { } public static void register(RegisterPayloadHandlersEvent event) { - PayloadRegistrar registrar = event.registrar("9"); + PayloadRegistrar registrar = event.registrar("10"); + registrar.playToServer(RenameNotebookPagePayload.TYPE, RenameNotebookPagePayload.STREAM_CODEC, + ModNetworking::renameNotebookPage); registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC, ModNetworking::requestDescriptiveBookScreen); registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC, @@ -70,6 +73,16 @@ public final class ModNetworking { ClientAgeCelestialAngles::handle); } + private static void renameNotebookPage(RenameNotebookPagePayload payload, IPayloadContext context) { + if (!(context.player() instanceof ServerPlayer player) + || !(player.containerMenu instanceof NotebookMenu menu) + || menu.containerId != payload.containerId() + || payload.name().length() > MAX_BOOK_TITLE_LENGTH) { + return; + } + menu.renamePage(payload.slot(), normalizeTitle(payload.name())); + } + private static void requestDescriptiveBookScreen(RequestDescriptiveBookScreenPayload payload, IPayloadContext context) { if (!(context.player() instanceof ServerPlayer player)) { return; diff --git a/src/main/java/net/dnorberg/rivencraft/network/RenameNotebookPagePayload.java b/src/main/java/net/dnorberg/rivencraft/network/RenameNotebookPagePayload.java new file mode 100644 index 0000000..09a225c --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/network/RenameNotebookPagePayload.java @@ -0,0 +1,20 @@ +package net.dnorberg.rivencraft.network; + +import io.netty.buffer.ByteBuf; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; + +public record RenameNotebookPagePayload(int containerId, int slot, String name) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "rename_notebook_page")); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.VAR_INT, RenameNotebookPagePayload::containerId, + ByteBufCodecs.VAR_INT, RenameNotebookPagePayload::slot, + ByteBufCodecs.STRING_UTF8, RenameNotebookPagePayload::name, + RenameNotebookPagePayload::new); + + @Override public Type type() { return TYPE; } +} diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java index c418f47..257b688 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java @@ -20,6 +20,7 @@ public final class ModCreativeTabs { output.accept(ModItems.DESCRIPTIVE_BOOK.get()); output.accept(ModItems.LINKING_BOOK.get()); output.accept(ModItems.PAGE.get()); + output.accept(ModItems.NOTEBOOK.get()); }) .build()); diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModDataComponents.java b/src/main/java/net/dnorberg/rivencraft/registry/ModDataComponents.java index a356657..bb022d9 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModDataComponents.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModDataComponents.java @@ -4,6 +4,7 @@ import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.book.LinkTarget; import net.dnorberg.rivencraft.book.AgeReference; import net.dnorberg.rivencraft.writing.PageContent; +import net.dnorberg.rivencraft.writing.NotebookContents; import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata; import net.minecraft.core.component.DataComponentType; import net.minecraft.core.registries.Registries; @@ -53,6 +54,12 @@ public final class ModDataComponents { .networkSynchronized(SymbolDisplayMetadata.STREAM_CODEC) .cacheEncoding() .build()); + public static final DeferredHolder, DataComponentType> NOTEBOOK_CONTENTS = + DATA_COMPONENTS.register("notebook_contents", () -> DataComponentType.builder() + .persistent(NotebookContents.CODEC) + .networkSynchronized(NotebookContents.STREAM_CODEC) + .cacheEncoding() + .build()); private ModDataComponents() { } diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java b/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java index 60c0cf4..7e04ee2 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java @@ -4,6 +4,7 @@ import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.item.DescriptiveBookItem; import net.dnorberg.rivencraft.item.LinkingBookItem; import net.dnorberg.rivencraft.item.PageItem; +import net.dnorberg.rivencraft.item.NotebookItem; import net.minecraft.world.item.Item; import net.neoforged.bus.api.IEventBus; import net.neoforged.neoforge.registries.DeferredItem; @@ -27,6 +28,11 @@ public final class ModItems { PageItem::new, new Item.Properties().stacksTo(64) ); + public static final DeferredItem NOTEBOOK = ITEMS.registerItem( + "notebook", + NotebookItem::new, + new Item.Properties().stacksTo(1) + ); private ModItems() { } diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModMenus.java b/src/main/java/net/dnorberg/rivencraft/registry/ModMenus.java new file mode 100644 index 0000000..a3a9a80 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModMenus.java @@ -0,0 +1,25 @@ +package net.dnorberg.rivencraft.registry; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.menu.NotebookMenu; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.inventory.MenuType; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.neoforge.common.extensions.IMenuTypeExtension; +import net.neoforged.neoforge.registries.DeferredHolder; +import net.neoforged.neoforge.registries.DeferredRegister; + +public final class ModMenus { + private static final DeferredRegister> MENUS = + DeferredRegister.create(Registries.MENU, Rivencraft.MOD_ID); + + public static final DeferredHolder, MenuType> NOTEBOOK = + MENUS.register("notebook", () -> IMenuTypeExtension.create(NotebookMenu::new)); + + private ModMenus() { + } + + public static void register(IEventBus modEventBus) { + MENUS.register(modEventBus); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/writing/NotebookContents.java b/src/main/java/net/dnorberg/rivencraft/writing/NotebookContents.java new file mode 100644 index 0000000..e1249bc --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/writing/NotebookContents.java @@ -0,0 +1,88 @@ +package net.dnorberg.rivencraft.writing; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import java.util.ArrayList; +import java.util.List; +import net.dnorberg.rivencraft.item.PageItem; +import net.dnorberg.rivencraft.registry.ModItems; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.world.item.ItemStack; + +/** A bounded, position-preserving collection of Page stacks stored on a Notebook item. */ +public record NotebookContents(List pages) { + public static final int MAX_PAGES = 27; + public static final NotebookContents EMPTY = new NotebookContents(List.of()); + public static final Codec CODEC = ItemStack.OPTIONAL_CODEC.listOf() + .validate(NotebookContents::validate) + .xmap(NotebookContents::new, NotebookContents::pages); + public static final StreamCodec STREAM_CODEC = StreamCodec.of( + NotebookContents::encode, NotebookContents::decode); + + public NotebookContents { + if (pages.size() > MAX_PAGES) { + throw new IllegalArgumentException("Notebook exceeds " + MAX_PAGES + " page positions"); + } + List copies = new ArrayList<>(pages.size()); + for (ItemStack page : pages) { + validatePage(page).getOrThrow(); + copies.add(page.copy()); + } + pages = List.copyOf(copies); + } + + public int pageCount() { + return (int)pages.stream().filter(page -> !page.isEmpty()).count(); + } + + public int unresolvedCount() { + return (int)pages.stream() + .filter(page -> !page.isEmpty() + && PageContent.Kind.UNRESOLVED_SYMBOL == PageItem.content(page).kind()) + .count(); + } + + private static DataResult> validate(List pages) { + if (pages.size() > MAX_PAGES) { + return DataResult.error(() -> "Notebook exceeds " + MAX_PAGES + " page positions"); + } + for (ItemStack page : pages) { + DataResult result = validatePage(page); + if (result.error().isPresent()) { + return DataResult.error(() -> result.error().orElseThrow().message()); + } + } + return DataResult.success(pages); + } + + private static DataResult validatePage(ItemStack page) { + if (page.isEmpty()) { + return DataResult.success(page); + } + if (!page.is(ModItems.PAGE.get())) { + return DataResult.error(() -> "Notebook contains a non-Page item"); + } + return page.getCount() == 1 + ? DataResult.success(page) + : DataResult.error(() -> "Notebook Page stacks must contain exactly one item"); + } + + private static void encode(RegistryFriendlyByteBuf buffer, NotebookContents contents) { + ByteBufCodecs.VAR_INT.encode(buffer, contents.pages.size()); + contents.pages.forEach(page -> ItemStack.OPTIONAL_STREAM_CODEC.encode(buffer, page)); + } + + private static NotebookContents decode(RegistryFriendlyByteBuf buffer) { + int size = ByteBufCodecs.VAR_INT.decode(buffer); + if (size < 0 || size > MAX_PAGES) { + throw new IllegalArgumentException("Invalid Notebook page count " + size); + } + List pages = new ArrayList<>(size); + for (int index = 0; index < size; index++) { + pages.add(ItemStack.OPTIONAL_STREAM_CODEC.decode(buffer)); + } + return new NotebookContents(pages); + } +} diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index 21a54a2..d337180 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -12,6 +12,12 @@ "item.rivencraft.page.link_panel": "Link Panel Page", "item.rivencraft.page.link_panel.description": "The baseline panel required to assemble a Descriptive Book.", "item.rivencraft.page.unresolved": "Unresolved Symbol Page", + "item.rivencraft.notebook": "Notebook", + "item.rivencraft.notebook.pages": "Pages: %s/%s", + "item.rivencraft.notebook.unresolved": "Unresolved pages: %s", + "screen.rivencraft.notebook.rename": "Rename selected page", + "screen.rivencraft.notebook.move_previous": "Move selected page left", + "screen.rivencraft.notebook.move_next": "Move selected page right", "message.rivencraft.linking_book.bind_hint": "Sneak-use to bind this book to your current location", "message.rivencraft.linking_book.unbound": "This Linking Book is unbound", "message.rivencraft.linking_book.bound": "Linking Book bound as %s", diff --git a/src/main/resources/assets/rivencraft/models/item/notebook.json b/src/main/resources/assets/rivencraft/models/item/notebook.json new file mode 100644 index 0000000..bbd4363 --- /dev/null +++ b/src/main/resources/assets/rivencraft/models/item/notebook.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "rivencraft:item/notebook" + } +} diff --git a/src/main/resources/assets/rivencraft/textures/gui/notebook.png b/src/main/resources/assets/rivencraft/textures/gui/notebook.png new file mode 100644 index 0000000..309c770 Binary files /dev/null and b/src/main/resources/assets/rivencraft/textures/gui/notebook.png differ diff --git a/src/main/resources/assets/rivencraft/textures/item/notebook.png b/src/main/resources/assets/rivencraft/textures/item/notebook.png new file mode 100644 index 0000000..f8b27f4 Binary files /dev/null and b/src/main/resources/assets/rivencraft/textures/item/notebook.png differ diff --git a/src/main/resources/data/rivencraft/recipe/notebook.json b/src/main/resources/data/rivencraft/recipe/notebook.json new file mode 100644 index 0000000..60194b9 --- /dev/null +++ b/src/main/resources/data/rivencraft/recipe/notebook.json @@ -0,0 +1,21 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "key": { + "L": { + "item": "minecraft:leather" + }, + "S": { + "item": "minecraft:string" + } + }, + "pattern": [ + " L ", + "S ", + " L " + ], + "result": { + "count": 1, + "id": "rivencraft:notebook" + } +}