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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-18 16:30:23 +02:00
parent 4db0b2f16c
commit b04d393329
4 changed files with 319 additions and 0 deletions

View file

@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics;
/// <summary>
/// Composite <see cref="IAnimationHookSink"/> — 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.
///
/// <para>
/// 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.
/// </para>
///
/// <para>
/// Thread-safety: mutators (<see cref="Register"/>) are locked; the hot
/// path <see cref="OnHook"/> iterates a snapshot array to avoid
/// allocation and lock contention in the render thread.
/// </para>
/// </summary>
public sealed class AnimationHookRouter : IAnimationHookSink
{
private readonly object _gate = new();
private IAnimationHookSink[] _sinks = Array.Empty<IAnimationHookSink>();
/// <summary>
/// Register a sink. Idempotent — adding the same instance twice is a no-op.
/// </summary>
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;
}
}
/// <summary>
/// Unregister a sink. No-op if not registered.
/// </summary>
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;
}
}
/// <summary>
/// Snapshot of currently-registered sinks (for diagnostics / tests).
/// </summary>
public IReadOnlyList<IAnimationHookSink> Sinks => _sinks;
/// <inheritdoc />
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.
}
}
}
}