diff --git a/src/main/java/dev/murad/shipping/block/rail/PortalRail.java b/src/main/java/dev/murad/shipping/block/rail/PortalRail.java new file mode 100644 index 00000000..05bd405f --- /dev/null +++ b/src/main/java/dev/murad/shipping/block/rail/PortalRail.java @@ -0,0 +1,179 @@ +package dev.murad.shipping.block.rail; + +import com.mojang.serialization.MapCodec; +import dev.murad.shipping.util.RailHelper; +import dev.murad.shipping.util.RailShapeUtil; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.tags.FluidTags; +import net.minecraft.world.entity.vehicle.AbstractMinecart; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LevelAccessor; +import net.minecraft.world.level.LevelReader; +import net.minecraft.world.level.block.BaseRailBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.Mirror; +import net.minecraft.world.level.block.Rotation; +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.level.block.state.properties.EnumProperty; +import net.minecraft.world.level.block.state.properties.Property; +import net.minecraft.world.level.block.state.properties.RailShape; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.level.material.Fluids; +import net.minecraft.world.level.portal.PortalForcer; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; + +public class PortalRail extends BaseRailBlock { + + public static final MapCodec CODEC = simpleCodec(PortalRail::new); + public static final EnumProperty RAIL_SHAPE = RailShapeUtil.RAIL_SHAPE_STRAIGHT_FLAT; + // Direction toward the adjacent nether portal; determines the model variant and is read by destroyLinkedRail to locate the paired rail in the other dimension. + public static final DirectionProperty PORTAL_FACING = BlockStateProperties.HORIZONTAL_FACING; + + @Override + protected MapCodec codec() { + return CODEC; + } + + public PortalRail(Properties properties) { + super(true, properties); + registerDefaultState(stateDefinition.any() + .setValue(RAIL_SHAPE, RailShape.NORTH_SOUTH) + .setValue(PORTAL_FACING, Direction.NORTH)); + } + + @Override + public BlockState getStateForPlacement(BlockPlaceContext context) { + Direction facing = context.getHorizontalDirection(); + RailShape shape = (facing == Direction.EAST || facing == Direction.WEST) + ? RailShape.EAST_WEST : RailShape.NORTH_SOUTH; + return defaultBlockState().setValue(RAIL_SHAPE, shape).setValue(PORTAL_FACING, facing); + } + + @Override + protected BlockState updateState(BlockState state, Level level, BlockPos pos, boolean moving) { + return state; + } + + @Override + protected BlockState updateShape(BlockState state, Direction dir, BlockState neighborState, + LevelAccessor level, BlockPos pos, BlockPos neighborPos) { + if (neighborState.getFluidState().is(FluidTags.WATER)) { + return Blocks.AIR.defaultBlockState(); + } + return state; + } + + @Override + public void neighborChanged(BlockState state, Level level, BlockPos pos, + Block neighborBlock, BlockPos fromPos, boolean isMoving) { + super.neighborChanged(state, level, pos, neighborBlock, fromPos, isMoving); + if (level.isClientSide()) return; + if (!level.getBlockState(pos.relative(state.getValue(PORTAL_FACING))).is(Blocks.NETHER_PORTAL)) { + level.destroyBlock(pos, false); + } + } + + // Returns empty so creative pick-block doesn't hand the player a PortalRail; it's placed automatically, not by hand. + @Override + public ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state) { + return ItemStack.EMPTY; + } + + // Destroys the paired PortalRail in the other dimension so both sides always disappear together. + @Override + protected void onRemove(BlockState state, Level level, BlockPos pos, BlockState newState, boolean isMoving) { + super.onRemove(state, level, pos, newState, isMoving); + if (newState.is(this) || level.isClientSide() || !(level instanceof ServerLevel serverLevel)) return; + + destroyLinkedRail(serverLevel, state, pos); + } + + private static void destroyLinkedRail(ServerLevel level, BlockState state, BlockPos pos) { + Direction portalFacing = state.getValue(PORTAL_FACING); + if (!level.getBlockState(pos.relative(portalFacing)).is(Blocks.NETHER_PORTAL)) return; + + ResourceKey thisDim = level.dimension(); + ResourceKey otherDim = thisDim == Level.NETHER ? Level.OVERWORLD : Level.NETHER; + ServerLevel otherLevel = level.getServer().getLevel(otherDim); + if (otherLevel == null) return; + + double scale = thisDim == Level.NETHER ? 8.0 : 0.125; + BlockPos scaledPos = BlockPos.containing(pos.getX() * scale, pos.getY(), pos.getZ() * scale); + + Optional exitPortal = otherLevel.getPortalForcer() + .findClosestPortalPosition(scaledPos, otherDim == Level.NETHER, otherLevel.getWorldBorder()); + + exitPortal.ifPresent(exitPos -> { + BlockPos floor = exitPos; + while (otherLevel.getBlockState(floor.below()).is(Blocks.NETHER_PORTAL)) { + floor = floor.below(); + } + for (Direction dir : Direction.Plane.HORIZONTAL) { + BlockPos candidate = floor.relative(dir); + if (otherLevel.getBlockState(candidate).getBlock() instanceof PortalRail) { + otherLevel.destroyBlock(candidate, false); + break; + } + } + }); + } + + @Override + protected FluidState getFluidState(BlockState state) { + return Fluids.EMPTY.defaultFluidState(); + } + + @Deprecated + @Override + public Property getShapeProperty() { + return RAIL_SHAPE; + } + + @Override + public VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) { + return FLAT_AABB; + } + + @Override + public boolean canMakeSlopes(BlockState state, BlockGetter world, BlockPos pos) { + return false; + } + + @Override + public RailShape getRailDirection(BlockState state, BlockGetter world, BlockPos pos, + @Nullable AbstractMinecart cart) { + if (cart == null) return state.getValue(RAIL_SHAPE); + return RailHelper.directionFromVelocity(cart.getDeltaMovement()).getAxis() == Direction.Axis.X + ? RailShape.EAST_WEST : RailShape.NORTH_SOUTH; + } + + @Override + public BlockState rotate(BlockState state, Rotation rot) { + return state; + } + + @Override + public BlockState mirror(BlockState state, Mirror mirror) { + return state; + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + super.createBlockStateDefinition(builder); + builder.add(RAIL_SHAPE, PORTAL_FACING); + } +} \ No newline at end of file diff --git a/src/main/java/dev/murad/shipping/data/ModBlockTagsProvider.java b/src/main/java/dev/murad/shipping/data/ModBlockTagsProvider.java index 5639c643..66f137bb 100644 --- a/src/main/java/dev/murad/shipping/data/ModBlockTagsProvider.java +++ b/src/main/java/dev/murad/shipping/data/ModBlockTagsProvider.java @@ -25,5 +25,6 @@ protected void addTags(HolderLookup.@NotNull Provider lookupProvider) { tag(BlockTags.RAILS).add(ModBlocks.TEE_JUNCTION_RAIL.get()); tag(BlockTags.RAILS).add(ModBlocks.AUTOMATIC_TEE_JUNCTION_RAIL.get()); tag(BlockTags.RAILS).add(ModBlocks.JUNCTION_RAIL.get()); + tag(BlockTags.RAILS).add(ModBlocks.PORTAL_RAIL.get()); } } diff --git a/src/main/java/dev/murad/shipping/data/ModLootTableProvider.java b/src/main/java/dev/murad/shipping/data/ModLootTableProvider.java index ccb39b8e..10129f4e 100644 --- a/src/main/java/dev/murad/shipping/data/ModLootTableProvider.java +++ b/src/main/java/dev/murad/shipping/data/ModLootTableProvider.java @@ -50,6 +50,7 @@ protected void generate() { dropSelf(ModBlocks.AUTOMATIC_TEE_JUNCTION_RAIL.get()); dropSelf(ModBlocks.JUNCTION_RAIL.get()); dropSelf(ModBlocks.DOCKING_STATION.get()); + add(ModBlocks.PORTAL_RAIL.get(), LootTable.lootTable()); } @Override diff --git a/src/main/java/dev/murad/shipping/data/client/ModBlockStateProvider.java b/src/main/java/dev/murad/shipping/data/client/ModBlockStateProvider.java index 9efe4d95..bcff02ed 100644 --- a/src/main/java/dev/murad/shipping/data/client/ModBlockStateProvider.java +++ b/src/main/java/dev/murad/shipping/data/client/ModBlockStateProvider.java @@ -4,6 +4,7 @@ import dev.murad.shipping.block.dockingstation.DockingStationBlock; import dev.murad.shipping.block.dockingstation.DockingStationPart; import dev.murad.shipping.block.guiderail.CornerGuideRailBlock; +import dev.murad.shipping.block.rail.PortalRail; import dev.murad.shipping.block.rail.SwitchRail; import dev.murad.shipping.block.vesseldetector.VesselDetectorBlock; import dev.murad.shipping.setup.ModBlocks; @@ -131,6 +132,21 @@ protected void registerStatesAndModels() { .texture("rail", getBlTx("junction_rail"))) .build()); + ModelFile portalRailModel = models().getExistingFile(modLoc("block/portal_rail")); + getVariantBuilder(ModBlocks.PORTAL_RAIL.get()).forAllStates(state -> { + Direction facing = state.getValue(PortalRail.PORTAL_FACING); + int yRot = switch (facing) { + case NORTH -> 0; + case EAST -> 90; + case SOUTH -> 180; + default -> 270; // WEST + }; + return ConfiguredModel.builder() + .modelFile(portalRailModel) + .rotationY(yRot) + .build(); + }); + // --- Docking Station --- // Models are hand-crafted in src/main/resources; just reference them. ModelFile portModel = models().getExistingFile(modLoc("block/docking_station_port")); diff --git a/src/main/java/dev/murad/shipping/entity/custom/train/AbstractTrainCarEntity.java b/src/main/java/dev/murad/shipping/entity/custom/train/AbstractTrainCarEntity.java index d6988b03..32838f32 100644 --- a/src/main/java/dev/murad/shipping/entity/custom/train/AbstractTrainCarEntity.java +++ b/src/main/java/dev/murad/shipping/entity/custom/train/AbstractTrainCarEntity.java @@ -40,7 +40,9 @@ import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.GameRules; import net.minecraft.world.level.Level; +import dev.murad.shipping.block.rail.PortalRail; import net.minecraft.world.level.block.BaseRailBlock; +import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.properties.RailShape; import net.minecraft.world.phys.AABB; @@ -80,6 +82,16 @@ public void setFrozen(boolean frozen) { this.frozen = frozen; } + public void clearFollowerWait() { + linkingHandler.clearWaitForDominated(); + } + + /* Non-null while this wagon must keep moving toward a portal. Applied every tick, overriding + chain math and friction. Cleared when the entity crosses; the new instance starts null. */ + @Getter @Setter + private Vec3 forcedPortalVelocity = null; + private int forcedPortalVelocityTicks = 0; + private static final Map> EXITS = Util.make(Maps.newEnumMap(RailShape.class), (enumMap) -> { Vec3i west = Direction.WEST.getNormal(); Vec3i east = Direction.EAST.getNormal(); @@ -132,9 +144,20 @@ private void resetAttributes() { protected Optional getRailShape() { for (var pos : Arrays.asList(getOnPos().above(), getOnPos())) { var state = level().getBlockState(pos); - if (state.getBlock() instanceof BaseRailBlock railBlock) { + if (state.getBlock() instanceof BaseRailBlock) { return Optional.of(railHelper.getShape(pos)); } + /* No rail exists inside the portal block. The adjacent PortalRail defines the axis; + returning its shape prevents accelerate() from stalling and stops the ascending-slope + check from resetting position every tick. */ + if (state.is(Blocks.NETHER_PORTAL)) { + for (Direction dir : Direction.Plane.HORIZONTAL) { + BlockPos adj = pos.relative(dir); + if (level().getBlockState(adj).getBlock() instanceof PortalRail) { + return Optional.of(railHelper.getShape(adj)); + } + } + } } return Optional.empty(); } @@ -233,9 +256,33 @@ public void tick() { this.setYRot(yrot); if (!level().isClientSide) { doChainMath(); + applyForcedPortalVelocity(); + } + } + + /* Refreshes the forced velocity and resets the timeout. Called every tick by the + locomotive while it's alive in the other dimension. */ + public void updateForcedPortalVelocity(Vec3 velocity) { + this.forcedPortalVelocity = velocity; + this.forcedPortalVelocityTicks = 0; + } + + private void applyForcedPortalVelocity() { + if (forcedPortalVelocity == null) return; + setDeltaMovement(forcedPortalVelocity); + // Self-clear after 600 ticks if the loco disappears without calling releasePortalWagons (e.g. server crash). + if (++forcedPortalVelocityTicks > 600) { + forcedPortalVelocity = null; + forcedPortalVelocityTicks = 0; } } + @Override + public int getDimensionChangingDelay() { + // 40 ticks (2 s) prevents an immediate return trip; without this the entity spawns inside the portal block and re-enters on the next tick. + return 40; + } + @Override public float getMaxCartSpeedOnRail() { return (float) (ShippingConfig.Server.TRAIN_MAX_SPEED.get() * 1f); @@ -256,6 +303,7 @@ protected void enforceMaxVelocity(double maxSpeed) { @Override public void push(Entity pEntity) { if (!this.level().isClientSide) { + if (forcedPortalVelocity != null) return; // not perfect, doesn't work when a mob stand in the way without moving, but works well enough underwater to keep this if (pEntity instanceof LivingEntity l && l.getVehicle() == null){ if (this instanceof StallingCapability s) { @@ -488,8 +536,7 @@ protected void tickVanilla(){ @Override public void remove(RemovalReason r) { - // Only sever chain links on permanent removal. Chunk unloads are temporary — - // the head entity's consist list will reconnect when the chunk reloads. + // Only sever links on permanent removal; UNLOADED_TO_CHUNK is temporary and tickReconnect will relink when the chunk reloads. if (r != RemovalReason.UNLOADED_TO_CHUNK) { handleLinkableKill(); } diff --git a/src/main/java/dev/murad/shipping/entity/custom/train/locomotive/AbstractLocomotiveEntity.java b/src/main/java/dev/murad/shipping/entity/custom/train/locomotive/AbstractLocomotiveEntity.java index 5a6b95aa..7b7fde96 100644 --- a/src/main/java/dev/murad/shipping/entity/custom/train/locomotive/AbstractLocomotiveEntity.java +++ b/src/main/java/dev/murad/shipping/entity/custom/train/locomotive/AbstractLocomotiveEntity.java @@ -23,6 +23,7 @@ import net.minecraft.nbt.StringTag; import net.minecraft.nbt.Tag; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.portal.DimensionTransition; import net.minecraft.network.protocol.game.ClientboundAddEntityPacket; import net.minecraft.network.syncher.EntityDataAccessor; import net.minecraft.network.syncher.EntityDataSerializers; @@ -66,6 +67,12 @@ public abstract class AbstractLocomotiveEntity extends AbstractTrainCarEntity im private static final String CONSIST_TAG = "consist"; private static final int CONSIST_RECONNECT_TIMEOUT = 600; + private static final String PORTAL_WAIT_TAG = "portalWait"; + + /* True from loco portal crossing until all consist wagons arrive. Suppresses the unowned-loco + stall in tickReconnect so the loco keeps running; cleared when all wagons arrive or after 400 ticks. */ + private boolean waitingForPortalWagons = false; + private int portalWaitTicks = 0; private List consistUUIDs = new ArrayList<>(); private final Map reconnectAttempts = new HashMap<>(); @@ -143,12 +150,75 @@ public ResourceLocation getRouteIcon() { @Override public void remove(RemovalReason r) { - if(!this.level().isClientSide && r != RemovalReason.UNLOADED_TO_CHUNK){ - this.spawnAtLocation(routeItemHandler.getStackInSlot(0)); + if (!this.level().isClientSide) { + if (r != RemovalReason.UNLOADED_TO_CHUNK && r != RemovalReason.CHANGED_DIMENSION) { + this.spawnAtLocation(routeItemHandler.getStackInSlot(0)); + } + /* Release wagons on any removal except CHANGED_DIMENSION; clearing forced velocity + lets them coast under vanilla physics instead of staying locked at portal speed. */ + if (waitingForPortalWagons && r != RemovalReason.CHANGED_DIMENSION + && level() instanceof ServerLevel serverLevel) { + releasePortalWagons(serverLevel); + } } super.remove(r); } + private void releasePortalWagons(ServerLevel level) { + for (ServerLevel otherLevel : level.getServer().getAllLevels()) { + if (otherLevel == level) continue; + for (int i = 1; i < consistUUIDs.size(); i++) { + Entity e = otherLevel.getEntity(consistUUIDs.get(i)); + if (e instanceof AbstractTrainCarEntity wagon) { + wagon.setForcedPortalVelocity(null); + } + } + } + } + + @Override + public Entity changeDimension(DimensionTransition transition) { + // Capture velocity before super.changeDimension; vanilla rotates deltaMovement 90° when source and destination portal axes differ. + Vec3 preCrossVelocity = getDeltaMovement(); + + if (!level().isClientSide) { + if (getFollower().isPresent()) { + /* Assign forced velocity to every wagon so they keep moving toward the portal at loco + speed. The chain link breaks when the loco crosses, so wagons would stall without this. */ + double speed = Math.max(preCrossVelocity.horizontalDistance(), 0.2); + Vec3 portalVelocity = new Vec3( + getDirection().getStepX() * speed, + 0, + getDirection().getStepZ() * speed + ); + Optional cur = getFollower(); + while (cur.isPresent()) { + cur.get().setForcedPortalVelocity(portalVelocity); + cur = cur.get().getFollower(); + } + waitingForPortalWagons = true; + } + } + + Entity result = super.changeDimension(transition); + if (result instanceof AbstractLocomotiveEntity locoResult) { + // Skip the enrollment freeze (enrollMe = 5) set on load; it's for the server-restart race condition, not portal crossings. + locoResult.enrollmentHandler.skipPortalArrivalFreeze(); + /* Clear waitForDominated so tickLoad() doesn't stall the loco every tick. Wagons are still + in the source dimension, so linkingHandler sees follower=empty + waitForDominated=true → stall() loop. */ + locoResult.linkingHandler.clearWaitForDominated(); + + // Restore pre-crossing velocity and yaw; vanilla changes both when portal axes differ. + if (preCrossVelocity.horizontalDistanceSqr() > 1e-6) { + locoResult.setDeltaMovement(preCrossVelocity); + float yaw = RailHelper.directionFromVelocity(preCrossVelocity).toYRot(); + locoResult.setYRot(yaw); + locoResult.yRotO = yaw; + } + } + return result; + } + @Override public InteractionResult interact(Player pPlayer, InteractionHand pHand) { @@ -660,10 +730,37 @@ public void unfreeze() { // ========================================================================= private void tickConsist(ServerLevel level) { + tickPortalWait(level); tickReconnect(level); rebuildConsistList(); } + private void tickPortalWait(ServerLevel level) { + if (!waitingForPortalWagons) return; + portalWaitTicks++; + + // Propagate loco velocity to wagons still in the source dimension; if the loco is blocked, wagons stop too. + Vec3 currentVelocity = new Vec3(getDeltaMovement().x, 0, getDeltaMovement().z); + for (ServerLevel otherLevel : level.getServer().getAllLevels()) { + if (otherLevel == level) continue; + for (int i = 1; i < consistUUIDs.size(); i++) { + Entity e = otherLevel.getEntity(consistUUIDs.get(i)); + if (e instanceof AbstractTrainCarEntity wagon) { + wagon.updateForcedPortalVelocity(currentVelocity); + } + } + } + + boolean allArrived = consistUUIDs.stream().skip(1).allMatch(uuid -> { + Entity e = level.getEntity(uuid); + return e != null && !e.isRemoved(); + }); + if (allArrived || portalWaitTicks > 400) { + waitingForPortalWagons = false; + portalWaitTicks = 0; + } + } + private void tickReconnect(ServerLevel level) { if (consistUUIDs.size() <= 1) return; @@ -710,6 +807,11 @@ private void tickReconnect(ServerLevel level) { if (curr.getLeader().map(Entity::isRemoved).orElse(true)) { prev.setDominated(curr); curr.setDominant(prev); + /* Clear waitForDominated on the newly connected wagon. If left set, the wagon + delegates stall() to the loco every tick until its own follower arrives. */ + if (waitingForPortalWagons) { + curr.clearFollowerWait(); + } } } @@ -718,7 +820,7 @@ private void tickReconnect(ServerLevel level) { consistUUIDs = updated; - if (hadUnloaded && !hasOwner()) { + if (hadUnloaded && !hasOwner() && !waitingForPortalWagons) { stall(); } } @@ -772,6 +874,7 @@ protected void readAdditionalSaveData(@NotNull CompoundTag compound) { if (consistUUIDs.isEmpty()) { consistUUIDs.add(this.getUUID()); } + waitingForPortalWagons = compound.getBoolean(PORTAL_WAIT_TAG); } @Override @@ -787,6 +890,7 @@ protected void addAdditionalSaveData(@NotNull CompoundTag compound) { consistTag.add(StringTag.valueOf(uuid.toString())); } compound.put(CONSIST_TAG, consistTag); + compound.putBoolean(PORTAL_WAIT_TAG, waitingForPortalWagons); } // duplicate due to linking issues diff --git a/src/main/java/dev/murad/shipping/event/ForgeEventHandler.java b/src/main/java/dev/murad/shipping/event/ForgeEventHandler.java index 317ebe3d..cb0f4b3e 100644 --- a/src/main/java/dev/murad/shipping/event/ForgeEventHandler.java +++ b/src/main/java/dev/murad/shipping/event/ForgeEventHandler.java @@ -2,23 +2,40 @@ import dev.murad.shipping.ShippingConfig; import dev.murad.shipping.ShippingMod; +import dev.murad.shipping.block.rail.PortalRail; import dev.murad.shipping.entity.custom.vessel.tug.VehicleFrontPart; import dev.murad.shipping.global.PlayerTrainChunkManager; import dev.murad.shipping.global.TrainChunkManagerManager; import dev.murad.shipping.item.SpringItem; +import dev.murad.shipping.setup.ModBlocks; import dev.murad.shipping.util.LinkableEntity; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.resources.ResourceKey; import net.minecraft.server.level.ServerLevel; +import net.minecraft.BlockUtil; import net.minecraft.world.InteractionResult; import net.minecraft.world.entity.Entity; import net.minecraft.world.item.Item; import net.minecraft.world.item.ShearsItem; -import net.neoforged.neoforge.event.tick.LevelTickEvent; -import net.neoforged.neoforge.event.entity.player.PlayerEvent; -import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseRailBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.RailShape; +import net.minecraft.world.level.portal.PortalForcer; import net.neoforged.bus.api.EventPriority; import net.neoforged.bus.api.ICancellableEvent; import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.level.BlockEvent; +import net.neoforged.neoforge.event.tick.LevelTickEvent; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; + +import java.util.Optional; /** * Forge-wide event bus @@ -66,6 +83,105 @@ public static void onPlayerSignInEvent(PlayerEvent.PlayerLoggedOutEvent event){ .forEach(PlayerTrainChunkManager::deactivate); } + /* Replaces a rail placed adjacent to a nether portal with a PortalRail and places a + matching one on the destination side, so trains can cross without derailing. */ + @SubscribeEvent + public static void onRailPlacedNearPortal(BlockEvent.EntityPlaceEvent event) { + if (!(event.getLevel() instanceof ServerLevel level)) return; + BlockState placed = event.getPlacedBlock(); + if (!(placed.getBlock() instanceof BaseRailBlock) || placed.getBlock() instanceof PortalRail) return; + + BlockPos railPos = event.getPos(); + + for (Direction dir : Direction.Plane.HORIZONTAL) { + BlockPos neighborPos = railPos.relative(dir); + BlockState neighborState = level.getBlockState(neighborPos); + if (!neighborState.is(Blocks.NETHER_PORTAL)) continue; + + // Skip if a PortalRail already exists adjacent to this portal; avoids duplicate pairs. + boolean alreadyLinked = false; + for (Direction checkDir : Direction.Plane.HORIZONTAL) { + BlockPos adj = neighborPos.relative(checkDir); + if (!adj.equals(railPos) && level.getBlockState(adj).getBlock() instanceof PortalRail) { + alreadyLinked = true; + break; + } + } + if (alreadyLinked) break; + + RailShape railShape = (dir.getAxis() == Direction.Axis.Z) ? RailShape.NORTH_SOUTH : RailShape.EAST_WEST; + // Portal axis is perpendicular to travel direction: traveling N/S means the portal faces along X. + Direction.Axis portalAxis = neighborState.getValue(BlockStateProperties.HORIZONTAL_AXIS); + + // Don't place the source rail if the destination can't accept one; a one-sided pair would strand any train that crosses. + if (!placeDestinationPortalRail(level, railPos, dir, portalAxis)) break; + + BlockState portalRailState = ModBlocks.PORTAL_RAIL.get().defaultBlockState() + .setValue(PortalRail.RAIL_SHAPE, railShape) + .setValue(PortalRail.PORTAL_FACING, dir); + level.setBlock(railPos, portalRailState, Block.UPDATE_ALL); + + break; + } + } + + /* Places a PortalRail on the exit side of the destination portal so trains continue in the same + direction after crossing (north in → north out). Returns false if the exit position is blocked. */ + private static boolean placeDestinationPortalRail(ServerLevel sourceLevel, BlockPos sourceRailPos, + Direction entryDir, Direction.Axis sourcePortalAxis) { + ResourceKey srcDim = sourceLevel.dimension(); + ResourceKey destDim = srcDim == Level.NETHER ? Level.OVERWORLD : Level.NETHER; + ServerLevel destLevel = sourceLevel.getServer().getLevel(destDim); + if (destLevel == null) return false; + + double scale = srcDim == Level.NETHER ? 8.0 : 0.125; + BlockPos scaledPos = BlockPos.containing( + sourceRailPos.getX() * scale, + sourceRailPos.getY(), + sourceRailPos.getZ() * scale); + + PortalForcer forcer = destLevel.getPortalForcer(); + Optional exitPortal = forcer.findClosestPortalPosition( + scaledPos, destDim == Level.NETHER, destLevel.getWorldBorder()); + + // Create the portal if it doesn't exist yet; rail placement must happen before the first entity crossing. + if (exitPortal.isEmpty()) { + exitPortal = forcer.createPortal(scaledPos, sourcePortalAxis) + .map(rect -> rect.minCorner); + } + + if (exitPortal.isEmpty()) return false; + + // Scan down to the floor-level portal block; findClosestPortalPosition may return any block in the column. + BlockPos floor = exitPortal.get(); + while (destLevel.getBlockState(floor.below()).is(Blocks.NETHER_PORTAL)) { + floor = floor.below(); + } + if (!destLevel.getBlockState(floor).is(Blocks.NETHER_PORTAL)) return false; + + // Skip if this destination portal already has a PortalRail; it may have been placed from the other side. + for (Direction checkDir : Direction.Plane.HORIZONTAL) { + if (destLevel.getBlockState(floor.relative(checkDir)).getBlock() instanceof PortalRail) return false; + } + + // Exit rail is on the entryDir side so the train exits pointing the same direction it entered: north in → north out. + BlockPos railPos = floor.relative(entryDir); + RailShape exitShape = entryDir.getAxis() == Direction.Axis.Z + ? RailShape.NORTH_SOUTH : RailShape.EAST_WEST; + + if (!destLevel.getBlockState(railPos.below()).isSolid() + || !destLevel.getBlockState(railPos).canBeReplaced()) { + return false; + } + + // PORTAL_FACING is entryDir.getOpposite(): from the exit rail's perspective, the portal is behind it. + BlockState exitRailState = ModBlocks.PORTAL_RAIL.get().defaultBlockState() + .setValue(PortalRail.RAIL_SHAPE, exitShape) + .setValue(PortalRail.PORTAL_FACING, entryDir.getOpposite()); + destLevel.setBlock(railPos, exitRailState, Block.UPDATE_ALL); + return true; + } + private static void handleEvent(PlayerInteractEvent event, Entity target) { if(!event.getItemStack().isEmpty()) { Item item = event.getItemStack().getItem(); @@ -95,4 +211,4 @@ private static void cancelEvent(PlayerInteractEvent event) { e.setCancellationResult(InteractionResult.SUCCESS); } } -} +} \ No newline at end of file diff --git a/src/main/java/dev/murad/shipping/mixin/PortalRailTraversalMixin.java b/src/main/java/dev/murad/shipping/mixin/PortalRailTraversalMixin.java new file mode 100644 index 00000000..cf29cc1b --- /dev/null +++ b/src/main/java/dev/murad/shipping/mixin/PortalRailTraversalMixin.java @@ -0,0 +1,34 @@ +package dev.murad.shipping.mixin; + +import dev.murad.shipping.block.rail.PortalRail; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.vehicle.AbstractMinecart; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/* Cancels comeOffTrack() when a minecart is inside a nether portal adjacent to a PortalRail, + so the cart keeps its velocity instead of derailing. */ +@Mixin(AbstractMinecart.class) +public class PortalRailTraversalMixin { + + @Inject(method = "comeOffTrack", at = @At("HEAD"), cancellable = true) + private void ll_portalRailPassthrough(CallbackInfo ci) { + AbstractMinecart self = (AbstractMinecart) (Object) this; + BlockPos pos = self.blockPosition(); + Level level = self.level(); + if (!level.getBlockState(pos).is(Blocks.NETHER_PORTAL)) return; + for (Direction dir : Direction.Plane.HORIZONTAL) { + if (level.getBlockState(pos.relative(dir)).getBlock() instanceof PortalRail) { + self.move(MoverType.SELF, self.getDeltaMovement()); + ci.cancel(); + return; + } + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/murad/shipping/setup/ModBlocks.java b/src/main/java/dev/murad/shipping/setup/ModBlocks.java index 69bb787f..a48ba77f 100644 --- a/src/main/java/dev/murad/shipping/setup/ModBlocks.java +++ b/src/main/java/dev/murad/shipping/setup/ModBlocks.java @@ -95,6 +95,10 @@ public class ModBlocks { CreativeModeTabs.TOOLS_AND_UTILITIES, CreativeModeTabs.REDSTONE_BLOCKS)); + public static final DeferredHolder PORTAL_RAIL = registerNoItem( + "portal_rail", + () -> new PortalRail(RAIL_BLOCK_BEHAVIOUR)); + public static final DeferredHolder DOCKING_STATION = register( "docking_station", () -> new DockingStationBlock(BlockBehaviour.Properties.ofFullCopy(Blocks.IRON_BLOCK).noOcclusion()), diff --git a/src/main/java/dev/murad/shipping/util/ChunkManagerEnrollmentHandler.java b/src/main/java/dev/murad/shipping/util/ChunkManagerEnrollmentHandler.java index 2f50df80..4eb44267 100644 --- a/src/main/java/dev/murad/shipping/util/ChunkManagerEnrollmentHandler.java +++ b/src/main/java/dev/murad/shipping/util/ChunkManagerEnrollmentHandler.java @@ -63,6 +63,14 @@ public void load(CompoundTag tag){ } } + /* Skips the server-restart enrollment freeze (enrollMe = 5) after a portal crossing. + The freeze guards a TrainChunkManagerManager race on disk load, which doesn't apply + here; the destination manager is created fresh with active = true. */ + public void skipPortalArrivalFreeze() { + enrollMe = -1; + } + + public Optional getPlayerName(){ if(uuid == null) return Optional.empty(); diff --git a/src/main/java/dev/murad/shipping/util/LinkingHandler.java b/src/main/java/dev/murad/shipping/util/LinkingHandler.java index 7bd4785e..81f117e7 100644 --- a/src/main/java/dev/murad/shipping/util/LinkingHandler.java +++ b/src/main/java/dev/murad/shipping/util/LinkingHandler.java @@ -90,6 +90,10 @@ private void stallNonTicking() { } } + public void clearWaitForDominated() { + waitForDominated = false; + } + public void readAdditionalSaveData(CompoundTag compound) { dominantNBT = compound.getCompound("dominant"); waitForDominated = compound.getBoolean("hasChild"); diff --git a/src/main/resources/assets/littlelogistics/lang/en_us.json b/src/main/resources/assets/littlelogistics/lang/en_us.json index 08869b47..ff88d5ed 100644 --- a/src/main/resources/assets/littlelogistics/lang/en_us.json +++ b/src/main/resources/assets/littlelogistics/lang/en_us.json @@ -52,6 +52,7 @@ "block.littlelogistics.rapid_hopper": "Rapid Hopper", "block.littlelogistics.vessel_detector": "Vehicle Detector", + "block.littlelogistics.portal_rail": "Portal Rail", "block.littlelogistics.junction_rail": "Junction Rail", "block.littlelogistics.switch_rail": "Switch Rail", "block.littlelogistics.automatic_switch_rail": "Automatic Switch Rail", diff --git a/src/main/resources/assets/littlelogistics/models/block/portal_rail.json b/src/main/resources/assets/littlelogistics/models/block/portal_rail.json new file mode 100644 index 00000000..0400e8a2 --- /dev/null +++ b/src/main/resources/assets/littlelogistics/models/block/portal_rail.json @@ -0,0 +1,18 @@ +{ + "render_type": "minecraft:cutout_mipped", + "ambientocclusion": false, + "textures": { + "particle": "littlelogistics:block/rail_portal", + "rail": "littlelogistics:block/rail_portal" + }, + "elements": [ + { + "from": [0, 0, -8], + "to": [16, 1, 16], + "faces": { + "up": { "uv": [0, 0, 16, 16], "texture": "#rail" }, + "down": { "uv": [0, 0, 16, 16], "texture": "#rail", "cullface": "down" } + } + } + ] +} diff --git a/src/main/resources/assets/littlelogistics/textures/block/rail_portal.png b/src/main/resources/assets/littlelogistics/textures/block/rail_portal.png new file mode 100644 index 00000000..885b0d3d Binary files /dev/null and b/src/main/resources/assets/littlelogistics/textures/block/rail_portal.png differ diff --git a/src/main/resources/littlelogistics.mixins.json b/src/main/resources/littlelogistics.mixins.json index 15e32e43..31ae5c08 100644 --- a/src/main/resources/littlelogistics.mixins.json +++ b/src/main/resources/littlelogistics.mixins.json @@ -4,6 +4,7 @@ "compatibilityLevel": "JAVA_21", "minVersion": "0.8", "mixins": [ + "PortalRailTraversalMixin", "RailStateAccessor", "RailStateMixin" ],