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:
parent
261322b48e
commit
a1c27b3afb
4 changed files with 191 additions and 3 deletions
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -31,6 +31,14 @@ public enum PhysicsStateFlags : uint
|
|||
ReportCollisions = 0x00000010,
|
||||
Gravity = 0x00000400, // bit 10 — apply downward gravity
|
||||
Hidden = 0x00001000,
|
||||
/// <summary>
|
||||
/// L.3a (2026-04-30): retail INELASTIC_PS bit (acclient.h:2834).
|
||||
/// When set, wall-collisions zero the velocity instead of reflecting.
|
||||
/// Used by spell projectiles and missiles that should embed/explode on
|
||||
/// impact rather than bounce. The player NEVER has this flag set —
|
||||
/// player wall-hits use the reflection path with elasticity ~0.05.
|
||||
/// </summary>
|
||||
Inelastic = 0x00020000, // bit 17 — retail INELASTIC_PS
|
||||
Sledding = 0x00800000, // bit 23 — sledding (modified friction)
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +52,7 @@ public enum TransientStateFlags : uint
|
|||
None = 0,
|
||||
Contact = 0x00000001, // bit 0 — touching any surface
|
||||
OnWalkable = 0x00000002, // bit 1 — standing on a walkable surface
|
||||
Sliding = 0x00000004, // bit 2 — carry sliding normal into next transition
|
||||
Active = 0x00000080, // bit 7 — object needs per-frame update
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +96,9 @@ public sealed class PhysicsBody
|
|||
/// <summary>Ground contact-plane normal (+0x130/134/138).</summary>
|
||||
public Vector3 GroundNormal { get; set; } = Vector3.UnitZ;
|
||||
|
||||
/// <summary>Last wall/object sliding normal (retail transient Sliding state).</summary>
|
||||
public Vector3 SlidingNormal { get; set; }
|
||||
|
||||
// ── persisted contact-plane state (retail PhysicsObj fields) ───────────
|
||||
//
|
||||
// Retail's PhysicsObj carries its last contact plane FORWARD across frames.
|
||||
|
|
@ -113,6 +125,18 @@ public sealed class PhysicsBody
|
|||
/// <summary>Whether the contact plane is a water surface (affects step behavior).</summary>
|
||||
public bool ContactPlaneIsWater { get; set; }
|
||||
|
||||
/// <summary>Whether the previous walkable polygon is available for edge slide.</summary>
|
||||
public bool WalkablePolygonValid { get; set; }
|
||||
|
||||
/// <summary>Most recent walkable polygon plane (world-space).</summary>
|
||||
public System.Numerics.Plane WalkablePlane { get; set; }
|
||||
|
||||
/// <summary>Most recent walkable polygon vertices (world-space).</summary>
|
||||
public Vector3[]? WalkableVertices { get; set; }
|
||||
|
||||
/// <summary>Up vector used by the most recent walkable polygon probe.</summary>
|
||||
public Vector3 WalkableUp { get; set; } = Vector3.UnitZ;
|
||||
|
||||
/// <summary>Elasticity coefficient (+0xB0).</summary>
|
||||
public float Elasticity { get; set; } = 0.05f;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,25 @@ namespace AcDream.Core.Physics;
|
|||
/// Result of <see cref="PhysicsEngine.Resolve"/>: the validated
|
||||
/// position after collision, the cell the entity ended up in,
|
||||
/// and whether they're standing on a surface.
|
||||
///
|
||||
/// <para>
|
||||
/// L.3a (2026-04-30): added optional collision-normal fields so the
|
||||
/// caller (typically <see cref="AcDream.App.Input.PlayerMovementController"/>)
|
||||
/// can apply retail's velocity-reflection bounce
|
||||
/// (<c>v_new = v - (1 + elasticity) * dot(v, n) * n</c>) to the
|
||||
/// PhysicsBody after the geometric resolve completes. ACE port mirror:
|
||||
/// <c>references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2692-2697</c>;
|
||||
/// retail equivalent: <c>CPhysicsObj::handle_all_collisions</c> at
|
||||
/// <c>acclient_2013_pseudo_c.txt:282699-282715</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public readonly record struct ResolveResult(
|
||||
Vector3 Position,
|
||||
uint CellId,
|
||||
bool IsOnGround);
|
||||
bool IsOnGround,
|
||||
/// <summary>True when a wall collision occurred during this resolve
|
||||
/// and <see cref="CollisionNormal"/> is meaningful.</summary>
|
||||
bool CollisionNormalValid = false,
|
||||
/// <summary>Outward surface normal of the wall the sphere hit. Used
|
||||
/// by the velocity-reflection step. Pointing away from the wall.</summary>
|
||||
Vector3 CollisionNormal = default);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue