From 2becff418c9b9984cbd99b85357a28ae61061e24 Mon Sep 17 00:00:00 2001 From: Vatsal2005 <83007841+Vatsal2005@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:30:03 +0200 Subject: [PATCH] feat: switch locomotion through authored bridge clips Use exported clips and contact poses for the idle, walk and run transitions. Reverse the supplied bridges for stopping, and retain stepped playback and strong takeoff and impact poses without scaling sprites. Choose an idle variant after five seconds still and expose the current clip and frame in the debug overlay. Test transitions against the real tables and retain placeholder fallback behavior. --- game/README.md | 5 +- game/src/player/animation.test.ts | 56 ++++++++++++++++++++ game/src/player/animation.ts | 85 +++++++++++++++++++++++++++++++ game/src/player/player.ts | 23 ++++++--- 4 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 game/src/player/animation.test.ts create mode 100644 game/src/player/animation.ts 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'}`, + ]); } }