acdream/src/AcDream.Core/Physics/IAnimationHookSink.cs
Erik b04d393329 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>
2026-04-18 16:30:23 +02:00

88 lines
3.4 KiB
C#

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) { }
}