diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a43a74d..1f75b56 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -123,6 +123,12 @@ public sealed class GameWindow : IDisposable private AcDream.Core.Physics.DatCollectionLoader? _animLoader; + // Phase E.1: central fan-out for animation hooks. Audio (E.2), + // particles (E.3), combat (E.4), and renderer state mutators all + // register sinks at startup. The router is always non-null so the + // per-entity tick loop can just call it unconditionally. + private readonly AcDream.Core.Physics.AnimationHookRouter _hookRouter = new(); + // Phase B.2: player movement mode. private AcDream.App.Input.PlayerMovementController? _playerController; private AcDream.App.Rendering.ChaseCamera? _chaseCamera; @@ -2923,6 +2929,22 @@ public sealed class GameWindow : IDisposable if (ae.Sequencer is not null) { seqFrames = ae.Sequencer.Advance(dt); + + // Phase E.1: drain animation hooks (footstep sounds, attack + // damage frames, particle spawns, part swaps, etc.) and fan + // them out to registered subsystems via the hook router. + // Mirrors ACE's PhysicsObj.add_anim_hook dispatch path. + var hooks = ae.Sequencer.ConsumePendingHooks(); + if (hooks.Count > 0) + { + System.Numerics.Vector3 worldPos = ae.Entity.Position; + for (int hi = 0; hi < hooks.Count; hi++) + { + var hook = hooks[hi]; + if (hook is null) continue; + _hookRouter.OnHook(ae.Entity.Id, worldPos, hook); + } + } } else { diff --git a/src/AcDream.Core/Physics/AnimationHookRouter.cs b/src/AcDream.Core/Physics/AnimationHookRouter.cs new file mode 100644 index 0000000..3c092ec --- /dev/null +++ b/src/AcDream.Core/Physics/AnimationHookRouter.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.Types; + +namespace AcDream.Core.Physics; + +/// +/// Composite — fans hooks out to any +/// number of registered sinks. Each downstream subsystem (audio, +/// particles, combat, etc.) registers once at startup and the router +/// broadcasts every hook. +/// +/// +/// Ordering matters only if a sink mutates shared state that a later +/// sink reads; the default order is registration order. Register +/// audio + particles before combat so they fire even if combat +/// chooses to ignore / modify the hook. +/// +/// +/// +/// Thread-safety: mutators () are locked; the hot +/// path iterates a snapshot array to avoid +/// allocation and lock contention in the render thread. +/// +/// +public sealed class AnimationHookRouter : IAnimationHookSink +{ + private readonly object _gate = new(); + private IAnimationHookSink[] _sinks = Array.Empty(); + + /// + /// Register a sink. Idempotent — adding the same instance twice is a no-op. + /// + public void Register(IAnimationHookSink sink) + { + ArgumentNullException.ThrowIfNull(sink); + lock (_gate) + { + foreach (var existing in _sinks) + if (ReferenceEquals(existing, sink)) return; + + var updated = new IAnimationHookSink[_sinks.Length + 1]; + Array.Copy(_sinks, updated, _sinks.Length); + updated[_sinks.Length] = sink; + _sinks = updated; + } + } + + /// + /// Unregister a sink. No-op if not registered. + /// + public void Unregister(IAnimationHookSink sink) + { + if (sink is null) return; + lock (_gate) + { + int idx = -1; + for (int i = 0; i < _sinks.Length; i++) + if (ReferenceEquals(_sinks[i], sink)) { idx = i; break; } + if (idx < 0) return; + + var updated = new IAnimationHookSink[_sinks.Length - 1]; + for (int i = 0, j = 0; i < _sinks.Length; i++) + if (i != idx) updated[j++] = _sinks[i]; + _sinks = updated; + } + } + + /// + /// Snapshot of currently-registered sinks (for diagnostics / tests). + /// + public IReadOnlyList Sinks => _sinks; + + /// + public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) + { + // Snapshot — no lock in the hot path (render thread). + var sinks = _sinks; + for (int i = 0; i < sinks.Length; i++) + { + try + { + sinks[i].OnHook(entityId, entityWorldPosition, hook); + } + catch + { + // Swallow — one misbehaving sink must not take down the + // entire animation tick. Individual subsystems can log + // their own errors internally. + } + } + } +} diff --git a/src/AcDream.Core/Physics/IAnimationHookSink.cs b/src/AcDream.Core/Physics/IAnimationHookSink.cs new file mode 100644 index 0000000..259df82 --- /dev/null +++ b/src/AcDream.Core/Physics/IAnimationHookSink.cs @@ -0,0 +1,88 @@ +using System.Numerics; +using DatReaderWriter.Types; + +namespace AcDream.Core.Physics; + +/// +/// Phase E.1 — the consumption point for hooks that +/// emits during each Advance tick. +/// +/// +/// The sequencer mirrors ACE's Sequence.execute_hooks / +/// PhysicsObj.add_anim_hook by collecting all hooks that fire at +/// each crossed frame boundary; a per-entity tick loop drains +/// and forwards each +/// hook to an IAnimationHookSink implementation. +/// +/// +/// +/// In production the sink fans hooks out to downstream subsystems: +/// +/// +/// / / +/// → Phase E.2 audio engine +/// (OpenAL voice allocation + 3D positional playback). +/// +/// +/// / +/// / / +/// / / +/// → Phase E.3 particle system. +/// +/// +/// → Phase E.4 combat dispatcher +/// (animation-hook frame = damage frame for melee / thrown). +/// +/// +/// , +/// , +/// , +/// , +/// , +/// , +/// , +/// , +/// → +/// GfxObjMesh / renderer state mutations on the target entity. +/// +/// +/// → UI / controller notifications +/// ("emote finished", "attack animation complete"). +/// +/// +/// +/// +/// +/// A is provided for headless tests and +/// for offline mode where audio/particles aren't desired. +/// +/// +public interface IAnimationHookSink +{ + /// + /// Called for each hook produced by . + /// + /// + /// Local WorldEntity id that produced this hook. The sink can use this + /// to attach side-effects to the right entity (e.g. 3D-positional + /// audio at that entity's world position). + /// + /// + /// Current world-space position of the entity, captured at tick time. + /// Pre-computed for the sink so each implementation doesn't have to + /// resolve it independently. + /// + /// The hook (a typed subclass of AnimationHook). + void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook); +} + +/// +/// No-op — discards every hook. +/// Used for tests + headless renders. +/// +public sealed class NullAnimationHookSink : IAnimationHookSink +{ + public static readonly NullAnimationHookSink Instance = new(); + private NullAnimationHookSink() { } + public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) { } +} diff --git a/tests/AcDream.Core.Tests/Physics/AnimationHookRouterTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationHookRouterTests.cs new file mode 100644 index 0000000..51c9416 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/AnimationHookRouterTests.cs @@ -0,0 +1,115 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public sealed class AnimationHookRouterTests +{ + private sealed class RecordingSink : IAnimationHookSink + { + public readonly List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Events = new(); + public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) + { + Events.Add((entityId, entityWorldPosition, hook)); + } + } + + [Fact] + public void Register_SingleSink_ReceivesHook() + { + var router = new AnimationHookRouter(); + var sink = new RecordingSink(); + router.Register(sink); + + var hook = new SoundHook { Direction = AnimationHookDir.Both }; + router.OnHook(entityId: 42, entityWorldPosition: new Vector3(1, 2, 3), hook); + + Assert.Single(sink.Events); + Assert.Equal(42u, sink.Events[0].EntityId); + Assert.Equal(new Vector3(1, 2, 3), sink.Events[0].Pos); + Assert.Same(hook, sink.Events[0].Hook); + } + + [Fact] + public void Register_MultipleSinks_AllReceiveHook() + { + var router = new AnimationHookRouter(); + var sinkA = new RecordingSink(); + var sinkB = new RecordingSink(); + var sinkC = new RecordingSink(); + router.Register(sinkA); + router.Register(sinkB); + router.Register(sinkC); + + router.OnHook(1, Vector3.Zero, new AttackHook { Direction = AnimationHookDir.Forward }); + + Assert.Single(sinkA.Events); + Assert.Single(sinkB.Events); + Assert.Single(sinkC.Events); + } + + [Fact] + public void Register_SameSinkTwice_IsIdempotent() + { + var router = new AnimationHookRouter(); + var sink = new RecordingSink(); + router.Register(sink); + router.Register(sink); + + router.OnHook(1, Vector3.Zero, new SoundHook { Direction = AnimationHookDir.Both }); + + Assert.Single(sink.Events); // Not two events — registration deduped. + Assert.Single(router.Sinks); + } + + [Fact] + public void Unregister_RemovesSink() + { + var router = new AnimationHookRouter(); + var sinkA = new RecordingSink(); + var sinkB = new RecordingSink(); + router.Register(sinkA); + router.Register(sinkB); + router.Unregister(sinkA); + + router.OnHook(1, Vector3.Zero, new SoundHook { Direction = AnimationHookDir.Both }); + + Assert.Empty(sinkA.Events); + Assert.Single(sinkB.Events); + } + + [Fact] + public void OnHook_SinkThrows_OtherSinksStillReceive() + { + // A misbehaving sink must not poison the dispatch. + var router = new AnimationHookRouter(); + router.Register(new ThrowingSink()); + var recording = new RecordingSink(); + router.Register(recording); + + router.OnHook(1, Vector3.Zero, new SoundHook { Direction = AnimationHookDir.Both }); + + Assert.Single(recording.Events); + } + + [Fact] + public void NullAnimationHookSink_AcceptsAnyHookWithoutCrashing() + { + // Trivially verify the null sink exists and swallows hooks. + var sink = NullAnimationHookSink.Instance; + sink.OnHook(1, Vector3.Zero, new SoundHook { Direction = AnimationHookDir.Both }); + sink.OnHook(2, new Vector3(1, 2, 3), new AttackHook { Direction = AnimationHookDir.Forward }); + } + + private sealed class ThrowingSink : IAnimationHookSink + { + public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) + { + throw new System.Exception("bad sink"); + } + } +}