Files
Project-M/Assets/_Project/Scripts/Editor/PlayerRigTools.cs
T
kronic 7571091394 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>
2026-07-16 13:56:02 -07:00

609 lines
35 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
namespace ProjectM.EditorTools
{
/// <summary>
/// MC-4 — builds the player's MELEE SWING clip + adds the IsAttacking param + MeleeSwing state to AC_PlayerTopDown.
/// The swing clip is built FROM the full idle pose (every bone keyed) + a Root YAW twist on top. A clip that keys
/// ONLY the Root makes Rukhanka collapse every un-keyed bone (Hips/Spine/legs) to identity for the state's
/// duration -> the body sinks into the floor; writeDefaultValues does NOT prevent it (2026-06-11 fix). Basing on
/// idle leaves nothing un-keyed; the Root yaw is a vertical-axis (height-preserving) twist that reads as a
/// horizontal slash, paired with the CombatFeedbackSystem slash-arc VFX. PlayerAnimationDriveSystem drives
/// IsAttacking from MeleeCombo. Idempotent / re-runnable (menu: ProjectM/Animation).
/// </summary>
public static class PlayerRigTools
{
const string SwingClip = "Assets/_Project/Animation/PlayerMeleeSwing.anim";
const string PlayerController = "Assets/_Project/Animation/AC_PlayerTopDown.controller";
[MenuItem("ProjectM/Animation/Player - Build Melee Swing")]
public static void BuildPlayerMeleeSwing()
{
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController);
if (ac == null) { Debug.LogError($"[PlayerRigTools] Controller missing: {PlayerController}"); return; }
var idle = FindIdleClip(ac);
if (idle == null) { Debug.LogError("[PlayerRigTools] No Idle-state clip to base the swing on."); return; }
// Full idle pose (every bone keyed) + a Root yaw twist. See the class summary for why a Root-only clip sinks.
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(SwingClip);
if (clip == null) { clip = new AnimationClip { frameRate = 30f }; AssetDatabase.CreateAsset(clip, SwingClip); }
var yaw = new AnimationCurve(
new Keyframe(0f, 0f), new Keyframe(0.06f, -25f), new Keyframe(0.16f, 48f), new Keyframe(0.30f, 0f));
AnimRigUtil.BuildRootYawOverlayClip(clip, idle, yaw);
if (!AnimRigUtil.HasParam(ac, "IsAttacking"))
ac.AddParameter("IsAttacking", AnimatorControllerParameterType.Bool);
var sm = ac.layers[0].stateMachine;
var swing = FindState(sm, "MeleeSwing");
if (swing == null)
{
swing = sm.AddState("MeleeSwing");
swing.writeDefaultValues = false;
var toSwing = sm.AddAnyStateTransition(swing);
toSwing.hasExitTime = false;
toSwing.duration = 0.05f;
toSwing.canTransitionToSelf = false;
toSwing.AddCondition(AnimatorConditionMode.If, 0f, "IsAttacking");
var fromSwing = swing.AddExitTransition();
fromSwing.hasExitTime = false;
fromSwing.duration = 0.10f;
fromSwing.AddCondition(AnimatorConditionMode.IfNot, 0f, "IsAttacking");
}
swing.motion = clip;
EditorUtility.SetDirty(ac);
AssetDatabase.SaveAssets();
Debug.Log("[PlayerRigTools] AC_PlayerTopDown: IsAttacking + idle-based MeleeSwing built (no un-keyed-bone collapse).");
}
/// <summary>LANTERN P5 (realignment): swap the Bathynaut suit body onto Player.prefab IN PLACE (GUID
/// preserved — the subscene PlayerSpawner refs never break). Mirrors the proven EnemyRigTools.BuildOne
/// recipe: strip the old Synty-soldier skeleton + SMR children (every ROOT component — ghost/authoring/
/// CC/Animator/RigDefinitionAuthoring — stays), flatten the suit body's skeleton + SMRs under the player
/// root, keep the feet-on-ground Root offset, re-slot the deformation material, and adopt the clone's
/// avatar (same shared humanoid Characters avatar — AC_PlayerTopDown's muscle clips retarget). Both
/// frames share this visual until per-frame player models land. Idempotent / re-runnable.</summary>
[MenuItem("ProjectM/Animation/Player - Build Bathynaut Player (LANTERN)")]
public static void BuildBathynautPlayer()
{
const string synty = "Assets/Synty/PolygonSciFiSpace/Prefabs/Characters/SM_Chr_SpaceSoldier_HelmetArmor_Male_01.prefab";
const string matPath = "Assets/_Project/Materials/M_SuitFrame_Bathynaut_Animated.mat";
const string output = "Assets/_Project/Prefabs/Player.prefab";
const float rootY = -0.90f; // the player's existing feet-on-ground offset (entity origin = capsule center)
var syntyAsset = AssetDatabase.LoadAssetAtPath<GameObject>(synty);
var mat = AssetDatabase.LoadAssetAtPath<Material>(matPath);
if (syntyAsset == null) { Debug.LogError($"[PlayerRigTools] Suit body prefab missing: {synty}"); return; }
if (mat == null) { Debug.LogError($"[PlayerRigTools] Deformation material missing: {matPath}"); return; }
var root = PrefabUtility.LoadPrefabContents(output);
try
{
// Strip ONLY the old visual (skeleton + SMR children); root components stay untouched.
var kids = new System.Collections.Generic.List<Transform>();
foreach (Transform c in root.transform) kids.Add(c);
foreach (var c in kids) Object.DestroyImmediate(c.gameObject);
// Clone the suit body (unlinked) and flatten its skeleton + SMRs under the player root.
var clone = (GameObject)Object.Instantiate(syntyAsset);
var srcAnimator = clone.GetComponentInChildren<Animator>();
var avatar = srcAnimator != null ? srcAnimator.avatar : null;
var children = new System.Collections.Generic.List<Transform>();
foreach (Transform c in clone.transform) children.Add(c);
foreach (var c in children) c.SetParent(root.transform, false); // keep local transforms
Object.DestroyImmediate(clone);
// Feet-on-ground: offset the un-keyed Root bone.
var rootBone = root.transform.Find("Root");
if (rootBone != null) { var lp = rootBone.localPosition; lp.y = rootY; rootBone.localPosition = lp; }
else Debug.LogWarning("[PlayerRigTools] No 'Root' bone after the swap; feet offset skipped.");
// Deformation material on every SMR slot (else unskinned-static under Entities Graphics).
foreach (var smr in root.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
var mats = smr.sharedMaterials;
for (int i = 0; i < mats.Length; i++) mats[i] = mat;
smr.sharedMaterials = mats;
}
var anim = root.GetComponent<Animator>();
if (anim != null && avatar != null) anim.avatar = avatar; // controller (AC_PlayerTopDown) stays
PrefabUtility.SaveAsPrefabAsset(root, output);
Debug.Log("[PlayerRigTools] Bathynaut suit body swapped onto Player.prefab (in place).");
}
finally
{
PrefabUtility.UnloadPrefabContents(root);
}
AssetDatabase.SaveAssets();
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)
{
foreach (var c in ac.layers[0].stateMachine.states)
if (c.state.name == "Idle") return c.state.motion as AnimationClip;
return null;
}
static AnimatorState FindState(AnimatorStateMachine sm, string name)
{
foreach (var c in sm.states) if (c.state.name == name) return c.state;
return null;
}
}
}