fix(physics): Phase 2 — synthesize indoor walkable plane from cell floor

When the indoor cell-BSP query returns OK (no wall collision), the player
is standing on a floor poly inside the cell. Previously the code fell
through to outdoor terrain (SampleTerrainWalkable + ValidateWalkable),
which used the OUTDOOR terrain plane — below the indoor floor due to the
+0.02f Z-bump applied for render z-fight prevention. ValidateWalkable
saw the player 0.5m above the outdoor plane → marked them as airborne
→ walkable=False → falling animation, never recovers.

Adds TryFindIndoorWalkablePlane (internal static for testability): scans
the cell's resolved physics polys for a walkable floor poly (normal.Z >=
0.6664, walkable-slope threshold matching retail) under the player's XY,
transforms its plane + vertices to world space via WorldTransform, and
calls ValidateWalkable with the indoor plane. Adds PointInPolygonXY
(ray-casting even-odd rule, ignores Z). Both are wired just after the
BSP OK branch in FindEnvCollisions; outdoor terrain remains a defensive
backstop if no floor poly is found under the player indoors (rare).

Matches retail's CEnvCell::find_env_collisions behavior: no fall-through
to terrain when the cell BSP successfully completes a query.

Evidence: launch-phase2-verify5.log captured 12,141 walkable=False
events during an indoor session where the player never managed to walk
back outdoor through a door — they got stuck against the indoor wall
and the resolver never re-established a walkable contact plane.

Adds 13 unit tests in IndoorWalkablePlaneTests.cs covering:
- player over floor poly (returns true, plane normal up, plane at correct Z)
- player outside poly XY (returns false)
- no walkable polys (returns false)
- empty Resolved dict (returns false)
- cell with world translation (plane + vertices in world space)
- PointInPolygonXY cases (centre, near corner, on boundary, outside, Z ignored)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-19 19:13:13 +02:00
parent 3ffe1e44f6
commit eb0f772f0f
2 changed files with 359 additions and 0 deletions

View file

@ -1166,6 +1166,92 @@ public sealed class Transition
// Environment collision — outdoor terrain
// -----------------------------------------------------------------------
/// <summary>
/// Indoor walking Phase 2 follow-up (2026-05-19). Finds the walkable floor
/// polygon directly under <paramref name="localFootCenter"/> within
/// <paramref name="cellPhysics"/>. Used when the indoor cell-BSP query
/// returns OK (no wall collision) — we need to provide a walkable contact
/// plane from the cell's geometry instead of falling through to outdoor
/// terrain (which is below the cell floor due to the +0.02f Z-bump
/// applied at <c>GameWindow.BuildInteriorEntitiesForStreaming</c>).
///
/// <para>
/// Iterates <see cref="CellPhysics.Resolved"/> physics polygons; selects
/// the one with the most upward-facing normal (Z &gt;= 0.6664 = walkable
/// slope threshold matching retail's WalkableSlopeMin) whose XY projection
/// contains the player's local foot XY. Returns the polygon's plane +
/// vertices in WORLD space for the <c>ValidateWalkable</c> call.
/// </para>
///
/// <para>
/// Returns <c>false</c> if no walkable floor poly is found under the
/// player. The caller falls through to outdoor terrain in that case
/// (defensive backstop — should not normally happen inside a sealed cell).
/// </para>
/// </summary>
internal static bool TryFindIndoorWalkablePlane(
CellPhysics cellPhysics,
Vector3 localFootCenter,
out System.Numerics.Plane worldPlane,
out Vector3[] worldVertices,
out uint hitPolyId)
{
worldPlane = default;
worldVertices = System.Array.Empty<Vector3>();
hitPolyId = 0;
foreach (var (id, poly) in cellPhysics.Resolved)
{
// Walkable slope threshold matches retail WalkableSlopeMin (0.6664...)
// and our existing TerrainSurface.WalkableSlopeMin check.
if (poly.Plane.Normal.Z < 0.6664f) continue;
if (poly.Vertices is null || poly.Vertices.Length < 3) continue;
// Point-in-polygon test in XY (ignore Z). Ray-casting even-odd rule.
if (!PointInPolygonXY(localFootCenter, poly.Vertices)) continue;
// Found a floor poly under the player. Transform plane + vertices
// to world space.
var worldNormal = Vector3.TransformNormal(poly.Plane.Normal, cellPhysics.WorldTransform);
worldNormal = Vector3.Normalize(worldNormal);
// Take vertex 0, transform to world, recompute D so the plane
// equation normal·p + D = 0 holds at the world-space vertex.
var worldV0 = Vector3.Transform(poly.Vertices[0], cellPhysics.WorldTransform);
float worldD = -Vector3.Dot(worldNormal, worldV0);
worldPlane = new System.Numerics.Plane(worldNormal, worldD);
worldVertices = new Vector3[poly.Vertices.Length];
for (int i = 0; i < poly.Vertices.Length; i++)
worldVertices[i] = Vector3.Transform(poly.Vertices[i], cellPhysics.WorldTransform);
hitPolyId = id;
return true;
}
return false;
}
/// <summary>
/// Point-in-polygon test in the XY plane (ignores Z). Standard ray-casting
/// even-odd rule. Works for convex and concave polygons.
/// </summary>
internal static bool PointInPolygonXY(Vector3 point, Vector3[] vertices)
{
bool inside = false;
int n = vertices.Length;
for (int i = 0, j = n - 1; i < n; j = i++)
{
var vi = vertices[i];
var vj = vertices[j];
if (((vi.Y > point.Y) != (vj.Y > point.Y)) &&
(point.X < (vj.X - vi.X) * (point.Y - vi.Y) / (vj.Y - vi.Y) + vi.X))
{
inside = !inside;
}
}
return inside;
}
/// <summary>
/// Query the outdoor terrain at CheckPos and apply ValidateWalkable logic.
/// Indoor BSP collision is deferred to Task 6c.
@ -1255,6 +1341,39 @@ public sealed class Transition
ci.CollidedWithEnvironment = true;
return cellState;
}
// ── Synthesize indoor walkable contact plane ──────────────
// Indoor walking Phase 2 follow-up (2026-05-19). When the BSP
// returns OK (no wall collision), the player is standing on a
// floor poly inside the cell. We must NOT fall through to
// outdoor terrain (SampleTerrainWalkable) — the outdoor terrain
// Z is below the indoor floor due to the +0.02f Z-bump applied
// for render z-fight prevention. ValidateWalkable would then see
// the player 0.5m above the outdoor plane → marks them as
// airborne → walkable=False → falling animation, never recovers.
//
// Retail: CEnvCell::find_env_collisions returns from the cell
// branch with the cell's walkable plane set — no fall-through
// to terrain.
if (TryFindIndoorWalkablePlane(cellPhysics, localCenter,
out var indoorPlane,
out var indoorVertices,
out uint _))
{
return ValidateWalkable(
footCenter,
sphereRadius,
indoorPlane,
isWater: false,
waterDepth: 0f,
cellId: sp.CheckCellId,
walkableVertices: indoorVertices);
}
// If no walkable floor was found under the player indoors
// (rare — cell with only walls/ceiling), fall through to
// outdoor terrain as a defensive backstop. Indoor walking
// will report walkable=False until the player moves over a
// cell with a proper floor poly.
}
}