- 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>
207 lines
12 KiB
C#
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
|