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
35 changes: 35 additions & 0 deletions docs/05-ENEMIES-AND-BOSSES.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,41 @@ Every enemy is a real **autonomous** machine from THD – things that already dr

All of them: contact damage 1 cell, white hit flash, 3–4 frame knockback, and they stop dead and leak black fluid when defeated.

### Rover controller plan (#45)

Use only `kind: rover` spawns and their translated `patrol` endpoints.
The standing `enemy-rover:idle-00` alpha bounds are 31 × 25 px, feet at
the atlas pivot. The approved charge poses lower the mast (18–21 px
visible height); keep their native pixels and the standing collision box.
Patrol tuning is half the documented walking speed (75.25 px/s); charge
uses the documented running speed (258 px/s). Lidar checks one walking
second ahead (150.5 px), within the hero's 76 px height band. These are
initial controller tuning, with the fixed 0.5 s tell and 3 HP above.
The tell holds red-lidar `charge-00`, then reared `charge-01`; moving
charge alternates poses 02/03. A normal hit knocks half a rover width
(15.5 px) over three authored 12 fps frames. A boundary ends a charge
with another 0.5 s recovery; it never homes on a jumping hero.
The red lidar points left and dust trails right in the approved charge;
mirror these poses for rightward motion.

```mermaid
flowchart LR
IN["Authored rover spawn + patrol range<br/>enemy-rover atlas, 31 × 25 body"] --> PATROL["Patrol within endpoints<br/>lidar tests forward hero band"]
PATROL --> TELL["Red lidar + rear-up<br/>0.5 s; lock charge direction"]
TELL --> CHARGE["Straight charge at 258 px/s<br/>stop at patrol boundary"]
CHARGE --> RECOVER["Recover 0.5 s, turn inward"]
RECOVER --> PATROL
HIT["Chair register target<br/>one cell of contact damage to hero"] --> DAMAGE["3 HP; white flash<br/>3-frame knockback or fatal wreck"]
DAMAGE --> WRECK["5 approved wreck poses<br/>hit, flip, stopped machine"]
WRECK --> EVENT["enemy-defeated once<br/>black-fluid consumer in #48"]
IN --> CHECK["Reference baseline + real patrol tests<br/>sweep playthrough; pogo #40 / guard #41"]
```

The development `rover-test` URL starts three hero body widths behind the
real tutorial spawn, on its deck, without changing the annotation.
Guard, pogo and the shared fluid effect are verified when #41, #40 and
#48 reach this stack; keep their integration criteria open until then.

## Boss – Jenny, the self-driving race car (Region 1)

Jenny is **Fast Forest**'s car – THD's Formula Student team, founded 2008. Every car they build is called Jenny; the current one is **Jenny 16GDX** (rolled out 22 May 2026), carbon-black with lime-green, teal and blue stripes. Fast Forest has worked on a **driverless** Jenny since 2017: a camera finds the track's cones and the car steers itself (sources: fast-forest.de, PNP 12 Oct 2023). In the game the cockpit is empty, the blue autonomous-status light on the roll hoop flashes, and the anomaly is doing the driving. 30 chair hits, three phases. The pattern comes straight from the brief: she tries to run you over, crashes into things, sheds parts you have to dodge, slows down, you hit her, she charges up again, and it repeats.
Expand Down
10 changes: 9 additions & 1 deletion game/src/core/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { Player } from '../player/player';
import { cameraFollow } from '../stage/camera';
import { ChairCombat } from '../combat/chair';
import { CoffeePoints } from '../combat/coffee';
import { Rovers } from '../enemies/rovers';
import { BODY_WIDTH } from '../player/body';

// Bootstrap-only scenes live in lane B until lane A provides story/UI scenes.
export class Boot extends Phaser.Scene {
Expand Down Expand Up @@ -36,6 +38,7 @@ export class Stage extends Phaser.Scene {
private follow?: ReturnType<typeof cameraFollow>;
private combat?: ChairCombat;
private coffee?: CoffeePoints;
private rovers?: Rovers;
get playerActor() { return this.player; }
get chair() { return this.combat; }
constructor() { super('Stage'); }
Expand All @@ -44,14 +47,18 @@ export class Stage extends Phaser.Scene {
collisionDebug(this, data.collisions);
const machine = import.meta.env.DEV && new URLSearchParams(location.search).has('coffee-test')
? 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;
this.player = new Player(this, data.collisions, data.manifest.region, this.defeatedBosses,
machine ? { x: machine.x + machine.w / 2, y: machine.y + machine.h } : undefined);
machine ? { x: machine.x + machine.w / 2, y: machine.y + machine.h }
: rover ? { x: rover.x - BODY_WIDTH * 3, y: rover.y } : undefined);
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.follow = import.meta.env.DEV && new URLSearchParams(location.search).has('inspect')
? undefined : cameraFollow(this, data.manifest);
this.scene.launch('UI');
Expand All @@ -70,6 +77,7 @@ export class Stage extends Phaser.Scene {
if (active <= 0) return;
this.coffee?.update();
this.player?.update(active);
this.rovers?.update(active);
this.combat?.update(active);
if (this.player) this.follow?.(this.player.body, active);
}
Expand Down
56 changes: 56 additions & 0 deletions game/src/enemies/rover.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { Rover, CHARGE_SPEED, PATROL_SPEED } from './rover';
import { parseCollision, placeCollision } from '../stage/collision';
import type { Damageable } from '../combat/sweeps';
import { Sweeps, intersects } from '../combat/sweeps';
import raw from '../../../art/stages/rusty-creek/rc-0-0.anomaly.collision.json';

const tutorial = () => {
const spawn = placeCollision(parseCollision(raw, 'rc-0-0', 'anomaly'), [0, 0]).world.spawn.find(point => point.kind === 'rover')!;
return new Rover({ x: spawn.x, y: spawn.y }, spawn.patrol as [number, number]);
};
describe('autonomous rover', () => {
it('patrols the real tutorial range at half walking speed without leaving its deck', () => {
const rover = tutorial(); expect(rover.patrol).toEqual([640, 880]);
expect(rover.bounds).toEqual({ x: 744.5, y: 483, w: 31, h: 25 });
expect(PATROL_SPEED).toBe(75.25); rover.step(1); expect(rover.x).toBeCloseTo(835.25);
for (let n = 0; n < 30; n++) { rover.step(0.1); expect(rover.x).toBeGreaterThanOrEqual(640); expect(rover.x).toBeLessThanOrEqual(880); }
});
it('shows red lidar and rear-up for exactly half a second before a locked straight charge', () => {
const rover = tutorial(); rover.step(1 / 120, { x: 840, y: 508 });
expect(rover.phase).toBe('tell'); expect(rover.pose).toBe('charge-00');
rover.step(0.25); expect(rover.pose).toBe('charge-01'); expect(rover.x).toBe(760);
rover.step(0.25); expect(rover.phase).toBe('charge'); expect(CHARGE_SPEED).toBe(258);
rover.step(0.1, { x: 700, y: 400 }); expect(rover.x).toBeCloseTo(785.8); expect(rover.direction).toBe(1);
rover.step(1); expect(rover.x).toBeLessThanOrEqual(880);
});
it('spots only forward within the documented tuning band', () => {
for (const hero of [{ x: 700, y: 508 }, { x: 920, y: 508 }, { x: 800, y: 400 }]) {
const rover = tutorial(); rover.step(1 / 120, hero); expect(rover.phase).toBe('patrol');
}
});
it('accepts three separate sweeps, plays the five wreck poses, and signals defeat once', () => {
const rover = tutorial(), sweep = new Sweeps(), hero = { x: 730, y: 508 };
const target: Damageable = { id: 'rover-1', bounds: () => rover.bounds, hit: hit => rover.hit(hit.damage, hit.direction) };
for (let n = 0; n < 3; n++) {
hero.x = rover.x - 30;
sweep.press(1); sweep.step((n % 2 ? 3 : 4) / 12);
expect(sweep.hit(hero, [target])).toHaveLength(1);
expect(sweep.hit(hero, [target])).toHaveLength(0);
expect(rover.health).toBe(2 - n);
if (n < 2) rover.step(3 / 12);
sweep.step(1);
}
expect(rover.phase).toBe('wreck'); expect(rover.pose).toBe('wreck-00');
expect(rover.step(4 / 12)).toBe(false); expect(rover.pose).toBe('wreck-04');
expect(rover.step(1 / 12)).toBe(true); expect(rover.phase).toBe('dead');
expect(rover.step(2)).toBe(false); expect(rover.hit(1, 1)).toBe(false);
rover.reset(); expect(rover.health).toBe(3); expect(rover.x).toBe(760);
});
it('uses three-frame hit recovery and lets the hero jump clear of the body', () => {
const rover = tutorial(); expect(rover.hit(1, -1)).toBe(true); expect(rover.hit(1, -1)).toBe(false);
rover.step(3 / 12); expect(rover.x).toBeCloseTo(744.5); expect(rover.phase).toBe('patrol');
expect(intersects(rover.bounds, { x: 730, y: 360, w: 28, h: 76 })).toBe(false);
expect(intersects(rover.bounds, { x: 730, y: 432, w: 28, h: 76 })).toBe(true);
});
});
69 changes: 69 additions & 0 deletions game/src/enemies/rover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { WALK_SPEED, RUN_SPEED } from '../player/movement';
import { BODY_HEIGHT } from '../player/body';
import type { Position } from '../core/events';

// docs/05: measured idle-00 alpha bounds and controller tuning.
export const ROVER_WIDTH = 31, ROVER_HEIGHT = 25;
export const TELL_SECONDS = 0.5, PATROL_SPEED = WALK_SPEED / 2, CHARGE_SPEED = RUN_SPEED;
export type RoverPhase = 'patrol' | 'tell' | 'charge' | 'recover' | 'hit' | 'wreck' | 'dead';
export class Rover {
x: number;
readonly y: number;
health = 3;
phase: RoverPhase = 'patrol';
direction: -1 | 1 = 1;
elapsed = 0;
private hitDirection = 1;
constructor(readonly spawn: Position, readonly patrol: [number, number]) {
this.x = spawn.x; this.y = spawn.y;
}
private enter(phase: RoverPhase) { this.phase = phase; this.elapsed = 0; }
reset() { this.x = this.spawn.x; this.health = 3; this.direction = 1; this.enter('patrol'); }
get bounds() { return { x: this.x - ROVER_WIDTH / 2, y: this.y - ROVER_HEIGHT, w: ROVER_WIDTH, h: ROVER_HEIGHT }; }
hit(damage: number, direction: number) {
if (!Number.isInteger(damage) || damage <= 0) throw new Error('Damage must be a positive integer');
if (this.health <= 0 || this.phase === 'hit') return false;
this.health = Math.max(0, this.health - damage); this.hitDirection = direction;
this.enter(this.health ? 'hit' : 'wreck'); return true;
}
/** Returns true exactly when the last wreck frame ends. Stage hit-stop pauses this clock. */
step(seconds: number, hero?: Position) {
let defeated = false, remaining = seconds;
while (remaining > 1e-9) {
const dt = Math.min(remaining, 1 / 120); remaining -= dt; this.elapsed += dt;
switch (this.phase) {
case 'patrol':
if (hero && (hero.x - this.x) * this.direction > 0 && Math.abs(hero.x - this.x) <= WALK_SPEED
&& Math.abs(hero.y - this.y) <= BODY_HEIGHT) { this.enter('tell'); break; }
this.x += this.direction * PATROL_SPEED * dt;
if (this.x <= this.patrol[0] || this.x >= this.patrol[1]) {
this.x = Math.max(this.patrol[0], Math.min(this.patrol[1], this.x)); this.direction = this.x <= this.patrol[0] ? 1 : -1;
}
break;
case 'tell': if (this.elapsed + 1e-9 >= TELL_SECONDS) this.enter('charge'); break;
case 'charge':
this.x += this.direction * CHARGE_SPEED * dt;
if (this.x <= this.patrol[0] || this.x >= this.patrol[1]) {
this.x = Math.max(this.patrol[0], Math.min(this.patrol[1], this.x)); this.direction = this.x <= this.patrol[0] ? 1 : -1;
this.enter('recover');
}
break;
case 'recover': if (this.elapsed + 1e-9 >= TELL_SECONDS) this.enter('patrol'); break;
case 'hit':
this.x = Math.max(this.patrol[0], Math.min(this.patrol[1], this.x + this.hitDirection * ROVER_WIDTH / 2 / (3 / 12) * dt));
if (this.elapsed + 1e-9 >= 3 / 12) this.enter('patrol');
break;
case 'wreck': if (this.elapsed + 1e-9 >= 5 / 12) { this.enter('dead'); defeated = true; } break;
}
}
return defeated;
}
get pose() {
const frame = Math.floor((this.elapsed + 1e-9) * 12);
if (this.phase === 'wreck' || this.phase === 'dead') return `wreck-${String(this.phase === 'dead' ? 4 : Math.min(4, frame)).padStart(2, '0')}`;
if (this.phase === 'hit') return 'idle-00'; // The renderer flashes the living body white.
if (this.phase === 'tell') return `charge-0${this.elapsed + 1e-9 < TELL_SECONDS / 2 ? 0 : 1}`;
if (this.phase === 'charge') return `charge-0${2 + frame % 2}`;
return this.phase === 'patrol' ? `drive-${String(frame % 6).padStart(2, '0')}` : `idle-${String(frame % 4).padStart(2, '0')}`;
}
}
50 changes: 50 additions & 0 deletions game/src/enemies/rovers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type Phaser from 'phaser';
import type { CollisionChunk } from '../stage/collision';
import { BODY_HEIGHT, BODY_WIDTH } from '../player/body';
import type { Player } from '../player/player';
import type { ChairCombat } from '../combat/chair';
import { intersects } from '../combat/sweeps';
import { gameEvents } from '../core/events';
import { Rover, ROVER_HEIGHT } from './rover';

export class Rovers {
private rows;
private debug: Phaser.GameObjects.Graphics;
private visible = false;
constructor(private scene: Phaser.Scene, private player: Player, chair: ChairCombat, chunks: CollisionChunk[]) {
const cleanup: (() => void)[] = [];
this.debug = scene.add.graphics().setDepth(10003);
this.rows = chunks.flatMap(chunk => chunk.world.spawn).filter(spawn => spawn.kind === 'rover').map(spawn => {
const range = spawn.patrol;
if (!Array.isArray(range) || range.length !== 2 || !range.every(Number.isFinite) || range[0] >= range[1]) {
throw new Error(`Invalid rover patrol: ${spawn.id}`);
}
const model = new Rover({ x: spawn.x, y: spawn.y }, range as [number, number]);
const sprite = scene.add.sprite(model.x, model.y, 'enemy-rover', 'idle-00').setOrigin(0.5, 1).setDepth(101);
const label = scene.add.text(model.x, model.y - ROVER_HEIGHT - BODY_WIDTH / 2, '',
{ fontFamily: 'monospace', fontSize: '14px', backgroundColor: '#17242d' }).setDepth(102).setVisible(false);
cleanup.push(chair.register({ id: spawn.id!, bounds: () => model.bounds, hit: hit => model.hit(hit.damage, hit.direction) }));
cleanup.push(player.life.register(spawn.id!, () => model.reset()));
return { id: spawn.id!, model, sprite, label };
});
const toggle = () => { this.visible = !this.visible; for (const row of this.rows) row.label.setVisible(this.visible); };
scene.input.keyboard?.on('keydown-F2', toggle);
scene.events.once('shutdown', () => { scene.input.keyboard?.off('keydown-F2', toggle); cleanup.forEach(off => off()); });
}
update(milliseconds: number) {
this.debug.clear();
for (const { id, model, sprite, label } of this.rows) {
const defeated = model.step(milliseconds / 1000, this.player.life.dead === 0 ? this.player.body : undefined);
if (defeated) gameEvents.emit('enemy-defeated', { id, kind: 'rover', position: { x: model.x, y: model.y } });
sprite.setPosition(Math.round(model.x), model.y).setFrame(model.pose)
.setFlipX(model.direction > 0); // Approved charge has lidar left / trailing dust right.
if (model.phase === 'hit') sprite.setTintFill(0xffffff); else sprite.clearTint();
const box = model.bounds, body = this.player.body;
if (model.health > 0 && intersects(box, { x: body.x - BODY_WIDTH / 2, y: body.y - BODY_HEIGHT, w: BODY_WIDTH, h: BODY_HEIGHT })) {
this.player.damage(1, model.x);
}
label.setPosition(model.x, model.y - ROVER_HEIGHT - BODY_WIDTH / 2).setText(`${id} ${model.health}/3 ${model.phase}`);
if (this.visible && model.health > 0) this.debug.lineStyle(1, 0xff7835).strokeRect(box.x, box.y, box.w, box.h);
}
}
}
Binary file added game/verification/45-rover-pose-compare.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading