Animate enemies: client-derived Rukhanka rigs (Werewolf/Kaiju Husks)

Extends the DR-022 player pipeline to Husk enemies. A Husk is an ownerless
interpolated ghost = structurally a remote player, so the new client-only
EnemyAnimationDriveSystem mirrors PlayerAnimationDriveSystem's remote path:
velocity from LocalTransform-delta (prevPos cache, pruned every frame), facing
from LocalTransform.Rotation (AnimParamMath.PlanarForward), maxSpeed from baked
EnemyStats, IsAttacking from the already-replicated AttackWindup telegraph. No
new [GhostField], no server/asmdef/ghost-hash change.

Monster-mash roster: Werewolf (Grunt), Werewolf-Undead (Swarmer), Kaiju (Brute),
built by the reusable, GUID-preserving EnemyRigTools editor tool (materials +
AC_EnemyTopDown + EnemyAttackWindup clip + 3 rigged prefabs). WaveSystem now
preserves the baked variant Scale (was reset to 1 by LocalTransform.FromPosition).

See DR-023. EditMode 208/208; validated in Play (rigs skin, scales replicate,
locomotion + attack telegraph drive correctly).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 23:30:03 -07:00
parent 5a59d8e14f
commit 2fcff9a7a1
23 changed files with 8831 additions and 4 deletions
@@ -0,0 +1,292 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
namespace ProjectM.EditorTools
{
/// <summary>
/// Reusable editor pipeline that turns Synty Polygon character meshes into ANIMATED interpolated-ghost enemy
/// prefabs, mirroring the DR-022 player-rig recipe so the same Rukhanka client-derived animation works for
/// Husks (see DR-023). Three idempotent, re-runnable steps (menu: ProjectM/Animation):
/// 1. Build deformation materials (duplicate the player's AnimatedLitShader mat, swap the pack atlas).
/// 2. Build AC_EnemyTopDown (fork AC_PlayerTopDown + an Attack state) and the EnemyAttackWindup clip.
/// 3. Build the 3 animated enemy prefabs (clone the capsule ghost template, strip the capsule, flatten the
/// Synty skeleton + SMRs under the ghost root, add Animator + RigDefinitionAuthoring, set the deformation
/// material, offset the Root bone for feet-on-ground).
/// RigDefinitionAuthoring is set via reflection (no Rukhanka asmdef ref needed — same "by name" tactic as
/// ServerStripAnimationSystem). Outputs are NEW prefabs (templates stay pristine capsules); re-point the
/// gameplay subscene WaveDirector.EnemyPrefabs[] at them.
/// </summary>
public static class EnemyRigTools
{
const string PlayerMat = "Assets/_Project/Materials/M_SpaceSoldier_Animated.mat";
const string PlayerController = "Assets/_Project/Animation/AC_PlayerTopDown.controller";
const string EnemyController = "Assets/_Project/Animation/AC_EnemyTopDown.controller";
const string AttackClip = "Assets/_Project/Animation/EnemyAttackWindup.anim";
const string WerewolfAtlas = "Assets/Synty/PolygonWerewolf/Textures/PolygonWerewolf_01_A.png";
const string KaijuAtlas = "Assets/Synty/PolygonKaiju/Textures/PolygonKaiju_01.png";
const string MatWerewolf = "Assets/_Project/Materials/M_Enemy_Werewolf_Animated.mat";
const string MatWerewolfUndead = "Assets/_Project/Materials/M_Enemy_WerewolfUndead_Animated.mat";
const string MatKaiju = "Assets/_Project/Materials/M_Enemy_Kaiju_Animated.mat";
const string SyntyWerewolf = "Assets/Synty/PolygonWerewolf/Prefabs/Characters/SM_Chr_Werewolf_01.prefab";
const string SyntyKaiju = "Assets/Synty/PolygonKaiju/Prefabs/Characters/SM_Chr_Kaiju_01.prefab";
struct Variant
{
public string Name, Template, Synty, Output, Material;
public float RootY; // Root-bone local Y (capsule-center -> feet; tuned per scale in Play)
public float Scale; // 0 = keep the template's baked scale
}
static Variant[] Variants() => new[]
{
new Variant { Name = "Grunt (Werewolf)", Template = "Assets/_Project/Prefabs/Enemy.prefab", Synty = SyntyWerewolf, Output = "Assets/_Project/Prefabs/EnemyWerewolf.prefab", Material = MatWerewolf, RootY = -1.25f, Scale = 0f },
new Variant { Name = "Swarmer (Werewolf Undead)", Template = "Assets/_Project/Prefabs/EnemySwarmer.prefab", Synty = SyntyWerewolf, Output = "Assets/_Project/Prefabs/EnemyWerewolfUndead.prefab", Material = MatWerewolfUndead, RootY = -1.67f, Scale = 0f },
new Variant { Name = "Brute (Kaiju)", Template = "Assets/_Project/Prefabs/EnemyBrute.prefab", Synty = SyntyKaiju, Output = "Assets/_Project/Prefabs/EnemyKaiju.prefab", Material = MatKaiju, RootY = -0.52f, Scale = 0f },
};
[MenuItem("ProjectM/Animation/Enemy Rigs - Build All")]
public static void BuildAll()
{
BuildMaterials();
BuildControllerAndClip();
BuildPrefabs();
Debug.Log("[EnemyRigTools] Build All complete. Re-point WaveDirector.EnemyPrefabs[] at the new prefabs and re-bake the gameplay subscene.");
}
// ---- 1. Materials ------------------------------------------------------------------------------------
[MenuItem("ProjectM/Animation/Enemy Rigs - 1 Build Materials")]
public static void BuildMaterials()
{
MakeMat(MatWerewolf, WerewolfAtlas, null);
// Undead = the same werewolf atlas with a sickly desaturated-green tint so the Swarmer reads distinct.
MakeMat(MatWerewolfUndead, WerewolfAtlas, new Color(0.62f, 0.78f, 0.55f, 1f));
MakeMat(MatKaiju, KaijuAtlas, null);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("[EnemyRigTools] Materials built.");
}
static void MakeMat(string dst, string atlas, Color? tint)
{
if (AssetDatabase.LoadAssetAtPath<Material>(PlayerMat) == null)
{ Debug.LogError($"[EnemyRigTools] Source material missing: {PlayerMat}"); return; }
AssetDatabase.DeleteAsset(dst);
if (!AssetDatabase.CopyAsset(PlayerMat, dst))
{ Debug.LogError($"[EnemyRigTools] CopyAsset failed -> {dst}"); return; }
AssetDatabase.ImportAsset(dst);
var m = AssetDatabase.LoadAssetAtPath<Material>(dst);
var tex = AssetDatabase.LoadAssetAtPath<Texture>(atlas);
if (tex == null) Debug.LogWarning($"[EnemyRigTools] Atlas not found: {atlas}");
if (m.HasProperty("_BaseColorMap") && tex != null) m.SetTexture("_BaseColorMap", tex);
if (tint.HasValue && m.HasProperty("_BaseColor")) m.SetColor("_BaseColor", tint.Value);
EditorUtility.SetDirty(m);
}
// ---- 2. Controller + attack clip --------------------------------------------------------------------
[MenuItem("ProjectM/Animation/Enemy Rigs - 2 Build Controller")]
public static void BuildControllerAndClip()
{
// Attack wind-up: pitch the Root bone forward (whole-body lunge) and back, non-looping. Root is the
// top of every Synty Generic rig, so one clip fits all variants; the locomotion clips don't key Root,
// so no conflict and Root returns to its authored Y-offset when the state exits.
var clip = new AnimationClip { frameRate = 30f };
var pitch = new AnimationCurve(new Keyframe(0f, 0f), new Keyframe(0.15f, 30f), new Keyframe(0.40f, 0f));
AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Root", typeof(Transform), "localEulerAnglesRaw.x"), pitch);
var s = AnimationUtility.GetAnimationClipSettings(clip);
s.loopTime = false;
AnimationUtility.SetAnimationClipSettings(clip, s);
AssetDatabase.DeleteAsset(AttackClip);
AssetDatabase.CreateAsset(clip, AttackClip);
AssetDatabase.SaveAssets();
if (AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController) == null)
{ Debug.LogError($"[EnemyRigTools] Source controller missing: {PlayerController}"); return; }
AssetDatabase.DeleteAsset(EnemyController);
AssetDatabase.CopyAsset(PlayerController, EnemyController);
AssetDatabase.ImportAsset(EnemyController);
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(EnemyController);
if (!HasParam(ac, "IsAttacking"))
ac.AddParameter("IsAttacking", AnimatorControllerParameterType.Bool);
var sm = ac.layers[0].stateMachine;
var attackClipAsset = AssetDatabase.LoadAssetAtPath<AnimationClip>(AttackClip);
var attack = sm.AddState("Attack");
attack.motion = attackClipAsset;
attack.writeDefaultValues = true;
var toAttack = sm.AddAnyStateTransition(attack);
toAttack.hasExitTime = false;
toAttack.duration = 0.06f;
toAttack.canTransitionToSelf = false;
toAttack.AddCondition(AnimatorConditionMode.If, 0f, "IsAttacking");
var fromAttack = attack.AddExitTransition();
fromAttack.hasExitTime = false;
fromAttack.duration = 0.12f;
fromAttack.AddCondition(AnimatorConditionMode.IfNot, 0f, "IsAttacking");
EditorUtility.SetDirty(ac);
AssetDatabase.SaveAssets();
Debug.Log("[EnemyRigTools] AC_EnemyTopDown + EnemyAttackWindup clip built.");
}
static bool HasParam(AnimatorController ac, string name)
{
foreach (var p in ac.parameters) if (p.name == name) return true;
return false;
}
// ---- 3. Prefabs --------------------------------------------------------------------------------------
[MenuItem("ProjectM/Animation/Enemy Rigs - 3 Build Prefabs")]
public static void BuildPrefabs()
{
foreach (var v in Variants()) BuildOne(v);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("[EnemyRigTools] Animated enemy prefabs built. Re-point WaveDirector.EnemyPrefabs[] and re-bake.");
}
static void BuildOne(Variant v)
{
if (AssetDatabase.LoadAssetAtPath<GameObject>(v.Template) == null)
{ Debug.LogError($"[EnemyRigTools] Template missing: {v.Template}"); return; }
if (AssetDatabase.LoadAssetAtPath<GameObject>(v.Synty) == null)
{ Debug.LogError($"[EnemyRigTools] Synty char prefab missing: {v.Synty}"); return; }
// GUID-preserving: COPY the template only on the FIRST build (mints the output + its GUID). On a
// re-run, modify the existing output IN PLACE so its GUID stays stable -> the gameplay subscene's
// WaveDirector.EnemyPrefabs[] references never break. (DeleteAsset+CopyAsset would mint a new GUID
// each run and silently orphan those refs.)
if (AssetDatabase.LoadAssetAtPath<GameObject>(v.Output) == null)
{
if (!AssetDatabase.CopyAsset(v.Template, v.Output))
{ Debug.LogError($"[EnemyRigTools] CopyAsset failed -> {v.Output}"); return; }
AssetDatabase.ImportAsset(v.Output);
}
var root = PrefabUtility.LoadPrefabContents(v.Output);
try
{
// Clean slate for idempotent re-runs: drop any previously-built rig (children + rig components)
// and the primitive capsule visual; keep the root's ghost/authoring components + transform.
var kids = new List<Transform>();
foreach (Transform c in root.transform) kids.Add(c);
foreach (var c in kids) UnityEngine.Object.DestroyImmediate(c.gameObject);
foreach (var mf in root.GetComponents<MeshFilter>()) UnityEngine.Object.DestroyImmediate(mf);
foreach (var mr in root.GetComponents<MeshRenderer>()) UnityEngine.Object.DestroyImmediate(mr);
foreach (var an in root.GetComponents<Animator>()) UnityEngine.Object.DestroyImmediate(an);
var oldRda = FindType("Rukhanka.Hybrid.RigDefinitionAuthoring");
if (oldRda != null) { var ex = root.GetComponent(oldRda); if (ex != null) UnityEngine.Object.DestroyImmediate(ex); }
// Optional scale override (else keep the template's baked variant scale).
if (v.Scale > 0f) root.transform.localScale = new Vector3(v.Scale, v.Scale, v.Scale);
// Clone the Synty character (unlinked) and flatten its skeleton + SMRs under the ghost root.
var syntyAsset = AssetDatabase.LoadAssetAtPath<GameObject>(v.Synty);
var clone = (GameObject)UnityEngine.Object.Instantiate(syntyAsset);
var srcAnimator = clone.GetComponentInChildren<Animator>();
var avatar = srcAnimator != null ? srcAnimator.avatar : null;
var children = new List<Transform>();
foreach (Transform c in clone.transform) children.Add(c);
foreach (var c in children) c.SetParent(root.transform, false); // keep local transforms
UnityEngine.Object.DestroyImmediate(clone);
// Feet-on-ground: offset the un-keyed Root bone (entity origin = capsule center ~1m up).
var rootBone = root.transform.Find("Root");
if (rootBone != null)
{
var lp = rootBone.localPosition;
lp.y = v.RootY;
rootBone.localPosition = lp;
}
else Debug.LogWarning($"[EnemyRigTools] No 'Root' bone under {v.Output}; feet offset skipped.");
// Deformation material on every SMR slot (else unskinned-static + "does not support skinning").
var mat = AssetDatabase.LoadAssetAtPath<Material>(v.Material);
if (mat == null) Debug.LogWarning($"[EnemyRigTools] Material missing: {v.Material}");
foreach (var smr in root.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
var mats = smr.sharedMaterials;
for (int i = 0; i < mats.Length; i++) mats[i] = mat;
smr.sharedMaterials = mats;
}
// Animator on the ghost root (the entity that holds EnemyAuthoring + the Rukhanka param buffer).
var anim = root.GetComponent<Animator>();
if (anim == null) anim = root.AddComponent<Animator>();
anim.avatar = avatar;
anim.runtimeAnimatorController = AssetDatabase.LoadAssetAtPath<RuntimeAnimatorController>(EnemyController);
anim.applyRootMotion = false;
// RigDefinitionAuthoring (Rukhanka.Hybrid) via reflection — CPU engine, match the player rig.
AddRigDefinition(root);
PrefabUtility.SaveAsPrefabAsset(root, v.Output);
Debug.Log($"[EnemyRigTools] Built {v.Name} -> {v.Output} (rootY={v.RootY}).");
}
finally
{
PrefabUtility.UnloadPrefabContents(root);
}
}
static void AddRigDefinition(GameObject root)
{
var t = FindType("Rukhanka.Hybrid.RigDefinitionAuthoring");
if (t == null) { Debug.LogError("[EnemyRigTools] RigDefinitionAuthoring type not found (Rukhanka.Hybrid)."); return; }
var rda = root.GetComponent(t);
if (rda == null) rda = root.AddComponent(t);
SetMember(rda, "applyRootMotion", false);
SetMember(rda, "boneEntityStrippingMode", 1); // match Player.prefab
SetMember(rda, "animationEngine", 0); // 0 = CPU
EditorUtility.SetDirty(rda);
}
static Type FindType(string fullName)
{
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
var t = asm.GetType(fullName);
if (t != null) return t;
}
return null;
}
static void SetMember(object o, string name, object val)
{
const BindingFlags BF = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
var f = o.GetType().GetField(name, BF);
if (f != null)
{
object v = f.FieldType.IsEnum ? Enum.ToObject(f.FieldType, Convert.ToInt32(val))
: Convert.ChangeType(val, f.FieldType);
f.SetValue(o, v);
return;
}
var p = o.GetType().GetProperty(name, BF);
if (p != null && p.CanWrite)
{
object v = p.PropertyType.IsEnum ? Enum.ToObject(p.PropertyType, Convert.ToInt32(val))
: Convert.ChangeType(val, p.PropertyType);
p.SetValue(o, v);
return;
}
Debug.LogWarning($"[EnemyRigTools] Field/prop '{name}' not found on {o.GetType().Name}.");
}
}
}