The A6.P4 port, fused into one installment per the BR-2 half-port lesson
(registration and query are co-dependent: flood-registering shells under
the old radial query would re-open #98 through the vestibule).
REGISTRATION (ShadowObjectRegistry rewritten):
- Register/RegisterMultiPart/UpdatePosition compute the cell set via
CellTransit.BuildShadowCellSet (the C2 find_cell_list flood) seeded by
the entity's m_position cell id; the private 24m XY-grid rectangle and
its single-landblock clamp are deleted. Flood spheres follow retail's
CylSphere rule (base point + cyl radius, cap 10; BSP bounding-sphere
fallback - Ghidra 0x0052b9f0). Statics flood with the do_not_load
prune; dynamics (server spawns, isStatic:false) without.
- Keep-when-empty (SetPositionInternal num_cells gate, pc:283540): a
failed flood leaves the previous registration in place.
- RefloodLandblock: streaming-race hook re-runs the flood when a
landblock's cells hydrate (retail init_objects -> recalc_cross_cells,
Ghidra 0x0052b420/0x00515a30); wired at GameWindow's hydration tail.
- GameWindow sites pass the server position's full cell id as the seed
(spawn + UpdatePosition); the five static sites pass ParentCellId.
BUILDING CHANNEL (CSortCell.building shape):
- Building SHELLS are not shadow objects in retail (only caller of
find_building_collisions is CSortCell::find_collisions 0x005340aa;
one building per origin landcell, init_buildings 0x0052fd80 verified
verbatim + ACE cross-ref). IsBuildingShell entities skip the registry;
Transition.FindBuildingCollisions runs the shell part-0 BSP off
cache.GetBuilding(cellId) with bldg_check set around it
(find_building_collisions 0x006b5300), CollidedWithEnvironment on
non-Contact non-OK. BuildingPhysics.ModelId = pre-resolved part-0
GfxObj (0x02 Setups resolved at the CacheBuilding site).
- Placement/ethereal weakening: BSPQuery Path 1 passes center_solid=0
when BldgCheck && HitsInteriorCell (BSPTREE::find_collisions 0x0053a82e
+ placement_insert 0x005399d8) so doorway crossings don't hard-fail
against shell solids. SpherePath gains both retail fields;
HitsInteriorCell is rebuilt at every cell-array build
(build_cell_array reset 0x00509ef2 + find_cell_list/check_building_
transit set sites).
QUERY (retail per-cell order, transitional_insert 0x0050b6f0):
- TransitionalInsert per attempt: env -> building (LandCell only) ->
objects on the PRIMARY cell, then on OK the check_other_cells pass
(env -> building -> objects per OTHER overlapped cell) + the
carried-cell advance - the advance now happens AFTER all per-cell
object passes (the WF1 ordering divergence), with Adjusted/Slid
feeding the retry exactly like retail's OK_TS case.
- FindObjCollisionsInCell = CObjCell::find_obj_collisions (0x0052b750):
iterate ONLY the asked cell's list. DELETED: the radial 9-landblock
sweep, the +5m query pad, the b3ce505 indoor-primary gate, and the
isViewer exemption (the camera is bounded by interior cell-BSP env
collision - retail's own channel; CameraCornerSealReplayTests pins it
against real dat, and the new building-channel camera test pins the
outdoor stop).
TESTS: Core 1416/0/2 (was 1398 + 4 pre-existing #99-era fails + 1 skip),
App 225, UI 420, Net 294 - all green.
- 3 of the 4 #99-era reds flipped green as designed: the door apparatus
(Apparatus_Grounded_50cmOffCenter_FrontApproach_Blocks) and tick-13558
(indoor walkthrough) now assert the door BLOCKS; tick-22760 pins the
outdoor blocking invariant.
- The 4th (BSPStepUp D4) + 22760's lateral-slide delta are NOT cell-set
problems (probes prove the door is found + BSP-only dispatched;
BR-7 left both byte-identical) - filed as issue #116 (slide-response
family), D4 skipped with the issue reference.
- FindEnvCollisionsMultiCellTests migrated to the public entry (the A4
multi-cell halt now lives at the retail call site).
- New registry pins: per-cell query surface, outdoor-footprint-never-
indoor (#98 architectural), door-outdoor-cell membership, reflood.
- CameraCollisionIndoorTests rewritten against the building channel
(the isViewer-exemption pins died with the exemption).
Closes #99 (doors block both ways via registration-time cell membership
+ the straddle-spanning player cell array). #97 likely closed (the +5m
radial pad that produced phantom-collision candidates is gone) - verify
at T5. #98 stays closed ARCHITECTURALLY (outdoor footprints structurally
cannot reach interior cells; the cellar harness stays green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
430 lines
18 KiB
C#
430 lines
18 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
using DatReaderWriter;
|
||
using DatReaderWriter.DBObjs;
|
||
using DatReaderWriter.Enums;
|
||
using DatReaderWriter.Options;
|
||
using Xunit;
|
||
using Xunit.Abstractions;
|
||
using Env = System.Environment;
|
||
using Plane = System.Numerics.Plane;
|
||
|
||
namespace AcDream.Core.Tests.Physics;
|
||
|
||
/// <summary>
|
||
/// A6.P4 door fix (2026-05-24) — apparatus test. Loads door Setup
|
||
/// <c>0x020019FF</c> + GfxObj <c>0x010044B5</c> (the collision-bearing
|
||
/// frame/slab part — see
|
||
/// <see cref="DoorSetupGfxObjInspectionTests"/>) from the real dat,
|
||
/// registers a synthetic door entity at a known world position via
|
||
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/>, and sweeps a
|
||
/// player sphere into the door from multiple angles.
|
||
///
|
||
/// <para>
|
||
/// Purpose: with the dat inspection having confirmed that
|
||
/// <c>0x010044B5</c> has a complete 1.9 × 0.26 × 2.5 m door-slab
|
||
/// PhysicsBSP, this test isolates whether our
|
||
/// <see cref="BSPQuery.FindCollisions"/> + the
|
||
/// <see cref="RegisterMultiPart"/> registration path actually fires
|
||
/// collisions against that BSP. If it does, the live Holtburg door
|
||
/// bug is somewhere DOWNSTREAM (live registration race, entity
|
||
/// rotation, cell scoping). If it doesn't, the bug is here in
|
||
/// the integration we can fix with confidence.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// The test skips gracefully when the dat directory isn't present so
|
||
/// CI stays green. Local developer machines always have it.
|
||
/// </para>
|
||
/// </summary>
|
||
public class DoorCollisionApparatusTests
|
||
{
|
||
private readonly ITestOutputHelper _out;
|
||
public DoorCollisionApparatusTests(ITestOutputHelper output) => _out = output;
|
||
|
||
private const uint DoorSetupId = 0x020019FFu;
|
||
private const uint DoorFrameGfxObjId = 0x010044B5u;
|
||
private const uint DoorEntityId = 0xF424Fu;
|
||
private const uint TestLandblockId = 0xA9B40000u;
|
||
|
||
// Cell (0, 0) in landblock 0xA9B40000 — outdoor 24-m cell, low byte
|
||
// = (0 * 8) + 0 + 1 = 1.
|
||
private const uint TestCellId = TestLandblockId | 0x0001u;
|
||
|
||
private const float SphereRadius = 0.48f; // ~retail player capsule radius
|
||
private const float SphereHeight = 1.20f;
|
||
private const float StepUpHeight = 0.60f;
|
||
private const float StepDownHeight = 0.04f;
|
||
|
||
/// <summary>
|
||
/// Walk the sphere toward the closed door, dead-center, from
|
||
/// the door's -Y front face approach. Expect a CollisionNormalValid
|
||
/// = true AND the sphere stops before passing through the door.
|
||
/// </summary>
|
||
[Fact]
|
||
public void Apparatus_DeadCenter_FrontApproach_BlocksOnBSP()
|
||
{
|
||
if (!TryBuildScenario(out var ctx)) return;
|
||
|
||
// Door at world (12, 12, 0). The frame's BSP slab extends
|
||
// entity-local Y∈[-0.009, 0.252] (poly Y range plus frame[0] offset).
|
||
// Approach from -Y at sphere Y=11 (1 m before the front face),
|
||
// walk +Y at 0.10 m/tick.
|
||
var start = new Vector3(12f, 11f, 0.5f);
|
||
var perTick = new Vector3(0f, 0.10f, 0f);
|
||
|
||
var (blocked, finalPos, normal, ticks) = SweepUntilBlocked(
|
||
ctx.engine, start, perTick, maxTicks: 30);
|
||
|
||
_out.WriteLine($"Final pos = ({finalPos.X:F3}, {finalPos.Y:F3}, {finalPos.Z:F3}) " +
|
||
$"after {ticks} ticks; blocked={blocked} normal=({normal.X:F3},{normal.Y:F3},{normal.Z:F3})");
|
||
|
||
Assert.True(blocked,
|
||
$"Expected collision before passing through the door. Sphere reached " +
|
||
$"({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after {ticks} ticks " +
|
||
$"without firing CollisionNormalValid.");
|
||
Assert.True(finalPos.Y < 12.0f,
|
||
$"Sphere should stop before the door's front face (Y ≈ 11.99). " +
|
||
$"Got Y={finalPos.Y:F3}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 50 cm off-center: the small Sphere shape (r=0.10) can't catch
|
||
/// this, but the BSP slab (1.9 m wide) MUST. This is the live
|
||
/// regression — pre-fix the user can walk around the cylinder.
|
||
/// </summary>
|
||
[Fact]
|
||
public void Apparatus_50cmOffCenter_FrontApproach_BlocksOnBSP()
|
||
{
|
||
if (!TryBuildScenario(out var ctx)) return;
|
||
|
||
// Same setup, but start 0.5 m off-center in X. Slab X range
|
||
// is approximately [11.05, 12.97] (frame[0].X offset = -0.006).
|
||
var start = new Vector3(12.5f, 11f, 0.5f);
|
||
var perTick = new Vector3(0f, 0.10f, 0f);
|
||
|
||
var (blocked, finalPos, normal, ticks) = SweepUntilBlocked(
|
||
ctx.engine, start, perTick, maxTicks: 30);
|
||
|
||
_out.WriteLine($"Final pos = ({finalPos.X:F3}, {finalPos.Y:F3}, {finalPos.Z:F3}) " +
|
||
$"after {ticks} ticks; blocked={blocked} normal=({normal.X:F3},{normal.Y:F3},{normal.Z:F3})");
|
||
|
||
Assert.True(blocked,
|
||
$"Expected BSP collision off-center. Sphere reached " +
|
||
$"({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after {ticks} ticks " +
|
||
$"without firing CollisionNormalValid.");
|
||
Assert.True(finalPos.Y < 12.0f,
|
||
$"Sphere should stop before door front face. Got Y={finalPos.Y:F3}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reverse approach: walk from the door's +Y back side toward -Y.
|
||
/// SidesType=Landblock polys are two-sided, so this must also block.
|
||
/// This pins the live "walking out from inside passes through" bug
|
||
/// in its simplest geometric form.
|
||
/// </summary>
|
||
[Fact]
|
||
public void Apparatus_DeadCenter_BackApproach_BlocksOnBSP()
|
||
{
|
||
if (!TryBuildScenario(out var ctx)) return;
|
||
|
||
// Start past the door at Y=13, walk back toward -Y.
|
||
var start = new Vector3(12f, 13f, 0.5f);
|
||
var perTick = new Vector3(0f, -0.10f, 0f);
|
||
|
||
var (blocked, finalPos, normal, ticks) = SweepUntilBlocked(
|
||
ctx.engine, start, perTick, maxTicks: 30);
|
||
|
||
_out.WriteLine($"Final pos = ({finalPos.X:F3}, {finalPos.Y:F3}, {finalPos.Z:F3}) " +
|
||
$"after {ticks} ticks; blocked={blocked} normal=({normal.X:F3},{normal.Y:F3},{normal.Z:F3})");
|
||
|
||
Assert.True(blocked,
|
||
$"Expected BSP collision from back side (two-sided Landblock poly). " +
|
||
$"Sphere reached ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) " +
|
||
$"after {ticks} ticks.");
|
||
Assert.True(finalPos.Y > 12.30f,
|
||
$"Sphere should stop after the door's back face (Y ≈ 12.25). " +
|
||
$"Got Y={finalPos.Y:F3}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Diagnostic dump: drive 5 ticks with probes on, just to see
|
||
/// what the engine reports per-tick. Useful when the assertion
|
||
/// tests above fail — the diagnostic test shows the FULL log
|
||
/// without yielding a green/red verdict.
|
||
/// </summary>
|
||
[Fact]
|
||
public void Apparatus_DiagnosticDump_FrontApproach()
|
||
{
|
||
if (!TryBuildScenario(out var ctx)) return;
|
||
|
||
PhysicsDiagnostics.ProbeResolveEnabled = true;
|
||
PhysicsDiagnostics.ProbeBuildingEnabled = true;
|
||
try
|
||
{
|
||
var pos = new Vector3(12f, 11f, 0.5f);
|
||
var perTick = new Vector3(0f, 0.10f, 0f);
|
||
uint cellId = TestCellId;
|
||
bool isOnGround = false;
|
||
|
||
for (int tick = 0; tick < 8; tick++)
|
||
{
|
||
Vector3 target = pos + perTick;
|
||
var result = ctx.engine.ResolveWithTransition(
|
||
pos, target, cellId,
|
||
SphereRadius, SphereHeight,
|
||
StepUpHeight, StepDownHeight,
|
||
isOnGround,
|
||
body: null,
|
||
moverFlags: ObjectInfoState.IsPlayer,
|
||
movingEntityId: 0);
|
||
_out.WriteLine($"tick={tick} pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) → " +
|
||
$"({result.Position.X:F3},{result.Position.Y:F3},{result.Position.Z:F3}) " +
|
||
$"hit={result.CollisionNormalValid} " +
|
||
$"normal=({result.CollisionNormal.X:F3},{result.CollisionNormal.Y:F3},{result.CollisionNormal.Z:F3})");
|
||
pos = result.Position;
|
||
cellId = result.CellId;
|
||
isOnGround = result.IsOnGround;
|
||
}
|
||
|
||
Assert.True(true, "Diagnostic test — always passes; check stdout.");
|
||
}
|
||
finally
|
||
{
|
||
PhysicsDiagnostics.ProbeResolveEnabled = false;
|
||
PhysicsDiagnostics.ProbeBuildingEnabled = false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reproduces the LIVE bug: a grounded player (isOnGround=true with
|
||
/// seeded ContactPlane) walking off-center toward a closed door
|
||
/// passes through. The path-difference test: this hits Path 5
|
||
/// (Contact branch + StepSphereUp), while the other apparatus
|
||
/// tests above hit Path 6 (Default). If Path 5 incorrectly
|
||
/// declares step-up success the BSP collision returns OK and the
|
||
/// sphere walks through — exactly what the user reports in the
|
||
/// live Holtburg session 2026-05-24.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// BR-7 / A6.P4 (2026-06-11) — FLIPPED from documents-the-bug to
|
||
/// asserts-the-fix: with the per-cell shadow architecture the door is
|
||
/// found from every overlapped cell and this approach now blocks.
|
||
/// </remarks>
|
||
[Fact]
|
||
public void Apparatus_Grounded_50cmOffCenter_FrontApproach_Blocks()
|
||
{
|
||
if (!TryBuildScenario(out var ctx)) return;
|
||
|
||
PhysicsDiagnostics.ProbeResolveEnabled = true;
|
||
PhysicsDiagnostics.ProbeBuildingEnabled = true;
|
||
Env.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", "1");
|
||
|
||
// Synthetic floor plane at Z = 0 so the grounded sphere has a
|
||
// walkable plane to rest on. Sphere foot center starts at Z=radius
|
||
// = 0.48, head at 0.48 + 1.20 = 1.68.
|
||
var floorPlane = new Plane(0f, 0f, 1f, 0f); // z = 0 plane
|
||
var floorVerts = new[]
|
||
{
|
||
new Vector3( 0f, 0f, 0f),
|
||
new Vector3(24f, 0f, 0f),
|
||
new Vector3(24f, 24f, 0f),
|
||
new Vector3( 0f, 24f, 0f),
|
||
};
|
||
|
||
var body = new PhysicsBody
|
||
{
|
||
Position = new Vector3(12.5f, 11f, 0.48f),
|
||
Orientation = Quaternion.Identity,
|
||
ContactPlaneValid = true,
|
||
ContactPlane = floorPlane,
|
||
ContactPlaneCellId = TestCellId,
|
||
WalkablePolygonValid = true,
|
||
WalkablePlane = floorPlane,
|
||
WalkableVertices = floorVerts,
|
||
WalkableUp = Vector3.UnitZ,
|
||
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
||
};
|
||
|
||
var perTick = new Vector3(0f, 0.10f, 0f);
|
||
Vector3 pos = body.Position;
|
||
uint cellId = TestCellId;
|
||
bool isOnGround = true;
|
||
bool blocked = false;
|
||
Vector3 lastNormal = Vector3.Zero;
|
||
int ticks = 0;
|
||
|
||
for (int tick = 0; tick < 30; tick++)
|
||
{
|
||
Vector3 target = pos + perTick;
|
||
var result = ctx.engine.ResolveWithTransition(
|
||
pos, target, cellId,
|
||
SphereRadius, SphereHeight,
|
||
StepUpHeight, StepDownHeight,
|
||
isOnGround,
|
||
body: body,
|
||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||
movingEntityId: 0);
|
||
|
||
ticks = tick;
|
||
body.Position = result.Position;
|
||
pos = result.Position;
|
||
cellId = result.CellId;
|
||
isOnGround = result.IsOnGround;
|
||
lastNormal = result.CollisionNormal;
|
||
|
||
if (result.CollisionNormalValid)
|
||
{
|
||
blocked = true;
|
||
_out.WriteLine($"Tick {tick}: BLOCKED at pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) normal=({lastNormal.X:F3},{lastNormal.Y:F3},{lastNormal.Z:F3})");
|
||
break;
|
||
}
|
||
}
|
||
|
||
_out.WriteLine($"Final pos = ({pos.X:F3}, {pos.Y:F3}, {pos.Z:F3}) after {ticks + 1} ticks; blocked={blocked}");
|
||
_out.WriteLine($"Grounded={isOnGround}");
|
||
|
||
// FLIPPED (BR-7 / A6.P4, 2026-06-11): this test documented the #99
|
||
// walk-through; the per-cell shadow architecture closes it. The
|
||
// grounded Path-5 approach must now BLOCK before the slab
|
||
// (Y < 12.0) — the same shape as the Path-6 apparatus tests above.
|
||
PhysicsDiagnostics.ProbeResolveEnabled = false;
|
||
PhysicsDiagnostics.ProbeBuildingEnabled = false;
|
||
Env.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", null);
|
||
|
||
Assert.True(blocked,
|
||
$"Door must block the grounded off-center approach (#99 closed). " +
|
||
$"Current pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) blocked={blocked}");
|
||
Assert.True(pos.Y < 12.0f,
|
||
$"Sphere must stop before the slab; pos.Y={pos.Y:F3}");
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Apparatus setup
|
||
// ───────────────────────────────────────────────────────────────
|
||
|
||
private sealed record Context(
|
||
PhysicsEngine engine,
|
||
PhysicsDataCache cache,
|
||
Setup setup,
|
||
IReadOnlyList<ShadowShape> registeredShapes);
|
||
|
||
/// <summary>
|
||
/// Build the engine + cache + landblock + registered door entity.
|
||
/// Returns false if the dat directory is missing (test skips).
|
||
/// </summary>
|
||
private bool TryBuildScenario(out Context ctx)
|
||
{
|
||
ctx = default!;
|
||
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||
"Documents", "Asheron's Call");
|
||
if (!Directory.Exists(datDir))
|
||
{
|
||
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||
return false;
|
||
}
|
||
|
||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||
|
||
// Load + cache the door's collision-bearing part.
|
||
var doorFrameGfx = dats.Get<GfxObj>(DoorFrameGfxObjId);
|
||
Assert.NotNull(doorFrameGfx);
|
||
var cache = new PhysicsDataCache();
|
||
cache.CacheGfxObj(DoorFrameGfxObjId, doorFrameGfx!);
|
||
Assert.NotNull(cache.GetGfxObj(DoorFrameGfxObjId));
|
||
|
||
var engine = new PhysicsEngine { DataCache = cache };
|
||
|
||
// Stub landblock at world (0, 0) so TryGetLandblockContext succeeds
|
||
// for player XY in the [0, 192) range. Flat far-below terrain so it
|
||
// doesn't interact with the door collision.
|
||
var heights = new byte[81];
|
||
var heightTable = new float[256];
|
||
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
|
||
engine.AddLandblock(
|
||
landblockId: TestLandblockId,
|
||
terrain: new TerrainSurface(heights, heightTable),
|
||
cells: Array.Empty<CellSurface>(),
|
||
portals: Array.Empty<PortalPlane>(),
|
||
worldOffsetX: 0f,
|
||
worldOffsetY: 0f);
|
||
|
||
// Load door setup, build shapes via the production builder.
|
||
var setup = dats.Get<Setup>(DoorSetupId);
|
||
Assert.NotNull(setup);
|
||
var shapes = ShadowShapeBuilder.FromSetup(
|
||
setup!,
|
||
entScale: 1.0f,
|
||
hasPhysicsBsp: id => cache.GetGfxObj(id)?.BSP?.Root is not null);
|
||
|
||
_out.WriteLine($"Shapes from FromSetup: {shapes.Count} entries");
|
||
foreach (var s in shapes)
|
||
{
|
||
_out.WriteLine($" type={s.CollisionType} gfxObj=0x{s.GfxObjId:X8} " +
|
||
$"localPos=({s.LocalPosition.X:F3},{s.LocalPosition.Y:F3},{s.LocalPosition.Z:F3}) " +
|
||
$"radius={s.Radius:F3} cylH={s.CylHeight:F3}");
|
||
}
|
||
|
||
// Register the door at world (12, 12, 0). That's cell (0,0) of
|
||
// landblock 0xA9B40000.
|
||
Vector3 doorWorldPos = new(12f, 12f, 0f);
|
||
Quaternion doorWorldRot = Quaternion.Identity;
|
||
const uint doorState = 0x10008u; // PhysicsState.HasPhysicsBSP | HasDefaultScript (typical)
|
||
|
||
engine.ShadowObjects.RegisterMultiPart(
|
||
entityId: DoorEntityId,
|
||
entityWorldPos: doorWorldPos,
|
||
entityWorldRot: doorWorldRot,
|
||
shapes: shapes,
|
||
state: doorState,
|
||
flags: EntityCollisionFlags.None,
|
||
worldOffsetX: 0f,
|
||
worldOffsetY: 0f,
|
||
landblockId: TestLandblockId);
|
||
|
||
ctx = new Context(engine, cache, setup!, shapes);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Drive a sphere from <paramref name="start"/> by
|
||
/// <paramref name="perTick"/> each tick until either
|
||
/// <c>CollisionNormalValid</c> fires or <paramref name="maxTicks"/>
|
||
/// elapse. Returns the outcome + final position.
|
||
/// </summary>
|
||
private (bool blocked, Vector3 finalPos, Vector3 normal, int ticks)
|
||
SweepUntilBlocked(PhysicsEngine engine, Vector3 start, Vector3 perTick, int maxTicks)
|
||
{
|
||
Vector3 pos = start;
|
||
uint cellId = TestCellId;
|
||
bool isOnGround = false;
|
||
Vector3 lastNormal = Vector3.Zero;
|
||
|
||
for (int tick = 0; tick < maxTicks; tick++)
|
||
{
|
||
Vector3 target = pos + perTick;
|
||
var result = engine.ResolveWithTransition(
|
||
pos, target, cellId,
|
||
SphereRadius, SphereHeight,
|
||
StepUpHeight, StepDownHeight,
|
||
isOnGround,
|
||
body: null,
|
||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||
movingEntityId: 0);
|
||
|
||
if (result.CollisionNormalValid)
|
||
return (true, result.Position, result.CollisionNormal, tick);
|
||
|
||
pos = result.Position;
|
||
cellId = result.CellId;
|
||
isOnGround = result.IsOnGround;
|
||
lastNormal = result.CollisionNormal;
|
||
}
|
||
|
||
return (false, pos, lastNormal, maxTicks);
|
||
}
|
||
}
|