diff --git a/common/src/main/java/dev/ryanhcode/sable/ActiveSableCompanion.java b/common/src/main/java/dev/ryanhcode/sable/ActiveSableCompanion.java index ddc8c23a..dd82d4be 100644 --- a/common/src/main/java/dev/ryanhcode/sable/ActiveSableCompanion.java +++ b/common/src/main/java/dev/ryanhcode/sable/ActiveSableCompanion.java @@ -171,7 +171,13 @@ public Iterable getAllIntersecting(final Level level, final BoundingBo public Vector3d projectOutOfSubLevel(final Level level, final Vector3dc pos, final Vector3d dest) { final SubLevel subLevel = this.getContaining(level, pos); - if (subLevel == null) return dest.set(pos); + if (subLevel == null) { + final Pose3dc lastPose = this.lastKnownContainingPose(level, pos.x(), pos.z()); + if (lastPose != null) { + return lastPose.transformPosition(pos, dest); + } + return dest.set(pos); + } final Pose3dc pose; if (level instanceof final LevelPoseProviderExtension extension) { @@ -192,7 +198,13 @@ public Vec3 projectOutOfSubLevel(final Level level, final Vec3 pos) { public Vec3 projectOutOfSubLevel(final Level level, final Position pos) { final SubLevel subLevel = this.getContaining(level, pos); - if (subLevel == null) return pos instanceof final Vec3 vec ? vec : new Vec3(pos.x(), pos.y(), pos.z()); + if (subLevel == null) { + final Pose3dc lastPose = this.lastKnownContainingPose(level, pos.x(), pos.z()); + if (lastPose != null) { + return JOMLConversion.toMojang(lastPose.transformPosition(JOMLConversion.toJOML(pos))); + } + return pos instanceof final Vec3 vec ? vec : new Vec3(pos.x(), pos.y(), pos.z()); + } final Pose3dc pose; if (level instanceof final LevelPoseProviderExtension extension) { @@ -204,6 +216,19 @@ public Vec3 projectOutOfSubLevel(final Level level, final Position pos) { return JOMLConversion.toMojang(pose.transformPosition(JOMLConversion.toJOML(pos))); } + /** + * @return the last-known pose of the held sub-level whose reserved plot contains the position, or {@code null} if none + */ + private @Nullable Pose3dc lastKnownContainingPose(final Level level, final double blockX, final double blockZ) { + final SubLevelContainer container = SubLevelContainer.getContainer(level); + if (container == null) { + return null; + } + final int chunkX = Mth.floor(blockX) >> SectionPos.SECTION_BITS; + final int chunkZ = Mth.floor(blockZ) >> SectionPos.SECTION_BITS; + return container.getLastKnownPose(chunkX, chunkZ); + } + @Override public @Nullable T runIncludingSubLevels(final Level level, final Vec3 origin, final boolean shouldCheckOrigin, @Nullable final S subLevel, final BiFunction converter) { return this.runIncludingSubLevels(level, (Position) origin, shouldCheckOrigin, subLevel, converter); diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java index 70168e2e..88bda640 100644 --- a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java +++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java @@ -58,6 +58,14 @@ public abstract class SubLevelContainer { * The occupancy of the plotgrid, including loaded and unloaded plots */ private final BitSet occupancy; + /** + * The last-known pose of each plot, kept while held so unloaded sub-levels can be located. {@code null} if unoccupied. + */ + private final Pose3d[] lastKnownPoses; + /** + * The sub-level UUID of each plot, kept while held. {@code null} if unoccupied. + */ + private final UUID[] lastKnownUuids; /** * All observers/listeners for the plotgrid */ @@ -135,6 +143,8 @@ public SubLevelContainer(final Level level, final int logSideLength, final int l this.originZ = originZ; this.subLevels = new SubLevel[(1 << logSideLength) * (1 << logSideLength)]; this.occupancy = new BitSet(this.subLevels.length); + this.lastKnownPoses = new Pose3d[this.subLevels.length]; + this.lastKnownUuids = new UUID[this.subLevels.length]; } /** @@ -261,6 +271,8 @@ public SubLevel allocateSubLevel(final UUID uuid, final int x, final int z, fina final int index = this.getIndex(x, z); this.subLevels[index] = subLevel; this.getOccupancy().set(index); + this.lastKnownPoses[index] = new Pose3d(pose); + this.lastKnownUuids[index] = uuid; this.allSubLevels.add(subLevel); this.subLevelsByUUID.put(subLevel.getUniqueId(), subLevel); this.observers.forEach(observer -> observer.onSubLevelAdded(subLevel)); @@ -487,7 +499,91 @@ public void removeSubLevel(final int x, final int z, final SubLevelRemovalReason if (reason == SubLevelRemovalReason.REMOVED) { this.getOccupancy().clear(index); + this.lastKnownPoses[index] = null; + this.lastKnownUuids[index] = null; + } else { + // Held, not removed: remember its pose so it can be located while unloaded. + this.lastKnownPoses[index] = new Pose3d(subLevel.logicalPose()); + } + + if (this.level instanceof final ServerLevel serverLevel) { + SubLevelOccupancySavedData.getOrLoad(serverLevel).setDirty(); + } + } + + /** + * @return the last-known pose of the (possibly unloaded) sub-level at the given chunk, or {@code null} if none. + * Stale while loaded; prefer {@link #getContaining} when a live pose is required + */ + public @Nullable Pose3d getLastKnownPose(final int chunkX, final int chunkZ) { + final int plotX = (chunkX >> this.logPlotSize) - this.originX; + final int plotZ = (chunkZ >> this.logPlotSize) - this.originZ; + final int sideLength = 1 << this.logSideLength; + if (plotX < 0 || plotX >= sideLength || plotZ < 0 || plotZ >= sideLength) { + return null; + } + return this.lastKnownPoses[this.getIndex(plotX, plotZ)]; + } + + /** + * @return the UUID of the (possibly unloaded) sub-level at the given chunk, or {@code null} if none + */ + public @Nullable UUID getLastKnownUuid(final int chunkX, final int chunkZ) { + final int plotX = (chunkX >> this.logPlotSize) - this.originX; + final int plotZ = (chunkZ >> this.logPlotSize) - this.originZ; + final int sideLength = 1 << this.logSideLength; + if (plotX < 0 || plotX >= sideLength || plotZ < 0 || plotZ >= sideLength) { + return null; + } + return this.lastKnownUuids[this.getIndex(plotX, plotZ)]; + } + + /** + * @return the sub-level UUID stored for the plot index, or {@code null} if none + */ + @ApiStatus.Internal + public @Nullable UUID getLastKnownUuid(final int index) { + if (index < 0 || index >= this.lastKnownUuids.length) { + return null; + } + return this.lastKnownUuids[index]; + } + + /** + * Restores a persisted sub-level UUID for the plot at the given index. + */ + @ApiStatus.Internal + public void setLastKnownUuid(final int index, final UUID uuid) { + if (index < 0 || index >= this.lastKnownUuids.length) { + return; + } + this.lastKnownUuids[index] = uuid; + } + + /** + * @return the live pose if the plot's sub-level is loaded, otherwise its last-known pose + */ + @ApiStatus.Internal + public @Nullable Pose3d getPersistablePose(final int index) { + if (index < 0 || index >= this.subLevels.length) { + return null; + } + final SubLevel loaded = this.subLevels[index]; + if (loaded != null) { + return new Pose3d(loaded.logicalPose()); + } + return this.lastKnownPoses[index]; + } + + /** + * Restores a persisted last-known pose for the plot at the given index. + */ + @ApiStatus.Internal + public void setLastKnownPose(final int index, final Pose3d pose) { + if (index < 0 || index >= this.lastKnownPoses.length) { + return; } + this.lastKnownPoses[index] = pose; } /** diff --git a/common/src/main/java/dev/ryanhcode/sable/mixin/compatibility/waystones/WaystoneButtonMixin.java b/common/src/main/java/dev/ryanhcode/sable/mixin/compatibility/waystones/WaystoneButtonMixin.java new file mode 100644 index 00000000..c44618a6 --- /dev/null +++ b/common/src/main/java/dev/ryanhcode/sable/mixin/compatibility/waystones/WaystoneButtonMixin.java @@ -0,0 +1,52 @@ +package dev.ryanhcode.sable.mixin.compatibility.waystones; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.llamalad7.mixinextras.sugar.Share; +import com.llamalad7.mixinextras.sugar.ref.LocalBooleanRef; +import dev.ryanhcode.sable.Sable; +import dev.ryanhcode.sable.api.sublevel.SubLevelContainer; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; + +/** + * Hides the distance for a waystone whose sub-level isn't loaded on this client. Its stored plot-yard + * coordinate can't be projected without a pose here, so the distance would show a meaningless + * ~20,000 km; omit it instead, as Waystones already does for cross-dimension waystones. + */ +@Mixin(targets = "net.blay09.mods.waystones.client.gui.widget.WaystoneButton", remap = false) +public abstract class WaystoneButtonMixin { + + // Receiver must be LocalPlayer (its static type at the call site); WrapOperation needs the exact owner. + @WrapOperation(method = "renderWidget", at = @At(value = "INVOKE", target = "distanceToSqr(Lnet/minecraft/world/phys/Vec3;)D")) + private double sable$detectUnloadedSubLevel(final LocalPlayer player, final Vec3 waystonePos, final Operation original, @Share("sableUnknownDistance") final LocalBooleanRef unknown) { + unknown.set(sable$isOnUnloadedSubLevel(player.level(), waystonePos)); + return original.call(player, waystonePos); + } + + @WrapOperation(method = "renderWidget", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/gui/GuiGraphics;drawString(Lnet/minecraft/client/gui/Font;Ljava/lang/String;III)I")) + private int sable$hideUnloadedSubLevelDistance(final GuiGraphics graphics, final Font font, final String text, final int x, final int y, final int color, final Operation original, @Share("sableUnknownDistance") final LocalBooleanRef unknown) { + if (unknown.get()) { + return 0; + } + return original.call(graphics, font, text, x, y, color); + } + + @Unique + private static boolean sable$isOnUnloadedSubLevel(final Level level, final Vec3 pos) { + // Loaded here: the projected distance is accurate. + if (Sable.HELPER.getContaining(level, pos.x, pos.z) != null) { + return false; + } + // Only unknown when it's a plot-yard coordinate; a genuinely far waystone keeps its real distance. + final SubLevelContainer container = SubLevelContainer.getContainer(level); + return container != null && container.inBounds(BlockPos.containing(pos)); + } +} diff --git a/common/src/main/java/dev/ryanhcode/sable/mixin/compatibility/waystones/WaystoneImplMixin.java b/common/src/main/java/dev/ryanhcode/sable/mixin/compatibility/waystones/WaystoneImplMixin.java new file mode 100644 index 00000000..c7d70149 --- /dev/null +++ b/common/src/main/java/dev/ryanhcode/sable/mixin/compatibility/waystones/WaystoneImplMixin.java @@ -0,0 +1,41 @@ +package dev.ryanhcode.sable.mixin.compatibility.waystones; + +import dev.ryanhcode.sable.Sable; +import dev.ryanhcode.sable.api.sublevel.SubLevelContainer; +import net.minecraft.core.BlockPos; +import net.minecraft.core.SectionPos; +import net.minecraft.server.level.ServerLevel; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * Keeps a waystone on a held (unloaded) sub-level valid. {@code isValidInLevel} checks the block at + * the stored plot-yard position, which is empty while the contraption is unloaded; treat a + * reserved-but-unloaded sub-level plot as valid so the warp ({@link WaystoneTeleportManagerMixin}) + * can re-activate it. + */ +@Mixin(targets = "net.blay09.mods.waystones.core.WaystoneImpl", remap = false) +public abstract class WaystoneImplMixin { + + @Shadow + private BlockPos pos; + + @Inject(method = "isValidInLevel", at = @At("HEAD"), cancellable = true) + private void sable$validOnHeldSubLevel(final ServerLevel level, final CallbackInfoReturnable cir) { + final SubLevelContainer container = SubLevelContainer.getContainer(level); + if (container == null) { + return; + } + + final int chunkX = this.pos.getX() >> SectionPos.SECTION_BITS; + final int chunkZ = this.pos.getZ() >> SectionPos.SECTION_BITS; + + // Reserved plot, no loaded sub-level = held contraption; when loaded, let the vanilla block check run. + if (container.getLastKnownPose(chunkX, chunkZ) != null && Sable.HELPER.getContaining(level, chunkX, chunkZ) == null) { + cir.setReturnValue(true); + } + } +} diff --git a/common/src/main/java/dev/ryanhcode/sable/mixin/compatibility/waystones/WaystoneTeleportManagerMixin.java b/common/src/main/java/dev/ryanhcode/sable/mixin/compatibility/waystones/WaystoneTeleportManagerMixin.java new file mode 100644 index 00000000..9fd566e2 --- /dev/null +++ b/common/src/main/java/dev/ryanhcode/sable/mixin/compatibility/waystones/WaystoneTeleportManagerMixin.java @@ -0,0 +1,80 @@ +package dev.ryanhcode.sable.mixin.compatibility.waystones; + +import com.llamalad7.mixinextras.sugar.Local; +import com.llamalad7.mixinextras.sugar.Share; +import com.llamalad7.mixinextras.sugar.ref.LocalRef; +import dev.ryanhcode.sable.Sable; +import dev.ryanhcode.sable.api.sublevel.SubLevelContainer; +import dev.ryanhcode.sable.mixinterface.player_freezing.PlayerFreezeExtension; +import dev.ryanhcode.sable.network.packets.tcp.ClientboundFreezePlayerPacket; +import it.unimi.dsi.fastutil.Pair; +import net.minecraft.core.Direction; +import net.minecraft.core.SectionPos; +import net.minecraft.network.protocol.common.ClientboundCustomPayloadPacket; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; +import org.jetbrains.annotations.Nullable; +import org.joml.Vector3d; +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.ModifyVariable; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import java.util.UUID; + +/** + * Lets Waystones warp onto sub-levels. A waystone on a sub-level is stored at its plot-yard + * coordinate, so the target is projected out to the contraption's real (or last-known) position - + * Waystones' same-dimension warp uses {@code connection.teleport} directly and would otherwise drop + * the player into the plot-yard. If the contraption is unloaded, the player is frozen to it after the + * teleport so they land on the deck once it re-activates, as bed respawns do. + */ +@Mixin(targets = "net.blay09.mods.waystones.core.WaystoneTeleportManager", remap = false) +public class WaystoneTeleportManagerMixin { + + private static final String TELEPORT_ENTITY = "teleportEntity(Lnet/minecraft/world/entity/Entity;Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/phys/Vec3;Lnet/minecraft/core/Direction;)Lnet/minecraft/world/entity/Entity;"; + + @ModifyVariable(method = TELEPORT_ENTITY, at = @At("HEAD"), argsOnly = true, index = 2) + private static Vec3 sable$projectTeleportTarget(final Vec3 targetPos3d, @Local(argsOnly = true) final ServerLevel targetWorld, @Share("sableHeldFreeze") final LocalRef> freezeRef) { + // On an unloaded sub-level, remember it (and the local anchor) to freeze the player afterwards. + final UUID heldUuid = sable$heldSubLevelUuid(targetWorld, targetPos3d); + if (heldUuid != null) { + freezeRef.set(Pair.of(heldUuid, new Vector3d(targetPos3d.x, targetPos3d.y, targetPos3d.z))); + } + + return Sable.HELPER.projectOutOfSubLevel(targetWorld, targetPos3d); + } + + @Inject(method = TELEPORT_ENTITY, at = @At("RETURN")) + private static void sable$freezeOntoSubLevel(final Entity entity, final ServerLevel targetWorld, final Vec3 targetPos3d, final Direction direction, final CallbackInfoReturnable cir, @Share("sableHeldFreeze") final LocalRef> freezeRef) { + final Pair freeze = freezeRef.get(); + if (freeze == null) { + return; + } + + if (cir.getReturnValue() instanceof final ServerPlayer player) { + ((PlayerFreezeExtension) player).sable$freezeTo(freeze.first(), freeze.second()); + player.connection.send(new ClientboundCustomPayloadPacket(new ClientboundFreezePlayerPacket(freeze.first(), freeze.second()))); + } + } + + /** + * @return the UUID of the held (unloaded) sub-level at the target, or {@code null} if it is open world or already loaded + */ + private static @Nullable UUID sable$heldSubLevelUuid(final ServerLevel level, final Vec3 pos) { + if (Sable.HELPER.getContaining(level, pos.x, pos.z) != null) { + return null; + } + final SubLevelContainer container = SubLevelContainer.getContainer(level); + if (container == null) { + return null; + } + final int chunkX = Mth.floor(pos.x) >> SectionPos.SECTION_BITS; + final int chunkZ = Mth.floor(pos.z) >> SectionPos.SECTION_BITS; + return container.getLastKnownUuid(chunkX, chunkZ); + } +} diff --git a/common/src/main/java/dev/ryanhcode/sable/sublevel/storage/SubLevelOccupancySavedData.java b/common/src/main/java/dev/ryanhcode/sable/sublevel/storage/SubLevelOccupancySavedData.java index eb1bec96..2925ba33 100644 --- a/common/src/main/java/dev/ryanhcode/sable/sublevel/storage/SubLevelOccupancySavedData.java +++ b/common/src/main/java/dev/ryanhcode/sable/sublevel/storage/SubLevelOccupancySavedData.java @@ -1,13 +1,18 @@ package dev.ryanhcode.sable.sublevel.storage; import dev.ryanhcode.sable.api.sublevel.SubLevelContainer; +import dev.ryanhcode.sable.companion.math.Pose3d; +import dev.ryanhcode.sable.util.SableNBTUtils; import net.minecraft.core.HolderLookup; import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; import net.minecraft.server.level.ServerLevel; import net.minecraft.util.datafix.DataFixTypes; import net.minecraft.world.level.saveddata.SavedData; import java.util.BitSet; +import java.util.UUID; /** * Stores the map for which plots are occupied @@ -45,6 +50,18 @@ private static SubLevelOccupancySavedData load(final ServerLevel level, final Co final BitSet occupancy = container.getOccupancy(); occupancy.clear(); occupancy.or(occupancyData); + + // restore the last-known pose of each reserved (possibly unloaded) plot + final ListTag poses = tag.getList("last_known_poses", Tag.TAG_COMPOUND); + for (int i = 0; i < poses.size(); i++) { + final CompoundTag entry = poses.getCompound(i); + final int index = entry.getInt("index"); + final Pose3d pose = SableNBTUtils.readPose3d(entry.getCompound("pose")); + container.setLastKnownPose(index, pose); + if (entry.hasUUID("uuid")) { + container.setLastKnownUuid(index, entry.getUUID("uuid")); + } + } } return data; @@ -61,6 +78,24 @@ public CompoundTag save(final CompoundTag compoundTag, final HolderLookup.Provid compoundTag.putLongArray("sub_level_occupancy", longArray); + // persist each reserved plot's last-known pose & uuid for sub-levels still unloaded after a restart + final ListTag poses = new ListTag(); + for (int index = occupancy.nextSetBit(0); index >= 0; index = occupancy.nextSetBit(index + 1)) { + final Pose3d pose = container.getPersistablePose(index); + if (pose == null) { + continue; + } + final CompoundTag entry = new CompoundTag(); + entry.putInt("index", index); + entry.put("pose", SableNBTUtils.writePose3d(pose)); + final UUID uuid = container.getLastKnownUuid(index); + if (uuid != null) { + entry.putUUID("uuid", uuid); + } + poses.add(entry); + } + compoundTag.put("last_known_poses", poses); + return compoundTag; } } \ No newline at end of file diff --git a/common/src/main/resources/sable.mixins.json b/common/src/main/resources/sable.mixins.json index faf5ee1b..c302d6e6 100644 --- a/common/src/main/resources/sable.mixins.json +++ b/common/src/main/resources/sable.mixins.json @@ -17,6 +17,7 @@ "clip_overwrite.ClientLevelMixin", "clip_overwrite.GameRendererMixin", "compatibility.iris.ExtendedShaderMixin", + "compatibility.waystones.WaystoneButtonMixin", "config.GameRendererAccessor", "debug_render.DebugRendererMixin", "debug_render.DebugScreenOverlayMixin", @@ -110,6 +111,8 @@ "compatibility.vista.LODMixin", "compatibility.vista.ViewFinderAccessMixin", "compatibility.vista.ViewFinderControllerMixin", + "compatibility.waystones.WaystoneImplMixin", + "compatibility.waystones.WaystoneTeleportManagerMixin", "death_message.CombatTrackerMixin", "death_message.EntityMixin", "enchanting_table.EnchantingTableBlockEntityMixin",