Files
Project-M/Assets/_Project/Scripts/Editor/PlayerRigTools.cs
T
kronic 977b5c1f8e Art: silhouette broadening in-game + PlayerRigTools material-drift fix
Silhouette: broadened the armour's shoulder pads (inflate Shoulder_L/R-weighted pad caps about the
shoulder joint, smooth falloff, arms untouched) + beefed the boots -> Silhouette_Armour.asset (built
idempotently from the stock Synty armour). Bind pose is a T-pose, so broadening is by BONE WEIGHT, not
raw X (a naive |X| threshold would grab the outstretched arms).

Drift fix: baked the correct assignments into the tools so a re-run no longer reverts them -
BuildBathynautPlayer now does the brass/undersuit split + swaps the armour to Silhouette_Armour;
AttachBathynautKit assigns gunmetal tanks / warm beacon / Mark-V brass-gunmetal-glass (3 submeshes).
Full rebuild chain re-run clean + verified live: identical correct diver, reproducible.
(replace_method stripped the [MenuItem] attrs -> re-added; see the swallow-adjacent-line gotcha.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:27:29 -07:00

957 lines
58 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 brassPath = "Assets/_Project/Art/Materials/M_Diver_Brass_Skinned.mat";
const string underPath = "Assets/_Project/Art/Materials/M_Diver_Undersuit_Skinned.mat";
const string silhouettePath = "Assets/_Project/Art/Models/Silhouette_Armour.asset";
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);
var brass = AssetDatabase.LoadAssetAtPath<Material>(brassPath);
var under = AssetDatabase.LoadAssetAtPath<Material>(underPath);
var silhouette = AssetDatabase.LoadAssetAtPath<Mesh>(silhouettePath); // optional: broadened shoulders/boots
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.");
// Diver material split: brass armour, dark-teal undersuit body, suit default elsewhere. The
// armour ALSO swaps to the broadened Silhouette mesh (stronger shoulders/boots) when present.
// Color MUST ride _BaseColorMap on these (the hit-flash per-instance _BaseColor override is
// white and replaces the material _BaseColor) - see the DiverTex solid-colour textures.
foreach (var smr in root.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
Material use = mat;
if (smr.name.Contains("Armour") && brass != null) { use = brass; if (silhouette != null) smr.sharedMesh = silhouette; }
else if (smr.name == "SM_Chr_SpaceSoldier_Male_01" && under != null) use = under;
var mats = smr.sharedMaterials;
for (int i = 0; i < mats.Length; i++) mats[i] = use;
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 body swapped onto Player.prefab (brass/undersuit split + silhouette armour).");
}
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 gunMat = "Assets/_Project/Art/Materials/M_Diver_Metal_Skinned.mat";
const string brassMat = "Assets/_Project/Art/Materials/M_Diver_Brass_Skinned.mat";
const string glassMat = "Assets/_Project/Art/Materials/M_Diver_PortholeGlow_Skinned.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 mGun = AssetDatabase.LoadAssetAtPath<Material>(gunMat);
var mBrass = AssetDatabase.LoadAssetAtPath<Material>(brassMat);
var mGlass = AssetDatabase.LoadAssetAtPath<Material>(glassMat);
var mGlow = AssetDatabase.LoadAssetAtPath<Material>(glowMat);
var mBody = AssetDatabase.LoadAssetAtPath<Material>(bodyMat);
if (kit == null) { Debug.LogError($"[PlayerRigTools] Kit FBX missing: {kitFbx}"); return; }
if (mGun == null || mBrass == null || mGlow == null) { Debug.LogError("[PlayerRigTools] Diver kit materials missing."); 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))
{
bool isHelmet = src.name.Contains("Helmet") || src.name.Contains("MarkV");
bool isGlow = src.name.Contains("Glow") || src.name.Contains("Beacon");
// graft with a base material, then the Mark-V gets its 3 submesh materials applied.
GraftSmr(root, bones, src, isHelmet ? mBrass : (isGlow ? mGlow : mGun));
if (isHelmet)
{
var hc = root.transform.Find(src.name);
var hs = hc != null ? hc.GetComponent<SkinnedMeshRenderer>() : null;
// submesh order (from the .blend slots): 0 brass bonnet / 1 gunmetal accents / 2 teal glass
if (hs != null) hs.sharedMaterials = new[] { mBrass, mGun, mGlass != null ? mGlass : mGlow };
}
}
// Drop the Synty sci-fi helmet + graft the bare head (hidden inside the Mark-V; keeps the neck seam).
var helmet = root.transform.Find("SM_Chr_Attach_SpaceSoldier_Male_Helmet_01");
if (helmet != null) Object.DestroyImmediate(helmet.gameObject);
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 (gunmetal tanks + warm beacon + Mark-V brass/gunmetal/glass).");
}
/// <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;
}
/// <summary>07-18 melee-anim pass: swap the attack states onto AnimationSwordCombat pack clips
/// (LightCombo01 A/B/C -> Swing1/2/3, HeavyCombo01A -> SpecialSlam, Death_B_01 -> Death) and add the
/// Menacing01 combat idle (CombatIdle state + InCombat param; PlayerAnimationDriveSystem holds it for
/// FeelConfig.CombatIdleHoldSec after a swing). State speeds land each clip's HIT take inside the
/// 13-tick swing-anim window (CastFacingTicks ~0.22s + 0.12s exit blend). Idempotent / re-runnable.</summary>
[MenuItem("ProjectM/Animation/Player - Wire Sword Combat Attacks (LANTERN)")]
public static void WireSwordCombatAttackClips()
{
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController);
if (ac == null) { Debug.LogError($"[PlayerRigTools] Controller missing: {PlayerController}"); return; }
const string packDir = "Assets/Synty/AnimationSwordCombat/Animations/Polygon/";
AnimationClip PackClip(string fbxSub, string clipName)
{
foreach (var o in AssetDatabase.LoadAllAssetsAtPath(packDir + fbxSub + ".fbx"))
if (o is AnimationClip c && c.name == clipName) return c;
Debug.LogError($"[PlayerRigTools] SwordCombat clip missing: {fbxSub} -> {clipName}");
return null;
}
// Menacing01: the pack take ships loopTime=false -- flip it on the pack importer once (idempotent).
const string menacingFbx = "Idle/Menacing01/A_Idle_Menacing01_Sword";
var menacing = PackClip(menacingFbx, "A_Idle_Menacing01_Sword");
if (menacing != null && !menacing.isLooping)
{
var imp = AssetImporter.GetAtPath(packDir + menacingFbx + ".fbx") as ModelImporter;
var takes = (imp.clipAnimations != null && imp.clipAnimations.Length > 0)
? imp.clipAnimations : imp.defaultClipAnimations;
foreach (var t in takes) t.loopTime = true;
imp.clipAnimations = takes;
imp.SaveAndReimport();
menacing = PackClip(menacingFbx, "A_Idle_Menacing01_Sword");
}
// 07-19 retime: a custom SLAM take on the HeavyCombo01A fbx -- the stock windup is ~1s of the 2s clip,
// so the take starts ~22% in (leaves ~0.5s clip-time of windup before the hit; at speed 1.5 the strike
// lands ~0.35s into the 26-tick window) instead of comic 5x compression. Idempotent by take name.
{
string fbx = packDir + "Attack/HeavyCombo01/A_Attack_HeavyCombo01A_Sword.fbx";
var imp = AssetImporter.GetAtPath(fbx) as ModelImporter;
var takes = (imp.clipAnimations != null && imp.clipAnimations.Length > 0) ? imp.clipAnimations : imp.defaultClipAnimations;
bool hasSlam = false;
ModelImporterClipAnimation full = null;
foreach (var tk in takes)
{
if (tk.name == "A_Attack_HeavyCombo01A_Slam_Sword") hasSlam = true;
if (tk.name == "A_Attack_HeavyCombo01A_Sword") full = tk;
}
if (!hasSlam && full != null)
{
var slam = imp.defaultClipAnimations[0]; // fresh instance (never mutate an existing take entry)
slam.name = "A_Attack_HeavyCombo01A_Slam_Sword";
slam.firstFrame = Mathf.Lerp(full.firstFrame, full.lastFrame, 0.22f);
slam.lastFrame = full.lastFrame;
slam.loopTime = false;
var list = new System.Collections.Generic.List<ModelImporterClipAnimation>(takes) { slam };
imp.clipAnimations = list.ToArray();
imp.SaveAndReimport();
}
else if (full == null) Debug.LogError("[PlayerRigTools] HeavyCombo01A full take not found -- slam take skipped.");
}
var sm = ac.layers[0].stateMachine;
// Attack/death states: full pack clip + a state speed that lands the HIT take inside the swing
// window. Hit-start seconds measured off the pack takes (= the WindUp take length).
var swaps = new (string state, string fbxSub, string clip, float speed)[]
{
("Swing1", "Attack/LightCombo01/A_Attack_LightCombo01A_Sword", "A_Attack_LightCombo01A_Sword", 1.0f), // 07-21 heavy lock: TRUE contact 0.333s = 20t (matches MeleeContactTicks 20)
("Swing2", "Attack/LightCombo01/A_Attack_LightCombo01B_Sword", "A_Attack_LightCombo01B_Sword", 0.85f), // TRUE contact 0.196s = 12t (knob step2 = round(20*0.625) = 12)
("Swing3", "Attack/LightCombo01/A_Attack_LightCombo01C_Sword", "A_Attack_LightCombo01C_Sword", 0.88f), // finisher: TRUE contact 0.417s = 25t (knob step3 = 25; 1 tick under CastFacingTicks)
("SpecialSlam", "Attack/HeavyCombo01/A_Attack_HeavyCombo01A_Sword", "A_Attack_HeavyCombo01A_Slam_Sword", 1.5f), // custom take (skips ~2/3 of the windup); hit ~0.35s
("Death", "Death/A_Death_B_01_Sword", "A_Death_B_01_Sword", 1.0f), // backward collapse, holds last frame
};
foreach (var s in swaps)
{
var st = FindState(sm, s.state);
var clip = PackClip(s.fbxSub, s.clip);
if (st == null || clip == null) { Debug.LogError($"[PlayerRigTools] Skipped {s.state} (state or clip missing)."); continue; }
st.motion = clip;
st.speed = s.speed;
}
// 07-19 retime: soften the swing entries (0.03 read as a snap-cut) + exits, matching the heavy cadence.
foreach (var tr in sm.anyStateTransitions)
{
if (tr.destinationState == null) continue;
string dn = tr.destinationState.name;
if (dn == "Swing1" || dn == "Swing2" || dn == "Swing3") tr.duration = 0.06f;
else if (dn == "SpecialSlam") tr.duration = 0.07f;
}
foreach (var sn in new[] { "Swing1", "Swing2", "Swing3", "SpecialSlam" })
{
var st2 = FindState(sm, sn);
if (st2 == null) continue;
foreach (var tr in st2.transitions) if (tr.isExit) tr.duration = 0.15f;
}
// InCombat param + the CombatIdle (Menacing01) state. Idle -> CombatIdle is appended AFTER the
// existing Idle -> Locomotion transition, so movement keeps priority while InCombat holds.
bool hasInCombat = false;
foreach (var p in ac.parameters) if (p.name == "InCombat") hasInCombat = true;
if (!hasInCombat) ac.AddParameter("InCombat", AnimatorControllerParameterType.Bool);
var idle = FindState(sm, "Idle");
var loco = FindState(sm, "Locomotion");
var combatIdle = FindState(sm, "CombatIdle");
if (combatIdle == null && menacing != null && idle != null && loco != null)
{
combatIdle = sm.AddState("CombatIdle");
var toCombat = idle.AddTransition(combatIdle);
toCombat.hasExitTime = false; toCombat.duration = 0.25f;
toCombat.AddCondition(AnimatorConditionMode.If, 0f, "InCombat");
var toIdle = combatIdle.AddTransition(idle);
toIdle.hasExitTime = false; toIdle.duration = 0.4f;
toIdle.AddCondition(AnimatorConditionMode.IfNot, 0f, "InCombat");
var toLoco = combatIdle.AddTransition(loco);
toLoco.hasExitTime = false; toLoco.duration = 0.25f;
toLoco.AddCondition(AnimatorConditionMode.Greater, 0.1f, "Speed");
}
if (combatIdle != null && menacing != null) { combatIdle.motion = menacing; combatIdle.speed = 1f; }
EditorUtility.SetDirty(ac);
AssetDatabase.SaveAssets();
Debug.Log("[PlayerRigTools] SwordCombat wired: Swing1/2/3 = LightCombo01 A/B/C, SpecialSlam = HeavyCombo01A, Death = Death_B_01, CombatIdle = Menacing01.");
}
// 07-19 grip offset (Hand_R bone-local). Iterate these + re-run Attach Melee Weapon if the grip reads wrong.
static readonly Vector3 WeaponGripPos = new Vector3(0f, 0.06f, 0.02f);
static readonly Vector3 WeaponGripEuler = new Vector3(0f, 0f, -90f);
const float WeaponScale = 1.25f; // 07-20 G2.3 (reach honesty): the Synty-proportion exaggeration that sells the 2.2m cone
/// <summary>07-19 melee feel: rigid-skin a bulky Synty salvage axe into the RIGHT hand (100% Hand_R;
/// GraftSmr's rebase conventions: world-rest verts + rigid inverse bindpose; RecalculateTangents or
/// Rukhanka/BRG registration fails and the whole rig vanishes). An SMR so Rukhanka + Entities-Graphics
/// deform it with the rig like the suit kit. Idempotent / re-runnable.</summary>
[MenuItem("ProjectM/Animation/Player - Attach Melee Weapon (LANTERN)")]
public static void AttachMeleeWeapon()
{
const string weaponFbx = "Assets/Synty/PolygonDungeonRealms/Models/SM_Wep_Axe_Large_01.fbx";
const string matPath = "Assets/_Project/Materials/M_Weapon_Animated.mat";
const string bodyMat = "Assets/_Project/Materials/M_SuitFrame_Bathynaut_Animated.mat";
const string output = "Assets/_Project/Prefabs/Player.prefab";
const string childName = "SM_Wep_Melee";
const string meshPath = "Assets/_Project/Art/Models/Rebased_SM_Wep_Melee.asset";
var src = AssetDatabase.LoadAssetAtPath<GameObject>(weaponFbx);
if (src == null) { Debug.LogError($"[PlayerRigTools] Weapon fbx missing: {weaponFbx}"); return; }
var srcMf = src.GetComponentInChildren<MeshFilter>(true);
if (srcMf == null || srcMf.sharedMesh == null) { Debug.LogError("[PlayerRigTools] Weapon mesh missing."); return; }
var srcMesh = srcMf.sharedMesh;
// Deformation-aware material: clone the suit's AnimatedLit material, swap the atlas to the weapon pack's.
var mat = AssetDatabase.LoadAssetAtPath<Material>(matPath);
if (mat == null)
{
var body = AssetDatabase.LoadAssetAtPath<Material>(bodyMat);
if (body == null) { Debug.LogError($"[PlayerRigTools] Body material missing: {bodyMat}"); return; }
mat = new Material(body) { name = "M_Weapon_Animated" };
// EXPLICIT pack atlas (07-19 fix): the FBX's importer material carries NO texture, so a mainTexture
// fallback silently keeps the cloned suit atlas (caught by the material-VALUES check, not the render).
var atlas = AssetDatabase.LoadAssetAtPath<Texture>("Assets/Synty/PolygonDungeonRealms/Textures/Dungeons_2_Texture_01_A.png");
if (atlas == null) { var srcMr = srcMf.GetComponent<MeshRenderer>(); atlas = srcMr != null && srcMr.sharedMaterial != null ? srcMr.sharedMaterial.mainTexture : null; }
if (atlas != null && mat.HasProperty("_BaseColorMap")) mat.SetTexture("_BaseColorMap", atlas);
else Debug.LogWarning("[PlayerRigTools] Weapon atlas not resolved -- material keeps the cloned suit atlas.");
AssetDatabase.CreateAsset(mat, matPath);
}
var root = PrefabUtility.LoadPrefabContents(output);
try
{
Transform hand = null;
foreach (var t in root.GetComponentsInChildren<Transform>(true)) if (t.name == "Hand_R") { hand = t; break; }
if (hand == null) { Debug.LogError("[PlayerRigTools] Hand_R not found on Player.prefab."); return; }
// Rigid rest matrices (GraftSmr conventions: scale-stripped rest, feet-at-0 prefab space).
var hm = hand.localToWorldMatrix;
var handRigid = Matrix4x4.TRS((Vector3)hm.GetColumn(3), hm.rotation, Vector3.one);
// 07-20 designer flow: the WeaponGripAnchor child of Hand_R (EditorOnly, carries a live preview of
// the weapon mesh) is the AUTHORING source when present -- move/rotate/scale it with normal gizmos
// in the prefab stage, then re-run this tool to bake. The consts are the fallback/seed only.
Vector3 gripPos = WeaponGripPos;
Quaternion gripRot = Quaternion.Euler(WeaponGripEuler);
float gripScale = WeaponScale;
var gripAnchor = hand.Find("WeaponGripAnchor");
if (gripAnchor != null)
{
gripPos = gripAnchor.localPosition;
gripRot = gripAnchor.localRotation;
gripScale = gripAnchor.localScale.x; // uniform: gizmo-scaled non-uniformly -> x wins
if (Mathf.Abs(gripAnchor.localScale.y - gripScale) > 1e-3f || Mathf.Abs(gripAnchor.localScale.z - gripScale) > 1e-3f)
Debug.LogWarning("[PlayerRigTools] WeaponGripAnchor scale is non-uniform; using X for the rigid bake.");
}
var grip = Matrix4x4.TRS(gripPos, gripRot, Vector3.one * gripScale); // uniform scale: normals keep their directions
var toWorld = handRigid * grip;
var verts = srcMesh.vertices;
var norms = srcMesh.normals;
var newVerts = new Vector3[verts.Length];
var newNorms = new Vector3[norms.Length];
for (int v = 0; v < verts.Length; v++)
{
newVerts[v] = toWorld.MultiplyPoint3x4(verts[v]);
newNorms[v] = toWorld.rotation * norms[v];
}
var weights = new BoneWeight[verts.Length];
for (int v = 0; v < verts.Length; v++) weights[v] = new BoneWeight { boneIndex0 = 0, weight0 = 1f };
var mesh = AssetDatabase.LoadAssetAtPath<Mesh>(meshPath);
bool fresh = mesh == null;
if (fresh) mesh = new Mesh(); else mesh.Clear();
mesh.name = "Rebased_" + childName;
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 = new[] { handRigid.inverse };
mesh.RecalculateBounds();
mesh.RecalculateTangents(); // Rukhanka/BRG registration fails on a tangent-less mesh
if (fresh) AssetDatabase.CreateAsset(mesh, meshPath); else EditorUtility.SetDirty(mesh);
var old = root.transform.Find(childName);
if (old != null) Object.DestroyImmediate(old.gameObject);
var go = new GameObject(childName);
go.transform.SetParent(root.transform, false);
var smr = go.AddComponent<SkinnedMeshRenderer>();
smr.sharedMesh = mesh;
smr.bones = new[] { hand };
smr.rootBone = hand;
// Bounds in rootBone (hand) space = the grip-transformed source verts.
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 < verts.Length; v++)
{
var p = grip.MultiplyPoint3x4(verts[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[] { mat };
PrefabUtility.SaveAsPrefabAsset(root, output);
}
finally { PrefabUtility.UnloadPrefabContents(root); }
AssetDatabase.SaveAssets();
Debug.Log($"[PlayerRigTools] Melee weapon attached to Hand_R ({srcMesh.vertexCount}v). Grip source: WeaponGripAnchor if present (gizmo-authored), else the consts. Re-run after moving the anchor.");
}
/// <summary>07-20 designer grip flow: create (or refresh the preview on) the WeaponGripAnchor under
/// Hand_R in Player.prefab. The anchor CARRIES the actual weapon mesh as an edit-time preview (plain
/// MeshRenderer + a URP/Lit material with the pack atlas), so what you grab with the move/rotate/scale
/// gizmos in the prefab stage IS the axe at true size, live. Tagged <b>EditorOnly</b> -- the Entities
/// baker skips it, so it can never leak an un-animated duplicate into the game; the baked rigid-skinned
/// SMR stays the only runtime weapon. Until you re-run Attach Melee Weapon you'll see BOTH (preview at
/// the new grip, baked axe at the old) -- a free before/after ghost; when they coincide, you're baked.
/// Seeds from the consts ONLY on first creation; re-runs never stomp a hand-tuned anchor.</summary>
[MenuItem("ProjectM/Animation/Player - Create Weapon Grip Anchor (LANTERN)")]
public static void CreateWeaponGripAnchor()
{
const string weaponFbx = "Assets/Synty/PolygonDungeonRealms/Models/SM_Wep_Axe_Large_01.fbx";
const string atlasPath = "Assets/Synty/PolygonDungeonRealms/Textures/Dungeons_2_Texture_01_A.png";
const string previewMatPath = "Assets/_Project/Materials/M_Weapon_Preview.mat";
const string output = "Assets/_Project/Prefabs/Player.prefab";
var src = AssetDatabase.LoadAssetAtPath<GameObject>(weaponFbx);
var srcMf = src != null ? src.GetComponentInChildren<MeshFilter>(true) : null;
if (srcMf == null || srcMf.sharedMesh == null) { Debug.LogError($"[PlayerRigTools] Weapon fbx/mesh missing: {weaponFbx}"); return; }
// Plain URP/Lit preview material (the runtime deformation shader won't render on a static MeshRenderer).
var mat = AssetDatabase.LoadAssetAtPath<Material>(previewMatPath);
if (mat == null)
{
var lit = Shader.Find("Universal Render Pipeline/Lit");
if (lit == null) { Debug.LogError("[PlayerRigTools] URP/Lit shader not found."); return; }
mat = new Material(lit) { name = "M_Weapon_Preview" };
var atlas = AssetDatabase.LoadAssetAtPath<Texture>(atlasPath);
if (atlas != null && mat.HasProperty("_BaseMap")) mat.SetTexture("_BaseMap", atlas);
AssetDatabase.CreateAsset(mat, previewMatPath);
}
var root = PrefabUtility.LoadPrefabContents(output);
try
{
Transform hand = null;
foreach (var t in root.GetComponentsInChildren<Transform>(true)) if (t.name == "Hand_R") { hand = t; break; }
if (hand == null) { Debug.LogError("[PlayerRigTools] Hand_R not found on Player.prefab."); return; }
var anchor = hand.Find("WeaponGripAnchor");
bool fresh = anchor == null;
if (fresh)
{
var go = new GameObject("WeaponGripAnchor");
anchor = go.transform;
anchor.SetParent(hand, false);
// Seed exactly where the current bake sits, so the preview and the baked axe start coincident.
anchor.localPosition = WeaponGripPos;
anchor.localRotation = Quaternion.Euler(WeaponGripEuler);
anchor.localScale = Vector3.one * WeaponScale;
}
anchor.gameObject.tag = "EditorOnly"; // the Entities baker SKIPS EditorOnly -- preview never reaches runtime
var mf = anchor.GetComponent<MeshFilter>();
if (mf == null) mf = anchor.gameObject.AddComponent<MeshFilter>();
mf.sharedMesh = srcMf.sharedMesh;
var mr = anchor.GetComponent<MeshRenderer>();
if (mr == null) mr = anchor.gameObject.AddComponent<MeshRenderer>();
mr.sharedMaterial = mat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
PrefabUtility.SaveAsPrefabAsset(root, output);
}
finally { PrefabUtility.UnloadPrefabContents(root); }
AssetDatabase.SaveAssets();
Debug.Log("[PlayerRigTools] WeaponGripAnchor ready under Hand_R (EditorOnly, live axe preview). "
+ "Open Player.prefab, search 'WeaponGripAnchor', move/rotate/scale it with normal gizmos, then run "
+ "'Player - Attach Melee Weapon' to bake. Preview + baked axe overlap = you're in sync.");
}
}
}