From 21e3b7c4c290d1bc2ef00382cdc337ec05f61af1 Mon Sep 17 00:00:00 2001 From: Vatsal2005 <83007841+Vatsal2005@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:44:51 +0200 Subject: [PATCH] feat: patrol and charge with autonomous rovers Use authored rover spawns and patrol ranges, a half-second lidar tell, straight charges and a measured 31 by 25 pixel body. Register three-hit chair targets and contact damage; play the wreck poses and emit one defeat event. Reset normal rovers on player death and document controller tuning with a reference comparison. Typecheck, 113 tests, build and docs pass. Guard, pogo and shared fluid integration remain for their assigned issues. --- docs/05-ENEMIES-AND-BOSSES.md | 35 ++++++++++ game/src/core/scaffold.ts | 10 ++- game/src/enemies/rover.test.ts | 56 ++++++++++++++++ game/src/enemies/rover.ts | 69 ++++++++++++++++++++ game/src/enemies/rovers.ts | 50 ++++++++++++++ game/verification/45-rover-pose-compare.png | Bin 0 -> 10879 bytes 6 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 game/src/enemies/rover.test.ts create mode 100644 game/src/enemies/rover.ts create mode 100644 game/src/enemies/rovers.ts create mode 100644 game/verification/45-rover-pose-compare.png 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 0000000000000000000000000000000000000000..412f260ebad775824dd303d90703aecbcaea146e GIT binary patch literal 10879 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