diff --git a/BUILDING.txt b/BUILDING.txt index 2e26d73..09b8496 100644 --- a/BUILDING.txt +++ b/BUILDING.txt @@ -73,6 +73,18 @@ Or, with mise: All required GameTests should pass before distributing a build. +GameTests use `build/gametest-run/world`, separate from the normal development +world. Before each run, Gradle removes that test world if it exceeds 512 MiB. +Override the limit with `-PgameTestMaxWorldMiB=NUMBER`. A caller-supplied +`-PgameTestRunDir` is never removed automatically. + +To run the suite in a fresh temporary directory instead, use: + + test_dir=$(mktemp -d /tmp/rivencraft-gametest.XXXXXX) + ./gradlew runGameTestServer --offline -PgameTestRunDir="$test_dir" + +With mise, prefix the second command with `mise exec --`. + 6. Locate and inspect the JAR ----------------------------- diff --git a/README.md b/README.md index f9415dc..6fb3362 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,22 @@ copy selection is checked by the server. Selecting a stored page on the desk surface shows its Legacy-proportioned portrait preview and name above the player inventory. +## Book Binder + +Craft a Book Binder from three iron ingots and five planks. Put leather or an +empty Notebook in the left cover slot. Enter a title, then place a Link Panel +Page first in the horizontal page strip. Click a page in the strip with a +written Page on the cursor to insert it before that position; click it with an +empty cursor to take it out. The arrows or mouse wheel scroll through up to +27 written Pages. Clicking the strip with a filled Notebook imports its Pages +at that position and leaves the emptied Notebook on the cursor. The right slot +outputs a Descriptive Book once the title, cover, and Link Panel are present. +Inputs are consumed only when the result is taken. + +The completed book receives its Age identity when assembled. With only a Link +Panel, it describes a randomly generated Age. Its writing is stored on the +book; Milestone 5 will interpret additional symbols during Age generation. + ## Linking Book Craft a Linking Book from a vanilla book and an ender pearl, then use it in the diff --git a/build.gradle b/build.gradle index c81103c..1cab065 100644 --- a/build.gradle +++ b/build.gradle @@ -78,6 +78,9 @@ neoForge { // The gametest system is also enabled by default for other run configs under the /test command. gameTestServer { type = "gameTestServer" + // Keep generated test Ages separate from worlds used by the dev client/server. + gameDirectory = project.file(project.findProperty('gameTestRunDir') + ?: layout.buildDirectory.dir('gametest-run').get().asFile) systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id } @@ -116,6 +119,44 @@ neoForge { } } +// GameTests generate Ages, so their dedicated world can grow quickly across runs. +// Never auto-delete a caller-supplied gameTestRunDir or a normal dev world. +long gameTestWorldLimitMiB = providers.gradleProperty('gameTestMaxWorldMiB').orElse('512').get().toLong() +if (gameTestWorldLimitMiB <= 0) { + throw new GradleException('gameTestMaxWorldMiB must be a positive number') +} +boolean customGameTestRunDir = providers.gradleProperty('gameTestRunDir').isPresent() +File dedicatedGameTestDir = layout.buildDirectory.dir('gametest-run').get().asFile.canonicalFile +File dedicatedGameTestWorld = new File(dedicatedGameTestDir, 'world') +def cleanOversizedGameTestWorld = tasks.register('cleanOversizedGameTestWorld') { + doLast { + if (customGameTestRunDir) { + getLogger().lifecycle('Skipping automatic GameTest world cleanup for custom gameTestRunDir') + return + } + + def worldDir = dedicatedGameTestWorld + if (!worldDir.isDirectory() || java.nio.file.Files.isSymbolicLink(worldDir.toPath())) { + return + } + + def paths = java.nio.file.Files.walk(worldDir.toPath()).withCloseable { it.toList() } + long bytes = paths.sum(0L) { path -> + java.nio.file.Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS) + ? java.nio.file.Files.size(path) : 0L + } + long limit = gameTestWorldLimitMiB * 1024L * 1024L + if (bytes > limit) { + getLogger().lifecycle('Removing oversized GameTest world {} ({} MiB; limit {} MiB)', + worldDir, Math.ceil(bytes / 1048576d) as long, gameTestWorldLimitMiB) + paths.toSorted(java.util.Comparator.reverseOrder()).each { java.nio.file.Files.delete(it) } + } + } +} +tasks.named('runGameTestServer').configure { + dependsOn(cleanOversizedGameTestWorld) +} + // Sets up a dependency configuration called 'localRuntime'. // This configuration should be used instead of 'runtimeOnly' to declare // a dependency that will be present for runtime testing but that is diff --git a/dev images/Pasted image (2).png b/dev images/Pasted image (2).png new file mode 100644 index 0000000..9f5d330 Binary files /dev/null and b/dev images/Pasted image (2).png differ diff --git a/dev images/Pasted image (3).png b/dev images/Pasted image (3).png new file mode 100644 index 0000000..41f2ed0 Binary files /dev/null and b/dev images/Pasted image (3).png differ diff --git a/dev images/Pasted image.png b/dev images/Pasted image.png index 7beeff1..e525c44 100644 Binary files a/dev images/Pasted image.png and b/dev images/Pasted image.png differ diff --git a/docs/ASSET_PROVENANCE.md b/docs/ASSET_PROVENANCE.md index 9d7df04..df1f1d0 100644 --- a/docs/ASSET_PROVENANCE.md +++ b/docs/ASSET_PROVENANCE.md @@ -10,9 +10,11 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license. | `assets/rivencraft/textures/gui/notebook_pages.png` | `assets/mystcraft/gui/bookui_pagel.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Notebook draws the native 30×40 page at atlas coordinates `(156, 0)` | | `assets/rivencraft/textures/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; full desk panel, tabs, and cycling controls at original atlas coordinates | | `assets/rivencraft/textures/gui/scrollbar.png` | `assets/mystcraft/gui/scrollbar.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; tiled track and normal/pressed thumb | +| `assets/rivencraft/textures/block/book_binder.png` | `assets/mystcraft/textures/entity/bookbinder/bookbinder.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder model uses this body texture | +| `assets/rivencraft/textures/block/book_binder_press.png` | `assets/mystcraft/textures/entity/bookbinder/press_part.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder model uses this press texture | +| `assets/rivencraft/textures/gui/book_binder_legacy.png` | `assets/mystcraft/gui/pagebinder.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder GUI uses the original 176×181 panel | | `assets/rivencraft/textures/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 | diff --git a/gradle.properties b/gradle.properties index 25522fb..c3b3bf7 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.6.9 +mod_version=0.3.7.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. diff --git a/src/main/java/net/dnorberg/rivencraft/block/BookBinderBlock.java b/src/main/java/net/dnorberg/rivencraft/block/BookBinderBlock.java new file mode 100644 index 0000000..be3837a --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/block/BookBinderBlock.java @@ -0,0 +1,66 @@ +package net.dnorberg.rivencraft.block; + +import com.mojang.serialization.MapCodec; +import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity; +import net.dnorberg.rivencraft.menu.BookBinderMenu; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Containers; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.DirectionProperty; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.phys.BlockHitResult; + +public final class BookBinderBlock extends BaseEntityBlock { + public static final MapCodec CODEC = simpleCodec(BookBinderBlock::new); + public static final DirectionProperty FACING = BlockStateProperties.HORIZONTAL_FACING; + + public BookBinderBlock(Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH)); + } + + @Override public MapCodec codec() { return CODEC; } + @Override protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(FACING); + } + @Override public BlockState getStateForPlacement(BlockPlaceContext context) { + Direction facing = context.getHorizontalDirection().getOpposite(); + return defaultBlockState().setValue(FACING, facing); + } + @Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; } + @Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new BookBinderBlockEntity(pos, state); + } + @Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, + Player player, BlockHitResult hit) { + if (!level.isClientSide && player instanceof ServerPlayer serverPlayer + && level.getBlockEntity(pos) instanceof BookBinderBlockEntity binder) { + serverPlayer.openMenu(new SimpleMenuProvider( + (id, inventory, ignored) -> new BookBinderMenu(id, inventory, binder), + Component.translatable("container.rivencraft.book_binder")), + buffer -> buffer.writeBlockPos(pos)); + } + return InteractionResult.sidedSuccess(level.isClientSide); + } + @Override protected void onRemove(BlockState state, Level level, BlockPos pos, + BlockState replacement, boolean moving) { + if (!state.is(replacement.getBlock()) && !level.isClientSide + && level.getBlockEntity(pos) instanceof BookBinderBlockEntity binder) { + Containers.dropContents(level, pos, binder); + level.updateNeighbourForOutputSignal(pos, this); + } + super.onRemove(state, level, pos, replacement, moving); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/block/entity/BookBinderBlockEntity.java b/src/main/java/net/dnorberg/rivencraft/block/entity/BookBinderBlockEntity.java new file mode 100644 index 0000000..d0a5872 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/block/entity/BookBinderBlockEntity.java @@ -0,0 +1,258 @@ +package net.dnorberg.rivencraft.block.entity; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.dnorberg.rivencraft.book.AgeReference; +import net.dnorberg.rivencraft.book.AgeRegistrySavedData; +import net.dnorberg.rivencraft.item.PageItem; +import net.dnorberg.rivencraft.registry.ModBlockEntities; +import net.dnorberg.rivencraft.registry.ModDataComponents; +import net.dnorberg.rivencraft.registry.ModItems; +import net.dnorberg.rivencraft.writing.NotebookContents; +import net.dnorberg.rivencraft.writing.OrderedWriting; +import net.dnorberg.rivencraft.writing.PageContent; +import net.dnorberg.rivencraft.writing.WritingEntry; +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.NonNullList; +import net.minecraft.core.component.DataComponents; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.world.Container; +import net.minecraft.world.ContainerHelper; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.server.level.ServerLevel; + +/** Persistent inputs for a binder; the assembled output is never stored here. */ +public final class BookBinderBlockEntity extends BlockEntity implements Container { + public static final int COVER = 0; + public static final int LINK_PANEL = 1; + public static final int NOTEBOOK = 2; + public static final int FIRST_PAGE = 3; + public static final int PAGE_COUNT = NotebookContents.MAX_PAGES; + public static final int SIZE = FIRST_PAGE + PAGE_COUNT; + public static final int MAX_TITLE_LENGTH = 32; + + private NonNullList items = NonNullList.withSize(SIZE, ItemStack.EMPTY); + private String title = ""; + private UUID pendingAgeId; + + public BookBinderBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.BOOK_BINDER.get(), pos, state); + } + + public String title() { return title; } + + public void setTitle(String value) { + String normalized = value == null ? "" : value.strip(); + if (normalized.length() > MAX_TITLE_LENGTH) normalized = normalized.substring(0, MAX_TITLE_LENGTH); + if (!normalized.equals(title)) { + title = normalized; + setChanged(); + } + } + + public static boolean validCover(ItemStack stack) { + return stack.is(Items.LEATHER) || stack.is(ModItems.NOTEBOOK.get()) + && stack.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pageCount() == 0; + } + + private static boolean validPage(ItemStack stack) { + return stack.is(ModItems.PAGE.get()) && PageItem.content(stack).kind() != PageContent.Kind.LINK_PANEL; + } + + @Override public int getContainerSize() { return SIZE; } + @Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); } + @Override public ItemStack getItem(int slot) { return items.get(slot); } + @Override public ItemStack removeItem(int slot, int amount) { + ItemStack removed = ContainerHelper.removeItem(items, slot, amount); + if (!removed.isEmpty()) setChanged(); + return removed; + } + @Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); } + @Override public void setItem(int slot, ItemStack stack) { + items.set(slot, stack); + if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize()); + setChanged(); + } + @Override public boolean canPlaceItem(int slot, ItemStack stack) { + return switch (slot) { + case COVER -> validCover(stack); + case LINK_PANEL -> stack.is(ModItems.PAGE.get()) + && PageItem.content(stack).kind() == PageContent.Kind.LINK_PANEL; + case NOTEBOOK -> stack.is(ModItems.NOTEBOOK.get()); + default -> slot >= FIRST_PAGE && slot < SIZE && validPage(stack); + }; + } + @Override public boolean stillValid(Player player) { + return level != null && level.isLoaded(worldPosition) && level.getBlockEntity(worldPosition) == this + && player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5, + worldPosition.getZ() + 0.5) <= 64.0; + } + + /** All checks precede any mutation so a failed assembly leaves every input intact. */ + public boolean canAssemble() { + if (title.isBlank() || !validCover(items.get(COVER)) + || !canPlaceItem(LINK_PANEL, items.get(LINK_PANEL)) + || items.get(LINK_PANEL).getCount() != 1) return false; + for (int slot = FIRST_PAGE; slot < SIZE; slot++) { + ItemStack page = items.get(slot); + if (!page.isEmpty() && (!validPage(page) || page.getCount() != 1 + || PageItem.content(page).kind() == PageContent.Kind.BLANK)) return false; + } + return true; + } + + public ItemStack previewBook() { + if (!canAssemble()) return ItemStack.EMPTY; + if (pendingAgeId == null) { + pendingAgeId = UUID.randomUUID(); + setChanged(); + } + List entries = new ArrayList<>(); + entries.add(WritingEntry.linkPanel()); + for (int slot = FIRST_PAGE; slot < SIZE; slot++) { + ItemStack page = items.get(slot); + if (!page.isEmpty()) entries.add(WritingEntry.symbol(PageItem.content(page).symbol().orElseThrow())); + } + ItemStack book = new ItemStack(ModItems.DESCRIPTIVE_BOOK.get()); + book.set(ModDataComponents.AGE_REFERENCE.get(), new AgeReference(pendingAgeId, title)); + book.set(ModDataComponents.BOOK_WRITING.get(), new OrderedWriting(entries)); + book.set(DataComponents.CUSTOM_NAME, Component.literal(title)); + return book; + } + + /** Called once, on the server, after the output can be delivered. */ + public boolean consumeAssembly() { + if (!canAssemble()) return false; + if (pendingAgeId == null) pendingAgeId = UUID.randomUUID(); + if (level instanceof ServerLevel serverLevel) + AgeRegistrySavedData.resolveOrCreate(serverLevel, new AgeReference(pendingAgeId, title)); + items.get(COVER).shrink(1); + if (items.get(COVER).isEmpty()) items.set(COVER, ItemStack.EMPTY); + items.get(LINK_PANEL).shrink(1); + if (items.get(LINK_PANEL).isEmpty()) items.set(LINK_PANEL, ItemStack.EMPTY); + for (int slot = FIRST_PAGE; slot < SIZE; slot++) items.set(slot, ItemStack.EMPTY); + title = ""; + pendingAgeId = null; + setChanged(); + return true; + } + + public int nextPageIndex() { + for (int index = PAGE_COUNT - 1; index >= 0; index--) { + if (!items.get(FIRST_PAGE + index).isEmpty()) return index + 1; + } + return 0; + } + + /** Insert before a displayed page, shifting later pages as Legacy's strip does. */ + public boolean insertPage(int index, ItemStack page) { + int end = nextPageIndex(); + if (index < 0 || index > end || end >= PAGE_COUNT || page.getCount() != 1 + || !validPage(page)) return false; + for (int current = end; current > index; current--) + items.set(FIRST_PAGE + current, items.get(FIRST_PAGE + current - 1)); + items.set(FIRST_PAGE + index, page.copy()); + setChanged(); + return true; + } + + /** Take a page back to the cursor, closing the gap in the displayed order. */ + public ItemStack takePage(int index) { + int end = nextPageIndex(); + if (index < 0 || index >= end || items.get(FIRST_PAGE + index).isEmpty()) return ItemStack.EMPTY; + ItemStack removed = items.get(FIRST_PAGE + index); + for (int current = index; current < end - 1; current++) + items.set(FIRST_PAGE + current, items.get(FIRST_PAGE + current + 1)); + items.set(FIRST_PAGE + end - 1, ItemStack.EMPTY); + setChanged(); + return removed; + } + + /** Move every Notebook page as one transaction, leaving its empty cover in the input slot. */ + public boolean importNotebook() { + ItemStack notebook = items.get(NOTEBOOK); + ItemStack emptied = importNotebookFromStack(notebook, nextPageIndex()); + if (emptied.isEmpty()) return false; + items.set(NOTEBOOK, emptied); + setChanged(); + return true; + } + + /** Import a Notebook from either its stored slot or the player's cursor. */ + public ItemStack importNotebookFromStack(ItemStack notebook, int index) { + if (!notebook.is(ModItems.NOTEBOOK.get()) || notebook.getCount() != 1) return ItemStack.EMPTY; + List pages = notebook.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages(); + if (pages.isEmpty()) return ItemStack.EMPTY; + int end = nextPageIndex(); + if (index < 0 || index > end) return ItemStack.EMPTY; + boolean link = false; + List writing = new ArrayList<>(); + for (ItemStack page : pages) { + if (page.isEmpty()) continue; + if (!page.is(ModItems.PAGE.get()) || page.getCount() != 1) return ItemStack.EMPTY; + if (PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL) { + if (link || !items.get(LINK_PANEL).isEmpty()) return ItemStack.EMPTY; + link = true; + } else if (validPage(page)) writing.add(page); + else return ItemStack.EMPTY; + } + if (end + writing.size() > PAGE_COUNT || writing.isEmpty() && !link) return ItemStack.EMPTY; + if (link) { + for (ItemStack page : pages) { + if (!page.isEmpty() && PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL) { + items.set(LINK_PANEL, page.copy()); + break; + } + } + } + for (int current = end - 1; current >= index; current--) + items.set(FIRST_PAGE + current + writing.size(), items.get(FIRST_PAGE + current)); + for (ItemStack page : writing) items.set(FIRST_PAGE + index++, page.copy()); + ItemStack emptyNotebook = notebook.copy(); + emptyNotebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY); + setChanged(); + return emptyNotebook; + } + + @Override public void clearContent() { + items = NonNullList.withSize(SIZE, ItemStack.EMPTY); + title = ""; + pendingAgeId = null; + setChanged(); + } + @Override protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) { + super.loadAdditional(tag, registries); + items = NonNullList.withSize(SIZE, ItemStack.EMPTY); + ContainerHelper.loadAllItems(tag, items, registries); + title = tag.getString("Title"); + pendingAgeId = tag.hasUUID("PendingAgeId") ? tag.getUUID("PendingAgeId") : null; + } + @Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { + super.saveAdditional(tag, registries); + ContainerHelper.saveAllItems(tag, items, registries); + tag.putString("Title", title); + if (pendingAgeId != null) tag.putUUID("PendingAgeId", pendingAgeId); + } + @Override public Packet 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); + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java index 2d580d5..57bff58 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java +++ b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java @@ -15,6 +15,7 @@ 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.client.screen.BookBinderScreen; import net.dnorberg.rivencraft.registry.ModMenus; @EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT) @@ -32,6 +33,7 @@ public final class ClientEvents { public static void registerScreens(RegisterMenuScreensEvent event) { event.register(ModMenus.NOTEBOOK.get(), NotebookScreen::new); event.register(ModMenus.WRITING_DESK.get(), WritingDeskScreen::new); + event.register(ModMenus.BOOK_BINDER.get(), BookBinderScreen::new); } @SubscribeEvent diff --git a/src/main/java/net/dnorberg/rivencraft/client/screen/BookBinderScreen.java b/src/main/java/net/dnorberg/rivencraft/client/screen/BookBinderScreen.java new file mode 100644 index 0000000..0aa69cb --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/client/screen/BookBinderScreen.java @@ -0,0 +1,215 @@ +package net.dnorberg.rivencraft.client.screen; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity; +import net.dnorberg.rivencraft.item.PageItem; +import net.dnorberg.rivencraft.menu.BookBinderMenu; +import net.dnorberg.rivencraft.network.SetBookBinderTitlePayload; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; +import net.neoforged.neoforge.network.PacketDistributor; + +/** Legacy-sized binder: cover and result flank a scrolling strip of full page thumbnails. */ +public final class BookBinderScreen extends AbstractContainerScreen { + private static final ResourceLocation BACKGROUND = ResourceLocation.fromNamespaceAndPath( + Rivencraft.MOD_ID, "textures/gui/book_binder_legacy.png"); + private static final ResourceLocation PAGE_ATLAS = ResourceLocation.fromNamespaceAndPath( + Rivencraft.MOD_ID, "textures/gui/notebook_pages.png"); + private static final int STRIP_X = 7; + private static final int STRIP_Y = 45; + private static final int STRIP_WIDTH = 162; + private static final int STRIP_HEIGHT = 40; + private static final int PAGE_WIDTH = 26; + private static final int PAGE_HEIGHT = 34; + private static final int PAGE_STEP = 28; + private static final int VISIBLE_PAGES = 5; + + private EditBox titleInput; + private int firstVisible; + private boolean stripClickPendingRelease; + + public BookBinderScreen(BookBinderMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + imageWidth = 176; + imageHeight = 181; + inventoryLabelX = 8; + inventoryLabelY = 87; + } + + @Override protected void init() { + super.init(); + titleInput = new EditBox(font, leftPos + 9, topPos + 10, 112, 12, + Component.translatable("screen.rivencraft.book_binder.title")); + titleInput.setBordered(false); + titleInput.setTextColor(0xFFFFFFFF); + titleInput.setMaxLength(BookBinderBlockEntity.MAX_TITLE_LENGTH); + titleInput.setValue(menu.title()); + titleInput.setHint(Component.translatable("screen.rivencraft.book_binder.title_hint")); + titleInput.setResponder(value -> PacketDistributor.sendToServer( + new SetBookBinderTitlePayload(menu.containerId, value))); + addRenderableWidget(titleInput); + } + + @Override public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + renderBg(graphics, partialTick, mouseX, mouseY); + } + + @Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) { + graphics.blit(BACKGROUND, leftPos, topPos, 0, 0, imageWidth, imageHeight, 256, 256); + if (menu.getSlot(BookBinderBlockEntity.LINK_PANEL).getItem().isEmpty()) { + float fade = (System.currentTimeMillis() % 4000L) / 2000F; + if (fade > 1F) fade = 2F - fade; + graphics.blit(BACKGROUND, leftPos + 27, topPos + 26, + 18, 18, 176, 0, 30, 40, 256, 256); + graphics.setColor(1F, 0.3F, 0.3F, 0.8F * fade); + graphics.blit(BACKGROUND, leftPos + 27, topPos + 26, + 18, 18, 176, 0, 30, 40, 256, 256); + graphics.setColor(1F, 1F, 1F, 1F); + } + graphics.fill(leftPos + 7, topPos + 9, leftPos + 123, topPos + 23, 0xFF000000); + graphics.renderOutline(leftPos + 7, topPos + 9, 116, 14, + titleInput == null || titleInput.getValue().isBlank() ? 0xFFFF3030 : 0xFFA0A0A0); + } + + @Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false); + } + + @Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + super.render(graphics, mouseX, mouseY, partialTick); + firstVisible = Math.min(firstVisible, maxFirstVisible()); + graphics.enableScissor(leftPos + STRIP_X + 8, topPos + STRIP_Y, + leftPos + STRIP_X + STRIP_WIDTH - 8, topPos + STRIP_Y + STRIP_HEIGHT); + int count = visiblePageCount(); + for (int visible = 0; visible < VISIBLE_PAGES; visible++) { + int index = firstVisible + visible; + if (index >= count) break; + drawPage(graphics, pageAtIndex(index), index, + leftPos + 16 + visible * PAGE_STEP, topPos + STRIP_Y + 3); + } + graphics.disableScissor(); + drawScrollEnds(graphics); + if (pageAtIndex(0).isEmpty() && mouseX >= leftPos + 27 && mouseX < leftPos + 45 + && mouseY >= topPos + 26 && mouseY < topPos + 44) { + graphics.renderTooltip(font, + Component.translatable("screen.rivencraft.book_binder.missing_panel"), mouseX, mouseY); + return; + } + int hovered = pageIndexAt(mouseX, mouseY); + if (hovered >= 0 && hovered < displayedPages() && !pageAtIndex(hovered).isEmpty()) + graphics.renderTooltip(font, pageAtIndex(hovered), mouseX, mouseY); + else renderTooltip(graphics, mouseX, mouseY); + } + + private ItemStack pageAtIndex(int index) { + return menu.getSlot(index == 0 ? BookBinderBlockEntity.LINK_PANEL + : BookBinderBlockEntity.FIRST_PAGE + index - 1).getItem(); + } + + private int displayedPages() { + for (int index = BookBinderBlockEntity.PAGE_COUNT; index >= 1; index--) + if (!pageAtIndex(index).isEmpty()) return index + 1; + return pageAtIndex(0).isEmpty() ? 0 : 1; + } + + private int maxFirstVisible() { + return Math.max(0, visiblePageCount() - VISIBLE_PAGES); + } + + private int visiblePageCount() { + return Math.min(BookBinderBlockEntity.PAGE_COUNT + 1, + displayedPages() + (menu.getCarried().isEmpty() ? 0 : 1)); + } + + private void drawPage(GuiGraphics graphics, ItemStack page, int index, int x, int y) { + graphics.blit(PAGE_ATLAS, x, y, PAGE_WIDTH, PAGE_HEIGHT, 156, 0, 30, 40, 256, 256); + if (page.isEmpty()) { + graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0xAA000000); + return; + } + switch (PageItem.content(page).kind()) { + case BLANK -> { } + case LINK_PANEL -> graphics.fill(x + 5, y + 6, x + 22, y + 17, 0xFF000000); + case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(NotebookScreen.symbolTexture(page), + x + 1, y + 5, 24, 24, 16, 16, 96, 96, 128, 128); + } + } + + private void drawScrollEnds(GuiGraphics graphics) { + int x = leftPos + STRIP_X; + int y = topPos + STRIP_Y; + graphics.fill(x, y, x + 8, y + STRIP_HEIGHT, + firstVisible == 0 ? 0x33000000 : 0xAA000000); + graphics.fill(x + STRIP_WIDTH - 8, y, x + STRIP_WIDTH, y + STRIP_HEIGHT, + firstVisible >= maxFirstVisible() ? 0x33000000 : 0xAA000000); + } + + private int pageIndexAt(double mouseX, double mouseY) { + int localX = (int) mouseX - leftPos; + int localY = (int) mouseY - topPos; + if (localX < STRIP_X + 8 || localX >= STRIP_X + STRIP_WIDTH - 8 + || localY < STRIP_Y || localY >= STRIP_Y + STRIP_HEIGHT) return -1; + int visible = (localX - 16) / PAGE_STEP; + if (visible < 0 || visible >= VISIBLE_PAGES) return -1; + return Math.min(firstVisible + visible, BookBinderBlockEntity.PAGE_COUNT); + } + + @Override public boolean mouseClicked(double mouseX, double mouseY, int button) { + int localX = (int) mouseX - leftPos; + int localY = (int) mouseY - topPos; + if ((button == 0 || button == 1) && localY >= STRIP_Y && localY < STRIP_Y + STRIP_HEIGHT + && localX >= STRIP_X && localX < STRIP_X + STRIP_WIDTH) { + stripClickPendingRelease = true; + if (localX < STRIP_X + 8) firstVisible = Math.max(0, firstVisible - 1); + else if (localX >= STRIP_X + STRIP_WIDTH - 8) + firstVisible = Math.min(maxFirstVisible(), firstVisible + 1); + else { + int index = pageIndexAt(mouseX, mouseY); + if (index >= 0 && index <= displayedPages() + && (index < displayedPages() || !menu.getCarried().isEmpty())) + clickStrip(index); + } + return true; + } + return super.mouseClicked(mouseX, mouseY, button); + } + + private void clickStrip(int index) { + if (minecraft != null && minecraft.gameMode != null) + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, + BookBinderMenu.STRIP_CLICK_BASE + index); + } + + @Override public boolean mouseReleased(double mouseX, double mouseY, int button) { + if (stripClickPendingRelease) { + stripClickPendingRelease = false; + return true; + } + return super.mouseReleased(mouseX, mouseY, button); + } + + @Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + if (mouseX >= leftPos + STRIP_X && mouseX < leftPos + STRIP_X + STRIP_WIDTH + && mouseY >= topPos + STRIP_Y && mouseY < topPos + STRIP_Y + STRIP_HEIGHT) { + firstVisible = Math.clamp(firstVisible - (int) Math.signum(scrollY), 0, maxFirstVisible()); + return true; + } + return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); + } + + @Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (titleInput != null && titleInput.isFocused()) { + if (keyCode == 256) { + setFocused(null); + titleInput.setFocused(false); + } else titleInput.keyPressed(keyCode, scanCode, modifiers); + return true; + } + return super.keyPressed(keyCode, scanCode, modifiers); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/BookBinderGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/BookBinderGameTests.java new file mode 100644 index 0000000..36e6fc6 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/gametest/BookBinderGameTests.java @@ -0,0 +1,284 @@ +package net.dnorberg.rivencraft.gametest; + +import java.util.List; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity; +import net.dnorberg.rivencraft.book.AgeReference; +import net.dnorberg.rivencraft.book.AgeDimensionService; +import net.dnorberg.rivencraft.book.AgeRegistrySavedData; +import net.dnorberg.rivencraft.item.PageItem; +import net.dnorberg.rivencraft.menu.BookBinderMenu; +import net.dnorberg.rivencraft.registry.ModBlocks; +import net.dnorberg.rivencraft.registry.ModDataComponents; +import net.dnorberg.rivencraft.registry.ModItems; +import net.dnorberg.rivencraft.writing.NotebookContents; +import net.dnorberg.rivencraft.writing.OrderedWriting; +import net.dnorberg.rivencraft.writing.SymbolReference; +import net.dnorberg.rivencraft.writing.WritingEntry; +import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.inventory.ClickType; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.phys.AABB; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +@GameTestHolder(Rivencraft.MOD_ID) +@PrefixGameTestTemplate(false) +public final class BookBinderGameTests { + private BookBinderGameTests() {} + + private static ItemStack terrainPage() { + return PageItem.createSymbolPage(new SymbolReference( + ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "terrain/standard"))); + } + + private static ItemStack weatherPage() { + return PageItem.createSymbolPage(new SymbolReference( + ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "weather/normal"))); + } + + private static BookBinderBlockEntity place(GameTestHelper helper) { + BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1)); + helper.getLevel().setBlock(pos, ModBlocks.BOOK_BINDER.get().defaultBlockState(), 3); + return (BookBinderBlockEntity) helper.getLevel().getBlockEntity(pos); + } + + @GameTest(template = "empty") + public static void binderInputsAndTitleSurviveSaveLoad(GameTestHelper helper) { + BookBinderBlockEntity binder = place(helper); + binder.setTitle("Ordered Age"); + binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER)); + binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage()); + binder.setItem(BookBinderBlockEntity.FIRST_PAGE, terrainPage()); + var saved = binder.saveWithoutMetadata(helper.getLevel().registryAccess()); + var restored = new BookBinderBlockEntity(binder.getBlockPos(), binder.getBlockState()); + restored.loadWithComponents(saved, helper.getLevel().registryAccess()); + helper.assertValueEqual(restored.title(), "Ordered Age", "Title must persist"); + helper.assertTrue(restored.getItem(BookBinderBlockEntity.COVER).is(Items.LEATHER), "Cover must persist"); + helper.assertTrue(restored.getItem(BookBinderBlockEntity.LINK_PANEL).is(ModItems.PAGE.get()), + "Link Panel must persist"); + helper.assertTrue(ItemStack.matches(restored.getItem(BookBinderBlockEntity.FIRST_PAGE), terrainPage()), + "Ordered page must persist"); + helper.assertTrue(restored.canAssemble(), "Restored binder must remain ready to assemble"); + AgeReference beforeSave = binder.previewBook().get(ModDataComponents.AGE_REFERENCE.get()); + var previewSaved = binder.saveWithoutMetadata(helper.getLevel().registryAccess()); + restored.loadWithComponents(previewSaved, helper.getLevel().registryAccess()); + helper.assertValueEqual(restored.previewBook().get(ModDataComponents.AGE_REFERENCE.get()), beforeSave, + "Pending Age identity must survive a world restart"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void assemblyIsAtomicAndRetainsPageOrder(GameTestHelper helper) { + BookBinderBlockEntity binder = place(helper); + binder.setTitle("A New Age"); + binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER)); + binder.setItem(BookBinderBlockEntity.FIRST_PAGE, terrainPage()); + binder.setItem(BookBinderBlockEntity.FIRST_PAGE + 1, weatherPage()); + helper.assertFalse(binder.canAssemble(), "Missing Link Panel must prevent assembly"); + helper.assertFalse(binder.consumeAssembly(), "Failure must consume nothing"); + helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).is(Items.LEATHER), + "Rejected assembly must preserve cover"); + binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage()); + var player = helper.makeMockServerPlayerInLevel(); + player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5, + binder.getBlockPos().getZ() + 0.5); + BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder); + menu.broadcastChanges(); + helper.assertTrue(menu.getSlot(BookBinderMenu.RESULT).hasItem(), "Valid binder must show output"); + menu.clicked(BookBinderMenu.RESULT, 0, ClickType.PICKUP, player); + ItemStack book = menu.getCarried(); + helper.assertTrue(book.is(ModItems.DESCRIPTIVE_BOOK.get()), "Result must be a Descriptive Book"); + AgeReference age = book.get(ModDataComponents.AGE_REFERENCE.get()); + helper.assertTrue(age != null, "Assembled book must already be a written Age book"); + OrderedWriting writing = book.get(ModDataComponents.BOOK_WRITING.get()); + helper.assertTrue(writing != null, "Assembled book must keep its writing"); + helper.assertValueEqual(writing.entries().size(), 3, "Link Panel and two pages must be present"); + helper.assertValueEqual(writing.entries().get(0).kind(), WritingEntry.Kind.LINK_PANEL, + "Link Panel must be first"); + helper.assertValueEqual(writing.entries().get(1).symbol().orElseThrow().id().toString(), + "rivencraft:terrain/standard", "Terrain page order must be preserved"); + helper.assertValueEqual(writing.entries().get(2).symbol().orElseThrow().id().toString(), + "rivencraft:weather/normal", "Weather page order must be preserved"); + helper.assertTrue(AgeRegistrySavedData.get(helper.getLevel()).find(age.id()).isPresent(), + "Taking the completed book must register its random Age definition"); + helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).isEmpty(), "Cover must be consumed once"); + helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).isEmpty(), "Link Panel must be consumed once"); + helper.assertTrue(binder.getItem(BookBinderBlockEntity.FIRST_PAGE).isEmpty(), "Pages must be consumed once"); + menu.broadcastChanges(); + helper.assertTrue(menu.getSlot(BookBinderMenu.RESULT).getItem().isEmpty(), "No duplicate output may remain"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void linkPanelOnlyCreatesCompletedRandomAgeBook(GameTestHelper helper) { + BookBinderBlockEntity binder = place(helper); + binder.setTitle("Random Binder Age"); + binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER)); + binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage()); + var player = helper.makeMockServerPlayerInLevel(); + player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5, + binder.getBlockPos().getZ() + 0.5); + BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder); + menu.clicked(BookBinderMenu.RESULT, 0, ClickType.PICKUP, player); + ItemStack book = menu.getCarried(); + AgeReference age = book.get(ModDataComponents.AGE_REFERENCE.get()); + helper.assertTrue(age != null, "Link-Panel-only output must be written, not unwritten"); + helper.assertValueEqual(age.name(), "Random Binder Age", "The assigned title must name the Age"); + helper.assertValueEqual(book.get(ModDataComponents.BOOK_WRITING.get()).entries().size(), 1, + "A random Age book must contain only its Link Panel when no symbols are supplied"); + helper.assertTrue(AgeRegistrySavedData.get(helper.getLevel()).find(age.id()).isPresent(), + "Taking the book must register its random Age definition"); + helper.assertTrue(AgeDimensionService.resolve(player, age).isPresent(), + "The completed book must resolve to a usable random Age"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void notebookImportPreservesOrderAndRejectsOverflow(GameTestHelper helper) { + BookBinderBlockEntity binder = place(helper); + ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get()); + notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of( + PageItem.createLinkPanelPage(), terrainPage(), weatherPage()))); + binder.setItem(BookBinderBlockEntity.NOTEBOOK, notebook); + helper.assertTrue(binder.importNotebook(), "Notebook pages must import together"); + helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).is(ModItems.PAGE.get()), + "Imported Link Panel must use its dedicated slot"); + helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE), terrainPage()), + "First Notebook symbol must stay first"); + helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 1), weatherPage()), + "Second Notebook symbol must stay second"); + helper.assertValueEqual(binder.getItem(BookBinderBlockEntity.NOTEBOOK) + .getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pageCount(), 0, + "Empty Notebook cover must remain recoverable"); + for (int slot = BookBinderBlockEntity.FIRST_PAGE + 2; slot < BookBinderBlockEntity.SIZE; slot++) + binder.setItem(slot, terrainPage()); + ItemStack overflow = new ItemStack(ModItems.NOTEBOOK.get()); + overflow.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(weatherPage()))); + binder.setItem(BookBinderBlockEntity.NOTEBOOK, overflow); + helper.assertFalse(binder.importNotebook(), "Full binder must reject Notebook import"); + helper.assertValueEqual(binder.getItem(BookBinderBlockEntity.NOTEBOOK) + .getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pageCount(), 1, + "Rejected import must preserve the Notebook page"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void pageControlsAndBreakingPreserveStoredInputs(GameTestHelper helper) { + BookBinderBlockEntity binder = place(helper); + binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER)); + binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage()); + binder.setItem(BookBinderBlockEntity.FIRST_PAGE, terrainPage()); + var player = helper.makeMockServerPlayerInLevel(); + player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5, + binder.getBlockPos().getZ() + 0.5); + BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder); + helper.assertFalse(menu.clickMenuButton(player, BookBinderMenu.MOVE_PREVIOUS), + "Move without selection must be rejected"); + helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.SELECT_BASE), "Page selection must succeed"); + helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.MOVE_NEXT), "Page move must succeed"); + helper.assertTrue(binder.getItem(BookBinderBlockEntity.FIRST_PAGE).isEmpty(), "Old position must be empty"); + helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 1), terrainPage()), + "Moved page must keep its data"); + helper.assertFalse(menu.clickMenuButton(player, 999), "Unknown action must be rejected"); + BlockPos pos = binder.getBlockPos(); + helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3); + helper.assertFalse(menu.stillValid(player), "Removed binder must invalidate the menu"); + var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(pos).inflate(2)); + int leather = drops.stream().filter(entity -> entity.getItem().is(Items.LEATHER)) + .mapToInt(entity -> entity.getItem().getCount()).sum(); + int pages = drops.stream().filter(entity -> entity.getItem().is(ModItems.PAGE.get())) + .mapToInt(entity -> entity.getItem().getCount()).sum(); + helper.assertValueEqual(leather, 1, "Cover must drop once"); + helper.assertValueEqual(pages, 2, "Link Panel and writing page must drop once each"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void fullInventoryCannotConsumeBinderInputs(GameTestHelper helper) { + BookBinderBlockEntity binder = place(helper); + binder.setTitle("Full Inventory"); + binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER)); + binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage()); + var player = helper.makeMockServerPlayerInLevel(); + player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5, + binder.getBlockPos().getZ() + 0.5); + for (int slot = 0; slot < 36; slot++) + player.getInventory().setItem(slot, new ItemStack(Items.COBBLESTONE, 64)); + BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder); + helper.assertTrue(menu.quickMoveStack(player, BookBinderMenu.RESULT).isEmpty(), + "Full inventory must reject output transfer"); + helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).is(Items.LEATHER), + "Rejected transfer must preserve cover"); + helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).is(ModItems.PAGE.get()), + "Rejected transfer must preserve Link Panel"); + helper.assertTrue(menu.getSlot(BookBinderMenu.RESULT).hasItem(), + "Result must remain available after inventory space is freed"); + helper.assertFalse(menu.setTitle(player, "A title that is much too long for the binder slot"), + "Oversized title packet must be rejected"); + helper.assertValueEqual(binder.title(), "Full Inventory", "Rejected title must preserve existing title"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void legacyStripInsertsAndRemovesPagesInOrder(GameTestHelper helper) { + BookBinderBlockEntity binder = place(helper); + var player = helper.makeMockServerPlayerInLevel(); + player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5, + binder.getBlockPos().getZ() + 0.5); + BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder); + menu.setCarried(PageItem.createLinkPanelPage()); + helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE), + "The first thumbnail must accept a Link Panel"); + helper.assertTrue(menu.getCarried().isEmpty(), "Inserted panel must leave the cursor"); + menu.setCarried(terrainPage()); + helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE + 1), + "The strip must accept a written Page"); + menu.setCarried(weatherPage()); + helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE + 1), + "Inserting before a Page must shift it right"); + helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE), weatherPage()), + "Inserted Page must be first"); + helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 1), terrainPage()), + "Existing Page must move right"); + helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE + 1), + "Clicking a thumbnail with an empty cursor must take the Page"); + helper.assertTrue(ItemStack.matches(menu.getCarried(), weatherPage()), + "Removed Page must return to the cursor"); + helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE), terrainPage()), + "Removal must close the gap"); + helper.assertFalse(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE), + "A symbol Page must not replace the Link Panel"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void legacyStripImportsNotebookAtClickedPosition(GameTestHelper helper) { + BookBinderBlockEntity binder = place(helper); + binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage()); + binder.setItem(BookBinderBlockEntity.FIRST_PAGE, terrainPage()); + var player = helper.makeMockServerPlayerInLevel(); + player.moveTo(binder.getBlockPos().getX() + 0.5, binder.getBlockPos().getY() + 0.5, + binder.getBlockPos().getZ() + 0.5); + BookBinderMenu menu = new BookBinderMenu(1, player.getInventory(), binder); + ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get()); + notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(weatherPage()))); + menu.setCarried(notebook); + helper.assertTrue(menu.clickMenuButton(player, BookBinderMenu.STRIP_CLICK_BASE + 1), + "Notebook pages must import where clicked"); + helper.assertValueEqual(menu.getCarried().getOrDefault( + ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pageCount(), 0, + "The empty Notebook must remain on the cursor"); + helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE), weatherPage()), + "Imported Page must occupy the clicked position"); + helper.assertTrue(ItemStack.matches(binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 1), terrainPage()), + "Existing Page must shift right"); + helper.succeed(); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/menu/BookBinderMenu.java b/src/main/java/net/dnorberg/rivencraft/menu/BookBinderMenu.java new file mode 100644 index 0000000..46065f2 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/menu/BookBinderMenu.java @@ -0,0 +1,224 @@ +package net.dnorberg.rivencraft.menu; + +import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity; +import net.dnorberg.rivencraft.item.PageItem; +import net.dnorberg.rivencraft.registry.ModItems; +import net.dnorberg.rivencraft.registry.ModMenus; +import net.dnorberg.rivencraft.writing.PageContent; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ClickType; +import net.minecraft.world.inventory.DataSlot; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +/** Fixed, ordered binder slots and a server-owned virtual result. */ +public final class BookBinderMenu extends AbstractContainerMenu { + public static final int RESULT = BookBinderBlockEntity.SIZE; + public static final int SELECT_BASE = 100; + public static final int MOVE_PREVIOUS = 200; + public static final int MOVE_NEXT = 201; + public static final int IMPORT_NOTEBOOK = 202; + public static final int STRIP_CLICK_BASE = 300; + private static final int PLAYER_START = RESULT + 1; + + private final BookBinderBlockEntity binder; + private final Container inputs; + private final SimpleContainer result = new SimpleContainer(1); + private final boolean clientSide; + private int selectedPage = -1; + + public BookBinderMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) { + this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos()) + instanceof BookBinderBlockEntity found ? found : null); + } + + public BookBinderMenu(int id, Inventory inventory, BookBinderBlockEntity binder) { + super(ModMenus.BOOK_BINDER.get(), id); + this.binder = binder; + inputs = binder == null ? new SimpleContainer(BookBinderBlockEntity.SIZE) : binder; + clientSide = inventory.player.level().isClientSide; + addDataSlot(new DataSlot() { + @Override public int get() { return selectedPage; } + @Override public void set(int value) { selectedPage = value; } + }); + addSlot(new BinderSlot(BookBinderBlockEntity.COVER, 8, 27, 64)); + addSlot(new BinderSlot(BookBinderBlockEntity.LINK_PANEL, -1000, -1000, 1)); + addSlot(new BinderSlot(BookBinderBlockEntity.NOTEBOOK, -1000, -1000, 1)); + for (int page = 0; page < BookBinderBlockEntity.PAGE_COUNT; page++) { + addSlot(new BinderSlot(BookBinderBlockEntity.FIRST_PAGE + page, -1000, -1000, 1)); + } + addSlot(new ResultSlot(result, 0, 152, 27)); + for (int row = 0; row < 3; row++) { + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column + row * 9 + 9, 8 + column * 18, 99 + row * 18)); + } + } + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column, 8 + column * 18, 157)); + } + refreshResult(); + } + + public int selectedPage() { return selectedPage; } + public String title() { return binder == null ? "" : binder.title(); } + public boolean setTitle(Player player, String title) { + if (clientSide || !stillValid(player) || title == null + || title.length() > BookBinderBlockEntity.MAX_TITLE_LENGTH) return false; + binder.setTitle(title); + refreshResult(); + broadcastChanges(); + return true; + } + + @Override public boolean stillValid(Player player) { + return binder != null && binder.stillValid(player); + } + + @Override public boolean clickMenuButton(Player player, int id) { + if (clientSide || !stillValid(player)) return false; + if (id >= STRIP_CLICK_BASE && id <= STRIP_CLICK_BASE + BookBinderBlockEntity.PAGE_COUNT) { + if (!clickStrip(id - STRIP_CLICK_BASE)) return false; + } else if (id >= SELECT_BASE && id < SELECT_BASE + BookBinderBlockEntity.PAGE_COUNT) { + selectedPage = id - SELECT_BASE; + } else if (id == MOVE_PREVIOUS || id == MOVE_NEXT) { + int target = selectedPage + (id == MOVE_PREVIOUS ? -1 : 1); + if (selectedPage < 0 || target < 0 || target >= BookBinderBlockEntity.PAGE_COUNT + || binder.getItem(BookBinderBlockEntity.FIRST_PAGE + selectedPage).isEmpty() + || !getCarried().isEmpty()) return false; + int fromSlot = BookBinderBlockEntity.FIRST_PAGE + selectedPage; + int toSlot = BookBinderBlockEntity.FIRST_PAGE + target; + ItemStack from = binder.getItem(fromSlot); + ItemStack to = binder.getItem(toSlot); + binder.setItem(fromSlot, to); + binder.setItem(toSlot, from); + selectedPage = target; + } else if (id == IMPORT_NOTEBOOK) { + if (!binder.importNotebook()) return false; + } else return false; + refreshResult(); + broadcastChanges(); + return true; + } + + private boolean clickStrip(int index) { + ItemStack carried = getCarried(); + if (carried.is(ModItems.NOTEBOOK.get())) { + ItemStack emptied = binder.importNotebookFromStack(carried, Math.min(index == 0 ? 0 : index - 1, + binder.nextPageIndex())); + if (emptied.isEmpty()) return false; + setCarried(emptied); + return true; + } + if (!carried.isEmpty()) { + if (index == 0) { + if (!binder.getItem(BookBinderBlockEntity.LINK_PANEL).isEmpty() + || !binder.canPlaceItem(BookBinderBlockEntity.LINK_PANEL, carried)) return false; + binder.setItem(BookBinderBlockEntity.LINK_PANEL, carried.copyWithCount(1)); + } else if (!binder.insertPage(index - 1, carried.copyWithCount(1))) return false; + ItemStack remainder = carried.copy(); + remainder.shrink(1); + setCarried(remainder); + return true; + } + ItemStack removed; + if (index == 0) { + removed = binder.removeItem(BookBinderBlockEntity.LINK_PANEL, 1); + } else { + removed = binder.takePage(index - 1); + } + if (removed.isEmpty()) return false; + setCarried(removed); + return true; + } + + @Override public ItemStack quickMoveStack(Player player, int index) { + if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY; + if (index == RESULT) { + refreshResult(); + if (!binder.canAssemble()) return ItemStack.EMPTY; + ItemStack book = binder.previewBook(); + ItemStack remainder = book.copy(); + if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty()) { + return ItemStack.EMPTY; + } + binder.consumeAssembly(); + refreshResult(); + return book; + } + Slot sourceSlot = slots.get(index); + if (!sourceSlot.hasItem()) return ItemStack.EMPTY; + ItemStack source = sourceSlot.getItem(); + ItemStack original = source.copy(); + boolean moved; + if (index < RESULT) { + moved = moveItemStackTo(source, PLAYER_START, slots.size(), true); + } else if (source.is(Items.LEATHER) || BookBinderBlockEntity.validCover(source)) { + moved = moveItemStackTo(source, BookBinderBlockEntity.COVER, BookBinderBlockEntity.COVER + 1, false); + } else if (source.is(ModItems.NOTEBOOK.get())) { + ItemStack emptied = binder.importNotebookFromStack(source, binder.nextPageIndex()); + if (emptied.isEmpty()) return ItemStack.EMPTY; + sourceSlot.setByPlayer(emptied); + refreshResult(); + return original; + } else if (source.is(ModItems.PAGE.get()) + && PageItem.content(source).kind() == PageContent.Kind.LINK_PANEL) { + moved = moveItemStackTo(source, BookBinderBlockEntity.LINK_PANEL, + BookBinderBlockEntity.LINK_PANEL + 1, false); + } else if (source.is(ModItems.PAGE.get())) { + moved = moveItemStackTo(source, BookBinderBlockEntity.FIRST_PAGE, RESULT, false); + } else moved = false; + if (!moved) return ItemStack.EMPTY; + if (source.isEmpty()) sourceSlot.setByPlayer(ItemStack.EMPTY); + else sourceSlot.setChanged(); + refreshResult(); + return original; + } + + @Override public void clicked(int slotId, int button, ClickType clickType, Player player) { + if (!stillValid(player)) { + player.closeContainer(); + return; + } + refreshResult(); + super.clicked(slotId, button, clickType, player); + } + + @Override public void broadcastChanges() { + refreshResult(); + super.broadcastChanges(); + } + + private void refreshResult() { + if (clientSide) return; + ItemStack preview = binder == null ? ItemStack.EMPTY : binder.previewBook(); + if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview); + } + + private final class BinderSlot extends Slot { + private final int capacity; + private BinderSlot(int index, int x, int y, int capacity) { + super(inputs, index, x, y); + this.capacity = capacity; + } + @Override public boolean mayPlace(ItemStack stack) { return inputs.canPlaceItem(getContainerSlot(), stack); } + @Override public int getMaxStackSize() { return capacity; } + } + + private final class ResultSlot extends Slot { + private ResultSlot(Container container, int index, int x, int y) { super(container, index, x, y); } + @Override public boolean mayPlace(ItemStack stack) { return false; } + @Override public boolean mayPickup(Player player) { + return clientSide ? hasItem() : stillValid(player) && binder.canAssemble(); + } + @Override public void onTake(Player player, ItemStack stack) { + if (!clientSide && binder.consumeAssembly()) refreshResult(); + super.onTake(player, stack); + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java b/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java index ade2596..74a94f3 100644 --- a/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java +++ b/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java @@ -13,6 +13,7 @@ import net.dnorberg.rivencraft.client.ClientWritingDeskCatalog; import net.dnorberg.rivencraft.event.AgeCelestialAngleSync; import net.dnorberg.rivencraft.item.DescriptiveBookItem; import net.dnorberg.rivencraft.item.LinkingBookItem; +import net.dnorberg.rivencraft.menu.BookBinderMenu; import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModItems; import net.minecraft.core.component.DataComponents; @@ -49,7 +50,7 @@ public final class ModNetworking { } public static void register(RegisterPayloadHandlersEvent event) { - PayloadRegistrar registrar = event.registrar("11"); + PayloadRegistrar registrar = event.registrar("12"); registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC, ModNetworking::requestDescriptiveBookScreen); registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC, @@ -71,6 +72,16 @@ public final class ModNetworking { ClientAgeCelestialAngles::handle); registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC, ClientWritingDeskCatalog::handle); + registrar.playToServer(SetBookBinderTitlePayload.TYPE, SetBookBinderTitlePayload.STREAM_CODEC, + ModNetworking::setBookBinderTitle); + } + + private static void setBookBinderTitle(SetBookBinderTitlePayload payload, IPayloadContext context) { + if (context.player() instanceof ServerPlayer player + && player.containerMenu instanceof BookBinderMenu menu + && menu.containerId == payload.containerId()) { + menu.setTitle(player, payload.title()); + } } private static void requestDescriptiveBookScreen(RequestDescriptiveBookScreenPayload payload, IPayloadContext context) { diff --git a/src/main/java/net/dnorberg/rivencraft/network/SetBookBinderTitlePayload.java b/src/main/java/net/dnorberg/rivencraft/network/SetBookBinderTitlePayload.java new file mode 100644 index 0000000..a48766b --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/network/SetBookBinderTitlePayload.java @@ -0,0 +1,19 @@ +package net.dnorberg.rivencraft.network; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; + +public record SetBookBinderTitlePayload(int containerId, String title) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "set_book_binder_title")); + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite(ByteBufCodecs.VAR_INT, SetBookBinderTitlePayload::containerId, + ByteBufCodecs.STRING_UTF8, SetBookBinderTitlePayload::title, + SetBookBinderTitlePayload::new); + + @Override public Type type() { return TYPE; } +} diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java b/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java index e335cd3..11aacd6 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java @@ -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.BookBinderBlockEntity; import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity; import net.minecraft.core.registries.Registries; import net.minecraft.world.level.block.entity.BlockEntityType; @@ -20,6 +21,9 @@ public final class ModBlockEntities { public static final DeferredHolder, BlockEntityType> WRITING_DESK = BLOCK_ENTITIES.register("writing_desk", () -> BlockEntityType.Builder .of(WritingDeskBlockEntity::new, ModBlocks.WRITING_DESK.get()).build(null)); + public static final DeferredHolder, BlockEntityType> BOOK_BINDER = + BLOCK_ENTITIES.register("book_binder", () -> BlockEntityType.Builder + .of(BookBinderBlockEntity::new, ModBlocks.BOOK_BINDER.get()).build(null)); private ModBlockEntities() { } diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java b/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java index 5ecd3f5..e0fd842 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java @@ -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.BookBinderBlock; import net.dnorberg.rivencraft.block.WritingDeskBlock; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockBehaviour; @@ -21,6 +22,10 @@ public final class ModBlocks { "writing_desk", WritingDeskBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion() ); + public static final DeferredBlock BOOK_BINDER = BLOCKS.registerBlock( + "book_binder", BookBinderBlock::new, + BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion() + ); private ModBlocks() { } diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java index 2f5891c..9aca6a8 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java @@ -23,6 +23,7 @@ public final class ModCreativeTabs { output.accept(ModItems.NOTEBOOK.get()); output.accept(ModItems.INK_VIAL.get()); output.accept(ModItems.WRITING_DESK.get()); + output.accept(ModItems.BOOK_BINDER.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 e41f4f4..636225f 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModDataComponents.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModDataComponents.java @@ -5,6 +5,7 @@ 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.OrderedWriting; import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata; import com.mojang.serialization.Codec; import net.minecraft.core.component.DataComponentType; @@ -61,6 +62,12 @@ public final class ModDataComponents { .networkSynchronized(NotebookContents.STREAM_CODEC) .cacheEncoding() .build()); + public static final DeferredHolder, DataComponentType> BOOK_WRITING = + DATA_COMPONENTS.register("book_writing", () -> DataComponentType.builder() + .persistent(OrderedWriting.CODEC) + .networkSynchronized(OrderedWriting.STREAM_CODEC) + .cacheEncoding() + .build()); public static final DeferredHolder, DataComponentType> INK_REMAINING = DATA_COMPONENTS.register("ink_remaining", () -> DataComponentType.builder() .persistent(Codec.intRange(0, 16)) diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java b/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java index 4751d04..8f48b2a 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java @@ -41,6 +41,7 @@ public final class ModItems { new Item.Properties().stacksTo(16) ); public static final DeferredItem WRITING_DESK = ITEMS.registerSimpleBlockItem(ModBlocks.WRITING_DESK); + public static final DeferredItem BOOK_BINDER = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_BINDER); private ModItems() { } diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModMenus.java b/src/main/java/net/dnorberg/rivencraft/registry/ModMenus.java index 757703b..aff506f 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModMenus.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModMenus.java @@ -3,6 +3,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.dnorberg.rivencraft.menu.BookBinderMenu; import net.minecraft.core.registries.Registries; import net.minecraft.world.inventory.MenuType; import net.neoforged.bus.api.IEventBus; @@ -18,6 +19,8 @@ public final class ModMenus { MENUS.register("notebook", () -> IMenuTypeExtension.create(NotebookMenu::new)); public static final DeferredHolder, MenuType> WRITING_DESK = MENUS.register("writing_desk", () -> IMenuTypeExtension.create(WritingDeskMenu::new)); + public static final DeferredHolder, MenuType> BOOK_BINDER = + MENUS.register("book_binder", () -> IMenuTypeExtension.create(BookBinderMenu::new)); private ModMenus() { } diff --git a/src/main/resources/assets/rivencraft/blockstates/book_binder.json b/src/main/resources/assets/rivencraft/blockstates/book_binder.json new file mode 100644 index 0000000..8ea80dd --- /dev/null +++ b/src/main/resources/assets/rivencraft/blockstates/book_binder.json @@ -0,0 +1,8 @@ +{ + "variants": { + "facing=north": {"model":"rivencraft:block/book_binder"}, + "facing=east": {"model":"rivencraft:block/book_binder","y":90}, + "facing=south": {"model":"rivencraft:block/book_binder","y":180}, + "facing=west": {"model":"rivencraft:block/book_binder","y":270} + } +} diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index 7fab302..34ec84e 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -18,6 +18,17 @@ "item.rivencraft.ink_vial": "Ink Vial", "block.rivencraft.writing_desk": "Writing Desk", "container.rivencraft.writing_desk": "Writing Desk", + "block.rivencraft.book_binder": "Book Binder", + "container.rivencraft.book_binder": "Book Binder", + "screen.rivencraft.book_binder.title": "Book title", + "screen.rivencraft.book_binder.title_hint": "Name the book", + "screen.rivencraft.book_binder.missing_panel": "Link Panel required as the first page", + "screen.rivencraft.book_binder.cover": "Cover · Link Panel · Notebook", + "screen.rivencraft.book_binder.import": "Import Notebook", + "screen.rivencraft.book_binder.import.tooltip": "Move all Notebook pages into the binder in order", + "screen.rivencraft.book_binder.move_previous": "Move selected page left", + "screen.rivencraft.book_binder.move_next": "Move selected page right", + "screen.rivencraft.book_binder.select_hint": "Right-click page to select", "screen.rivencraft.writing_desk.ink": "Ink: %s/%s", "screen.rivencraft.writing_desk.search": "Search", "screen.rivencraft.writing_desk.no_symbols": "No available symbols", diff --git a/src/main/resources/assets/rivencraft/models/block/book_binder.json b/src/main/resources/assets/rivencraft/models/block/book_binder.json new file mode 100644 index 0000000..6061d43 --- /dev/null +++ b/src/main/resources/assets/rivencraft/models/block/book_binder.json @@ -0,0 +1,61 @@ +{ + "parent": "minecraft:block/block", + "textures": { + "particle": "rivencraft:block/book_binder", + "body": "rivencraft:block/book_binder", + "press": "rivencraft:block/book_binder_press" + }, + "elements": [ + { + "from": [0, 0, 0], "to": [16, 10, 16], + "faces": { + "north": {"texture":"#body","uv":[0,4,4,6.5]}, + "east": {"texture":"#body","uv":[4,4,8,6.5]}, + "south": {"texture":"#body","uv":[8,4,12,6.5]}, + "west": {"texture":"#body","uv":[12,4,16,6.5]}, + "up": {"texture":"#body","uv":[4,0,8,4]}, + "down": {"texture":"#body","uv":[8,0,12,4]} + } + }, + { + "from": [0, 10, 1], "to": [3, 14, 15], + "faces": { + "north": {"texture":"#press","uv":[0,4,1.5,6]}, + "east": {"texture":"#press","uv":[0,0,7,2]}, + "south": {"texture":"#press","uv":[2,4,3.5,6]}, + "west": {"texture":"#press","uv":[0,2,7,4]}, + "up": {"texture":"#press","uv":[7,0,8.5,6]}, + "down": {"texture":"#press","uv":[8.5,0,10,7]} + } + }, + { + "from": [13, 10, 1], "to": [16, 14, 15], + "faces": { + "north": {"texture":"#press","uv":[0,4,1.5,6]}, + "east": {"texture":"#press","uv":[0,0,7,2]}, + "south": {"texture":"#press","uv":[2,4,3.5,6]}, + "west": {"texture":"#press","uv":[0,2,7,4]}, + "up": {"texture":"#press","uv":[7,0,8.5,6]}, + "down": {"texture":"#press","uv":[8.5,0,10,7]} + } + }, + { + "from": [3, 11, 3], "to": [13, 12, 4], + "faces": { + "north": {"texture":"#press","uv":[1,15.5,6,16]}, + "south": {"texture":"#press","uv":[7,15.5,12,16]}, + "up": {"texture":"#press","uv":[0.5,15,5.5,15.5]}, + "down": {"texture":"#press","uv":[5.5,15,10.5,15.5]} + } + }, + { + "from": [3, 11, 12], "to": [13, 12, 13], + "faces": { + "north": {"texture":"#press","uv":[1,15.5,6,16]}, + "south": {"texture":"#press","uv":[7,15.5,12,16]}, + "up": {"texture":"#press","uv":[0.5,15,5.5,15.5]}, + "down": {"texture":"#press","uv":[5.5,15,10.5,15.5]} + } + } + ] +} diff --git a/src/main/resources/assets/rivencraft/models/item/book_binder.json b/src/main/resources/assets/rivencraft/models/item/book_binder.json new file mode 100644 index 0000000..3619e0a --- /dev/null +++ b/src/main/resources/assets/rivencraft/models/item/book_binder.json @@ -0,0 +1,3 @@ +{ + "parent": "rivencraft:block/book_binder" +} diff --git a/src/main/resources/assets/rivencraft/textures/block/book_binder.png b/src/main/resources/assets/rivencraft/textures/block/book_binder.png new file mode 100644 index 0000000..13518c5 Binary files /dev/null and b/src/main/resources/assets/rivencraft/textures/block/book_binder.png differ diff --git a/src/main/resources/assets/rivencraft/textures/block/book_binder_press.png b/src/main/resources/assets/rivencraft/textures/block/book_binder_press.png new file mode 100644 index 0000000..de69879 Binary files /dev/null and b/src/main/resources/assets/rivencraft/textures/block/book_binder_press.png differ diff --git a/src/main/resources/assets/rivencraft/textures/block/writing_desk_inlay.png b/src/main/resources/assets/rivencraft/textures/block/writing_desk_inlay.png deleted file mode 100644 index 205f1a8..0000000 Binary files a/src/main/resources/assets/rivencraft/textures/block/writing_desk_inlay.png and /dev/null differ diff --git a/src/main/resources/assets/rivencraft/textures/block/writing_desk_wood.png b/src/main/resources/assets/rivencraft/textures/block/writing_desk_wood.png deleted file mode 100644 index 8dd0645..0000000 Binary files a/src/main/resources/assets/rivencraft/textures/block/writing_desk_wood.png and /dev/null differ diff --git a/src/main/resources/assets/rivencraft/textures/gui/book_binder_legacy.png b/src/main/resources/assets/rivencraft/textures/gui/book_binder_legacy.png new file mode 100644 index 0000000..7d2cf6d Binary files /dev/null and b/src/main/resources/assets/rivencraft/textures/gui/book_binder_legacy.png differ diff --git a/src/main/resources/data/rivencraft/loot_table/blocks/book_binder.json b/src/main/resources/data/rivencraft/loot_table/blocks/book_binder.json new file mode 100644 index 0000000..1169792 --- /dev/null +++ b/src/main/resources/data/rivencraft/loot_table/blocks/book_binder.json @@ -0,0 +1,4 @@ +{ + "type": "minecraft:block", + "pools": [{"rolls":1,"entries":[{"type":"minecraft:item","name":"rivencraft:book_binder"}]}] +} diff --git a/src/main/resources/data/rivencraft/recipe/book_binder.json b/src/main/resources/data/rivencraft/recipe/book_binder.json new file mode 100644 index 0000000..ae44e6f --- /dev/null +++ b/src/main/resources/data/rivencraft/recipe/book_binder.json @@ -0,0 +1,10 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "pattern": ["III", "PPP", "P P"], + "key": { + "I": {"item":"minecraft:iron_ingot"}, + "P": {"tag":"minecraft:planks"} + }, + "result": {"id":"rivencraft:book_binder","count":1} +}