From 2e632b8d394181c650a59f56d5516e11b61d3520 Mon Sep 17 00:00:00 2001 From: Vatsal2005 <83007841+Vatsal2005@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:47:58 +0200 Subject: [PATCH] feat: add health damage and checkpoint respawn Apply the documented cell costs, knockback and hit immunity, then collapse into pixel dust and restore full health at the checkpoint on death. Keep free no-go resets separate from damaging hazards and return to the last safe ground. Expose healing, checkpoint and actor-reset hooks for the coffee, food and enemy stories. Preserve defeated bosses across scene restarts and update the public HUD model without changing the agreed game events. --- game/README.md | 8 +++ game/src/combat/life-visuals.ts | 27 ++++++++++ game/src/combat/life.test.ts | 45 +++++++++++++++++ game/src/combat/life.ts | 62 +++++++++++++++++++++++ game/src/core/scaffold.ts | 7 ++- game/src/player/player.ts | 87 ++++++++++++++++++++++++++++----- 6 files changed, 223 insertions(+), 13 deletions(-) create mode 100644 game/src/combat/life-visuals.ts create mode 100644 game/src/combat/life.test.ts create mode 100644 game/src/combat/life.ts 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)}°`,