using UnityEditor; using UnityEditor.Animations; using UnityEngine; namespace ProjectM.EditorTools { /// /// 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). /// 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(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(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)."); } /// 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. [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(synty); var mat = AssetDatabase.LoadAssetAtPath(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(); 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(); var avatar = srcAnimator != null ? srcAnimator.avatar : null; var children = new System.Collections.Generic.List(); 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(true)) { var mats = smr.sharedMaterials; for (int i = 0; i < mats.Length; i++) mats[i] = mat; smr.sharedMaterials = mats; } var anim = root.GetComponent(); 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(); } /// 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. [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(kitFbx); var mBrass = AssetDatabase.LoadAssetAtPath(brassMat); var mGlow = AssetDatabase.LoadAssetAtPath(glowMat); var mBody = AssetDatabase.LoadAssetAtPath(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(); foreach (var t in root.GetComponentsInChildren(true)) if (!bones.ContainsKey(t.name)) bones.Add(t.name, t); foreach (var src in kit.GetComponentsInChildren(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(barePrefab); SkinnedMeshRenderer headSrc = null; if (bare != null) foreach (var s in bare.GetComponentsInChildren(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)."); } /// 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. static void GraftSmr(GameObject root, System.Collections.Generic.Dictionary 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 ?? ""}' 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(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(); 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" : "")})."); } /// 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. [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(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))."); } /// 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. [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; } /// 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. [MenuItem("ProjectM/Animation/Player - 2 Wire Underwater Clips (LANTERN)")] public static void WireUnderwaterFeelClips() { var ac = AssetDatabase.LoadAssetAtPath(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; } /// 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. [MenuItem("ProjectM/Animation/Player - Wire Sword Combat Attacks (LANTERN)")] public static void WireSwordCombatAttackClips() { var ac = AssetDatabase.LoadAssetAtPath(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(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 /// 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. [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(weaponFbx); if (src == null) { Debug.LogError($"[PlayerRigTools] Weapon fbx missing: {weaponFbx}"); return; } var srcMf = src.GetComponentInChildren(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(matPath); if (mat == null) { var body = AssetDatabase.LoadAssetAtPath(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("Assets/Synty/PolygonDungeonRealms/Textures/Dungeons_2_Texture_01_A.png"); if (atlas == null) { var srcMr = srcMf.GetComponent(); 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(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(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(); 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."); } /// 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 EditorOnly -- 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. [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(weaponFbx); var srcMf = src != null ? src.GetComponentInChildren(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(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(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(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(); if (mf == null) mf = anchor.gameObject.AddComponent(); mf.sharedMesh = srcMf.sharedMesh; var mr = anchor.GetComponent(); if (mr == null) mr = anchor.gameObject.AddComponent(); 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."); } } }