Add book GUI and Ponder preview foundation
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-09-21 07:24:12 -04:00
1 parent 3109938a97
commit 2b916ccd0b
19 files changed
+437 -24

No files matched your search

@@ -7,6 +7,7 @@ import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.event.BookStandConversionHandler;
import net.dnorberg.rivencraft.network.ModNetworking;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.Mod;
import net.neoforged.neoforge.common.NeoForge;
@@ -18,6 +19,7 @@ public final class Rivencraft {
public static final Logger LOGGER = LogUtils.getLogger();
public Rivencraft(IEventBus modEventBus) {
modEventBus.addListener(ModNetworking::register);
ModDataComponents.register(modEventBus);
ModBlocks.register(modEventBus);
ModBlockEntities.register(modEventBus);
@@ -1,6 +1,7 @@
package net.dnorberg.rivencraft.book;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import io.netty.buffer.ByteBuf;
import java.util.UUID;
import net.minecraft.core.UUIDUtil;
@@ -13,11 +14,21 @@ import net.minecraft.network.codec.StreamCodec;
* Books only retain the identifier so dimension regeneration or migration does
* not invalidate every existing book.</p>
*/
public record AgeReference(UUID id) {
public static final Codec<AgeReference> CODEC = UUIDUtil.STRING_CODEC.xmap(AgeReference::new, AgeReference::id);
public record AgeReference(UUID id, String name) {
private static final String UNNAMED_AGE = "Unnamed Age";
private static final Codec<AgeReference> RECORD_CODEC = RecordCodecBuilder.create(instance -> instance.group(
UUIDUtil.STRING_CODEC.fieldOf("id").forGetter(AgeReference::id),
Codec.STRING.optionalFieldOf("name", UNNAMED_AGE).forGetter(AgeReference::name)
).apply(instance, AgeReference::new));
public static final Codec<AgeReference> CODEC = Codec.withAlternative(
RECORD_CODEC,
UUIDUtil.STRING_CODEC.xmap(id -> new AgeReference(id, UNNAMED_AGE), AgeReference::id)
);
public static final StreamCodec<ByteBuf, AgeReference> STREAM_CODEC = StreamCodec.composite(
UUIDUtil.STREAM_CODEC,
AgeReference::id,
net.minecraft.network.codec.ByteBufCodecs.STRING_UTF8,
AgeReference::name,
AgeReference::new
);
@@ -0,0 +1,29 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.client.screen.LinkingBookScreen;
import net.dnorberg.rivencraft.client.screen.DescriptiveBookScreen;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.client.Minecraft;
import net.minecraft.world.InteractionResult;
import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent;
public final class ClientBookInteractionHandler {
private ClientBookInteractionHandler() {
}
public static void onRightClickItem(PlayerInteractEvent.RightClickItem event) {
boolean linkingBook = event.getItemStack().is(ModItems.LINKING_BOOK.get());
boolean descriptiveBook = event.getItemStack().is(ModItems.DESCRIPTIVE_BOOK.get());
if (!linkingBook && !descriptiveBook) {
return;
}
event.setCanceled(true);
event.setCancellationResult(InteractionResult.SUCCESS);
if (linkingBook) {
Minecraft.getInstance().setScreen(new LinkingBookScreen(event.getHand(), event.getItemStack().copy()));
} else {
Minecraft.getInstance().setScreen(new DescriptiveBookScreen(event.getItemStack().copy()));
}
}
}
@@ -5,6 +5,8 @@ import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.client.event.EntityRenderersEvent;
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
@@ -16,4 +18,9 @@ public final class ClientEvents {
public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) {
event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new);
}
@SubscribeEvent
public static void clientSetup(FMLClientSetupEvent event) {
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickItem);
}
}
@@ -0,0 +1,13 @@
package net.dnorberg.rivencraft.client.preview;
import net.minecraft.client.gui.GuiGraphics;
public interface DestinationPreviewRenderer extends AutoCloseable {
void tick();
void render(GuiGraphics graphics, int left, int top, int width, int height, float partialTick);
@Override
default void close() {
}
}
@@ -0,0 +1,50 @@
package net.dnorberg.rivencraft.client.preview;
import net.createmod.ponder.api.level.PonderLevel;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
/**
* Initial Ponder-backed preview boundary.
*
* <p>The isolated level is ready to receive server-provided snapshot blocks.
* Until snapshot networking lands, the panel renders an animated loading
* state rather than reading arbitrary remote chunks from the client.</p>
*/
public final class PonderDestinationPreviewRenderer implements DestinationPreviewRenderer {
private final PonderLevel previewLevel;
private int ticks;
public PonderDestinationPreviewRenderer() {
Minecraft minecraft = Minecraft.getInstance();
previewLevel = new PonderLevel(BlockPos.ZERO, minecraft.level);
}
@Override
public void tick() {
ticks++;
previewLevel.tick();
}
@Override
public void render(GuiGraphics graphics, int left, int top, int width, int height, float partialTick) {
graphics.fill(left, top, left + width, top + height, 0xFF080C18);
graphics.renderOutline(left, top, width, height, 0xFF7B6AA8);
double angle = (ticks + partialTick) * 0.04;
int centerX = left + width / 2;
int centerY = top + height / 2;
int orbitX = centerX + (int)(Math.cos(angle) * Math.max(8, width / 4));
int orbitY = centerY + (int)(Math.sin(angle) * Math.max(4, height / 8));
graphics.fill(orbitX - 2, orbitY - 2, orbitX + 3, orbitY + 3, 0xFF8ED6FF);
graphics.drawCenteredString(
Minecraft.getInstance().font,
Component.translatable("screen.rivencraft.book.preview_pending"),
centerX,
centerY - 4,
0xFFB8C5E0
);
}
}
@@ -0,0 +1,39 @@
package net.dnorberg.rivencraft.client.screen;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.ItemStack;
public final class DescriptiveBookScreen extends Screen {
private final AgeReference age;
public DescriptiveBookScreen(ItemStack book) {
super(book.getHoverName());
age = book.get(ModDataComponents.AGE_REFERENCE.get());
}
@Override
protected void init() {
addRenderableWidget(Button.builder(Component.translatable("gui.done"), button -> onClose())
.bounds(width / 2 - 75, height / 2 + 55, 150, 20)
.build());
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
super.render(graphics, mouseX, mouseY, partialTick);
int centerX = width / 2;
graphics.drawCenteredString(font, title, centerX, height / 2 - 70, 0xFFFFFFFF);
if (age == null) {
graphics.drawCenteredString(font, Component.translatable("screen.rivencraft.book.unwritten"),
centerX, height / 2 - 4, 0xFFAAAAAA);
} else {
graphics.drawCenteredString(font, Component.literal(age.name()), centerX, height / 2 - 16, 0xFFE7D7A9);
graphics.drawCenteredString(font, Component.literal(age.id().toString()), centerX, height / 2 + 4, 0xFF777777);
}
}
}
@@ -0,0 +1,111 @@
package net.dnorberg.rivencraft.client.screen;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderer;
import net.dnorberg.rivencraft.client.preview.PonderDestinationPreviewRenderer;
import net.dnorberg.rivencraft.network.BindLinkingBookPayload;
import net.dnorberg.rivencraft.network.ModNetworking;
import net.dnorberg.rivencraft.network.UseLinkingBookPayload;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.network.PacketDistributor;
public final class LinkingBookScreen extends Screen {
private static final int PANEL_WIDTH = 180;
private static final int PREVIEW_HEIGHT = 100;
private final InteractionHand hand;
private final ItemStack book;
private final LinkTarget target;
private DestinationPreviewRenderer preview;
private EditBox titleInput;
public LinkingBookScreen(InteractionHand hand, ItemStack book) {
super(book.getHoverName());
this.hand = hand;
this.book = book;
target = book.get(ModDataComponents.LINK_TARGET.get());
}
@Override
protected void init() {
int left = (width - PANEL_WIDTH) / 2;
int buttonY = height / 2 + 72;
if (target == null) {
titleInput = new EditBox(font, left, height / 2 - 10, PANEL_WIDTH, 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(left, height / 2 + 20, 86, 20)
.build());
addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
.bounds(left + 94, height / 2 + 20, 86, 20)
.build());
} else {
preview = new PonderDestinationPreviewRenderer();
addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.book.link"), button -> link())
.bounds(left, buttonY, 86, 20)
.build());
addRenderableWidget(Button.builder(Component.translatable("gui.done"), button -> onClose())
.bounds(left + 94, buttonY, 86, 20)
.build());
}
}
@Override
public void tick() {
if (preview != null) {
preview.tick();
}
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
super.render(graphics, mouseX, mouseY, partialTick);
int centerX = width / 2;
graphics.drawCenteredString(font, title, centerX, height / 2 - 82, 0xFFFFFFFF);
if (target == null) {
graphics.drawCenteredString(font, Component.translatable("screen.rivencraft.book.name_prompt"),
centerX, height / 2 - 34, 0xFFB8B8B8);
return;
}
int left = (width - PANEL_WIDTH) / 2;
int previewTop = height / 2 - 60;
preview.render(graphics, left, previewTop, PANEL_WIDTH, PREVIEW_HEIGHT, partialTick);
BlockPos pos = target.location().pos();
graphics.drawCenteredString(font, Component.literal(
target.location().dimension().location() + " " + pos.getX() + ", " + pos.getY() + ", " + pos.getZ()),
centerX, previewTop + PREVIEW_HEIGHT + 8, 0xFFB8B8B8);
}
@Override
public void removed() {
if (preview != null) {
preview.close();
}
}
private void bind() {
String name = titleInput.getValue().strip();
if (!name.isEmpty()) {
PacketDistributor.sendToServer(new BindLinkingBookPayload(hand == InteractionHand.OFF_HAND, name));
onClose();
}
}
private void link() {
PacketDistributor.sendToServer(new UseLinkingBookPayload(hand == InteractionHand.OFF_HAND));
onClose();
}
}
@@ -37,7 +37,7 @@ public final class DescriptiveBookItem extends Item {
AgeReference age = stack.get(ModDataComponents.AGE_REFERENCE.get());
MutableComponent status = age == null
? Component.translatable("item.rivencraft.descriptive_book.unwritten")
: Component.translatable("item.rivencraft.descriptive_book.age", age.shortId());
: Component.translatable("item.rivencraft.descriptive_book.age", age.name());
tooltipComponents.add(status.withStyle(ChatFormatting.GRAY));
}
}
@@ -33,19 +33,9 @@ public final class LinkingBookItem extends Item {
LinkTarget target = stack.get(ModDataComponents.LINK_TARGET.get());
if (target == null) {
if (!player.isShiftKeyDown()) {
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.bind_hint"), true);
return InteractionResultHolder.fail(stack);
}
target = LinkTarget.from(serverPlayer);
stack.set(ModDataComponents.LINK_TARGET.get(), target);
level.playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN, SoundSource.PLAYERS, 1.0F, 1.0F);
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.bound"), true);
return InteractionResultHolder.success(stack);
return InteractionResultHolder.consume(stack);
}
return useBoundBook(serverPlayer, stack);
return InteractionResultHolder.consume(stack);
}
public static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack) {
@@ -0,0 +1,26 @@
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 BindLinkingBookPayload(boolean offhand, String title) implements CustomPacketPayload {
public static final Type<BindLinkingBookPayload> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "bind_linking_book")
);
public static final StreamCodec<RegistryFriendlyByteBuf, BindLinkingBookPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.BOOL,
BindLinkingBookPayload::offhand,
ByteBufCodecs.STRING_UTF8,
BindLinkingBookPayload::title,
BindLinkingBookPayload::new
);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,72 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.book.LinkTarget;
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.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
import net.neoforged.neoforge.network.handling.IPayloadContext;
import net.neoforged.neoforge.network.registration.PayloadRegistrar;
public final class ModNetworking {
public static final int MAX_BOOK_TITLE_LENGTH = 32;
private ModNetworking() {
}
public static void register(RegisterPayloadHandlersEvent event) {
PayloadRegistrar registrar = event.registrar("1");
registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC, ModNetworking::bindBook);
registrar.playToServer(UseLinkingBookPayload.TYPE, UseLinkingBookPayload.STREAM_CODEC, ModNetworking::useBook);
}
private static void bindBook(BindLinkingBookPayload payload, IPayloadContext context) {
if (!(context.player() instanceof ServerPlayer player)) {
return;
}
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (!stack.is(ModItems.LINKING_BOOK.get()) || stack.has(ModDataComponents.LINK_TARGET.get())) {
return;
}
String title = normalizeTitle(payload.title());
if (title.isEmpty()) {
return;
}
stack.set(ModDataComponents.LINK_TARGET.get(), LinkTarget.from(player));
stack.set(DataComponents.CUSTOM_NAME, Component.literal(title));
player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN, SoundSource.PLAYERS, 1.0F, 1.0F);
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.bound", title), true);
}
private static void useBook(UseLinkingBookPayload payload, IPayloadContext context) {
if (!(context.player() instanceof ServerPlayer player)) {
return;
}
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (stack.is(ModItems.LINKING_BOOK.get()) && stack.has(ModDataComponents.LINK_TARGET.get())) {
LinkingBookItem.useBoundBook(player, stack);
}
}
private static InteractionHand hand(boolean offhand) {
return offhand ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
}
private static String normalizeTitle(String title) {
String normalized = title.strip();
return normalized.length() <= MAX_BOOK_TITLE_LENGTH
? normalized
: normalized.substring(0, MAX_BOOK_TITLE_LENGTH).stripTrailing();
}
}
@@ -0,0 +1,24 @@
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 UseLinkingBookPayload(boolean offhand) implements CustomPacketPayload {
public static final Type<UseLinkingBookPayload> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "use_linking_book")
);
public static final StreamCodec<RegistryFriendlyByteBuf, UseLinkingBookPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.BOOL,
UseLinkingBookPayload::offhand,
UseLinkingBookPayload::new
);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -1,15 +1,22 @@
{
"item.rivencraft.descriptive_book": "Descriptive Book",
"item.rivencraft.descriptive_book.unwritten": "Unwritten",
"item.rivencraft.descriptive_book.age": "Age %s",
"item.rivencraft.descriptive_book.age": "%s",
"item.rivencraft.linking_book": "Linking Book",
"item.rivencraft.linking_book.unbound": "Unbound — sneak-use to bind",
"item.rivencraft.linking_book.destination": "%s (%s, %s, %s)",
"message.rivencraft.linking_book.bind_hint": "Sneak-use to bind this book to your current location",
"message.rivencraft.linking_book.unbound": "This Linking Book is unbound",
"message.rivencraft.linking_book.bound": "Linking Book bound",
"message.rivencraft.linking_book.bound": "Linking Book bound as %s",
"message.rivencraft.linking_book.linked": "Linked",
"message.rivencraft.linking_book.missing_dimension": "That dimension is unavailable",
"message.rivencraft.linking_book.unsafe": "No safe destination was found near the link",
"screen.rivencraft.book.title": "Book title",
"screen.rivencraft.book.title_hint": "Name this Linking Book",
"screen.rivencraft.book.name_prompt": "Name the book before binding it to this location",
"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",
"itemGroup.rivencraft": "Rivencraft"
}
@@ -86,6 +86,13 @@ A spiritual successor and modern port of Mystcraft for Minecraft 1.21.1 on NeoFo
ordering="NONE"
side="BOTH"
[[dependencies.${mod_id}]]
modId="ponder"
type="required"
versionRange="[1.0.82,2.0.0)"
ordering="AFTER"
side="CLIENT"
# Features are specific properties of the game environment, that you may want to declare you require. This example declares
# that your mod requires GL version 3.2 or higher. Other features will be added. They are side aware so declaring this won't
# stop your mod loading on the server for example.