Phase L.4 closes the "stuck in falling animation on a steep roof" bug
the user reported on 2026-04-30 ("I jump up, I land on it. It should not
even let me land, should just slide with a falling animation"). After
this commit the body no longer sticks to a steep roof when jumping
into it — it slides along the slope while keeping the falling animation.
Two pieces:
1. BSPQuery Path 6 steep-poly slide
When an airborne sphere hits a polygon whose world normal Z is below
FloorZ (≈ 0.6642, slope > ~49°), the previous flow was:
Path 6 SetCollide → Path 4 set_walkable → ContactPlane committed →
body "lands" on the steep poly with Contact bit + falling animation.
This left the player stuck mid-slope because OnWalkable was cleared
but Contact stayed set.
The new branch detects the steep normal in Path 6 BEFORE SetCollide
is called. Instead of entering the landing path, it removes the
into-wall component of the move (project onto the steep face), sets
CollisionNormal + SlidingNormal, and returns Slid. Same shape as
Path 5's step-up fallback and CylinderCollision. The resolver retries;
the sphere is now outside the poly; FindCollisions returns OK;
ValidateTransition commits the slid position. ContactPlane is never
set, so the body stays airborne with falling animation.
2. PlayerMovementController L.3a-bounce carve-out + Inelastic stop
Re-enables the velocity-reflection bounce when the contact normal is
upward-facing but steeper than walkable (0 < N.Z < FloorZ). The base
L.3a rule suppresses bounce on landing transitions to avoid micro-
bounce on flat terrain; that suppression also stuck the player to
too-steep roofs they shouldn't land on. This carve-out re-enables
the reflection specifically for the steep upward case.
Also lands related L.2c precipice / edge-slide work that was in flight:
- TransitionTypes EdgeSlideAfterStepDownFailed: walkable-poly-steep
cliff route + steep-ContactPlane cliff route ordering, so that
CliffSlide fires when the stored walkable polygon itself is too
steep (Path 4 had previously accepted it as a "landing" via the
permissive LandingZ threshold).
- CliffSlide reference-normal selection: prefer LastWalkable, fall back
to LastKnownContactPlane only when walkable, else use world-up. This
prevents the cross(steepN, steepN) = 0 degenerate case that left the
cliff slide as a no-op when both current and last-known were steep.
- Phase 2 / step-down branch / edge-slide branch / cliff-slide
diagnostic helpers gated on ACDREAM_DUMP_EDGE_SLIDE / ACDREAM_DUMP_STEEP_ROOF.
- Two new airborne-mover regression tests in BSPStepUpTests +
PhysicsEngineTests covering wall-slide and edge tangent motion.
DEVIATION FROM RETAIL — DOCUMENTED FOR FOLLOW-UP
The Path 6 steep slide is NOT what retail does. Retail's flow on the
same hit is:
Path 6 SetCollide (no steep check) → Path 4 find_walkable returns
nothing for steep → Phase 3 reset path: restore_check_pos +
kill_velocity → return COLLIDED → validate_transition reverts CheckPos
to CurPos and forces OK.
Net retail behavior: position reverts to pre-failed-move (typically
just below the roof in the common jump-up case), velocity zeroed,
gravity rebuilds Z next frame, body falls back down naturally with
the falling animation. The "freeze" framing I used earlier was wrong;
in the typical case retail just bounces the body off and lets gravity
take over.
Strict retail behavior would match the user's intent better in the
common case AND avoid the bounce-energy-accumulation we saw with the
slide-tangent approach (V grew to ~50 m/s in continuous-contact frames).
However, retail's behavior degenerates in the edge case of an overhead
landing onto a steep slope (body would freeze mid-air above the roof).
This commit ships the slide-tangent fix as an interim "much better"
state per user verification on 2026-04-30. Follow-up work to match
retail strictly: revert Path 6 steep-slide, audit Phase 3 reset to
ensure kill_velocity (matching OBJECTINFO::kill_velocity ->
CPhysicsObj::set_velocity({0,0,0}, 0)) actually fires, and re-test.
Refs:
- acclient_2013_pseudo_c.txt:323784-323821 (Path 6 SetCollide)
- acclient_2013_pseudo_c.txt:273191-273239 (Phase 3 reset path)
- acclient_2013_pseudo_c.txt:272563-272596 (validate_transition revert)
- acclient_2013_pseudo_c.txt:274467-274475 (kill_velocity)
- acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions bounce)
Tests: 833/833 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
786 lines
36 KiB
C#
786 lines
36 KiB
C#
using System;
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
|
||
namespace AcDream.App.Input;
|
||
|
||
/// <summary>
|
||
/// Input state for a single frame of player movement.
|
||
/// </summary>
|
||
public readonly record struct MovementInput(
|
||
bool Forward = false,
|
||
bool Backward = false,
|
||
bool StrafeLeft = false,
|
||
bool StrafeRight = false,
|
||
bool TurnLeft = false,
|
||
bool TurnRight = false,
|
||
bool Run = false,
|
||
float MouseDeltaX = 0f,
|
||
bool Jump = false);
|
||
|
||
/// <summary>
|
||
/// Result of a single frame's movement update.
|
||
///
|
||
/// <para>
|
||
/// <b>Wire vs. local animation command.</b> ACE's <c>MovementData</c>
|
||
/// (<c>ACE.Server/Network/Motion/MovementData.cs</c>) only computes
|
||
/// <c>interpState.ForwardSpeed</c> for raw <c>WalkForward</c>/
|
||
/// <c>WalkBackwards</c> — on every other command the <c>else</c> branch
|
||
/// passes through command without setting speed, leaving observers with
|
||
/// <c>speed=0</c>. The client therefore has to send <c>WalkForward</c>
|
||
/// (with <c>HoldKey.Run</c> for running) and let ACE auto-upgrade to
|
||
/// <c>RunForward</c> for broadcast. But the LOCAL view wants the run
|
||
/// cycle immediately, so we carry a separate
|
||
/// <see cref="LocalAnimationCommand"/> for the player's own renderer.
|
||
/// </para>
|
||
/// <para>
|
||
/// <see cref="IsRunning"/> — true when the player is holding Shift to run.
|
||
/// Used by the GameWindow when building the outbound MoveToState's
|
||
/// CURRENT_HOLD_KEY (2=Run) vs (1=None).
|
||
/// </para>
|
||
/// </summary>
|
||
public readonly record struct MovementResult(
|
||
Vector3 Position,
|
||
uint CellId,
|
||
bool IsOnGround,
|
||
bool MotionStateChanged,
|
||
uint? ForwardCommand, // wire-side command (WalkForward / WalkBackward / …)
|
||
uint? SidestepCommand,
|
||
uint? TurnCommand,
|
||
float? ForwardSpeed,
|
||
float? SidestepSpeed,
|
||
float? TurnSpeed,
|
||
bool IsRunning = false,
|
||
uint? LocalAnimationCommand = null, // which cycle to play on the local player (RunForward when running)
|
||
// K-fix5 (2026-04-26): cycle-pace multiplier for the LOCAL animation
|
||
// sequencer. Decoupled from ForwardSpeed so the wire can keep sending
|
||
// 1.0 for WalkBackward (ACE-compatible) while the animation plays at
|
||
// runRate × so the cycle visually matches the run-speed velocity.
|
||
// Forward+Run = runRate (same as ForwardSpeed); Backward+Run, Strafe+Run
|
||
// = runRate (where ForwardSpeed is 1.0 / null); everything else = 1.0.
|
||
float LocalAnimationSpeed = 1f,
|
||
bool JustLanded = false, // true on the single frame we transitioned airborne → grounded
|
||
float? JumpExtent = null, // non-null when a jump was triggered this frame
|
||
Vector3? JumpVelocity = null); // world-space launch velocity (sent in jump packet)
|
||
|
||
/// <summary>
|
||
/// Portal-space state for the player movement controller.
|
||
/// PortalSpace freezes all movement input while the server is moving the
|
||
/// player through a portal — resumed once the destination UpdatePosition
|
||
/// arrives and the player is snapped to the new location.
|
||
/// While in PortalSpace, Update returns immediately with a zero-movement
|
||
/// result so no WASD input or physics is processed.
|
||
/// </summary>
|
||
public enum PlayerState { InWorld, PortalSpace }
|
||
|
||
/// <summary>
|
||
/// Per-frame player movement controller. Reads input, drives the
|
||
/// ported PhysicsBody + MotionInterpreter, tracks motion state for
|
||
/// animation + server messages.
|
||
///
|
||
/// Architecture:
|
||
/// - PhysicsBody owns integration: gravity, friction, sub-stepping,
|
||
/// velocity clamping — all from the decompiled retail client.
|
||
/// - MotionInterpreter owns the motion state machine: walk/run/jump
|
||
/// validation, state tracking, speed constants from the retail dat.
|
||
/// - PhysicsEngine.Resolve is still used each frame to snap the player
|
||
/// to terrain/cell floor Z and detect ground contact.
|
||
/// </summary>
|
||
public sealed class PlayerMovementController
|
||
{
|
||
private readonly PhysicsEngine _physics;
|
||
private readonly PhysicsBody _body;
|
||
private readonly MotionInterpreter _motion;
|
||
private readonly PlayerWeenie _weenie;
|
||
|
||
public float MouseTurnSensitivity { get; set; } = 0.003f;
|
||
|
||
/// <summary>
|
||
/// Maximum Z increase per movement step before the move is rejected.
|
||
/// Retail's <c>step_up_height</c> for human characters is ~0.4 m (hip-
|
||
/// level). Setting this too high lets the player teleport up small
|
||
/// buildings via the step-up scan finding any walkable polygon within
|
||
/// reach (Bug 3 in L.2.3 testing — walking into a steep slope mounted
|
||
/// the building's flat top instead of sliding off the slope).
|
||
/// Authoritative source is the player's <c>Setup.StepUpHeight</c> set
|
||
/// in GameWindow.cs at world-entry time.
|
||
/// </summary>
|
||
public float StepUpHeight { get; set; } = 0.4f;
|
||
|
||
/// <summary>
|
||
/// L.2.3a (2026-04-29): how far below the foot the step-down probe
|
||
/// reaches when transitioning between surfaces. Retail's
|
||
/// <c>step_down_height</c> for human characters is ~0.4 m. With the
|
||
/// previous 4 cm hardcoded value, walking off the top of a stair onto
|
||
/// the ground 25 cm below produced a one-frame contact-plane gap — the
|
||
/// animation system briefly flickered to falling.
|
||
/// </summary>
|
||
public float StepDownHeight { get; set; } = 0.4f;
|
||
|
||
/// <summary>
|
||
/// Current portal-space state. Set to PortalSpace when the server sends
|
||
/// PlayerTeleport (0xF751); set back to InWorld once the destination
|
||
/// UpdatePosition arrives and the player is snapped to the new cell.
|
||
/// While in PortalSpace, Update returns immediately with a zero-movement
|
||
/// result so no WASD input or physics is processed.
|
||
/// </summary>
|
||
public PlayerState State { get; set; } = PlayerState.InWorld;
|
||
|
||
public float Yaw { get; set; }
|
||
public Vector3 Position => _body.Position;
|
||
public uint CellId { get; private set; }
|
||
|
||
public bool IsAirborne => !_body.OnWalkable;
|
||
|
||
/// <summary>
|
||
/// Current vertical (Z-axis) velocity of the physics body.
|
||
/// Positive = rising, negative = falling. Exposed for tests and HUD.
|
||
/// </summary>
|
||
public float VerticalVelocity => _body.Velocity.Z;
|
||
|
||
/// <summary>Full 3D world-space velocity of the physics body. Exposed for diagnostic logging.</summary>
|
||
public Vector3 BodyVelocity => _body.Velocity;
|
||
|
||
// Jump charge state.
|
||
private bool _jumpCharging;
|
||
private float _jumpExtent;
|
||
// K-fix6 (2026-04-26): retail's PowerBar charge constant for jump is
|
||
// not legible in the named decomp (the divisor was clobbered in
|
||
// GetPowerBarLevel's FPU stack reordering at FUN_0056ade0). 2.0/s
|
||
// (full charge in 0.5s) feels matches retail muscle memory better
|
||
// than the previous 1.0/s — a tap gives a noticeable hop, half-hold
|
||
// a meaningful jump, full-hold the maximum extent. The vertical
|
||
// velocity formula itself (height × 19.6 → vz) is unchanged and
|
||
// matches retail byte-for-byte; only the time-to-fill is faster.
|
||
private const float JumpChargeRate = 2.0f;
|
||
|
||
// Airborne → grounded transition detection. Flipped on every frame where
|
||
// the body transitions from airborne to on-walkable; used by the GameWindow
|
||
// to drive the landing animation cycle.
|
||
private bool _wasAirborneLastFrame;
|
||
|
||
// Previous frame's motion commands for change detection.
|
||
private uint? _prevForwardCmd;
|
||
private uint? _prevSidestepCmd;
|
||
private uint? _prevTurnCmd;
|
||
private float? _prevForwardSpeed;
|
||
private bool _prevRunHold;
|
||
private uint? _prevLocalAnimCmd;
|
||
|
||
// Heartbeat timer.
|
||
private float _heartbeatAccum;
|
||
public const float HeartbeatInterval = 0.2f; // 200ms
|
||
public bool HeartbeatDue { get; private set; }
|
||
|
||
public PlayerMovementController(PhysicsEngine physics)
|
||
{
|
||
_physics = physics;
|
||
|
||
_body = new PhysicsBody
|
||
{
|
||
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
|
||
};
|
||
|
||
// Default skills — tuned toward mid-retail feel. Real characters'
|
||
// skills come from PlayerDescription (0xF7B0/0x0013) which we don't
|
||
// parse yet; override via env vars:
|
||
// ACDREAM_RUN_SKILL, ACDREAM_JUMP_SKILL
|
||
// K-fix6 (2026-04-26): bumped default jump skill from 200 → 300.
|
||
// Retail formula: height = (skill/(skill+1300))*22.2 + 0.05 (extent=1):
|
||
// skill=200 → 3.01m max (felt too low — user complaint)
|
||
// skill=300 → 4.21m max (closer to a typical retail mid-tier
|
||
// character's "I can clear that fence" hop)
|
||
// Until #7 ships and PlayerDescription gives us the server's real
|
||
// skill, this default is the right "feels like retail" baseline.
|
||
int runSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_RUN_SKILL"), out var rs) ? rs : 200;
|
||
int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 300;
|
||
_weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill);
|
||
_motion = new MotionInterpreter(_body, _weenie);
|
||
}
|
||
|
||
public void SetCharacterSkills(int runSkill, int jumpSkill)
|
||
{
|
||
_weenie.SetSkills(runSkill, jumpSkill);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Wire the player's AnimationSequencer current cycle velocity into
|
||
/// <see cref="MotionInterpreter.GetCycleVelocity"/>. When attached,
|
||
/// <c>get_state_velocity</c> uses <c>MotionData.Velocity * speedMod</c>
|
||
/// as the primary forward-axis drive, keeping the body's world velocity
|
||
/// locked to the animation's baked-in root-motion velocity.
|
||
///
|
||
/// <para>
|
||
/// Without this accessor, the decompiled constant path
|
||
/// (<c>RunAnimSpeed * ForwardSpeed</c>) is used — matches retail only
|
||
/// when the character's MotionTable happens to bake Velocity=4.0 on
|
||
/// RunForward, which is true for Humanoid but not for arbitrary
|
||
/// creatures. See <see cref="MotionInterpreter.GetCycleVelocity"/>
|
||
/// for the full rationale.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Called once from <c>GameWindow.CreateAnimatedEntity</c> after the
|
||
/// player's <c>AnimatedEntity.Sequencer</c> is constructed.
|
||
/// </para>
|
||
/// </summary>
|
||
public void AttachCycleVelocityAccessor(Func<Vector3> accessor)
|
||
{
|
||
if (accessor is null) throw new ArgumentNullException(nameof(accessor));
|
||
_motion.GetCycleVelocity = accessor;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Apply a server-echoed run rate (ForwardSpeed from UpdateMotion) to the
|
||
/// player's MotionInterpreter. The server broadcasts the real RunRate
|
||
/// derived from the character's Run skill; wiring it here ensures
|
||
/// get_state_velocity produces the correct speed instead of the default 1.0.
|
||
/// </summary>
|
||
public void ApplyServerRunRate(float forwardSpeed)
|
||
{
|
||
_motion.InterpretedState.ForwardSpeed = forwardSpeed;
|
||
_motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||
}
|
||
|
||
public void SetPosition(Vector3 pos, uint cellId)
|
||
{
|
||
_body.Position = pos;
|
||
CellId = cellId;
|
||
|
||
// Treat as grounded after a server-side position snap.
|
||
_body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||
_body.Velocity = Vector3.Zero;
|
||
|
||
// Reset physics clock so any subsequent update_object calls start fresh.
|
||
_body.LastUpdateTime = 0.0;
|
||
}
|
||
|
||
public MovementResult Update(float dt, MovementInput input)
|
||
{
|
||
// Portal-space guard: while teleporting, no input is processed and
|
||
// no physics is resolved. Return a zero-movement result so the caller
|
||
// can detect the frozen state (MotionStateChanged = false, no commands).
|
||
if (State == PlayerState.PortalSpace)
|
||
{
|
||
return new MovementResult(
|
||
Position: Position,
|
||
CellId: CellId,
|
||
IsOnGround: _body.OnWalkable,
|
||
MotionStateChanged: false,
|
||
ForwardCommand: null,
|
||
SidestepCommand: null,
|
||
TurnCommand: null,
|
||
ForwardSpeed: null,
|
||
SidestepSpeed: null,
|
||
TurnSpeed: null);
|
||
}
|
||
|
||
// ── 1. Apply turning from keyboard + mouse ────────────────────────────
|
||
if (input.TurnRight)
|
||
Yaw -= MotionInterpreter.WalkAnimSpeed * 0.5f * dt; // ~90°/s
|
||
if (input.TurnLeft)
|
||
Yaw += MotionInterpreter.WalkAnimSpeed * 0.5f * dt;
|
||
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
|
||
// Wrap yaw to [-PI, PI] so it doesn't grow unbounded.
|
||
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
|
||
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
|
||
|
||
// Sync the body's orientation quaternion with our Yaw (rotation about Z).
|
||
// Convention: Yaw=0 faces +X. Local body +Y is "forward", so we rotate
|
||
// by (Yaw - PI/2) about Z to map local +Y → world (cos Yaw, sin Yaw, 0).
|
||
_body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f);
|
||
|
||
// ── 2. Set velocity via MotionInterpreter state machine ───────────────
|
||
// Determine the dominant forward/backward command and speed.
|
||
uint forwardCmd;
|
||
float forwardCmdSpeed;
|
||
if (input.Forward)
|
||
{
|
||
forwardCmd = input.Run ? MotionCommand.RunForward : MotionCommand.WalkForward;
|
||
// When running, use the PlayerWeenie's RunRate as ForwardSpeed.
|
||
// The retail server computes this from Run skill + encumbrance and
|
||
// broadcasts it in UpdateMotion, but it doesn't echo to the sender.
|
||
// We compute locally using the same formula.
|
||
if (input.Run && _weenie.InqRunRate(out float runRate))
|
||
forwardCmdSpeed = runRate;
|
||
else
|
||
forwardCmdSpeed = 1.0f;
|
||
}
|
||
else if (input.Backward)
|
||
{
|
||
forwardCmd = MotionCommand.WalkBackward;
|
||
// K-fix3 (2026-04-26): backward also honors Run. Without
|
||
// this, holding X with Run=true (default) still produced
|
||
// walk-tier backward speed because forwardCmdSpeed was
|
||
// hardcoded to 1.0. Now scale by runRate the same way
|
||
// RunForward does.
|
||
if (input.Run && _weenie.InqRunRate(out float runRateBack))
|
||
forwardCmdSpeed = runRateBack;
|
||
else
|
||
forwardCmdSpeed = 1.0f;
|
||
}
|
||
else
|
||
{
|
||
forwardCmd = MotionCommand.Ready;
|
||
forwardCmdSpeed = 1.0f;
|
||
}
|
||
|
||
// Update interpreted motion state (needed for animation + server messages).
|
||
_motion.DoMotion(forwardCmd, forwardCmdSpeed);
|
||
|
||
// Sidestep.
|
||
if (input.StrafeRight)
|
||
_motion.DoInterpretedMotion(MotionCommand.SideStepRight, 1.0f, modifyInterpretedState: true);
|
||
else if (input.StrafeLeft)
|
||
_motion.DoInterpretedMotion(MotionCommand.SideStepLeft, 1.0f, modifyInterpretedState: true);
|
||
else
|
||
{
|
||
_motion.StopInterpretedMotion(MotionCommand.SideStepRight, modifyInterpretedState: true);
|
||
_motion.StopInterpretedMotion(MotionCommand.SideStepLeft, modifyInterpretedState: true);
|
||
}
|
||
|
||
// Only replace velocity with motion interpreter output when grounded.
|
||
// While airborne, the physics body's integrated velocity (from LeaveGround)
|
||
// persists — gravity pulls Z down, horizontal momentum is preserved.
|
||
// Retail AC works this way: you maintain momentum in the air.
|
||
if (_body.OnWalkable)
|
||
{
|
||
float savedWorldVz = _body.Velocity.Z;
|
||
var stateVel = _motion.get_state_velocity();
|
||
|
||
float localY = 0f;
|
||
float localX = 0f;
|
||
|
||
// K-fix3 (2026-04-26): unified run-multiplier for backward
|
||
// + strafe. Forward already scales correctly because it uses
|
||
// stateVel.Y (which the motion state machine fed runRate
|
||
// into via DoMotion). Backward + strafe bypass the state
|
||
// machine and hardcoded speed; previously they capped at
|
||
// walk speed regardless of Run, which made the ~2.4×
|
||
// forward-vs-back/strafe ratio feel wrong. Now both scale
|
||
// with the same runRate the forward branch uses.
|
||
float runMul = 1.0f;
|
||
if (input.Run && _weenie.InqRunRate(out float vrr))
|
||
runMul = vrr;
|
||
|
||
if (input.Forward)
|
||
localY = stateVel.Y;
|
||
else if (input.Backward)
|
||
localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f * runMul);
|
||
|
||
// Strafe scales with the same runMul so sidestep matches
|
||
// the forward pace at run speed (retail uses speed=1.0 for
|
||
// SideStep + the same hold-key-driven run/walk multiplier).
|
||
if (input.StrafeRight)
|
||
localX = MotionInterpreter.SidestepAnimSpeed * runMul;
|
||
else if (input.StrafeLeft)
|
||
localX = -MotionInterpreter.SidestepAnimSpeed * runMul;
|
||
|
||
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
|
||
}
|
||
|
||
// ── 3. Jump (charged) ─────────────────────────────────────────────────
|
||
// Hold spacebar to charge (0→1 over JumpChargeRate seconds).
|
||
// Release to execute: jump(extent) validates + sets JumpExtent,
|
||
// then LeaveGround() applies the scaled velocity via get_leave_ground_velocity.
|
||
float? outJumpExtent = null;
|
||
Vector3? outJumpVelocity = null;
|
||
|
||
if (input.Jump && _body.OnWalkable)
|
||
{
|
||
// Spacebar held and on the ground — accumulate charge.
|
||
if (!_jumpCharging)
|
||
{
|
||
_jumpCharging = true;
|
||
_jumpExtent = 0f;
|
||
}
|
||
_jumpExtent = MathF.Min(_jumpExtent + dt * JumpChargeRate, 1.0f);
|
||
}
|
||
else if (_jumpCharging)
|
||
{
|
||
// Spacebar released (or left ground during charge) — fire jump.
|
||
var jumpResult = _motion.jump(_jumpExtent);
|
||
if (jumpResult == WeenieError.None)
|
||
{
|
||
_motion.LeaveGround();
|
||
outJumpExtent = _jumpExtent;
|
||
outJumpVelocity = _body.Velocity; // capture after LeaveGround applies it
|
||
}
|
||
_jumpCharging = false;
|
||
_jumpExtent = 0f;
|
||
}
|
||
|
||
// ── 4. Integrate physics (gravity, friction, sub-stepping) ────────────
|
||
var preIntegratePos = _body.Position;
|
||
_body.calc_acceleration();
|
||
_body.UpdatePhysicsInternal(dt);
|
||
var postIntegratePos = _body.Position;
|
||
|
||
// ── 5. Collision resolution via CTransition sphere-sweep ─────────────
|
||
// The Transition system subdivides the movement from pre→post into
|
||
// sphere-radius steps, testing terrain collision at each step.
|
||
// Falls back to simple Z-snap if transition fails.
|
||
var resolveResult = _physics.ResolveWithTransition(
|
||
preIntegratePos, postIntegratePos, CellId,
|
||
sphereRadius: 0.48f, // human player radius from Setup
|
||
sphereHeight: 1.2f, // human player height from Setup
|
||
stepUpHeight: StepUpHeight,
|
||
stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight
|
||
isOnGround: _body.OnWalkable,
|
||
body: _body, // persist ContactPlane across frames for slope tracking
|
||
// L.2c 2026-04-30: retail PhysicsGlobals.DefaultState includes
|
||
// EdgeSlide, and PhysicsObj.get_object_info copies that bit into
|
||
// OBJECTINFO. Keep it explicit here so edge/cliff handling runs
|
||
// under the same flag profile as retail player movement.
|
||
//
|
||
// Commit C 2026-04-29 — local player is always IsPlayer.
|
||
// The PK/PKLite/Impenetrable bits come from PlayerDescription's
|
||
// PlayerKillerStatus property; not yet parsed (non-PK pair → walks
|
||
// through other non-PK players, which is retail's default for
|
||
// ACE's character creation defaults too).
|
||
moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer
|
||
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide);
|
||
|
||
// L.4-diag (2026-04-30): trace position transitions so we can see
|
||
// whether the body is actually moving frame-to-frame on the steep
|
||
// roof, or whether it's frozen at the impact point.
|
||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1"
|
||
&& resolveResult.CollisionNormalValid)
|
||
{
|
||
Console.WriteLine(
|
||
$"[steep-roof] FRAME pre=({preIntegratePos.X:F2},{preIntegratePos.Y:F2},{preIntegratePos.Z:F2}) " +
|
||
$"post=({postIntegratePos.X:F2},{postIntegratePos.Y:F2},{postIntegratePos.Z:F2}) " +
|
||
$"resolved=({resolveResult.Position.X:F2},{resolveResult.Position.Y:F2},{resolveResult.Position.Z:F2}) " +
|
||
$"isOnGround={resolveResult.IsOnGround}");
|
||
}
|
||
|
||
// Apply resolved position.
|
||
_body.Position = resolveResult.Position;
|
||
|
||
// L.3a (2026-04-30): retail wall-bounce / velocity reflection.
|
||
//
|
||
// Retail's CPhysicsObj::handle_all_collisions runs after every
|
||
// SetPositionInternal. It reads the wall normal that the
|
||
// transition's slide computed and reflects the body's velocity:
|
||
//
|
||
// v_new = v - (1 + elasticity) * dot(v, n) * n
|
||
//
|
||
// This is what gives retail its "bouncy" feel — fast head-on
|
||
// jumps push the player back from the wall, glancing angles
|
||
// produce a small deflection. acdream's transition resolver
|
||
// SLID position correctly but never updated velocity, so the
|
||
// player kept driving into walls until the controller's input
|
||
// changed direction. Felt sticky / fragile.
|
||
//
|
||
// Suppression rule (apply_bounce): grounded movement on a wall
|
||
// SHOULDN'T bounce — sliding along a corridor is expected. Only
|
||
// airborne wall hits reflect. Mirrors retail's `var_10_1` guard
|
||
// and ACE PhysicsObj.cs:2656-2660 `apply_bounce`.
|
||
//
|
||
// Inelastic flag (spell projectiles, missiles) zeros velocity
|
||
// entirely instead of reflecting. The player never has it set.
|
||
//
|
||
// Sources:
|
||
// acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions)
|
||
// acclient.h:2834 (INELASTIC_PS = 0x20000)
|
||
// ACE PhysicsObj.cs:2656-2721 (line-for-line port)
|
||
// PhysicsGlobals.DefaultElasticity = 0.05f, MaxElasticity = 0.1f
|
||
if (resolveResult.CollisionNormalValid)
|
||
{
|
||
bool prevOnWalkable = _body.OnWalkable;
|
||
bool nowOnWalkable = resolveResult.IsOnGround;
|
||
|
||
// apply_bounce: bounce ONLY when the body stays airborne both
|
||
// before and after this step. That is: jumping into a wall
|
||
// mid-flight, hitting a ceiling, etc. Specifically NOT:
|
||
//
|
||
// - prev grounded + now grounded → wall-slide along corridor
|
||
// (bounce would feel sticky on every wall touch).
|
||
// - prev airborne + now grounded → terrain landing
|
||
// (terrain normal is mostly +Z; reflecting downward velocity
|
||
// would push the body upward and prevent the landing snap
|
||
// from firing — player perpetually micro-bouncing on the
|
||
// floor instead of resting).
|
||
// - prev grounded + now airborne → walked off cliff
|
||
// (gravity should take over, not lateral bounce).
|
||
//
|
||
// Sledding mode reverts to retail's broader rule (bounce
|
||
// unless both grounded), since sledding intentionally bounces
|
||
// off ramps.
|
||
//
|
||
// This is more conservative than retail's strict
|
||
// `!(prev && now && !sledding)` rule — retail bounces on
|
||
// landing too, but at elasticity 0.05 the visual effect is
|
||
// imperceptible there. acdream's per-frame architecture
|
||
// amplifies the artifact (the post-reflection upward Z
|
||
// defeats the controller's `Velocity.Z <= 0` landing-snap
|
||
// gate), so we suppress it on landing to avoid the
|
||
// micro-bounce death spiral.
|
||
bool applyBounce = _body.State.HasFlag(PhysicsStateFlags.Sledding)
|
||
? !(prevOnWalkable && nowOnWalkable)
|
||
: (!prevOnWalkable && !nowOnWalkable);
|
||
|
||
// L.4-steep-landing-bounce (2026-04-30): also bounce on
|
||
// landing IF the contact surface is upward-facing but
|
||
// steeper than walkable (FloorZ ≈ 49°). Per retail and the
|
||
// user's expectation: jumping onto a steep roof should NOT
|
||
// result in a landing — the player should bounce off, keep
|
||
// the falling animation, and slide off.
|
||
//
|
||
// Without this: the L.3a base rule suppresses the bounce on
|
||
// landing transitions (prev air → now ground) to avoid
|
||
// micro-bounce on flat terrain, but that suppression also
|
||
// sticks the player to too-steep roofs they shouldn't land
|
||
// on. This carve-out re-enables the bounce specifically for
|
||
// steep upward-facing surfaces.
|
||
//
|
||
// Range `0 < N.Z < FloorZ` means "facing upward but too
|
||
// steep" — excludes walls (N.Z ≈ 0) which are handled by the
|
||
// existing prevAirborne+nowAirborne rule, and ceilings
|
||
// (N.Z < 0) which the body shouldn't bounce off the same way.
|
||
if (!applyBounce
|
||
&& resolveResult.CollisionNormalValid
|
||
&& resolveResult.CollisionNormal.Z > 0f
|
||
&& resolveResult.CollisionNormal.Z < PhysicsGlobals.FloorZ)
|
||
{
|
||
applyBounce = true;
|
||
}
|
||
|
||
// L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug.
|
||
bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
|
||
if (diagSteep && resolveResult.CollisionNormalValid)
|
||
{
|
||
var n0 = resolveResult.CollisionNormal;
|
||
var v0 = _body.Velocity;
|
||
Console.WriteLine(
|
||
$"[steep-roof] BOUNCE-CHECK applyBounce={applyBounce} " +
|
||
$"prevWalk={prevOnWalkable} nowWalk={nowOnWalkable} " +
|
||
$"N=({n0.X:F2},{n0.Y:F2},{n0.Z:F2}) FloorZ={PhysicsGlobals.FloorZ:F2} " +
|
||
$"V=({v0.X:F2},{v0.Y:F2},{v0.Z:F2}) " +
|
||
$"dot={Vector3.Dot(v0, n0):F3} " +
|
||
$"isOnGround={resolveResult.IsOnGround}");
|
||
}
|
||
|
||
if (applyBounce)
|
||
{
|
||
if (_body.State.HasFlag(PhysicsStateFlags.Inelastic))
|
||
{
|
||
// Full stop on impact. Spell projectiles / missiles.
|
||
_body.Velocity = Vector3.Zero;
|
||
}
|
||
else
|
||
{
|
||
var v = _body.Velocity;
|
||
var n = resolveResult.CollisionNormal;
|
||
float dotVN = Vector3.Dot(v, n);
|
||
if (dotVN < 0f)
|
||
{
|
||
// Reflect the into-wall component back out.
|
||
// Player elasticity is 0.05 → 105% of perpendicular
|
||
// velocity reflects (subtle bounce).
|
||
float k = -(dotVN * (_body.Elasticity + 1f));
|
||
_body.Velocity = v + n * k;
|
||
|
||
if (diagSteep)
|
||
{
|
||
var v1 = _body.Velocity;
|
||
Console.WriteLine(
|
||
$"[steep-roof] BOUNCE-APPLIED V_after=({v1.X:F2},{v1.Y:F2},{v1.Z:F2}) k={k:F3}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
bool justLanded = false;
|
||
if (resolveResult.IsOnGround)
|
||
{
|
||
if (_body.Velocity.Z <= 0f)
|
||
{
|
||
// Grounded — snap to resolved position and land.
|
||
bool wasAirborne = !_body.OnWalkable;
|
||
_body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||
_body.calc_acceleration();
|
||
|
||
if (_body.Velocity.Z < 0f)
|
||
_body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f);
|
||
|
||
if (wasAirborne)
|
||
{
|
||
_motion.HitGround();
|
||
justLanded = true;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Moving upward (jump) — stay airborne even though terrain is below.
|
||
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
|
||
_body.calc_acceleration();
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// No ground found — airborne.
|
||
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
|
||
_body.calc_acceleration();
|
||
}
|
||
|
||
|
||
_wasAirborneLastFrame = !_body.OnWalkable;
|
||
CellId = resolveResult.CellId;
|
||
|
||
// ── 6. Determine outbound motion commands ─────────────────────────────
|
||
uint? outForwardCmd = null;
|
||
float? outForwardSpeed = null;
|
||
uint? outSidestepCmd = null;
|
||
float? outSidestepSpeed = null;
|
||
uint? outTurnCmd = null;
|
||
float? outTurnSpeed = null;
|
||
|
||
// Retail-faithful wire commands. ACE's MovementData constructor only
|
||
// computes interpState.ForwardSpeed for WalkForward / WalkBackwards
|
||
// (Network/Motion/MovementData.cs:104-119) — for any other command
|
||
// the else-branch passes through without setting speed, so observers
|
||
// dead-reckon at speed=0. The wire therefore must be:
|
||
// - Forward (walk): WalkForward @ 1.0
|
||
// - Forward (run): WalkForward @ run_rate + HoldKey.Run
|
||
// (ACE auto-upgrades to RunForward for observers)
|
||
// - Backward: WalkBackward @ 1.0
|
||
// Our own local animation still wants the actual RunForward cycle
|
||
// though — that's carried separately in LocalAnimationCommand below.
|
||
uint? localAnimCmd = null;
|
||
if (input.Forward)
|
||
{
|
||
outForwardCmd = MotionCommand.WalkForward;
|
||
if (input.Run && _weenie.InqRunRate(out float runRate))
|
||
{
|
||
outForwardSpeed = runRate;
|
||
localAnimCmd = MotionCommand.RunForward; // local cycle is RunForward
|
||
}
|
||
else
|
||
{
|
||
outForwardSpeed = 1.0f;
|
||
localAnimCmd = MotionCommand.WalkForward;
|
||
}
|
||
}
|
||
else if (input.Backward)
|
||
{
|
||
outForwardCmd = MotionCommand.WalkBackward;
|
||
outForwardSpeed = 1.0f;
|
||
localAnimCmd = MotionCommand.WalkBackward;
|
||
}
|
||
|
||
// Strafe: retail uses speed=1.0 for SideStep (see holtburger
|
||
// common.rs::locomotion_command_for_state). 0.5 was our earlier guess
|
||
// and made strafing feel lethargic; the retail feel is full-speed
|
||
// sidestep matching the walk forward pace.
|
||
if (input.StrafeRight)
|
||
{
|
||
outSidestepCmd = MotionCommand.SideStepRight;
|
||
outSidestepSpeed = 1.0f;
|
||
}
|
||
else if (input.StrafeLeft)
|
||
{
|
||
outSidestepCmd = MotionCommand.SideStepLeft;
|
||
outSidestepSpeed = 1.0f;
|
||
}
|
||
|
||
// Turn commands from KEYBOARD only (A/D). Mouse turning is applied
|
||
// directly to Yaw above and doesn't generate a turn command — if it
|
||
// did, mouse jitter would flip turnCmd between TurnRight/TurnLeft
|
||
// every frame, causing stateChanged=True on every frame and flooding
|
||
// the server with MoveToState spam.
|
||
if (input.TurnRight)
|
||
{
|
||
outTurnCmd = MotionCommand.TurnRight;
|
||
outTurnSpeed = 1.0f;
|
||
}
|
||
else if (input.TurnLeft)
|
||
{
|
||
outTurnCmd = MotionCommand.TurnLeft;
|
||
outTurnSpeed = 1.0f;
|
||
}
|
||
|
||
// ── 7. Detect motion state change ─────────────────────────────────────
|
||
// Bug fix: ForwardCommand can stay the same (WalkForward) while ONLY
|
||
// ForwardSpeed or the run-hold bit changes. If the user is already
|
||
// walking (W held), then presses Shift, the outbound wire still has
|
||
// ForwardCommand=WalkForward but outForwardSpeed jumps from 1.0 to
|
||
// runRate. Without also tracking speed + hold-key here, no new
|
||
// MoveToState is sent — the server keeps thinking the player walks,
|
||
// and retail observers render walking animation despite the local
|
||
// player's RunForward cycle.
|
||
//
|
||
// Similarly LocalAnimationCommand change (Walk→Run on local cycle)
|
||
// must force a fresh outbound so ACE's BroadcastMovement re-runs
|
||
// MovementData(this, moveToState) which only reads ForwardCommand +
|
||
// ForwardSpeed + HoldKey to pick between WalkForward vs RunForward
|
||
// for remote observers.
|
||
bool runHold = input.Run;
|
||
bool changed = outForwardCmd != _prevForwardCmd
|
||
|| outSidestepCmd != _prevSidestepCmd
|
||
|| outTurnCmd != _prevTurnCmd
|
||
|| !FloatsEqual(outForwardSpeed, _prevForwardSpeed)
|
||
|| runHold != _prevRunHold
|
||
|| localAnimCmd != _prevLocalAnimCmd;
|
||
_prevForwardCmd = outForwardCmd;
|
||
_prevSidestepCmd = outSidestepCmd;
|
||
_prevTurnCmd = outTurnCmd;
|
||
_prevForwardSpeed = outForwardSpeed;
|
||
_prevRunHold = runHold;
|
||
_prevLocalAnimCmd = localAnimCmd;
|
||
|
||
static bool FloatsEqual(float? a, float? b)
|
||
{
|
||
if (a.HasValue != b.HasValue) return false;
|
||
if (!a.HasValue || !b.HasValue) return true;
|
||
return System.Math.Abs(a.Value - b.Value) < 1e-4f;
|
||
}
|
||
|
||
// ── 8. Heartbeat timer (only while moving) ────────────────────────────
|
||
bool isMoving = outForwardCmd is not null
|
||
|| outSidestepCmd is not null
|
||
|| outTurnCmd is not null;
|
||
if (isMoving)
|
||
{
|
||
_heartbeatAccum += dt;
|
||
HeartbeatDue = _heartbeatAccum >= HeartbeatInterval;
|
||
if (HeartbeatDue) _heartbeatAccum = 0f;
|
||
}
|
||
else
|
||
{
|
||
_heartbeatAccum = 0f;
|
||
HeartbeatDue = false;
|
||
}
|
||
|
||
// K-fix5 (2026-04-26): local-animation-cycle pacing. Visual rate
|
||
// should match the actual movement speed. For Forward+Run this is
|
||
// already runRate (it equals ForwardSpeed). For Backward+Run and
|
||
// Strafe+Run it must be runRate too even though the wire keeps
|
||
// those at 1.0. Picking runMul (already computed above) keeps the
|
||
// math in one place.
|
||
bool anyDirectional = input.Forward || input.Backward
|
||
|| input.StrafeLeft || input.StrafeRight;
|
||
float localAnimSpeed = (input.Run && anyDirectional)
|
||
? (_weenie.InqRunRate(out float vrrAnim) ? vrrAnim : 1f)
|
||
: 1f;
|
||
|
||
return new MovementResult(
|
||
Position: Position,
|
||
CellId: CellId,
|
||
IsOnGround: _body.OnWalkable,
|
||
MotionStateChanged: changed,
|
||
ForwardCommand: outForwardCmd,
|
||
SidestepCommand: outSidestepCmd,
|
||
TurnCommand: outTurnCmd,
|
||
ForwardSpeed: outForwardSpeed,
|
||
SidestepSpeed: outSidestepSpeed,
|
||
TurnSpeed: outTurnSpeed,
|
||
IsRunning: input.Run && input.Forward,
|
||
LocalAnimationCommand: localAnimCmd,
|
||
LocalAnimationSpeed: localAnimSpeed,
|
||
JustLanded: justLanded,
|
||
JumpExtent: outJumpExtent,
|
||
JumpVelocity: outJumpVelocity);
|
||
}
|
||
}
|