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;
}
}
|