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