diff --git a/game/README.md b/game/README.md index d9e400b..81665f4 100644 --- a/game/README.md +++ b/game/README.md @@ -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. diff --git a/game/src/combat/life-visuals.ts b/game/src/combat/life-visuals.ts new file mode 100644 index 0000000..c448514 --- /dev/null +++ b/game/src/combat/life-visuals.ts @@ -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() }); + } +} diff --git a/game/src/combat/life.test.ts b/game/src/combat/life.test.ts new file mode 100644 index 0000000..7d061d5 --- /dev/null +++ b/game/src/combat/life.test.ts @@ -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); + }); +}); diff --git a/game/src/combat/life.ts b/game/src/combat/life.ts new file mode 100644 index 0000000..a9070a4 --- /dev/null +++ b/game/src/combat/life.ts @@ -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(); + constructor(spawn: Position, readonly defeatedBosses = new Set()) { + 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); }; + } +} diff --git a/game/src/core/scaffold.ts b/game/src/core/scaffold.ts index 40ac39e..68a12d2 100644 --- a/game/src/core/scaffold.ts +++ b/game/src/core/scaffold.ts @@ -29,12 +29,17 @@ export class Preload extends Phaser.Scene { export class Stage extends Phaser.Scene { private player?: Player; + private defeatedBosses = new Set(); private follow?: ReturnType; 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'); diff --git a/game/src/player/player.ts b/game/src/player/player.ts index 9a48ba5..43967fd 100644 --- a/game/src/player/player.ts +++ b/game/src/player/player.ts @@ -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; @@ -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()) { 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; 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); @@ -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 { @@ -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)}°`,