diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 35daf3f..64f9f5c 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -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) { diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs index 9c93915..2c2b20a 100644 --- a/src/AcDream.Core/Physics/PhysicsBody.cs +++ b/src/AcDream.Core/Physics/PhysicsBody.cs @@ -31,6 +31,14 @@ public enum PhysicsStateFlags : uint ReportCollisions = 0x00000010, Gravity = 0x00000400, // bit 10 — apply downward gravity Hidden = 0x00001000, + /// + /// 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. + /// + 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 /// Ground contact-plane normal (+0x130/134/138). public Vector3 GroundNormal { get; set; } = Vector3.UnitZ; + /// Last wall/object sliding normal (retail transient Sliding state). + 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 /// Whether the contact plane is a water surface (affects step behavior). public bool ContactPlaneIsWater { get; set; } + /// Whether the previous walkable polygon is available for edge slide. + public bool WalkablePolygonValid { get; set; } + + /// Most recent walkable polygon plane (world-space). + public System.Numerics.Plane WalkablePlane { get; set; } + + /// Most recent walkable polygon vertices (world-space). + public Vector3[]? WalkableVertices { get; set; } + + /// Up vector used by the most recent walkable polygon probe. + public Vector3 WalkableUp { get; set; } = Vector3.UnitZ; + /// Elasticity coefficient (+0xB0). public float Elasticity { get; set; } = 0.05f; diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 22f48ef..2fee39b 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -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); } } diff --git a/src/AcDream.Core/Physics/ResolveResult.cs b/src/AcDream.Core/Physics/ResolveResult.cs index cc7fef8..63d1845 100644 --- a/src/AcDream.Core/Physics/ResolveResult.cs +++ b/src/AcDream.Core/Physics/ResolveResult.cs @@ -6,8 +6,25 @@ namespace AcDream.Core.Physics; /// Result of : the validated /// position after collision, the cell the entity ended up in, /// and whether they're standing on a surface. +/// +/// +/// L.3a (2026-04-30): added optional collision-normal fields so the +/// caller (typically ) +/// can apply retail's velocity-reflection bounce +/// (v_new = v - (1 + elasticity) * dot(v, n) * n) to the +/// PhysicsBody after the geometric resolve completes. ACE port mirror: +/// references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2692-2697; +/// retail equivalent: CPhysicsObj::handle_all_collisions at +/// acclient_2013_pseudo_c.txt:282699-282715. +/// /// public readonly record struct ResolveResult( Vector3 Position, uint CellId, - bool IsOnGround); + bool IsOnGround, + /// True when a wall collision occurred during this resolve + /// and is meaningful. + bool CollisionNormalValid = false, + /// Outward surface normal of the wall the sphere hit. Used + /// by the velocity-reflection step. Pointing away from the wall. + Vector3 CollisionNormal = default);