diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..61c51c5 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,8 @@ +[mcp_servers.minecraft] +# The development bridge is reachable through IPv4 loopback only. +url = "http://127.0.0.1:8765/mcp" +enabled = true +required = false +startup_timeout_sec = 10 +tool_timeout_sec = 120 +default_tools_approval_mode = "writes" diff --git a/.gitignore b/.gitignore index ef4f619..8856aaf 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,13 @@ repo/ !**/src/**/repo/ ### Local reference checkouts ### +SableGravity-oriented-aabb-test18-server-authoritative.zip +SableGravity-test21-rotated-obb-recovery-source.zip + +### Local MCP development artifacts ### +.codex/recovery/ +runclient.*.log + +### Local reverse-engineering output ### +verification/*.javap.txt +verification/sable-*-inspect/ diff --git a/README.md b/README.md index f0b1b24..fbd94ef 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,51 @@ -# Sable Gravity 0.6.0 test16 +# Sable Gravity 0.7.4 -Built on the working test13 Sable OBB collision path. +Sable Gravity is a multidimensional gravity-frame kernel for moving and arbitrarily rotated +Sable ships. A frame owns a dimension-qualified identity, a local coordinate system, a rigid +pose and a local `-Y` gravity direction. -Changes from test15: -- never imports Sable-mutated world deltaMovement back into local velocity; removes tilted-floor drift; -- uses Sable local floor contact as the authoritative ground state; -- keeps creative flight independent from stale world-axis onGround state; -- captures flight ascend/descend against the exact previous post-tick velocity, so ship rotation cannot become fake acceleration; -- updates walk animation directly from the actual local displacement because cancelled Player.travel never reaches vanilla calculateEntityAnimation; -- replaces Sable/vanilla sneak edge backoff with a full local plot-space floor query; -- leaves Entity.bb axis-aligned only for Minecraft broadphase. It is no longer used as the source of local ground, flight or sneak behaviour. +## Physics model + +- Players use an oriented body and plot-local feet anchor. Crouching, standing, jumping, + creative flight, edge sneaking and wall sliding are resolved in the ship frame without using + the parent-world AABB as the source of truth. +- Entities always remain in physical parent-world coordinates. Sable is the only owner of their + OBB collision, tracking and packet conversion; Sable Gravity rotates gravity, locomotion and + drag into the selected frame without exposing the hidden plot grid to entity networking. +- Non-player entities keep their ordinary parent-world/network position. Their oriented body is + foot-anchored only inside Sable's collision transform, so items and mobs do not need a spawn-time + coordinate rebase and cannot leak plot coordinates into packets. +- Client particle attachment and packet conversion remain on Sable's native path. This mod does + not install a second universal particle lifecycle on top of Sable's particle mixins. +- For the local player, plot-local feet position and relative velocity are authoritative. A + translating or rotating ship is reconstructed from them every tick instead of accumulating pose + deltas. + +## Dimensions and Cosmonautics + +`GravityFrameId` contains provider, dimension and local UUID. Frame lookup is always performed in +the entity's current `Level`, so an identical SubLevel UUID in another dimension cannot alias the +old ship. A recreated destination frame is resolved again from that level's Sable container. + +Gravity magnitude comes from the final parent-world `Entity#getGravity()` result while the entity +stays physical. Dimension/altitude modifiers such as Cosmonautics therefore compose with Sable +direction and never evaluate against the plot's synthetic Y coordinate. + +There is no hardcoded list of planets, dimensions or entity types. The built-in provider accepts +Sable SubLevels from any `Level`, including Cosmonautics dimensions. Other mods can register a +higher-priority `GravityFrameProvider` through `SableGravityApi` for directional gravity and frame +consumers; arbitrary-orientation ship collision additionally requires a Sable-backed frame. + +## Development + +The development client loads Create, Aeronautics, Cosmonautics and the local Minecraft MCP bridge. +The bridge is configured for `127.0.0.1:8765` only; it is not packaged into the release jar. + +Build with: + +```powershell +.\gradlew.bat build +``` + +Player controls remain available through `/sablegravity enable|disable`, +`/sablegravity strength ` and `/sablegravity test `. diff --git a/build.gradle b/build.gradle index 3f57647..ce0d04f 100644 --- a/build.gradle +++ b/build.gradle @@ -12,6 +12,13 @@ base { java.toolchain.languageVersion = JavaLanguageVersion.of(21) +sourceSets { + // Cosmonautics 26.07.270 advertises a relocated Orekit javac plugin that is absent from its + // published jar. The dev-only provider keeps Minecraft MCP's in-process Java compiler usable. + // This source set is placed on runClient's classpath and is never included in sablegravity.jar. + mcpCompat +} + repositories { mavenCentral() maven { @@ -61,7 +68,12 @@ dependencies { // Development client mod set. Aeronautics requires Create and Sable. runtimeOnly "maven.modrinth:create:${project.create_version}" runtimeOnly "maven.modrinth:create-aeronautics:${project.aeronautics_version}" + runtimeOnly "maven.modrinth:micg1iTH:${project.cosmonautics_version}" runtimeOnly "maven.modrinth:the-one-probe:${project.the_one_probe_version}" + + // Local-only game control/inspection bridge used by the development client. + runtimeOnly files('libs/minecraft_mcp-0.1.0.jar') + runtimeOnly sourceSets.mcpCompat.output } tasks.withType(JavaCompile).configureEach { diff --git a/gradle.properties b/gradle.properties index 534978b..00853cf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,11 +2,12 @@ org.gradle.jvmargs=-Xmx2G org.gradle.daemon=false org.gradle.parallel=true mod_id=sablegravity -mod_version=0.6.0-test16-ground-flight-sneak +mod_version=0.7.4-player-recovery minecraft_version=1.21.1 neo_version=21.1.228 sable_version=2.0.3+mc1.21.1 sable_companion_version=1.6.0 create_version=6.0.10+mc1.21.1 aeronautics_version=1.3.0+mc1.21.1 +cosmonautics_version=c6IOumoH the_one_probe_version=1.21_neo-12.0.8 diff --git a/libs/minecraft_mcp-0.1.0.jar b/libs/minecraft_mcp-0.1.0.jar new file mode 100644 index 0000000..79fec9c Binary files /dev/null and b/libs/minecraft_mcp-0.1.0.jar differ diff --git a/src/main/java/dev/nightly/sablegravity/GravityEntityState.java b/src/main/java/dev/nightly/sablegravity/GravityEntityState.java new file mode 100644 index 0000000..3c87f37 --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/GravityEntityState.java @@ -0,0 +1,42 @@ +package dev.nightly.sablegravity; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.phys.Vec3; + +import java.util.UUID; + +/** + * Runtime state shared by every entity that can be bound to a gravity frame. + * + *

The frame identity is deliberately dimension-qualified. Cosmonautics recreates Sable + * SubLevels while warping a ship between dimensions, so a bare SubLevel UUID is not a stable + * cross-dimensional identity.

+ */ +public interface GravityEntityState { + boolean sablegravity$isInLocalSimulation(); + void sablegravity$setInLocalSimulation(boolean value); + + boolean sablegravity$isLocalStateValid(); + void sablegravity$setLocalStateValid(boolean value); + Vec3 sablegravity$getLocalPosition(); + void sablegravity$setLocalPosition(Vec3 value); + Vec3 sablegravity$getLocalVelocity(); + void sablegravity$setLocalVelocity(Vec3 value); + + UUID sablegravity$getFrameId(); + void sablegravity$setFrameId(UUID value); + ResourceLocation sablegravity$getFrameProvider(); + void sablegravity$setFrameProvider(ResourceLocation value); + ResourceLocation sablegravity$getFrameDimension(); + void sablegravity$setFrameDimension(ResourceLocation value); + + boolean sablegravity$isGravitySampleValid(); + void sablegravity$setGravitySampleValid(boolean value); + double sablegravity$getGravitySample(); + void sablegravity$setGravitySample(double value); + + boolean sablegravity$isFrameHandoffPending(); + void sablegravity$setFrameHandoffPending(boolean value); + int sablegravity$getFrameHandoffExpiryTick(); + void sablegravity$setFrameHandoffExpiryTick(int value); +} diff --git a/src/main/java/dev/nightly/sablegravity/SableGravityMod.java b/src/main/java/dev/nightly/sablegravity/SableGravityMod.java index 7fd645e..b700f54 100644 --- a/src/main/java/dev/nightly/sablegravity/SableGravityMod.java +++ b/src/main/java/dev/nightly/sablegravity/SableGravityMod.java @@ -4,16 +4,24 @@ import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.DoubleArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.suggestion.SuggestionProvider; +import dev.nightly.sablegravity.physics.SableLocalCollisionProbe; +import dev.ryanhcode.sable.Sable; +import dev.ryanhcode.sable.sublevel.SubLevel; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; import net.minecraft.commands.arguments.EntityArgument; import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.ModContainer; import net.neoforged.fml.common.Mod; import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.RegisterCommandsEvent; +import org.joml.Quaterniondc; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.Collection; import java.util.List; @@ -23,12 +31,16 @@ import java.util.Map; @Mod(SableGravityMod.MOD_ID) public final class SableGravityMod { public static final String MOD_ID = "sablegravity"; + public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); public static final float VANILLA_STRENGTH = 9.8F; private static volatile float globalStrength = VANILLA_STRENGTH; private static final Map TEST_FUNCTIONS = Map.of( - "axistestb", SableGravityMod::axisTestB + "state", SableGravityMod::testState, + "frame", SableGravityMod::testFrame, + "support", SableGravityMod::testSupport, + "all", SableGravityMod::testAll ); private static final SuggestionProvider TEST_SUGGESTIONS = @@ -185,7 +197,11 @@ public final class SableGravityMod { TEST_FUNCTIONS.keySet().stream().sorted().toList() ); - source.sendFailure( Component.literal("Unknown test function: " + functionName + ". Available: " + functions) ); + source.sendFailure( + Component.literal( + "Unknown test function: " + functionName + ". Available: " + functions + ) + ); return 0; } @@ -213,37 +229,103 @@ public final class SableGravityMod { } } - private static void axisTestB(CommandSourceStack source) { - try { - ServerPlayer player = source.getPlayer(); - - if (player == null) { - source.sendFailure(Component.literal("This test must be executed by a player")); - return; - } - - player.getBoundingBox(); - // Тут вызываешь свою void-функцию: - // - // SomeClass.axisTestB(player); - // someVoidFunction(); - - source.sendSuccess(() -> Component.literal("Axis test B executed"),false ); - } catch (Exception e) { - source.sendFailure(Component.literal("ERROR:\n " + e.toString())); - } + private static void testState(CommandSourceStack source) throws Exception { + ServerPlayer player = source.getPlayerOrException(); + SableGravityState state = (SableGravityState) player; + SubLevel tracked = Sable.HELPER.getTrackingSubLevel(player); + source.sendSuccess( + () -> Component.literal( + "server-state: enabled=" + state.sablegravity$isEnabled() + + ", strength=" + format(state.sablegravity$getStrength()) + + ", trackedFrame=" + (tracked == null ? "null" : tracked.getUniqueId()) + + ". Local physics lives on LocalPlayer; run /sablegravityclient all" + ), + false + ); } - private static String format(float value) { + private static void testFrame(CommandSourceStack source) throws Exception { + ServerPlayer player = source.getPlayerOrException(); + SubLevel frame = requireTrackedFrame(player); + + Quaterniondc q = frame.logicalPose().orientation(); + Vec3 localPosition = frame.logicalPose().transformPositionInverse(player.position()); + source.sendSuccess( + () -> Component.literal( + "frame: id=" + frame.getUniqueId() + + ", q=(" + format(q.x()) + ", " + format(q.y()) + ", " + + format(q.z()) + ", " + format(q.w()) + ")" + + ", localPos=" + formatVec(localPosition) + ), + false + ); + } + + private static void testSupport(CommandSourceStack source) throws Exception { + ServerPlayer player = source.getPlayerOrException(); + SubLevel frame = requireTrackedFrame(player); + + Vec3 localFeet = SableLocalCollisionProbe.getLocalFeet(player, frame); + double halfWidth = player.getBbWidth() * 0.5D; + double epsilon = 1.0E-5D; + AABB floorProbe = new AABB( + localFeet.x - halfWidth, + localFeet.y - player.maxUpStep() - epsilon, + localFeet.z - halfWidth, + localFeet.x + halfWidth, + localFeet.y + epsilon, + localFeet.z + halfWidth + ); + boolean supported = SableLocalCollisionProbe.hasSupport(player, frame, floorProbe); + source.sendSuccess( + () -> Component.literal( + "support: " + supported + + ", localFeet=" + formatVec(localFeet) + + ", probe=" + formatBox(floorProbe) + ), + false + ); + } + + private static void testAll(CommandSourceStack source) throws Exception { + testState(source); + testFrame(source); + testSupport(source); + source.sendSuccess( + () -> Component.literal( + "Server diagnostics completed. For real local velocity/bounds/collision use /sablegravityclient all" + ), + false + ); + } + + private static SubLevel requireTrackedFrame(ServerPlayer player) { + SubLevel frame = Sable.HELPER.getTrackingSubLevel(player); + if (frame == null || frame.isRemoved()) { + throw new IllegalStateException("no tracked Sable SubLevel"); + } + return frame; + } + + private static String formatVec(Vec3 value) { + return "(" + format(value.x) + ", " + format(value.y) + ", " + format(value.z) + ")"; + } + + private static String formatBox(AABB box) { + return "[" + format(box.minX) + ", " + format(box.minY) + ", " + format(box.minZ) + + " -> " + format(box.maxX) + ", " + format(box.maxY) + ", " + format(box.maxZ) + "]"; + } + + private static String format(double value) { if (value == (long) value) { return Long.toString((long) value); } - return Float.toString(value); + return String.format(Locale.ROOT, "%.6f", value); } @FunctionalInterface private interface TestFunction { void run(CommandSourceStack source) throws Exception; } -} \ No newline at end of file +} diff --git a/src/main/java/dev/nightly/sablegravity/SableGravityState.java b/src/main/java/dev/nightly/sablegravity/SableGravityState.java index cfd5155..12c0900 100644 --- a/src/main/java/dev/nightly/sablegravity/SableGravityState.java +++ b/src/main/java/dev/nightly/sablegravity/SableGravityState.java @@ -1,23 +1,8 @@ package dev.nightly.sablegravity; -import net.minecraft.world.phys.Vec3; -import java.util.UUID; - -public interface SableGravityState { +public interface SableGravityState extends GravityEntityState { boolean sablegravity$isEnabled(); void sablegravity$setEnabled(boolean enabled); float sablegravity$getStrength(); void sablegravity$setStrength(float strength); - - boolean sablegravity$isInLocalSimulation(); - void sablegravity$setInLocalSimulation(boolean value); - - boolean sablegravity$isLocalStateValid(); - void sablegravity$setLocalStateValid(boolean value); - Vec3 sablegravity$getLocalPosition(); - void sablegravity$setLocalPosition(Vec3 value); - Vec3 sablegravity$getLocalVelocity(); - void sablegravity$setLocalVelocity(Vec3 value); - UUID sablegravity$getFrameId(); - void sablegravity$setFrameId(UUID value); } diff --git a/src/main/java/dev/nightly/sablegravity/api/GravityFrame.java b/src/main/java/dev/nightly/sablegravity/api/GravityFrame.java new file mode 100644 index 0000000..1723252 --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/api/GravityFrame.java @@ -0,0 +1,44 @@ +package dev.nightly.sablegravity.api; + +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaterniondc; + +/** + * A moving, rotating coordinate system whose local -Y axis is gravity down. + * + *

Positions and velocities are intentionally separate: a relative velocity must not inherit + * the frame translation twice. Consumers that need inertial velocity can add + * {@link #frameVelocityAt(Vec3)} explicitly.

+ */ +public interface GravityFrame { + GravityFrameId id(); + Level level(); + boolean isValid(); + + Vec3 toLocalPosition(Vec3 worldPosition); + Vec3 toWorldPosition(Vec3 localPosition); + Vec3 toLocalVelocity(Vec3 worldVelocity); + Vec3 toWorldVelocity(Vec3 localVelocity); + Quaterniondc orientation(float partialTick); + + default Vec3 down(float partialTick) { + Quaterniondc rotation = orientation(partialTick); + org.joml.Vector3d down = rotation.transform( + new org.joml.Vector3d(0.0D, -1.0D, 0.0D) + ); + return new Vec3(down.x, down.y, down.z); + } + + /** Movement contributed by the frame between its previous and current physics poses. */ + Vec3 frameVelocityAt(Vec3 worldPosition); + + /** + * Final gravity magnitude after vanilla attributes and other mods (including Cosmonautics) + * have had a chance to modify {@link Entity#getGravity()}. + */ + default double gravityPerTick(Entity entity) { + return Math.max(0.0D, entity.getGravity()); + } +} diff --git a/src/main/java/dev/nightly/sablegravity/api/GravityFrameId.java b/src/main/java/dev/nightly/sablegravity/api/GravityFrameId.java new file mode 100644 index 0000000..ae0b758 --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/api/GravityFrameId.java @@ -0,0 +1,19 @@ +package dev.nightly.sablegravity.api; + +import net.minecraft.resources.ResourceLocation; + +import java.util.Objects; +import java.util.UUID; + +/** Globally unambiguous identity of one gravity frame in one dimension. */ +public record GravityFrameId( + ResourceLocation provider, + ResourceLocation dimension, + UUID localId +) { + public GravityFrameId { + Objects.requireNonNull(provider, "provider"); + Objects.requireNonNull(dimension, "dimension"); + Objects.requireNonNull(localId, "localId"); + } +} diff --git a/src/main/java/dev/nightly/sablegravity/api/GravityFrameProvider.java b/src/main/java/dev/nightly/sablegravity/api/GravityFrameProvider.java new file mode 100644 index 0000000..6ac62de --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/api/GravityFrameProvider.java @@ -0,0 +1,20 @@ +package dev.nightly.sablegravity.api; + +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +import java.util.Optional; + +/** Extension point for Sable ships, artificial fields, planets, and other reference frames. */ +public interface GravityFrameProvider { + Optional resolve(Entity entity, GravityFrameId previousFrame); + + default Optional resolveAt( + Level level, + Vec3 worldPosition, + GravityFrameId previousFrame + ) { + return Optional.empty(); + } +} diff --git a/src/main/java/dev/nightly/sablegravity/api/SableGravityApi.java b/src/main/java/dev/nightly/sablegravity/api/SableGravityApi.java new file mode 100644 index 0000000..a99af4b --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/api/SableGravityApi.java @@ -0,0 +1,26 @@ +package dev.nightly.sablegravity.api; + +import dev.nightly.sablegravity.physics.GravityFrames; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +import java.util.Optional; + +/** Public, dependency-light entry point for gravity providers and consumers. */ +public final class SableGravityApi { + private SableGravityApi() {} + + /** Higher priority providers win. Registration is safe during parallel mod construction. */ + public static void registerFrameProvider(GravityFrameProvider provider, int priority) { + GravityFrames.register(provider, priority); + } + + public static Optional resolve(Entity entity) { + return GravityFrames.resolve(entity); + } + + public static Optional resolveAt(Level level, Vec3 position) { + return GravityFrames.resolveAt(level, position, null); + } +} diff --git a/src/main/java/dev/nightly/sablegravity/client/SableGravityClientCommands.java b/src/main/java/dev/nightly/sablegravity/client/SableGravityClientCommands.java new file mode 100644 index 0000000..13c16d7 --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/client/SableGravityClientCommands.java @@ -0,0 +1,244 @@ +package dev.nightly.sablegravity.client; + +import com.mojang.brigadier.CommandDispatcher; +import dev.nightly.sablegravity.SableGravityMod; +import dev.nightly.sablegravity.SableGravityState; +import dev.nightly.sablegravity.physics.SableLocalCollisionProbe; +import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics; +import dev.nightly.sablegravity.physics.SableOrientedBounds; +import dev.nightly.sablegravity.physics.SableParentWorldCollision; +import dev.ryanhcode.sable.mixinterface.entity.entity_sublevel_collision.EntityMovementExtension; +import dev.ryanhcode.sable.mixinterface.entity.entity_sublevel_collision.LivingEntityMovementExtension; +import dev.ryanhcode.sable.sublevel.SubLevel; +import dev.ryanhcode.sable.sublevel.entity_collision.SubLevelEntityCollision; +import net.minecraft.client.Minecraft; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent; +import org.joml.Quaterniondc; + +import java.util.Locale; + +/** Diagnostics that execute on the actual LocalPlayer instead of the integrated server copy. */ +@EventBusSubscriber(modid = SableGravityMod.MOD_ID, value = Dist.CLIENT) +public final class SableGravityClientCommands { + private SableGravityClientCommands() {} + + @SubscribeEvent + public static void register(RegisterClientCommandsEvent event) { + CommandDispatcher dispatcher = event.getDispatcher(); + dispatcher.register( + Commands.literal("sablegravityclient") + .executes(context -> run("all")) + .then(Commands.literal("state").executes(context -> run("state"))) + .then(Commands.literal("frame").executes(context -> run("frame"))) + .then(Commands.literal("bounds").executes(context -> run("bounds"))) + .then(Commands.literal("support").executes(context -> run("support"))) + .then(Commands.literal("collision").executes(context -> run("collision"))) + .then(Commands.literal("velocity").executes(context -> run("velocity"))) + .then(Commands.literal("all").executes(context -> run("all"))) + ); + } + + private static int run(String function) { + Player player = Minecraft.getInstance().player; + if (player == null) { + return 0; + } + + try { + switch (function) { + case "state" -> state(player); + case "frame" -> frame(player); + case "bounds" -> bounds(player); + case "support" -> support(player); + case "collision" -> collision(player); + case "velocity" -> velocity(player); + case "all" -> { + state(player); + frame(player); + bounds(player); + support(player); + collision(player); + velocity(player); + } + default -> throw new IllegalArgumentException("unknown function: " + function); + } + message(player, "client diagnostic completed: " + function); + return 1; + } catch (Exception exception) { + message( + player, + "client diagnostic " + function + " failed: " + + exception.getClass().getSimpleName() + ": " + exception.getMessage() + ); + exception.printStackTrace(); + return 0; + } + } + + private static void state(Player player) { + SableGravityState state = (SableGravityState) player; + SubLevel latched = SableLocalPlayerPhysics.getAttachedFrameIfPresent(player); + SubLevel tracked = dev.ryanhcode.sable.Sable.HELPER.getTrackingSubLevel(player); + message( + player, + "client-state: enabled=" + state.sablegravity$isEnabled() + + ", strength=" + format(state.sablegravity$getStrength()) + + ", localValid=" + state.sablegravity$isLocalStateValid() + + ", grounded=" + SableLocalPlayerPhysics.isLocalGrounded(player) + + ", stateFrame=" + state.sablegravity$getFrameId() + + ", latchedFrame=" + frameId(latched) + + ", sableTracked=" + frameId(tracked) + + ", localVelocity=" + formatVec(state.sablegravity$getLocalVelocity()) + ); + } + + private static void frame(Player player) { + SubLevel frame = requireFrame(player); + Quaterniondc q = SableOrientedBounds.logicalOrientation(frame); + message( + player, + "client-frame: id=" + frame.getUniqueId() + + ", q=" + formatQuat(q) + + ", localFeet=" + formatVec(SableLocalCollisionProbe.getLocalFeet(player, frame)) + ); + } + + private static void bounds(Player player) { + SubLevel frame = requireFrame(player); + Quaterniondc q = SableOrientedBounds.logicalOrientation(frame); + AABB canonical = SableOrientedBounds.canonicalAabb(player); + AABB envelope = SableOrientedBounds.worldEnvelope(player, q); + Vec3 center = SableOrientedBounds.worldCenter(player, q); + Vec3 up = toVec(q.transform( + new org.joml.Vector3d(0.0D, 1.0D, 0.0D), + new org.joml.Vector3d() + )); + message( + player, + "bounds: pose=" + player.getPose() + + ", entityAabb=" + formatBox(player.getBoundingBox()) + + ", canonical=" + formatBox(canonical) + + ", obbCenter=" + formatVec(center) + + ", obbUp=" + formatVec(up) + + ", physicalFeet=" + formatVec(SableOrientedBounds.worldFeet(player, q)) + + ", envelope=" + formatBox(envelope) + ); + } + + private static void support(Player player) { + SubLevel frame = requireFrame(player); + Vec3 localFeet = SableLocalCollisionProbe.getLocalFeet(player, frame); + AABB probe = SableLocalCollisionProbe.createFloorProbe(player, localFeet, 0.08D); + message( + player, + "support: " + SableLocalCollisionProbe.hasSupport(player, frame, probe) + + ", localFeet=" + formatVec(localFeet) + + ", probe=" + formatBox(probe) + ); + } + + private static void collision(Player player) { + SubLevelEntityCollision.CollisionInfo info = + ((EntityMovementExtension) player).sable$getCollisionInfo(); + if (info == null) { + message(player, "collision: null"); + return; + } + int contacts = info.firstCollisions == null ? 0 : info.firstCollisions.size(); + SableParentWorldCollision.CollisionResult parent = + SableParentWorldCollision.getLastResult(player); + message( + player, + "collision: horizontal=" + info.horizontalCollision + + ", subHorizontal=" + info.subLevelHorizontalCollision + + ", vertical=" + info.verticalCollision + + ", below=" + info.verticalCollisionBelow + + ", contacts=" + contacts + + ", motion=" + formatVec(info.motion) + + ", parentContacts=" + parent.contacts() + + ", parentHorizontal=" + parent.horizontalCollision() + + ", parentVertical=" + parent.verticalCollision() + + ", parentBelow=" + parent.verticalCollisionBelow() + + ", parentCorrection=" + format(parent.maxCorrection()) + + ", parentMotion=" + formatVec(parent.motion()) + ); + } + + + private static void velocity(Player player) { + SableGravityState state = (SableGravityState) player; + org.joml.Vector3dc inherited = + ((LivingEntityMovementExtension) player).sable$getInheritedVelocity(); + SubLevelEntityCollision.CollisionInfo info = + ((EntityMovementExtension) player).sable$getCollisionInfo(); + Vec3 inheritedMotion = info == null ? null : info.inheritedMotion; + Vec3 inheritedVelocity = new Vec3(inherited.x(), inherited.y(), inherited.z()); + Vec3 correctedFrameMotion = SableLocalPlayerPhysics.getLastFrameMotion(player); + Vec3 rejectedSableMotion = + SableLocalPlayerPhysics.getLastRejectedSableInheritedMotion(player); + message( + player, + "velocity: local=" + formatVec(state.sablegravity$getLocalVelocity()) + + " |local|=" + format(state.sablegravity$getLocalVelocity().length()) + + ", deltaMovement=" + formatVec(player.getDeltaMovement()) + + " |delta|=" + format(player.getDeltaMovement().length()) + + ", correctedFrameMotion=" + formatVec(correctedFrameMotion) + + " |frame|=" + format(correctedFrameMotion.length()) + + ", inheritedVelocity=" + formatVec(inheritedVelocity) + + " |inherited|=" + format(inheritedVelocity.length()) + + ", liveSableInherited=" + formatVec(inheritedMotion) + + ", rejectedSableInherited=" + formatVec(rejectedSableMotion) + ); + } + + private static SubLevel requireFrame(Player player) { + SubLevel frame = SableLocalPlayerPhysics.getAttachedFrame(player); + if (frame == null || frame.isRemoved()) { + throw new IllegalStateException("no attached client Sable frame"); + } + return frame; + } + + + private static String frameId(SubLevel frame) { + return frame == null ? "null" : frame.getUniqueId().toString(); + } + + private static Vec3 toVec(org.joml.Vector3dc value) { + return new Vec3(value.x(), value.y(), value.z()); + } + + private static void message(Player player, String text) { + player.displayClientMessage(Component.literal(text), false); + } + + private static String formatQuat(Quaterniondc q) { + return "(" + format(q.x()) + ", " + format(q.y()) + ", " + + format(q.z()) + ", " + format(q.w()) + ")"; + } + + private static String formatVec(Vec3 value) { + if (value == null) { + return "null"; + } + return "(" + format(value.x) + ", " + format(value.y) + ", " + format(value.z) + ")"; + } + + private static String formatBox(AABB box) { + return "[" + format(box.minX) + ", " + format(box.minY) + ", " + format(box.minZ) + + " -> " + format(box.maxX) + ", " + format(box.maxY) + ", " + format(box.maxZ) + "]"; + } + + private static String format(double value) { + return String.format(Locale.ROOT, "%.6f", value); + } +} diff --git a/src/main/java/dev/nightly/sablegravity/mixin/EntityAabbIsolationMixin.java b/src/main/java/dev/nightly/sablegravity/mixin/EntityAabbIsolationMixin.java new file mode 100644 index 0000000..bff2c36 --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/mixin/EntityAabbIsolationMixin.java @@ -0,0 +1,171 @@ +package dev.nightly.sablegravity.mixin; + +import dev.nightly.sablegravity.SableGravityState; +import dev.nightly.sablegravity.physics.SableFrameOrientation; +import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics; +import dev.nightly.sablegravity.physics.SableOrientedBounds; +import dev.nightly.sablegravity.physics.SableParentWorldCollision; +import dev.ryanhcode.sable.sublevel.SubLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaterniond; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import java.util.UUID; + +/** + * Keeps Entity.bb as metadata/broadphase only while arbitrary gravity is active. This mixin has + * higher priority than Sable's Entity mixin so the semantic-flag restoration point is inserted + * before Sable wraps the same on-ground invocation. + */ +@Mixin(value = Entity.class, priority = 3000) +public abstract class EntityAabbIsolationMixin { + @Unique private Vec3 sablegravity$localFeetBeforeResize; + @Unique private UUID sablegravity$frameBeforeResize; + @Unique private boolean sablegravity$insideMove; + + @Inject(method = "collide", at = @At("HEAD"), cancellable = true) + private void sablegravity$replaceUprightCollision( + Vec3 motion, + CallbackInfoReturnable cir + ) { + Entity self = (Entity) (Object) this; + if (self instanceof Player player) { + SableGravityState state = (SableGravityState) player; + if (state.sablegravity$isEnabled()) { + if (player.isLocalPlayer()) { + SubLevel frame = SableLocalPlayerPhysics.getAttachedFrameIfPresent(player); + if (validFrame(player, frame)) { + cir.setReturnValue(SableParentWorldCollision.collideOrTrust( + player, + motion, + frame, + sablegravity$insideMove + )); + return; + } + } + } + } + } + + @Inject(method = "move", at = @At("HEAD")) + private void sablegravity$beginMove(CallbackInfo ci) { + sablegravity$insideMove = true; + } + + @Inject( + method = "move", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/world/entity/Entity;setOnGroundWithMovement(ZLnet/minecraft/world/phys/Vec3;)V", + shift = At.Shift.BEFORE + ) + ) + private void sablegravity$restoreLocalCollisionSemanticsBeforeGroundUpdate( + CallbackInfo ci + ) { + Entity self = (Entity) (Object) this; + if (self instanceof Player player) { + SableParentWorldCollision.restoreSableFlags(player); + } + } + + @Inject(method = "move", at = @At("TAIL")) + private void sablegravity$endMove(CallbackInfo ci) { + sablegravity$insideMove = false; + } + + @Inject(method = "refreshDimensions", at = @At("HEAD")) + private void sablegravity$captureFeetBeforeResize(CallbackInfo ci) { + Entity self = (Entity) (Object) this; + if (!(self instanceof Player player) || !usesOrientedBody(player)) { + sablegravity$clearResizeAnchor(); + return; + } + + SubLevel frame = currentFrame(player); + if (!validFrame(player, frame)) { + sablegravity$clearResizeAnchor(); + return; + } + + // A pose transition belongs to the Sable frame, not to parent-world space. Anchoring the + // transient resize to a world point lets a simultaneously moving/rotating frame turn + // crouch into a visible teleport or tangent slide. Store the invariant local feet point. + Quaterniond orientation = SableFrameOrientation.getFrameOrientation(frame, 1.0F); + Vec3 worldFeet = SableOrientedBounds.worldFeet(player, orientation); + sablegravity$localFeetBeforeResize = frame.logicalPose() + .transformPositionInverse(worldFeet); + sablegravity$frameBeforeResize = frame.getUniqueId(); + } + + @Inject(method = "refreshDimensions", at = @At("TAIL")) + private void sablegravity$restoreFeetAfterResize(CallbackInfo ci) { + Entity self = (Entity) (Object) this; + if (!(self instanceof Player player) + || sablegravity$localFeetBeforeResize == null) { + sablegravity$clearResizeAnchor(); + return; + } + + SubLevel frame = currentFrame(player); + if (!validFrame(player, frame) + || !frame.getUniqueId().equals(sablegravity$frameBeforeResize)) { + sablegravity$clearResizeAnchor(); + return; + } + + Quaterniond orientation = SableFrameOrientation.getFrameOrientation(frame, 1.0F); + Vec3 physicalFeet = frame.logicalPose().transformPosition( + sablegravity$localFeetBeforeResize + ); + Vec3 rawPosition = SableOrientedBounds.rawPositionForFeet( + player, + physicalFeet, + orientation + ); + if (Double.isFinite(rawPosition.x) + && Double.isFinite(rawPosition.y) + && Double.isFinite(rawPosition.z)) { + player.setPos(rawPosition); + } + sablegravity$clearResizeAnchor(); + } + + @Unique + private static boolean usesOrientedBody(Player player) { + if (!((SableGravityState) player).sablegravity$isEnabled()) { + return false; + } + return player.isLocalPlayer(); + } + + @Unique + private static SubLevel currentFrame(Player player) { + if (player.isLocalPlayer()) { + SubLevel attached = SableLocalPlayerPhysics.getAttachedFrameIfPresent(player); + if (validFrame(player, attached)) { + return attached; + } + } + return null; + } + + @Unique + private static boolean validFrame(Player player, SubLevel frame) { + return frame != null && !frame.isRemoved() && frame.getLevel() == player.level(); + } + + @Unique + private void sablegravity$clearResizeAnchor() { + sablegravity$localFeetBeforeResize = null; + sablegravity$frameBeforeResize = null; + } +} diff --git a/src/main/java/dev/nightly/sablegravity/mixin/EntityCustomOrientationMixin.java b/src/main/java/dev/nightly/sablegravity/mixin/EntityCustomOrientationMixin.java index b9bf6a1..230539b 100644 --- a/src/main/java/dev/nightly/sablegravity/mixin/EntityCustomOrientationMixin.java +++ b/src/main/java/dev/nightly/sablegravity/mixin/EntityCustomOrientationMixin.java @@ -1,6 +1,9 @@ package dev.nightly.sablegravity.mixin; +import dev.nightly.sablegravity.GravityEntityState; import dev.nightly.sablegravity.SableGravityState; +import dev.nightly.sablegravity.physics.GravityFrames; +import dev.nightly.sablegravity.physics.SableFrameOrientation; import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics; import dev.ryanhcode.sable.api.entity.EntitySubLevelUtil; import dev.ryanhcode.sable.sublevel.SubLevel; @@ -12,14 +15,7 @@ import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; -/** - * Exposes one authoritative ship-frame orientation to all Sable consumers. - * - * Sable's camera helper and its world-space OBB collision resolver both read - * EntitySubLevelUtil's custom orientation. The direct logical-pose quaternion - * maps player-local axes into world axes; Sable applies the inverse itself when - * constructing the view matrix. - */ +/** Supplies Sable with a substep-interpolated full quaternion OBB orientation. */ @Mixin(value = EntitySubLevelUtil.class, priority = 3000, remap = false) public abstract class EntityCustomOrientationMixin { @Inject( @@ -33,21 +29,19 @@ public abstract class EntityCustomOrientationMixin { float partialTicks, CallbackInfoReturnable cir ) { - if (!(entity instanceof Player player)) { + GravityEntityState state = (GravityEntityState) entity; + if (state.sablegravity$isInLocalSimulation()) { return; } - SableGravityState state = (SableGravityState) player; - if (!state.sablegravity$isEnabled() || state.sablegravity$isInLocalSimulation()) { - return; - } - - SubLevel frame = SableLocalPlayerPhysics.getAttachedFrame(player); + SubLevel frame = resolveFrame(entity); if (frame == null || frame.isRemoved()) { return; } - cir.setReturnValue(frame.logicalPose().orientation()); + cir.setReturnValue( + SableFrameOrientation.getFrameOrientation(frame, partialTicks) + ); } @Inject( @@ -60,20 +54,38 @@ public abstract class EntityCustomOrientationMixin { Entity entity, CallbackInfoReturnable cir ) { - if (!(entity instanceof Player player)) { + GravityEntityState state = (GravityEntityState) entity; + if (state.sablegravity$isInLocalSimulation()) { return; } - SableGravityState state = (SableGravityState) player; - if (!state.sablegravity$isEnabled() || state.sablegravity$isInLocalSimulation()) { - return; - } - - SubLevel frame = SableLocalPlayerPhysics.getAttachedFrame(player); + SubLevel frame = resolveFrame(entity); if (frame == null || frame.isRemoved()) { return; } cir.setReturnValue(true); } + + private static SubLevel resolveFrame(Entity entity) { + if (entity instanceof Player player) { + if (!player.isLocalPlayer()) { + return null; + } + SableGravityState state = (SableGravityState) player; + if (!state.sablegravity$isEnabled()) { + return null; + } + SubLevel attached = SableLocalPlayerPhysics.getAttachedFrameIfPresent(player); + if (attached != null + && !attached.isRemoved() + && attached.getLevel() == player.level()) { + return attached; + } + return null; + } + return GravityFrames.resolveSable(entity) + .map(dev.nightly.sablegravity.physics.SableSubLevelGravityFrame::subLevel) + .orElse(null); + } } diff --git a/src/main/java/dev/nightly/sablegravity/mixin/EntityDirectionalGravityMixin.java b/src/main/java/dev/nightly/sablegravity/mixin/EntityDirectionalGravityMixin.java new file mode 100644 index 0000000..8075f59 --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/mixin/EntityDirectionalGravityMixin.java @@ -0,0 +1,19 @@ +package dev.nightly.sablegravity.mixin; + +import dev.nightly.sablegravity.physics.DirectionalGravity; +import net.minecraft.world.entity.Entity; +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; + +/** Covers modded calls to applyGravity that occur outside the wrapped entity tick. */ +@Mixin(Entity.class) +public abstract class EntityDirectionalGravityMixin { + @Inject(method = "applyGravity", at = @At("HEAD"), cancellable = true) + private void sablegravity$applyFrameGravity(CallbackInfo ci) { + if (DirectionalGravity.apply((Entity) (Object) this)) { + ci.cancel(); + } + } +} diff --git a/src/main/java/dev/nightly/sablegravity/mixin/EntityGravityStateMixin.java b/src/main/java/dev/nightly/sablegravity/mixin/EntityGravityStateMixin.java new file mode 100644 index 0000000..58be03e --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/mixin/EntityGravityStateMixin.java @@ -0,0 +1,49 @@ +package dev.nightly.sablegravity.mixin; + +import dev.nightly.sablegravity.GravityEntityState; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +import java.util.UUID; + +/** Per-entity frame state. It is runtime state, not saved NBT: every load/warp re-resolves safely. */ +@Mixin(Entity.class) +public abstract class EntityGravityStateMixin implements GravityEntityState { + @Unique private boolean sablegravity$inLocalSimulation; + @Unique private boolean sablegravity$localStateValid; + @Unique private Vec3 sablegravity$localPosition = Vec3.ZERO; + @Unique private Vec3 sablegravity$localVelocity = Vec3.ZERO; + @Unique private UUID sablegravity$frameId; + @Unique private ResourceLocation sablegravity$frameProvider; + @Unique private ResourceLocation sablegravity$frameDimension; + @Unique private boolean sablegravity$gravitySampleValid; + @Unique private double sablegravity$gravitySample; + @Unique private boolean sablegravity$frameHandoffPending; + @Unique private int sablegravity$frameHandoffExpiryTick; + + @Override public boolean sablegravity$isInLocalSimulation() { return sablegravity$inLocalSimulation; } + @Override public void sablegravity$setInLocalSimulation(boolean value) { sablegravity$inLocalSimulation = value; } + @Override public boolean sablegravity$isLocalStateValid() { return sablegravity$localStateValid; } + @Override public void sablegravity$setLocalStateValid(boolean value) { sablegravity$localStateValid = value; } + @Override public Vec3 sablegravity$getLocalPosition() { return sablegravity$localPosition; } + @Override public void sablegravity$setLocalPosition(Vec3 value) { sablegravity$localPosition = value; } + @Override public Vec3 sablegravity$getLocalVelocity() { return sablegravity$localVelocity; } + @Override public void sablegravity$setLocalVelocity(Vec3 value) { sablegravity$localVelocity = value; } + @Override public UUID sablegravity$getFrameId() { return sablegravity$frameId; } + @Override public void sablegravity$setFrameId(UUID value) { sablegravity$frameId = value; } + @Override public ResourceLocation sablegravity$getFrameProvider() { return sablegravity$frameProvider; } + @Override public void sablegravity$setFrameProvider(ResourceLocation value) { sablegravity$frameProvider = value; } + @Override public ResourceLocation sablegravity$getFrameDimension() { return sablegravity$frameDimension; } + @Override public void sablegravity$setFrameDimension(ResourceLocation value) { sablegravity$frameDimension = value; } + @Override public boolean sablegravity$isGravitySampleValid() { return sablegravity$gravitySampleValid; } + @Override public void sablegravity$setGravitySampleValid(boolean value) { sablegravity$gravitySampleValid = value; } + @Override public double sablegravity$getGravitySample() { return sablegravity$gravitySample; } + @Override public void sablegravity$setGravitySample(double value) { sablegravity$gravitySample = value; } + @Override public boolean sablegravity$isFrameHandoffPending() { return sablegravity$frameHandoffPending; } + @Override public void sablegravity$setFrameHandoffPending(boolean value) { sablegravity$frameHandoffPending = value; } + @Override public int sablegravity$getFrameHandoffExpiryTick() { return sablegravity$frameHandoffExpiryTick; } + @Override public void sablegravity$setFrameHandoffExpiryTick(int value) { sablegravity$frameHandoffExpiryTick = value; } +} diff --git a/src/main/java/dev/nightly/sablegravity/mixin/LivingEntityDirectionalGravityMixin.java b/src/main/java/dev/nightly/sablegravity/mixin/LivingEntityDirectionalGravityMixin.java new file mode 100644 index 0000000..fcba566 --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/mixin/LivingEntityDirectionalGravityMixin.java @@ -0,0 +1,199 @@ +package dev.nightly.sablegravity.mixin; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import dev.nightly.sablegravity.api.GravityFrame; +import dev.nightly.sablegravity.physics.GravityFrames; +import dev.nightly.sablegravity.physics.SableFrameOrientation; +import dev.nightly.sablegravity.physics.SableOrientedBounds; +import dev.nightly.sablegravity.physics.SableSubLevelGravityFrame; +import net.minecraft.core.BlockPos; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.animal.FlyingAnimal; +import net.minecraft.world.entity.player.Player; +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; + +/** + * Expresses ordinary living-entity locomotion, gravity and drag in the selected frame while the + * entity itself always remains in physical parent-world coordinates. Sable therefore stays the + * sole owner of OBB collision and entity packet coordinate conversion. + */ +@Mixin(value = LivingEntity.class, priority = 3000) +public abstract class LivingEntityDirectionalGravityMixin { + private static final double AIR_VERTICAL_DRAG = 0.9800000190734863D; + private static final double MOVEMENT_DAMPING = 0.91D; + private static final double EPSILON = 1.0E-10D; + + @WrapOperation( + method = "handleRelativeFrictionAndCalculateMovement", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/world/entity/LivingEntity;moveRelative(FLnet/minecraft/world/phys/Vec3;)V" + ) + ) + private void sablegravity$accelerateInFramePlane( + LivingEntity self, + float speed, + Vec3 input, + Operation original + ) { + if (self instanceof Player) { + original.call(self, speed, input); + return; + } + + GravityFrame frame = GravityFrames.resolve(self).orElse(null); + if (frame == null) { + original.call(self, speed, input); + return; + } + + Vec3 worldBefore = self.getDeltaMovement(); + self.setDeltaMovement(frame.toLocalVelocity(worldBefore)); + try { + // Vanilla yaw and travel input describe heading in the entity's local floor plane. + original.call(self, speed, input); + self.setDeltaMovement(frame.toWorldVelocity(self.getDeltaMovement())); + } catch (RuntimeException | Error failure) { + self.setDeltaMovement(worldBefore); + throw failure; + } + } + + @WrapOperation( + method = "travel", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/world/entity/LivingEntity;setDeltaMovement(DDD)V", + ordinal = 2 + ) + ) + private void sablegravity$finishDiscardFrictionTravelInFrame( + LivingEntity self, + double vanillaX, + double vanillaY, + double vanillaZ, + Operation original + ) { + sablegravity$finishOrdinaryTravel( + self, + vanillaX, + vanillaY, + vanillaZ, + original, + true + ); + } + + @WrapOperation( + method = "travel", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/world/entity/LivingEntity;setDeltaMovement(DDD)V", + ordinal = 3 + ) + ) + private void sablegravity$finishOrdinaryTravelInFrame( + LivingEntity self, + double vanillaX, + double vanillaY, + double vanillaZ, + Operation original + ) { + sablegravity$finishOrdinaryTravel( + self, + vanillaX, + vanillaY, + vanillaZ, + original, + false + ); + } + + @Unique + private static void sablegravity$finishOrdinaryTravel( + LivingEntity self, + double vanillaX, + double vanillaY, + double vanillaZ, + Operation original, + boolean discardFriction + ) { + if (self instanceof Player) { + original.call(self, vanillaX, vanillaY, vanillaZ); + return; + } + + GravityFrame frame = GravityFrames.resolve(self).orElse(null); + if (frame == null) { + original.call(self, vanillaX, vanillaY, vanillaZ); + return; + } + + Vec3 localVelocity = frame.toLocalVelocity(self.getDeltaMovement()); + double localY = localVelocity.y; + if (self.hasEffect(MobEffects.LEVITATION)) { + int amplifier = self.getEffect(MobEffects.LEVITATION).getAmplifier(); + localY += (0.05D * (amplifier + 1) - localY) * 0.2D; + } else { + double gravity = Math.max(0.0D, self.getGravity()); + if (localY <= 0.0D && self.hasEffect(MobEffects.SLOW_FALLING)) { + gravity = Math.min(gravity, 0.01D); + self.fallDistance = 0.0F; + } + localY -= gravity; + } + + double horizontalDrag = discardFriction + ? 1.0D + : sablegravity$getHorizontalDrag(self, frame); + double verticalDrag = self instanceof FlyingAnimal + ? horizontalDrag + : AIR_VERTICAL_DRAG; + Vec3 finalLocalVelocity = new Vec3( + localVelocity.x * horizontalDrag, + localY * verticalDrag, + localVelocity.z * horizontalDrag + ); + Vec3 finalWorldVelocity = frame.toWorldVelocity(finalLocalVelocity); + original.call( + self, + finalWorldVelocity.x, + finalWorldVelocity.y, + finalWorldVelocity.z + ); + } + + @Unique + private static double sablegravity$getHorizontalDrag( + LivingEntity self, + GravityFrame frame + ) { + if (!self.onGround()) { + return MOVEMENT_DAMPING; + } + + Vec3 worldFeet = frame instanceof SableSubLevelGravityFrame sableFrame + ? SableOrientedBounds.worldFeet( + self, + SableFrameOrientation.getFrameOrientation(sableFrame.subLevel(), 1.0F) + ) + : self.position(); + Vec3 localPosition = frame.toLocalPosition(worldFeet); + BlockPos localBelow = BlockPos.containing( + localPosition.x, + localPosition.y - 0.5000001D, + localPosition.z + ); + var blockState = self.level().getBlockState(localBelow); + float friction = blockState.getFriction(self.level(), localBelow, self); + if (!Float.isFinite(friction) || friction <= EPSILON) { + friction = 0.6F; + } + return friction * MOVEMENT_DAMPING; + } +} diff --git a/src/main/java/dev/nightly/sablegravity/mixin/LivingEntityInputAccessor.java b/src/main/java/dev/nightly/sablegravity/mixin/LivingEntityInputAccessor.java new file mode 100644 index 0000000..c392d9f --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/mixin/LivingEntityInputAccessor.java @@ -0,0 +1,12 @@ +package dev.nightly.sablegravity.mixin; + +import net.minecraft.world.entity.LivingEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +/** Exposes the normalized jump input already copied by LocalPlayer.serverAiStep. */ +@Mixin(LivingEntity.class) +public interface LivingEntityInputAccessor { + @Accessor("jumping") + boolean sablegravity$isJumpingInput(); +} diff --git a/src/main/java/dev/nightly/sablegravity/mixin/LocalPlayerFlightCrouchMixin.java b/src/main/java/dev/nightly/sablegravity/mixin/LocalPlayerFlightCrouchMixin.java new file mode 100644 index 0000000..9cccc07 --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/mixin/LocalPlayerFlightCrouchMixin.java @@ -0,0 +1,29 @@ +package dev.nightly.sablegravity.mixin; + +import dev.nightly.sablegravity.SableGravityState; +import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics; +import dev.ryanhcode.sable.sublevel.SubLevel; +import net.minecraft.world.entity.player.Player; +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.CallbackInfoReturnable; + +/** Gives Shift its crouch pose and dimensions while creative flight uses it for local descent. */ +@Mixin(targets = "net.minecraft.client.player.LocalPlayer", priority = 4000) +public abstract class LocalPlayerFlightCrouchMixin { + @Inject(method = "isCrouching", at = @At("HEAD"), cancellable = true) + private void sablegravity$crouchWhileFlying(CallbackInfoReturnable cir) { + Player player = (Player) (Object) this; + if (!player.getAbilities().flying + || !player.isShiftKeyDown() + || !((SableGravityState) player).sablegravity$isEnabled()) { + return; + } + + SubLevel frame = SableLocalPlayerPhysics.getAttachedFrameIfPresent(player); + if (frame != null && !frame.isRemoved() && frame.getLevel() == player.level()) { + cir.setReturnValue(true); + } + } +} diff --git a/src/main/java/dev/nightly/sablegravity/mixin/PlayerEdgeSneakMixin.java b/src/main/java/dev/nightly/sablegravity/mixin/PlayerEdgeSneakMixin.java index 9134dc9..e827e63 100644 --- a/src/main/java/dev/nightly/sablegravity/mixin/PlayerEdgeSneakMixin.java +++ b/src/main/java/dev/nightly/sablegravity/mixin/PlayerEdgeSneakMixin.java @@ -1,12 +1,12 @@ package dev.nightly.sablegravity.mixin; import dev.nightly.sablegravity.SableGravityState; +import dev.nightly.sablegravity.physics.SableLocalCollisionProbe; import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics; import dev.ryanhcode.sable.companion.math.Pose3dc; import dev.ryanhcode.sable.sublevel.SubLevel; import net.minecraft.world.entity.MoverType; import net.minecraft.world.entity.player.Player; -import net.minecraft.world.level.Level; import net.minecraft.world.phys.AABB; import net.minecraft.world.phys.Vec3; import org.spongepowered.asm.mixin.Mixin; @@ -14,10 +14,7 @@ import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; -/** - * Replaces vanilla/Sable edge sneaking with a full ship-local query. - * The synthetic AABB exists only in hidden plot coordinates and is never written to Entity.bb. - */ +/** Replaces world-axis edge sneaking with a tracked-frame local collision-shape query. */ @Mixin(value = Player.class, priority = 4000) public abstract class PlayerEdgeSneakMixin { private static final double STEP = 0.05D; @@ -36,7 +33,6 @@ public abstract class PlayerEdgeSneakMixin { || !state.sablegravity$isEnabled() || player.getAbilities().flying || !player.isShiftKeyDown() - || !SableLocalPlayerPhysics.isLocalGrounded(player) || (moverType != MoverType.SELF && moverType != MoverType.PLAYER)) { return; } @@ -46,64 +42,68 @@ public abstract class PlayerEdgeSneakMixin { return; } - Pose3dc pose = frame.logicalPose(); - Vec3 localMovement = pose.transformNormalInverse(movement); + // The player position still belongs to lastPose here, but the requested displacement was + // encoded by the local travel kernel with logicalPose. Decode/encode the vector with that + // same basis while probing the body's start-of-sweep feet from lastPose. + Pose3dc movementPose = frame.logicalPose(); + Vec3 localMovement = movementPose.transformNormalInverse(movement); if (localMovement.y > 0.0D) { cir.setReturnValue(movement); return; } - Vec3 localFeet = pose.transformPositionInverse(player.position()); - double halfWidth = player.getBbWidth() * 0.5D; - double height = player.getBbHeight(); - AABB localBounds = new AABB( - localFeet.x - halfWidth, - localFeet.y, - localFeet.z - halfWidth, - localFeet.x + halfWidth, - localFeet.y + height, - localFeet.z + halfWidth - ); + Vec3 localFeet = SableLocalCollisionProbe.getLocalFeetAtCollisionStart(player, frame); + if (!SableLocalPlayerPhysics.isLocalGrounded(player) + && !SableLocalCollisionProbe.hasSupport( + player, + frame, + SableLocalCollisionProbe.createFloorProbe(player, localFeet, 0.08D) + )) { + return; + } + AABB localBounds = SableLocalCollisionProbe.createLocalBody(player, localFeet); - Level level = player.level(); double fallDistance = player.maxUpStep(); double x = localMovement.x; double z = localMovement.z; - while (x != 0.0D && wouldSlideOff(level, player, localBounds, x, 0.0D, fallDistance)) { + while (x != 0.0D && wouldSlideOff(player, frame, localBounds, x, 0.0D, fallDistance)) { x = reduceTowardsZero(x); } - while (z != 0.0D && wouldSlideOff(level, player, localBounds, 0.0D, z, fallDistance)) { + while (z != 0.0D && wouldSlideOff(player, frame, localBounds, 0.0D, z, fallDistance)) { z = reduceTowardsZero(z); } while (x != 0.0D && z != 0.0D - && wouldSlideOff(level, player, localBounds, x, z, fallDistance)) { + && wouldSlideOff(player, frame, localBounds, x, z, fallDistance)) { x = reduceTowardsZero(x); z = reduceTowardsZero(z); } - cir.setReturnValue(pose.transformNormal(new Vec3(x, localMovement.y, z))); + cir.setReturnValue( + movementPose.transformNormal(new Vec3(x, localMovement.y, z)) + ); } private static boolean wouldSlideOff( - Level level, Player player, + SubLevel frame, AABB localBounds, double localX, double localZ, double fallDistance ) { AABB future = localBounds.move(localX, 0.0D, localZ); + double inset = Math.min(1.0E-5D, Math.max(0.0D, future.getXsize() * 0.25D)); AABB floorProbe = new AABB( - future.minX, + future.minX + inset, future.minY - fallDistance - FLOOR_EPSILON, - future.minZ, - future.maxX, - future.minY, - future.maxZ + future.minZ + inset, + future.maxX - inset, + future.minY + FLOOR_EPSILON, + future.maxZ - inset ); - return level.noCollision(player, floorProbe); + return !SableLocalCollisionProbe.hasSupport(player, frame, floorProbe); } private static double reduceTowardsZero(double value) { diff --git a/src/main/java/dev/nightly/sablegravity/mixin/PlayerFlightCrouchPoseMixin.java b/src/main/java/dev/nightly/sablegravity/mixin/PlayerFlightCrouchPoseMixin.java new file mode 100644 index 0000000..f421a2f --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/mixin/PlayerFlightCrouchPoseMixin.java @@ -0,0 +1,38 @@ +package dev.nightly.sablegravity.mixin; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import dev.nightly.sablegravity.SableGravityState; +import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics; +import dev.ryanhcode.sable.Sable; +import dev.ryanhcode.sable.sublevel.SubLevel; +import net.minecraft.world.entity.player.Player; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +/** Lets local-frame creative descent use the real crouch pose and 1.5-block body. */ +@Mixin(Player.class) +public abstract class PlayerFlightCrouchPoseMixin { + @ModifyExpressionValue( + method = "updatePlayerPose", + at = @At( + value = "FIELD", + target = "Lnet/minecraft/world/entity/player/Abilities;flying:Z" + ) + ) + private boolean sablegravity$allowCrouchPoseDuringLocalFlight(boolean flying) { + if (!flying) { + return false; + } + + Player player = (Player) (Object) this; + if (!player.isShiftKeyDown() + || !((SableGravityState) player).sablegravity$isEnabled()) { + return true; + } + + SubLevel frame = player.isLocalPlayer() + ? SableLocalPlayerPhysics.getAttachedFrameIfPresent(player) + : Sable.HELPER.getTrackingSubLevel(player); + return frame == null || frame.isRemoved() || frame.getLevel() != player.level(); + } +} diff --git a/src/main/java/dev/nightly/sablegravity/mixin/PlayerGravityDataMixin.java b/src/main/java/dev/nightly/sablegravity/mixin/PlayerGravityDataMixin.java index 127f12f..9486aff 100644 --- a/src/main/java/dev/nightly/sablegravity/mixin/PlayerGravityDataMixin.java +++ b/src/main/java/dev/nightly/sablegravity/mixin/PlayerGravityDataMixin.java @@ -6,8 +6,6 @@ import net.minecraft.network.syncher.EntityDataAccessor; import net.minecraft.network.syncher.EntityDataSerializers; import net.minecraft.network.syncher.SynchedEntityData; import net.minecraft.world.entity.player.Player; -import net.minecraft.world.phys.Vec3; -import java.util.UUID; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; @@ -24,12 +22,6 @@ public abstract class PlayerGravityDataMixin implements SableGravityState { private static final EntityDataAccessor SABLEGRAVITY_STRENGTH = SynchedEntityData.defineId(Player.class, EntityDataSerializers.FLOAT); - @Unique private boolean sablegravity$inLocalSimulation; - @Unique private boolean sablegravity$localStateValid; - @Unique private Vec3 sablegravity$localPosition = Vec3.ZERO; - @Unique private Vec3 sablegravity$localVelocity = Vec3.ZERO; - @Unique private UUID sablegravity$frameId; - @Inject(method = "defineSynchedData", at = @At("TAIL")) private void sablegravity$defineData(SynchedEntityData.Builder builder, CallbackInfo ci) { builder.define(SABLEGRAVITY_ENABLED, false); @@ -44,8 +36,10 @@ public abstract class PlayerGravityDataMixin implements SableGravityState { @Override public void sablegravity$setEnabled(boolean enabled) { ((Player) (Object) this).getEntityData().set(SABLEGRAVITY_ENABLED, enabled); - sablegravity$localStateValid = false; - sablegravity$frameId = null; + sablegravity$setLocalStateValid(false); + sablegravity$setFrameId(null); + sablegravity$setFrameProvider(null); + sablegravity$setFrameDimension(null); } @Override @@ -58,14 +52,4 @@ public abstract class PlayerGravityDataMixin implements SableGravityState { ((Player) (Object) this).getEntityData().set(SABLEGRAVITY_STRENGTH, strength); } - @Override public boolean sablegravity$isInLocalSimulation() { return sablegravity$inLocalSimulation; } - @Override public void sablegravity$setInLocalSimulation(boolean value) { sablegravity$inLocalSimulation = value; } - @Override public boolean sablegravity$isLocalStateValid() { return sablegravity$localStateValid; } - @Override public void sablegravity$setLocalStateValid(boolean value) { sablegravity$localStateValid = value; } - @Override public Vec3 sablegravity$getLocalPosition() { return sablegravity$localPosition; } - @Override public void sablegravity$setLocalPosition(Vec3 value) { sablegravity$localPosition = value; } - @Override public Vec3 sablegravity$getLocalVelocity() { return sablegravity$localVelocity; } - @Override public void sablegravity$setLocalVelocity(Vec3 value) { sablegravity$localVelocity = value; } - @Override public UUID sablegravity$getFrameId() { return sablegravity$frameId; } - @Override public void sablegravity$setFrameId(UUID value) { sablegravity$frameId = value; } } diff --git a/src/main/java/dev/nightly/sablegravity/mixin/PlayerPoseFitMixin.java b/src/main/java/dev/nightly/sablegravity/mixin/PlayerPoseFitMixin.java new file mode 100644 index 0000000..1e2e412 --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/mixin/PlayerPoseFitMixin.java @@ -0,0 +1,51 @@ +package dev.nightly.sablegravity.mixin; + +import dev.nightly.sablegravity.SableGravityState; +import dev.nightly.sablegravity.physics.SableFrameOrientation; +import dev.nightly.sablegravity.physics.SableLocalCollisionProbe; +import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics; +import dev.nightly.sablegravity.physics.SableOrientedBounds; +import dev.nightly.sablegravity.physics.SableParentWorldCollision; +import dev.ryanhcode.sable.sublevel.SubLevel; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.Pose; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaterniondc; +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.CallbackInfoReturnable; + +/** Uses the exact proposed OBB for crouch/stand clearance instead of an upright temporary AABB. */ +@Mixin(value = Player.class, priority = 4500) +public abstract class PlayerPoseFitMixin { + @Inject(method = "canPlayerFitWithinBlocksAndEntitiesWhen", at = @At("HEAD"), cancellable = true) + private void sablegravity$canOrientedBodyFit( + Pose pose, + CallbackInfoReturnable cir + ) { + Player player = (Player) (Object) this; + SableGravityState state = (SableGravityState) player; + if (!state.sablegravity$isEnabled() || !player.isLocalPlayer()) { + return; + } + + SubLevel frame = SableLocalPlayerPhysics.getAttachedFrameIfPresent(player); + if (frame == null || frame.isRemoved() || frame.getLevel() != player.level()) { + return; + } + + Quaterniondc orientation = SableFrameOrientation.getFrameOrientation(frame, 1.0F); + Vec3 physicalFeet = SableOrientedBounds.worldFeet(player, orientation); + EntityDimensions dimensions = player.getDimensions(pose); + boolean plotFit = SableLocalCollisionProbe.canFit(player, frame, dimensions); + boolean parentFit = SableParentWorldCollision.canFit( + player, + physicalFeet, + dimensions, + orientation + ); + cir.setReturnValue(plotFit && parentFit); + } +} diff --git a/src/main/java/dev/nightly/sablegravity/mixin/SableCollisionYawMixin.java b/src/main/java/dev/nightly/sablegravity/mixin/SableCollisionYawMixin.java new file mode 100644 index 0000000..89f6dd0 --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/mixin/SableCollisionYawMixin.java @@ -0,0 +1,133 @@ +package dev.nightly.sablegravity.mixin; + +import dev.nightly.sablegravity.GravityEntityState; +import dev.nightly.sablegravity.SableGravityState; +import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics; +import dev.ryanhcode.sable.ActiveSableCompanion; +import dev.ryanhcode.sable.api.math.LevelReusedVectors; +import dev.ryanhcode.sable.companion.math.Pose3dc; +import dev.ryanhcode.sable.sublevel.SubLevel; +import dev.ryanhcode.sable.sublevel.entity_collision.SubLevelEntityCollision; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaterniondc; +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.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Prevents Sable from stacking its residual world-Y hitbox yaw on top of our complete + * quaternion orientation. + * + * The vanilla Sable path computes getHitBoxYaw() from a reusable pose before that pose is + * updated for the current substep. That is harmless for ordinary upright entities, but a + * custom full-orientation OBB would receive both the complete frame quaternion and this + * stale extra yaw. For gravity players the custom quaternion is authoritative, so yaw=0. + */ +@Mixin(value = SubLevelEntityCollision.class, priority = 3500, remap = false) +public abstract class SableCollisionYawMixin { + + @Redirect( + method = "collide", + at = @At( + value = "INVOKE", + target = "Ldev/ryanhcode/sable/sublevel/entity_collision/SubLevelEntityCollision;getHitBoxYaw(Ldev/ryanhcode/sable/companion/math/Pose3dc;)D" + ), + remap = false + ) + private static double sablegravity$useAuthoritativeCustomOrientation( + Pose3dc pose, + Entity entity, + Vec3 collisionMotion, + Vec3 velocityMotion, + LevelReusedVectors sink + ) { + if (entity instanceof Player player) { + SableGravityState state = (SableGravityState) player; + SubLevel frame = SableLocalPlayerPhysics.getAttachedFrameIfPresent(player); + if (state.sablegravity$isEnabled() + && !state.sablegravity$isInLocalSimulation() + && frame != null + && !frame.isRemoved()) { + return 0.0D; + } + } else { + GravityEntityState state = (GravityEntityState) entity; + if (!state.sablegravity$isInLocalSimulation() + && state.sablegravity$getFrameId() != null) { + return 0.0D; + } + } + + return SubLevelEntityCollision.getHitBoxYaw(pose); + } + + /** Rotates non-player bodies around their invariant raw feet position, never around the eye. */ + @Inject( + method = "transformEntityBoundsCenter", + at = @At("HEAD"), + cancellable = true, + remap = false + ) + private static void sablegravity$useFeetAnchoredBodyCenter( + LevelReusedVectors sink, + Quaterniondc orientation, + Entity entity, + Vector3d center, + CallbackInfo ci + ) { + if (entity instanceof Player || orientation == null) { + return; + } + GravityEntityState state = (GravityEntityState) entity; + if (state.sablegravity$isInLocalSimulation() + || state.sablegravity$getFrameId() == null) { + return; + } + + double halfHeight = entity.getBoundingBox().getYsize() * 0.5D; + Vector3d offset = orientation.transform( + new Vector3d(0.0D, halfHeight, 0.0D), + new Vector3d() + ); + center.set( + entity.getX() + offset.x, + entity.getY() + offset.y, + entity.getZ() + offset.z + ); + ci.cancel(); + } + + /** Keeps Sable's inherited-motion samples on the same feet-anchored convention. */ + @Redirect( + method = "collide", + at = @At( + value = "INVOKE", + target = "Ldev/ryanhcode/sable/ActiveSableCompanion;getFeetPos(Lnet/minecraft/world/entity/Entity;FLorg/joml/Quaterniondc;)Lorg/joml/Vector3d;" + ), + remap = false + ) + private static Vector3d sablegravity$getFeetPosWithoutEyeOffset( + ActiveSableCompanion companion, + Entity entity, + float partialTicks, + Quaterniondc orientation + ) { + if (!(entity instanceof Player) && orientation != null) { + GravityEntityState state = (GravityEntityState) entity; + if (!state.sablegravity$isInLocalSimulation() + && state.sablegravity$getFrameId() != null) { + return new Vector3d( + entity.getX(), + entity.getY() - partialTicks, + entity.getZ() + ); + } + } + return companion.getFeetPos(entity, partialTicks, orientation); + } +} diff --git a/src/main/java/dev/nightly/sablegravity/physics/DirectionalGravity.java b/src/main/java/dev/nightly/sablegravity/physics/DirectionalGravity.java new file mode 100644 index 0000000..0141e6d --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/physics/DirectionalGravity.java @@ -0,0 +1,31 @@ +package dev.nightly.sablegravity.physics; + +import dev.nightly.sablegravity.GravityEntityState; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.Vec3; + +/** Applies non-player gravity along the selected frame without changing coordinate spaces. */ +public final class DirectionalGravity { + private DirectionalGravity() {} + + public static boolean apply(Entity entity) { + GravityEntityState state = (GravityEntityState) entity; + if (entity instanceof Player || state.sablegravity$isInLocalSimulation()) { + return false; + } + + var frame = GravityFrames.resolve(entity).orElse(null); + if (frame == null) { + return false; + } + + double magnitude = frame.gravityPerTick(entity); + if (magnitude == 0.0D) { + return true; + } + Vec3 down = frame.down(1.0F); + entity.setDeltaMovement(entity.getDeltaMovement().add(down.scale(magnitude))); + return true; + } +} diff --git a/src/main/java/dev/nightly/sablegravity/physics/GravityFrames.java b/src/main/java/dev/nightly/sablegravity/physics/GravityFrames.java new file mode 100644 index 0000000..487d473 --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/physics/GravityFrames.java @@ -0,0 +1,190 @@ +package dev.nightly.sablegravity.physics; + +import dev.nightly.sablegravity.GravityEntityState; +import dev.nightly.sablegravity.SableGravityMod; +import dev.nightly.sablegravity.SableGravityState; +import dev.nightly.sablegravity.api.GravityFrame; +import dev.nightly.sablegravity.api.GravityFrameId; +import dev.nightly.sablegravity.api.GravityFrameProvider; +import dev.ryanhcode.sable.sublevel.SubLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicLong; + +/** Thread-safe provider registry and the only place where a gravity frame is selected. */ +public final class GravityFrames { + private static final CopyOnWriteArrayList PROVIDERS = new CopyOnWriteArrayList<>(); + private static final AtomicLong NEXT_ORDER = new AtomicLong(); + + static { + register(new SableSubLevelFrameProvider(), 0); + } + + private GravityFrames() {} + + public static void register(GravityFrameProvider provider, int priority) { + if (provider == null) { + throw new IllegalArgumentException("provider cannot be null"); + } + PROVIDERS.add(new Entry(provider, priority, NEXT_ORDER.getAndIncrement())); + PROVIDERS.sort( + Comparator.comparingInt(Entry::priority).reversed() + .thenComparingLong(Entry::order) + ); + } + + public static Optional resolve(Entity entity) { + if (!isEnabledFor(entity)) { + clearBinding(entity); + return Optional.empty(); + } + + GravityFrameId previous = previousId(entity); + GravityEntityState state = (GravityEntityState) entity; + if (previous != null + && !previous.dimension().equals(entity.level().dimension().location()) + && state.sablegravity$isLocalStateValid()) { + markDimensionHandoff(entity); + } + for (Entry entry : PROVIDERS) { + try { + Optional result = + entry.provider().resolve(entity, previous); + if (result.isPresent() && validFor(result.get(), entity.level())) { + GravityFrame frame = result.get(); + bind(entity, frame.id()); + return Optional.of(frame); + } + } catch (RuntimeException exception) { + SableGravityMod.LOGGER.warn( + "Gravity frame provider {} failed for {}", + entry.provider().getClass().getName(), + entity, + exception + ); + } + } + + clearBinding(entity); + return Optional.empty(); + } + + public static Optional resolveAt( + Level level, + Vec3 position, + GravityFrameId previous + ) { + for (Entry entry : PROVIDERS) { + try { + Optional result = + entry.provider().resolveAt(level, position, previous); + if (result.isPresent() && validFor(result.get(), level)) { + return Optional.of(result.get()); + } + } catch (RuntimeException exception) { + SableGravityMod.LOGGER.warn( + "Gravity frame provider {} failed at {} in {}", + entry.provider().getClass().getName(), + position, + level.dimension().location(), + exception + ); + } + } + return Optional.empty(); + } + + public static Optional resolveSable(Entity entity) { + return resolve(entity) + .filter(SableSubLevelGravityFrame.class::isInstance) + .map(SableSubLevelGravityFrame.class::cast); + } + + private static boolean isEnabledFor(Entity entity) { + if (entity instanceof Player player) { + return ((SableGravityState) player).sablegravity$isEnabled(); + } + return true; + } + + private static boolean validFor(GravityFrame frame, Level level) { + return frame.isValid() && frame.level() == level; + } + + private static GravityFrameId previousId(Entity entity) { + GravityEntityState state = (GravityEntityState) entity; + if (state.sablegravity$getFrameId() == null + || state.sablegravity$getFrameProvider() == null + || state.sablegravity$getFrameDimension() == null) { + return null; + } + return new GravityFrameId( + state.sablegravity$getFrameProvider(), + state.sablegravity$getFrameDimension(), + state.sablegravity$getFrameId() + ); + } + + private static void bind(Entity entity, GravityFrameId id) { + GravityEntityState state = (GravityEntityState) entity; + boolean changed = !id.localId().equals(state.sablegravity$getFrameId()) + || !id.provider().equals(state.sablegravity$getFrameProvider()) + || !id.dimension().equals(state.sablegravity$getFrameDimension()); + state.sablegravity$setFrameId(id.localId()); + state.sablegravity$setFrameProvider(id.provider()); + state.sablegravity$setFrameDimension(id.dimension()); + if (changed) { + state.sablegravity$setLocalStateValid(false); + } + } + + public static void clearBinding(Entity entity) { + GravityEntityState state = (GravityEntityState) entity; + state.sablegravity$setFrameId(null); + state.sablegravity$setFrameProvider(null); + state.sablegravity$setFrameDimension(null); + state.sablegravity$setLocalStateValid(false); + } + + public static void markDimensionHandoff(Entity entity) { + GravityEntityState state = (GravityEntityState) entity; + if (!state.sablegravity$isLocalStateValid()) { + return; + } + state.sablegravity$setFrameHandoffPending(true); + state.sablegravity$setFrameHandoffExpiryTick(entity.tickCount + 100); + } + + public static boolean consumeDimensionHandoff(Entity entity, GravityFrame destination) { + GravityEntityState state = (GravityEntityState) entity; + if (!state.sablegravity$isFrameHandoffPending()) { + return false; + } + state.sablegravity$setFrameHandoffPending(false); + if (entity.tickCount > state.sablegravity$getFrameHandoffExpiryTick()) { + return false; + } + Vec3 localVelocity = state.sablegravity$getLocalVelocity(); + if (localVelocity == null + || !Double.isFinite(localVelocity.x) + || !Double.isFinite(localVelocity.y) + || !Double.isFinite(localVelocity.z)) { + return false; + } + entity.setDeltaMovement(destination.toWorldVelocity(localVelocity)); + return true; + } + + public static List providers() { + return PROVIDERS.stream().map(Entry::provider).toList(); + } + + private record Entry(GravityFrameProvider provider, int priority, long order) {} +} diff --git a/src/main/java/dev/nightly/sablegravity/physics/SableFrameOrientation.java b/src/main/java/dev/nightly/sablegravity/physics/SableFrameOrientation.java new file mode 100644 index 0000000..f46b92a --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/physics/SableFrameOrientation.java @@ -0,0 +1,27 @@ +package dev.nightly.sablegravity.physics; + +import dev.ryanhcode.sable.sublevel.SubLevel; +import org.joml.Quaterniond; + +/** + * Produces the exact player-body orientation for each of Sable's collision substeps. + * + * Sable sweeps the local player in eight interpolated poses. Returning the logical-pose + * endpoint for every request makes the player OBB rotate ahead of the ship collision OBB, + * so SAT resolves a different penetration direction on every substep. The interpolation + * here keeps both bodies in the same rotating frame for arbitrary quaternions. + */ +public final class SableFrameOrientation { + private SableFrameOrientation() {} + + public static Quaterniond getFrameOrientation(SubLevel frame, float partialTicks) { + double t = clamp(partialTicks, 0.0D, 1.0D); + return new Quaterniond(frame.lastPose().orientation()) + .slerp(frame.logicalPose().orientation(), t) + .normalize(); + } + + private static double clamp(double value, double min, double max) { + return Math.max(min, Math.min(max, value)); + } +} diff --git a/src/main/java/dev/nightly/sablegravity/physics/SableLocalCollisionProbe.java b/src/main/java/dev/nightly/sablegravity/physics/SableLocalCollisionProbe.java new file mode 100644 index 0000000..ee0d1c0 --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/physics/SableLocalCollisionProbe.java @@ -0,0 +1,156 @@ +package dev.nightly.sablegravity.physics; + +import dev.ryanhcode.sable.physics.impl.SubLevelEntityCollisionContext; +import dev.ryanhcode.sable.sublevel.SubLevel; +import dev.ryanhcode.sable.util.LevelAccelerator; +import net.minecraft.core.BlockPos; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.level.block.ScaffoldingBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** Collision probes expressed entirely in the tracked SubLevel's local block coordinates. */ +public final class SableLocalCollisionProbe { + private static final double BLOCK_RANGE_EPSILON = 1.0E-7D; + private static final double FOOTPRINT_INSET = 1.0E-5D; + private static final double FLOOR_EPSILON = 1.0E-5D; + + private SableLocalCollisionProbe() {} + + /** Returns the physical oriented feet position at the current logical pose. */ + public static Vec3 getLocalFeet(Player player, SubLevel frame) { + return SableOrientedBounds.localFeet( + player, + frame.logicalPose(), + SableFrameOrientation.getFrameOrientation(frame, 1.0F) + ); + } + + /** Returns physical feet at the start of Sable's next lastPose -> logicalPose sweep. */ + public static Vec3 getLocalFeetAtCollisionStart(Player player, SubLevel frame) { + return SableOrientedBounds.localFeet( + player, + frame.lastPose(), + SableFrameOrientation.getFrameOrientation(frame, 0.0F) + ); + } + + public static AABB createLocalBody(Player player, Vec3 localFeet) { + double halfWidth = player.getBbWidth() * 0.5D; + return new AABB( + localFeet.x - halfWidth, + localFeet.y, + localFeet.z - halfWidth, + localFeet.x + halfWidth, + localFeet.y + player.getBbHeight(), + localFeet.z + halfWidth + ); + } + + /** Thin support slab directly beneath the body footprint, inset to reject adjacent walls. */ + public static AABB createFloorProbe(Player player, Vec3 localFeet, double depth) { + double halfWidth = Math.max( + 0.0D, + player.getBbWidth() * 0.5D - FOOTPRINT_INSET + ); + return new AABB( + localFeet.x - halfWidth, + localFeet.y - Math.max(0.0D, depth) - FLOOR_EPSILON, + localFeet.z - halfWidth, + localFeet.x + halfWidth, + localFeet.y + FLOOR_EPSILON, + localFeet.z + halfWidth + ); + } + + public static boolean hasSupportAtCollisionStart(Player player, SubLevel frame) { + Vec3 localFeet = getLocalFeetAtCollisionStart(player, frame); + return hasSupport(player, frame, createFloorProbe(player, localFeet, 0.08D)); + } + + public static boolean hasSupportNow(Player player, SubLevel frame) { + Vec3 localFeet = getLocalFeet(player, frame); + return hasSupport(player, frame, createFloorProbe(player, localFeet, 0.08D)); + } + + /** Exact pose-fit test in plot coordinates; Entity.bb never participates. */ + public static boolean canFit(Player player, SubLevel frame, EntityDimensions dimensions) { + return canFitAtLocalFeet(player, frame, dimensions, getLocalFeet(player, frame)); + } + + /** Exact pose-fit test at an explicit plot-local feet point. */ + public static boolean canFitAtLocalFeet( + Player player, + SubLevel frame, + EntityDimensions dimensions, + Vec3 localFeet + ) { + double halfWidth = Math.max(0.0D, dimensions.width() * 0.5D - FLOOR_EPSILON); + double height = Math.max(0.0D, dimensions.height() - FLOOR_EPSILON * 2.0D); + AABB localBody = new AABB( + localFeet.x - halfWidth, + localFeet.y + FLOOR_EPSILON, + localFeet.z - halfWidth, + localFeet.x + halfWidth, + localFeet.y + FLOOR_EPSILON + height, + localFeet.z + halfWidth + ); + return !intersectsCollisionShape(player, frame, localBody); + } + + + /** True when at least one tracked-frame collision shape overlaps the local probe. */ + public static boolean hasSupport(Player player, SubLevel frame, AABB localProbe) { + return intersectsCollisionShape(player, frame, localProbe); + } + + private static boolean intersectsCollisionShape( + Player player, + SubLevel frame, + AABB localProbe + ) { + LevelAccelerator level = new LevelAccelerator(player.level()); + + int minX = Mth.floor(localProbe.minX); + int minY = Mth.floor(localProbe.minY); + int minZ = Mth.floor(localProbe.minZ); + int maxX = Mth.floor(localProbe.maxX - BLOCK_RANGE_EPSILON); + int maxY = Mth.floor(localProbe.maxY - BLOCK_RANGE_EPSILON); + int maxZ = Mth.floor(localProbe.maxZ - BLOCK_RANGE_EPSILON); + + if (maxX < minX || maxY < minY || maxZ < minZ) { + return false; + } + + for (BlockPos pos : BlockPos.betweenClosed(minX, minY, minZ, maxX, maxY, maxZ)) { + BlockState state = level.getBlockState(pos); + if (state.isAir()) { + continue; + } + + VoxelShape shape; + if (state.getBlock() instanceof ScaffoldingBlock) { + shape = state.getCollisionShape( + level, + pos, + new SubLevelEntityCollisionContext(player) + ); + } else { + shape = state.getCollisionShape(level, pos, CollisionContext.of(player)); + } + + for (AABB box : shape.toAabbs()) { + if (localProbe.intersects(box.move(pos.getX(), pos.getY(), pos.getZ()))) { + return true; + } + } + } + + return false; + } +} diff --git a/src/main/java/dev/nightly/sablegravity/physics/SableLocalPlayerPhysics.java b/src/main/java/dev/nightly/sablegravity/physics/SableLocalPlayerPhysics.java index 0e72f0a..19517b6 100644 --- a/src/main/java/dev/nightly/sablegravity/physics/SableLocalPlayerPhysics.java +++ b/src/main/java/dev/nightly/sablegravity/physics/SableLocalPlayerPhysics.java @@ -2,42 +2,64 @@ package dev.nightly.sablegravity.physics; import dev.nightly.sablegravity.SableGravityMod; import dev.nightly.sablegravity.SableGravityState; +import dev.nightly.sablegravity.mixin.LivingEntityInputAccessor; import dev.ryanhcode.sable.Sable; import dev.ryanhcode.sable.companion.math.Pose3dc; +import dev.ryanhcode.sable.mixinterface.EntityExtension; import dev.ryanhcode.sable.mixinterface.entity.entity_sublevel_collision.EntityMovementExtension; +import dev.ryanhcode.sable.mixinterface.entity.entity_sublevel_collision.LivingEntityMovementExtension; import dev.ryanhcode.sable.sublevel.SubLevel; import dev.ryanhcode.sable.sublevel.entity_collision.SubLevelEntityCollision; import net.minecraft.world.entity.MoverType; +import net.minecraft.world.effect.MobEffects; import net.minecraft.world.entity.player.Abilities; import net.minecraft.world.entity.player.Player; import net.minecraft.world.phys.Vec3; +import org.joml.Vector3d; +import org.joml.Vector3dc; import java.util.Collections; +import java.util.IdentityHashMap; import java.util.Map; -import java.util.WeakHashMap; /** * Player movement in a SubLevel-local basis while the real entity remains in parent-world space. - * Sable remains the sole owner of OBB collision and inherited ship motion. + * Sable owns the OBB sweep; this class owns the custom-orientation foot anchor and frame carry. */ public final class SableLocalPlayerPhysics { private static final double EPSILON = 1.0E-7D; private static final double COLLISION_EPSILON = 1.0E-5D; + private static final double BLOCKED_MOTION_RATIO = 0.20D; + private static final double POSITION_CONSTRAINED_MOTION_RATIO = 0.80D; private static final double VANILLA_GRAVITY_PER_TICK = 0.08D; private static final double AIR_DRAG = 0.98D; private static final double AIR_HORIZONTAL_FRICTION = 0.91D; + /** + * Normal-block horizontal damping used by vanilla: slipperiness 0.6 * movement damping 0.91. + * + * Do not feed arbitrary modded Block#getFriction() values directly into the vanilla + * acceleration compensation formula. Values below 0.4 create enormous input impulses and + * values above 1 / 0.91 make damping exceed one, which becomes exponential acceleration. + * Block-specific movement needs the complete vanilla speed-factor/slipperiness hook chain; + * until that chain is reproduced, the stable normal-block pair is the only safe contract. + */ private static final double DEFAULT_GROUND_FRICTION = 0.6D * 0.91D; private static final double FLIGHT_VERTICAL_DRAG = 0.6D; - private static final double MAX_CAPTURED_FLIGHT_INPUT = 0.35D; private static final double GROUND_STATIC_FRICTION_LIMIT = 0.03D; private static final float AIR_ACCELERATION = 0.02F; private static final Map ATTACHED_FRAMES = - Collections.synchronizedMap(new WeakHashMap<>()); + Collections.synchronizedMap(new IdentityHashMap<>()); private static final Map LOCAL_GROUNDED = - Collections.synchronizedMap(new WeakHashMap<>()); + Collections.synchronizedMap(new IdentityHashMap<>()); private static final Map LAST_POST_TICK_VELOCITY = - Collections.synchronizedMap(new WeakHashMap<>()); + Collections.synchronizedMap(new IdentityHashMap<>()); + private static final Map LAST_FRAME_MOTION = + Collections.synchronizedMap(new IdentityHashMap<>()); + private static final Map LAST_SABLE_INHERITED_MOTION = + Collections.synchronizedMap(new IdentityHashMap<>()); + private static final Map WAS_FLYING = + Collections.synchronizedMap(new IdentityHashMap<>()); private SableLocalPlayerPhysics() {} @@ -49,55 +71,155 @@ public final class SableLocalPlayerPhysics { return null; } - SubLevel latched = ATTACHED_FRAMES.get(player); - if (latched != null) { - if (latched.isRemoved() || latched.getLevel() != player.level()) { - detach(player); - latched = null; - } else if (state.sablegravity$getFrameId() != null - && state.sablegravity$getFrameId().equals(latched.getUniqueId())) { - ((EntityMovementExtension) player).sable$setTrackingSubLevel(latched); - return latched; - } else { - ATTACHED_FRAMES.remove(player); - latched = null; + // Read Sable's result before restoring our latch. Sable changes this field when the player + // lands on a different ship. The previous implementation immediately wrote the old frame + // back and could therefore keep collision, gravity and Shift attached to the wrong ship. + SubLevel tracked = validFrame(player, Sable.HELPER.getTrackingSubLevel(player)); + SubLevel rawLatched = ATTACHED_FRAMES.get(player); + SubLevel latched = validFrame(player, rawLatched); + + if (latched == null && ATTACHED_FRAMES.containsKey(player)) { + if (rawLatched != null + && rawLatched.getLevel() != player.level() + && state.sablegravity$isLocalStateValid()) { + GravityFrames.markDimensionHandoff(player); } + detach(player); } - SubLevel tracked = Sable.HELPER.getTrackingSubLevel(player); - if (tracked == null || tracked.isRemoved() || tracked.getLevel() != player.level()) { + if (tracked != null && !sameFrame(tracked, latched)) { + return attach(player, state, tracked); + } + + if (latched != null) { + if (state.sablegravity$getFrameId() == null + || !state.sablegravity$getFrameId().equals(latched.getUniqueId())) { + state.sablegravity$setFrameId(latched.getUniqueId()); + state.sablegravity$setFrameProvider(SableSubLevelGravityFrame.PROVIDER_ID); + state.sablegravity$setFrameDimension(player.level().dimension().location()); + state.sablegravity$setLocalStateValid(false); + } + ((EntityMovementExtension) player).sable$setTrackingSubLevel(latched); + return latched; + } + + if (tracked == null) { return null; } + return attach(player, state, tracked); + } - ATTACHED_FRAMES.put(player, tracked); - LOCAL_GROUNDED.put(player, player.onGround()); - state.sablegravity$setFrameId(tracked.getUniqueId()); - state.sablegravity$setLocalStateValid(false); - state.sablegravity$setLocalPosition(tracked.logicalPose().transformPositionInverse(player.position())); - ((EntityMovementExtension) player).sable$setTrackingSubLevel(tracked); - return tracked; + + private static boolean sameFrame(SubLevel first, SubLevel second) { + return first != null + && second != null + && first.getUniqueId().equals(second.getUniqueId()); + } + + private static SubLevel validFrame(Player player, SubLevel frame) { + if (frame == null || frame.isRemoved() || frame.getLevel() != player.level()) { + return null; + } + return frame; + } + + private static SubLevel attach( + Player player, + SableGravityState state, + SubLevel frame + ) { + SubLevel previousFrame = validFrame(player, ATTACHED_FRAMES.get(player)); + + // Sable's custom-orientation OBB is rotated around the eye, while vanilla stores the raw + // entity position at the unrotated feet. Switching the quaternion without rebasing the raw + // position moves the physical OBB bottom by up to twice the eye height. That exact offset + // was then reported as inheritedMotion (2.32 blocks in the supplied log) and launched the + // player. Preserve the physical feet point and change only the raw eye-anchor position. + Vec3 physicalFeet; + if (previousFrame != null) { + physicalFeet = SableOrientedBounds.worldFeet( + player, + SableFrameOrientation.getFrameOrientation(previousFrame, 1.0F) + ); + } else { + physicalFeet = player.position(); + } + + Vec3 rebasedRawPosition = SableOrientedBounds.rawPositionForFeet( + player, + physicalFeet, + SableFrameOrientation.getFrameOrientation(frame, 1.0F) + ); + if (rebasedRawPosition.distanceToSqr(player.position()) > EPSILON) { + player.setPos(rebasedRawPosition); + } + + ATTACHED_FRAMES.put(player, frame); + LAST_FRAME_MOTION.put(player, Vec3.ZERO); + LAST_SABLE_INHERITED_MOTION.remove(player); + LAST_POST_TICK_VELOCITY.remove(player); + WAS_FLYING.put(player, player.getAbilities().flying); + ((LivingEntityMovementExtension) player).sable$getInheritedVelocity().zero(); + + Vec3 localFeet = frame.logicalPose().transformPositionInverse(physicalFeet); + boolean supported = SableLocalCollisionProbe.hasSupportNow(player, frame); + Vec3 handoffLocalVelocity = state.sablegravity$getLocalVelocity(); + boolean consumedHandoff = GravityFrames.consumeDimensionHandoff( + player, + new SableSubLevelGravityFrame(frame) + ); + Vec3 initialLocalVelocity = consumedHandoff + ? handoffLocalVelocity + : supported + ? Vec3.ZERO + : frame.logicalPose().transformNormalInverse(player.getDeltaMovement()); + + state.sablegravity$setFrameId(frame.getUniqueId()); + state.sablegravity$setFrameProvider(SableSubLevelGravityFrame.PROVIDER_ID); + state.sablegravity$setFrameDimension(player.level().dimension().location()); + state.sablegravity$setLocalPosition(localFeet); + state.sablegravity$setLocalVelocity(initialLocalVelocity); + state.sablegravity$setLocalStateValid(true); + player.setDeltaMovement(frame.logicalPose().transformNormal(initialLocalVelocity)); + LOCAL_GROUNDED.put(player, supported); + ((EntityMovementExtension) player).sable$setTrackingSubLevel(frame); + return frame; } public static SubLevel getAttachedFrame(Player player) { return resolveFrame(player); } + /** Returns the already latched frame without querying or mutating Sable tracking state. */ + public static SubLevel getAttachedFrameIfPresent(Player player) { + return ATTACHED_FRAMES.get(player); + } + public static boolean isLocalGrounded(Player player) { return Boolean.TRUE.equals(LOCAL_GROUNDED.get(player)); } - /** Called at LocalPlayer.aiStep HEAD so vanilla cannot cancel flight from a stale ground flag. */ + public static Vec3 getLastFrameMotion(Player player) { + return LAST_FRAME_MOTION.getOrDefault(player, Vec3.ZERO); + } + + public static Vec3 getLastRejectedSableInheritedMotion(Player player) { + return LAST_SABLE_INHERITED_MOTION.get(player); + } + + /** Restores the previous local support flag before vanilla processes jump/flight toggles. */ public static void beforeLocalPlayerTick(Player player) { - if (!player.getAbilities().flying) { - return; - } SableGravityState state = (SableGravityState) player; if (!state.sablegravity$isEnabled()) { return; } SubLevel frame = getAttachedFrame(player); if (frame != null && !frame.isRemoved()) { - player.setOnGround(false); + // LocalPlayer consumes onGround before travel for both normal jumping and creative + // flight toggles. Feed it the frame-local result from the previous sweep. In + // particular, do not force this false while flying: vanilla intentionally ends + // creative flight when the player touches the local floor. + player.setOnGround(isLocalGrounded(player)); } } @@ -108,20 +230,38 @@ public final class SableLocalPlayerPhysics { LAST_POST_TICK_VELOCITY.remove(player); return; } - if (player.getAbilities().flying) { - player.setOnGround(false); - } LAST_POST_TICK_VELOCITY.put(player, player.getDeltaMovement()); } public static void detach(Player player) { + SubLevel attached = ATTACHED_FRAMES.get(player); + if (attached != null + && !attached.isRemoved() + && attached.getLevel() == player.level()) { + // Undo the eye-anchor raw-position rebase before vanilla resumes treating position() + // as the physical feet. Otherwise toggling gravity off visibly teleports the player. + Vec3 physicalFeet = SableOrientedBounds.worldFeet( + player, + SableFrameOrientation.getFrameOrientation(attached, 1.0F) + ); + if (isFinite(physicalFeet)) { + player.setPos(physicalFeet); + } + } + ATTACHED_FRAMES.remove(player); LOCAL_GROUNDED.remove(player); LAST_POST_TICK_VELOCITY.remove(player); + LAST_FRAME_MOTION.remove(player); + LAST_SABLE_INHERITED_MOTION.remove(player); + WAS_FLYING.remove(player); + ((LivingEntityMovementExtension) player).sable$getInheritedVelocity().zero(); SableGravityState state = (SableGravityState) player; state.sablegravity$setLocalStateValid(false); state.sablegravity$setLocalPosition(Vec3.ZERO); state.sablegravity$setFrameId(null); + state.sablegravity$setFrameProvider(null); + state.sablegravity$setFrameDimension(null); } public static boolean simulate(Player player, Vec3 input, SubLevel frame) { @@ -130,7 +270,11 @@ public final class SableLocalPlayerPhysics { EntityMovementExtension movement = (EntityMovementExtension) player; Abilities abilities = player.getAbilities(); boolean flying = abilities.flying; + boolean wasFlying = Boolean.TRUE.equals(WAS_FLYING.get(player)); boolean groundedBeforeMove = isLocalGrounded(player); + double flightControlImpulse = flying + ? getFlightControlImpulse(player, abilities) + : 0.0D; Vec3 localVelocity; if (!state.sablegravity$isLocalStateValid() @@ -140,47 +284,76 @@ public final class SableLocalPlayerPhysics { state.sablegravity$setLocalVelocity(localVelocity); state.sablegravity$setLocalStateValid(true); state.sablegravity$setFrameId(frame.getUniqueId()); - state.sablegravity$setLocalPosition(pose.transformPositionInverse(player.position())); + state.sablegravity$setFrameProvider(SableSubLevelGravityFrame.PROVIDER_ID); + state.sablegravity$setFrameDimension(player.level().dimension().location()); + state.sablegravity$setLocalPosition( + SableLocalCollisionProbe.getLocalFeet(player, frame) + ); } else { localVelocity = state.sablegravity$getLocalVelocity(); - if (flying) { - // LocalPlayer adds creative ascend/descend to deltaMovement before travel. - // Compare against the exact final velocity captured at the previous aiStep TAIL, - // not against a velocity rotated by the current ship pose. This prevents ship - // rotation from being mistaken for flight input and creating runaway acceleration. - Vec3 previousFinalWorld = LAST_POST_TICK_VELOCITY.get(player); - if (previousFinalWorld != null) { - Vec3 preTravelChangeWorld = player.getDeltaMovement().subtract(previousFinalWorld); - if (preTravelChangeWorld.lengthSqr() > EPSILON) { - Vec3 preTravelChangeLocal = pose.transformNormalInverse(preTravelChangeWorld); - double verticalInput = clamp( - preTravelChangeLocal.y, - -MAX_CAPTURED_FLIGHT_INPUT, - MAX_CAPTURED_FLIGHT_INPUT - ); - if (Math.abs(verticalInput) > EPSILON) { - localVelocity = localVelocity.add(0.0D, verticalInput, 0.0D); - } - } + // Import external world-space changes only for ordinary movement. During creative + // flight LocalPlayer and Sable both rewrite deltaMovement as bookkeeping, and + // hasImpulse also remains set across jump/pose transitions. Treating those writes as + // knockback creates a false local X/Z acceleration. Flight controls and the initial + // jump are already written directly to local state by their dedicated paths. + Vec3 previousFinalWorld = LAST_POST_TICK_VELOCITY.get(player); + if (!flying && !wasFlying && previousFinalWorld != null) { + Vec3 externalWorldChange = player.getDeltaMovement().subtract(previousFinalWorld); + if (externalWorldChange.lengthSqr() > EPSILON) { + Vec3 externalLocalChange = pose.transformNormalInverse(externalWorldChange); + localVelocity = localVelocity.add(externalLocalChange); } } } + if (flying && Math.abs(flightControlImpulse) > EPSILON) { + localVelocity = localVelocity.add(0.0D, flightControlImpulse, 0.0D); + } + + Vec3 frameAnchorLocal = state.sablegravity$getLocalPosition(); + Vec3 frameMotion = computeFrameCorrection(player, frame, frameAnchorLocal); + + // Sable's inheritedVelocity is a separate release-momentum channel. Its built-in custom + // orientation feet calculation assumes a different raw-position convention and produced + // the 2.323654-block false impulse from the supplied log. Keep it out of this sweep; the + // corrected frame carry is reconstructed immediately below. + ((LivingEntityMovementExtension) player).sable$getInheritedVelocity().zero(); + + // Reconstruct the frame anchor absolutely before applying this tick's local movement. + // Incrementally adding lastPose -> logicalPose deltas accumulated interpolation and + // client/server round-off as a small tangent displacement every tick; the player had zero + // local X/Z velocity but still slid off the deck. The local feet coordinate is the source + // of truth, so converge the raw eye-anchor position to its current logical-pose image. + Vec3 actualFrameMotion = applyCorrectedFrameMotion(player, frameMotion); + LAST_FRAME_MOTION.put(player, actualFrameMotion); + Vector3d inheritedVelocity = + ((LivingEntityMovementExtension) player).sable$getInheritedVelocity(); + inheritedVelocity.set(actualFrameMotion.x, actualFrameMotion.y, actualFrameMotion.z); + float acceleration; if (flying) { acceleration = abilities.getFlyingSpeed() * (player.isSprinting() ? 2.0F : 1.0F); } else { + // On a normal 0.6-slipperiness block vanilla's 0.216 / friction^3 compensation is + // effectively 1.0, so getSpeed() is the correct ground impulse. test22 queried a raw + // modded friction value and reused it without vanilla's speed-factor hooks. A value + // below 0.4 produced a huge impulse; a value above 1/0.91 produced damping > 1 and + // therefore exponential acceleration. Keep the normal-block contract here. acceleration = groundedBeforeMove ? player.getSpeed() : AIR_ACCELERATION; } - // Creative vertical input has already been added to deltaMovement by LocalPlayer.aiStep. - // Only horizontal input is handled here, otherwise ascend/descend would be applied twice. + // Creative vertical input was removed from vanilla's world Y and re-applied to local Y + // above. Only horizontal travel input belongs in the ordinary acceleration path. Vec3 travelInput = flying ? new Vec3(input.x, 0.0D, input.z) : input; localVelocity = localVelocity.add(getInputVector(travelInput, acceleration, player.getYRot())); Vec3 requestedLocalVelocity = localVelocity; movement.sable$setTrackingSubLevel(frame); + // Sable's arbitrary-orientation step-up checks Entity.onGround() inside its OBB sweep. + // Feed it the previous local ground state instead of the stale world-axis vanilla flag. + player.setOnGround(!flying && groundedBeforeMove); + Vec3 requestedWorldMotion = pose.transformNormal(requestedLocalVelocity); Vec3 positionBeforeMove = player.position(); player.setDeltaMovement(requestedWorldMotion); @@ -188,20 +361,57 @@ public final class SableLocalPlayerPhysics { SubLevelEntityCollision.CollisionInfo collision = movement.sable$getCollisionInfo(); Vec3 actualWorldMotion = player.position().subtract(positionBeforeMove); - Vec3 actualLocalMotion = pose.transformNormalInverse(actualWorldMotion); + + // Do not let Sable's LivingEntity.travel RETURN hook apply its custom-orientation + // inheritedMotion after our cancellable Player.travel injection returns. The value is + // based on SableCompanion#getFeetPos, whose custom-orientation branch treats raw position + // as an eye anchor; this mod keeps vanilla's raw-position field and rebases it explicitly. + Vec3 rejectedSableInherited = collision == null ? null : collision.inheritedMotion; + if (rejectedSableInherited == null) { + LAST_SABLE_INHERITED_MOTION.remove(player); + } else { + LAST_SABLE_INHERITED_MOTION.put(player, rejectedSableInherited); + collision.inheritedMotion = null; + } + + Vec3 actualLocalMotion; + if (collision != null && collision.motion != null && collision.subLevelHorizontalCollision) { + // Sable's returned motion already contains every OBB MTV but excludes inherited ship + // movement. That makes it the stable source for wall/step displacement on a moving + // frame; comparing feet before Sable applies inheritedMotion creates a fake impulse. + actualLocalMotion = pose.transformNormalInverse(collision.motion); + } else { + // Parent-world vanilla collision is applied after collision.motion is stored, so use + // the real displacement when no blocking SubLevel wall participated. + actualLocalMotion = pose.transformNormalInverse(actualWorldMotion); + } // Never import Sable's collision-mutated deltaMovement back into local state. At a tilted // floor that mutation contains tiny tangent components and caused the persistent drift. // Preserve the requested local velocity and only remove axes that were actually blocked. - localVelocity = resolveBlockedVelocity(requestedLocalVelocity, actualLocalMotion, collision); + localVelocity = resolveBlockedVelocity( + requestedLocalVelocity, + actualLocalMotion, + collision, + pose + ); - boolean localGrounded = collision != null && collision.verticalCollisionBelow; - LOCAL_GROUNDED.put(player, !flying && localGrounded); + boolean localGrounded = collision != null + && collision.verticalCollisionBelow + && requestedLocalVelocity.y <= COLLISION_EPSILON; + if (!localGrounded && requestedLocalVelocity.y <= COLLISION_EPSILON) { + // Keep the ground latch stable when SAT reports no fresh penetration on an exact rest. + localGrounded = SableLocalCollisionProbe.hasSupportNow(player, frame); + } + LOCAL_GROUNDED.put(player, localGrounded); - // inheritedMotion/inheritedVelocity are intentionally left to Sable's travel RETURN mixin. + // Local velocity is independent from frame carry. The corrected carry above is stored in + // Sable's inheritedVelocity only as release momentum and is cleared before the next sweep. if (flying) { player.fallDistance = 0.0F; - player.setOnGround(false); + // Preserve local contact semantics so LocalPlayer.aiStep can turn flight off on + // landing, exactly like vanilla does on an ordinary world-axis floor. + player.setOnGround(localGrounded); localVelocity = new Vec3( localVelocity.x * AIR_HORIZONTAL_FRICTION, localVelocity.y * FLIGHT_VERTICAL_DRAG, @@ -210,8 +420,12 @@ public final class SableLocalPlayerPhysics { } else { player.setOnGround(localGrounded); if (!player.isNoGravity()) { - double gravity = VANILLA_GRAVITY_PER_TICK - * (Math.max(0.0F, state.sablegravity$getStrength()) / SableGravityMod.VANILLA_STRENGTH); + double gravity = Math.max(0.0D, player.getGravity()) + * (Math.max(0.0F, state.sablegravity$getStrength()) + / SableGravityMod.VANILLA_STRENGTH); + if (localVelocity.y <= 0.0D && player.hasEffect(MobEffects.SLOW_FALLING)) { + gravity = Math.min(gravity, 0.01D); + } localVelocity = localVelocity.add(0.0D, -gravity, 0.0D); } @@ -236,47 +450,275 @@ public final class SableLocalPlayerPhysics { } updateLocalWalkAnimation(player, actualLocalMotion); - state.sablegravity$setLocalPosition(pose.transformPositionInverse(player.position())); + Vec3 measuredLocalFeet = SableLocalCollisionProbe.getLocalFeet(player, frame); + if (collision != null && collision.horizontalCollision) { + boolean constrainX = axisWasPositionConstrained( + requestedLocalVelocity.x, + actualLocalMotion.x + ); + boolean constrainZ = axisWasPositionConstrained( + requestedLocalVelocity.z, + actualLocalMotion.z + ); + if (constrainX || constrainZ) { + // Sable occasionally returns a small inward displacement at a rotated block seam + // even though it marks the axis as horizontally constrained. Keep constrained + // plot axes on their pre-sweep anchor and preserve the other axis verbatim. This + // is deterministic wall sliding and cannot turn a floor MTV into sideways drift. + measuredLocalFeet = new Vec3( + constrainX ? frameAnchorLocal.x : measuredLocalFeet.x, + measuredLocalFeet.y, + constrainZ ? frameAnchorLocal.z : measuredLocalFeet.z + ); + localVelocity = new Vec3( + constrainX ? 0.0D : localVelocity.x, + localVelocity.y, + constrainZ ? 0.0D : localVelocity.z + ); + + Vec3 correctedWorldFeet = pose.transformPosition(measuredLocalFeet); + Vec3 correctedRaw = SableOrientedBounds.rawPositionForFeet( + player, + correctedWorldFeet, + SableFrameOrientation.getFrameOrientation(frame, 1.0F) + ); + if (isFinite(correctedRaw)) { + player.setPos(correctedRaw); + } + } + } + if (shouldLockRestingTangents( + input, + requestedLocalVelocity, + collision, + SableParentWorldCollision.getLastResult(player) + )) { + // SAT may return a floor MTV with a microscopic tangent component when the frame is + // tilted or interpolated. Velocity clipping alone cannot fix that: the displacement + // has already happened, so its residue accumulated into a visible downhill slide + // while local X/Z velocity remained exactly zero. For a pure resting floor contact, + // the prior local feet X/Z are authoritative; retain only the measured vertical fit. + measuredLocalFeet = new Vec3( + frameAnchorLocal.x, + measuredLocalFeet.y, + frameAnchorLocal.z + ); + Vec3 correctedWorldFeet = pose.transformPosition(measuredLocalFeet); + Vec3 correctedRaw = SableOrientedBounds.rawPositionForFeet( + player, + correctedWorldFeet, + SableFrameOrientation.getFrameOrientation(frame, 1.0F) + ); + if (isFinite(correctedRaw)) { + player.setPos(correctedRaw); + } + } + state.sablegravity$setLocalPosition(measuredLocalFeet); state.sablegravity$setLocalVelocity(localVelocity); player.setDeltaMovement(pose.transformNormal(localVelocity)); + + // Entity.move may have selected another SubLevel after a floor contact. Do not overwrite + // that decision with the old latch on the next tick. Mark local velocity invalid so it is + // cleanly re-expressed in the new frame from the already-correct world velocity. + if (collision != null) { + SubLevel collisionFrame = validFrame(player, collision.trackingSubLevel); + if (collisionFrame != null && !sameFrame(collisionFrame, frame)) { + attach(player, state, collisionFrame); + } + } + WAS_FLYING.put(player, flying); return true; } + private static boolean shouldLockRestingTangents( + Vec3 input, + Vec3 requestedLocalVelocity, + SubLevelEntityCollision.CollisionInfo collision, + SableParentWorldCollision.CollisionResult parentCollision + ) { + if (collision == null + || !collision.verticalCollisionBelow + || collision.subLevelHorizontalCollision + || parentCollision.horizontalCollision()) { + return false; + } + double inputHorizontalSqr = input.x * input.x + input.z * input.z; + double velocityHorizontalSqr = requestedLocalVelocity.x * requestedLocalVelocity.x + + requestedLocalVelocity.z * requestedLocalVelocity.z; + return inputHorizontalSqr <= EPSILON && velocityHorizontalSqr <= EPSILON; + } + + private static Vec3 computeFrameCorrection( + Player player, + SubLevel frame, + Vec3 localPhysicalFeet + ) { + if (localPhysicalFeet == null) { + return Vec3.ZERO; + } + + var orientation = SableFrameOrientation.getFrameOrientation(frame, 1.0F); + Vec3 physicalFeet = frame.logicalPose().transformPosition(localPhysicalFeet); + Vec3 targetRaw = SableOrientedBounds.rawPositionForFeet( + player, + physicalFeet, + orientation + ); + + Vec3 motion = targetRaw.subtract(player.position()); + if (!isFinite(motion)) { + return Vec3.ZERO; + } + return motion; + } + + private static Vec3 applyCorrectedFrameMotion(Player player, Vec3 frameMotion) { + if (frameMotion == null || frameMotion.lengthSqr() <= EPSILON) { + return Vec3.ZERO; + } + Vec3 collidedMotion = ((EntityExtension) player).sable$vanillaCollide(frameMotion); + if (!isFinite(collidedMotion)) { + return Vec3.ZERO; + } + player.setPos(player.position().add(collidedMotion)); + return collidedMotion; + } + + private static boolean isFinite(Vec3 value) { + return Double.isFinite(value.x) + && Double.isFinite(value.y) + && Double.isFinite(value.z); + } + private static Vec3 resolveBlockedVelocity( Vec3 requested, Vec3 actualMotion, - SubLevelEntityCollision.CollisionInfo collision + SubLevelEntityCollision.CollisionInfo collision, + Pose3dc pose ) { - double x = requested.x; - double y = requested.y; - double z = requested.z; + Vec3 resolved = requested; + boolean handledHorizontalContact = false; + boolean handledVerticalContact = false; - if (collision != null) { - if (collision.verticalCollisionBelow && y < 0.0D) { - y = 0.0D; - } else if (collision.verticalCollision && y > 0.0D) { - y = 0.0D; + if (collision != null && collision.firstCollisions != null) { + for (Object value : collision.firstCollisions.values()) { + if (!(value instanceof SubLevelEntityCollision.FirstCollisionInfo info)) { + continue; + } + + // Sable records a wall before trying step-up. If the step succeeded, that first + // wall is stale and must not consume any velocity. + if (info.horizontal() && !collision.subLevelHorizontalCollision) { + continue; + } + + Vector3dc worldNormalJoml = info.globalDirection(); + Vec3 worldNormal = new Vec3( + worldNormalJoml.x(), + worldNormalJoml.y(), + worldNormalJoml.z() + ); + Vec3 localNormal = pose.transformNormalInverse(worldNormal); + double normalLengthSqr = localNormal.lengthSqr(); + if (normalLengthSqr <= EPSILON) { + continue; + } + localNormal = localNormal.scale(1.0D / Math.sqrt(normalLengthSqr)); + + if (!info.horizontal()) { + // A floor/ceiling MTV is never allowed to shave horizontal deck velocity. + // On a rotating frame its sampled normal is not perfectly equal to final local + // +/-Y; full vector projection therefore removed a little X/Z every tick and + // made walking/sprinting speed collapse or pulse. Use the semantic contact + // type and clip only local vertical velocity. + if (localNormal.y > 0.0D && resolved.y < 0.0D) { + resolved = new Vec3(resolved.x, 0.0D, resolved.z); + } else if (localNormal.y < 0.0D && resolved.y > 0.0D) { + resolved = new Vec3(resolved.x, 0.0D, resolved.z); + } + handledVerticalContact = true; + continue; + } + + // Wall response lives in the local floor plane. Drop numerical vertical residue + // from the SAT normal so a wall cannot eat jump/fall speed, then remove only the + // inward horizontal component and preserve the tangent exactly. + double horizontalNormalSqr = + localNormal.x * localNormal.x + localNormal.z * localNormal.z; + if (horizontalNormalSqr <= EPSILON) { + continue; + } + double invHorizontalLength = 1.0D / Math.sqrt(horizontalNormalSqr); + double nx = localNormal.x * invHorizontalLength; + double nz = localNormal.z * invHorizontalLength; + double inwardSpeed = resolved.x * nx + resolved.z * nz; + if (inwardSpeed < -COLLISION_EPSILON) { + resolved = new Vec3( + resolved.x - nx * inwardSpeed, + resolved.y, + resolved.z - nz * inwardSpeed + ); + } + handledHorizontalContact = true; } } - if (axisWasBlocked(requested.x, actualMotion.x)) { - x = 0.0D; - } - if (axisWasBlocked(requested.z, actualMotion.z)) { - z = 0.0D; + double x = resolved.x; + double y = resolved.y; + double z = resolved.z; + + if (collision != null) { + if (!handledVerticalContact) { + if (collision.verticalCollisionBelow && requested.y < 0.0D) { + y = 0.0D; + } else if (collision.verticalCollision + && !collision.verticalCollisionBelow + && requested.y > 0.0D) { + y = 0.0D; + } + } + + if (collision.horizontalCollision && !handledHorizontalContact) { + if (axisWasBlocked(requested.x, actualMotion.x)) { + x = 0.0D; + } + if (axisWasBlocked(requested.z, actualMotion.z)) { + z = 0.0D; + } + } } return new Vec3(x, y, z); } private static boolean axisWasBlocked(double requested, double actual) { - if (Math.abs(requested) <= COLLISION_EPSILON) { + double requestedAbs = Math.abs(requested); + if (requestedAbs <= COLLISION_EPSILON) { return false; } - if (Math.signum(requested) != Math.signum(actual) && Math.abs(actual) > COLLISION_EPSILON) { + if (Math.signum(requested) != Math.signum(actual) + && Math.abs(actual) > COLLISION_EPSILON) { return true; } - return Math.abs(actual) + COLLISION_EPSILON < Math.abs(requested); + return Math.abs(actual) <= Math.max( + COLLISION_EPSILON, + requestedAbs * BLOCKED_MOTION_RATIO + ); + } + + private static boolean axisWasPositionConstrained(double requested, double actual) { + double requestedAbs = Math.abs(requested); + if (requestedAbs <= COLLISION_EPSILON) { + return false; + } + if (Math.signum(requested) != Math.signum(actual) + && Math.abs(actual) > COLLISION_EPSILON) { + return true; + } + return Math.abs(actual) <= Math.max( + COLLISION_EPSILON, + requestedAbs * POSITION_CONSTRAINED_MOTION_RATIO + ); } private static void updateLocalWalkAnimation(Player player, Vec3 actualLocalMotion) { @@ -310,7 +752,13 @@ public final class SableLocalPlayerPhysics { ); } - private static double clamp(double value, double min, double max) { - return Math.max(min, Math.min(max, value)); + private static double getFlightControlImpulse(Player player, Abilities abilities) { + int verticalInput = ((LivingEntityInputAccessor) player) + .sablegravity$isJumpingInput() ? 1 : 0; + if (player.isShiftKeyDown()) { + verticalInput--; + } + return verticalInput * abilities.getFlyingSpeed() * 3.0D; } + } diff --git a/src/main/java/dev/nightly/sablegravity/physics/SableOrientedBounds.java b/src/main/java/dev/nightly/sablegravity/physics/SableOrientedBounds.java new file mode 100644 index 0000000..82fba0b --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/physics/SableOrientedBounds.java @@ -0,0 +1,143 @@ +package dev.nightly.sablegravity.physics; + +import dev.ryanhcode.sable.companion.math.Pose3dc; +import dev.ryanhcode.sable.sublevel.SubLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaterniondc; +import org.joml.Vector3d; + +/** + * Canonical dimensions and the exact eye-anchored OBB used by Sable gravity entities. + * + * Minecraft's AABB type cannot encode an arbitrary orientation. The exact body is therefore + * represented by a center, half extents and quaternion; {@link #worldEnvelope(Entity, Quaterniondc)} + * is only the smallest world-axis AABB containing all eight OBB corners. + */ +public final class SableOrientedBounds { + private SableOrientedBounds() {} + + public static AABB canonicalAabb(Entity entity) { + double halfWidth = entity.getBbWidth() * 0.5D; + double height = entity.getBbHeight(); + Vec3 position = entity.position(); + return new AABB( + position.x - halfWidth, + position.y, + position.z - halfWidth, + position.x + halfWidth, + position.y + height, + position.z + halfWidth + ); + } + + /** Exact world-space OBB center matching Sable's transformEntityBoundsCenter contract. */ + public static Vec3 worldCenter(Entity entity, Quaterniondc orientation) { + double halfHeight = entity.getBbHeight() * 0.5D; + double anchorOffset = entity.getEyeHeight() - halfHeight; + Vector3d rotatedOffset = orientation.transform( + new Vector3d(0.0D, anchorOffset, 0.0D), + new Vector3d() + ); + Vec3 position = entity.position(); + return new Vec3( + position.x - rotatedOffset.x, + position.y + entity.getEyeHeight() - rotatedOffset.y, + position.z - rotatedOffset.z + ); + } + + /** Physical feet point after rotating the canonical body around the eye anchor. */ + public static Vec3 worldFeet(Entity entity, Quaterniondc orientation) { + Vector3d rotatedEyeOffset = orientation.transform( + new Vector3d(0.0D, entity.getEyeHeight(), 0.0D), + new Vector3d() + ); + Vec3 position = entity.position(); + return new Vec3( + position.x - rotatedEyeOffset.x, + position.y + entity.getEyeHeight() - rotatedEyeOffset.y, + position.z - rotatedEyeOffset.z + ); + } + + /** + * Converts a physical world-space feet point back to Minecraft's raw entity position for + * Sable's eye-anchored custom orientation contract. + * + *

Sable rotates a custom entity OBB around the eye. Therefore {@code Entity.position()} + * is no longer the physical bottom-center of the OBB. Keeping the feet fixed while changing + * orientation requires moving the raw position by {@code Q * eyeOffset - worldEyeOffset}.

+ */ + public static Vec3 rawPositionForFeet( + Entity entity, + Vec3 physicalFeet, + Quaterniondc orientation + ) { + Vector3d rotatedEyeOffset = orientation.transform( + new Vector3d(0.0D, entity.getEyeHeight(), 0.0D), + new Vector3d() + ); + return new Vec3( + physicalFeet.x + rotatedEyeOffset.x, + physicalFeet.y + rotatedEyeOffset.y - entity.getEyeHeight(), + physicalFeet.z + rotatedEyeOffset.z + ); + } + + public static Vec3 localFeet( + Entity entity, + Pose3dc pose, + Quaterniondc orientation + ) { + return pose.transformPositionInverse(worldFeet(entity, orientation)); + } + + public static Vec3[] worldCorners(Entity entity, Quaterniondc orientation) { + Vec3 center = worldCenter(entity, orientation); + double halfX = entity.getBbWidth() * 0.5D; + double halfY = entity.getBbHeight() * 0.5D; + double halfZ = entity.getBbWidth() * 0.5D; + Vec3[] corners = new Vec3[8]; + int index = 0; + + for (int xSign : new int[]{-1, 1}) { + for (int ySign : new int[]{-1, 1}) { + for (int zSign : new int[]{-1, 1}) { + Vector3d offset = orientation.transform( + new Vector3d(xSign * halfX, ySign * halfY, zSign * halfZ), + new Vector3d() + ); + corners[index++] = center.add(offset.x, offset.y, offset.z); + } + } + } + return corners; + } + + /** Smallest world-axis AABB containing the exact rotated OBB. Not used as Sable's body. */ + public static AABB worldEnvelope(Entity entity, Quaterniondc orientation) { + Vec3[] corners = worldCorners(entity, orientation); + double minX = Double.POSITIVE_INFINITY; + double minY = Double.POSITIVE_INFINITY; + double minZ = Double.POSITIVE_INFINITY; + double maxX = Double.NEGATIVE_INFINITY; + double maxY = Double.NEGATIVE_INFINITY; + double maxZ = Double.NEGATIVE_INFINITY; + + for (Vec3 corner : corners) { + minX = Math.min(minX, corner.x); + minY = Math.min(minY, corner.y); + minZ = Math.min(minZ, corner.z); + maxX = Math.max(maxX, corner.x); + maxY = Math.max(maxY, corner.y); + maxZ = Math.max(maxZ, corner.z); + } + return new AABB(minX, minY, minZ, maxX, maxY, maxZ); + } + + public static Quaterniondc logicalOrientation(SubLevel frame) { + return SableFrameOrientation.getFrameOrientation(frame, 1.0F); + } +} diff --git a/src/main/java/dev/nightly/sablegravity/physics/SableParentWorldCollision.java b/src/main/java/dev/nightly/sablegravity/physics/SableParentWorldCollision.java new file mode 100644 index 0000000..817588e --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/physics/SableParentWorldCollision.java @@ -0,0 +1,442 @@ +package dev.nightly.sablegravity.physics; + +import dev.ryanhcode.sable.api.math.LevelReusedVectors; +import dev.ryanhcode.sable.api.math.OrientedBoundingBox3d; +import dev.ryanhcode.sable.mixinterface.entity.entity_sublevel_collision.EntityMovementExtension; +import dev.ryanhcode.sable.mixinterface.entity.entity_sublevel_collision.LevelExtension; +import dev.ryanhcode.sable.sublevel.SubLevel; +import dev.ryanhcode.sable.sublevel.entity_collision.SubLevelEntityCollision; +import net.minecraft.core.BlockPos; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.joml.Quaterniondc; +import org.joml.Vector3d; + +import java.util.Collections; +import java.util.Map; +import java.util.WeakHashMap; + +/** + * Replaces vanilla's second upright-AABB collision pass with the same quaternion body that is + * used for Sable plot collision. Parent-world blocks remain solid without becoming fake walls + * merely because the player's local up direction is tilted. + */ +public final class SableParentWorldCollision { + private static final double EPSILON = 1.0E-7D; + private static final double SAT_EPSILON_SQR = 1.0E-14D; + private static final double MAX_SUBSTEP = 0.125D; + private static final int MAX_SUBSTEPS = 64; + private static final int MAX_RESOLUTION_ITERATIONS = 8; + + private static final ThreadLocal ACTIVE_PASS = new ThreadLocal<>(); + private static final Map LAST_RESULTS = + Collections.synchronizedMap(new WeakHashMap<>()); + + private SableParentWorldCollision() {} + + /** Called from the redirected vanilla collision invocation inside Sable's merged move hook. */ + public static Vec3 collideOrTrust( + Player player, + Vec3 requestedMotion, + SubLevel frame, + boolean captureSablePass + ) { + SubLevelEntityCollision.CollisionInfo info = + ((EntityMovementExtension) player).sable$getCollisionInfo(); + CollisionPass pass = captureSablePass ? CollisionPass.capture(info) : null; + + // ServerPlayer receives an already OBB-resolved position from the authoritative local + // client. Running an upright server AABB through collide() here only creates corrections. + if (!player.isLocalPlayer() || frame == null || frame.isRemoved()) { + CollisionResult result = CollisionResult.clear(requestedMotion); + if (pass != null) { + ACTIVE_PASS.set(pass.withResult(result)); + } + LAST_RESULTS.put(player, result); + return requestedMotion; + } + + Quaterniondc orientation = SableFrameOrientation.getFrameOrientation(frame, 1.0F); + CollisionResult result = resolve(player, requestedMotion, orientation); + if (pass != null) { + ACTIVE_PASS.set(pass.withResult(result)); + } + LAST_RESULTS.put(player, result); + return result.motion(); + } + + /** Restores semantic local-up flags after Sable compares the motions as world X/Y/Z axes. */ + public static void restoreSableFlags(Player player) { + CollisionPass pass = ACTIVE_PASS.get(); + ACTIVE_PASS.remove(); + if (pass == null) { + return; + } + + EntityMovementExtension movement = (EntityMovementExtension) player; + SubLevelEntityCollision.CollisionInfo info = movement.sable$getCollisionInfo(); + if (info == null) { + return; + } + + CollisionResult parent = pass.parentResult(); + info.horizontalCollision = pass.subLevelHorizontalCollision() + || pass.horizontalCollision() + || parent.horizontalCollision(); + info.verticalCollision = pass.verticalCollision() || parent.verticalCollision(); + info.verticalCollisionBelow = pass.verticalCollisionBelow() + || parent.verticalCollisionBelow(); + info.minorHorizontalCollision = pass.minorHorizontalCollision(); + info.trackingSubLevel = pass.trackingSubLevel(); + movement.sable$setTrackingSubLevel(pass.trackingSubLevel()); + } + + public static CollisionResult getLastResult(Player player) { + return LAST_RESULTS.getOrDefault(player, CollisionResult.clear(Vec3.ZERO)); + } + + /** Exact parent-world fit test for crouch/stand pose selection. */ + public static boolean canFit( + Player player, + Vec3 physicalFeet, + EntityDimensions dimensions, + Quaterniondc orientation + ) { + double width = Math.max(0.0D, dimensions.width() - EPSILON * 2.0D); + double height = Math.max(0.0D, dimensions.height() - EPSILON * 2.0D); + Vector3d up = orientation.transform(new Vector3d(0.0D, 1.0D, 0.0D)); + Vector3d center = new Vector3d( + physicalFeet.x + up.x * (height * 0.5D + EPSILON), + physicalFeet.y + up.y * (height * 0.5D + EPSILON), + physicalFeet.z + up.z * (height * 0.5D + EPSILON) + ); + return !hasCollision(player, center, width, height, orientation); + } + + private static CollisionResult resolve( + Player player, + Vec3 requestedMotion, + Quaterniondc orientation + ) { + if (!isFinite(requestedMotion)) { + return CollisionResult.clear(Vec3.ZERO); + } + + Vector3d startCenter = toJoml(SableOrientedBounds.worldCenter(player, orientation)); + Vector3d resolvedCenter = new Vector3d(startCenter); + Vector3d step = toJoml(requestedMotion); + int substeps = Math.min( + MAX_SUBSTEPS, + Math.max(1, (int) Math.ceil(step.length() / MAX_SUBSTEP)) + ); + step.div(substeps); + + Vector3d up = orientation.transform(new Vector3d(0.0D, 1.0D, 0.0D)).normalize(); + MutableCollision flags = new MutableCollision(); + + for (int substep = 0; substep < substeps; substep++) { + resolvedCenter.add(step); + resolvePenetration(player, resolvedCenter, step, up, orientation, flags); + } + + Vector3d resolved = resolvedCenter.sub(startCenter); + Vec3 motion = new Vec3(resolved.x, resolved.y, resolved.z); + if (!isFinite(motion)) { + motion = Vec3.ZERO; + } + return new CollisionResult( + requestedMotion, + motion, + flags.horizontal, + flags.vertical, + flags.below, + flags.contacts, + flags.maxCorrection + ); + } + + private static void resolvePenetration( + Player player, + Vector3d center, + Vector3d requestedStep, + Vector3d up, + Quaterniondc orientation, + MutableCollision flags + ) { + LevelReusedVectors sink = ((LevelExtension) player.level()).sable$getJOMLSink(); + OrientedBoundingBox3d entity = new OrientedBoundingBox3d( + center, + new Vector3d(player.getBbWidth(), player.getBbHeight(), player.getBbWidth()), + orientation, + sink + ); + OrientedBoundingBox3d block = new OrientedBoundingBox3d(sink); + block.getOrientation().identity(); + Vector3d mtv = new Vector3d(); + Vector3d largestMtv = new Vector3d(); + + for (int iteration = 0; iteration < MAX_RESOLUTION_ITERATIONS; iteration++) { + largestMtv.zero(); + double[] largestLengthSqr = {0.0D}; + + AABB envelope = envelope(center, player.getBbWidth(), player.getBbHeight(), orientation) + .inflate(EPSILON); + forEachCollisionBox(player, envelope, box -> { + setBlockObb(block, box); + mtv.set(Double.MAX_VALUE); + OrientedBoundingBox3d.sat(entity, block, mtv); + double lengthSqr = validMtv(mtv) ? mtv.lengthSquared() : 0.0D; + if (lengthSqr > largestLengthSqr[0]) { + largestLengthSqr[0] = lengthSqr; + largestMtv.set(mtv); + } + }); + + if (largestLengthSqr[0] <= SAT_EPSILON_SQR) { + return; + } + + Vector3d normal = new Vector3d(largestMtv).normalize(); + double upDot = normal.dot(up); + boolean vertical = Math.abs(upDot) > 0.6D; + + if (!vertical + && player.onGround() + && requestedStep.dot(normal) < 0.0D + && tryStepUp(player, center, up, orientation)) { + entity.setPosition(center); + continue; + } + + center.add(largestMtv); + entity.setPosition(center); + flags.contacts++; + flags.maxCorrection = Math.max( + flags.maxCorrection, + Math.sqrt(largestLengthSqr[0]) + ); + if (vertical) { + flags.vertical = true; + if (upDot > 0.0D) { + flags.below = true; + } + } else { + flags.horizontal = true; + } + } + } + + private static boolean tryStepUp( + Player player, + Vector3d center, + Vector3d up, + Quaterniondc orientation + ) { + double maxStep = player.maxUpStep(); + if (maxStep <= EPSILON) { + return false; + } + Vector3d candidate = new Vector3d(); + for (double amount = 0.0625D; amount <= maxStep + EPSILON; amount += 0.0625D) { + candidate.set(center).fma(amount, up); + if (!hasCollision( + player, + candidate, + player.getBbWidth(), + player.getBbHeight(), + orientation + )) { + center.set(candidate); + return true; + } + } + return false; + } + + private static boolean hasCollision( + Player player, + Vector3d center, + double width, + double height, + Quaterniondc orientation + ) { + LevelReusedVectors sink = ((LevelExtension) player.level()).sable$getJOMLSink(); + OrientedBoundingBox3d entity = new OrientedBoundingBox3d( + center, + new Vector3d(width, height, width), + orientation, + sink + ); + OrientedBoundingBox3d block = new OrientedBoundingBox3d(sink); + block.getOrientation().identity(); + Vector3d mtv = new Vector3d(); + boolean[] collision = {false}; + AABB envelope = envelope(center, width, height, orientation).inflate(EPSILON); + forEachCollisionBox(player, envelope, box -> { + if (collision[0]) { + return; + } + setBlockObb(block, box); + mtv.set(Double.MAX_VALUE); + OrientedBoundingBox3d.sat(entity, block, mtv); + collision[0] = validMtv(mtv); + }); + return collision[0]; + } + + private static void forEachCollisionBox( + Player player, + AABB envelope, + java.util.function.Consumer consumer + ) { + Level level = player.level(); + CollisionContext context = CollisionContext.of(player); + int minX = Mth.floor(envelope.minX); + int minY = Math.max(level.getMinBuildHeight(), Mth.floor(envelope.minY)); + int minZ = Mth.floor(envelope.minZ); + int maxX = Mth.floor(envelope.maxX - EPSILON); + int maxY = Math.min(level.getMaxBuildHeight() - 1, Mth.floor(envelope.maxY - EPSILON)); + int maxZ = Mth.floor(envelope.maxZ - EPSILON); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + + for (int x = minX; x <= maxX; x++) { + for (int z = minZ; z <= maxZ; z++) { + pos.set(x, minY, z); + if (!level.hasChunkAt(pos)) { + continue; + } + for (int y = minY; y <= maxY; y++) { + pos.set(x, y, z); + BlockState state = level.getBlockState(pos); + if (state.isAir()) { + continue; + } + VoxelShape shape = state.getCollisionShape(level, pos, context); + for (AABB localBox : shape.toAabbs()) { + consumer.accept(localBox.move(x, y, z)); + } + } + } + } + } + + private static void setBlockObb(OrientedBoundingBox3d block, AABB box) { + block.getPosition().set( + (box.minX + box.maxX) * 0.5D, + (box.minY + box.maxY) * 0.5D, + (box.minZ + box.maxZ) * 0.5D + ); + block.getDimensions().set(box.getXsize(), box.getYsize(), box.getZsize()); + block.getOrientation().identity(); + } + + private static boolean validMtv(Vector3d mtv) { + return Double.isFinite(mtv.x) + && Double.isFinite(mtv.y) + && Double.isFinite(mtv.z) + && mtv.x != Double.MAX_VALUE + && mtv.y != Double.MAX_VALUE + && mtv.z != Double.MAX_VALUE + && mtv.lengthSquared() > SAT_EPSILON_SQR; + } + + private static AABB envelope( + Vector3d center, + double width, + double height, + Quaterniondc orientation + ) { + double halfWidth = width * 0.5D; + double halfHeight = height * 0.5D; + Vector3d right = orientation.transform(new Vector3d(halfWidth, 0.0D, 0.0D)); + Vector3d up = orientation.transform(new Vector3d(0.0D, halfHeight, 0.0D)); + Vector3d forward = orientation.transform(new Vector3d(0.0D, 0.0D, halfWidth)); + double extentX = Math.abs(right.x) + Math.abs(up.x) + Math.abs(forward.x); + double extentY = Math.abs(right.y) + Math.abs(up.y) + Math.abs(forward.y); + double extentZ = Math.abs(right.z) + Math.abs(up.z) + Math.abs(forward.z); + return new AABB( + center.x - extentX, + center.y - extentY, + center.z - extentZ, + center.x + extentX, + center.y + extentY, + center.z + extentZ + ); + } + + private static Vector3d toJoml(Vec3 value) { + return new Vector3d(value.x, value.y, value.z); + } + + private static boolean isFinite(Vec3 value) { + return Double.isFinite(value.x) + && Double.isFinite(value.y) + && Double.isFinite(value.z); + } + + private static final class MutableCollision { + private boolean horizontal; + private boolean vertical; + private boolean below; + private int contacts; + private double maxCorrection; + } + + private record CollisionPass( + boolean subLevelHorizontalCollision, + boolean horizontalCollision, + boolean verticalCollision, + boolean verticalCollisionBelow, + boolean minorHorizontalCollision, + SubLevel trackingSubLevel, + CollisionResult parentResult + ) { + private static CollisionPass capture(SubLevelEntityCollision.CollisionInfo info) { + if (info == null) { + return new CollisionPass(false, false, false, false, false, null, null); + } + return new CollisionPass( + info.subLevelHorizontalCollision, + info.horizontalCollision, + info.verticalCollision, + info.verticalCollisionBelow, + info.minorHorizontalCollision, + info.trackingSubLevel, + null + ); + } + + private CollisionPass withResult(CollisionResult result) { + return new CollisionPass( + subLevelHorizontalCollision, + horizontalCollision, + verticalCollision, + verticalCollisionBelow, + minorHorizontalCollision, + trackingSubLevel, + result + ); + } + } + + public record CollisionResult( + Vec3 requestedMotion, + Vec3 motion, + boolean horizontalCollision, + boolean verticalCollision, + boolean verticalCollisionBelow, + int contacts, + double maxCorrection + ) { + private static CollisionResult clear(Vec3 motion) { + return new CollisionResult(motion, motion, false, false, false, 0, 0.0D); + } + } +} diff --git a/src/main/java/dev/nightly/sablegravity/physics/SableSubLevelFrameProvider.java b/src/main/java/dev/nightly/sablegravity/physics/SableSubLevelFrameProvider.java new file mode 100644 index 0000000..43e8146 --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/physics/SableSubLevelFrameProvider.java @@ -0,0 +1,126 @@ +package dev.nightly.sablegravity.physics; + +import dev.nightly.sablegravity.api.GravityFrameId; +import dev.nightly.sablegravity.api.GravityFrameProvider; +import dev.ryanhcode.sable.Sable; +import dev.ryanhcode.sable.api.sublevel.SubLevelContainer; +import dev.ryanhcode.sable.companion.math.BoundingBox3d; +import dev.ryanhcode.sable.sublevel.SubLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +import java.util.Optional; + +/** Built-in provider for physical Sable ship/contraption frames. */ +final class SableSubLevelFrameProvider implements GravityFrameProvider { + /** Captures entities emitted just outside a hull before world gravity can steal them. */ + private static final double CAPTURE_MARGIN = 8.0D; + /** Wider release radius prevents one-tick frame flapping at a moving/rotating hull boundary. */ + private static final double RELEASE_MARGIN = 12.0D; + + @Override + public Optional resolve( + Entity entity, + GravityFrameId previousFrame + ) { + Level level = entity.level(); + + // Sable's collision result is authoritative when it has just selected another ship. + SubLevel tracked = valid(level, Sable.HELPER.getTrackingSubLevel(entity)); + if (tracked != null && withinLocalBounds(tracked, entity.position(), RELEASE_MARGIN)) { + return Optional.of(new SableSubLevelGravityFrame(tracked)); + } + + SubLevel previous = fromId(level, previousFrame); + if (previous != null && withinLocalBounds(previous, entity.position(), RELEASE_MARGIN)) { + return Optional.of(new SableSubLevelGravityFrame(previous)); + } + + SubLevel lastTracked = valid(level, Sable.HELPER.getLastTrackingSubLevel(entity)); + if (lastTracked != null + && withinLocalBounds(lastTracked, entity.position(), RELEASE_MARGIN)) { + return Optional.of(new SableSubLevelGravityFrame(lastTracked)); + } + + return findAt(level, entity.position(), entity.getBoundingBox()); + } + + @Override + public Optional resolveAt( + Level level, + Vec3 worldPosition, + GravityFrameId previousFrame + ) { + SubLevel previous = fromId(level, previousFrame); + if (previous != null && withinLocalBounds(previous, worldPosition, RELEASE_MARGIN)) { + return Optional.of(new SableSubLevelGravityFrame(previous)); + } + AABB pointBounds = new AABB(worldPosition, worldPosition).inflate(CAPTURE_MARGIN); + return findAt(level, worldPosition, pointBounds); + } + + private static Optional findAt( + Level level, + Vec3 worldPosition, + AABB searchBounds + ) { + SubLevel best = null; + double bestDistance = Double.POSITIVE_INFINITY; + for (SubLevel candidate : Sable.HELPER.getAllIntersecting( + level, + new BoundingBox3d(searchBounds.inflate(CAPTURE_MARGIN)) + )) { + candidate = valid(level, candidate); + if (candidate == null) { + continue; + } + double distance = distanceToLocalBounds(candidate, worldPosition); + if (distance <= CAPTURE_MARGIN * CAPTURE_MARGIN && distance < bestDistance) { + best = candidate; + bestDistance = distance; + } + } + return best == null + ? Optional.empty() + : Optional.of(new SableSubLevelGravityFrame(best)); + } + + static SubLevel fromId(Level level, GravityFrameId id) { + if (id == null + || !SableSubLevelGravityFrame.PROVIDER_ID.equals(id.provider()) + || !id.dimension().equals(level.dimension().location())) { + return null; + } + SubLevelContainer container = SubLevelContainer.getContainer(level); + return container == null ? null : valid(level, container.getSubLevel(id.localId())); + } + + private static SubLevel valid(Level level, SubLevel frame) { + return frame != null && !frame.isRemoved() && frame.getLevel() == level ? frame : null; + } + + private static boolean withinLocalBounds(SubLevel frame, Vec3 worldPosition, double margin) { + return distanceToLocalBounds(frame, worldPosition) <= margin * margin; + } + + private static double distanceToLocalBounds(SubLevel frame, Vec3 worldPosition) { + Vec3 local = frame.logicalPose().transformPositionInverse(worldPosition); + var bounds = frame.getPlot().getBoundingBox(); + double dx = axisDistance(local.x, bounds.minX(), bounds.maxX() + 1.0D); + double dy = axisDistance(local.y, bounds.minY(), bounds.maxY() + 1.0D); + double dz = axisDistance(local.z, bounds.minZ(), bounds.maxZ() + 1.0D); + return dx * dx + dy * dy + dz * dz; + } + + private static double axisDistance(double value, double min, double max) { + if (value < min) { + return min - value; + } + if (value > max) { + return value - max; + } + return 0.0D; + } +} diff --git a/src/main/java/dev/nightly/sablegravity/physics/SableSubLevelGravityFrame.java b/src/main/java/dev/nightly/sablegravity/physics/SableSubLevelGravityFrame.java new file mode 100644 index 0000000..d95f489 --- /dev/null +++ b/src/main/java/dev/nightly/sablegravity/physics/SableSubLevelGravityFrame.java @@ -0,0 +1,80 @@ +package dev.nightly.sablegravity.physics; + +import dev.nightly.sablegravity.SableGravityMod; +import dev.nightly.sablegravity.api.GravityFrame; +import dev.nightly.sablegravity.api.GravityFrameId; +import dev.ryanhcode.sable.sublevel.SubLevel; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaterniondc; + +/** Gravity frame backed by a live Sable SubLevel pose. */ +public final class SableSubLevelGravityFrame implements GravityFrame { + public static final net.minecraft.resources.ResourceLocation PROVIDER_ID = + net.minecraft.resources.ResourceLocation.fromNamespaceAndPath( + SableGravityMod.MOD_ID, + "sable_sublevel" + ); + + private final SubLevel subLevel; + private final GravityFrameId id; + + public SableSubLevelGravityFrame(SubLevel subLevel) { + this.subLevel = subLevel; + this.id = new GravityFrameId( + PROVIDER_ID, + subLevel.getLevel().dimension().location(), + subLevel.getUniqueId() + ); + } + + public SubLevel subLevel() { + return subLevel; + } + + @Override + public GravityFrameId id() { + return id; + } + + @Override + public Level level() { + return subLevel.getLevel(); + } + + @Override + public boolean isValid() { + return !subLevel.isRemoved(); + } + + @Override + public Vec3 toLocalPosition(Vec3 worldPosition) { + return subLevel.logicalPose().transformPositionInverse(worldPosition); + } + + @Override + public Vec3 toWorldPosition(Vec3 localPosition) { + return subLevel.logicalPose().transformPosition(localPosition); + } + + @Override + public Vec3 toLocalVelocity(Vec3 worldVelocity) { + return subLevel.logicalPose().transformNormalInverse(worldVelocity); + } + + @Override + public Vec3 toWorldVelocity(Vec3 localVelocity) { + return subLevel.logicalPose().transformNormal(localVelocity); + } + + @Override + public Quaterniondc orientation(float partialTick) { + return SableFrameOrientation.getFrameOrientation(subLevel, partialTick); + } + + @Override + public Vec3 frameVelocityAt(Vec3 worldPosition) { + Vec3 previousLocal = subLevel.lastPose().transformPositionInverse(worldPosition); + return subLevel.logicalPose().transformPosition(previousLocal).subtract(worldPosition); + } +} diff --git a/src/main/resources/META-INF/neoforge.mods.toml b/src/main/resources/META-INF/neoforge.mods.toml index e1da7df..a6d4e03 100644 --- a/src/main/resources/META-INF/neoforge.mods.toml +++ b/src/main/resources/META-INF/neoforge.mods.toml @@ -4,10 +4,10 @@ license = "All Rights Reserved" [[mods]] modId = "sablegravity" -version = "0.6.0-test16-ground-flight-sneak" +version = "0.7.4-player-recovery" displayName = "Sable Gravity Local Physics" authors = "Nightly" -description = '''Adds SubLevel-local gravity with Sable OBB collision, stable local grounding, creative flight, local-space animation and ship-local edge sneaking.''' +description = '''Sable ship-local player gravity and collision. Non-player entities remain on Sable's native lifecycle while the universal frame layer is rebuilt safely.''' [[mixins]] config = "sablegravity.mixins.json" diff --git a/src/main/resources/sablegravity.mixins.json b/src/main/resources/sablegravity.mixins.json index f711e28..18a9dc8 100644 --- a/src/main/resources/sablegravity.mixins.json +++ b/src/main/resources/sablegravity.mixins.json @@ -4,14 +4,23 @@ "compatibilityLevel": "JAVA_21", "minVersion": "0.8", "mixins": [ + "EntityGravityStateMixin", "PlayerGravityDataMixin", + "EntityDirectionalGravityMixin", + "LivingEntityDirectionalGravityMixin", "PlayerLocalPhysicsMixin", - "EntityCustomOrientationMixin", + "LivingEntityInputAccessor", "LivingEntityJumpMixin", - "PlayerEdgeSneakMixin" + "PlayerPoseFitMixin", + "EntityCustomOrientationMixin", + "EntityAabbIsolationMixin", + "SableCollisionYawMixin", + "PlayerEdgeSneakMixin", + "PlayerFlightCrouchPoseMixin" ], "client": [ - "LocalPlayerLifecycleMixin" + "LocalPlayerLifecycleMixin", + "LocalPlayerFlightCrouchMixin" ], "injectors": { "defaultRequire": 1 diff --git a/src/mcpCompat/java/dev/devce/rocketnautics/shaded/orekit/compiler/plugin/DefaultDataContextPlugin.java b/src/mcpCompat/java/dev/devce/rocketnautics/shaded/orekit/compiler/plugin/DefaultDataContextPlugin.java new file mode 100644 index 0000000..e8f04f3 --- /dev/null +++ b/src/mcpCompat/java/dev/devce/rocketnautics/shaded/orekit/compiler/plugin/DefaultDataContextPlugin.java @@ -0,0 +1,17 @@ +package dev.devce.rocketnautics.shaded.orekit.compiler.plugin; + +import com.sun.source.util.JavacTask; +import com.sun.source.util.Plugin; + +/** Dev-runtime service shim for the provider omitted from Cosmonautics 26.07.270. */ +public final class DefaultDataContextPlugin implements Plugin { + @Override + public String getName() { + return "DefaultDataContextPlugin"; + } + + @Override + public void init(JavacTask task, String... arguments) { + // Java-eval snippets do not compile Orekit @DefaultDataContext annotated sources. + } +}