using System; using AcDream.Core.Vfx; using AcDream.Core.World; namespace AcDream.App.Rendering.Vfx; /// /// Fires Setup.DefaultScript through /// when a server-spawned 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. /// /// /// Wires alongside EntitySpawnAdapter in GpuWorldState: the /// adapter handles meshes + animation state, the activator handles scripts + /// particles. Both are render-thread-only. /// /// /// /// 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 _defaultScriptResolver; /// 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 only calls its /// to drop any per-entity emitter handles on despawn. /// Returns /// entity.SourceGfxObjOrSetupId's Setup.DefaultScript.DataId, /// or 0 on miss / dat throw / missing field. Production lambda hits /// ; tests pass a hand-rolled /// stub. public EntityScriptActivator( PhysicsScriptRunner scriptRunner, ParticleHookSink particleSink, Func defaultScriptResolver) { ArgumentNullException.ThrowIfNull(scriptRunner); ArgumentNullException.ThrowIfNull(particleSink); ArgumentNullException.ThrowIfNull(defaultScriptResolver); _scriptRunner = scriptRunner; _particleSink = particleSink; _defaultScriptResolver = defaultScriptResolver; } /// /// Resolve the entity's Setup.DefaultScript 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). /// public void OnCreate(WorldEntity entity) { ArgumentNullException.ThrowIfNull(entity); if (entity.ServerGuid == 0) return; uint scriptId = _defaultScriptResolver(entity); if (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. 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 because the local-Z lift becomes a world-axis offset. _particleSink.SetEntityRotation(entity.ServerGuid, entity.Rotation); _scriptRunner.Play(scriptId, entity.ServerGuid, entity.Position); } /// /// 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). /// public void OnRemove(uint serverGuid) { if (serverGuid == 0) return; _scriptRunner.StopAllForEntity(serverGuid); _particleSink.StopAllForEntity(serverGuid, fadeOut: false); } }