Files
udxandClaude Opus 5.5 89e0fa246f Add formal enemies, remaining formal partners and synthesized UI SFX
- UI SFX v3: procedurally synthesized one-shots (Tools/synth_ui_sfx.py) wired to
  buttons, rewards, errors and red dots via UiSfx, gated by the SFX setting.
- Five formal enemies (Hornlet, Dashlet, Shellhorn, HornCaptain, GreatHorn):
  Hunyuan 50k -> 2000-tri XPbr, DGXA TokenRig, Idle/Walk/Attack, nested per type
  in PrototypeArena; PrototypeBattle.Enemy gains presentation-only Moving/Strikes.
- Formal partners for all ten pets in the arena and portraits, including new
  VineSprite, ThunderLeopard and TideWhale.
- Pipeline fixes: reject local TokenRig fallback, drop loose mesh islands,
  configurable Unity run_script timeout, companion static stage.
- Also includes concurrent work from another tool in this working tree
  (FlameLion, enemy combat FX, challenge mode, home explorer, server updates).
- Resources registered in Doc/ResDesc_*.md; verified on isolated :18081.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:23:55 +08:00

218 lines
15 KiB
C#

#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEngine;
using Object=UnityEngine.Object;
/// <summary>Offline source-motion editing; saves assets, never adds runtime correction.</summary>
public static class FinalizeEnemyMotions
{
[Serializable] public sealed class Recipe
{
public string id,assetRoot,rootBone,chestBone,rightArm,leftArm,outputDir,profile,attackStyle,attackEvent,usage;
public string headBone,neckBone,frontRightFoot,frontLeftFoot,hindRightFoot,hindLeftFoot,rightFoot,leftFoot;
public float height,idleSeconds,idleBreath,attackSeconds,attackWindup,attackImpact,attackRecover;
public float attackLeanDegrees,attackArmDegrees,attackLunge,attackReach,idleSourceWeight,attackSourceWeight,wingSpreadDegrees;
public float attackHeadReach,attackHeadPitchDegrees,attackSpreadDegrees;
}
sealed class Pose
{
public Vector3[] p,s;
public Quaternion[] q;
public static Pose Read(Transform[] b)=>new Pose { p=b.Select(t=>t.localPosition).ToArray(),s=b.Select(t=>t.localScale).ToArray(),q=b.Select(t=>t.localRotation).ToArray() };
public void Write(Transform[] b) { for(int i=0;i<b.Length;i++) { b[i].localPosition=p[i];b[i].localRotation=q[i];b[i].localScale=s[i]; } }
}
static string Repo=>Path.GetFullPath(Path.Combine(Application.dataPath,"../.."));
static float Smooth(float t)=>Mathf.SmoothStep(0,1,Mathf.Clamp01(t));
static float Strike(Recipe r,float t)
{
if(t<r.attackWindup) return -.3f*Smooth(t/r.attackWindup);
if(t<r.attackImpact) return Mathf.Lerp(-.3f,1,Smooth((t-r.attackWindup)/(r.attackImpact-r.attackWindup)));
if(t<r.attackRecover) return 1-Smooth((t-r.attackImpact)/(r.attackRecover-r.attackImpact));
return 0;
}
static Vector3[] Vertices(SkinnedMeshRenderer skin)
{
var mesh=skin.sharedMesh;var bind=mesh.bindposes;var weights=mesh.boneWeights;
var matrices=skin.bones.Select((b,i)=>b.localToWorldMatrix*bind[i]).ToArray();
return mesh.vertices.Select((v,i)=> { var w=weights[i];return matrices[w.boneIndex0].MultiplyPoint3x4(v)*w.weight0
+matrices[w.boneIndex1].MultiplyPoint3x4(v)*w.weight1+matrices[w.boneIndex2].MultiplyPoint3x4(v)*w.weight2
+matrices[w.boneIndex3].MultiplyPoint3x4(v)*w.weight3; }).ToArray();
}
static float Bottom(GameObject model)=>model.GetComponentsInChildren<SkinnedMeshRenderer>().SelectMany(Vertices).Min(v=>v.y);
static void Save(AnimationClip clip,Transform model,Transform[] bones,Pose[] poses,float seconds,bool loop)
{
Undo.RecordObject(clip,"Author character motion");
clip.ClearCurves();clip.frameRate=30;
int count=poses.Length;
for(int f=1;f<count;f++) for(int b=0;b<bones.Length;b++)
if(Quaternion.Dot(poses[f-1].q[b],poses[f].q[b])<0) {var q=poses[f].q[b];poses[f].q[b]=new Quaternion(-q.x,-q.y,-q.z,-q.w);}
poses[count-1]=poses[0];
for(int b=0;b<bones.Length;b++)
{
string path=AnimationUtility.CalculateTransformPath(bones[b],model);
for(int c=0;c<10;c++)
{
var keys=new Keyframe[count];
for(int f=0;f<count;f++) keys[f]=new Keyframe(seconds*f/(count-1),c<3 ? poses[f].p[b][c] : c<7 ? poses[f].q[b][c-3] : poses[f].s[b][c-7]);
for(int f=0;f<count;f++)
{
int prev=f==0 ? (loop ? count-2 : 0) : f-1;
int next=f==count-1 ? (loop ? 1 : count-1) : f+1;
float tangent=(keys[next].value-keys[prev].value)*(count-1)/(2*seconds);
if(!loop && (f==0 || f==count-1)) tangent=0;
keys[f].inTangent=tangent;keys[f].outTangent=tangent;
}
string prop=c<3 ? "m_LocalPosition."+"xyz"[c] : c<7 ? "m_LocalRotation."+"xyzw"[c-3] : "m_LocalScale."+"xyz"[c-7];
AnimationUtility.SetEditorCurve(clip,EditorCurveBinding.FloatCurve(path,typeof(Transform),prop),new AnimationCurve(keys));
}
}
var settings=AnimationUtility.GetAnimationClipSettings(clip);
settings.startTime=0;settings.stopTime=seconds;settings.loopTime=loop;settings.loopBlend=false;
AnimationUtility.SetAnimationClipSettings(clip,settings);
AnimationUtility.SetAnimationEvents(clip,new AnimationEvent[0]);
EditorUtility.SetDirty(clip);
}
public static string Run(string recipePath="Config/Art/EnemyCharacters/RockBall.json")
{
var r=JsonUtility.FromJson<Recipe>(File.ReadAllText(Path.Combine(Repo,recipePath)));
if(string.IsNullOrEmpty(r.attackStyle))r.attackStyle="body_slam";
if(string.IsNullOrEmpty(r.attackEvent))r.attackEvent=r.usage=="companion_visual" ? "OnCompanionAttackImpact" : "OnEnemyAttackImpact";
bool bird=r.profile=="avian_grounded";
bool turtle=r.profile=="quadruped_turtle";
bool heavy=r.profile=="quadruped_heavy";
bool quadruped=turtle || heavy;
bool roar=r.attackStyle=="flame_roar";
if(r.profile!="biped_short" && !bird && !quadruped)throw new ArgumentException("Unsupported anatomy");
if(r.attackStyle!="body_slam" && r.attackStyle!="ranged_cast" && r.attackStyle!="wing_cast" && r.attackStyle!="turtle_bubble" && r.attackStyle!="braced_bash" && !roar)throw new ArgumentException("Unsupported attack style");
if(roar && r.profile!="biped_short")throw new ArgumentException("Flame roar requires an upright biped");
if(bird!=(r.attackStyle=="wing_cast") || (bird && !(r.wingSpreadDegrees>0 && r.wingSpreadDegrees<=60)))
throw new ArgumentException("Invalid grounded-bird wing cast");
if(turtle!=(r.attackStyle=="turtle_bubble"))throw new ArgumentException("Invalid turtle bubble cast");
if(heavy!=(r.attackStyle=="braced_bash"))throw new ArgumentException("Invalid heavy quadruped bash");
if(r.height<=0 || !(0<r.attackWindup && r.attackWindup<r.attackImpact && r.attackImpact<r.attackRecover && r.attackRecover<1))
throw new ArgumentException("Invalid dimensions / attack phase order");
var sb=new StringBuilder();
foreach(string name in new[] {"Idle","Attack"})
{
var model=Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>(r.assetRoot+"/Rigged/"+r.id+"_Rigged.fbx"));
try
{
var anim=model.GetComponent<Animator>();if(anim!=null) anim.enabled=false;
var bones=model.GetComponentsInChildren<Transform>().Where(t=>t!=model.transform && (t.name=="Armature" || t.name.StartsWith("bone_"))).ToArray();
var root=bones.First(t=>t.name==r.rootBone);
var head=quadruped || roar ? bones.First(t=>t.name==r.headBone) : null;
var neck=quadruped || roar ? bones.First(t=>t.name==r.neckBone) : null;
var feet=(quadruped ? new[]{r.frontRightFoot,r.frontLeftFoot,r.hindRightFoot,r.hindLeftFoot} : roar ? new[]{r.rightFoot,r.leftFoot} : new string[0]).Select(n=>bones.First(t=>t.name==n)).ToArray();
var footPositions=feet.Select(t=>t.position).ToArray();var footRotations=feet.Select(t=>t.rotation).ToArray();
var chest=bones.FirstOrDefault(t=>t.name==r.chestBone);
var arms=new[] {bones.FirstOrDefault(t=>t.name==r.rightArm),bones.FirstOrDefault(t=>t.name==r.leftArm)};
if(!quadruped && (chest==null || arms.Any(t=>t==null) || arms[0]==arms[1])) throw new Exception("Missing/distinct profile bones required");
if((heavy || roar) && (chest==null || chest==head))throw new Exception("Independent chest and head required");
var bind=Pose.Read(bones);var bindRootPosition=root.position;float bindBottom=Bottom(model);
var bindRootRotation=root.rotation;
var wingSides=bird || roar ? arms.Select(a=>Mathf.Sign(a.position.x-root.position.x)).ToArray() : new float[0];
if((bird || roar) && wingSides[0]==wingSides[1])throw new Exception("Arm/wing roots must be on opposite sides");
var skins=model.GetComponentsInChildren<SkinnedMeshRenderer>();
float unitScale=(skins.SelectMany(Vertices).Max(v=>v.y)-bindBottom)/r.height;
var source=AssetDatabase.LoadAllAssetsAtPath(r.assetRoot+"/Anims/"+r.id+"_"+name+".fbx").OfType<AnimationClip>().First(c=>!c.name.StartsWith("__preview__"));
source.SampleAnimation(model,0);var reference=Pose.Read(bones);
bool idle=name=="Idle";float seconds=idle ? r.idleSeconds : r.attackSeconds;
int count=Mathf.RoundToInt(seconds*30)+1;var poses=new Pose[count];
for(int f=0;f<count;f++)
{
float t=(float)f/(count-1);float idleWeight=r.idleSourceWeight>0 ? r.idleSourceWeight : .2f;
float attackWeight=r.attackSourceWeight>0 ? r.attackSourceWeight : .35f;
float weight=Mathf.Pow(Mathf.Sin(Mathf.PI*t),2)*(idle ? idleWeight : attackWeight);
bind.Write(bones);source.SampleAnimation(model,t*source.length);var raw=Pose.Read(bones);
for(int b=0;b<bones.Length;b++)
{
bones[b].localPosition=bind.p[b]+(raw.p[b]-reference.p[b])*weight;
bones[b].localRotation=Quaternion.Slerp(bind.q[b],bind.q[b]*Quaternion.Inverse(reference.q[b])*raw.q[b],weight);
bones[b].localScale=bind.s[b];
}
root.position=new Vector3(bindRootPosition.x,root.position.y,bindRootPosition.z);
if(roar)root.rotation=bindRootRotation;
if(quadruped)
{
root.rotation=bindRootRotation;
if(idle)head.position+=Vector3.up*(r.idleBreath*Mathf.Pow(Mathf.Sin(Mathf.PI*t),2)*unitScale);
else
{
float strike=Strike(r,t);
// Heavy animals drive the shoulder and head together; a turtle
// only extends its neck. Paws are planted in either case.
if(heavy)chest.position+=Vector3.back*(r.attackLunge*strike*unitScale);
neck.position+=Vector3.back*(r.attackHeadReach*strike*unitScale);
head.rotation=Quaternion.AngleAxis(-r.attackHeadPitchDegrees*strike,Vector3.right)*head.rotation;
}
}
if(r.profile=="biped_short" || bird)
{
if(idle)
{
float breath=Mathf.Pow(Mathf.Sin(Mathf.PI*t),2);
if(roar)head.position+=Vector3.up*(r.idleBreath*breath*unitScale);
else chest.localScale=Vector3.Scale(chest.localScale,new Vector3(1+.007f*breath,1+r.idleBreath/r.height*breath,1+.007f*breath));
}
else
{
float strike=Strike(r,t);
if(!roar)
{
root.rotation=Quaternion.AngleAxis(-r.attackLeanDegrees*strike,Vector3.right)*root.rotation;
root.position+=Vector3.back*(r.attackLunge*strike*unitScale);
}
for(int side=0;side<arms.Length;side++)
{
var arm=arms[side];
float armSign=r.attackStyle=="ranged_cast" || roar ? 1 : -1;
arm.rotation=Quaternion.AngleAxis(armSign*r.attackArmDegrees*strike,Vector3.right)*arm.rotation;
// Standing bird wings start outward/upward: negative X sweeps
// them toward -Z. Opposite Z rotations open each wing upward.
if(bird)arm.rotation=Quaternion.AngleAxis(wingSides[side]*r.wingSpreadDegrees*strike,Vector3.forward)*arm.rotation;
if(roar)arm.rotation=Quaternion.AngleAxis(wingSides[side]*r.attackSpreadDegrees*strike,Vector3.forward)*arm.rotation;
// A ranged cast presents both paws in front, without moving the actor.
// Small offline bone translation supplies reach for very short chibi arms.
if(r.attackStyle=="ranged_cast" || bird || roar)arm.position+=Vector3.back*(r.attackReach*strike*unitScale);
}
if(roar)
{
neck.position+=Vector3.back*(r.attackHeadReach*strike*unitScale);
head.rotation=Quaternion.AngleAxis(r.attackHeadPitchDegrees*strike,Vector3.right)*head.rotation;
}
}
}
// Planted feet are baked offline for the standing braced actions.
for(int foot=0;foot<feet.Length;foot++)
{
for(int b=0;b<bones.Length;b++)if(bones[b]!=feet[foot] && bones[b].IsChildOf(feet[foot]))
{bones[b].localPosition=bind.p[b];bones[b].localRotation=bind.q[b];bones[b].localScale=bind.s[b];}
feet[foot].position=footPositions[foot];feet[foot].rotation=footRotations[foot];
}
root.position+=Vector3.up*(bindBottom-Bottom(model));
poses[f]=Pose.Read(bones);
}
poses[0]=bind;poses[count-1]=bind;
var clip=AssetDatabase.LoadAssetAtPath<AnimationClip>(r.assetRoot+"/Clips/"+name+".anim");
Save(clip,model.transform,bones,poses,seconds,idle);
if(!idle)
{
AnimationUtility.SetAnimationEvents(clip,new[] {new AnimationEvent {
time=r.attackImpact*seconds,functionName=r.attackEvent,messageOptions=SendMessageOptions.DontRequireReceiver
}});
}
sb.AppendLine($"{name}: {seconds:F3}s loop={idle} start/end=bind generated source + offline {r.profile}/{r.attackStyle} authoring");
}
finally {Object.DestroyImmediate(model);}
}
AssetDatabase.SaveAssets();
string report=Path.Combine(Repo,r.outputDir,"motion_processing.txt");Directory.CreateDirectory(Path.GetDirectoryName(report));File.WriteAllText(report,sb.ToString());
return sb.ToString();
}
}
#endif