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,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");
}
}
}