From 8dbde191b806c9dbb06e7c674e9ac75c25404746 Mon Sep 17 00:00:00 2001
From: Dustin Norberg
Date: Mon, 21 Sep 2026 08:13:08 -0400
Subject: [PATCH] Render destination snapshots with Ponder
---
README.md | 6 +-
docs/PORTING.md | 6 +-
gradle.properties | 2 +-
.../rivencraft/client/ClientEvents.java | 1 +
.../client/ClientLinkPreviewHandler.java | 19 +++++
.../preview/DestinationPreviewRenderer.java | 3 +
.../PonderDestinationPreviewRenderer.java | 71 +++++++++++++++++++
.../client/screen/LinkingBookScreen.java | 13 ++++
.../network/LinkPreviewPayload.java | 47 ++++++++++++
.../rivencraft/network/ModNetworking.java | 50 ++++++++++++-
.../network/RequestLinkPreviewPayload.java | 28 ++++++++
.../assets/rivencraft/lang/en_us.json | 2 +-
12 files changed, 241 insertions(+), 7 deletions(-)
create mode 100644 src/main/java/net/dnorberg/rivencraft/client/ClientLinkPreviewHandler.java
create mode 100644 src/main/java/net/dnorberg/rivencraft/network/LinkPreviewPayload.java
create mode 100644 src/main/java/net/dnorberg/rivencraft/network/RequestLinkPreviewPayload.java
diff --git a/README.md b/README.md
index 1174de6..7279fed 100644
--- a/README.md
+++ b/README.md
@@ -26,9 +26,9 @@ right-clicking with an empty hand. Sneak-right-click with an empty hand to take
the book back and restore the vanilla lectern. Breaking a Book Stand drops both
the stored book and a vanilla lectern.
-Version 0.1.4 establishes a Ponder-backed destination-preview boundary and an
-animated loading view. Transmitting a destination snapshot and rendering its
-actual blocks as the rotating scene is the next preview iteration.
+Bound Linking Books request a small, server-validated snapshot around their
+destination and render its blocks as a rotating Ponder scene on the left page.
+The snapshot is visual only; the server independently validates every link.
## Requirements
diff --git a/docs/PORTING.md b/docs/PORTING.md
index 750407a..307c9c3 100644
--- a/docs/PORTING.md
+++ b/docs/PORTING.md
@@ -51,7 +51,7 @@ the symbol grammar is introduced.
- [x] Book-screen foundation with server-authoritative naming, binding, and
explicit link activation.
- [x] Ponder dependency and isolated destination-preview level boundary.
-- [ ] Server-provided destination snapshots rendered as rotating Ponder scenes.
+- [x] Server-provided destination snapshots rendered as rotating Ponder scenes.
- [x] Book Stand floating names synchronized from the stored book stack.
- [x] Open the stored book screen from a Book Stand with server-validated linking.
- [ ] Automated in-game tests for binding, persistence, and travel.
@@ -110,6 +110,10 @@ revalidates the stand position, interaction distance, stored item, and target.
Version 0.1.12 replaces the Linking Book screen's convenience text draws with
explicit no-shadow rendering for clearer dark ink on the parchment texture.
+Version 0.1.13 requests a bounded, server-validated 9-by-7-by-9 block snapshot
+from the held book or nearby Book Stand and renders it as a clipped, rotating
+Ponder world section on the Linking Book's left page.
+
The preview code is separated behind a renderer interface and uses an isolated
Ponder level. The first iteration supplies the animated loading boundary; the
next iteration will add server-authoritative snapshot transport and populate
diff --git a/gradle.properties b/gradle.properties
index 4a42a22..e0912c5 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -33,7 +33,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. See https://semver.org/
-mod_version=0.1.12
+mod_version=0.1.13
# 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.
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
diff --git a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java
index 8f28f7f..cbb0638 100644
--- a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java
+++ b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java
@@ -24,4 +24,5 @@ public final class ClientEvents {
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickItem);
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickBlock);
}
+
}
diff --git a/src/main/java/net/dnorberg/rivencraft/client/ClientLinkPreviewHandler.java b/src/main/java/net/dnorberg/rivencraft/client/ClientLinkPreviewHandler.java
new file mode 100644
index 0000000..484f9d9
--- /dev/null
+++ b/src/main/java/net/dnorberg/rivencraft/client/ClientLinkPreviewHandler.java
@@ -0,0 +1,19 @@
+package net.dnorberg.rivencraft.client;
+
+import net.dnorberg.rivencraft.client.screen.LinkingBookScreen;
+import net.dnorberg.rivencraft.network.LinkPreviewPayload;
+import net.minecraft.client.Minecraft;
+import net.neoforged.neoforge.network.handling.IPayloadContext;
+
+public final class ClientLinkPreviewHandler {
+ private ClientLinkPreviewHandler() {
+ }
+
+ public static void handle(LinkPreviewPayload payload, IPayloadContext context) {
+ context.enqueueWork(() -> {
+ if (Minecraft.getInstance().screen instanceof LinkingBookScreen screen) {
+ screen.acceptPreview(payload);
+ }
+ });
+ }
+}
diff --git a/src/main/java/net/dnorberg/rivencraft/client/preview/DestinationPreviewRenderer.java b/src/main/java/net/dnorberg/rivencraft/client/preview/DestinationPreviewRenderer.java
index 267c3c9..8f13e16 100644
--- a/src/main/java/net/dnorberg/rivencraft/client/preview/DestinationPreviewRenderer.java
+++ b/src/main/java/net/dnorberg/rivencraft/client/preview/DestinationPreviewRenderer.java
@@ -1,8 +1,11 @@
package net.dnorberg.rivencraft.client.preview;
import net.minecraft.client.gui.GuiGraphics;
+import net.dnorberg.rivencraft.network.LinkPreviewPayload;
public interface DestinationPreviewRenderer extends AutoCloseable {
+ void accept(LinkPreviewPayload payload);
+
void tick();
void render(GuiGraphics graphics, int left, int top, int width, int height, float partialTick);
diff --git a/src/main/java/net/dnorberg/rivencraft/client/preview/PonderDestinationPreviewRenderer.java b/src/main/java/net/dnorberg/rivencraft/client/preview/PonderDestinationPreviewRenderer.java
index 9178c1a..ad37bfa 100644
--- a/src/main/java/net/dnorberg/rivencraft/client/preview/PonderDestinationPreviewRenderer.java
+++ b/src/main/java/net/dnorberg/rivencraft/client/preview/PonderDestinationPreviewRenderer.java
@@ -1,10 +1,27 @@
package net.dnorberg.rivencraft.client.preview;
+import com.mojang.blaze3d.systems.RenderSystem;
import net.createmod.ponder.api.level.PonderLevel;
+import net.createmod.ponder.api.element.PonderSceneElement;
+import net.createmod.ponder.api.scene.Selection;
+import net.createmod.ponder.foundation.SelectionImpl;
+import net.createmod.ponder.foundation.element.WorldSectionElementImpl;
+import net.createmod.catnip.gui.UIRenderHelper;
+import net.createmod.catnip.render.DefaultSuperRenderTypeBuffer;
+import net.createmod.catnip.render.SuperRenderTypeBuffer;
+import net.dnorberg.rivencraft.network.LinkPreviewPayload;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.renderer.RenderType;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.levelgen.structure.BoundingBox;
+import com.mojang.blaze3d.vertex.PoseStack;
+import com.mojang.blaze3d.vertex.VertexSorting;
+import com.mojang.math.Axis;
+import org.joml.Matrix4f;
+import org.joml.Vector3f;
/**
* Initial Ponder-backed preview boundary.
@@ -14,12 +31,27 @@ import net.minecraft.network.chat.Component;
* state rather than reading arbitrary remote chunks from the client.
*/
public final class PonderDestinationPreviewRenderer implements DestinationPreviewRenderer {
+ private static final Vector3f DIFFUSE_LIGHT_0 = new Vector3f(0.4F, -1.0F, 0.7F).normalize();
+ private static final Vector3f DIFFUSE_LIGHT_1 = new Vector3f(-0.4F, -0.5F, 0.7F).normalize();
private final PonderLevel previewLevel;
+ private final Selection selection;
+ private final PonderSceneElement section;
private int ticks;
+ private boolean loaded;
public PonderDestinationPreviewRenderer() {
Minecraft minecraft = Minecraft.getInstance();
previewLevel = new PonderLevel(BlockPos.ZERO, minecraft.level);
+ selection = SelectionImpl.of(new BoundingBox(0, 0, 0, 8, 6, 8));
+ section = new WorldSectionElementImpl(selection);
+ }
+
+ @Override
+ public void accept(LinkPreviewPayload payload) {
+ selection.forEach(pos -> previewLevel.setBlock(pos, Blocks.AIR.defaultBlockState(), 2));
+ payload.blocks().forEach(block -> previewLevel.setBlock(block.pos(), block.state(), 2));
+ ((net.createmod.ponder.api.element.WorldSectionElement)section).queueRedraw();
+ loaded = true;
}
@Override
@@ -33,6 +65,11 @@ public final class PonderDestinationPreviewRenderer implements DestinationPrevie
graphics.fill(left, top, left + width, top + height, 0xFF080C18);
graphics.renderOutline(left, top, width, height, 0xFF7B6AA8);
+ if (loaded) {
+ renderSnapshot(graphics, left, top, width, height, partialTick);
+ return;
+ }
+
double angle = (ticks + partialTick) * 0.04;
int centerX = left + width / 2;
int centerY = top + height / 2;
@@ -47,4 +84,38 @@ public final class PonderDestinationPreviewRenderer implements DestinationPrevie
0xFFB8C5E0
);
}
+
+ private void renderSnapshot(GuiGraphics graphics, int left, int top, int width, int height, float partialTick) {
+ graphics.enableScissor(left + 1, top + 1, left + width - 1, top + height - 1);
+ RenderSystem.enableBlend();
+ RenderSystem.enableDepthTest();
+ RenderSystem.backupProjectionMatrix();
+ RenderSystem.setupLevelDiffuseLighting(DIFFUSE_LIGHT_0, DIFFUSE_LIGHT_1);
+
+ Matrix4f projection = new Matrix4f(RenderSystem.getProjectionMatrix());
+ projection.translate(0.0F, 0.0F, 800.0F);
+ RenderSystem.setProjectionMatrix(projection, VertexSorting.DISTANCE_TO_ORIGIN);
+
+ PoseStack poseStack = graphics.pose();
+ poseStack.pushPose();
+ poseStack.translate(0.0F, 0.0F, -800.0F);
+ poseStack.translate(left + width / 2.0F, top + height / 2.0F + 8.0F, 200.0F);
+ poseStack.mulPose(Axis.XP.rotationDegrees(-28.0F));
+ poseStack.mulPose(Axis.YP.rotationDegrees((ticks + partialTick) * 0.6F));
+ UIRenderHelper.flipForGuiRender(poseStack);
+ poseStack.scale(10.0F, 10.0F, 10.0F);
+ poseStack.translate(-4.5F, -1.5F, -4.5F);
+
+ SuperRenderTypeBuffer buffer = DefaultSuperRenderTypeBuffer.getInstance();
+ section.renderFirst(previewLevel, buffer, graphics, partialTick);
+ for (RenderType type : RenderType.chunkBufferLayers()) {
+ section.renderLayer(previewLevel, buffer, type, graphics, partialTick);
+ }
+ section.renderLast(previewLevel, buffer, graphics, partialTick);
+ buffer.draw();
+
+ poseStack.popPose();
+ RenderSystem.restoreProjectionMatrix();
+ graphics.disableScissor();
+ }
}
diff --git a/src/main/java/net/dnorberg/rivencraft/client/screen/LinkingBookScreen.java b/src/main/java/net/dnorberg/rivencraft/client/screen/LinkingBookScreen.java
index fcd5f3a..34a55f5 100644
--- a/src/main/java/net/dnorberg/rivencraft/client/screen/LinkingBookScreen.java
+++ b/src/main/java/net/dnorberg/rivencraft/client/screen/LinkingBookScreen.java
@@ -7,6 +7,8 @@ import net.dnorberg.rivencraft.network.BindLinkingBookPayload;
import net.dnorberg.rivencraft.network.ModNetworking;
import net.dnorberg.rivencraft.network.UseLinkingBookPayload;
import net.dnorberg.rivencraft.network.UseBookStandPayload;
+import net.dnorberg.rivencraft.network.RequestLinkPreviewPayload;
+import net.dnorberg.rivencraft.network.LinkPreviewPayload;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
@@ -77,6 +79,11 @@ public final class LinkingBookScreen extends Screen {
.build());
} else {
preview = new PonderDestinationPreviewRenderer();
+ PacketDistributor.sendToServer(new RequestLinkPreviewPayload(
+ bookStandPos != null,
+ bookStandPos == null && hand == InteractionHand.OFF_HAND,
+ bookStandPos == null ? BlockPos.ZERO : bookStandPos
+ ));
}
}
@@ -143,6 +150,12 @@ public final class LinkingBookScreen extends Screen {
}
}
+ public void acceptPreview(LinkPreviewPayload payload) {
+ if (preview != null) {
+ preview.accept(payload);
+ }
+ }
+
private void bind() {
String name = titleInput.getValue().strip();
if (!name.isEmpty()) {
diff --git a/src/main/java/net/dnorberg/rivencraft/network/LinkPreviewPayload.java b/src/main/java/net/dnorberg/rivencraft/network/LinkPreviewPayload.java
new file mode 100644
index 0000000..ed8eea6
--- /dev/null
+++ b/src/main/java/net/dnorberg/rivencraft/network/LinkPreviewPayload.java
@@ -0,0 +1,47 @@
+package net.dnorberg.rivencraft.network;
+
+import java.util.ArrayList;
+import java.util.List;
+import net.dnorberg.rivencraft.Rivencraft;
+import net.minecraft.core.BlockPos;
+import net.minecraft.network.RegistryFriendlyByteBuf;
+import net.minecraft.network.codec.StreamCodec;
+import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.state.BlockState;
+
+public record LinkPreviewPayload(List blocks) implements CustomPacketPayload {
+ private static final int MAX_BLOCKS = 9 * 7 * 9;
+ public static final Type TYPE = new Type<>(
+ ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "link_preview")
+ );
+ public static final StreamCodec STREAM_CODEC = StreamCodec.of(
+ (buffer, payload) -> {
+ buffer.writeVarInt(payload.blocks.size());
+ for (SnapshotBlock block : payload.blocks) {
+ buffer.writeBlockPos(block.pos);
+ buffer.writeVarInt(Block.getId(block.state));
+ }
+ },
+ buffer -> {
+ int size = buffer.readVarInt();
+ if (size < 0 || size > MAX_BLOCKS) {
+ throw new IllegalArgumentException("Invalid link preview size: " + size);
+ }
+ List blocks = new ArrayList<>(size);
+ for (int i = 0; i < size; i++) {
+ blocks.add(new SnapshotBlock(buffer.readBlockPos(), Block.stateById(buffer.readVarInt())));
+ }
+ return new LinkPreviewPayload(List.copyOf(blocks));
+ }
+ );
+
+ @Override
+ public Type extends CustomPacketPayload> type() {
+ return TYPE;
+ }
+
+ public record SnapshotBlock(BlockPos pos, BlockState state) {
+ }
+}
diff --git a/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java b/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java
index 7b5d829..08d8076 100644
--- a/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java
+++ b/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java
@@ -2,12 +2,15 @@ package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
+import net.dnorberg.rivencraft.client.ClientLinkPreviewHandler;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.component.DataComponents;
+import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
@@ -16,6 +19,9 @@ import net.minecraft.world.phys.Vec3;
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
import net.neoforged.neoforge.network.handling.IPayloadContext;
import net.neoforged.neoforge.network.registration.PayloadRegistrar;
+import net.neoforged.neoforge.network.PacketDistributor;
+import java.util.ArrayList;
+import java.util.List;
public final class ModNetworking {
public static final int MAX_BOOK_TITLE_LENGTH = 32;
@@ -24,10 +30,14 @@ public final class ModNetworking {
}
public static void register(RegisterPayloadHandlersEvent event) {
- PayloadRegistrar registrar = event.registrar("1");
+ PayloadRegistrar registrar = event.registrar("2");
registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC, ModNetworking::bindBook);
registrar.playToServer(UseLinkingBookPayload.TYPE, UseLinkingBookPayload.STREAM_CODEC, ModNetworking::useBook);
registrar.playToServer(UseBookStandPayload.TYPE, UseBookStandPayload.STREAM_CODEC, ModNetworking::useBookStand);
+ registrar.playToServer(RequestLinkPreviewPayload.TYPE, RequestLinkPreviewPayload.STREAM_CODEC,
+ ModNetworking::requestLinkPreview);
+ registrar.playToClient(LinkPreviewPayload.TYPE, LinkPreviewPayload.STREAM_CODEC,
+ ClientLinkPreviewHandler::handle);
}
private static void bindBook(BindLinkingBookPayload payload, IPayloadContext context) {
@@ -75,6 +85,44 @@ public final class ModNetworking {
}
}
+ private static void requestLinkPreview(RequestLinkPreviewPayload payload, IPayloadContext context) {
+ if (!(context.player() instanceof ServerPlayer player)) {
+ return;
+ }
+
+ ItemStack stack;
+ if (payload.fromStand()) {
+ if (player.distanceToSqr(Vec3.atCenterOf(payload.standPos())) > 64.0D
+ || !(player.level().getBlockEntity(payload.standPos()) instanceof BookStandBlockEntity stand)) {
+ return;
+ }
+ stack = stand.getBook();
+ } else {
+ stack = player.getItemInHand(hand(payload.offhand()));
+ }
+
+ LinkTarget target = stack.is(ModItems.LINKING_BOOK.get())
+ ? stack.get(ModDataComponents.LINK_TARGET.get()) : null;
+ if (target == null) {
+ return;
+ }
+
+ ServerLevel level = player.getServer().getLevel(target.location().dimension());
+ if (level == null) {
+ return;
+ }
+
+ BlockPos origin = target.location().pos();
+ List blocks = new ArrayList<>();
+ for (BlockPos pos : BlockPos.betweenClosed(origin.offset(-4, -2, -4), origin.offset(4, 4, 4))) {
+ var state = level.getBlockState(pos);
+ if (!state.isAir()) {
+ blocks.add(new LinkPreviewPayload.SnapshotBlock(pos.subtract(origin).offset(4, 2, 4), state));
+ }
+ }
+ PacketDistributor.sendToPlayer(player, new LinkPreviewPayload(List.copyOf(blocks)));
+ }
+
private static InteractionHand hand(boolean offhand) {
return offhand ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
}
diff --git a/src/main/java/net/dnorberg/rivencraft/network/RequestLinkPreviewPayload.java b/src/main/java/net/dnorberg/rivencraft/network/RequestLinkPreviewPayload.java
new file mode 100644
index 0000000..9befab5
--- /dev/null
+++ b/src/main/java/net/dnorberg/rivencraft/network/RequestLinkPreviewPayload.java
@@ -0,0 +1,28 @@
+package net.dnorberg.rivencraft.network;
+
+import net.dnorberg.rivencraft.Rivencraft;
+import net.minecraft.core.BlockPos;
+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 RequestLinkPreviewPayload(boolean fromStand, boolean offhand, BlockPos standPos)
+ implements CustomPacketPayload {
+ public static final Type TYPE = new Type<>(
+ ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "request_link_preview")
+ );
+ public static final StreamCodec STREAM_CODEC =
+ StreamCodec.composite(
+ ByteBufCodecs.BOOL, RequestLinkPreviewPayload::fromStand,
+ ByteBufCodecs.BOOL, RequestLinkPreviewPayload::offhand,
+ BlockPos.STREAM_CODEC, RequestLinkPreviewPayload::standPos,
+ RequestLinkPreviewPayload::new
+ );
+
+ @Override
+ public Type extends CustomPacketPayload> type() {
+ return TYPE;
+ }
+}
diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json
index 1c78174..a8153f3 100644
--- a/src/main/resources/assets/rivencraft/lang/en_us.json
+++ b/src/main/resources/assets/rivencraft/lang/en_us.json
@@ -17,7 +17,7 @@
"screen.rivencraft.book.bind": "Bind",
"screen.rivencraft.book.link": "Link",
"screen.rivencraft.book.unwritten": "This Descriptive Book is unwritten",
- "screen.rivencraft.book.preview_pending": "Ponder preview loading",
+ "screen.rivencraft.book.preview_pending": "Loading destination...",
"screen.rivencraft.book.click_to_link": "Click the panel to link",
"itemGroup.rivencraft": "Rivencraft"
}