Compare commits

..
2 Commits
Author SHA1 Message Date
dnorberg 350d137d9e Render fallback crystal portals as two-sided surfaces
Build / build (push) Canceled after 0s
2026-10-18 00:22:56 -04:00
dnorberg 59bd1c7f6c Implement crystal portals with optional Immersive Portals view (#77)
Build / build (push) Canceled after 0s
2026-10-17 12:13:12 -04:00
34 changed files with 1060 additions and 6 deletions

No files matched your search

+3
View File
@@ -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`
+6
View File
@@ -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.
+1 -1
View File
@@ -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.5
immersive_portals_version=6.0.7
terrablender_version=4.1.0.8
serene_seasons_version_id=B6nTjh4b
@@ -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<BookReceptacleBlock> 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<? extends BaseEntityBlock> 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<Block, BlockState> builder) {
builder.add(FACING);
}
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new BookReceptacleBlockEntity(pos, state);
}
@Override public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state,
BlockEntityType<T> 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());
}
}
@@ -0,0 +1,94 @@
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.core.Direction;
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.block.state.properties.EnumProperty;
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<CrystalPortalBlock> 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");
public static final EnumProperty<Direction.Axis> AXIS = EnumProperty.create("axis", Direction.Axis.class);
private static final String COOLDOWN = "RivencraftCrystalPortalCooldown";
public CrystalPortalBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(LIVE_VIEW, false).setValue(AXIS, Direction.Axis.Z));
}
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(LIVE_VIEW, AXIS);
}
@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);
}
}
@@ -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<ClientGamePacketListener> 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);
}
}
}
@@ -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<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
return saveWithoutMetadata(registries);
}
}
@@ -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<Vec3> 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());
@@ -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);
@@ -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();
}
}
@@ -0,0 +1,147 @@
package net.dnorberg.rivencraft.gametest;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
import net.dnorberg.rivencraft.block.CrystalPortalBlock;
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");
helper.assertValueEqual(level.getBlockState(center).getValue(CrystalPortalBlock.AXIS), Direction.Axis.Z,
"A north/south opening must only show its two player-facing sides");
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 eastWestOpeningOrientsFallbackSurface(GameTestHelper helper) {
var level = helper.getLevel();
for (int z = 1; z <= 3; z++) for (int y = 1; y <= 3; y++) {
if (z == 2 && y == 2) continue;
level.setBlock(helper.absolutePos(new BlockPos(1, y, z)),
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
}
BlockPos holderPos = helper.absolutePos(new BlockPos(1, 1, 0));
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
((BookReceptacleBlockEntity) level.getBlockEntity(holderPos))
.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, CrystalPortalNetwork.plan(level, holderPos)),
"The east/west crystal opening must activate");
helper.assertValueEqual(level.getBlockState(helper.absolutePos(new BlockPos(1, 2, 2)))
.getValue(CrystalPortalBlock.AXIS), Direction.Axis.X,
"The fallback surface must face east and west, not north and south");
helper.succeed();
}
@GameTest(template = "empty")
public static void horizontalOpeningOrientsFallbackSurface(GameTestHelper helper) {
var level = helper.getLevel();
for (int x = 0; x < 3; x++) for (int z = 0; z < 3; z++) {
if (x == 1 && z == 1) continue;
level.setBlock(helper.absolutePos(new BlockPos(x, 1, z)),
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
}
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 2, 1));
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
.setValue(BookReceptacleBlock.FACING, Direction.UP), 3);
((BookReceptacleBlockEntity) level.getBlockEntity(holderPos))
.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, CrystalPortalNetwork.plan(level, holderPos)),
"The horizontal crystal opening must activate");
helper.assertValueEqual(level.getBlockState(helper.absolutePos(new BlockPos(1, 1, 1)))
.getValue(CrystalPortalBlock.AXIS), Direction.Axis.Y,
"The fallback surface must face up and down");
helper.succeed();
}
@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();
}
}
@@ -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",
@@ -42,6 +42,14 @@ public final class LinkingBookItem extends Item {
}
public static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack) {
return useBoundBook(player, stack, false);
}
public static InteractionResultHolder<ItemStack> usePortalBook(ServerPlayer player, ItemStack stack) {
return useBoundBook(player, stack, true);
}
private static InteractionResultHolder<ItemStack> 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
@@ -0,0 +1,255 @@
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.CrystalPortalBlock;
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<BlockPos> 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<BlockPos> crystals = new HashSet<>();
ArrayDeque<BlockPos> 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<BlockPos> portals = new LinkedHashSet<>();
ArrayDeque<BlockPos> candidates = new ArrayDeque<>();
Set<BlockPos> 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<BlockPos> reachable = new HashSet<>();
ArrayDeque<BlockPos> 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<BlockPos> 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;
}
}
// All cells are now present, so the same bracing rule used for the
// Immersive Portals surface can orient the two-sided fallback model.
for (CrystalPortalSurfaces.Surface surface : CrystalPortalSurfaces.group(level, plan)) {
for (BlockPos pos : surface.cells()) {
BlockState state = level.getBlockState(pos);
Direction.Axis axis = surface.normal().getAxis();
if (state.getValue(CrystalPortalBlock.AXIS) != axis)
level.setBlock(pos, state.setValue(CrystalPortalBlock.AXIS, axis), 2);
}
}
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<BlockPos> 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<BlockPos> 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<BlockPos> crystals, Set<BlockPos> 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<BlockPos> crystals, Set<BlockPos> 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<BlockPos> crystals, Set<BlockPos> portals) {
return crystals.contains(pos) || portals.contains(pos);
}
private static void addCandidates(BlockPos around, BlockPos base, Set<BlockPos> queued,
ArrayDeque<BlockPos> 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;
}
}
@@ -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<BlockPos> cells) {}
private record Plane(Direction normal, int coordinate) {}
public static List<Surface> group(ServerLevel level, CrystalPortalNetwork.Plan plan) {
Map<Plane, List<BlockPos>> 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<Surface> result = new ArrayList<>();
for (Map.Entry<Plane, List<BlockPos>> entry : planes.entrySet()) {
List<BlockPos> 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());
}
}
@@ -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;
}
}
@@ -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<?>, BlockEntityType<StarFissureBlockEntity>> STAR_FISSURE =
BLOCK_ENTITIES.register("star_fissure", () -> BlockEntityType.Builder
.of(StarFissureBlockEntity::new, ModBlocks.STAR_FISSURE.get()).build(null));
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<BookReceptacleBlockEntity>> BOOK_RECEPTACLE =
BLOCK_ENTITIES.register("book_receptacle", () -> BlockEntityType.Builder
.of(BookReceptacleBlockEntity::new, ModBlocks.BOOK_RECEPTACLE.get()).build(null));
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<CrystalPortalBlockEntity>> CRYSTAL_PORTAL =
BLOCK_ENTITIES.register("crystal_portal", () -> BlockEntityType.Builder
.of(CrystalPortalBlockEntity::new, ModBlocks.CRYSTAL_PORTAL.get()).build(null));
private ModBlockEntities() {
}
@@ -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<Block> CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block",
Block::new, BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK).mapColor(MapColor.COLOR_BLUE));
public static final DeferredBlock<BookReceptacleBlock> BOOK_RECEPTACLE = BLOCKS.registerBlock("book_receptacle",
BookReceptacleBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK)
.noOcclusion().mapColor(MapColor.COLOR_BLUE));
public static final DeferredBlock<CrystalPortalBlock> CRYSTAL_PORTAL = BLOCKS.registerBlock("crystal_portal",
CrystalPortalBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.NETHER_PORTAL)
.noCollission().noOcclusion().lightLevel(state -> 11).noLootTable());
public static final DeferredBlock<BuddingCrystalBlock> BUDDING_CRYSTAL = BLOCKS.registerBlock("budding_crystal",
BuddingCrystalBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.BUDDING_AMETHYST)
.mapColor(MapColor.COLOR_BLUE));
@@ -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());
@@ -44,6 +44,7 @@ public final class ModItems {
public static final DeferredItem<BlockItem> BOOK_BINDER = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_BINDER);
public static final DeferredItem<BlockItem> INK_MIXER = ITEMS.registerSimpleBlockItem(ModBlocks.INK_MIXER);
public static final DeferredItem<BlockItem> CRYSTAL_BLOCK = ITEMS.registerSimpleBlockItem(ModBlocks.CRYSTAL_BLOCK);
public static final DeferredItem<BlockItem> BOOK_RECEPTACLE = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_RECEPTACLE);
public static final DeferredItem<BlockItem> BUDDING_CRYSTAL = ITEMS.registerSimpleBlockItem(ModBlocks.BUDDING_CRYSTAL);
public static final DeferredItem<BlockItem> CRYSTAL_CLUSTER = ITEMS.registerSimpleBlockItem(ModBlocks.CRYSTAL_CLUSTER);
public static final DeferredItem<BlockItem> LARGE_CRYSTAL_BUD = ITEMS.registerSimpleBlockItem(ModBlocks.LARGE_CRYSTAL_BUD);
@@ -15,6 +15,7 @@ public final class ModSounds {
public static final DeferredHolder<SoundEvent, SoundEvent> LINK = register("linking.link");
public static final DeferredHolder<SoundEvent, SoundEvent> LINK_FISSURE = register("linking.link-fissure");
public static final DeferredHolder<SoundEvent, SoundEvent> LINK_PORTAL = register("linking.link-portal");
public static final DeferredHolder<SoundEvent, SoundEvent> METEOR_ROAR = register("entity.meteor.roar");
private ModSounds() {
@@ -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}}}
@@ -0,0 +1 @@
{"variants":{"axis=x,live_view=false":{"model":"rivencraft:block/crystal_portal","y":90},"axis=x,live_view=true":{"model":"rivencraft:block/crystal_portal","y":90},"axis=y,live_view=false":{"model":"rivencraft:block/crystal_portal","x":90},"axis=y,live_view=true":{"model":"rivencraft:block/crystal_portal","x":90},"axis=z,live_view=false":{"model":"rivencraft:block/crystal_portal"},"axis=z,live_view=true":{"model":"rivencraft:block/crystal_portal"}}}
@@ -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",
@@ -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"}}}]}
@@ -0,0 +1 @@
{"textures":{"portal":"rivencraft:block/crystal_portal"},"elements":[{"from":[0,0,7.5],"to":[16,16,8.5],"shade":false,"faces":{"north":{"texture":"#portal"},"south":{"texture":"#portal"}}}]}
@@ -0,0 +1 @@
{"parent":"rivencraft:block/book_receptacle"}
@@ -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"}]
Binary file not shown.

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,5 @@
{
"animation": {
"frametime": 2
}
}
@@ -0,0 +1 @@
{"type":"minecraft:block","pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"rivencraft:book_receptacle"}]}]}
@@ -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}}