motion / src /game-controller.js
harshthedev's picture
SuperHQ Motion experiment: locomotion and action checkpoints, runtime, clips, evaluation
6adbbb1 verified
Raw History Blame Contribute Delete
18.5 kB
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;
}
}