diff --git a/src/SiegeFX.Core/Actors/Actor.cs b/src/SiegeFX.Core/Actors/Actor.cs index 8870459c..43310219 100644 --- a/src/SiegeFX.Core/Actors/Actor.cs +++ b/src/SiegeFX.Core/Actors/Actor.cs @@ -67,8 +67,9 @@ public int GetClipIndex(string choreName) => public ActorCombatState Combat { get; } /// SC-IDLE-FIDGET — the clip an undirected actor idles on: the - /// template's authored initial_chore when it loaded, else clip 0 - /// (chore_default). DS1 authors initial_chore = chore_fidget on + /// template's authored initial_chore when it loaded, else + /// chore_fidget when chore_default is a frozen one-frame stance (see + /// ), else clip 0 (chore_default). DS1 authors initial_chore = chore_fidget on /// actors whose default stance is a STATIC pose — the phrak's default /// dfs is a frozen glide; its fidget dff is the wing-flap /// hover. Falling back to clip 0 left idle flyers frozen mid-air. @@ -80,20 +81,31 @@ public int GetClipIndex(string choreName) => /// Current blender-selected clip. Clamped to the legal range so the caller /// can index blindly; defaults to /// before the first dispatch resolves. - public int CurrentClipIndex + public int CurrentClipIndex => + ResolveClipIndex(Host.CurrentAnimIndex, Clips.Length, IdleClipIndex, + Buried, Awakened, Host.IsOverrideActive); + + /// The clip-pick rule behind , kept + /// pure so it is testable without a mesh. is the + /// blender's index (-1 before the first dispatch). + public static int ResolveClipIndex(int h, int clipCount, int idleClipIndex, + bool buried, bool awakened, bool overrideActive) { - get - { - int idle = IdleClipIndex >= 0 && IdleClipIndex < Clips.Length ? IdleClipIndex : 0; - int h = Host.CurrentAnimIndex; - if (Buried && !Host.IsOverrideActive) return 0; - if (h < 0 || h >= Clips.Length) return idle; - // SC-BURROWER — once awake, an actor whose chore_default is its - // buried pose idles in its initial chore (the skrubb peeking - // from its mound) when the script asks for the default. - if (h == 0 && Awakened) return idle; - return h; - } + int idle = idleClipIndex >= 0 && idleClipIndex < clipCount ? idleClipIndex : 0; + if (buried && !overrideActive) return 0; + if (h < 0 || h >= clipCount) return idle; + // SC-BURROWER — once awake, an actor whose chore_default is its + // buried pose idles in its initial chore (the skrubb peeking + // from its mound) when the script asks for the default. + if (h == 0 && awakened) return idle; + // SC-IDLE-FIDGET — the chore_default skrit (infinite_loop) asks for + // index 0 the moment it starts, so the idle clip has to win over a + // skrit-driven 0 too. Before, IdleClipIndex only showed in the + // instant before the first dispatch, and an actor whose default is a + // frozen one-frame stance (the blacksmith's ds) never left it. An + // explicit override of clip 0 still shows clip 0. + if (h == 0 && !overrideActive) return idle; + return h; } /// SC-BURROWER — a startup_reveal monster before it wakes: it diff --git a/src/SiegeFX.Core/Actors/ActorIdle.cs b/src/SiegeFX.Core/Actors/ActorIdle.cs new file mode 100644 index 00000000..47317b97 --- /dev/null +++ b/src/SiegeFX.Core/Actors/ActorIdle.cs @@ -0,0 +1,66 @@ +using System.Numerics; +using SiegeFX.Core.Assets; + +namespace SiegeFX.Core.Actors; + +/// SC-IDLE-FIDGET — which chore an undirected actor idles in. +/// +/// In DS1 an idle actor whose mind has actor_auto_fidgets (the actor +/// base template sets it true) runs job_fidget / the talk jobs' +/// StartFidgetIfRequired$: chore_fidget is re-queued on every +/// WE_ANIM_DONE, so the fidget loop IS the idle. chore_default only shows +/// between chores. We idle on chore_default because for nearly every actor +/// it is a breathing loop, but some templates author it as a single frozen +/// frame and put the real idle in chore_fidget: the blacksmith's +/// a_c_gbn_bs_fs2_ds is one 0.08 s frame, his fs2_dsf is the +/// hammering loop (its note fires the "blacksmith_hammer" sfx trigger). +/// Idling on clip 0 left him standing rigid at the anvil. +public static class ActorIdle +{ + /// The chore name to idle in, or null for chore_default. + /// An authored initial_chore wins; otherwise chore_fidget when + /// chore_default is a static pose, the fidget clip actually moves, and the + /// mind doesn't turn auto-fidgeting off. + public static string? PickIdleChore(string? initialChore, PrsAnimation? defaultClip, + PrsAnimation? fidgetClip, bool autoFidgets) => + PickIdleChore(initialChore, + defaultIsStatic: defaultClip is not null && IsStaticPose(defaultClip), + fidgetMoves: fidgetClip is not null && !IsStaticPose(fidgetClip), + autoFidgets); + + /// The same decision over precomputed clip facts. + public static string? PickIdleChore(string? initialChore, bool defaultIsStatic, + bool fidgetMoves, bool autoFidgets) + { + if (!string.IsNullOrWhiteSpace(initialChore)) return initialChore.Trim(); + return autoFidgets && defaultIsStatic && fidgetMoves ? "chore_fidget" : null; + } + + public static bool IsStaticPose(PrsAnimation clip) => IsStaticPose(clip.RootKeys, clip.BoneKeys); + + /// True when no track in the clip changes: every rotation and + /// position key of the root and of each bone equals that track's first. + public static bool IsStaticPose(PrsAnimation.KeyList? root, IReadOnlyList bones) + { + if (!IsStaticTrack(root)) return false; + foreach (var b in bones) + if (!IsStaticTrack(b)) return false; + return true; + } + + static bool IsStaticTrack(PrsAnimation.KeyList? keys) + { + if (keys is null) return true; + const float eps = 1e-4f; + for (int i = 1; i < keys.RotKeys.Count; i++) + { + // q and -q are the same rotation. + float dot = MathF.Abs(Quaternion.Dot(keys.RotKeys[0].Rotation, keys.RotKeys[i].Rotation)); + if (dot < 1f - eps) return false; + } + for (int i = 1; i < keys.PosKeys.Count; i++) + if (Vector3.DistanceSquared(keys.PosKeys[0].Position, keys.PosKeys[i].Position) > eps * eps) + return false; + return true; + } +} diff --git a/src/SiegeFX.Core/Actors/ActorSpawner.cs b/src/SiegeFX.Core/Actors/ActorSpawner.cs index c40c1289..d0990be2 100644 --- a/src/SiegeFX.Core/Actors/ActorSpawner.cs +++ b/src/SiegeFX.Core/Actors/ActorSpawner.cs @@ -295,17 +295,6 @@ public IReadOnlyList Spawn( // max_life / scale_multiplier) wins over the template chain. var stats = ActorStats.FromTemplate(_store, template, inst.Node); var actor = new Actor(inst, template, world, mesh, clips, skrit, host, stats, walkIdx, clipIndexByName); - // SC-IDLE-FIDGET — honor the template's authored [body] initial_chore - // as the idle fallback clip. DS1 starts actors in it; for flyers - // (phrak) and other static-default creatures that's chore_fidget — - // the hover/flap loop — while chore_default (clip 0) is a frozen - // stance pose. Without this, undirected actors idled on clip 0 and - // flyers hung motionless mid-air. - var initialChore = (_store.GetAttribute(template, "body", "initial_chore") - ?? _store.GetAttribute(template, "mind", "initial_chore"))?.Trim(); - if (!string.IsNullOrEmpty(initialChore) - && clipIndexByName.TryGetValue(initialChore!, out var idleIdx)) - actor.IdleClipIndex = idleIdx; // SC-BURROWER — a startup_reveal monster (the skrubb) lies in its // chore_default pose, below ground, until it wakes. if ((_store.GetAttribute(template, "mind", "jat_startup") ?? "") @@ -318,6 +307,26 @@ public IReadOnlyList Spawn( // attack-variant set + qffg pad + authored base duration for every // spawned combatant (clip cache makes the second pass cheap). RefreshMotionClips(actor, preferredStance ?? DeriveStanceFromEquipment(template)); + // SC-IDLE-FIDGET — honor the template's authored [body] initial_chore + // as the idle clip. DS1 starts actors in it; for flyers (phrak) and + // other static-default creatures that's chore_fidget — the hover/flap + // loop — while chore_default (clip 0) is a frozen stance pose. With + // no initial_chore, a one-frame chore_default and a moving + // chore_fidget (the blacksmith's ds / hammering dsf) idle in the + // fidget too, as DS1's auto-fidget job does. Picked after the stance + // refresh so the test sees the clips the actor will actually play. + var autoFidgets = !string.Equals( + _store.GetAttribute(template, "mind", "actor_auto_fidgets")?.Trim(), "false", + StringComparison.OrdinalIgnoreCase); + var idleChore = ActorIdle.PickIdleChore( + (_store.GetAttribute(template, "body", "initial_chore") + ?? _store.GetAttribute(template, "mind", "initial_chore"))?.Trim(), + clips.Length > 0 ? clips[0] : null, + clipIndexByName.TryGetValue("chore_fidget", out var fidIdx) ? clips[fidIdx] : null, + autoFidgets); + if (!string.IsNullOrEmpty(idleChore) + && clipIndexByName.TryGetValue(idleChore!, out var idleIdx)) + actor.IdleClipIndex = idleIdx; return actor; } diff --git a/src/SiegeFX.Tools/Program.cs b/src/SiegeFX.Tools/Program.cs index c1b9c2af..60210824 100644 --- a/src/SiegeFX.Tools/Program.cs +++ b/src/SiegeFX.Tools/Program.cs @@ -8879,6 +8879,14 @@ static int CmdRegionSpawn(string[] a) } Console.WriteLine($" bus posted : {spawner.MessageBus.PostedCount}"); Console.WriteLine($" bus delivered : {delivered} (undelivered: {spawner.MessageBus.UndeliveredCount})"); + // SC-IDLE-FIDGET — actors that idle in something other than chore_default. + var nonDefaultIdle = actors + .Where(x => x.IdleClipIndex != 0) + .GroupBy(x => (x.Template.Name, + Chore: x.ClipIndexByName.FirstOrDefault(kv => kv.Value == x.IdleClipIndex).Key ?? $"#{x.IdleClipIndex}")) + .Select(g => $"{g.Key.Name}→{g.Key.Chore}×{g.Count()}") + .ToList(); + Console.WriteLine($" non-default idle : {(nonDefaultIdle.Count == 0 ? "none" : string.Join(", ", nonDefaultIdle))}"); Console.WriteLine(); Console.WriteLine("sample actors (first 5):"); diff --git a/tests/SiegeFX.Tests/ActorIdleTests.cs b/tests/SiegeFX.Tests/ActorIdleTests.cs new file mode 100644 index 00000000..3876f0e9 --- /dev/null +++ b/tests/SiegeFX.Tests/ActorIdleTests.cs @@ -0,0 +1,87 @@ +using System.Numerics; +using SiegeFX.Core.Actors; +using SiegeFX.Core.Assets; + +namespace SiegeFX.Tests; + +public class ActorIdleTests +{ + static PrsAnimation.KeyList Track(params (Quaternion r, Vector3 p)[] keys) => + new(keys.Select((k, i) => new PrsAnimation.RotKey(i, k.r)).ToArray(), + keys.Select((k, i) => new PrsAnimation.PosKey(i, k.p)).ToArray()); + + static readonly Quaternion Bent = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.6f); + + [Fact] + public void AOneFrameStanceIsStatic() + { + // The blacksmith's a_c_gbn_bs_fs2_ds: one key per track. + var root = Track((Quaternion.Identity, Vector3.Zero)); + var bones = new[] { Track((Bent, Vector3.UnitY)), null, Track((Quaternion.Identity, Vector3.One)) }; + Assert.True(ActorIdle.IsStaticPose(root, bones)); + } + + [Fact] + public void RepeatedIdenticalKeysAreStaticAndNegatedQuaternionsAreTheSameRotation() + { + var bones = new[] { Track((Bent, Vector3.UnitY), (Quaternion.Negate(Bent), Vector3.UnitY)) }; + Assert.True(ActorIdle.IsStaticPose(null, bones)); + } + + [Fact] + public void AnArmThatSwingsIsNotStatic() + { + // The hammering fidget: the arm rotates across its keys. + var bones = new[] { Track((Quaternion.Identity, Vector3.Zero), (Bent, Vector3.Zero)) }; + Assert.False(ActorIdle.IsStaticPose(null, bones)); + var moved = new[] { Track((Quaternion.Identity, Vector3.Zero), (Quaternion.Identity, Vector3.UnitZ)) }; + Assert.False(ActorIdle.IsStaticPose(null, moved)); + } + + [Fact] + public void AFrozenDefaultWithAMovingFidgetIdlesInTheFidget() + { + Assert.Equal("chore_fidget", + ActorIdle.PickIdleChore(null, defaultIsStatic: true, fidgetMoves: true, autoFidgets: true)); + } + + [Theory] + [InlineData(false, true, true)] // a moving default stays the idle + [InlineData(true, false, true)] // nothing better to show + [InlineData(true, true, false)] // actor_auto_fidgets = false + public void OtherwiseTheDefaultStays(bool defaultIsStatic, bool fidgetMoves, bool autoFidgets) + { + Assert.Null(ActorIdle.PickIdleChore(null, defaultIsStatic, fidgetMoves, autoFidgets)); + } + + [Fact] + public void AnAuthoredInitialChoreWins() + { + Assert.Equal("chore_misc", + ActorIdle.PickIdleChore(" chore_misc ", defaultIsStatic: false, fidgetMoves: false, autoFidgets: false)); + } + + [Fact] + public void TheIdleClipWinsOverTheDefaultLoopsRequestForClipZero() + { + // infinite_loop asks for index 0 as soon as chore_default starts; + // before this the idle clip only showed until that first dispatch. + Assert.Equal(4, Actor.ResolveClipIndex(h: 0, clipCount: 6, idleClipIndex: 4, + buried: false, awakened: false, overrideActive: false)); + Assert.Equal(4, Actor.ResolveClipIndex(h: -1, clipCount: 6, idleClipIndex: 4, + buried: false, awakened: false, overrideActive: false)); + } + + [Fact] + public void OverridesAndOtherChoresStillShow() + { + // A pinned clip-0 override (a scripted pose) shows clip 0. + Assert.Equal(0, Actor.ResolveClipIndex(0, 6, 4, buried: false, awakened: false, overrideActive: true)); + // A swing or any other chore is untouched. + Assert.Equal(2, Actor.ResolveClipIndex(2, 6, 4, buried: false, awakened: false, overrideActive: false)); + // An actor with no idle of its own keeps chore_default. + Assert.Equal(0, Actor.ResolveClipIndex(0, 6, 0, buried: false, awakened: false, overrideActive: false)); + // A buried skrubb holds its default until it wakes. + Assert.Equal(0, Actor.ResolveClipIndex(3, 6, 4, buried: true, awakened: false, overrideActive: false)); + } +}