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
30 changes: 30 additions & 0 deletions docs/02-GAME-DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<br/>hero-hurt interact atlas/table"] --> MASK["Grounded living hero overlaps machine<br/>no damage knockback or ladder"]
UP["New Up / W / pad Up edge<br/>held input never repeats"] --> MASK
MASK --> DRINK["Stop movement; play 3 interact poses<br/>ignore attacks and climbing during drink"]
DRINK --> SAVE["Refill all 5 cells<br/>copy current feet into life checkpoint<br/>emit unchanged save payload once"]
SAVE --> DEATH["On later death: return to checkpoint<br/>full health, normal enemies reset"]
DATA --> IDLE["40 × 80 atlas placeholder at authored box<br/>one-frame idle flicker"]
IDLE --> PROOF["compare.py ruler + real-box tests<br/>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.
Expand Down
53 changes: 53 additions & 0 deletions game/src/combat/coffee.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
37 changes: 37 additions & 0 deletions game/src/combat/coffee.ts
Original file line number Diff line number Diff line change
@@ -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<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);
if (machine && this.player.beginInteraction()) this.player.saveCheckpoint(machine.id!);
for (const actor of this.actors) actor.setAlpha(coffeeAlpha(this.scene.time.now));
}
}
2 changes: 1 addition & 1 deletion game/src/combat/life.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
10 changes: 9 additions & 1 deletion game/src/core/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 });
}
}
Expand All @@ -33,18 +35,23 @@ export class Stage extends Phaser.Scene {
private defeatedBosses = new Set<string>();
private follow?: ReturnType<typeof cameraFollow>;
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');
Expand All @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions game/src/player/interaction.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
18 changes: 18 additions & 0 deletions game/src/player/interaction.ts
Original file line number Diff line number Diff line change
@@ -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') };
}
}
27 changes: 20 additions & 7 deletions game/src/player/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string>()) {
this.spawn = chunks.flatMap(c => c.world.spawn).find(p => p.kind === 'player') ?? { x: 120, y: 461 };
private region = 'rusty-creek', bosses = new Set<string>(), 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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions game/src/stage/assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fetch>) {
Expand Down
7 changes: 7 additions & 0 deletions game/src/stage/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnimationTable> {
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');
}
}
Binary file added game/verification/43-coffee-box-compare.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added game/verification/43-drink-pose-compare.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading