From 88355ee2763a3ffcef9a9722bee92722be5519b3 Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Tue, 29 Sep 2026 10:35:29 -0400 Subject: [PATCH] Add Ink Mixer and optional JEI writing guides --- NOTICE | 3 + README.md | 20 +++ build.gradle | 20 ++- docs/ASSET_PROVENANCE.md | 2 + docs/PORTING.md | 6 +- gradle.properties | 3 +- .../rivencraft/block/InkMixerBlock.java | 52 +++++++ .../block/entity/InkMixerBlockEntity.java | 140 ++++++++++++++++++ .../rivencraft/client/ClientEvents.java | 2 + .../client/screen/BookBinderScreen.java | 18 +++ .../client/screen/DescriptiveBookScreen.java | 17 ++- .../client/screen/InkMixerScreen.java | 52 +++++++ .../client/screen/LinkingBookScreen.java | 17 ++- .../client/screen/NotebookScreen.java | 2 + .../client/screen/WritingDeskScreen.java | 33 ++++- .../compat/jei/RivencraftJeiPlugin.java | 117 +++++++++++++++ .../gametest/InkMixerGameTests.java | 103 +++++++++++++ .../dnorberg/rivencraft/item/PageItem.java | 18 +++ .../rivencraft/menu/InkMixerMenu.java | 119 +++++++++++++++ .../rivencraft/registry/ModBlockEntities.java | 4 + .../rivencraft/registry/ModBlocks.java | 5 + .../rivencraft/registry/ModCreativeTabs.java | 6 + .../rivencraft/registry/ModItems.java | 1 + .../rivencraft/registry/ModMenus.java | 3 + .../rivencraft/blockstates/ink_mixer.json | 5 + .../assets/rivencraft/lang/en_us.json | 19 +++ .../rivencraft/models/block/ink_mixer.json | 23 +++ .../rivencraft/models/item/ink_mixer.json | 3 + .../textures/block/ink_mixer_bottom.png | Bin 0 -> 747 bytes .../textures/block/ink_mixer_side.png | Bin 0 -> 616 bytes .../textures/block/ink_mixer_top.png | Bin 0 -> 715 bytes .../textures/gui/ink_mixer_legacy.png | Bin 0 -> 4616 bytes .../loot_table/blocks/ink_mixer.json | 4 + .../data/rivencraft/recipe/ink_mixer.json | 11 ++ 34 files changed, 814 insertions(+), 14 deletions(-) create mode 100644 src/main/java/net/dnorberg/rivencraft/block/InkMixerBlock.java create mode 100644 src/main/java/net/dnorberg/rivencraft/block/entity/InkMixerBlockEntity.java create mode 100644 src/main/java/net/dnorberg/rivencraft/client/screen/InkMixerScreen.java create mode 100644 src/main/java/net/dnorberg/rivencraft/compat/jei/RivencraftJeiPlugin.java create mode 100644 src/main/java/net/dnorberg/rivencraft/gametest/InkMixerGameTests.java create mode 100644 src/main/java/net/dnorberg/rivencraft/menu/InkMixerMenu.java create mode 100644 src/main/resources/assets/rivencraft/blockstates/ink_mixer.json create mode 100644 src/main/resources/assets/rivencraft/models/block/ink_mixer.json create mode 100644 src/main/resources/assets/rivencraft/models/item/ink_mixer.json create mode 100644 src/main/resources/assets/rivencraft/textures/block/ink_mixer_bottom.png create mode 100644 src/main/resources/assets/rivencraft/textures/block/ink_mixer_side.png create mode 100644 src/main/resources/assets/rivencraft/textures/block/ink_mixer_top.png create mode 100644 src/main/resources/assets/rivencraft/textures/gui/ink_mixer_legacy.png create mode 100644 src/main/resources/data/rivencraft/loot_table/blocks/ink_mixer.json create mode 100644 src/main/resources/data/rivencraft/recipe/ink_mixer.json diff --git a/NOTICE b/NOTICE index ebbedae..3dc3d00 100644 --- a/NOTICE +++ b/NOTICE @@ -16,6 +16,9 @@ The Writing Desk block texture is copied from Legacy's assets/mystcraft/textures/entity/desk.png under the same license. The Writing Desk GUI atlas is copied from Legacy's assets/mystcraft/gui/writingdesk.png under the same license; desk wood and inlay textures are cropped from desk.png. +The Ink Mixer GUI atlas and block textures are copied from Legacy's +assets/mystcraft/gui/inkmixer.png and textures/blocks/inkmixer_*.png under +the same license. Rivencraft's block model is original and uses those textures. The initial build layout is derived from the official NeoForge 1.21.1 ModDevGradle MDK: https://github.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle diff --git a/README.md b/README.md index 8372083..0393158 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,11 @@ hand and use the Ink Vial in the other to prepare a baseline Link Panel Page. Each vial holds 16 ink units, and the default link-panel cost is two units. The spent vial returns a glass bottle. Server owners can adjust the symbol-copy and link-panel costs in the `writing` section of `rivencraft-server.toml`. +The Ink Mixer now offers a Legacy-style station for producing Link Panels: +insert paper and Ink Vials, then take a Link Panel from the result slot. Each +vial fills its 16-unit basin and returns a glass bottle; no server tick is +needed. The earlier paper-in-one-hand method remains available. Ink modifier +ingredients and link properties are planned for Milestone 5. The Writing Desk can copy a selected written page from its collection tabs. The Writing Desk is now craftable from planks, a glass bottle, and a feather. It occupies two adjacent blocks, with one shared inventory; loaded paper and @@ -61,6 +66,21 @@ portrait preview and name above the player inventory. ## Book Binder +For a new survival Age: craft an Ink Vial, an Ink Mixer, a Writing Desk, and a Book Binder. +Put vanilla paper and an Ink Vial in the Ink Mixer to make a Link Panel +Page. Place leather (or an empty Notebook) and the Link Panel in the Book Binder, +enter an Age name, and take the finished Descriptive Book. Written symbol Pages +are optional; without them, the book creates a random Age. To duplicate a Page +you already own, store it in a Writing Desk tab, select it, supply paper and ink, +and take the copied Page from the output. A Notebook can hold and transfer a +larger ordered collection of Pages between desks and the binder. + +With JEI installed, Rivencraft displays the Ink Mixer operation in its own +category and the hand-use, copying, and binding flows in a Writing category. +JEI is optional; vanilla crafting +recipes and all writing interactions work without it. For a development client +with JEI, run `./gradlew runClient -PwithJei=true`. + 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 diff --git a/build.gradle b/build.gradle index 1cab065..54e8f88 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,14 @@ sourceSets.main.resources { } repositories { + maven { + name = 'JaredsMaven' + url = 'https://maven.blamejared.com' + content { + includeGroup 'mezz.jei' + includeGroup 'net.mezzdev.config' + } + } maven { url = 'https://maven.createmod.net' } maven { url = 'https://www.cursemaven.com' @@ -172,12 +180,12 @@ dependencies { compileOnly "maven.modrinth:immersive-portals-neoforge:${immersive_portals_version}" implementation "curse.maven:dynamicdimension-ar-edition-1469680:${dynamic_dimensions_file_id}" implementation "lol.bai:badpackets:neo-${badpackets_version}" - // Example optional mod dependency with JEI - // The JEI API is declared for compile time use, while the full JEI artifact is used at runtime - // compileOnly "mezz.jei:jei-${mc_version}-common-api:${jei_version}" - // compileOnly "mezz.jei:jei-${mc_version}-neoforge-api:${jei_version}" - // We add the full version to localRuntime, not runtimeOnly, so that we do not publish a dependency on it - // localRuntime "mezz.jei:jei-${mc_version}-neoforge:${jei_version}" + // JEI integration is isolated in its plugin and absent from the published runtime dependencies. + compileOnly "mezz.jei:jei-${minecraft_version}-neoforge-api:${jei_version}" + // Opt in to JEI for a dev client with -PwithJei=true. + if (providers.gradleProperty('withJei').map { it.toBoolean() }.orElse(false).get()) { + localRuntime "mezz.jei:jei-${minecraft_version}-neoforge:${jei_version}" + } // Example mod dependency using a mod jar from ./libs with a flat dir repository // This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar diff --git a/docs/ASSET_PROVENANCE.md b/docs/ASSET_PROVENANCE.md index 3b6abc8..ba85adb 100644 --- a/docs/ASSET_PROVENANCE.md +++ b/docs/ASSET_PROVENANCE.md @@ -19,6 +19,8 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license. | `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/gui/ink_mixer_legacy.png` | `assets/mystcraft/gui/inkmixer.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Ink Mixer GUI uses the original 176×181 panel | +| `assets/rivencraft/textures/block/ink_mixer_top.png`, `ink_mixer_side.png`, `ink_mixer_bottom.png` | `assets/mystcraft/textures/blocks/inkmixer_top.png`, `inkmixer_side.png`, `inkmixer_bottom.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Rivencraft supplies its own block model | | `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/docs/PORTING.md b/docs/PORTING.md index 0350d02..52f6fbb 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -33,7 +33,8 @@ acceptance issue and all required implementation issues are complete. lifecycle management, data-pack integration, and optional destination renderer compatibility. **Complete.** See [Milestone 3](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/8). -4. **Writing system** — pages, symbols, notebooks, desks, and book assembly. +4. **Writing system** — pages, notebooks, desks, book assembly, and the baseline + Ink Mixer for Link Panels. Symbol effects are deferred until Milestone 5. **In progress.** 5. **Age grammar** — translate symbol combinations into modern biome, noise, dimension-type, and world-generation configuration. Optional integrations @@ -66,6 +67,9 @@ Milestone 4 begins with a versioned, data-pack-defined symbol catalog and an immutable ordered-writing model. See [WRITING_DATA.md](WRITING_DATA.md). These contracts preserve unknown symbol identifiers and store author intent; they do not interpret symbols into Age generation, which remains Milestone 5 work. +The Ink Mixer is included in Milestone 4 after book assembly; its first release +uses paper and Ink Vials to make baseline Link Panels. Adding modifier +ingredients and symbol-driven link properties belongs to Milestone 5. ## Provenance rules diff --git a/gradle.properties b/gradle.properties index 8ba39dc..f522465 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,6 +22,7 @@ neo_version=21.1.251 ponder_version=1.0.82 dynamic_dimensions_file_id=7707789 badpackets_version=0.8.2 +jei_version=19.57.0.449 # The loader version range can only use the major version of FML as bounds loader_version_range=[1,) @@ -35,7 +36,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.8.2 +mod_version=0.3.9.1 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/InkMixerBlock.java b/src/main/java/net/dnorberg/rivencraft/block/InkMixerBlock.java new file mode 100644 index 0000000..fbe335e --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/block/InkMixerBlock.java @@ -0,0 +1,52 @@ +package net.dnorberg.rivencraft.block; + +import com.mojang.serialization.MapCodec; +import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity; +import net.dnorberg.rivencraft.menu.InkMixerMenu; +import net.minecraft.core.BlockPos; +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.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.BlockHitResult; + +public final class InkMixerBlock extends BaseEntityBlock { + public static final MapCodec CODEC = simpleCodec(InkMixerBlock::new); + + public InkMixerBlock(Properties properties) { super(properties); } + + @Override protected MapCodec codec() { return CODEC; } + @Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; } + @Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new InkMixerBlockEntity(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 InkMixerBlockEntity mixer) { + serverPlayer.openMenu(new SimpleMenuProvider( + (id, inventory, ignored) -> new InkMixerMenu(id, inventory, mixer), + Component.translatable("container.rivencraft.ink_mixer")), + 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 InkMixerBlockEntity mixer) { + Containers.dropContents(level, pos, mixer); + level.updateNeighbourForOutputSignal(pos, this); + } + super.onRemove(state, level, pos, replacement, moving); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/block/entity/InkMixerBlockEntity.java b/src/main/java/net/dnorberg/rivencraft/block/entity/InkMixerBlockEntity.java new file mode 100644 index 0000000..3464ff4 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/block/entity/InkMixerBlockEntity.java @@ -0,0 +1,140 @@ +package net.dnorberg.rivencraft.block.entity; + +import net.dnorberg.rivencraft.item.InkVialItem; +import net.dnorberg.rivencraft.registry.ModBlockEntities; +import net.dnorberg.rivencraft.registry.ModItems; +import net.dnorberg.rivencraft.writing.WritingInkService; +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.NonNullList; +import net.minecraft.nbt.CompoundTag; +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.Level; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** Baseline Link Panel mixer. Modifier ingredients belong to the symbol/ink-effects milestone. */ +public final class InkMixerBlockEntity extends BlockEntity implements Container { + public static final int INK = 0; + public static final int PAPER = 1; + public static final int BOTTLES = 2; + public static final int SIZE = 3; + + private NonNullList items = NonNullList.withSize(SIZE, ItemStack.EMPTY); + private int inkRemaining; + + public InkMixerBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.INK_MIXER.get(), pos, state); + } + + public int inkRemaining() { return inkRemaining; } + + @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(); + if (slot == BOTTLES) refillEmptyBasin(); + } + 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(); + if (slot == INK || slot == BOTTLES) refillEmptyBasin(); + } + @Override public boolean canPlaceItem(int slot, ItemStack stack) { + return switch (slot) { + case INK -> stack.is(ModItems.INK_VIAL.get()); + case PAPER -> stack.is(Items.PAPER); + default -> false; + }; + } + @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; + } + + private boolean hasBottleSpace() { + ItemStack bottles = items.get(BOTTLES); + return bottles.isEmpty() || bottles.is(Items.GLASS_BOTTLE) + && bottles.getCount() < bottles.getMaxStackSize(); + } + + /** No server tick: refill only when ink, bottle space, or consumption changes. */ + private void refillEmptyBasin() { + if (level == null || level.isClientSide || inkRemaining != 0 || !hasBottleSpace()) return; + ItemStack vial = items.get(INK); + if (!vial.is(ModItems.INK_VIAL.get())) return; + int amount = Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY); + if (amount == 0) return; + inkRemaining = amount; + vial.shrink(1); + if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY); + ItemStack bottles = items.get(BOTTLES); + if (bottles.isEmpty()) items.set(BOTTLES, new ItemStack(Items.GLASS_BOTTLE)); + else bottles.grow(1); + setChanged(); + } + + public boolean canCreateLinkPanel() { + if (!items.get(PAPER).is(Items.PAPER)) return false; + int cost = WritingInkService.linkPanelCost(); + if (inkRemaining >= cost) return true; + ItemStack vial = items.get(INK); + return vial.is(ModItems.INK_VIAL.get()) && hasBottleSpace() + && inkRemaining + InkVialItem.remaining(vial) >= cost; + } + + /** Commit inputs only when a virtual output is actually taken. */ + public boolean consumeLinkPanelMaterials() { + if (!canCreateLinkPanel()) return false; + int cost = WritingInkService.linkPanelCost(); + if (inkRemaining < cost) { + ItemStack vial = items.get(INK); + inkRemaining += Math.clamp(InkVialItem.remaining(vial), 0, InkVialItem.CAPACITY); + vial.shrink(1); + if (vial.isEmpty()) items.set(INK, ItemStack.EMPTY); + ItemStack bottles = items.get(BOTTLES); + if (bottles.isEmpty()) items.set(BOTTLES, new ItemStack(Items.GLASS_BOTTLE)); + else bottles.grow(1); + } + inkRemaining -= cost; + items.get(PAPER).shrink(1); + if (items.get(PAPER).isEmpty()) items.set(PAPER, ItemStack.EMPTY); + setChanged(); + if (inkRemaining == 0) refillEmptyBasin(); + return true; + } + + @Override public void setLevel(Level level) { + super.setLevel(level); + refillEmptyBasin(); + } + @Override public void clearContent() { + items = NonNullList.withSize(SIZE, ItemStack.EMPTY); + inkRemaining = 0; + 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); + inkRemaining = Math.clamp(tag.getInt("InkRemaining"), 0, InkVialItem.CAPACITY); + refillEmptyBasin(); + } + @Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { + super.saveAdditional(tag, registries); + ContainerHelper.saveAllItems(tag, items, registries); + tag.putInt("InkRemaining", inkRemaining); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java index 57bff58..7cb2c55 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java +++ b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java @@ -16,6 +16,7 @@ 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.client.screen.InkMixerScreen; import net.dnorberg.rivencraft.registry.ModMenus; @EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT) @@ -34,6 +35,7 @@ public final class ClientEvents { event.register(ModMenus.NOTEBOOK.get(), NotebookScreen::new); event.register(ModMenus.WRITING_DESK.get(), WritingDeskScreen::new); event.register(ModMenus.BOOK_BINDER.get(), BookBinderScreen::new); + event.register(ModMenus.INK_MIXER.get(), InkMixerScreen::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 index 0aa69cb..b1f80b6 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/screen/BookBinderScreen.java +++ b/src/main/java/net/dnorberg/rivencraft/client/screen/BookBinderScreen.java @@ -13,6 +13,7 @@ import net.minecraft.resources.ResourceLocation; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.item.ItemStack; import net.neoforged.neoforge.network.PacketDistributor; +import org.lwjgl.glfw.GLFW; /** Legacy-sized binder: cover and result flank a scrolling strip of full page thumbnails. */ public final class BookBinderScreen extends AbstractContainerScreen { @@ -41,6 +42,8 @@ public final class BookBinderScreen extends AbstractContainerScreen= topPos + STRIP_Y && mouseY < topPos + STRIP_Y + STRIP_HEIGHT) { + if (firstVisible > 0 && mouseX >= leftPos + STRIP_X && mouseX < leftPos + STRIP_X + 8) + graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book_binder.previous_pages"), mouseX, mouseY); + else if (firstVisible < maxFirstVisible() && mouseX >= leftPos + STRIP_X + STRIP_WIDTH - 8 + && mouseX < leftPos + STRIP_X + STRIP_WIDTH) + graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book_binder.next_pages"), mouseX, mouseY); + } if (pageAtIndex(0).isEmpty() && mouseX >= leftPos + 27 && mouseX < leftPos + 45 && mouseY >= topPos + 26 && mouseY < topPos + 44) { graphics.renderTooltip(font, @@ -210,6 +220,14 @@ public final class BookBinderScreen extends AbstractContainerScreen 0) { + firstVisible--; + return true; + } + if (keyCode == GLFW.GLFW_KEY_RIGHT && firstVisible < maxFirstVisible()) { + firstVisible++; + return true; + } return super.keyPressed(keyCode, scanCode, modifiers); } } diff --git a/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java b/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java index 249d5b1..2282d97 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java +++ b/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java @@ -22,12 +22,14 @@ import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; import net.minecraft.util.FormattedCharSequence; import net.minecraft.world.item.ItemStack; import net.minecraft.world.InteractionHand; import net.neoforged.neoforge.network.PacketDistributor; +import org.lwjgl.glfw.GLFW; import org.jetbrains.annotations.Nullable; public final class DescriptiveBookScreen extends Screen implements ExplorableBookScreen { @@ -113,7 +115,8 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo if (preview.supportsExploration()) { exploreButton = addRenderableWidget(Button.builder( EXPLORE_ICON, button -> enterExploreOverlay()) - .bounds(left + 229, top + 145, 20, 20).build()); + .bounds(left + 229, top + 145, 20, 20) + .tooltip(Tooltip.create(Component.translatable("screen.rivencraft.book.explore"))).build()); exploreButton.active = bookStandPos == null; exploreButton.visible = pageIndex == 0; } @@ -150,6 +153,14 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { renderBook(graphics, partialTick); super.render(graphics, mouseX, mouseY, partialTick); + int left = (width - BOOK_WIDTH) / 2; + int top = (height - BOOK_HEIGHT) / 2; + if (mouseY >= top + 164 && mouseY < top + 195) { + if (pageIndex > 0 && mouseX >= left + 7 && mouseX < left + 35) + graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book.previous_page"), mouseX, mouseY); + else if (pageIndex < pageCount() && mouseX >= left + 293 && mouseX < left + 320) + graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book.next_page"), mouseX, mouseY); + } } private void renderBook(GuiGraphics graphics, float partialTick) { @@ -303,6 +314,10 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo @Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (titleInput == null || !titleInput.isFocused()) { + if (keyCode == GLFW.GLFW_KEY_LEFT && pageIndex > 0) { turnPage(-1); return true; } + if (keyCode == GLFW.GLFW_KEY_RIGHT && pageIndex < pageCount()) { turnPage(1); return true; } + } return preview != null && preview.keyPressed(keyCode, scanCode) || super.keyPressed(keyCode, scanCode, modifiers); } diff --git a/src/main/java/net/dnorberg/rivencraft/client/screen/InkMixerScreen.java b/src/main/java/net/dnorberg/rivencraft/client/screen/InkMixerScreen.java new file mode 100644 index 0000000..d3eefe7 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/client/screen/InkMixerScreen.java @@ -0,0 +1,52 @@ +package net.dnorberg.rivencraft.client.screen; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.item.InkVialItem; +import net.dnorberg.rivencraft.menu.InkMixerMenu; +import net.minecraft.client.gui.GuiGraphics; +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; + +/** Legacy Ink Mixer atlas with a server-synced, sixteen-use basin. */ +public final class InkMixerScreen extends AbstractContainerScreen { + private static final ResourceLocation BACKGROUND = ResourceLocation.fromNamespaceAndPath( + Rivencraft.MOD_ID, "textures/gui/ink_mixer_legacy.png"); + + public InkMixerScreen(InkMixerMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + imageWidth = 176; + imageHeight = 181; + inventoryLabelY = 87; + } + + @Override public boolean isPauseScreen() { return false; } + @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); + int centerX = leftPos + 88; + int centerY = topPos + 49; + int fillTop = centerY + 26 - Math.round(menu.inkRemaining() / (float) InkVialItem.CAPACITY * 52); + if (menu.inkRemaining() > 0) { + for (int y = Math.max(centerY - 26, fillTop); y <= centerY + 26; y++) { + int halfWidth = (int) Math.sqrt(26 * 26 - (y - centerY) * (y - centerY)); + graphics.fill(centerX - halfWidth, y, centerX + halfWidth + 1, y + 1, 0xFF241228); + } + } + } + @Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false); + } + @Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + super.render(graphics, mouseX, mouseY, partialTick); + renderTooltip(graphics, mouseX, mouseY); + int x = mouseX - (leftPos + 88); + int y = mouseY - (topPos + 49); + if (x * x + y * y <= 26 * 26) + graphics.renderTooltip(font, Component.translatable("screen.rivencraft.ink_mixer.ink", + menu.inkRemaining(), InkVialItem.CAPACITY), mouseX, mouseY); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/client/screen/LinkingBookScreen.java b/src/main/java/net/dnorberg/rivencraft/client/screen/LinkingBookScreen.java index 41e5654..1547820 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/screen/LinkingBookScreen.java +++ b/src/main/java/net/dnorberg/rivencraft/client/screen/LinkingBookScreen.java @@ -16,6 +16,7 @@ import net.dnorberg.rivencraft.item.LinkingBookItem; 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.Screen; import net.minecraft.core.BlockPos; import net.minecraft.core.component.DataComponents; @@ -25,6 +26,7 @@ import net.minecraft.util.FormattedCharSequence; import net.minecraft.world.InteractionHand; import net.minecraft.world.item.ItemStack; import net.neoforged.neoforge.network.PacketDistributor; +import org.lwjgl.glfw.GLFW; public final class LinkingBookScreen extends Screen implements ExplorableBookScreen { private static final int BOOK_TEXT_COLOR = 0xFF3B281B; @@ -93,7 +95,8 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr if (preview.supportsExploration()) { exploreButton = addRenderableWidget(Button.builder( EXPLORE_ICON, button -> enterExploreOverlay()) - .bounds(left + 229, top + 145, 20, 20).build()); + .bounds(left + 229, top + 145, 20, 20) + .tooltip(Tooltip.create(Component.translatable("screen.rivencraft.book.explore"))).build()); exploreButton.active = bookStandPos == null; exploreButton.visible = pageIndex == 0; } @@ -131,6 +134,14 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { renderBook(graphics, partialTick); super.render(graphics, mouseX, mouseY, partialTick); + int left = (width - BOOK_WIDTH) / 2; + int top = (height - BOOK_HEIGHT) / 2; + if (mouseY >= top + 164 && mouseY < top + 195) { + if (pageIndex > 0 && mouseX >= left + 7 && mouseX < left + 35) + graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book.previous_page"), mouseX, mouseY); + else if (pageIndex == 0 && mouseX >= left + 293 && mouseX < left + 320) + graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book.next_page"), mouseX, mouseY); + } } private void renderBook(GuiGraphics graphics, float partialTick) { @@ -233,6 +244,10 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr @Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (titleInput == null || !titleInput.isFocused()) { + if (keyCode == GLFW.GLFW_KEY_LEFT && pageIndex > 0) { turnPage(0); return true; } + if (keyCode == GLFW.GLFW_KEY_RIGHT && pageIndex == 0) { turnPage(1); return true; } + } return preview != null && preview.keyPressed(keyCode, scanCode) || super.keyPressed(keyCode, scanCode, modifiers); } diff --git a/src/main/java/net/dnorberg/rivencraft/client/screen/NotebookScreen.java b/src/main/java/net/dnorberg/rivencraft/client/screen/NotebookScreen.java index 3c5f38b..90a0b3d 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/screen/NotebookScreen.java +++ b/src/main/java/net/dnorberg/rivencraft/client/screen/NotebookScreen.java @@ -47,6 +47,8 @@ public final class NotebookScreen extends AbstractContainerScreen inventoryLabelY = 123; } + @Override public boolean isPauseScreen() { return false; } + @Override protected void init() { super.init(); diff --git a/src/main/java/net/dnorberg/rivencraft/client/screen/WritingDeskScreen.java b/src/main/java/net/dnorberg/rivencraft/client/screen/WritingDeskScreen.java index 30ef89b..a2f6fea 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/screen/WritingDeskScreen.java +++ b/src/main/java/net/dnorberg/rivencraft/client/screen/WritingDeskScreen.java @@ -15,12 +15,14 @@ import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.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.Slot; import net.minecraft.world.item.ItemStack; +import org.lwjgl.glfw.GLFW; /** Legacy desk atlas layout, with four visible tabs out of the desk's 88 collections. */ public final class WritingDeskScreen extends AbstractContainerScreen { @@ -60,12 +62,15 @@ public final class WritingDeskScreen extends AbstractContainerScreen { sortAlphabetically = !sortAlphabetically; scrollRows = 0; - }).bounds(leftPos + 58, topPos, 20, 18).build()); + }).bounds(leftPos + 58, topPos, 20, 18) + .tooltip(Tooltip.create(Component.translatable("screen.rivencraft.writing_desk.sort"))).build()); allButton = addRenderableWidget(Button.builder(Component.literal("ALL"), button -> { allMode = !allMode; scrollRows = 0; @@ -75,7 +80,8 @@ public final class WritingDeskScreen extends AbstractContainerScreen= leftPos + 213 && mouseX < leftPos + 231 && mouseY >= topPos && mouseY < topPos + 18) { - graphics.renderTooltip(font, Component.literal(selectedCategory == null - ? "All categories" : selectedCategory.toString()), mouseX, mouseY); + graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.category", + selectedCategory == null + ? Component.translatable("screen.rivencraft.writing_desk.all_categories") + : Component.literal(selectedCategory.toString())), mouseX, mouseY); + } + if (mouseX >= leftPos && mouseX < leftPos + 58) { + int localY = mouseY - topPos; + if (localY >= 20 && localY < 29) + graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.previous_tabs"), mouseX, mouseY); + else if (localY >= 177 && localY < 186) + graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.next_tabs"), mouseX, mouseY); } } @@ -445,6 +460,16 @@ public final class WritingDeskScreen extends AbstractContainerScreen WRITING = + RecipeType.create(Rivencraft.MOD_ID, "writing", WritingGuide.class); + private static final RecipeType INK_MIXER = + RecipeType.create(Rivencraft.MOD_ID, "ink_mixer", InkMixerGuide.class); + + @Override + public ResourceLocation getPluginUid() { + return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "jei_writing"); + } + + @Override + public void registerCategories(IRecipeCategoryRegistration registration) { + IGuiHelper guiHelper = registration.getJeiHelpers().getGuiHelper(); + registration.addRecipeCategories(new WritingCategory(guiHelper), new InkMixerCategory(guiHelper)); + } + + @Override + public void registerRecipes(IRecipeRegistration registration) { + ItemStack panel = PageItem.createLinkPanelPage(); + registration.addRecipes(WRITING, List.of( + new WritingGuide(List.of(new ItemStack(Items.PAPER), new ItemStack(ModItems.INK_VIAL.get())), + panel, "jei.rivencraft.writing.link_panel_hand"), + new WritingGuide(List.of(panel, new ItemStack(Items.PAPER), new ItemStack(ModItems.INK_VIAL.get())), + panel, "jei.rivencraft.writing.copy"), + new WritingGuide(List.of(new ItemStack(Items.LEATHER), panel), + new ItemStack(ModItems.DESCRIPTIVE_BOOK.get()), "jei.rivencraft.writing.bind") + )); + registration.addRecipes(INK_MIXER, List.of(new InkMixerGuide(panel))); + } + + @Override + public void registerRecipeCatalysts(IRecipeCatalystRegistration registration) { + registration.addRecipeCatalysts(WRITING, ModItems.INK_VIAL.get(), + ModItems.WRITING_DESK.get(), ModItems.BOOK_BINDER.get()); + registration.addRecipeCatalysts(INK_MIXER, ModItems.INK_MIXER.get()); + } + + private record WritingGuide(List inputs, ItemStack output, String instructionKey) {} + private record InkMixerGuide(ItemStack panel) {} + + private static final class InkMixerCategory extends AbstractRecipeCategory { + InkMixerCategory(IGuiHelper guiHelper) { + super(INK_MIXER, Component.translatable("jei.rivencraft.ink_mixer"), + guiHelper.createDrawableIngredient(VanillaTypes.ITEM_STACK, + new ItemStack(ModItems.INK_MIXER.get())), 138, 80); + } + + @Override + public void setRecipe(IRecipeLayoutBuilder builder, InkMixerGuide recipe, IFocusGroup focuses) { + builder.addInputSlot(2, 3).setStandardSlotBackground().addItemStack(new ItemStack(Items.PAPER)); + builder.addInputSlot(23, 3).setStandardSlotBackground() + .addItemStack(new ItemStack(ModItems.INK_VIAL.get())); + builder.addOutputSlot(112, 3).setOutputSlotBackground().addItemStack(recipe.panel()); + builder.addOutputSlot(112, 46).setStandardSlotBackground() + .addItemStack(new ItemStack(Items.GLASS_BOTTLE)); + } + + @Override + public void draw(InkMixerGuide recipe, IRecipeSlotsView slots, GuiGraphics graphics, + double mouseX, double mouseY) { + graphics.drawWordWrap(Minecraft.getInstance().font, + Component.translatable("jei.rivencraft.ink_mixer.usage"), 2, 27, 104, 0x404040); + } + } + + private static final class WritingCategory extends AbstractRecipeCategory { + WritingCategory(IGuiHelper guiHelper) { + super(WRITING, Component.translatable("jei.rivencraft.writing"), + guiHelper.createDrawableIngredient(VanillaTypes.ITEM_STACK, + new ItemStack(ModItems.WRITING_DESK.get())), 138, 80); + } + + @Override + public void setRecipe(IRecipeLayoutBuilder builder, WritingGuide recipe, IFocusGroup focuses) { + for (int index = 0; index < recipe.inputs().size(); index++) { + builder.addInputSlot(2 + index * 21, 3).setStandardSlotBackground() + .addItemStack(recipe.inputs().get(index)); + } + builder.addOutputSlot(112, 3).setOutputSlotBackground().addItemStack(recipe.output()); + } + + @Override + public void draw(WritingGuide recipe, IRecipeSlotsView slots, GuiGraphics graphics, + double mouseX, double mouseY) { + graphics.drawWordWrap(Minecraft.getInstance().font, + Component.translatable(recipe.instructionKey()), 2, 27, 134, 0x404040); + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/InkMixerGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/InkMixerGameTests.java new file mode 100644 index 0000000..60ff99b --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/gametest/InkMixerGameTests.java @@ -0,0 +1,103 @@ +package net.dnorberg.rivencraft.gametest; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity; +import net.dnorberg.rivencraft.menu.InkMixerMenu; +import net.dnorberg.rivencraft.registry.ModBlocks; +import net.dnorberg.rivencraft.registry.ModItems; +import net.dnorberg.rivencraft.writing.WritingInkService; +import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +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.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +@GameTestHolder(Rivencraft.MOD_ID) +@PrefixGameTestTemplate(false) +public final class InkMixerGameTests { + private InkMixerGameTests() {} + + private static InkMixerBlockEntity place(GameTestHelper helper) { + BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1)); + helper.getLevel().setBlock(pos, ModBlocks.INK_MIXER.get().defaultBlockState(), 3); + return (InkMixerBlockEntity) helper.getLevel().getBlockEntity(pos); + } + + @GameTest(template = "empty") + public static void linkPanelRequiresTakingOutputAndPersistsInk(GameTestHelper helper) { + InkMixerBlockEntity mixer = place(helper); + mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER, 2)); + mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get())); + var player = helper.makeMockServerPlayerInLevel(); + player.moveTo(mixer.getBlockPos().getX() + 0.5, mixer.getBlockPos().getY() + 0.5, + mixer.getBlockPos().getZ() + 0.5); + InkMixerMenu menu = new InkMixerMenu(1, player.getInventory(), mixer); + menu.broadcastChanges(); + helper.assertTrue(menu.getSlot(InkMixerMenu.RESULT).getItem().is(ModItems.PAGE.get()), + "Paper and filled basin must preview a Link Panel"); + helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.PAPER).getCount(), 2, + "Preview must not consume paper"); + helper.assertValueEqual(mixer.inkRemaining(), 16, "Vial must fill the basin"); + helper.assertTrue(mixer.getItem(InkMixerBlockEntity.BOTTLES).is(Items.GLASS_BOTTLE), + "Consumed vial must return a vanilla glass bottle"); + menu.clicked(InkMixerMenu.RESULT, 0, ClickType.PICKUP, player); + helper.assertTrue(menu.getCarried().is(ModItems.PAGE.get()), "Taking result must give a Link Panel"); + helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.PAPER).getCount(), 1, + "Taking result must use exactly one paper"); + helper.assertValueEqual(mixer.inkRemaining(), 16 - WritingInkService.linkPanelCost(), + "Taking result must spend the configured ink cost"); + var saved = mixer.saveWithoutMetadata(helper.getLevel().registryAccess()); + var restored = new InkMixerBlockEntity(mixer.getBlockPos(), ModBlocks.INK_MIXER.get().defaultBlockState()); + restored.loadWithComponents(saved, helper.getLevel().registryAccess()); + helper.assertValueEqual(restored.inkRemaining(), mixer.inkRemaining(), "Ink basin must persist"); + helper.assertValueEqual(restored.getItem(InkMixerBlockEntity.PAPER).getCount(), 1, + "Paper input must persist"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void bottleCapacityBlocksAndThenPermitsRefill(GameTestHelper helper) { + InkMixerBlockEntity mixer = place(helper); + mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER)); + mixer.setItem(InkMixerBlockEntity.BOTTLES, new ItemStack(Items.GLASS_BOTTLE, 64)); + mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get())); + helper.assertValueEqual(mixer.inkRemaining(), 0, "Full bottle output must block refill"); + helper.assertTrue(mixer.getItem(InkMixerBlockEntity.INK).is(ModItems.INK_VIAL.get()), + "Blocked refill must preserve vial"); + helper.assertFalse(mixer.canCreateLinkPanel(), "Blocked refill must suppress output"); + mixer.removeItem(InkMixerBlockEntity.BOTTLES, 1); + helper.assertValueEqual(mixer.inkRemaining(), 16, "Making bottle space must immediately refill"); + helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.BOTTLES).getCount(), 64, + "One bottle must be returned after refill"); + helper.assertTrue(mixer.canCreateLinkPanel(), "Refill must enable Link Panel output"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void shiftClickNeedsInventorySpaceAndBreakingReturnsInputs(GameTestHelper helper) { + InkMixerBlockEntity mixer = place(helper); + mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER, 2)); + mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get())); + var player = helper.makeMockServerPlayerInLevel(); + player.moveTo(mixer.getBlockPos().getX() + 0.5, mixer.getBlockPos().getY() + 0.5, + mixer.getBlockPos().getZ() + 0.5); + InkMixerMenu menu = new InkMixerMenu(1, player.getInventory(), mixer); + for (int slot = 0; slot < 36; slot++) player.getInventory().setItem(slot, new ItemStack(Items.STONE, 64)); + helper.assertTrue(menu.quickMoveStack(player, InkMixerMenu.RESULT).isEmpty(), + "Full inventory must not consume mixer output"); + helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.PAPER).getCount(), 2, + "Full inventory must preserve paper"); + player.getInventory().setItem(0, ItemStack.EMPTY); + helper.assertTrue(menu.quickMoveStack(player, InkMixerMenu.RESULT).is(ModItems.PAGE.get()), + "Free inventory space must accept the Link Panel"); + helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.PAPER).getCount(), 1, + "Shift-click must consume only one paper"); + helper.getLevel().setBlock(mixer.getBlockPos(), Blocks.AIR.defaultBlockState(), 3); + helper.assertFalse(menu.stillValid(player), "Breaking the mixer must invalidate its menu"); + helper.succeed(); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/item/PageItem.java b/src/main/java/net/dnorberg/rivencraft/item/PageItem.java index a0c990f..d715233 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/PageItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/PageItem.java @@ -1,6 +1,7 @@ package net.dnorberg.rivencraft.item; import java.util.List; +import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.writing.PageContent; @@ -28,6 +29,23 @@ public final class PageItem extends Item { return create(PageContent.symbol(reference)); } + /** Fixed starter examples for the creative tab; the server still validates catalog membership on use. */ + public static ItemStack createCreativeStarterPage(String path) { + if (!List.of("terrain/standard", "biome/native", "lighting/normal", "weather/normal", + "celestial/normal_time", "feature/caves").contains(path)) { + throw new IllegalArgumentException("Not a starter symbol: " + path); + } + SymbolReference reference = new SymbolReference( + net.minecraft.resources.ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path)); + ItemStack page = create(PageContent.symbol(reference)); + String key = "symbol.rivencraft." + path.replace('/', '.'); + page.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), new SymbolDisplayMetadata( + reference.id(), key, key + ".description", + net.minecraft.resources.ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path.split("/")[0]), + true)); + return page; + } + public static ItemStack createLinkPanelPage() { return create(PageContent.LINK_PANEL); } diff --git a/src/main/java/net/dnorberg/rivencraft/menu/InkMixerMenu.java b/src/main/java/net/dnorberg/rivencraft/menu/InkMixerMenu.java new file mode 100644 index 0000000..f735e5b --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/menu/InkMixerMenu.java @@ -0,0 +1,119 @@ +package net.dnorberg.rivencraft.menu; + +import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity; +import net.dnorberg.rivencraft.item.PageItem; +import net.dnorberg.rivencraft.registry.ModItems; +import net.dnorberg.rivencraft.registry.ModMenus; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.DataSlot; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +/** Server-owned virtual output; taking it spends paper and the configured ink cost once. */ +public final class InkMixerMenu extends AbstractContainerMenu { + public static final int RESULT = InkMixerBlockEntity.SIZE; + private static final int PLAYER_START = RESULT + 1; + private final InkMixerBlockEntity mixer; + private final Container inputs; + private final SimpleContainer result = new SimpleContainer(1); + private final boolean clientSide; + private int inkRemaining; + + public InkMixerMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) { + this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos()) + instanceof InkMixerBlockEntity found ? found : null); + } + + public InkMixerMenu(int id, Inventory inventory, InkMixerBlockEntity mixer) { + super(ModMenus.INK_MIXER.get(), id); + this.mixer = mixer; + inputs = mixer == null ? new SimpleContainer(InkMixerBlockEntity.SIZE) : mixer; + clientSide = inventory.player.level().isClientSide; + addDataSlot(new DataSlot() { + @Override public int get() { return clientSide ? inkRemaining : mixer.inkRemaining(); } + @Override public void set(int value) { inkRemaining = value; } + }); + addSlot(new MixerSlot(InkMixerBlockEntity.INK, 8, 27)); + addSlot(new MixerSlot(InkMixerBlockEntity.PAPER, 8, 48)); + addSlot(new MixerSlot(InkMixerBlockEntity.BOTTLES, 152, 27)); + addSlot(new ResultSlot(152, 48)); + 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 inkRemaining() { return inkRemaining; } + + @Override public boolean stillValid(Player player) { + return mixer != null && mixer.stillValid(player); + } + + @Override public void broadcastChanges() { + refreshResult(); + super.broadcastChanges(); + } + + private void refreshResult() { + if (clientSide) return; + ItemStack preview = mixer != null && mixer.canCreateLinkPanel() + ? PageItem.createLinkPanelPage() : ItemStack.EMPTY; + if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview); + } + + @Override public ItemStack quickMoveStack(Player player, int index) { + if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY; + if (index == RESULT) { + refreshResult(); + if (!mixer.canCreateLinkPanel()) return ItemStack.EMPTY; + ItemStack output = result.getItem(0).copy(); + ItemStack remainder = output.copy(); + if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty()) + return ItemStack.EMPTY; + if (!mixer.consumeLinkPanelMaterials()) return ItemStack.EMPTY; + refreshResult(); + return output; + } + Slot slot = slots.get(index); + if (!slot.hasItem()) return ItemStack.EMPTY; + ItemStack source = slot.getItem(); + ItemStack original = source.copy(); + boolean moved; + if (index < RESULT) moved = moveItemStackTo(source, PLAYER_START, slots.size(), true); + else if (source.is(ModItems.INK_VIAL.get())) + moved = moveItemStackTo(source, InkMixerBlockEntity.INK, InkMixerBlockEntity.INK + 1, false); + else if (source.is(Items.PAPER)) + moved = moveItemStackTo(source, InkMixerBlockEntity.PAPER, InkMixerBlockEntity.PAPER + 1, false); + else moved = false; + if (!moved) return ItemStack.EMPTY; + if (source.isEmpty()) slot.setByPlayer(ItemStack.EMPTY); + else slot.setChanged(); + refreshResult(); + return original; + } + + private final class MixerSlot extends Slot { + MixerSlot(int index, int x, int y) { super(inputs, index, x, y); } + @Override public boolean mayPlace(ItemStack stack) { return inputs.canPlaceItem(getContainerSlot(), stack); } + } + + private final class ResultSlot extends Slot { + ResultSlot(int x, int y) { super(result, 0, x, y); } + @Override public boolean mayPlace(ItemStack stack) { return false; } + @Override public boolean mayPickup(Player player) { + return clientSide ? hasItem() : stillValid(player) && mixer.canCreateLinkPanel(); + } + @Override public void onTake(Player player, ItemStack stack) { + if (!clientSide && mixer.consumeLinkPanelMaterials()) refreshResult(); + super.onTake(player, stack); + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java b/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java index 11aacd6..9efb719 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java @@ -4,6 +4,7 @@ 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.dnorberg.rivencraft.block.entity.InkMixerBlockEntity; import net.minecraft.core.registries.Registries; import net.minecraft.world.level.block.entity.BlockEntityType; import net.neoforged.bus.api.IEventBus; @@ -24,6 +25,9 @@ public final class ModBlockEntities { public static final DeferredHolder, BlockEntityType> BOOK_BINDER = BLOCK_ENTITIES.register("book_binder", () -> BlockEntityType.Builder .of(BookBinderBlockEntity::new, ModBlocks.BOOK_BINDER.get()).build(null)); + public static final DeferredHolder, BlockEntityType> INK_MIXER = + BLOCK_ENTITIES.register("ink_mixer", () -> BlockEntityType.Builder + .of(InkMixerBlockEntity::new, ModBlocks.INK_MIXER.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 e0fd842..c90f35c 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java @@ -4,6 +4,7 @@ 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.dnorberg.rivencraft.block.InkMixerBlock; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockBehaviour; import net.neoforged.bus.api.IEventBus; @@ -26,6 +27,10 @@ public final class ModBlocks { "book_binder", BookBinderBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion() ); + public static final DeferredBlock INK_MIXER = BLOCKS.registerBlock( + "ink_mixer", InkMixerBlock::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 9aca6a8..276fab8 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.INK_MIXER.get()); output.accept(ModItems.BOOK_BINDER.get()); }) .build()); @@ -32,7 +33,12 @@ public final class ModCreativeTabs { .title(Component.translatable("itemGroup.rivencraft.pages")) .icon(PageItem::createLinkPanelPage) .displayItems((parameters, output) -> { + output.accept(ModItems.PAGE.get()); output.accept(PageItem.createLinkPanelPage()); + for (String id : new String[] {"terrain/standard", "biome/native", "lighting/normal", + "weather/normal", "celestial/normal_time", "feature/caves"}) { + output.accept(PageItem.createCreativeStarterPage(id)); + } }) .build()); diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java b/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java index 8f48b2a..1cdf953 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java @@ -42,6 +42,7 @@ public final class ModItems { ); public static final DeferredItem WRITING_DESK = ITEMS.registerSimpleBlockItem(ModBlocks.WRITING_DESK); public static final DeferredItem BOOK_BINDER = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_BINDER); + public static final DeferredItem INK_MIXER = ITEMS.registerSimpleBlockItem(ModBlocks.INK_MIXER); 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 aff506f..a59029a 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModMenus.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModMenus.java @@ -4,6 +4,7 @@ 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.dnorberg.rivencraft.menu.InkMixerMenu; import net.minecraft.core.registries.Registries; import net.minecraft.world.inventory.MenuType; import net.neoforged.bus.api.IEventBus; @@ -21,6 +22,8 @@ public final class ModMenus { MENUS.register("writing_desk", () -> IMenuTypeExtension.create(WritingDeskMenu::new)); public static final DeferredHolder, MenuType> BOOK_BINDER = MENUS.register("book_binder", () -> IMenuTypeExtension.create(BookBinderMenu::new)); + public static final DeferredHolder, MenuType> INK_MIXER = + MENUS.register("ink_mixer", () -> IMenuTypeExtension.create(InkMixerMenu::new)); private ModMenus() { } diff --git a/src/main/resources/assets/rivencraft/blockstates/ink_mixer.json b/src/main/resources/assets/rivencraft/blockstates/ink_mixer.json new file mode 100644 index 0000000..9199b63 --- /dev/null +++ b/src/main/resources/assets/rivencraft/blockstates/ink_mixer.json @@ -0,0 +1,5 @@ +{ + "variants": { + "": { "model": "rivencraft:block/ink_mixer" } + } +} diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index 9a1b39d..2339084 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -20,6 +20,9 @@ "container.rivencraft.writing_desk": "Writing Desk", "block.rivencraft.book_binder": "Book Binder", "container.rivencraft.book_binder": "Book Binder", + "block.rivencraft.ink_mixer": "Ink Mixer", + "container.rivencraft.ink_mixer": "Ink Mixer", + "screen.rivencraft.ink_mixer.ink": "Ink: %s/%s", "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", @@ -28,9 +31,17 @@ "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.previous_pages": "Scroll pages left (Left Arrow)", + "screen.rivencraft.book_binder.next_pages": "Scroll pages right (Right Arrow)", "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.sort": "Sort pages by name (A–Z)", + "screen.rivencraft.writing_desk.all": "Show pages from all tabs", + "screen.rivencraft.writing_desk.category": "Cycle page category: %s", + "screen.rivencraft.writing_desk.all_categories": "All categories", + "screen.rivencraft.writing_desk.previous_tabs": "Previous four tabs (Page Up)", + "screen.rivencraft.writing_desk.next_tabs": "Next four tabs (Page Down)", "screen.rivencraft.writing_desk.no_symbols": "No available symbols", "item.rivencraft.ink_vial.remaining": "Ink: %s/%s", "item.rivencraft.ink_vial.usage": "Use with paper in the other hand to prepare a Link Panel.", @@ -117,6 +128,14 @@ "screen.rivencraft.book.explore": "Explore", "screen.rivencraft.book.exploring": "Exploring", "screen.rivencraft.book.return_from_explore": "Right-click to return", + "screen.rivencraft.book.previous_page": "Previous page (Left Arrow)", + "screen.rivencraft.book.next_page": "Next page (Right Arrow)", + "jei.rivencraft.writing": "Rivencraft Writing", + "jei.rivencraft.writing.link_panel_hand": "Hold paper and an Ink Vial in opposite hands; use the vial to make a Link Panel.", + "jei.rivencraft.ink_mixer": "Ink Mixer", + "jei.rivencraft.ink_mixer.usage": "Paper + ink makes a Link Panel. One vial fills 16 ink; each panel uses the configured ink cost. The empty vial returns as a glass bottle.", + "jei.rivencraft.writing.copy": "At a Writing Desk, select a stored Page, add paper and ink, then take the copy. The source Page stays.", + "jei.rivencraft.writing.bind": "At a Book Binder, name the Age and add a cover plus Link Panel. Optional symbol Pages follow the panel.", "itemGroup.rivencraft": "Rivencraft", "itemGroup.rivencraft.pages": "Rivencraft Pages" } diff --git a/src/main/resources/assets/rivencraft/models/block/ink_mixer.json b/src/main/resources/assets/rivencraft/models/block/ink_mixer.json new file mode 100644 index 0000000..27430ac --- /dev/null +++ b/src/main/resources/assets/rivencraft/models/block/ink_mixer.json @@ -0,0 +1,23 @@ +{ + "parent": "minecraft:block/block", + "textures": { + "particle": "rivencraft:block/ink_mixer_side", + "side": "rivencraft:block/ink_mixer_side", + "top": "rivencraft:block/ink_mixer_top", + "bottom": "rivencraft:block/ink_mixer_bottom" + }, + "elements": [ + { + "from": [0, 0, 0], + "to": [16, 12, 16], + "faces": { + "north": { "texture": "#side", "uv": [0, 4, 16, 16] }, + "east": { "texture": "#side", "uv": [0, 4, 16, 16] }, + "south": { "texture": "#side", "uv": [0, 4, 16, 16] }, + "west": { "texture": "#side", "uv": [0, 4, 16, 16] }, + "up": { "texture": "#top" }, + "down": { "texture": "#bottom" } + } + } + ] +} diff --git a/src/main/resources/assets/rivencraft/models/item/ink_mixer.json b/src/main/resources/assets/rivencraft/models/item/ink_mixer.json new file mode 100644 index 0000000..d8263e9 --- /dev/null +++ b/src/main/resources/assets/rivencraft/models/item/ink_mixer.json @@ -0,0 +1,3 @@ +{ + "parent": "rivencraft:block/ink_mixer" +} diff --git a/src/main/resources/assets/rivencraft/textures/block/ink_mixer_bottom.png b/src/main/resources/assets/rivencraft/textures/block/ink_mixer_bottom.png new file mode 100644 index 0000000000000000000000000000000000000000..e0bfcbe7d1f98999c826393b702f8a7b11177559 GIT binary patch literal 747 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/data/rivencraft/loot_table/blocks/ink_mixer.json b/src/main/resources/data/rivencraft/loot_table/blocks/ink_mixer.json new file mode 100644 index 0000000..316c021 --- /dev/null +++ b/src/main/resources/data/rivencraft/loot_table/blocks/ink_mixer.json @@ -0,0 +1,4 @@ +{ + "type": "minecraft:block", + "pools": [{ "rolls": 1, "entries": [{ "type": "minecraft:item", "name": "rivencraft:ink_mixer" }] }] +} diff --git a/src/main/resources/data/rivencraft/recipe/ink_mixer.json b/src/main/resources/data/rivencraft/recipe/ink_mixer.json new file mode 100644 index 0000000..14e81cc --- /dev/null +++ b/src/main/resources/data/rivencraft/recipe/ink_mixer.json @@ -0,0 +1,11 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "pattern": ["S S", "SVS", "PSP"], + "key": { + "S": { "item": "minecraft:stone" }, + "V": { "item": "minecraft:glass_bottle" }, + "P": { "tag": "minecraft:planks" } + }, + "result": { "id": "rivencraft:ink_mixer", "count": 1 } +}