7571091394
- 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>
609 lines
35 KiB
C#
609 lines
35 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|