From b04d393329a442865663b7ad882afbe554d3dd67 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 18 Apr 2026 16:30:23 +0200 Subject: [PATCH] feat(anim): Phase E.1 hook router + GameWindow wiring Adds IAnimationHookSink + AnimationHookRouter for fan-out of animation hooks to downstream subsystems (audio, particles, combat, renderer mutators). GameWindow.TickAnimations now drains ConsumePendingHooks every tick and broadcasts each hook via the router with the entity's world position pre-computed. The router is a composite sink: register N sinks once at startup, each sees every hook. Registration is idempotent, unregister works, and a throwing sink no longer poisons dispatch (each OnHook call is wrapped in try/catch so one bad subsystem can't halt the whole animation tick). A NullAnimationHookSink is provided for headless tests / offline mode. 6 router tests verify: single/multi sink fan-out, idempotent register, unregister, throwing-sink isolation, null-sink no-op. Total: 376 Core tests + 109 Core.Net = 485 (up from 479). This closes Phase E.1 plumbing; E.2 (audio) and E.3 (particles) will each register a concrete sink that translates their hook types into real-world effects. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 22 ++++ .../Physics/AnimationHookRouter.cs | 94 ++++++++++++++ .../Physics/IAnimationHookSink.cs | 88 ++++++++++++++ .../Physics/AnimationHookRouterTests.cs | 115 ++++++++++++++++++ 4 files changed, 319 insertions(+) create mode 100644 src/AcDream.Core/Physics/AnimationHookRouter.cs create mode 100644 src/AcDream.Core/Physics/IAnimationHookSink.cs create mode 100644 tests/AcDream.Core.Tests/Physics/AnimationHookRouterTests.cs 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"); + } + } +}