refactor(world): extract live projection mechanics
Move appearance rebinding, collision construction, default-pose resolution, local shadow ownership, and exact leave-world presentation into focused owners. Preserve retail parent ordering with staged validation, committed recovery, recursive attached-subtree withdrawal, and retryable exact teardown across parent, pickup, position, unwield, and pose-loss edges. Co-Authored-By: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
c87bac31a0
commit
69a2ca0c6d
26 changed files with 4172 additions and 430 deletions
231
src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs
Normal file
231
src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable input to the canonical shadow registry for one live object.
|
||||
/// Building is DAT/physics-only and therefore testable without a window or GL
|
||||
/// context; registration remains one explicit commit.
|
||||
/// </summary>
|
||||
internal sealed record LiveEntityCollisionRegistration(
|
||||
uint EntityId,
|
||||
uint SourceId,
|
||||
Vector3 EntityWorldPosition,
|
||||
Quaternion EntityWorldRotation,
|
||||
IReadOnlyList<ShadowShape> Shapes,
|
||||
uint State,
|
||||
EntityCollisionFlags Flags,
|
||||
float WorldOffsetX,
|
||||
float WorldOffsetY,
|
||||
uint LandblockId,
|
||||
uint SeedCellId);
|
||||
|
||||
/// <summary>
|
||||
/// Ports the live-object collision-shape policy used by
|
||||
/// <c>CPartArray::FindObjCollisions</c>: CylSpheres before Spheres, every
|
||||
/// physics-BSP part, and the established Setup-radius fallback for the
|
||||
/// remaining ACE prop data.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityCollisionBuilder
|
||||
{
|
||||
private readonly Func<uint, bool> _hasPhysicsBsp;
|
||||
private readonly Func<uint, float?> _physicsBspRadius;
|
||||
private readonly LiveEntityDefaultPoseResolver _defaultPose;
|
||||
|
||||
public LiveEntityCollisionBuilder(
|
||||
PhysicsDataCache physicsData,
|
||||
LiveEntityDefaultPoseResolver defaultPose)
|
||||
: this(
|
||||
id => physicsData.GetGfxObj(id)?.BSP?.Root is not null,
|
||||
id => physicsData.GetGfxObj(id)?.BoundingSphere?.Radius,
|
||||
defaultPose)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(physicsData);
|
||||
}
|
||||
|
||||
internal LiveEntityCollisionBuilder(
|
||||
Func<uint, bool> hasPhysicsBsp,
|
||||
Func<uint, float?> physicsBspRadius,
|
||||
LiveEntityDefaultPoseResolver defaultPose)
|
||||
{
|
||||
_hasPhysicsBsp = hasPhysicsBsp
|
||||
?? throw new ArgumentNullException(nameof(hasPhysicsBsp));
|
||||
_physicsBspRadius = physicsBspRadius
|
||||
?? throw new ArgumentNullException(nameof(physicsBspRadius));
|
||||
_defaultPose = defaultPose
|
||||
?? throw new ArgumentNullException(nameof(defaultPose));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves collision slot zero for every post-AnimPartChanged identity.
|
||||
/// This intentionally has no visual-LOD option or Setup-type input.
|
||||
/// </summary>
|
||||
public static uint[] ResolveEffectivePartIdentities(
|
||||
IReadOnlyList<uint> postAnimPartGfxObjIds,
|
||||
Func<uint, uint> resolveSlotZero)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(postAnimPartGfxObjIds);
|
||||
ArgumentNullException.ThrowIfNull(resolveSlotZero);
|
||||
var result = new uint[postAnimPartGfxObjIds.Count];
|
||||
for (int i = 0; i < result.Length; i++)
|
||||
result[i] = resolveSlotZero(postAnimPartGfxObjIds[i]);
|
||||
return result;
|
||||
}
|
||||
|
||||
public LiveEntityCollisionRegistration? Build(
|
||||
WorldEntity entity,
|
||||
Setup setup,
|
||||
IReadOnlyList<uint> effectivePartGfxObjIds,
|
||||
WorldSession.EntitySpawn spawn,
|
||||
LiveEntityRecord exactRecord,
|
||||
Vector3 worldOrigin,
|
||||
bool retainEmptyPayload = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(setup);
|
||||
ArgumentNullException.ThrowIfNull(effectivePartGfxObjIds);
|
||||
ArgumentNullException.ThrowIfNull(exactRecord);
|
||||
if (spawn.Position is not { } position)
|
||||
return null;
|
||||
if (spawn.Guid != exactRecord.ServerGuid
|
||||
|| spawn.InstanceSequence != exactRecord.Generation
|
||||
|| !ReferenceEquals(exactRecord.WorldEntity, entity))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Live collision construction requires the exact materialized record.");
|
||||
}
|
||||
|
||||
float scale = spawn.ObjScale ?? 1f;
|
||||
IReadOnlyList<Frame>? defaultPose = _defaultPose.Resolve(
|
||||
spawn.MotionTableId ?? 0u,
|
||||
setup.Parts.Count);
|
||||
IReadOnlyList<ShadowShape> raw = ShadowShapeBuilder.FromSetup(
|
||||
setup,
|
||||
scale,
|
||||
_hasPhysicsBsp,
|
||||
partPoseOverride: defaultPose,
|
||||
effectivePartGfxObjIds: effectivePartGfxObjIds);
|
||||
var shapes = new List<ShadowShape>(raw.Count);
|
||||
foreach (ShadowShape shape in raw)
|
||||
{
|
||||
if (shape.CollisionType == ShadowCollisionType.BSP)
|
||||
{
|
||||
float radius = (_physicsBspRadius(shape.GfxObjId) ?? 2f) * scale;
|
||||
shapes.Add(shape with { Radius = radius });
|
||||
}
|
||||
else
|
||||
{
|
||||
shapes.Add(shape);
|
||||
}
|
||||
}
|
||||
|
||||
if (shapes.Count == 0 && setup.Radius > 0.0001f)
|
||||
{
|
||||
shapes.Add(new ShadowShape(
|
||||
GfxObjId: 0u,
|
||||
LocalPosition: Vector3.Zero,
|
||||
LocalRotation: Quaternion.Identity,
|
||||
Scale: scale,
|
||||
CollisionType: ShadowCollisionType.Cylinder,
|
||||
Radius: setup.Radius * scale,
|
||||
CylHeight: (setup.Height > 0f ? setup.Height : setup.Radius * 2f) * scale));
|
||||
}
|
||||
|
||||
if (shapes.Count == 0 && !retainEmptyPayload)
|
||||
return null;
|
||||
|
||||
EntityCollisionFlags flags = EntityCollisionFlags.HasWeenie;
|
||||
if (spawn.ObjectDescriptionFlags is { } descriptionFlags)
|
||||
flags |= EntityCollisionFlagsExt.FromPwdBitfield(descriptionFlags);
|
||||
if (spawn.ItemType == (uint)ItemType.Creature)
|
||||
flags |= EntityCollisionFlags.IsCreature;
|
||||
|
||||
return new LiveEntityCollisionRegistration(
|
||||
entity.Id,
|
||||
entity.SourceGfxObjOrSetupId,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
shapes,
|
||||
(uint)exactRecord.FinalPhysicsState,
|
||||
flags,
|
||||
worldOrigin.X,
|
||||
worldOrigin.Y,
|
||||
position.LandblockId,
|
||||
position.LandblockId);
|
||||
}
|
||||
|
||||
public static void Register(
|
||||
ShadowObjectRegistry registry,
|
||||
LiveEntityCollisionRegistration registration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
registry.RegisterMultiPart(
|
||||
registration.EntityId,
|
||||
registration.EntityWorldPosition,
|
||||
registration.EntityWorldRotation,
|
||||
registration.Shapes,
|
||||
registration.State,
|
||||
registration.Flags,
|
||||
registration.WorldOffsetX,
|
||||
registration.WorldOffsetY,
|
||||
registration.LandblockId,
|
||||
registration.SeedCellId,
|
||||
isStatic: false);
|
||||
|
||||
if (!PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
return;
|
||||
|
||||
int cylinders = 0;
|
||||
int bsps = 0;
|
||||
foreach (ShadowShape shape in registration.Shapes)
|
||||
{
|
||||
if (shape.CollisionType == ShadowCollisionType.Cylinder)
|
||||
cylinders++;
|
||||
else
|
||||
bsps++;
|
||||
}
|
||||
Console.WriteLine(FormattableString.Invariant(
|
||||
$"[entity-source] id=0x{registration.EntityId:X8} entityId=0x{registration.EntityId:X8} src=0x{registration.SourceId:X8} gfxObj=0x{registration.SourceId:X8} lb=0x{registration.LandblockId:X8} shapes=cyl{cylinders}+bsp{bsps} note=server-spawn-root state=0x{registration.State:X8} flags={registration.Flags}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits a retail ObjDesc part-array replacement. A shapeless result
|
||||
/// removes the prior payload; a shaped result replaces it without making
|
||||
/// a Hidden, parented, or cell-less object collide in world cells.
|
||||
/// </summary>
|
||||
public static void ReconcileAppearance(
|
||||
ShadowObjectRegistry registry,
|
||||
uint entityId,
|
||||
LiveEntityCollisionRegistration? registration,
|
||||
bool suspendIfNew)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
if (registration is null)
|
||||
return;
|
||||
|
||||
if (registration.EntityId != entityId)
|
||||
throw new InvalidOperationException("Collision replacement belongs to a different live entity.");
|
||||
|
||||
registry.ReplaceMultiPartPayload(
|
||||
registration.EntityId,
|
||||
registration.EntityWorldPosition,
|
||||
registration.EntityWorldRotation,
|
||||
registration.Shapes,
|
||||
registration.State,
|
||||
registration.Flags,
|
||||
registration.WorldOffsetX,
|
||||
registration.WorldOffsetY,
|
||||
registration.LandblockId,
|
||||
registration.SeedCellId,
|
||||
isStatic: false,
|
||||
suspendIfNew);
|
||||
}
|
||||
}
|
||||
59
src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs
Normal file
59
src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the motion table's authored default-state part pose used by a
|
||||
/// live object's collision PartArray. Retail keeps the collision parts in the
|
||||
/// same current pose as the visual PartArray; an unresolved table falls back
|
||||
/// to Setup placement frames in <see cref="ShadowShapeBuilder"/>.
|
||||
/// The retained default-pose snapshot approximation is tracked as AP-84 in
|
||||
/// the retail divergence register until live per-frame collision poses land.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityDefaultPoseResolver
|
||||
{
|
||||
private readonly Func<uint, MotionTable?> _loadMotionTable;
|
||||
private readonly IAnimationLoader _animationLoader;
|
||||
private readonly bool _dumpMotion;
|
||||
|
||||
public LiveEntityDefaultPoseResolver(
|
||||
Func<uint, MotionTable?> loadMotionTable,
|
||||
IAnimationLoader animationLoader,
|
||||
bool dumpMotion)
|
||||
{
|
||||
_loadMotionTable = loadMotionTable
|
||||
?? throw new ArgumentNullException(nameof(loadMotionTable));
|
||||
_animationLoader = animationLoader
|
||||
?? throw new ArgumentNullException(nameof(animationLoader));
|
||||
_dumpMotion = dumpMotion;
|
||||
}
|
||||
|
||||
public IReadOnlyList<Frame>? Resolve(uint motionTableId, int partCount)
|
||||
{
|
||||
if (motionTableId == 0u || partCount == 0)
|
||||
return null;
|
||||
|
||||
MotionTable? motionTable = _loadMotionTable(motionTableId);
|
||||
if (motionTable is null)
|
||||
return null;
|
||||
|
||||
IReadOnlyList<Frame>? pose = MotionTablePose.DefaultStatePartFrames(
|
||||
motionTable,
|
||||
_animationLoader.LoadAnimation);
|
||||
|
||||
if (_dumpMotion)
|
||||
{
|
||||
string description = pose is null
|
||||
? "null->placement-fallback"
|
||||
: FormattableString.Invariant(
|
||||
$"part0=({pose[0].Origin.X:F2},{pose[0].Origin.Y:F2},{pose[0].Origin.Z:F2})");
|
||||
Console.WriteLine(FormattableString.Invariant(
|
||||
$"[shape-pose] mt=0x{motionTableId:X8} parts={partCount} {description}"));
|
||||
}
|
||||
|
||||
return pose;
|
||||
}
|
||||
}
|
||||
19
src/AcDream.App/Physics/LocalPlayerShadowState.cs
Normal file
19
src/AcDream.App/Physics/LocalPlayerShadowState.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>Session-scoped cache of the local player's last published shadow pose.</summary>
|
||||
internal sealed class LocalPlayerShadowState
|
||||
{
|
||||
public readonly record struct Snapshot(
|
||||
Vector3 Position,
|
||||
Quaternion Orientation,
|
||||
uint CellId);
|
||||
|
||||
public Snapshot? Current { get; private set; }
|
||||
|
||||
public void Set(Vector3 position, Quaternion orientation, uint cellId) =>
|
||||
Current = new Snapshot(position, orientation, cellId);
|
||||
|
||||
public void Clear() => Current = null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue