@gtanczyk/diorama 0.2.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.
Files changed (45) hide show
  1. package/README.md +1 -0
  2. package/app/src/actions/fix.ts +28 -10
  3. package/app/src/actions/story.ts +28 -1
  4. package/app/src/actions/style.ts +3 -2
  5. package/app/src/commands/backdrop.ts +23 -3
  6. package/app/src/commands/clip.ts +7 -0
  7. package/app/src/commands/derive.ts +9 -0
  8. package/app/src/commands/export.ts +1 -0
  9. package/app/src/commands/language.ts +2 -1
  10. package/app/src/commands/layout.ts +9 -4
  11. package/app/src/commands/masks.ts +2 -1
  12. package/app/src/commands/paint.ts +15 -7
  13. package/app/src/commands/play.ts +1 -0
  14. package/app/src/commands/playthrough.ts +2 -2
  15. package/app/src/commands/world.ts +9 -6
  16. package/app/src/play/car.ts +2 -0
  17. package/app/src/play/geometry.ts +12 -2
  18. package/app/src/play/parallax.ts +2 -0
  19. package/app/src/qa/route.ts +12 -2
  20. package/app/src/scene/cast.ts +4 -2
  21. package/app/src/scene/critic.ts +3 -2
  22. package/app/src/scene/derive.ts +46 -1
  23. package/app/src/scene/layers.ts +52 -0
  24. package/app/src/scene/layout.ts +13 -5
  25. package/app/src/scene/paint-layout.ts +2 -1
  26. package/app/src/scene/paint.ts +15 -8
  27. package/app/src/scene/photo.ts +25 -2
  28. package/app/src/scene/status.ts +2 -1
  29. package/app/src/scene/vehicle.ts +22 -0
  30. package/app/src/scene/verify.ts +12 -0
  31. package/app/src/setup/checks.ts +2 -2
  32. package/app/src/sound/plan.ts +6 -1
  33. package/app/src/story/engine.ts +9 -4
  34. package/app/src/story/fixes.ts +11 -2
  35. package/app/src/studio/agent.ts +21 -8
  36. package/app/src/studio/repair.ts +1 -1
  37. package/app/src/world/design.ts +1 -0
  38. package/app/src/world/drawings.ts +47 -14
  39. package/app/src/world/schema.ts +5 -0
  40. package/app/web/living.ts +36 -0
  41. package/app/web/main.ts +192 -18
  42. package/docs/releasing.md +22 -0
  43. package/docs/scenes.md +10 -1
  44. package/docs/studio.md +3 -1
  45. package/package.json +2 -2
@@ -19,7 +19,7 @@ export function repairPrompt(job: Job): string {
19
19
  if (job.unplayable?.length) {
20
20
  const places = job.unplayable.map((u) => `- ${u.place}: ${u.errors.join('; ') || 'see place_status'}`).join('\n');
21
21
  return `[Studio] The build "${job.label}" ended with places that are not playable, and its free fixes did not help:\n${places}\n`
22
- + 'Repair them as your brief says: place_status and place_view first, then the playbook\'s fix tools in order, at most three fixes a place, '
22
+ + 'Repair them as your brief says: place_status and place_view first, then the playbook\'s fix tools in order, after three failed fixes of a place take a different path rather than repeating one, '
23
23
  + 'and ask me before the last resort. Then tell me in two sentences what you fixed and what is left for me.' + animation;
24
24
  }
25
25
  const tail = job.tail.slice(-15).map((l) => ` ${l}`).join('\n');
@@ -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/living.ts CHANGED
@@ -402,3 +402,39 @@ export class Clouds {
402
402
  ctx.drawImage(canvas, this.box[0] + sx, this.box[1] + sy);
403
403
  }
404
404
  }
405
+
406
+ /**
407
+ * Small things painted falling (code rain, snow, petals): the glyphs cut out of the backdrop fall in columns,
408
+ * each column at its own pace, wrapping round inside their painted region (a soft mask) so they keep falling.
409
+ */
410
+ export class Stream {
411
+ private readonly canvas: HTMLCanvasElement;
412
+ /** Column width, px: about one glyph, so a letter falls whole. */
413
+ private static readonly COLUMN = 22;
414
+
415
+ constructor(readonly img: HTMLImageElement, readonly region: HTMLImageElement, readonly box: Box, readonly k: number) {
416
+ this.canvas = canvasOf(box[2] - box[0], box[3] - box[1]);
417
+ }
418
+
419
+ draw(ctx: CanvasRenderingContext2D, t: number, shift: Shift) {
420
+ const { img, region, canvas } = this;
421
+ const [w, h] = [canvas.width, canvas.height];
422
+ const c = canvas.getContext('2d')!;
423
+ c.globalCompositeOperation = 'source-over';
424
+ c.clearRect(0, 0, w, h);
425
+ const cw = Stream.COLUMN;
426
+ for (let x = 0, i = 0; x < w; x += cw, i++) {
427
+ const speed = 28 + 46 * rand(i, 31 + this.k);
428
+ const y = (t * speed + rand(i, 32 + this.k) * h) % h;
429
+ const sw = Math.min(cw, w - x);
430
+ c.drawImage(img, x, 0, sw, h, x, y, sw, h);
431
+ c.drawImage(img, x, 0, sw, h, x, y - h, sw, h);
432
+ }
433
+ // Only inside the painted rain, fading at its edges.
434
+ c.globalCompositeOperation = 'destination-in';
435
+ c.drawImage(region, 0, 0, w, h);
436
+ c.globalCompositeOperation = 'source-over';
437
+ const [sx, sy] = shift(this.box[3]);
438
+ ctx.drawImage(canvas, this.box[0] + sx, this.box[1] + sy);
439
+ }
440
+ }
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';
@@ -20,7 +21,7 @@ import { spokenLine, type SoundData } from '../src/play/sound.ts';
20
21
  import type { Figure3D, ModelInfo } from './hero3d.ts';
21
22
  import type { Weather } from '../src/play/weather.ts';
22
23
  import { WeatherFx } from './weather.ts';
23
- import { Clouds, Critter, drawSwell, Fall, Surf } from './living.ts';
24
+ import { Clouds, Critter, drawSwell, Fall, Stream, Surf } from './living.ts';
24
25
  import { stormOf } from '../src/play/living.ts';
25
26
  import type { Reactions } from '../src/play/reactions.ts';
26
27
  import { ReactionFx } from './reactions.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 = {
@@ -103,6 +105,8 @@ type Place = {
103
105
  surf?: Surf;
104
106
  /** Painted waterfalls pouring, painted animals swimming and flying, cloud bands drifting (see living.ts). */
105
107
  falls?: Fall[];
108
+ /** Small things painted falling (code rain, snow, petals), column by column (see living.ts). */
109
+ streams?: Stream[];
106
110
  critters?: Critter[];
107
111
  clouds?: Clouds;
108
112
  far?: { sky?: HTMLImageElement; flats: Array<{ img: HTMLImageElement; box: [number, number, number, number]; d: number }> };
@@ -492,12 +496,16 @@ class Game {
492
496
  }
493
497
 
494
498
  /** The waterfalls, animals and cloud bands a place's layers set moving. */
495
- private async living(layers: Layers): Promise<Pick<Place, 'falls' | 'critters' | 'clouds'>> {
499
+ private async living(layers: Layers): Promise<Pick<Place, 'falls' | 'streams' | 'critters' | 'clouds'>> {
496
500
  const img = (p: string) => loadImage(files(p)).catch(() => undefined);
497
501
  const falls = (await Promise.all((layers.falls ?? []).map(async (f) => {
498
502
  const i = await img(f.file);
499
503
  return i && new Fall(i, f.box);
500
504
  }))).filter((f): f is Fall => !!f);
505
+ const streams = (await Promise.all((layers.streams ?? []).map(async (f, k) => {
506
+ const [i, r] = await Promise.all([img(f.file), img(f.region)]);
507
+ return i && r && new Stream(i, r, f.box, k);
508
+ }))).filter((f): f is Stream => !!f);
501
509
  const critters = (await Promise.all((layers.critters ?? []).map(async (c, k) => {
502
510
  const i = await img(c.file);
503
511
  return i && new Critter(i, c.box, c.medium, c.facing, k);
@@ -508,7 +516,7 @@ class Game {
508
516
  const [clip, keep, ...bands] = await Promise.all([img(cl.clip), cl.keep ? img(cl.keep) : Promise.resolve(undefined), ...cl.bands.map((b) => img(b.file))]);
509
517
  if (clip && bands.every(Boolean)) clouds = new Clouds(cl.bands.map((b, i) => ({ img: bands[i]!, box: b.box, speed: b.speed })), clip, keep, cl.box);
510
518
  }
511
- return { falls, critters, clouds };
519
+ return { falls, streams, critters, clouds };
512
520
  }
513
521
 
514
522
  /** A place, loaded once: a second ask while it loads (the preload, an NPC's walk) waits on the same files. */
@@ -664,7 +672,10 @@ class Game {
664
672
  [this.hero.x, this.hero.y] = arrive ?? place.g.start;
665
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
666
674
  // where one stands at it, instead of popping up in the middle of the place.
667
- 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];
668
679
  if (edge) {
669
680
  [this.hero.x, this.hero.y] = edge;
670
681
  this.hero.path = this.place.grid.route(edge, arrive!);
@@ -838,12 +849,16 @@ class Game {
838
849
  try {
839
850
  const placeKey = this.place.key;
840
851
  const [sx, sy] = this.approach(hot);
841
- 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;
842
854
  if (this.place.key !== placeKey) return;
843
855
  const [x0, , x1] = hot.thing.box;
844
- this.hero.facing = (hot.npc?.body.x ?? (x0 + x1) / 2) >= this.hero.x ? 1 : -1;
845
- // Three-quarter towards it, the way a painted figure talks to someone.
846
- 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
+ }
847
862
  this.emit('use', { thing: key, verb });
848
863
  document.body.classList.add('played');
849
864
  await this.act(this.actionFor(key, hot, verb));
@@ -885,6 +900,8 @@ class Game {
885
900
  /** What a click on a thing means: the exit, the talk, the held item, or the first verb the story answers. */
886
901
  private actionFor(key: string, hot: Hot, verb?: Verb): Action {
887
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' };
888
905
  if (this.place.spec.exits.some((e) => e.via === key)) return { kind: 'go', via: key };
889
906
  const role = hot.spec?.role ?? hot.thing.role;
890
907
  // A person talks: in a conversation when the story has one for them now, else in their own talk lines.
@@ -955,7 +972,7 @@ class Game {
955
972
  if (nudge && this.hinted?.key === nudge.thing) this.hinted = { key: nudge.thing, until: this.time + 6 };
956
973
  if (after.place !== from) {
957
974
  // Out the same way: along the bridge or down the steps toward the frame's edge before the picture fades.
958
- 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;
959
976
  if (edge) await Promise.race([this.walkTo(...edge), this.wait(2500)]);
960
977
  this.emit('exit', { from, to: after.place });
961
978
  await this.fadeOut();
@@ -1274,13 +1291,39 @@ class Game {
1274
1291
  thingAt(x: number, y: number): Hot | undefined {
1275
1292
  const g = this.place.g;
1276
1293
  const gone = this.gone();
1294
+ const [sx0, sy0] = [x, y];
1277
1295
  for (let i = this.drawn.length - 1; i >= 0; i--) {
1278
1296
  const [x0, y0, x1, y1] = this.drawn[i].rect;
1279
1297
  // The rect includes the shadow; trim it and the transparent margin at the sides.
1280
1298
  const inset = (x1 - x0) * 0.12;
1281
- if (x >= x0 + inset && x <= x1 - inset && y >= y0 && y <= y1 - 4) return this.drawn[i].hot;
1299
+ if (!(x >= x0 + inset && x <= x1 - inset && y >= y0 && y <= y1 - 4)) continue;
1300
+ // A person's rect is wider than the person: where nobody was drawn over a painted thing (a potion at a
1301
+ // scientist's feet), the thing is what is under the pointer.
1302
+ const under = this.drawnOver(sx0, sy0) === false ? this.paintedAt(sx0, sy0, gone) : undefined;
1303
+ return under?.mask ? under : this.drawn[i].hot;
1282
1304
  }
1283
- const [sx0, sy0] = [x, y];
1305
+ return this.paintedAt(sx0, sy0, gone);
1306
+ }
1307
+
1308
+ /**
1309
+ * Whether anyone was drawn over the frame point in the last frame: the canvas there differs from the place as
1310
+ * drawn without anyone (the depth snapshot). Undefined without a snapshot.
1311
+ */
1312
+ private drawnOver(x: number, y: number): boolean | undefined {
1313
+ if (!this.snapped || !this.snap) return undefined;
1314
+ const { width: W, height: H } = this.place.g.frame;
1315
+ const z = this.zoom();
1316
+ const [cx, cy] = [Math.floor(((x - (this.viewX || W / 2)) * z + W / 2) * (canvas.width / W)), Math.floor(((y - (this.viewY || H / 2)) * z + H / 2) * (canvas.height / H))];
1317
+ if (cx < 0 || cy < 0 || cx >= canvas.width || cy >= canvas.height) return undefined;
1318
+ const a = ctx.getImageData(cx, cy, 1, 1).data;
1319
+ const b = this.snap.getContext('2d')!.getImageData(cx, cy, 1, 1).data;
1320
+ return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) > 24;
1321
+ }
1322
+
1323
+ /** The painted thing (not a person) under a frame point, by its own mask where it has one. */
1324
+ private paintedAt(sx0: number, sy0: number, gone: Set<string>): Hot | undefined {
1325
+ const g = this.place.g;
1326
+ let [x, y] = [sx0, sy0];
1284
1327
  for (const hot of this.hots()) {
1285
1328
  if (this.drawn.some((d) => d.hot === hot)) continue;
1286
1329
  if (gone.has(hot.thing.key)) continue;
@@ -1444,6 +1487,12 @@ class Game {
1444
1487
  return this.shift(y);
1445
1488
  }
1446
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
+
1447
1496
  /** Draws the card's columns [xa, xb) (frame x) in strips, each slid by the floor under it. */
1448
1497
  private drawCard(cut: Cut, xa = cut.x0, xb = cut.x0 + cut.canvas.width, ya = cut.y0, yb = cut.y0 + cut.canvas.height) {
1449
1498
  ctx.save();
@@ -1459,6 +1508,17 @@ class Game {
1459
1508
  * card (its outline, its story change) goes through here, so it moves exactly as the card does.
1460
1509
  */
1461
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
+ }
1462
1522
  const float = this.afloat(cut);
1463
1523
  if (float && !params.has('still')) {
1464
1524
  const t = this.time + (hashSeed(cut.occ.key) % 1000) / 160;
@@ -1907,7 +1967,9 @@ class Game {
1907
1967
  const gone = this.gone();
1908
1968
  this.recut();
1909
1969
  const npcs = this.hots().filter((h) => (h.spec?.role ?? h.thing.role) === 'npc' && !h.mask);
1910
- 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);
1911
1973
  const fire = this.fireOf(gone);
1912
1974
  this.climbs.length = 0;
1913
1975
  if (p && backdrop) {
@@ -1963,6 +2025,7 @@ class Game {
1963
2025
  this.place.clouds?.draw(ctx, this.time, this.place.weather?.w.wind ?? 0, (y) => shiftAt(g, p, y));
1964
2026
  this.drawWater(p);
1965
2027
  for (const f of this.place.falls ?? []) f.draw(ctx, this.time, (y) => shiftAt(g, p, y));
2028
+ for (const f of this.place.streams ?? []) f.draw(ctx, this.time, (y) => shiftAt(g, p, y));
1966
2029
  for (const c of this.place.critters ?? []) c.draw(ctx, this.time, (y) => shiftAt(g, p, y));
1967
2030
  this.drawFloats(p);
1968
2031
  this.drawSmoke(p);
@@ -2019,6 +2082,7 @@ class Game {
2019
2082
  this.drawClimbs();
2020
2083
  }
2021
2084
  this.place.glows?.draw(ctx, (y) => this.shift(y));
2085
+ if (this.place.spec.kind === 'drive') this.drawIdle();
2022
2086
  this.place.weather?.drawDrips(ctx, (y) => this.shift(y));
2023
2087
  this.drawFocus();
2024
2088
  // The place as drawn so far, without anyone: what the depth map puts back over a person (occludeDepth).
@@ -2659,6 +2723,108 @@ class Game {
2659
2723
  }
2660
2724
 
2661
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
+
2662
2828
  private climbs: Array<{ canvas: HTMLCanvasElement; box: [number, number, number, number]; dark: number; blur: string; shift: [number, number] }> = [];
2663
2829
  private climbPool: HTMLCanvasElement[] = [];
2664
2830
  private climbMask = document.createElement('canvas');
@@ -3065,7 +3231,9 @@ class Game {
3065
3231
  const y0 = Math.max(top, cut.y0);
3066
3232
  const y1 = Math.min(bottom, cut.y0 + cut.canvas.height);
3067
3233
  if (y1 <= y0) continue;
3068
- for (const [a, b] of hiddenRuns(cut.occ, left, right, feetY, support)) {
3234
+ // Where the feet are, as seen from the card: a figure's box need not be centred on them.
3235
+ const feet = (feetX ?? (rect[0] + rect[2]) / 2) - sx;
3236
+ for (const [a, b] of hiddenRuns(cut.occ, left, right, feetY, support, feet)) {
3069
3237
  const xa = Math.max(a, cut.x0);
3070
3238
  const xb = Math.min(b, cut.x0 + cut.canvas.width);
3071
3239
  if (xb > xa) this.drawCard(cut, xa, xb, y0, y1);
@@ -3089,9 +3257,12 @@ class Game {
3089
3257
  const width = Math.max(...lines.map((l) => ctx.measureText(l).width)) + 40;
3090
3258
  const height = lines.length * 28 + (name ? 30 : 22);
3091
3259
  const head = (left + right) / 2;
3092
- 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));
3093
3264
  const tail = 22;
3094
- const y = Math.max(name ? 24 : 10, top - height - tail - 6);
3265
+ const y = Math.max(visT + (name ? 24 : 10), top - height - tail - 6);
3095
3266
  const ink = '#2b2118';
3096
3267
  ctx.lineJoin = 'round';
3097
3268
  ctx.lineWidth = 2.5;
@@ -3369,10 +3540,11 @@ canvas.addEventListener('pointermove', (e) => {
3369
3540
  usePad(false);
3370
3541
  const r = canvas.getBoundingClientRect();
3371
3542
  pointer = { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height };
3372
- game.hover = game.thingAt(...toFrame(e));
3543
+ game.boardHover = game.destinationAt(...toFrame(e));
3544
+ game.hover = game.boardHover ? undefined : game.thingAt(...toFrame(e));
3373
3545
  // Off the floor (a wall, the sky, the water) the cursor says no, so nobody clicks blind.
3374
- const off = !game.hover && !game.place.grid.walkable(...game.floorAt(...toFrame(e)));
3375
- 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';
3376
3548
  game.bag.point(e.clientX, e.clientY, game.hover?.thing.key.replace(/-/g, ' '));
3377
3549
  document.getElementById('hover')!.textContent = game.hover ? game.hover.thing.key.replace(/-/g, ' ') : '';
3378
3550
  });
@@ -3404,6 +3576,8 @@ canvas.addEventListener('click', (e) => {
3404
3576
  if (e.detail >= 2 && game.hero.path.length) return void (game.hero.run = true);
3405
3577
  game.hero.run = false;
3406
3578
  const [x, y] = toFrame(e);
3579
+ const to = game.destinationAt(x, y);
3580
+ if (to) return void game.driveTo(to);
3407
3581
  const hot = game.thingAt(x, y);
3408
3582
  if (hot) void game.use(hot.thing.key);
3409
3583
  else void game.walkTo(...game.floorAt(x, y));
@@ -0,0 +1,22 @@
1
+ # Releasing
2
+
3
+ `CHANGELOG.md` decides every npm release of `@gtanczyk/diorama`; nobody runs `npm version` by hand.
4
+
5
+ 1. With a change to shipped code (`app/bin`, `app/src`, `app/studio`, `app/web`), add one line under
6
+ `## Unreleased`, written for someone updating Diorama:
7
+ - `### Breaking`: a command, flag or world file layout changed incompatibly (minor before 1.0, major after)
8
+ - `### Added`: something new you can do (minor)
9
+ - `### Fixed`: something that was wrong now works (patch)
10
+ - `### Internal`: refactors, tests, tooling; never releases
11
+ 2. Push to main. `release-pr.yaml` checks the changelog, cuts the next version on branch `release/next`
12
+ and opens (or refreshes) the PR **`release: vX.Y.Z`**. Later pushes with more lines update the same PR.
13
+ 3. Merge the PR. `publish.yaml` sees `package.json` change, publishes to npm (trusted publishing, no
14
+ token) and creates the `vX.Y.Z` tag and a GitHub Release with that section as notes.
15
+
16
+ Hold a release by leaving the PR open. Set the repository variable `RELEASE_AUTOMERGE=true` to merge it
17
+ by itself, and the secret `RELEASE_TOKEN` (a PAT) if CI must run on the release PR. A version npm already holds is stepped past by patch and never published twice; Actions → Publish to npm → Run workflow
18
+ publishes the version in `package.json` by hand.
19
+
20
+ Locally: `node app/scripts/release.mjs next` (the next version, or none), `notes 0.2.0`, `check`
21
+ (changelog well formed and agreeing with package.json; `--files a,b` also asks for a changelog line
22
+ when those files ship).