diff --git a/docs/02-GAME-DESIGN.md b/docs/02-GAME-DESIGN.md index 381bb9d..d03dff7 100644 --- a/docs/02-GAME-DESIGN.md +++ b/docs/02-GAME-DESIGN.md @@ -71,6 +71,36 @@ Every weapon is a real object we photographed and posed with on 23 Oct. - Enemies show a 0.4–0.6 s **tell** (flash, rear-up, lidar turns red) before attacking. - **Guard:** blocks frontal damage and pushes you back a little. A heavy hit (Jenny) breaks the guard. +### Chair sweep implementation – measured contact (#39) + +The `chair-side` atlas uses 86 × 81 px cells with the feet at (43, 81). +The approved contact poses are A04 and B03; B is mirrored into the strike +direction. A04 reaches 31 px forward and B03 reaches 33 px. Their wooden +seat pixels start 8 px in front of the pivot; the highest contact wood is +58 px above the feet. The collision envelope therefore extends **8–33 px +forward, 23–58 px above the feet**. Its lower edge includes two pixels of +overlap with a 25 px rover (the 2 px grain in [09](09-ART-PIPELINE.md)), +so the short sweep can catch the small ground enemy without extending its +horizontal reach beyond the approved pose. This envelope is gameplay +collision, not an alpha mask of the coat or legs. + +```mermaid +flowchart LR + IN["chair-side atlas + animation table
86 × 81 cells, feet 43,81"] --> POSE["8 poses A / B at authored 12 fps
release Act; buffer one tap"] + POSE --> MASK["A04 / B03 contact window only
front 8–33; above feet 23–58 px"] + MASK --> HIT["Overlap target bounds
one attempt per target per swing"] + HIT --> FX["Accepted hit: 60 ms stop
spark, sound, target knockback"] + FX --> OUT["Finish sweep + 2 recoil poses
consume buffered return swing"] + MASK --> CHECK["compare.py baseline comparison
exact bounds + both directions in tests"] +``` + +The development rover fixture is 25 px tall ([05](05-ENEMIES-AND-BOSSES.md)) +and 31 px wide (the approved `enemy-rover:idle-00` alpha bounds). +It starts 38.5 px ahead: 33 px reach + half the rover width − its 8 px test +knockback − 2 px overlap. Both alternating sweeps can then connect while +the hero stands still. The 8 px displacement is fixture tuning; real enemy +knockback belongs to the enemy controller. + ## The glitch meter (after Region 1) Each defeated anomaly charges a 3-segment **glitch meter**. This was Lex's idea, merged with Alexander's Dexter version. **It is not a magic system** (A14): spells would make the game too easy. diff --git a/game/README.md b/game/README.md index 81665f4..9179b63 100644 --- a/game/README.md +++ b/game/README.md @@ -71,3 +71,9 @@ Actors register reset callbacks with Player.life; defeated boss ids persist. Orange hazards damage and return to safe ground; black no-go resets are free. Development F5 tests a normal hit, Shift+F5 a two-cell hit, Ctrl+F5 a hazard reset, and Alt+F5 a free no-go reset. These keys are absent from production builds. + +Tap J or gamepad X/Square for alternating chair sweeps, each followed by the +two-frame recoil. Only contact poses deal damage; a hit freezes stage actors for +60ms and creates a small spark. F2 shows the active hitbox. Development +`?region=rusty-creek&state=anomaly&combat-test` adds a stationary rover target. +Story integration can gate chair use through `Stage.chair.enabled`. diff --git a/game/src/combat/chair.ts b/game/src/combat/chair.ts new file mode 100644 index 0000000..12da5aa --- /dev/null +++ b/game/src/combat/chair.ts @@ -0,0 +1,92 @@ +import Phaser from 'phaser'; +import type { Player } from '../player/player'; +import { sound } from '../fx/sound/Audio'; +import { HitStop, Sweeps, SWEEP_CONTACT, type Damageable, type Rect } from './sweeps'; + +const ROVER_HEIGHT = 25; // docs/05 and approved enemy-rover:idle-00 alpha height. +const ROVER_WIDTH = 31; // Approved enemy-rover:idle-00 alpha width; compare.py measures 31×25. +const FIXTURE_KNOCK = 8; // docs/02: development fixture displacement, not real enemy tuning. +const GRAIN = 2; // docs/09: two-pixel grain; retain this overlap after the fixture moves. + +export class ChairCombat { + readonly sweeps = new Sweeps(); + readonly pause = new HitStop(); + private targets = new Map(); + private key: Phaser.Input.Keyboard.Key; + private wasDown = false; + private debug: Phaser.GameObjects.Graphics; + private label: Phaser.GameObjects.Text; + private visible = false; + enabled: boolean; + constructor(private scene: Phaser.Scene, private player: Player, region: string) { + this.enabled = region === 'rusty-creek'; + this.key = scene.input.keyboard!.addKey('J'); + this.debug = scene.add.graphics().setDepth(10003); + this.label = scene.add.text(20, 260, '', { fontFamily: 'monospace', fontSize: '18px', backgroundColor: '#17242d' }) + .setDepth(10004).setScrollFactor(0).setVisible(false); + const toggle = () => { this.visible = !this.visible; this.label.setVisible(this.visible); }; + scene.input.keyboard?.on('keydown-F2', toggle); + scene.events.once('shutdown', () => scene.input.keyboard?.off('keydown-F2', toggle)); + if (import.meta.env.DEV && new URLSearchParams(location.search).has('combat-test')) this.dummy(); + } + register(target: Damageable) { + this.targets.set(target.id, target); + return () => { if (this.targets.get(target.id) === target) this.targets.delete(target.id); }; + } + private spark(box: Rect) { + sound.play('chair-hit'); + for (let i = 0; i < 6; i++) { + const pixel = this.scene.add.rectangle(box.x + box.w / 2, box.y + box.h / 2, 3, 2, 0xfff4cb).setDepth(120); + this.scene.tweens.add({ targets: pixel, x: pixel.x - 12 + i * 5, y: pixel.y - 14 + i % 3 * 8, + alpha: 0, duration: 140, onComplete: () => pixel.destroy() }); + } + } + private dummy() { + const table = this.scene.cache.json.get('enemy-rover.anims'); + const distance = SWEEP_CONTACT.reach + ROVER_WIDTH / 2 - FIXTURE_KNOCK - GRAIN; + const actor = this.scene.add.sprite(this.player.body.x + distance, this.player.body.y, 'enemy-rover', Object.keys(table)[0] + '-00') + .setOrigin(0.5, 1).setDepth(101); + const spawn = { x: actor.x, y: actor.y }; + let count = 0; + const counter = this.scene.add.text(actor.x, actor.y - 40, 'hits 0', { fontSize: '14px', backgroundColor: '#17242d' }).setDepth(102); + const bounds = () => ({ x: actor.x - ROVER_WIDTH / 2, y: actor.y - ROVER_HEIGHT, w: ROVER_WIDTH, h: ROVER_HEIGHT }); + this.register({ id: 'verification-rover', bounds, hit: hit => { + count++; actor.x += hit.direction * FIXTURE_KNOCK; actor.setTintFill(0xffffff); + counter.setPosition(actor.x, actor.y - 40).setText('hits ' + count); + this.scene.time.delayedCall(100, () => actor.clearTint()); return true; + } }); + this.player.life.register('verification-rover', () => { + count = 0; actor.setPosition(spawn.x, spawn.y); counter.setPosition(spawn.x, spawn.y - 40).setText('hits 0'); + }); + } + update(milliseconds: number) { + const pad = this.scene.input.gamepad?.getAll().find(p => p.connected); + const down = this.key.isDown || !!pad?.X; + const keyReleased = Phaser.Input.Keyboard.JustUp(this.key); + if (this.enabled && this.player.canAttack && !down && (this.wasDown || keyReleased)) { + const first = this.sweeps.phase === 'idle'; + this.sweeps.press(this.player.facing); + if (first) sound.play('chair-whoosh'); + } + this.wasDown = down; + if (!this.enabled || !this.player.canAttack) this.sweeps.cancel(); + let remaining = milliseconds / 1000; + while (remaining > 1e-9) { + const dt = Math.min(remaining, 1 / 120), previous = this.sweeps.phase; + this.sweeps.step(dt); + if (previous !== this.sweeps.phase && (this.sweeps.phase === 'sweep-a' || this.sweeps.phase === 'sweep-b')) sound.play('chair-whoosh'); + const hits = this.sweeps.hit(this.player.body, this.targets.values()); + if (hits.length) { hits.forEach(box => this.spark(box)); this.pause.trigger(); break; } + remaining -= dt; + } + if (this.sweeps.phase !== 'idle') { + const frame = this.sweeps.phase + '-' + String(this.sweeps.frame).padStart(2, '0'); + if (this.scene.textures.get('chair-side').has(frame)) this.player.sprite.setTexture('chair-side', frame) + .setFlipX((this.sweeps.direction < 0) !== (this.sweeps.phase === 'sweep-b')); + } + this.debug.clear(); + const box = this.sweeps.box(this.player.body); + if (this.visible && box) this.debug.lineStyle(2, 0xff7835).strokeRect(box.x, box.y, box.w, box.h); + this.label.setText('chair ' + this.sweeps.phase + ' ' + this.sweeps.frame + ' | hit-stop ' + this.pause.remaining.toFixed(0) + 'ms'); + } +} diff --git a/game/src/combat/sweeps.test.ts b/game/src/combat/sweeps.test.ts new file mode 100644 index 0000000..c4559cf --- /dev/null +++ b/game/src/combat/sweeps.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { HitStop, Sweeps, type Damageable } from './sweeps'; +import clips from '../../public/assets/sprites/chair-side.anims.json'; +describe('light chair sweeps', () => { + it('alternates eight-frame sweeps and completes two recoil frames, with a one-tap buffer', () => { + expect(clips['sweep-a'].frames).toBe(8); expect(clips['sweep-b'].frames).toBe(8); expect(clips.recoil.frames).toBe(2); + const sweep = new Sweeps(); sweep.press(1); expect(sweep.phase).toBe('sweep-a'); + sweep.press(1); sweep.step(8 / 12); expect(sweep.phase).toBe('recoil'); expect(sweep.frame).toBe(0); + sweep.step(1 / 12); expect(sweep.phase).toBe('recoil'); expect(sweep.frame).toBe(1); + sweep.step(1 / 12); expect(sweep.phase).toBe('sweep-b'); expect(sweep.frame).toBe(0); + }); + it('enables hitboxes only on A04 and B03, with a single hit per target per swing', () => { + const sweep = new Sweeps(), position = { x: 100, y: 500 }; let hits = 0; + const target: Damageable = { id: 'dummy', bounds: () => ({ x: 128, y: 475, w: 25, h: 25 }), hit: () => { hits++; return true; } }; + sweep.press(1); sweep.step(3 / 12); expect(sweep.box(position)).toBeNull(); + sweep.step(1 / 12); expect(sweep.hit(position, [target])).toHaveLength(1); + expect(sweep.hit(position, [target])).toHaveLength(0); expect(hits).toBe(1); + sweep.step(1 / 12); expect(sweep.box(position)).toBeNull(); + sweep.step(5 / 12); sweep.press(1); sweep.step(3 / 12); + expect(sweep.hit(position, [target])).toHaveLength(1); expect(hits).toBe(2); + }); + it('hits in the facing direction on both sides and never retries a rejected strike', () => { + for (const direction of [-1, 1] as const) { + const sweep = new Sweeps(); let attempts = 0; + const target: Damageable = { id: 'guarded', bounds: () => ({ x: direction > 0 ? 130 : 50, y: 475, w: 25, h: 25 }), + hit: hit => { attempts++; expect(hit.direction).toBe(direction); return false; } }; + sweep.press(direction); sweep.step(4 / 12); + expect(sweep.hit({ x: 100, y: 500 }, [target])).toHaveLength(0); + sweep.hit({ x: 100, y: 500 }, [target]); expect(attempts).toBe(1); + } + }); + it('cancels pending attacks on damage, death or climbing', () => { + const sweep = new Sweeps(); sweep.press(1); sweep.press(-1); sweep.cancel(); sweep.step(2); + expect(sweep.phase).toBe('idle'); expect(sweep.box({ x: 0, y: 0 })).toBeNull(); + }); + it('matches the documented 8–33 px reach and 23–58 px vertical envelope on both sides', () => { + const right = new Sweeps(), left = new Sweeps(), position = { x: 100, y: 500 }; + right.press(1); left.press(-1); right.step(4 / 12); left.step(4 / 12); + expect(right.box(position)).toEqual({ x: 108, y: 442, w: 25, h: 35 }); + expect(left.box(position)).toEqual({ x: 67, y: 442, w: 25, h: 35 }); + const beyond: Damageable = { id: 'beyond', bounds: () => ({ x: 134, y: 475, w: 25, h: 25 }), + hit: () => { throw new Error('A short chair cannot hit beyond the approved pose'); } }; + expect(right.hit(position, [beyond])).toHaveLength(0); + }); + it('consumes exactly 60ms of hit-stop, including a partial rendered frame', () => { + const stop = new HitStop(); stop.trigger(); let active = 0; + for (let i = 0; i < 6; i++) active += stop.consume(1000 / 60); + expect(active).toBeCloseTo(40, 7); expect(stop.remaining).toBe(0); + }); +}); diff --git a/game/src/combat/sweeps.ts b/game/src/combat/sweeps.ts new file mode 100644 index 0000000..7f2fbfb --- /dev/null +++ b/game/src/combat/sweeps.ts @@ -0,0 +1,68 @@ +export interface Rect { x: number; y: number; w: number; h: number } +export interface ChairHit { damage: number; direction: -1 | 1; position: { x: number; y: number }; kind: 'sweep' } +export interface Damageable { id: string; bounds(): Rect; hit(hit: ChairHit): boolean } +export const intersects = (a: Rect, b: Rect) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; + +// docs/02 measured contact: chair-side A04/B03, cell 86×81, foot pivot (43,81). +export const SWEEP_CONTACT = { inner: 8, reach: 33, top: 58, bottom: 23 } as const; +const HIT_STOP_MS = 60; // docs/02: the chair is light; every accepted hit stops for 60 ms. + +export class HitStop { + remaining = 0; + trigger() { this.remaining = HIT_STOP_MS; } + consume(milliseconds: number) { + const paused = Math.min(this.remaining, milliseconds); + this.remaining -= paused; + return milliseconds - paused; + } +} + +/** Contact poses inspected in the approved atlas: A=04, B=03. */ +export class Sweeps { + phase: 'idle' | 'sweep-a' | 'sweep-b' | 'recoil' = 'idle'; + frame = 0; + direction: -1 | 1 = 1; + private nextB = false; + private elapsed = 0; + private queued: -1 | 1 | null = null; + private struck = new Set(); + private begin(direction: -1 | 1) { + this.phase = this.nextB ? 'sweep-b' : 'sweep-a'; this.nextB = !this.nextB; + this.direction = direction; this.frame = 0; this.elapsed = 0; this.struck.clear(); + } + press(direction: -1 | 1) { + if (this.phase === 'idle') this.begin(direction); + else this.queued = direction; + } + cancel() { this.phase = 'idle'; this.frame = 0; this.elapsed = 0; this.queued = null; this.struck.clear(); } + step(dt: number) { + if (this.phase === 'idle') return; + this.elapsed += dt; + while (this.elapsed + 1e-9 >= 1 / 12 && this.phase !== 'idle') { + this.elapsed = Math.max(0, this.elapsed - 1 / 12); this.frame++; + if (this.frame < (this.phase === 'recoil' ? 2 : 8)) continue; + if (this.phase !== 'recoil') { this.phase = 'recoil'; this.frame = 0; } + else { + this.phase = 'idle'; this.frame = 0; + if (this.queued !== null) { const direction = this.queued; this.queued = null; this.begin(direction); } + } + } + } + box(position: { x: number; y: number }): Rect | null { + const contact = this.phase === 'sweep-a' && this.frame === 4 || this.phase === 'sweep-b' && this.frame === 3; + const { inner, reach, top, bottom } = SWEEP_CONTACT; + return contact ? { x: position.x + (this.direction > 0 ? inner : -reach), y: position.y - top, + w: reach - inner, h: top - bottom } : null; + } + hit(position: { x: number; y: number }, targets: Iterable) { + const box = this.box(position), hits: Rect[] = []; + if (!box) return hits; + for (const target of targets) { + const bounds = target.bounds(); + if (this.struck.has(target.id) || !intersects(box, bounds)) continue; + this.struck.add(target.id); + if (target.hit({ damage: 1, direction: this.direction, position: { ...position }, kind: 'sweep' })) hits.push(bounds); + } + return hits; + } +} diff --git a/game/src/core/scaffold.ts b/game/src/core/scaffold.ts index 68a12d2..2704ae9 100644 --- a/game/src/core/scaffold.ts +++ b/game/src/core/scaffold.ts @@ -6,6 +6,7 @@ import { loadCollisions, type CollisionChunk } from '../stage/collision'; import { collisionDebug } from '../stage/collision-debug'; import { Player } from '../player/player'; import { cameraFollow } from '../stage/camera'; +import { ChairCombat } from '../combat/chair'; // Bootstrap-only scenes live in lane B until lane A provides story/UI scenes. export class Boot extends Phaser.Scene { @@ -31,6 +32,9 @@ export class Stage extends Phaser.Scene { private player?: Player; private defeatedBosses = new Set(); private follow?: ReturnType; + private combat?: ChairCombat; + get playerActor() { return this.player; } + get chair() { return this.combat; } constructor() { super('Stage'); } create(data: { manifest: StageManifest; state: RegionState; collisions: CollisionChunk[] }) { drawStage(this, data.manifest, data.state); @@ -40,6 +44,7 @@ export class Stage extends Phaser.Scene { 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.follow = import.meta.env.DEV && new URLSearchParams(location.search).has('inspect') ? undefined : cameraFollow(this, data.manifest); this.scene.launch('UI'); @@ -54,8 +59,11 @@ export class Stage extends Phaser.Scene { } } update(_time: number, delta: number) { - this.player?.update(delta); - if (this.player) this.follow?.(this.player.body, delta); + const active = Math.min(100, this.combat?.pause.consume(delta) ?? delta); + if (active <= 0) return; + this.player?.update(active); + this.combat?.update(active); + if (this.player) this.follow?.(this.player.body, active); } } diff --git a/game/src/player/player.ts b/game/src/player/player.ts index 43967fd..09d1865 100644 --- a/game/src/player/player.ts +++ b/game/src/player/player.ts @@ -25,6 +25,7 @@ export class Player { private traversal = traversal(); private animation: LocomotionAnimation; get canAttack() { return this.traversal.ladder === null && this.life.dead === 0 && this.life.knock === 0; } + get facing() { return this.motion.facing; } constructor(private scene: Phaser.Scene, private chunks: CollisionChunk[], private region = 'rusty-creek', bosses = new Set()) { this.spawn = chunks.flatMap(c => c.world.spawn).find(p => p.kind === 'player') ?? { x: 120, y: 461 }; diff --git a/game/verification/39-chair-contact-compare.png b/game/verification/39-chair-contact-compare.png new file mode 100644 index 0000000..98608f7 Binary files /dev/null and b/game/verification/39-chair-contact-compare.png differ