Setup.Spheres were previously coerced to short cylinders (CylHeight=2*r), which is geometrically wrong: a cylinder has flat caps; a sphere does not. This ported CSphere::intersects_sphere (0x00537A80) so sphere-typed shadow entries are tested as spheres — 3-D distance, no height clamping. Changes: - ShadowObjectRegistry.cs: added ShadowCollisionType.Sphere (enum value 2). The BuildFloodSpheres anyCyl dedup at :232 is unaffected: only Cylinder sets anyCyl=true; Sphere shapes fall through to the BSP-fallback path (anyCyl=false → included), which is correct. - ShadowShapeBuilder.cs: FromSetup now emits ShadowCollisionType.Sphere (CylHeight=0) for Setup.Spheres instead of a short Cylinder. - CollisionPrimitives.cs: added SweptSphereHitsSphere — quadratic swept solve ported from ACE Sphere.cs::FindTimeOfCollision, which is a C# port of retail's CSphere::intersects_sphere @ 0x00537A80. Sign convention confirmed against the decomp: retail negates the root to produce a forward t ∈ (0,1]. - TransitionTypes.cs: added Sphere narrow-phase branch between BSP and Cylinder in FindObjCollisionsInCell; uses 3-D distance for overlap (not XY-only). Added SphereCollision() method implementing the 3-D wall-slide response. Updated diagnostic logging at :2734 to cover Sphere. - Updated ShadowShapeBuilderTests for new Sphere type assertion. - New SphereIntersectsSphereConformanceTests: 9 geometrically-anchored cases (head-on, tangent, perpendicular-miss, lateral-near-miss, sweep-away, beyond-step, degenerate-zero-sweep, already-overlapping, vertical-sweep). Retail oracle: CSphere::intersects_sphere @ 0x00537A80 (named-retail); ACE Sphere.cs::FindTimeOfCollision (C# port, cross-confirmed). Build: 0 errors, 10 warnings (pre-existing). Tests: 1576 pass / 0 fail / 2 skip (1578 total). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
123 lines
5.3 KiB
C#
123 lines
5.3 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using DatReaderWriter.Types;
|
|
|
|
namespace AcDream.Core.Physics;
|
|
|
|
/// <summary>
|
|
/// Pure-function builder that translates a <see cref="Setup"/> into a list of
|
|
/// <see cref="ShadowShape"/>s suitable for registration via
|
|
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/>.
|
|
///
|
|
/// <para>
|
|
/// Walks (1) every CylSphere → Cylinder shape, (2) every Sphere ONLY when no
|
|
/// CylSpheres are present (matches retail and the existing landblock-static
|
|
/// convention at GameWindow.cs:6034), and (3) every Part whose GfxObj has a
|
|
/// non-null PhysicsBSP → per-part BSP shape, with local transforms from
|
|
/// PlacementFrames[Resting | Default | first available].
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> calls
|
|
/// <c>CPartArray::FindObjCollisions</c> which iterates parts; each part's
|
|
/// <c>find_obj_collisions</c> tests CylSpheres + GfxObj BSP. We emit one
|
|
/// ShadowShape per part contribution so the existing FindObjCollisions
|
|
/// iteration loop in <see cref="Transition"/> tests each part independently.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class ShadowShapeBuilder
|
|
{
|
|
/// <summary>
|
|
/// Build the shape list for a Setup.
|
|
/// </summary>
|
|
/// <param name="setup">The Setup to walk.</param>
|
|
/// <param name="entScale">The entity's overall scale factor; multiplies
|
|
/// every radius, height, and local offset.</param>
|
|
/// <param name="hasPhysicsBsp">Predicate: does the GfxObj with this id
|
|
/// have a non-null PhysicsBSP? Production: <c>id => cache.GetGfxObj(id)?.BSP?.Root is not null</c>.</param>
|
|
public static IReadOnlyList<ShadowShape> FromSetup(
|
|
Setup setup,
|
|
float entScale,
|
|
Func<uint, bool> hasPhysicsBsp)
|
|
{
|
|
if (setup is null) throw new ArgumentNullException(nameof(setup));
|
|
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
|
|
|
|
var result = new List<ShadowShape>();
|
|
|
|
// 1. CylSpheres — each becomes a Cylinder shape.
|
|
foreach (var cyl in setup.CylSpheres)
|
|
{
|
|
if (cyl.Radius <= 0f) continue;
|
|
float baseHeight = cyl.Height > 0f ? cyl.Height : cyl.Radius * 4f;
|
|
result.Add(new ShadowShape(
|
|
GfxObjId: 0u,
|
|
LocalPosition: new Vector3(cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale,
|
|
LocalRotation: Quaternion.Identity,
|
|
Scale: entScale,
|
|
CollisionType: ShadowCollisionType.Cylinder,
|
|
Radius: cyl.Radius * entScale,
|
|
CylHeight: baseHeight * entScale));
|
|
}
|
|
|
|
// 2. Spheres — only when no CylSpheres (matches landblock-static convention
|
|
// at GameWindow.cs:6034). Each becomes a true Sphere (no height clamping).
|
|
// Retail anchor: CSphere::intersects_sphere @ 0x00537A80 uses 3-D distance
|
|
// for the overlap check, unlike CCylSphere which clips to [low_pt, high_pt].
|
|
if (setup.CylSpheres.Count == 0)
|
|
{
|
|
foreach (var sph in setup.Spheres)
|
|
{
|
|
if (sph.Radius <= 0f) continue;
|
|
result.Add(new ShadowShape(
|
|
GfxObjId: 0u,
|
|
LocalPosition: new Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale,
|
|
LocalRotation: Quaternion.Identity,
|
|
Scale: entScale,
|
|
CollisionType: ShadowCollisionType.Sphere,
|
|
Radius: sph.Radius * entScale,
|
|
CylHeight: 0f));
|
|
}
|
|
}
|
|
|
|
// 3. Parts — one BSP shape per part with a non-null PhysicsBSP.
|
|
AnimationFrame? placementFrame = ResolvePlacementFrame(setup);
|
|
for (int i = 0; i < setup.Parts.Count; i++)
|
|
{
|
|
uint gfxId = (uint)setup.Parts[i];
|
|
if (!hasPhysicsBsp(gfxId)) continue;
|
|
|
|
Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count
|
|
? placementFrame.Frames[i]
|
|
: new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
|
|
|
|
// BSP radius default; caller substitutes the real BoundingSphere.Radius
|
|
// at registration time when available. Loose-but-safe broadphase value.
|
|
float bspRadius = 2f * entScale;
|
|
|
|
result.Add(new ShadowShape(
|
|
GfxObjId: gfxId,
|
|
LocalPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale,
|
|
LocalRotation: partFrame.Orientation,
|
|
Scale: entScale,
|
|
CollisionType: ShadowCollisionType.BSP,
|
|
Radius: bspRadius,
|
|
CylHeight: 0f));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>Resolve the placement frame in priority Resting → Default →
|
|
/// first available. Mirrors <c>SetupMesh.Flatten</c>'s convention.</summary>
|
|
private static AnimationFrame? ResolvePlacementFrame(Setup setup)
|
|
{
|
|
if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting)) return resting;
|
|
if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def)) return def;
|
|
foreach (var kvp in setup.PlacementFrames) return kvp.Value;
|
|
return null;
|
|
}
|
|
}
|