test16 version as base for OOB gravity

This commit is contained in:
f1den
2026-07-12 01:51:53 +03:00
commit ba22b12998
27 changed files with 1716 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
### Gradle ###
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/**/build/
### IntelliJ IDEA ###
.idea/
*.iws
*.iml
*.ipr
out/
!**/src/**/out/
.run/
### Eclipse ###
.apt_generated
.classpath
.eclipse/
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/**/bin/
### VS Code ###
.vscode/
### OpenCode ###
.opencode/
### Mac OS ###
.DS_Store
### Minecraft Modding ###
run/
!**/src/**/run/
**/src/generated/**/.cache/
repo/
!**/src/**/repo/
### Local reference checkouts ###
+12
View File
@@ -0,0 +1,12 @@
# Sable Gravity 0.6.0 test16
Built on the working test13 Sable OBB collision path.
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.
+70
View File
@@ -0,0 +1,70 @@
plugins {
id 'java'
id 'net.neoforged.moddev' version '2.0.107'
}
group = 'dev.nightly.sablegravity'
version = project.mod_version
base {
archivesName = "sablegravity"
}
java.toolchain.languageVersion = JavaLanguageVersion.of(21)
repositories {
mavenCentral()
maven {
name = 'Modrinth'
url = 'https://api.modrinth.com/maven'
content {
includeGroup 'maven.modrinth'
}
}
maven {
name = 'RyanHCode'
url = 'https://maven.ryanhcode.dev/releases'
content {
includeGroup 'dev.ryanhcode.sable-companion'
}
}
}
neoForge {
version = project.neo_version
runs {
client {
client()
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
}
server {
server()
programArgument '--nogui'
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
}
}
mods {
sablegravity {
sourceSet sourceSets.main
}
}
}
dependencies {
// Sable is part of the compile API used by this mod and is also loaded in dev runs.
implementation "maven.modrinth:sable:${project.sable_version}"
// Sable embeds Companion at runtime, but its public API is a separate compile artifact.
compileOnly "dev.ryanhcode.sable-companion:sable-companion-common-1.21.1:${project.sable_companion_version}"
// 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:the-one-probe:${project.the_one_probe_version}"
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.release = 21
}
+12
View File
@@ -0,0 +1,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
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
the_one_probe_version=1.21_neo-12.0.8
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
# Architectury Loom needs Gradle 8.x (not 9.x). 8.10.2 is known-good with Loom 1.7.
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+9
View File
@@ -0,0 +1,9 @@
pluginManagement {
repositories {
gradlePluginPortal()
maven { url = 'https://maven.neoforged.net/releases' }
mavenCentral()
}
}
rootProject.name = 'sablegravity'
@@ -0,0 +1,125 @@
package dev.nightly.sablegravity;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.DoubleArgumentType;
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.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 java.util.Collection;
import java.util.List;
@Mod(SableGravityMod.MOD_ID)
public final class SableGravityMod {
public static final String MOD_ID = "sablegravity";
public static final float VANILLA_STRENGTH = 9.8F;
private static volatile float globalStrength = VANILLA_STRENGTH;
public SableGravityMod(ModContainer container, IEventBus modBus) {
NeoForge.EVENT_BUS.addListener(this::registerCommands);
}
private void registerCommands(RegisterCommandsEvent event) {
register(event.getDispatcher());
}
private static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(
Commands.literal("sablegravity")
.requires(source -> source.hasPermission(2))
.then(Commands.literal("strength")
.executes(context -> showStrength(context.getSource()))
.then(Commands.argument("value", DoubleArgumentType.doubleArg(0.0D, 100.0D))
.executes(context -> setStrength(
context.getSource(),
(float) DoubleArgumentType.getDouble(context, "value")
))
)
)
.then(Commands.literal("enable")
.executes(context -> setEnabled(
context.getSource(),
List.of(context.getSource().getPlayerOrException()),
true
))
.then(Commands.argument("targets", EntityArgument.players())
.executes(context -> setEnabled(
context.getSource(),
EntityArgument.getPlayers(context, "targets"),
true
))
)
)
.then(Commands.literal("disable")
.executes(context -> setEnabled(
context.getSource(),
List.of(context.getSource().getPlayerOrException()),
false
))
.then(Commands.argument("targets", EntityArgument.players())
.executes(context -> setEnabled(
context.getSource(),
EntityArgument.getPlayers(context, "targets"),
false
))
)
)
);
}
private static int showStrength(CommandSourceStack source) {
source.sendSuccess(
() -> Component.literal("Sable gravity strength: " + format(globalStrength) + " m/s²"),
false
);
return 1;
}
private static int setStrength(CommandSourceStack source, float strength) {
globalStrength = strength;
for (ServerPlayer player : source.getServer().getPlayerList().getPlayers()) {
SableGravityState state = (SableGravityState) player;
state.sablegravity$setStrength(strength);
}
source.sendSuccess(
() -> Component.literal(
"Sable gravity strength set to " + format(strength) + " m/s²"
+ (strength == VANILLA_STRENGTH ? " (vanilla)" : "")
),
true
);
return 1;
}
private static int setEnabled(CommandSourceStack source, Collection<ServerPlayer> targets, boolean enabled) {
for (ServerPlayer player : targets) {
SableGravityState state = (SableGravityState) player;
state.sablegravity$setStrength(globalStrength);
state.sablegravity$setEnabled(enabled);
state.sablegravity$setLocalStateValid(false);
}
int count = targets.size();
source.sendSuccess(
() -> Component.literal(
"Sable gravity " + (enabled ? "enabled" : "disabled")
+ " for " + count + " player" + (count == 1 ? "" : "s")
),
true
);
return count;
}
private static String format(float value) {
if (value == (long) value) {
return Long.toString((long) value);
}
return Float.toString(value);
}
}
@@ -0,0 +1,23 @@
package dev.nightly.sablegravity;
import net.minecraft.world.phys.Vec3;
import java.util.UUID;
public interface SableGravityState {
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);
}
@@ -0,0 +1,79 @@
package dev.nightly.sablegravity.mixin;
import dev.nightly.sablegravity.SableGravityState;
import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics;
import dev.ryanhcode.sable.api.entity.EntitySubLevelUtil;
import dev.ryanhcode.sable.sublevel.SubLevel;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
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;
/**
* 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.
*/
@Mixin(value = EntitySubLevelUtil.class, priority = 3000, remap = false)
public abstract class EntityCustomOrientationMixin {
@Inject(
method = "getCustomEntityOrientation",
at = @At("HEAD"),
cancellable = true,
remap = false
)
private static void sablegravity$getCustomOrientation(
Entity entity,
float partialTicks,
CallbackInfoReturnable<Quaterniondc> cir
) {
if (!(entity instanceof Player player)) {
return;
}
SableGravityState state = (SableGravityState) player;
if (!state.sablegravity$isEnabled() || state.sablegravity$isInLocalSimulation()) {
return;
}
SubLevel frame = SableLocalPlayerPhysics.getAttachedFrame(player);
if (frame == null || frame.isRemoved()) {
return;
}
cir.setReturnValue(frame.logicalPose().orientation());
}
@Inject(
method = "hasCustomEntityOrientation",
at = @At("HEAD"),
cancellable = true,
remap = false
)
private static void sablegravity$hasCustomOrientation(
Entity entity,
CallbackInfoReturnable<Boolean> cir
) {
if (!(entity instanceof Player player)) {
return;
}
SableGravityState state = (SableGravityState) player;
if (!state.sablegravity$isEnabled() || state.sablegravity$isInLocalSimulation()) {
return;
}
SubLevel frame = SableLocalPlayerPhysics.getAttachedFrame(player);
if (frame == null || frame.isRemoved()) {
return;
}
cir.setReturnValue(true);
}
}
@@ -0,0 +1,51 @@
package dev.nightly.sablegravity.mixin;
import dev.nightly.sablegravity.SableGravityState;
import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics;
import dev.ryanhcode.sable.companion.math.Pose3dc;
import dev.ryanhcode.sable.sublevel.SubLevel;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
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;
/** Computes limb animation from displacement in the ship-local frame, not world X/Z. */
@Mixin(LivingEntity.class)
public abstract class LivingEntityAnimationMixin {
@Inject(method = "calculateEntityAnimation", at = @At("HEAD"), cancellable = true)
private void sablegravity$calculateLocalAnimation(boolean flutter, CallbackInfo ci) {
LivingEntity self = (LivingEntity) (Object) this;
if (!(self instanceof Player player)
|| !player.level().isClientSide()
|| !player.isLocalPlayer()) {
return;
}
SableGravityState state = (SableGravityState) player;
if (!state.sablegravity$isEnabled() || !state.sablegravity$isLocalStateValid()) {
return;
}
SubLevel frame = SableLocalPlayerPhysics.getAttachedFrame(player);
if (frame == null || frame.isRemoved()) {
return;
}
Pose3dc pose = frame.logicalPose();
Vec3 currentLocal = pose.transformPositionInverse(player.position());
Vec3 previousLocal = state.sablegravity$getLocalPosition();
Vec3 localDelta = currentLocal.subtract(previousLocal);
state.sablegravity$setLocalPosition(currentLocal);
double dy = flutter ? localDelta.y : 0.0D;
float distance = (float) Math.sqrt(
localDelta.x * localDelta.x + dy * dy + localDelta.z * localDelta.z
);
float animationSpeed = Math.min(distance * 4.0F, 1.0F);
self.walkAnimation.update(animationSpeed, 0.4F);
ci.cancel();
}
}
@@ -0,0 +1,79 @@
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.LivingEntity;
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;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Runs vanilla jump construction in ship-local velocity coordinates. */
@Mixin(LivingEntity.class)
public abstract class LivingEntityJumpMixin {
@Unique private SubLevel sablegravity$jumpFrame;
@Unique private boolean sablegravity$localizedJump;
@Inject(method = "jumpFromGround", at = @At("HEAD"))
private void sablegravity$beforeJump(CallbackInfo ci) {
if (!((Object) this instanceof Player player) || !player.isLocalPlayer()) {
return;
}
SableGravityState state = (SableGravityState) player;
if (!state.sablegravity$isEnabled()) {
return;
}
SubLevel frame = SableLocalPlayerPhysics.getAttachedFrame(player);
if (frame == null || frame.isRemoved()) {
return;
}
// jumpFromGround assumes +Y and yaw are ordinary world axes. Temporarily make velocity
// local, let vanilla build jump/sprint impulse unchanged, then rotate the result back.
Vec3 localVelocity;
if (state.sablegravity$isLocalStateValid()
&& state.sablegravity$getFrameId() != null
&& state.sablegravity$getFrameId().equals(frame.getUniqueId())) {
localVelocity = state.sablegravity$getLocalVelocity();
} else {
localVelocity = frame.logicalPose().transformNormalInverse(player.getDeltaMovement());
}
player.setDeltaMovement(localVelocity);
state.sablegravity$setInLocalSimulation(true);
sablegravity$jumpFrame = frame;
sablegravity$localizedJump = true;
}
@Inject(method = "jumpFromGround", at = @At("TAIL"))
private void sablegravity$afterJump(CallbackInfo ci) {
if (!sablegravity$localizedJump
|| !((Object) this instanceof Player player)
|| sablegravity$jumpFrame == null) {
sablegravity$clearJumpState();
return;
}
SableGravityState state = (SableGravityState) player;
Vec3 localVelocity = player.getDeltaMovement();
state.sablegravity$setLocalVelocity(localVelocity);
state.sablegravity$setLocalStateValid(true);
state.sablegravity$setFrameId(sablegravity$jumpFrame.getUniqueId());
player.setDeltaMovement(sablegravity$jumpFrame.logicalPose().transformNormal(localVelocity));
sablegravity$clearJumpState();
}
@Unique
private void sablegravity$clearJumpState() {
if ((Object) this instanceof SableGravityState state) {
state.sablegravity$setInLocalSimulation(false);
}
sablegravity$jumpFrame = null;
sablegravity$localizedJump = false;
}
}
@@ -0,0 +1,22 @@
package dev.nightly.sablegravity.mixin;
import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics;
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.CallbackInfo;
/** Keeps creative flight independent from vanilla's world-axis ground flag. */
@Mixin(targets = "net.minecraft.client.player.LocalPlayer", priority = 4000)
public abstract class LocalPlayerLifecycleMixin {
@Inject(method = "aiStep", at = @At("HEAD"))
private void sablegravity$beforeLocalAiStep(CallbackInfo ci) {
SableLocalPlayerPhysics.beforeLocalPlayerTick((Player) (Object) this);
}
@Inject(method = "aiStep", at = @At("TAIL"))
private void sablegravity$afterLocalAiStep(CallbackInfo ci) {
SableLocalPlayerPhysics.afterLocalPlayerTick((Player) (Object) this);
}
}
@@ -0,0 +1,115 @@
package dev.nightly.sablegravity.mixin;
import dev.nightly.sablegravity.SableGravityState;
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;
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.
*/
@Mixin(value = Player.class, priority = 4000)
public abstract class PlayerEdgeSneakMixin {
private static final double STEP = 0.05D;
private static final double FLOOR_EPSILON = 1.0E-5D;
@Inject(method = "maybeBackOffFromEdge", at = @At("HEAD"), cancellable = true)
private void sablegravity$localEdgeBackoff(
Vec3 movement,
MoverType moverType,
CallbackInfoReturnable<Vec3> cir
) {
Player player = (Player) (Object) this;
SableGravityState state = (SableGravityState) player;
if (!player.isLocalPlayer()
|| !state.sablegravity$isEnabled()
|| player.getAbilities().flying
|| !player.isShiftKeyDown()
|| !SableLocalPlayerPhysics.isLocalGrounded(player)
|| (moverType != MoverType.SELF && moverType != MoverType.PLAYER)) {
return;
}
SubLevel frame = SableLocalPlayerPhysics.getAttachedFrame(player);
if (frame == null || frame.isRemoved()) {
return;
}
Pose3dc pose = frame.logicalPose();
Vec3 localMovement = pose.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
);
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)) {
x = reduceTowardsZero(x);
}
while (z != 0.0D && wouldSlideOff(level, player, localBounds, 0.0D, z, fallDistance)) {
z = reduceTowardsZero(z);
}
while (x != 0.0D
&& z != 0.0D
&& wouldSlideOff(level, player, localBounds, x, z, fallDistance)) {
x = reduceTowardsZero(x);
z = reduceTowardsZero(z);
}
cir.setReturnValue(pose.transformNormal(new Vec3(x, localMovement.y, z)));
}
private static boolean wouldSlideOff(
Level level,
Player player,
AABB localBounds,
double localX,
double localZ,
double fallDistance
) {
AABB future = localBounds.move(localX, 0.0D, localZ);
AABB floorProbe = new AABB(
future.minX,
future.minY - fallDistance - FLOOR_EPSILON,
future.minZ,
future.maxX,
future.minY,
future.maxZ
);
return level.noCollision(player, floorProbe);
}
private static double reduceTowardsZero(double value) {
if (Math.abs(value) <= STEP) {
return 0.0D;
}
return value - Math.copySign(STEP, value);
}
}
@@ -0,0 +1,71 @@
package dev.nightly.sablegravity.mixin;
import dev.nightly.sablegravity.SableGravityMod;
import dev.nightly.sablegravity.SableGravityState;
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;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(Player.class)
public abstract class PlayerGravityDataMixin implements SableGravityState {
@Unique
private static final EntityDataAccessor<Boolean> SABLEGRAVITY_ENABLED =
SynchedEntityData.defineId(Player.class, EntityDataSerializers.BOOLEAN);
@Unique
private static final EntityDataAccessor<Float> 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);
builder.define(SABLEGRAVITY_STRENGTH, SableGravityMod.VANILLA_STRENGTH);
}
@Override
public boolean sablegravity$isEnabled() {
return ((Player) (Object) this).getEntityData().get(SABLEGRAVITY_ENABLED);
}
@Override
public void sablegravity$setEnabled(boolean enabled) {
((Player) (Object) this).getEntityData().set(SABLEGRAVITY_ENABLED, enabled);
sablegravity$localStateValid = false;
sablegravity$frameId = null;
}
@Override
public float sablegravity$getStrength() {
return ((Player) (Object) this).getEntityData().get(SABLEGRAVITY_STRENGTH);
}
@Override
public void sablegravity$setStrength(float strength) {
((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; }
}
@@ -0,0 +1,55 @@
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 net.minecraft.world.phys.Vec3;
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;
/** Replaces movement only for the authoritative local client player. */
@Mixin(value = Player.class, priority = 3000)
public abstract class PlayerLocalPhysicsMixin {
@Inject(method = "travel", at = @At("HEAD"), cancellable = true)
private void sablegravity$replacePlayerTravel(Vec3 input, CallbackInfo ci) {
Player player = (Player) (Object) this;
SableGravityState state = (SableGravityState) player;
// Sable intentionally does not run SubLevel OBB collision for ServerPlayer. The local
// client owns this kernel and Sable transforms the resulting movement packets.
if (!player.isLocalPlayer()) {
return;
}
if (!state.sablegravity$isEnabled()) {
SableLocalPlayerPhysics.detach(player);
return;
}
if (state.sablegravity$isInLocalSimulation()) {
return;
}
// Riding, elytra and fluids still use vanilla/Sable paths. Creative flight is handled by
// the local kernel so vertical controls, inertia and collision stay in the ship basis.
if (player.isSpectator()
|| player.isPassenger()
|| player.isFallFlying()
|| player.isInWaterOrBubble()
|| player.isInLava()) {
SableLocalPlayerPhysics.detach(player);
return;
}
SubLevel frame = SableLocalPlayerPhysics.resolveFrame(player);
if (frame == null) {
return;
}
if (SableLocalPlayerPhysics.simulate(player, input, frame)) {
ci.cancel();
}
}
}
@@ -0,0 +1,316 @@
package dev.nightly.sablegravity.physics;
import dev.nightly.sablegravity.SableGravityMod;
import dev.nightly.sablegravity.SableGravityState;
import dev.ryanhcode.sable.Sable;
import dev.ryanhcode.sable.companion.math.Pose3dc;
import dev.ryanhcode.sable.mixinterface.entity.entity_sublevel_collision.EntityMovementExtension;
import dev.ryanhcode.sable.sublevel.SubLevel;
import dev.ryanhcode.sable.sublevel.entity_collision.SubLevelEntityCollision;
import net.minecraft.world.entity.MoverType;
import net.minecraft.world.entity.player.Abilities;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import java.util.Collections;
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.
*/
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 VANILLA_GRAVITY_PER_TICK = 0.08D;
private static final double AIR_DRAG = 0.98D;
private static final double AIR_HORIZONTAL_FRICTION = 0.91D;
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<Player, SubLevel> ATTACHED_FRAMES =
Collections.synchronizedMap(new WeakHashMap<>());
private static final Map<Player, Boolean> LOCAL_GROUNDED =
Collections.synchronizedMap(new WeakHashMap<>());
private static final Map<Player, Vec3> LAST_POST_TICK_VELOCITY =
Collections.synchronizedMap(new WeakHashMap<>());
private SableLocalPlayerPhysics() {}
/** Latches the first tracked frame and keeps it while the player is airborne or flying. */
public static SubLevel resolveFrame(Player player) {
SableGravityState state = (SableGravityState) player;
if (!state.sablegravity$isEnabled()) {
detach(player);
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;
}
}
SubLevel tracked = Sable.HELPER.getTrackingSubLevel(player);
if (tracked == null || tracked.isRemoved() || tracked.getLevel() != player.level()) {
return null;
}
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;
}
public static SubLevel getAttachedFrame(Player player) {
return resolveFrame(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 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);
}
}
/** Captures the final velocity after vanilla/Sable post-travel hooks for the next flight tick. */
public static void afterLocalPlayerTick(Player player) {
SableGravityState state = (SableGravityState) player;
if (!state.sablegravity$isEnabled() || !state.sablegravity$isLocalStateValid()) {
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) {
ATTACHED_FRAMES.remove(player);
LOCAL_GROUNDED.remove(player);
LAST_POST_TICK_VELOCITY.remove(player);
SableGravityState state = (SableGravityState) player;
state.sablegravity$setLocalStateValid(false);
state.sablegravity$setLocalPosition(Vec3.ZERO);
state.sablegravity$setFrameId(null);
}
public static boolean simulate(Player player, Vec3 input, SubLevel frame) {
SableGravityState state = (SableGravityState) player;
Pose3dc pose = frame.logicalPose();
EntityMovementExtension movement = (EntityMovementExtension) player;
Abilities abilities = player.getAbilities();
boolean flying = abilities.flying;
boolean groundedBeforeMove = isLocalGrounded(player);
Vec3 localVelocity;
if (!state.sablegravity$isLocalStateValid()
|| state.sablegravity$getFrameId() == null
|| !state.sablegravity$getFrameId().equals(frame.getUniqueId())) {
localVelocity = pose.transformNormalInverse(player.getDeltaMovement());
state.sablegravity$setLocalVelocity(localVelocity);
state.sablegravity$setLocalStateValid(true);
state.sablegravity$setFrameId(frame.getUniqueId());
state.sablegravity$setLocalPosition(pose.transformPositionInverse(player.position()));
} 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);
}
}
}
}
}
float acceleration;
if (flying) {
acceleration = abilities.getFlyingSpeed() * (player.isSprinting() ? 2.0F : 1.0F);
} else {
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.
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);
Vec3 requestedWorldMotion = pose.transformNormal(requestedLocalVelocity);
Vec3 positionBeforeMove = player.position();
player.setDeltaMovement(requestedWorldMotion);
player.move(MoverType.SELF, requestedWorldMotion);
SubLevelEntityCollision.CollisionInfo collision = movement.sable$getCollisionInfo();
Vec3 actualWorldMotion = player.position().subtract(positionBeforeMove);
Vec3 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);
boolean localGrounded = collision != null && collision.verticalCollisionBelow;
LOCAL_GROUNDED.put(player, !flying && localGrounded);
// inheritedMotion/inheritedVelocity are intentionally left to Sable's travel RETURN mixin.
if (flying) {
player.fallDistance = 0.0F;
player.setOnGround(false);
localVelocity = new Vec3(
localVelocity.x * AIR_HORIZONTAL_FRICTION,
localVelocity.y * FLIGHT_VERTICAL_DRAG,
localVelocity.z * AIR_HORIZONTAL_FRICTION
);
} else {
player.setOnGround(localGrounded);
if (!player.isNoGravity()) {
double gravity = VANILLA_GRAVITY_PER_TICK
* (Math.max(0.0F, state.sablegravity$getStrength()) / SableGravityMod.VANILLA_STRENGTH);
localVelocity = localVelocity.add(0.0D, -gravity, 0.0D);
}
double horizontalFriction = localGrounded
? DEFAULT_GROUND_FRICTION
: AIR_HORIZONTAL_FRICTION;
localVelocity = new Vec3(
localVelocity.x * horizontalFriction,
localVelocity.y * AIR_DRAG,
localVelocity.z * horizontalFriction
);
// Static friction for numerical OBB residue. Real motion/knockback above this small
// threshold remains untouched, while a motionless player no longer creeps downhill.
double horizontalInputSqr = input.x * input.x + input.z * input.z;
if (localGrounded
&& horizontalInputSqr < EPSILON
&& Math.hypot(localVelocity.x, localVelocity.z) < GROUND_STATIC_FRICTION_LIMIT) {
localVelocity = new Vec3(0.0D, localVelocity.y, 0.0D);
}
}
updateLocalWalkAnimation(player, actualLocalMotion);
state.sablegravity$setLocalPosition(pose.transformPositionInverse(player.position()));
state.sablegravity$setLocalVelocity(localVelocity);
player.setDeltaMovement(pose.transformNormal(localVelocity));
return true;
}
private static Vec3 resolveBlockedVelocity(
Vec3 requested,
Vec3 actualMotion,
SubLevelEntityCollision.CollisionInfo collision
) {
double x = requested.x;
double y = requested.y;
double z = requested.z;
if (collision != null) {
if (collision.verticalCollisionBelow && y < 0.0D) {
y = 0.0D;
} else if (collision.verticalCollision && y > 0.0D) {
y = 0.0D;
}
}
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) {
return false;
}
if (Math.signum(requested) != Math.signum(actual) && Math.abs(actual) > COLLISION_EPSILON) {
return true;
}
return Math.abs(actual) + COLLISION_EPSILON < Math.abs(requested);
}
private static void updateLocalWalkAnimation(Player player, Vec3 actualLocalMotion) {
if (!player.level().isClientSide()) {
return;
}
float distance = (float) Math.sqrt(
actualLocalMotion.x * actualLocalMotion.x
+ actualLocalMotion.z * actualLocalMotion.z
);
float animationSpeed = Math.min(distance * 4.0F, 1.0F);
player.walkAnimation.update(animationSpeed, 0.4F);
}
private static Vec3 getInputVector(Vec3 input, float speed, float yawDegrees) {
double lengthSqr = input.lengthSqr();
if (lengthSqr < EPSILON) {
return Vec3.ZERO;
}
Vec3 normalized = lengthSqr > 1.0D ? input.scale(1.0D / Math.sqrt(lengthSqr)) : input;
normalized = normalized.scale(speed);
double radians = yawDegrees * (Math.PI / 180.0D);
double sin = Math.sin(radians);
double cos = Math.cos(radians);
return new Vec3(
normalized.x * cos - normalized.z * sin,
normalized.y,
normalized.z * cos + normalized.x * sin
);
}
private static double clamp(double value, double min, double max) {
return Math.max(min, Math.min(max, value));
}
}
@@ -0,0 +1,34 @@
modLoader = "javafml"
loaderVersion = "[4,)"
license = "All Rights Reserved"
[[mods]]
modId = "sablegravity"
version = "0.6.0-test16-ground-flight-sneak"
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.'''
[[mixins]]
config = "sablegravity.mixins.json"
[[dependencies.sablegravity]]
modId = "neoforge"
type = "required"
versionRange = "[21.1.228,)"
ordering = "NONE"
side = "BOTH"
[[dependencies.sablegravity]]
modId = "minecraft"
type = "required"
versionRange = "[1.21.1]"
ordering = "NONE"
side = "BOTH"
[[dependencies.sablegravity]]
modId = "sable"
type = "required"
versionRange = "[2.0.3,2.1.0)"
ordering = "AFTER"
side = "BOTH"
+1
View File
@@ -0,0 +1 @@
{"pack":{"description":"Sable Gravity resources","pack_format":48}}
@@ -0,0 +1,19 @@
{
"required": true,
"package": "dev.nightly.sablegravity.mixin",
"compatibilityLevel": "JAVA_21",
"minVersion": "0.8",
"mixins": [
"PlayerGravityDataMixin",
"PlayerLocalPhysicsMixin",
"EntityCustomOrientationMixin",
"LivingEntityJumpMixin",
"PlayerEdgeSneakMixin"
],
"client": [
"LocalPlayerLifecycleMixin"
],
"injectors": {
"defaultRequire": 1
}
}
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Checks the relative frame used by Sable's OBB collision.
Sable gives a ship block OBB the SubLevel pose q_ship and gives the custom player
OBB q_entity. For an upright player relative to the logical deck, q_entity must be
q_ship, so inverse(q_ship) * q_entity is identity.
"""
import math
def qx(deg):
a = math.radians(deg) / 2.0
return (math.cos(a), math.sin(a), 0.0, 0.0) # w,x,y,z
def conj(q):
w,x,y,z=q
return (w,-x,-y,-z)
def mul(a,b):
aw,ax,ay,az=a; bw,bx,by,bz=b
return (
aw*bw-ax*bx-ay*by-az*bz,
aw*bx+ax*bw+ay*bz-az*by,
aw*by-ax*bz+ay*bw+az*bx,
aw*bz+ax*by-ay*bx+az*bw,
)
def rotate(q,v):
p=(0.0,*v)
r=mul(mul(q,p),conj(q))
return r[1:]
def close(a,b,eps=1e-9):
return all(abs(x-y)<eps for x,y in zip(a,b))
for deg in (0,20,90,180):
ship=qx(deg)
entity=ship
relative=mul(conj(ship),entity)
world_up=rotate(entity,(0.0,1.0,0.0))
world_gravity=rotate(ship,(0.0,-1.0,0.0))
assert close(relative,(1.0,0.0,0.0,0.0)) or close(relative,(-1.0,0.0,0.0,0.0))
assert abs(sum(a*b for a,b in zip(world_up,world_gravity))+1.0)<1e-9
print(f"X {deg:3d}: relative={relative}, up={world_up}, gravity={world_gravity}")
print("OK: player OBB stays upright relative to the logical deck at 0/20/90/180 degrees")
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Regression checks for local creative-flight input and changing ship orientation."""
import math
def qx(deg):
a = math.radians(deg) / 2.0
return (math.cos(a), math.sin(a), 0.0, 0.0) # w,x,y,z
def conj(q):
w,x,y,z=q
return (w,-x,-y,-z)
def mul(a,b):
aw,ax,ay,az=a; bw,bx,by,bz=b
return (
aw*bw-ax*bx-ay*by-az*bz,
aw*bx+ax*bw+ay*bz-az*by,
aw*by-ax*bz+ay*bw+az*bx,
aw*bz+ax*by-ay*bx+az*bw,
)
def rotate(q,v):
p=(0.0,*v)
r=mul(mul(q,p),conj(q))
return r[1:]
def add(a,b): return tuple(x+y for x,y in zip(a,b))
def sub(a,b): return tuple(x-y for x,y in zip(a,b))
def close(a,b,e=1e-9): return all(abs(x-y)<e for x,y in zip(a,b))
# Stored local velocity is authoritative. LocalPlayer.aiStep adds a local-Y flight impulse,
# already rotated into world space by Sable. Reconciliation must recover exactly that impulse,
# even when the frame orientation changes between ticks.
stored_local=(0.13,-0.04,0.27)
ascend_local=(0.0,0.15,0.0)
for previous,current in ((0,20),(20,90),(90,180),(180,10)):
q=qx(current)
expected_world=rotate(q,stored_local)
actual_world=add(expected_world,rotate(q,ascend_local))
external_world=sub(actual_world,expected_world)
recovered_external=rotate(conj(q),external_world)
reconciled=add(stored_local,recovered_external)
assert close(recovered_external,ascend_local)
assert close(reconciled,add(stored_local,ascend_local))
print(f"{previous:3d}->{current:3d}: recovered flight impulse {recovered_external}")
# A frame rotation by itself must not manufacture a flight impulse.
for deg in (0,20,90,180):
q=qx(deg)
expected=rotate(q,stored_local)
recovered=rotate(conj(q),sub(expected,expected))
assert close(recovered,(0.0,0.0,0.0))
print('OK: flight input is recovered in local Y and frame rotation adds no fake acceleration')
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Deterministic basis checks for the local/world gravity convention used by test11."""
import math
def q_axis(axis, degrees):
a = math.radians(degrees) * 0.5
s = math.sin(a)
return (axis[0]*s, axis[1]*s, axis[2]*s, math.cos(a))
def q_conj(q):
return (-q[0], -q[1], -q[2], q[3])
def q_mul(a, b):
ax, ay, az, aw = a; bx, by, bz, bw = b
return (
aw*bx + ax*bw + ay*bz - az*by,
aw*by - ax*bz + ay*bw + az*bx,
aw*bz + ax*by - ay*bx + az*bw,
aw*bw - ax*bx - ay*by - az*bz,
)
def rotate(q, v):
r = q_mul(q_mul(q, (v[0], v[1], v[2], 0.0)), q_conj(q))
return r[:3]
def close(a, b, eps=1e-9):
return all(abs(x-y) <= eps for x, y in zip(a, b))
UP = (0.0, 1.0, 0.0)
DOWN = (0.0, -1.0, 0.0)
for angle in (0, 20, 90, 180):
q = q_axis((1.0, 0.0, 0.0), angle)
world_up = rotate(q, UP)
world_gravity = rotate(q, DOWN)
assert close(rotate(q_conj(q), world_up), UP)
assert close(rotate(q_conj(q), world_gravity), DOWN)
assert close(tuple(-x for x in world_up), world_gravity)
print(f"X {angle:3d}: up={world_up!r}, gravity={world_gravity!r}")
q180 = q_axis((1.0, 0.0, 0.0), 180)
assert close(rotate(q180, UP), (0.0, -1.0, 0.0))
assert close(rotate(q180, DOWN), (0.0, 1.0, 0.0))
print("OK: at 180 degrees jump points world-down and gravity points world-up")
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Checks that limb animation uses ship-local displacement, not world X/Z."""
import math
def qx(deg):
a=math.radians(deg)/2
return (math.cos(a),math.sin(a),0.0,0.0)
def conj(q):
w,x,y,z=q; return (w,-x,-y,-z)
def mul(a,b):
aw,ax,ay,az=a; bw,bx,by,bz=b
return (aw*bw-ax*bx-ay*by-az*bz,
aw*bx+ax*bw+ay*bz-az*by,
aw*by-ax*bz+ay*bw+az*bx,
aw*bz+ax*by-ay*bx+az*bw)
def rotate(q,v):
r=mul(mul(q,(0.0,*v)),conj(q)); return r[1:]
def length_xz(v): return math.sqrt(v[0]*v[0]+v[2]*v[2])
def length_local(v): return math.sqrt(v[0]*v[0]+v[2]*v[2])
walk=(0.0,0.0,0.25)
for deg in (0,20,90,180):
world=rotate(qx(deg),walk)
recovered=rotate(conj(qx(deg)),world)
assert abs(length_local(recovered)-0.25)<1e-9
print(f"X {deg:3d}: world delta={world}, local walk distance={length_local(recovered):.3f}, vanilla world-XZ={length_xz(world):.3f}")
# At 90 degrees around X, forward walking is world-vertical: vanilla X/Z animation would be 0.
assert length_xz(rotate(qx(90),walk)) < 1e-9
print('OK: local animation still walks at 90 degrees while vanilla world-XZ would freeze')
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Regression check for the test9/test10 fake-impulse bug."""
import math
def qx(deg):
a=math.radians(deg)/2
return (math.cos(a),math.sin(a),0.0,0.0)
def conj(q):
w,x,y,z=q; return (w,-x,-y,-z)
def mul(a,b):
aw,ax,ay,az=a; bw,bx,by,bz=b
return (aw*bw-ax*bx-ay*by-az*bz,
aw*bx+ax*bw+ay*bz-az*by,
aw*by-ax*bz+ay*bw+az*bx,
aw*bz+ax*by-ay*bx+az*bw)
def rotate(q,v):
r=mul(mul(q,(0.0,*v)),conj(q)); return r[1:]
def invrotate(q,v): return rotate(conj(q),v)
def close(a,b,e=1e-9): return all(abs(x-y)<e for x,y in zip(a,b))
local=(0.17,0.42,-0.08)
for deg in (0,20,45,90,180):
world=rotate(qx(deg),local)
recovered=invrotate(qx(deg),world)
assert close(recovered,local)
print(f"X {deg:3d}: local={local} -> world={world} -> local={recovered}")
print('OK: changing ship orientation changes world representation without changing local velocity')