Adds per-entity part-transform side-table mirroring _rotationByEntity. SpawnFromHook now transforms the hook offset through partTransforms[partIndex] before rotating to world space. Backwards-compatible: entities without registered part transforms fall through to identity (pre-C.1.5b behavior), so the existing C.1.5a rotation-seed test stays green. Adds SetEntityPartTransforms public method. Cleared on StopAllForEntity alongside the rotation entry. 2 new xUnit tests: - SpawnFromHook_AppliesPartTransform_WhenRegistered — part 1 lifted +Z=1, hook offset (1,0,0), PartIndex=1 → world (1,0,1). - SpawnFromHook_FallsBackToIdentity_WhenPartIndexOutOfBounds — PartIndex=99 on a 2-part array → offset applied without crash, pre-C.1.5b behavior. Closes the renderer side of #56. EntityScriptActivator wiring (Task 3) lands next. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
175 lines
6.7 KiB
C#
175 lines
6.7 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.Vfx;
|
|
using DatReaderWriter.Types;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Vfx;
|
|
|
|
public sealed class ParticleHookSinkTests
|
|
{
|
|
private static EmitterDesc MakeDesc(uint id, bool attachLocal, int totalParticles = 0)
|
|
{
|
|
return new EmitterDesc
|
|
{
|
|
DatId = id,
|
|
Type = ParticleType.Still,
|
|
Flags = EmitterFlags.Billboard | (attachLocal ? EmitterFlags.AttachLocal : 0),
|
|
EmitterKind = ParticleEmitterKind.BirthratePerSec,
|
|
MaxParticles = 4,
|
|
InitialParticles = 1,
|
|
TotalParticles = totalParticles,
|
|
LifetimeMin = 0.05f, LifetimeMax = 0.05f, Lifespan = 0.05f,
|
|
StartSize = 1f, EndSize = 1f,
|
|
StartAlpha = 1f, EndAlpha = 1f,
|
|
Birthrate = 1000f, // effectively never re-emit
|
|
};
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor()
|
|
{
|
|
var registry = new EmitterDescRegistry();
|
|
registry.Register(MakeDesc(0x32000010u, attachLocal: true));
|
|
var sys = new ParticleSystem(registry, new System.Random(42));
|
|
var sink = new ParticleHookSink(sys);
|
|
|
|
var hook = new CreateParticleHook
|
|
{
|
|
EmitterInfoId = 0x32000010u,
|
|
EmitterId = 0,
|
|
PartIndex = 0,
|
|
Offset = new Frame(),
|
|
};
|
|
// First spawn at world origin.
|
|
sink.OnHook(entityId: 0xCAFEu, entityWorldPosition: Vector3.Zero, hook);
|
|
sys.Tick(0.01f);
|
|
|
|
var live1 = System.Linq.Enumerable.Single(sys.EnumerateLive());
|
|
Assert.Equal(Vector3.Zero, live1.Emitter.Particles[live1.Index].Position);
|
|
|
|
// Move the parent to (5, 7, 0) — UpdateEntityAnchor must propagate.
|
|
sink.UpdateEntityAnchor(0xCAFEu, new Vector3(5, 7, 0), Quaternion.Identity);
|
|
sys.Tick(0.01f);
|
|
|
|
var live2 = System.Linq.Enumerable.Single(sys.EnumerateLive());
|
|
Assert.Equal(new Vector3(5, 7, 0), live2.Emitter.Particles[live2.Index].Position);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmitterDied_PrunesPerEntityHandleTracking()
|
|
{
|
|
// M4: ConcurrentBag<int> couldn't drop entries when a particle
|
|
// emitter expired naturally, so per-entity tracking grew without
|
|
// bound. The sink now subscribes to ParticleSystem.EmitterDied
|
|
// and prunes both the (entity,key) map and the per-entity set.
|
|
var registry = new EmitterDescRegistry();
|
|
registry.Register(MakeDesc(0x32000020u, attachLocal: false, totalParticles: 1));
|
|
var sys = new ParticleSystem(registry, new System.Random(42));
|
|
var sink = new ParticleHookSink(sys);
|
|
|
|
var hook = new CreateParticleHook
|
|
{
|
|
EmitterInfoId = 0x32000020u,
|
|
EmitterId = 0xABCDu, // logical key
|
|
PartIndex = 0,
|
|
Offset = new Frame(),
|
|
};
|
|
sink.OnHook(0xCAFEu, Vector3.Zero, hook);
|
|
Assert.Equal(1, sys.ActiveEmitterCount);
|
|
|
|
// TotalParticles=1 cap hit immediately by the InitialParticles spawn,
|
|
// so the emitter Finishes once its single particle expires (0.05s
|
|
// lifetime). After this, EmitterDied has fired and tracking is pruned.
|
|
for (int i = 0; i < 5; i++) sys.Tick(0.05f);
|
|
Assert.Equal(0, sys.ActiveEmitterCount);
|
|
|
|
// A fresh spawn for the same (entity, key) succeeds and is the only
|
|
// live emitter — i.e., the previous handle was pruned cleanly.
|
|
sink.OnHook(0xCAFEu, Vector3.Zero, hook);
|
|
Assert.Equal(1, sys.ActiveEmitterCount);
|
|
|
|
sink.StopAllForEntity(0xCAFEu, fadeOut: false);
|
|
sys.Tick(0.01f);
|
|
Assert.Equal(0, sys.ActiveEmitterCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void SpawnFromHook_AppliesPartTransform_WhenRegistered()
|
|
{
|
|
// C.1.5b #56: when SetEntityPartTransforms has been called for
|
|
// entityId, SpawnFromHook must transform the hook offset through
|
|
// the part-local matrix before applying entity rotation.
|
|
// Part 1 is lifted +Z=1; hook offset = (1, 0, 0), PartIndex=1.
|
|
// Expected world position: (1, 0, 1) with identity rotation.
|
|
var registry = new EmitterDescRegistry();
|
|
registry.Register(MakeDesc(0x32000030u, attachLocal: false));
|
|
var sys = new ParticleSystem(registry, new System.Random(42));
|
|
var sink = new ParticleHookSink(sys);
|
|
|
|
var partTransforms = new Matrix4x4[]
|
|
{
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.CreateTranslation(0f, 0f, 1f),
|
|
};
|
|
sink.SetEntityRotation(0xCAFEu, Quaternion.Identity);
|
|
sink.SetEntityPartTransforms(0xCAFEu, partTransforms);
|
|
|
|
sink.OnHook(0xCAFEu, Vector3.Zero, new CreateParticleHook
|
|
{
|
|
EmitterInfoId = 0x32000030u,
|
|
EmitterId = 0,
|
|
PartIndex = 1,
|
|
Offset = new Frame
|
|
{
|
|
Origin = new Vector3(1f, 0f, 0f),
|
|
Orientation = Quaternion.Identity,
|
|
},
|
|
});
|
|
sys.Tick(0.001f);
|
|
|
|
var live = System.Linq.Enumerable.Single(sys.EnumerateLive());
|
|
var pos = live.Emitter.Particles[live.Index].Position;
|
|
Assert.InRange(pos.X, 0.99f, 1.01f);
|
|
Assert.InRange(pos.Y, -0.01f, 0.01f);
|
|
Assert.InRange(pos.Z, 0.99f, 1.01f);
|
|
}
|
|
|
|
[Fact]
|
|
public void SpawnFromHook_FallsBackToIdentity_WhenPartIndexOutOfBounds()
|
|
{
|
|
// Out-of-bounds PartIndex must NOT crash and must NOT apply a
|
|
// wrong matrix; falls back to no part transform (Identity), so
|
|
// the offset is applied in entity-local space as pre-C.1.5b.
|
|
var registry = new EmitterDescRegistry();
|
|
registry.Register(MakeDesc(0x32000031u, attachLocal: false));
|
|
var sys = new ParticleSystem(registry, new System.Random(42));
|
|
var sink = new ParticleHookSink(sys);
|
|
|
|
var partTransforms = new Matrix4x4[]
|
|
{
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.CreateTranslation(0f, 0f, 1f),
|
|
};
|
|
sink.SetEntityRotation(0xCAFEu, Quaternion.Identity);
|
|
sink.SetEntityPartTransforms(0xCAFEu, partTransforms);
|
|
|
|
sink.OnHook(0xCAFEu, Vector3.Zero, new CreateParticleHook
|
|
{
|
|
EmitterInfoId = 0x32000031u,
|
|
EmitterId = 0,
|
|
PartIndex = 99, // way past the 2-part array
|
|
Offset = new Frame
|
|
{
|
|
Origin = new Vector3(2f, 0f, 0f),
|
|
Orientation = Quaternion.Identity,
|
|
},
|
|
});
|
|
sys.Tick(0.001f);
|
|
|
|
var live = System.Linq.Enumerable.Single(sys.EnumerateLive());
|
|
var pos = live.Emitter.Particles[live.Index].Position;
|
|
Assert.InRange(pos.X, 1.99f, 2.01f);
|
|
Assert.InRange(pos.Y, -0.01f, 0.01f);
|
|
Assert.InRange(pos.Z, -0.01f, 0.01f);
|
|
}
|
|
}
|