Compare commits

..
2 Commits
Author SHA1 Message Date
dnorberg 568490e625 Persist ordered book writing and restore Legacy book pages
Build / build (push) Canceled after 0s
2026-09-29 09:46:17 -04:00
dnorberg 85da1f047d Add Legacy-style Book Binder
Build / build (push) Canceled after 0s
2026-09-29 09:06:34 -04:00
46 changed files with 1664 additions and 119 deletions

No files matched your search

+12
View File
@@ -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
-----------------------------
+25
View File
@@ -59,6 +59,29 @@ 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; the Descriptive Book screen's turned pages show ordered symbols and
unresolved IDs. Until Milestone 5, symbols do not affect generation and written
books use the same random Age fallback. The Descriptive Book screen follows
Legacy's book spread: the title and Age details are on the left, the destination
preview is on the right, and clicks in the bottom corners turn pages. Ordered
symbol pages appear before the final blank spread; their pictograms await
Milestone 5. Link-panel-only books show their details in the enchantment-table
font.
## Linking Book
Craft a Linking Book from a vanilla book and an ender pearl, then use it in the
@@ -68,6 +91,8 @@ its preview panel is clicked. The destination is stored on the item and
survives inventory and world saves. Travel only succeeds if the target
dimension is loaded and a non-fluid, passable landing position can be found
nearby.
The Linking Book uses the same Legacy page layout and bottom-corner page turns
as the Descriptive Book, but without the Descriptive Book's gold cover edges.
Successful book travel plays Mystcraft Legacy's original linking sound at both
the departure and arrival locations.
+41
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+7 -1
View File
@@ -8,11 +8,17 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
| --- | --- | --- | --- |
| `assets/rivencraft/textures/item/page.png` | `assets/mystcraft/textures/items/page_background.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/gui/notebook_pages.png` | `assets/mystcraft/gui/bookui_pagel.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Notebook draws the native 30×40 page at atlas coordinates `(156, 0)` |
| `assets/rivencraft/textures/gui/bookui_cover.png` | `assets/mystcraft/gui/bookui_cover.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; shared book cover, with gold edges drawn only for Descriptive Books |
| `assets/rivencraft/textures/gui/bookui_pagel.png` | `assets/mystcraft/gui/bookui_pagel.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; turned left page |
| `assets/rivencraft/textures/gui/bookui_pager.png` | `assets/mystcraft/gui/bookui_pager.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; link-panel frame on the first right page |
| `assets/rivencraft/textures/gui/bookui_rpage_full.png` | `assets/mystcraft/gui/bookui_rpage_full.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; full right written page |
| `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 |
+12
View File
@@ -40,6 +40,18 @@ a symbol reference or a baseline link-panel marker. `OrderedWriting` retains up
to 256 entries in exact player-authored order. All three contracts have both
persistent codecs and bounded network codecs.
Assembled Descriptive Books store `OrderedWriting` schema version 1 alongside
their Age reference. Books assembled in 0.3.7.2 saved writing as a bare list;
that form still loads as version 1 without reordering or dropping unknown IDs.
Older page-free books without writing continue to work unchanged. Copying a
book copies its writing and Age reference; merely copying it never creates a
new Age. The Book Binder creates the Age when its finished output is taken.
The Descriptive Book screen can inspect the ordered pages by turning them from
the bottom corners and identifies unresolved IDs using server-provided symbol
metadata. Until Milestone 5, the
saved symbols are informational only: Age creation uses the existing random
fallback and does not apply them to generation.
Removing a data pack never removes its IDs from saved writing. A missing ID is
resolved as client-safe **Unresolved Symbol** display metadata while retaining
the original namespaced ID for diagnostics and later restoration. This is
+1 -1
View File
@@ -35,7 +35,7 @@ mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.3.6.9
mod_version=0.3.8.2
immersive_portals_version=6.0.7
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources.
@@ -0,0 +1,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<BookBinderBlock> 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<? extends BaseEntityBlock> codec() { return CODEC; }
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> 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);
}
}
@@ -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<ItemStack> 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<WritingEntry> 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<ItemStack> 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<ItemStack> 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<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
return saveWithoutMetadata(registries);
}
@Override public void setChanged() {
super.setChanged();
if (level != null && !level.isClientSide) {
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
}
}
}
@@ -92,8 +92,8 @@ public final class ClientBookInteractionHandler {
return;
}
minecraft.setScreen(payload.fromStand()
? new DescriptiveBookScreen(payload.source().pos(), payload.book(), payload.traits())
: new DescriptiveBookScreen(hand, payload.book(), payload.traits()));
? new DescriptiveBookScreen(payload.source().pos(), payload.book(), payload.traits(), payload.writingSymbols())
: new DescriptiveBookScreen(hand, payload.book(), payload.traits(), payload.writingSymbols()));
});
}
}
@@ -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
@@ -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<BookBinderMenu> {
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);
}
}
@@ -1,5 +1,7 @@
package net.dnorberg.rivencraft.client.screen;
import java.util.List;
import java.util.Locale;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeTraits;
import net.dnorberg.rivencraft.client.ClientBookExploreOverlay;
@@ -10,6 +12,9 @@ import net.dnorberg.rivencraft.network.RequestLinkPreviewPayload;
import net.dnorberg.rivencraft.network.LinkPreviewPayload;
import net.dnorberg.rivencraft.network.ReleaseLinkPreviewPayload;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderer;
import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderers;
import net.minecraft.core.BlockPos;
@@ -25,47 +30,56 @@ import net.minecraft.world.InteractionHand;
import net.neoforged.neoforge.network.PacketDistributor;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
public final class DescriptiveBookScreen extends Screen implements ExplorableBookScreen {
private static final int BOOK_TEXT_COLOR = 0xFF3B281B;
private static final ResourceLocation BOOK_TEXTURE = ResourceLocation.fromNamespaceAndPath(
"rivencraft", "textures/gui/linking_book.png"
);
private static final int UNRESOLVED_TEXT_COLOR = 0xFF9B302C;
private static final ResourceLocation ENCHANTMENT_FONT = ResourceLocation.withDefaultNamespace("alt");
private static final int BOOK_WIDTH = 327;
private static final int BOOK_HEIGHT = 199;
private static final int TEXTURE_WIDTH = 512;
private static final int TEXTURE_HEIGHT = 312;
private static final int LINK_PANEL_X = 28;
private static final int LINK_PANEL_Y = 35;
private static final int LINK_PANEL_WIDTH = 126;
private static final int LINK_PANEL_HEIGHT = 86;
private static final int LINK_PANEL_X = 173;
private static final int LINK_PANEL_Y = 20;
private static final int LINK_PANEL_WIDTH = 132;
private static final int LINK_PANEL_HEIGHT = 83;
private static final Component EXPLORE_ICON = Component.literal("👁");
private final AgeReference age;
private final ResourceLocation generationProfile;
private final AgeTraits traits;
private final OrderedWriting writing;
private final List<SymbolDisplayMetadata> writingSymbols;
private final boolean pageFree;
private final InteractionHand hand;
private final BlockPos bookStandPos;
private DestinationPreviewRenderer preview;
private EditBox titleInput;
private Button exploreButton;
private Button createButton;
private Button cancelButton;
private int pageIndex;
private boolean movingToExploreOverlay;
public DescriptiveBookScreen(InteractionHand hand, ItemStack book, @Nullable AgeTraits traits) {
public DescriptiveBookScreen(InteractionHand hand, ItemStack book, @Nullable AgeTraits traits,
List<SymbolDisplayMetadata> writingSymbols) {
super(book.getHoverName());
age = book.get(ModDataComponents.AGE_REFERENCE.get());
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
this.traits = traits;
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
this.writingSymbols = List.copyOf(writingSymbols);
pageFree = writing.entries().stream().noneMatch(entry -> entry.kind() == WritingEntry.Kind.SYMBOL);
this.hand = hand;
bookStandPos = null;
}
public DescriptiveBookScreen(BlockPos bookStandPos, ItemStack book, @Nullable AgeTraits traits) {
public DescriptiveBookScreen(BlockPos bookStandPos, ItemStack book, @Nullable AgeTraits traits,
List<SymbolDisplayMetadata> writingSymbols) {
super(book.getHoverName());
age = book.get(ModDataComponents.AGE_REFERENCE.get());
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
this.traits = traits;
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
this.writingSymbols = List.copyOf(writingSymbols);
pageFree = writing.entries().stream().noneMatch(entry -> entry.kind() == WritingEntry.Kind.SYMBOL);
hand = InteractionHand.MAIN_HAND;
this.bookStandPos = bookStandPos.immutable();
}
@@ -75,19 +89,22 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2;
if (age == null) {
int rightPage = left + 177;
titleInput = new EditBox(font, rightPage, top + 69, 124, 20,
int leftPage = left + 40;
titleInput = new EditBox(font, leftPage, top + 69, 110, 20,
Component.translatable("screen.rivencraft.age.title"));
titleInput.setMaxLength(ModNetworking.MAX_BOOK_TITLE_LENGTH);
titleInput.setHint(Component.translatable("screen.rivencraft.age.title_hint"));
addRenderableWidget(titleInput);
setInitialFocus(titleInput);
addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.age.create"), button -> createAge())
.bounds(rightPage, top + 100, 59, 20)
createButton = addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.age.create"), button -> createAge())
.bounds(leftPage, top + 100, 53, 20)
.build());
addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
.bounds(rightPage + 65, top + 100, 59, 20)
cancelButton = addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
.bounds(leftPage + 57, top + 100, 53, 20)
.build());
titleInput.visible = pageIndex == 0;
createButton.visible = pageIndex == 0;
cancelButton.visible = pageIndex == 0;
} else {
boolean requestPreview = preview == null;
if (requestPreview) {
@@ -96,8 +113,9 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
if (preview.supportsExploration()) {
exploreButton = addRenderableWidget(Button.builder(
EXPLORE_ICON, button -> enterExploreOverlay())
.bounds(left + 81, top + 145, 20, 20).build());
.bounds(left + 229, top + 145, 20, 20).build());
exploreButton.active = bookStandPos == null;
exploreButton.visible = pageIndex == 0;
}
if (requestPreview) {
PacketDistributor.sendToServer(new RequestLinkPreviewPayload(
@@ -137,45 +155,95 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
private void renderBook(GuiGraphics graphics, float partialTick) {
int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2;
graphics.blit(BOOK_TEXTURE, left, top, BOOK_WIDTH, BOOK_HEIGHT, 0, 0,
TEXTURE_WIDTH, TEXTURE_HEIGHT, TEXTURE_WIDTH, TEXTURE_HEIGHT);
LegacyBookArtwork.drawCover(graphics, left, top, true);
if (pageIndex > 0) {
LegacyBookArtwork.drawLeftPage(graphics, left, top);
}
if (pageIndex == 0) {
if (age != null && preview != null) {
preview.render(graphics, left + LINK_PANEL_X, top + LINK_PANEL_Y,
LINK_PANEL_WIDTH, LINK_PANEL_HEIGHT, partialTick);
}
LegacyBookArtwork.drawPanelPage(graphics, left, top);
graphics.enableScissor(left + 38, top + 38, left + 154, top + 174);
renderFirstPageText(graphics, left, top);
graphics.disableScissor();
} else if (pageIndex < pageCount()) {
LegacyBookArtwork.drawFullPage(graphics, left, top);
renderWrittenPage(graphics, left, top);
}
String counter = pageIndex + "/" + pageCount();
graphics.drawString(font, counter, left + 165 - font.width(counter) / 2,
top + 185, 0xFF000000, false);
}
private int pageCount() {
// Older page-free Descriptive Books do not have a BOOK_WRITING component.
return Math.max(1, writing.entries().size());
}
private void renderFirstPageText(GuiGraphics graphics, int left, int top) {
int x = left + 40;
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.title"),
x, top + 40, 110);
if (age == null) {
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.name_prompt"),
left + 177, top + 45, 124);
} else {
preview.render(graphics, left + LINK_PANEL_X, top + LINK_PANEL_Y, LINK_PANEL_WIDTH, LINK_PANEL_HEIGHT, partialTick);
int detailsY = drawWrappedWithoutShadow(
graphics, Component.literal(age.name()), left + 177, top + 43, 124) + 3;
detailsY = Math.max(detailsY, top + 72);
if (generationProfile != null) {
Component profileName = generationProfile.getNamespace().equals("rivencraft")
? Component.translatable("generator.rivencraft." + generationProfile.getPath())
: Component.literal(generationProfile.toString());
detailsY = drawWrappedWithoutShadow(graphics,
Component.translatable("screen.rivencraft.age.generator", profileName),
left + 177, detailsY, 124) + 2;
}
if (traits != null) {
detailsY = drawTrait(graphics, "biomes", traitName("biome_distribution", traits.biomeDistribution()),
left + 177, detailsY);
detailsY = drawTrait(graphics, "lighting", traitName("lighting", traits.lighting()),
left + 177, detailsY);
detailsY = drawTrait(graphics, "weather", traitName("weather", traits.weather()),
left + 177, detailsY);
Component dayLength = traits.hasStoppedCelestialCycle()
? Component.translatable("trait.rivencraft.day_length.stopped")
: Component.literal(String.format(Locale.ROOT, "%.2fx", traits.celestialPeriod()));
detailsY = drawTrait(graphics, "day_length", dayLength, left + 177, detailsY);
detailsY = drawTrait(graphics, "celestial_angle",
Component.literal(String.format(Locale.ROOT, "%.0f°", traits.celestialAngle())),
left + 177, detailsY);
drawTrait(graphics, "celestial_phase",
Component.literal(String.format(Locale.ROOT, "%.0f%%", traits.celestialPhase() * 100.0F)),
left + 177, detailsY);
}
x, top + 52, 110);
return;
}
int detailsY = drawWrappedWithoutShadow(graphics, Component.literal(age.name()),
x + 10, top + 52, 100) + 10;
if (generationProfile != null) {
Component profileName = generationProfile.getNamespace().equals("rivencraft")
? Component.translatable("generator.rivencraft." + generationProfile.getPath())
: Component.literal(generationProfile.toString());
detailsY = drawWrappedWithoutShadow(graphics,
Component.translatable("screen.rivencraft.age.generator", profileName),
x, detailsY, 110) + 2;
}
if (traits != null) {
detailsY = drawTrait(graphics, "biomes", traitName("biome_distribution", traits.biomeDistribution()), x, detailsY);
detailsY = drawTrait(graphics, "lighting", traitName("lighting", traits.lighting()), x, detailsY);
detailsY = drawTrait(graphics, "weather", traitName("weather", traits.weather()), x, detailsY);
Component dayLength = traits.hasStoppedCelestialCycle()
? Component.translatable("trait.rivencraft.day_length.stopped")
: Component.literal(String.format(Locale.ROOT, "%.2fx", traits.celestialPeriod()));
detailsY = drawTrait(graphics, "day_length", dayLength, x, detailsY);
detailsY = drawTrait(graphics, "celestial_angle",
Component.literal(String.format(Locale.ROOT, "%.0f°", traits.celestialAngle())), x, detailsY);
drawTrait(graphics, "celestial_phase",
Component.literal(String.format(Locale.ROOT, "%.0f%%", traits.celestialPhase() * 100.0F)), x, detailsY);
}
}
private void renderWrittenPage(GuiGraphics graphics, int left, int top) {
WritingEntry entry = writing.entries().get(pageIndex);
int x = left + 176;
if (entry.kind() == WritingEntry.Kind.LINK_PANEL) {
drawWrappedWithoutShadow(graphics, Component.translatable("item.rivencraft.page.link_panel"),
x, top + 39, 126);
return;
}
int symbolIndex = (int) writing.entries().subList(0, pageIndex + 1).stream()
.filter(candidate -> candidate.kind() == WritingEntry.Kind.SYMBOL).count() - 1;
SymbolDisplayMetadata display = symbolIndex < writingSymbols.size()
? writingSymbols.get(symbolIndex) : SymbolDisplayMetadata.unresolved(entry.symbol().orElseThrow());
int color = display.resolved() ? BOOK_TEXT_COLOR : UNRESOLVED_TEXT_COLOR;
drawWrappedWithoutShadow(graphics, Component.translatable(display.nameTranslationKey()),
x, top + 39, 126, color);
drawWrappedWithoutShadow(graphics, Component.literal(display.id().toString()),
x, top + 64, 126, color);
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.pages_not_applied"),
x, top + 137, 126);
}
private void turnPage(int direction) {
pageIndex = Math.max(0, Math.min(pageCount(), pageIndex + direction));
if (titleInput != null) titleInput.visible = pageIndex == 0;
if (createButton != null) createButton.visible = pageIndex == 0;
if (cancelButton != null) cancelButton.visible = pageIndex == 0;
if (exploreButton != null) exploreButton.visible = pageIndex == 0;
}
@Override
@@ -201,14 +269,25 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
resetExploration();
return true;
}
if (age != null && button == 0) {
int panelLeft = (width - BOOK_WIDTH) / 2 + LINK_PANEL_X;
int panelTop = (height - BOOK_HEIGHT) / 2 + LINK_PANEL_Y;
if (mouseX >= panelLeft && mouseX < panelLeft + LINK_PANEL_WIDTH
&& mouseY >= panelTop && mouseY < panelTop + LINK_PANEL_HEIGHT) {
if (button == 0) {
int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2;
if (pageIndex == 0 && age != null
&& mouseX >= left + LINK_PANEL_X && mouseX < left + LINK_PANEL_X + LINK_PANEL_WIDTH
&& mouseY >= top + LINK_PANEL_Y && mouseY < top + LINK_PANEL_Y + LINK_PANEL_HEIGHT) {
link();
return true;
}
if (mouseY >= top + 164 && mouseY < top + 195) {
if (pageIndex > 0 && mouseX >= left + 7 && mouseX < left + 35) {
turnPage(-1);
return true;
}
if (pageIndex < pageCount() && mouseX >= left + 293 && mouseX < left + 320) {
turnPage(1);
return true;
}
}
}
return super.mouseClicked(mouseX, mouseY, button);
}
@@ -268,7 +347,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
int top = (height - BOOK_HEIGHT) / 2;
graphics.drawCenteredString(font,
Component.translatable("screen.rivencraft.book.return_from_explore"),
left + 91, top + 154, BOOK_TEXT_COLOR);
left + 239, top + 154, BOOK_TEXT_COLOR);
}
@Override
@@ -317,7 +396,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
private int drawTrait(GuiGraphics graphics, String key, Component value, int x, int y) {
return drawWrappedWithoutShadow(graphics,
Component.translatable("screen.rivencraft.age.trait." + key, value), x, y, 124) + 2;
Component.translatable("screen.rivencraft.age.trait." + key, value), x, y, 110) + 2;
}
private static Component traitName(String group, Enum<?> value) {
@@ -326,8 +405,14 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
}
private int drawWrappedWithoutShadow(GuiGraphics graphics, Component text, int x, int y, int maxWidth) {
for (FormattedCharSequence line : font.split(text, maxWidth)) {
graphics.drawString(font, line, x, y, BOOK_TEXT_COLOR, false);
return drawWrappedWithoutShadow(graphics, text, x, y, maxWidth, BOOK_TEXT_COLOR);
}
private int drawWrappedWithoutShadow(GuiGraphics graphics, Component text, int x, int y, int maxWidth, int color) {
Component displayed = pageFree && age != null && pageIndex == 0
? text.copy().withStyle(style -> style.withFont(ENCHANTMENT_FONT)) : text;
for (FormattedCharSequence line : font.split(displayed, maxWidth)) {
graphics.drawString(font, line, x, y, color, false);
y += font.lineHeight;
}
return y;
@@ -0,0 +1,48 @@
package net.dnorberg.rivencraft.client.screen;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.resources.ResourceLocation;
/** Original Mystcraft Legacy book atlases and page composition (LGPL-3.0,
* copyright XCompWiz, revision 9bc8ddc061845df0cd5ea47f4fdef773cff786ae). */
final class LegacyBookArtwork {
private static final int ATLAS_SIZE = 256;
private static final ResourceLocation COVER = texture("bookui_cover.png");
private static final ResourceLocation LEFT_PAGE = texture("bookui_pagel.png");
private static final ResourceLocation RIGHT_PANEL = texture("bookui_pager.png");
private static final ResourceLocation RIGHT_FULL = texture("bookui_rpage_full.png");
private LegacyBookArtwork() {}
static void drawCover(GuiGraphics graphics, int left, int top, boolean gilded) {
blit(graphics, COVER, left, top + 7, 34, 192, 152, 0);
blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0);
blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0);
blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0);
if (gilded) {
blit(graphics, COVER, left, top + 7, 34, 192, 186, 0);
blit(graphics, COVER, left + 293, top + 7, 34, 192, 186, 0);
}
}
static void drawLeftPage(GuiGraphics graphics, int left, int top) {
blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0);
}
static void drawPanelPage(GuiGraphics graphics, int left, int top) {
blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0);
}
static void drawFullPage(GuiGraphics graphics, int left, int top) {
blit(graphics, RIGHT_FULL, left + 163, top, 156, 195, 0, 0);
}
private static ResourceLocation texture(String filename) {
return ResourceLocation.fromNamespaceAndPath("rivencraft", "textures/gui/" + filename);
}
private static void blit(GuiGraphics graphics, ResourceLocation texture, int x, int y,
int width, int height, int u, int v) {
graphics.blit(texture, x, y, width, height, u, v, width, height, ATLAS_SIZE, ATLAS_SIZE);
}
}
@@ -18,8 +18,9 @@ import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.util.FormattedCharSequence;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
@@ -27,17 +28,12 @@ import net.neoforged.neoforge.network.PacketDistributor;
public final class LinkingBookScreen extends Screen implements ExplorableBookScreen {
private static final int BOOK_TEXT_COLOR = 0xFF3B281B;
private static final ResourceLocation BOOK_TEXTURE = ResourceLocation.fromNamespaceAndPath(
"rivencraft", "textures/gui/linking_book.png"
);
private static final int BOOK_WIDTH = 327;
private static final int BOOK_HEIGHT = 199;
private static final int TEXTURE_WIDTH = 512;
private static final int TEXTURE_HEIGHT = 312;
private static final int PREVIEW_X = 28;
private static final int PREVIEW_Y = 35;
private static final int PREVIEW_WIDTH = 126;
private static final int PREVIEW_HEIGHT = 86;
private static final int PREVIEW_X = 173;
private static final int PREVIEW_Y = 20;
private static final int PREVIEW_WIDTH = 132;
private static final int PREVIEW_HEIGHT = 83;
private static final Component EXPLORE_ICON = Component.literal("👁");
private final InteractionHand hand;
@@ -47,6 +43,9 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
private DestinationPreviewRenderer preview;
private EditBox titleInput;
private Button exploreButton;
private Button bindButton;
private Button cancelButton;
private int pageIndex;
private boolean movingToExploreOverlay;
public LinkingBookScreen(InteractionHand hand, ItemStack book) {
@@ -70,19 +69,22 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2;
if (target == null) {
int rightPage = left + 177;
titleInput = new EditBox(font, rightPage, top + 69, 124, 20,
int leftPage = left + 40;
titleInput = new EditBox(font, leftPage, top + 69, 110, 20,
Component.translatable("screen.rivencraft.book.title"));
titleInput.setMaxLength(ModNetworking.MAX_BOOK_TITLE_LENGTH);
titleInput.setHint(Component.translatable("screen.rivencraft.book.title_hint"));
addRenderableWidget(titleInput);
setInitialFocus(titleInput);
addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.book.bind"), button -> bind())
.bounds(rightPage, top + 100, 59, 20)
bindButton = addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.book.bind"), button -> bind())
.bounds(leftPage, top + 100, 53, 20)
.build());
addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
.bounds(rightPage + 65, top + 100, 59, 20)
cancelButton = addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
.bounds(leftPage + 57, top + 100, 53, 20)
.build());
titleInput.visible = pageIndex == 0;
bindButton.visible = pageIndex == 0;
cancelButton.visible = pageIndex == 0;
} else {
boolean requestPreview = preview == null;
if (requestPreview) {
@@ -91,8 +93,9 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
if (preview.supportsExploration()) {
exploreButton = addRenderableWidget(Button.builder(
EXPLORE_ICON, button -> enterExploreOverlay())
.bounds(left + 81, top + 145, 20, 20).build());
.bounds(left + 229, top + 145, 20, 20).build());
exploreButton.active = bookStandPos == null;
exploreButton.visible = pageIndex == 0;
}
if (requestPreview) {
PacketDistributor.sendToServer(new RequestLinkPreviewPayload(
@@ -133,28 +136,42 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
private void renderBook(GuiGraphics graphics, float partialTick) {
int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2;
graphics.blit(BOOK_TEXTURE, left, top, BOOK_WIDTH, BOOK_HEIGHT, 0, 0,
TEXTURE_WIDTH, TEXTURE_HEIGHT, TEXTURE_WIDTH, TEXTURE_HEIGHT);
if (target == null) {
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.book.name_prompt"),
left + 177, top + 45, 124);
LegacyBookArtwork.drawCover(graphics, left, top, false);
if (pageIndex > 0) {
LegacyBookArtwork.drawLeftPage(graphics, left, top);
} else {
int previewLeft = left + PREVIEW_X;
int previewTop = top + PREVIEW_Y;
preview.render(graphics, previewLeft, previewTop, PREVIEW_WIDTH, PREVIEW_HEIGHT, partialTick);
int rightCenter = left + 239;
drawWrappedWithoutShadow(graphics, title, left + 177, top + 43, 124);
BlockPos pos = target.location().pos();
drawCenteredWithoutShadow(graphics,
LinkingBookItem.destinationName(book, target), rightCenter, top + 76);
drawCenteredWithoutShadow(graphics,
Component.literal(pos.getX() + ", " + pos.getY() + ", " + pos.getZ()), rightCenter, top + 89);
drawCenteredWithoutShadow(graphics,
Component.translatable("screen.rivencraft.book.click_to_link"), rightCenter, top + 119);
if (target != null && preview != null) {
preview.render(graphics, left + PREVIEW_X, top + PREVIEW_Y,
PREVIEW_WIDTH, PREVIEW_HEIGHT, partialTick);
}
LegacyBookArtwork.drawPanelPage(graphics, left, top);
graphics.enableScissor(left + 38, top + 38, left + 154, top + 174);
if (target == null) {
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.book.name_prompt"),
left + 40, top + 43, 110);
} else {
int x = left + 40;
Component customName = book.get(DataComponents.CUSTOM_NAME);
boolean returnLink = customName != null
&& customName.getContents() instanceof TranslatableContents translated
&& translated.getKey().equals("item.rivencraft.linking_book.return");
if (!returnLink) {
drawWrappedWithoutShadow(graphics, title, x, top + 40, 110);
}
int destinationY = returnLink ? top + 40 : top + 58;
drawWrappedWithoutShadow(graphics, LinkingBookItem.destinationName(book, target),
x + 10, destinationY, 100);
BlockPos pos = target.location().pos();
drawWrappedWithoutShadow(graphics,
Component.literal(pos.getX() + ", " + pos.getY() + ", " + pos.getZ()),
x, destinationY + 28, 110);
}
graphics.disableScissor();
}
String counter = pageIndex + "/1";
graphics.drawString(font, counter, left + 165 - font.width(counter) / 2,
top + 185, 0xFF000000, false);
}
@Override
@@ -163,18 +180,37 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
resetExploration();
return true;
}
if (target != null && button == 0) {
int left = (width - BOOK_WIDTH) / 2 + PREVIEW_X;
int top = (height - BOOK_HEIGHT) / 2 + PREVIEW_Y;
if (mouseX >= left && mouseX < left + PREVIEW_WIDTH
&& mouseY >= top && mouseY < top + PREVIEW_HEIGHT) {
if (button == 0) {
int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2;
if (pageIndex == 0 && target != null
&& mouseX >= left + PREVIEW_X && mouseX < left + PREVIEW_X + PREVIEW_WIDTH
&& mouseY >= top + PREVIEW_Y && mouseY < top + PREVIEW_Y + PREVIEW_HEIGHT) {
link();
return true;
}
if (mouseY >= top + 164 && mouseY < top + 195) {
if (pageIndex > 0 && mouseX >= left + 7 && mouseX < left + 35) {
turnPage(0);
return true;
}
if (pageIndex == 0 && mouseX >= left + 293 && mouseX < left + 320) {
turnPage(1);
return true;
}
}
}
return super.mouseClicked(mouseX, mouseY, button);
}
private void turnPage(int page) {
pageIndex = page;
if (titleInput != null) titleInput.visible = pageIndex == 0;
if (bindButton != null) bindButton.visible = pageIndex == 0;
if (cancelButton != null) cancelButton.visible = pageIndex == 0;
if (exploreButton != null) exploreButton.visible = pageIndex == 0;
}
@Override
public void removed() {
if (preview != null && !movingToExploreOverlay) {
@@ -247,7 +283,7 @@ public final class LinkingBookScreen extends Screen implements ExplorableBookScr
int top = (height - BOOK_HEIGHT) / 2;
drawCenteredWithoutShadow(graphics,
Component.translatable("screen.rivencraft.book.return_from_explore"),
left + 91, top + 154);
left + 239, top + 154);
}
@Override
@@ -0,0 +1,295 @@
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.schemaVersion(), OrderedWriting.CURRENT_SCHEMA_VERSION,
"Assembled writing must use the current schema");
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");
ItemStack copy = book.copy();
helper.assertValueEqual(copy.get(ModDataComponents.BOOK_WRITING.get()), writing,
"A book copy must retain identical ordered writing");
helper.assertValueEqual(copy.get(ModDataComponents.AGE_REFERENCE.get()), age,
"A book copy must retain the same Age rather than assigning a new one");
var savedBook = (net.minecraft.nbt.CompoundTag) copy.saveOptional(helper.getLevel().registryAccess());
ItemStack restoredBook = ItemStack.parseOptional(helper.getLevel().registryAccess(), savedBook);
helper.assertValueEqual(restoredBook.get(ModDataComponents.BOOK_WRITING.get()), writing,
"Versioned ordered writing must survive full book save/load");
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();
}
}
@@ -46,6 +46,8 @@ public final class WritingDataGameTests {
));
var json = OrderedWriting.CODEC.encodeStart(JsonOps.INSTANCE, original).result().orElseThrow();
helper.assertValueEqual(json.getAsJsonObject().get("schema_version").getAsInt(),
OrderedWriting.CURRENT_SCHEMA_VERSION, "Writing must save an explicit schema version");
OrderedWriting persistentCopy = OrderedWriting.CODEC.parse(JsonOps.INSTANCE, json).result().orElseThrow();
helper.assertValueEqual(persistentCopy, original,
"Persistent writing codec must retain entry order and unknown symbol IDs");
@@ -58,6 +60,24 @@ public final class WritingDataGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void oldBareListWritingAndPageFreeBooksRemainReadable(GameTestHelper helper) {
OrderedWriting writing = new OrderedWriting(List.of(
WritingEntry.linkPanel(), WritingEntry.symbol(reference("example:removed_symbol"))));
var oldJson = WritingEntry.CODEC.listOf().encodeStart(JsonOps.INSTANCE, writing.entries())
.result().orElseThrow();
OrderedWriting migrated = OrderedWriting.CODEC.parse(JsonOps.INSTANCE, oldJson).result().orElseThrow();
helper.assertValueEqual(migrated, writing,
"0.3.7.2 bare-list writing must load into schema version 1 intact");
ItemStack oldPageFreeBook = new ItemStack(ModItems.DESCRIPTIVE_BOOK.get());
CompoundTag saved = (CompoundTag) oldPageFreeBook.saveOptional(helper.getLevel().registryAccess());
ItemStack restored = ItemStack.parseOptional(helper.getLevel().registryAccess(), saved);
helper.assertValueEqual(restored.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY),
OrderedWriting.EMPTY, "Older page-free books must not require writing migration");
helper.succeed();
}
@GameTest(template = "empty")
public static void serverCatalogResolvesBuiltinsAndKeepsUnknownReferencesReadable(GameTestHelper helper) {
SymbolReference known = reference("rivencraft:terrain/standard");
@@ -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);
}
}
}
@@ -13,8 +13,13 @@ 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.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.GlobalPos;
@@ -49,7 +54,7 @@ public final class ModNetworking {
}
public static void register(RegisterPayloadHandlersEvent event) {
PayloadRegistrar registrar = event.registrar("11");
PayloadRegistrar registrar = event.registrar("13");
registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC,
ModNetworking::requestDescriptiveBookScreen);
registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC,
@@ -71,6 +76,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) {
@@ -92,10 +107,15 @@ public final class ModNetworking {
// Requests run on the same server thread as Age creation. A request queued
// during generation can only open the finished book, never a stale client copy.
player.inventoryMenu.broadcastChanges();
List<SymbolDisplayMetadata> writingSymbols = access.stack()
.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY).entries().stream()
.filter(entry -> entry.kind() == WritingEntry.Kind.SYMBOL)
.map(entry -> SymbolCatalog.display(entry.symbol().orElseThrow()))
.toList();
PacketDistributor.sendToPlayer(player, new OpenDescriptiveBookScreenPayload(
payload.fromStand(), payload.offhand(),
GlobalPos.of(player.level().dimension(), payload.standPos()), access.stack().copy(),
definition == null ? null : definition.traits()));
definition == null ? null : definition.traits(), writingSymbols));
}
private static void bindDescriptiveBook(BindDescriptiveBookPayload payload, IPayloadContext context) {
@@ -1,7 +1,11 @@
package net.dnorberg.rivencraft.network;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.AgeTraits;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.minecraft.core.GlobalPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
@@ -16,7 +20,8 @@ public record OpenDescriptiveBookScreenPayload(
boolean offhand,
GlobalPos source,
ItemStack book,
@Nullable AgeTraits traits
@Nullable AgeTraits traits,
List<SymbolDisplayMetadata> writingSymbols
)
implements CustomPacketPayload {
public static final Type<OpenDescriptiveBookScreenPayload> TYPE = new Type<>(
@@ -40,6 +45,11 @@ public record OpenDescriptiveBookScreenPayload(
buffer.writeFloat(traits.celestialAngle());
buffer.writeFloat(traits.celestialPhase());
}
if (payload.writingSymbols().size() > OrderedWriting.MAX_ENTRIES) {
throw new IllegalArgumentException("Too many book symbol displays");
}
buffer.writeVarInt(payload.writingSymbols().size());
payload.writingSymbols().forEach(symbol -> SymbolDisplayMetadata.STREAM_CODEC.encode(buffer, symbol));
}
private static OpenDescriptiveBookScreenPayload decode(RegistryFriendlyByteBuf buffer) {
@@ -57,7 +67,16 @@ public record OpenDescriptiveBookScreenPayload(
buffer.readFloat()
)
: null;
return new OpenDescriptiveBookScreenPayload(fromStand, offhand, source, book, traits);
int count = buffer.readVarInt();
if (count < 0 || count > OrderedWriting.MAX_ENTRIES) {
throw new IllegalArgumentException("Invalid book symbol display count " + count);
}
List<SymbolDisplayMetadata> writingSymbols = new ArrayList<>(count);
for (int index = 0; index < count; index++) {
writingSymbols.add(SymbolDisplayMetadata.STREAM_CODEC.decode(buffer));
}
return new OpenDescriptiveBookScreenPayload(fromStand, offhand, source, book, traits,
List.copyOf(writingSymbols));
}
@Override
@@ -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<SetBookBinderTitlePayload> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "set_book_binder_title"));
public static final StreamCodec<RegistryFriendlyByteBuf, SetBookBinderTitlePayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.VAR_INT, SetBookBinderTitlePayload::containerId,
ByteBufCodecs.STRING_UTF8, SetBookBinderTitlePayload::title,
SetBookBinderTitlePayload::new);
@Override public Type<? extends CustomPacketPayload> type() { return TYPE; }
}
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.block.entity.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<?>, BlockEntityType<WritingDeskBlockEntity>> WRITING_DESK =
BLOCK_ENTITIES.register("writing_desk", () -> BlockEntityType.Builder
.of(WritingDeskBlockEntity::new, ModBlocks.WRITING_DESK.get()).build(null));
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<BookBinderBlockEntity>> BOOK_BINDER =
BLOCK_ENTITIES.register("book_binder", () -> BlockEntityType.Builder
.of(BookBinderBlockEntity::new, ModBlocks.BOOK_BINDER.get()).build(null));
private ModBlockEntities() {
}
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.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<BookBinderBlock> BOOK_BINDER = BLOCKS.registerBlock(
"book_binder", BookBinderBlock::new,
BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion()
);
private ModBlocks() {
}
@@ -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());
@@ -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<?>, DataComponentType<OrderedWriting>> BOOK_WRITING =
DATA_COMPONENTS.register("book_writing", () -> DataComponentType.<OrderedWriting>builder()
.persistent(OrderedWriting.CODEC)
.networkSynchronized(OrderedWriting.STREAM_CODEC)
.cacheEncoding()
.build());
public static final DeferredHolder<DataComponentType<?>, DataComponentType<Integer>> INK_REMAINING =
DATA_COMPONENTS.register("ink_remaining", () -> DataComponentType.<Integer>builder()
.persistent(Codec.intRange(0, 16))
@@ -41,6 +41,7 @@ public final class ModItems {
new Item.Properties().stacksTo(16)
);
public static final DeferredItem<BlockItem> WRITING_DESK = ITEMS.registerSimpleBlockItem(ModBlocks.WRITING_DESK);
public static final DeferredItem<BlockItem> BOOK_BINDER = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_BINDER);
private ModItems() {
}
@@ -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<?>, MenuType<WritingDeskMenu>> WRITING_DESK =
MENUS.register("writing_desk", () -> IMenuTypeExtension.create(WritingDeskMenu::new));
public static final DeferredHolder<MenuType<?>, MenuType<BookBinderMenu>> BOOK_BINDER =
MENUS.register("book_binder", () -> IMenuTypeExtension.create(BookBinderMenu::new));
private ModMenus() {
}
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import io.netty.buffer.ByteBuf;
import java.util.ArrayList;
import java.util.List;
@@ -10,30 +11,50 @@ import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
/** Immutable ordered writing shared by pages, notebooks, desks, and books. */
public record OrderedWriting(List<WritingEntry> entries) {
public record OrderedWriting(int schemaVersion, List<WritingEntry> entries) {
public static final int CURRENT_SCHEMA_VERSION = 1;
public static final int MAX_ENTRIES = 256;
public static final OrderedWriting EMPTY = new OrderedWriting(List.of());
public static final Codec<OrderedWriting> CODEC = WritingEntry.CODEC.listOf()
private static final Codec<List<WritingEntry>> ENTRIES_CODEC = WritingEntry.CODEC.listOf()
.validate(entries -> entries.size() <= MAX_ENTRIES
? DataResult.success(entries)
: DataResult.error(() -> "Writing exceeds " + MAX_ENTRIES + " entries"))
.xmap(OrderedWriting::new, OrderedWriting::entries);
: DataResult.error(() -> "Writing exceeds " + MAX_ENTRIES + " entries"));
private static final Codec<OrderedWriting> VERSIONED_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.intRange(CURRENT_SCHEMA_VERSION, CURRENT_SCHEMA_VERSION)
.fieldOf("schema_version").forGetter(OrderedWriting::schemaVersion),
ENTRIES_CODEC.fieldOf("entries").forGetter(OrderedWriting::entries)
).apply(instance, OrderedWriting::new));
// 0.3.7.2 stored BOOK_WRITING as a bare list. Decode it without changing its order.
public static final Codec<OrderedWriting> CODEC = Codec.withAlternative(
VERSIONED_CODEC, ENTRIES_CODEC.xmap(OrderedWriting::new, OrderedWriting::entries));
public static final StreamCodec<ByteBuf, OrderedWriting> STREAM_CODEC = StreamCodec.of(
OrderedWriting::encode, OrderedWriting::decode);
public OrderedWriting {
if (schemaVersion != CURRENT_SCHEMA_VERSION) {
throw new IllegalArgumentException("Unsupported writing schema version " + schemaVersion);
}
entries = List.copyOf(Objects.requireNonNull(entries, "entries"));
if (entries.size() > MAX_ENTRIES) {
throw new IllegalArgumentException("Writing exceeds " + MAX_ENTRIES + " entries");
}
}
public OrderedWriting(List<WritingEntry> entries) {
this(CURRENT_SCHEMA_VERSION, entries);
}
private static void encode(ByteBuf buffer, OrderedWriting writing) {
ByteBufCodecs.VAR_INT.encode(buffer, writing.schemaVersion);
ByteBufCodecs.VAR_INT.encode(buffer, writing.entries.size());
writing.entries.forEach(entry -> WritingEntry.STREAM_CODEC.encode(buffer, entry));
}
private static OrderedWriting decode(ByteBuf buffer) {
int schemaVersion = ByteBufCodecs.VAR_INT.decode(buffer);
if (schemaVersion != CURRENT_SCHEMA_VERSION) {
throw new IllegalArgumentException("Unsupported writing schema version " + schemaVersion);
}
int size = ByteBufCodecs.VAR_INT.decode(buffer);
if (size < 0 || size > MAX_ENTRIES) {
throw new IllegalArgumentException("Invalid writing entry count " + size);
@@ -42,6 +63,6 @@ public record OrderedWriting(List<WritingEntry> entries) {
for (int index = 0; index < size; index++) {
entries.add(WritingEntry.STREAM_CODEC.decode(buffer));
}
return new OrderedWriting(entries);
return new OrderedWriting(schemaVersion, entries);
}
}
@@ -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}
}
}
@@ -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",
@@ -49,6 +60,10 @@
"screen.rivencraft.age.name_prompt": "Name this Age before writing the Descriptive Book",
"screen.rivencraft.age.create": "Create",
"screen.rivencraft.age.generator": "Terrain: %s",
"screen.rivencraft.age.pages": "Pages",
"screen.rivencraft.age.details": "Details",
"screen.rivencraft.age.pages_count": "Writing %s/%s",
"screen.rivencraft.age.pages_not_applied": "Symbols not applied yet",
"screen.rivencraft.age.trait.biomes": "Biomes: %s",
"screen.rivencraft.age.trait.lighting": "Lighting: %s",
"screen.rivencraft.age.trait.weather": "Weather: %s",
@@ -99,7 +114,6 @@
"generator.rivencraft.void": "Void",
"generator.rivencraft.random_vanilla": "Standard",
"screen.rivencraft.book.preview_pending": "Loading destination...",
"screen.rivencraft.book.click_to_link": "Click the panel to link",
"screen.rivencraft.book.explore": "Explore",
"screen.rivencraft.book.exploring": "Exploring",
"screen.rivencraft.book.return_from_explore": "Right-click to return",
@@ -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]}
}
}
]
}
@@ -0,0 +1,3 @@
{
"parent": "rivencraft:block/book_binder"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,4 @@
{
"type": "minecraft:block",
"pools": [{"rolls":1,"entries":[{"type":"minecraft:item","name":"rivencraft:book_binder"}]}]
}
@@ -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}
}