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:
parent
3ffe1e44f6
commit
eb0f772f0f
2 changed files with 359 additions and 0 deletions
|
|
@ -1166,6 +1166,92 @@ public sealed class Transition
|
||||||
// Environment collision — outdoor terrain
|
// 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 >= 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>
|
/// <summary>
|
||||||
/// Query the outdoor terrain at CheckPos and apply ValidateWalkable logic.
|
/// Query the outdoor terrain at CheckPos and apply ValidateWalkable logic.
|
||||||
/// Indoor BSP collision is deferred to Task 6c.
|
/// Indoor BSP collision is deferred to Task 6c.
|
||||||
|
|
@ -1255,6 +1341,39 @@ public sealed class Transition
|
||||||
ci.CollidedWithEnvironment = true;
|
ci.CollidedWithEnvironment = true;
|
||||||
return cellState;
|
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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
240
tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs
Normal file
240
tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using DatReaderWriter.Enums;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="Transition.TryFindIndoorWalkablePlane"/> and
|
||||||
|
/// <see cref="Transition.PointInPolygonXY"/>.
|
||||||
|
///
|
||||||
|
/// Indoor walking Phase 2 follow-up (2026-05-19): these helpers synthesize
|
||||||
|
/// a walkable contact plane from cell floor polys so the resolver does not
|
||||||
|
/// fall through to outdoor terrain when the player is standing indoors.
|
||||||
|
/// </summary>
|
||||||
|
public class IndoorWalkablePlaneTests
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a CellPhysics with a single upward-facing floor polygon
|
||||||
|
/// (a 10×10 square in the XY plane at local Z=0), plus identity transforms.
|
||||||
|
/// </summary>
|
||||||
|
private static CellPhysics BuildCellWithFloor(float floorZ = 0f)
|
||||||
|
{
|
||||||
|
var verts = new[]
|
||||||
|
{
|
||||||
|
new Vector3(-5f, -5f, floorZ),
|
||||||
|
new Vector3( 5f, -5f, floorZ),
|
||||||
|
new Vector3( 5f, 5f, floorZ),
|
||||||
|
new Vector3(-5f, 5f, floorZ),
|
||||||
|
};
|
||||||
|
var normal = new Vector3(0f, 0f, 1f); // straight up
|
||||||
|
float D = -Vector3.Dot(normal, verts[0]); // = -floorZ
|
||||||
|
|
||||||
|
var floorPoly = new ResolvedPolygon
|
||||||
|
{
|
||||||
|
Vertices = verts,
|
||||||
|
Plane = new Plane(normal, D),
|
||||||
|
NumPoints = 4,
|
||||||
|
SidesType = CullMode.None,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new CellPhysics
|
||||||
|
{
|
||||||
|
WorldTransform = Matrix4x4.Identity,
|
||||||
|
InverseWorldTransform = Matrix4x4.Identity,
|
||||||
|
Resolved = new Dictionary<ushort, ResolvedPolygon> { [0] = floorPoly },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// TryFindIndoorWalkablePlane
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var cell = BuildCellWithFloor(floorZ: 0f);
|
||||||
|
var localFoot = new Vector3(0f, 0f, 0.5f); // centred over the 10×10 square
|
||||||
|
|
||||||
|
bool found = Transition.TryFindIndoorWalkablePlane(
|
||||||
|
cell, localFoot,
|
||||||
|
out var plane, out var verts, out uint polyId);
|
||||||
|
|
||||||
|
Assert.True(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneNormalIsUp()
|
||||||
|
{
|
||||||
|
var cell = BuildCellWithFloor(floorZ: 0f);
|
||||||
|
var localFoot = new Vector3(0f, 0f, 0.5f);
|
||||||
|
|
||||||
|
Transition.TryFindIndoorWalkablePlane(
|
||||||
|
cell, localFoot, out var plane, out _, out _);
|
||||||
|
|
||||||
|
// The floor's normal must point up (Z close to 1).
|
||||||
|
Assert.True(plane.Normal.Z > 0.99f,
|
||||||
|
$"Expected plane.Normal.Z > 0.99, got {plane.Normal.Z}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneAtFloorZ()
|
||||||
|
{
|
||||||
|
const float floorZ = 2.5f;
|
||||||
|
var cell = BuildCellWithFloor(floorZ);
|
||||||
|
var localFoot = new Vector3(0f, 0f, floorZ + 0.5f);
|
||||||
|
|
||||||
|
Transition.TryFindIndoorWalkablePlane(
|
||||||
|
cell, localFoot, out var plane, out _, out _);
|
||||||
|
|
||||||
|
// With identity transform and an upward normal, plane.D = -floorZ.
|
||||||
|
// The plane equation: normal·p + D = 0 → p.Z = floorZ when normal=(0,0,1).
|
||||||
|
Assert.True(MathF.Abs(plane.D - (-floorZ)) < 1e-4f,
|
||||||
|
$"Expected plane.D ≈ {-floorZ}, got {plane.D}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryFindIndoorWalkablePlane_PlayerOutsidePolygonXY_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var cell = BuildCellWithFloor();
|
||||||
|
// XY = (20, 20) is far outside the 10×10 square (-5..5 in both axes).
|
||||||
|
var localFoot = new Vector3(20f, 20f, 0.5f);
|
||||||
|
|
||||||
|
bool found = Transition.TryFindIndoorWalkablePlane(
|
||||||
|
cell, localFoot, out _, out _, out _);
|
||||||
|
|
||||||
|
Assert.False(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryFindIndoorWalkablePlane_NoWalkablePolys_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// A polygon whose normal points sideways (wall) — normal.Z < 0.6664.
|
||||||
|
var wallPoly = new ResolvedPolygon
|
||||||
|
{
|
||||||
|
Vertices = new[] { Vector3.Zero, Vector3.UnitY, Vector3.UnitZ },
|
||||||
|
Plane = new Plane(new Vector3(1f, 0f, 0f), 0f), // normal.Z = 0
|
||||||
|
NumPoints = 3,
|
||||||
|
SidesType = CullMode.None,
|
||||||
|
};
|
||||||
|
var cell = new CellPhysics
|
||||||
|
{
|
||||||
|
WorldTransform = Matrix4x4.Identity,
|
||||||
|
InverseWorldTransform = Matrix4x4.Identity,
|
||||||
|
Resolved = new Dictionary<ushort, ResolvedPolygon> { [1] = wallPoly },
|
||||||
|
};
|
||||||
|
|
||||||
|
bool found = Transition.TryFindIndoorWalkablePlane(
|
||||||
|
cell, new Vector3(0f, 0f, 0.5f), out _, out _, out _);
|
||||||
|
|
||||||
|
Assert.False(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryFindIndoorWalkablePlane_EmptyResolved_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var cell = new CellPhysics
|
||||||
|
{
|
||||||
|
WorldTransform = Matrix4x4.Identity,
|
||||||
|
InverseWorldTransform = Matrix4x4.Identity,
|
||||||
|
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
bool found = Transition.TryFindIndoorWalkablePlane(
|
||||||
|
cell, new Vector3(0f, 0f, 0.5f), out _, out _, out _);
|
||||||
|
|
||||||
|
Assert.False(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryFindIndoorWalkablePlane_WithWorldTranslation_PlaneInWorldSpace()
|
||||||
|
{
|
||||||
|
// Cell is translated 100 units in X and 200 units in Y.
|
||||||
|
var translation = Matrix4x4.CreateTranslation(100f, 200f, 94f);
|
||||||
|
Matrix4x4.Invert(translation, out var inv);
|
||||||
|
|
||||||
|
var localVerts = new[]
|
||||||
|
{
|
||||||
|
new Vector3(-5f, -5f, 0f),
|
||||||
|
new Vector3( 5f, -5f, 0f),
|
||||||
|
new Vector3( 5f, 5f, 0f),
|
||||||
|
new Vector3(-5f, 5f, 0f),
|
||||||
|
};
|
||||||
|
var floorPoly = new ResolvedPolygon
|
||||||
|
{
|
||||||
|
Vertices = localVerts,
|
||||||
|
Plane = new Plane(new Vector3(0f, 0f, 1f), 0f),
|
||||||
|
NumPoints = 4,
|
||||||
|
SidesType = CullMode.None,
|
||||||
|
};
|
||||||
|
var cell = new CellPhysics
|
||||||
|
{
|
||||||
|
WorldTransform = translation,
|
||||||
|
InverseWorldTransform = inv,
|
||||||
|
Resolved = new Dictionary<ushort, ResolvedPolygon> { [0] = floorPoly },
|
||||||
|
};
|
||||||
|
|
||||||
|
// The player's local foot is at (0,0,0.5) in local space.
|
||||||
|
var localFoot = new Vector3(0f, 0f, 0.5f);
|
||||||
|
|
||||||
|
bool found = Transition.TryFindIndoorWalkablePlane(
|
||||||
|
cell, localFoot, out var plane, out var worldVerts, out _);
|
||||||
|
|
||||||
|
Assert.True(found);
|
||||||
|
// World normal should still be (0,0,1).
|
||||||
|
Assert.True(plane.Normal.Z > 0.99f);
|
||||||
|
// World vertex[0] should be at local (-5,-5,0) + translation = (95, 195, 94).
|
||||||
|
Assert.True(MathF.Abs(worldVerts[0].X - 95f) < 1e-3f);
|
||||||
|
Assert.True(MathF.Abs(worldVerts[0].Y - 195f) < 1e-3f);
|
||||||
|
Assert.True(MathF.Abs(worldVerts[0].Z - 94f) < 1e-3f,
|
||||||
|
$"Expected worldVerts[0].Z ≈ 94, got {worldVerts[0].Z}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// PointInPolygonXY
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData( 0f, 0f, true)] // centre
|
||||||
|
[InlineData( 4f, 4f, true)] // near corner, inside
|
||||||
|
[InlineData( 5f, 5f, false)] // on the corner — outside by convention
|
||||||
|
[InlineData(10f, 0f, false)] // clearly outside
|
||||||
|
[InlineData(-4f, -4f, true)] // near opposite corner, inside
|
||||||
|
public void PointInPolygonXY_UnitSquare(float px, float py, bool expected)
|
||||||
|
{
|
||||||
|
var square = new[]
|
||||||
|
{
|
||||||
|
new Vector3(-5f, -5f, 0f),
|
||||||
|
new Vector3( 5f, -5f, 0f),
|
||||||
|
new Vector3( 5f, 5f, 0f),
|
||||||
|
new Vector3(-5f, 5f, 0f),
|
||||||
|
};
|
||||||
|
bool result = Transition.PointInPolygonXY(new Vector3(px, py, 99f), square);
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PointInPolygonXY_IgnoresZ()
|
||||||
|
{
|
||||||
|
// Same XY, different Z — should still be inside.
|
||||||
|
var square = new[]
|
||||||
|
{
|
||||||
|
new Vector3(-5f, -5f, 0f),
|
||||||
|
new Vector3( 5f, -5f, 0f),
|
||||||
|
new Vector3( 5f, 5f, 0f),
|
||||||
|
new Vector3(-5f, 5f, 0f),
|
||||||
|
};
|
||||||
|
// Point has the same XY as the inside case but a very different Z.
|
||||||
|
bool atLowZ = Transition.PointInPolygonXY(new Vector3(0f, 0f, -1000f), square);
|
||||||
|
bool atHighZ = Transition.PointInPolygonXY(new Vector3(0f, 0f, 1000f), square);
|
||||||
|
|
||||||
|
Assert.True(atLowZ);
|
||||||
|
Assert.True(atHighZ);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue