feat(A): wire SweepEye to the verbatim update_viewer (start-cell + fallbacks)
Complete Render Residual A's faithful port: PhysicsCameraCollisionProbe.SweepEye now mirrors SmartBox::update_viewer (acclient_2013_pseudo_c.txt:92761) end-to-end: - Start cell (pc:92824-92844): indoor (>=0x100) seats the sweep at the head-PIVOT via PhysicsEngine.AdjustPosition (the cellar-lip case — feet in the low connector, head up at floor level); outdoor keeps the player cell. - Sweep pivot -> sought-eye from the seated start cell (unchanged 0x5c viewer flags). - Success (pc:92870): set_viewer(curr_pos), viewer_cell = curr_cell. - Fallback 1 (pc:92878): AdjustPosition(sought_eye). - Fallback 2 / no-cell (pc:92775, 92886): snap to player, viewer_cell = null. This also makes cellId==0 faithful (was returning the desired eye; retail snaps to player_pos) and adds the playerPos arg to ICameraCollisionProbe.SweepEye. Supporting: ResolveResult.Ok surfaces FindTransitionalPosition's return (retail find_valid_position != 0, pc:273898) so SweepEye knows when to fall back. TDD: 11 new tests (FindVisibleChildCell 4, AdjustPosition 3, ResolveResult.Ok 2, SweepEye orchestration 2). The seating test's RED proved the sweep does NOT auto- advance feet->room, so the pivot-seated start cell is genuinely decisive. Core 1326 pass / 4 documented-fail / 1 skip; App 179 pass / 0 fail. No regression. Per the live-capture finding, the visible payoff is the cellar-corner (point 3); the cottage-room bluish void stays for residual C. Spec: docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5177b54bbe
commit
9e70031bc6
10 changed files with 234 additions and 44 deletions
|
|
@ -21,9 +21,11 @@ public interface ICameraCollisionProbe
|
|||
/// <summary>
|
||||
/// Roll a collision sphere from <paramref name="pivot"/> to
|
||||
/// <paramref name="desiredEye"/>; return the position it reaches without
|
||||
/// penetrating geometry AND the cell it ended in. Returns
|
||||
/// <paramref name="desiredEye"/> + <paramref name="cellId"/> unchanged
|
||||
/// when nothing blocks the path or when <paramref name="cellId"/> is 0.
|
||||
/// penetrating geometry AND the cell it ended in. Mirrors retail
|
||||
/// <c>SmartBox::update_viewer</c>: when <paramref name="cellId"/> is indoor the
|
||||
/// sweep's start cell is seated at the pivot, and when there is no start cell or
|
||||
/// the sweep fails the eye snaps to <paramref name="playerPos"/> (retail
|
||||
/// <c>set_viewer(player_pos)</c>, viewer cell null).
|
||||
/// </summary>
|
||||
CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId);
|
||||
CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,22 +21,35 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
|
|||
|
||||
public PhysicsCameraCollisionProbe(PhysicsEngine physics) => _physics = physics;
|
||||
|
||||
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
|
||||
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
|
||||
{
|
||||
// No starting cell → nothing to sweep against; keep the desired eye + cell.
|
||||
if (cellId == 0) return new CameraSweepResult(desiredEye, cellId);
|
||||
// update_viewer: player->cell == 0 → set_viewer(player_pos, 1), viewer_cell = null
|
||||
// (acclient_2013_pseudo_c.txt:92775). No cell to sweep against → snap to the player.
|
||||
if (cellId == 0) return new CameraSweepResult(playerPos, 0u);
|
||||
|
||||
// SpherePath.InitPath puts sphere0's center at pathPos + (0,0,radius)
|
||||
// (the player foot-capsule convention). Retail's viewer_sphere center is
|
||||
// (0,0,0), so shift the path DOWN by the radius to make the SPHERE CENTER
|
||||
// travel pivot→eye, then add it back to the swept stop position.
|
||||
// === Start cell (pc:92824-92844) ===
|
||||
// Indoor (objcell_id >= 0x100): seat the sweep's start cell at the head-PIVOT via
|
||||
// CPhysicsObj::AdjustPosition (pc:92832) — the head can sit in a different cell than
|
||||
// the feet (the cellar lip: feet in the low connector, head up at floor level). On
|
||||
// failure retail falls back to player->cell. Outdoor: cell = player->cell (no AdjustPosition).
|
||||
uint startCell = cellId;
|
||||
if ((cellId & 0xFFFFu) >= 0x0100u)
|
||||
{
|
||||
var (pivotCell, found) = _physics.AdjustPosition(cellId, pivot);
|
||||
if (found) startCell = pivotCell;
|
||||
}
|
||||
|
||||
// === Sweep the viewer_sphere pivot → sought-eye from the start cell (pc:92860-92868) ===
|
||||
// SpherePath.InitPath puts sphere0's center at pathPos + (0,0,radius) (the player
|
||||
// foot-capsule convention). Retail's viewer_sphere center is (0,0,0), so shift the
|
||||
// path DOWN by the radius to make the SPHERE CENTER travel pivot→eye, then add it back.
|
||||
Vector3 begin = ToSpherePath(pivot, ViewerSphereRadius);
|
||||
Vector3 end = ToSpherePath(desiredEye, ViewerSphereRadius);
|
||||
|
||||
var r = _physics.ResolveWithTransition(
|
||||
currentPos: begin,
|
||||
targetPos: end,
|
||||
cellId: cellId,
|
||||
cellId: startCell,
|
||||
sphereRadius: ViewerSphereRadius,
|
||||
sphereHeight: 0f, // single sphere (no head sphere)
|
||||
stepUpHeight: 0f, // no step-up for a camera
|
||||
|
|
@ -58,32 +71,34 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
|
|||
|
||||
Vector3 eye = FromSpherePath(r.Position, ViewerSphereRadius);
|
||||
|
||||
// Phase U.4c spike apparatus (THROWAWAY — strip with ACDREAM_PROBE_FLAP).
|
||||
// The post-fix [flap-cam] capture shows the eye flying to full chase distance
|
||||
// (eyeInRoot=n ~90%) in cells like 0xA9B40174/0175 — i.e. this sweep is not
|
||||
// stopping it. This line answers WHY, the fork that picks the primary residual
|
||||
// fix: pulledIn≈0 with resolved=Y bsp=ok ⇒ the sweep ran but found NOTHING in
|
||||
// that cell (space genuinely open, or wall geometry the per-cell sweep can't
|
||||
// reach → clip-robustness is primary); resolved=n / bsp=nobsp/noroot ⇒ collision
|
||||
// can't even run there (cell/BSP not loaded → camera-collision reliability is
|
||||
// primary); pulledIn large ⇒ collision IS engaging (eye leaving is then expected
|
||||
// through an opening). Paired per-frame with the builder's [flap]/[flap-cam].
|
||||
// [flap-sweep] camera-collision probe (ACDREAM_PROBE_FLAP), paired with the
|
||||
// builder's [flap]/[flap-cam]. start = the pivot-seated start cell (vs cell = the
|
||||
// player feet cell); ok = the sweep found a valid position (find_valid_position != 0).
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
|
||||
{
|
||||
var cp = _physics.DataCache?.GetCellStruct(cellId);
|
||||
var cp = _physics.DataCache?.GetCellStruct(startCell);
|
||||
string bsp = cp?.BSP is null ? "nobsp" : (cp.BSP.Root is null ? "noroot" : "ok");
|
||||
float desiredBack = Vector3.Distance(pivot, desiredEye);
|
||||
float eyeBack = Vector3.Distance(pivot, eye);
|
||||
System.Console.WriteLine(
|
||||
$"[flap-sweep] cell=0x{cellId:X8} resolved={(cp is not null ? "Y" : "n")} bsp={bsp} " +
|
||||
$"[flap-sweep] cell=0x{cellId:X8} start=0x{startCell:X8} ok={r.Ok} resolved={(cp is not null ? "Y" : "n")} bsp={bsp} " +
|
||||
$"desiredBack={desiredBack:F2} eyeBack={eyeBack:F2} pulledIn={desiredBack - eyeBack:F2} " +
|
||||
$"collNormValid={r.CollisionNormalValid}");
|
||||
$"viewerCell=0x{r.CellId:X8} collNormValid={r.CollisionNormalValid}");
|
||||
}
|
||||
|
||||
// Phase W single-viewpoint V1 (2026-06-03): surface the swept cell (r.CellId =
|
||||
// sp.CurCellId) as the viewer cell — retail viewer_cell = sphere_path.curr_cell
|
||||
// (update_viewer pc:92871). Graph-tracked, no AABB/grace → the U.4c flap source is gone.
|
||||
return new CameraSweepResult(eye, r.CellId);
|
||||
// success: set_viewer(curr_pos, 0); viewer_cell = sphere_path.curr_cell (pc:92870-92871).
|
||||
// Graph-tracked, no AABB/grace.
|
||||
if (r.Ok) return new CameraSweepResult(eye, r.CellId);
|
||||
|
||||
// === Fallback 1 (pc:92878-92883): AdjustPosition at the sought eye ===
|
||||
// The sweep found no valid position; try to seat the eye at its own cell.
|
||||
// (Seed with the player cell — acdream's camera doesn't track the sought-eye's
|
||||
// cell separately; the eye is near the player so its stab-list is the right one.)
|
||||
var (eyeCell, eyeFound) = _physics.AdjustPosition(cellId, desiredEye);
|
||||
if (eyeFound) return new CameraSweepResult(desiredEye, eyeCell);
|
||||
|
||||
// === Fallback 2 (pc:92886-92887): set_viewer(player_pos), viewer_cell = null ===
|
||||
return new CameraSweepResult(playerPos, 0u);
|
||||
}
|
||||
|
||||
/// <summary>Eye/pivot point → InitPath path point (subtract the sphere-center offset).</summary>
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ public sealed class RetailChaseCamera : ICamera
|
|||
ViewerCellId = cellId;
|
||||
if (CameraDiagnostics.CollideCamera && CollisionProbe is not null)
|
||||
{
|
||||
var swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId);
|
||||
var swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId, playerPosition);
|
||||
publishedEye = swept.Eye;
|
||||
ViewerCellId = swept.ViewerCellId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -965,7 +965,8 @@ public sealed class PhysicsEngine
|
|||
sp.CurCellId != 0 ? sp.CurCellId : partialCellId,
|
||||
partialOnGround,
|
||||
collisionNormalValid,
|
||||
collisionNormal);
|
||||
collisionNormal,
|
||||
Ok: false); // Render Residual A — the sweep failed (find_valid_position == 0)
|
||||
}
|
||||
|
||||
// A6.P3 #98 capture: emit one JSON Lines record per player call,
|
||||
|
|
|
|||
|
|
@ -27,4 +27,11 @@ public readonly record struct ResolveResult(
|
|||
bool CollisionNormalValid = false,
|
||||
/// <summary>Outward surface normal of the wall the sphere hit. Used
|
||||
/// by the velocity-reflection step. Pointing away from the wall.</summary>
|
||||
Vector3 CollisionNormal = default);
|
||||
Vector3 CollisionNormal = default,
|
||||
/// <summary>Render Residual A — whether the underlying
|
||||
/// <c>FindTransitionalPosition</c> found a valid position (retail
|
||||
/// <c>find_valid_position != 0</c>, pc:273898). False when the sweep had no
|
||||
/// start cell or was immediately stuck. The camera <c>SweepEye</c> reads this
|
||||
/// to trigger <c>SmartBox::update_viewer</c>'s fallbacks. Default <c>true</c>
|
||||
/// so existing callers are unaffected.</summary>
|
||||
bool Ok = true);
|
||||
|
|
|
|||
|
|
@ -140,7 +140,8 @@ public class CameraCollisionIndoorTests
|
|||
pivot: PivotWorld,
|
||||
desiredEye: DesiredEye,
|
||||
cellId: IndoorCellId,
|
||||
selfEntityId: 0u).Eye;
|
||||
selfEntityId: 0u,
|
||||
playerPos: PivotWorld - new Vector3(0f, 0f, 1.5f)).Eye;
|
||||
|
||||
// The eye should be stopped before the exterior wall at Y=4.0.
|
||||
// Expected stopped eye Y ≈ 4.0 - ViewerSphereRadius = 3.7.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Render Residual A — the verbatim <c>SmartBox::update_viewer</c>
|
||||
/// (<c>acclient_2013_pseudo_c.txt:92761</c>) orchestration in
|
||||
/// <see cref="PhysicsCameraCollisionProbe.SweepEye"/>: seat the sweep's start
|
||||
/// cell at the head-PIVOT when indoor (via <c>AdjustPosition</c> →
|
||||
/// <c>find_visible_child_cell</c>), and snap the eye to the player when there is
|
||||
/// no start cell / the sweep fails.
|
||||
/// </summary>
|
||||
public class CameraCollisionUpdateViewerTests
|
||||
{
|
||||
private const uint FeetCellId = 0xA9B40174u; // cellar (low ≥ 0x0100 → indoor), interior Z ≤ 94
|
||||
private const uint RoomCellId = 0xA9B40171u; // cottage floor above, interior Z ≥ 94, in feet cell's stab list
|
||||
private const uint LandblockId = 0xA9B40000u;
|
||||
|
||||
/// <summary>
|
||||
/// The cellar-lip case (user point 3): the player's FEET are in the low cellar
|
||||
/// cell, but the head-PIVOT is up at cottage-floor level in a different cell.
|
||||
/// Retail seats the sweep at the pivot's cell (<c>AdjustPosition</c>, pc:92832),
|
||||
/// so the viewer cell is the room above — NOT the feet cell. Without the start-cell
|
||||
/// port the sweep stays in the feet cell.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SweepEye_IndoorPivotInCellAboveFeet_SeatsStartAtPivotCell()
|
||||
{
|
||||
var engine = BuildTwoCellEngine();
|
||||
var probe = new PhysicsCameraCollisionProbe(engine);
|
||||
|
||||
var feet = new Vector3(0f, 0f, 93f); // in the feet cell (Z ≤ 94)
|
||||
var pivot = new Vector3(0f, 0f, 94.5f); // head, up in the room cell (Z ≥ 94)
|
||||
var eye = new Vector3(0f, 3f, 95.5f); // behind + up, still in the room region, no wall
|
||||
|
||||
var result = probe.SweepEye(pivot, eye, cellId: FeetCellId, selfEntityId: 0u, playerPos: feet);
|
||||
|
||||
Assert.Equal(RoomCellId, result.ViewerCellId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>update_viewer</c> snaps the viewer to the player position when the
|
||||
/// player has no cell (pc:92775) and as fallback 2 when the sweep fails
|
||||
/// (pc:92886): <c>set_viewer(player_pos); viewer_cell = null</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SweepEye_NoStartCell_SnapsToPlayer()
|
||||
{
|
||||
var probe = new PhysicsCameraCollisionProbe(new PhysicsEngine());
|
||||
|
||||
var player = new Vector3(7f, 8f, 9f);
|
||||
var result = probe.SweepEye(
|
||||
pivot: new Vector3(7f, 8f, 10.5f), desiredEye: new Vector3(7f, 13f, 11f),
|
||||
cellId: 0u, selfEntityId: 0u, playerPos: player);
|
||||
|
||||
Assert.Equal(player, result.Eye);
|
||||
Assert.Equal(0u, result.ViewerCellId);
|
||||
}
|
||||
|
||||
// ── fixture ────────────────────────────────────────────────────────────
|
||||
|
||||
private static PhysicsEngine BuildTwoCellEngine()
|
||||
{
|
||||
var cache = new PhysicsDataCache();
|
||||
var engine = new PhysicsEngine { DataCache = cache };
|
||||
|
||||
// Feet cell: interior Z ≤ 94, in its stab list the room cell above. No portals
|
||||
// (so the collision sweep cannot transit to the room — the start cell is decisive).
|
||||
cache.RegisterCellStructForTest(FeetCellId, MakeCell(InteriorZAtMost(94f), new uint[] { RoomCellId }));
|
||||
// Room cell: interior Z ≥ 94, no walls, no portals.
|
||||
cache.RegisterCellStructForTest(RoomCellId, MakeCell(InteriorZAtLeast(94f), Array.Empty<uint>()));
|
||||
|
||||
var heights = new byte[81];
|
||||
var heightTable = new float[256];
|
||||
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
|
||||
engine.AddLandblock(
|
||||
landblockId: LandblockId,
|
||||
terrain: new TerrainSurface(heights, heightTable),
|
||||
cells: Array.Empty<CellSurface>(),
|
||||
portals: Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static CellBSPNode InteriorZAtMost(float boundary) => new()
|
||||
{
|
||||
SplittingPlane = new Plane(new Vector3(0f, 0f, -1f), boundary), // dist = boundary − Z ≥ 0 ⇔ Z ≤ boundary
|
||||
PosNode = new CellBSPNode { Type = BSPNodeType.Leaf },
|
||||
};
|
||||
|
||||
private static CellBSPNode InteriorZAtLeast(float boundary) => new()
|
||||
{
|
||||
SplittingPlane = new Plane(new Vector3(0f, 0f, 1f), -boundary), // dist = Z − boundary ≥ 0 ⇔ Z ≥ boundary
|
||||
PosNode = new CellBSPNode { Type = BSPNodeType.Leaf },
|
||||
};
|
||||
|
||||
private static CellPhysics MakeCell(CellBSPNode cellBspRoot, uint[] visibleCellIds) => new()
|
||||
{
|
||||
BSP = new PhysicsBSPTree { Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf } },
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
CellBSP = new CellBSPTree { Root = cellBspRoot },
|
||||
Portals = Array.Empty<PortalInfo>(),
|
||||
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
VisibleCellIds = new HashSet<uint>(visibleCellIds),
|
||||
};
|
||||
}
|
||||
|
|
@ -27,18 +27,20 @@ public class PhysicsCameraCollisionProbeTests
|
|||
Assert.Equal(p.Z, back.Z, 5);
|
||||
}
|
||||
|
||||
// cellId == 0 means "no starting cell" — the probe must short-circuit and
|
||||
// return the desired eye without touching the engine.
|
||||
// cellId == 0 means "no starting cell" — retail update_viewer snaps the viewer
|
||||
// to the player position (set_viewer(player_pos), viewer_cell = null; pc:92775),
|
||||
// so the probe must short-circuit to playerPos without touching the engine.
|
||||
[Fact]
|
||||
public void SweepEye_NoStartingCell_ReturnsDesiredEyeUnchanged()
|
||||
public void SweepEye_NoStartingCell_SnapsToPlayer()
|
||||
{
|
||||
var probe = new PhysicsCameraCollisionProbe(new PhysicsEngine());
|
||||
var pivot = new Vector3(0f, 0f, 1.5f);
|
||||
var eye = new Vector3(-2f, 0f, 2.2f);
|
||||
var probe = new PhysicsCameraCollisionProbe(new PhysicsEngine());
|
||||
var pivot = new Vector3(0f, 0f, 1.5f);
|
||||
var eye = new Vector3(-2f, 0f, 2.2f);
|
||||
var player = new Vector3(0f, 0f, 0f);
|
||||
|
||||
var result = probe.SweepEye(pivot, eye, cellId: 0, selfEntityId: 0);
|
||||
var result = probe.SweepEye(pivot, eye, cellId: 0, selfEntityId: 0, playerPos: player);
|
||||
|
||||
Assert.Equal(eye, result.Eye);
|
||||
Assert.Equal(0u, result.ViewerCellId); // cellId==0 → returned unchanged
|
||||
Assert.Equal(player, result.Eye);
|
||||
Assert.Equal(0u, result.ViewerCellId); // cellId==0 → snap to player, null viewer cell
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -453,7 +453,7 @@ public class RetailChaseCameraTests
|
|||
public int Calls;
|
||||
public Vector3 ReturnEye;
|
||||
public uint ReturnCell;
|
||||
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
|
||||
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
|
||||
{
|
||||
Calls++;
|
||||
return new CameraSweepResult(ReturnEye, ReturnCell);
|
||||
|
|
@ -571,7 +571,7 @@ public class RetailChaseCameraTests
|
|||
{
|
||||
public int Calls;
|
||||
public Vector3 ClampEye;
|
||||
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
|
||||
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
|
||||
{
|
||||
Calls++;
|
||||
return new CameraSweepResult(Calls == 1 ? ClampEye : desiredEye, cellId);
|
||||
|
|
|
|||
45
tests/AcDream.Core.Tests/Physics/ResolveResultOkTests.cs
Normal file
45
tests/AcDream.Core.Tests/Physics/ResolveResultOkTests.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Render Residual A — <see cref="ResolveResult.Ok"/> surfaces the
|
||||
/// <c>FindTransitionalPosition</c> return (retail <c>find_valid_position != 0</c>,
|
||||
/// pc:273898) so the camera <c>SweepEye</c> can trigger <c>SmartBox::update_viewer</c>'s
|
||||
/// fallbacks when the sweep fails. Default <c>true</c> keeps every existing caller
|
||||
/// (which never reads it) unaffected.
|
||||
/// </summary>
|
||||
public class ResolveResultOkTests
|
||||
{
|
||||
[Fact]
|
||||
public void NoStartCell_ReportsNotOk()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
|
||||
// cellId == 0 → FindTransitionalPosition returns false at its first guard
|
||||
// (TransitionTypes.cs:665) → the sweep did not find a valid position.
|
||||
var r = engine.ResolveWithTransition(
|
||||
currentPos: Vector3.Zero, targetPos: new Vector3(1f, 0f, 0f), cellId: 0u,
|
||||
sphereRadius: 0.3f, sphereHeight: 0f, stepUpHeight: 0f, stepDownHeight: 0f,
|
||||
isOnGround: false);
|
||||
|
||||
Assert.False(r.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroMovementValidCell_ReportsOk()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
|
||||
// Zero offset with a non-zero start cell → FindTransitionalPosition's
|
||||
// zero-step path returns true (TransitionTypes.cs:718-723), no geometry needed.
|
||||
var r = engine.ResolveWithTransition(
|
||||
currentPos: new Vector3(5f, 5f, 5f), targetPos: new Vector3(5f, 5f, 5f), cellId: 0xA9B40001u,
|
||||
sphereRadius: 0.3f, sphereHeight: 0f, stepUpHeight: 0f, stepDownHeight: 0f,
|
||||
isOnGround: false);
|
||||
|
||||
Assert.True(r.Ok);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue