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..cb2466d4 100644 --- a/src/SiegeFX.Core/Actors/ActorSpawner.cs +++ b/src/SiegeFX.Core/Actors/ActorSpawner.cs @@ -178,6 +178,17 @@ public IReadOnlyList 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"); @@ -295,17 +306,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 +318,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; } @@ -353,6 +373,21 @@ Matrix4x4 ComposeWorldTransform(NodePlacement p) return local * nodeWorld; } + /// 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. + public bool DressArmoredBodies { get; set; } = true; + + /// The model name a spawned actor's was + /// loaded from (meshes are cached per name), or null. + 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; 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/Assets/EquipmentResolver.cs b/src/SiegeFX.Core/Assets/EquipmentResolver.cs index 98ab6fe8..ae819569 100644 --- a/src/SiegeFX.Core/Assets/EquipmentResolver.cs +++ b/src/SiegeFX.Core/Assets/EquipmentResolver.cs @@ -113,14 +113,19 @@ private static void EnsureLookupLoaded(AssetResolver resolver) /// the template — picked-up items override / extend the template's authored /// loadout. Slots that fail to resolve (missing item template, no mesh on /// disk) are returned with so the caller can log - /// them without crashing the spawn path. + /// them without crashing the spawn path. + /// names the body mesh actually drawn when it differs from the template's + /// aspect.model. public static List Resolve( TemplateStore templates, AssetResolver resolver, Template playerTemplate, - IReadOnlyDictionary? liveSlots = null) + IReadOnlyDictionary? liveSlots = null, + string? bodyModelOverride = null) { var layers = new List(); var armorVersion = templates.GetAttribute(playerTemplate, "body", "armor_version"); - var bodyModel = templates.GetAttribute(playerTemplate, "aspect", "model"); + // The body actually drawn — an NPC in plate spawns on its armored + // pos_aN mesh, and texture + boot/gauntlet subtypes must follow it. + var bodyModel = bodyModelOverride ?? templates.GetAttribute(playerTemplate, "aspect", "model"); var bodyType = ExtractBodyType(bodyModel); // e.g. m_c_gah_fb_pos_a1 → a1 var weaponBone = templates.GetAttribute(playerTemplate, "body", "bone_translator", "weapon_bone"); var shieldBone = templates.GetAttribute(playerTemplate, "body", "bone_translator", "shield_bone"); @@ -152,6 +157,58 @@ public static List Resolve( return layers; } + /// The es_* slots an NPC actually wears: the template chain's + /// [inventory][equipment], overlaid by the map instance's own + /// [inventory][equipment] block (instance wins per slot). Used to + /// feed for non-player actors, whose worn armor + /// (Elddim's Legionnaire guards: plate chest, helm, boots, gauntlets) lives + /// only in these blocks — their [aspect][textures] carries just the + /// skin, so without this they render in the bare citizen body. + public static Dictionary CollectActorSlots( + TemplateStore templates, Template template, GasNode? instance) + { + var slots = new Dictionary(StringComparer.OrdinalIgnoreCase); + void Add(GasNode? equip) + { + if (equip is null) return; + foreach (var attr in equip.Attributes) + { + var name = attr.Name.Trim(); + var value = attr.Value?.Trim(); + if (!name.StartsWith("es_", StringComparison.OrdinalIgnoreCase)) continue; + if (string.IsNullOrEmpty(value)) continue; + slots[name] = value; + } + } + Add(templates.GetSection(template, "inventory", "equipment")); + if (instance is not null + && TemplateStore.FindChild(instance, "inventory") is { } inv) + Add(TemplateStore.FindChild(inv, "equipment")); + return slots; + } + + /// DS1 body armor picks the body MESH as well as the texture: the + /// chest item's defend.armor_type (a1..a7) names the + /// pos_aN body it is drawn on — Legionnaire plate (a3) puts + /// the bulky m_c_gah_fb_pos_a3 body under b_c_pos_a3_014, + /// where the a1 body would show a thin shirt. Returns the armored model + /// for (m_c_gah_fb_pos_a1 → + /// m_c_gah_fb_pos_a3), or null when there is no chest armor, the + /// armor type is not a body type, it already matches, or the model does + /// not carry an aN suffix (monsters, dwarves' b1). + public static string? ArmoredBodyModel( + TemplateStore templates, string? bodyModel, IReadOnlyDictionary slots) + { + var bodyType = ExtractBodyType(bodyModel); + if (bodyType is null || bodyModel is null) return null; + if (!slots.TryGetValue("es_chest", out var chestRef) || string.IsNullOrEmpty(chestRef)) return null; + if (!templates.TryGet(chestRef.Trim(), out var chest)) return null; + var armorType = templates.GetAttribute(chest, "defend", "armor_type")?.Trim(); + if (ExtractBodyType("_" + armorType) is not { } wanted) return null; + if (string.Equals(wanted, bodyType, StringComparison.OrdinalIgnoreCase)) return null; + return bodyModel[..^bodyType.Length] + wanted; + } + private static EquipmentLayer ResolveSlot( TemplateStore templates, AssetResolver resolver, string slotName, string itemRef, diff --git a/src/SiegeFX.Core/Nav/NavMesh.cs b/src/SiegeFX.Core/Nav/NavMesh.cs index 36311996..d0d867fd 100644 --- a/src/SiegeFX.Core/Nav/NavMesh.cs +++ b/src/SiegeFX.Core/Nav/NavMesh.cs @@ -356,6 +356,12 @@ private static bool EdgeWithinDiskXZ(Vector3 e0, Vector3 e1, float cx, float cz, /// slot-starved fallback (see ). public int DoorSeamOverflowCount { get; } + /// SC-NAV-SEAM-BARRIER — overflow links the door stitcher dropped + /// because every candidate hop crossed a barrier (an Ignored nav face standing + /// above the floor — e.g. a wooden fence piece's boards between its two floor + /// strips) and the two ends were connected some other way anyway. + public int DoorSeamBarrierRejectCount { get; private init; } + public int TriangleCount => Indices.Length / 3; // XZ uniform-grid spatial index. Built once at construction and queried by @@ -585,7 +591,7 @@ int Intern(Vector3 p) // Runs BEFORE land↔water stitching so this pass's newly-paired // edges aren't counted as boundary candidates by the water pass. var extraLinksBuild = new Dictionary>(); - int doorSeams = StitchSnoDoorSeams(graph, layout, resolveSno, sourceSnodeGuid.ToArray(), indices, neighbors, vertsArr, extraLinksBuild, out int doorSeamOverflow); + int doorSeams = StitchSnoDoorSeams(graph, layout, resolveSno, sourceSnodeGuid.ToArray(), indices, neighbors, vertsArr, extraLinksBuild, out int doorSeamOverflow, out int doorSeamBarrierRejects); // Land↔water seam stitching: shoreline Floor and Water SNOs are authored in // separate meshes whose vertices don't fall inside the WeldToleranceUnits bucket, // so the manifold pass leaves them on disconnected components. Wire cross-kind @@ -681,7 +687,7 @@ int Intern(Vector3 p) originZ, cellsX, cellsZ, - grid); + grid) { DoorSeamBarrierRejectCount = doorSeamBarrierRejects }; } /// SC-NAV-CROSS-SNO-STITCH — door-anchored cross-SNO nav @@ -710,6 +716,36 @@ int Intern(Vector3 p) // pairings (tread side edge vs. threshold edge). Matches the shoreline // stitcher's |cos θ| gate. private const float DoorSeamCollinearityCosMin = 0.85f; + // SC-NAV-SEAM-BARRIER — an Ignored edge crossing an overflow hop counts as a + // barrier when, at the crossing, it stands between these heights above the + // hop: above a kerb or a floor decal, below a lintel, arch or ceiling. + private const float BarrierMinRise = 0.25f; + private const float BarrierMaxRise = 2.0f; + + /// SC-NAV-SEAM-BARRIER — does the hop → + /// cross (in XZ) one of the (flat endpoint pairs) at a + /// point where the edge stands between and + /// above the hop? A vertical fence board projects to + /// a line in XZ, so its top and diagonal edges are what register. + public static bool CrossesBarrier(Vector3 p, Vector3 q, Vector3[] edges) + { + float rx = q.X - p.X, rz = q.Z - p.Z; + for (int i = 0; i + 1 < edges.Length; i += 2) + { + var e0 = edges[i]; + var e1 = edges[i + 1]; + float sx = e1.X - e0.X, sz = e1.Z - e0.Z; + float denom = Cross2(rx, rz, sx, sz); + if (MathF.Abs(denom) < 1e-8f) continue; // parallel, or an edge seen end-on + float qpx = e0.X - p.X, qpz = e0.Z - p.Z; + float t = Cross2(qpx, qpz, sx, sz) / denom; // along the hop + float u = Cross2(qpx, qpz, rx, rz) / denom; // along the edge + if (t < 0f || t > 1f || u < 0f || u > 1f) continue; + float rise = (e0.Y + u * (e1.Y - e0.Y)) - (p.Y + t * (q.Y - p.Y)); + if (rise >= BarrierMinRise && rise <= BarrierMaxRise) return true; + } + return false; + } private static int StitchSnoDoorSeams( RegionGraph graph, @@ -720,9 +756,11 @@ private static int StitchSnoDoorSeams( int[] neighbors, Vector3[] verts, Dictionary> extraLinks, - out int overflowLinks) + out int overflowLinks, + out int overflowBarrierRejects) { overflowLinks = 0; + overflowBarrierRejects = 0; int triCount = indices.Length / 3; // Index unwelded edges by snode guid. Each entry is (tri, slot, // midpoint, unit XZ direction, XZ length) — slot s is the edge @@ -767,6 +805,56 @@ private static int StitchSnoDoorSeams( } Vector3 TriCentroid(int t) => (verts[indices[3 * t + 0]] + verts[indices[3 * t + 1]] + verts[indices[3 * t + 2]]) / 3f; + // SC-NAV-SEAM-BARRIER — edges of a snode's Ignored (non-walkable) nav faces, + // lifted to the region frame on first use: the authored solid parts of a + // piece (fence boards, rails, wall stubs). Flat pairs of edge endpoints. + var barrierEdges = new Dictionary(); + var hops = new List<(int a, int b, float score)>(); + // Hops that found no barrier-free pair: kept only if dropping them would + // cut the mesh apart (see the restore pass at the end). + var deferredHops = new List<(int a, int b, float score)>(); + var hopCheckNodes = new HashSet(); + // Barriers of both door snodes and of their door neighbours: at a fence + // corner the boards a hop crosses can belong to a third piece. + bool HopBlocked(Vector3 p, Vector3 q, RegionGraph.NodeInstance a, RegionGraph.NodeInstance b) + { + hopCheckNodes.Clear(); + foreach (var end in (ReadOnlySpan)[a, b]) + { + if (hopCheckNodes.Add(end.Guid) && CrossesBarrier(p, q, BarrierEdges(end))) return true; + foreach (var d in end.Doors) + { + if (!hopCheckNodes.Add(d.FarGuid) || !graph.TryGetNode(d.FarGuid, out var nb)) continue; + if (CrossesBarrier(p, q, BarrierEdges(nb))) return true; + } + } + return false; + } + Vector3[] BarrierEdges(RegionGraph.NodeInstance n) + { + if (barrierEdges.TryGetValue(n.Guid, out var cached)) return cached; + var list = new List(); + var nodeSno = resolveSno(n.MeshGuid); + if (nodeSno is not null && layout.TryGetTransform(n.Guid, out var xf)) + { + foreach (var g in nodeSno.LogicalGroupings) + { + if (g.Kind != SnoModel.FloorKind.Ignored) continue; + foreach (var f in g.Faces) + { + var a = Vector3.Transform(f.A, xf); + var b = Vector3.Transform(f.B, xf); + var c = Vector3.Transform(f.C, xf); + list.Add(a); list.Add(b); + list.Add(b); list.Add(c); + list.Add(c); list.Add(a); + } + } + } + var arr = list.ToArray(); + barrierEdges[n.Guid] = arr; + return arr; + } // Split a small tri set into its slot-connected local components. // Stepped pieces fragment internally: riser faces are degenerate in XZ // and dropped from the mesh, so each tread strip is its own island — @@ -907,8 +995,7 @@ bool AnyLink(List ca, List cb) foreach (var compB in LocalComponents(bNear)) { if (AnyLink(compA, compB)) continue; - int bestA = -1, bestB = -1; - float bestScore = float.MaxValue; + hops.Clear(); foreach (var tA in compA) { var ca = TriCentroid(tA); @@ -918,22 +1005,76 @@ bool AnyLink(List ca, List cb) float dy = MathF.Abs(ca.Y - cb.Y); if (dy > 1.0f) continue; float ddx = ca.X - cb.X, ddz = ca.Z - cb.Z; - float score = MathF.Sqrt(ddx * ddx + ddz * ddz) + dy; - if (score < bestScore) { bestScore = score; bestA = tA; bestB = tB; } + hops.Add((tA, tB, MathF.Sqrt(ddx * ddx + ddz * ddz) + dy)); } } - if (bestA < 0) continue; - if (!extraLinks.TryGetValue(bestA, out var la)) extraLinks[bestA] = la = new List(); - if (!la.Contains(bestB)) la.Add(bestB); - if (!extraLinks.TryGetValue(bestB, out var lb)) extraLinks[bestB] = lb = new List(); - if (!lb.Contains(bestA)) lb.Add(bestA); + if (hops.Count == 0) continue; + // SC-NAV-SEAM-BARRIER — the link is a straight hop between two + // centroids; never let it jump a barrier either snode authors + // between them. Field case: multiplayer_world town_center, the + // wooden pig-pen fence by the co-op start (players walked + // straight through it, and through house walls). Each + // t_grs01_fence_generic_wood-* piece has a floor strip either + // side of its Ignored boards, every door off it lands here (the + // strips weld to their neighbours), and the nearest-centroid hop + // tied one side of the fence to the other. Best-first, so a + // barrier-free hop around an obstacle still wins over none. + hops.Sort((x, y) => x.score.CompareTo(y.score)); + int bestA = -1, bestB = -1; + foreach (var (hA, hB, _) in hops) + { + if (HopBlocked(TriCentroid(hA), TriCentroid(hB), node, farNode)) continue; + bestA = hA; + bestB = hB; + break; + } + if (bestA < 0) { deferredHops.Add(hops[0]); continue; } + AddExtraLink(extraLinks, bestA, bestB); overflowLinks++; } } } + + // SC-NAV-SEAM-BARRIER restore — a hop that only crossed barriers is dropped + // when its two ends are joined some other way (the fence pen has its gate), + // but kept when it is the only thing holding the two pieces of mesh + // together: an over-eager barrier verdict must not strand floor that the + // pre-barrier stitcher left reachable. + if (deferredHops.Count > 0) + { + var parent = new int[triCount]; + for (int i = 0; i < triCount; i++) parent[i] = i; + int Find(int x) + { + while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } + return x; + } + void Union(int x, int y) { x = Find(x); y = Find(y); if (x != y) parent[x] = y; } + for (int t = 0; t < triCount; t++) + for (int s = 0; s < 3; s++) + if (neighbors[3 * t + s] >= 0) Union(t, neighbors[3 * t + s]); + foreach (var (t, links) in extraLinks) + foreach (var nb in links) Union(t, nb); + deferredHops.Sort((x, y) => x.score.CompareTo(y.score)); + foreach (var (a, b, _) in deferredHops) + { + if (Find(a) == Find(b)) { overflowBarrierRejects++; continue; } + Union(a, b); + AddExtraLink(extraLinks, a, b); + overflowLinks++; + } + } return stitched; } + private static void AddExtraLink(Dictionary> extraLinks, int a, int b) + { + if (!extraLinks.TryGetValue(a, out var la)) extraLinks[a] = la = new List(); + if (!la.Contains(b)) la.Add(b); + if (!extraLinks.TryGetValue(b, out var lb)) extraLinks[b] = lb = new List(); + if (!lb.Contains(a)) lb.Add(a); + } + /// Wires cross-kind adjacencies for Floor↔Water boundary edges that visually /// share a shoreline. DS1 authors water surfaces in separate SNOs whose vertices don't /// weld to the floor under , so a vertex-equality diff --git a/src/SiegeFX.Core/Net/MpSession.cs b/src/SiegeFX.Core/Net/MpSession.cs index fba7b93d..b61b03ef 100644 --- a/src/SiegeFX.Core/Net/MpSession.cs +++ b/src/SiegeFX.Core/Net/MpSession.cs @@ -114,9 +114,16 @@ public sealed class MpSession : IDisposable public MpSession(ISessionTransport transport, bool isHost, string localName) { _t = transport; IsHost = isHost; _localName = localName ?? "Player"; - _t.PeerDisconnected += OnPeerGone; + _t.PeerDisconnected += QueuePeerGone; } + // Transports raise PeerDisconnected from their own threads (the LAN + // keepalive task, EOS callbacks). OnPeerGone fires OnPlayerLeft, whose + // handlers edit the render host's actor list, so it must run on the + // thread that ticks the session — queue it and drain it in Tick. + readonly System.Collections.Concurrent.ConcurrentQueue _gonePeers = new(); + void QueuePeerGone(int peer) => _gonePeers.Enqueue(peer); + void OnPeerGone(int peer) { if (IsHost) @@ -297,6 +304,8 @@ public void SendRecruitGrant(uint scid, int owningPlayer) /// Call once per engine frame with the frame dt. public void Tick(double dt) { + while (_gonePeers.TryDequeue(out int gone)) + OnPeerGone(gone); while (_t.TryReceive(out int peer, out var data)) Handle(peer, data); @@ -684,6 +693,6 @@ void RelayChat(int fromPlayer, string text) public void Dispose() { - _t.PeerDisconnected -= OnPeerGone; + _t.PeerDisconnected -= QueuePeerGone; } } 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/NetSelfTest.cs b/src/SiegeFX.Runtime/NetSelfTest.cs index fa72a8d1..b94685b8 100644 --- a/src/SiegeFX.Runtime/NetSelfTest.cs +++ b/src/SiegeFX.Runtime/NetSelfTest.cs @@ -198,6 +198,7 @@ void Assert(bool cond, string what) "in-region: GameStart delivered region + difficulty to the client"); c2.Dispose(); // client drops — host must be told player 1 left + hs.Tick(0); // (on its next tick: transports report leaves off-thread) Assert(hostSawLeave == 1, $"in-region: host fired OnPlayerLeft on disconnect (got {hostSawLeave})"); hs.Dispose(); cs.Dispose(); h2.Dispose(); } 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 88d1fcc7..9470aae5 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; @@ -1644,6 +1644,13 @@ private sealed record ItemMesh(StaticMesh Mesh, GlTexture? Texture, string? Disp // TrySpawnPlayer; rebuilt on every equipment change. private readonly List _equippedLayers = new(); private string? _chestTexOverrideName; + // SC-NPC-ARMOR — NPC worn armor. Chest = slot-1 texture override keyed by + // actor (weak, so despawned actors drop out); layered meshes are cached per + // (mesh, texture) and shared by every NPC wearing the same piece. + private readonly System.Runtime.CompilerServices.ConditionalWeakTable + _npcChestTex = new(); + private readonly Dictionary<(string Mesh, string? Tex), EquippedLayer?> _npcArmorLayerCache = new(); + private readonly HashSet _npcChestLogged = new(StringComparer.OrdinalIgnoreCase); // 21d-2a-viii — character creator picks resolved at spawn. Skin = body // subset 0 (face/hair/arms); pants = body subset 1 (clothing strip). Both // are nulled out for NPCs and for player templates without a matching env @@ -2077,6 +2084,11 @@ private sealed class ActorRenderState public int ShieldBoneIdx = -1; public string? WieldedWeaponRef; public bool WeaponDroppedOnDeath; + // SC-NPC-ARMOR — worn helm/boots/gauntlets, skinned to the body's + // skeleton and posed with the body's clip each frame. Entries are + // SHARED via _npcArmorLayerCache (never disposed per-actor). Chest + // armor is a body slot-1 texture swap kept in _npcChestTex. + public List? ArmorLayers; // SC-ALIGNMENT — actor_evil in the specializes chain = a valid // player attack target. DS1 never lets you attack good/neutral // NPCs (Norick, guards, kings) — no attack cursor, no swing. @@ -15648,6 +15660,14 @@ private void AlphaOver(byte[] dst, string? overlayName, int w, int h) $"player slot {textureIndex}: no picker -> texset fallback"); } } + // SC-NPC-ARMOR — an NPC's worn chest armor replaces the clothing strip + // (Legionnaire plate instead of the citizen shirt), same rule as the + // player's override above. A missing .raw falls through to the texset. + if (textureIndex == 1 && _npcChestTex.TryGetValue(actor, out var npcChest)) + { + var ct = LoadEquipmentTexture("__chest__", npcChest); + if (ct is not null) return ct; + } var key = (actor.Template, actor.Mesh, textureIndex); if (_actorTextureCache.TryGetValue(key, out var cached)) return cached; string? baseName = null; @@ -25460,6 +25480,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; @@ -25483,6 +25504,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); } } } @@ -28829,7 +28852,12 @@ private void TrySpawnPlayerWithPicker( $"=> model={heroOverride?.ModelName ?? "