feat(#182): route player collision response through the ported UpdateObjectInternal chain (Slice 2b/2c)
Replaces PlayerMovementController's ad-hoc airborne-only reflect + Velocity.Z landing snap with the verbatim retail chain: cached_velocity = (resolved-old)/dt (separate reporting value), SetPositionInternal contact determination (kept Velocity.Z<=0 gate for acdream's always-step-down resolver), then handle_all_collisions (fsf<=1 reflect / fsf>1 zero — the airborne-stuck bleed). Contact is committed BEFORE the reflect so the landing gate isn't defeated by a reflected +Z; that ordering + the ungated small-velocity-zero (Slice 1a) retire AD-25's micro-bounce spiral. Load-bearing: handle_all_collisions + cached_velocity are gated on candidateMoved (retail UpdateObjectInternal pc:283657 only reaches SetPositionInternal when the integrated candidate moved off m_position). After fsf>1 zeros a blocked jump, the next frame integrates zero motion (velMag2==0), so the candidate hasn't moved — skipping the response that frame lets gravity rebuild the velocity instead of re-zeroing it and re-wedging. End-to-end Core test (Issue182CrowdJumpTests): a jump blocked by an overhead creature bleeds its +12 up-velocity to ~0 within a couple frames (fsf>1) and the body grounds on the manufactured plane instead of hanging with persistent +12. Core 2617 / App 741 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
78df1370b6
commit
54d5622960
2 changed files with 177 additions and 147 deletions
|
|
@ -827,6 +827,7 @@ public sealed class PlayerMovementController
|
||||||
// declaration for the full retail trace evidence.
|
// declaration for the full retail trace evidence.
|
||||||
var preIntegratePos = _body.Position;
|
var preIntegratePos = _body.Position;
|
||||||
bool physicsTickRan = false;
|
bool physicsTickRan = false;
|
||||||
|
float lastTickDt = 0f; // the dt actually integrated this frame (for cached_velocity)
|
||||||
Vector3 oldTickEndPos = _currPhysicsPos;
|
Vector3 oldTickEndPos = _currPhysicsPos;
|
||||||
_physicsAccum += dt;
|
_physicsAccum += dt;
|
||||||
|
|
||||||
|
|
@ -842,6 +843,7 @@ public sealed class PlayerMovementController
|
||||||
// Integrate accumulated dt, clamped to MaxQuantum so a long
|
// Integrate accumulated dt, clamped to MaxQuantum so a long
|
||||||
// pause doesn't produce one giant integration step.
|
// pause doesn't produce one giant integration step.
|
||||||
float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum);
|
float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum);
|
||||||
|
lastTickDt = tickDt;
|
||||||
|
|
||||||
// R5-V3 (#171): retail UpdatePositionInternal (0x00512c30) —
|
// R5-V3 (#171): retail UpdatePositionInternal (0x00512c30) —
|
||||||
// PositionManager::adjust_offset (@0x00512d0e) composes the
|
// PositionManager::adjust_offset (@0x00512d0e) composes the
|
||||||
|
|
@ -876,6 +878,14 @@ public sealed class PlayerMovementController
|
||||||
// (motion commands, animation, return) runs normally.
|
// (motion commands, animation, return) runs normally.
|
||||||
var postIntegratePos = _body.Position;
|
var postIntegratePos = _body.Position;
|
||||||
|
|
||||||
|
// retail UpdateObjectInternal (pc:283657): the transition + handle_all_collisions are
|
||||||
|
// reached ONLY when the integrated candidate actually MOVED off m_position. This gate
|
||||||
|
// is load-bearing for the #182 bleed: after fsf>1 zeros a blocked jump's velocity, the
|
||||||
|
// next frame integrates zero motion (velMag2==0 → no position step, just v += gravity),
|
||||||
|
// so the candidate hasn't moved yet — handle_all_collisions MUST be skipped that frame
|
||||||
|
// or it re-zeros the gravity velocity and the body re-wedges instead of falling off.
|
||||||
|
bool candidateMoved = postIntegratePos != preIntegratePos;
|
||||||
|
|
||||||
// ── 5. Collision resolution via CTransition sphere-sweep ─────────────
|
// ── 5. Collision resolution via CTransition sphere-sweep ─────────────
|
||||||
// The Transition system subdivides the movement from pre→post into
|
// The Transition system subdivides the movement from pre→post into
|
||||||
// sphere-radius steps, testing terrain collision at each step.
|
// sphere-radius steps, testing terrain collision at each step.
|
||||||
|
|
@ -928,7 +938,23 @@ public sealed class PlayerMovementController
|
||||||
$"isOnGround={resolveResult.IsOnGround}");
|
$"isOnGround={resolveResult.IsOnGround}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply resolved position.
|
// ── Apply the resolve: retail UpdateObjectInternal (0x005156b0) →
|
||||||
|
// SetPositionInternal (0x00515330) → handle_all_collisions (0x00514780).
|
||||||
|
// #182 verbatim rebuild: this REPLACES the ad-hoc airborne-only bounce with the
|
||||||
|
// frames_stationary_fall-driven velocity model — the airborne-stuck bleed
|
||||||
|
// (fsf>1 → velocity zeroed) that lets a blocked jump fall/glide off a monster crowd.
|
||||||
|
|
||||||
|
// cached_velocity = realized displacement / dt (retail's SEPARATE reporting/DR value,
|
||||||
|
// pc:005158cb-5158ff; not fed back into the integrator velocity).
|
||||||
|
_body.CachedVelocity = (candidateMoved && physicsTickRan && lastTickDt > 0f)
|
||||||
|
? (resolveResult.Position - preIntegratePos) / lastTickDt
|
||||||
|
: Vector3.Zero;
|
||||||
|
|
||||||
|
// Capture prev contact/walkable BEFORE committing the new state — retail
|
||||||
|
// SetPositionInternal reads transient_state at entry for handle_all_collisions' args.
|
||||||
|
bool prevContact = _body.InContact;
|
||||||
|
bool prevOnWalkable = _body.OnWalkable;
|
||||||
|
|
||||||
_body.Position = resolveResult.Position;
|
_body.Position = resolveResult.Position;
|
||||||
if (physicsTickRan)
|
if (physicsTickRan)
|
||||||
{
|
{
|
||||||
|
|
@ -939,169 +965,63 @@ public sealed class PlayerMovementController
|
||||||
// UseTime (@0x005159b3) runs AFTER the quantum's integration
|
// UseTime (@0x005159b3) runs AFTER the quantum's integration
|
||||||
// (whose head hosts adjust_offset) and only on frames where a
|
// (whose head hosts adjust_offset) and only on frames where a
|
||||||
// physics quantum executed (retail's update_object MinQuantum
|
// physics quantum executed (retail's update_object MinQuantum
|
||||||
// gate skips the whole UpdateObjectInternal). Diff-review find:
|
// gate skips the whole UpdateObjectInternal).
|
||||||
// a head-of-frame placement tore an expiring stick down BEFORE
|
|
||||||
// the crossing quantum's final steer/turn — retail applies that
|
|
||||||
// last adjust_offset, THEN the tail watchdog tears down.
|
|
||||||
PositionManager?.UseTime();
|
PositionManager?.UseTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
// L.3a (2026-04-30): retail wall-bounce / velocity reflection.
|
// SetPositionInternal contact determination (pc:283468-510). acdream's resolver
|
||||||
//
|
// reports IsOnGround even during an UPWARD jump (it always step-downs), so the
|
||||||
// Retail's CPhysicsObj::handle_all_collisions runs after every
|
// contact-plane intent stays gated by Velocity.Z<=0 (documented adaptation AD-25): a
|
||||||
// SetPositionInternal. It reads the wall normal that the
|
// jump stays airborne until it descends. Determined BEFORE handle_all_collisions so the
|
||||||
// transition's slide computed and reflects the body's velocity:
|
// landing state is committed before any reflect — this ordering plus the ungated
|
||||||
//
|
// small-velocity-zero (Slice 1a) is what retires AD-25's micro-bounce death spiral
|
||||||
// v_new = v - (1 + elasticity) * dot(v, n) * n
|
// (the old code reflected FIRST, so the reflected +Z defeated the landing gate).
|
||||||
//
|
|
||||||
// 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-diag (2026-04-30): per-frame bounce trace for steep-roof bug.
|
|
||||||
bool diagSteep = AcDream.Core.Physics.PhysicsDiagnostics.DumpSteepRoofEnabled;
|
|
||||||
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;
|
bool justLanded = false;
|
||||||
if (resolveResult.IsOnGround)
|
if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f)
|
||||||
{
|
{
|
||||||
if (_body.Velocity.Z <= 0f)
|
bool wasAirborne = !_body.OnWalkable;
|
||||||
{
|
_body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||||
// Grounded — snap to resolved position and land.
|
_body.calc_acceleration();
|
||||||
bool wasAirborne = !_body.OnWalkable;
|
|
||||||
_body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
|
||||||
_body.calc_acceleration();
|
|
||||||
|
|
||||||
if (_body.Velocity.Z < 0f)
|
// Stop the fall on landing (retail settles the into-ground component via
|
||||||
_body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f);
|
// calc_friction next frame; the hand-zero avoids a one-frame floor dip and makes
|
||||||
|
// handle_all_collisions' landing reflect a no-op — dot(v,n)=0).
|
||||||
|
if (_body.Velocity.Z < 0f)
|
||||||
|
_body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f);
|
||||||
|
|
||||||
if (wasAirborne)
|
if (wasAirborne)
|
||||||
{
|
|
||||||
// R4-V5 → R5-V5: retail order — minterp first, then
|
|
||||||
// moveto (MovementManager::HitGround 0x00524300, decomp
|
|
||||||
// §2d — now the literal facade relay); re-arms a moveto
|
|
||||||
// suspended by the airborne UseTime contact gate.
|
|
||||||
// LeaveGround has NO moveto side (COMDAT no-op, §2e) —
|
|
||||||
// do not add one.
|
|
||||||
Movement.HitGround();
|
|
||||||
justLanded = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
// Moving upward (jump) — stay airborne even though terrain is below.
|
// R4-V5 → R5-V5: retail order — minterp then moveto
|
||||||
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
|
// (MovementManager::HitGround 0x00524300). Re-arms a moveto suspended by the
|
||||||
_body.calc_acceleration();
|
// airborne UseTime contact gate. LeaveGround has NO moveto side (§2e).
|
||||||
|
Movement.HitGround();
|
||||||
|
justLanded = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// No ground found — airborne.
|
// Airborne: jumping up (IsOnGround but v.z>0) OR no ground found.
|
||||||
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
|
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
|
||||||
_body.calc_acceleration();
|
_body.calc_acceleration();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handle_all_collisions (0x00514780): reflect the into-surface velocity (fsf≤1) or
|
||||||
|
// ZERO it entirely (fsf>1 — THE airborne-stuck fix). The Stationary* bit round-trip is
|
||||||
|
// owned by the Core resolve writeback. Restores retail's should_reflect rule; on a
|
||||||
|
// landing the Velocity.Z hand-zero above makes the reflect a no-op (no micro-bounce).
|
||||||
|
// Gated on candidateMoved (retail SetPositionInternal is only reached when the candidate
|
||||||
|
// moved) so a no-move frame doesn't re-zero the gravity velocity rebuilding after a bleed.
|
||||||
|
if (candidateMoved)
|
||||||
|
{
|
||||||
|
PhysicsObjUpdate.HandleAllCollisions(
|
||||||
|
_body,
|
||||||
|
resolveResult.CollisionNormalValid, resolveResult.CollisionNormal,
|
||||||
|
prevContact, prevOnWalkable, nowOnWalkable: _body.OnWalkable);
|
||||||
|
}
|
||||||
|
|
||||||
// R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's
|
// R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's LeaveGround (0x00528b00) —
|
||||||
// LeaveGround (0x00528b00) — jump launches (jump() cleared
|
// jump launches and walk-off-a-ledge both route here. The landing branch above fires
|
||||||
// OnWalkable earlier this frame) AND walk-off-a-ledge both route
|
// HitGround on the opposite edge.
|
||||||
// here, replacing the old manual jump-block call (and giving
|
|
||||||
// ledge departures the momentum fallback + link strip they never
|
|
||||||
// had). Edge = grounded last frame, airborne now; the landing
|
|
||||||
// branch above already fires HitGround on the opposite edge.
|
|
||||||
if (!_body.OnWalkable && !_wasAirborneLastFrame)
|
if (!_body.OnWalkable && !_wasAirborneLastFrame)
|
||||||
_motion.LeaveGround();
|
_motion.LeaveGround();
|
||||||
|
|
||||||
|
|
|
||||||
110
tests/AcDream.Core.Tests/Physics/Issue182CrowdJumpTests.cs
Normal file
110
tests/AcDream.Core.Tests/Physics/Issue182CrowdJumpTests.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
using System;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end (Core) proof of the #182 airborne-stuck fix: a jump velocity blocked by a
|
||||||
|
/// creature BLEEDS to zero within a few frames so gravity can resume — the player falls /
|
||||||
|
/// glides off instead of hanging in the falling animation. Chains the same steps the
|
||||||
|
/// PlayerMovementController per-frame loop runs, minus the App layer:
|
||||||
|
/// integrate (UpdatePhysicsInternal + gravity) → ResolveWithTransition → apply →
|
||||||
|
/// handle_all_collisions (PhysicsObjUpdate).
|
||||||
|
/// Before the rebuild the velocity persisted (only an airborne-only reflect that barely
|
||||||
|
/// touched +Z against a near-horizontal normal); now fs>1 zeros it.
|
||||||
|
/// </summary>
|
||||||
|
public class Issue182CrowdJumpTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public Issue182CrowdJumpTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
private const uint Lb = 0xA9B40000u;
|
||||||
|
private const uint Cell = Lb | 0x0001u;
|
||||||
|
private const float R = 0.48f, H = 1.835f, StepUp = 0.60f, StepDown = 0.04f;
|
||||||
|
private const float Dt = 1f / 30f; // one physics quantum
|
||||||
|
|
||||||
|
private static PhysicsEngine BuildEngine()
|
||||||
|
{
|
||||||
|
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
||||||
|
engine.AddLandblock(Lb, new TerrainSurface(new byte[81], new float[256]),
|
||||||
|
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RegisterCreatureAt(PhysicsEngine e, uint id, Vector3 c)
|
||||||
|
=> e.ShadowObjects.Register(id, 0u, c, Quaternion.Identity, R,
|
||||||
|
0f, 0f, Lb, ShadowCollisionType.Sphere, 0f, 1f, 0u,
|
||||||
|
EntityCollisionFlags.IsCreature, isStatic: false);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BlockedJump_VelocityBleedsToZero_ThenGravityResumes()
|
||||||
|
{
|
||||||
|
var engine = BuildEngine();
|
||||||
|
RegisterCreatureAt(engine, 0xC0B0u, new Vector3(12f, 10f, 4.5f)); // creature overhead
|
||||||
|
|
||||||
|
var body = new PhysicsBody
|
||||||
|
{
|
||||||
|
Position = new Vector3(12f, 10f, 3.0f),
|
||||||
|
Orientation = Quaternion.Identity,
|
||||||
|
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
|
||||||
|
TransientState = TransientStateFlags.None, // airborne
|
||||||
|
Velocity = new Vector3(0f, 0f, 12f), // a jump: moving straight up
|
||||||
|
};
|
||||||
|
uint cell = Cell;
|
||||||
|
|
||||||
|
bool bled = false;
|
||||||
|
for (int frame = 0; frame < 20; frame++)
|
||||||
|
{
|
||||||
|
// 1) integrate velocity + gravity into a candidate (mirrors the controller's §4).
|
||||||
|
var pre = body.Position;
|
||||||
|
body.calc_acceleration();
|
||||||
|
body.UpdatePhysicsInternal(Dt);
|
||||||
|
var post = body.Position;
|
||||||
|
bool candidateMoved = post != pre; // retail candidate != m_position gate
|
||||||
|
|
||||||
|
// 2) resolve the candidate against the crowd.
|
||||||
|
var r = engine.ResolveWithTransition(pre, post, cell, R, H, StepUp, StepDown,
|
||||||
|
isOnGround: false, body: body,
|
||||||
|
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
|
||||||
|
|
||||||
|
// 3) apply + contact determination (mirrors the controller's SetPositionInternal).
|
||||||
|
bool prevOnWalkable = body.OnWalkable;
|
||||||
|
body.Position = r.Position;
|
||||||
|
cell = r.CellId;
|
||||||
|
if (r.IsOnGround && body.Velocity.Z <= 0f)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
|
||||||
|
body.calc_acceleration();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) handle_all_collisions: reflect (fsf<=1) or zero (fsf>1) — but ONLY when the
|
||||||
|
// candidate moved (retail skips SetPositionInternal otherwise), so gravity can
|
||||||
|
// rebuild the velocity after the bleed instead of it being re-zeroed each frame.
|
||||||
|
if (candidateMoved)
|
||||||
|
PhysicsObjUpdate.HandleAllCollisions(body,
|
||||||
|
r.CollisionNormalValid, r.CollisionNormal,
|
||||||
|
prevContact: false, prevOnWalkable, nowOnWalkable: body.OnWalkable);
|
||||||
|
|
||||||
|
_out.WriteLine($"frame{frame,2}: z={body.Position.Z:F3} vz={body.Velocity.Z:F3} " +
|
||||||
|
$"fsf={body.FramesStationaryFall}");
|
||||||
|
|
||||||
|
// The upward jump velocity must collapse to ~0 (or reverse to falling) within a few
|
||||||
|
// frames of being blocked — the bleed. Before the fix it persisted near +12.
|
||||||
|
if (frame >= 1 && frame <= 6 && body.Velocity.Z <= 0.5f)
|
||||||
|
bled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(bled, "the blocked jump velocity must bleed to ~0 within a few frames (fsf>1 → v=0)");
|
||||||
|
// After bleeding, gravity has taken over — the body is no longer being shoved upward.
|
||||||
|
Assert.True(body.Velocity.Z <= 0.5f, $"velocity should not persist upward; got vz={body.Velocity.Z:F3}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue