diff --git a/docs/ASSET_PROVENANCE.md b/docs/ASSET_PROVENANCE.md index 935c4cb..16ffae4 100644 --- a/docs/ASSET_PROVENANCE.md +++ b/docs/ASSET_PROVENANCE.md @@ -29,6 +29,9 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license. | `assets/rivencraft/textures/item/crystal_shard.png` | Original sprite generated with OpenAI ImageGen from the Legacy crystal palette | 2026-10-05 | Cropped and nearest-neighbor scaled to 16×16 | | `assets/rivencraft/sounds/linking/link-fissure.ogg` | `assets/mystcraft/sounds/linking/link-fissure.ogg` | Legacy 0.13.7.06 local extraction | Namespace/path rename only; plays when a Star Fissure returns an entity home | | `assets/rivencraft/sounds/entity/meteor/roar.ogg` | `assets/mystcraft/sounds/entity/meteor/roar.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical copy under the Rivencraft namespace; plays during the bounded meteor descent | +| `assets/rivencraft/textures/block/book_receptacle.png` | `assets/mystcraft/textures/blocks/book_receptacle.png` | Legacy 0.13.7.06 local extraction | Byte-identical 16×16 face tile; modern block model recreates the Legacy thin slab | +| `assets/rivencraft/textures/block/crystal_portal.png` and `.mcmeta` | `assets/mystcraft/textures/blocks/portal.png` and `.mcmeta` | Legacy 0.13.7.06 local extraction | Byte-identical texture and line-ending-normalized animation metadata, used when Immersive Portals is absent | +| `assets/rivencraft/sounds/linking/link-portal.ogg` | `assets/mystcraft/sounds/linking/link-portal.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical portal-link sound | Mystcraft Legacy identifies XCompWiz as its project developer and includes the full LGPL v3 text in its root `LICENSE` file. Rivencraft's root `LICENSE` diff --git a/docs/LEGACY_M7_AUDIT.md b/docs/LEGACY_M7_AUDIT.md index 0a36c7b..8c34e3d 100644 --- a/docs/LEGACY_M7_AUDIT.md +++ b/docs/LEGACY_M7_AUDIT.md @@ -15,6 +15,12 @@ Rivencraft has books, Ages, symbol writing, book stands, a desk, binder, Ink Mix ## Crystal portals: targets for #77–#78 +Issue #77 now has a craftable Book Receptacle (eight Crystal Blocks around an empty center), a bounded loaded-chunk crystal-network validator, persistent book holder/source pointers, animated Legacy portal cells, and portal collision routed through the existing Linking/Descriptive Book travel services. A book is only taken after a nonempty frame plan and destination dimension are validated. Removing the book or breaking its support removes the portal; a periodic server repair pass checks the loaded frame after restarts or edits. The Descriptive Book path preserves the configured automatic Return Link policy and link modifiers. Crystal-portal arrival does not generate a landing platform, matching Legacy's external travel semantics. A normal Linking Book still needs Intra Linking for same-dimension travel. + +When Immersive Portals is present, render-only, non-teleporting IP portal entities show the book's destination through both faces of each coplanar opening; individual crystal cells are combined into one shaped surface so the destination is not repeated tile by tile. The view's central camera is aligned to the same safe arrival position and yaw/pitch used by book travel (at standing eye height). Perspective still changes naturally as the observer moves in front of a live portal. Each live view is recessed inside the opening; the fallback portal texture is always hidden while IP is installed, and crystal blocks render after the IP view so the frame appears on top. Without IP, the animated Legacy portal texture remains visible. Rivencraft portal-block contact remains the travel authority. This is an optional compile-only integration. The first placement of a Descriptive Book can generate/load its Age to establish a view destination. If the optional view is absent after restart, the holder's periodic pass recreates it. #78 retains exhaustive multiplayer, protection, destination deletion, and chunk-ticket hardening. + +Manual #77 check: build a 3×3 vertical Crystal Block ring, put the Receptacle on one frame crystal's outward face, then insert a bound Linking Book. The center should animate and link on contact; the book stays in the Receptacle. Confirm unbound books and incomplete frames do not consume items; empty-hand right-click retrieves the book and clears the surface. Repeat with a Descriptive Book and with Immersive Portals installed: the latter should show the destination live. Restart with the portal active and confirm the surface/view returns; break the support crystal and confirm book/portal cleanup. + ## Library implementation (#76) The operator-only test controls are `/rivencraft library test-spacing 8` (roughly 16× normal candidate density in newly generated chunks), `/rivencraft library test-spacing` (show the current setting), and `/rivencraft library test-spacing reset` (return to Legacy spacing). The override affects only the current Age, lasts only until server restart, and never retroactively places libraries in existing chunks. Normal sites remain eligible. A spacing of `1` attempts a candidate in every new chunk and may be expensive. `/rivencraft library nearest` searches nearby test sites while the override is active. `/rivencraft library test-place` is a separate OP-only manual test: it places a library about 13 blocks ahead of the player in the current Age if an 11×11×11 volume is clear, building a one-block cobblestone platform below it. The command reports the chest coordinates; it does not alter natural spawning or make the manual library a locate candidate. Move to open ground if the command rejects the site. diff --git a/gradle.properties b/gradle.properties index 6694a73..601e7c6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -36,7 +36,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. Rivencraft uses the x.y.z.b policy documented in README.md. -mod_version=0.6.1.11 +mod_version=0.6.2.4 immersive_portals_version=6.0.7 terrablender_version=4.1.0.8 serene_seasons_version_id=B6nTjh4b diff --git a/src/main/java/net/dnorberg/rivencraft/block/BookReceptacleBlock.java b/src/main/java/net/dnorberg/rivencraft/block/BookReceptacleBlock.java new file mode 100644 index 0000000..909a461 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/block/BookReceptacleBlock.java @@ -0,0 +1,155 @@ +package net.dnorberg.rivencraft.block; + +import com.mojang.serialization.MapCodec; +import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity; +import net.dnorberg.rivencraft.portal.CrystalPortalNetwork; +import net.dnorberg.rivencraft.book.AgeDimensionService; +import net.dnorberg.rivencraft.book.LinkTarget; +import net.dnorberg.rivencraft.registry.ModBlocks; +import net.dnorberg.rivencraft.registry.ModDataComponents; +import net.dnorberg.rivencraft.registry.ModItems; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +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.InteractionResult; +import net.minecraft.world.ItemInteractionResult; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityTicker; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.dnorberg.rivencraft.registry.ModBlockEntities; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.DirectionProperty; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** A book-holding crystal attachment, with server-authoritative frame activation. */ +public final class BookReceptacleBlock extends BaseEntityBlock { + public static final MapCodec CODEC = simpleCodec(BookReceptacleBlock::new); + public static final DirectionProperty FACING = BlockStateProperties.FACING; + + public BookReceptacleBlock(Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH)); + } + + @Override protected MapCodec codec() { return CODEC; } + @Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; } + @Override protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, + CollisionContext context) { + return switch (state.getValue(FACING)) { + case NORTH -> Block.box(0, 0, 10, 16, 16, 16); + case SOUTH -> Block.box(0, 0, 0, 16, 16, 6); + case EAST -> Block.box(0, 0, 0, 6, 16, 16); + case WEST -> Block.box(10, 0, 0, 16, 16, 16); + case UP -> Block.box(0, 0, 0, 16, 6, 16); + case DOWN -> Block.box(0, 10, 0, 16, 16, 16); + }; + } + @Override protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(FACING); + } + @Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new BookReceptacleBlockEntity(pos, state); + } + @Override public BlockEntityTicker getTicker(Level level, BlockState state, + BlockEntityType type) { + return level instanceof ServerLevel + ? createTickerHelper(type, ModBlockEntities.BOOK_RECEPTACLE.get(), BookReceptacleBlockEntity::serverTick) + : null; + } + @Override public BlockState getStateForPlacement(BlockPlaceContext context) { + Direction face = context.getClickedFace(); + if (face == Direction.DOWN || !context.getLevel().getBlockState( + context.getClickedPos().relative(face.getOpposite())).is(ModBlocks.CRYSTAL_BLOCK.get())) return null; + return defaultBlockState().setValue(FACING, face); + } + + @Override protected ItemInteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos, + Player player, InteractionHand hand, BlockHitResult hit) { + if (!isBoundBook(stack)) return ItemInteractionResult.PASS_TO_DEFAULT_BLOCK_INTERACTION; + if (level.isClientSide) return ItemInteractionResult.SUCCESS; + if (!(level instanceof ServerLevel server) || + !(level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder)) return ItemInteractionResult.FAIL; + if (!holder.getBook().isEmpty()) return ItemInteractionResult.FAIL; + CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(server, pos); + if (!plan.valid()) { + player.displayClientMessage(Component.translatable("message.rivencraft.portal.invalid_frame"), true); + return ItemInteractionResult.FAIL; + } + LinkTarget destination = stack.is(ModItems.LINKING_BOOK.get()) + ? stack.get(ModDataComponents.LINK_TARGET.get()) + : player instanceof ServerPlayer serverPlayer + ? AgeDimensionService.resolve(serverPlayer, stack.get(ModDataComponents.AGE_REFERENCE.get())).orElse(null) + : null; + if (destination == null || server.getServer().getLevel(destination.location().dimension()) == null) { + player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true); + return ItemInteractionResult.FAIL; + } + holder.setBook(stack.copyWithCount(1)); + holder.setDestination(destination); + if (!CrystalPortalNetwork.activate(server, pos, plan)) { + holder.takeBook(); + return ItemInteractionResult.FAIL; + } + if (!player.getAbilities().instabuild) stack.shrink(1); + if (net.neoforged.fml.ModList.get().isLoaded("immersive_portals_core")) + net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.activate(server, pos, plan, destination); + level.playSound(null, pos, SoundEvents.ENCHANTMENT_TABLE_USE, SoundSource.BLOCKS, 1, 1); + return ItemInteractionResult.SUCCESS; + } + + @Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, + Player player, BlockHitResult hit) { + if (level.isClientSide) return InteractionResult.SUCCESS; + if (level instanceof ServerLevel server && level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder + && !holder.getBook().isEmpty()) { + CrystalPortalNetwork.shutdown(server, pos); + ItemStack book = holder.takeBook(); + if (!player.getInventory().add(book)) player.drop(book, false); + level.playSound(null, pos, SoundEvents.BOOK_PAGE_TURN, SoundSource.BLOCKS, 1, 1); + return InteractionResult.SUCCESS; + } + return InteractionResult.PASS; + } + + @Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor, + BlockPos neighborPos, boolean moved) { + super.neighborChanged(state, level, pos, neighbor, neighborPos, moved); + if (!level.isClientSide && !level.getBlockState(pos.relative(state.getValue(FACING).getOpposite())) + .is(ModBlocks.CRYSTAL_BLOCK.get())) level.destroyBlock(pos, true); + } + + @Override protected void onRemove(BlockState state, Level level, BlockPos pos, BlockState replacement, boolean moving) { + if (!state.is(replacement.getBlock()) && level instanceof ServerLevel server) { + CrystalPortalNetwork.shutdown(server, pos); + if (level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder) { + ItemStack book = holder.takeBook(); + if (!book.isEmpty()) level.addFreshEntity(new ItemEntity(level, pos.getX()+0.5, pos.getY()+0.5, + pos.getZ()+0.5, book)); + } + } + super.onRemove(state, level, pos, replacement, moving); + } + + private static boolean isBoundBook(ItemStack stack) { + return stack.is(ModItems.LINKING_BOOK.get()) && stack.has(ModDataComponents.LINK_TARGET.get()) + || stack.is(ModItems.DESCRIPTIVE_BOOK.get()) && stack.has(ModDataComponents.AGE_REFERENCE.get()); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/block/CrystalPortalBlock.java b/src/main/java/net/dnorberg/rivencraft/block/CrystalPortalBlock.java new file mode 100644 index 0000000..61dc964 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/block/CrystalPortalBlock.java @@ -0,0 +1,91 @@ +package net.dnorberg.rivencraft.block; + +import com.mojang.serialization.MapCodec; +import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity; +import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity; +import net.dnorberg.rivencraft.item.DescriptiveBookItem; +import net.dnorberg.rivencraft.item.LinkingBookItem; +import net.dnorberg.rivencraft.portal.CrystalPortalNetwork; +import net.dnorberg.rivencraft.portal.PortalTravelCooldown; +import net.dnorberg.rivencraft.registry.ModBlocks; +import net.dnorberg.rivencraft.registry.ModItems; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.Tag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BooleanProperty; +import net.minecraft.world.level.material.Fluid; +import net.neoforged.fml.ModList; + +/** Non-solid linked surface; all travel is delegated to the held book's rules. */ +public final class CrystalPortalBlock extends BaseEntityBlock { + public static final MapCodec CODEC = simpleCodec(CrystalPortalBlock::new); + // Retained so portal blocks already saved with this state still deserialize. + public static final BooleanProperty LIVE_VIEW = BooleanProperty.create("live_view"); + private static final String COOLDOWN = "RivencraftCrystalPortalCooldown"; + + public CrystalPortalBlock(Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any().setValue(LIVE_VIEW, false)); + } + @Override protected MapCodec codec() { return CODEC; } + @Override protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(LIVE_VIEW); + } + @Override protected RenderShape getRenderShape(BlockState state) { + // The live-view flag can lag behind portal-entity creation or fail to + // synchronize for an existing frame. Never draw the fallback texture + // when Immersive Portals owns the view for this installation. + return ModList.get().isLoaded("immersive_portals_core") + ? RenderShape.INVISIBLE : RenderShape.MODEL; + } + @Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new CrystalPortalBlockEntity(pos, state); + } + @Override protected boolean canBeReplaced(BlockState state, Fluid fluid) { return false; } + + @Override protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity) { + if (!(level instanceof ServerLevel server) || !(entity instanceof ServerPlayer player)) return; + long now = server.getServer().getTickCount(); + long previous = player.getPersistentData().contains(COOLDOWN, Tag.TAG_LONG) + ? player.getPersistentData().getLong(COOLDOWN) : -1; + if (previous > now) { + player.getPersistentData().remove(COOLDOWN); + previous = -1; + } + if (PortalTravelCooldown.blocked(now, previous)) return; + if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) + || portal.receptacle() == null || !level.hasChunkAt(portal.receptacle()) + || !(level.getBlockEntity(portal.receptacle()) instanceof BookReceptacleBlockEntity holder)) return; + ItemStack book = holder.getBook(); + if (book.isEmpty()) return; + player.getPersistentData().putLong(COOLDOWN, now); + if (book.is(ModItems.LINKING_BOOK.get())) LinkingBookItem.usePortalBook(player, book); + else if (book.is(ModItems.DESCRIPTIVE_BOOK.get())) DescriptiveBookItem.usePortalBook(player, book); + } + + @Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor, + BlockPos neighborPos, boolean moved) { + super.neighborChanged(state, level, pos, neighbor, neighborPos, moved); + if (level instanceof ServerLevel server) server.scheduleTick(pos, this, 2); + } + + @Override protected void tick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) { + if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) + || portal.receptacle() == null || !CrystalPortalNetwork.validCell(level, pos, portal.receptacle())) { + level.removeBlock(pos, false); + return; + } + level.scheduleTick(pos, this, 100); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/block/entity/BookReceptacleBlockEntity.java b/src/main/java/net/dnorberg/rivencraft/block/entity/BookReceptacleBlockEntity.java new file mode 100644 index 0000000..ee3b867 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/block/entity/BookReceptacleBlockEntity.java @@ -0,0 +1,95 @@ +package net.dnorberg.rivencraft.block.entity; + +import net.dnorberg.rivencraft.book.LinkTarget; +import net.dnorberg.rivencraft.registry.ModBlockEntities; +import net.dnorberg.rivencraft.portal.CrystalPortalNetwork; +import net.minecraft.nbt.NbtOps; +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.server.level.ServerLevel; +import net.neoforged.fml.ModList; + +/** The book stays in this server-owned inventory while its crystal portal is active. */ +public final class BookReceptacleBlockEntity extends BlockEntity { + private static final String BOOK_TAG = "Book"; + private static final String DESTINATION_TAG = "Destination"; + private ItemStack book = ItemStack.EMPTY; + private LinkTarget destination; + + public BookReceptacleBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.BOOK_RECEPTACLE.get(), pos, state); + } + + public ItemStack getBook() { return book; } + public LinkTarget destination() { return destination; } + + public void setBook(ItemStack stack) { + book = stack.copyWithCount(1); + sync(); + } + + public void setDestination(LinkTarget target) { + destination = target; + sync(); + } + + public ItemStack takeBook() { + ItemStack result = book; + book = ItemStack.EMPTY; + destination = null; + sync(); + return result; + } + + private void sync() { + setChanged(); + if (level != null && !level.isClientSide) + level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2); + } + + @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; + destination = tag.contains(DESTINATION_TAG) + ? LinkTarget.CODEC.parse(NbtOps.INSTANCE, tag.get(DESTINATION_TAG)).result().orElse(null) : null; + } + + @Override + protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { + super.saveAdditional(tag, registries); + if (!book.isEmpty()) tag.put(BOOK_TAG, book.save(registries)); + if (destination != null) LinkTarget.CODEC.encodeStart(NbtOps.INSTANCE, destination) + .result().ifPresent(encoded -> tag.put(DESTINATION_TAG, encoded)); + } + + @Override public Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); + } + + @Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { + return saveWithoutMetadata(registries); + } + + public static void serverTick(net.minecraft.world.level.Level world, BlockPos pos, BlockState state, + BookReceptacleBlockEntity holder) { + if (!(world instanceof ServerLevel level)) return; + if (level.getGameTime() % 200 != 0 || holder.book.isEmpty()) return; + CrystalPortalNetwork.reconcile(level, pos); + if (holder.destination == null || !ModList.get().isLoaded("immersive_portals_core")) return; + if (!net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.exists(level, pos)) { + CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(level, pos); + if (plan.valid()) net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.activate( + level, pos, plan, holder.destination); + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/block/entity/CrystalPortalBlockEntity.java b/src/main/java/net/dnorberg/rivencraft/block/entity/CrystalPortalBlockEntity.java new file mode 100644 index 0000000..220e1d2 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/block/entity/CrystalPortalBlockEntity.java @@ -0,0 +1,51 @@ +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.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** Persistent pointer to the local receptacle; no duplicate book or destination state. */ +public final class CrystalPortalBlockEntity extends BlockEntity { + private static final String SOURCE_TAG = "Receptacle"; + private BlockPos receptacle; + + public CrystalPortalBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.CRYSTAL_PORTAL.get(), pos, state); + } + + public BlockPos receptacle() { return receptacle; } + + public void setReceptacle(BlockPos pos) { + receptacle = pos.immutable(); + setChanged(); + if (level != null && !level.isClientSide) + level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2); + } + + @Override + protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) { + super.loadAdditional(tag, registries); + receptacle = tag.contains(SOURCE_TAG, CompoundTag.TAG_COMPOUND) + ? net.minecraft.nbt.NbtUtils.readBlockPos(tag, SOURCE_TAG).orElse(null) : null; + } + + @Override + protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { + super.saveAdditional(tag, registries); + if (receptacle != null) tag.put(SOURCE_TAG, net.minecraft.nbt.NbtUtils.writeBlockPos(receptacle)); + } + + @Override public Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); + } + + @Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { + return saveWithoutMetadata(registries); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/LinkingService.java b/src/main/java/net/dnorberg/rivencraft/book/LinkingService.java index 59dc06b..f6a7312 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/LinkingService.java +++ b/src/main/java/net/dnorberg/rivencraft/book/LinkingService.java @@ -62,6 +62,20 @@ public final class LinkingService { return LinkResult.SUCCESS; } + /** A descriptive portal uses the book's Age destination without requiring a landing platform. */ + public static LinkResult travelDescriptivePortal(ServerPlayer player, LinkTarget target, LinkProperties properties) { + ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension()); + if (destinationLevel == null) return LinkResult.MISSING_DIMENSION; + Optional destination = resolveDestination(destinationLevel, target.location().pos(), false); + if (destination.isEmpty()) return LinkResult.NO_SAFE_DESTINATION; + Vec3 momentum = player.getDeltaMovement(); + if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player); + teleport(player, destinationLevel, destination.get(), target); + if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum); + player.resetFallDistance(); + return LinkResult.SUCCESS; + } + private static LinkResult travel(ServerPlayer player, LinkTarget target, boolean requiresFooting, LinkProperties properties) { ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension()); diff --git a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java index ea196ec..1316fc9 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java +++ b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java @@ -13,6 +13,7 @@ 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.fml.ModList; import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.client.event.EntityRenderersEvent; import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent; @@ -78,6 +79,12 @@ public final class ClientEvents { ItemBlockRenderTypes.setRenderLayer(ModBlocks.LARGE_CRYSTAL_BUD.get(), RenderType.cutout()); ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_CLUSTER.get(), RenderType.cutout()); ItemBlockRenderTypes.setRenderLayer(ModBlocks.CLOUD_PLATFORM.get(), RenderType.translucent()); + ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_PORTAL.get(), RenderType.translucent()); + // Immersive Portals composes the remote view before translucent blocks. + // Draw the opaque crystal frame in that later pass so it masks the view; + // the portal cell itself is invisible while its live view is active. + if (ModList.get().isLoaded("immersive_portals_core")) + ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_BLOCK.get(), RenderType.translucent()); }); NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickItem); NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickBlock); diff --git a/src/main/java/net/dnorberg/rivencraft/compat/ImmersivePortalsCrystalView.java b/src/main/java/net/dnorberg/rivencraft/compat/ImmersivePortalsCrystalView.java new file mode 100644 index 0000000..3e186cc --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/compat/ImmersivePortalsCrystalView.java @@ -0,0 +1,100 @@ +package net.dnorberg.rivencraft.compat; + +import net.dnorberg.rivencraft.book.LinkTarget; +import net.dnorberg.rivencraft.book.LinkingService; +import net.dnorberg.rivencraft.portal.CrystalPortalNetwork; +import net.dnorberg.rivencraft.portal.CrystalPortalSurfaces; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import qouteall.imm_ptl.core.api.PortalAPI; +import qouteall.imm_ptl.core.portal.Portal; +import qouteall.imm_ptl.core.portal.shape.SpecialFlatPortalShape; +import qouteall.q_misc_util.my_util.DQuaternion; +import qouteall.q_misc_util.my_util.Mesh2D; + +/** Render-only Immersive Portals surfaces; Rivencraft blocks remain the travel authority. */ +public final class ImmersivePortalsCrystalView { + private static final String PREFIX = "rivencraft:crystal:v2:"; + private static final String OLD_PREFIX = "rivencraft:crystal:"; + + private ImmersivePortalsCrystalView() {} + + public static void activate(ServerLevel source, BlockPos receptacle, + CrystalPortalNetwork.Plan plan, LinkTarget target) { + remove(source, receptacle); + ServerLevel destinationLevel = source.getServer().getLevel(target.location().dimension()); + if (destinationLevel == null) return; + // This is the same safe-location search used by portal book travel. + var arrival = LinkingService.resolveDestination(destinationLevel, target.location().pos(), false); + if (arrival.isEmpty()) return; + Vec3 arrivalEye = arrival.get().add(0, 1.62, 0); + for (CrystalPortalSurfaces.Surface surface : CrystalPortalSurfaces.group(source, plan)) { + spawn(source, receptacle, surface, surface.normal(), target, arrivalEye); + spawn(source, receptacle, surface, surface.normal().getOpposite(), target, arrivalEye); + } + } + + public static void remove(ServerLevel source, BlockPos receptacle) { + String tag = tag(receptacle); + String oldTag = OLD_PREFIX + receptacle.asLong(); + AABB area = new AABB(receptacle).inflate(CrystalPortalNetwork.MAX_RADIUS + 2); + for (Portal portal : source.getEntitiesOfClass(Portal.class, area, + entity -> entity.portalTag != null + && (entity.portalTag.equals(tag) || entity.portalTag.equals(oldTag)))) portal.discard(); + } + + public static boolean exists(ServerLevel source, BlockPos receptacle) { + String tag = tag(receptacle); + return !source.getEntitiesOfClass(Portal.class, + new AABB(receptacle).inflate(CrystalPortalNetwork.MAX_RADIUS + 2), + entity -> tag.equals(entity.portalTag)).isEmpty(); + } + + private static void spawn(ServerLevel source, BlockPos receptacle, CrystalPortalSurfaces.Surface surface, + Direction normal, LinkTarget target, Vec3 arrivalEye) { + Portal portal = Portal.ENTITY_TYPE.create(source); + if (portal == null) return; + // One mesh spans the entire coplanar opening. Each cell contributes a + // quad to the same view rather than repeating the destination image. + PortalAPI.setPortalOrthodoxShape(portal, normal, surface.bounds()); + portal.setOriginPos(portal.getOriginPos().add( + Vec3.atLowerCornerOf(normal.getNormal()).scale(-0.125))); + Mesh2D mesh = new Mesh2D(); + double halfU = 1 / portal.getWidth(); + double halfV = 1 / portal.getHeight(); + for (BlockPos cell : surface.cells()) { + Vec3 faceCenter = Vec3.atCenterOf(cell).add( + Vec3.atLowerCornerOf(normal.getNormal()).scale(0.5)); + Vec3 offset = faceCenter.subtract(portal.getOriginPos()); + double u = 2 * offset.dot(portal.getAxisW()) / portal.getWidth(); + double v = 2 * offset.dot(portal.getAxisH()) / portal.getHeight(); + mesh.addQuad(u - halfU, v - halfV, u + halfU, v + halfV); + } + portal.setPortalShape(new SpecialFlatPortalShape(mesh)); + PortalAPI.setPortalTransformation(portal, target.location().dimension(), + arrivalEye, cameraRotation(normal, target), 1); + portal.setTeleportable(false); + portal.setInteractable(false); + portal.portalTag = tag(receptacle); + PortalAPI.spawnServerEntity(portal); + } + + private static DQuaternion cameraRotation(Direction normal, LinkTarget target) { + Vec3 sourceLook = Vec3.atLowerCornerOf(normal.getOpposite().getNormal()); + Vec3 sourceUp = normal.getAxis() == Direction.Axis.Y + ? new Vec3(0, 0, -1) : new Vec3(0, 1, 0); + Vec3 destinationLook = Vec3.directionFromRotation(target.pitch(), target.yaw()); + Vec3 destinationUp = Vec3.directionFromRotation(target.pitch() - 90, target.yaw()); + DQuaternion source = DQuaternion.fromFacingVecs(sourceLook.cross(sourceUp), sourceUp); + DQuaternion destination = DQuaternion.fromFacingVecs( + destinationLook.cross(destinationUp), destinationUp); + return destination.hamiltonProduct(source.getConjugated()).getNormalized(); + } + + private static String tag(BlockPos receptacle) { + return PREFIX + receptacle.asLong(); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/CrystalPortalGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/CrystalPortalGameTests.java new file mode 100644 index 0000000..c72647e --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/gametest/CrystalPortalGameTests.java @@ -0,0 +1,102 @@ +package net.dnorberg.rivencraft.gametest; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.block.BookReceptacleBlock; +import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity; +import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity; +import net.dnorberg.rivencraft.book.LinkTarget; +import net.dnorberg.rivencraft.portal.CrystalPortalNetwork; +import net.dnorberg.rivencraft.portal.CrystalPortalSurfaces; +import net.dnorberg.rivencraft.portal.PortalTravelCooldown; +import net.dnorberg.rivencraft.registry.ModBlocks; +import net.dnorberg.rivencraft.registry.ModItems; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.GlobalPos; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.world.item.ItemStack; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +@GameTestHolder(Rivencraft.MOD_ID) +@PrefixGameTestTemplate(false) +public final class CrystalPortalGameTests { + private CrystalPortalGameTests() {} + + @GameTest(template = "empty") + public static void completeFrameActivatesAndReleasesBook(GameTestHelper helper) { + var level = helper.getLevel(); + for (int x = 0; x < 3; x++) for (int y = 1; y < 4; y++) { + if (x == 1 && y == 2) continue; + level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)), + ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3); + } + BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0)); + level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState() + .setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3); + CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(level, holderPos); + helper.assertTrue(plan.valid(), "A closed crystal frame must form a portal"); + BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos); + holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get())); + LinkTarget destination = new LinkTarget(GlobalPos.of(level.dimension(), centerTarget(helper)), 0, 0); + holder.setDestination(destination); + BookReceptacleBlockEntity restored = new BookReceptacleBlockEntity(holderPos, holder.getBlockState()); + restored.loadWithComponents(holder.saveWithoutMetadata(level.registryAccess()), level.registryAccess()); + helper.assertValueEqual(restored.destination(), destination, + "The portal destination must survive a save/reload cycle"); + helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, plan), "Portal placement must succeed"); + BlockPos center = helper.absolutePos(new BlockPos(1, 2, 1)); + helper.assertTrue(level.getBlockEntity(center) instanceof CrystalPortalBlockEntity portal + && holderPos.equals(portal.receptacle()), "The portal must point to its one book holder"); + CrystalPortalNetwork.shutdown(level, holderPos); + helper.assertTrue(level.getBlockState(center).isAir(), "Removing the book must release the portal"); + helper.succeed(); + } + + private static BlockPos centerTarget(GameTestHelper helper) { + return helper.absolutePos(new BlockPos(1, 2, 1)); + } + + @GameTest(template = "empty") + public static void incompleteFrameCannotActivate(GameTestHelper helper) { + var level = helper.getLevel(); + BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0)); + level.setBlock(helper.absolutePos(new BlockPos(0, 1, 1)), + ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3); + level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState() + .setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3); + helper.assertTrue(!CrystalPortalNetwork.plan(level, holderPos).valid(), + "An incomplete frame must not take a book or leave portal cells"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void multiCellOpeningHasOneContinuousView(GameTestHelper helper) { + var level = helper.getLevel(); + for (int x = 0; x < 3; x++) for (int y = 1; y < 5; y++) { + if (x == 1 && (y == 2 || y == 3)) continue; + level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)), + ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3); + } + BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0)); + level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState() + .setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3); + CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(level, holderPos); + helper.assertValueEqual(plan.portalCells().size(), 2, "The opening contains two portal cells"); + helper.assertValueEqual(CrystalPortalSurfaces.group(level, plan).size(), 1, + "Both cells must share one coherent Immersive Portals view"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void savedCooldownFromPreviousServerCannotBlockPortal(GameTestHelper helper) { + helper.assertTrue(!PortalTravelCooldown.blocked(5, -1), + "A player with no prior portal use must link immediately"); + helper.assertTrue(!PortalTravelCooldown.blocked(5, 12000), + "A saved tick from the previous server session must not block travel"); + helper.assertTrue(PortalTravelCooldown.blocked(105, 100), + "A recent portal use in the current session must still prevent loops"); + helper.succeed(); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java b/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java index 807607f..2a23b89 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java @@ -40,6 +40,14 @@ public final class DescriptiveBookItem extends Item { } public static boolean useWrittenBook(ServerPlayer player, ItemStack stack) { + return useWrittenBook(player, stack, false); + } + + public static boolean usePortalBook(ServerPlayer player, ItemStack stack) { + return useWrittenBook(player, stack, true); + } + + private static boolean useWrittenBook(ServerPlayer player, ItemStack stack, boolean portal) { AgeReference age = stack.get(ModDataComponents.AGE_REFERENCE.get()); if (age == null) { player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unwritten"), true); @@ -57,7 +65,9 @@ public final class DescriptiveBookItem extends Item { var sourcePosition = player.blockPosition(); LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE) .forDescriptiveBook(); - LinkingService.LinkResult result = LinkingService.travelWithFooting(player, ageTarget, properties); + LinkingService.LinkResult result = portal + ? LinkingService.travelDescriptivePortal(player, ageTarget, properties) + : LinkingService.travelWithFooting(player, ageTarget, properties); if (result != LinkingService.LinkResult.SUCCESS) { player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unsafe"), true); return false; @@ -75,9 +85,9 @@ public final class DescriptiveBookItem extends Item { player.drop(returnBook, false); } } - sourceLevel.playSound(null, sourcePosition, ModSounds.LINK.get(), SoundSource.PLAYERS, + sourceLevel.playSound(null, sourcePosition, (portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(), SoundSource.PLAYERS, 0.8F, sourceLevel.random.nextFloat() * 0.2F + 0.9F); - player.level().playSound(null, player.blockPosition(), ModSounds.LINK.get(), SoundSource.PLAYERS, + player.level().playSound(null, player.blockPosition(), (portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(), SoundSource.PLAYERS, 0.8F, player.level().random.nextFloat() * 0.2F + 0.9F); player.displayClientMessage(Component.translatable( createReturnLink ? "message.rivencraft.descriptive_book.linked_first" : "message.rivencraft.descriptive_book.linked", diff --git a/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java b/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java index 3c4cb14..345dcb2 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java @@ -42,6 +42,14 @@ public final class LinkingBookItem extends Item { } public static InteractionResultHolder useBoundBook(ServerPlayer player, ItemStack stack) { + return useBoundBook(player, stack, false); + } + + public static InteractionResultHolder usePortalBook(ServerPlayer player, ItemStack stack) { + return useBoundBook(player, stack, true); + } + + private static InteractionResultHolder useBoundBook(ServerPlayer player, ItemStack stack, boolean portal) { LinkTarget target = stack.get(ModDataComponents.LINK_TARGET.get()); if (target == null) { player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.unbound"), true); @@ -53,12 +61,12 @@ public final class LinkingBookItem extends Item { LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE); LinkingService.LinkResult result = LinkingService.travel(player, target, properties); if (result == LinkingService.LinkResult.SUCCESS) { - sourceLevel.playSound(null, sourcePosition, ModSounds.LINK.get(), SoundSource.PLAYERS, + sourceLevel.playSound(null, sourcePosition, (portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(), SoundSource.PLAYERS, 0.8F, sourceLevel.random.nextFloat() * 0.2F + 0.9F); player.level().playSound( null, player.blockPosition(), - ModSounds.LINK.get(), + (portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(), SoundSource.PLAYERS, 0.8F, player.level().random.nextFloat() * 0.2F + 0.9F diff --git a/src/main/java/net/dnorberg/rivencraft/portal/CrystalPortalNetwork.java b/src/main/java/net/dnorberg/rivencraft/portal/CrystalPortalNetwork.java new file mode 100644 index 0000000..afe28ec --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/portal/CrystalPortalNetwork.java @@ -0,0 +1,244 @@ +package net.dnorberg.rivencraft.portal; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import net.dnorberg.rivencraft.block.BookReceptacleBlock; +import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity; +import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity; +import net.dnorberg.rivencraft.registry.ModBlocks; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; + +/** Bounded, loaded-chunk-only adaptation of Legacy's crystal network flood. */ +public final class CrystalPortalNetwork { + public static final int MAX_RADIUS = 16; + private static final int CLEANUP_RADIUS = MAX_RADIUS + 1; + private static final int MAX_CRYSTALS = 2048; + private static final int MAX_PORTALS = 1024; + private static final int[][] EXPANSION = { + {1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0}, {0, 0, 1}, {0, 0, -1}, + {1, 1, 0}, {-1, 1, 0}, {1, -1, 0}, {-1, -1, 0}, + {1, 0, 1}, {-1, 0, 1}, {1, 0, -1}, {-1, 0, -1}, + {0, 1, 1}, {0, -1, 1}, {0, 1, -1}, {0, -1, -1} + }; + + private CrystalPortalNetwork() {} + + public record Plan(List portalCells) { + public boolean valid() { return !portalCells.isEmpty(); } + } + + public static Plan plan(ServerLevel level, BlockPos receptacle) { + BlockState holder = level.getBlockState(receptacle); + if (!holder.is(ModBlocks.BOOK_RECEPTACLE.get())) return new Plan(List.of()); + BlockPos base = receptacle.relative(holder.getValue(BookReceptacleBlock.FACING).getOpposite()); + if (!level.getBlockState(base).is(ModBlocks.CRYSTAL_BLOCK.get())) return new Plan(List.of()); + + Set crystals = new HashSet<>(); + ArrayDeque pendingCrystals = new ArrayDeque<>(); + pendingCrystals.add(base); + while (!pendingCrystals.isEmpty()) { + BlockPos pos = pendingCrystals.removeFirst(); + if (!within(base, pos) || !level.isInWorldBounds(pos)) continue; + if (!level.hasChunkAt(pos)) return new Plan(List.of()); + if (!level.getBlockState(pos).is(ModBlocks.CRYSTAL_BLOCK.get()) || !crystals.add(pos)) continue; + if (atBoundary(base, pos)) return new Plan(List.of()); + if (crystals.size() > MAX_CRYSTALS) return new Plan(List.of()); + for (Direction direction : Direction.values()) pendingCrystals.add(pos.relative(direction)); + } + + LinkedHashSet portals = new LinkedHashSet<>(); + ArrayDeque candidates = new ArrayDeque<>(); + Set queued = new HashSet<>(); + for (BlockPos crystal : crystals) addCandidates(crystal, base, queued, candidates); + while (!candidates.isEmpty()) { + BlockPos pos = candidates.removeFirst(); + queued.remove(pos); + if (!level.isInWorldBounds(pos) || !level.hasChunkAt(pos)) return new Plan(List.of()); + if (portals.contains(pos) || !mayFill(level, pos, receptacle)) continue; + if (adjacentCount(pos, crystals, portals) < 2) continue; + portals.add(pos); + if (portals.size() > MAX_PORTALS) return new Plan(List.of()); + addCandidates(pos, base, queued, candidates); + } + + // Legacy's second pass deletes every cell without opposing neighbors on + // at least two axes. Repeat because deleting one cell can unbrace another. + boolean changed; + do { + changed = portals.removeIf(pos -> !hasTension(pos, crystals, portals)); + } while (changed); + if (portals.isEmpty()) return new Plan(List.of()); + + // Every surviving cell must still be reachable from the receptacle's + // crystal base through this one network, never through another portal. + Set reachable = new HashSet<>(); + ArrayDeque frontier = new ArrayDeque<>(); + frontier.add(base); + while (!frontier.isEmpty()) { + BlockPos pos = frontier.removeFirst(); + if (!reachable.add(pos)) continue; + for (Direction direction : Direction.values()) { + BlockPos next = pos.relative(direction); + if (!reachable.contains(next) && (crystals.contains(next) || portals.contains(next))) + frontier.add(next); + } + } + if (!reachable.containsAll(portals)) return new Plan(List.of()); + return new Plan(List.copyOf(portals)); + } + + public static boolean activate(ServerLevel level, BlockPos receptacle, Plan plan) { + if (!plan.valid() || !(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder) + || holder.getBook().isEmpty()) return false; + List placed = new ArrayList<>(); + for (BlockPos pos : plan.portalCells()) { + if (!mayFill(level, pos, receptacle)) { + rollback(level, placed); + return false; + } + if (level.getBlockState(pos).isAir()) { + if (!level.setBlock(pos, ModBlocks.CRYSTAL_PORTAL.get().defaultBlockState(), 3)) { + rollback(level, placed); + return false; + } + placed.add(pos); + } + if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) { + portal.setReceptacle(receptacle); + level.scheduleTick(pos, ModBlocks.CRYSTAL_PORTAL.get(), 40); + } else { + rollback(level, placed); + return false; + } + } + return true; + } + + public static void shutdown(ServerLevel level, BlockPos receptacle) { + if (net.neoforged.fml.ModList.get().isLoaded("immersive_portals_core")) + net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.remove(level, receptacle); + // A broken support can disconnect the graph. Scan a bounded volume rather + // than trusting a path through blocks that no longer exist. + for (BlockPos pos : BlockPos.betweenClosed(receptacle.offset(-CLEANUP_RADIUS, -CLEANUP_RADIUS, -CLEANUP_RADIUS), + receptacle.offset(CLEANUP_RADIUS, CLEANUP_RADIUS, CLEANUP_RADIUS))) { + if (!level.hasChunkAt(pos)) continue; + if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal + && receptacle.equals(portal.receptacle())) + level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); + } + } + + /** One bounded repair pass per receptacle, rather than a full flood per portal cell. */ + public static void reconcile(ServerLevel level, BlockPos receptacle) { + Plan plan = plan(level, receptacle); + if (!plan.valid()) { + shutdown(level, receptacle); + return; + } + Set wanted = new HashSet<>(plan.portalCells()); + for (BlockPos pos : BlockPos.betweenClosed(receptacle.offset(-CLEANUP_RADIUS, -CLEANUP_RADIUS, -CLEANUP_RADIUS), + receptacle.offset(CLEANUP_RADIUS, CLEANUP_RADIUS, CLEANUP_RADIUS))) { + if (!level.hasChunkAt(pos)) continue; + if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal + && receptacle.equals(portal.receptacle()) && !wanted.contains(pos)) + level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); + } + activate(level, receptacle, plan); + } + + public static boolean validCell(ServerLevel level, BlockPos pos, BlockPos receptacle) { + if (Math.abs(receptacle.getX() - pos.getX()) > CLEANUP_RADIUS + || Math.abs(receptacle.getY() - pos.getY()) > CLEANUP_RADIUS + || Math.abs(receptacle.getZ() - pos.getZ()) > CLEANUP_RADIUS + || !level.hasChunkAt(receptacle) + || !(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder) + || holder.getBook().isEmpty()) return false; + BlockState state = level.getBlockState(receptacle); + if (!state.is(ModBlocks.BOOK_RECEPTACLE.get()) || !level.getBlockState(receptacle.relative( + state.getValue(BookReceptacleBlock.FACING).getOpposite())).is(ModBlocks.CRYSTAL_BLOCK.get())) + return false; + for (Direction.Axis axis : Direction.Axis.values()) { + // Quick tension check before the bounded network/path revalidation. + if (!axisBraced(level, pos, axis)) continue; + int other = 0; + for (Direction.Axis alternative : Direction.Axis.values()) + if (alternative != axis && axisBraced(level, pos, alternative)) other++; + if (other > 0) return true; + } + return false; + } + + private static boolean axisBraced(ServerLevel level, BlockPos pos, Direction.Axis axis) { + Direction positive = Direction.get(Direction.AxisDirection.POSITIVE, axis); + Direction negative = positive.getOpposite(); + BlockPos a = pos.relative(positive); + BlockPos b = pos.relative(negative); + return level.hasChunkAt(a) && level.hasChunkAt(b) + && isFrameOrPortal(level.getBlockState(a)) && isFrameOrPortal(level.getBlockState(b)); + } + + private static boolean isFrameOrPortal(BlockState state) { + return state.is(ModBlocks.CRYSTAL_BLOCK.get()) || state.is(ModBlocks.CRYSTAL_PORTAL.get()); + } + + private static void rollback(ServerLevel level, List placed) { + for (BlockPos pos : placed) level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); + } + + private static boolean mayFill(ServerLevel level, BlockPos pos, BlockPos receptacle) { + if (!level.hasChunkAt(pos) || !level.isInWorldBounds(pos)) return false; + BlockState state = level.getBlockState(pos); + return state.isAir() || state.is(ModBlocks.CRYSTAL_PORTAL.get()) + && level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal + && receptacle.equals(portal.receptacle()); + } + + private static int adjacentCount(BlockPos pos, Set crystals, Set portals) { + int count = 0; + for (Direction direction : Direction.values()) + if (crystals.contains(pos.relative(direction)) || portals.contains(pos.relative(direction))) count++; + return count; + } + + private static boolean hasTension(BlockPos pos, Set crystals, Set portals) { + int axes = 0; + for (Direction.Axis axis : Direction.Axis.values()) { + Direction positive = Direction.get(Direction.AxisDirection.POSITIVE, axis); + if (member(pos.relative(positive), crystals, portals) + && member(pos.relative(positive.getOpposite()), crystals, portals)) axes++; + } + return axes >= 2; + } + + private static boolean member(BlockPos pos, Set crystals, Set portals) { + return crystals.contains(pos) || portals.contains(pos); + } + + private static void addCandidates(BlockPos around, BlockPos base, Set queued, + ArrayDeque pending) { + for (int[] offset : EXPANSION) { + BlockPos candidate = around.offset(offset[0], offset[1], offset[2]); + if (within(base, candidate) && queued.add(candidate)) pending.add(candidate); + } + } + + private static boolean within(BlockPos center, BlockPos pos) { + return Math.abs(center.getX() - pos.getX()) <= MAX_RADIUS + && Math.abs(center.getY() - pos.getY()) <= MAX_RADIUS + && Math.abs(center.getZ() - pos.getZ()) <= MAX_RADIUS; + } + + private static boolean atBoundary(BlockPos center, BlockPos pos) { + return Math.abs(center.getX() - pos.getX()) == MAX_RADIUS + || Math.abs(center.getY() - pos.getY()) == MAX_RADIUS + || Math.abs(center.getZ() - pos.getZ()) == MAX_RADIUS; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/portal/CrystalPortalSurfaces.java b/src/main/java/net/dnorberg/rivencraft/portal/CrystalPortalSurfaces.java new file mode 100644 index 0000000..f5d1672 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/portal/CrystalPortalSurfaces.java @@ -0,0 +1,59 @@ +package net.dnorberg.rivencraft.portal; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.dnorberg.rivencraft.registry.ModBlocks; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.phys.AABB; + +/** Groups portal cells into coherent, coplanar views while retaining irregular outlines. */ +public final class CrystalPortalSurfaces { + private CrystalPortalSurfaces() {} + + public record Surface(Direction normal, AABB bounds, List cells) {} + private record Plane(Direction normal, int coordinate) {} + + public static List group(ServerLevel level, CrystalPortalNetwork.Plan plan) { + Map> planes = new LinkedHashMap<>(); + for (BlockPos cell : plan.portalCells()) { + Direction normal = planeNormal(level, cell); + if (normal == null) continue; + int coordinate = normal.getAxis().choose(cell.getX(), cell.getY(), cell.getZ()); + planes.computeIfAbsent(new Plane(normal, coordinate), ignored -> new ArrayList<>()).add(cell); + } + List result = new ArrayList<>(); + for (Map.Entry> entry : planes.entrySet()) { + List cells = entry.getValue(); + int minX = cells.stream().mapToInt(BlockPos::getX).min().orElseThrow(); + int minY = cells.stream().mapToInt(BlockPos::getY).min().orElseThrow(); + int minZ = cells.stream().mapToInt(BlockPos::getZ).min().orElseThrow(); + int maxX = cells.stream().mapToInt(BlockPos::getX).max().orElseThrow() + 1; + int maxY = cells.stream().mapToInt(BlockPos::getY).max().orElseThrow() + 1; + int maxZ = cells.stream().mapToInt(BlockPos::getZ).max().orElseThrow() + 1; + result.add(new Surface(entry.getKey().normal(), new AABB(minX, minY, minZ, maxX, maxY, maxZ), + List.copyOf(cells))); + } + return List.copyOf(result); + } + + private static Direction planeNormal(ServerLevel level, BlockPos cell) { + if (braced(level, cell, Direction.UP) && braced(level, cell, Direction.EAST)) return Direction.NORTH; + if (braced(level, cell, Direction.UP) && braced(level, cell, Direction.SOUTH)) return Direction.EAST; + if (braced(level, cell, Direction.EAST) && braced(level, cell, Direction.SOUTH)) return Direction.UP; + return null; + } + + private static boolean braced(ServerLevel level, BlockPos cell, Direction direction) { + return linked(level, cell.relative(direction)) && linked(level, cell.relative(direction.getOpposite())); + } + + private static boolean linked(ServerLevel level, BlockPos pos) { + if (!level.hasChunkAt(pos)) return false; + var state = level.getBlockState(pos); + return state.is(ModBlocks.CRYSTAL_BLOCK.get()) || state.is(ModBlocks.CRYSTAL_PORTAL.get()); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/portal/PortalTravelCooldown.java b/src/main/java/net/dnorberg/rivencraft/portal/PortalTravelCooldown.java new file mode 100644 index 0000000..1eed14c --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/portal/PortalTravelCooldown.java @@ -0,0 +1,12 @@ +package net.dnorberg.rivencraft.portal; + +/** Server tick counters restart with the process; old saved values cannot block new sessions. */ +public final class PortalTravelCooldown { + public static final long TICKS = 40; + + private PortalTravelCooldown() {} + + public static boolean blocked(long now, long previous) { + return previous >= 0 && now >= previous && now - previous < TICKS; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java b/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java index 0239541..8614a5f 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java @@ -6,6 +6,8 @@ import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity; import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity; import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity; import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity; +import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity; +import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity; import net.minecraft.core.registries.Registries; import net.minecraft.world.level.block.entity.BlockEntityType; import net.neoforged.bus.api.IEventBus; @@ -32,6 +34,12 @@ public final class ModBlockEntities { public static final DeferredHolder, BlockEntityType> STAR_FISSURE = BLOCK_ENTITIES.register("star_fissure", () -> BlockEntityType.Builder .of(StarFissureBlockEntity::new, ModBlocks.STAR_FISSURE.get()).build(null)); + public static final DeferredHolder, BlockEntityType> BOOK_RECEPTACLE = + BLOCK_ENTITIES.register("book_receptacle", () -> BlockEntityType.Builder + .of(BookReceptacleBlockEntity::new, ModBlocks.BOOK_RECEPTACLE.get()).build(null)); + public static final DeferredHolder, BlockEntityType> CRYSTAL_PORTAL = + BLOCK_ENTITIES.register("crystal_portal", () -> BlockEntityType.Builder + .of(CrystalPortalBlockEntity::new, ModBlocks.CRYSTAL_PORTAL.get()).build(null)); private ModBlockEntities() { } diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java b/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java index ff2fc97..d76b737 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java @@ -9,6 +9,8 @@ import net.dnorberg.rivencraft.block.BuddingCrystalBlock; import net.dnorberg.rivencraft.block.StarFissureBlock; import net.dnorberg.rivencraft.block.DecayBlock; import net.dnorberg.rivencraft.block.CloudPlatformBlock; +import net.dnorberg.rivencraft.block.BookReceptacleBlock; +import net.dnorberg.rivencraft.block.CrystalPortalBlock; import net.minecraft.world.level.block.AmethystClusterBlock; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; @@ -40,6 +42,12 @@ public final class ModBlocks { ); public static final DeferredBlock CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block", Block::new, BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK).mapColor(MapColor.COLOR_BLUE)); + public static final DeferredBlock BOOK_RECEPTACLE = BLOCKS.registerBlock("book_receptacle", + BookReceptacleBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK) + .noOcclusion().mapColor(MapColor.COLOR_BLUE)); + public static final DeferredBlock CRYSTAL_PORTAL = BLOCKS.registerBlock("crystal_portal", + CrystalPortalBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.NETHER_PORTAL) + .noCollission().noOcclusion().lightLevel(state -> 11).noLootTable()); public static final DeferredBlock BUDDING_CRYSTAL = BLOCKS.registerBlock("budding_crystal", BuddingCrystalBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.BUDDING_AMETHYST) .mapColor(MapColor.COLOR_BLUE)); diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java index bf5770e..21e3f30 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java @@ -32,6 +32,7 @@ public final class ModCreativeTabs { output.accept(ModItems.INK_MIXER.get()); output.accept(ModItems.BOOK_BINDER.get()); output.accept(ModItems.CRYSTAL_BLOCK.get()); + output.accept(ModItems.BOOK_RECEPTACLE.get()); output.accept(ModItems.BUDDING_CRYSTAL.get()); output.accept(ModItems.CRYSTAL_CLUSTER.get()); output.accept(ModItems.LARGE_CRYSTAL_BUD.get()); diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java b/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java index 1f95dfd..8222c15 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java @@ -44,6 +44,7 @@ public final class ModItems { public static final DeferredItem BOOK_BINDER = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_BINDER); public static final DeferredItem INK_MIXER = ITEMS.registerSimpleBlockItem(ModBlocks.INK_MIXER); public static final DeferredItem CRYSTAL_BLOCK = ITEMS.registerSimpleBlockItem(ModBlocks.CRYSTAL_BLOCK); + public static final DeferredItem BOOK_RECEPTACLE = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_RECEPTACLE); public static final DeferredItem BUDDING_CRYSTAL = ITEMS.registerSimpleBlockItem(ModBlocks.BUDDING_CRYSTAL); public static final DeferredItem CRYSTAL_CLUSTER = ITEMS.registerSimpleBlockItem(ModBlocks.CRYSTAL_CLUSTER); public static final DeferredItem LARGE_CRYSTAL_BUD = ITEMS.registerSimpleBlockItem(ModBlocks.LARGE_CRYSTAL_BUD); diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModSounds.java b/src/main/java/net/dnorberg/rivencraft/registry/ModSounds.java index fe968ff..e967d07 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModSounds.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModSounds.java @@ -15,6 +15,7 @@ public final class ModSounds { public static final DeferredHolder LINK = register("linking.link"); public static final DeferredHolder LINK_FISSURE = register("linking.link-fissure"); + public static final DeferredHolder LINK_PORTAL = register("linking.link-portal"); public static final DeferredHolder METEOR_ROAR = register("entity.meteor.roar"); private ModSounds() { diff --git a/src/main/resources/assets/rivencraft/blockstates/book_receptacle.json b/src/main/resources/assets/rivencraft/blockstates/book_receptacle.json new file mode 100644 index 0000000..fc561ce --- /dev/null +++ b/src/main/resources/assets/rivencraft/blockstates/book_receptacle.json @@ -0,0 +1 @@ +{"variants":{"facing=north":{"model":"rivencraft:block/book_receptacle","y":180},"facing=east":{"model":"rivencraft:block/book_receptacle","y":270},"facing=south":{"model":"rivencraft:block/book_receptacle"},"facing=west":{"model":"rivencraft:block/book_receptacle","y":90},"facing=up":{"model":"rivencraft:block/book_receptacle","x":270},"facing=down":{"model":"rivencraft:block/book_receptacle","x":90}}} diff --git a/src/main/resources/assets/rivencraft/blockstates/crystal_portal.json b/src/main/resources/assets/rivencraft/blockstates/crystal_portal.json new file mode 100644 index 0000000..6106e20 --- /dev/null +++ b/src/main/resources/assets/rivencraft/blockstates/crystal_portal.json @@ -0,0 +1 @@ +{"variants":{"live_view=false":{"model":"rivencraft:block/crystal_portal"},"live_view=true":{"model":"rivencraft:block/crystal_portal"}}} diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index 81805b6..b1ae376 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -308,6 +308,9 @@ "symbol.rivencraft.feature.star_fissures.description": "Open a starry fissure near this Age's spawn that links back to the Overworld.", "block.rivencraft.star_fissure": "Star Fissure", "block.rivencraft.crystal_block": "Crystal Block", + "block.rivencraft.book_receptacle": "Book Receptacle", + "block.rivencraft.crystal_portal": "Crystal Portal", + "message.rivencraft.portal.invalid_frame": "The crystal frame cannot form a portal.", "block.rivencraft.budding_crystal": "Budding Crystal", "block.rivencraft.small_crystal_bud": "Small Crystal Bud", "block.rivencraft.medium_crystal_bud": "Medium Crystal Bud", diff --git a/src/main/resources/assets/rivencraft/models/block/book_receptacle.json b/src/main/resources/assets/rivencraft/models/block/book_receptacle.json new file mode 100644 index 0000000..b7201a5 --- /dev/null +++ b/src/main/resources/assets/rivencraft/models/block/book_receptacle.json @@ -0,0 +1 @@ +{"parent":"minecraft:block/block","textures":{"particle":"rivencraft:block/crystal_block","crystal":"rivencraft:block/crystal_block","face":"rivencraft:block/book_receptacle"},"elements":[{"from":[0,0,0],"to":[16,16,6],"faces":{"north":{"texture":"#crystal"},"east":{"texture":"#crystal"},"south":{"texture":"#face"},"west":{"texture":"#crystal"},"up":{"texture":"#crystal"},"down":{"texture":"#crystal"}}}]} diff --git a/src/main/resources/assets/rivencraft/models/block/crystal_portal.json b/src/main/resources/assets/rivencraft/models/block/crystal_portal.json new file mode 100644 index 0000000..8e3c8a9 --- /dev/null +++ b/src/main/resources/assets/rivencraft/models/block/crystal_portal.json @@ -0,0 +1 @@ +{"parent":"minecraft:block/cube_all","textures":{"all":"rivencraft:block/crystal_portal"}} diff --git a/src/main/resources/assets/rivencraft/models/item/book_receptacle.json b/src/main/resources/assets/rivencraft/models/item/book_receptacle.json new file mode 100644 index 0000000..c8e4922 --- /dev/null +++ b/src/main/resources/assets/rivencraft/models/item/book_receptacle.json @@ -0,0 +1 @@ +{"parent":"rivencraft:block/book_receptacle"} diff --git a/src/main/resources/assets/rivencraft/sounds.json b/src/main/resources/assets/rivencraft/sounds.json index 2a28738..81a3851 100644 --- a/src/main/resources/assets/rivencraft/sounds.json +++ b/src/main/resources/assets/rivencraft/sounds.json @@ -11,6 +11,10 @@ "category": "player", "sounds": [{"name": "rivencraft:linking/link-fissure"}] }, + "linking.link-portal": { + "category": "player", + "sounds": [{"name": "rivencraft:linking/link-portal"}] + }, "entity.meteor.roar": { "category": "hostile", "sounds": [{"name": "rivencraft:entity/meteor/roar"}] diff --git a/src/main/resources/assets/rivencraft/sounds/linking/link-portal.ogg b/src/main/resources/assets/rivencraft/sounds/linking/link-portal.ogg new file mode 100644 index 0000000..326f382 Binary files /dev/null and b/src/main/resources/assets/rivencraft/sounds/linking/link-portal.ogg differ diff --git a/src/main/resources/assets/rivencraft/textures/block/book_receptacle.png b/src/main/resources/assets/rivencraft/textures/block/book_receptacle.png new file mode 100644 index 0000000..76c758e Binary files /dev/null and b/src/main/resources/assets/rivencraft/textures/block/book_receptacle.png differ diff --git a/src/main/resources/assets/rivencraft/textures/block/crystal_portal.png b/src/main/resources/assets/rivencraft/textures/block/crystal_portal.png new file mode 100644 index 0000000..7305935 Binary files /dev/null and b/src/main/resources/assets/rivencraft/textures/block/crystal_portal.png differ diff --git a/src/main/resources/assets/rivencraft/textures/block/crystal_portal.png.mcmeta b/src/main/resources/assets/rivencraft/textures/block/crystal_portal.png.mcmeta new file mode 100644 index 0000000..0645f48 --- /dev/null +++ b/src/main/resources/assets/rivencraft/textures/block/crystal_portal.png.mcmeta @@ -0,0 +1,5 @@ +{ + "animation": { + "frametime": 2 + } +} diff --git a/src/main/resources/data/rivencraft/loot_table/blocks/book_receptacle.json b/src/main/resources/data/rivencraft/loot_table/blocks/book_receptacle.json new file mode 100644 index 0000000..52f0cb6 --- /dev/null +++ b/src/main/resources/data/rivencraft/loot_table/blocks/book_receptacle.json @@ -0,0 +1 @@ +{"type":"minecraft:block","pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"rivencraft:book_receptacle"}]}]} diff --git a/src/main/resources/data/rivencraft/recipe/book_receptacle.json b/src/main/resources/data/rivencraft/recipe/book_receptacle.json new file mode 100644 index 0000000..2cf9009 --- /dev/null +++ b/src/main/resources/data/rivencraft/recipe/book_receptacle.json @@ -0,0 +1 @@ +{"type":"minecraft:crafting_shaped","pattern":["CCC","C C","CCC"],"key":{"C":{"item":"rivencraft:crystal_block"}},"result":{"id":"rivencraft:book_receptacle","count":1}}