Convert lecterns into Rivencraft book stands
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-09-21 07:00:25 -04:00
1 parent 0e602f0154
commit 3109938a97
15 files changed
+390 -8

No files matched your search

+6
View File
@@ -18,6 +18,12 @@ there. The destination is stored on the item and survives inventory and world
saves. Travel only succeeds if the target dimension is loaded and a solid,
collision-free landing position can be found nearby.
Right-click an empty vanilla lectern with a Rivencraft book to convert it into
a Book Stand and place the book on it. Use a Linking Book on the stand by
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.
## Requirements
- A Java 21 JDK
+13 -1
View File
@@ -46,7 +46,8 @@ the symbol grammar is introduced.
- [x] Nearby landing search and clear failure messages.
- [x] Descriptive Book identity and Age reference components.
- [ ] Return-link creation as part of Descriptive Book travel.
- [ ] Lectern/book-stand interaction.
- [x] Lectern-to-Book-Stand conversion, displayed book, activation, removal,
persistence, and drops.
- [ ] Automated in-game tests for binding, persistence, and travel.
The temporary shapeless Linking Book recipe makes the linking subsystem easy
@@ -57,6 +58,17 @@ Age references use UUIDs rather than dimension keys. Milestone 3 will own the
mapping from that stable identity to server data and a runtime dimension. This
keeps saved books valid if an Age is migrated or regenerated.
Vanilla lecterns accept extensible item tags when inserting books, but their
block entity still validates only vanilla writable and written books. To avoid
an invalid lectern state, inserting a Rivencraft book converts the lectern into
a visually compatible Rivencraft Book Stand. Removing the book converts it
back.
Mystcraft Legacy did not impose an item cooldown when linking. Rivencraft's
initial cooldown used Minecraft's item-type cooldown system, which delayed all
Linking Book stacks at once. Version 0.1.3 removes that cooldown to match the
legacy behavior.
## Version policy
Versions follow `x.y.z`:
+1 -1
View File
@@ -32,7 +32,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.2
mod_version=0.1.3
# 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
@@ -4,8 +4,12 @@ import com.mojang.logging.LogUtils;
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
import net.dnorberg.rivencraft.registry.ModDataComponents;
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.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.Mod;
import net.neoforged.neoforge.common.NeoForge;
import org.slf4j.Logger;
@Mod(Rivencraft.MOD_ID)
@@ -15,8 +19,11 @@ public final class Rivencraft {
public Rivencraft(IEventBus modEventBus) {
ModDataComponents.register(modEventBus);
ModBlocks.register(modEventBus);
ModBlockEntities.register(modEventBus);
ModItems.register(modEventBus);
ModCreativeTabs.register(modEventBus);
NeoForge.EVENT_BUS.addListener(BookStandConversionHandler::onRightClickBlock);
LOGGER.info("Initializing Rivencraft");
}
}
@@ -0,0 +1,91 @@
package net.dnorberg.rivencraft.block;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.LecternBlock;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
/** A lectern-compatible stand that safely stores and activates Rivencraft books. */
public final class BookStandBlock extends LecternBlock {
public static final MapCodec<LecternBlock> CODEC = simpleCodec(BookStandBlock::new);
public BookStandBlock(BlockBehaviour.Properties properties) {
super(properties);
}
@Override
public MapCodec<LecternBlock> codec() {
return CODEC;
}
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new BookStandBlockEntity(pos, state);
}
@Override
protected InteractionResult useWithoutItem(
BlockState state,
Level level,
BlockPos pos,
Player player,
BlockHitResult hitResult
) {
if (!(level.getBlockEntity(pos) instanceof BookStandBlockEntity stand)) {
return InteractionResult.PASS;
}
if (player.isShiftKeyDown()) {
if (!level.isClientSide) {
ItemStack book = stand.takeBook();
BlockState lectern = Blocks.LECTERN.defaultBlockState()
.setValue(FACING, state.getValue(FACING));
level.setBlock(pos, lectern, 3);
if (!player.getInventory().add(book)) {
player.drop(book, false);
}
level.playSound(null, pos, SoundEvents.BOOK_PAGE_TURN, SoundSource.BLOCKS, 1.0F, 1.0F);
}
return InteractionResult.sidedSuccess(level.isClientSide);
}
ItemStack book = stand.getBook();
if (book.getItem() instanceof LinkingBookItem && player instanceof ServerPlayer serverPlayer) {
LinkingBookItem.useBoundBook(serverPlayer, book);
}
return InteractionResult.sidedSuccess(level.isClientSide);
}
@Override
protected void onRemove(BlockState state, Level level, BlockPos pos, BlockState newState, boolean isMoving) {
if (!state.is(newState.getBlock()) && level.getBlockEntity(pos) instanceof BookStandBlockEntity stand) {
ItemStack book = stand.takeBook();
if (!book.isEmpty()) {
ItemEntity droppedBook = new ItemEntity(
level,
pos.getX() + 0.5,
pos.getY() + 1.0,
pos.getZ() + 0.5,
book
);
droppedBook.setDefaultPickUpDelay();
level.addFreshEntity(droppedBook);
}
}
super.onRemove(state, level, pos, newState, isMoving);
}
}
@@ -0,0 +1,50 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
public final class BookStandBlockEntity extends BlockEntity {
private static final String BOOK_TAG = "Book";
private ItemStack book = ItemStack.EMPTY;
public BookStandBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.BOOK_STAND.get(), pos, state);
}
public ItemStack getBook() {
return book;
}
public void setBook(ItemStack book) {
this.book = book;
setChanged();
}
public ItemStack takeBook() {
ItemStack result = book;
book = ItemStack.EMPTY;
setChanged();
return result;
}
@Override
protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.loadAdditional(tag, registries);
book = tag.contains(BOOK_TAG, CompoundTag.TAG_COMPOUND)
? ItemStack.parse(registries, tag.getCompound(BOOK_TAG)).orElse(ItemStack.EMPTY)
: ItemStack.EMPTY;
}
@Override
protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries);
if (!book.isEmpty()) {
tag.put(BOOK_TAG, book.save(registries));
}
}
}
@@ -0,0 +1,49 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import com.mojang.math.Axis;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.minecraft.client.model.BookModel;
import net.minecraft.client.model.geom.ModelLayers;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.blockentity.EnchantTableRenderer;
import net.minecraft.world.level.block.state.BlockState;
public final class BookStandRenderer implements BlockEntityRenderer<BookStandBlockEntity> {
private final BookModel bookModel;
public BookStandRenderer(BlockEntityRendererProvider.Context context) {
bookModel = new BookModel(context.bakeLayer(ModelLayers.BOOK));
}
@Override
public void render(
BookStandBlockEntity blockEntity,
float partialTick,
PoseStack poseStack,
MultiBufferSource bufferSource,
int packedLight,
int packedOverlay
) {
BlockState state = blockEntity.getBlockState();
if (!state.getValue(BookStandBlock.HAS_BOOK)) {
return;
}
poseStack.pushPose();
poseStack.translate(0.5F, 1.0625F, 0.5F);
float rotation = state.getValue(BookStandBlock.FACING).getClockWise().toYRot();
poseStack.mulPose(Axis.YP.rotationDegrees(-rotation));
poseStack.mulPose(Axis.ZP.rotationDegrees(67.5F));
poseStack.translate(0.0F, -0.125F, 0.0F);
bookModel.setupAnim(0.0F, 0.1F, 0.9F, 1.2F);
VertexConsumer consumer = EnchantTableRenderer.BOOK_LOCATION.buffer(bufferSource, RenderType::entitySolid);
bookModel.render(poseStack, consumer, packedLight, packedOverlay, -1);
poseStack.popPose();
}
}
@@ -0,0 +1,19 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.Rivencraft;
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.neoforge.client.event.EntityRenderersEvent;
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
public final class ClientEvents {
private ClientEvents() {
}
@SubscribeEvent
public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) {
event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new);
}
}
@@ -0,0 +1,53 @@
package net.dnorberg.rivencraft.event;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.LecternBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent;
public final class BookStandConversionHandler {
private BookStandConversionHandler() {
}
public static void onRightClickBlock(PlayerInteractEvent.RightClickBlock event) {
ItemStack heldBook = event.getItemStack();
if (!heldBook.is(ModItems.LINKING_BOOK.get()) && !heldBook.is(ModItems.DESCRIPTIVE_BOOK.get())) {
return;
}
BlockState lectern = event.getLevel().getBlockState(event.getPos());
if (!lectern.is(Blocks.LECTERN) || lectern.getValue(LecternBlock.HAS_BOOK)) {
return;
}
event.setCanceled(true);
event.setCancellationResult(InteractionResult.sidedSuccess(event.getLevel().isClientSide));
if (event.getLevel().isClientSide) {
return;
}
BlockState bookStand = ModBlocks.BOOK_STAND.get().defaultBlockState()
.setValue(BookStandBlock.FACING, lectern.getValue(LecternBlock.FACING))
.setValue(BookStandBlock.HAS_BOOK, true);
event.getLevel().setBlock(event.getPos(), bookStand, 3);
if (event.getLevel().getBlockEntity(event.getPos()) instanceof BookStandBlockEntity stand) {
stand.setBook(heldBook.consumeAndReturn(1, event.getEntity()));
}
event.getLevel().playSound(
null,
event.getPos(),
SoundEvents.BOOK_PUT,
SoundSource.BLOCKS,
1.0F,
1.0F
);
}
}
@@ -20,8 +20,6 @@ import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
public final class LinkingBookItem extends Item {
private static final int LINK_COOLDOWN_TICKS = 40;
public LinkingBookItem(Properties properties) {
super(properties);
}
@@ -47,12 +45,21 @@ public final class LinkingBookItem extends Item {
return InteractionResultHolder.success(stack);
}
LinkingService.LinkResult result = LinkingService.travel(serverPlayer, target);
return useBoundBook(serverPlayer, stack);
}
public static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack) {
LinkTarget target = stack.get(ModDataComponents.LINK_TARGET.get());
if (target == null) {
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.unbound"), true);
return InteractionResultHolder.fail(stack);
}
LinkingService.LinkResult result = LinkingService.travel(player, target);
if (result == LinkingService.LinkResult.SUCCESS) {
serverPlayer.getCooldowns().addCooldown(this, LINK_COOLDOWN_TICKS);
serverPlayer.level().playSound(
player.level().playSound(
null,
serverPlayer.blockPosition(),
player.blockPosition(),
SoundEvents.PORTAL_TRAVEL,
SoundSource.PLAYERS,
0.35F,
@@ -0,0 +1,26 @@
package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister;
public final class ModBlockEntities {
private static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITIES =
DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, Rivencraft.MOD_ID);
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<BookStandBlockEntity>> BOOK_STAND =
BLOCK_ENTITIES.register("book_stand", () -> BlockEntityType.Builder
.of(BookStandBlockEntity::new, ModBlocks.BOOK_STAND.get())
.build(null));
private ModBlockEntities() {
}
public static void register(IEventBus modEventBus) {
BLOCK_ENTITIES.register(modEventBus);
}
}
@@ -0,0 +1,26 @@
package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredBlock;
import net.neoforged.neoforge.registries.DeferredRegister;
public final class ModBlocks {
private static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(Rivencraft.MOD_ID);
public static final DeferredBlock<BookStandBlock> BOOK_STAND = BLOCKS.registerBlock(
"book_stand",
BookStandBlock::new,
BlockBehaviour.Properties.ofFullCopy(Blocks.LECTERN)
);
private ModBlocks() {
}
public static void register(IEventBus modEventBus) {
BLOCKS.register(modEventBus);
}
}
@@ -0,0 +1,19 @@
{
"variants": {
"facing=east": {
"model": "minecraft:block/lectern",
"y": 90
},
"facing=north": {
"model": "minecraft:block/lectern"
},
"facing=south": {
"model": "minecraft:block/lectern",
"y": 180
},
"facing=west": {
"model": "minecraft:block/lectern",
"y": 270
}
}
}
@@ -6,6 +6,7 @@
"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.linked": "Linked",
"message.rivencraft.linking_book.missing_dimension": "That dimension is unavailable",
@@ -0,0 +1,16 @@
{
"type": "minecraft:block",
"pools": [
{
"bonus_rolls": 0.0,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:lectern"
}
],
"rolls": 1.0
}
],
"random_sequence": "rivencraft:blocks/book_stand"
}