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
30 changes: 30 additions & 0 deletions docs/02-GAME-DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<br/>86 × 81 cells, feet 43,81"] --> POSE["8 poses A / B at authored 12 fps<br/>release Act; buffer one tap"]
POSE --> MASK["A04 / B03 contact window only<br/>front 8–33; above feet 23–58 px"]
MASK --> HIT["Overlap target bounds<br/>one attempt per target per swing"]
HIT --> FX["Accepted hit: 60 ms stop<br/>spark, sound, target knockback"]
FX --> OUT["Finish sweep + 2 recoil poses<br/>consume buffered return swing"]
MASK --> CHECK["compare.py baseline comparison<br/>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.
Expand Down
6 changes: 6 additions & 0 deletions game/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
92 changes: 92 additions & 0 deletions game/src/combat/chair.ts
Original file line number Diff line number Diff line change
@@ -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<string, Damageable>();
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');
}
}
50 changes: 50 additions & 0 deletions game/src/combat/sweeps.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
68 changes: 68 additions & 0 deletions game/src/combat/sweeps.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<Damageable>) {
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;
}
}
12 changes: 10 additions & 2 deletions game/src/core/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -31,6 +32,9 @@ export class Stage extends Phaser.Scene {
private player?: Player;
private defeatedBosses = new Set<string>();
private follow?: ReturnType<typeof cameraFollow>;
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);
Expand All @@ -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');
Expand All @@ -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);
}
}

Expand Down
1 change: 1 addition & 0 deletions game/src/player/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()) {
this.spawn = chunks.flatMap(c => c.world.spawn).find(p => p.kind === 'player') ?? { x: 120, y: 461 };
Expand Down
Binary file added game/verification/39-chair-contact-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