- 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>
218 lines
15 KiB
C#
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
|