acdream/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs
Erik 5ca5827abe feat(vfx #C.1.5b): activator handles dat-hydrated entities + per-part transforms
Resolver returns ScriptActivationInfo(ScriptId, PartTransforms) — one
dat lookup per spawn yields both pieces of info. The C.1.5a ServerGuid==0
guard is relaxed: activator now keys by ServerGuid when nonzero, else
entity.Id, so dat-hydrated entities (EnvCell statics, exterior stabs)
flow through the same code path as server-spawned ones. PartTransforms
pushed into ParticleHookSink before scheduling Play, closing the
activator side of #56.

GameWindow resolver lambda upgraded: now constructs ScriptActivationInfo
from setup.DefaultScript.DataId + SetupPartTransforms.Compute(setup),
swallowing dat-lookup throws the same way C.1.5a did.

Tests: 4 existing tests updated for new ScriptActivationInfo signature;
3 new tests cover entity.Id keying for dat-hydrated entities, end-to-end
part-transform pipeline (resolver → sink → particle world position), and
OnRemove with an arbitrary caller-picked key. 77 Vfx+Meshing+Activator
tests green.

GpuWorldState fire-site wiring (Task 4) lands next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:02:16 +02:00

133 lines
6.1 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// What the activator's resolver returns when an entity's Setup carries
/// a <c>DefaultScript</c>. Bundles the script id with the per-part
/// transforms baked from <c>Setup.PlacementFrames</c> so a single dat
/// lookup yields both pieces of state. The activator pushes the part
/// transforms into <see cref="ParticleHookSink.SetEntityPartTransforms"/>
/// before calling <see cref="PhysicsScriptRunner.Play"/>, which closes
/// the part-anchor pipeline introduced for issue #56.
/// </summary>
public sealed record ScriptActivationInfo(
uint ScriptId,
IReadOnlyList<Matrix4x4> PartTransforms);
/// <summary>
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/>
/// when a <see cref="WorldEntity"/> enters the world, so static objects
/// (portals, chimneys, fireplaces, EnvCell decorations, building details)
/// emit their retail-faithful persistent particle effects automatically.
/// Stops the scripts and live emitters when the entity despawns.
///
/// <para>
/// Handles both server-spawned entities (<c>ServerGuid != 0</c>, keyed by
/// ServerGuid) and dat-hydrated entities (<c>ServerGuid == 0</c>, keyed by
/// <c>entity.Id</c>). The C.1.5a guard that early-returned for
/// <c>ServerGuid == 0</c> was relaxed in C.1.5b so EnvCell static objects
/// (which have no server guid because they come from the dat file, not
/// the network) also fire their DefaultScript.
/// </para>
///
/// <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. The activator is invoked from
/// four GpuWorldState fire-sites (AppendLiveEntity, AddLandblock,
/// AddEntitiesToExistingLandblock, plus the matching remove paths).
/// </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, ScriptActivationInfo?> _resolver;
/// <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 pushes per-entity rotation + part transforms here, and
/// calls <see cref="ParticleHookSink.StopAllForEntity"/> to drop
/// per-entity emitter handles on despawn.</param>
/// <param name="resolver">Returns
/// <see cref="ScriptActivationInfo"/> with the entity's
/// <c>Setup.DefaultScript.DataId</c> and per-part transforms (via
/// <c>SetupPartTransforms.Compute</c>), or <c>null</c> on dat miss /
/// throw / missing DefaultScript. Production lambda hits
/// <c>DatCollection</c>; tests pass a hand-rolled stub.</param>
public EntityScriptActivator(
PhysicsScriptRunner scriptRunner,
ParticleHookSink particleSink,
Func<WorldEntity, ScriptActivationInfo?> resolver)
{
ArgumentNullException.ThrowIfNull(scriptRunner);
ArgumentNullException.ThrowIfNull(particleSink);
ArgumentNullException.ThrowIfNull(resolver);
_scriptRunner = scriptRunner;
_particleSink = particleSink;
_resolver = resolver;
}
/// <summary>
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
/// the script runner. Keys by <c>entity.ServerGuid</c> when non-zero,
/// otherwise by <c>entity.Id</c> (the latter handles dat-hydrated
/// EnvCell statics + exterior stabs whose <c>entity.Id</c> lives in
/// the <c>0x40xxxxxx</c> range — collision-free with server guids).
/// No-op if the entity has no DefaultScript (resolver returns null
/// or zero-script).
/// </summary>
public void OnCreate(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
uint key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id;
if (key == 0) return; // malformed entity
var info = _resolver(entity);
if (info is null || info.ScriptId == 0) return;
// Seed the sink's per-entity rotation so CreateParticleHook.Offset.Origin
// (in entity-local frame) transforms correctly to world space when the
// hook fires. C.1.5a fix: without this, the sink falls through to
// Quaternion.Identity and the offset gets applied in world axes —
// visual symptom for portals: swirl oriented along world XYZ instead
// of the portal's facing, partially buried.
_particleSink.SetEntityRotation(key, entity.Rotation);
// C.1.5b #56: seed the sink's per-entity part transforms so
// CreateParticleHook.PartIndex routes the hook offset through the
// right mesh part's resting transform. Without this, every emitter
// in a multi-part Setup collapses to the entity root.
_particleSink.SetEntityPartTransforms(key, info.PartTransforms);
_scriptRunner.Play(info.ScriptId, key, entity.Position);
}
/// <summary>
/// Stop every script instance the runner is tracking for this key, and
/// kill every live emitter the sink has attributed to it. Caller picks
/// the key (the matching ServerGuid for live entities, or
/// <c>entity.Id</c> for dat-hydrated entities — mirror whatever was
/// used at <see cref="OnCreate"/>). Idempotent for unknown keys.
/// </summary>
public void OnRemove(uint key)
{
if (key == 0) return;
_scriptRunner.StopAllForEntity(key);
_particleSink.StopAllForEntity(key, fadeOut: false);
}
}