@gtanczyk/diorama 0.1.0 → 0.3.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 (42) hide show
  1. package/README.md +1 -0
  2. package/app/src/actions/budget.ts +11 -3
  3. package/app/src/actions/cast.ts +19 -0
  4. package/app/src/actions/fix.ts +28 -10
  5. package/app/src/actions/index.ts +1 -0
  6. package/app/src/actions/language.ts +52 -0
  7. package/app/src/actions/world.ts +4 -3
  8. package/app/src/commands/backdrop.ts +23 -3
  9. package/app/src/commands/cast.ts +32 -13
  10. package/app/src/commands/export.ts +17 -0
  11. package/app/src/commands/index.ts +1 -0
  12. package/app/src/commands/language.ts +87 -0
  13. package/app/src/commands/play.ts +5 -1
  14. package/app/src/commands/sound.ts +16 -6
  15. package/app/src/meta/memo.ts +14 -0
  16. package/app/src/meta/voice.ts +2 -2
  17. package/app/src/play/geometry.ts +12 -2
  18. package/app/src/play/parallax.ts +2 -0
  19. package/app/src/scene/cast-fixes.ts +5 -3
  20. package/app/src/scene/cast.ts +16 -9
  21. package/app/src/scene/derive.ts +46 -1
  22. package/app/src/scene/held.ts +62 -8
  23. package/app/src/scene/layers.ts +52 -0
  24. package/app/src/scene/rig.ts +6 -2
  25. package/app/src/scene/shadows.ts +10 -2
  26. package/app/src/scene/verify.ts +12 -0
  27. package/app/src/scene/warp.ts +5 -3
  28. package/app/src/setup/checks.ts +2 -2
  29. package/app/src/speech/transcribe.ts +2 -2
  30. package/app/src/studio/agent.ts +10 -8
  31. package/app/src/studio/repair.ts +1 -1
  32. package/app/src/studio/server.ts +4 -2
  33. package/app/src/world/language.ts +116 -0
  34. package/app/studio/build.tsx +5 -3
  35. package/app/studio/main.tsx +3 -2
  36. package/app/web/living.ts +36 -0
  37. package/app/web/main.ts +83 -30
  38. package/docs/characters.md +1 -1
  39. package/docs/releasing.md +22 -0
  40. package/docs/scenes.md +1 -1
  41. package/docs/setup.md +1 -1
  42. package/package.json +3 -3
@@ -0,0 +1,116 @@
1
+ // A world told in another language: every word the player reads or hears (title, pitch, names, the whole story)
2
+ // translated in one Spark pass, so names stay the same everywhere they appear. What the painters read (style,
3
+ // shows, mood, looks, a change's looks) is English already and stays; keys and flags never change, so the
4
+ // puzzles, masks, paintings and sounds all still fit. The voices are cast again for the new language afterwards.
5
+ import fs from 'node:fs';
6
+ import { z } from 'zod';
7
+ import { askSparkJson } from '../meta/spark.ts';
8
+ import type { Memo } from '../meta/memo.ts';
9
+ import { soundQuality } from '../sound/cost.ts';
10
+ import { languageKey } from '../sound/language.ts';
11
+ import { needsDesign, storyLines, Voices } from '../sound/voices.ts';
12
+ import type { Story } from '../story/schema.ts';
13
+ import type { WorldFiles } from './files.ts';
14
+ import type { World } from './schema.ts';
15
+
16
+ /** Story fields the player reads or hears. A change's `looks` is for the painter: it stays English. */
17
+ const STORY_TEXT = new Set(['intro', 'say', 'says', 'text', 'title', 'name', 'nope']);
18
+
19
+ /** Every player-facing text of a world and its story, by an id that says where it lives. */
20
+ export function worldTexts(world: World, story: Story | null): Record<string, string> {
21
+ const out: Record<string, string> = { 'world.title': world.title, 'world.pitch': world.pitch };
22
+ for (const [key, name] of Object.entries(world.names ?? {})) out[`names.${key}`] = name;
23
+ const walk = (node: unknown, path: string[]) => {
24
+ if (Array.isArray(node)) node.forEach((n, i) => walk(n, [...path, String(i)]));
25
+ else if (node && typeof node === 'object') {
26
+ for (const [k, v] of Object.entries(node)) {
27
+ if (typeof v === 'string') {
28
+ if (STORY_TEXT.has(k) && !(path[0] === 'changes')) out[`story.${[...path, k].join('.')}`] = v;
29
+ } else walk(v, [...path, k]);
30
+ }
31
+ }
32
+ };
33
+ if (story) walk(story, []);
34
+ return out;
35
+ }
36
+
37
+ /** The world and story with the translated texts put back at their ids. */
38
+ export function applyWorldTexts(world: World, story: Story | null, texts: Record<string, string>, language: string): { world: World; story: Story | null } {
39
+ const w: World = structuredClone(world);
40
+ const s: Story | null = story && structuredClone(story);
41
+ for (const [id, text] of Object.entries(texts)) {
42
+ const [head, ...rest] = id.split('.');
43
+ if (head === 'world') (w as Record<string, unknown>)[rest[0]] = text;
44
+ else if (head === 'names') w.names = { ...w.names, [rest.join('.')]: text };
45
+ else if (head === 'story' && s) {
46
+ let at: unknown = s;
47
+ const last = rest.pop()!;
48
+ for (const p of rest) at = Array.isArray(at) ? at[Number(p)] : (at as Record<string, unknown>)[p];
49
+ if (at && typeof at === 'object') (at as Record<string, unknown>)[last] = text;
50
+ }
51
+ }
52
+ if (languageKey(language) === 'en') delete w.language;
53
+ else w.language = language;
54
+ return { world: w, story: s };
55
+ }
56
+
57
+ const Answer = z.object({ texts: z.record(z.string(), z.string()) });
58
+
59
+ /**
60
+ * One Spark pass over all the texts, so a name reads the same in the title, the dialogue and the inventory.
61
+ * Ids missing from the answer are asked for once more, with the names already chosen; an empty answer throws.
62
+ */
63
+ export async function translateTexts(memo: Memo, texts: Record<string, string>, from: string, to: string, kids: boolean): Promise<Record<string, string>> {
64
+ const ask = (todo: Record<string, string>, names: Record<string, string>) => askSparkJson(memo, {
65
+ label: `language:${languageKey(to)}`,
66
+ schema: Answer,
67
+ prompt: `Translate the player-facing text of a point-and-click adventure game from ${from} to ${to}.
68
+ Write it as a native ${to} game writer would${kids ? ' for children aged 5 to 7, who hear every line read aloud' : ''}: natural, idiomatic and warm, not word for word. Keep each line's meaning, mood and humour, and keep it about as short (never longer by more than a few words).
69
+ - Names of characters, places and items ("names.*", items' "name"): give each a natural ${to} name that keeps the idea or pun of the original (a cat named after butter keeps a buttery name). Use exactly the same name every time it appears in any line, with the same capitalisation style as the original (a lowercase item name stays lowercase).
70
+ - A line that is a statement stays a statement; a question stays a question. Keep sound tags in square brackets ([laughs], [whispers]) exactly as they are, untranslated.
71
+ - Numbers, riddles and wordplay must still work in ${to}: adapt them, keep the answer the same.
72
+ - Every text must be fully in ${to}: no ${from} words left, except a name that is the same in both languages.
73
+ ${Object.keys(names).length ? `Names already chosen (use them): ${JSON.stringify(names)}\n` : ''}Texts (id: text): ${JSON.stringify(todo)}
74
+ Reply with JSON only: {"texts": {"<the same id>": "<${to} text>", ...}} with every id above exactly once.`,
75
+ });
76
+ const first = (await ask(texts, {})).texts;
77
+ const done = Object.fromEntries(Object.keys(texts).filter((id) => first[id]?.trim()).map((id) => [id, first[id].trim()]));
78
+ const missing = Object.fromEntries(Object.entries(texts).filter(([id]) => !done[id]));
79
+ if (Object.keys(missing).length) {
80
+ const names = Object.fromEntries(Object.entries(done).filter(([id]) => id.startsWith('names.') || /\.name$/.test(id)));
81
+ const again = (await ask(missing, names)).texts;
82
+ for (const id of Object.keys(missing)) if (again[id]?.trim()) done[id] = again[id].trim();
83
+ }
84
+ const still = Object.keys(texts).filter((id) => !done[id]);
85
+ if (still.length) throw new Error(`Spark left ${still.length} text(s) untranslated: ${still.slice(0, 5).join(', ')}`);
86
+ return done;
87
+ }
88
+
89
+ /** A person's voice wish written for the old language ("Native Polish speaker…") says the new one instead. */
90
+ export function relanguage(text: string, from: string, to: string): string {
91
+ return text.replace(new RegExp(`\\b${from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi'), to);
92
+ }
93
+
94
+ export type LanguagePlan = { from: string; to: string; same: boolean; texts: number; lines: number; characters: number; credits: number; designs: string[]; quality: 'draft' | 'final' };
95
+
96
+ /**
97
+ * What a change of language redoes: one Spark pass over the texts, then every spoken line read again (about one
98
+ * ElevenLabs credit a character in final quality, half in draft) and a Voice Design for each child, teen or old speaker.
99
+ * The lines are counted in the old language: a translation is about as long.
100
+ */
101
+ export function languagePlan(files: WorldFiles, world: World, story: Story | null, language: string): LanguagePlan {
102
+ const from = world.language ?? 'English';
103
+ const same = languageKey(from) === languageKey(language);
104
+ const kids = world.audience === 'kids';
105
+ const lines = story ? storyLines(world, story, kids) : [];
106
+ const characters = lines.reduce((n, l) => n + l.text.length, 0);
107
+ const quality = soundQuality();
108
+ const voices = fs.existsSync(files.path('voices.json')) ? Voices.safeParse(files.readJson(files.path('voices.json'))) : undefined;
109
+ const designs = voices?.success ? Object.entries(voices.data.cast).filter(([, c]) => c.profile && needsDesign(c.profile)).map(([k]) => k) : [];
110
+ return { from, to: language, same, texts: Object.keys(worldTexts(world, story)).length, lines: lines.length, characters, credits: Math.round(characters * (quality === 'final' ? 1 : 0.5)), designs, quality };
111
+ }
112
+
113
+ export const planLine = (p: LanguagePlan) => (p.same
114
+ ? `already in ${p.to}: the texts stay; the voices are read again only where they are not in ${p.to} yet`
115
+ : `${p.texts} texts translated from ${p.from} to ${p.to} in one Spark pass (cents); then ${p.lines} spoken lines read again, about ${p.characters} characters, ~${p.credits} ElevenLabs credits (${p.quality})`
116
+ + `${p.designs.length ? `, plus a new designed voice for ${p.designs.join(', ')}` : ''}; paintings, masks, sounds and puzzles stay`);
@@ -34,11 +34,12 @@ export const PHASES = [
34
34
 
35
35
  /** memo/calls.jsonl's model names as people know them. */
36
36
  export const MODEL_NAME: Record<string, string> = { spark: 'Muse Spark', muse: 'Muse Image', sam: 'SAM 3.1', sound: 'ElevenLabs', music: 'ElevenLabs', voice: 'ElevenLabs', space: '3D' };
37
- const META = new Set(['Muse Spark', 'Muse Image', 'SAM 3.1']);
37
+ const META = new Set(['Muse Spark', 'Muse Image', 'SAM 3.1', 'Muse Voice']);
38
38
  const tone = (by: string) => ({ 'Muse Spark': 'spark', 'Muse Image': 'image', 'SAM 3.1': 'sam' })[by] ?? 'other';
39
39
 
40
40
  /** A model another than Meta's, by a short name: what a call's `via` or Setup's pick names. */
41
41
  export function viaName(via: string): string {
42
+ if (via === 'muse-voice') return 'Muse Voice';
42
43
  if (/claude/i.test(via)) return 'Claude';
43
44
  if (/banana/i.test(via)) return 'Nano Banana';
44
45
  if (/gemini|google/i.test(via)) return 'Gemini';
@@ -218,9 +219,10 @@ export function Pipeline({ job, all, live, build, typical, total, onOpen }: { jo
218
219
  <section class={`pipeline${job ? ' on' : ''}`} aria-live="polite">
219
220
  <div class="row head">
220
221
  <strong>{job ? `${job.by === 'agent' ? 'Agent: ' : ''}${job.label}` : 'Build'}</strong>
221
- {/* This job's calls, each with the world's total beside it: a new job starts at 0, the world keeps counting. */}
222
+ {/* This job's calls, with the world's total beside it only when earlier jobs added to it: "136 of 136" read as
223
+ a progress bar that was always full, while it only said this job made every call the world has. */}
222
224
  {job && calls.length > 0 && <span class="detail calls-kicker" title="Model calls this job made. The number after 'of' is the whole world so far, every build and agent job together">this job</span>}
223
- {job && calls.map(([by, n]) => <span class={`count ${tone(by)}${fresh && live.lastCall?.by === by ? ' hot' : ''}`} title={`${nowBy(by)}: ${n} call${n === 1 ? '' : 's'} in this job${total?.[by] ? `, ${Math.max(n, total[by])} for this world so far` : ''}`}><ModelTag by={by} /> {n}{total?.[by] ? <span class="of"> of {Math.max(n, total[by])}</span> : null}</span>)}
225
+ {job && calls.map(([by, n]) => <span class={`count ${tone(by)}${fresh && live.lastCall?.by === by ? ' hot' : ''}`} title={`${nowBy(by)}: ${n} call${n === 1 ? '' : 's'} sent in this job (not a target: it grows with every take and check)${(total?.[by] ?? 0) > n ? `, ${total![by]} for this world so far` : ''}`}><ModelTag by={by} /> {n}{(total?.[by] ?? 0) > n ? <span class="of"> · {total![by]} in all</span> : null}</span>)}
224
226
  {/* A step's raw line is cut to what it is doing ("barn-interior layout try 1"); the whole line is on hover. */}
225
227
  <span class="detail tail" title={job ? live.tail : undefined}>{job ? (fresh && live.lastCall ? `${nowBy(live.lastCall.by)}: ${live.lastCall.label}` : live.tail.split(/: |[.!?]\s/)[0].slice(0, 60)) : ''}</span>
226
228
  {when && <span class="detail when">{when}</span>}
@@ -1,7 +1,7 @@
1
1
  // Diorama Studio: Start (worlds and a new idea), Setup (what this machine needs) and the Workshop (a world
2
2
  // building live). Everything here goes through actions, the same ones `diorama action` and the agent use.
3
3
  import { render, type ComponentChildren } from 'preact';
4
- import { useEffect, useRef, useState } from 'preact/hooks';
4
+ import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
5
5
  import type { ChatNotice, SessionTurn } from 'genaicode/react';
6
6
  import { agentDefault, askAgent, call, type AgentDefaults, fileUrl, follow, listActions, openSession, token, type StudioEvent } from './api.ts';
7
7
  import { AgentPanel } from './agent.tsx';
@@ -592,7 +592,8 @@ function Workshop({ slug }: { slug: string }) {
592
592
  const creatorBrief = useCreatorBrief(slug);
593
593
  const worldRef = useRef<HTMLElement>(null);
594
594
  const stageRef = useRef<HTMLElement>(null);
595
- useEffect(() => {
595
+ // Layout effect: Esc must close the drawer from the moment it shows, not only after the next frame.
596
+ useLayoutEffect(() => {
596
597
  if (!byHand) return;
597
598
  const esc = (e: KeyboardEvent) => e.key === 'Escape' && setByHand(null);
598
599
  addEventListener('keydown', esc);
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
@@ -20,7 +20,7 @@ import { spokenLine, type SoundData } from '../src/play/sound.ts';
20
20
  import type { Figure3D, ModelInfo } from './hero3d.ts';
21
21
  import type { Weather } from '../src/play/weather.ts';
22
22
  import { WeatherFx } from './weather.ts';
23
- import { Clouds, Critter, drawSwell, Fall, Surf } from './living.ts';
23
+ import { Clouds, Critter, drawSwell, Fall, Stream, Surf } from './living.ts';
24
24
  import { stormOf } from '../src/play/living.ts';
25
25
  import type { Reactions } from '../src/play/reactions.ts';
26
26
  import { ReactionFx } from './reactions.ts';
@@ -35,7 +35,7 @@ type Data = {
35
35
  world: World;
36
36
  places: Record<string, { geometry: Geometry | null; painting: string | null; backdrop?: string; layers?: Layers; weather?: Weather; reactions?: Reactions; glows?: Glow[]; issue?: string }>;
37
37
  /** Figure per hero or NPC key: a cut-out image, and its rig when `diorama cast` could cut it into parts. */
38
- cast: Record<string, { image: string; height?: number; upright?: boolean; face?: { dir: string; info: FaceDefinition }; rig?: Rig; parts?: string; model?: { dir: string; info: ModelInfo }; sprites?: { dir: string; info: SpriteInfo } }>;
38
+ cast: Record<string, { image: string; height?: number; upright?: boolean; share?: number; face?: { dir: string; info: FaceDefinition }; rig?: Rig; parts?: string; model?: { dir: string; info: ModelInfo }; sprites?: { dir: string; info: SpriteInfo } }>;
39
39
  story: Story | null;
40
40
  /** Index into story.changes → the painting as it looks once that change holds. */
41
41
  changes?: Record<string, string>;
@@ -70,7 +70,7 @@ export type SpriteInfo = {
70
70
  /** walk: the cells a walk steps through, without the ones the sprites step found off the gait (a crouch, a sit). */
71
71
  type Sheet = { file: string; yaw: number; stand: number; img: HTMLImageElement; gestures: string[]; walk: number[] };
72
72
  /** height: metres standing (a grown-up 1.62, a cat 0.3), so each is drawn at its own size; upright false: an animal, drawn as painted. */
73
- type Figure = { img: HTMLImageElement; face?: PaintedFace; faceInfo?: FaceDefinition; height?: number; upright?: boolean; rig?: { rig: Rig; parts: Map<PartName, HTMLImageElement>; sheet?: HTMLImageElement; canvas: HTMLCanvasElement }; model?: Figure3D; sprites?: { info: SpriteInfo; sheets: Sheet[]; talk: Sheet[] } };
73
+ type Figure = { img: HTMLImageElement; face?: PaintedFace; faceInfo?: FaceDefinition; height?: number; upright?: boolean; share?: number; rig?: { rig: Rig; parts: Map<PartName, HTMLImageElement>; sheet?: HTMLImageElement; canvas: HTMLCanvasElement }; model?: Figure3D; sprites?: { info: SpriteInfo; sheets: Sheet[]; talk: Sheet[] } };
74
74
  /** y: the floor row the cut stands on, which sets how far it slides with parallax. */
75
75
  /** Things that float and ride the swell, by their names. */
76
76
  const FLOATS = /\b(boat|rowboat|dinghy|skiff|canoe|kayak|raft|ship|barge|punt|gondola|sampan|junk|ferry|trawler|yacht|sailboat)s?\b/i;
@@ -103,6 +103,8 @@ type Place = {
103
103
  surf?: Surf;
104
104
  /** Painted waterfalls pouring, painted animals swimming and flying, cloud bands drifting (see living.ts). */
105
105
  falls?: Fall[];
106
+ /** Small things painted falling (code rain, snow, petals), column by column (see living.ts). */
107
+ streams?: Stream[];
106
108
  critters?: Critter[];
107
109
  clouds?: Clouds;
108
110
  far?: { sky?: HTMLImageElement; flats: Array<{ img: HTMLImageElement; box: [number, number, number, number]; d: number }> };
@@ -386,7 +388,7 @@ class Game {
386
388
  await Promise.all(Object.entries(this.data.cast).map(async ([key, cast]) => {
387
389
  const img = await loadImage(files(cast.image)).catch(() => undefined);
388
390
  if (!img) return;
389
- const figure: Figure = { img, height: cast.height, upright: cast.upright };
391
+ const figure: Figure = { img, height: cast.height, upright: cast.upright, share: cast.share };
390
392
  if (cast.rig && cast.parts) {
391
393
  const parts = new Map<PartName, HTMLImageElement>();
392
394
  await Promise.all(cast.rig.parts.map(async (p) => parts.set(p.name, await loadImage(files(`${cast.parts}/${p.file}`)))));
@@ -492,12 +494,16 @@ class Game {
492
494
  }
493
495
 
494
496
  /** The waterfalls, animals and cloud bands a place's layers set moving. */
495
- private async living(layers: Layers): Promise<Pick<Place, 'falls' | 'critters' | 'clouds'>> {
497
+ private async living(layers: Layers): Promise<Pick<Place, 'falls' | 'streams' | 'critters' | 'clouds'>> {
496
498
  const img = (p: string) => loadImage(files(p)).catch(() => undefined);
497
499
  const falls = (await Promise.all((layers.falls ?? []).map(async (f) => {
498
500
  const i = await img(f.file);
499
501
  return i && new Fall(i, f.box);
500
502
  }))).filter((f): f is Fall => !!f);
503
+ const streams = (await Promise.all((layers.streams ?? []).map(async (f, k) => {
504
+ const [i, r] = await Promise.all([img(f.file), img(f.region)]);
505
+ return i && r && new Stream(i, r, f.box, k);
506
+ }))).filter((f): f is Stream => !!f);
501
507
  const critters = (await Promise.all((layers.critters ?? []).map(async (c, k) => {
502
508
  const i = await img(c.file);
503
509
  return i && new Critter(i, c.box, c.medium, c.facing, k);
@@ -508,7 +514,7 @@ class Game {
508
514
  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
515
  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
516
  }
511
- return { falls, critters, clouds };
517
+ return { falls, streams, critters, clouds };
512
518
  }
513
519
 
514
520
  /** A place, loaded once: a second ask while it loads (the preload, an NPC's walk) waits on the same files. */
@@ -1274,13 +1280,39 @@ class Game {
1274
1280
  thingAt(x: number, y: number): Hot | undefined {
1275
1281
  const g = this.place.g;
1276
1282
  const gone = this.gone();
1283
+ const [sx0, sy0] = [x, y];
1277
1284
  for (let i = this.drawn.length - 1; i >= 0; i--) {
1278
1285
  const [x0, y0, x1, y1] = this.drawn[i].rect;
1279
1286
  // The rect includes the shadow; trim it and the transparent margin at the sides.
1280
1287
  const inset = (x1 - x0) * 0.12;
1281
- if (x >= x0 + inset && x <= x1 - inset && y >= y0 && y <= y1 - 4) return this.drawn[i].hot;
1288
+ if (!(x >= x0 + inset && x <= x1 - inset && y >= y0 && y <= y1 - 4)) continue;
1289
+ // A person's rect is wider than the person: where nobody was drawn over a painted thing (a potion at a
1290
+ // scientist's feet), the thing is what is under the pointer.
1291
+ const under = this.drawnOver(sx0, sy0) === false ? this.paintedAt(sx0, sy0, gone) : undefined;
1292
+ return under?.mask ? under : this.drawn[i].hot;
1282
1293
  }
1283
- const [sx0, sy0] = [x, y];
1294
+ return this.paintedAt(sx0, sy0, gone);
1295
+ }
1296
+
1297
+ /**
1298
+ * Whether anyone was drawn over the frame point in the last frame: the canvas there differs from the place as
1299
+ * drawn without anyone (the depth snapshot). Undefined without a snapshot.
1300
+ */
1301
+ private drawnOver(x: number, y: number): boolean | undefined {
1302
+ if (!this.snapped || !this.snap) return undefined;
1303
+ const { width: W, height: H } = this.place.g.frame;
1304
+ const z = this.zoom();
1305
+ 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))];
1306
+ if (cx < 0 || cy < 0 || cx >= canvas.width || cy >= canvas.height) return undefined;
1307
+ const a = ctx.getImageData(cx, cy, 1, 1).data;
1308
+ const b = this.snap.getContext('2d')!.getImageData(cx, cy, 1, 1).data;
1309
+ return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) > 24;
1310
+ }
1311
+
1312
+ /** The painted thing (not a person) under a frame point, by its own mask where it has one. */
1313
+ private paintedAt(sx0: number, sy0: number, gone: Set<string>): Hot | undefined {
1314
+ const g = this.place.g;
1315
+ let [x, y] = [sx0, sy0];
1284
1316
  for (const hot of this.hots()) {
1285
1317
  if (this.drawn.some((d) => d.hot === hot)) continue;
1286
1318
  if (gone.has(hot.thing.key)) continue;
@@ -1963,6 +1995,7 @@ class Game {
1963
1995
  this.place.clouds?.draw(ctx, this.time, this.place.weather?.w.wind ?? 0, (y) => shiftAt(g, p, y));
1964
1996
  this.drawWater(p);
1965
1997
  for (const f of this.place.falls ?? []) f.draw(ctx, this.time, (y) => shiftAt(g, p, y));
1998
+ for (const f of this.place.streams ?? []) f.draw(ctx, this.time, (y) => shiftAt(g, p, y));
1966
1999
  for (const c of this.place.critters ?? []) c.draw(ctx, this.time, (y) => shiftAt(g, p, y));
1967
2000
  this.drawFloats(p);
1968
2001
  this.drawSmoke(p);
@@ -2057,15 +2090,19 @@ class Game {
2057
2090
  // ?people=0: the place alone, for a landscape take; everyone keeps playing, just unseen.
2058
2091
  if (nobody) continue;
2059
2092
  if (who.npc) {
2093
+ this.bodyTop = undefined;
2060
2094
  const drawn = this.drawWho(who, lit, 'body');
2095
+ const bodyTop = this.bodyTop;
2061
2096
  this.drawn.push({ hot: who.npc, rect: drawn });
2062
2097
  const body = who.npc.npc?.body;
2063
- this.occlude(drawn, who.y, gone, body?.x, body && (body.foot ?? stepFootY(g, body.x, who.y)));
2098
+ this.occlude(drawn, who.y, gone, body?.x, body && (body.foot ?? stepFootY(g, body.x, who.y)), bodyTop);
2064
2099
  } else {
2100
+ this.bodyTop = undefined;
2065
2101
  const drawn = this.drawWho(who, lit, 'body');
2102
+ const bodyTop = this.bodyTop;
2066
2103
  if (who.body === this.hero) rect = drawn;
2067
2104
  // The depth map goes by where the feet are drawn: on a stair's tread, not at the walker's row.
2068
- this.occlude(drawn, who.y, gone, who.body?.x, who.body && (who.body.foot ?? stepFootY(g, who.body.x, who.y)));
2105
+ this.occlude(drawn, who.y, gone, who.body?.x, who.body && (who.body.foot ?? stepFootY(g, who.body.x, who.y)), bodyTop);
2069
2106
  }
2070
2107
  }
2071
2108
  burn();
@@ -2136,14 +2173,14 @@ class Game {
2136
2173
  * than the feet. Painted figures and painted things are equally lit and equally sharp; this occlusion is
2137
2174
  * the cue that puts the figure in front. ctx is already shifted for parallax.
2138
2175
  */
2139
- private separate(x: number, y: number, h: number, paint: (c: CanvasRenderingContext2D) => number) {
2176
+ private separate(x: number, y: number, h: number, paint: (c: CanvasRenderingContext2D) => number, tall = h) {
2140
2177
  const { focus: d, g } = this.place;
2141
2178
  const feet = y - g.scale.horizonY;
2142
2179
  if (!d || separateScale <= 0 || feet < 4 || h < 8) return;
2143
2180
  const blur = h * SEPARATE_BLUR;
2144
2181
  const pad = Math.ceil(blur * 2.5 + h * SEPARATE_SHIFT);
2145
- const [bw, bh] = [Math.ceil(h * 1.2) + 2 * pad, Math.ceil(h * 1.15) + 2 * pad];
2146
- const [ox, oy] = [Math.round(bw / 2), Math.ceil(pad + h * 1.1)];
2182
+ const [bw, bh] = [Math.ceil(Math.max(h * 1.2, tall * 1.2)) + 2 * pad, Math.ceil(tall + h * 0.15) + 2 * pad];
2183
+ const [ox, oy] = [Math.round(bw / 2), Math.ceil(pad + tall + h * 0.1)];
2147
2184
  const c = this.sepCanvas;
2148
2185
  if (c.width < bw || c.height < bh) Object.assign(c, { width: Math.max(c.width, bw), height: Math.max(c.height, bh) });
2149
2186
  const cc = c.getContext('2d')!;
@@ -2399,6 +2436,10 @@ class Game {
2399
2436
  // A rig walks its legs; a plain cut-out (or the stand-in) only bobs and rocks as it slides.
2400
2437
  const bob = creature ? creature.lift * h : rig || model || sprites || !walking ? 0 : Math.abs(Math.sin(phase * Math.PI * 2)) * h * 0.025;
2401
2438
  const rock = creature ? creature.lean * (facing < 0 ? -1 : 1) : rig || model || sprites || !walking ? 0 : Math.sin(phase * Math.PI * 2) * 0.035;
2439
+ // How far above the feet the drawn figure reaches: the body's height, or more when it holds something over
2440
+ // its head (a walk sheet cut with it, or a cut-out's cut.json share). Scratch canvases and boxes are sized by it.
2441
+ const tall = rig ? Math.max(h, (h * rig.rig.ground) / rig.rig.height) : !model && !sprites && figure?.img && figure.share ? h / Math.max(0.2, Math.min(1, figure.share)) : h;
2442
+ this.bodyTop = y - h * 1.04;
2402
2443
  let rigCanvas: CanvasImageSource | undefined;
2403
2444
  if (rig) {
2404
2445
  const at = pose(rig.rig, phase, walking);
@@ -2446,10 +2487,12 @@ class Game {
2446
2487
  return Math.max(ax, rig.canvas.width - ax) * s;
2447
2488
  }
2448
2489
  const img = figure?.img;
2449
- const w = img ? (h * img.width) / img.height : h * 0.36;
2490
+ // A cut-out holding something over its head (cut.json share): the body keeps its height, the thing rises above.
2491
+ const ih = h / Math.max(0.2, Math.min(1, figure?.share ?? 1));
2492
+ const w = img ? (ih * img.width) / img.height : h * 0.36;
2450
2493
  // A creature squashes and stretches from the feet up, keeping its volume.
2451
2494
  const [sx2, sy2] = creature ? [1 / Math.sqrt(creature.stretch), creature.stretch] : [1, 1];
2452
- if (img) c.drawImage(figure?.face?.draw(img, '$image', 0, 1, 1, articulation, faceTime) ?? img, (-w / 2) * sx2, -h * sy2, w * sx2, h * sy2);
2495
+ if (img) c.drawImage(figure?.face?.draw(img, '$image', 0, 1, 1, articulation, faceTime) ?? img, (-w / 2) * sx2, -ih * sy2, w * sx2, ih * sy2);
2453
2496
  else drawFigure(c, h, colour);
2454
2497
  return w / 2;
2455
2498
  };
@@ -2467,7 +2510,7 @@ class Game {
2467
2510
  const breath = 0.5 + 0.5 * Math.sin(t * ((2 * Math.PI) / 3.6));
2468
2511
  const m = c.getTransform();
2469
2512
  const density = Math.max(1, Math.min(1.5, Math.hypot(m.a, m.b)));
2470
- return idleWarp(c, paintRaw, token, h, density, sway, k * 0.02 * breath, 0.009 * k * Math.sin(t * ((2 * Math.PI) / 4.1) + 0.7));
2513
+ return idleWarp(c, paintRaw, token, h, density, sway, k * 0.02 * breath, 0.009 * k * Math.sin(t * ((2 * Math.PI) / 4.1) + 0.7), tall);
2471
2514
  };
2472
2515
  // How much of the floor under the feet lies in a painted shadow: the person stands in it too.
2473
2516
  const shaded = Math.max(this.shadeAt(x, y, h), this.fireShadeAt(x, y, h));
@@ -2507,7 +2550,7 @@ class Game {
2507
2550
  const bright = this.lightAt(x, y - h * 0.5) * (1 - shaded * strength) * breath;
2508
2551
  // A party member is edged in its player's colour (twice, so the edge reads at a glance on a busy painting).
2509
2552
  const edge = glow ? ` drop-shadow(0 0 ${Math.max(2, h * 0.008).toFixed(1)}px ${glow}) drop-shadow(0 0 ${Math.max(2, h * 0.008).toFixed(1)}px ${glow})` : '';
2510
- this.separate(x, y, h, paintRaw);
2553
+ this.separate(x, y, h, paintRaw, tall);
2511
2554
  ctx.filter = `brightness(${bright.toFixed(2)})${lit ? ' drop-shadow(0 0 5px rgba(255,225,150,0.95))' : ''}${edge}`;
2512
2555
  const frameAt = ctx.getTransform();
2513
2556
  ctx.translate(x, y - bob);
@@ -2520,9 +2563,10 @@ class Game {
2520
2563
  ctx.setTransform(feetAt);
2521
2564
  this.rim(h, paint, bright * (1 - shaded), light);
2522
2565
  }
2523
- this.shadeOver(paint, frameAt, feetAt, [x - half - 4, y - h * 1.06 - bob - 4, x + half + 4, y + Math.max(3, h * 0.04) + 4]);
2566
+ this.shadeOver(paint, frameAt, feetAt, [x - half - 4, y - tall * 1.06 - bob - 4, x + half + 4, y + Math.max(3, h * 0.04) + 4]);
2524
2567
  ctx.restore();
2525
- return [x - half - 2 + sx, y - h * 1.04 - bob - 2 + sy, x + half + 2 + sx, y + Math.max(3, h * 0.04) + sy];
2568
+ this.bodyTop = y - h * 1.04 - bob - 2 + sy;
2569
+ return [x - half - 2 + sx, y - tall * 1.04 - bob - 2 + sy, x + half + 2 + sx, y + Math.max(3, h * 0.04) + sy];
2526
2570
  }
2527
2571
 
2528
2572
  /**
@@ -2933,16 +2977,18 @@ class Game {
2933
2977
  if (!light) return 1;
2934
2978
  const c = Math.min(light.cols - 1, Math.max(0, Math.floor(x / LIGHT_CELL)));
2935
2979
  const r = Math.min(light.rows - 1, Math.max(0, Math.floor(y / LIGHT_CELL)));
2936
- let sum = 0;
2937
- let n = 0;
2938
- for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) {
2980
+ // The light, not the colour of what lies there: a black tile, a dark table or a blue chair is dark paint in
2981
+ // bright light, so the brighter cells around (the upper quartile of a 5 x 5) say how lit the spot is.
2982
+ const around: number[] = [];
2983
+ for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) {
2939
2984
  const rr = r + dr;
2940
2985
  const cc = c + dc;
2941
2986
  if (rr < 0 || cc < 0 || rr >= light.rows || cc >= light.cols) continue;
2942
- sum += light.luma[rr * light.cols + cc];
2943
- n++;
2987
+ around.push(light.luma[rr * light.cols + cc]);
2944
2988
  }
2945
- return Math.min(1.1, Math.max(0.4, 0.35 + (0.65 * sum) / n / 150));
2989
+ around.sort((a, b) => a - b);
2990
+ const lit = around[Math.floor(around.length * 0.75)] ?? 150;
2991
+ return Math.min(1.1, Math.max(0.4, 0.35 + (0.65 * lit) / 150));
2946
2992
  }
2947
2993
 
2948
2994
  /** For the explorer: where the depth map puts the painting in front of feet at (x, feetY), over rect (src/qa/probe.ts). */
@@ -2955,6 +3001,8 @@ class Game {
2955
3001
  private snap?: HTMLCanvasElement;
2956
3002
  private snapped = false;
2957
3003
  private depthMask?: HTMLCanvasElement;
3004
+ /** The top of the body of the person drawn last (drawPerson), below anything held over their head. */
3005
+ private bodyTop?: number;
2958
3006
  private depthPatch?: HTMLCanvasElement;
2959
3007
 
2960
3008
  /**
@@ -3035,8 +3083,10 @@ class Game {
3035
3083
  }
3036
3084
 
3037
3085
  /** Puts the painting back over the hero, column by column, wherever an occluder stands in front of him. */
3038
- private occlude(rect: [number, number, number, number], feetY: number, gone: Set<string>, feetX?: number, drawnFeetY?: number) {
3039
- if (this.depthOcclusion) this.occludeDepth(rect, drawnFeetY ?? feetY, feetX);
3086
+ private occlude(rect: [number, number, number, number], feetY: number, gone: Set<string>, feetX?: number, drawnFeetY?: number, bodyTop?: number) {
3087
+ // The depth map hides only the body: what is held up over the head (a giant slice of toast) reaches where the
3088
+ // map holds the near ceiling and the wall's top, which never stand in front of someone on the floor.
3089
+ if (this.depthOcclusion) this.occludeDepth(bodyTop !== undefined && bodyTop > rect[1] ? [rect[0], bodyTop, rect[2], rect[3]] : rect, drawnFeetY ?? feetY, feetX);
3040
3090
  const support = (k: string) => this.place.byKey.get(k);
3041
3091
  for (const cut of this.place.cuts) {
3042
3092
  if (cut.occ.thing && gone.has(cut.occ.thing)) continue;
@@ -3048,7 +3098,9 @@ class Game {
3048
3098
  const y0 = Math.max(top, cut.y0);
3049
3099
  const y1 = Math.min(bottom, cut.y0 + cut.canvas.height);
3050
3100
  if (y1 <= y0) continue;
3051
- for (const [a, b] of hiddenRuns(cut.occ, left, right, feetY, support)) {
3101
+ // Where the feet are, as seen from the card: a figure's box need not be centred on them.
3102
+ const feet = (feetX ?? (rect[0] + rect[2]) / 2) - sx;
3103
+ for (const [a, b] of hiddenRuns(cut.occ, left, right, feetY, support, feet)) {
3052
3104
  const xa = Math.max(a, cut.x0);
3053
3105
  const xb = Math.min(b, cut.x0 + cut.canvas.width);
3054
3106
  if (xb > xa) this.drawCard(cut, xa, xb, y0, y1);
@@ -3680,8 +3732,9 @@ let idleOwner: { token: object; density: number; half: number } | undefined;
3680
3732
  * sway x height x u^1.7 (u = height above the feet as a share of the figure), so the feet stay planted and the head
3681
3733
  * moves most; the slices above the hips lift by up to `lift` x height (the breath), and the head nods by `nod`.
3682
3734
  */
3683
- function idleWarp(c: CanvasRenderingContext2D, paint: (c: CanvasRenderingContext2D) => number, token: object, h: number, density: number, sway: number, lift: number, nod: number): number {
3684
- const W = Math.ceil(h * 1.6 * density), H = Math.ceil(h * 1.35 * density);
3735
+ function idleWarp(c: CanvasRenderingContext2D, paint: (c: CanvasRenderingContext2D) => number, token: object, h: number, density: number, sway: number, lift: number, nod: number, tall = h): number {
3736
+ // Wide and high enough for what the figure holds over its head too (tall), else it is cut off flat.
3737
+ const W = Math.ceil(Math.max(h * 1.6, tall * 1.2) * density), H = Math.ceil((tall + h * 0.35) * density);
3685
3738
  const foot = Math.ceil(h * 0.15 * density);
3686
3739
  if (!(W >= 8 && H >= 8 && W <= 4096 && H <= 4096)) return paint(c);
3687
3740
  const scratch = (idleScratch ??= document.createElement('canvas'));
@@ -4,7 +4,7 @@
4
4
 
5
5
  `build` also makes the characters, after the places (steps `cast`, `model`, `sprites`; each also runs alone):
6
6
 
7
- - `diorama cast <slug>` paints the hero and every NPC (cast/<key>.png, with what they hold: a lantern or a net SAM's person leaves out stays on, gaps it leaves are filled, and what floats beside them as theirs, a heart or the bubble they are in, comes along) and makes their 2D walk: SAM finds arms, legs and boots to place the joints, then the whole painting is bent by those bones as one fine mesh, not cut into parts, so a coat, an apron or a skirt stretches with the legs and never tears (src/scene/warp.ts). The walk is drawn once into a sheet (cast/<key>/walk.png, 8 frames and the untouched painting for standing; joints in cast/<key>/rig.json) and measured: a walk that opens holes or loses a piece is marked in rig.json `check` and the game slides the painted figure as one piece instead. `diorama rig <slug> [key...]` (Studio agent: `cast_rewalk`) makes the walk again from the paintings already there, with no Muse calls. A drawing SAM does not see as a person is asked as a character; when it still finds none, `cast_status` says why in `rigIssue` and `moves` says how the game moves them instead. Walk sheets whose frames left the pose are listed in `spriteIssues`; `sprites_redo` paints new takes. Spark also says how tall each one stands and whether upright like a person (cast/figures.json, a grown-up 1.62 m): a cat or a child is drawn at its own size, and a creature that is not upright (an animal, a round eye on tiny feet, a living teapot) gets no 2D rig or sprites: it moves as one body, hopping as it goes, squashing as it lands, leaning the way it goes, breathing and blinking (src/play/creature.ts), as its painted figure or, when it is in the sprites plan, as its own 3D model.
7
+ - `diorama cast <slug>` paints the hero and every NPC (cast/<key>.png, with what they hold: a lantern or a net SAM's person leaves out stays on, gaps it leaves are filled, and what floats beside them as theirs, a heart or the bubble they are in, comes along) and makes their 2D walk: SAM finds arms, legs and boots to place the joints, then the whole painting is bent by those bones as one fine mesh, not cut into parts, so a coat, an apron or a skirt stretches with the legs and never tears (src/scene/warp.ts). The walk is drawn once into a sheet (cast/<key>/walk.png, 8 frames and the untouched painting for standing; joints in cast/<key>/rig.json) and measured: a walk that opens holes or loses a piece is marked in rig.json `check` and the game slides the painted figure as one piece instead. What the description says they hold or carry (one Spark call, only when the description says they hold something; the person's own list in cast/fixes.json `holds`, Studio agent: `cast_holds`) is found by SAM on its own, or as what stands out from the plain ground and touches them when SAM finds nothing, and kept on the cut-out and the walk however big it is: a slice of toast held over the head in both hands goes with the body and both arms carry it instead of swinging; cast/<key>/cut.json keeps the list and the share of the cut-out's height the body takes, so the game draws the body at its own height with the thing above it (src/scene/held.ts). `diorama rig <slug> [key...]` (Studio agent: `cast_rewalk`) makes the walk again from the paintings already there, with no Muse calls. A drawing SAM does not see as a person is asked as a character; when it still finds none, `cast_status` says why in `rigIssue` and `moves` says how the game moves them instead. Walk sheets whose frames left the pose are listed in `spriteIssues`; `sprites_redo` paints new takes. Spark also says how tall each one stands and whether upright like a person (cast/figures.json, a grown-up 1.62 m): a cat or a child is drawn at its own size, and a creature that is not upright (an animal, a round eye on tiny feet, a living teapot) gets no 2D rig or sprites: it moves as one body, hopping as it goes, squashing as it lands, leaning the way it goes, breathing and blinking (src/play/creature.ts), as its painted figure or, when it is in the sprites plan, as its own 3D model.
8
8
  - `diorama model <slug>` gives each character who needs sprites a rigged 3D model: Muse paints them from four sides, TRELLIS turns the views into a mesh and Make-It-Animatable adds a skeleton (both open Hugging Face Spaces; set `HUGGING_FACE_API_KEY` or `HF_TOKEN`, as they run on your account's GPU quota). The model only supplies poses. A creature (not upright) is painted from four sides as it is (no A-pose, no hands) and keeps TRELLIS's mesh with no skeleton (`model.json` `creature: true`): the game hops, squashes, leans and blinks the whole model, so a creature hero or NPC needs no sheets. Studio's Cast **Animate in 3D** (action `cast_animate`) builds a model for anyone the build skips, and the model step keeps building it. The figure is stood up straight in the file, and the poses start from the shape the skin was bound in (the reconstructed figure), not from whatever pose the rig saved its bones in (Make-It-Animatable's T-pose can sit a figure in a long coat down). What the character wears (a satchel, a backpack) is read off the front view and named in every other view's prompt with the side it shows on; Spark then checks all four together, and a view with it on the wrong side, or with anything standing on the ground at the feet, is painted again (up to 2 rounds; a wrong front repaints all four once). Things held in the hands are left out of the views: a suitcase put down beside the feet would be fused to a boot and swing with the leg in every sprite. A view the image safety filter refuses is asked once more in calm words (the looks left to the picture, the hands said plainly); refused again, the model made before stays, or the character walks with its 2D rig, and the build goes on.
9
9
  - Optional local Blender (3.6+, found on PATH, in /Applications, or at `DIORAMA_BLENDER`; on/off in Studio Setup, **3D animation**): after the rig, `src/model/blender/relax.py` puts the figure into a calm rest pose. Muse paints the reference with arms raised and feet wide, TRELLIS builds that pose, and the walk then had to turn each arm ~100° down from it, which pinched the shoulders, splayed the hands sideways and kept the legs straddled. Blender poses arms down by the sides with palms to the thighs and feet under the hips, smooths the bent shoulders and hips (Corrective Smooth) and makes that the rest pose (`model.json` `relaxed: true`, the rig as it came kept in `model.source.glb`). The original surface rides along as the `_REST` vertex attribute, so views fitted and painted in the original pose still land where they belong. `diorama model <slug> [key] --relax` (Studio agent: `cast_relax`) does it for existing models and draws their UV sprites again, with no calls. Without Blender nothing changes.
10
10
  - `diorama sprites <slug>` draws their walk and talk sheets (cast/<key>/sprites/): the model walks or gestures in a sheet of 8 frames, Muse draws the sheet again in the world's style, and each frame is cut out and lined up with its pose. A sheet that Muse handed back barely changed is drawn again over a different guide. A walk frame Muse painted out of the gait (a crouch, a sit: the painted figure under 88% of the sheet's usual height) is listed as `off` in sprites.json; the walk steps over it and standing still never shows it. Only what the story uses is drawn: the walk facings the hero's routes need, one talk sheet per speaker in the facing of their conversation, and the gesture Spark picks for each line (`--plan` prints the plan without drawing). Rendering the guides needs Playwright's Chromium (`diorama browsers`).
@@ -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).
package/docs/scenes.md CHANGED
@@ -55,7 +55,7 @@ To change it, tell the Studio agent ("no rain in the hut", "it should pour", "wa
55
55
 
56
56
  ## Checking a world
57
57
 
58
- `diorama build` ends with `verify`, which writes `verify.json` per place and prints which places are playable and what was flagged on the way. Places are built side by side and independently, and no place waits for another: each goes through its own steps and is told playable (`place.verdict`) as soon as its verify and free repairs are done, while the rest are still being made. The world's own steps start as soon as what they read is there (`app/src/world/schedule.ts`): the cast is painted from world.json while the places are, voices once the story is, an item's icon once its place is cut out, sprites once every place has its walking routes, sound once every place is painted. A place whose step breaks is set aside with why, and the others go on to the end, characters and sound included. Each model has a cap on requests open at once so the overlap does not run into rate limits (`DIORAMA_LANES="muse=8,spark=12,sam=16"`, the defaults). The report gives each error its playbook (`app/src/scene/playbook.ts`): where to look, the fix tools to try in order, and the costly last resort (a camera too close, `SCALE_NEAR` or a floor cut off once derive shut the front rows for it, is rerun derive first, then relayout and repaint); `place_status` hands the same to the Studio agent, which repairs one place at a time with the fix tools. The `masks` step also checks that each mask holds its thing: one Spark look per place at the painting with every mask box numbered (`app/src/scene/maskcheck.ts`), since a box and its mask can agree and both sit on the neighbour. When Spark points to where the thing really is, that is `MASK_WRONG` in verify (an error for any thing, props too). When it finds the thing nowhere and what it sees in the box is another kind of thing (hay where a horse should be), the thing was never painted: `THING_NOT_PAINTED`, an error whose play is to paint it in. A doubt about details only (a bronze bell for a brass one) is the flag `MASK_DOUBT`. Spark is asked to judge the kind of thing, not its details, and a box someone fixed is taken as given. Before anyone is asked, the build makes the cheap fixes itself (`app/src/scene/autofix.ts`): a gap of up to three 60 px cells with nothing solid in it, or up to six along stairs or a gangway (a walk-on thing) whose mask reaches both floors, is bridged with a floor brush stroke (two passes, as one bridge can bring the next patch within reach; stairs that lead somewhere else, with water between the floors, bridge nothing: that place needs a floor in the layout or a repaint, so it goes to the agent), a stand spot off the floor moves to the nearest reachable point (both without a model call), and a `MASK_WRONG` thing gets the box Spark saw it in (one SAM call). A card flagged `CARD_CUT` gets the cut part brushed onto its mask (a lantern on a boat's gunwale), so the card carries it and the backdrop is made again without it (once per cut). A `THING_NOT_PAINTED` thing left after them gets one Muse edit at its layout box (once per thing, kept as a repaint correction) and the place is rebuilt from paint. They are ordinary corrections, so `corrections_get` lists them and `corrections_clear` takes them back; `build.json` keeps them under `repairs`, and `--repair off` skips them. What the free fixes leave, the Studio hands to the agent at once: a build that ends with places unplayable wakes it with the places and their errors (`budget_set autoRepair:false` turns that off). It repairs with the fix tools in the playbook's order; after three fixes of a place in a row that leave it unplayable the fix tools refuse it and it tells you what it tried, which is your turn. `place_view` is how the agent looks, as a picture it sees (layer `walk`, `masks` or `painting`, all on the 60 px grid), so it has no need to dig through files; after three shell calls in a turn it is reminded of that once. Layer `masks` tints each thing's mask against the box it should be in and flags a mask that cut out a neighbour. Layer `walk` shows why a place cannot be walked: reached floor green, unreached red, each gap as a dashed line naming the 60 px cells to bridge (the ones `floor_cells` takes) and the things in it, and stand spots off the floor (`places/<place>/diagnose.png`; the agent gets it as an image). Every step also leaves a picture to look at: `masks/overlay.png` (what SAM found) and `walk.png` (where the hero can walk, footprints, floor lines and his size at a few depths).
58
+ `diorama build` ends with `verify`, which writes `verify.json` per place and prints which places are playable and what was flagged on the way. Places are built side by side and independently, and no place waits for another: each goes through its own steps and is told playable (`place.verdict`) as soon as its verify and free repairs are done, while the rest are still being made. The world's own steps start as soon as what they read is there (`app/src/world/schedule.ts`): the cast is painted from world.json while the places are, voices once the story is, an item's icon once its place is cut out, sprites once every place has its walking routes, sound once every place is painted. A place whose step breaks is set aside with why, and the others go on to the end, characters and sound included. Each model has a cap on requests open at once so the overlap does not run into rate limits (`DIORAMA_LANES="muse=8,spark=12,sam=16"`, the defaults). The report gives each error its playbook (`app/src/scene/playbook.ts`): where to look, the fix tools to try in order, and the costly last resort (a camera too close, `SCALE_NEAR` or a floor cut off once derive shut the front rows for it, is rerun derive first, then relayout and repaint); `place_status` hands the same to the Studio agent, which repairs one place at a time with the fix tools. The `masks` step also checks that each mask holds its thing: one Spark look per place at the painting with every mask box numbered (`app/src/scene/maskcheck.ts`), since a box and its mask can agree and both sit on the neighbour. When Spark points to where the thing really is, that is `MASK_WRONG` in verify (an error for any thing, props too). When it finds the thing nowhere and what it sees in the box is another kind of thing (hay where a horse should be), the thing was never painted: `THING_NOT_PAINTED`, an error whose play is to paint it in. A doubt about details only (a bronze bell for a brass one) is the flag `MASK_DOUBT`. Spark is asked to judge the kind of thing, not its details, and a box someone fixed is taken as given. Before anyone is asked, the build makes the cheap fixes itself (`app/src/scene/autofix.ts`): a gap of up to three 60 px cells with nothing solid in it, or up to six along stairs or a gangway (a walk-on thing) whose mask reaches both floors, is bridged with a floor brush stroke (two passes, as one bridge can bring the next patch within reach; stairs that lead somewhere else, with water between the floors, bridge nothing: that place needs a floor in the layout or a repaint, so it goes to the agent), a stand spot off the floor moves to the nearest reachable point (both without a model call), and a `MASK_WRONG` thing gets the box Spark saw it in (one SAM call). A card flagged `CARD_CUT` gets the cut part brushed onto its mask (a lantern on a boat's gunwale), so the card carries it and the backdrop is made again without it (once per cut). A `THING_NOT_PAINTED` thing left after them gets one Muse edit at its layout box (once per thing, kept as a repaint correction) and the place is rebuilt from paint. They are ordinary corrections, so `corrections_get` lists them and `corrections_clear` takes them back; `build.json` keeps them under `repairs`, and `--repair off` skips them. What the free fixes leave, the Studio hands to the agent at once: a build that ends with places unplayable wakes it with the places and their errors (`budget_set autoRepair:false` turns that off). It repairs with the fix tools in the playbook's order; after three fixes of a place in a row that leave it unplayable the fix tools refuse an exact repeat of a fix that failed and steer it to a different path (a mask brushed by hand, floor cells, taking a thing off), and after six they keep repaints and relayouts, which cost a Muse call, for you to approve; when the new path fails too it tells you briefly what it tried. `place_view` is how the agent looks, as a picture it sees (layer `walk`, `masks` or `painting`, all on the 60 px grid), so it has no need to dig through files; after three shell calls in a turn it is reminded of that once. Layer `masks` tints each thing's mask against the box it should be in and flags a mask that cut out a neighbour. Layer `walk` shows why a place cannot be walked: reached floor green, unreached red, each gap as a dashed line naming the 60 px cells to bridge (the ones `floor_cells` takes) and the things in it, and stand spots off the floor (`places/<place>/diagnose.png`; the agent gets it as an image). Every step also leaves a picture to look at: `masks/overlay.png` (what SAM found) and `walk.png` (where the hero can walk, footprints, floor lines and his size at a few depths).
59
59
 
60
60
  `diorama explore <slug>` puts the hero on every part of each floor in the real play page and checks each frame; it needs Playwright with Chromium (`diorama browsers` downloads it once). `--fix` closes the floor where he looked wrong.
61
61
 
package/docs/setup.md CHANGED
@@ -32,7 +32,7 @@ npm run doctor -- --now # before building in front of people: is the netw
32
32
  npm run studio # the GUI
33
33
  npm run build -- <slug> "<idea>"
34
34
  npm run play -- <slug>
35
- npm run publish -- <slug>
35
+ npm run pages -- <slug> # diorama publish (npm keeps the script name publish for itself)
36
36
  npm run export -- <slug> --zip
37
37
  npm run browsers # Playwright's Chromium, for explore, playthrough and sprites
38
38
  npm run diorama -- <command> # any other command, e.g. npm run diorama -- masks <slug>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtanczyk/diorama",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Turn a story prompt into playable painted point-and-click scenes with Meta Muse and SAM 3.1",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -42,7 +42,7 @@
42
42
  "studio": "node app/bin/diorama.mjs studio",
43
43
  "build": "node app/bin/diorama.mjs build",
44
44
  "play": "node app/bin/diorama.mjs play",
45
- "publish": "node app/bin/diorama.mjs publish",
45
+ "pages": "node app/bin/diorama.mjs publish",
46
46
  "export": "node app/bin/diorama.mjs export",
47
47
  "browsers": "node app/bin/diorama.mjs browsers",
48
48
  "typecheck": "tsc --noEmit -p app && tsc --noEmit -p app/web && tsc --noEmit -p app/studio",
@@ -54,7 +54,7 @@
54
54
  "@meta-sam/parser": "^0.1.0",
55
55
  "@preact/signals": "^2.11.3",
56
56
  "esbuild": "^0.25.0",
57
- "genaicode": "^2.25.0",
57
+ "genaicode": "^2.28.1",
58
58
  "onnxruntime-web": "^1.31.1",
59
59
  "playwright": "^1.63.0",
60
60
  "preact": "^10.29.8",