File size: 18,510 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
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
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
import {PlaygroundDriver} from './game-driver.js';
import {MotionProfileController} from './profile-controller.js';
import {MovementNetwork} from './movement-network.js';
import {MotionActions,TestPhysics} from './action-controller.js';
import {blendPoses,worldPositions} from './pose.js';
import {StrideJump} from './stride-jump.js';
import {ClipLibrary,PoseOffset,ONE_SHOT_ACTIONS,rotY,yawQuaternion} from './clip-actions.js';

const DT=1/60;
const smooth=t=>{t=Math.max(0,Math.min(1,t));return t*t*t*(10+t*(-15+6*t));};
const mulYaw=(h,q)=>{const y=yawQuaternion(h);return [y[3]*q[0]+y[0]*q[3]+y[1]*q[2]-y[2]*q[1],y[3]*q[1]-y[0]*q[2]+y[1]*q[3]+y[2]*q[0],y[3]*q[2]+y[0]*q[1]-y[1]*q[0]+y[2]*q[3],y[3]*q[3]-y[0]*q[0]-y[1]*q[1]-y[2]*q[2]];};
const copy=p=>({...p,rootPosition:p.rootPosition.slice(),localQuaternions:p.localQuaternions.map(q=>q.slice())});

/** Neural locomotion and action poses, composed by a game-side controller.
 * Running jumps (movingJump 'stride', the default) extend the locomotion stride:
 * takeoff from a planted support foot, a time-warped stride in flight and a
 * spring-damped landing (see stride-jump.js). movingJump 'prior' keeps the
 * earlier behavior that blends toward the stationary jump prior. Horizontal
 * momentum, facing, timing and gravity are runtime behavior, not learned.
 * Call step at 60 Hz. Supply a physics host with state(), launch() and step()
 * to replace the flat-ground example host. Horizontal collision resolution is
 * optional and cannot move the host vertically.
 *
 * One-shot actions (dash, lunge, knockback with get-up, ledge climb) replay
 * authored CC0 clips with offset blending into and out of locomotion; see
 * clip-actions.js. Climbing needs a host with place(height).
 */
export class MotionGameController {
  constructor(locomotion,actions,{physics=new TestPhysics(),movingJump='stride',strideJump={},clips=null}={}) {
    if(clips!==null&&!(clips instanceof ClipLibrary))throw Error('clips must be a ClipLibrary.');
    this.clips=clips;
    if(JSON.stringify(locomotion.rig)!==JSON.stringify(actions.rig))throw Error('Action and locomotion rigs differ.');
    if(!['stride','prior'].includes(movingJump))throw Error('movingJump must be stride or prior.');
    this.movingJumpMode=movingJump;this.strideOptions=strideJump;
    this.locomotion=new PlaygroundDriver(locomotion);this.actions=actions;this.physics=physics;this.rig=locomotion.rig;
    this.strideCache=new StrideJump();if(movingJump==='stride')this.strideCache.prepare(locomotion);this.reset();
  }
  static async load(profileURL,actionURL,{fetch:fetcher=globalThis.fetch,clipsURL=null,...options}={}) {
    const actions=new URL(actionURL,globalThis.location?.href);
    const [locomotion,network,response]=await Promise.all([
      MotionProfileController.load(profileURL,{fetch:fetcher}),
      MovementNetwork.load(actions,{fetch:fetcher}),
      fetcher(new URL('movement.json',actions))
    ]);
    if(!response.ok)throw Error('Cannot load action contract.');
    const manifest=await response.json();
    const clips=clipsURL?await ClipLibrary.load(clipsURL,locomotion.rig,{fetch:fetcher}):null;
    return new MotionGameController(locomotion,new MotionActions(network,locomotion.rig,{contract:manifest.action_contract}),{...options,clips});
  }
  reset() {
    this.locomotion.reset();this.actions.reset();
    if(typeof this.physics.reset!=='function')throw Error('Physics host must implement reset().');
    this.physics.reset();
    this.position=[0,0,0];this.velocity=[0,0];this.flightVelocity=[0,0];this.heading=0;
    this.frames=0;this.weight=0;this.mixStart=0;this.mixTarget=0;this.mixElapsed=0;
    this.blendLift=0;
    const tuckPose=this.clips?.has('NinjaJump_Idle_Loop')?this.clips.sample('NinjaJump_Idle_Loop',0):null;
    this.movingJump=false;this.stride=new StrideJump({tuckPose,...this.strideOptions});this.stride.cache=this.strideCache.cache;
    this.jumpQueued=false;this.stanceActive=false;this.lastIntent={};this.pose=null;this.events=[];
    this.oneShot=null;this.pendingExit=null;this.exitOffset=null;
  }
  /** Available one-shot action names (requires a clip library). */
  get oneShotActions(){return this.clips?Object.keys(ONE_SHOT_ACTIONS).filter(n=>ONE_SHOT_ACTIONS[n].clips.every(c=>this.clips.has(c))):[];}
  /** Start a one-shot action. knockback takes direction [x,z] (world push
   * direction); climb takes ledge {height} in metres (0.85 to 1.25) with the
   * ledge front about ledgeFront metres ahead. Returns false when unavailable.
   */
  requestAction(name,{direction=null,ledge=null}={}) {
    const spec=ONE_SHOT_ACTIONS[name],intent=this.lastIntent;
    if(!spec||!this.oneShotActions.includes(name)||this.oneShot||this.jumpQueued||this.stride.active||
       this.actions.mode!=='stance'||!this.physics.state().grounded||intent.crouch||this.actions.depth>.05||
       (intent.style??'natural')!=='natural')return false;
    let heading=this.heading,rise=0,scale=1;
    if(name==='knockback'&&direction){
      if(!Array.isArray(direction)||direction.length!==2||!direction.every(Number.isFinite)||Math.hypot(...direction)<1e-6)throw Error('Invalid knockback direction.');
      heading=Math.atan2(-direction[0],-direction[1]);
    }
    if(spec.ledge){
      const meta=this.clips.clips[spec.clips[0]];
      if(!ledge||!Number.isFinite(ledge.height)||ledge.height<.85||ledge.height>1.25||typeof this.physics.place!=='function')return false;
      rise=ledge.height;scale=ledge.height/meta.ledge_height_m;
    }
    const first=this.clipPose(spec.clips[0],0,{scale});
    let offset=null;
    if(this.pose){
      // Re-express the shown pose in the action's facing so nothing pops.
      const d=this.heading-heading,from={rootPosition:rotY(this.pose.rootPosition,d),
        localQuaternions:this.pose.localQuaternions.map((q,i)=>i?q:mulYaw(d,q))};
      offset=new PoseOffset(from,first,.2);
    }
    this.oneShot={name,spec,index:0,t:0,heading,rise,scale,offset,previousRoot:null};
    this.heading=heading;this.events.push({frame:this.frames,event:'action',name});
    return true;
  }
  clipPose(name,t,{scale=1}={}) {
    const p=this.clips.sample(name,t),y0=this.clips.clips[name].root[0][1];
    return {rootPosition:[0,y0+(p.rootPosition[1]-y0)*scale,0],localQuaternions:p.localQuaternions,authoredRoot:p.rootPosition};
  }
  stepOneShot(intent,{resolveHorizontal}) {
    const s=this.oneShot,host=this.physics.state();
    let name=s.spec.clips[s.index];
    if(s.t>this.clips.duration(name)+1e-9){
      if(s.index+1<s.spec.clips.length){
        // Chained clips share the authored contact pose; a short offset covers small seams.
        s.index++;s.t=0;name=s.spec.clips[s.index];s.previousRoot=null;
        s.offset=new PoseOffset(this.pose,this.clipPose(name,0,{scale:s.scale}),.12);
      }else return this.finishOneShot(intent,{resolveHorizontal});
    }
    const clip=this.clipPose(name,s.t,{scale:s.scale}),root=clip.authoredRoot;
    const delta=s.previousRoot?rotY([root[0]-s.previousRoot[0],0,root[2]-s.previousRoot[2]],s.heading):[0,0,0];
    s.previousRoot=root;s.t+=1/60;
    const old=this.position.slice();
    this.position[0]+=delta[0];this.position[2]+=delta[2];
    // Climbing passes over the ledge front, so collision is not resolved during it.
    if(resolveHorizontal&&!s.spec.ledge){
      const resolved=resolveHorizontal(this.position.slice(),{previous:old,grounded:true});
      if(!Array.isArray(resolved)||resolved.length!==3||!resolved.every(Number.isFinite)||resolved[1]!==this.position[1])throw Error('Expected collision-resolved horizontal position.');
      this.position=resolved.slice();
    }
    this.velocity=[(this.position[0]-old[0])*60,(this.position[2]-old[2])*60];this.position[1]=host.position[1];
    let canonical={rootPosition:clip.rootPosition,localQuaternions:clip.localQuaternions};
    if(s.offset){canonical=s.offset.apply(canonical);if(s.offset.done)s.offset=null;}
    const world=worldPositions(canonical,this.rig);
    canonical.contacts=[[21,22],[26,27]].map(js=>Number(Math.min(...js.map(j=>world[j][1]))<.04));
    this.pose={...canonical,worldPosition:this.position.slice(),heading:this.heading,speed:Math.hypot(...this.velocity),
      grounded:true,verticalVelocity:0,mode:'action',action:s.name,actionClip:name,actionWeight:0,depth:0,
      rawComposedPose:copy(canonical),blendCorrection:{root_lift_mm:0,requested_mm:0,saturated:false},
      waitingForMovement:false,profile:name,path:[],blending:true};
    this.frames++;this.physics.step();
    if(this.events.length>100)this.events.shift();
    return this.pose;
  }
  finishOneShot(intent,options) {
    const s=this.oneShot,last=copy(this.pose);
    if(s.rise){
      // The host now stands on the ledge; express the last pose relative to it.
      this.physics.place(this.physics.state().position[1]+s.rise);last.rootPosition[1]-=s.rise;
    }
    this.oneShot=null;this.events.push({frame:this.frames,event:'action_end',name:s.name});
    const moving=s.spec.exit==='moving'&&!!intent.moving&&!intent.crouch;
    const profile=moving?(intent.sprint?'Sprint_Loop':intent.jog?'Jog_Fwd_Loop':'Walk_Loop'):'Idle_Loop';
    const c=this.locomotion.controller,position=c.clock.position.slice();
    c.reset({profile,phase:moving?this.stride.matchPhase(profile,last,this.rig):0,position});
    this.locomotion.lastSimulation=position;this.locomotion.heading=this.heading;
    this.actions.reset({phase:0});this.stanceActive=false;this.blendLift=0;
    const w=moving?0:1;this.weight=this.mixStart=this.mixTarget=w;this.mixElapsed=1;
    this.pendingExit=last;
    return this.step(intent,options);
  }
  requestJump(currentIntent={}) {
    const intent={...this.lastIntent,...currentIntent};
    if(this.oneShot||this.jumpQueued||this.stride.active||this.actions.mode!=='stance'||!this.physics.state().grounded||intent.crouch||
       this.actions.depth>.05||this.actions.goal>.05||(intent.style??'natural')!=='natural')return false;
    this.jumpQueued=true;return true;
  }
  step(intent={}, {resolveHorizontal}={}) {
    if(resolveHorizontal!==undefined&&typeof resolveHorizontal!=='function')throw Error('Invalid horizontal resolver.');
    this.lastIntent={...intent};
    if(this.oneShot)return this.stepOneShot(intent,{resolveHorizontal});
    const moving=!!intent.moving,style=intent.style??'natural',crouch=!!intent.crouch;
    let host=this.physics.state();
    if(!Array.isArray(host.position)||host.position.length!==3||!host.position.every(Number.isFinite))throw Error('Invalid host position.');
    const flight=!host.grounded;
    if(moving&&this.stanceActive&&this.actions.mode==='stance'){
      // Align the hidden locomotion clock before leaving a stationary stance.
      // Host travel and gait phase continue; no intermediate walking is used
      // to perform an idle-to-crouch change.
      const controller=this.locomotion.controller,position=controller.clock.position.slice();
      controller.reset({profile:crouch?'Crouch_Idle_Loop':'Idle_Loop',phase:this.actions.phase,position});
      this.locomotion.lastSimulation=position;
    }
    const strideActive=this.stride.active;
    if(strideActive)this.stride.beforeLocomotion(this.locomotion.controller.clock,host);
    const previous=this.locomotion.position.slice();
    // A fixed takeoff heading and velocity prevent camera input from swivelling
    // a character or erasing its momentum during flight.
    const l=this.locomotion.step({...intent,heading:flight?this.heading:(intent.heading??this.heading)});
    this.locomotionPose=l;
    let groundVelocity=[(this.locomotion.position[0]-previous[0])/DT,(this.locomotion.position[2]-previous[2])/DT];
    const stationary=!moving&&l.speed<.03&&style==='natural';
    if(this.jumpQueued){
      this.jumpQueued=false;
      const physics={impulse:this.physics.impulse??3.5,gravity:this.physics.gravity??9.81};
      if(!crouch&&style==='natural'&&this.movingJumpMode==='stride'&&moving&&Math.hypot(...groundVelocity)>=2.5&&
         this.stride.start(this.locomotion.controller,{...physics,previousProfile:this.locomotion.events.at(-1)?.from})){
        // Takeoff waits for a support foot; the stride continues meanwhile.
        this.movingJump=true;this.events.push({frame:this.frames,event:'jump'});
      }else if(!crouch&&style==='natural'){
        if(!this.stanceActive)this.actions.reset({phase:l.phase});
        if(this.actions.requestJump()){
          this.events.push({frame:this.frames,event:'jump'});
          this.movingJump=moving&&Math.hypot(...groundVelocity)>.5;
          if(this.movingJump&&this.physics.launch()){
            // The locomotion stride supplies moving takeoff. The pending action
            // request sees the host leave the ground and enters extension,
            // without first replacing the stride with stationary anticipation.
            this.flightVelocity=groundVelocity.slice();this.heading=l.heading;
            host=this.physics.state();
            this.events.push({frame:this.frames,event:'takeoff',velocity:this.flightVelocity.slice()});
          }
        }
      }
    }
    if(stationary&&!this.stanceActive&&this.actions.mode==='stance'){
      this.actions.reset({phase:l.phase});
      this.actions.depth=this.actions.goal=l.flags[5];
    }
    this.actions.setGoal({crouchDepth:this.actions.mode==='stance'&&stationary?Number(crouch):0});
    // The stationary prior idles in stance while a stride jump owns the pose.
    const a=this.actions.step(this.stride.active?{verticalVelocity:0,grounded:true}:{verticalVelocity:host.verticalVelocity,grounded:host.grounded});
    const action=a.mode!=='stance';
    this.stanceActive=stationary&&!action;
    if(flight)groundVelocity=this.flightVelocity.slice();
    else if(a.mode==='landing'){
      const u=smooth(this.actions.elapsed/.35);
      groundVelocity=groundVelocity.map((v,i)=>this.flightVelocity[i]*(1-u)+v*u);
    }
    const oldPosition=this.position.slice();
    this.position[0]+=groundVelocity[0]*DT;this.position[2]+=groundVelocity[1]*DT;
    if(resolveHorizontal){
      const proposed=this.position.slice();
      const resolved=resolveHorizontal(this.position.slice(),{previous:this.pose?.worldPosition?.slice()??[0,0,0],grounded:host.grounded});
      if(!Array.isArray(resolved)||resolved.length!==3||!resolved.every(Number.isFinite)||resolved[1]!==this.position[1])throw Error('Expected collision-resolved horizontal position.');
      this.position=resolved.slice();
      // Colliding in air also removes blocked momentum on subsequent frames.
      if(flight)this.flightVelocity=[0,2].map((axis,i)=>Math.abs(resolved[axis]-proposed[axis])>1e-10?0:this.flightVelocity[i]);
    }
    groundVelocity=[(this.position[0]-oldPosition[0])/DT,(this.position[2]-oldPosition[2])/DT];
    this.velocity=groundVelocity;
    this.position[1]=host.position[1];
    if(!flight)this.heading=l.heading;
    if(a.takeoffRequested){
      if(this.physics.launch()){
        this.flightVelocity=groundVelocity.slice();
        this.events.push({frame:this.frames,event:'takeoff',velocity:this.flightVelocity.slice()});
      }
    }
    let target=stationary||action?1:0;
    if(a.mode==='landing'&&moving)target=this.movingJump?1-smooth((this.actions.elapsed-.08)/.32):1-smooth((this.actions.elapsed-.30)/.25);
    if(a.mode==='landing'&&moving){this.weight=target;this.mixTarget=target;this.mixStart=target;this.mixElapsed=0;}
    else {
      if(target!==this.mixTarget){this.mixStart=this.weight;this.mixTarget=target;this.mixElapsed=0;}
      this.mixElapsed+=DT;
      this.weight=this.mixStart+(this.mixTarget-this.mixStart)*smooth(this.mixElapsed/.24);
    }
    let canonical=blendPoses(l,a,this.weight);
    let strideState=this.stride.state;
    if(strideState){
      const result=this.stride.apply(canonical,{host,position:this.position,heading:this.heading,rig:this.rig});
      canonical=result.pose;
      if(result.launch&&this.physics.launch()){
        this.flightVelocity=groundVelocity.slice();this.heading=l.heading;
        this.events.push({frame:this.frames,event:'takeoff',velocity:this.flightVelocity.slice()});
      }
    }
    if(this.pendingExit){this.exitOffset=new PoseOffset(this.pendingExit,canonical,.3);this.pendingExit=null;}
    if(this.exitOffset){canonical={...canonical,...this.exitOffset.apply(canonical)};if(this.exitOffset.done)this.exitOffset=null;}
    const rawComposedPose=copy(canonical);
    // Quaternion interpolation does not preserve foot height. At a grounded
    // blend, keep the composed skeleton above the contact plane. This changes
    // the canonical pelvis offset, never the collision-resolved host root.
    const world=worldPositions(canonical,this.rig);
    const minimum=Math.min(...[21,22,26,27].map(i=>world[i][1]));
    const required=host.grounded&&this.weight>0?Math.max(0,.005-minimum):0;
    // Release a lift gradually as a swinging foot clears the floor; removing
    // it immediately creates a vertical bump even when both poses are valid.
    this.blendLift=Math.min(.12,Math.max(required,this.blendLift*Math.exp(-DT/.08)));
    if(this.blendLift<1e-6)this.blendLift=0;
    const lift=this.blendLift;canonical.rootPosition[1]+=lift;
    // Contact output reflects collision state, even when pose interpolation is
    // still finishing the takeoff. No planted feet are advertised in flight.
    if(!host.grounded)canonical.contacts=[0,0];
    this.pose={...l,...canonical,worldPosition:this.position.slice(),heading:this.heading,
      speed:Math.hypot(...groundVelocity),grounded:host.grounded,verticalVelocity:host.verticalVelocity,
      mode:strideState?{approach:'takeoff',push:'takeoff',flight:'air',land:'landing'}[strideState]:a.mode,stridePhase:strideState,actionWeight:this.weight,depth:a.depth,rawActionPose:copy(a.rawPose),actionCorrection:a.correction,
      rawComposedPose,blendCorrection:{root_lift_mm:lift*1000,requested_mm:required*1000,saturated:required>.12},
      waitingForMovement:stationary?false:l.waitingForMovement,
      profile:strideState?{approach:'Jump_Start',push:'Jump_Start',flight:'Jump_Air',land:'Jump_Land'}[strideState]:action?({anticipation:'Jump_Start',extension:'Jump_Rise',air:'Jump_Air',landing:'Jump_Land'}[a.mode]):stationary?(a.depth>.5?'Crouch_Idle_Loop':'Idle_Loop'):l.profile,
      path:stationary?[]:l.path,blending:action||!!strideState||this.mixElapsed<.24||l.blending};
    this.frames++;this.physics.step();
    if(this.events.length>100)this.events.shift();
    return this.pose;
  }
}