Public Access
Implement crystal portals with optional Immersive Portals view (#77)
Build / build (push) Canceled after 0s
Build / build (push) Canceled after 0s
This commit is contained in:
1 parent
644d242bba
commit
59bd1c7f6c
34 files changed
+1001
-6
No files matched your search
@@ -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,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<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");
|
||||
private static final String COOLDOWN = "RivencraftCrystalPortalCooldown";
|
||||
|
||||
public CrystalPortalBlock(Properties properties) {
|
||||
super(properties);
|
||||
registerDefaultState(stateDefinition.any().setValue(LIVE_VIEW, false));
|
||||
}
|
||||
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
|
||||
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> 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);
|
||||
}
|
||||
}
|
||||
@@ -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,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();
|
||||
}
|
||||
}
|
||||
@@ -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,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<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;
|
||||
}
|
||||
}
|
||||
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":{"live_view=false":{"model":"rivencraft:block/crystal_portal"},"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 @@
|
||||
{"parent":"minecraft:block/cube_all","textures":{"all":"rivencraft:block/crystal_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.
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}}
|
||||
Reference in new issue
Block a user