Files
AIGame/AIClient/AgentScripts/FinalizeAnimalWalk.cs
T
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

207 lines
12 KiB
C#

#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
/// <summary>Bake an upright, grounded, periodic in-place gait onto this exact bind.</summary>
public static class FinalizeAnimalWalk
{
[Serializable] public sealed class QuadrupedRecipe
{
public string assetRoot,id,rootBone,frontRightFoot,hindLeftFoot,frontLeftFoot,hindRightFoot,outputDir;
public float height,walkStride,walkLift,walkSeconds,walkSourceWeight,walkSwingFraction;
}
public static string RunQuadruped(string recipePath)
{
var r=JsonUtility.FromJson<QuadrupedRecipe>(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../..",recipePath))));
if(!(r.walkSwingFraction>0 && r.walkSwingFraction<=.25f))throw new ArgumentException("Four-beat swing must be at most a quarter cycle");
return RunCore(r.assetRoot,r.id,r.height,r.rootBone,new[]{r.frontRightFoot,r.hindLeftFoot,r.frontLeftFoot,r.hindRightFoot},r.walkStride,r.walkLift,r.walkSeconds,r.outputDir,true,r.walkSourceWeight,true,r.walkSwingFraction);
}
const string Root = "Assets/Bundles/Characters/Animal/RockBall";
sealed class Pose
{
public Vector3[] p, s;
public Quaternion[] q;
}
static Pose Read(Transform[] bones) => new Pose {
p=bones.Select(t=>t.localPosition).ToArray(), s=bones.Select(t=>t.localScale).ToArray(),
q=bones.Select(t=>t.localRotation).ToArray() };
static void Write(Transform[] bones, Pose pose)
{
for(int i=0;i<bones.Length;i++) { bones[i].localPosition=pose.p[i]; bones[i].localRotation=pose.q[i]; bones[i].localScale=pose.s[i]; }
}
static Vector3[] Vertices(SkinnedMeshRenderer skin)
{
var m=skin.sharedMesh;
var bind=m.bindposes;
var matrices=skin.bones.Select((b,i)=>b.localToWorldMatrix*bind[i]).ToArray();
var weights=m.boneWeights;
return m.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 float Difference(Pose a,Pose b)
{
float sum=0;
for(int i=0;i<a.q.Length;i++) sum+=Mathf.Pow(Quaternion.Angle(a.q[i],b.q[i]),2)+10000*(a.p[i]-b.p[i]).sqrMagnitude;
return Mathf.Sqrt(sum/a.q.Length);
}
public static string Run(float strideMeters = .06f, float liftMeters = .018f, float cycleSeconds = 1.1f)
=> RunFor(Root,"RockBall",.55f,"bone_0","bone_8","bone_10",strideMeters,liftMeters,cycleSeconds,"Tools/output/rockball_walk_audit");
public static string RunFor(string assetRoot,string actor,float height,string rootBone,string rightFoot,string leftFoot,
float strideMeters,float liftMeters,float cycleSeconds,string reportDirectory,bool calibrateBind=false,float sourceWeight=1f)
=>RunCore(assetRoot,actor,height,rootBone,new[]{rightFoot,leftFoot},strideMeters,liftMeters,cycleSeconds,reportDirectory,calibrateBind,sourceWeight,false,.2f);
static string RunCore(string assetRoot,string actor,float height,string rootBone,string[] footNames,
float strideMeters,float liftMeters,float cycleSeconds,string reportDirectory,bool calibrateBind,float sourceWeight,bool fourBeat,float swingFraction)
{
var asset=AssetDatabase.LoadAssetAtPath<GameObject>(assetRoot+"/Rigged/"+actor+"_Rigged.fbx");
var model=Object.Instantiate(asset);
try
{
var animator=model.GetComponent<Animator>();
if(animator!=null) animator.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==rootBone);
var feet=footNames.Select(name=>bones.First(t=>t.name==name)).ToArray();
if(feet.Distinct().Count()!=feet.Length)throw new ArgumentException("Foot mappings must be distinct");
var footPositions=feet.Select(f=>f.position).ToArray();var footRotations=feet.Select(f=>f.rotation).ToArray();
var bindPose=Read(bones);
var bindRotation=root.rotation;
var bindPosition=root.position;
float bindBottom=Bottom(model);
float bindHeight=model.GetComponentsInChildren<SkinnedMeshRenderer>().SelectMany(Vertices).Max(v=>v.y)-bindBottom;
float metersToBind=bindHeight/height;
var src=AssetDatabase.LoadAllAssetsAtPath(assetRoot+"/Anims/"+actor+"_Walk.fbx").OfType<AnimationClip>().First(c=>!c.name.StartsWith("__preview__"));
int count=Mathf.RoundToInt(src.length*30)+1;
var poses=new Pose[count];
Quaternion reference=Quaternion.identity;
Pose referencePose=null;
float referenceTilt=0;
for(int f=0;f<count;f++)
{
Write(bones,bindPose);
src.SampleAnimation(model,f/30f);
if(f==0) { reference=root.rotation; referenceTilt=Vector3.Angle(root.up,Vector3.up); referencePose=Read(bones); }
// Root policy is authored into the clip, never counter-rotated at runtime.
// Remove the generated initial pitch/roll offset, retain gait deltas.
if(calibrateBind)
{
var raw=Read(bones);
for(int b=0;b<bones.Length;b++)
{
bones[b].localPosition=bindPose.p[b]+(raw.p[b]-referencePose.p[b])*sourceWeight;
bones[b].localRotation=Quaternion.Slerp(bindPose.q[b],bindPose.q[b]*Quaternion.Inverse(referencePose.q[b])*raw.q[b],sourceWeight);
bones[b].localScale=bindPose.s[b];
}
}
else root.rotation=bindRotation*Quaternion.Inverse(reference)*root.rotation;
if(fourBeat)root.rotation=bindRotation;
root.position=new Vector3(bindPosition.x,root.position.y,bindPosition.z);
root.position+=Vector3.up*(bindBottom-Bottom(model));
poses[f]=Read(bones);
}
// Select repeated gait phases, not an arbitrary first/last truncation.
int first=0,last=count-1;
float error=float.MaxValue;
for(int a=0;a<count/4;a++)
for(int b=Mathf.Max(a+21,count*2/3);b<count;b++)
{
float d=Difference(poses[a],poses[b]);
if(d<error) { error=d; first=a; last=b; }
}
if(error>15) throw new InvalidOperationException($"No usable repeated gait phase (RMS={error:F2}). Generate a better walk.");
int frames=last-first+1;
var loop=new Pose[frames];
for(int f=0;f<frames;f++)
{
float t=(float)f/(frames-1);
float blend=t*t*t*(t*(6*t-15)+10); // smooth residual closure, zero derivative at ends
var a=poses[first+f]; var begin=poses[first]; var end=poses[last];
var p=new Pose { p=new Vector3[bones.Length],s=new Vector3[bones.Length],q=new Quaternion[bones.Length] };
for(int i=0;i<bones.Length;i++)
{
p.p[i]=a.p[i]+blend*(begin.p[i]-end.p[i]);
p.s[i]=a.s[i]+blend*(begin.s[i]-end.s[i]);
p.q[i]=a.q[i]*Quaternion.Slerp(Quaternion.identity,Quaternion.Inverse(end.q[i])*begin.q[i],blend);
}
Write(bones,p);
if(fourBeat)root.rotation=bindRotation;
// Author an explicit alternating short step in the OFFLINE clip bake.
// Bone mapping and dimensions belong to this character's recipe.
for(int foot=0;foot<feet.Length;foot++)
{
if(fourBeat)
{
// Four separate swing windows leave the other three paws supporting.
float u=Mathf.Repeat(t-foot*.25f,1);
bool swinging=u<swingFraction;
float advance=swinging ? Mathf.SmoothStep(-.5f,.5f,u/swingFraction) : .5f-(u-swingFraction)/(1-swingFraction);
float lift=swinging ? Mathf.Pow(Mathf.Sin(Mathf.PI*u/swingFraction),2) : 0;
for(int b=0;b<bones.Length;b++)if(bones[b]!=feet[foot] && bones[b].IsChildOf(feet[foot]))
{bones[b].localPosition=bindPose.p[b];bones[b].localRotation=bindPose.q[b];bones[b].localScale=bindPose.s[b];}
feet[foot].position=footPositions[foot]+(Vector3.back*(advance*strideMeters)+Vector3.up*(lift*liftMeters))*metersToBind;
feet[foot].rotation=footRotations[foot];
continue;
}
float phase=2*Mathf.PI*t+foot*Mathf.PI;
float swing=Mathf.Max(0,Mathf.Cos(phase));
feet[foot].position+=Vector3.back*(Mathf.Sin(phase)*strideMeters*.5f*metersToBind)
+Vector3.up*(swing*swing*liftMeters*metersToBind);
}
root.position+=Vector3.up*(bindBottom-Bottom(model));
loop[f]=Read(bones);
}
// Force identical quaternion representatives on the seam.
for(int f=1;f<frames;f++) for(int b=0;b<bones.Length;b++)
if(Quaternion.Dot(loop[f-1].q[b],loop[f].q[b])<0) {
var q=loop[f].q[b]; loop[f].q[b]=new Quaternion(-q.x,-q.y,-q.z,-q.w);
}
loop[frames-1]=loop[0];
var clip=AssetDatabase.LoadAssetAtPath<AnimationClip>(assetRoot+"/Clips/Walk.anim");
Undo.RecordObject(clip,"Bake verified in-place Walk");
clip.ClearCurves();
clip.frameRate=30;
for(int b=0;b<bones.Length;b++)
{
string path=AnimationUtility.CalculateTransformPath(bones[b],model.transform);
for(int component=0;component<10;component++)
{
int c=component;
var keys=Enumerable.Range(0,frames).Select(f=>new Keyframe(cycleSeconds*f/(frames-1),
c<3 ? loop[f].p[b][c] : c<7 ? loop[f].q[b][c-3] : loop[f].s[b][c-7])).ToArray();
for(int k=0;k<frames;k++)
{
int prev=k==0 ? frames-2 : k-1, next=k==frames-1 ? 1 : k+1;
float tangent=(keys[next].value-keys[prev].value)*(frames-1)/(2*cycleSeconds);
keys[k].inTangent=tangent; keys[k].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=cycleSeconds; settings.loopTime=true; settings.loopBlend=false;
AnimationUtility.SetAnimationClipSettings(clip,settings);
EditorUtility.SetDirty(clip);
AssetDatabase.SaveAssets();
string report=$"frames={first}..{last} length={clip.length:F4} phaseRms={error:F3} sourceInitialRootTilt={referenceTilt:F3}; allBoneBindCalibration={calibrateBind} sourceWeight={sourceWeight:F3}; root bind calibration + in-place XZ + per-frame grounded skin + periodic closure; feet={feet.Length} fourBeat={fourBeat} swingFraction={swingFraction:F3}; authored short-foot stride={strideMeters:F3}m lift={liftMeters:F3}m cycle={cycleSeconds:F3}s";
string outPath=Path.GetFullPath(Path.Combine(Application.dataPath,"../../",reportDirectory,"walk_processing.txt"));
Directory.CreateDirectory(Path.GetDirectoryName(outPath));
File.WriteAllText(outPath,report);
return report;
}
finally { Object.DestroyImmediate(model); }
}
}
#endif