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