From 3109938a97882ae84d4f7c27aa3409ee0cb46bcf Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Mon, 21 Sep 2026 07:00:25 -0400 Subject: [PATCH] Convert lecterns into Rivencraft book stands --- README.md | 6 ++ docs/PORTING.md | 14 ++- gradle.properties | 2 +- .../net/dnorberg/rivencraft/Rivencraft.java | 7 ++ .../rivencraft/block/BookStandBlock.java | 91 +++++++++++++++++++ .../block/entity/BookStandBlockEntity.java | 50 ++++++++++ .../rivencraft/client/BookStandRenderer.java | 49 ++++++++++ .../rivencraft/client/ClientEvents.java | 19 ++++ .../event/BookStandConversionHandler.java | 53 +++++++++++ .../rivencraft/item/LinkingBookItem.java | 19 ++-- .../rivencraft/registry/ModBlockEntities.java | 26 ++++++ .../rivencraft/registry/ModBlocks.java | 26 ++++++ .../rivencraft/blockstates/book_stand.json | 19 ++++ .../assets/rivencraft/lang/en_us.json | 1 + .../loot_table/blocks/book_stand.json | 16 ++++ 15 files changed, 390 insertions(+), 8 deletions(-) create mode 100644 src/main/java/net/dnorberg/rivencraft/block/BookStandBlock.java create mode 100644 src/main/java/net/dnorberg/rivencraft/block/entity/BookStandBlockEntity.java create mode 100644 src/main/java/net/dnorberg/rivencraft/client/BookStandRenderer.java create mode 100644 src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java create mode 100644 src/main/java/net/dnorberg/rivencraft/event/BookStandConversionHandler.java create mode 100644 src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java create mode 100644 src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java create mode 100644 src/main/resources/assets/rivencraft/blockstates/book_stand.json create mode 100644 src/main/resources/data/rivencraft/loot_table/blocks/book_stand.json diff --git a/README.md b/README.md index 431045d..9475624 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/PORTING.md b/docs/PORTING.md index 2d581e0..6755a2f 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -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`: diff --git a/gradle.properties b/gradle.properties index 5c64f25..a635e38 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index c44783f..8820ff7 100644 --- a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -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"); } } diff --git a/src/main/java/net/dnorberg/rivencraft/block/BookStandBlock.java b/src/main/java/net/dnorberg/rivencraft/block/BookStandBlock.java new file mode 100644 index 0000000..65273ad --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/block/BookStandBlock.java @@ -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 CODEC = simpleCodec(BookStandBlock::new); + + public BookStandBlock(BlockBehaviour.Properties properties) { + super(properties); + } + + @Override + public MapCodec 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); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/block/entity/BookStandBlockEntity.java b/src/main/java/net/dnorberg/rivencraft/block/entity/BookStandBlockEntity.java new file mode 100644 index 0000000..74c17f4 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/block/entity/BookStandBlockEntity.java @@ -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)); + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/client/BookStandRenderer.java b/src/main/java/net/dnorberg/rivencraft/client/BookStandRenderer.java new file mode 100644 index 0000000..66ff3f0 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/client/BookStandRenderer.java @@ -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 { + 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(); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java new file mode 100644 index 0000000..4dec8ba --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java @@ -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); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/event/BookStandConversionHandler.java b/src/main/java/net/dnorberg/rivencraft/event/BookStandConversionHandler.java new file mode 100644 index 0000000..a54f548 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/event/BookStandConversionHandler.java @@ -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 + ); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java b/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java index ba45baf..68cb06e 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java @@ -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 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, diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java b/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java new file mode 100644 index 0000000..9452892 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java @@ -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> BLOCK_ENTITIES = + DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, Rivencraft.MOD_ID); + + public static final DeferredHolder, BlockEntityType> 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); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java b/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java new file mode 100644 index 0000000..177e6dd --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java @@ -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 BOOK_STAND = BLOCKS.registerBlock( + "book_stand", + BookStandBlock::new, + BlockBehaviour.Properties.ofFullCopy(Blocks.LECTERN) + ); + + private ModBlocks() { + } + + public static void register(IEventBus modEventBus) { + BLOCKS.register(modEventBus); + } +} diff --git a/src/main/resources/assets/rivencraft/blockstates/book_stand.json b/src/main/resources/assets/rivencraft/blockstates/book_stand.json new file mode 100644 index 0000000..78b0622 --- /dev/null +++ b/src/main/resources/assets/rivencraft/blockstates/book_stand.json @@ -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 + } + } +} diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index 13b6484..b6c0490 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -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", diff --git a/src/main/resources/data/rivencraft/loot_table/blocks/book_stand.json b/src/main/resources/data/rivencraft/loot_table/blocks/book_stand.json new file mode 100644 index 0000000..ca302f8 --- /dev/null +++ b/src/main/resources/data/rivencraft/loot_table/blocks/book_stand.json @@ -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" +}