using System; using System.Collections.Generic; using System.Numerics; using AcDream.Core.Vfx; using AcDream.Core.World; namespace AcDream.App.Rendering.Vfx; /// /// What the activator's resolver returns when an entity's Setup carries /// a DefaultScript. Bundles the script id with the per-part /// transforms baked from Setup.PlacementFrames so a single dat /// lookup yields both pieces of state. The activator pushes the part /// transforms into /// before calling , which closes /// the part-anchor pipeline introduced for issue #56. /// public sealed record ScriptActivationInfo( uint ScriptId, IReadOnlyList PartTransforms); /// /// Fires Setup.DefaultScript through /// when a 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. /// /// /// Handles both server-spawned entities (ServerGuid != 0, keyed by /// ServerGuid) and dat-hydrated entities (ServerGuid == 0, keyed by /// entity.Id). The C.1.5a guard that early-returned for /// ServerGuid == 0 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. /// /// /// /// Wires alongside EntitySpawnAdapter in GpuWorldState: 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). /// /// /// /// Retail oracle: play_script_internal(setup.DefaultScript) is what /// retail's CPhysicsObj invokes at object load (see Phase C.1 plan /// §C.1 and memory/project_sky_pes_port.md). C.1 already shipped the /// runner; this class adds the missing fire-on-spawn call site. /// /// public sealed class EntityScriptActivator { private readonly PhysicsScriptRunner _scriptRunner; private readonly ParticleHookSink _particleSink; private readonly Func _resolver; /// Already-shipped runner from C.1. Owns the /// (scriptId, entityId) instance table and schedules hooks at their /// StartTime offsets. /// Already-shipped hook sink from C.1. The /// activator pushes per-entity rotation + part transforms here, and /// calls to drop /// per-entity emitter handles on despawn. /// Returns /// with the entity's /// Setup.DefaultScript.DataId and per-part transforms (via /// SetupPartTransforms.Compute), or null on dat miss / /// throw / missing DefaultScript. Production lambda hits /// DatCollection; tests pass a hand-rolled stub. public EntityScriptActivator( PhysicsScriptRunner scriptRunner, ParticleHookSink particleSink, Func resolver) { ArgumentNullException.ThrowIfNull(scriptRunner); ArgumentNullException.ThrowIfNull(particleSink); ArgumentNullException.ThrowIfNull(resolver); _scriptRunner = scriptRunner; _particleSink = particleSink; _resolver = resolver; } /// /// Resolve the entity's Setup.DefaultScript and fire it through /// the script runner. Keys by entity.ServerGuid when non-zero, /// otherwise by entity.Id (the latter handles dat-hydrated /// EnvCell statics + exterior stabs whose entity.Id lives in /// the 0x40xxxxxx range — collision-free with server guids). /// No-op if the entity has no DefaultScript (resolver returns null /// or zero-script). /// 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); } /// /// 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 /// entity.Id for dat-hydrated entities — mirror whatever was /// used at ). Idempotent for unknown keys. /// public void OnRemove(uint key) { if (key == 0) return; _scriptRunner.StopAllForEntity(key); _particleSink.StopAllForEntity(key, fadeOut: false); } }