diff --git a/docs/05-ENEMIES-AND-BOSSES.md b/docs/05-ENEMIES-AND-BOSSES.md index 4530c68..3c3fb68 100644 --- a/docs/05-ENEMIES-AND-BOSSES.md +++ b/docs/05-ENEMIES-AND-BOSSES.md @@ -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
enemy-rover atlas, 31 × 25 body"] --> PATROL["Patrol within endpoints
lidar tests forward hero band"] + PATROL --> TELL["Red lidar + rear-up
0.5 s; lock charge direction"] + TELL --> CHARGE["Straight charge at 258 px/s
stop at patrol boundary"] + CHARGE --> RECOVER["Recover 0.5 s, turn inward"] + RECOVER --> PATROL + HIT["Chair register target
one cell of contact damage to hero"] --> DAMAGE["3 HP; white flash
3-frame knockback or fatal wreck"] + DAMAGE --> WRECK["5 approved wreck poses
hit, flip, stopped machine"] + WRECK --> EVENT["enemy-defeated once
black-fluid consumer in #48"] + IN --> CHECK["Reference baseline + real patrol tests
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. diff --git a/game/src/core/scaffold.ts b/game/src/core/scaffold.ts index 2bf5a9c..c57ce2f 100644 --- a/game/src/core/scaffold.ts +++ b/game/src/core/scaffold.ts @@ -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 { @@ -36,6 +38,7 @@ export class Stage extends Phaser.Scene { private follow?: ReturnType; private combat?: ChairCombat; private coffee?: CoffeePoints; + private rovers?: Rovers; get playerActor() { return this.player; } get chair() { return this.combat; } constructor() { super('Stage'); } @@ -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'); @@ -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); } diff --git a/game/src/enemies/rover.test.ts b/game/src/enemies/rover.test.ts new file mode 100644 index 0000000..822b91d --- /dev/null +++ b/game/src/enemies/rover.test.ts @@ -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); + }); +}); diff --git a/game/src/enemies/rover.ts b/game/src/enemies/rover.ts new file mode 100644 index 0000000..6d2089c --- /dev/null +++ b/game/src/enemies/rover.ts @@ -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')}`; + } +} diff --git a/game/src/enemies/rovers.ts b/game/src/enemies/rovers.ts new file mode 100644 index 0000000..175e612 --- /dev/null +++ b/game/src/enemies/rovers.ts @@ -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); + } + } +} diff --git a/game/verification/45-rover-pose-compare.png b/game/verification/45-rover-pose-compare.png new file mode 100644 index 0000000..412f260 Binary files /dev/null and b/game/verification/45-rover-pose-compare.png differ