Revert "fix(physics): remove per-frame indoor walkable-plane synthesis"
This reverts commit 9f874f4650.
This commit is contained in:
parent
9f874f4650
commit
0a7ce8fd58
4 changed files with 573 additions and 23 deletions
|
|
@ -1152,14 +1152,11 @@ public static class BSPQuery
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Out-of-band "find a walkable plane indoors" entry point for callers
|
||||
/// that genuinely need to query a cell's walkable floor (spawn-placement
|
||||
/// validation, teleport-target verification, future debug overlays).
|
||||
/// NOT called from the per-frame physics resolver — the original
|
||||
/// per-frame caller (TryFindIndoorWalkablePlane) was deleted 2026-05-20
|
||||
/// because retail's BSPTREE::find_collisions does NOT re-synthesize the
|
||||
/// ContactPlane on the OK path. The wrapper is kept here as the
|
||||
/// underlying retail-faithful walkable-finder API.
|
||||
/// Intended call site: indoor walkable-plane synthesis in
|
||||
/// <c>Transition.TryFindIndoorWalkablePlane</c> when the indoor cell-BSP
|
||||
/// collision returns OK (no wall hit) and the resolver still needs a
|
||||
/// ContactPlane to feed ValidateWalkable. Outdoor terrain has its own path
|
||||
/// (<see cref="PhysicsEngine.SampleTerrainWalkable"/>) and does not use this.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
|
|||
|
|
@ -1266,6 +1266,120 @@ public sealed class Transition
|
|||
// Environment collision — outdoor terrain
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Synthesize the indoor walkable contact plane for the player's current
|
||||
/// position when the cell BSP returns OK (no wall collision).
|
||||
///
|
||||
/// <para>
|
||||
/// Routes through the retail-faithful BSP walkable-finder
|
||||
/// (<see cref="BSPQuery.FindWalkableSphere"/>) — which traverses the cell
|
||||
/// PhysicsBSP and picks the polygon closest to the foot along the up vector.
|
||||
/// Phase 2 commit eb0f772 introduced a linear first-match XY scan as a
|
||||
/// stop-gap; that scan picked the wrong floor whenever two polygons
|
||||
/// overlapped in XY at different Z (cellars, 2nd floors, balconies).
|
||||
/// </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>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail oracle: BSPLEAF::find_walkable (acclient_2013_pseudo_c.txt:326793),
|
||||
/// BSPNODE::find_walkable (:326211), CPolygon::walkable_hits_sphere (:323006),
|
||||
/// CPolygon::adjust_sphere_to_plane (:322032).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal bool TryFindIndoorWalkablePlane(
|
||||
CellPhysics cellPhysics,
|
||||
Vector3 localFootCenter,
|
||||
float sphereRadius,
|
||||
out System.Numerics.Plane worldPlane,
|
||||
out Vector3[] worldVertices,
|
||||
out uint hitPolyId)
|
||||
{
|
||||
worldPlane = default;
|
||||
worldVertices = System.Array.Empty<Vector3>();
|
||||
hitPolyId = 0;
|
||||
|
||||
if (cellPhysics.BSP?.Root is null) return false;
|
||||
|
||||
// Build foot sphere in cell-local space. Caller passes localFootCenter
|
||||
// already transformed into cell-local space and the resolver's
|
||||
// foot-sphere radius.
|
||||
var localSphere = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = localFootCenter,
|
||||
Radius = sphereRadius,
|
||||
};
|
||||
|
||||
// Save/restore WalkableAllowance: CPolygon::walkable_hits_sphere reads
|
||||
// path.WalkableAllowance (acclient_2013_pseudo_c.txt:323010). For
|
||||
// "standing here, find my floor" we want the walkability slope
|
||||
// threshold FloorZ. The outer resolver may have set it to LandingZ
|
||||
// (airborne→ground transition) or another value; we must not leak our
|
||||
// change back to the resolver. try/finally so an exception inside
|
||||
// FindWalkableSphere doesn't leak the modified state.
|
||||
float savedWalkableAllowance = this.SpherePath.WalkableAllowance;
|
||||
this.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||
|
||||
ResolvedPolygon? hitPoly = null;
|
||||
ushort hitId = 0;
|
||||
Vector3 adjustedCenter;
|
||||
bool found;
|
||||
|
||||
try
|
||||
{
|
||||
found = BSPQuery.FindWalkableSphere(
|
||||
cellPhysics.BSP.Root,
|
||||
cellPhysics.Resolved,
|
||||
this,
|
||||
localSphere,
|
||||
INDOOR_WALKABLE_PROBE_DISTANCE,
|
||||
Vector3.UnitZ, // local Z is up for indoor cells (identity transform)
|
||||
out hitPoly,
|
||||
out hitId,
|
||||
out adjustedCenter);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.SpherePath.WalkableAllowance = savedWalkableAllowance;
|
||||
}
|
||||
|
||||
// adjustedCenter (sphere slid onto polygon plane) is intentionally
|
||||
// discarded — ValidateWalkable recomputes contact geometry from the
|
||||
// world-space plane + foot position, consistent with the outdoor terrain
|
||||
// path (SampleTerrainWalkable returns only plane + vertices, no adjusted
|
||||
// sphere). The local is held only to satisfy the out param.
|
||||
|
||||
if (!found || hitPoly is null) return false;
|
||||
|
||||
// Transform hit polygon's plane + vertices to world space. Math is
|
||||
// unchanged from the previous TryFindIndoorWalkablePlane implementation.
|
||||
var worldNormal = Vector3.TransformNormal(hitPoly.Plane.Normal, cellPhysics.WorldTransform);
|
||||
worldNormal = Vector3.Normalize(worldNormal);
|
||||
var worldV0 = Vector3.Transform(hitPoly.Vertices[0], cellPhysics.WorldTransform);
|
||||
float worldD = -Vector3.Dot(worldNormal, worldV0);
|
||||
worldPlane = new System.Numerics.Plane(worldNormal, worldD);
|
||||
|
||||
worldVertices = new Vector3[hitPoly.Vertices.Length];
|
||||
for (int i = 0; i < hitPoly.Vertices.Length; i++)
|
||||
worldVertices[i] = Vector3.Transform(hitPoly.Vertices[i], cellPhysics.WorldTransform);
|
||||
|
||||
hitPolyId = hitId;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downward probe distance used by <see cref="TryFindIndoorWalkablePlane"/>
|
||||
/// when scanning for the indoor walkable contact plane. 50 cm.
|
||||
/// Larger than the +0.02f cell-origin Z-bump and larger than any realistic
|
||||
/// step riser; smaller than a full cell height so we don't reach through
|
||||
/// a thin floor into the cell above/below.
|
||||
/// </summary>
|
||||
private const float INDOOR_WALKABLE_PROBE_DISTANCE = 0.5f;
|
||||
|
||||
/// <summary>
|
||||
/// Query the outdoor terrain at CheckPos and apply ValidateWalkable logic.
|
||||
/// Indoor BSP collision is deferred to Task 6c.
|
||||
|
|
@ -1389,22 +1503,59 @@ public sealed class Transition
|
|||
return cellState;
|
||||
}
|
||||
|
||||
// Indoor BSP returned OK — no wall collision. ContactPlane
|
||||
// is RETAINED from the prior tick's seed
|
||||
// (PhysicsEngine.ResolveWithTransition:583, the
|
||||
// init_contact_plane equivalent) OR refreshed by Path 3
|
||||
// step-down / Path 4 land if those fired this tick. Either
|
||||
// way, no synthesis is needed here — matches retail's
|
||||
// BSPTREE::find_collisions OK path
|
||||
// (acclient_2013_pseudo_c.txt:323938).
|
||||
// ── 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.
|
||||
//
|
||||
// Do NOT fall through to outdoor terrain backstop: the
|
||||
// player is in an indoor cell, and the outdoor terrain
|
||||
// Z is below the indoor floor by ~0.02m (the render Z-bump),
|
||||
// which would mark the player as airborne and trigger the
|
||||
// falling-animation stuck symptom (the original Bug A).
|
||||
// 2026-05-20 slice 2 of indoor ContactPlane retention.
|
||||
return TransitionState.OK;
|
||||
// Retail: CEnvCell::find_env_collisions returns from the cell
|
||||
// branch with the cell's walkable plane set — no fall-through
|
||||
// to terrain.
|
||||
bool walkableHit = TryFindIndoorWalkablePlane(
|
||||
cellPhysics, localCenter, sphereRadius,
|
||||
out var indoorPlane,
|
||||
out var indoorVertices,
|
||||
out uint hitPolyId);
|
||||
|
||||
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
{
|
||||
if (walkableHit)
|
||||
{
|
||||
// dz = signed gap between foot and synthesized plane.
|
||||
// Plane: N·p + D = 0 ⇒ pZ_on_plane = -D/N.z (for upward-facing planes)
|
||||
// gap = foot.Z - pZ_on_plane = foot.Z - (-D/N.z) = foot.Z + D/N.z
|
||||
float dz = footCenter.Z + indoorPlane.D / indoorPlane.Normal.Z;
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[indoor-walkable] cell=0x{sp.CheckCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) probe={INDOOR_WALKABLE_PROBE_DISTANCE:F2} result=HIT poly=0x{hitPolyId:X4} wn=({indoorPlane.Normal.X:F3},{indoorPlane.Normal.Y:F3},{indoorPlane.Normal.Z:F3}) wD={indoorPlane.D:F3} dz={dz:+0.00;-0.00;+0.00}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[indoor-walkable] cell=0x{sp.CheckCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) probe={INDOOR_WALKABLE_PROBE_DISTANCE:F2} result=MISS"));
|
||||
}
|
||||
}
|
||||
|
||||
if (walkableHit)
|
||||
{
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
291
tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs
Normal file
291
tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="Transition.TryFindIndoorWalkablePlane"/>.
|
||||
///
|
||||
/// Indoor walking Phase 2 follow-up (2026-05-19): the helper synthesizes
|
||||
/// a walkable contact plane from cell floor polys so the resolver does not
|
||||
/// fall through to outdoor terrain when the player is standing indoors.
|
||||
///
|
||||
/// Task 3 (2026-05-19): refactored to route through BSPQuery.FindWalkableSphere.
|
||||
/// Fixtures now include a PhysicsBSPTree with a Leaf node listing all polygon ids,
|
||||
/// and calls pass sphereRadius explicitly. PointInPolygonXY tests removed since
|
||||
/// that helper was deleted (it was the dead linear-scan body).
|
||||
/// </summary>
|
||||
public class IndoorWalkablePlaneTests
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Build a BSP Leaf node that lists the given polygon ids, with a bounding
|
||||
/// sphere large enough to always contain the test geometry.
|
||||
/// </summary>
|
||||
private static PhysicsBSPTree BuildLeafBsp(IEnumerable<ushort> polyIds,
|
||||
Vector3 center, float radius)
|
||||
{
|
||||
var node = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
BoundingSphere = new Sphere { Origin = center, Radius = radius },
|
||||
};
|
||||
foreach (var id in polyIds)
|
||||
node.Polygons.Add(id);
|
||||
return new PhysicsBSPTree { Root = node };
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// and a BSP leaf that covers all polygons.
|
||||
/// </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,
|
||||
};
|
||||
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon> { [0] = floorPoly };
|
||||
var bsp = BuildLeafBsp(new ushort[] { 0 }, new Vector3(0f, 0f, floorZ), 10f);
|
||||
|
||||
return new CellPhysics
|
||||
{
|
||||
BSP = bsp,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = resolved,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// TryFindIndoorWalkablePlane
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_ReturnsTrue()
|
||||
{
|
||||
var cell = BuildCellWithFloor(floorZ: 0f);
|
||||
var transition = new Transition();
|
||||
// Foot sphere centre at Z=0.4, radius=0.48 → overlaps floor at Z=0.
|
||||
var localFoot = new Vector3(0f, 0f, 0.4f);
|
||||
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cell, localFoot, sphereRadius: 0.48f,
|
||||
out _, out _, out _);
|
||||
|
||||
Assert.True(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneNormalIsUp()
|
||||
{
|
||||
var cell = BuildCellWithFloor(floorZ: 0f);
|
||||
var transition = new Transition();
|
||||
var localFoot = new Vector3(0f, 0f, 0.4f);
|
||||
|
||||
transition.TryFindIndoorWalkablePlane(
|
||||
cell, localFoot, sphereRadius: 0.48f,
|
||||
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 transition = new Transition();
|
||||
// Foot sphere overlaps floor: centre at floorZ + 0.4, radius=0.48 → dist=0.4 < 0.48.
|
||||
var localFoot = new Vector3(0f, 0f, floorZ + 0.4f);
|
||||
|
||||
transition.TryFindIndoorWalkablePlane(
|
||||
cell, localFoot, sphereRadius: 0.48f,
|
||||
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();
|
||||
var transition = new Transition();
|
||||
// XY = (20, 20) is far outside the 10×10 square (-5..5 in both axes).
|
||||
var localFoot = new Vector3(20f, 20f, 0.4f);
|
||||
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cell, localFoot, sphereRadius: 0.48f,
|
||||
out _, out _, out _);
|
||||
|
||||
Assert.False(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_NoBsp_ReturnsFalse()
|
||||
{
|
||||
// CellPhysics without a BSP → BSP?.Root is null → early return false.
|
||||
var cell = new CellPhysics
|
||||
{
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
};
|
||||
var transition = new Transition();
|
||||
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cell, new Vector3(0f, 0f, 0.4f), sphereRadius: 0.48f,
|
||||
out _, out _, out _);
|
||||
|
||||
Assert.False(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_WallPolyInBsp_ReturnsFalse()
|
||||
{
|
||||
// A polygon with a horizontal normal (Z = 0) is a wall, not a floor.
|
||||
// walkable_hits_sphere rejects it: dp = dot(UnitZ, (0,1,0)) = 0 <= FloorZ.
|
||||
// Regression coverage for the previous NoWalkablePolys_ReturnsFalse intent
|
||||
// (the renamed NoBsp_ReturnsFalse only covers the null-BSP early-return).
|
||||
Vector3[] wallVerts =
|
||||
{
|
||||
new Vector3(0f, 0f, 0f),
|
||||
new Vector3(1f, 0f, 0f),
|
||||
new Vector3(1f, 0f, 1f),
|
||||
new Vector3(0f, 0f, 1f),
|
||||
};
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon>
|
||||
{
|
||||
[0] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = wallVerts,
|
||||
Plane = new Plane(new Vector3(0f, 1f, 0f), 0f), // wall facing +Y
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
},
|
||||
};
|
||||
|
||||
var center = new Vector3(0.5f, 0f, 0.5f);
|
||||
var bsp = BuildLeafBsp(new ushort[] { 0 }, center, 2f);
|
||||
|
||||
var cell = new CellPhysics
|
||||
{
|
||||
BSP = bsp,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = resolved,
|
||||
};
|
||||
|
||||
var transition = new Transition();
|
||||
transition.SpherePath.WalkInterp = 1.0f;
|
||||
|
||||
// Foot sphere positioned to overlap the wall's plane (|Y - 0| = 0 < radius 0.48).
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cell,
|
||||
localFootCenter: new Vector3(0.5f, 0f, 0.5f),
|
||||
sphereRadius: 0.48f,
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
|
||||
Assert.False(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_EmptyResolved_ReturnsFalse()
|
||||
{
|
||||
// BSP leaf exists but references no polygons → FindWalkableSphere returns false.
|
||||
var bsp = BuildLeafBsp(System.Array.Empty<ushort>(), Vector3.Zero, 10f);
|
||||
var cell = new CellPhysics
|
||||
{
|
||||
BSP = bsp,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
};
|
||||
var transition = new Transition();
|
||||
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cell, new Vector3(0f, 0f, 0.4f), sphereRadius: 0.48f,
|
||||
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 resolved = new Dictionary<ushort, ResolvedPolygon> { [0] = floorPoly };
|
||||
var bsp = BuildLeafBsp(new ushort[] { 0 }, Vector3.Zero, 10f);
|
||||
|
||||
var cell = new CellPhysics
|
||||
{
|
||||
BSP = bsp,
|
||||
WorldTransform = translation,
|
||||
InverseWorldTransform = inv,
|
||||
Resolved = resolved,
|
||||
};
|
||||
|
||||
// The player's local foot sphere centre at (0,0,0.4) overlaps the floor at Z=0.
|
||||
var localFoot = new Vector3(0f, 0f, 0.4f);
|
||||
var transition = new Transition();
|
||||
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cell, localFoot, sphereRadius: 0.48f,
|
||||
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}");
|
||||
}
|
||||
}
|
||||
111
tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs
Normal file
111
tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
using System.Numerics;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
using Plane = System.Numerics.Plane;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public class TransitionTypesTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_TwoOverlappingFloors_PicksClosestBelowFoot_PreservesAllowance()
|
||||
{
|
||||
// Build a CellPhysics with two horizontal walkable polygons at
|
||||
// local Z=0 and Z=3, both covering the unit square X[0..1] × Y[0..1].
|
||||
// Foot sphere at local Z=0.4 → sphere overlaps the Z=0 polygon
|
||||
// (|0.4| < radius 0.48); Z=3 is out of range. Expect the lower poly
|
||||
// to be returned. Sentinel WalkableAllowance value must be preserved
|
||||
// across the call.
|
||||
|
||||
var cellPhysics = BuildTwoFloorCellPhysics(lowerZ: 0f, upperZ: 3f);
|
||||
|
||||
var transition = new Transition();
|
||||
const float sentinelAllowance = 0.42f;
|
||||
transition.SpherePath.WalkableAllowance = sentinelAllowance;
|
||||
transition.SpherePath.WalkInterp = 1.0f;
|
||||
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cellPhysics,
|
||||
localFootCenter: new Vector3(0.5f, 0.5f, 0.4f),
|
||||
sphereRadius: 0.48f,
|
||||
out var worldPlane,
|
||||
out var worldVertices,
|
||||
out var hitPolyId);
|
||||
|
||||
Assert.True(found);
|
||||
// Lower polygon's local plane Normal.Z = 1.0; identity world transform
|
||||
// means world Normal.Z is also 1.0.
|
||||
Assert.Equal(1.0f, worldPlane.Normal.Z, precision: 3);
|
||||
// World vertices match the lower polygon (Z=0 in world space, identity transform).
|
||||
Assert.Equal(4, worldVertices.Length);
|
||||
Assert.Equal(0f, worldVertices[0].Z, precision: 3);
|
||||
// hitPolyId is the dictionary key — lower polygon was inserted as key 0.
|
||||
Assert.Equal(0u, hitPolyId);
|
||||
// WalkableAllowance must be restored to the sentinel.
|
||||
Assert.Equal(sentinelAllowance, transition.SpherePath.WalkableAllowance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a minimal CellPhysics with two horizontal walkable polygons at
|
||||
/// local Z=lowerZ and Z=upperZ. Identity world transform so world == local.
|
||||
/// </summary>
|
||||
private static CellPhysics BuildTwoFloorCellPhysics(float lowerZ, float upperZ)
|
||||
{
|
||||
Vector3[] lowerVerts =
|
||||
{
|
||||
new Vector3(0f, 0f, lowerZ),
|
||||
new Vector3(1f, 0f, lowerZ),
|
||||
new Vector3(1f, 1f, lowerZ),
|
||||
new Vector3(0f, 1f, lowerZ),
|
||||
};
|
||||
Vector3[] upperVerts =
|
||||
{
|
||||
new Vector3(0f, 0f, upperZ),
|
||||
new Vector3(1f, 0f, upperZ),
|
||||
new Vector3(1f, 1f, upperZ),
|
||||
new Vector3(0f, 1f, upperZ),
|
||||
};
|
||||
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon>
|
||||
{
|
||||
[0] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = lowerVerts,
|
||||
Plane = new Plane(Vector3.UnitZ, -lowerZ),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
},
|
||||
[1] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = upperVerts,
|
||||
Plane = new Plane(Vector3.UnitZ, -upperZ),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
},
|
||||
};
|
||||
|
||||
var center = new Vector3(0.5f, 0.5f, (lowerZ + upperZ) * 0.5f);
|
||||
float halfHeight = MathF.Abs(upperZ - lowerZ) * 0.5f + 1.0f;
|
||||
float radius = MathF.Sqrt(0.5f * 0.5f + 0.5f * 0.5f + halfHeight * halfHeight);
|
||||
|
||||
var root = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
BoundingSphere = new Sphere { Origin = center, Radius = radius },
|
||||
};
|
||||
root.Polygons.Add(0);
|
||||
root.Polygons.Add(1);
|
||||
|
||||
var bsp = new PhysicsBSPTree { Root = root };
|
||||
|
||||
return new CellPhysics
|
||||
{
|
||||
BSP = bsp,
|
||||
Resolved = resolved,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue