Weapon grip anchor: gizmo-authored WYSIWYG grip + operator's hand-tuned grip baked

WeaponGripAnchor under Hand_R in Player.prefab carries the actual axe
mesh as an EditorOnly preview (baker skips it; play-verified single axe)
so grip manipulation is the real model live in the prefab stage;
AttachMeleeWeapon reads the anchor (consts = seed/fallback). Includes
the operator's first gizmo-tuned grip (rebaked mesh matches expected
radius exactly). PlayerRigTools also carries the heavy-lock clip
re-speeds (1.0/0.85/0.88) consumed by the next commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 00:13:48 -07:00
parent 2710d680b4
commit 7f032571df
5 changed files with 428 additions and 106 deletions
@@ -674,9 +674,9 @@ namespace ProjectM.EditorTools
// 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.25f), // hit @0.33s -> ~0.27s (07-19 retime: 26-tick window)
("Swing2", "Attack/LightCombo01/A_Attack_LightCombo01B_Sword", "A_Attack_LightCombo01B_Sword", 1.0f), // hit @0.17s natural speed
("Swing3", "Attack/LightCombo01/A_Attack_LightCombo01C_Sword", "A_Attack_LightCombo01C_Sword", 1.1f), // finisher: hit @0.33s, heaviest of the chain
("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
};
@@ -784,7 +784,22 @@ namespace ProjectM.EditorTools
// 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);
var grip = Matrix4x4.TRS(WeaponGripPos, Quaternion.Euler(WeaponGripEuler), Vector3.one * WeaponScale); // uniform scale: normals keep their directions
// 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;
@@ -838,8 +853,79 @@ namespace ProjectM.EditorTools
}
finally { PrefabUtility.UnloadPrefabContents(root); }
AssetDatabase.SaveAssets();
Debug.Log($"[PlayerRigTools] Melee weapon attached to Hand_R ({srcMesh.vertexCount}v, grip pos {WeaponGripPos} euler {WeaponGripEuler}).");
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.");
}
}
}