LANTERN feel pass (DR-052 + gap list): SoD facing, underwater feel, Bathynaut kit, walk/run gait, suit lamp + new Synty anim packs

- SoD facing: PlayerFacing = body-yaw only (move-facing / cast-turn / idle-hold); every fire
  direction re-sourced to FacingMath.ResolveAim (pre-code review blocking catch); TickWindowMath
  shared windows with Movement-skip; reticle/FX coupled to the damage direction; cursor-dash kept.
- Underwater feel: sharpness 15->6, turn 720->360, MoveSpeed 6->4.2; TuningKnob 26-28
  (0 = no-override sentinels, dev-protocol bump on DebugTuningReport); stride footsteps + silt +
  cadence floor; bubbles; underwater ambience bed + distant groans; camera drag + dev scroll zoom.
- Bathynaut kit in-engine: dome/tank/shoulder-lamp + bare head grafted (GraftSmr rigid rebase,
  RecalculateTangents); EmissiveGloamSkinned shader (Rukhanka deformation); shoulder lamp CASTS
  (warm steady spot on body yaw).
- Gait: two-ring walk/run FreeformDirectional tree (walk @0.35, run @1.0) + blended-natural
  StrideScale; additive Posture(Bank) + Lead(chest-lead) layers; idle = AnimationIdles Base;
  banking driven from facing turn rate; flat terrain (Env_SeabedKit seabed squashed - CC is planar).
- New packs: Synty AnimationIdles + AnimationSwordCombat (combat pass queued) + SyntyPropBoneTool;
  four authored clips (sway/trudge/banks/lean) + Anim_Player_Underwater.blend + suit-kit FBX.
- Validation: 411/411 EditMode green; Play smokes (server==client facing, Aim-true projectile,
  bank/stride live-sampled, lamp beam verified); pre-code + post-impl adversarial reviews applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 13:56:02 -07:00
parent 385d0de08e
commit 7571091394
2637 changed files with 1810753 additions and 303 deletions
@@ -126,6 +126,471 @@ namespace ProjectM.EditorTools
AssetDatabase.Refresh();
}
/// <summary>LANTERN suit-attachment follow-up (DR-051): graft the Bathynaut kitbash attachments (brass
/// diving-bell dome + porthole, back tank pack, shoulder dive-lamp — exported rigid-skinned to
/// Head/Spine_03/Clavicle_L in Suit_Bathynaut_Kitbash.blend) onto Player.prefab IN PLACE (GUID preserved,
/// visual-only: ghost surface unchanged, same class of edit as the P5 body swap). The kit FBX's two SMRs
/// are re-bound onto the player's existing flattened skeleton by bone NAME. Brass rides the shared
/// M_Skinned_Palette (AnimatedLitShader + PaletteAtlas); glow rides M_EmissiveGloam_WarmSkinned
/// (steady warm = true light). Idempotent / re-runnable.</summary>
[MenuItem("ProjectM/Animation/Player - Attach Bathynaut Kit (LANTERN)")]
public static void AttachBathynautKit()
{
const string kitFbx = "Assets/_Project/Art/Models/SM_Suit_BathynautKit.fbx";
const string brassMat = "Assets/_Project/Art/Materials/M_Skinned_Palette.mat";
const string glowMat = "Assets/_Project/Art/Materials/M_EmissiveGloam_WarmSkinned.mat";
const string bodyMat = "Assets/_Project/Materials/M_SuitFrame_Bathynaut_Animated.mat";
const string barePrefab = "Assets/Synty/PolygonSciFiSpace/Prefabs/Characters/SM_Chr_SpaceSoldier_Male_01.prefab";
const string output = "Assets/_Project/Prefabs/Player.prefab";
var kit = AssetDatabase.LoadAssetAtPath<GameObject>(kitFbx);
var mBrass = AssetDatabase.LoadAssetAtPath<Material>(brassMat);
var mGlow = AssetDatabase.LoadAssetAtPath<Material>(glowMat);
var mBody = AssetDatabase.LoadAssetAtPath<Material>(bodyMat);
if (kit == null) { Debug.LogError($"[PlayerRigTools] Kit FBX missing: {kitFbx}"); return; }
if (mBrass == null) { Debug.LogError($"[PlayerRigTools] Brass material missing: {brassMat}"); return; }
if (mGlow == null) { Debug.LogError($"[PlayerRigTools] Glow material missing: {glowMat}"); return; }
var root = PrefabUtility.LoadPrefabContents(output);
try
{
// Bone map: every transform under the player root by name (the flattened Synty skeleton).
var bones = new System.Collections.Generic.Dictionary<string, Transform>();
foreach (var t in root.GetComponentsInChildren<Transform>(true))
if (!bones.ContainsKey(t.name)) bones.Add(t.name, t);
foreach (var src in kit.GetComponentsInChildren<SkinnedMeshRenderer>(true))
GraftSmr(root, bones, src, src.name.Contains("Glow") ? mGlow : mBrass);
// 07-15 operator fork: brass dome + BARE head (the kitbash look) — drop the Synty sci-fi helmet
// and graft the bare head SMR so the face reads through the amber porthole up close.
var helmet = root.transform.Find("SM_Chr_Attach_SpaceSoldier_Male_Helmet_01");
if (helmet != null) Object.DestroyImmediate(helmet.gameObject);
// Purge any wrong-variant head graft (the Synty container holds Female/Male heads) before re-grafting.
for (int i = root.transform.childCount - 1; i >= 0; i--)
{
var c = root.transform.GetChild(i);
if (c.name.StartsWith("SM_Chr_SpaceSoldier_Head_") && c.name != "SM_Chr_SpaceSoldier_Head_Male_01")
Object.DestroyImmediate(c.gameObject);
}
if (root.transform.Find("SM_Chr_SpaceSoldier_Head_Male_01") == null)
{
var bare = AssetDatabase.LoadAssetAtPath<GameObject>(barePrefab);
SkinnedMeshRenderer headSrc = null;
if (bare != null)
foreach (var s in bare.GetComponentsInChildren<SkinnedMeshRenderer>(true))
if (s.name == "SM_Chr_SpaceSoldier_Head_Male_01") { headSrc = s; break; }
if (headSrc != null && mBody != null) GraftSmr(root, bones, headSrc, mBody, rebase: false); // meter-scale Synty source + blend-skinned neck: native bindposes are correct
else Debug.LogWarning("[PlayerRigTools] Bare head SMR (or body material) not found — helmet removed without a head.");
}
PrefabUtility.SaveAsPrefabAsset(root, output);
}
finally
{
PrefabUtility.UnloadPrefabContents(root);
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("[PlayerRigTools] Bathynaut kit attached to Player.prefab (in place).");
}
/// <summary>Re-bind a source SkinnedMeshRenderer onto the player's existing flattened skeleton by bone
/// NAME and drop it in as a fresh child (idempotent: replaces a same-named child). Blender dedup
/// suffixes ("Finger_01.001" — the Synty rig duplicates L/R finger names) fall back to the base name;
/// safe because the kit meshes only WEIGHT Head/Spine_03/Clavicle_L.</summary>
static void GraftSmr(GameObject root, System.Collections.Generic.Dictionary<string, Transform> bones,
SkinnedMeshRenderer src, Material material, bool rebase = true)
{
// Resolve the bone rebind FIRST (07-16 review): destroying the old child before validation meant a
// bone-name mismatch on a re-run silently stripped the piece from the prefab.
var srcBones = src.bones;
var dst = new Transform[srcBones.Length];
for (int i = 0; i < srcBones.Length; i++)
{
string bn = srcBones[i] != null ? srcBones[i].name : null;
Transform t = null;
if (bn != null && !bones.TryGetValue(bn, out t))
{
// Blender dedup suffix ("Finger_01.001" — the Synty rig duplicates L/R finger names): strip
// and take the first base-name match. Safe: the kit only WEIGHTS Head/Spine_03/Clavicle_L.
int dot = bn.LastIndexOf('.');
if (dot > 0) bones.TryGetValue(bn.Substring(0, dot), out t);
}
if (t == null)
{
Debug.LogError($"[PlayerRigTools] Player skeleton missing bone '{bn ?? "<null>"}' for {src.name}; skipped (existing graft left untouched).");
return;
}
dst[i] = t;
}
Mesh mesh;
if (!rebase)
{
// Source skeleton already matches the player's conventions (meter-scale Synty prefab, e.g. the
// bare head): keep the native mesh + bindposes; only the bones array is remapped.
mesh = src.sharedMesh;
}
else
{
// REBASE (07-15/16): a Blender FBX roundtrip imports cm bones under a 0.01 armature (Synty
// prefabs are meter bones) so raw bindpose reuse explodes ×100 on rebind. Bake the SOURCE rest
// pose into world-space vertices and compute bindposes as the inverse of the RIGID
// (scale-stripped) rest matrices — self-consistent feet-at-0 rest space regardless of exporter
// scaling; the player's Root feet-offset applies at runtime exactly like the body meshes.
var srcMesh = src.sharedMesh;
var srcBind = srcMesh.bindposes;
var restWorld = new Matrix4x4[srcBones.Length];
var skinRest = new Matrix4x4[srcBones.Length];
for (int i = 0; i < srcBones.Length; i++)
{
restWorld[i] = srcBones[i].localToWorldMatrix;
skinRest[i] = restWorld[i] * srcBind[i];
}
var verts = srcMesh.vertices;
var norms = srcMesh.normals;
var weights = srcMesh.boneWeights;
var newVerts = new Vector3[verts.Length];
var newNorms = new Vector3[norms.Length];
for (int v = 0; v < verts.Length; v++)
{
// The rigid rebase is only valid for 100%-single-bone skins (07-16 review guard): a
// blend-skinned mesh (e.g. the bare head's neck weights) would distort — graft it rebase:false.
if (weights[v].weight0 < 0.999f)
{
Debug.LogError($"[PlayerRigTools] {src.name} vertex {v} is blend-skinned (w0={weights[v].weight0:0.###}); rigid rebase would distort — skipped (graft with rebase:false instead).");
return;
}
int b = weights[v].boneIndex0;
newVerts[v] = skinRest[b].MultiplyPoint3x4(verts[v]);
newNorms[v] = skinRest[b].rotation * norms[v];
}
var newBind = new Matrix4x4[srcBones.Length];
for (int i = 0; i < srcBones.Length; i++)
{
var m = restWorld[i];
newBind[i] = Matrix4x4.TRS((Vector3)m.GetColumn(3), m.rotation, Vector3.one).inverse;
}
// Persist the rebased mesh GUID-stably (Clear+refill keeps an existing asset's GUID on re-runs).
string meshPath = $"Assets/_Project/Art/Models/Rebased_{src.name}.asset";
mesh = AssetDatabase.LoadAssetAtPath<Mesh>(meshPath);
bool fresh = mesh == null;
if (fresh) mesh = new Mesh();
else mesh.Clear();
mesh.name = "Rebased_" + src.name;
mesh.vertices = newVerts;
mesh.normals = newNorms;
mesh.uv = srcMesh.uv;
mesh.subMeshCount = srcMesh.subMeshCount;
for (int s = 0; s < srcMesh.subMeshCount; s++) mesh.SetTriangles(srcMesh.GetTriangles(s), s);
mesh.boneWeights = weights;
mesh.bindposes = newBind;
mesh.RecalculateBounds();
mesh.RecalculateTangents(); // Rukhanka/BRG registration fails on a tangent-less mesh (BatchMeshID missing)
if (fresh) AssetDatabase.CreateAsset(mesh, meshPath);
else EditorUtility.SetDirty(mesh);
}
var old = root.transform.Find(src.name);
if (old != null) Object.DestroyImmediate(old.gameObject);
var go = new GameObject(src.name);
go.transform.SetParent(root.transform, false);
var smr = go.AddComponent<SkinnedMeshRenderer>();
smr.sharedMesh = mesh;
smr.bones = dst;
smr.rootBone = src.rootBone != null && bones.TryGetValue(src.rootBone.name, out var rb) ? rb : dst[0];
if (!rebase)
{
smr.localBounds = src.localBounds;
}
else
{
// Bounds in rootBone space: rebased (feet-at-0 world) verts through the RIGID root rest inverse.
var rootRest = src.rootBone != null ? src.rootBone.localToWorldMatrix : srcBones[0].localToWorldMatrix;
var rootRestInv = Matrix4x4.TRS((Vector3)rootRest.GetColumn(3), rootRest.rotation, Vector3.one).inverse;
var mv = mesh.vertices;
var bmin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
var bmax = new Vector3(float.MinValue, float.MinValue, float.MinValue);
for (int v = 0; v < mv.Length; v++)
{
var p = rootRestInv.MultiplyPoint3x4(mv[v]);
bmin = Vector3.Min(bmin, p); bmax = Vector3.Max(bmax, p);
}
var lb = new Bounds(); lb.SetMinMax(bmin, bmax);
smr.localBounds = lb;
}
smr.sharedMaterials = new[] { material };
Debug.Log($"[PlayerRigTools] Grafted {src.name} ({mesh.vertexCount}v, {dst.Length} bones{(rebase ? ", rebased" : "")}).");
}
/// <summary>07-15 underwater feel: heavier gait — slow the Locomotion blend-state playback and lengthen
/// the Idle⇄Locomotion transition blends (weightier starts/stops). The velocity-side drift (sharpness
/// 15→6) already slows the Speed/MoveX/MoveZ ramps; this makes the cycle itself read heavy. Uses the
/// AnimatorController API per the manage_animation gotcha. Idempotent / re-runnable.</summary>
[MenuItem("ProjectM/Animation/Player - Retime Locomotion Gait (LANTERN)")]
public static void RetimeLocomotionGait()
{
const float gaitSpeed = 0.85f;
const float blend = 0.25f;
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController);
if (ac == null) { Debug.LogError($"[PlayerRigTools] Controller missing: {PlayerController}"); return; }
var sm = ac.layers[0].stateMachine;
int retimed = 0, blends = 0;
foreach (var cs in sm.states)
{
if (cs.state.name == "Locomotion" && !Mathf.Approximately(cs.state.speed, gaitSpeed))
{
cs.state.speed = gaitSpeed;
retimed++;
}
foreach (var tr in cs.state.transitions)
{
if (tr.destinationState == null) continue;
bool idleLoco = (cs.state.name == "Idle" && tr.destinationState.name == "Locomotion")
|| (cs.state.name == "Locomotion" && tr.destinationState.name == "Idle");
if (idleLoco && !Mathf.Approximately(tr.duration, blend)) { tr.duration = blend; blends++; }
}
}
EditorUtility.SetDirty(ac);
AssetDatabase.SaveAssets();
Debug.Log($"[PlayerRigTools] Locomotion gait retimed (speed {gaitSpeed}: {retimed} state(s); idle⇄locomotion blends {blend}s: {blends} transition(s)).");
}
/// <summary>07-16 gap-list: configure the four Blender underwater-feel clips (A_Idle_Sway, A_Walk_Trudge,
/// A_Bank_L/R) — HUMANOID + CreateFromThisModel (CopyFromOther fails on Blender's Armature node), root
/// motion baked into pose (lock+keepOriginal ×3 — the CC owns transforms), loops; the bank poses import
/// as ADDITIVE (playback frames 8-12 held pose, additive reference = frame 1's base stance) for the
/// Posture layer. Idempotent / re-runnable.</summary>
[MenuItem("ProjectM/Animation/Player - 1 Import Underwater Clips (LANTERN)")]
public static void ImportUnderwaterClips()
{
var specs = new (string file, float first, float last, bool additive)[]
{
("A_Idle_Sway", 1f, 121f, false),
("A_Walk_Trudge", 1f, 33f, false), // v2: 32f long-stride cycle (foot-skate fix)
("A_Bank_L", 8f, 12f, true),
("A_Bank_R", 8f, 12f, true),
};
foreach (var s in specs)
{
string path = $"Assets/_Project/Animation/Authored/{s.file}.fbx";
var imp = AssetImporter.GetAtPath(path) as ModelImporter;
if (imp == null) { Debug.LogError($"[PlayerRigTools] Missing clip fbx: {path}"); continue; }
imp.animationType = ModelImporterAnimationType.Human;
imp.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel;
imp.importAnimation = true;
imp.SaveAndReimport(); // two-pass: takes only enumerate AFTER the rig import runs
var clips = imp.defaultClipAnimations;
if (clips.Length == 0) { Debug.LogError($"[PlayerRigTools] No takes in {path}"); continue; }
var c = clips[0];
c.name = s.file;
c.firstFrame = s.first;
c.lastFrame = s.last;
c.loopTime = true;
c.lockRootRotation = true; c.lockRootHeightY = true; c.lockRootPositionXZ = true;
c.keepOriginalOrientation = true; c.keepOriginalPositionY = true; c.keepOriginalPositionXZ = true;
if (s.additive)
{
c.hasAdditiveReferencePose = true;
c.additiveReferencePoseFrame = 1f; // the base stance key — the layer plays bank-minus-stance
}
imp.clipAnimations = new[] { c };
imp.SaveAndReimport();
Debug.Log($"[PlayerRigTools] Imported {s.file} (frames {s.first}-{s.last}{(s.additive ? ", additive" : "")}).");
}
// A_Lean_Fwd: ONE take -> TWO additive clips (the held lean + a zero-delta companion so the Lead
// layer's 1D Speed tree has a neutral end at 0).
{
string path = "Assets/_Project/Animation/Authored/A_Lean_Fwd.fbx";
var imp = AssetImporter.GetAtPath(path) as ModelImporter;
if (imp != null)
{
imp.animationType = ModelImporterAnimationType.Human;
imp.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel;
imp.importAnimation = true;
imp.SaveAndReimport();
var takes = imp.defaultClipAnimations;
if (takes.Length > 0)
{
ModelImporterClipAnimation Mk(string n, float f0, float f1)
{
var c = imp.defaultClipAnimations[0];
c.name = n; c.firstFrame = f0; c.lastFrame = f1; c.loopTime = true;
c.lockRootRotation = true; c.lockRootHeightY = true; c.lockRootPositionXZ = true;
c.keepOriginalOrientation = true; c.keepOriginalPositionY = true; c.keepOriginalPositionXZ = true;
c.hasAdditiveReferencePose = true; c.additiveReferencePoseFrame = 1f;
return c;
}
imp.clipAnimations = new[] { Mk("A_Lean_Fwd", 8f, 12f), Mk("A_Lean_Zero", 1f, 2f) };
imp.SaveAndReimport();
Debug.Log("[PlayerRigTools] Imported A_Lean_Fwd + A_Lean_Zero (additive pair).");
}
else Debug.LogError("[PlayerRigTools] No takes in A_Lean_Fwd.fbx");
}
}
}
static AnimationClip LoadAuthoredClip(string file)
{
foreach (var o in AssetDatabase.LoadAllAssetsAtPath($"Assets/_Project/Animation/Authored/{file}.fbx"))
if (o is AnimationClip clip && !clip.name.StartsWith("__preview")) return clip;
return null;
}
/// <summary>07-16 gap-list: wire the underwater clips into AC_PlayerTopDown — Idle state → A_Idle_Sway
/// (buoyant sway replaces the static idle); the Locomotion 2D tree's FORWARD motion → A_Walk_Trudge
/// (chest-lead heavy walk; strafe/back clips stay Synty — they mostly show mid-cast); plus an ADDITIVE
/// "Posture" layer with a 1D Bank tree (A_Bank_L @ -1 … A_Bank_R @ +1, symmetric so Bank=0 cancels to
/// neutral) driven by PlayerAnimationDriveSystem's turn-rate Bank param. Idempotent.</summary>
[MenuItem("ProjectM/Animation/Player - 2 Wire Underwater Clips (LANTERN)")]
public static void WireUnderwaterFeelClips()
{
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController);
if (ac == null) { Debug.LogError($"[PlayerRigTools] Controller missing: {PlayerController}"); return; }
var bankL = LoadAuthoredClip("A_Bank_L");
var bankR = LoadAuthoredClip("A_Bank_R");
if (bankL == null || bankR == null)
{ Debug.LogError("[PlayerRigTools] Underwater clips not found — run Import Underwater Clips first."); return; }
// 07-16d (operator): idles come from the AnimationIdles pack (professionally-authored base loop).
AnimationClip packIdle = null;
foreach (var o in AssetDatabase.LoadAllAssetsAtPath("Assets/Synty/AnimationIdles/Animations/Polygon/Masculine/Base/Stances/A_POLY_IDL_Base_Masc.fbx"))
if (o is AnimationClip pcl && !pcl.name.StartsWith("__preview")) packIdle = pcl;
if (packIdle == null) Debug.LogError("[PlayerRigTools] AnimationIdles base idle not found — Idle keeps its current motion.");
const string locoDir = "Assets/Synty/AnimationBaseLocomotion/Animations/Polygon/Masculine/Locomotion/";
AnimationClip Clip(string sub, string file)
{
foreach (var o in AssetDatabase.LoadAllAssetsAtPath(locoDir + sub + "/" + file + ".fbx"))
if (o is AnimationClip cl && !cl.name.StartsWith("__preview")) return cl;
Debug.LogError($"[PlayerRigTools] Locomotion clip missing: {sub}/{file}");
return null;
}
// 07-16e TWO-RING gait tree: MoveX/MoveZ magnitude = speed/MoveSpeed, so a WALK ring at the walk
// clip's natural fraction (1.46/4.2 MoveSpeed ≈ 0.35) and a RUN ring at full deflection blend
// walk→run as the underwater accel ramps velocity — "walk first, run when held". StrideScale
// fine-corrects residual cadence against the blended natural (PlayerAnimationDriveSystem).
const float walkRing = 0.35f; // = FeelConfig.TrudgeNaturalSpeed / Character_Default MoveSpeed (4.2)
var dirs = new (Vector2 dir, string walk, string run)[]
{
(new Vector2(0f, 1f), "A_Walk_FwdStrafeF_Masc", "A_Run_FwdStrafeF_Masc"),
(new Vector2(0.7f, 0.7f), "A_Walk_FwdStrafeFR_Masc", "A_Run_FwdStrafeFR_Masc"),
(new Vector2(1f, 0f), "A_Walk_FwdStrafeR_Masc", "A_Run_FwdStrafeR_Masc"),
(new Vector2(0.7f, -0.7f), "A_Walk_BckStrafeBR_Masc", "A_Run_FwdStrafeBR_Masc"),
(new Vector2(0f, -1f), "A_Walk_BckStrafeB_Masc", "A_Run_BckStrafeB_Masc"),
(new Vector2(-0.7f, -0.7f), "A_Walk_BckStrafeBL_Masc", "A_Run_BckStrafeBL_Masc"),
(new Vector2(-1f, 0f), "A_Walk_FwdStrafeL_Masc", "A_Run_FwdStrafeL_Masc"),
(new Vector2(-0.7f, 0.7f), "A_Walk_FwdStrafeFL_Masc", "A_Run_FwdStrafeFL_Masc"),
};
var sm = ac.layers[0].stateMachine;
foreach (var cs in sm.states)
{
if (cs.state.name == "Idle")
{
if (packIdle != null) cs.state.motion = packIdle;
}
else if (cs.state.name == "Locomotion" && cs.state.motion is BlendTree tree)
{
cs.state.speed = 1f;
cs.state.speedParameterActive = true;
cs.state.speedParameter = "StrideScale";
tree.useAutomaticThresholds = false;
tree.children = new UnityEditor.Animations.ChildMotion[0]; // full rebuild (idempotent)
if (packIdle != null) tree.AddChild(packIdle, Vector2.zero);
foreach (var d in dirs)
{
var w = Clip("Walk", d.walk);
var r = Clip("Run", d.run);
if (w != null) tree.AddChild(w, d.dir.normalized * walkRing);
if (r != null) tree.AddChild(r, d.dir);
}
Debug.Log($"[PlayerRigTools] Locomotion rebuilt: idle center + walk ring @{walkRing} + run ring @1.0 ({tree.children.Length} children).");
}
}
if (!AnimRigUtil.HasParam(ac, "Bank"))
ac.AddParameter("Bank", AnimatorControllerParameterType.Float);
if (!AnimRigUtil.HasParam(ac, "StrideScale"))
{
ac.AddParameter("StrideScale", AnimatorControllerParameterType.Float);
var ps = ac.parameters;
for (int i = 0; i < ps.Length; i++) if (ps[i].name == "StrideScale") ps[i].defaultFloat = 1f;
ac.parameters = ps;
}
// Additive Posture layer (Bank: symmetric poses cancel at 0).
int postureIdx = EnsureAdditiveLayer(ac, "Posture");
var psm = ac.layers[postureIdx].stateMachine;
AnimatorState bankState = null;
foreach (var cs in psm.states) if (cs.state.name == "Bank") bankState = cs.state;
if (bankState == null) bankState = psm.AddState("Bank");
psm.defaultState = bankState;
if (!(bankState.motion is BlendTree))
{
var bankTree = new BlendTree { name = "BankTree", blendType = BlendTreeType.Simple1D, blendParameter = "Bank", useAutomaticThresholds = false, hideFlags = HideFlags.HideInHierarchy };
AssetDatabase.AddObjectToAsset(bankTree, ac);
bankTree.AddChild(bankL, -1f);
bankTree.AddChild(bankR, 1f);
bankState.motion = bankTree;
}
bankState.writeDefaultValues = false;
// Additive Lead layer (underwater chest-lead scales with Speed).
var leanFwd = LoadAuthoredClip("A_Lean_Fwd");
AnimationClip leanZero = null;
foreach (var o in AssetDatabase.LoadAllAssetsAtPath("Assets/_Project/Animation/Authored/A_Lean_Fwd.fbx"))
if (o is AnimationClip cl && cl.name == "A_Lean_Zero") leanZero = cl;
if (leanFwd != null && leanZero != null)
{
int leadIdx = EnsureAdditiveLayer(ac, "Lead");
var lsm = ac.layers[leadIdx].stateMachine;
AnimatorState leadState = null;
foreach (var cs in lsm.states) if (cs.state.name == "Lean") leadState = cs.state;
if (leadState == null) leadState = lsm.AddState("Lean");
lsm.defaultState = leadState;
if (!(leadState.motion is BlendTree))
{
var leadTree = new BlendTree { name = "LeadTree", blendType = BlendTreeType.Simple1D, blendParameter = "Speed", useAutomaticThresholds = false, hideFlags = HideFlags.HideInHierarchy };
AssetDatabase.AddObjectToAsset(leadTree, ac);
leadTree.AddChild(leanZero, 0.1f);
leadTree.AddChild(leanFwd, 0.85f);
leadState.motion = leadTree;
}
leadState.writeDefaultValues = false;
}
else Debug.LogError("[PlayerRigTools] Lean clips missing — run Import Underwater Clips first.");
EditorUtility.SetDirty(ac);
AssetDatabase.SaveAssets();
Debug.Log("[PlayerRigTools] Underwater clips wired v5 (pack idle, walk+run rings + StrideScale, Bank + Lead additive layers).");
}
static int EnsureAdditiveLayer(AnimatorController ac, string name)
{
int idx = -1;
for (int i = 0; i < ac.layers.Length; i++) if (ac.layers[i].name == name) idx = i;
if (idx < 0)
{
ac.AddLayer(name);
idx = ac.layers.Length - 1;
}
var layers = ac.layers; // array copy — modify + assign back
layers[idx].blendingMode = UnityEditor.Animations.AnimatorLayerBlendingMode.Additive;
layers[idx].defaultWeight = 1f;
ac.layers = layers;
return idx;
}
static AnimationClip FindIdleClip(AnimatorController ac)
{