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
39 changes: 39 additions & 0 deletions docs/02-GAME-DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<br/>keyboard or gamepad"] --> PRIORITY["Consume special input<br/>suppress coffee, climb and release sweep"]
ATLAS["chair-overhead atlas + table<br/>108 × 126, pivot 54,126"] --> MASK["Up: measured legs at poses 02/03<br/>Slam: swept probe 11–37 px forward"]
PRIORITY --> MASK
MASK --> HIT["One attempt per target<br/>accepted damage: 60 ms stop + spark"]
MASK --> BOUNCE["Enemy / hazard top contact before damage<br/>snap feet; full-jump impulse"]
BOUNCE --> POSE["4 pogo poses; air control<br/>one buffered directional press starts next slam"]
MASK --> FLOOR["Plain floor: remaining slam poses<br/>no crack or shockwave"]
HIT --> CHECK["Measured comparison + tests<br/>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.
Expand Down
49 changes: 40 additions & 9 deletions game/src/combat/chair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -11,16 +12,30 @@ 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<string, Damageable>();
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;
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.directions = scene.input.keyboard!.addKeys('W,S,UP,DOWN') as Record<string, Phaser.Input.Keyboard.Key>;
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);
Expand All @@ -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;
}
}
4 changes: 2 additions & 2 deletions game/src/combat/coffee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Phaser.Input.Keyboard.Key>;
this.keys = scene.input.keyboard!.addKeys('W,UP,J') as Record<string, Phaser.Input.Keyboard.Key>;
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));
}
Expand Down
83 changes: 83 additions & 0 deletions game/src/combat/overhead.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading