From 758dad02a229f7849e6b68f5ec6122127ca56669 Mon Sep 17 00:00:00 2001 From: Vatsal2005 <83007841+Vatsal2005@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:01:27 +0200 Subject: [PATCH 1/2] feat: add alternating chair sweeps and contact hits Alternate the approved side sweeps through their recoil poses and expose short hitboxes only at the inspected contact frames. Keep one hit attempt per target and one buffered return swing. Freeze stage actors for 60 milliseconds on an accepted strike, play the hit sound and spark, and let targets apply knockback. Add a development rover fixture and preserve very short keyboard taps through release flags. --- game/README.md | 6 +++ game/src/combat/chair.ts | 86 ++++++++++++++++++++++++++++++++++ game/src/combat/sweeps.test.ts | 41 ++++++++++++++++ game/src/combat/sweeps.ts | 62 ++++++++++++++++++++++++ game/src/core/scaffold.ts | 12 ++++- game/src/player/player.ts | 1 + 6 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 game/src/combat/chair.ts create mode 100644 game/src/combat/sweeps.test.ts create mode 100644 game/src/combat/sweeps.ts 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..725ce97 --- /dev/null +++ b/game/src/combat/chair.ts @@ -0,0 +1,86 @@ +import Phaser from 'phaser'; +import type { Player } from '../player/player'; +import { sound } from '../fx/sound/Audio'; +import { HitStop, Sweeps, type Damageable, type Rect } from './sweeps'; + +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 actor = this.scene.add.sprite(this.player.body.x + 48, 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 - 12.5, y: actor.y - 25, w: 25, h: 25 }); + this.register({ id: 'verification-rover', bounds, hit: hit => { + count++; actor.x += hit.direction * 8; 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..e5e5dd5 --- /dev/null +++ b/game/src/combat/sweeps.test.ts @@ -0,0 +1,41 @@ +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: 135, 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('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..1b67d21 --- /dev/null +++ b/game/src/combat/sweeps.ts @@ -0,0 +1,62 @@ +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; + +export class HitStop { + remaining = 0; + trigger() { this.remaining = 60; } + 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; + return contact ? { x: position.x + (this.direction > 0 ? 8 : -50), y: position.y - 52, w: 42, h: 44 } : 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 }; From 6011cbf60c28cd8729606da48a84bffeacc1a4f1 Mon Sep 17 00:00:00 2001 From: Vatsal2005 <83007841+Vatsal2005@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:24:05 +0200 Subject: [PATCH 2/2] fix: match chair contact to the approved reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the contact plan and native atlas measurements in the design. Limit the contact envelope to 8–33 pixels in front of the hero and keep the rover fixture within both alternating sweeps after knockback. Add a baseline comparison and regression coverage for exact bounds on both sides. Typecheck, 99 tests, build and documentation checks pass; browser strikes connect in both directions. --- docs/02-GAME-DESIGN.md | 30 ++++++++++++++++++ game/src/combat/chair.ts | 14 +++++--- game/src/combat/sweeps.test.ts | 11 ++++++- game/src/combat/sweeps.ts | 10 ++++-- .../verification/39-chair-contact-compare.png | Bin 0 -> 21030 bytes 5 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 game/verification/39-chair-contact-compare.png 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/src/combat/chair.ts b/game/src/combat/chair.ts index 725ce97..12da5aa 100644 --- a/game/src/combat/chair.ts +++ b/game/src/combat/chair.ts @@ -1,7 +1,12 @@ import Phaser from 'phaser'; import type { Player } from '../player/player'; import { sound } from '../fx/sound/Audio'; -import { HitStop, Sweeps, type Damageable, type Rect } from './sweeps'; +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(); @@ -38,14 +43,15 @@ export class ChairCombat { } private dummy() { const table = this.scene.cache.json.get('enemy-rover.anims'); - const actor = this.scene.add.sprite(this.player.body.x + 48, this.player.body.y, 'enemy-rover', Object.keys(table)[0] + '-00') + 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 - 12.5, y: actor.y - 25, w: 25, h: 25 }); + 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 * 8; actor.setTintFill(0xffffff); + 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; } }); diff --git a/game/src/combat/sweeps.test.ts b/game/src/combat/sweeps.test.ts index e5e5dd5..c4559cf 100644 --- a/game/src/combat/sweeps.test.ts +++ b/game/src/combat/sweeps.test.ts @@ -11,7 +11,7 @@ describe('light chair sweeps', () => { }); 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: 135, y: 475, w: 25, h: 25 }), hit: () => { hits++; return true; } }; + 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); @@ -33,6 +33,15 @@ describe('light chair sweeps', () => { 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); diff --git a/game/src/combat/sweeps.ts b/game/src/combat/sweeps.ts index 1b67d21..7f2fbfb 100644 --- a/game/src/combat/sweeps.ts +++ b/game/src/combat/sweeps.ts @@ -3,9 +3,13 @@ export interface ChairHit { damage: number; direction: -1 | 1; position: { x: nu 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 = 60; } + trigger() { this.remaining = HIT_STOP_MS; } consume(milliseconds: number) { const paused = Math.min(this.remaining, milliseconds); this.remaining -= paused; @@ -46,7 +50,9 @@ export class Sweeps { } box(position: { x: number; y: number }): Rect | null { const contact = this.phase === 'sweep-a' && this.frame === 4 || this.phase === 'sweep-b' && this.frame === 3; - return contact ? { x: position.x + (this.direction > 0 ? 8 : -50), y: position.y - 52, w: 42, h: 44 } : null; + 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[] = []; diff --git a/game/verification/39-chair-contact-compare.png b/game/verification/39-chair-contact-compare.png new file mode 100644 index 0000000000000000000000000000000000000000..98608f763e04d4d08e4dcb84436498d2839dc98a GIT binary patch literal 21030 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 X-|0A05>SurfBeBn-vm*n=X~eiUEe@c literal 0 HcmV?d00001