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
1 change: 1 addition & 0 deletions docs/05-ENEMIES-AND-BOSSES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion game/src/core/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'); }
Expand All @@ -55,16 +57,20 @@ 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);
};
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);
Expand All @@ -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);
}
Expand Down
86 changes: 86 additions & 0 deletions game/src/enemies/jenny.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
177 changes: 177 additions & 0 deletions game/src/enemies/jenny.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading