File size: 11,828 Bytes
ee5ec19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
<!DOCTYPE html><html><head><meta charset="utf-8"><title>Physics moves trainer</title>
<style>html,body{margin:0;height:100%;background:#202028;overflow:hidden}
#hud{position:fixed;top:0;left:0;color:#0f0;font:12px monospace;z-index:9;padding:6px;background:#000a;white-space:pre}
#ui{position:fixed;top:6px;right:6px;z-index:9;font:12px monospace;display:none}
#ui button{display:block;margin:2px;width:110px}</style>
<script type="importmap">{"imports":{
"three":"https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/",
"fflate":"https://unpkg.com/fflate@0.8.2/esm/browser.js"
}}</script></head>
<body><div id="hud">loading…</div>
<div id="ui"><button id="save">save weights</button></div>
<script type="module">
import * as THREE from 'three';
import {FBXLoader} from 'three/addons/loaders/FBXLoader.js';
import {PhysRig, sampleClipTargets} from './physbody.js';
import {PhysTrainer} from './rltrain.js';

const hud=document.getElementById('hud');const L=m=>hud.textContent=m;
const renderer=new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,1.5));renderer.setSize(innerWidth,innerHeight);
renderer.shadowMap.enabled=true;document.body.appendChild(renderer.domElement);
const scene=new THREE.Scene();scene.background=new THREE.Color(0x202028);
scene.add(new THREE.HemisphereLight(0xffffff,0x445,1.2));
const dl=new THREE.DirectionalLight(0xffffff,2.2);dl.position.set(3,8,6);dl.castShadow=true;scene.add(dl);
const ground=new THREE.Mesh(new THREE.PlaneGeometry(60,60),new THREE.MeshStandardMaterial({color:0x33343e}));
ground.rotation.x=-Math.PI/2;ground.receiveShadow=true;scene.add(ground);
const grid=new THREE.GridHelper(60,60,0x555,0x3a3a44);grid.position.y=0.002;scene.add(grid);
const cam=new THREE.PerspectiveCamera(45,innerWidth/innerHeight,0.05,100);
cam.position.set(2.8,1.4,3.2);cam.lookAt(0,1.0,0);
const fbx=new FBXLoader();
const load=p=>new Promise((res,rej)=>fbx.load(p,res,undefined,rej));

function makeDebug(rig){
  const g=new THREE.Group();scene.add(g);
  const mat=new THREE.MeshStandardMaterial({color:0x50c0ff,transparent:true,opacity:0.45});
  rig.bodies.forEach(b=>{
    const m=new THREE.Mesh(new THREE.CapsuleGeometry(b.r,Math.max(b.len-2*b.r,0.02),4,8),mat);
    g.add(m);b._dbg=m;
    const axis=b.endB.clone().sub(b.endA).normalize();
    b._dbgQ=new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0,1,0),axis);
  });
  return {group:g,update(){rig.bodies.forEach(b=>{
    b._dbg.position.copy(b.endA).add(b.endB).multiplyScalar(0.5).applyQuaternion(b.quat).add(b.pos);
    b._dbg.quaternion.copy(b.quat).multiply(b._dbgQ);});}};
}

// FIGHT-IMITATION training: the physics ragdoll (student) learns to reproduce
// the REAL Mixamo animation (teacher) while withstanding combat knockback.
// perturbMag = knockback speed (m/s) injected mid-move, sized from the game's
// actual hit knock values (boss hits ~4-9 -> impulse ~3-6 m/s). Locomotion &
// block get hit hardest; fragile jumps train clean.
const LEGS11=['thighL','shinL','footL','thighR','shinR','footR','torso','uarmL','farmL','uarmR','farmR'];
const _q=new URLSearchParams(location.search);const _itOv=+_q.get('iters')||0;
const MOVES=(_q.get('only')==='walk')?[
  ['walk',    {oneshot:false, iters:_itOv||4000, pop:56, imWeight:1.3, speedWeight:2.6, distWeight:0.8, driven:LEGS11, perturbMag:4, clip:_q.get('clip')||'walk'}],
]:[
  // trained on the Male Locomotion Pack (cleaner set) + boxing/uppercut/roll.
  // cfg.clip = source fbx; move key = how the game refers to it.
  ['idle',    {oneshot:false, iters:30,  pop:32, imWeight:1.6, speedWeight:0, targetSpeed:0, perturbMag:3.5, clip:'lp_idle'}],
  ['walk',    {oneshot:false, iters:110, pop:56, imWeight:1.3, speedWeight:2.6, distWeight:0.8, driven:LEGS11, perturbMag:4, clip:'lp_walk'}],
  ['walkback',{oneshot:false, iters:110, pop:56, imWeight:1.3, speedWeight:2.6, distWeight:0.8, dirZ:-1, driven:LEGS11, perturbMag:4, clip:'walkback'}],
  ['run',     {oneshot:false, iters:110, pop:56, imWeight:1.2, speedWeight:2.6, distWeight:0.8, driven:LEGS11, perturbMag:4, clip:'lp_run'}],
  ['runback', {oneshot:false, iters:110, pop:56, imWeight:1.2, speedWeight:2.6, distWeight:0.8, dirZ:-1, driven:LEGS11, perturbMag:4, clip:'runback'}],
  ['turnL',   {oneshot:true,  iters:50,  pop:36, imWeight:2.5, speedWeight:0, targetSpeed:0, perturbMag:2.5, clip:'lp_turnL'}],
  ['turnR',   {oneshot:true,  iters:50,  pop:36, imWeight:2.5, speedWeight:0, targetSpeed:0, perturbMag:2.5, clip:'lp_turnR'}],
  ['jump',    {oneshot:true,  iters:70,  pop:40, imWeight:1.0, speedWeight:0, targetSpeed:0, vertWeight:3.0, assistScale:0.15, clip:'lp_jump'}],
  ['attack1', {oneshot:true,  iters:60,  pop:36, imWeight:2.0, perturbMag:3, clip:'boxing'}],
  ['hit',     {oneshot:true,  iters:50,  pop:36, imWeight:1.5, perturbMag:5, assistScale:0.6, clip:'uppercut'}],
  ['block',   {oneshot:false, iters:60,  pop:36, imWeight:2.0, perturbMag:5.5, clip:'block'}],
  ['roll',    {oneshot:true,  iters:64,  pop:36, assistScale:0.75, imWeight:1.2, perturbMag:2, clip:'roll2'}],
];

let rig,obj,dbg,trained={},cur='walk',phase=0,playing=false,showMesh=true;
window.__mesh=v=>{showMesh=v;obj.visible=v;return v;};
window.__poke=(x=0,y=0,z=2)=>{rig.bodies.forEach(b=>b.vel.add(new THREE.Vector3(x,y,z)));return 'poked';};
window.__move=n=>{if(trained[n]){cur=n;phase=0;}return cur;};
window.__weights=()=>{const moves={};for(const[n,t] of Object.entries(trained))
  moves[n]=t.trainer.exportWeights(t.params,{clip:t.clip||n,character:'player'});
  return {kind:'physmoves-v1',character:'player',moves};};

(async()=>{
  L('loading player…');
  obj=await load('assets/chars/player.fbx');
  const box=new THREE.Box3().setFromObject(obj);const s=new THREE.Vector3();box.getSize(s);
  const sc=1.8/s.y;obj.scale.setScalar(sc);obj.position.y=-box.min.y*sc;
  obj.traverse(o=>{if(o.isMesh){o.castShadow=true;o.frustumCulled=false;}});
  scene.add(obj);
  const bones={};obj.traverse(o=>{if(o.isBone)bones[o.name]=o;});
  obj.updateWorldMatrix(true,true);
  rig=new PhysRig(bones,obj,{mass:78});
  dbg=makeDebug(rig);
  window.__rig=rig;

  const t0=performance.now();
  for(const[name,cfg] of MOVES){
    L(`loading clip ${name}…`);
    let clip;
    try{clip=(await load(`assets/anim/player/${cfg.clip||name}.fbx`)).animations[0];}
    catch(e){console.warn('no clip',name,e);continue;}
    rig.reset();obj.updateWorldMatrix(true,true);
    const ref=sampleClipTargets(clip,obj,rig,30);
    const trainer=new PhysTrainer(rig,ref,{oneshot:cfg.oneshot,T:cfg.oneshot?undefined:170,assistScale:cfg.assistScale,imWeight:cfg.imWeight,speedWeight:cfg.speedWeight,distWeight:cfg.distWeight,driven:cfg.driven,dirZ:cfg.dirZ,vertWeight:cfg.vertWeight,targetSpeed:cfg.targetSpeed,perturbMag:cfg.perturbMag,perturbK:2,contactWeight:cfg.contactWeight});
    const res=await trainer.cem({pop:cfg.pop,elite:9,iters:cfg.iters,seed:7,
      onIter:(it,r)=>L(`training ${name}… iter ${it+1}/${cfg.iters}  reward ${r.toFixed(3)}  (${((performance.now()-t0)/1000).toFixed(0)}s total)`)});
    const fid=trainer.fidelity(res.params);
    trained[name]={trainer,params:res.params,reward:res.reward,oneshot:cfg.oneshot,fid,clip:cfg.clip||name};
    // ui button per move
    const b=document.createElement('button');b.textContent=`${name} (${res.reward.toFixed(2)})`;
    b.onclick=()=>window.__move(name);document.getElementById('ui').appendChild(b);
    window.__T=window.__T||trainer;
  }
  window.__fid=()=>Object.fromEntries(Object.entries(trained).map(([n,t])=>[n,{fidelity:+(t.fid.fidelity*100).toFixed(0)+'%',errDeg:t.fid.meanErrDeg,motionDeg:t.fid.meanMotionDeg}]));
  // robustness: fraction of hit-directions the move survives without falling
  window.__robust=(mag=4.5)=>{const V=rig.byName.pelvis.pos.constructor;const out={};
    for(const[n,t] of Object.entries(trained)){const T=t.trainer;let ok=0,tot=0;
      for(let a=0;a<8;a++){const ang=a/8*2*Math.PI;rig.reset();rig.heading=null;
        const up0=rig.uprightK,hv0=rig.hoverK;rig.uprightK*=(T.assistScale||1);rig.hoverK*=(T.assistScale||1);
        let fell=false;const N=t.oneshot?T.T:Math.round(2.2/T.dt);
        for(let i=0;i<N;i++){let ph=T.freq*i*T.dt;if(!t.oneshot)ph%=1;T.drive(t.params,ph);rig.step(T.dt,T.substeps);
          if(i===Math.floor(N*0.3))rig.bodies.forEach(b=>{b.vel.x+=Math.cos(ang)*mag;b.vel.z+=Math.sin(ang)*mag;});
          if(rig.byName.pelvis.pos.y<rig.restPelvisY*0.5)fell=true;}
        rig.uprightK=up0;rig.hoverK=hv0;if(!fell)ok++;tot++;}
      out[n]=Math.round(ok/tot*100)+'%';}
    rig.reset();rig.heading=0;return out;};
  // measure actual physical motion each trained move produces
  window.__behavior=()=>{const V=rig.byName.pelvis.pos.constructor;const out={};
    for(const[n,t] of Object.entries(trained)){const T=t.trainer;rig.reset();rig.heading=null;
      const up0=rig.uprightK,hv0=rig.hoverK;rig.uprightK*=(t.trainer.assistScale||1);rig.hoverK*=(t.trainer.assistScale||1);
      const p0=rig.byName.pelvis.pos.clone();const f0=new V(0,0,1).applyQuaternion(rig.byName.pelvis.quat);
      const y0v=rig.restPelvisY;let peak=-9,minY=9;const N=t.oneshot?T.T:Math.round(2.5/T.dt);
      for(let i=0;i<N;i++){let ph=T.freq*i*T.dt;if(!t.oneshot)ph%=1;T.drive(t.params,ph);rig.step(T.dt,T.substeps);
        peak=Math.max(peak,rig.byName.pelvis.pos.y);minY=Math.min(minY,rig.byName.pelvis.pos.y);}
      const p1=rig.byName.pelvis.pos,f1=new V(0,0,1).applyQuaternion(rig.byName.pelvis.quat);
      const yaw0=Math.atan2(f0.x,f0.z),yaw1=Math.atan2(f1.x,f1.z);
      let dyaw=(yaw1-yaw0)*180/Math.PI;while(dyaw>180)dyaw-=360;while(dyaw<-180)dyaw+=360;
      out[n]={dz:+(p1.z-p0.z).toFixed(2),dx:+(p1.x-p0.x).toFixed(2),dyawDeg:+dyaw.toFixed(0),
        peakAboveRest:+(peak-y0v).toFixed(2),minY:+minY.toFixed(2),fell:minY<y0v*0.55};
      rig.uprightK=up0;rig.hoverK=hv0;}
    rig.reset();rig.heading=0;return out;};
  L(`all trained in ${((performance.now()-t0)/1000).toFixed(1)}s\n`+
    Object.entries(trained).map(([n,t])=>`${n}: R${t.reward.toFixed(2)} fid${(t.fid.fidelity*100).toFixed(0)}%`).join('  ')+
    `\nkeys: 1 walk  2 attack1  3 block  4 roll   __poke(x,y,z)`);
  document.getElementById('ui').style.display='block';
  window.__trained=trained;
  rig.reset();rig.heading=0;playing=true;

  let last=performance.now(),acc=0;
  (function loop(now){requestAnimationFrame(loop);
    const dt=Math.min((now-last)/1000,0.05);last=now;
    if(playing&&trained[cur]){acc+=dt;
      const t=trained[cur],T=t.trainer;
      while(acc>=T.dt){acc-=T.dt;
        phase+=T.dt*T.freq;
        if(t.oneshot&&phase>=1){cur='walk';phase=0;}
        else if(!t.oneshot)phase%=1;
        const tt=trained[cur];
        tt.trainer.drive(tt.params,phase);
        rig.step(tt.trainer.dt,tt.trainer.substeps);
        const p=rig.byName.pelvis;
        if(p.pos.z>12||p.pos.z<-12||Math.abs(p.pos.x)>12){rig.reset();rig.heading=0;phase=0;}
        if(p.pos.y<rig.restPelvisY*0.35){rig.reset();rig.heading=0;phase=0;cur='walk';}
      }
      if(showMesh)rig.applyToBones();
      dbg.update();
      const p=rig.byName.pelvis.pos;
      cam.position.set(p.x+2.8,1.5,p.z+3.2);cam.lookAt(p.x,1.0,p.z);
    }
    renderer.render(scene,cam);})(performance.now());
})().catch(e=>L('ERR '+e.message+'\n'+(e.stack||'')));

addEventListener('keydown',e=>{
  const k={Digit1:'walk',Digit2:'attack1',Digit3:'block',Digit4:'roll'}[e.code];
  if(k)window.__move(k);});
document.getElementById('save').onclick=()=>{
  const blob=new Blob([JSON.stringify(window.__weights())],{type:'application/json'});
  const a=document.createElement('a');a.href=URL.createObjectURL(blob);
  a.download='player_moves.json';a.click();
  localStorage.setItem('player_moves',JSON.stringify(window.__weights()));
};
</script></body></html>