diff --git a/docs/02-GAME-DESIGN.md b/docs/02-GAME-DESIGN.md index d03dff7..d75a547 100644 --- a/docs/02-GAME-DESIGN.md +++ b/docs/02-GAME-DESIGN.md @@ -57,6 +57,36 @@ The weapon is switched in the pocket computer menu (or ←/→ while the menu is - **Healing:** Mensa food that **Dexter leaves on your path**. Each item heals a set amount, and some add a small extra: muffin 1 cell, cake slice 1, wafer 1, Leberkäse roll 2 + a short defence boost (it's heavy food). Drinks give small boosts (sparkling water: a short speed-up). Numbers and joke effects get tuned in playtests. Drinking coffee at a save point refills everything. - **Pits and hazards** (creek current, toxic sap): lose 1 cell and reappear at the last safe ground. No full death. +### Coffee save implementation (#43) + +Use the authored `interact` box with `kind: coffee-machine`, translated by +the chunk grid. Rusty Creek's `coffee-1` occupies local (1770, 457), +40 × 80 px in `rc-1-0.anomaly`; its bottom sits on the y=537 walk line. +The placeholder uses that exact box through the existing atlas contract. +`mensa-drinks` remains pending in [07](07-ASSET-MANIFEST.md); its named +idle/flicker/pour clips will replace the placeholder when lane A exports it. +Until then, flicker dims the rectangle for one 12 fps frame every two +seconds (placeholder timing). The hero uses the approved three-frame +`hero-hurt:interact` clip at its authored 12 fps, a quarter-second drink. +Its leaning poses measure 72 px high in the approved atlas; preserve their +native size against the 76 px standing body in the comparison. + +```mermaid +flowchart LR + DATA["Collision interact box + chunk origin
hero-hurt interact atlas/table"] --> MASK["Grounded living hero overlaps machine
no damage knockback or ladder"] + UP["New Up / W / pad Up edge
held input never repeats"] --> MASK + MASK --> DRINK["Stop movement; play 3 interact poses
ignore attacks and climbing during drink"] + DRINK --> SAVE["Refill all 5 cells
copy current feet into life checkpoint
emit unchanged save payload once"] + SAVE --> DEATH["On later death: return to checkpoint
full health, normal enemies reset"] + DATA --> IDLE["40 × 80 atlas placeholder at authored box
one-frame idle flicker"] + IDLE --> PROOF["compare.py ruler + real-box tests
browser drink, die, respawn"] +``` + +This checkpoint lasts for the running game, as does the existing life state. +Disk persistence is outside this issue's acceptance criteria. A development +`coffee-test` URL starts at the real machine's foot position to check the +interaction without moving or duplicating its collision annotation. + ## Combat Every weapon is a real object we photographed and posed with on 23 Oct. diff --git a/game/src/combat/coffee.test.ts b/game/src/combat/coffee.test.ts new file mode 100644 index 0000000..7c015b4 --- /dev/null +++ b/game/src/combat/coffee.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { CoffeeUse, coffeeAlpha } from './coffee'; +import { Life } from './life'; +import { parseCollision, placeCollision } from '../stage/collision'; +import type { Body } from '../player/body'; +import raw from '../../../art/stages/rusty-creek/rc-1-0.anomaly.collision.json'; +import clips from '../../public/assets/sprites/hero-hurt.anims.json'; + +const chunk = placeCollision(parseCollision(raw, 'rc-1-0', 'anomaly'), [2160, 0]); +const machines = chunk.world.interact; +const body = (): Body => ({ x: 3950, y: 537, vx: 0, vy: 0, grounded: true }); +describe('coffee save interaction', () => { + it('flickers for one 12 fps frame every two seconds while idle', () => { + expect(coffeeAlpha(0)).toBe(0.65); expect(coffeeAlpha(82)).toBe(0.65); + expect(coffeeAlpha(84)).toBe(1); expect(coffeeAlpha(1999)).toBe(1); + expect(coffeeAlpha(2000)).toBe(0.65); expect(coffeeAlpha(2084)).toBe(1); + }); + it('uses the authored 40×80 machine at the translated walk surface and the approved drink timing', () => { + expect(machines.find(box => box.kind === 'coffee-machine')).toMatchObject({ id: 'coffee-1', x: 3930, y: 457, w: 40, h: 80 }); + expect(clips.interact).toEqual({ frames: 3, fps: 12, loop: false }); + expect(clips.interact.frames / clips.interact.fps).toBe(0.25); + expect(new CoffeeUse().step(true, body(), true, machines)?.id).toBe('coffee-1'); + }); + it('saves once per fresh Up edge, never once per rendered frame', () => { + const use = new CoffeeUse(), player = body(); + expect(use.step(true, player, true, machines)?.id).toBe('coffee-1'); + expect(use.step(true, player, true, machines)).toBeUndefined(); + use.step(false, player, true, machines); + expect(use.step(true, player, true, machines)?.id).toBe('coffee-1'); + }); + it('does not auto-save when walking into range with Up held', () => { + const use = new CoffeeUse(), player = body(); player.x = 3800; + expect(use.step(true, player, true, machines)).toBeUndefined(); + player.x = 3950; + expect(use.step(true, player, true, machines)).toBeUndefined(); + }); + it('rejects air, knockback, death, climbing and non-coffee interactables', () => { + const player = body(); + expect(new CoffeeUse().step(true, player, false, machines)).toBeUndefined(); + player.grounded = false; + expect(new CoffeeUse().step(true, player, true, machines)).toBeUndefined(); + player.grounded = true; + expect(new CoffeeUse().step(true, player, true, [{ x: 3930, y: 457, w: 40, h: 80, kind: 'sign' }])).toBeUndefined(); + }); + it('refills at the real machine, copies the checkpoint, and respawns there at full health', () => { + const player = body(), life = new Life({ x: 120, y: 461 }); life.health = 2; + const machine = new CoffeeUse().step(true, player, true, machines); + expect(machine?.id).toBe('coffee-1'); expect(life.save(player)).toBe(true); + expect(life.health).toBe(5); player.x += 200; + expect(life.hit(5)).toBe(true); expect(life.tick(1).respawned).toBe(true); + expect(life.checkpoint).toEqual({ x: 3950, y: 537 }); expect(life.health).toBe(5); + }); +}); diff --git a/game/src/combat/coffee.ts b/game/src/combat/coffee.ts new file mode 100644 index 0000000..00c80c3 --- /dev/null +++ b/game/src/combat/coffee.ts @@ -0,0 +1,37 @@ +import type Phaser from 'phaser'; +import type { Box, CollisionChunk } from '../stage/collision'; +import { overlaps, type Body } from '../player/body'; +import type { Player } from '../player/player'; + +// docs/02 placeholder timing: one authored 12 fps frame dimmed every two seconds. +export const coffeeAlpha = (milliseconds: number) => Math.floor(milliseconds / (1000 / 12)) % 24 === 0 ? 0.65 : 1; + +/** Up is edge-triggered, including when it was already held on entering range. */ +export class CoffeeUse { + private held = false; + step(up: boolean, body: Body, eligible: boolean, machines: Box[]) { + const pressed = up && !this.held; this.held = up; + return pressed && eligible && body.grounded + ? machines.find(box => box.kind === 'coffee-machine' && overlaps(body, box)) : undefined; + } +} + +export class CoffeePoints { + private use = new CoffeeUse(); + private keys; + private actors: Phaser.GameObjects.Sprite[]; + 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.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); + 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/life.ts b/game/src/combat/life.ts index a9070a4..4d4a6ac 100644 --- a/game/src/combat/life.ts +++ b/game/src/combat/life.ts @@ -52,7 +52,7 @@ export class Life { } save(position: Position) { if (this.dead > 0) return false; - this.checkpoint = { ...position }; this.safe = { ...position }; this.health = MAX_HEALTH; + this.checkpoint = { x: position.x, y: position.y }; this.safe = { ...this.checkpoint }; this.health = MAX_HEALTH; return true; } register(id: string, reset: () => void, boss = false) { diff --git a/game/src/core/scaffold.ts b/game/src/core/scaffold.ts index 2704ae9..2bf5a9c 100644 --- a/game/src/core/scaffold.ts +++ b/game/src/core/scaffold.ts @@ -7,6 +7,7 @@ import { collisionDebug } from '../stage/collision-debug'; import { Player } from '../player/player'; import { cameraFollow } from '../stage/camera'; import { ChairCombat } from '../combat/chair'; +import { CoffeePoints } from '../combat/coffee'; // Bootstrap-only scenes live in lane B until lane A provides story/UI scenes. export class Boot extends Phaser.Scene { @@ -24,6 +25,7 @@ export class Preload extends Phaser.Scene { const manifest = await assets.stage(region, state); const collisions = manifest.chunks[0].id === 'fallback' ? [] : await loadCollisions(manifest, state); for (const board of BOARDS) await assets.board(board); + await assets.placeholder('coffee-placeholder'); this.scene.start('Stage', { manifest, state, collisions }); } } @@ -33,18 +35,23 @@ export class Stage extends Phaser.Scene { private defeatedBosses = new Set(); private follow?: ReturnType; private combat?: ChairCombat; + private coffee?: CoffeePoints; 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); collisionDebug(this, data.collisions); - this.player = new Player(this, data.collisions, data.manifest.region, this.defeatedBosses); + const machine = import.meta.env.DEV && new URLSearchParams(location.search).has('coffee-test') + ? data.collisions.flatMap(chunk => chunk.world.interact).find(box => box.kind === 'coffee-machine') : undefined; + this.player = new Player(this, data.collisions, data.manifest.region, this.defeatedBosses, + machine ? { x: machine.x + machine.w / 2, y: machine.y + machine.h } : undefined); this.player.onHealthChanged = health => { 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.coffee = new CoffeePoints(this, this.player, data.collisions); this.follow = import.meta.env.DEV && new URLSearchParams(location.search).has('inspect') ? undefined : cameraFollow(this, data.manifest); this.scene.launch('UI'); @@ -61,6 +68,7 @@ export class Stage extends Phaser.Scene { update(_time: number, delta: number) { const active = Math.min(100, this.combat?.pause.consume(delta) ?? delta); if (active <= 0) return; + this.coffee?.update(); this.player?.update(active); this.combat?.update(active); if (this.player) this.follow?.(this.player.body, active); diff --git a/game/src/player/interaction.test.ts b/game/src/player/interaction.test.ts new file mode 100644 index 0000000..90bfa31 --- /dev/null +++ b/game/src/player/interaction.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { Interaction } from './interaction'; +import clips from '../../public/assets/sprites/hero-hurt.anims.json'; +describe('authored drink clip', () => { + it('owns input for exactly three poses at 12 fps, then releases it', () => { + const action = new Interaction(); action.begin(clips.interact); + expect(action.active).toBe(true); + expect(action.step(1 / 12)?.frame).toBe('interact-00'); + expect(action.step(1 / 12)?.frame).toBe('interact-01'); + expect(action.active).toBe(true); + expect(action.step(1 / 12)?.frame).toBe('interact-02'); + expect(action.active).toBe(false); expect(action.step(1 / 12)).toBeUndefined(); + }); + it('can be interrupted by damage without leaving input locked', () => { + const action = new Interaction(); action.begin(clips.interact); action.step(0.05); action.cancel(); + expect(action.active).toBe(false); expect(action.step(0.01)).toBeUndefined(); + action.begin(clips.interact); expect(action.step(0.01)?.frame).toBe('interact-00'); + }); +}); diff --git a/game/src/player/interaction.ts b/game/src/player/interaction.ts new file mode 100644 index 0000000..1ee483f --- /dev/null +++ b/game/src/player/interaction.ts @@ -0,0 +1,18 @@ +import type { AnimationClip } from '../stage/assets'; + +/** A finite authored clip owns input for exactly its frame count / fps. */ +export class Interaction { + private elapsed = 0; + private clip: AnimationClip | undefined; + get active() { return this.clip !== undefined; } + begin(clip: AnimationClip) { this.clip = clip; this.elapsed = 0; } + cancel() { this.clip = undefined; } + step(seconds: number) { + if (!this.clip) return undefined; + const clip = this.clip; + const frame = Math.min(clip.frames - 1, Math.floor((this.elapsed + 1e-9) * clip.fps)); + this.elapsed += seconds; + if (this.elapsed + 1e-9 >= clip.frames / clip.fps) this.cancel(); + return { board: 'hero-hurt', frame: 'interact-' + String(frame).padStart(2, '0') }; + } +} diff --git a/game/src/player/player.ts b/game/src/player/player.ts index 09d1865..fdf149f 100644 --- a/game/src/player/player.ts +++ b/game/src/player/player.ts @@ -8,6 +8,8 @@ import { catalogue, LocomotionAnimation, type MotionPose } from './animation'; import { gameEvents } from '../core/events'; import { Life } from '../combat/life'; import { deathDust, lifePose, paintLife } from '../combat/life-visuals'; +import type { Position } from '../core/events'; +import { Interaction } from './interaction'; export class Player { readonly body: Body; @@ -24,11 +26,12 @@ export class Player { private air; private traversal = traversal(); private animation: LocomotionAnimation; - get canAttack() { return this.traversal.ladder === null && this.life.dead === 0 && this.life.knock === 0; } + private interaction = new Interaction(); + get canAttack() { return this.traversal.ladder === null && this.life.dead === 0 && this.life.knock === 0 && !this.interaction.active; } get facing() { return this.motion.facing; } constructor(private scene: Phaser.Scene, private chunks: CollisionChunk[], - private region = 'rusty-creek', bosses = new Set()) { - this.spawn = chunks.flatMap(c => c.world.spawn).find(p => p.kind === 'player') ?? { x: 120, y: 461 }; + private region = 'rusty-creek', bosses = new Set(), spawnOverride?: Position) { + this.spawn = spawnOverride ?? chunks.flatMap(c => c.world.spawn).find(p => p.kind === 'player') ?? { x: 120, y: 461 }; this.body = { x: this.spawn.x, y: this.spawn.y, vx: 0, vy: 0, grounded: true }; this.life = new Life(this.body, bosses); this.air = jumpState(this.body.y); @@ -58,6 +61,7 @@ export class Player { } damage(amount: number, sourceX: number) { if (!this.life.hit(amount, this.body.x >= sourceX ? 1 : -1)) return false; + this.interaction.cancel(); const position = { x: this.body.x, y: this.body.y }; gameEvents.emit('player-hit', { health: this.life.health, damage: amount, position }); if (this.life.dead > 0) { @@ -74,6 +78,13 @@ export class Player { this.onHealthChanged(this.life.health); gameEvents.emit('save', { checkpoint: id, region: this.region, position: { x: this.body.x, y: this.body.y } }); } + beginInteraction() { + if (!this.canAttack || !this.body.grounded) return false; + const clip = this.scene.cache.json.get('hero-hurt.anims').interact; + this.interaction.begin(clip); + this.motion = { ...movement(), facing: this.motion.facing }; this.body.vx = 0; this.body.vy = 0; + return true; + } 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(); @@ -84,10 +95,12 @@ export class Player { } update(milliseconds: number) { const pad = this.scene.input.gamepad?.getAll().find(p => p.connected); - const axis = horizontal(this.keys.A.isDown || this.keys.LEFT.isDown, this.keys.D.isDown || this.keys.RIGHT.isDown, + const interacting = this.interaction.active; + const interactionPose = this.interaction.step(milliseconds / 1000); + 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 = this.keys.SPACE.isDown || this.keys.K.isDown || !!pad?.A; - const vertical = horizontal(this.keys.W.isDown || this.keys.UP.isDown, this.keys.S.isDown || this.keys.DOWN.isDown, + 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, 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. @@ -136,7 +149,7 @@ export class Player { : !this.body.grounded ? this.body.vy < 0 ? 'jump' : 'fall' : Math.abs(this.body.vx) > 180 ? 'run' : Math.abs(this.body.vx) > 5 ? 'walk' : 'idle'; const locomotion = this.animation.update(desired, desired === 'ladder' && vertical === 0 ? 0 : milliseconds / 1000); - const pose = lifePose(this.life) ?? locomotion; + const pose = lifePose(this.life) ?? interactionPose ?? locomotion; if (this.scene.textures.get(pose.board).has(pose.frame)) this.sprite.setTexture(pose.board, pose.frame); else this.sprite.setTexture('hero-move', 'idle-00'); this.sprite.setPosition(Math.round(this.body.x), Math.round(this.body.y)).setFlipX(this.motion.facing < 0); diff --git a/game/src/stage/assets.test.ts b/game/src/stage/assets.test.ts index fc8c7ae..7fdad42 100644 --- a/game/src/stage/assets.test.ts +++ b/game/src/stage/assets.test.ts @@ -49,6 +49,11 @@ describe('stage manifest', () => { }); describe('asset loading', () => { + it('loads an intentional pending-art placeholder without requesting a missing real board', async () => { + const h = harness('/sprites/'); + expect(await new Assets(h.scene, true, h.request).placeholder('coffee-placeholder')).toEqual(clips); + expect(h.loadAtlas).toHaveBeenCalledWith('coffee-placeholder', '/assets/placeholder/hero.png', atlas); + }); it('does not bind the native browser fetch receiver to the asset loader', async () => { const h = harness(); vi.stubGlobal('fetch', function(this: unknown, ...args: Parameters) { diff --git a/game/src/stage/assets.ts b/game/src/stage/assets.ts index c1ef035..c97470a 100644 --- a/game/src/stage/assets.ts +++ b/game/src/stage/assets.ts @@ -91,4 +91,11 @@ export class Assets { return this.atlas(board, `/assets/placeholder/${fallback}`); } } + + /** Intentional pending-art placeholder; never request an unexported board. */ + async placeholder(key: string): Promise { + if (!/^[a-z][a-z0-9-]*$/.test(key)) throw new Error('Invalid placeholder id'); + if (this.scene.textures.exists(key)) return this.scene.cache.json.get(`${key}.anims`); + return this.atlas(key, '/assets/placeholder/hero'); + } } diff --git a/game/verification/43-coffee-box-compare.png b/game/verification/43-coffee-box-compare.png new file mode 100644 index 0000000..652c824 Binary files /dev/null and b/game/verification/43-coffee-box-compare.png differ diff --git a/game/verification/43-drink-pose-compare.png b/game/verification/43-drink-pose-compare.png new file mode 100644 index 0000000..f7aaa62 Binary files /dev/null and b/game/verification/43-drink-pose-compare.png differ