Skip to content
Merged
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ voice-lab's virtualenv, which loads it, and the first time downloads it, even wi
| Space | Jump. Hold it to jump higher, and run to jump further. Walk off a ledge and you land safely on whatever is below. |
| ↑ (or W) | Upstairs or up a ladder, from the tile beside its foot. On the lift, up a floor. |
| ↓ (or S) | Downstairs or down a ladder, from the tile beside its top. On the lift, down a floor. |
| E | Read the news at a terminal, look at a mural or a painting, or order at a counter. Otherwise go through a door, or talk to the ringed resident. |
| E | Read the news at a terminal, look at a mural or a painting, order at a counter, or pick up what you left when you collapsed. Otherwise go through a door, or talk to the ringed resident. |
| F | Eat, once you're peckish: a meal at home, at the café or the noodle bar, or food you carry. Hunger rises while you play, and if it runs out you collapse. |
| Enter | Talk to the ringed resident, then send what you typed. |
| Tab | Choose who to talk to. Clicking a resident does too. |
| Esc | Close the chat or a panel. |
Expand Down
31 changes: 30 additions & 1 deletion client/src/net/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, it, vi } from 'vitest';
import { server, type LineMessage } from '.';
import { playerState, server, type LineMessage, type PlayerStateMessage } from '.';

const line: LineMessage = {
type: 'line',
Expand All @@ -14,6 +14,25 @@ const line: LineMessage = {
voiced: false,
};

const hungry: PlayerStateMessage = {
type: 'player_state',
player: {
money: 4,
items: [],
carry_limit: 10,
body: {
legs: 'present',
vertebra: 'present',
left_eye: 'present',
right_eye: 'present',
left_kidney: 'present',
right_kidney: 'present',
},
hunger: 0.6,
},
meal: { place: 'noodle_bar', name: 'Golden Bowl Noodles', price: 10 },
};

it('imports in tests without connecting', () => {
expect(server.send({ type: 'player_move', position: { x: 1, y: 2 } })).toBe(false);
});
Expand All @@ -31,3 +50,13 @@ it('lets tests feed in messages and spy on what is sent', () => {
unsubscribe();
send.mockRestore();
});

it('keeps the last player_state for scenes that start later', () => {
server.dispatch(JSON.stringify(hungry));
expect(playerState.latest).toEqual(hungry);

server.dispatch(JSON.stringify({ ...hungry, meal: undefined, player: { ...hungry.player, hunger: 0.61 } }));

expect(playerState.latest?.player.hunger).toBe(0.61);
expect(playerState.latest?.meal).toBeUndefined();
});
5 changes: 5 additions & 0 deletions client/src/net/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ServerConnection } from './connection';
import type { PlayerStateMessage } from './protocol';

export { SAMPLE_RATE, type AudioChunk } from './audio';
export type { ServerEvent, ServerEvents } from './connection';
Expand All @@ -7,6 +8,10 @@ export type * from './protocol';
/** Messages aren't replayed, so subscribe when your module loads, before the first one arrives. */
export const server = new ServerConnection();

/** The last `player_state`, for scenes that start after it arrives. */
export const playerState: { latest?: PlayerStateMessage } = {};
server.on('player_state', (message) => (playerState.latest = message));

if (import.meta.env.MODE !== 'test') {
server.on('snapshot', (snapshot) => console.info('Snapshot from the sim server', snapshot));
server.connect();
Expand Down
4 changes: 4 additions & 0 deletions client/src/side/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ The Phaser-free halves are tested under vitest.
lands with a squash over `LANDING_SECONDS`; its shadow stays on the ground under it, fading with
height. The camera stays at the height the jump left from, following only a fall below it
(`steady`). Anywhere off a whole row, as a snapshot can leave the player, they fall.
- When the player collapses from hunger, `player_collapsed` puts them at home, as through a door,
once the street is shown and any door they're going through is done. It's also where the scene
starts them next (`sideResidents.start`), for a tab that wasn't in the game when they collapsed. After every `snapshot` the
scene sends the player's position again, so a restarted server knows where they are.
- The server keeps every position a jump or a fall sends, and ignores one nobody could be at
(`simile.street.moves.possible`, `docs/protocol.md`). `server/tests/street/positions.json` is a
table of the client's sends and places nobody could be that the server's tests and
Expand Down
15 changes: 14 additions & 1 deletion client/src/side/residents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import type { Entity, EntityMovedMessage, Facing, Pose, Position } from '../net'
import { WALKING_SPEED } from '../world/tuning';
import { village } from './content';
import { CLIMB_SPEED, RIDE_SPEED, Walker, WALK_CYCLE_M } from './motion';
import { Residents } from './residents';
import { server } from '../net';
import { Residents, sideResidents } from './residents';

const odalys: Entity = {
id: 'odalys',
Expand Down Expand Up @@ -200,3 +201,15 @@ describe('crossing between floors', () => {
expect(all.all.get('odalys')!.under).toBeUndefined();
});
});

describe('the player', () => {
it('starts where the snapshot says, or at home once they collapse', () => {
const player = { id: 'player', kind: 'player', name: 'You', position: { x: 51, y: 15, map: 'high_street' } };
server.dispatch(JSON.stringify({ type: 'snapshot', time: '2026-03-14T09:10:03Z', map_id: 'high_street', entities: [player], conversations: [] }));
expect(sideResidents.start).toEqual({ x: 51, y: 15, map: 'high_street' });

server.dispatch(JSON.stringify({ type: 'player_collapsed', position: { x: 13, y: 2, map: 'maker_space_floor_0' } }));

expect(sideResidents.start).toEqual({ x: 13, y: 2, map: 'maker_space_floor_0' });
});
});
2 changes: 2 additions & 0 deletions client/src/side/residents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,5 @@ export class Residents {
export const sideResidents = new Residents();
server.on('snapshot', ({ entities, map_id }) => sideResidents.reset(entities, map_id));
server.on('entity_moved', (message) => sideResidents.move(message));
// So a tab that wasn't in the game when the player collapsed starts them at home.
server.on('player_collapsed', ({ position: { x, y, map = '' } }) => (sideResidents.start = { x, y, map }));
25 changes: 23 additions & 2 deletions client/src/side/side-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export class SideScene extends Phaser.Scene {
private readonly running = new RunToggle();
private jumped = false;
private passing = false;
/** Where they wake once the street is shown and any door they are going through is done. */
private waking?: MapPosition;
private worksChanged = true;
private snap = true;
/** A tap shorter than a frame still counts. */
Expand Down Expand Up @@ -103,7 +105,11 @@ export class SideScene extends Phaser.Scene {
positionOf: (id) => this.positionOf(id),
listenerPosition: () => this.positionOf(PLAYER_ID),
}),
server.on('snapshot', () => this.throttle.forget()),
server.on('snapshot', () => {
this.throttle.forget();
this.sent.map = '';
}),
server.on('player_collapsed', ({ position: { x, y, map } }) => this.wake({ x, y, map: map ?? '' })),
setMuralView({
wallAt: ({ x, y }) => {
const point = this.cameras.main.getWorldPoint(x, y);
Expand All @@ -129,10 +135,12 @@ export class SideScene extends Phaser.Scene {
const { startHarness } = await import('./harness');
startHarness(this, content.village);
}
const start = this.startPosition(content.village);
const start = this.waking ?? this.startPosition(content.village);
this.waking = undefined;
this.player = new SidePlayer(content.village, start);
await this.showStreet(content.village.map(start.map)!.street);
this.cameras.main.fadeIn(DOOR_FADE_IN_SECONDS * 1000);
this.wakeIfWaiting();
}

/** For the dev harness. */
Expand Down Expand Up @@ -238,6 +246,18 @@ export class SideScene extends Phaser.Scene {
this.view.setTo(this.centre.x - size.width / 2, this.centre.y - size.height / 2, size.width, size.height);
}

/** The server moved them home after they collapsed. */
private wake(at: MapPosition): void {
if (this.passing || !this.street) this.waking = at;
else this.takeDoorTo(at);
}

private wakeIfWaiting(): void {
const waking = this.waking;
this.waking = undefined;
if (waking) this.takeDoorTo(waking);
}

private takeDoor(door: Link): void {
this.takeDoorTo({ x: door.to.x, y: door.to.y, map: door.to.map });
}
Expand All @@ -261,6 +281,7 @@ export class SideScene extends Phaser.Scene {
} finally {
this.passing = false;
camera.fadeIn(DOOR_FADE_IN_SECONDS * 1000);
this.wakeIfWaiting();
}
});
}
Expand Down
23 changes: 20 additions & 3 deletions client/src/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ The text side of conversations, drawn as HTML over the game canvas by `DialogueS
- a scrollable log of everything the player has heard or said, with an NPC's lines to the player
marked "→ You".
- the news and residents' writing at a terminal (see *Reading the news*);
- a mural up close (see *Looking at a mural*).
- a mural up close (see *Looking at a mural*);
- how hungry the player is and what they can eat (see *Hunger*).

## Choosing who to talk to

Expand Down Expand Up @@ -97,14 +98,30 @@ player as they step up and takes the order over `player_say` like any conversati
([`simile.cafe`](../../../server/simile/cafe/README.md)). Other kinds of interactable get their own
prompts from their own tasks.

## Hunger

The top-left corner shows the player's hunger from the last `player_state` (`playerState` in
[`net/`](../net/index.ts)), in [`hunger.ts`](hunger.ts):

- "You're hungry." from 0.5, and "You're starving. Eat soon, or you'll collapse." from 0.85. The
server collapses them at 1.
- From 0.3, what F eats: the meal on offer where they stand if they can pay ("Press F to eat at
home.", "Press F to eat at Golden Bowl Noodles for 10."), else the first food they carry ("Press F
to eat your pork bun."). F sends `eat`, with the item's id or `null` for the meal. From 0.5, with
nothing F can eat, it says where they could, or what the meal here costs against what they have.
- "Press E to pick up what you left here." within a tile of a pile they left when they collapsed
(`pileInReach` in [`world/reach.ts`](../world/reach.ts)), where E sends `pick_up`.
- "You collapsed from hunger and woke at home." for six seconds after `player_collapsed`. The world
scene puts the player at home.

## Keys

The game's keys (Enter, Tab, Esc, E, ?) are handled on `window` only when nothing on the page has
The game's keys (Enter, Tab, Esc, E, F, ?) are handled on `window` only when nothing on the page has
focus (`onGameKey` in [`keys.ts`](keys.ts)), so a focused link, button or input keeps its own Enter,
Tab and Esc.

E uses the nearest thing in reach, as `usedByE` in [`world/reach.ts`](../world/reach.ts) finds it:
a terminal, the counter, a painting (the gallery panel's) or a mural. The world scene takes a door
a terminal, the counter, a painting (the gallery panel's), a mural or a pile the player left. The world scene takes a door
on E only when none is, so one press never does two things, and then up takes the door. With
neither, E opens the chat to the ringed NPC, as Enter does, and their name tag says so: "E: talk to
Lachlan" (`eUse` in [`keys.ts`](keys.ts)). Stairs, ladders and the lift are the arrows' alone. A small tag over each door, flight, ladder or lift a key
Expand Down
17 changes: 15 additions & 2 deletions client/src/ui/dialogue-scene.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Phaser from 'phaser';
import { server, type Position } from '../net';
import { playerState, server, type Position } from '../net';
import { maps } from '../world/assets';
import type { Interactable } from '../world/interactables';
import { muralWalls } from '../world/murals';
Expand All @@ -11,6 +11,7 @@ import { CounterPrompt, servingAt } from './counter';
import { dialogue } from './dialogue';
import './dialogue.css';
import { HelpPanel } from './help';
import { HungerHint, toEat } from './hunger';
import { eUse, onGameKey } from './keys';
import { LogPanel } from './log-panel';
import { MuralPanel } from './mural-panel';
Expand All @@ -34,6 +35,7 @@ export class DialogueScene extends Phaser.Scene {
private ways!: WayPromptLayer;
private murals!: MuralPanel;
private counter!: CounterPrompt;
private hunger!: HungerHint;
private chat!: ChatBox;
private readonly targets = new ChatTarget(worldView, dialogue);
private fitted = '';
Expand Down Expand Up @@ -63,8 +65,10 @@ export class DialogueScene extends Phaser.Scene {
this.ways = new WayPromptLayer(this.root);
this.murals = new MuralPanel(this.root, muralWalls);
this.counter = new CounterPrompt(this.root);
const hunger = (this.hunger = new HungerHint(this.root));
const stopCollapses = server.on('player_collapsed', () => hunger.collapsed());
const help = new HelpPanel(this.root);
const onKeyDown = onGameKey({ chat, use: () => this.pressE(), help });
const onKeyDown = onGameKey({ chat, use: () => this.pressE(), eat: () => this.eat(), help });
const onPointerDown = ({ x, y }: Phaser.Input.Pointer) => {
const npcId = clicked({ x, y }, worldView());
if (npcId) chat.choose(npcId);
Expand All @@ -76,6 +80,7 @@ export class DialogueScene extends Phaser.Scene {
window.removeEventListener('keydown', onKeyDown);
this.input.off(Phaser.Input.Events.POINTER_DOWN, onPointerDown);
stopInvitations();
stopCollapses();
this.news.stop();
this.murals.stop();
log.stop();
Expand All @@ -100,6 +105,7 @@ export class DialogueScene extends Phaser.Scene {
this.newsHint.hidden = !atTerminal || this.news.isOpen || this.murals.isOpen;
this.murals.update(player, used?.kind === 'mural' && !this.news.isOpen);
this.counter.update(used?.kind === 'counter' ? used.interactable : undefined, !this.chat.isOpen);
this.hunger.update(playerState.latest, used?.kind === 'pile');
const panelOpen = this.news.isOpen || this.murals.isOpen;
const ways = panelOpen ? [] : wayPrompts(used !== undefined);
this.ways.render(ways, this.fromGame);
Expand All @@ -122,6 +128,13 @@ export class DialogueScene extends Phaser.Scene {
this.murals.close();
this.news.open(used.terminal.label);
} else if (used?.kind === 'mural') this.murals.look(this.player());
else if (used?.kind === 'pile') server.send({ type: 'pick_up' });
}

/** F has the meal on offer here if they can pay, else eats something they carry. */
private eat(): void {
const eat = toEat(playerState.latest);
if (eat) server.send({ type: 'eat', item: eat.kind === 'item' ? eat.item.id : null });
}

/** At a counter, E opens the chat to whoever is serving behind it. */
Expand Down
8 changes: 8 additions & 0 deletions client/src/ui/dialogue.css
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,14 @@
text-shadow: 0 1px 0 #000;
}

.hunger-hint {
position: absolute;
left: 4px;
top: 4px;
max-width: 260px;
text-shadow: 0 1px 0 #000;
}

.help-hint {
position: absolute;
right: 4px;
Expand Down
3 changes: 2 additions & 1 deletion client/src/ui/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export const CONTROLS: readonly (readonly [keys: readonly string[], what: string
[['Space'], 'jump, higher while held and further running'],
[['↑', 'W'], 'upstairs or up a ladder from beside its foot, or the lift up'],
[['↓', 'S'], 'downstairs or down a ladder from beside its top, or the lift down'],
[['E'], 'read the news at a terminal, look at a mural or a painting, order at a counter, go through a door, or talk to the ringed resident'],
[['E'], 'read the news at a terminal, look at a mural or a painting, order at a counter, pick up what you left, go through a door, or talk to the ringed resident'],
[['F'], 'eat: a meal at home, at the café or the noodle bar, or food you carry'],
[['Enter'], 'talk to the ringed resident, then send'],
[['Tab'], 'choose who to talk to'],
[['Esc'], 'close the chat or a panel'],
Expand Down
Loading
Loading