Skip to content
Closed
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
42 changes: 27 additions & 15 deletions src/SiegeFX.Core/Actors/Actor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ public int GetClipIndex(string choreName) =>
public ActorCombatState Combat { get; }

/// <summary>SC-IDLE-FIDGET — the clip an undirected actor idles on: the
/// template's authored <c>initial_chore</c> when it loaded, else clip 0
/// (chore_default). DS1 authors <c>initial_chore = chore_fidget</c> on
/// template's authored <c>initial_chore</c> when it loaded, else
/// chore_fidget when chore_default is a frozen one-frame stance (see
/// <see cref="ActorIdle"/>), else clip 0 (chore_default). DS1 authors <c>initial_chore = chore_fidget</c> on
/// actors whose default stance is a STATIC pose — the phrak's default
/// <c>dfs</c> is a frozen glide; its fidget <c>dff</c> is the wing-flap
/// hover. Falling back to clip 0 left idle flyers frozen mid-air.
Expand All @@ -80,20 +81,31 @@ public int GetClipIndex(string choreName) =>
/// <summary>Current blender-selected clip. Clamped to the legal range so the caller
/// can index <see cref="Clips"/> blindly; defaults to <see cref="IdleClipIndex"/>
/// before the first dispatch resolves.</summary>
public int CurrentClipIndex
public int CurrentClipIndex =>
ResolveClipIndex(Host.CurrentAnimIndex, Clips.Length, IdleClipIndex,
Buried, Awakened, Host.IsOverrideActive);

/// <summary>The clip-pick rule behind <see cref="CurrentClipIndex"/>, kept
/// pure so it is testable without a mesh. <paramref name="h"/> is the
/// blender's index (-1 before the first dispatch).</summary>
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;
}

/// <summary>SC-BURROWER — a startup_reveal monster before it wakes: it
Expand Down
66 changes: 66 additions & 0 deletions src/SiegeFX.Core/Actors/ActorIdle.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System.Numerics;
using SiegeFX.Core.Assets;

namespace SiegeFX.Core.Actors;

/// <summary>SC-IDLE-FIDGET — which chore an undirected actor idles in.
///
/// In DS1 an idle actor whose mind has <c>actor_auto_fidgets</c> (the actor
/// base template sets it true) runs job_fidget / the talk jobs'
/// <c>StartFidgetIfRequired$</c>: 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
/// <c>a_c_gbn_bs_fs2_ds</c> is one 0.08 s frame, his <c>fs2_dsf</c> is the
/// hammering loop (its note fires the "blacksmith_hammer" sfx trigger).
/// Idling on clip 0 left him standing rigid at the anvil.</summary>
public static class ActorIdle
{
/// <summary>The chore name to idle in, or null for chore_default.
/// An authored <c>initial_chore</c> 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.</summary>
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);

/// <summary>The same decision over precomputed clip facts.</summary>
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);

/// <summary>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.</summary>
public static bool IsStaticPose(PrsAnimation.KeyList? root, IReadOnlyList<PrsAnimation.KeyList?> 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;
}
}
57 changes: 46 additions & 11 deletions src/SiegeFX.Core/Actors/ActorSpawner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,17 @@ public IReadOnlyList<Actor> Spawn(
return null;
}

// SC-NPC-ARMOR — worn body armor selects the pos_aN body mesh (plate =
// a3). Only for plain spawns: hero/avatar picks carry their own model.
if (DressArmoredBodies && overrides is null
&& SiegeFX.Core.Assets.EquipmentResolver.ArmoredBodyModel(_store, modelName,
SiegeFX.Core.Assets.EquipmentResolver.CollectActorSlots(_store, template, inst.Node)) is { } armored
&& GetOrLoadMesh(armored) is { } armoredMesh)
{
modelName = armored;
mesh = armoredMesh;
}

// chore_default is every actor's idle. Its {prefix, stance list, suffix} triple
// composes one PRS clip; the skrit lives alongside under `skrit = NAME`.
var chorePrefix = _store.GetAttribute(template, "body", "chore_dictionary", "chore_prefix");
Expand Down Expand Up @@ -295,17 +306,6 @@ public IReadOnlyList<Actor> 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") ?? "")
Expand All @@ -318,6 +318,26 @@ public IReadOnlyList<Actor> 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;
}

Expand Down Expand Up @@ -353,6 +373,21 @@ Matrix4x4 ComposeWorldTransform(NodePlacement p)
return local * nodeWorld;
}

/// <summary>SC-NPC-ARMOR — when true (default), a plain spawn whose worn
/// chest armor names another body type (plate = a3) spawns on that pos_aN
/// body mesh, as DS1 does. The host turns it off around the hero spawn,
/// whose armor is a live texture swap on a fixed body.</summary>
public bool DressArmoredBodies { get; set; } = true;

/// <summary>The model name a spawned actor's <see cref="Actor.Mesh"/> was
/// loaded from (meshes are cached per name), or null.</summary>
public string? ModelNameOf(AspMesh mesh)
{
foreach (var (name, m) in _meshCache)
if (ReferenceEquals(m, mesh)) return name;
return null;
}

AspMesh? GetOrLoadMesh(string modelName)
{
if (_meshCache.TryGetValue(modelName, out var cached)) return cached;
Expand Down
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
Loading
Loading