977b5c1f8e
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>
957 lines
58 KiB
C#
957 lines
58 KiB
C#
using UnityEditor;
|
||
using UnityEditor.Animations;
|
||
using UnityEngine;
|
||
|
||
namespace ProjectM.EditorTools
|
||
{
|
||
/// <summary>
|
||
/// MC-4 — builds the player's MELEE SWING clip + adds the IsAttacking param + MeleeSwing state to AC_PlayerTopDown.
|
||
/// The swing clip is built FROM the full idle pose (every bone keyed) + a Root YAW twist on top. A clip that keys
|
||
/// ONLY the Root makes Rukhanka collapse every un-keyed bone (Hips/Spine/legs) to identity for the state's
|
||
/// duration -> the body sinks into the floor; writeDefaultValues does NOT prevent it (2026-06-11 fix). Basing on
|
||
/// idle leaves nothing un-keyed; the Root yaw is a vertical-axis (height-preserving) twist that reads as a
|
||
/// horizontal slash, paired with the CombatFeedbackSystem slash-arc VFX. PlayerAnimationDriveSystem drives
|
||
/// IsAttacking from MeleeCombo. Idempotent / re-runnable (menu: ProjectM/Animation).
|
||
/// </summary>
|
||
public static class PlayerRigTools
|
||
{
|
||
const string SwingClip = "Assets/_Project/Animation/PlayerMeleeSwing.anim";
|
||
const string PlayerController = "Assets/_Project/Animation/AC_PlayerTopDown.controller";
|
||
|
||
[MenuItem("ProjectM/Animation/Player - Build Melee Swing")]
|
||
public static void BuildPlayerMeleeSwing()
|
||
{
|
||
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController);
|
||
if (ac == null) { Debug.LogError($"[PlayerRigTools] Controller missing: {PlayerController}"); return; }
|
||
|
||
var idle = FindIdleClip(ac);
|
||
if (idle == null) { Debug.LogError("[PlayerRigTools] No Idle-state clip to base the swing on."); return; }
|
||
|
||
// Full idle pose (every bone keyed) + a Root yaw twist. See the class summary for why a Root-only clip sinks.
|
||
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(SwingClip);
|
||
if (clip == null) { clip = new AnimationClip { frameRate = 30f }; AssetDatabase.CreateAsset(clip, SwingClip); }
|
||
var yaw = new AnimationCurve(
|
||
new Keyframe(0f, 0f), new Keyframe(0.06f, -25f), new Keyframe(0.16f, 48f), new Keyframe(0.30f, 0f));
|
||
AnimRigUtil.BuildRootYawOverlayClip(clip, idle, yaw);
|
||
|
||
if (!AnimRigUtil.HasParam(ac, "IsAttacking"))
|
||
ac.AddParameter("IsAttacking", AnimatorControllerParameterType.Bool);
|
||
|
||
var sm = ac.layers[0].stateMachine;
|
||
var swing = FindState(sm, "MeleeSwing");
|
||
if (swing == null)
|
||
{
|
||
swing = sm.AddState("MeleeSwing");
|
||
swing.writeDefaultValues = false;
|
||
|
||
var toSwing = sm.AddAnyStateTransition(swing);
|
||
toSwing.hasExitTime = false;
|
||
toSwing.duration = 0.05f;
|
||
toSwing.canTransitionToSelf = false;
|
||
toSwing.AddCondition(AnimatorConditionMode.If, 0f, "IsAttacking");
|
||
|
||
var fromSwing = swing.AddExitTransition();
|
||
fromSwing.hasExitTime = false;
|
||
fromSwing.duration = 0.10f;
|
||
fromSwing.AddCondition(AnimatorConditionMode.IfNot, 0f, "IsAttacking");
|
||
}
|
||
swing.motion = clip;
|
||
|
||
EditorUtility.SetDirty(ac);
|
||
AssetDatabase.SaveAssets();
|
||
Debug.Log("[PlayerRigTools] AC_PlayerTopDown: IsAttacking + idle-based MeleeSwing built (no un-keyed-bone collapse).");
|
||
}
|
||
|
||
/// <summary>LANTERN P5 (realignment): swap the Bathynaut suit body onto Player.prefab IN PLACE (GUID
|
||
/// preserved — the subscene PlayerSpawner refs never break). Mirrors the proven EnemyRigTools.BuildOne
|
||
/// recipe: strip the old Synty-soldier skeleton + SMR children (every ROOT component — ghost/authoring/
|
||
/// CC/Animator/RigDefinitionAuthoring — stays), flatten the suit body's skeleton + SMRs under the player
|
||
/// root, keep the feet-on-ground Root offset, re-slot the deformation material, and adopt the clone's
|
||
/// avatar (same shared humanoid Characters avatar — AC_PlayerTopDown's muscle clips retarget). Both
|
||
/// frames share this visual until per-frame player models land. Idempotent / re-runnable.</summary>
|
||
[MenuItem("ProjectM/Animation/Player - Build Bathynaut Player (LANTERN)")]
|
||
public static void BuildBathynautPlayer()
|
||
{
|
||
const string synty = "Assets/Synty/PolygonSciFiSpace/Prefabs/Characters/SM_Chr_SpaceSoldier_HelmetArmor_Male_01.prefab";
|
||
const string matPath = "Assets/_Project/Materials/M_SuitFrame_Bathynaut_Animated.mat";
|
||
const string 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.");
|
||
}
|
||
|
||
|
||
}
|
||
}
|