feat(physics): port retail complete object frame pipeline
Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates. Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
31a0889f08
commit
f961d70023
77 changed files with 12513 additions and 1871 deletions
|
|
@ -0,0 +1,542 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class RetailStaticAnimatingObjectSchedulerTests
|
||||
{
|
||||
private const uint OwnerId = 0x7F000001u;
|
||||
private const uint AnimationId = 0x0300AA01u;
|
||||
private const uint GfxId = 0x0100AA01u;
|
||||
|
||||
[Fact]
|
||||
public void DefaultAnimation_UsesSeparateWholeElapsedStaticWorkset()
|
||||
{
|
||||
var loader = new Loader();
|
||||
loader.Add(AnimationId, TwoFrameAnimation());
|
||||
int hookCaptures = 0;
|
||||
int posePublishes = 0;
|
||||
var scheduler = new RetailStaticAnimatingObjectScheduler(
|
||||
loader,
|
||||
(_, _) => hookCaptures++,
|
||||
(_, _, _) => posePublishes++);
|
||||
Setup setup = MakeSetup();
|
||||
WorldEntity entity = MakeEntity();
|
||||
scheduler.Register(entity, new ScriptActivationInfo(
|
||||
ScriptId: 0,
|
||||
PartTransforms: entity.IndexedPartTransforms,
|
||||
PartAvailability: entity.IndexedPartAvailable,
|
||||
Setup: setup,
|
||||
DefaultAnimationId: AnimationId,
|
||||
UsesStaticAnimationWorkset: true));
|
||||
|
||||
var animatedIds = new HashSet<uint>();
|
||||
scheduler.CopyAnimatedEntityIdsTo(animatedIds);
|
||||
Assert.Contains(OwnerId, animatedIds);
|
||||
|
||||
scheduler.Tick(1f / 60f);
|
||||
scheduler.ProcessHooks();
|
||||
|
||||
Assert.Equal(1, hookCaptures);
|
||||
Assert.Equal(1, posePublishes);
|
||||
Assert.Single(entity.MeshRefs);
|
||||
Assert.True(entity.MeshRefs[0].PartTransform.Translation.X > 0f);
|
||||
|
||||
scheduler.Tick(2f);
|
||||
scheduler.ProcessHooks();
|
||||
Assert.Equal(2, hookCaptures);
|
||||
Assert.Equal(2, posePublishes);
|
||||
|
||||
Matrix4x4 beforeDiscard = entity.MeshRefs[0].PartTransform;
|
||||
scheduler.Tick(2.01f);
|
||||
scheduler.ProcessHooks();
|
||||
Assert.Equal(beforeDiscard, entity.MeshRefs[0].PartTransform);
|
||||
Assert.Equal(2, hookCaptures);
|
||||
Assert.Equal(2, posePublishes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubEpsilonElapsed_IsDiscardedInsteadOfAccumulated()
|
||||
{
|
||||
var loader = new Loader();
|
||||
loader.Add(AnimationId, TwoFrameAnimation());
|
||||
int hookCaptures = 0;
|
||||
var scheduler = new RetailStaticAnimatingObjectScheduler(
|
||||
loader,
|
||||
(_, _) => hookCaptures++,
|
||||
(_, _, _) => { });
|
||||
WorldEntity entity = MakeEntity();
|
||||
scheduler.Register(entity, new ScriptActivationInfo(
|
||||
ScriptId: 0,
|
||||
PartTransforms: entity.IndexedPartTransforms,
|
||||
PartAvailability: entity.IndexedPartAvailable,
|
||||
Setup: MakeSetup(),
|
||||
DefaultAnimationId: AnimationId,
|
||||
UsesStaticAnimationWorkset: true));
|
||||
|
||||
scheduler.Tick(0.0001f);
|
||||
scheduler.ProcessHooks();
|
||||
scheduler.Tick(0.0001f);
|
||||
scheduler.ProcessHooks();
|
||||
Assert.Equal(0, hookCaptures);
|
||||
|
||||
scheduler.Tick(0.0003f);
|
||||
scheduler.ProcessHooks();
|
||||
Assert.Equal(1, hookCaptures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptOnlyOwner_DoesNotEnterAnimationWorkset()
|
||||
{
|
||||
var scheduler = new RetailStaticAnimatingObjectScheduler(
|
||||
new Loader(),
|
||||
(_, _) => { },
|
||||
(_, _, _) => { });
|
||||
Setup setup = MakeSetup();
|
||||
WorldEntity entity = MakeEntity();
|
||||
scheduler.Register(entity, new ScriptActivationInfo(
|
||||
ScriptId: 0x3300AA01u,
|
||||
PartTransforms: entity.IndexedPartTransforms,
|
||||
PartAvailability: entity.IndexedPartAvailable,
|
||||
Setup: setup));
|
||||
|
||||
Assert.Equal(0, scheduler.Count);
|
||||
var animatedIds = new HashSet<uint>();
|
||||
scheduler.CopyAnimatedEntityIdsTo(animatedIds);
|
||||
Assert.Empty(animatedIds);
|
||||
|
||||
scheduler.Unregister(OwnerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LivePhysicsStaticOwner_CatchesUpShortNonResidentIntervalOnReentry()
|
||||
{
|
||||
var loader = new Loader();
|
||||
loader.Add(AnimationId, TwoFrameAnimation());
|
||||
bool resident = false;
|
||||
int captures = 0;
|
||||
var scheduler = new RetailStaticAnimatingObjectScheduler(
|
||||
loader,
|
||||
(_, _) => captures++,
|
||||
(_, _, _) => { },
|
||||
_ => resident);
|
||||
Setup setup = MakeSetup();
|
||||
WorldEntity entity = MakeEntity(serverGuid: 0x70000001u);
|
||||
scheduler.Register(entity, new ScriptActivationInfo(
|
||||
ScriptId: 0,
|
||||
PartTransforms: entity.IndexedPartTransforms,
|
||||
PartAvailability: entity.IndexedPartAvailable,
|
||||
Setup: setup,
|
||||
DefaultAnimationId: AnimationId,
|
||||
UsesStaticAnimationWorkset: true));
|
||||
BindLiveOwner(
|
||||
scheduler,
|
||||
entity,
|
||||
setup,
|
||||
loader,
|
||||
out _);
|
||||
|
||||
scheduler.Tick(0.02f);
|
||||
scheduler.ProcessHooks();
|
||||
Assert.Equal(0, captures);
|
||||
|
||||
resident = true;
|
||||
scheduler.Tick(0.02f);
|
||||
scheduler.ProcessHooks();
|
||||
Assert.Equal(1, captures);
|
||||
Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out var frames));
|
||||
|
||||
var reference = new AnimationSequencer(
|
||||
setup,
|
||||
new MotionTable(),
|
||||
loader);
|
||||
IReadOnlyList<PartTransform> referenceFrames = reference.Advance(0.04f);
|
||||
|
||||
Assert.Equal(referenceFrames[0], frames[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LivePhysicsStaticOwner_DiscardsLongNonResidentIntervalOnReentry()
|
||||
{
|
||||
var loader = new Loader();
|
||||
loader.Add(AnimationId, TwoFrameAnimation());
|
||||
bool resident = false;
|
||||
int captures = 0;
|
||||
var scheduler = new RetailStaticAnimatingObjectScheduler(
|
||||
loader,
|
||||
(_, _) => captures++,
|
||||
(_, _, _) => { },
|
||||
_ => resident);
|
||||
WorldEntity entity = MakeEntity(serverGuid: 0x70000001u);
|
||||
scheduler.Register(entity, new ScriptActivationInfo(
|
||||
ScriptId: 0,
|
||||
PartTransforms: entity.IndexedPartTransforms,
|
||||
PartAvailability: entity.IndexedPartAvailable,
|
||||
Setup: MakeSetup(),
|
||||
DefaultAnimationId: AnimationId,
|
||||
UsesStaticAnimationWorkset: true));
|
||||
BindLiveOwner(
|
||||
scheduler,
|
||||
entity,
|
||||
MakeSetup(),
|
||||
loader,
|
||||
out _);
|
||||
|
||||
scheduler.Tick(2.01f);
|
||||
scheduler.ProcessHooks();
|
||||
resident = true;
|
||||
scheduler.Tick(0.1f);
|
||||
scheduler.ProcessHooks();
|
||||
Assert.Equal(0, captures);
|
||||
|
||||
scheduler.Tick(0.1f);
|
||||
scheduler.ProcessHooks();
|
||||
Assert.Equal(1, captures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LivePhysicsStaticOwner_UsesCanonicalSequencerAndRawOmega()
|
||||
{
|
||||
const uint alternateAnimationId = 0x0300AA02u;
|
||||
var loader = new Loader();
|
||||
loader.Add(AnimationId, TwoFrameAnimation());
|
||||
loader.Add(alternateAnimationId, OffsetAnimation(20f));
|
||||
var shadows = new ShadowObjectRegistry();
|
||||
Matrix4x4 committedRoot = Matrix4x4.Identity;
|
||||
var scheduler = new RetailStaticAnimatingObjectScheduler(
|
||||
loader,
|
||||
(_, _) => { },
|
||||
(_, _, _) => { },
|
||||
commitLiveRoot: (entity, body) =>
|
||||
{
|
||||
shadows.UpdatePosition(
|
||||
entity.Id,
|
||||
body.Position,
|
||||
body.Orientation,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: 0x01010000u,
|
||||
seedCellId: body.CellPosition.ObjCellId);
|
||||
committedRoot = Matrix4x4.CreateFromQuaternion(entity.Rotation)
|
||||
* Matrix4x4.CreateTranslation(entity.Position);
|
||||
});
|
||||
Setup setup = MakeSetup();
|
||||
WorldEntity entity = MakeEntity(serverGuid: 0x70000001u);
|
||||
entity.Position = new Vector3(12f, 12f, 50f);
|
||||
shadows.RegisterMultiPart(
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
new[]
|
||||
{
|
||||
new ShadowShape(
|
||||
GfxId,
|
||||
Vector3.UnitX,
|
||||
Quaternion.Identity,
|
||||
Scale: 1f,
|
||||
CollisionType: ShadowCollisionType.Cylinder,
|
||||
Radius: 0.5f,
|
||||
CylHeight: 1f),
|
||||
},
|
||||
state: 0u,
|
||||
flags: EntityCollisionFlags.None,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: 0x01010000u,
|
||||
seedCellId: 0x01010001u);
|
||||
scheduler.Register(entity, new ScriptActivationInfo(
|
||||
ScriptId: 0,
|
||||
PartTransforms: entity.IndexedPartTransforms,
|
||||
PartAvailability: entity.IndexedPartAvailable,
|
||||
Setup: setup,
|
||||
DefaultAnimationId: AnimationId,
|
||||
UsesStaticAnimationWorkset: true));
|
||||
AnimationSequencer sequencer = BindLiveOwner(
|
||||
scheduler,
|
||||
entity,
|
||||
setup,
|
||||
loader,
|
||||
out PhysicsBody body);
|
||||
body.Omega = new Vector3(0f, 0f, MathF.PI / 2f);
|
||||
|
||||
// Simulates UpdateMotion replacing the sequence on the same PartArray.
|
||||
Assert.True(sequencer.InitializeSetupDefaultAnimation(alternateAnimationId));
|
||||
scheduler.Tick(0.01f);
|
||||
|
||||
Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out var frames));
|
||||
Assert.True(frames[0].Origin.X >= 20f);
|
||||
Vector3 rotatedX = Vector3.Transform(Vector3.UnitX, entity.Rotation);
|
||||
Assert.InRange(rotatedX.X, -0.001f, 0.001f);
|
||||
Assert.InRange(rotatedX.Y, 0.999f, 1.001f);
|
||||
Assert.Equal(entity.Rotation, body.Orientation);
|
||||
ShadowEntry rotatedShadow = Assert.Single(
|
||||
shadows.AllEntriesForDebug(),
|
||||
item => item.EntityId == entity.Id);
|
||||
Assert.InRange(rotatedShadow.Position.X, 11.999f, 12.001f);
|
||||
Assert.InRange(rotatedShadow.Position.Y, 12.999f, 13.001f);
|
||||
Vector3 committedX = Vector3.TransformNormal(
|
||||
Vector3.UnitX,
|
||||
committedRoot);
|
||||
Assert.InRange(committedX.X, -0.001f, 0.001f);
|
||||
Assert.InRange(committedX.Y, 0.999f, 1.001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LivePhysicsStaticOwner_ZeroOmegaDoesNotCommitUnchangedRoot()
|
||||
{
|
||||
var loader = new Loader();
|
||||
loader.Add(AnimationId, TwoFrameAnimation());
|
||||
int rootCommits = 0;
|
||||
var scheduler = new RetailStaticAnimatingObjectScheduler(
|
||||
loader,
|
||||
(_, _) => { },
|
||||
(_, _, _) => { },
|
||||
commitLiveRoot: (_, _) => rootCommits++);
|
||||
Setup setup = MakeSetup();
|
||||
WorldEntity entity = MakeEntity(serverGuid: 0x70000001u);
|
||||
scheduler.Register(entity, new ScriptActivationInfo(
|
||||
ScriptId: 0,
|
||||
PartTransforms: entity.IndexedPartTransforms,
|
||||
PartAvailability: entity.IndexedPartAvailable,
|
||||
Setup: setup,
|
||||
DefaultAnimationId: AnimationId,
|
||||
UsesStaticAnimationWorkset: true));
|
||||
BindLiveOwner(scheduler, entity, setup, loader, out _);
|
||||
|
||||
scheduler.Tick(0.1f);
|
||||
|
||||
Assert.Equal(0, rootCommits);
|
||||
Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LivePhysicsStaticOwner_WithdrawalDuringRootCommitDropsPreparedPoseAndHooks()
|
||||
{
|
||||
var loader = new Loader();
|
||||
loader.Add(AnimationId, TwoFrameAnimation());
|
||||
bool resident = true;
|
||||
ulong residencyVersion = 1;
|
||||
int captures = 0;
|
||||
var scheduler = new RetailStaticAnimatingObjectScheduler(
|
||||
loader,
|
||||
(_, _) => captures++,
|
||||
(_, _, _) => { },
|
||||
isResident: _ => resident,
|
||||
commitLiveRoot: (_, _) =>
|
||||
{
|
||||
resident = false;
|
||||
residencyVersion++;
|
||||
},
|
||||
residencyVersion: _ => residencyVersion);
|
||||
Setup setup = MakeSetup();
|
||||
WorldEntity entity = MakeEntity(serverGuid: 0x70000001u);
|
||||
scheduler.Register(entity, new ScriptActivationInfo(
|
||||
ScriptId: 0,
|
||||
PartTransforms: entity.IndexedPartTransforms,
|
||||
PartAvailability: entity.IndexedPartAvailable,
|
||||
Setup: setup,
|
||||
DefaultAnimationId: AnimationId,
|
||||
UsesStaticAnimationWorkset: true));
|
||||
BindLiveOwner(scheduler, entity, setup, loader, out PhysicsBody body);
|
||||
body.Omega = new Vector3(0f, 0f, MathF.PI / 2f);
|
||||
|
||||
scheduler.Tick(0.1f);
|
||||
scheduler.ProcessHooks();
|
||||
|
||||
Assert.False(scheduler.TryTakeLivePartFrames(OwnerId, out _));
|
||||
Assert.Equal(0, captures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LivePhysicsStaticOwner_NonCyclicAnimationDoneRunsAfterFinalPartAndChildPose()
|
||||
{
|
||||
const uint style = 0x8000003Eu;
|
||||
const uint readyMotion = 0x41000003u;
|
||||
const uint actionMotion = 0x10000058u;
|
||||
const uint actionAnimationId = 0x0300AA03u;
|
||||
var loader = new Loader();
|
||||
loader.Add(AnimationId, TwoFrameAnimation());
|
||||
loader.Add(actionAnimationId, OffsetAnimation(20f));
|
||||
Setup setup = MakeSetup();
|
||||
var table = new MotionTable
|
||||
{
|
||||
DefaultStyle = (DRWMotionCommand)style,
|
||||
};
|
||||
table.StyleDefaults[(DRWMotionCommand)style] =
|
||||
(DRWMotionCommand)readyMotion;
|
||||
int readyKey = unchecked((int)((style << 16) | (readyMotion & 0xFFFFFFu)));
|
||||
table.Cycles[readyKey] = MakeMotionData(AnimationId);
|
||||
var links = new MotionCommandData();
|
||||
links.MotionData[unchecked((int)actionMotion)] =
|
||||
MakeMotionData(actionAnimationId);
|
||||
table.Links[readyKey] = links;
|
||||
|
||||
WorldEntity entity = MakeEntity(serverGuid: 0x70000001u);
|
||||
var sequencer = new AnimationSequencer(setup, table, loader);
|
||||
sequencer.SetCycle(style, readyMotion);
|
||||
sequencer.ConsumePendingHooks();
|
||||
sequencer.PlayAction(actionMotion);
|
||||
Assert.NotEmpty(sequencer.Manager.PendingAnimations);
|
||||
|
||||
var order = new List<string>();
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
poses.Publish(entity, entity.IndexedPartTransforms, entity.IndexedPartAvailable);
|
||||
var hookFrames = new AnimationHookFrameQueue(
|
||||
new AnimationHookRouter(),
|
||||
poses);
|
||||
var scheduler = new RetailStaticAnimatingObjectScheduler(
|
||||
loader,
|
||||
(ownerId, ownerSequencer) =>
|
||||
{
|
||||
order.Add("process_hooks");
|
||||
hookFrames.Capture(ownerId, ownerSequencer);
|
||||
},
|
||||
(_, _, _) => { },
|
||||
commitLiveRoot: (_, _) => order.Add("root_committed"));
|
||||
scheduler.Register(entity, new ScriptActivationInfo(
|
||||
ScriptId: 0,
|
||||
PartTransforms: entity.IndexedPartTransforms,
|
||||
PartAvailability: entity.IndexedPartAvailable,
|
||||
Setup: setup,
|
||||
DefaultAnimationId: AnimationId,
|
||||
UsesStaticAnimationWorkset: true));
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
Orientation = entity.Rotation,
|
||||
Omega = new Vector3(0f, 0f, MathF.PI / 2f),
|
||||
};
|
||||
body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero);
|
||||
Assert.True(scheduler.BindLiveOwner(entity, sequencer, body));
|
||||
sequencer.MotionDoneTarget = (_, success) =>
|
||||
{
|
||||
Assert.True(success);
|
||||
order.Add("animation_done");
|
||||
};
|
||||
|
||||
scheduler.Tick(0.2f);
|
||||
|
||||
Assert.DoesNotContain("animation_done", order);
|
||||
Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out _));
|
||||
order.Add("parts_published");
|
||||
order.Add("children_updated");
|
||||
scheduler.ProcessHooks();
|
||||
|
||||
Assert.Equal("root_committed", order[0]);
|
||||
Assert.Equal("parts_published", order[1]);
|
||||
Assert.Equal("children_updated", order[2]);
|
||||
Assert.Equal("process_hooks", order[3]);
|
||||
Assert.True(order.Count > 4);
|
||||
Assert.All(order.Skip(4), item => Assert.Equal("animation_done", item));
|
||||
Assert.Empty(sequencer.Manager.PendingAnimations);
|
||||
}
|
||||
|
||||
private static Setup MakeSetup()
|
||||
{
|
||||
var setup = new Setup();
|
||||
setup.Parts.Add(GfxId);
|
||||
setup.DefaultScale.Add(Vector3.One);
|
||||
setup.DefaultAnimation = (QualifiedDataId<Animation>)AnimationId;
|
||||
return setup;
|
||||
}
|
||||
|
||||
private static WorldEntity MakeEntity(
|
||||
uint serverGuid = 0,
|
||||
uint ownerId = OwnerId)
|
||||
{
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = ownerId,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0x0200AA01u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = new[] { new MeshRef(GfxId, Matrix4x4.Identity) },
|
||||
};
|
||||
entity.SetIndexedPartPoses(
|
||||
new[] { Matrix4x4.Identity },
|
||||
new[] { true });
|
||||
return entity;
|
||||
}
|
||||
|
||||
private static Animation TwoFrameAnimation()
|
||||
{
|
||||
var animation = new Animation();
|
||||
var first = new AnimationFrame(1);
|
||||
first.Frames.Add(new Frame
|
||||
{
|
||||
Origin = Vector3.Zero,
|
||||
Orientation = Quaternion.Identity,
|
||||
});
|
||||
var second = new AnimationFrame(1);
|
||||
second.Frames.Add(new Frame
|
||||
{
|
||||
Origin = new Vector3(2f, 0f, 0f),
|
||||
Orientation = Quaternion.Identity,
|
||||
});
|
||||
animation.PartFrames.Add(first);
|
||||
animation.PartFrames.Add(second);
|
||||
return animation;
|
||||
}
|
||||
|
||||
private static Animation OffsetAnimation(float x)
|
||||
{
|
||||
var animation = new Animation();
|
||||
var frame = new AnimationFrame(1);
|
||||
frame.Frames.Add(new Frame
|
||||
{
|
||||
Origin = new Vector3(x, 0f, 0f),
|
||||
Orientation = Quaternion.Identity,
|
||||
});
|
||||
animation.PartFrames.Add(frame);
|
||||
return animation;
|
||||
}
|
||||
|
||||
private static MotionData MakeMotionData(uint animationId)
|
||||
{
|
||||
var data = new MotionData();
|
||||
data.Anims.Add(new AnimData
|
||||
{
|
||||
AnimId = (QualifiedDataId<Animation>)animationId,
|
||||
LowFrame = 0,
|
||||
HighFrame = -1,
|
||||
Framerate = 10f,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
private static AnimationSequencer BindLiveOwner(
|
||||
RetailStaticAnimatingObjectScheduler scheduler,
|
||||
WorldEntity entity,
|
||||
Setup setup,
|
||||
IAnimationLoader loader,
|
||||
out PhysicsBody body)
|
||||
{
|
||||
var sequencer = new AnimationSequencer(
|
||||
setup,
|
||||
new MotionTable(),
|
||||
loader);
|
||||
body = new PhysicsBody
|
||||
{
|
||||
Orientation = entity.Rotation,
|
||||
};
|
||||
body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero);
|
||||
Assert.True(scheduler.BindLiveOwner(entity, sequencer, body));
|
||||
return sequencer;
|
||||
}
|
||||
|
||||
private sealed class Loader : IAnimationLoader
|
||||
{
|
||||
private readonly Dictionary<uint, Animation> _animations = new();
|
||||
public void Add(uint id, Animation animation) => _animations[id] = animation;
|
||||
public Animation? LoadAnimation(uint id) =>
|
||||
_animations.TryGetValue(id, out Animation? animation)
|
||||
? animation
|
||||
: null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue