SuperHQ Motion experiment: locomotion and action checkpoints, runtime, clips, evaluation
6adbbb1 verified Download src/game-controller.js from superhq-ai/motion: direct link, hf CLI and curl.
- Browser
- Download file 18.5 kB
-
https://huggingface.co/superhq-ai/motion/resolve/main/src/game-controller.js
- Command line
-
hf download hf://superhq-ai/motion/src/game-controller.js
-
curl -L -o game-controller.js https://huggingface.co/superhq-ai/motion/resolve/main/src/game-controller.js
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; | |
| } | |
| } | |