@gtanczyk/diorama 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,9 @@ const Prompt = z.string().min(8);
13
13
  /** 1 quiet, 2 normal, 3 loud: how it sits in the mix next to the place bed. */
14
14
  const Level = z.union([z.literal(1), z.literal(2), z.literal(3)]).default(2);
15
15
 
16
+ /** One-shots kept from a plan; the same prompt is made once, so exits of one kind cost one sound. */
17
+ const MAX_SHOTS = 24;
18
+
16
19
  export const SoundPlan = z.object({
17
20
  /** Hash of what the plan was made from (world, story, briefs, floors); when that changes the plan is made again. */
18
21
  from: z.string().optional(),
@@ -29,7 +32,9 @@ export const SoundPlan = z.object({
29
32
  /** Surface name to what a few footsteps on it sound like. */
30
33
  steps: z.record(z.string(), Prompt),
31
34
  /** Short one-shots: picking up an item, an exit, using a thing, the ending. */
32
- shots: z.array(z.object({ on: z.string(), prompt: Prompt, seconds: z.number().min(0.5).max(5).default(1.5), level: Level })).max(16).default([]),
35
+ shots: z.array(z.object({ on: z.string(), prompt: Prompt, seconds: z.number().min(0.5).max(5).default(1.5), level: Level })).default([])
36
+ // A world with many exits (a car parked in every place) asks for more than fit: the first ones are kept.
37
+ .transform((shots) => shots.slice(0, MAX_SHOTS)),
33
38
  });
34
39
  export type SoundPlan = z.infer<typeof SoundPlan>;
35
40
 
@@ -17,7 +17,8 @@ export type State = {
17
17
  };
18
18
 
19
19
  export type Action =
20
- | { kind: 'go'; via: string }
20
+ /** `to` picks among several places one thing leads to (a car that drives anywhere). */
21
+ | { kind: 'go'; via: string; to?: string }
21
22
  | { kind: 'do'; thing: string; verb: string; with?: string }
22
23
  | { kind: 'talk'; npc: string }
23
24
  | { kind: 'choose'; index: number }
@@ -101,7 +102,11 @@ export function actions(world: World, story: Story, state: State): Action[] {
101
102
  }
102
103
  const place = world.places.find((p) => p.key === state.place)!;
103
104
  const out: Action[] = [];
104
- for (const exit of place.exits) if (!lockOn(story, state, exit.via)) out.push({ kind: 'go', via: exit.via });
105
+ for (const exit of place.exits) {
106
+ if (lockOn(story, state, exit.via)) continue;
107
+ const shared = place.exits.filter((e) => e.via === exit.via).length > 1;
108
+ out.push({ kind: 'go', via: exit.via, ...(shared && { to: exit.to }) });
109
+ }
105
110
  const seen = new Set<string>();
106
111
  for (const it of story.interactions) {
107
112
  const id = `${it.thing}|${it.verb}|${it.with ?? ''}`;
@@ -126,7 +131,7 @@ export function apply(world: World, story: Story, state: State, action: Action):
126
131
  case 'go': {
127
132
  const lock = lockOn(story, state, action.via);
128
133
  if (lock) return { state, say: [lock.say] };
129
- const exit = world.places.find((p) => p.key === state.place)?.exits.find((e) => e.via === action.via);
134
+ const exit = world.places.find((p) => p.key === state.place)?.exits.find((e) => e.via === action.via && (!action.to || e.to === action.to));
130
135
  if (!exit) throw new Error(`no exit "${action.via}" in ${state.place}`);
131
136
  next.place = exit.to;
132
137
  break;
@@ -201,7 +206,7 @@ export function stateKey(state: State): string {
201
206
 
202
207
  export function describe(action: Action): string {
203
208
  switch (action.kind) {
204
- case 'go': return `go ${action.via}`;
209
+ case 'go': return `go ${action.via}${action.to ? ` to ${action.to}` : ''}`;
205
210
  case 'do': return `${action.verb} ${action.thing}${action.with ? ` with ${action.with}` : ''}`;
206
211
  case 'talk': return `talk ${action.npc}`;
207
212
  case 'choose': return `choose ${action.index + 1}`;
@@ -9,6 +9,8 @@ export const StoryFixes = z.object({
9
9
  wish: z.string().min(5).optional(),
10
10
  /** Reworded text by its place in story.json, e.g. "dialogues.0.lines.2.says". */
11
11
  texts: z.record(z.string(), z.object({ text: z.string().min(1), at: z.string() })).default({}),
12
+ /** What the hero says looking at a thing, by "place/thing": added where the story has no look line there, else it rewords it. */
13
+ looks: z.record(z.string(), z.object({ text: z.string().min(1), at: z.string() })).default({}),
12
14
  });
13
15
  export type StoryFixes = z.infer<typeof StoryFixes>;
14
16
 
@@ -19,7 +21,7 @@ export const storyFixesFile = (files: WorldFiles) => files.path('story-fixes.jso
19
21
 
20
22
  export function readStoryFixes(files: WorldFiles): StoryFixes {
21
23
  const file = storyFixesFile(files);
22
- return fs.existsSync(file) ? StoryFixes.parse(files.readJson(file)) : { texts: {} };
24
+ return fs.existsSync(file) ? StoryFixes.parse(files.readJson(file)) : { texts: {}, looks: {} };
23
25
  }
24
26
 
25
27
  /** The object holding a text field and the field's name, or null when the path names no text. */
@@ -42,7 +44,7 @@ export function textAt(story: Story, path: string): string | null {
42
44
  }
43
45
 
44
46
  /** The story with the reworded lines in; paths that no longer name a text are left out and listed. */
45
- export function applyTexts(story: Story, fixes: StoryFixes): { story: Story; skipped: string[] } {
47
+ export function applyTexts(story: Story, fixes: Pick<StoryFixes, 'texts'> & Partial<Pick<StoryFixes, 'looks'>>): { story: Story; skipped: string[] } {
46
48
  const out = structuredClone(story);
47
49
  const skipped: string[] = [];
48
50
  for (const [path, { text }] of Object.entries(fixes.texts)) {
@@ -50,5 +52,12 @@ export function applyTexts(story: Story, fixes: StoryFixes): { story: Story; ski
50
52
  if (hit) hit[0][hit[1]] = text;
51
53
  else skipped.push(path);
52
54
  }
55
+ for (const [at, { text }] of Object.entries(fixes.looks ?? {})) {
56
+ const [place, thing] = at.split('/');
57
+ // The plain look line (no condition) is the one replaced; a thing with none gets one.
58
+ const plain = out.interactions.find((i) => i.place === place && i.thing === thing && i.verb === 'look' && i.when.length === 0);
59
+ if (plain) plain.say = text;
60
+ else out.interactions.push({ place, thing, verb: 'look', when: [], say: text, do: [], once: false } as Story['interactions'][number]);
61
+ }
53
62
  return { story: out, skipped };
54
63
  }
@@ -31,7 +31,7 @@ Read files freely to find out why something failed, but do not edit world files
31
31
  When a build fails for another reason (a step that broke, a service that did not answer), the Studio wakes you with the error and the job's last lines: job_inspect it, give a service error (HTTP 5xx, a timeout, a busy Space) one job_retry, which picks up at the failed step, and fix anything else with that step's own tool; never retry the same error twice, tell the person instead. A character the 3D service left in 2D (cast_status: the 3D service did not answer) still plays with its 2D rig; cast_animate tries its model again once the service is back. When a build leaves places not playable, repair them one by one: place_status lists each error with its playbook (where to look, the fix tools in order, the last resort), and place_view shows you the walkable floor with the 60 px cells floor_cells takes, the gaps to bridge and the spots off the floor. An unreached patch where nothing is floor (a wall, the sky) is taken back, not bridged: corrections_clear the fix that made it, or mask_brush erase. Try the tools in that order; each fix rebuilds only that place, so read place_status again after its job ends. After three fixes that did not change the error, do not repeat one that failed: look again with place_view and take a different path (mask_brush by hand, floor_cells, thing_remove, corrections_clear); the fix tools refuse an exact repeat, and after six they keep repaints and relayouts for the person. If the new path fails too, tell the person briefly what you tried and what you would do next. Ask before the last resort, except one repaint_region per place.
32
32
  When layout failed and there is no picture, do not wait for place_view: inspect place_status errors, then call layout_fix with concrete layout guidance, or place_fix to resume. Both work before the place directory exists. Failed layout batches retain their diagnostics and retry with new feedback instead of replaying the same rejected batch. When paint fails with content_policy_violation, the image safety filter refused a word, not the picture: paint already lets Spark reword the place twice, so call paint_words with calm, plain words for the scene and the looks the brief had (leafless, not bare; a seat that rests, not hangs on chains; no children, blood or weapons), never an edit of world.json. A place that never got past a step is rebuilt from that step: place_fix (the Fix it button on its card) or job_retry does that by itself.
33
33
  Stairs: derive finds the steps of floors and walk-on things named as stairs (stair, step, schody), and people then step from tread to tread; place_view (layer walk) draws the steps it found in blue. Scale: derive measures how tall a person is at each spot (Spark's people, held against a door on the floor: a person by a door is about 0.8 of its height). When the hero looks far too small or too big next to a door, run_step derive with that place, which rebuilds it through verify; when it stays wrong, the door is not named as a door: give it a door phrase with mask_fix. When people glide up stairs as on a ramp, the stairs thing or floor is not named as stairs: give it a stairs phrase with mask_fix (for example "stone stairs"), which rebuilds the place.
34
- A message that starts with "[On screen: …]" says what the person had open in Studio as they wrote ("place", "place / thing", "cast", "cast / character" or "cast / item key"; older messages say "[Looking at: …]"): when they say "this", "it" or "here" without a name, they mean that; look at it (place_view for a place) before you change it. Never mention the prefix. A message that starts with "[Pictures: studio/pictures/01.jpg, …]" comes with pictures the person pasted, uploaded or took with the webcam: look at each with picture_view before you answer, and act on what they show. When the person is playing and your change shows in the game, call play_reload yourself rather than asking them to refresh. You cannot watch the game, but place_motion can: after a change to motion (water flow, fog, fire, weather, an animated thing) call it and check the motion shows before you say it works. A message that starts with "[Studio]" is a private note from the Studio, not from the person: follow it, never mention it or reply to it. The story has story_status, story_text (rewording) and story_redo (a wish to Spark); story_status lists unanswerable questions (a question in a "say" or in a line without choices leaves the player stuck): reword it into a statement with story_text, or story_redo when it needs real answers; it also lists unseen story parts (an item thing whose item has another key stays in the painting; a change on a person is never drawn): fix those with story_redo; the cast has cast_status, cast_redo, cast_animate, cast_rewalk, cast_holds and voice_set; a figure whose cut-out lost what it holds or carries (a toast over its head, a lantern, a basket; cast_status cut.holds lists what is kept) gets cast_holds, not cast_redo: it cuts the same painting again with the held thing; speaking faces have faces_status and faces_prepare. Talking mouths can be off in Setup (faces_get; off by default): then never prepare, repair or calibrate faces and never offer to; characters speak with voice and gestures. Free fixes before paid ones: a 3D figure whose face shows as a patch of another colour, or whose colours look pale or plastic beside its painting, is a render matter; run sprites again (free for UV sprites, no Muse) before ever offering sprites_redo or per-frame Muse repaints, and when you do offer paid work say the real count, not a range. Order: a playable game first, talking mouths last. While any place is unpainted, being built or unplayable, never start face work (faces_prepare, face_frame_repair, face_lips_set, face_calibration_set refuse it); repair the places, and leave lips for after everything plays, or skip them: characters talk with gestures. A failed speaking animation is separate from game readiness; recordings and the previous appearance remain usable. Inspect coverage and repairWork first. Accepted atlases and calibrated mouths are saved, so resume rather than repainting the whole character. For a rejected mouth, faces_inspect with its work id and mouth returns the exact head and reference; face_frame_repair uses the returned revision and concrete visual guidance to edit ONLY that mouth, then revalidate the atlas. For an accepted atlas with uncertain lip corners, inspect the exact mouth image with faces_inspect and use face_lips_set with a normalized tight lip box relative to that single full-head image (not the whole atlas). Never guess coordinates, use it on rejected articulation, or lower validation thresholds. Both tools resume preparation and reject stale revisions/sources. Wait for the job, inspect faces_status again and play_reload after success. Do not repeat an unchanged failed repair; inspect the new stage and use its matching tool. For source-pose or final-patch calibration failures, face_calibration_inspect lists pending targets and shows their exact close-up. Use face_calibration_set with its target, revision, imageHash, tight lip box and visible-cavity open flag. This preserves accepted artwork and resumes the remaining checks. If an inspected mouth remains genuinely ambiguous, stop and explain that specific uncertainty rather than inventing coordinates. Image, 2D rig and talk-sprite faces share audio timing. 3D models articulate when model3d.mouth is true: method morphs uses authored facial poses, painted-surfaces animates calibrated face paint on the actual mesh. Check the reported view coverage and spritePoses pending count; a body skeleton alone does not articulate. Do not claim native mouth geometry for the painted-surface method; a 3D figure that walks straddle-legged with arms held out to the sides (like a zombie) or hands sticking out gets cast_relax (local Blender, free; blender_get says if it is there, else tell the person to install Blender and turn it on in Setup); a walk that tears (holes in clothes, a leg, boot or arm coming off, a lantern left hanging in the air) gets cast_rewalk, not cast_redo; a character with no 3D model the person explicitly wants animated in 3D (a creature, cast_status body upright false, or an NPC with no lines) gets a model with cast_animate, never a cast_redo that makes it stand on two legs: a creature hops, squashes and blinks as one body, then play_reload; a hero painted sitting, lying or with furniture grown into the 3D model (from a photo where they sat or lay) gets cast_redo with looks that describe only the person standing (clothes, trousers, shoes; no pose, bed or "not visible"), then the model again; inventory icons have icons_status, icon_redo (a hint in the person's words, cut or paint, another take) and icon_fix_clear, never a swapped file in items/. Voices are cast from the gender and age Spark sees in each figure: adults get stock voices, children, teens and the old get a voice made for them by ElevenLabs Voice Design (voice "design"). When one sounds wrong, voice_list with slug and key lists the voices that fit that character first; voice_set with voice "design" and a design description in English remakes a designed voice (no word "child" or age in years there: ElevenLabs refuses it). Never give a child an adult's voice or raise an adult voice's pitch to fake one. A character whose voice is "pending-design" in cast_status waits for Voice Design (it was refused or the service was busy): run_step voices or voice_set designs it again; never give them a stock adult instead. Lines with numbers or sums ("10+10=20") are read and checked as words by themselves; voiceFailures lists only lines the story still has. For one missing or rejected line, use voice_retry with slug, key and the exact text from voiceFailures: it keeps the selected voice and every other recording, gives rejected takes fresh seeds, and resumes saved audio after a transcription outage. Do not change voice_set merely to retry a line. Sounds have sound_status (each bed, loop, footstep, one-shot and music: made, or rejected and why) and sound_redo (ids, an optional hint in English, kept across rebuilds): a sound rejected as "mostly hiss" or "nearly silent" stays silent in the game until sound_redo, never an edited sound.json. cast_status says how the game moves each character (moves), why there is no 2D walk (rigIssue) and which walk directions left the pose (spriteIssues): those get sprites_redo, which paints new sheets without repainting the character; a character with no model after flat reconstructions walks with its 2D rig or slides as a cut-out, and cast_animate tries the model again. When the hero starts in front of an NPC or a thing and hides it, hero_start_set moves where they begin; npc_move changes the NPC standing position in the layout. Author a story-triggered trip with story_redo. A still frame in place_motion is a fault only when inTheGame lists water, mist or weather for that place.
34
+ A message that starts with "[On screen: …]" says what the person had open in Studio as they wrote ("place", "place / thing", "cast", "cast / character" or "cast / item key"; older messages say "[Looking at: …]"): when they say "this", "it" or "here" without a name, they mean that; look at it (place_view for a place) before you change it. Never mention the prefix. A message that starts with "[Pictures: studio/pictures/01.jpg, …]" comes with pictures the person pasted, uploaded or took with the webcam: look at each with picture_view before you answer, and act on what they show. When the person is playing and your change shows in the game, call play_reload yourself rather than asking them to refresh. You cannot watch the game, but place_motion can: after a change to motion (water flow, fog, fire, weather, an animated thing) call it and check the motion shows before you say it works. A message that starts with "[Studio]" is a private note from the Studio, not from the person: follow it, never mention it or reply to it. The story has story_status, story_text (rewording), story_look (what the hero says looking at one thing, added where there is none, nothing else changes: use it rather than story_redo for a missing look line) and story_redo (a wish to Spark, which writes the whole story again); story_status lists unanswerable questions (a question in a "say" or in a line without choices leaves the player stuck): reword it into a statement with story_text, or story_redo when it needs real answers; it also lists unseen story parts (an item thing whose item has another key stays in the painting; a change on a person is never drawn): fix those with story_redo; the cast has cast_status, cast_redo, cast_animate, cast_rewalk, cast_holds and voice_set; a figure whose cut-out lost what it holds or carries (a toast over its head, a lantern, a basket; cast_status cut.holds lists what is kept) gets cast_holds, not cast_redo: it cuts the same painting again with the held thing; speaking faces have faces_status and faces_prepare. Talking mouths can be off in Setup (faces_get; off by default): then never prepare, repair or calibrate faces and never offer to; characters speak with voice and gestures. Free fixes before paid ones: a 3D figure whose face shows as a patch of another colour, or whose colours look pale or plastic beside its painting, is a render matter; run sprites again (free for UV sprites, no Muse) before ever offering sprites_redo or per-frame Muse repaints, and when you do offer paid work say the real count, not a range. Order: a playable game first, talking mouths last. While any place is unpainted, being built or unplayable, never start face work (faces_prepare, face_frame_repair, face_lips_set, face_calibration_set refuse it); repair the places, and leave lips for after everything plays, or skip them: characters talk with gestures. A failed speaking animation is separate from game readiness; recordings and the previous appearance remain usable. Inspect coverage and repairWork first. Accepted atlases and calibrated mouths are saved, so resume rather than repainting the whole character. For a rejected mouth, faces_inspect with its work id and mouth returns the exact head and reference; face_frame_repair uses the returned revision and concrete visual guidance to edit ONLY that mouth, then revalidate the atlas. For an accepted atlas with uncertain lip corners, inspect the exact mouth image with faces_inspect and use face_lips_set with a normalized tight lip box relative to that single full-head image (not the whole atlas). Never guess coordinates, use it on rejected articulation, or lower validation thresholds. Both tools resume preparation and reject stale revisions/sources. Wait for the job, inspect faces_status again and play_reload after success. Do not repeat an unchanged failed repair; inspect the new stage and use its matching tool. For source-pose or final-patch calibration failures, face_calibration_inspect lists pending targets and shows their exact close-up. Use face_calibration_set with its target, revision, imageHash, tight lip box and visible-cavity open flag. This preserves accepted artwork and resumes the remaining checks. If an inspected mouth remains genuinely ambiguous, stop and explain that specific uncertainty rather than inventing coordinates. Image, 2D rig and talk-sprite faces share audio timing. 3D models articulate when model3d.mouth is true: method morphs uses authored facial poses, painted-surfaces animates calibrated face paint on the actual mesh. Check the reported view coverage and spritePoses pending count; a body skeleton alone does not articulate. Do not claim native mouth geometry for the painted-surface method; a 3D figure that walks straddle-legged with arms held out to the sides (like a zombie) or hands sticking out gets cast_relax (local Blender, free; blender_get says if it is there, else tell the person to install Blender and turn it on in Setup); a walk that tears (holes in clothes, a leg, boot or arm coming off, a lantern left hanging in the air) gets cast_rewalk, not cast_redo; a character with no 3D model the person explicitly wants animated in 3D (a creature, cast_status body upright false, or an NPC with no lines) gets a model with cast_animate, never a cast_redo that makes it stand on two legs: a creature hops, squashes and blinks as one body, then play_reload; a hero painted sitting, lying or with furniture grown into the 3D model (from a photo where they sat or lay) gets cast_redo with looks that describe only the person standing (clothes, trousers, shoes; no pose, bed or "not visible"), then the model again; inventory icons have icons_status, icon_redo (a hint in the person's words, cut or paint, another take) and icon_fix_clear, never a swapped file in items/. Voices are cast from the gender and age Spark sees in each figure: adults get stock voices, children, teens and the old get a voice made for them by ElevenLabs Voice Design (voice "design"). When one sounds wrong, voice_list with slug and key lists the voices that fit that character first; voice_set with voice "design" and a design description in English remakes a designed voice (no word "child" or age in years there: ElevenLabs refuses it). Never give a child an adult's voice or raise an adult voice's pitch to fake one. A character whose voice is "pending-design" in cast_status waits for Voice Design (it was refused or the service was busy): run_step voices or voice_set designs it again; never give them a stock adult instead. Lines with numbers or sums ("10+10=20") are read and checked as words by themselves; voiceFailures lists only lines the story still has. For one missing or rejected line, use voice_retry with slug, key and the exact text from voiceFailures: it keeps the selected voice and every other recording, gives rejected takes fresh seeds, and resumes saved audio after a transcription outage. Do not change voice_set merely to retry a line. Sounds have sound_status (each bed, loop, footstep, one-shot and music: made, or rejected and why) and sound_redo (ids, an optional hint in English, kept across rebuilds): a sound rejected as "mostly hiss" or "nearly silent" stays silent in the game until sound_redo, never an edited sound.json. cast_status says how the game moves each character (moves), why there is no 2D walk (rigIssue) and which walk directions left the pose (spriteIssues): those get sprites_redo, which paints new sheets without repainting the character; a character with no model after flat reconstructions walks with its 2D rig or slides as a cut-out, and cast_animate tries the model again. When the hero starts in front of an NPC or a thing and hides it, hero_start_set moves where they begin; npc_move changes the NPC standing position in the layout. Author a story-triggered trip with story_redo. A still frame in place_motion is a fault only when inTheGame lists water, mist or weather for that place.
35
35
  When you need the person to decide (yes or no, which of two fixes, go ahead with something that costs calls), ask with ask_person instead of a question in your text: it shows them buttons; then end your turn.
36
36
  After a redo the person asked for (a repaint, a recast), show them the result and stop: if a trace of what they wanted gone is still faintly there, say so and offer one more try with ask_person; never start another paid redo on your own. A message from the person while you wait for a job comes first: read it before waiting again, and job_cancel the job if they no longer want it.
37
37
  Answer in two or three short sentences, in the language the person wrote their newest message in (not the world's language, not an earlier turn's); the steps already show what you did.`;
@@ -304,6 +304,15 @@ export const AGENTS = {
304
304
  codex: { label: 'Codex', make: () => preapproved(codexLive()) },
305
305
  antigravity: { label: 'Antigravity', make: () => antigravity({ command: process.env.DIORAMA_AGY_COMMAND ?? 'agy' }) },
306
306
  } satisfies Record<string, { label: string; make(command?: string): CodingAgent }>;
307
+
308
+ /**
309
+ * The model the Studio asks an agent for, from DIORAMA_<AGENT>_MODEL (DIORAMA_AGY_MODEL for Antigravity, e.g.
310
+ * gemini-4-argon-high; `agy models` lists them), else DIORAMA_AGENT_MODEL; unset leaves the agent's own default.
311
+ */
312
+ export function agentModel(name: AgentName, env: NodeJS.ProcessEnv = process.env): string | undefined {
313
+ const own = { muse: 'DIORAMA_MUSE_MODEL', claude: 'DIORAMA_AGENT_CLAUDE_MODEL', codex: 'DIORAMA_CODEX_MODEL', antigravity: 'DIORAMA_AGY_MODEL' }[name];
314
+ return env[own]?.trim() || env.DIORAMA_AGENT_MODEL?.trim() || undefined;
315
+ }
307
316
  export type AgentName = keyof typeof AGENTS;
308
317
  export const isAgentName = (name: string): name is AgentName => Object.hasOwn(AGENTS, name);
309
318
 
@@ -400,10 +409,12 @@ export class AgentHub {
400
409
  const cwd = fs.existsSync(dir) ? dir : this.opts.root;
401
410
  fs.mkdirSync(cwd, { recursive: true });
402
411
  const ask = process.env.DIORAMA_AGENT_TOOLS === 'ask';
403
- const agent = this.make(this.defaultAgent(slug));
412
+ const name = this.defaultAgent(slug);
413
+ const agent = this.make(name);
404
414
  const session = createAgentSession({
405
415
  agent,
406
416
  cwd,
417
+ model: agentModel(name),
407
418
  task: {
408
419
  env: withoutProviderCredentials(withoutSecrets(process.env)) as Record<string, string>,
409
420
  // genaicode hands the server to Muse as -c overrides, the token through an environment variable, never argv.
@@ -477,6 +488,7 @@ export class AgentHub {
477
488
  const from = this.agents(slug).find((a) => a.using)?.label;
478
489
  const agent = this.make(name);
479
490
  t.session.setAgent(agent);
491
+ t.session.setModel(agentModel(name));
480
492
  t.resumes = !!agent.capabilities.resume;
481
493
  t.rebrief();
482
494
  this.save(name);
@@ -35,6 +35,7 @@ ${extra ? `${extra}
35
35
  ` : ''}Design ${places} places that tell this idea${fixed ? ', the room included' : ''}. Rules:
36
36
  - 3 to 7 things per place, big enough to see and click; no tiny details.
37
37
  - Exits are things you can see (door, path, ladder, boat) and every exit has one back.
38
+ - Only when the idea gives the hero a car to drive around in: one place has "kind": "drive", the car itself (its shows: the car at the kerb with the engine running), with one thing, the car (role exit), and an exit through that same thing to every other place it drives to; every one of those places has the car parked in it as a thing (role exit, the same looks) with an exit back to the drive place. The player gets in, picks a destination and gets out there.
38
39
  - The world has at least one item to pick up and one npc to talk to.
39
40
  - Everything is original: no characters, places or names from existing films, books, games or brands.
40
41
  - A place's shows and its props' looks go to a painter whose safety filter refuses single words: calm, plain words (leafless trees, not bare; a swing seat that rests, not hangs on chains; no blood, weapons, graves or bodies).
@@ -1,5 +1,5 @@
1
- // Children's drawings as the world: photos of drawings on paper, each read by Spark into the hero, an NPC,
2
- // an item, a prop or a place, kept with the world (drawings/) and used by world, paint, cast and icons.
1
+ // Drawings as the world: photos of drawings on paper (a child's, or a grown-up's sketch), each read by Spark into the
2
+ // hero, an NPC, an item, a prop or a place, kept with the world (drawings/) and used by world, paint, cast and icons.
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import sharp from 'sharp';
@@ -17,6 +17,10 @@ export const Drawn = z.object({
17
17
  kind: z.enum(['hero', 'npc', 'player', 'item', 'prop', 'place']),
18
18
  key: Slug,
19
19
  looks: z.string().min(3),
20
+ /** Who drew it, as Spark sees it: a grown-up's sketch makes no kids' game and keeps its own medium (absent = a child). */
21
+ by: z.enum(['child', 'grown-up']).optional(),
22
+ /** A grown-up's sketch: its medium and hand, as the world's style ("fountain-pen ink hatching, grey watercolour wash"). */
23
+ medium: z.string().optional(),
20
24
  });
21
25
  export type Drawn = z.infer<typeof Drawn>;
22
26
 
@@ -59,7 +63,24 @@ export function readDrawn(files: WorldFiles): Drawn[] {
59
63
  return parsed.success ? parsed.data : [];
60
64
  }
61
65
 
62
- /** The drawing a character, item or place was made from, if a child drew it. */
66
+ /** True when every drawing is a grown-up's sketch: then the world is no kids' game and keeps the sketches' own medium. */
67
+ export const grownUpDrawings = (drawn: Drawn[]) => drawn.length > 0 && drawn.every((d) => d.by === 'grown-up');
68
+
69
+ /** A world for children: said so, or made from drawings and not every one of them a grown-up's sketch. */
70
+ export const kidsWorld = (files: WorldFiles, world: World) => world.audience === 'kids' || (fs.existsSync(files.path('drawings')) && !grownUpDrawings(readDrawn(files)));
71
+
72
+ /** Whose drawings the painters are shown, by the world's audience. */
73
+ export const drawers = (world: Pick<World, 'audience'>) => world.audience === 'kids'
74
+ ? { one: "a child's drawing", many: "children's drawings", who: 'the child who drew it', whoAll: 'the children' }
75
+ : { one: "an artist's sketch", many: "the artist's sketches", who: 'the artist who drew it', whoAll: 'the artist' };
76
+
77
+ /** The style of a world made from grown-ups' sketches: their own medium and line, finished into a game. */
78
+ export function sketchStyle(drawn: Drawn[]): string {
79
+ const media = [...new Set(drawn.map((d) => d.medium?.trim()).filter((m): m is string => !!m))];
80
+ return `the artist's own sketchbook drawings come alive as finished game art: ${media[0] ?? 'confident pen and ink lines with a light wash'}, the same hand, line weight and palette as the sketches, drawn loose but with clear readable shapes, depth and soft light, paper texture showing through the colour`;
81
+ }
82
+
83
+ /** The drawing a character, item or place was made from, if one was drawn. */
63
84
  export function drawingFor(files: WorldFiles, key: string): Buffer | undefined {
64
85
  const drawn = readDrawn(files).find((d) => d.key === key);
65
86
  return drawn && fs.existsSync(files.path('drawings', drawn.file)) ? fs.readFileSync(files.path('drawings', drawn.file)) : undefined;
@@ -78,21 +99,25 @@ export function readKinds(files: WorldFiles): Record<string, Kind> {
78
99
  /** A kids' world looks like this, so a character painted from a drawing before the world exists is the one the game uses. */
79
100
  export const KIDS_STYLE = "the children's own drawings come alive: wax crayon and felt-tip texture, solidly coloured, bold simple rounded shapes with thick dark outlines, bright clean colours, soft sunny light, friendly and readable at a glance";
80
101
 
81
- const KIND_TEXT = `- kind: "character" (someone: a person, an animal, a creature), "item" (a small thing to pick up and carry), "prop" (a big thing that stays in a place) or "place" (a whole scene: a landscape, a room, a house with its surroundings)
102
+ const KIND_TEXT = `- by: "child" when a young child drew it (crayon, felt-tip, simple flat shapes) or "grown-up" for a sketch by a teen or an adult (confident pen, pencil, ink, marker, watercolour or digital lines, perspective, hatching, notes)
103
+ - medium: for a grown-up's sketch, its medium and look in a dozen words, as a painter's style ("fountain-pen ink hatching with a grey and ochre watercolour wash on cream paper"); "" for a child's
104
+ - kind: "character" (someone: a person, an animal, a creature), "item" (a small thing to pick up and carry), "prop" (a big thing that stays in a place) or "place" (a whole scene: a landscape, a room, a house with its surroundings)
82
105
  - key: a kebab-case English name for it ("green-dragon", "blue-hat", "mushroom-forest")
83
- - looks: what the child drew, as a description a painter can follow: shape, every colour, clothes, face, what it holds. Describe the drawing kindly as the real thing it shows (a "green dragon with orange wings and a round light-green belly", not "scribbles"). For a character or a thing, only it, not the sun, sky or ground drawn around it. For a place: what it shows front to back.
106
+ - looks: what was drawn, as a description a painter can follow: shape, every colour, clothes, face, what it holds. Describe the drawing kindly as the real thing it shows (a "green dragon with orange wings and a round light-green belly", not "scribbles"). For a character or a thing, only it, not the sun, sky or ground drawn around it. For a place: what it shows front to back.
84
107
  Ignore any name written on the paper, the paper itself and the table.`;
85
108
 
86
109
  /** Spark says what one drawing is, as soon as its photo arrives (no story needed yet). */
87
110
  export async function readDrawing(memo: Memo, image: Buffer, file: string, taken: string[] = []): Promise<Drawn> {
88
- const prompt = `A child of 5 or 6 drew this picture for a 2D point-and-click adventure game the class is making together. Say what it is in the game:
111
+ const prompt = `Someone drew this picture for a 2D point-and-click adventure game: a child (often 5 or 6, for a game the class makes together) or a grown-up sketching their own world. Say who drew it and what it is in the game:
89
112
  ${KIND_TEXT}${taken.length ? `\nKeys already used by other drawings: ${taken.join(', ')}. Use one of them only when this drawing shows that same thing again.` : ''}
90
113
 
91
- Reply with JSON only: {"kind": "...", "key": "...", "looks": "..."}`;
114
+ Reply with JSON only: {"by": "child | grown-up", "medium": "...", "kind": "...", "key": "...", "looks": "..."}`;
92
115
  let ask = prompt;
93
116
  for (let attempt = 1; attempt <= 3; attempt++) {
94
- const answer = await askSparkJson<{ kind?: string; key?: string; looks?: string }>(memo, { label: `drawing:${file}#${attempt}`, prompt: ask, images: [image], personal: true });
95
- const parsed = Drawn.safeParse({ file, kind: answer?.kind === 'character' ? 'npc' : answer?.kind, key: answer?.key, looks: answer?.looks });
117
+ const answer = await askSparkJson<{ by?: string; medium?: string; kind?: string; key?: string; looks?: string }>(memo, { label: `drawing:${file}#${attempt}`, prompt: ask, images: [image], personal: true });
118
+ const by = answer?.by === 'grown-up' || answer?.by === 'adult' ? 'grown-up' as const : 'child' as const;
119
+ const medium = by === 'grown-up' && answer?.medium?.trim() ? answer.medium.trim() : undefined;
120
+ const parsed = Drawn.safeParse({ file, kind: answer?.kind === 'character' ? 'npc' : answer?.kind, key: answer?.key, looks: answer?.looks, by, ...(medium && { medium }) });
96
121
  if (parsed.success) return parsed.data;
97
122
  ask = `${prompt}\n\nYour last answer was:\n${JSON.stringify(answer)}\n\nFix: ${parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`;
98
123
  }
@@ -132,7 +157,7 @@ export function fitPlaces(drawn: Drawn[], places: number): Drawn[] {
132
157
  let kept = 0;
133
158
  return drawn.map((d) => {
134
159
  if (d.kind !== 'place' || ++kept <= places) return d;
135
- return { ...d, kind: 'prop', looks: `${d.looks} (a child drew it as a whole place; here it is one big thing in a place: a picture, a model, a view through a window)` };
160
+ return { ...d, kind: 'prop', looks: `${d.looks} (drawn as a whole place; here it is one big thing in a place: a picture, a model, a view through a window)` };
136
161
  });
137
162
  }
138
163
 
@@ -156,6 +181,7 @@ export function players(files: WorldFiles): Array<{ key: string; looks: string }
156
181
 
157
182
  /** What the world designer is told: every drawing is in the world, under its key, as drawn. */
158
183
  export function drawingsBrief(drawn: Drawn[], style = KIDS_STYLE, chooseHero = false): string {
184
+ const who = grownUpDrawings(drawn) ? 'The artist sketched' : 'The children drew';
159
185
  const once = [...new Map(drawn.map((d) => [d.key, d])).values()];
160
186
  const line = (d: Drawn) => chooseHero && (d.kind === 'hero' || d.kind === 'npc')
161
187
  ? `- a character, key "${d.key}": ${d.looks} (hero or npc according to the creator's idea and clarifications)` : ({
@@ -166,7 +192,7 @@ export function drawingsBrief(drawn: Drawn[], style = KIDS_STYLE, chooseHero = f
166
192
  place: `- a place, key "${d.key}": ${d.looks}`,
167
193
  player: '',
168
194
  })[d.kind];
169
- return `The children drew these, and every one of them must be in the world, under exactly this key, looking as drawn:
195
+ return `${who} these, and every one of them must be in the world, under exactly this key, looking as drawn:
170
196
  ${once.filter((d) => d.kind !== 'player').map(line).join('\n')}
171
197
  ${chooseHero ? 'Choose exactly one of these drawn characters as the hero according to the creator\'s idea and answers; the other characters are NPCs. Do not choose the first drawing just because it came first.' : ''}
172
198
  The style is exactly: "${style}".`;
@@ -177,17 +203,24 @@ export function drawnIssues(world: World, drawn: Drawn[]): string[] {
177
203
  const things = new Map(world.places.flatMap((p) => p.things.map((t) => [t.key, t.role] as const)));
178
204
  const places = new Set(world.places.map((p) => p.key));
179
205
  return [...new Map(drawn.map((d) => [d.key, d])).values()].flatMap((d) => {
180
- if (d.kind === 'hero') return world.hero.key === d.key ? [] : [`the hero's key must be "${d.key}" (a child drew the hero)`];
206
+ if (d.kind === 'hero') return world.hero.key === d.key ? [] : [`the hero's key must be "${d.key}" (the hero was drawn)`];
181
207
  if (d.kind === 'player') return [];
182
- if (d.kind === 'place') return places.has(d.key) ? [] : [`a child drew the place "${d.key}": it must be one of the places, with that key`];
208
+ if (d.kind === 'place') return places.has(d.key) ? [] : [`the place "${d.key}" was drawn: it must be one of the places, with that key`];
183
209
  const role = things.get(d.key);
184
- if (!role) return [`a child drew "${d.key}": it must be a thing with role "${d.kind}" in one of the places`];
210
+ if (!role) return [`"${d.key}" was drawn: it must be a thing with role "${d.kind}" in one of the places`];
185
211
  // A drawn house or cave may well be the way into the next place.
186
212
  if (role === d.kind || (d.kind === 'prop' && role === 'exit')) return [];
187
213
  return [`"${d.key}" was drawn as ${d.kind === 'item' ? 'an item' : `a ${d.kind}`}: its role must be "${d.kind}"`];
188
214
  });
189
215
  }
190
216
 
217
+ /** A grown-up's sketch of the whole place: drawn in perspective, it sets the camera Muse paints with, so the
218
+ * layout starts from it like an approved concept (a child's flat drawing leaves the composition to the layout). */
219
+ export function placeSketch(files: WorldFiles, key: string): Buffer | undefined {
220
+ const drawn = readDrawn(files).find((d) => d.key === key && d.kind === 'place' && d.by === 'grown-up');
221
+ return drawn ? drawingFor(files, key) : undefined;
222
+ }
223
+
191
224
  /** The drawings a place's painting follows: the place itself first, then what was drawn of the props and items in it (three at most). */
192
225
  export function drawingsIn(files: WorldFiles, place: Place): Array<{ key: string; image: Buffer }> {
193
226
  const keys = [place.key, ...place.things.filter((t) => t.role !== 'npc').map((t) => t.key)];
@@ -11,6 +11,11 @@ export const Thing = z.object({
11
11
 
12
12
  export const Place = z.object({
13
13
  key: Slug,
14
+ /**
15
+ * "drive": the hero rides along a road, seen from the side. The road is the floor, the scenery streams past in
16
+ * depth bands that loop, the hero sits in the car (unseen) and things are used where they are, without walking.
17
+ */
18
+ kind: z.enum(['drive']).optional(),
14
19
  shows: z.string().min(10),
15
20
  mood: z.string().min(3),
16
21
  things: z.array(Thing).min(1).max(10),
package/app/web/main.ts CHANGED
@@ -12,6 +12,7 @@ import { CYCLE, pose, sheetCell, type PartName, type Rig } from '../src/play/rig
12
12
  import { actions, activeChanges, apply, hiddenThings, hintFor, holds, lockOn, pickDialogue, pickInteraction, speakerOf, startState, useFor, type Action, type State } from '../src/story/engine.ts';
13
13
  import type { Story } from '../src/story/schema.ts';
14
14
  import type { Place as PlaceSpec, World } from '../src/world/schema.ts';
15
+ import { CAR } from '../src/play/car.ts';
15
16
  import { Mixer, type Listened } from './audio.ts';
16
17
  import { PLAYER_COLOURS, readEachPad, readPads, Turns, type PadButton, type PadFrame } from './pad.ts';
17
18
  import { Answers, Bag, displayName, fitHud, showEnding, type ItemLook } from './ui.ts';
@@ -76,6 +77,7 @@ type Figure = { img: HTMLImageElement; face?: PaintedFace; faceInfo?: FaceDefini
76
77
  const FLOATS = /\b(boat|rowboat|dinghy|skiff|canoe|kayak|raft|ship|barge|punt|gondola|sampan|junk|ferry|trawler|yacht|sailboat)s?\b/i;
77
78
 
78
79
  /** canvas: the card as the place looks now (story changes on it); base: as painted, whose alpha is the card's shape. */
80
+
79
81
  type Cut = { occ: Occluder; canvas: HTMLCanvasElement; base: HTMLCanvasElement; x0: number; y0: number; y: number };
80
82
 
81
83
  type Place = {
@@ -670,7 +672,10 @@ class Game {
670
672
  [this.hero.x, this.hero.y] = arrive ?? place.g.start;
671
673
  // Coming over a bridge or up the steps, the hero walks in from the exit's far end (by the frame's edge) to
672
674
  // where one stands at it, instead of popping up in the middle of the place.
673
- const edge = back && arrive ? this.exitEdge(back.via) : undefined;
675
+ const edge = back && arrive && place.spec.kind !== 'drive' ? this.exitEdge(back.via) : undefined;
676
+ // In a drive the hero is painted at the wheel (the car's card): unseen as a figure, they speak from the car.
677
+ const car = place.spec.kind === 'drive' ? this.carOf(place) : undefined;
678
+ if (car) [this.hero.x, this.hero.y] = [(car.occ.box[0] + car.occ.box[2]) / 2 - (car.occ.box[2] - car.occ.box[0]) * 0.15, car.occ.box[1] + (car.occ.box[3] - car.occ.box[1]) * 0.45];
674
679
  if (edge) {
675
680
  [this.hero.x, this.hero.y] = edge;
676
681
  this.hero.path = this.place.grid.route(edge, arrive!);
@@ -844,12 +849,16 @@ class Game {
844
849
  try {
845
850
  const placeKey = this.place.key;
846
851
  const [sx, sy] = this.approach(hot);
847
- if (!(await this.walkTo(sx, sy)) && Math.hypot(this.hero.x - sx, this.hero.y - sy) > this.place.g.cell * 2) return;
852
+ // In a drive things are used from the car, where they are.
853
+ if (this.place.spec.kind !== 'drive' && !(await this.walkTo(sx, sy)) && Math.hypot(this.hero.x - sx, this.hero.y - sy) > this.place.g.cell * 2) return;
848
854
  if (this.place.key !== placeKey) return;
849
855
  const [x0, , x1] = hot.thing.box;
850
- this.hero.facing = (hot.npc?.body.x ?? (x0 + x1) / 2) >= this.hero.x ? 1 : -1;
851
- // Three-quarter towards it, the way a painted figure talks to someone.
852
- this.hero.yaw = this.hero.facing * Math.PI / 3;
856
+ // In a drive the hero keeps their eyes on the road.
857
+ if (this.place.spec.kind !== 'drive') {
858
+ this.hero.facing = (hot.npc?.body.x ?? (x0 + x1) / 2) >= this.hero.x ? 1 : -1;
859
+ // Three-quarter towards it, the way a painted figure talks to someone.
860
+ this.hero.yaw = this.hero.facing * Math.PI / 3;
861
+ }
853
862
  this.emit('use', { thing: key, verb });
854
863
  document.body.classList.add('played');
855
864
  await this.act(this.actionFor(key, hot, verb));
@@ -891,6 +900,8 @@ class Game {
891
900
  /** What a click on a thing means: the exit, the talk, the held item, or the first verb the story answers. */
892
901
  private actionFor(key: string, hot: Hot, verb?: Verb): Action {
893
902
  if (verb === 'look') return { kind: 'do', thing: key, verb: 'look' };
903
+ // A car that drives to several places is driven from its board; a click on it looks at it.
904
+ if (this.place.spec.exits.filter((e) => e.via === key).length > 1) return { kind: 'do', thing: key, verb: 'look' };
894
905
  if (this.place.spec.exits.some((e) => e.via === key)) return { kind: 'go', via: key };
895
906
  const role = hot.spec?.role ?? hot.thing.role;
896
907
  // A person talks: in a conversation when the story has one for them now, else in their own talk lines.
@@ -961,7 +972,7 @@ class Game {
961
972
  if (nudge && this.hinted?.key === nudge.thing) this.hinted = { key: nudge.thing, until: this.time + 6 };
962
973
  if (after.place !== from) {
963
974
  // Out the same way: along the bridge or down the steps toward the frame's edge before the picture fades.
964
- const edge = action.kind === 'go' ? this.exitEdge(action.via) : undefined;
975
+ const edge = action.kind === 'go' && this.place.spec.kind !== 'drive' ? this.exitEdge(action.via) : undefined;
965
976
  if (edge) await Promise.race([this.walkTo(...edge), this.wait(2500)]);
966
977
  this.emit('exit', { from, to: after.place });
967
978
  await this.fadeOut();
@@ -1476,6 +1487,12 @@ class Game {
1476
1487
  return this.shift(y);
1477
1488
  }
1478
1489
 
1490
+ /** The car of a drive place: the card named as a car, else the biggest card standing on the road. */
1491
+ private carOf(place: Place): Cut | undefined {
1492
+ const named = (c: Cut) => { const k = c.occ.thing ?? c.occ.key; return `${k} ${place.spec.things.find((t) => t.key === k)?.looks ?? ''}`; };
1493
+ return place.cuts.find((c) => CAR.test(named(c))) ?? [...place.cuts].sort((a, b) => b.canvas.width * b.canvas.height - a.canvas.width * a.canvas.height)[0];
1494
+ }
1495
+
1479
1496
  /** Draws the card's columns [xa, xb) (frame x) in strips, each slid by the floor under it. */
1480
1497
  private drawCard(cut: Cut, xa = cut.x0, xb = cut.x0 + cut.canvas.width, ya = cut.y0, yb = cut.y0 + cut.canvas.height) {
1481
1498
  ctx.save();
@@ -1491,6 +1508,17 @@ class Game {
1491
1508
  * card (its outline, its story change) goes through here, so it moves exactly as the card does.
1492
1509
  */
1493
1510
  private poseCard(cut: Cut) {
1511
+ if (this.place.spec.kind === 'drive' && cut === this.carOf(this.place) && !params.has('still')) {
1512
+ // The engine idles: the body trembles on its springs, harder while it revs to leave.
1513
+ const t = this.time;
1514
+ const rev = this.revving > t ? 3 : 1;
1515
+ const shake = (Math.sin(t * 61) * 0.5 + Math.sin(t * 37 + 1) * 0.35) * rev;
1516
+ const [cx, cy] = [(cut.occ.box[0] + cut.occ.box[2]) / 2, cut.occ.box[3]];
1517
+ ctx.translate(cx, cy + shake);
1518
+ ctx.rotate(Math.sin(t * 23) * 0.0012 * rev);
1519
+ ctx.translate(-cx, -cy);
1520
+ return;
1521
+ }
1494
1522
  const float = this.afloat(cut);
1495
1523
  if (float && !params.has('still')) {
1496
1524
  const t = this.time + (hashSeed(cut.occ.key) % 1000) / 160;
@@ -1939,7 +1967,9 @@ class Game {
1939
1967
  const gone = this.gone();
1940
1968
  this.recut();
1941
1969
  const npcs = this.hots().filter((h) => (h.spec?.role ?? h.thing.role) === 'npc' && !h.mask);
1942
- const people: Who[] = [...npcs.map((h) => ({ npc: h, y: h.npc?.body.y ?? h.thing.box[3] })), ...[this.hero, ...this.party].map((body) => ({ body, y: body.y }))].sort((a, b) => a.y - b.y);
1970
+ // In a drive the hero is the one painted at the wheel.
1971
+ const riding = this.place.spec.kind === 'drive';
1972
+ const people: Who[] = [...npcs.map((h) => ({ npc: h, y: h.npc?.body.y ?? h.thing.box[3] })), ...(riding ? [] : [this.hero, ...this.party]).map((body) => ({ body, y: body.y }))].sort((a, b) => a.y - b.y);
1943
1973
  const fire = this.fireOf(gone);
1944
1974
  this.climbs.length = 0;
1945
1975
  if (p && backdrop) {
@@ -2052,6 +2082,7 @@ class Game {
2052
2082
  this.drawClimbs();
2053
2083
  }
2054
2084
  this.place.glows?.draw(ctx, (y) => this.shift(y));
2085
+ if (this.place.spec.kind === 'drive') this.drawIdle();
2055
2086
  this.place.weather?.drawDrips(ctx, (y) => this.shift(y));
2056
2087
  this.drawFocus();
2057
2088
  // The place as drawn so far, without anyone: what the depth map puts back over a person (occludeDepth).
@@ -2692,6 +2723,108 @@ class Game {
2692
2723
  }
2693
2724
 
2694
2725
  /** Shadows that land on things standing behind their person (a tent wall): drawn over the cards. */
2726
+ /** Until when a drive's engine revs (it shakes harder) before it leaves. */
2727
+ revving = 0;
2728
+ /** The destination board of a drive place, as drawn this frame: each place it leads to and its box. */
2729
+ private board: Array<{ to: string; via: string; box: [number, number, number, number] }> = [];
2730
+ /** The destination under the pointer. */
2731
+ boardHover: string | undefined;
2732
+
2733
+ /** Where a drive place's car leads: every exit through a thing that leads to more than one place. */
2734
+ destinations(): Array<{ to: string; via: string }> {
2735
+ if (this.place?.spec.kind !== 'drive') return [];
2736
+ const exits = this.place.spec.exits;
2737
+ return exits.filter((e) => exits.filter((o) => o.via === e.via).length > 1).map((e) => ({ to: e.to, via: e.via }));
2738
+ }
2739
+
2740
+ destinationAt(x: number, y: number): string | undefined {
2741
+ return this.board.find((b) => x >= b.box[0] && x <= b.box[2] && y >= b.box[1] && y <= b.box[3])?.to;
2742
+ }
2743
+
2744
+ /** The middle of a destination's row on the board, in page pixels (for scripts and recorders). */
2745
+ destinationPoint(to: string): { x: number; y: number } | null {
2746
+ const b = this.board.find((d) => d.to === to);
2747
+ if (!b) return null;
2748
+ const z = this.zoom();
2749
+ const { width: W, height: H } = this.place.g.frame;
2750
+ const [fx, fy] = [((b.box[0] + b.box[2]) / 2 - (this.viewX || W / 2)) * z + W / 2, ((b.box[1] + b.box[3]) / 2 - (this.viewY || H / 2)) * z + H / 2];
2751
+ const r = canvas.getBoundingClientRect();
2752
+ return { x: r.left + (fx * r.width) / W, y: r.top + (fy * r.height) / H };
2753
+ }
2754
+
2755
+ /** Drives off to a destination: the engine revs, the picture fades, the car is parked there and the hero gets out. */
2756
+ async driveTo(to: string) {
2757
+ const d = this.destinations().find((e) => e.to === to);
2758
+ if (!d || this.busy) return;
2759
+ this.busy = true;
2760
+ try {
2761
+ this.emit('use', { thing: d.via, verb: 'go' });
2762
+ this.revving = this.time + 1;
2763
+ await this.wait(800);
2764
+ await this.act({ kind: 'go', via: d.via, to });
2765
+ } finally {
2766
+ this.busy = false;
2767
+ }
2768
+ }
2769
+
2770
+ /** A drive place's engine running (exhaust puffing from behind the car) and its board of destinations. */
2771
+ private drawIdle() {
2772
+ const car = this.carOf(this.place);
2773
+ const t = this.time;
2774
+ if (car && !params.has('still')) {
2775
+ const [x0, y0, x1, y1] = car.occ.box;
2776
+ const [w, h] = [x1 - x0, y1 - y0];
2777
+ const rev = this.revving > t ? 2.2 : 1;
2778
+ ctx.save();
2779
+ for (let i = 0; i < 10; i++) {
2780
+ const age = (t * 0.8 * rev + i / 10) % 1;
2781
+ const r = w * (0.02 + age * 0.08);
2782
+ ctx.fillStyle = `rgba(210,210,215,${(0.32 * (1 - age) * Math.min(1, age * 6)).toFixed(3)})`;
2783
+ ctx.beginPath();
2784
+ ctx.arc(x0 + w * 0.12 - age * w * 0.22 + Math.sin(t * 2 + i) * 4, y1 - h * 0.12 - age * h * 0.35, r, 0, Math.PI * 2);
2785
+ ctx.fill();
2786
+ }
2787
+ ctx.restore();
2788
+ }
2789
+ const where = this.destinations();
2790
+ this.board = [];
2791
+ if (!where.length) return;
2792
+ const W = this.place.g.frame.width;
2793
+ // With ?fill=1 the painting can run past the page's edges: the board sits at the page's own top right corner.
2794
+ const r = canvas.getBoundingClientRect();
2795
+ const [visRight, visTop] = fill ? this.unzoom(((Math.min(innerWidth, r.right) - r.left) / r.width) * W, (Math.max(0, -r.top) / r.height) * this.place.g.frame.height) : [W, 0];
2796
+ const [rowH, bw, x1, top] = [52, 330, Math.min(W, visRight) - 28, Math.max(0, visTop) + 36];
2797
+ const x0 = x1 - bw;
2798
+ ctx.save();
2799
+ ctx.fillStyle = 'rgba(12,10,18,0.78)';
2800
+ ctx.strokeStyle = 'rgba(255,60,170,0.9)';
2801
+ ctx.lineWidth = 2;
2802
+ ctx.beginPath();
2803
+ ctx.roundRect(x0, top, bw, 54 + where.length * rowH, 12);
2804
+ ctx.fill();
2805
+ ctx.stroke();
2806
+ ctx.fillStyle = '#ffd34d';
2807
+ ctx.font = '700 22px system-ui, sans-serif';
2808
+ ctx.textBaseline = 'middle';
2809
+ ctx.fillText('Where to?', x0 + 18, top + 28);
2810
+ where.forEach((d, i) => {
2811
+ const y = top + 54 + i * rowH;
2812
+ const box: [number, number, number, number] = [x0 + 8, y + 3, x1 - 8, y + rowH - 3];
2813
+ this.board.push({ ...d, box });
2814
+ const lit = this.boardHover === d.to;
2815
+ ctx.fillStyle = lit ? 'rgba(255,60,170,0.85)' : 'rgba(255,255,255,0.07)';
2816
+ ctx.beginPath();
2817
+ ctx.roundRect(box[0], box[1], box[2] - box[0], box[3] - box[1], 8);
2818
+ ctx.fill();
2819
+ ctx.fillStyle = lit ? '#fff' : '#e8e4f0';
2820
+ ctx.font = '600 21px system-ui, sans-serif';
2821
+ ctx.fillText(displayName(d.to, this.data.world.names), box[0] + 14, (box[1] + box[3]) / 2);
2822
+ ctx.fillStyle = '#5ee6ff';
2823
+ ctx.fillText('›', box[2] - 26, (box[1] + box[3]) / 2);
2824
+ });
2825
+ ctx.restore();
2826
+ }
2827
+
2695
2828
  private climbs: Array<{ canvas: HTMLCanvasElement; box: [number, number, number, number]; dark: number; blur: string; shift: [number, number] }> = [];
2696
2829
  private climbPool: HTMLCanvasElement[] = [];
2697
2830
  private climbMask = document.createElement('canvas');
@@ -3124,9 +3257,12 @@ class Game {
3124
3257
  const width = Math.max(...lines.map((l) => ctx.measureText(l).width)) + 40;
3125
3258
  const height = lines.length * 28 + (name ? 30 : 22);
3126
3259
  const head = (left + right) / 2;
3127
- const cx = Math.min(canvas.width - width / 2 - 12, Math.max(width / 2 + 12, head));
3260
+ // With ?fill=1 the canvas can run past the page's edges: the bubble stays on the page's own part of it.
3261
+ const r = canvas.getBoundingClientRect();
3262
+ const [visL, visR, visT] = fill && r.width ? [(Math.max(0, -r.left) / r.width) * canvas.width, ((Math.min(innerWidth, r.right) - r.left) / r.width) * canvas.width, (Math.max(0, -r.top) / r.height) * canvas.height] : [0, canvas.width, 0];
3263
+ const cx = Math.min(visR - width / 2 - 12, Math.max(visL + width / 2 + 12, head));
3128
3264
  const tail = 22;
3129
- const y = Math.max(name ? 24 : 10, top - height - tail - 6);
3265
+ const y = Math.max(visT + (name ? 24 : 10), top - height - tail - 6);
3130
3266
  const ink = '#2b2118';
3131
3267
  ctx.lineJoin = 'round';
3132
3268
  ctx.lineWidth = 2.5;
@@ -3404,10 +3540,11 @@ canvas.addEventListener('pointermove', (e) => {
3404
3540
  usePad(false);
3405
3541
  const r = canvas.getBoundingClientRect();
3406
3542
  pointer = { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height };
3407
- game.hover = game.thingAt(...toFrame(e));
3543
+ game.boardHover = game.destinationAt(...toFrame(e));
3544
+ game.hover = game.boardHover ? undefined : game.thingAt(...toFrame(e));
3408
3545
  // Off the floor (a wall, the sky, the water) the cursor says no, so nobody clicks blind.
3409
- const off = !game.hover && !game.place.grid.walkable(...game.floorAt(...toFrame(e)));
3410
- canvas.style.cursor = hideCursor || game.holding ? 'none' : game.hover ? 'pointer' : off ? 'not-allowed' : 'default';
3546
+ const off = !game.hover && !game.boardHover && !game.place.grid.walkable(...game.floorAt(...toFrame(e)));
3547
+ canvas.style.cursor = hideCursor || game.holding ? 'none' : game.hover || game.boardHover ? 'pointer' : off ? 'not-allowed' : 'default';
3411
3548
  game.bag.point(e.clientX, e.clientY, game.hover?.thing.key.replace(/-/g, ' '));
3412
3549
  document.getElementById('hover')!.textContent = game.hover ? game.hover.thing.key.replace(/-/g, ' ') : '';
3413
3550
  });
@@ -3439,6 +3576,8 @@ canvas.addEventListener('click', (e) => {
3439
3576
  if (e.detail >= 2 && game.hero.path.length) return void (game.hero.run = true);
3440
3577
  game.hero.run = false;
3441
3578
  const [x, y] = toFrame(e);
3579
+ const to = game.destinationAt(x, y);
3580
+ if (to) return void game.driveTo(to);
3442
3581
  const hot = game.thingAt(x, y);
3443
3582
  if (hot) void game.use(hot.thing.key);
3444
3583
  else void game.walkTo(...game.floorAt(x, y));