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;