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
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/AcDream.Core/Physics/IAnimationHookSink.cs
Normal file
88
src/AcDream.Core/Physics/IAnimationHookSink.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using System.Numerics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Phase E.1 — the consumption point for hooks that
|
||||
/// <see cref="AnimationSequencer"/> emits during each Advance tick.
|
||||
///
|
||||
/// <para>
|
||||
/// The sequencer mirrors ACE's <c>Sequence.execute_hooks</c> /
|
||||
/// <c>PhysicsObj.add_anim_hook</c> by collecting all hooks that fire at
|
||||
/// each crossed frame boundary; a per-entity tick loop drains
|
||||
/// <see cref="AnimationSequencer.ConsumePendingHooks"/> and forwards each
|
||||
/// hook to an <c>IAnimationHookSink</c> implementation.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// In production the sink fans hooks out to downstream subsystems:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// <see cref="SoundHook"/> / <see cref="SoundTableHook"/> /
|
||||
/// <see cref="SoundTweakedHook"/> → Phase E.2 audio engine
|
||||
/// (OpenAL voice allocation + 3D positional playback).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="CreateParticleHook"/> /
|
||||
/// <see cref="DestroyParticleHook"/> / <see cref="StopParticleHook"/> /
|
||||
/// <see cref="CallPESHook"/> / <see cref="DefaultScriptHook"/> /
|
||||
/// <see cref="DefaultScriptPartHook"/> → Phase E.3 particle system.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="AttackHook"/> → Phase E.4 combat dispatcher
|
||||
/// (animation-hook frame = damage frame for melee / thrown).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="ReplaceObjectHook"/>,
|
||||
/// <see cref="TransparentHook"/>,
|
||||
/// <see cref="LuminousHook"/>,
|
||||
/// <see cref="DiffuseHook"/>,
|
||||
/// <see cref="ScaleHook"/>,
|
||||
/// <see cref="NoDrawHook"/>,
|
||||
/// <see cref="SetOmegaHook"/>,
|
||||
/// <see cref="TextureVelocityHook"/>,
|
||||
/// <see cref="SetLightHook"/> →
|
||||
/// GfxObjMesh / renderer state mutations on the target entity.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="AnimationDoneHook"/> → UI / controller notifications
|
||||
/// ("emote finished", "attack animation complete").
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// A <see cref="NullAnimationHookSink"/> is provided for headless tests and
|
||||
/// for offline mode where audio/particles aren't desired.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IAnimationHookSink
|
||||
{
|
||||
/// <summary>
|
||||
/// Called for each hook produced by <see cref="AnimationSequencer.Advance"/>.
|
||||
/// </summary>
|
||||
/// <param name="entityId">
|
||||
/// 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).
|
||||
/// </param>
|
||||
/// <param name="entityWorldPosition">
|
||||
/// 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.
|
||||
/// </param>
|
||||
/// <param name="hook">The hook (a typed subclass of AnimationHook).</param>
|
||||
void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No-op <see cref="IAnimationHookSink"/> — discards every hook.
|
||||
/// Used for tests + headless renders.
|
||||
/// </summary>
|
||||
public sealed class NullAnimationHookSink : IAnimationHookSink
|
||||
{
|
||||
public static readonly NullAnimationHookSink Instance = new();
|
||||
private NullAnimationHookSink() { }
|
||||
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) { }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue