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

@ -444,6 +444,93 @@ public sealed class PlayerMovementController
// 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);
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;
}
}
}
}
bool justLanded = false;
if (resolveResult.IsOnGround)
{