diff --git a/README.md b/README.md index 4a1de14..fccb55d 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,19 @@ Rivencraft is a clean, incremental port of the ideas and gameplay of [Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft 1.21.1 on NeoForge. -The bootstrap milestone is complete. The project currently provides a valid -NeoForge mod, a creative tab, and an unwritten Descriptive Book item. Age -generation, linking, symbols, instability, and writing mechanics have not been -ported yet. +The bootstrap milestone is complete and books/linking is in progress. The +project currently provides an unwritten Descriptive Book and a functional +Linking Book with persistent, dimension-aware destinations and safe landing +checks. Age generation, symbols, instability, and writing mechanics have not +been ported yet. + +## Linking Book + +Craft a Linking Book from a vanilla book and an ender pearl. Sneak-right-click +the unbound book to record the current location, then right-click it to return +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. ## Requirements diff --git a/docs/PORTING.md b/docs/PORTING.md index c55521d..62d044f 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -10,7 +10,7 @@ split into independently testable milestones. 1. **Bootstrap** — NeoForge 1.21.1 build, mod metadata, item registration, and CI. **Complete.** 2. **Books and linking** — descriptive/linking book data components, lectern-like - interaction, safe teleportation, and return links. + interaction, safe teleportation, and return links. **In progress.** 3. **Ages** — dimension definitions backed by data packs, deterministic seeds, lifecycle management, and persistence. 4. **Writing system** — pages, symbols, notebooks, desks, and book assembly. @@ -37,6 +37,22 @@ server-managed Age definition, travel to that Age, and use a Linking Book to return. This exercises persistence, networking, dimensions, and safety before the symbol grammar is introduced. +### Milestone 2 progress + +- [x] Persistent, network-synchronized link-target data component. +- [x] Linking Book binding with dimension, block position, yaw, and pitch. +- [x] Server-authoritative cross-dimension travel with world-border, collision, + fluid, and floor checks. +- [x] Nearby landing search and clear failure messages. +- [ ] Descriptive Book identity and Age reference components. +- [ ] Return-link creation as part of Descriptive Book travel. +- [ ] Lectern/book-stand interaction. +- [ ] Automated in-game tests for binding, persistence, and travel. + +The temporary shapeless Linking Book recipe makes the linking subsystem easy +to test. It can move into the writing/bookbinder progression when that system +is implemented. + ## Milestone 1 acceptance record - The project builds on a Java 21 JDK with `./gradlew build`. diff --git a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index d2ac486..c44783f 100644 --- a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -2,6 +2,7 @@ package net.dnorberg.rivencraft; 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.neoforged.bus.api.IEventBus; import net.neoforged.fml.common.Mod; @@ -13,6 +14,7 @@ public final class Rivencraft { public static final Logger LOGGER = LogUtils.getLogger(); public Rivencraft(IEventBus modEventBus) { + ModDataComponents.register(modEventBus); ModItems.register(modEventBus); ModCreativeTabs.register(modEventBus); LOGGER.info("Initializing Rivencraft"); diff --git a/src/main/java/net/dnorberg/rivencraft/book/LinkTarget.java b/src/main/java/net/dnorberg/rivencraft/book/LinkTarget.java new file mode 100644 index 0000000..4648c20 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/LinkTarget.java @@ -0,0 +1,36 @@ +package net.dnorberg.rivencraft.book; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import io.netty.buffer.ByteBuf; +import net.minecraft.core.GlobalPos; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.server.level.ServerPlayer; + +/** A persistent, dimension-aware destination recorded in a Linking Book. */ +public record LinkTarget(GlobalPos location, float yaw, float pitch) { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + GlobalPos.CODEC.fieldOf("location").forGetter(LinkTarget::location), + Codec.FLOAT.fieldOf("yaw").forGetter(LinkTarget::yaw), + Codec.FLOAT.fieldOf("pitch").forGetter(LinkTarget::pitch) + ).apply(instance, LinkTarget::new)); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + GlobalPos.STREAM_CODEC, + LinkTarget::location, + ByteBufCodecs.FLOAT, + LinkTarget::yaw, + ByteBufCodecs.FLOAT, + LinkTarget::pitch, + LinkTarget::new + ); + + public static LinkTarget from(ServerPlayer player) { + return new LinkTarget( + GlobalPos.of(player.level().dimension(), player.blockPosition()), + player.getYRot(), + player.getXRot() + ); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/LinkingService.java b/src/main/java/net/dnorberg/rivencraft/book/LinkingService.java new file mode 100644 index 0000000..b6babd2 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/LinkingService.java @@ -0,0 +1,79 @@ +package net.dnorberg.rivencraft.book; + +import java.util.Comparator; +import java.util.Optional; +import java.util.stream.Stream; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.border.WorldBorder; +import net.minecraft.world.phys.Vec3; + +/** Server-side validation and execution for book travel. */ +public final class LinkingService { + private static final int HORIZONTAL_SEARCH_RADIUS = 4; + private static final int VERTICAL_SEARCH_RADIUS = 2; + + private LinkingService() { + } + + public static LinkResult travel(ServerPlayer player, LinkTarget target) { + ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension()); + if (destinationLevel == null) { + return LinkResult.MISSING_DIMENSION; + } + + Optional destination = findSafeDestination(destinationLevel, target.location().pos()); + if (destination.isEmpty()) { + return LinkResult.NO_SAFE_DESTINATION; + } + + Vec3 position = destination.get(); + player.teleportTo( + destinationLevel, + position.x, + position.y, + position.z, + target.yaw(), + target.pitch() + ); + player.resetFallDistance(); + return LinkResult.SUCCESS; + } + + static Optional findSafeDestination(ServerLevel level, BlockPos origin) { + WorldBorder border = level.getWorldBorder(); + return candidates(origin) + .filter(level::isInWorldBounds) + .filter(border::isWithinBounds) + .filter(position -> isSafe(level, position)) + .map(position -> Vec3.atBottomCenterOf(position)) + .findFirst(); + } + + private static Stream candidates(BlockPos origin) { + return BlockPos.betweenClosedStream( + origin.offset(-HORIZONTAL_SEARCH_RADIUS, -VERTICAL_SEARCH_RADIUS, -HORIZONTAL_SEARCH_RADIUS), + origin.offset(HORIZONTAL_SEARCH_RADIUS, VERTICAL_SEARCH_RADIUS, HORIZONTAL_SEARCH_RADIUS) + ) + .map(BlockPos::immutable) + .sorted(Comparator.comparingDouble(position -> position.distSqr(origin))); + } + + private static boolean isSafe(ServerLevel level, BlockPos feet) { + BlockPos head = feet.above(); + BlockPos floor = feet.below(); + return level.getBlockState(feet).getCollisionShape(level, feet).isEmpty() + && level.getFluidState(feet).isEmpty() + && level.getBlockState(head).getCollisionShape(level, head).isEmpty() + && level.getFluidState(head).isEmpty() + && level.getBlockState(floor).isFaceSturdy(level, floor, Direction.UP); + } + + public enum LinkResult { + SUCCESS, + MISSING_DIMENSION, + NO_SAFE_DESTINATION + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java b/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java new file mode 100644 index 0000000..ba45baf --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java @@ -0,0 +1,102 @@ +package net.dnorberg.rivencraft.item; + +import java.util.List; +import net.dnorberg.rivencraft.book.LinkTarget; +import net.dnorberg.rivencraft.book.LinkingService; +import net.dnorberg.rivencraft.registry.ModDataComponents; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +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.InteractionResultHolder; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +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); + } + + @Override + public InteractionResultHolder use(Level level, Player player, InteractionHand usedHand) { + ItemStack stack = player.getItemInHand(usedHand); + if (!(player instanceof ServerPlayer serverPlayer)) { + return InteractionResultHolder.sidedSuccess(stack, level.isClientSide()); + } + + 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); + } + + LinkingService.LinkResult result = LinkingService.travel(serverPlayer, target); + if (result == LinkingService.LinkResult.SUCCESS) { + serverPlayer.getCooldowns().addCooldown(this, LINK_COOLDOWN_TICKS); + serverPlayer.level().playSound( + null, + serverPlayer.blockPosition(), + SoundEvents.PORTAL_TRAVEL, + SoundSource.PLAYERS, + 0.35F, + 1.0F + ); + player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.linked"), true); + return InteractionResultHolder.success(stack); + } + + String errorKey = result == LinkingService.LinkResult.MISSING_DIMENSION + ? "message.rivencraft.linking_book.missing_dimension" + : "message.rivencraft.linking_book.unsafe"; + player.displayClientMessage(Component.translatable(errorKey), true); + return InteractionResultHolder.fail(stack); + } + + @Override + public boolean isFoil(ItemStack stack) { + return stack.has(ModDataComponents.LINK_TARGET.get()) || super.isFoil(stack); + } + + @Override + public void appendHoverText( + ItemStack stack, + TooltipContext context, + List tooltipComponents, + TooltipFlag tooltipFlag + ) { + LinkTarget target = stack.get(ModDataComponents.LINK_TARGET.get()); + if (target == null) { + tooltipComponents.add(Component.translatable("item.rivencraft.linking_book.unbound") + .withStyle(ChatFormatting.GRAY)); + return; + } + + ResourceLocation dimension = target.location().dimension().location(); + BlockPos position = target.location().pos(); + tooltipComponents.add(Component.translatable( + "item.rivencraft.linking_book.destination", + dimension.toString(), + position.getX(), + position.getY(), + position.getZ() + ) + .withStyle(ChatFormatting.GRAY)); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java index 354d8b8..91d4902 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java @@ -16,7 +16,10 @@ public final class ModCreativeTabs { CREATIVE_TABS.register("rivencraft", () -> CreativeModeTab.builder() .title(Component.translatable("itemGroup.rivencraft")) .icon(() -> ModItems.DESCRIPTIVE_BOOK.get().getDefaultInstance()) - .displayItems((parameters, output) -> output.accept(ModItems.DESCRIPTIVE_BOOK.get())) + .displayItems((parameters, output) -> { + output.accept(ModItems.DESCRIPTIVE_BOOK.get()); + output.accept(ModItems.LINKING_BOOK.get()); + }) .build()); private ModCreativeTabs() { diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModDataComponents.java b/src/main/java/net/dnorberg/rivencraft/registry/ModDataComponents.java new file mode 100644 index 0000000..6f4efa5 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModDataComponents.java @@ -0,0 +1,28 @@ +package net.dnorberg.rivencraft.registry; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.book.LinkTarget; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.core.registries.Registries; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.neoforge.registries.DeferredHolder; +import net.neoforged.neoforge.registries.DeferredRegister; + +public final class ModDataComponents { + private static final DeferredRegister> DATA_COMPONENTS = + DeferredRegister.create(Registries.DATA_COMPONENT_TYPE, Rivencraft.MOD_ID); + + public static final DeferredHolder, DataComponentType> LINK_TARGET = + DATA_COMPONENTS.register("link_target", () -> DataComponentType.builder() + .persistent(LinkTarget.CODEC) + .networkSynchronized(LinkTarget.STREAM_CODEC) + .cacheEncoding() + .build()); + + private ModDataComponents() { + } + + public static void register(IEventBus modEventBus) { + DATA_COMPONENTS.register(modEventBus); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java b/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java index 38b85ee..7eeb238 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java @@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.registry; import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.item.DescriptiveBookItem; +import net.dnorberg.rivencraft.item.LinkingBookItem; import net.minecraft.world.item.Item; import net.neoforged.bus.api.IEventBus; import net.neoforged.neoforge.registries.DeferredItem; @@ -15,6 +16,11 @@ public final class ModItems { DescriptiveBookItem::new, new Item.Properties().stacksTo(1) ); + public static final DeferredItem LINKING_BOOK = ITEMS.registerItem( + "linking_book", + LinkingBookItem::new, + new Item.Properties().stacksTo(1) + ); private ModItems() { } diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index ba9bb8d..2dbe910 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -1,5 +1,13 @@ { "item.rivencraft.descriptive_book": "Descriptive Book", "item.rivencraft.descriptive_book.unwritten": "Unwritten", + "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.bound": "Linking Book bound", + "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", "itemGroup.rivencraft": "Rivencraft" } diff --git a/src/main/resources/assets/rivencraft/models/item/linking_book.json b/src/main/resources/assets/rivencraft/models/item/linking_book.json new file mode 100644 index 0000000..01589d3 --- /dev/null +++ b/src/main/resources/assets/rivencraft/models/item/linking_book.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "minecraft:item/enchanted_book" + } +} diff --git a/src/main/resources/data/rivencraft/recipe/linking_book.json b/src/main/resources/data/rivencraft/recipe/linking_book.json new file mode 100644 index 0000000..f587200 --- /dev/null +++ b/src/main/resources/data/rivencraft/recipe/linking_book.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shapeless", + "category": "misc", + "ingredients": [ + { + "item": "minecraft:book" + }, + { + "item": "minecraft:ender_pearl" + } + ], + "result": { + "count": 1, + "id": "rivencraft:linking_book" + } +}