From 03f37902b8de05146851e42d829641883fb04ca8 Mon Sep 17 00:00:00 2001 From: qwerin Date: Wed, 4 Nov 2026 18:14:34 +0100 Subject: [PATCH] Anim sound notes fire we_anim_sfx; effect offsets follow the owner's facing The blacksmith hammered in silence: nothing posted DS1's we_anim_sfx when a clip crossed a sound note, so his blacksmith_hammer trigger (sparks plus s_e_env_hammer_anvil) never ran. Non-hero actors now dispatch each note once per loop. Trigger rows match the note number in receive_world_message("we_anim_sfx",n) and only queue messages they listen for, so a looping clip no longer floods a row's inbox. An effect offset without its own target now uses the owner's facing and live position: the sparks land on the anvil instead of 0.8 m behind him (world +Z). `prs info` lists each clip's notes. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit a6f0871992242fb3e7436f4673edb57c15adb5fc) --- src/SiegeFX.Core/Actors/AttackAnim.cs | 44 ++++++ src/SiegeFX.Core/Actors/TriggerRuntime.cs | 81 ++++++++--- src/SiegeFX.Core/Sfx/SfxContext.cs | 8 ++ src/SiegeFX.Core/Sfx/SfxRuntime.cs | 14 +- .../Render/RenderHost.AnimSfx.cs | 62 +++++++++ src/SiegeFX.Runtime/Render/RenderHost.cs | 5 +- .../Render/RenderHostTriggerContext.cs | 4 +- src/SiegeFX.Tools/Program.cs | 2 + tests/SiegeFX.Tests/AnimSfxTests.cs | 128 ++++++++++++++++++ tests/SiegeFX.Tests/SfxOwnerFacingTests.cs | 34 +++++ 10 files changed, 356 insertions(+), 26 deletions(-) create mode 100644 src/SiegeFX.Runtime/Render/RenderHost.AnimSfx.cs create mode 100644 tests/SiegeFX.Tests/AnimSfxTests.cs create mode 100644 tests/SiegeFX.Tests/SfxOwnerFacingTests.cs diff --git a/src/SiegeFX.Core/Actors/AttackAnim.cs b/src/SiegeFX.Core/Actors/AttackAnim.cs index 686cdfb0..235554f6 100644 --- a/src/SiegeFX.Core/Actors/AttackAnim.cs +++ b/src/SiegeFX.Core/Actors/AttackAnim.cs @@ -93,6 +93,50 @@ public static float[] Times(PrsAnimation clip, uint token) hits.Sort(); return hits.ToArray(); } + + /// The SFX1..SFX4 note number (1-4) of , + /// or 0 when it is another note. + public static int SfxNumber(uint token) => token switch + { + 0x31584653 => 1, // 'SFX1' + 0x32584653 => 2, // 'SFX2' + 0x33584653 => 3, // 'SFX3' + 0x34584653 => 4, // 'SFX4' + _ => 0, + }; + + /// SC-ANIM-SFX — the SFX1..4 notes a clip passed while its + /// clock moved from to + /// seconds, in order, as note numbers (1-4). The clock is the unwrapped + /// play time: a looping idle keeps counting past the clip length, so a + /// note fires once per loop, every loop. The interval is (before, after], + /// plus a note at exactly 0 when the clip just started (before == 0). + /// DS1's chore skrits post WE_ANIM_SFX(n) to the actor for each of these + /// (select_fidget's ANIMEVENT_SFX_n tests). + public static void SfxCrossed(IReadOnlyList notes, float animLength, + double before, double after, List into) + { + if (notes.Count == 0 || animLength <= 0f || after <= before) return; + long first = (long)Math.Floor(before / animLength); + long last = (long)Math.Floor(after / animLength); + // A clock that jumped many loops at once (a hitch) fires one loop's worth. + if (last - first > 1) first = last - 1; + List<(double At, int Sfx)>? hits = null; + for (long k = first; k <= last; k++) + { + foreach (var n in notes) + { + int sfx = SfxNumber(n.Token); + if (sfx == 0) continue; + double at = k * (double)animLength + Math.Clamp(n.Time, 0f, 1f) * animLength; + if ((at > before && at <= after) || (before == 0 && at == 0)) + (hits ??= new()).Add((at, sfx)); + } + } + if (hits is null) return; + hits.Sort((a, b) => a.At.CompareTo(b.At)); + foreach (var h in hits) into.Add(h.Sfx); + } } /// Phase 18 — one in-flight attack iteration, DS1's model: the clip diff --git a/src/SiegeFX.Core/Actors/TriggerRuntime.cs b/src/SiegeFX.Core/Actors/TriggerRuntime.cs index 5366ee3b..aeacd0a5 100644 --- a/src/SiegeFX.Core/Actors/TriggerRuntime.cs +++ b/src/SiegeFX.Core/Actors/TriggerRuntime.cs @@ -504,7 +504,7 @@ bool EvaluateCondition(TriggerInstance trig, TriggerCall cond, TriggerContext ct // posts targeted at trig.Scid. Match is by message name (arg 0). if (cond.Args.Count == 0) return false; var name = cond.Args[0]; - return state.ConsumeReceivedMessage(name); + return state.ConsumeReceivedMessage(name, MessageQualifier(cond)); } default: return false; @@ -553,7 +553,7 @@ void Dispatch(TriggerInstance trig, TriggerCall act, TriggerContext ctx, bool de var scriptName = act.Args[0]; IReadOnlyList? scriptArgs = act.Args.Count > 1 ? act.Args.Skip(1).ToList() : null; - ctx.CallSfxScript(scriptName, scriptArgs, trig.Position); + ctx.CallSfxScript(scriptName, scriptArgs, trig.Position, trig.Scid); return; } // ALPHA-2B — runtime camera-flag flips on a specific snode: @@ -591,7 +591,10 @@ void Dispatch(TriggerInstance trig, TriggerCall act, TriggerContext ctx, bool de /// message into every row's pending-message inbox; rows whose /// receive_world_message condition matches will consume the stamp on their /// next eval. - public void PostInboundMessage(uint targetScid, string name) + /// SC-ANIM-SFX — the message's qualifier, matched + /// against a receive_world_message(name, N) condition's N + /// (WE_ANIM_SFX carries its SFX note number). Null = none. + public void PostInboundMessage(uint targetScid, string name, int? data = null) { // SC-TRIGGER-ARM — we_trigger_activate/_deactivate are ACTIVATION // messages, not inbox messages: they flip the instance's IsActive. @@ -605,10 +608,15 @@ public void PostInboundMessage(uint targetScid, string name) if (_instances[i].Scid != targetScid) continue; if (arm) _instances[i].IsActive = true; else if (disarm) _instances[i].IsActive = false; - else _instances[i].DepositMessage(name); + else _instances[i].DepositMessage(name, data); } } + /// The integer second argument of a receive_world_message + /// condition, or null when it authors none. + internal static int? MessageQualifier(TriggerCall cond) => + TryIntArg(cond, 1, out var q) ? q : null; + static bool TryFloatArg(TriggerCall call, int idx, out float v) { v = 0; @@ -681,8 +689,20 @@ public struct TriggerRowState /// run more than a couple deep between ticks. InboundMessages _inbox; - public void DepositMessage(string name) => _inbox.Push(name); - public bool ConsumeReceivedMessage(string name) => _inbox.TryConsume(name); + /// SC-ANIM-SFX — the message's qualifier (WE_ANIM_SFX + /// carries its note number 1-4); null when the poster sends none. + public void DepositMessage(string name, int? data = null) => _inbox.Push(new(name, data)); + /// The condition's second argument + /// (receive_world_message("we_anim_sfx",1)). A message and a + /// condition that both carry one must agree; either side without one + /// matches by name alone (the pre-qualifier behavior). + public bool ConsumeReceivedMessage(string name, int? qualifier = null) => _inbox.TryConsume(name, qualifier); + + readonly record struct Msg(string Name, int? Data) + { + public bool Matches(string name, int? qualifier) => + Name == name && (qualifier is null || Data is null || Data == qualifier); + } struct InboundMessages { @@ -690,23 +710,23 @@ struct InboundMessages // dropped — DS1's tightest trigger chain is never that deep, and a runaway // would be a content authoring bug we'd rather catch as "missed expected // message" than silently grow an unbounded queue. - string? _m0, _m1, _m2, _m3; + Msg? _m0, _m1, _m2, _m3; - public void Push(string name) + public void Push(Msg m) { - if (_m0 is null) { _m0 = name; return; } - if (_m1 is null) { _m1 = name; return; } - if (_m2 is null) { _m2 = name; return; } - if (_m3 is null) { _m3 = name; return; } + if (_m0 is null) { _m0 = m; return; } + if (_m1 is null) { _m1 = m; return; } + if (_m2 is null) { _m2 = m; return; } + if (_m3 is null) { _m3 = m; return; } // Inbox full — silently drop. Future SC can add an overflow counter. } - public bool TryConsume(string name) + public bool TryConsume(string name, int? qualifier) { - if (_m0 is not null && _m0 == name) { Compact(0); return true; } - if (_m1 is not null && _m1 == name) { Compact(1); return true; } - if (_m2 is not null && _m2 == name) { Compact(2); return true; } - if (_m3 is not null && _m3 == name) { Compact(3); return true; } + if (_m0 is { } a && a.Matches(name, qualifier)) { Compact(0); return true; } + if (_m1 is { } b && b.Matches(name, qualifier)) { Compact(1); return true; } + if (_m2 is { } c && c.Matches(name, qualifier)) { Compact(2); return true; } + if (_m3 is { } d && d.Matches(name, qualifier)) { Compact(3); return true; } return false; } @@ -780,12 +800,28 @@ public TriggerInstance(uint scid, uint nodeGuid, Vector3 position, TriggerMatrix public ref TriggerRowState RowStateAt(int rowIndex) => ref _rowStates[rowIndex]; - public void DepositMessage(string name) + public void DepositMessage(string name, int? data = null) { // Every row of the matrix sees the same inbox stream — DS1 trigger rows are // sibling listeners, not exclusive. Each row consumes (or doesn't) on its - // own evaluation pass. - for (int i = 0; i < _rowStates.Length; i++) _rowStates[i].DepositMessage(name); + // own evaluation pass. SC-ANIM-SFX — only rows that listen for this message + // get it: a row never consumes what it doesn't listen for, so a looping + // anim's WE_ANIM_SFX(2) used to pile up in the SFX1 row's 4-slot inbox and, + // once full, the inbox dropped the messages that row did want. + for (int i = 0; i < _rowStates.Length; i++) + if (Listens(Matrix.Rows[i], name, data)) _rowStates[i].DepositMessage(name, data); + } + + static bool Listens(TriggerRow row, string name, int? data) + { + foreach (var c in row.Conditions) + { + if (!c.Verb.Equals("receive_world_message", StringComparison.OrdinalIgnoreCase)) continue; + if (c.Args.Count == 0 || c.Args[0] != name) continue; + var q = TriggerRuntime.MessageQualifier(c); + if (q is null || data is null || q == data) return true; + } + return false; } } @@ -839,5 +875,8 @@ public virtual void ChangeQuestState(IReadOnlyList args) { } /// implementation is a no-op so the audit CLI's headless context can still /// drive trigger fan-out without dragging the Runtime project in. Live /// hosts override to dispatch into SfxRuntime. - public virtual void CallSfxScript(string scriptName, IReadOnlyList? args, Vector3 origin) { } + /// The trigger's SCID. For an actor's own + /// [template_triggers] (the blacksmith's hammer sparks) the host plays the + /// script where that actor stands now, not where it spawned. + public virtual void CallSfxScript(string scriptName, IReadOnlyList? args, Vector3 origin, uint ownerScid) { } } diff --git a/src/SiegeFX.Core/Sfx/SfxContext.cs b/src/SiegeFX.Core/Sfx/SfxContext.cs index fdc1e97f..445cd026 100644 --- a/src/SiegeFX.Core/Sfx/SfxContext.cs +++ b/src/SiegeFX.Core/Sfx/SfxContext.cs @@ -77,6 +77,14 @@ public Vector3 ResolveTargetBone(string boneToken) /// have a single anchor — both source and target collapse to it. public static SfxContext At(Vector3 origin) => new(origin, origin, origin); + /// SC-SFX-OWNER-FACING — the facing of the object the script + /// runs on when source and target are the same spot (an actor's own + /// [template_triggers] call_sfx_script). offset(x,y,z) is in that + /// object's frame: the blacksmith's hammer sparks author offset(0,0,.8), + /// 0.8 m in front of him, onto the anvil. Null = no owner facing; the + /// frame then falls back to world +Z. + public Vector3? OwnerForward { get; init; } + /// Resolve a DS1 logical bone name to a caster-skeleton world /// position. Strips the leading @ if present, calls the /// configured , and falls back to the per-bone diff --git a/src/SiegeFX.Core/Sfx/SfxRuntime.cs b/src/SiegeFX.Core/Sfx/SfxRuntime.cs index ea223181..6f89dd5c 100644 --- a/src/SiegeFX.Core/Sfx/SfxRuntime.cs +++ b/src/SiegeFX.Core/Sfx/SfxRuntime.cs @@ -1278,11 +1278,21 @@ struct DelayedStart /// Phase 23d-2a — build the caster-frame world offset for /// `offset(x,y,z)`: forward = source→target flattened to XZ (a caster - /// faces its target during a cast), up = world Y, right = their cross. - static Vector3 CasterFrameOffset(in SfxContext ctx, Vector3 off) + /// faces its target during a cast), up = world Y, right = their cross. + /// SC-SFX-OWNER-FACING — with no separate target (source == target, an + /// actor's own trigger script) forward is the owner's facing + /// (). It used to be world +Z, so + /// the blacksmith's offset(0,0,.8) anvil sparks landed 0.8 m along +Z + /// whichever way he faced — behind him at the Elddim smithy. + public static Vector3 CasterFrameOffset(in SfxContext ctx, Vector3 off) { var fwd = ctx.TargetPos - ctx.SourcePos; fwd.Y = 0f; + if (fwd.LengthSquared() <= 0.0001f && ctx.OwnerForward is { } own) + { + fwd = own; + fwd.Y = 0f; + } fwd = fwd.LengthSquared() > 0.0001f ? Vector3.Normalize(fwd) : Vector3.UnitZ; var right = Vector3.Normalize(Vector3.Cross(Vector3.UnitY, fwd)); return right * off.X + Vector3.UnitY * off.Y + fwd * off.Z; diff --git a/src/SiegeFX.Runtime/Render/RenderHost.AnimSfx.cs b/src/SiegeFX.Runtime/Render/RenderHost.AnimSfx.cs new file mode 100644 index 00000000..681197ce --- /dev/null +++ b/src/SiegeFX.Runtime/Render/RenderHost.AnimSfx.cs @@ -0,0 +1,62 @@ +using System.Numerics; +using SiegeFX.Core.Actors; +using SiegeFX.Core.Assets; + +namespace SiegeFX.Runtime.Render; + +// ── SC-ANIM-SFX — animation sound notes reach the actor's triggers ── +// DS1's chore skrits watch the blender for ANIMEVENT_SFX_1..4 (the SFX1..SFX4 +// notes a clip authors) and post WE_ANIM_SFX(n) to the actor itself; the +// template's [template_triggers] answer with receive_world_message +// ("we_anim_sfx", n) → call_sfx_script(...). The Elddim / Stonebridge / +// Glacern blacksmith's hammering fidget carries SFX1 at 62.5 %, and his row +// plays "blacksmith_hammer" (sparks + s_e_env_hammer_anvil). Our skrit +// bridge's UpdateBlender reports no events, so nothing ever posted the +// message and he hammered in silence. The draw clock owns clip time here, so +// the advance loop reports each crossed note instead. +// +// Local on every machine, like DS1's blender: a co-op host and client each +// animate the actor and each play the sound — nothing goes over the wire. +public sealed partial class RenderHost +{ + private readonly List _animSfxScratch = new(4); + + /// Post WE_ANIM_SFX(n) to for every SFX + /// note its clip passed while its clock went from + /// to . The hero is left out: its swing/cast + /// sounds run off the attack system's own note handling. + private void DispatchAnimSfxNotes(ActorRenderState s, PrsAnimation clip, double before, double after) + { + if (s.IsPlayer || clip.Notes.Count == 0 || after <= before) return; + _animSfxScratch.Clear(); + AnimNotes.SfxCrossed(clip.Notes, clip.AnimLength, before, after, _animSfxScratch); + if (_animSfxScratch.Count == 0) return; + uint scid = s.Actor.Instance.Scid; + foreach (int n in _animSfxScratch) + { + _actorBus?.Post("we_anim_sfx", scid, scid, n, 0); + _triggerRuntime?.PostInboundMessage(scid, "we_anim_sfx", n); + } + } + + /// A trigger's call_sfx_script. When the trigger is a live + /// actor's own ([template_triggers]: the blacksmith's hammer), the script + /// runs where the actor stands NOW and in its facing, so offset(x,y,z) + /// lands in front of / beside it as authored. Region triggers and + /// emitters (no actor with that SCID) keep their placement position. + internal void OnTriggerCallSfxScript(string scriptName, IReadOnlyList? args, Vector3 origin, uint ownerScid) + { + if (ownerScid != 0) + foreach (var s in _actors) + { + if (s.IsPlayer || s.Actor.Instance.Scid != ownerScid) continue; + var xf = s.CurrentTransform; + // Actors face their local +Z (the authored-facing convention). + var fwd = Vector3.TransformNormal(Vector3.UnitZ, xf); + _sfxRuntime?.Spawn(scriptName, + SiegeFX.Core.Sfx.SfxContext.At(xf.Translation) with { OwnerForward = fwd }, args); + return; + } + OnTriggerCallSfxScript(scriptName, args, origin); + } +} diff --git a/src/SiegeFX.Runtime/Render/RenderHost.cs b/src/SiegeFX.Runtime/Render/RenderHost.cs index dd4254b8..ca686fa1 100644 --- a/src/SiegeFX.Runtime/Render/RenderHost.cs +++ b/src/SiegeFX.Runtime/Render/RenderHost.cs @@ -17,7 +17,7 @@ namespace SiegeFX.Runtime.Render; /// Phase 3 just draws a reference grid so WASD + mouse-look can be verified; /// later phases will hang real scene objects off this. /// -public sealed class RenderHost : IDisposable +public sealed partial class RenderHost : IDisposable { private readonly IWindow _window; private readonly string? _meshPath; @@ -26881,6 +26881,7 @@ private void OnUpdate(double dt) var clip = s.Actor.Clips[Math.Min(idx, s.Actor.Clips.Length - 1)]; if (clip.AnimLength > 0f) { + double animBefore = s.AnimTime; if (isDead) { float endHold = clip.AnimLength - 0.01f; @@ -26904,6 +26905,8 @@ private void OnUpdate(double dt) { s.AnimTime += dt; } + // SC-ANIM-SFX — SFX notes the clip just passed (RenderHost.AnimSfx.cs). + if (!isDead) DispatchAnimSfxNotes(s, clip, animBefore, s.AnimTime); } } } diff --git a/src/SiegeFX.Runtime/Render/RenderHostTriggerContext.cs b/src/SiegeFX.Runtime/Render/RenderHostTriggerContext.cs index f07301eb..608e5f29 100644 --- a/src/SiegeFX.Runtime/Render/RenderHostTriggerContext.cs +++ b/src/SiegeFX.Runtime/Render/RenderHostTriggerContext.cs @@ -78,9 +78,9 @@ public override void SetInterestRadius(float radius) _host.OnTriggerSetInterestRadius(radius); } - public override void CallSfxScript(string scriptName, IReadOnlyList? args, Vector3 origin) + public override void CallSfxScript(string scriptName, IReadOnlyList? args, Vector3 origin, uint ownerScid) { - _host.OnTriggerCallSfxScript(scriptName, args, origin); + _host.OnTriggerCallSfxScript(scriptName, args, origin, ownerScid); } public override void FadeNodes(string verb, IReadOnlyList args) diff --git a/src/SiegeFX.Tools/Program.cs b/src/SiegeFX.Tools/Program.cs index c1b9c2af..49eebc83 100644 --- a/src/SiegeFX.Tools/Program.cs +++ b/src/SiegeFX.Tools/Program.cs @@ -560,6 +560,8 @@ static int CmdPrsInfo(string[] a) Console.WriteLine($"Length : {prs.AnimLength:F4} s"); Console.WriteLine($"Root travel : ({prs.RootTravel.X:F2}, {prs.RootTravel.Y:F2}, {prs.RootTravel.Z:F2})"); Console.WriteLine($"Notes : {prs.Notes.Count}"); + foreach (var n in prs.Notes) + Console.WriteLine($" t={n.Time:F3} '{System.Text.Encoding.ASCII.GetString(BitConverter.GetBytes(n.Token))}' (0x{n.Token:X8})"); Console.WriteLine($"Tracers : {prs.TracerCount}"); int bonesKeyed = 0, totalRot = 0, totalPos = 0; diff --git a/tests/SiegeFX.Tests/AnimSfxTests.cs b/tests/SiegeFX.Tests/AnimSfxTests.cs new file mode 100644 index 00000000..77e036b5 --- /dev/null +++ b/tests/SiegeFX.Tests/AnimSfxTests.cs @@ -0,0 +1,128 @@ +using System.Numerics; +using SiegeFX.Core.Actors; +using SiegeFX.Core.Assets; + +namespace SiegeFX.Tests; + +public class AnimSfxTests +{ + const uint Sfx1 = 0x31584653, Sfx2 = 0x32584653, Fire = 0x45524946; + // a_c_gbn_bs_fs2_dsf: 1.333 s, SFX1 at 0.625. + static readonly PrsAnimation.NoteEvent[] Hammer = { new(0.625f, Sfx1) }; + const float Len = 1.3333f; + + static List Crossed(IReadOnlyList notes, double a, double b) + { + var l = new List(); + AnimNotes.SfxCrossed(notes, Len, a, b, l); + return l; + } + + [Fact] + public void TheHammerNoteFiresOncePerLoopEveryLoop() + { + // Step a looping idle at 60 fps for 10 loops: one strike each. + int strikes = 0; + double t = 0; + for (int i = 0; i < 800; i++) + { + double next = t + 1.0 / 60; + strikes += Crossed(Hammer, t, next).Count; + t = next; + } + Assert.Equal((int)Math.Floor((t - 0.625 * Len) / Len) + 1, strikes); + Assert.True(strikes >= 9); + } + + [Fact] + public void OnlySfxNotesCountAndTheyKeepTheirNumbers() + { + var notes = new PrsAnimation.NoteEvent[] { new(0.8f, Sfx2), new(0.2f, Fire), new(0.1f, Sfx1) }; + Assert.Equal(new[] { 1, 2 }, Crossed(notes, 0.01, Len)); + } + + [Fact] + public void NoTimePassingFiresNothing() + { + var atZero = new PrsAnimation.NoteEvent[] { new(0f, Sfx1) }; + Assert.Empty(Crossed(atZero, 0, 0)); // paused on a fresh clip + Assert.Equal(new[] { 1 }, Crossed(atZero, 0, 0.02)); // the clip's first frame + Assert.Empty(Crossed(Hammer, 0.9, 0.9)); + } + + [Fact] + public void AHitchFiresAtMostOneLoopsWorth() + { + Assert.Single(Crossed(Hammer, 0.1, 0.1 + 20 * Len)); + } + + static (TriggerRuntime rt, TriggerInstance trig) BlacksmithLikeTrigger(string rows) + { + var doc = GasDocument.Parse("[common] { [template_triggers] { " + rows + " } }"); + var triggers = doc.Roots.Single().Children.Single(); + var matrix = new TriggerMatrix(triggers.Children + .Select(r => TriggerRow.Parse(r, "test", null)).ToList()); + var trig = new TriggerInstance(0x03200c26, 0, Vector3.Zero, matrix, startActive: true); + var rt = new TriggerRuntime(); + rt.Register(trig); + return (rt, trig); + } + + sealed class SfxCtx : TriggerContext + { + public readonly List Calls = new(); + public override void CallSfxScript(string scriptName, IReadOnlyList? args, Vector3 origin, uint ownerScid) + => Calls.Add($"{scriptName}@{ownerScid:X8}"); + } + + [Fact] + public void WeAnimSfxRunsTheTemplatesSfxScriptEveryTime() + { + var (rt, _) = BlacksmithLikeTrigger(""" + [*] { action* = call_sfx_script("blacksmith_hammer"); condition* = receive_world_message("we_anim_sfx",1); } + """); + var ctx = new SfxCtx(); + for (int loop = 0; loop < 8; loop++) + { + rt.PostInboundMessage(0x03200c26, "we_anim_sfx", 1); + rt.Tick(0.7, ctx); + rt.Tick(0.7, ctx); + } + Assert.Equal(8, ctx.Calls.Count); + Assert.All(ctx.Calls, c => Assert.Equal("blacksmith_hammer@03200C26", c)); + } + + [Fact] + public void TheNoteNumberPicksTheRowAndOtherNumbersDoNotClogIt() + { + // giant_spider-style: one row per foot note. + var (rt, _) = BlacksmithLikeTrigger(""" + [*] { action* = call_sfx_script("spark_1"); condition* = receive_world_message("we_anim_sfx",1); } + [*] { action* = call_sfx_script("spark_2"); condition* = receive_world_message("we_anim_sfx",2); } + """); + var ctx = new SfxCtx(); + for (int loop = 0; loop < 6; loop++) + { + rt.PostInboundMessage(0x03200c26, "we_anim_sfx", 2); + rt.Tick(0.1, ctx); + rt.Tick(0.1, ctx); + } + Assert.Equal(6, ctx.Calls.Count); + Assert.All(ctx.Calls, c => Assert.StartsWith("spark_2@", c)); + rt.PostInboundMessage(0x03200c26, "we_anim_sfx", 1); + rt.Tick(0.1, ctx); + Assert.StartsWith("spark_1@", ctx.Calls[^1]); + } + + [Fact] + public void AMessageWithoutAQualifierStillMatchesByName() + { + var (rt, _) = BlacksmithLikeTrigger(""" + [*] { action* = call_sfx_script("x"); condition* = receive_world_message("we_req_activate",0); } + """); + var ctx = new SfxCtx(); + rt.PostInboundMessage(0x03200c26, "we_req_activate"); + rt.Tick(0.1, ctx); + Assert.Single(ctx.Calls); + } +} diff --git a/tests/SiegeFX.Tests/SfxOwnerFacingTests.cs b/tests/SiegeFX.Tests/SfxOwnerFacingTests.cs new file mode 100644 index 00000000..145198be --- /dev/null +++ b/tests/SiegeFX.Tests/SfxOwnerFacingTests.cs @@ -0,0 +1,34 @@ +using System.Numerics; +using SiegeFX.Core.Sfx; + +namespace SiegeFX.Tests; + +public class SfxOwnerFacingTests +{ + static readonly Vector3 Smith = new(8.7f, 0.4f, -13.8f); + + [Fact] + public void AnActorsOwnScriptOffsetsAlongItsFacing() + { + // The blacksmith's hammer sparks: offset(0,0,.8) is 0.8 m in front of him. + var ctx = SfxContext.At(Smith) with { OwnerForward = -Vector3.UnitX }; + var off = SfxRuntime.CasterFrameOffset(ctx, new(0, 0, 0.8f)); + Assert.Equal(-0.8f, off.X, 4); + Assert.Equal(0f, off.Z, 4); + } + + [Fact] + public void WithoutAnOwnerFacingTheWorldZFrameStays() + { + var off = SfxRuntime.CasterFrameOffset(SfxContext.At(Smith), new(0, 0, 0.8f)); + Assert.Equal(0.8f, off.Z, 4); + } + + [Fact] + public void ACasterToTargetFrameStillWins() + { + var ctx = new SfxContext(Smith, Smith + new Vector3(0, 0, -5), Smith) { OwnerForward = Vector3.UnitX }; + var off = SfxRuntime.CasterFrameOffset(ctx, new(0, 0, 1f)); + Assert.Equal(-1f, off.Z, 4); + } +}