Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion game/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions game/src/player/animation.test.ts
Original file line number Diff line number Diff line change
@@ -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<AnimationTable>('../../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');
});
});
85 changes: 85 additions & 0 deletions game/src/player/animation.ts
Original file line number Diff line number Diff line change
@@ -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<string, Clip>;
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<string, AnimationTable>): 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<string, Step[]> = {
'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')}` };
}
}
23 changes: 16 additions & 7 deletions game/src/player/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, Phaser.Input.Keyboard.Key>;
this.debug = scene.add.graphics().setDepth(10001);
Expand Down Expand Up @@ -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'}`,
]);
}
}
Loading