feat: add pose system, slime block bounce, and crawling to physics engine

Implement the three Phase 0 prerequisites for the pathfinding rewrite:

- Pose system (vanilla Player.updatePlayerPose): dynamic AABB height based
  on current pose -- Standing (1.8), Sneaking (1.5), Swimming/Crawling (0.6).
  Automatically downgrades pose when headroom is insufficient (Standing ->
  Sneaking -> Swimming), matching vanilla 1.14+ forced-crawl behavior.

- Slime block bounce (vanilla SlimeBlock.bounceUp / stepOn): reverses
  downward velocity on landing, with sneaking suppression via
  isSuppressingBounce(). Also applies the horizontal slowdown effect
  from SlimeBlock.stepOn when walking on slime with low vertical velocity.

- Per-pose dimension constants in PhysicsConsts with eye heights for each
  pose, sourced from vanilla Avatar.POSES (26.1 decompiled).

- Debug logging for pose transitions, slime bounces, and periodic physics
  state dumps routed through McClient's ILogger.

Tested on a real 1.21.11 server: crawling triggers correctly under 1-block
ceilings, sneaking under 1.5-block ceilings, slime bounce produces correct
decaying oscillation, and pose recovery works when obstacles are removed.

Made-with: Cursor
This commit is contained in:
BruceChen 2026-04-11 01:35:58 +08:00
parent 4013d39068
commit 3efe63c54d
3 changed files with 186 additions and 17 deletions

View file

@ -685,6 +685,7 @@ namespace MinecraftClient
playerPhysics.SetPosition(location.X, location.Y, location.Z);
playerPhysics.Yaw = playerYaw;
playerPhysics.Pitch = playerPitch;
playerPhysics.DebugLog = msg => Log.Debug(msg);
physicsInitialized = true;
}

View file

@ -1,3 +1,5 @@
using System;
namespace MinecraftClient.Physics
{
/// <summary>
@ -6,12 +8,19 @@ namespace MinecraftClient.Physics
/// </summary>
public static class PhysicsConsts
{
// --- Player dimensions ---
// --- Player dimensions per pose (vanilla Avatar.POSES, 26.1) ---
public const double PlayerWidth = 0.6;
public const double PlayerHeight = 1.8;
public const double PlayerSneakHeight = 1.5;
public const double PlayerSwimHeight = 0.6;
public const double PlayerEyeHeight = 1.62;
public const double PlayerStandingHeight = 1.8;
public const double PlayerStandingEyeHeight = 1.62;
public const double PlayerCrouchingHeight = 1.5;
public const double PlayerCrouchingEyeHeight = 1.27;
public const double PlayerSwimmingHeight = 0.6;
public const double PlayerSwimmingEyeHeight = 0.4;
[Obsolete("Use PlayerStandingHeight instead")]
public const double PlayerHeight = PlayerStandingHeight;
[Obsolete("Use PlayerStandingEyeHeight instead")]
public const double PlayerEyeHeight = PlayerStandingEyeHeight;
// --- Gravity ---
public const double DefaultGravity = 0.08;

View file

@ -4,9 +4,9 @@ using MinecraftClient.Mapping;
namespace MinecraftClient.Physics
{
/// <summary>
/// Core physics tick engine for the player, faithfully replicating vanilla 1.21.11 physics.
/// Core physics tick engine for the player, faithfully replicating vanilla 1.21.11+ physics.
/// Mirrors the combined logic of Entity.move(), LivingEntity.aiStep()/travel()/travelInAir(),
/// Player.travel(), and LocalPlayer.aiStep().
/// Player.travel(), Player.updatePlayerPose(), and LocalPlayer.aiStep().
/// </summary>
public class PlayerPhysics
{
@ -33,15 +33,34 @@ namespace MinecraftClient.Physics
public bool Sneaking;
public bool CreativeFlying;
public bool InWater;
public bool IsUnderWater;
public bool InLava;
public bool OnClimbable;
public bool HasSlowFalling;
public bool HasLevitation;
public int LevitationAmplifier;
// Player dimensions
public double PlayerWidth = PhysicsConsts.PlayerWidth;
public double PlayerHeight = PhysicsConsts.PlayerHeight;
// --- Pose system (vanilla Player.updatePlayerPose / Avatar.POSES) ---
public EntityPose CurrentPose { get; private set; } = EntityPose.Standing;
private EntityPose previousPose = EntityPose.Standing;
public double PlayerWidth => PhysicsConsts.PlayerWidth;
public double PlayerHeight => CurrentPose switch
{
EntityPose.Sneaking => PhysicsConsts.PlayerCrouchingHeight,
EntityPose.Swimming or EntityPose.FallFlying or EntityPose.SpinAttack
=> PhysicsConsts.PlayerSwimmingHeight,
_ => PhysicsConsts.PlayerStandingHeight
};
public double EyeHeight => CurrentPose switch
{
EntityPose.Sneaking => PhysicsConsts.PlayerCrouchingEyeHeight,
EntityPose.Swimming or EntityPose.FallFlying or EntityPose.SpinAttack
=> PhysicsConsts.PlayerSwimmingEyeHeight,
_ => PhysicsConsts.PlayerStandingEyeHeight
};
// Anti-jump-spam
private int noJumpDelay;
@ -52,6 +71,11 @@ namespace MinecraftClient.Physics
// Movement speed attribute (base = 0.1 for players)
public float MovementSpeed = 0.1f;
/// <summary>
/// Debug log callback. Set from McClient to route messages through MCC's logger.
/// </summary>
public Action<string>? DebugLog;
/// <summary>
/// Get the player's bounding box at current position
/// </summary>
@ -67,6 +91,9 @@ namespace MinecraftClient.Physics
{
TickCount++;
// Update pose (vanilla Player.updatePlayerPose)
UpdatePlayerPose(world);
// Velocity threshold zeroing (LivingEntity.aiStep)
ZeroTinyVelocity();
@ -81,6 +108,14 @@ namespace MinecraftClient.Physics
if (noJumpDelay > 0)
noJumpDelay--;
// Periodic state dump every 5 seconds (100 ticks)
if (DebugLog is not null && TickCount % 100 == 0)
{
DebugLog($"[Physics] tick={TickCount} pos={Position} vel={DeltaMovement} " +
$"ground={OnGround} pose={CurrentPose} fall={FallDistance:F2} " +
$"water={InWater} underwater={IsUnderWater} swim={IsSwimming()} sneak={Sneaking}");
}
}
/// <summary>
@ -240,6 +275,9 @@ namespace MinecraftClient.Physics
// Block speed factor (soul sand, honey, etc.)
ApplyBlockSpeedFactor(world);
// SlimeBlock.stepOn: slow horizontal movement when walking on slime
ApplySlimeStepOn(world);
}
/// <summary>
@ -353,12 +391,6 @@ namespace MinecraftClient.Physics
double resolvedLenSqr = resolved.LengthSqr();
if (resolvedLenSqr > 1.0E-7 || movement.LengthSqr() - resolvedLenSqr < 1.0E-7)
{
// Fall distance reset via trace (simplified: reset on hitting ground)
if (FallDistance != 0.0 && resolvedLenSqr >= 1.0)
{
// Simplified: just check vertical collision
}
Position = Position.Add(resolved);
}
@ -385,13 +417,46 @@ namespace MinecraftClient.Physics
blockedZ ? 0 : DeltaMovement.Z);
}
// Vanilla: Block.updateEntityMovementAfterFallOn -> SlimeBlock.bounceUp
if (VerticalCollision)
UpdateMovementAfterFallOn(world);
}
/// <summary>
/// Vanilla Block.updateEntityMovementAfterFallOn / SlimeBlock.bounceUp.
/// Called when vertical collision is detected. Handles slime block bounce.
/// </summary>
private void UpdateMovementAfterFallOn(World world)
{
Location belowFeet = new(Position.X, Position.Y - 0.2, Position.Z);
Material landedOn = world.GetBlock(belowFeet).Type;
if (landedOn == Material.SlimeBlock && !IsSuppressingBounce())
{
// Slime block bounce would go here; for now just zero Y
double vy = DeltaMovement.Y;
if (vy < 0.0)
{
// LivingEntity bounce factor = 1.0
DeltaMovement = new Vec3d(DeltaMovement.X, -vy, DeltaMovement.Z);
DebugLog?.Invoke($"[Physics] Slime bounce! vy={vy:F4} -> {-vy:F4} at {Position}");
}
else
{
DeltaMovement = new Vec3d(DeltaMovement.X, 0, DeltaMovement.Z);
}
}
else
{
// Default: zero vertical velocity
DeltaMovement = new Vec3d(DeltaMovement.X, 0, DeltaMovement.Z);
}
}
/// <summary>
/// Vanilla Entity.isSuppressingBounce() - sneaking suppresses slime bounce.
/// </summary>
private bool IsSuppressingBounce() => Sneaking;
/// <summary>
/// Sneak edge detection: prevent walking off edges while sneaking.
/// Equivalent to Player.maybeBackOffFromEdge(Vec3, MoverType).
@ -507,6 +572,26 @@ namespace MinecraftClient.Physics
}
}
/// <summary>
/// Vanilla SlimeBlock.stepOn: reduces horizontal speed when walking on slime blocks.
/// Triggered when vertical velocity is small and player is not sneaking.
/// </summary>
private void ApplySlimeStepOn(World world)
{
if (!OnGround) return;
Location belowFeet = new(Position.X, Position.Y - 0.5000010, Position.Z);
if (world.GetBlock(belowFeet).Type != Material.SlimeBlock) return;
double absDeltaY = Math.Abs(DeltaMovement.Y);
if (absDeltaY >= 0.1 || Sneaking) return;
double scale = 0.4 + absDeltaY * 0.2;
DeltaMovement = DeltaMovement.Multiply(scale, 1.0, scale);
DebugLog?.Invoke($"[Physics] Slime stepOn slowdown: scale={scale:F3}, vel={DeltaMovement}");
}
/// <summary>
/// Get friction value for a material. Default 0.6, special blocks differ.
/// </summary>
@ -549,10 +634,84 @@ namespace MinecraftClient.Physics
InWater = feetBlock == Material.Water || headBlock == Material.Water
|| feetBlock == Material.BubbleColumn;
IsUnderWater = headBlock == Material.Water;
InLava = feetBlock == Material.Lava || headBlock == Material.Lava;
OnClimbable = feetBlock.CanBeClimbedOn();
}
// ==================== Pose System ====================
/// <summary>
/// Vanilla Player.updatePlayerPose().
/// Determines the correct pose based on player state and space constraints.
/// Forces crawling (Swimming pose on land) when standing/crouching does not fit.
/// </summary>
private void UpdatePlayerPose(World world)
{
EntityPose desired = GetDesiredPose();
EntityPose actual;
if (CanPlayerFitWithPose(world, EntityPose.Swimming))
{
if (CanPlayerFitWithPose(world, desired))
actual = desired;
else if (CanPlayerFitWithPose(world, EntityPose.Sneaking))
actual = EntityPose.Sneaking;
else
actual = EntityPose.Swimming;
}
else
{
actual = desired;
}
if (actual != previousPose)
{
DebugLog?.Invoke($"[Physics] Pose: {previousPose} -> {actual} (desired={desired}, " +
$"height={GetHeightForPose(actual):F1}, pos={Position})");
previousPose = actual;
}
CurrentPose = actual;
}
/// <summary>
/// Vanilla Player.getDesiredPose() -- determines what pose the player wants.
/// </summary>
private EntityPose GetDesiredPose()
{
if (IsSwimming())
return EntityPose.Swimming;
if (Sneaking && !CreativeFlying)
return EntityPose.Sneaking;
return EntityPose.Standing;
}
/// <summary>
/// Vanilla Entity.isSwimming() for players: sprinting underwater and not flying.
/// </summary>
private bool IsSwimming() => !CreativeFlying && Sprinting && IsUnderWater;
/// <summary>
/// Check if the player can fit at current position with the given pose's dimensions.
/// Vanilla Player.canPlayerFitWithinBlocksAndEntitiesWhen(Pose).
/// </summary>
private bool CanPlayerFitWithPose(World world, EntityPose pose)
{
double height = GetHeightForPose(pose);
Aabb box = Aabb.OfSize(Position.X, Position.Y, Position.Z, PlayerWidth, height);
Aabb deflated = box.Deflate(1.0E-7, 1.0E-7, 1.0E-7);
return CollisionDetector.NoCollision(world, deflated);
}
private static double GetHeightForPose(EntityPose pose) => pose switch
{
EntityPose.Sneaking => PhysicsConsts.PlayerCrouchingHeight,
EntityPose.Swimming or EntityPose.FallFlying or EntityPose.SpinAttack
=> PhysicsConsts.PlayerSwimmingHeight,
_ => PhysicsConsts.PlayerStandingHeight
};
/// <summary>
/// Set position from server teleport / initial spawn.
/// </summary>