File size: 8,133 Bytes
6adbbb1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { decodeMotionPose } from './controller.js';
import { worldPositions } from './pose.js';
import { ActionStabilizer,plantToes } from './action-stabilizer.js';

const DT=1/60,ANTICIPATION=.24,EXTENSION=.50,LANDING=.55;
const clamp=(x,a,b)=>Math.max(a,Math.min(b,x));
const vector=(x,n)=>(Array.isArray(x)||ArrayBuffer.isView(x))&&x.length===n&&Array.from(x).every(Number.isFinite);

/** Stationary action pose controller. The host owns gravity and collisions.
 * Features require superhq-motion-actions-v2 weights. No custom skills or clips
 * are loaded by this runtime. MotionGameController composes these canonical
 * poses with locomotion and horizontal game physics.
 */
export class MotionActions {
  constructor(network,rig,{contract,phase=0,runtimeCorrections=true}={}) {
    if(contract!=='superhq-motion-actions-v2'||!Number.isFinite(phase))throw Error('Expected v2 action weights.');
    this.network=network;this.rig=rig;this.runtimeCorrections=runtimeCorrections;this.reset({phase});
  }
  reset({phase=0}={}) {
    if(!Number.isFinite(phase))throw Error('Invalid phase.');
    this.phase=((phase%1)+1)%1;this.mode='stance';this.elapsed=0;this.airElapsed=0;
    this.depth=0;this.depthVelocity=0;this.goal=0;this.response=.65;
    this.wasGrounded=true;this.lastVerticalVelocity=0;this.takeoffSent=false;this.pendingJump=false;
    this.stabilizer=new ActionStabilizer();this.plantWeight=1;this.previousMode=null;this.goalChanged=false;
    const x=this.features();x[34]=0;
    const pose=decodeMotionPose(this.network.forward(x),this.rig);
    const world=worldPositions(pose,this.rig);
    this.anchors=[world[22].slice(),world[27].slice()];this.jumpAnchorMean=this.anchors[0].map((v,i)=>(v+this.anchors[1][i])/2);
  }
  setGoal({crouchDepth,responseSeconds=.65}) {
    if(!Number.isFinite(crouchDepth)||crouchDepth<0||crouchDepth>1||
        !Number.isFinite(responseSeconds)||responseSeconds<.5||responseSeconds>.9)throw Error('Invalid crouch goal.');
    this.goalChanged ||= crouchDepth!==this.goal||responseSeconds!==this.response;
    this.goal=crouchDepth;this.response=responseSeconds;
  }
  requestJump({horizontalSpeed=0}={}) {
    if(!Number.isFinite(horizontalSpeed)||horizontalSpeed<0)throw Error('Invalid movement speed.');
    if(this.mode!=='stance'||!this.wasGrounded||this.depth>.05||this.goal>.05||horizontalSpeed>.05||this.pendingJump)return false;
    this.pendingJump=true;return true;
  }
  features() {
    const x=new Float32Array(48);x[3]=1;for(const j of [9,15,21])x[j]=1;
    x.set([Math.sin(this.phase*2*Math.PI),Math.cos(this.phase*2*Math.PI),.4],22);x[25]=1;x[26]=1;x[41]=1;
    return x;
  }
  step({position=[0,0,0],verticalVelocity=0,grounded=true}={}) {
    if(!vector(position,3)||!Number.isFinite(verticalVelocity)||typeof grounded!=='boolean')throw Error('Invalid host physics state.');
    if(this.pendingJump){this.mode='anticipation';this.elapsed=0;this.takeoffSent=false;this.pendingJump=false;}
    if(!grounded&&this.wasGrounded) {
      this.mode=this.mode==='anticipation'?'extension':'air';this.airElapsed=0;this.elapsed=0;
    }
    if(grounded&&!this.wasGrounded) {
      this.mode='landing';this.elapsed=0;this.landingEntry=clamp(this.airElapsed/EXTENSION,0,1);this.impactVelocity=this.lastVerticalVelocity;
    }
    if(this.mode==='extension'&&this.airElapsed>=EXTENSION)this.mode='air';
    if(this.mode==='landing'&&this.elapsed>=LANDING){this.mode='stance';this.depth=0;this.depthVelocity=0;}
    const x=this.features();let takeoffRequested=false;
    if(this.mode==='stance') {
      const omega=3.89/this.response,error=this.depth-this.goal,cross=this.depthVelocity+omega*error,decay=Math.exp(-omega*DT);
      this.depth=this.goal+(error+cross*DT)*decay;this.depthVelocity=(this.depthVelocity-omega*cross*DT)*decay;
      x[30]=this.depth;x[34]=1;x[38]=this.depthVelocity;x[39]=this.depth;x.set(this.anchors.flat(),42);
    } else {
      x.set(this.jumpAnchorMean,45);
      if(this.mode==='anticipation') {
        x[35]=1;x[38]=Math.min(this.elapsed,ANTICIPATION)/(ANTICIPATION+EXTENSION);
        if(this.elapsed>=ANTICIPATION&&!this.takeoffSent){takeoffRequested=true;this.takeoffSent=true;}
      } else if(this.mode==='extension') {
        x[35]=1;x[38]=(ANTICIPATION+this.airElapsed)/(ANTICIPATION+EXTENSION);
      } else if(this.mode==='air') {
        x[36]=1;x[38]=clamp((this.airElapsed-EXTENSION)/.25,0,1);
      } else {
        x[37]=1;x[38]=clamp(this.elapsed/LANDING,0,1);x[42]=this.landingEntry;x[43]=.38;x[44]=this.impactVelocity;
      }
    }
    x[40]=verticalVelocity;x[41]=Number(grounded);
    const output=this.network.forward(x),raw=decodeMotionPose(output,this.rig);
    let canonical=raw,correction={transition:null,feet:[]};
    if(this.runtimeCorrections){
      const changed=this.previousMode!==null&&(this.previousMode!==this.mode||this.wasGrounded!==grounded||this.goalChanged);
      let next=raw;
      if(changed){
        const preview=x.slice(),phase=this.phase+.4*DT;preview[22]=Math.sin(phase*2*Math.PI);preview[23]=Math.cos(phase*2*Math.PI);
        if(this.mode==='stance'){
          const omega=3.89/this.response,error=this.depth-this.goal,cross=this.depthVelocity+omega*error,decay=Math.exp(-omega*DT);
          preview[30]=preview[39]=this.goal+(error+cross*DT)*decay;preview[38]=(this.depthVelocity-omega*cross*DT)*decay;
        }else if(this.mode==='anticipation'||this.mode==='extension')preview[38]=Math.min(1,x[38]+DT/(ANTICIPATION+EXTENSION));
        else if(this.mode==='air')preview[38]=Math.min(1,x[38]+DT/.25);
        else if(this.mode==='landing')preview[38]=Math.min(1,x[38]+DT/LANDING);
        next=decodeMotionPose(this.network.forward(preview),this.rig);
      }
      const filtered=this.stabilizer.apply(raw,next,changed);canonical=filtered.pose;correction.transition=filtered.correction;
      this.plantWeight+=(Number(this.mode==='stance'&&grounded)-this.plantWeight)*(1-Math.exp(-DT/.08));
      if(!grounded)this.plantWeight=0;
      if(this.plantWeight>.001){const planted=plantToes(canonical,this.rig,this.anchors,this.plantWeight);canonical=planted.pose;correction.feet=planted.details;}
    }
    const pose={...canonical,rootPosition:canonical.rootPosition.map((v,i)=>v+position[i])};
    const rawPose={...raw,rootPosition:raw.rootPosition.map((v,i)=>v+position[i])};
    const result={...pose,rawPose,correction,input:x,mode:this.mode,takeoffRequested,depth:this.depth,phase:this.phase,hostPosition:Array.from(position)};
    this.phase=(this.phase+.4*DT)%1;this.elapsed+=DT;if(!grounded)this.airElapsed+=DT;
    this.previousMode=this.mode;this.goalChanged=false;this.wasGrounded=grounded;this.lastVerticalVelocity=verticalVelocity;
    return result;
  }
}

/** Example host physics only. Games should supply their own collision state. */
export class TestPhysics {
  constructor({impulse=3.5,gravity=9.81,platformHeight=0}={}) {
    if(![impulse,gravity,platformHeight].every(Number.isFinite)||impulse<=0||gravity<=0||platformHeight<0)throw Error('Invalid test physics.');
    this.impulse=impulse;this.gravity=gravity;this.initialPlatform=platformHeight;this.reset();
  }
  reset(){this.platformHeight=this.initialPlatform;this.position=[0,0,0];this.velocity=0;this.grounded=true;this.takeoffs=0;this.landings=0;}
  state(){return {position:this.position.slice(),verticalVelocity:this.velocity,grounded:this.grounded};}
  /** Stand the host on a surface at height (used after a ledge climb). */
  place(height){if(!Number.isFinite(height)||height<0)throw Error('Invalid ground height.');this.position=[0,height,0];this.velocity=0;this.grounded=true;this.platformHeight=height;}
  launch(){if(!this.grounded)return false;this.velocity=this.impulse;this.grounded=false;this.takeoffs++;return true;}
  step(){if(this.grounded)return;const previous=this.position[1];this.position[1]+=this.velocity*DT-.5*this.gravity*DT*DT;this.velocity-=this.gravity*DT;
    const floor=this.velocity<0&&previous>=this.platformHeight?this.platformHeight:0;
    if(this.position[1]<=floor&&this.velocity<0){this.position[1]=floor;this.velocity=0;this.grounded=true;this.landings++;}}
}