From 2d29ed166af9ba07ab184ad0962c5712d860bc00 Mon Sep 17 00:00:00 2001 From: arudaev Date: Sat, 24 Oct 2026 03:45:27 +0200 Subject: [PATCH] feat: fight Jenny in the boss clearing Jenny revs, charges nose first, crashes at the far wall, sheds parts and is stunned, in three phases over 30 hits. Her armoured nose takes chair hits only from behind or while she is stunned. The arena comes from collision (boss gate and floor line), and clip lengths come from her anims.json. Beating her emits boss-defeated, which starts the ending. Dev: ?boss-test. --- docs/05-ENEMIES-AND-BOSSES.md | 1 + game/src/core/scaffold.ts | 9 +- game/src/enemies/jenny.test.ts | 86 ++++++++++++++++ game/src/enemies/jenny.ts | 177 +++++++++++++++++++++++++++++++++ game/src/enemies/jennyBoss.ts | 131 ++++++++++++++++++++++++ game/src/main.ts | 2 + 6 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 game/src/enemies/jenny.test.ts create mode 100644 game/src/enemies/jenny.ts create mode 100644 game/src/enemies/jennyBoss.ts 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);