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:
parent
4db0b2f16c
commit
b04d393329
4 changed files with 319 additions and 0 deletions
94
src/AcDream.Core/Physics/AnimationHookRouter.cs
Normal file
94
src/AcDream.Core/Physics/AnimationHookRouter.cs
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue