Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/SiegeFX.Core/Actors/AttackAnim.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,50 @@ public static float[] Times(PrsAnimation clip, uint token)
hits.Sort();
return hits.ToArray();
}

/// <summary>The SFX1..SFX4 note number (1-4) of <paramref name="token"/>,
/// or 0 when it is another note.</summary>
public static int SfxNumber(uint token) => token switch
{
0x31584653 => 1, // 'SFX1'
0x32584653 => 2, // 'SFX2'
0x33584653 => 3, // 'SFX3'
0x34584653 => 4, // 'SFX4'
_ => 0,
};

/// <summary>SC-ANIM-SFX — the SFX1..4 notes a clip passed while its
/// clock moved from <paramref name="before"/> to <paramref name="after"/>
/// 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).</summary>
public static void SfxCrossed(IReadOnlyList<PrsAnimation.NoteEvent> notes, float animLength,
double before, double after, List<int> 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);
}
}

/// <summary>Phase 18 — one in-flight attack iteration, DS1's model: the clip
Expand Down
81 changes: 60 additions & 21 deletions src/SiegeFX.Core/Actors/TriggerRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -553,7 +553,7 @@ void Dispatch(TriggerInstance trig, TriggerCall act, TriggerContext ctx, bool de
var scriptName = act.Args[0];
IReadOnlyList<string>? 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:
Expand Down Expand Up @@ -591,7 +591,10 @@ void Dispatch(TriggerInstance trig, TriggerCall act, TriggerContext ctx, bool de
/// message into every row's pending-message inbox; rows whose
/// <c>receive_world_message</c> condition matches will consume the stamp on their
/// next eval.</summary>
public void PostInboundMessage(uint targetScid, string name)
/// <param name="data">SC-ANIM-SFX — the message's qualifier, matched
/// against a <c>receive_world_message(name, N)</c> condition's N
/// (WE_ANIM_SFX carries its SFX note number). Null = none.</param>
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.
Expand All @@ -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);
}
}

/// <summary>The integer second argument of a receive_world_message
/// condition, or null when it authors none.</summary>
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;
Expand Down Expand Up @@ -681,32 +689,44 @@ public struct TriggerRowState
/// run more than a couple deep between ticks.</summary>
InboundMessages _inbox;

public void DepositMessage(string name) => _inbox.Push(name);
public bool ConsumeReceivedMessage(string name) => _inbox.TryConsume(name);
/// <param name="data">SC-ANIM-SFX — the message's qualifier (WE_ANIM_SFX
/// carries its note number 1-4); null when the poster sends none.</param>
public void DepositMessage(string name, int? data = null) => _inbox.Push(new(name, data));
/// <param name="qualifier">The condition's second argument
/// (<c>receive_world_message("we_anim_sfx",1)</c>). A message and a
/// condition that both carry one must agree; either side without one
/// matches by name alone (the pre-qualifier behavior).</param>
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
{
// Inline storage for up to 4 pending messages. Anything beyond that gets
// 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;
}

Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -839,5 +875,8 @@ public virtual void ChangeQuestState(IReadOnlyList<string> 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 <c>SfxRuntime</c>.</summary>
public virtual void CallSfxScript(string scriptName, IReadOnlyList<string>? args, Vector3 origin) { }
/// <param name="ownerScid">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.</param>
public virtual void CallSfxScript(string scriptName, IReadOnlyList<string>? args, Vector3 origin, uint ownerScid) { }
}
8 changes: 8 additions & 0 deletions src/SiegeFX.Core/Sfx/SfxContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ public Vector3 ResolveTargetBone(string boneToken)
/// have a single anchor — both source and target collapse to it.</summary>
public static SfxContext At(Vector3 origin) => new(origin, origin, origin);

/// <summary>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). <c>offset(x,y,z)</c> 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.</summary>
public Vector3? OwnerForward { get; init; }

/// <summary>Resolve a DS1 logical bone name to a caster-skeleton world
/// position. Strips the leading <c>@</c> if present, calls the
/// configured <see cref="Resolver"/>, and falls back to the per-bone
Expand Down
14 changes: 12 additions & 2 deletions src/SiegeFX.Core/Sfx/SfxRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1278,11 +1278,21 @@ struct DelayedStart

/// <summary>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.</summary>
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
/// (<see cref="SfxContext.OwnerForward"/>). 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.</summary>
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;
Expand Down
62 changes: 62 additions & 0 deletions src/SiegeFX.Runtime/Render/RenderHost.AnimSfx.cs
Original file line number Diff line number Diff line change
@@ -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<int> _animSfxScratch = new(4);

/// <summary>Post WE_ANIM_SFX(n) to <paramref name="s"/> for every SFX
/// note its clip passed while its clock went from <paramref name="before"/>
/// to <paramref name="after"/>. The hero is left out: its swing/cast
/// sounds run off the attack system's own note handling.</summary>
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);
}
}

/// <summary>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.</summary>
internal void OnTriggerCallSfxScript(string scriptName, IReadOnlyList<string>? 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);
}
}
5 changes: 4 additions & 1 deletion src/SiegeFX.Runtime/Render/RenderHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public sealed class RenderHost : IDisposable
public sealed partial class RenderHost : IDisposable
{
private readonly IWindow _window;
private readonly string? _meshPath;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/SiegeFX.Runtime/Render/RenderHostTriggerContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ public override void SetInterestRadius(float radius)
_host.OnTriggerSetInterestRadius(radius);
}

public override void CallSfxScript(string scriptName, IReadOnlyList<string>? args, Vector3 origin)
public override void CallSfxScript(string scriptName, IReadOnlyList<string>? args, Vector3 origin, uint ownerScid)
{
_host.OnTriggerCallSfxScript(scriptName, args, origin);
_host.OnTriggerCallSfxScript(scriptName, args, origin, ownerScid);
}

public override void FadeNodes(string verb, IReadOnlyList<string> args)
Expand Down
2 changes: 2 additions & 0 deletions src/SiegeFX.Tools/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading