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
8 changes: 8 additions & 0 deletions game/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,11 @@ The camera leads movement by up to 120px and eases vertical changes, clamped to
the complete chunk grid. Rendered camera coordinates stay on whole pixels; layers
retain the export's scroll factors. Development `?inspect` keeps manual drag panning
instead of following the hero, for collision review.

Health starts at five cells. Hits grant one second of invulnerability, with a
white flash and flicker, and roughly 64.5px of knockback. Death plays the supplied
collapse clip and pixel dust, then restores full health at the last checkpoint.
Actors register reset callbacks with Player.life; defeated boss ids persist.
Orange hazards damage and return to safe ground; black no-go resets are free.
Development F5 tests a normal hit, Shift+F5 a two-cell hit, Ctrl+F5 a hazard reset,
and Alt+F5 a free no-go reset. These keys are absent from production builds.
27 changes: 27 additions & 0 deletions game/src/combat/life-visuals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type Phaser from 'phaser';
import type { Position } from '../core/events';
import type { Life } from './life';

export function lifePose(life: Life) {
if (life.dead > 0) return { board: 'hero-death', frame: `death-${String(Math.min(11, Math.floor((1 - life.dead) * 12))).padStart(2, '0')}` };
if (life.knock > 0) {
const time = 1 - life.invulnerable;
return { board: 'hero-hurt', frame: time < 0.08 ? 'hit-00'
: `knockback-${String(Math.min(2, Math.floor((time - 0.08) * 12))).padStart(2, '0')}` };
}
return null;
}
export function paintLife(sprite: Phaser.GameObjects.Sprite, life: Life, time: number) {
if (life.dead === 0 && life.invulnerable > 0.92) sprite.setTintFill(0xffffff);
else sprite.clearTint();
sprite.setAlpha(life.dead === 0 && life.invulnerable > 0 && Math.floor(time / 80) % 2 ? 0.35 : 1);
}
/** Small game particles; no raster assets or changes to the approved art. */
export function deathDust(scene: Phaser.Scene, position: Position) {
for (let i = 0; i < 18; i++) {
const pixel = scene.add.rectangle(position.x - 14 + Math.random() * 28,
position.y - Math.random() * 76, 2, 2, [0x287c8e, 0x345e83, 0xeadbb4][i % 3]).setDepth(110);
scene.tweens.add({ targets: pixel, x: pixel.x - 20 + Math.random() * 40,
y: pixel.y - 10 - Math.random() * 20, alpha: 0, duration: 400, onComplete: () => pixel.destroy() });
}
}
45 changes: 45 additions & 0 deletions game/src/combat/life.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { Life } from './life';
describe('health and respawn', () => {
it('starts with five cells, rejects hits for one second and charges two for Jenny', () => {
const life = new Life({ x: 100, y: 500 });
expect(life.health).toBe(5); expect(life.hit(1)).toBe(true); expect(life.health).toBe(4);
life.tick(0.99); expect(life.hit(2)).toBe(false);
life.tick(0.01); expect(life.hit(2)).toBe(true); expect(life.health).toBe(2);
});
it('integrates about 1.5m of knockback in either direction', () => {
for (const direction of [-1, 1]) {
const life = new Life({ x: 100, y: 500 }); life.hit(1, direction);
let distance = 0;
for (let i = 0; i < 60; i++) distance += life.tick(1 / 120).dx;
expect(distance).toBeCloseTo(direction * 64.5, 7);
}
});
it('restores full health at the coffee checkpoint and resets normals but not defeated bosses', () => {
const life = new Life({ x: 100, y: 500 }); life.save({ x: 1400, y: 400 });
let normal = 0, boss = 0, liveBoss = 0;
life.register('rover-1', () => normal++);
life.register('jenny', () => boss++, true);
life.register('other-boss', () => liveBoss++, true);
life.defeatedBosses.add('jenny'); life.hit(5);
expect(life.tick(0.9).respawned).toBe(false); expect(life.heal(1)).toBe(false);
expect(life.tick(0.1).respawned).toBe(true);
expect(life.health).toBe(5); expect(life.safe).toEqual({ x: 1400, y: 400 });
expect(normal).toBe(1); expect(boss).toBe(0); expect(liveBoss).toBe(1);
life.tick(1); expect(normal).toBe(1); expect(life.defeatedBosses.has('jenny')).toBe(true);
});
it('keeps no-go resets free and hazard resets at the last safe position', () => {
const life = new Life({ x: 100, y: 500 }); life.rememberSafe({ x: 800, y: 300 });
expect(life.resetSafe()).toEqual({ x: 800, y: 300 }); expect(life.health).toBe(5);
life.hit(1); expect(life.resetSafe()).toEqual({ x: 800, y: 300 }); expect(life.health).toBe(4);
expect(life.knock).toBe(0); expect(life.checkpoint).toEqual({ x: 100, y: 500 });
});
it('caps healing, copies checkpoints and unregisters removed actors', () => {
const life = new Life({ x: 100, y: 500 }); life.hit(2); expect(life.heal(1)).toBe(true);
expect(life.health).toBe(4); life.heal(10); expect(life.health).toBe(5);
const position = { x: 600, y: 350 }; life.save(position); position.x = 0;
expect(life.checkpoint.x).toBe(600);
let resets = 0; const off = life.register('removed', () => resets++); off();
life.tick(1); life.hit(5); life.tick(1); expect(resets).toBe(0);
});
});
62 changes: 62 additions & 0 deletions game/src/combat/life.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Position } from '../core/events';
export const MAX_HEALTH = 5;
export const KNOCK_SPEED = 258;
export const KNOCK_TIME = 0.25;
interface Actor { boss: boolean; reset: () => void }

/** Per-stage life state. A shared boss set survives scene restarts. */
export class Life {
health = MAX_HEALTH;
invulnerable = 0;
knock = 0;
dead = 0;
checkpoint: Position;
safe: Position;
private direction = 1;
private actors = new Map<string, Actor>();
constructor(spawn: Position, readonly defeatedBosses = new Set<string>()) {
this.checkpoint = { ...spawn }; this.safe = { ...spawn };
}
hit(damage: number, direction = 1) {
if (!Number.isInteger(damage) || damage <= 0) throw new Error('Damage must be a positive integer');
if (this.dead > 0 || this.invulnerable > 1e-9) return false;
this.health = Math.max(0, this.health - damage);
this.invulnerable = 1; this.knock = KNOCK_TIME; this.direction = direction < 0 ? -1 : 1;
if (this.health === 0) this.dead = 1;
return true;
}
tick(dt: number) {
this.invulnerable = Math.max(0, this.invulnerable - dt);
const knocked = Math.min(this.knock, dt);
this.knock = Math.max(0, this.knock - dt);
if (this.knock < 1e-9) this.knock = 0;
const result = { dx: this.dead > 0 ? 0 : this.direction * KNOCK_SPEED * knocked,
knockFinished: knocked > 0 && this.knock === 0, respawned: false };
if (this.dead > 0) {
this.dead = Math.max(0, this.dead - dt);
if (this.dead < 1e-9) {
this.dead = 0; this.health = MAX_HEALTH; this.invulnerable = 1; this.knock = 0;
this.safe = { ...this.checkpoint }; result.respawned = true;
for (const [id, actor] of [...this.actors]) if (!actor.boss || !this.defeatedBosses.has(id)) actor.reset();
}
}
return result;
}
rememberSafe(position: Position) { this.safe = { ...position }; }
resetSafe() { this.knock = 0; return { ...this.safe }; }
heal(amount: number) {
if (!Number.isInteger(amount) || amount <= 0) throw new Error('Healing must be a positive integer');
if (this.dead > 0) return false;
const next = Math.min(MAX_HEALTH, this.health + amount);
const changed = next !== this.health; this.health = next; return changed;
}
save(position: Position) {
if (this.dead > 0) return false;
this.checkpoint = { ...position }; this.safe = { ...position }; this.health = MAX_HEALTH;
return true;
}
register(id: string, reset: () => void, boss = false) {
const actor = { boss, reset }; this.actors.set(id, actor);
return () => { if (this.actors.get(id) === actor) this.actors.delete(id); };
}
}
7 changes: 6 additions & 1 deletion game/src/core/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,17 @@ export class Preload extends Phaser.Scene {

export class Stage extends Phaser.Scene {
private player?: Player;
private defeatedBosses = new Set<string>();
private follow?: ReturnType<typeof cameraFollow>;
constructor() { super('Stage'); }
create(data: { manifest: StageManifest; state: RegionState; collisions: CollisionChunk[] }) {
drawStage(this, data.manifest, data.state);
collisionDebug(this, data.collisions);
this.player = new Player(this, data.collisions);
this.player = new Player(this, data.collisions, data.manifest.region, this.defeatedBosses);
this.player.onHealthChanged = health => {
const hud = this.scene.get('HUD') as Phaser.Scene & { model?: { setHealth(health: number, at: number): void } };
hud.model?.setHealth(health, this.time.now);
};
this.follow = import.meta.env.DEV && new URLSearchParams(location.search).has('inspect')
? undefined : cameraFollow(this, data.manifest);
this.scene.launch('UI');
Expand Down
87 changes: 75 additions & 12 deletions game/src/player/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ 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';
import { gameEvents } from '../core/events';
import { Life } from '../combat/life';
import { deathDust, lifePose, paintLife } from '../combat/life-visuals';

export class Player {
readonly body: Body;
readonly sprite: Phaser.GameObjects.Sprite;
readonly life: Life;
onHealthChanged: (health: number) => void = () => {};
private motion = movement();
private keys;
private debug: Phaser.GameObjects.Graphics;
Expand All @@ -19,21 +24,62 @@ export class Player {
private air;
private traversal = traversal();
private animation: LocomotionAnimation;
get canAttack() { return this.traversal.ladder === null; }
constructor(private scene: Phaser.Scene, private chunks: CollisionChunk[]) {
get canAttack() { return this.traversal.ladder === null && this.life.dead === 0 && this.life.knock === 0; }
constructor(private scene: Phaser.Scene, private chunks: CollisionChunk[],
private region = 'rusty-creek', bosses = new Set<string>()) {
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.life = new Life(this.body, bosses);
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);
this.label = scene.add.text(20, 80, '', { fontFamily: 'monospace', fontSize: '18px', backgroundColor: '#17242d' })
this.label = scene.add.text(20, 135, '', { fontFamily: 'monospace', fontSize: '18px', backgroundColor: '#17242d' })
.setScrollFactor(0).setDepth(10002).setVisible(false);
const toggle = () => { this.showDebug = !this.showDebug; this.label.setVisible(this.showDebug); };
scene.input.keyboard?.on('keydown-F2', toggle);
scene.events.once('shutdown', () => scene.input.keyboard?.off('keydown-F2', toggle));
const offBoss = gameEvents.on('boss-defeated', ({ id }) => this.life.defeatedBosses.add(id));
scene.events.once('shutdown', offBoss);
if (import.meta.env.DEV) {
scene.input.keyboard?.addCapture('F5');
const hit = (event: KeyboardEvent) => {
if (event.repeat) return;
if (event.altKey) this.resetFromDanger();
else if (event.ctrlKey) this.resetFromDanger(1);
else this.damage(event.shiftKey ? 2 : 1, this.body.x - 10);
};
scene.input.keyboard?.on('keydown-F5', hit);
scene.events.once('shutdown', () => scene.input.keyboard?.off('keydown-F5', hit));
}
}
damage(amount: number, sourceX: number) {
if (!this.life.hit(amount, this.body.x >= sourceX ? 1 : -1)) return false;
const position = { x: this.body.x, y: this.body.y };
gameEvents.emit('player-hit', { health: this.life.health, damage: amount, position });
if (this.life.dead > 0) {
gameEvents.emit('player-died', { position });
this.scene.time.delayedCall(600, () => deathDust(this.scene, position));
} else this.body.vy = -90;
return true;
}
heal(amount: number) {
if (this.life.heal(amount)) this.onHealthChanged(this.life.health);
}
saveCheckpoint(id: string) {
if (!this.life.save(this.body)) return;
this.onHealthChanged(this.life.health);
gameEvents.emit('save', { checkpoint: id, region: this.region, position: { x: this.body.x, y: this.body.y } });
}
private resetPosition(position: { x: number; y: number }) {
Object.assign(this.body, position, { vx: 0, vy: 0, grounded: true, sliding: false, slope: 0 });
this.motion = movement(); this.air = jumpState(this.body.y); this.traversal = traversal();
}
private resetFromDanger(damage = 0) {
if (damage) this.damage(damage, this.body.x - 10);
if (this.life.dead === 0) this.resetPosition(this.life.resetSafe());
}
update(milliseconds: number) {
const pad = this.scene.input.gamepad?.getAll().find(p => p.connected);
Expand All @@ -47,11 +93,23 @@ export class Player {
let remaining = Math.min(milliseconds / 1000, 0.1);
while (remaining > 1e-9) {
const dt = Math.min(remaining, 1 / 120);
const lifeStep = this.life.tick(dt);
if (lifeStep.respawned) {
this.resetPosition(this.life.checkpoint);
this.onHealthChanged(this.life.health);
}
if (this.life.dead > 0) { remaining -= dt; continue; }
const knocked = lifeStep.dx !== 0;
const pressed = jumpDown && !this.air.wasDown;
const dropped = vertical > 0 && pressed && dropThrough(this.body, this.traversal, geometry);
const climbing = climb(this.body, this.traversal, geometry, vertical, axis, pressed, dt);
const dropped = !knocked && vertical > 0 && pressed && dropThrough(this.body, this.traversal, geometry);
const climbing = !knocked && climb(this.body, this.traversal, geometry, vertical, axis, pressed, dt);
const wasGrounded = this.body.grounded;
if (dropped || climbing) {
if (knocked) {
this.body.vx = lifeStep.dx / dt;
this.traversal = traversal(); this.air.wasDown = jumpDown;
this.air.buffer = 0; this.air.coyote = 0; this.air.active = false;
this.motion = { ...movement(), facing: this.motion.facing };
} else if (dropped || climbing) {
this.air.wasDown = jumpDown; this.air.buffer = 0; this.air.coyote = 0; this.air.active = false;
this.motion = movement();
} else {
Expand All @@ -61,28 +119,33 @@ export class Player {
}
if (!climbing) stepBody(this.body, geometry, dt, this.traversal.drop > 0);
landing(this.body, this.air, wasGrounded, dt);
if (lifeStep.knockFinished) this.body.vx = 0;
this.motion.vx = this.body.vx;
remaining -= dt;
}
if (this.body.y > 900 || this.chunks.some(c => c.world.noGo.some(box => overlaps(this.body, box)))) {
Object.assign(this.body, { x: this.spawn.x, y: this.spawn.y, vx: 0, vy: 0, grounded: true, sliding: false, slope: 0 });
this.motion = movement();
this.air = jumpState(this.body.y);
this.traversal = traversal();
if (this.life.dead === 0) {
const hazard = geometry.flatMap(g => g.hazard).find(box => overlaps(this.body, box));
const noGo = this.body.y > 900 || geometry.some(g => g.noGo.some(box => overlaps(this.body, box)));
if (hazard) this.resetFromDanger(Number(hazard.damage) || 1);
else if (noGo) this.resetFromDanger();
else if (this.body.grounded && this.life.knock === 0) this.life.rememberSafe(this.body);
}
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);
const locomotion = this.animation.update(desired, desired === 'ladder' && vertical === 0 ? 0 : milliseconds / 1000);
const pose = lifePose(this.life) ?? locomotion;
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);
paintLife(this.sprite, this.life, this.scene.time.now);
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([
'hp ' + this.life.health + '/5 | invulnerable ' + this.life.invulnerable.toFixed(2) + 's',
`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)}°`,
Expand Down
Loading