// Game-side intent, transition routing and heading. The network still generates every pose. export function transitionPath(config, from, to) { if (!config.profiles[from] || !config.profiles[to]) throw Error('Unknown motion profile.'); const queue = [[from]], seen = new Set([from]); while (queue.length) { const path = queue.shift(), last = path.at(-1); if (last === to) return path.slice(1); for (const [a, b] of config.transitions) { const next = a === last ? b : b === last ? a : null; if (next && !seen.has(next)) { seen.add(next); queue.push([...path, next]); } } } throw Error('No validated transition path.'); } export class PlaygroundDriver { constructor(controller) { this.controller = controller; this.reset(); } reset() { this.controller.reset(); this.position = [0, 0, 0]; this.heading = 0; this.lastSimulation = [0, 0, 0]; this.desired = 'Idle_Loop'; this.path = []; this.events = []; this.frames = 0; } step({moving = false, crouch = false, jog = false, sprint = false, style = 'natural', heading = this.heading} = {}) { if (!Number.isFinite(heading) || !['natural', 'carry', 'zombie'].includes(style)) throw Error('Invalid playground intent.'); const c = this.controller; this.desired = crouch ? (moving ? 'Crouch_Fwd_Loop' : 'Crouch_Idle_Loop') : style === 'zombie' ? (moving ? 'Zombie_Walk_Fwd_Loop' : 'Zombie_Idle_Loop') : !moving ? 'Idle_Loop' : style === 'carry' ? 'Walk_Carry_Loop' : sprint ? 'Sprint_Loop' : jog ? 'Jog_Fwd_Loop' : 'Walk_Loop'; // Recompute from current target so rapid input changes never reset phase or interrupt a blend. this.path = transitionPath(c.config, c.profile, this.desired); // Never start walking solely to satisfy a stationary stance/style request. // Missing idle-to-idle edges remain pending until the player actually moves. const waitingForMovement = !moving && this.path.length > 0 && c.config.profiles[c.profile].gait === 'idle' && this.path.some(name => c.config.profiles[name].gait !== 'idle') && c.clock.time - c.clock.start >= c.clock.transitionSeconds - 1e-8; if (this.path.length && !waitingForMovement && c.clock.time - c.clock.start >= c.clock.transitionSeconds - 1e-8) { const from = c.profile, to = this.path[0]; c.setProfile(to); this.events.push({frame: this.frames, from, to}); if (this.events.length > 100) this.events.shift(); } if (moving) { const delta = Math.atan2(Math.sin(heading - this.heading), Math.cos(heading - this.heading)); this.heading += Math.max(-.075, Math.min(.075, delta)); } const pose = c.step(), dx = pose.simulationPosition[0] - this.lastSimulation[0], dz = pose.simulationPosition[2] - this.lastSimulation[2]; const sin = Math.sin(this.heading), cos = Math.cos(this.heading); this.position[0] += dx * cos + dz * sin; this.position[2] += dz * cos - dx * sin; this.lastSimulation = pose.simulationPosition.slice(); this.frames++; // Keep inference in the canonical forward frame; the host transforms the resulting pose. const root = pose.rootPosition.map((v, i) => v - pose.simulationPosition[i]); return {...pose, rootPosition: root, worldPosition: this.position.slice(), heading: this.heading, desired: this.desired, path: transitionPath(c.config, c.profile, this.desired), waitingForMovement: !!waitingForMovement, blending: c.clock.time - c.clock.start < c.clock.transitionSeconds - 1e-8}; } }