diff --git a/docs/02-GAME-DESIGN.md b/docs/02-GAME-DESIGN.md
index d75a547..39baf49 100644
--- a/docs/02-GAME-DESIGN.md
+++ b/docs/02-GAME-DESIGN.md
@@ -131,6 +131,45 @@ 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.
+### Up attack, slam and pogo implementation (#40)
+
+Directional Act starts on the press: Up takes priority; Down requires
+airborne feet. Ordinary Act retains release-to-sweep and later hold-to-guard
+(#41). Act suppresses climbing and coffee interaction, so Up + Act selects
+the attack even beside a ladder or machine. Damage, death and climbing
+cancel attacks.
+
+The `chair-overhead` cells are 108 × 126 px, pivot (54, 126). Measured dark
+chair legs in `up-03` occupy x=51–78, y=12–37: the up contact envelope is
+**−3…24 px horizontally, 89…114 px above the feet**. Only up poses 02/03
+are active, within the authored six-pose clip at 12 fps. It can overlap
+an enemy above a one-way deck; a deck does not block a chair strike.
+
+Slam uses wind-up poses 00–02, holds descent pose 03, then plays 04–06
+on an ordinary floor. In slam-03 the low chair legs span x=65–91, or
+**11…37 px ahead of the pivot**. The downward contact probe sweeps between
+the previous and current foot positions, with the documented 2 px grain
+as its margin, to prevent tunnelling. Descent tuning is twice run speed
+(516 px/s). Contact with a living enemy or hazard snaps the feet to its
+top and bounces with the existing 485 px/s full-jump impulse (about 96 px).
+The four authored pogo poses play during the bounce. Damage rejection
+does not suppress a physical bounce; a defeated wreck is excluded.
+One directional press during descent or those four poses is buffered until
+pogo recovery ends. Landing on a plain floor, damage or death clears it,
+as with the ordinary sweep buffer.
+
+```mermaid
+flowchart LR
+ INPUT["Up + new Act / airborne Down + new Act
keyboard or gamepad"] --> PRIORITY["Consume special input
suppress coffee, climb and release sweep"]
+ ATLAS["chair-overhead atlas + table
108 × 126, pivot 54,126"] --> MASK["Up: measured legs at poses 02/03
Slam: swept probe 11–37 px forward"]
+ PRIORITY --> MASK
+ MASK --> HIT["One attempt per target
accepted damage: 60 ms stop + spark"]
+ MASK --> BOUNCE["Enemy / hazard top contact before damage
snap feet; full-jump impulse"]
+ BOUNCE --> POSE["4 pogo poses; air control
one buffered directional press starts next slam"]
+ MASK --> FLOOR["Plain floor: remaining slam poses
no crack or shockwave"]
+ HIT --> CHECK["Measured comparison + tests
3 rover pogos; crab-through-deck fixture"]
+```
+
## 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 12da5aa..0a68659 100644
--- a/game/src/combat/chair.ts
+++ b/game/src/combat/chair.ts
@@ -2,6 +2,7 @@ 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';
+import { Overhead, SLAM_SPEED } from './overhead';
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.
@@ -11,9 +12,13 @@ const GRAIN = 2; // docs/09: two-pixel grain; retain this overlap after the fixt
export class ChairCombat {
readonly sweeps = new Sweeps();
readonly pause = new HitStop();
+ readonly overhead = new Overhead();
private targets = new Map();
private key: Phaser.Input.Keyboard.Key;
private wasDown = false;
+ private special = false;
+ private directions;
+ private previousY: number;
private debug: Phaser.GameObjects.Graphics;
private label: Phaser.GameObjects.Text;
private visible = false;
@@ -21,6 +26,16 @@ export class ChairCombat {
constructor(private scene: Phaser.Scene, private player: Player, region: string) {
this.enabled = region === 'rusty-creek';
this.key = scene.input.keyboard!.addKey('J');
+ this.directions = scene.input.keyboard!.addKeys('W,S,UP,DOWN') as Record;
+ this.previousY = player.body.y;
+ const oldHazards = player.onHazards;
+ player.onHazards = boxes => {
+ if (!this.enabled || !player.canAttack) return false;
+ const top = this.overhead.hazard(player.body, this.previousY, boxes);
+ if (top === undefined) return false;
+ player.pogo(top); this.pause.trigger(); this.spark(boxes.find(box => box.y === top)!); return true;
+ };
+ scene.events.once('shutdown', () => { player.onHazards = oldHazards; });
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);
@@ -47,46 +62,62 @@ export class ChairCombat {
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;
+ let count = 0, slams = 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);
+ count++; if (hit.kind === 'slam') slams++;
+ actor.x += hit.direction * FIXTURE_KNOCK; actor.setTintFill(0xffffff);
+ counter.setPosition(actor.x, actor.y - 40).setText('hits ' + count + ' | slam ' + slams);
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');
+ count = 0; slams = 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 pressed = Phaser.Input.Keyboard.JustDown(this.key) || down && !this.wasDown;
const keyReleased = Phaser.Input.Keyboard.JustUp(this.key);
- if (this.enabled && this.player.canAttack && !down && (this.wasDown || keyReleased)) {
+ const up = this.directions.W.isDown || this.directions.UP.isDown || !!pad?.up || (pad?.leftStick.y ?? 0) < -0.2;
+ const below = this.directions.S.isDown || this.directions.DOWN.isDown || !!pad?.down || (pad?.leftStick.y ?? 0) > 0.2;
+ if (this.enabled && this.player.canAttack && pressed && (up || below && !this.player.body.grounded)) {
+ this.special = true; this.sweeps.cancel();
+ if (this.overhead.begin(up ? 'up' : 'slam', this.player.facing)) sound.play('chair-whoosh');
+ }
+ if (this.enabled && this.player.canAttack && !this.special && this.overhead.phase === 'idle' && !down && (this.wasDown || keyReleased)) {
const first = this.sweeps.phase === 'idle';
this.sweeps.press(this.player.facing);
if (first) sound.play('chair-whoosh');
}
+ if (!down && (this.wasDown || keyReleased)) this.special = false;
this.wasDown = down;
- if (!this.enabled || !this.player.canAttack) this.sweeps.cancel();
+ if (!this.enabled || !this.player.canAttack) { this.sweeps.cancel(); this.overhead.cancel(); }
let remaining = milliseconds / 1000;
while (remaining > 1e-9) {
const dt = Math.min(remaining, 1 / 120), previous = this.sweeps.phase;
this.sweeps.step(dt);
+ this.overhead.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());
+ const contact = this.overhead.contact(this.player.body, this.previousY, this.targets.values());
+ if (contact.bounce !== undefined) this.player.pogo(contact.bounce);
+ this.overhead.land(this.player.body.grounded);
+ const hits = [...this.sweeps.hit(this.player.body, this.targets.values()), ...contact.hits];
if (hits.length) { hits.forEach(box => this.spark(box)); this.pause.trigger(); break; }
remaining -= dt;
}
+ if (this.overhead.phase === 'descend') this.player.body.vy = Math.max(this.player.body.vy, SLAM_SPEED);
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'));
}
+ if (this.overhead.pose) this.player.sprite.setTexture('chair-overhead', this.overhead.pose).setFlipX(this.overhead.direction < 0);
this.debug.clear();
- const box = this.sweeps.box(this.player.body);
+ const box = this.sweeps.box(this.player.body) ?? this.overhead.box(this.player.body, this.previousY);
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');
+ this.label.setText('chair ' + (this.overhead.pose ?? this.sweeps.phase + ' ' + this.sweeps.frame) + ' | hit-stop ' + this.pause.remaining.toFixed(0) + 'ms');
+ this.previousY = this.player.body.y;
}
}
diff --git a/game/src/combat/coffee.ts b/game/src/combat/coffee.ts
index 00c80c3..4ecc581 100644
--- a/game/src/combat/coffee.ts
+++ b/game/src/combat/coffee.ts
@@ -23,14 +23,14 @@ export class CoffeePoints {
private machines: Box[];
constructor(private scene: Phaser.Scene, private player: Player, chunks: CollisionChunk[]) {
this.machines = chunks.flatMap(chunk => chunk.world.interact).filter(box => box.kind === 'coffee-machine');
- this.keys = scene.input.keyboard!.addKeys('W,UP') as Record;
+ this.keys = scene.input.keyboard!.addKeys('W,UP,J') as Record;
this.actors = this.machines.map(box => scene.add.sprite(box.x + box.w / 2, box.y + box.h,
'coffee-placeholder', 'idle-00').setOrigin(0.5, 1).setDisplaySize(box.w, box.h).setDepth(99));
}
update() {
const pad = this.scene.input.gamepad?.getAll().find(p => p.connected);
const up = this.keys.W.isDown || this.keys.UP.isDown || !!pad?.up || (pad?.leftStick.y ?? 0) < -0.2;
- const machine = this.use.step(up, this.player.body, this.player.canAttack, this.machines);
+ const machine = this.use.step(up, this.player.body, this.player.canAttack && !this.keys.J.isDown && !pad?.X, this.machines);
if (machine && this.player.beginInteraction()) this.player.saveCheckpoint(machine.id!);
for (const actor of this.actors) actor.setAlpha(coffeeAlpha(this.scene.time.now));
}
diff --git a/game/src/combat/overhead.test.ts b/game/src/combat/overhead.test.ts
new file mode 100644
index 0000000..23802c2
--- /dev/null
+++ b/game/src/combat/overhead.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest';
+import { Overhead, SLAM_SPEED } from './overhead';
+import { Rover } from '../enemies/rover';
+import { pogo, jump } from '../player/jump';
+import { stepBody, type Body } from '../player/body';
+import type { Damageable } from './sweeps';
+import raw from '../../../art/stages/rusty-creek/rc-1-0.anomaly.collision.json';
+import clips from '../../public/assets/sprites/chair-overhead.anims.json';
+
+describe('measured overhead chair attacks', () => {
+ it('enables only up poses 02/03, with exact measured bounds and six-pose duration', () => {
+ expect(clips.up).toEqual({ frames: 6, fps: 12, loop: false });
+ expect(clips.slam.frames).toBe(7); expect(clips.pogo.frames).toBe(4);
+ const attack = new Overhead(), feet = { x: 100, y: 500 };
+ attack.begin('up', 1); attack.step(1 / 12); expect(attack.box(feet, 500)).toBeUndefined();
+ attack.step(1 / 12); expect(attack.box(feet, 500)).toEqual({ x: 97, y: 386, w: 27, h: 25 });
+ attack.step(1 / 12); expect(attack.box(feet, 500)).toBeDefined();
+ attack.step(1 / 12); expect(attack.box(feet, 500)).toBeUndefined();
+ attack.step(2 / 12); expect(attack.pose).toBeUndefined();
+ attack.begin('up', -1); attack.step(2 / 12);
+ expect(attack.box(feet, 500)).toEqual({ x: 76, y: 386, w: 27, h: 25 });
+ });
+ it('hits a 40px crab fixture at its authored one-way deck from below only once', () => {
+ const crab = raw.spawn.find(spawn => spawn.kind === 'crab')!;
+ expect(raw.oneWay.some(line => line.some(point => point[1] === crab.y))).toBe(true);
+ let hits = 0;
+ const target: Damageable = { id: crab.id, bounds: () => ({ x: crab.x - 20, y: crab.y - 40, w: 40, h: 40 }),
+ hit: hit => { expect(hit.kind).toBe('up'); hits++; return true; } };
+ const attack = new Overhead(), feet = { x: crab.x, y: crab.y + 80 };
+ attack.begin('up', 1); attack.step(2 / 12);
+ expect(attack.contact(feet, feet.y, [target]).hits).toHaveLength(1);
+ expect(attack.contact(feet, feet.y, [target]).hits).toHaveLength(0); expect(hits).toBe(1);
+ });
+ it('performs three fresh pogos on the rover, bouncing before body contact', () => {
+ const attack = new Overhead(), rover = new Rover({ x: 760, y: 508 }, [640, 880]);
+ const target: Damageable = { id: 'rover', active: () => rover.health > 0, bounds: () => rover.bounds,
+ hit: hit => { expect(hit.kind).toBe('slam'); return rover.hit(hit.damage, hit.direction); } };
+ for (let n = 0; n < 3; n++) {
+ expect(attack.begin('slam', 1)).toBe(true); attack.step(3 / 12);
+ const top = rover.bounds.y, feet = { x: rover.x - 20, y: top + 5 };
+ const contact = attack.contact(feet, top - 10, [target]);
+ expect(contact.hits).toHaveLength(1); expect(contact.bounce).toBe(top);
+ expect(attack.phase).toBe('pogo'); expect(rover.health).toBe(2 - n);
+ expect(attack.contact(feet, top - 10, [target]).bounce).toBeUndefined();
+ attack.step(4 / 12); rover.step(3 / 12);
+ }
+ });
+ it('sweeps thin hazards without tunnelling and never bounces while rising or on wrecks', () => {
+ const attack = new Overhead(), hazard = { x: 120, y: 500, w: 20, h: 2 };
+ attack.begin('slam', 1); attack.step(3 / 12);
+ expect(SLAM_SPEED).toBe(516);
+ expect(attack.hazard({ x: 100, y: 440 }, 450, [hazard])).toBeUndefined();
+ expect(attack.hazard({ x: 100, y: 520 }, 450, [hazard])).toBe(500);
+ expect(attack.hazard({ x: 100, y: 520 }, 450, [hazard])).toBeUndefined();
+ attack.step(4 / 12); attack.begin('slam', 1); attack.step(3 / 12);
+ const wreck: Damageable = { id: 'wreck', active: () => false, bounds: () => hazard, hit: () => true };
+ expect(attack.contact({ x: 100, y: 520 }, 450, [wreck]).bounce).toBeUndefined();
+ const armour = { ...wreck, active: () => true, hit: () => false };
+ expect(attack.contact({ x: 100, y: 520 }, 450, [armour]).bounce).toBe(500);
+ });
+ it('buffers one directional press during pogo recovery and clears it on cancellation', () => {
+ const attack = new Overhead(), hazard = { x: 120, y: 500, w: 20, h: 2 };
+ attack.begin('slam', 1); attack.step(3 / 12); attack.hazard({ x: 100, y: 510 }, 490, [hazard]);
+ expect(attack.begin('slam', -1)).toBe(true); expect(attack.phase).toBe('pogo');
+ attack.step(3 / 12); expect(attack.phase).toBe('pogo');
+ attack.step(1 / 12); expect(attack.phase).toBe('windup'); expect(attack.direction).toBe(-1);
+ attack.cancel(); attack.step(1); expect(attack.phase).toBe('idle');
+ attack.begin('slam', 1); attack.step(3 / 12); attack.begin('slam', 1);
+ attack.hazard({ x: 100, y: 510 }, 490, [hazard]); attack.cancel(); attack.step(1);
+ expect(attack.phase).toBe('idle');
+ attack.begin('slam', 1); attack.step(3 / 12); attack.begin('slam', 1);
+ attack.land(true); attack.step(3 / 12); expect(attack.phase).toBe('idle');
+ });
+ it('uses a full 96px bounce even without Jump held, then plays plain-floor recovery without shockwaves', () => {
+ const body: Body = { x: 100, y: 500, vx: 0, vy: 516, grounded: false };
+ const air = pogo(body, 483); let peak = body.y;
+ expect(body.y).toBe(483); expect(body.vy).toBe(-485);
+ for (let n = 0; n < 120; n++) { jump(body, air, false, 1 / 120); stepBody(body, [], 1 / 120); peak = Math.min(peak, body.y); }
+ expect(483 - peak).toBeGreaterThan(95); expect(483 - peak).toBeLessThan(97);
+ const attack = new Overhead(); attack.begin('slam', 1); attack.step(3 / 12); attack.land(true);
+ expect(attack.pose).toBe('slam-04'); attack.step(3 / 12); expect(attack.phase).toBe('idle');
+ });
+});
diff --git a/game/src/combat/overhead.ts b/game/src/combat/overhead.ts
new file mode 100644
index 0000000..9f20147
--- /dev/null
+++ b/game/src/combat/overhead.ts
@@ -0,0 +1,78 @@
+import { intersects, type Damageable, type Rect } from './sweeps';
+import type { Position } from '../core/events';
+import { RUN_SPEED } from '../player/movement';
+
+// docs/02: measured chair-overhead legs; cell 108×126, foot pivot (54,126).
+export const UP_CONTACT = { left: -3, right: 24, top: 114, bottom: 89 } as const;
+export const SLAM_CONTACT = { left: 11, right: 37, grain: 2 } as const;
+export const SLAM_SPEED = RUN_SPEED * 2; // Initial tuning in docs/02, 516 px/s.
+type Phase = 'idle' | 'up' | 'windup' | 'descend' | 'land' | 'pogo';
+export class Overhead {
+ phase: Phase = 'idle';
+ direction: -1 | 1 = 1;
+ private elapsed = 0;
+ private queued?: { kind: 'up' | 'slam'; direction: -1 | 1 };
+ private struck = new Set();
+ begin(kind: 'up' | 'slam', direction: -1 | 1) {
+ if (this.phase === 'pogo' || this.phase === 'descend') { this.queued = { kind, direction }; return true; }
+ if (this.phase !== 'idle') return false;
+ this.direction = direction; this.struck.clear(); this.enter(kind === 'up' ? 'up' : 'windup'); return true;
+ }
+ private enter(phase: Phase) { this.phase = phase; this.elapsed = 0; }
+ cancel() { this.enter('idle'); this.struck.clear(); this.queued = undefined; }
+ step(dt: number) {
+ this.elapsed += dt;
+ if (this.phase === 'windup' && this.elapsed + 1e-9 >= 3 / 12) this.enter('descend');
+ else if (this.phase === 'up' && this.elapsed + 1e-9 >= 6 / 12
+ || this.phase === 'land' && this.elapsed + 1e-9 >= 3 / 12
+ || this.phase === 'pogo' && this.elapsed + 1e-9 >= 4 / 12) {
+ const queued = this.phase === 'pogo' ? this.queued : undefined; this.cancel();
+ if (queued) this.begin(queued.kind, queued.direction);
+ }
+ }
+ land(grounded: boolean) { if (grounded && this.phase === 'descend') this.enter('land'); }
+ get pose() {
+ if (this.phase === 'idle') return undefined;
+ const frame = Math.floor((this.elapsed + 1e-9) * 12);
+ const clip = this.phase === 'up' ? 'up' : this.phase === 'pogo' ? 'pogo' : 'slam';
+ const index = this.phase === 'descend' ? 3 : this.phase === 'land' ? Math.min(6, 4 + frame)
+ : this.phase === 'windup' ? Math.min(2, frame) : Math.min(clip === 'up' ? 5 : 3, frame);
+ return `${clip}-${String(index).padStart(2, '0')}`;
+ }
+ private rect(position: Position, left: number, right: number, y: number, h: number): Rect {
+ return { x: position.x + (this.direction > 0 ? left : -right), y, w: right - left, h };
+ }
+ box(position: Position, previousY: number): Rect | undefined {
+ const frame = Math.floor((this.elapsed + 1e-9) * 12);
+ if (this.phase === 'up' && (frame === 2 || frame === 3)) {
+ const c = UP_CONTACT; return this.rect(position, c.left, c.right, position.y - c.top, c.top - c.bottom);
+ }
+ if (this.phase === 'descend' && position.y >= previousY) {
+ const c = SLAM_CONTACT;
+ return this.rect(position, c.left, c.right, previousY - c.grain, position.y - previousY + c.grain * 2);
+ }
+ return undefined;
+ }
+ contact(position: Position, previousY: number, targets: Iterable) {
+ const box = this.box(position, previousY), hits: Rect[] = [];
+ if (!box) return { hits, bounce: undefined as number | undefined };
+ const descending = this.phase === 'descend';
+ const candidates = [...targets].filter(target => target.active?.() !== false)
+ .map(target => ({ target, bounds: target.bounds() })).sort((a, b) => a.bounds.y - b.bounds.y);
+ for (const { target, bounds } of candidates) {
+ if (this.struck.has(target.id) || !intersects(box, bounds)
+ || descending && bounds.y < previousY - SLAM_CONTACT.grain) continue;
+ this.struck.add(target.id);
+ if (target.hit({ damage: 1, direction: this.direction, position: { ...position }, kind: descending ? 'slam' : 'up' })) hits.push(bounds);
+ if (descending) { this.enter('pogo'); return { hits, bounce: bounds.y }; }
+ }
+ return { hits, bounce: undefined as number | undefined };
+ }
+ hazard(position: Position, previousY: number, hazards: Rect[]) {
+ const box = this.box(position, previousY);
+ const hazard = this.phase === 'descend' && box && [...hazards].sort((a, b) => a.y - b.y)
+ .find(target => target.y >= previousY - SLAM_CONTACT.grain && intersects(box, target));
+ if (!hazard) return undefined;
+ this.enter('pogo'); return hazard.y;
+ }
+}
diff --git a/game/src/combat/sweeps.ts b/game/src/combat/sweeps.ts
index 7f2fbfb..5cb59f8 100644
--- a/game/src/combat/sweeps.ts
+++ b/game/src/combat/sweeps.ts
@@ -1,6 +1,6 @@
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 interface ChairHit { damage: number; direction: -1 | 1; position: { x: number; y: number }; kind: 'sweep' | 'up' | 'slam' }
+export interface Damageable { id: string; active?(): boolean; 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).
@@ -59,7 +59,7 @@ export class Sweeps {
if (!box) return hits;
for (const target of targets) {
const bounds = target.bounds();
- if (this.struck.has(target.id) || !intersects(box, bounds)) continue;
+ if (target.active?.() === false || 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);
}
diff --git a/game/src/core/scaffold.ts b/game/src/core/scaffold.ts
index c57ce2f..eca38ef 100644
--- a/game/src/core/scaffold.ts
+++ b/game/src/core/scaffold.ts
@@ -77,8 +77,8 @@ 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);
+ this.rovers?.update(active);
if (this.player) this.follow?.(this.player.body, active);
}
}
diff --git a/game/src/enemies/rovers.ts b/game/src/enemies/rovers.ts
index 175e612..2b75567 100644
--- a/game/src/enemies/rovers.ts
+++ b/game/src/enemies/rovers.ts
@@ -23,7 +23,8 @@ export class Rovers {
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(chair.register({ id: spawn.id!, active: () => model.health > 0,
+ 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 };
});
diff --git a/game/src/player/jump.ts b/game/src/player/jump.ts
index 834b6e0..8ff2aa9 100644
--- a/game/src/player/jump.ts
+++ b/game/src/player/jump.ts
@@ -7,10 +7,15 @@ export const HOLD_HEIGHT = 96;
export const JUMP_SPEED = 485; // Semi-implicit 120Hz integration reaches about 96px.
export interface Jump {
coyote: number; buffer: number; wasDown: boolean; active: boolean;
- origin: number; peak: number; height: number; landing: number; hard: boolean;
+ origin: number; peak: number; height: number; landing: number; hard: boolean; forced: boolean;
}
export const jumpState = (y: number): Jump => ({ coyote: 0, buffer: 0, wasDown: false, active: false,
- origin: y, peak: y, height: 0, landing: 0, hard: false });
+ origin: y, peak: y, height: 0, landing: 0, hard: false, forced: false });
+
+export function pogo(body: Body, top: number): Jump {
+ body.y = top; body.vy = -JUMP_SPEED; body.grounded = false;
+ return { ...jumpState(top), active: true, forced: true };
+}
export function jump(body: Body, state: Jump, down: boolean, dt: number) {
state.coyote = body.grounded ? COYOTE : Math.max(0, state.coyote - dt);
@@ -18,10 +23,10 @@ export function jump(body: Body, state: Jump, down: boolean, dt: number) {
state.wasDown = down;
if (state.buffer > 1e-9 && state.coyote > 1e-9) {
body.vy = -JUMP_SPEED; body.grounded = false;
- state.coyote = 0; state.buffer = 0; state.active = true;
+ state.coyote = 0; state.buffer = 0; state.active = true; state.forced = false;
state.origin = body.y; state.peak = body.y; state.height = 0; state.landing = 0;
}
- if (!down && state.active && body.vy < 0) {
+ if (!down && state.active && !state.forced && body.vy < 0) {
// Early releases share a 52px minimum rise; later releases stop at their current height.
const remainingRise = Math.max(0, TAP_HEIGHT - (state.origin - body.y));
body.vy = Math.max(body.vy, -Math.sqrt(2 * GRAVITY * remainingRise));
@@ -35,6 +40,6 @@ export function landing(body: Body, state: Jump, wasGrounded: boolean, dt: numbe
if (!wasGrounded && body.grounded) {
state.hard = body.y - state.peak >= 152; // Two body heights; tune with the route playtest.
state.landing = state.hard ? 4 / 12 : 2 / 12;
- state.active = false; state.peak = body.y;
+ state.active = false; state.forced = false; state.peak = body.y;
}
}
diff --git a/game/src/player/player.ts b/game/src/player/player.ts
index fdf149f..56337b3 100644
--- a/game/src/player/player.ts
+++ b/game/src/player/player.ts
@@ -1,8 +1,8 @@
import Phaser from 'phaser';
-import type { CollisionChunk } from '../stage/collision';
+import type { CollisionChunk, Box } from '../stage/collision';
import { BODY_HEIGHT, BODY_WIDTH, overlaps, stepBody, type Body } from './body';
import { horizontal, move, movement } from './movement';
-import { jump, jumpState, landing } from './jump';
+import { jump, jumpState, landing, pogo } from './jump';
import { climb, dropThrough, traversal } from './traversal';
import { catalogue, LocomotionAnimation, type MotionPose } from './animation';
import { gameEvents } from '../core/events';
@@ -16,6 +16,7 @@ export class Player {
readonly sprite: Phaser.GameObjects.Sprite;
readonly life: Life;
onHealthChanged: (health: number) => void = () => {};
+ onHazards: (boxes: Box[]) => boolean = () => false;
private motion = movement();
private keys;
private debug: Phaser.GameObjects.Graphics;
@@ -38,7 +39,7 @@ export class Player {
this.animation = new LocomotionAnimation(catalogue(Object.fromEntries(
['hero-move', 'hero-run', 'hero-idle', 'hero-air'].map(board => [board, scene.cache.json.get(`${board}.anims`)]))));
this.sprite = scene.add.sprite(this.body.x, this.body.y, 'hero-move', 'idle-00').setOrigin(0.5, 1).setDepth(100);
- this.keys = scene.input.keyboard!.addKeys('A,D,W,S,LEFT,RIGHT,UP,DOWN,SPACE,K') as Record;
+ this.keys = scene.input.keyboard!.addKeys('A,D,W,S,LEFT,RIGHT,UP,DOWN,SPACE,K,J') as Record;
this.debug = scene.add.graphics().setDepth(10001);
this.label = scene.add.text(20, 135, '', { fontFamily: 'monospace', fontSize: '18px', backgroundColor: '#17242d' })
.setScrollFactor(0).setDepth(10002).setVisible(false);
@@ -85,6 +86,7 @@ export class Player {
this.motion = { ...movement(), facing: this.motion.facing }; this.body.vx = 0; this.body.vy = 0;
return true;
}
+ pogo(top: number) { this.air = pogo(this.body, top); this.sprite.setPosition(Math.round(this.body.x), top); }
private resetPosition(position: { x: number; y: number }) {
Object.assign(this.body, position, { vx: 0, vy: 0, grounded: true, sliding: false, slope: 0 });
this.motion = movement(); this.air = jumpState(this.body.y); this.traversal = traversal();
@@ -100,7 +102,7 @@ export class Player {
const axis = interacting ? 0 : horizontal(this.keys.A.isDown || this.keys.LEFT.isDown, this.keys.D.isDown || this.keys.RIGHT.isDown,
pad?.leftStick.x, pad?.left, pad?.right);
const jumpDown = !interacting && (this.keys.SPACE.isDown || this.keys.K.isDown || !!pad?.A);
- const vertical = interacting ? 0 : horizontal(this.keys.W.isDown || this.keys.UP.isDown, this.keys.S.isDown || this.keys.DOWN.isDown,
+ const vertical = interacting || this.keys.J.isDown || pad?.X ? 0 : horizontal(this.keys.W.isDown || this.keys.UP.isDown, this.keys.S.isDown || this.keys.DOWN.isDown,
pad?.leftStick.y, pad?.up, pad?.down);
const geometry = this.chunks.map(c => c.world);
// Substeps keep wall and floor contacts stable during slow rendered frames.
@@ -138,9 +140,11 @@ export class Player {
remaining -= dt;
}
if (this.life.dead === 0) {
- const hazard = geometry.flatMap(g => g.hazard).find(box => overlaps(this.body, box));
+ const hazards = geometry.flatMap(g => g.hazard);
+ const bounced = this.onHazards(hazards);
+ const hazard = hazards.find(box => overlaps(this.body, box));
const noGo = this.body.y > 900 || geometry.some(g => g.noGo.some(box => overlaps(this.body, box)));
- if (hazard) this.resetFromDanger(Number(hazard.damage) || 1);
+ if (hazard && !bounced) this.resetFromDanger(Number(hazard.damage) || 1);
else if (noGo) this.resetFromDanger();
else if (this.body.grounded && this.life.knock === 0) this.life.rememberSafe(this.body);
}
diff --git a/game/verification/40-overhead-pose-compare.png b/game/verification/40-overhead-pose-compare.png
new file mode 100644
index 0000000..3fd09d5
Binary files /dev/null and b/game/verification/40-overhead-pose-compare.png differ