feat(vfx #C.1.5a): add EntityScriptActivator (no wiring yet)
New ~50-line orchestrator that fires Setup.DefaultScript through the already-shipped PhysicsScriptRunner on entity spawn and stops scripts + live emitters on despawn. Resolver delegate avoids DatCollection coupling so the class is fully unit-testable with stubs. Three xUnit tests cover the three branches: fire-with-script, no-op-without-script, stop-on-remove. No wiring into the live spawn path yet -- that lands in the next commit. Spec: docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ed5335b81e
commit
003c502774
2 changed files with 196 additions and 0 deletions
86
src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs
Normal file
86
src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/>
|
||||
/// when a server-spawned <see cref="WorldEntity"/> enters the world, so static
|
||||
/// objects (portals, chimneys, fireplaces, building details) emit their
|
||||
/// retail-faithful persistent particle effects automatically. Stops the
|
||||
/// scripts and live emitters when the entity despawns.
|
||||
///
|
||||
/// <para>
|
||||
/// Wires alongside <c>EntitySpawnAdapter</c> in <c>GpuWorldState</c>: the
|
||||
/// adapter handles meshes + animation state, the activator handles scripts +
|
||||
/// particles. Both are render-thread-only.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail oracle: <c>play_script_internal(setup.DefaultScript)</c> is what
|
||||
/// retail's <c>CPhysicsObj</c> invokes at object load (see Phase C.1 plan §C.1
|
||||
/// and <c>memory/project_sky_pes_port.md</c>). C.1 already shipped the runner;
|
||||
/// this class adds the missing fire-on-spawn call site.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class EntityScriptActivator
|
||||
{
|
||||
private readonly PhysicsScriptRunner _scriptRunner;
|
||||
private readonly ParticleHookSink _particleSink;
|
||||
private readonly Func<WorldEntity, uint> _defaultScriptResolver;
|
||||
|
||||
/// <param name="scriptRunner">Already-shipped runner from C.1. Owns the
|
||||
/// (scriptId, entityId) instance table and schedules hooks at their
|
||||
/// <c>StartTime</c> offsets.</param>
|
||||
/// <param name="particleSink">Already-shipped hook sink from C.1. The
|
||||
/// activator only calls its <see cref="ParticleHookSink.StopAllForEntity"/>
|
||||
/// to drop any per-entity emitter handles on despawn.</param>
|
||||
/// <param name="defaultScriptResolver">Returns
|
||||
/// <c>entity.SourceGfxObjOrSetupId</c>'s <c>Setup.DefaultScript.DataId</c>,
|
||||
/// or <c>0</c> on miss / dat throw / missing field. Production lambda hits
|
||||
/// <see cref="DatReaderWriter.DatCollection"/>; tests pass a hand-rolled
|
||||
/// stub.</param>
|
||||
public EntityScriptActivator(
|
||||
PhysicsScriptRunner scriptRunner,
|
||||
ParticleHookSink particleSink,
|
||||
Func<WorldEntity, uint> defaultScriptResolver)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scriptRunner);
|
||||
ArgumentNullException.ThrowIfNull(particleSink);
|
||||
ArgumentNullException.ThrowIfNull(defaultScriptResolver);
|
||||
_scriptRunner = scriptRunner;
|
||||
_particleSink = particleSink;
|
||||
_defaultScriptResolver = defaultScriptResolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
|
||||
/// the script runner. No-op if the entity has no DefaultScript
|
||||
/// (resolver returns 0) or if the entity has no server guid
|
||||
/// (atlas-tier entities are out of scope for this activator).
|
||||
/// </summary>
|
||||
public void OnCreate(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
if (entity.ServerGuid == 0) return;
|
||||
|
||||
uint scriptId = _defaultScriptResolver(entity);
|
||||
if (scriptId == 0) return;
|
||||
|
||||
_scriptRunner.Play(scriptId, entity.ServerGuid, entity.Position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop every script instance the runner is tracking for this entity, and
|
||||
/// kill every live emitter the sink has attributed to it. Idempotent for
|
||||
/// unknown guids (both calls no-op).
|
||||
/// </summary>
|
||||
public void OnRemove(uint serverGuid)
|
||||
{
|
||||
if (serverGuid == 0) return;
|
||||
_scriptRunner.StopAllForEntity(serverGuid);
|
||||
_particleSink.StopAllForEntity(serverGuid, fadeOut: false);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Vfx;
|
||||
|
||||
public sealed class EntityScriptActivatorTests
|
||||
{
|
||||
/// <summary>Recording sink so we can assert which hooks the runner fires.</summary>
|
||||
private sealed class RecordingSink : IAnimationHookSink
|
||||
{
|
||||
public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new();
|
||||
public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook)
|
||||
=> Calls.Add((entityId, worldPos, hook));
|
||||
}
|
||||
|
||||
private static DatPhysicsScript BuildScript(params (double time, AnimationHook hook)[] items)
|
||||
{
|
||||
var script = new DatPhysicsScript();
|
||||
foreach (var (t, h) in items)
|
||||
script.ScriptData.Add(new PhysicsScriptData { StartTime = t, Hook = h });
|
||||
return script;
|
||||
}
|
||||
|
||||
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) =>
|
||||
new()
|
||||
{
|
||||
Id = serverGuid,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = position,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
private record Pipeline(
|
||||
ParticleSystem System,
|
||||
ParticleHookSink Sink,
|
||||
PhysicsScriptRunner Runner,
|
||||
RecordingSink Recording);
|
||||
|
||||
private static Pipeline BuildPipeline(params (uint id, DatPhysicsScript script)[] scripts)
|
||||
{
|
||||
var registry = new EmitterDescRegistry();
|
||||
var system = new ParticleSystem(registry);
|
||||
var hookSink = new ParticleHookSink(system); // for activator's StopAllForEntity
|
||||
var recording = new RecordingSink(); // for runner's hook dispatch
|
||||
var table = new Dictionary<uint, DatPhysicsScript>();
|
||||
foreach (var (id, s) in scripts) table[id] = s;
|
||||
var runner = new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
recording);
|
||||
return new Pipeline(system, hookSink, runner, recording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_WithDefaultScript_FiresRunnerWithEntityGuidAndPosition()
|
||||
{
|
||||
var p = BuildPipeline(
|
||||
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0xAAu);
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: new Vector3(1, 2, 3));
|
||||
|
||||
activator.OnCreate(entity);
|
||||
|
||||
Assert.Equal(1, p.Runner.ActiveScriptCount);
|
||||
p.Runner.Tick(0.001f);
|
||||
Assert.Single(p.Recording.Calls);
|
||||
Assert.Equal(0xCAFEu, p.Recording.Calls[0].EntityId);
|
||||
Assert.Equal(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_WithoutDefaultScript_DoesNothing()
|
||||
{
|
||||
var p = BuildPipeline(); // no scripts registered
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0u);
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
|
||||
|
||||
activator.OnCreate(entity);
|
||||
|
||||
Assert.Equal(0, p.Runner.ActiveScriptCount);
|
||||
Assert.Empty(p.Recording.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnRemove_StopsScriptsAndEmitters()
|
||||
{
|
||||
var p = BuildPipeline(
|
||||
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0xAAu);
|
||||
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
|
||||
|
||||
activator.OnCreate(entity);
|
||||
Assert.Equal(1, p.Runner.ActiveScriptCount);
|
||||
|
||||
activator.OnRemove(0xCAFEu);
|
||||
|
||||
Assert.Equal(0, p.Runner.ActiveScriptCount);
|
||||
// Tick after Remove must not surface any further hook fires.
|
||||
p.Runner.Tick(1.0f);
|
||||
Assert.Empty(p.Recording.Calls);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue