diff --git a/game/README.md b/game/README.md index ca342ff..d9e400b 100644 --- a/game/README.md +++ b/game/README.md @@ -46,7 +46,10 @@ metadata is retained; reserved ledges and glide lifts do not become solid surfac Walk with A/D or left/right arrows, or a gamepad stick/D-pad. Holding the same direction for 0.4 seconds accelerates from 150.5 to 258 px/s (3.5 to 6 m/s). F2 shows speed, hold time, position and the 28×76 player body. The hero currently -uses the idle atlas pose on the ground; full animation switching is delivered by #31. +uses the supplied locomotion clips, switching walk/run at contact poses 00 and 04 +through the authored bridges. Playback is stepped at 12fps, retaining slower authored +idle and ladder timing, with takeoff and impact holds. After five seconds still, +one of the supplied idle variants plays. F2 shows the current clip and frame. Space/K or gamepad A/Cross jumps, with about 52px tap height and 96px held height, 0.1s coyote time and 0.12s buffering. Landing uses the drawn landing pose; falls of at least 152px use the four-frame hard landing. Authored walk polylines follow diff --git a/game/src/player/animation.test.ts b/game/src/player/animation.test.ts new file mode 100644 index 0000000..c429cb0 --- /dev/null +++ b/game/src/player/animation.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { catalogue, LocomotionAnimation, type MotionPose } from './animation'; +import type { AnimationTable } from '../stage/assets'; +// Test files live in src/player, two levels below the game root. +const realTables = import.meta.glob('../../public/assets/sprites/hero*.anims.json', { eager: true, import: 'default' }); +const clips = catalogue(Object.fromEntries(Object.entries(realTables).map(([path, table]) => [path.split('/').at(-1)!.replace('.anims.json', ''), table]))); +const tick = (machine: LocomotionAnimation, pose: MotionPose, frames: number) => { + for (let i = 0; i < frames; i++) machine.update(pose, 1 / 12); +}; +describe('stepped locomotion animation', () => { + it('uses the actual movement animation tables', () => { + expect(clips.walk.frames).toBe(8); expect(clips.run.frames).toBe(8); + expect(clips.land.start).toBe(8); + }); + it('plays idle-to-walk and only changes a walk cycle at a contact pose', () => { + const machine = new LocomotionAnimation(clips); + expect(machine.update('walk', 0).frame).toBe('idle-walk-00'); + tick(machine, 'walk', 4); expect(machine.clip).toBe('walk'); expect(machine.frame).toBe(2); + machine.update('run', 0); expect(machine.clip).toBe('walk'); + tick(machine, 'run', 2); expect(machine.clip).toBe('walk-run'); expect(machine.frame).toBe(0); + tick(machine, 'run', 2); expect(machine.clip).toBe('run'); expect(machine.frame).toBe(0); + tick(machine, 'run', 2); machine.update('idle', 0); expect(machine.clip).toBe('run'); + tick(machine, 'idle', 2); expect(machine.clip).toBe('walk-run'); expect(machine.reverse).toBe(true); + tick(machine, 'idle', 4); expect(machine.clip).toBe('idle'); + }); + it('steps at 12fps, holds anticipation and never scales or blends a frame', () => { + const machine = new LocomotionAnimation(clips); + expect(machine.update('jump', 1 / 12).frame).toBe('jump-00'); + expect(machine.update('jump', 1 / 12).frame).toBe('jump-01'); + expect(machine.update('jump', 1 / 120).frame).toBe('jump-01'); + expect(machine.update('fall', 0).frame).toBe('fall-00'); + expect(machine.update('land', 0)).toEqual({ board: 'hero-air', frame: 'jump-08' }); + }); + it('starts a random idle variant after five seconds and interrupts it for movement', () => { + const machine = new LocomotionAnimation(clips, () => 0.5); + tick(machine, 'idle', 59); expect(machine.clip).toBe('idle'); + tick(machine, 'idle', 2); expect(machine.clip).toBe('check-tablet'); + expect(machine.update('walk', 0).frame).toBe('idle-walk-00'); + }); + it('lets air and ladder poses interrupt a bridge immediately', () => { + const machine = new LocomotionAnimation(clips); + machine.update('walk', 0); + expect(machine.update('jump', 0).frame).toBe('jump-00'); + expect(machine.update('ladder', 0).frame).toBe('ladder-00'); + }); + it('uses the run-start clip when starting directly into a run', () => { + const machine = new LocomotionAnimation(clips); + expect(machine.update('run', 0).frame).toBe('run-start-00'); + tick(machine, 'run', 3); expect(machine.clip).toBe('run'); expect(machine.frame).toBe(0); + }); + it('finishes fallback bridges even if only the placeholder idle clip exists', () => { + const machine = new LocomotionAnimation(catalogue({ 'hero-move': { idle: { frames: 1, fps: 12, loop: true } } })); + tick(machine, 'walk', 8); expect(machine.clip).toBe('walk'); + expect(machine.update('walk', 0).frame).toBe('idle-00'); + }); +}); diff --git a/game/src/player/animation.ts b/game/src/player/animation.ts new file mode 100644 index 0000000..b7941c7 --- /dev/null +++ b/game/src/player/animation.ts @@ -0,0 +1,85 @@ +import type { AnimationClip, AnimationTable } from '../stage/assets'; +export interface Clip extends AnimationClip { board: string; name: string; start?: number } +export type Catalogue = Record; +export type MotionPose = 'idle' | 'walk' | 'run' | 'jump' | 'fall' | 'ladder' | 'land' | 'hard-landing'; +const VARIANTS = ['look-around', 'check-tablet', 'check-wounds']; +const LOOPS = ['idle', 'walk', 'run', 'fall', 'ladder']; +type Step = { clip: string; reverse?: boolean }; + +export function catalogue(tables: Record): Catalogue { + const clips: Catalogue = {}; + for (const [board, table] of Object.entries(tables)) for (const [name, clip] of Object.entries(table)) { + clips[name] ??= { ...clip, board, name }; + } + const jump = clips.jump; + clips.land = jump ? { ...jump, start: jump.frames - 1, frames: 1, fps: 6, loop: false } + : { ...clips.idle, frames: 1, fps: 6, loop: false }; + return clips; +} + +/** Idle keeps planted feet. Moving rows use contact/down/passing/up twice: contacts 00/04. */ +export class LocomotionAnimation { + clip = 'idle'; + frame = 0; + reverse = false; + private elapsed = 0; + private idleTime = 0; + private finished = false; + private queue: Step[] = []; + constructor(private clips: Catalogue, private random = Math.random) {} + private metadata() { return this.clips[this.clip] ?? this.clips.idle; } + private set(step: Step) { + this.clip = step.clip; this.reverse = !!step.reverse; + this.frame = this.reverse ? this.metadata().frames - 1 : 0; + this.elapsed = 0; this.finished = false; + } + private request(desired: MotionPose) { + if (!['idle', 'walk', 'run'].includes(desired)) { + if (this.clip !== desired) { this.queue = []; this.set({ clip: desired }); } + return; + } + if (this.queue.length) return; + if (VARIANTS.includes(this.clip) && desired === 'idle' && !this.finished) return; + if (['land', 'hard-landing'].includes(this.clip) && !this.finished) return; + if (!['idle', 'walk', 'run'].includes(this.clip)) this.set({ clip: 'idle' }); + if (this.clip === desired) return; + if (this.clip !== 'idle' && this.frame !== 0 && this.frame !== 4) return; + const plans: Record = { + 'idle:walk': [{ clip: 'idle-walk' }, { clip: 'walk' }], + 'idle:run': [{ clip: 'run-start' }, { clip: 'run' }], + 'walk:run': [{ clip: 'walk-run' }, { clip: 'run' }], + 'walk:idle': [{ clip: 'idle-walk', reverse: true }, { clip: 'idle' }], + 'run:walk': [{ clip: 'walk-run', reverse: true }, { clip: 'walk' }], + 'run:idle': [{ clip: 'walk-run', reverse: true }, { clip: 'idle-walk', reverse: true }, { clip: 'idle' }], + }; + const plan = plans[`${this.clip}:${desired}`]; + if (plan) { this.queue = [...plan]; this.set(this.queue.shift()!); } + } + private duration() { + const hold = this.clip === 'jump' && this.frame === 0 || this.clip === 'hard-landing' && this.frame === 0; + return (hold ? 2 : 1) / Math.min(12, this.metadata().fps); + } + private advance() { + const next = this.frame + (this.reverse ? -1 : 1); + if (next >= 0 && next < this.metadata().frames) { this.frame = next; return; } + if (this.queue.length) this.set(this.queue.shift()!); + else if (LOOPS.includes(this.clip) && this.metadata().loop) this.frame = 0; + else this.finished = true; + } + update(desired: MotionPose, dt: number) { + this.idleTime = desired === 'idle' ? this.idleTime + dt : 0; + this.request(desired); + if (this.clip === 'idle' && desired === 'idle' && this.idleTime >= 5) { + const available = VARIANTS.filter(name => this.clips[name]); + if (available.length) this.set({ clip: available[Math.min(available.length - 1, Math.floor(this.random() * available.length))] }); + this.idleTime = 0; + } + this.elapsed += Math.min(dt, 0.1); + for (let n = 0; n < 12 && !this.finished && this.elapsed + 1e-9 >= this.duration(); n++) { + const remainder = Math.max(0, this.elapsed - this.duration()); + this.advance(); this.request(desired); this.elapsed = remainder; + } + const metadata = this.metadata(); + return { board: metadata.board, frame: `${metadata.name}-${String((metadata.start ?? 0) + this.frame).padStart(2, '0')}` }; + } +} diff --git a/game/src/player/player.ts b/game/src/player/player.ts index eafe85a..9a48ba5 100644 --- a/game/src/player/player.ts +++ b/game/src/player/player.ts @@ -4,6 +4,7 @@ import { BODY_HEIGHT, BODY_WIDTH, overlaps, stepBody, type Body } from './body'; import { horizontal, move, movement } from './movement'; import { jump, jumpState, landing } from './jump'; import { climb, dropThrough, traversal } from './traversal'; +import { catalogue, LocomotionAnimation, type MotionPose } from './animation'; export class Player { readonly body: Body; @@ -17,11 +18,14 @@ export class Player { private spawn; private air; private traversal = traversal(); + private animation: LocomotionAnimation; get canAttack() { return this.traversal.ladder === null; } constructor(private scene: Phaser.Scene, private chunks: CollisionChunk[]) { this.spawn = chunks.flatMap(c => c.world.spawn).find(p => p.kind === 'player') ?? { x: 120, y: 461 }; this.body = { x: this.spawn.x, y: this.spawn.y, vx: 0, vy: 0, grounded: true }; this.air = jumpState(this.body.y); + this.animation = new LocomotionAnimation(catalogue(Object.fromEntries( + ['hero-move', 'hero-run', 'hero-idle', 'hero-air'].map(board => [board, scene.cache.json.get(`${board}.anims`)])))); this.sprite = scene.add.sprite(this.body.x, this.body.y, 'hero-move', 'idle-00').setOrigin(0.5, 1).setDepth(100); this.keys = scene.input.keyboard!.addKeys('A,D,W,S,LEFT,RIGHT,UP,DOWN,SPACE,K') as Record; this.debug = scene.add.graphics().setDepth(10001); @@ -66,18 +70,23 @@ export class Player { this.air = jumpState(this.body.y); this.traversal = traversal(); } - const pose = this.traversal.ladder ? `ladder-${String(Math.floor(this.body.y / 13) % 4).padStart(2, '0')}` - : this.air.landing > 0 ? this.air.hard - ? `hard-landing-${String(Math.min(3, Math.floor((4 / 12 - this.air.landing) * 12))).padStart(2, '0')}` - : 'jump-08' : this.body.sliding ? `fall-${String(Math.floor(this.scene.time.now * 12 / 1000) % 3).padStart(2, '0')}` - : !this.body.grounded ? this.body.vy < 0 ? 'jump-02' : 'fall-00' : null; - if (pose && this.scene.textures.get('hero-air').has(pose)) this.sprite.setTexture('hero-air', pose); + const desired: MotionPose = this.traversal.ladder ? 'ladder' : this.air.landing > 0 + ? this.air.hard ? 'hard-landing' : 'land' : this.body.sliding ? 'fall' + : !this.body.grounded ? this.body.vy < 0 ? 'jump' : 'fall' + : Math.abs(this.body.vx) > 180 ? 'run' : Math.abs(this.body.vx) > 5 ? 'walk' : 'idle'; + const pose = this.animation.update(desired, desired === 'ladder' && vertical === 0 ? 0 : milliseconds / 1000); + if (this.scene.textures.get(pose.board).has(pose.frame)) this.sprite.setTexture(pose.board, pose.frame); else this.sprite.setTexture('hero-move', 'idle-00'); this.sprite.setPosition(Math.round(this.body.x), Math.round(this.body.y)).setFlipX(this.motion.facing < 0); if (axis) this.lastSpeed = Math.abs(this.body.vx); this.debug.clear(); if (this.showDebug) this.debug.lineStyle(2, 0xffffff).strokeRect(this.body.x - BODY_WIDTH / 2, this.body.y - BODY_HEIGHT, BODY_WIDTH, BODY_HEIGHT); - this.label.setText(`vx ${this.body.vx.toFixed(1)} px/s | last ${this.lastSpeed.toFixed(1)} (${(this.lastSpeed / 43).toFixed(2)} m/s)\nheld ${this.motion.held.toFixed(2)}s | x ${this.body.x.toFixed(1)} y ${this.body.y.toFixed(1)}\njump ${this.air.height.toFixed(1)}px | ${this.traversal.ladder ? 'climb' : this.body.sliding ? 'slide' : 'walk'} slope ${(Math.atan(this.body.slope ?? 0) * 180 / Math.PI).toFixed(1)}° | land ${this.air.hard ? 'hard' : 'normal'}`); + this.label.setText([ + `vx ${this.body.vx.toFixed(1)} px/s | last ${this.lastSpeed.toFixed(1)} (${(this.lastSpeed / 43).toFixed(2)} m/s)`, + `held ${this.motion.held.toFixed(2)}s | x ${this.body.x.toFixed(1)} y ${this.body.y.toFixed(1)}`, + `jump ${this.air.height.toFixed(1)}px | ${this.traversal.ladder ? 'climb' : this.body.sliding ? 'slide' : 'walk'} slope ${(Math.atan(this.body.slope ?? 0) * 180 / Math.PI).toFixed(1)}°`, + `anim ${this.animation.clip} | ${pose.frame} | land ${this.air.hard ? 'hard' : 'normal'}`, + ]); } }