diff --git a/docs/05-ENEMIES-AND-BOSSES.md b/docs/05-ENEMIES-AND-BOSSES.md index 3c3fb68..dae9fde 100644 --- a/docs/05-ENEMIES-AND-BOSSES.md +++ b/docs/05-ENEMIES-AND-BOSSES.md @@ -69,6 +69,7 @@ Lex's read (A14), which the fight follows: Jenny is a **shielded** enemy. Her no - **Charge** does 2 cells. **Parts** do 1. Guarding a charge breaks the guard. - The arena is the Rusty Creek boss clearing: one flat girder floor, two walls, the creek below. Falling in is a hazard (−1 cell). - **Death:** she stalls, smokes and leaks black fluid. Dexter appears and absorbs it. +- **In the game** (`enemies/jenny.ts`, `enemies/jennyBoss.ts`, #57): the arena comes from collision, not code: the `boss-gate` box's right edge is the left wall, which closes behind you once you're through, and the floor line under her `jenny` spawn ends at the right wall. Her hit box is the car body without the rear wing, 250 × 70 px inside the ~290 × 134 px sprite, so the hero's 96 px jump clears a charge. A revving Jenny also hurts on contact (1 cell). Clip lengths come from `boss-jenny*.anims.json`. Beating her emits `boss-defeated`, and the `Ending` scene takes over. Dying resets the fight. Dev: `?boss-test` spawns you at the gate. - What we took from Terraria's Wall of Flesh: one huge shape crossing the whole screen, very readable, and you know exactly what to do after seeing it once. ## Later bosses diff --git a/game/src/core/scaffold.ts b/game/src/core/scaffold.ts index 48830c1..5908821 100644 --- a/game/src/core/scaffold.ts +++ b/game/src/core/scaffold.ts @@ -9,6 +9,7 @@ import { cameraFollow } from '../stage/camera'; import { ChairCombat } from '../combat/chair'; import { CoffeePoints } from '../combat/coffee'; import { Rovers } from '../enemies/rovers'; +import { JennyBoss } from '../enemies/jennyBoss'; import { BODY_WIDTH } from '../player/body'; import { Triggers } from '../stage/triggers'; import { START, type RegionTarget } from '../stage/swap'; @@ -45,6 +46,7 @@ export class Stage extends Phaser.Scene { private coffee?: CoffeePoints; private rovers?: Rovers; private triggers?: Triggers; + private jenny?: JennyBoss; get playerActor() { return this.player; } get chair() { return this.combat; } constructor() { super('Stage'); } @@ -55,9 +57,12 @@ export class Stage extends Phaser.Scene { ? data.collisions.flatMap(chunk => chunk.world.interact).find(box => box.kind === 'coffee-machine') : undefined; const rover = import.meta.env.DEV && new URLSearchParams(location.search).has('rover-test') ? data.collisions.flatMap(chunk => chunk.world.spawn).find(spawn => spawn.kind === 'rover') : undefined; + const gate = import.meta.env.DEV && new URLSearchParams(location.search).has('boss-test') + ? data.collisions.flatMap(chunk => chunk.world.interact).find(box => box.kind === 'boss-gate') : undefined; this.player = new Player(this, data.collisions, data.manifest.region, this.defeatedBosses, machine ? { x: machine.x + machine.w / 2, y: machine.y + machine.h } - : rover ? { x: rover.x - BODY_WIDTH * 3, y: rover.y } : data.spawn); + : rover ? { x: rover.x - BODY_WIDTH * 3, y: rover.y } + : gate ? { x: gate.x - BODY_WIDTH * 2, y: gate.y + gate.h } : data.spawn); 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); @@ -65,6 +70,7 @@ export class Stage extends Phaser.Scene { this.combat = new ChairCombat(this, this.player, data.manifest.region); this.coffee = new CoffeePoints(this, this.player, data.collisions); this.rovers = new Rovers(this, this.player, this.combat, data.collisions); + this.jenny = new JennyBoss(this, this.player, this.combat, data.collisions); this.triggers = new Triggers(data.collisions, data.manifest.region); this.follow = import.meta.env.DEV && new URLSearchParams(location.search).has('inspect') ? undefined : cameraFollow(this, data.manifest); @@ -86,6 +92,7 @@ export class Stage extends Phaser.Scene { this.player?.update(active); this.combat?.update(active); this.rovers?.update(active); + this.jenny?.update(active); if (this.player && this.player.life.dead === 0) this.triggers?.emit(this.player.body); if (this.player) this.follow?.(this.player.body, active); } diff --git a/game/src/enemies/jenny.test.ts b/game/src/enemies/jenny.test.ts new file mode 100644 index 0000000..3dce16a --- /dev/null +++ b/game/src/enemies/jenny.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { arenaFrom, JENNY_BOX, JENNY_HEALTH, JennyFight, REV_SECONDS } from './jenny'; + +// boss-jenny*.anims.json as exported (approved 24 Oct). +const CLIPS = { + rev: { frames: 4, fps: 12, loop: true }, charge: { frames: 4, fps: 12, loop: false }, + crash: { frames: 4, fps: 12, loop: false }, stunned: { frames: 3, fps: 12, loop: true }, + reboot: { frames: 2, fps: 12, loop: false }, +}; +// Rusty Creek rc-2-0 anomaly in world px: gate at 4320 + 1096, floor y 565 from 5410 to 6010, Jenny at 5760. +const arena = arenaFrom({ x: 5416, w: 30 }, { x: 5760, y: 565 }, + [[[5410, 565], [6010, 565]], [[6012, 548], [6080, 548]]]); +const run = (fight: JennyFight, seconds: number) => { + const seen: string[] = []; + let beaten = false; + for (let t = 0; t < seconds; t += 1 / 60) { + beaten = fight.step(1 / 60) || beaten; + if (seen.at(-1) !== fight.phase) seen.push(fight.phase); + } + return { seen, beaten }; +}; + +describe('Jenny', () => { + it('reads her arena from the gate and the floor under her spawn', () => { + expect(arena).toEqual({ left: 5446, right: 6010, floor: 565 }); + }); + + it('waits behind the gate, then revs, charges, crashes, is stunned and reboots', () => { + const fight = new JennyFight(arena, 5760, CLIPS); + expect(run(fight, 2).seen).toEqual(['waiting']); + fight.start(); + const { seen } = run(fight, REV_SECONDS + 5); + expect(seen.slice(0, 5)).toEqual(['rev', 'charge', 'crash', 'stunned', 'reboot']); + expect(fight.x).toBeGreaterThanOrEqual(arena.left + JENNY_BOX.w / 2); + }); + + it('crashes at the far wall and sheds three parts in phase 1', () => { + const fight = new JennyFight(arena, 5760, CLIPS); + fight.start(); + while (fight.phase !== 'crash') fight.step(1 / 60); + expect(fight.x).toBe(arena.left + JENNY_BOX.w / 2); + expect(fight.parts).toHaveLength(3); + expect(fight.parts.every(p => p.vx > 0)).toBe(true); // thrown back out of the wall she hit + }); + + it('shrugs off hits on the armoured nose and takes them from behind or stunned', () => { + const fight = new JennyFight(arena, 5760, CLIPS); + fight.start(); + expect(fight.hit(1, 1)).toBe(false); // she faces left: a hit travelling right meets the nose + expect(fight.hit(1, -1)).toBe(true); // from behind + while (fight.phase !== 'stunned') fight.step(1 / 60); + expect(fight.hit(1, 1)).toBe(true); + expect(fight.health).toBe(JENNY_HEALTH - 2); + }); + + it('charges twice in phase 2 and stun is shorter in phase 3', () => { + const fight = new JennyFight(arena, 5760, CLIPS); + fight.start(); + while (fight.phase !== 'stunned') fight.step(1 / 60); + for (let i = 0; i < 10; i++) fight.hit(1, 1); + expect(fight.stage).toBe(1); + while ((fight.phase as string) !== 'charge') fight.step(1 / 60); + const start = fight.facing; + while (fight.facing === start) fight.step(1 / 60); // turned at the wall instead of crashing + expect(fight.phase).toBe('charge'); + }); + + it('stalls at 0, fires once, and stays beaten through a reset', () => { + const fight = new JennyFight(arena, 5760, CLIPS); + fight.start(); + while (fight.phase !== 'stunned') fight.step(1 / 60); + for (let i = 0; i < JENNY_HEALTH; i++) fight.hit(1, 1); + expect(fight.phase).toBe('stall'); + expect(run(fight, 3)).toEqual({ seen: ['stall', 'gone'], beaten: true }); + fight.reset(); + expect(fight.phase).toBe('gone'); + }); + + it('resets to the start of the fight when the hero dies', () => { + const fight = new JennyFight(arena, 5760, CLIPS); + fight.start(); + run(fight, 2); + fight.reset(); + expect([fight.phase, fight.health, fight.x, fight.parts.length]).toEqual(['waiting', JENNY_HEALTH, 5760, 0]); + }); +}); diff --git a/game/src/enemies/jenny.ts b/game/src/enemies/jenny.ts new file mode 100644 index 0000000..9c62794 --- /dev/null +++ b/game/src/enemies/jenny.ts @@ -0,0 +1,177 @@ +import type { AnimationTable } from '../stage/assets'; + +/** + * Jenny, the Region 1 boss (docs/05, #57) as pure state. She revs, charges across the arena nose first, crashes + * into the far wall, sheds parts and sits stunned; then she reboots, turns round and goes again. Her nose is + * armoured: chair hits land from behind, or anywhere while she is stunned. Three phases by health. Clip lengths + * come from her approved animations (boss-jenny*.anims.json), never from constants in code. + */ +export type JennyPhase = 'waiting' | 'rev' | 'charge' | 'crash' | 'stunned' | 'reboot' | 'stall' | 'gone'; + +export const JENNY_HEALTH = 30; +/** Health where phase 2 and phase 3 begin. */ +export const PHASE_AT = [20, 10] as const; +/** docs/05: charge 2 cells, parts 1; stunned 3 s (2 s in phase 3); 3 parts per crash (5 in phase 3). */ +export const CHARGE_DAMAGE = 2, PART_DAMAGE = 1; +const STUN = [3, 3, 2], PARTS = [3, 4, 5]; +/** Charge speed in px/s, per phase: she is a race car, faster than the hero's 258 px/s run. */ +const SPEED = [520, 600, 680]; +/** Seconds of stall smoke before she is gone and `boss-defeated` fires. */ +export const STALL_SECONDS = 1.2; +/** Seconds of revving before a charge: the rev clip loops until then (docs/05: 1 s). */ +export const REV_SECONDS = 1; +/** + * Her hit box: the car body without the rear wing, inside the ~290 × 134 px sprite (measured on rev-00). It is + * lower than the hero's ~88 px jump, so you can jump her charge as docs/05 asks; x is her centre, y the floor. + */ +export const JENNY_BOX = { w: 250, h: 70 }; +const GRAVITY = 1400; + +export interface Part { kind: string; x: number; y: number; vx: number; vy: number; landed: number; rolling: boolean } +export interface Arena { left: number; right: number; floor: number } + +export class JennyFight { + phase: JennyPhase = 'waiting'; + health = JENNY_HEALTH; + /** -1 nose left, 1 nose right. */ + facing: -1 | 1 = -1; + x: number; + elapsed = 0; + parts: Part[] = []; + /** Phase 2's first charge turns at the wall instead of crashing. */ + private turnsLeft = 0; + private seed = 1; + + constructor(readonly arena: Arena, readonly spawnX: number, private clips: AnimationTable) { + this.x = spawnX; + } + + /** 0, 1 or 2. */ + get stage(): number { return this.health > PHASE_AT[0] ? 0 : this.health > PHASE_AT[1] ? 1 : 2; } + get active(): boolean { return this.phase !== 'waiting' && this.phase !== 'gone'; } + get bounds() { return { x: this.x - JENNY_BOX.w / 2, y: this.arena.floor - JENNY_BOX.h, ...JENNY_BOX }; } + + /** Seconds one pass of a clip takes, from its frames and fps. */ + clip(name: string): number { + const c = this.clips[name]; + return c ? c.frames / c.fps : 0.3; + } + + /** The current frame name, from the clip that plays in this phase. */ + get frame(): { atlas: 'boss-jenny' | 'boss-jenny-2'; name: string } { + const pick = (atlas: 'boss-jenny' | 'boss-jenny-2', clip: string, loop: boolean) => { + const c = this.clips[clip] ?? { frames: 1, fps: 12, loop }; + const n = Math.floor(this.elapsed * c.fps); + return { atlas, name: `${clip}-${String(loop ? n % c.frames : Math.min(c.frames - 1, n)).padStart(2, '0')}` }; + }; + switch (this.phase) { + case 'charge': return pick('boss-jenny', 'charge', true); + case 'crash': return pick('boss-jenny', 'crash', false); + case 'stunned': case 'stall': case 'gone': return pick('boss-jenny-2', 'stunned', true); + case 'reboot': return pick('boss-jenny-2', 'reboot', false); + default: return pick('boss-jenny', 'rev', true); + } + } + + start(): void { + if (this.phase === 'waiting') this.enter('rev'); + } + + /** Back to the start of the fight (the hero died). A beaten Jenny stays beaten. */ + reset(): void { + if (this.phase === 'stall' || this.phase === 'gone') return; + this.phase = 'waiting'; this.health = JENNY_HEALTH; this.facing = -1; this.x = this.spawnX; + this.elapsed = 0; this.parts = []; this.turnsLeft = 0; + } + + /** A chair hit travelling in `direction`. True if it did damage; false if the armoured nose took it. */ + hit(damage: number, direction: number): boolean { + if (!this.active || this.phase === 'stall' || this.health <= 0) return false; + const fromBehind = Math.sign(direction) === this.facing; + if (!fromBehind && this.phase !== 'stunned' && this.phase !== 'crash') return false; + this.health = Math.max(0, this.health - damage); + if (this.health === 0) { this.enter('stall'); this.parts = []; } + return true; + } + + /** Damage her body does to the hero on contact in this phase. */ + get contactDamage(): number { + return this.phase === 'charge' ? CHARGE_DAMAGE : this.phase === 'rev' ? 1 : 0; + } + + /** Advance; returns true exactly once, when the stall ends and she is beaten. */ + step(seconds: number): boolean { + let beaten = false; + let remaining = Math.min(seconds, 0.1); + while (remaining > 1e-9) { + const dt = Math.min(remaining, 1 / 120); remaining -= dt; this.elapsed += dt; + this.stepParts(dt); + switch (this.phase) { + case 'rev': if (this.elapsed >= REV_SECONDS) this.enter('charge'); break; + case 'charge': { + this.x += this.facing * SPEED[this.stage] * dt; + const wall = this.facing < 0 ? this.arena.left + JENNY_BOX.w / 2 : this.arena.right - JENNY_BOX.w / 2; + if ((this.x - wall) * this.facing >= 0) { + this.x = wall; + if (this.turnsLeft > 0) { this.turnsLeft--; this.facing = -this.facing as -1 | 1; this.elapsed = 0; } + else { this.enter('crash'); this.shed(); } + } + break; + } + case 'crash': if (this.elapsed >= this.clip('crash')) this.enter('stunned'); break; + case 'stunned': if (this.elapsed >= STUN[this.stage]) this.enter('reboot'); break; + case 'reboot': + if (this.elapsed >= this.clip('reboot')) { + this.facing = -this.facing as -1 | 1; + this.turnsLeft = this.stage === 1 ? 1 : 0; + this.enter('rev'); + } + break; + case 'stall': if (this.elapsed >= STALL_SECONDS) { this.enter('gone'); beaten = true; } break; + } + } + return beaten; + } + + private enter(phase: JennyPhase): void { this.phase = phase; this.elapsed = 0; } + + /** Parts fly back out of the crash in arcs: spring, bolt, wing shard … (debris-1…5); phase 2 adds a rolling wheel. */ + private shed(): void { + const count = PARTS[this.stage]; + for (let i = 0; i < count; i++) { + const kind = `debris-${(i % 5) + 1}`; + const spread = (i + 1) / count; + this.parts.push({ kind, x: this.x, y: this.arena.floor - JENNY_BOX.h * 0.6, + vx: -this.facing * (140 + 360 * spread + 40 * this.random()), vy: -(380 + 220 * this.random()), landed: 0, + rolling: this.stage === 1 && i === count - 1 }); + } + } + + private stepParts(dt: number): void { + for (const p of this.parts) { + if (p.landed > 0 && !p.rolling) { p.landed += dt; continue; } + p.x += p.vx * dt; + if (p.landed === 0) { + p.vy += GRAVITY * dt; p.y += p.vy * dt; + if (p.y >= this.arena.floor) { p.y = this.arena.floor; p.landed = dt; p.vx = p.rolling ? Math.sign(p.vx) * 300 : 0; } + } else p.landed += dt; + if (p.x < this.arena.left || p.x > this.arena.right) p.landed = Math.max(p.landed, 99); + } + // A landed part lies there a second, then it's gone; a rolling wheel leaves at the wall. + this.parts = this.parts.filter(p => p.landed < (p.rolling ? 99 : 1.2)); + } + + /** Deterministic jitter so tests and replays match. */ + private random(): number { + this.seed = (this.seed * 16807) % 2147483647; + return (this.seed - 1) / 2147483646; + } +} + +/** The arena from collision: the boss gate's right edge, the end of the floor under her spawn, its height. */ +export function arenaFrom(gate: { x: number; w: number }, spawn: { x: number; y: number }, walk: [number, number][][]): Arena { + const floor = walk.find(line => line.length >= 2 && line[0][0] <= spawn.x && line[line.length - 1][0] >= spawn.x + && line.some(p => Math.abs(p[1] - spawn.y) <= 4)); + const right = floor ? Math.max(...floor.filter(p => Math.abs(p[1] - spawn.y) <= 4).map(p => p[0])) : spawn.x + 300; + return { left: gate.x + gate.w, right, floor: spawn.y }; +} diff --git a/game/src/enemies/jennyBoss.ts b/game/src/enemies/jennyBoss.ts new file mode 100644 index 0000000..60f2a79 --- /dev/null +++ b/game/src/enemies/jennyBoss.ts @@ -0,0 +1,131 @@ +import type Phaser from 'phaser'; +import { gameEvents } from '../core/events'; +import { sound } from '../fx/sound/Audio'; +import type { ChairCombat } from '../combat/chair'; +import { intersects } from '../combat/sweeps'; +import { BODY_HEIGHT, BODY_WIDTH } from '../player/body'; +import type { Player } from '../player/player'; +import type { AnimationTable } from '../stage/assets'; +import type { Box, CollisionChunk } from '../stage/collision'; +import { arenaFrom, JENNY_HEALTH, JennyFight, PART_DAMAGE, type Part } from './jenny'; + +const ID = 'jenny'; +const NAME = 'Jenny'; +/** The wall that drops behind you at the boss gate: as tall as the gate box. */ +const DEPTH = 101; +type Hud = Phaser.Scene & { model?: { showBoss(name: string, max: number): void; setBossHealth(h: number): void; hideBoss(): void } }; + +/** + * Draws and wires Jenny's fight (#57) into the stage. Everything comes from the region's collision: her `jenny` + * spawn, the `boss-gate` box that closes behind you, and the floor she drives on. Walking through the gate + * starts the fight and the boss bar; beating her emits `boss-defeated`, which the Ending scene turns into the + * end of the slice. Dying resets the fight. + */ +export class JennyBoss { + private fight?: JennyFight; + private sprite?: Phaser.GameObjects.Sprite; + private parts = new Map(); + private hurt = new Set(); + private wall?: { box: Box; list: Box[] }; + private gate?: Box; + private flash = 0; + private cleanup: (() => void)[] = []; + + constructor(private scene: Phaser.Scene, private player: Player, chair: ChairCombat, chunks: CollisionChunk[]) { + const spawn = chunks.flatMap(c => c.world.spawn).find(s => s.kind === 'jenny'); + const gateChunk = chunks.find(c => c.world.interact.some(b => b.kind === 'boss-gate')); + this.gate = gateChunk?.world.interact.find(b => b.kind === 'boss-gate'); + if (!spawn || !this.gate || !gateChunk || player.life.defeatedBosses.has(ID)) return; + const clips = { ...scene.cache.json.get('boss-jenny.anims'), ...scene.cache.json.get('boss-jenny-2.anims') } as AnimationTable; + const walk = chunks.flatMap(c => c.world.walk); + this.fight = new JennyFight(arenaFrom(this.gate, spawn, walk), spawn.x, clips); + this.wall = { box: { id: 'boss-wall', kind: 'boss-wall', x: this.gate.x, y: this.gate.y - 200, w: this.gate.w, h: this.gate.h + 200 }, + list: gateChunk.world.solid }; + this.sprite = scene.add.sprite(spawn.x, spawn.y, 'boss-jenny', 'rev-00').setOrigin(0.5, 1).setDepth(DEPTH); + this.cleanup.push( + chair.register({ id: ID, active: () => !!this.fight && this.fight.active && this.fight.health > 0, + bounds: () => this.fight!.bounds, hit: hit => this.hit(hit.damage, hit.direction) }), + player.life.register(ID, () => this.reset(), true), + gameEvents.on('trigger-entered', ({ kind }) => { if (kind === 'boss-gate') this.start(); }), + ); + scene.events.once('shutdown', () => this.cleanup.forEach(off => off())); + } + + private get hud(): Hud | undefined { return this.scene.scene.get('HUD') as Hud | undefined; } + + private start(): void { + const fight = this.fight; + if (!fight || fight.phase !== 'waiting') return; + // Only once the hero is fully inside the arena, so the wall never lands on them. + if (this.player.body.x - BODY_WIDTH / 2 <= this.gate!.x + this.gate!.w) { + this.scene.time.delayedCall(250, () => this.start()); + return; + } + fight.start(); + if (!this.wall!.list.includes(this.wall!.box)) this.wall!.list.push(this.wall!.box); + this.hud?.model?.showBoss(NAME, JENNY_HEALTH); + } + + private hit(damage: number, direction: number): boolean { + const fight = this.fight!; + const landed = fight.hit(damage, direction); + if (landed) { this.flash = 0.08; this.hud?.model?.setBossHealth(fight.health); } + else sound.play('chair-hit'); // the armoured nose: a dull clank, no damage + return landed; + } + + private reset(): void { + this.fight?.reset(); + this.openWall(); + for (const s of this.parts.values()) s.destroy(); + this.parts.clear(); this.hurt.clear(); + if (this.fight?.phase === 'waiting') this.hud?.model?.hideBoss(); + } + + private openWall(): void { + if (!this.wall) return; + const i = this.wall.list.indexOf(this.wall.box); + if (i >= 0) this.wall.list.splice(i, 1); + } + + update(milliseconds: number): void { + const fight = this.fight, sprite = this.sprite; + if (!fight || !sprite) return; + if (fight.step(milliseconds / 1000)) { + this.openWall(); + gameEvents.emit('boss-defeated', { id: ID, position: { x: fight.x, y: fight.arena.floor } }); + } + const { atlas, name } = fight.frame; + if (this.scene.textures.get(atlas).has(name)) sprite.setTexture(atlas, name); + // The approved frames face left (nose left), so a right-facing Jenny is mirrored. + sprite.setPosition(Math.round(fight.x), fight.arena.floor).setFlipX(fight.facing > 0) + .setVisible(fight.phase !== 'gone').setAlpha(fight.phase === 'stall' ? 0.6 + 0.4 * Math.random() : 1); + this.flash = Math.max(0, this.flash - milliseconds / 1000); + if (this.flash > 0) sprite.setTintFill(0xffffff); else sprite.clearTint(); + this.drawParts(fight); + this.contact(fight); + } + + private drawParts(fight: JennyFight): void { + for (const [part, s] of this.parts) if (!fight.parts.includes(part)) { s.destroy(); this.parts.delete(part); this.hurt.delete(part); } + for (const part of fight.parts) { + let s = this.parts.get(part); + if (!s) { s = this.scene.add.sprite(part.x, part.y, 'boss-jenny-2', `${part.kind}-00`).setOrigin(0.5, 1).setDepth(DEPTH); this.parts.set(part, s); } + const frame = `${part.kind}-0${Math.floor(this.scene.time.now / 120) % 2}`; + if (this.scene.textures.get('boss-jenny-2').has(frame)) s.setFrame(frame); + // Debris art is drawn at the car's scale; a part is about a third of its board cell. + s.setScale(0.35).setPosition(Math.round(part.x), Math.round(part.y)).setAngle(part.landed ? 0 : part.x * 2); + } + } + + private contact(fight: JennyFight): void { + const body = this.player.body; + const hero = { x: body.x - BODY_WIDTH / 2, y: body.y - BODY_HEIGHT, w: BODY_WIDTH, h: BODY_HEIGHT }; + if (fight.contactDamage && intersects(fight.bounds, hero)) this.player.damage(fight.contactDamage, fight.x); + for (const part of fight.parts) { + if (this.hurt.has(part)) continue; + const box = { x: part.x - 12, y: part.y - 24, w: 24, h: 24 }; + if (intersects(box, hero) && this.player.damage(PART_DAMAGE, part.x)) this.hurt.add(part); + } + } +} diff --git a/game/src/main.ts b/game/src/main.ts index 6f1f54f..ba1ddff 100644 --- a/game/src/main.ts +++ b/game/src/main.ts @@ -21,6 +21,8 @@ function resize() { game.scale.setZoom(displayScale(window.innerWidth, window.innerHeight)); } game.events.once(Phaser.Core.Events.READY, resize); +// Dev builds: the console can reach the game (`game.scene.getScene('Stage')`) for checks and debugging. +if (import.meta.env.DEV) (window as unknown as { game: Phaser.Game }).game = game; window.addEventListener('resize', resize); if (import.meta.hot) import.meta.hot.dispose(() => { window.removeEventListener('resize', resize);