fix(core): Phase B.3 — add centroid + radius bounds to PortalPlane crossing test
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) <noreply@anthropic.com>
This commit is contained in:
parent
41013ce3e3
commit
dc0341e85a
3 changed files with 71 additions and 31 deletions
|
|
@ -88,6 +88,7 @@ public sealed class PlayerMovementController
|
||||||
|
|
||||||
// Heartbeat timer.
|
// Heartbeat timer.
|
||||||
private float _heartbeatAccum;
|
private float _heartbeatAccum;
|
||||||
|
private float _prevGroundZ;
|
||||||
public const float HeartbeatInterval = 0.2f; // 200ms
|
public const float HeartbeatInterval = 0.2f; // 200ms
|
||||||
public bool HeartbeatDue { get; private set; }
|
public bool HeartbeatDue { get; private set; }
|
||||||
|
|
||||||
|
|
@ -100,6 +101,7 @@ public sealed class PlayerMovementController
|
||||||
{
|
{
|
||||||
Position = pos;
|
Position = pos;
|
||||||
CellId = cellId;
|
CellId = cellId;
|
||||||
|
_prevGroundZ = pos.Z;
|
||||||
}
|
}
|
||||||
|
|
||||||
public MovementResult Update(float dt, MovementInput input)
|
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.
|
// Upward bias prevents feet from sinking into the terrain surface.
|
||||||
Position = new Vector3(result.Position.X, result.Position.Y, newZ + 0.15f);
|
// 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;
|
CellId = result.CellId;
|
||||||
|
|
||||||
// 4. Determine current motion commands.
|
// 4. Determine current motion commands.
|
||||||
|
|
|
||||||
|
|
@ -1473,13 +1473,18 @@ public sealed class GameWindow : IDisposable
|
||||||
if (poly.VertexIds.Count < 3)
|
if (poly.VertexIds.Count < 3)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// VertexIds are short; worldVerts keys are ushort.
|
// Collect ALL polygon vertices for accurate centroid + radius.
|
||||||
if (!worldVerts.TryGetValue((ushort)poly.VertexIds[0], out var v0)) continue;
|
var portalVerts = new System.Numerics.Vector3[poly.VertexIds.Count];
|
||||||
if (!worldVerts.TryGetValue((ushort)poly.VertexIds[1], out var v1)) continue;
|
bool allFound = true;
|
||||||
if (!worldVerts.TryGetValue((ushort)poly.VertexIds[2], out var v2)) continue;
|
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(
|
portalPlanes.Add(AcDream.Core.Physics.PortalPlane.FromVertices(
|
||||||
v0, v1, v2,
|
portalVerts.AsSpan(),
|
||||||
portal.OtherCellId, // target cell (0xFFFF = outdoor)
|
portal.OtherCellId, // target cell (0xFFFF = outdoor)
|
||||||
envCellId & 0xFFFFu, // owner cell (low 16 bits)
|
envCellId & 0xFFFFu, // owner cell (low 16 bits)
|
||||||
(ushort)portal.Flags));
|
(ushort)portal.Flags));
|
||||||
|
|
|
||||||
|
|
@ -16,29 +16,49 @@ public readonly record struct PortalPlane(
|
||||||
float Radius) // bounding radius of the portal polygon
|
float Radius) // bounding radius of the portal polygon
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Construct a PortalPlane from three coplanar vertices (winding order
|
/// Construct a PortalPlane from ALL polygon vertices (winding order of the
|
||||||
/// determines the normal direction via cross product). Also computes
|
/// first three determines the normal). Centroid + radius are computed from
|
||||||
/// the centroid and bounding radius from the vertices so
|
/// the full vertex set for accurate doorway bounds.
|
||||||
/// <see cref="IsCrossing"/> can reject crossings far from the portal.
|
/// </summary>
|
||||||
|
public static PortalPlane FromVertices(
|
||||||
|
ReadOnlySpan<Vector3> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience overload for 3-vertex portals (backwards-compatible with
|
||||||
|
/// existing test call sites).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static PortalPlane FromVertices(
|
public static PortalPlane FromVertices(
|
||||||
Vector3 v0, Vector3 v1, Vector3 v2,
|
Vector3 v0, Vector3 v1, Vector3 v2,
|
||||||
uint targetCellId, uint ownerCellId, ushort flags)
|
uint targetCellId, uint ownerCellId, ushort flags)
|
||||||
{
|
{
|
||||||
var edge1 = v1 - v0;
|
ReadOnlySpan<Vector3> verts = stackalloc Vector3[] { v0, v1, v2 };
|
||||||
var edge2 = v2 - v0;
|
return FromVertices(verts, targetCellId, ownerCellId, flags);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -50,12 +70,14 @@ public readonly record struct PortalPlane(
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsCrossing(Vector3 oldPos, Vector3 newPos)
|
public bool IsCrossing(Vector3 oldPos, Vector3 newPos)
|
||||||
{
|
{
|
||||||
// Quick reject: if both positions are far from the portal, skip
|
// Quick reject: 2D (XY) distance from the portal centroid.
|
||||||
// the plane test entirely. Use the closer of the two positions.
|
// Using XY-only prevents roof-level positions from triggering
|
||||||
float distOld = Vector3.Distance(oldPos, Centroid);
|
// ground-floor doorways. The radius is kept tight (no multiplier)
|
||||||
float distNew = Vector3.Distance(newPos, Centroid);
|
// so only positions genuinely near the doorway opening pass.
|
||||||
float minDist = MathF.Min(distOld, distNew);
|
float dx = MathF.Min(MathF.Abs(oldPos.X - Centroid.X), MathF.Abs(newPos.X - Centroid.X));
|
||||||
if (minDist > Radius * 2f) return false;
|
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 oldDist = Vector3.Dot(Normal, oldPos) + D;
|
||||||
float newDist = Vector3.Dot(Normal, newPos) + D;
|
float newDist = Vector3.Dot(Normal, newPos) + D;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue