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 and dat-hydrated entities, always keyed by /// canonical local 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. /// /// /// /// For live objects this is invoked by LiveEntityRuntime logical /// registration/teardown. GpuWorldState invokes it only for dat-static /// landblock load, promotion, demotion, and unload 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; private readonly HashSet _legacyPendingOwners = new(); /// 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, keyed by canonical local entity.Id. /// 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.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 the canonical /// local entity id. Idempotent for unknown keys. /// public void OnRemove(uint key) { if (key == 0) return; _scriptRunner.StopAllForEntity(key); _particleSink.StopAllForEntity(key, fadeOut: false); } /// /// Starts the temporary server-GUID owner used when F754 precedes the /// target's CreateObject/materialization. Step 4 replaces this with the /// retail mixed pending-packet FIFO. Tracking is required because no /// LiveEntityRecord may exist yet for delete/session teardown. /// public bool PlayLegacyPending( uint serverGuid, uint scriptDid, Vector3 anchorWorldPosition) { if (serverGuid == 0) return false; bool played = _scriptRunner.Play(scriptDid, serverGuid, anchorWorldPosition); if (played) _legacyPendingOwners.Add(serverGuid); return played; } /// /// Cleans the temporary server-GUID owner used when F754 arrives before a /// live projection has a canonical local ID. Step 4 replaces this alias /// with the retail mixed pending-packet FIFO; until then teardown must stop /// the alias as well as the normal local owner so an early effect cannot /// outlive its object incarnation. /// public void OnRemoveLegacyOwner(uint serverGuid, uint canonicalLocalId) { if (serverGuid == 0 || serverGuid == canonicalLocalId) return; if (!_legacyPendingOwners.Remove(serverGuid)) return; OnRemove(serverGuid); } /// Stops every pre-Create F754 alias at session teardown. public void ClearLegacyPendingOwners() { foreach (uint serverGuid in _legacyPendingOwners) OnRemove(serverGuid); _legacyPendingOwners.Clear(); } public int LegacyPendingOwnerCount => _legacyPendingOwners.Count; }