From dc0341e85a0d4a5ec29b605e432836faa4eaf315 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 12 Apr 2026 19:08:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(core):=20Phase=20B.3=20=E2=80=94=20add=20ce?= =?UTF-8?q?ntroid=20+=20radius=20bounds=20to=20PortalPlane=20crossing=20te?= =?UTF-8?q?st?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two targeted fixes for user-reported movement bugs: 1. Wall bounce: PortalPlane.FromVertices now accepts ALL polygon vertices (not just 3) for accurate centroid + bounding radius. IsCrossing uses 2D (XY) distance check with tight radius (no multiplier) to prevent wall faces from triggering false indoor transitions. Walking along a building wall no longer launches the player into the air. 2. Slope alignment: PlayerMovementController adds a slope-proportional Z bias when walking uphill (up to +0.8 on steep slopes, grounded only). Prevents feet from sinking into the visual terrain mesh on slopes where the physics sample point lags the render surface. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Input/PlayerMovementController.cs | 17 ++++- src/AcDream.App/Rendering/GameWindow.cs | 15 ++-- src/AcDream.Core/Physics/PortalPlane.cs | 70 ++++++++++++------- 3 files changed, 71 insertions(+), 31 deletions(-) diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 232184b..b1df540 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -88,6 +88,7 @@ public sealed class PlayerMovementController // Heartbeat timer. private float _heartbeatAccum; + private float _prevGroundZ; public const float HeartbeatInterval = 0.2f; // 200ms public bool HeartbeatDue { get; private set; } @@ -100,6 +101,7 @@ public sealed class PlayerMovementController { Position = pos; CellId = cellId; + _prevGroundZ = pos.Z; } public MovementResult Update(float dt, MovementInput input) @@ -198,8 +200,19 @@ public sealed class PlayerMovementController } } - // Small upward bias prevents feet from z-fighting with terrain surface. - Position = new Vector3(result.Position.X, result.Position.Y, newZ + 0.15f); + // Upward bias prevents feet from sinking into the terrain surface. + // On slopes the visual terrain mesh rises ahead of the physics sample + // point, so we add extra bias proportional to how fast the ground Z is + // changing (steeper slope → more bias). Only apply when grounded — during + // jumps/falls the bias would interfere with the ballistic arc. + float slopeBias = 0f; + if (!IsAirborne) + { + float slopeDelta = MathF.Max(0f, newZ - _prevGroundZ); + slopeBias = MathF.Min(slopeDelta * 3f, 0.8f); + } + _prevGroundZ = newZ; + Position = new Vector3(result.Position.X, result.Position.Y, newZ + 0.15f + slopeBias); CellId = result.CellId; // 4. Determine current motion commands. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 9d80f60..e298d7c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1473,13 +1473,18 @@ public sealed class GameWindow : IDisposable if (poly.VertexIds.Count < 3) continue; - // VertexIds are short; worldVerts keys are ushort. - if (!worldVerts.TryGetValue((ushort)poly.VertexIds[0], out var v0)) continue; - if (!worldVerts.TryGetValue((ushort)poly.VertexIds[1], out var v1)) continue; - if (!worldVerts.TryGetValue((ushort)poly.VertexIds[2], out var v2)) continue; + // Collect ALL polygon vertices for accurate centroid + radius. + var portalVerts = new System.Numerics.Vector3[poly.VertexIds.Count]; + bool allFound = true; + for (int pv = 0; pv < poly.VertexIds.Count; pv++) + { + if (!worldVerts.TryGetValue((ushort)poly.VertexIds[pv], out portalVerts[pv])) + { allFound = false; break; } + } + if (!allFound) continue; portalPlanes.Add(AcDream.Core.Physics.PortalPlane.FromVertices( - v0, v1, v2, + portalVerts.AsSpan(), portal.OtherCellId, // target cell (0xFFFF = outdoor) envCellId & 0xFFFFu, // owner cell (low 16 bits) (ushort)portal.Flags)); diff --git a/src/AcDream.Core/Physics/PortalPlane.cs b/src/AcDream.Core/Physics/PortalPlane.cs index acdfd96..918763f 100644 --- a/src/AcDream.Core/Physics/PortalPlane.cs +++ b/src/AcDream.Core/Physics/PortalPlane.cs @@ -16,29 +16,49 @@ public readonly record struct PortalPlane( float Radius) // bounding radius of the portal polygon { /// - /// Construct a PortalPlane from three coplanar vertices (winding order - /// determines the normal direction via cross product). Also computes - /// the centroid and bounding radius from the vertices so - /// can reject crossings far from the portal. + /// Construct a PortalPlane from ALL polygon vertices (winding order of the + /// first three determines the normal). Centroid + radius are computed from + /// the full vertex set for accurate doorway bounds. + /// + public static PortalPlane FromVertices( + ReadOnlySpan vertices, + uint targetCellId, uint ownerCellId, ushort flags) + { + if (vertices.Length < 3) + throw new ArgumentException("Need at least 3 vertices", nameof(vertices)); + + var edge1 = vertices[1] - vertices[0]; + var edge2 = vertices[2] - vertices[0]; + var normal = Vector3.Normalize(Vector3.Cross(edge1, edge2)); + float d = -Vector3.Dot(normal, vertices[0]); + + // Centroid = average of ALL vertices. + var sum = Vector3.Zero; + foreach (var v in vertices) sum += v; + var centroid = sum / vertices.Length; + + // Bounding radius = max distance from centroid to any vertex. + // NO padding — we rely on the tight radius to prevent wall-bounce. + float maxR = 0f; + foreach (var v in vertices) + { + float r = Vector3.Distance(centroid, v); + if (r > maxR) maxR = r; + } + + return new PortalPlane(normal, d, targetCellId, ownerCellId, flags, centroid, maxR); + } + + /// + /// Convenience overload for 3-vertex portals (backwards-compatible with + /// existing test call sites). /// public static PortalPlane FromVertices( Vector3 v0, Vector3 v1, Vector3 v2, uint targetCellId, uint ownerCellId, ushort flags) { - var edge1 = v1 - v0; - var edge2 = v2 - v0; - var normal = Vector3.Normalize(Vector3.Cross(edge1, edge2)); - float d = -Vector3.Dot(normal, v0); - - // Centroid = average of the three vertices. - var centroid = (v0 + v1 + v2) / 3f; - // Bounding radius = max distance from centroid to any vertex + padding. - float r0 = Vector3.Distance(centroid, v0); - float r1 = Vector3.Distance(centroid, v1); - float r2 = Vector3.Distance(centroid, v2); - float radius = MathF.Max(r0, MathF.Max(r1, r2)) + 2f; // 2 unit padding - - return new PortalPlane(normal, d, targetCellId, ownerCellId, flags, centroid, radius); + ReadOnlySpan verts = stackalloc Vector3[] { v0, v1, v2 }; + return FromVertices(verts, targetCellId, ownerCellId, flags); } /// @@ -50,12 +70,14 @@ public readonly record struct PortalPlane( /// public bool IsCrossing(Vector3 oldPos, Vector3 newPos) { - // Quick reject: if both positions are far from the portal, skip - // the plane test entirely. Use the closer of the two positions. - float distOld = Vector3.Distance(oldPos, Centroid); - float distNew = Vector3.Distance(newPos, Centroid); - float minDist = MathF.Min(distOld, distNew); - if (minDist > Radius * 2f) return false; + // Quick reject: 2D (XY) distance from the portal centroid. + // Using XY-only prevents roof-level positions from triggering + // ground-floor doorways. The radius is kept tight (no multiplier) + // so only positions genuinely near the doorway opening pass. + float dx = MathF.Min(MathF.Abs(oldPos.X - Centroid.X), MathF.Abs(newPos.X - Centroid.X)); + float dy = MathF.Min(MathF.Abs(oldPos.Y - Centroid.Y), MathF.Abs(newPos.Y - Centroid.Y)); + float minDist2D = MathF.Sqrt(dx * dx + dy * dy); + if (minDist2D > Radius) return false; float oldDist = Vector3.Dot(Normal, oldPos) + D; float newDist = Vector3.Dot(Normal, newPos) + D;