feat(physics): L.3a — wall-bounce velocity reflection on airborne hits

Three independent research agents converged: retail's "bouncy walls"
feel comes from CPhysicsObj::handle_all_collisions (acclient_2013_pseudo_c.txt:
282699-282715, ACE PhysicsObj.cs:2692-2697) which applies the canonical
reflection v_new = v - (1 + e) * dot(v, n) * n to the body's velocity
after every transition resolves. Player elasticity = 0.05 (5% bounce);
INELASTIC_PS = 0x20000 zeros velocity entirely (used by spell projectiles).

acdream had the data plumbed (PhysicsBody.Elasticity = 0.05 was already
set, ci.CollisionNormal was being populated in 8+ code paths) but
ResolveWithTransition discarded the normal before returning. Hence
"sticky walls on jumps" — perpendicular velocity got removed by
SlideSphere's geometric resolution, but never reflected back, so
hitting a wall mid-jump zeroed forward motion entirely instead of
producing a small push-back.

Files:
- PhysicsBody.cs: add PhysicsStateFlags.Inelastic = 0x20000.
- ResolveResult.cs: surface CollisionNormalValid + CollisionNormal.
- PhysicsEngine.cs:599-624: copy ci.CollisionNormal into ResolveResult
  before returning (both ok and partial paths).
- PlayerMovementController.cs:445-503: after position commit, apply
  reflection per the retail formula. Inelastic → zero velocity;
  else → reflect with v += n * -(dot(v,n) * (e + 1)).

apply_bounce rule (more conservative than retail by design):
- Sledding: retail's strict rule — bounce unless both grounded.
- Otherwise: bounce ONLY when both prev and now airborne. Suppress on
  landing (prev air, now ground) to avoid micro-bouncing on floor —
  the post-reflection upward Z defeats the controller's Velocity.Z<=0
  landing-snap gate. Retail's elasticity 0.05 makes the artifact
  visually imperceptible there; acdream's per-frame architecture
  amplifies it.

Tests: 1491 → 1491 still pass (existing AirborneFrames + WalkOffLedge
tests confirmed the conservative apply_bounce rule keeps landings
clean).

Live verification needed: jump into a wall mid-air — should produce a
visible bounce-back rather than sticking. Walking along corridor with
side-clip should still slide. Landing should still settle without
micro-bounce.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-30 09:41:04 +02:00
parent 261322b48e
commit a1c27b3afb
4 changed files with 191 additions and 3 deletions

View file

@ -518,8 +518,29 @@ public sealed class PhysicsEngine
body.ContactPlaneIsWater);
}
// Retail CPhysicsObj::get_object_info also seeds SlidingNormal when
// transient_state has bit 2 set. This matters for one-step/frame hits:
// a wall collision at the end of one transition must project the next
// frame's movement along the wall instead of hard-stopping again.
if (body is not null
&& body.TransientState.HasFlag(TransientStateFlags.Sliding)
&& body.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{
transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal);
}
transition.SpherePath.InitPath(currentPos, targetPos, cellId, sphereRadius, sphereHeight);
if (isOnGround && body is not null
&& body.WalkablePolygonValid
&& body.WalkableVertices is { Length: >= 3 })
{
transition.SpherePath.SetWalkable(
body.WalkablePlane,
body.WalkableVertices,
body.WalkableUp);
}
bool ok = transition.FindTransitionalPosition(this);
var sp = transition.SpherePath;
@ -548,8 +569,43 @@ public sealed class PhysicsEngine
{
body.ContactPlaneValid = false;
}
if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null)
{
body.WalkablePolygonValid = true;
body.WalkablePlane = sp.LastWalkablePlane;
body.WalkableVertices = (Vector3[])sp.LastWalkableVertices.Clone();
body.WalkableUp = sp.LastWalkableUp;
}
else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid)
{
body.WalkablePolygonValid = false;
body.WalkableVertices = null;
}
if (ci.SlidingNormalValid
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{
body.SlidingNormal = ci.SlidingNormal;
body.TransientState |= TransientStateFlags.Sliding;
}
else
{
body.SlidingNormal = Vector3.Zero;
body.TransientState &= ~TransientStateFlags.Sliding;
}
}
// L.3a (2026-04-30): surface the wall normal so callers can apply
// retail's velocity-reflection bounce (CPhysicsObj::handle_all_collisions
// at acclient_2013_pseudo_c.txt:282699-282715, ACE PhysicsObj.cs:
// 2692-2697). The reflection itself is applied in
// PlayerMovementController after the position commit, gated on
// apply_bounce = !(prevOnWalkable && newOnWalkable) — airborne wall
// hits bounce, grounded wall slides don't.
bool collisionNormalValid = ci.CollisionNormalValid;
Vector3 collisionNormal = ci.CollisionNormal;
if (ok)
{
bool onGround = ci.ContactPlaneValid
@ -558,7 +614,9 @@ public sealed class PhysicsEngine
return new ResolveResult(
sp.CheckPos,
ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId),
onGround);
onGround,
collisionNormalValid,
collisionNormal);
}
// Transition failed (e.g., stuck in corner, too many steps).
@ -574,6 +632,8 @@ public sealed class PhysicsEngine
return new ResolveResult(
sp.CheckPos,
ResolveOutdoorCellId(sp.CheckPos, partialCellId),
partialOnGround);
partialOnGround,
collisionNormalValid,
collisionNormal);
}
}