using System;
using System.Collections.Concurrent;
using System.Numerics;
using System.Threading;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
namespace AcDream.Core.Vfx;
///
/// that translates particle-bearing
/// animation hooks into spawn / stop calls.
///
///
/// Hook types handled (r04 §6):
///
/// -
/// — spawn an emitter from the
/// hook's EmitterInfoId at the entity's world pose plus the
/// hook offset. Retail attaches to a specific mesh part; we
/// attach to the entity's root and will refine per-part when the
/// renderer exposes per-part world transforms.
///
/// -
/// — same as
/// CreateParticleHook but the retail sequencer blocks animation
/// progression until the particle is done. We spawn the emitter
/// identically; the "blocking" semantics belong to the sequencer.
///
/// -
/// — stop the most-recent emitter
/// matching the hook's EmitterId. Deferred to a future pass
/// when we retain per-entity emitter-id → handle maps.
///
/// -
/// — pause all emitters on the
/// entity (fade out).
///
/// -
/// /
/// — trigger the entity's DefaultScriptId PhysicsScript.
/// Requires PhysicsScript table; deferred.
///
/// -
/// — fire a PhysicsScript by id.
/// Deferred until DRW exposes PhysicsScript dat.
///
///
///
///
///
/// Per-entity emitter handle tracking is kept here so DestroyParticle /
/// StopParticle can target the right emitter when a server-sent
/// PlayEffect fires.
///
///
public sealed class ParticleHookSink : IAnimationHookSink
{
private readonly ParticleSystem _system;
// entityId → most-recently-spawned emitter handle per emitterId.
// DestroyParticleHook.EmitterId is effectively an application-layer
// key ("the smoke trail I spawned 2 seconds ago"), so we track by
// (entity, emitterId).
private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new();
// entityId → set of live emitter handles. Dictionary-as-set so we can
// remove individual handles when their emitter dies (M4 fix —
// ConcurrentBag couldn't drop entries, so handles for naturally-expired
// emitters used to leak).
private readonly ConcurrentDictionary> _handlesByEntity = new();
// Reverse lookup: handle → (entity, key) for O(1) cleanup on EmitterDied.
private readonly ConcurrentDictionary _trackingByHandle = new();
private readonly ConcurrentDictionary _renderPassByEntity = new();
private readonly ConcurrentDictionary _rotationByEntity = new();
// C.1.5b #56: per-entity static part transforms (PlacementFrames[Resting]
// baked into a Matrix4x4 per Setup part). When set, SpawnFromHook applies
// partTransforms[hook.PartIndex] to the hook offset BEFORE rotating to
// world space. Without this, every emitter in a multi-part Setup
// collapses to the entity root (the bug). Cleared by StopAllForEntity.
// For ANIMATED entities this map would need a per-tick refresh similar
// to UpdateEntityAnchor — deferred to a future phase.
private readonly ConcurrentDictionary> _partTransformsByEntity = new();
private int _anonymousEmitterSerial;
public ParticleHookSink(ParticleSystem system)
{
_system = system ?? throw new ArgumentNullException(nameof(system));
_system.EmitterDied += OnEmitterDied;
}
private void OnEmitterDied(int handle)
{
if (!_trackingByHandle.TryRemove(handle, out var t))
return;
_handlesByKey.TryRemove((t.EntityId, t.KeyId), out _);
if (_handlesByEntity.TryGetValue(t.EntityId, out var bag))
bag.TryRemove(handle, out _);
}
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
{
switch (hook)
{
case CreateParticleHook cph:
SpawnFromHook(entityId, entityWorldPosition,
emitterInfoId: (uint)cph.EmitterInfoId,
offset: cph.Offset.Origin,
partIndex: (int)cph.PartIndex,
logicalId: cph.EmitterId);
break;
case CreateBlockingParticleHook:
// Retail's CreateBlockingParticleHook is a sentinel (no
// payload) — the sequencer pauses advancement until the
// current particle emitter finishes. We surface this as
// "no spawn needed, just keep the current particle alive";
// blocking semantics live in the sequencer, not here.
break;
case DestroyParticleHook dph:
if (_handlesByKey.TryRemove((entityId, dph.EmitterId), out var handleToDestroy))
_system.StopEmitter(handleToDestroy, fadeOut: false);
break;
case StopParticleHook sph:
if (_handlesByKey.TryGetValue((entityId, sph.EmitterId), out var handleToStop))
_system.StopEmitter(handleToStop, fadeOut: true);
break;
// DefaultScript / CallPES are wired when PhysicsScript loading
// is available. They arrive at the sink but we can't act until
// the script table returns a real emitter list.
}
}
public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass)
=> _renderPassByEntity[entityId] = renderPass;
public void SetEntityRotation(uint entityId, Quaternion rotation)
=> _rotationByEntity[entityId] = rotation;
///
/// Register per-part static transforms for an entity. The caller
/// (typically EntityScriptActivator) precomputes one
/// per Setup part using
/// SetupPartTransforms.Compute and pushes them here at spawn
/// time. applies
/// partTransforms[hook.PartIndex] to the hook offset BEFORE
/// transforming to world space. Cleared on
/// .
///
public void SetEntityPartTransforms(uint entityId, IReadOnlyList partTransforms)
=> _partTransformsByEntity[entityId] = partTransforms;
public void ClearEntityRenderPass(uint entityId)
=> _renderPassByEntity.TryRemove(entityId, out _);
///
/// Refresh every live emitter on this entity to a new world anchor +
/// rotation. The owning subsystem (sky-PES driver, animation tick)
/// drives this each frame for AttachLocal emitters so they track their
/// moving parent — retail-faithful via
/// ParticleEmitter::UpdateParticles at 0x0051d2d4, which
/// re-reads the parent frame each tick when is_parent_local != 0.
/// Safe to call for entities with no live emitters (no-op).
///
public void UpdateEntityAnchor(uint entityId, Vector3 anchor, Quaternion rotation)
{
_rotationByEntity[entityId] = rotation;
if (!_handlesByEntity.TryGetValue(entityId, out var bag))
return;
foreach (var handle in bag.Keys)
_system.UpdateEmitterAnchor(handle, anchor, rotation);
}
public void StopAllForEntity(uint entityId, bool fadeOut)
{
if (_handlesByEntity.TryRemove(entityId, out var handles))
{
foreach (var handle in handles.Keys)
{
_system.StopEmitter(handle, fadeOut);
_trackingByHandle.TryRemove(handle, out _);
}
}
foreach (var key in _handlesByKey.Keys)
{
if (key.EntityId == entityId)
_handlesByKey.TryRemove(key, out _);
}
ClearEntityRenderPass(entityId);
_rotationByEntity.TryRemove(entityId, out _);
_partTransformsByEntity.TryRemove(entityId, out _);
}
private void SpawnFromHook(
uint entityId,
Vector3 worldPos,
uint emitterInfoId,
Vector3 offset,
int partIndex,
uint logicalId)
{
// Spawn position: entity pose + hook offset, with the hook
// offset first passed through the per-part transform when
// available (C.1.5b #56 fix). Without the per-part transform,
// every emitter in a multi-emitter PES script collapses to the
// entity root — visible symptom: ground-buried portal swirls.
var rotation = _rotationByEntity.TryGetValue(entityId, out var rot)
? rot
: Quaternion.Identity;
Vector3 partLocal = offset;
if (_partTransformsByEntity.TryGetValue(entityId, out var partTransforms)
&& partIndex >= 0
&& partIndex < partTransforms.Count)
{
partLocal = Vector3.Transform(offset, partTransforms[partIndex]);
}
var anchor = worldPos + Vector3.Transform(partLocal, rotation);
var renderPass = _renderPassByEntity.TryGetValue(entityId, out var pass)
? pass
: ParticleRenderPass.Scene;
int handle = _system.SpawnEmitterById(
emitterId: emitterInfoId,
anchor: anchor,
rot: rotation,
attachedObjectId: entityId,
attachedPartIndex: partIndex,
renderPass: renderPass);
uint keyId = logicalId != 0
? logicalId
: 0x80000000u | (uint)Interlocked.Increment(ref _anonymousEmitterSerial);
if (logicalId != 0 && _handlesByKey.TryRemove((entityId, keyId), out var oldHandle))
{
_system.StopEmitter(oldHandle, fadeOut: false);
_trackingByHandle.TryRemove(oldHandle, out _);
}
_handlesByKey[(entityId, keyId)] = handle;
_handlesByEntity
.GetOrAdd(entityId, _ => new ConcurrentDictionary())
.TryAdd(handle, 0);
_trackingByHandle[handle] = (entityId, keyId);
}
}