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
|
|
@ -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