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:
Erik 2026-05-11 14:10:38 +02:00
parent ed5335b81e
commit 003c502774
2 changed files with 196 additions and 0 deletions

View 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);
}
}