@gtanczyk/diorama 0.3.0 → 0.4.1
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.
- package/app/src/actions/story.ts +28 -1
- package/app/src/actions/studio.ts +25 -0
- package/app/src/actions/style.ts +3 -2
- package/app/src/cli.ts +53 -0
- package/app/src/commands/browsers.ts +24 -8
- package/app/src/commands/clip.ts +7 -0
- package/app/src/commands/derive.ts +9 -0
- package/app/src/commands/explore.ts +2 -1
- package/app/src/commands/language.ts +2 -1
- package/app/src/commands/layout.ts +9 -4
- package/app/src/commands/masks.ts +2 -1
- package/app/src/commands/paint.ts +15 -7
- package/app/src/commands/playthrough.ts +2 -2
- package/app/src/commands/setup.ts +25 -1
- package/app/src/commands/world.ts +9 -6
- package/app/src/main.ts +5 -4
- package/app/src/play/car.ts +2 -0
- package/app/src/qa/route.ts +12 -2
- package/app/src/scene/cast.ts +4 -2
- package/app/src/scene/critic.ts +3 -2
- package/app/src/scene/layout.ts +13 -5
- package/app/src/scene/paint-layout.ts +2 -1
- package/app/src/scene/paint.ts +15 -8
- package/app/src/scene/photo.ts +25 -2
- package/app/src/scene/status.ts +2 -1
- package/app/src/scene/vehicle.ts +22 -0
- package/app/src/secrets.ts +2 -1
- package/app/src/setup/checks.ts +5 -4
- package/app/src/sound/plan.ts +6 -1
- package/app/src/story/engine.ts +9 -4
- package/app/src/story/fixes.ts +11 -2
- package/app/src/studio/agent.ts +16 -3
- package/app/src/studio/mcp.ts +2 -1
- package/app/src/studio/server.ts +3 -2
- package/app/src/world/design.ts +1 -0
- package/app/src/world/drawings.ts +47 -14
- package/app/src/world/schema.ts +5 -0
- package/app/studio/agent.tsx +1 -1
- package/app/studio/main.tsx +5 -2
- package/app/web/main.ts +151 -12
- package/docs/scenes.md +9 -0
- package/docs/setup.md +1 -1
- package/docs/studio.md +3 -1
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
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
|
-
/**
|
|
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 = `-
|
|
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
|
|
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 = `
|
|
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
|
|
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} (
|
|
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
|
|
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}" (
|
|
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) ? [] : [`
|
|
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 [`
|
|
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)];
|
package/app/src/world/schema.ts
CHANGED
|
@@ -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/studio/agent.tsx
CHANGED
|
@@ -19,7 +19,7 @@ export const SAYS: Record<string, string> = {
|
|
|
19
19
|
corrections_get: 'Read fixes', corrections_clear: 'Take fixes back', mask_fix: 'Fix a mask', thing_remove: 'Remove a thing', thing_add: 'Add a thing', mask_brush: 'Brush a mask',
|
|
20
20
|
floor_cells: 'Fix floor cells', stand_set: 'Move a stand spot', repaint_region: 'Repaint a region', water_flow: 'Set water flow', water_area: 'Fix where water moves', fog_drift: 'Set fog drift', weather_set: 'Set weather', reactions_set: 'Set reactions', reactions_status: 'Scene reactions',
|
|
21
21
|
feedback_pending: 'Read open notes', feedback_list: 'Read notes', feedback_done: 'Close a note', feedback_add: 'Leave a note', feedback_reopen: 'Reopen a note',
|
|
22
|
-
history_list: 'Read history', history_undo: 'Undo a job', history_redo: 'Redo', lock_set: 'Change a lock', ui_focus: 'Show a place', ask_person: 'Ask you', budget: 'Check budget', setup_status: 'Check Setup',
|
|
22
|
+
history_list: 'Read history', history_undo: 'Undo a job', history_redo: 'Redo', lock_set: 'Change a lock', ui_focus: 'Show a place', ask_person: 'Ask you', budget: 'Check budget', setup_status: 'Check Setup', setup_cli: 'Read the terminal command', setup_browsers: 'Download Chromium',
|
|
23
23
|
cast_status: 'Look at the cast', cast_redo: 'Repaint a character', voice_list: 'List voices', voice_set: 'Change a voice',
|
|
24
24
|
story_status: 'Read the story', story_text: 'Reword a line', story_redo: 'Rewrite the story', place_view: 'Look at the floor', play_reload: 'Reload the game', npc_move: 'Move an NPC',
|
|
25
25
|
};
|
package/app/studio/main.tsx
CHANGED
|
@@ -224,7 +224,9 @@ function Setup() {
|
|
|
224
224
|
const [checks, setChecks] = useState<Check[] | null>(null);
|
|
225
225
|
const [busy, setBusy] = useState('');
|
|
226
226
|
const [error, setError] = useState('');
|
|
227
|
-
const
|
|
227
|
+
const [setupCommand, setSetupCommand] = useState('diorama setup');
|
|
228
|
+
useEffect(() => void call<{ setup: string }>('setup_cli').then((c) => setSetupCommand(c.setup)).catch(() => {}), []);
|
|
229
|
+
const run = async (name: 'setup_status' | 'setup_check' | 'setup_browsers') => {
|
|
228
230
|
setBusy(name);
|
|
229
231
|
setError('');
|
|
230
232
|
try {
|
|
@@ -250,7 +252,7 @@ function Setup() {
|
|
|
250
252
|
<h1>Setup</h1>
|
|
251
253
|
</div>
|
|
252
254
|
<p class="lead">{!checks ? 'Checking this machine…' : blocking.length ? `${blocking.map((c) => c.label).join(' and ')} still ${blocking.length > 1 ? 'need' : 'needs'} you. Everything else is ready or optional.` : `Everything diorama needs is here.${ok < checks.length ? ' The unticked ones are optional.' : ''}`}</p>
|
|
253
|
-
<p class="note">Keys are typed only in your terminal with <code>
|
|
255
|
+
<p class="note">Keys are typed only in your terminal with <code>{setupCommand}</code>, hidden, and kept in your system keychain. This page never sees them.</p>
|
|
254
256
|
{error && <p class="error">{error}</p>}
|
|
255
257
|
<div class="row">
|
|
256
258
|
<button type="button" class="primary" disabled={!!busy} title="Look at this computer again: keys, tools, the agent. Free" onClick={() => run('setup_status')}>{busy === 'setup_status' ? 'Checking…' : 'Check again'}</button>
|
|
@@ -273,6 +275,7 @@ function Setup() {
|
|
|
273
275
|
<div class="fix">
|
|
274
276
|
<pre>{c.fix.join('\n')}</pre>
|
|
275
277
|
<Copy text={c.fix.join('\n')} />
|
|
278
|
+
{c.id === 'chromium' && <button type="button" class="primary small" disabled={!!busy} title="Download Playwright's Chromium now (~150 MB, once), then check again. Free" onClick={() => run('setup_browsers')}>{busy === 'setup_browsers' ? 'Downloading…' : 'Download'}</button>}
|
|
276
279
|
</div>
|
|
277
280
|
)}
|
|
278
281
|
</div>
|
package/app/web/main.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { CYCLE, pose, sheetCell, type PartName, type Rig } from '../src/play/rig
|
|
|
12
12
|
import { actions, activeChanges, apply, hiddenThings, hintFor, holds, lockOn, pickDialogue, pickInteraction, speakerOf, startState, useFor, type Action, type State } from '../src/story/engine.ts';
|
|
13
13
|
import type { Story } from '../src/story/schema.ts';
|
|
14
14
|
import type { Place as PlaceSpec, World } from '../src/world/schema.ts';
|
|
15
|
+
import { CAR } from '../src/play/car.ts';
|
|
15
16
|
import { Mixer, type Listened } from './audio.ts';
|
|
16
17
|
import { PLAYER_COLOURS, readEachPad, readPads, Turns, type PadButton, type PadFrame } from './pad.ts';
|
|
17
18
|
import { Answers, Bag, displayName, fitHud, showEnding, type ItemLook } from './ui.ts';
|
|
@@ -76,6 +77,7 @@ type Figure = { img: HTMLImageElement; face?: PaintedFace; faceInfo?: FaceDefini
|
|
|
76
77
|
const FLOATS = /\b(boat|rowboat|dinghy|skiff|canoe|kayak|raft|ship|barge|punt|gondola|sampan|junk|ferry|trawler|yacht|sailboat)s?\b/i;
|
|
77
78
|
|
|
78
79
|
/** canvas: the card as the place looks now (story changes on it); base: as painted, whose alpha is the card's shape. */
|
|
80
|
+
|
|
79
81
|
type Cut = { occ: Occluder; canvas: HTMLCanvasElement; base: HTMLCanvasElement; x0: number; y0: number; y: number };
|
|
80
82
|
|
|
81
83
|
type Place = {
|
|
@@ -670,7 +672,10 @@ class Game {
|
|
|
670
672
|
[this.hero.x, this.hero.y] = arrive ?? place.g.start;
|
|
671
673
|
// Coming over a bridge or up the steps, the hero walks in from the exit's far end (by the frame's edge) to
|
|
672
674
|
// where one stands at it, instead of popping up in the middle of the place.
|
|
673
|
-
const edge = back && arrive ? this.exitEdge(back.via) : undefined;
|
|
675
|
+
const edge = back && arrive && place.spec.kind !== 'drive' ? this.exitEdge(back.via) : undefined;
|
|
676
|
+
// In a drive the hero is painted at the wheel (the car's card): unseen as a figure, they speak from the car.
|
|
677
|
+
const car = place.spec.kind === 'drive' ? this.carOf(place) : undefined;
|
|
678
|
+
if (car) [this.hero.x, this.hero.y] = [(car.occ.box[0] + car.occ.box[2]) / 2 - (car.occ.box[2] - car.occ.box[0]) * 0.15, car.occ.box[1] + (car.occ.box[3] - car.occ.box[1]) * 0.45];
|
|
674
679
|
if (edge) {
|
|
675
680
|
[this.hero.x, this.hero.y] = edge;
|
|
676
681
|
this.hero.path = this.place.grid.route(edge, arrive!);
|
|
@@ -844,12 +849,16 @@ class Game {
|
|
|
844
849
|
try {
|
|
845
850
|
const placeKey = this.place.key;
|
|
846
851
|
const [sx, sy] = this.approach(hot);
|
|
847
|
-
|
|
852
|
+
// In a drive things are used from the car, where they are.
|
|
853
|
+
if (this.place.spec.kind !== 'drive' && !(await this.walkTo(sx, sy)) && Math.hypot(this.hero.x - sx, this.hero.y - sy) > this.place.g.cell * 2) return;
|
|
848
854
|
if (this.place.key !== placeKey) return;
|
|
849
855
|
const [x0, , x1] = hot.thing.box;
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
856
|
+
// In a drive the hero keeps their eyes on the road.
|
|
857
|
+
if (this.place.spec.kind !== 'drive') {
|
|
858
|
+
this.hero.facing = (hot.npc?.body.x ?? (x0 + x1) / 2) >= this.hero.x ? 1 : -1;
|
|
859
|
+
// Three-quarter towards it, the way a painted figure talks to someone.
|
|
860
|
+
this.hero.yaw = this.hero.facing * Math.PI / 3;
|
|
861
|
+
}
|
|
853
862
|
this.emit('use', { thing: key, verb });
|
|
854
863
|
document.body.classList.add('played');
|
|
855
864
|
await this.act(this.actionFor(key, hot, verb));
|
|
@@ -891,6 +900,8 @@ class Game {
|
|
|
891
900
|
/** What a click on a thing means: the exit, the talk, the held item, or the first verb the story answers. */
|
|
892
901
|
private actionFor(key: string, hot: Hot, verb?: Verb): Action {
|
|
893
902
|
if (verb === 'look') return { kind: 'do', thing: key, verb: 'look' };
|
|
903
|
+
// A car that drives to several places is driven from its board; a click on it looks at it.
|
|
904
|
+
if (this.place.spec.exits.filter((e) => e.via === key).length > 1) return { kind: 'do', thing: key, verb: 'look' };
|
|
894
905
|
if (this.place.spec.exits.some((e) => e.via === key)) return { kind: 'go', via: key };
|
|
895
906
|
const role = hot.spec?.role ?? hot.thing.role;
|
|
896
907
|
// A person talks: in a conversation when the story has one for them now, else in their own talk lines.
|
|
@@ -961,7 +972,7 @@ class Game {
|
|
|
961
972
|
if (nudge && this.hinted?.key === nudge.thing) this.hinted = { key: nudge.thing, until: this.time + 6 };
|
|
962
973
|
if (after.place !== from) {
|
|
963
974
|
// Out the same way: along the bridge or down the steps toward the frame's edge before the picture fades.
|
|
964
|
-
const edge = action.kind === 'go' ? this.exitEdge(action.via) : undefined;
|
|
975
|
+
const edge = action.kind === 'go' && this.place.spec.kind !== 'drive' ? this.exitEdge(action.via) : undefined;
|
|
965
976
|
if (edge) await Promise.race([this.walkTo(...edge), this.wait(2500)]);
|
|
966
977
|
this.emit('exit', { from, to: after.place });
|
|
967
978
|
await this.fadeOut();
|
|
@@ -1476,6 +1487,12 @@ class Game {
|
|
|
1476
1487
|
return this.shift(y);
|
|
1477
1488
|
}
|
|
1478
1489
|
|
|
1490
|
+
/** The car of a drive place: the card named as a car, else the biggest card standing on the road. */
|
|
1491
|
+
private carOf(place: Place): Cut | undefined {
|
|
1492
|
+
const named = (c: Cut) => { const k = c.occ.thing ?? c.occ.key; return `${k} ${place.spec.things.find((t) => t.key === k)?.looks ?? ''}`; };
|
|
1493
|
+
return place.cuts.find((c) => CAR.test(named(c))) ?? [...place.cuts].sort((a, b) => b.canvas.width * b.canvas.height - a.canvas.width * a.canvas.height)[0];
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1479
1496
|
/** Draws the card's columns [xa, xb) (frame x) in strips, each slid by the floor under it. */
|
|
1480
1497
|
private drawCard(cut: Cut, xa = cut.x0, xb = cut.x0 + cut.canvas.width, ya = cut.y0, yb = cut.y0 + cut.canvas.height) {
|
|
1481
1498
|
ctx.save();
|
|
@@ -1491,6 +1508,17 @@ class Game {
|
|
|
1491
1508
|
* card (its outline, its story change) goes through here, so it moves exactly as the card does.
|
|
1492
1509
|
*/
|
|
1493
1510
|
private poseCard(cut: Cut) {
|
|
1511
|
+
if (this.place.spec.kind === 'drive' && cut === this.carOf(this.place) && !params.has('still')) {
|
|
1512
|
+
// The engine idles: the body trembles on its springs, harder while it revs to leave.
|
|
1513
|
+
const t = this.time;
|
|
1514
|
+
const rev = this.revving > t ? 3 : 1;
|
|
1515
|
+
const shake = (Math.sin(t * 61) * 0.5 + Math.sin(t * 37 + 1) * 0.35) * rev;
|
|
1516
|
+
const [cx, cy] = [(cut.occ.box[0] + cut.occ.box[2]) / 2, cut.occ.box[3]];
|
|
1517
|
+
ctx.translate(cx, cy + shake);
|
|
1518
|
+
ctx.rotate(Math.sin(t * 23) * 0.0012 * rev);
|
|
1519
|
+
ctx.translate(-cx, -cy);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1494
1522
|
const float = this.afloat(cut);
|
|
1495
1523
|
if (float && !params.has('still')) {
|
|
1496
1524
|
const t = this.time + (hashSeed(cut.occ.key) % 1000) / 160;
|
|
@@ -1939,7 +1967,9 @@ class Game {
|
|
|
1939
1967
|
const gone = this.gone();
|
|
1940
1968
|
this.recut();
|
|
1941
1969
|
const npcs = this.hots().filter((h) => (h.spec?.role ?? h.thing.role) === 'npc' && !h.mask);
|
|
1942
|
-
|
|
1970
|
+
// In a drive the hero is the one painted at the wheel.
|
|
1971
|
+
const riding = this.place.spec.kind === 'drive';
|
|
1972
|
+
const people: Who[] = [...npcs.map((h) => ({ npc: h, y: h.npc?.body.y ?? h.thing.box[3] })), ...(riding ? [] : [this.hero, ...this.party]).map((body) => ({ body, y: body.y }))].sort((a, b) => a.y - b.y);
|
|
1943
1973
|
const fire = this.fireOf(gone);
|
|
1944
1974
|
this.climbs.length = 0;
|
|
1945
1975
|
if (p && backdrop) {
|
|
@@ -2052,6 +2082,7 @@ class Game {
|
|
|
2052
2082
|
this.drawClimbs();
|
|
2053
2083
|
}
|
|
2054
2084
|
this.place.glows?.draw(ctx, (y) => this.shift(y));
|
|
2085
|
+
if (this.place.spec.kind === 'drive') this.drawIdle();
|
|
2055
2086
|
this.place.weather?.drawDrips(ctx, (y) => this.shift(y));
|
|
2056
2087
|
this.drawFocus();
|
|
2057
2088
|
// The place as drawn so far, without anyone: what the depth map puts back over a person (occludeDepth).
|
|
@@ -2692,6 +2723,108 @@ class Game {
|
|
|
2692
2723
|
}
|
|
2693
2724
|
|
|
2694
2725
|
/** Shadows that land on things standing behind their person (a tent wall): drawn over the cards. */
|
|
2726
|
+
/** Until when a drive's engine revs (it shakes harder) before it leaves. */
|
|
2727
|
+
revving = 0;
|
|
2728
|
+
/** The destination board of a drive place, as drawn this frame: each place it leads to and its box. */
|
|
2729
|
+
private board: Array<{ to: string; via: string; box: [number, number, number, number] }> = [];
|
|
2730
|
+
/** The destination under the pointer. */
|
|
2731
|
+
boardHover: string | undefined;
|
|
2732
|
+
|
|
2733
|
+
/** Where a drive place's car leads: every exit through a thing that leads to more than one place. */
|
|
2734
|
+
destinations(): Array<{ to: string; via: string }> {
|
|
2735
|
+
if (this.place?.spec.kind !== 'drive') return [];
|
|
2736
|
+
const exits = this.place.spec.exits;
|
|
2737
|
+
return exits.filter((e) => exits.filter((o) => o.via === e.via).length > 1).map((e) => ({ to: e.to, via: e.via }));
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
destinationAt(x: number, y: number): string | undefined {
|
|
2741
|
+
return this.board.find((b) => x >= b.box[0] && x <= b.box[2] && y >= b.box[1] && y <= b.box[3])?.to;
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
/** The middle of a destination's row on the board, in page pixels (for scripts and recorders). */
|
|
2745
|
+
destinationPoint(to: string): { x: number; y: number } | null {
|
|
2746
|
+
const b = this.board.find((d) => d.to === to);
|
|
2747
|
+
if (!b) return null;
|
|
2748
|
+
const z = this.zoom();
|
|
2749
|
+
const { width: W, height: H } = this.place.g.frame;
|
|
2750
|
+
const [fx, fy] = [((b.box[0] + b.box[2]) / 2 - (this.viewX || W / 2)) * z + W / 2, ((b.box[1] + b.box[3]) / 2 - (this.viewY || H / 2)) * z + H / 2];
|
|
2751
|
+
const r = canvas.getBoundingClientRect();
|
|
2752
|
+
return { x: r.left + (fx * r.width) / W, y: r.top + (fy * r.height) / H };
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
/** Drives off to a destination: the engine revs, the picture fades, the car is parked there and the hero gets out. */
|
|
2756
|
+
async driveTo(to: string) {
|
|
2757
|
+
const d = this.destinations().find((e) => e.to === to);
|
|
2758
|
+
if (!d || this.busy) return;
|
|
2759
|
+
this.busy = true;
|
|
2760
|
+
try {
|
|
2761
|
+
this.emit('use', { thing: d.via, verb: 'go' });
|
|
2762
|
+
this.revving = this.time + 1;
|
|
2763
|
+
await this.wait(800);
|
|
2764
|
+
await this.act({ kind: 'go', via: d.via, to });
|
|
2765
|
+
} finally {
|
|
2766
|
+
this.busy = false;
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
/** A drive place's engine running (exhaust puffing from behind the car) and its board of destinations. */
|
|
2771
|
+
private drawIdle() {
|
|
2772
|
+
const car = this.carOf(this.place);
|
|
2773
|
+
const t = this.time;
|
|
2774
|
+
if (car && !params.has('still')) {
|
|
2775
|
+
const [x0, y0, x1, y1] = car.occ.box;
|
|
2776
|
+
const [w, h] = [x1 - x0, y1 - y0];
|
|
2777
|
+
const rev = this.revving > t ? 2.2 : 1;
|
|
2778
|
+
ctx.save();
|
|
2779
|
+
for (let i = 0; i < 10; i++) {
|
|
2780
|
+
const age = (t * 0.8 * rev + i / 10) % 1;
|
|
2781
|
+
const r = w * (0.02 + age * 0.08);
|
|
2782
|
+
ctx.fillStyle = `rgba(210,210,215,${(0.32 * (1 - age) * Math.min(1, age * 6)).toFixed(3)})`;
|
|
2783
|
+
ctx.beginPath();
|
|
2784
|
+
ctx.arc(x0 + w * 0.12 - age * w * 0.22 + Math.sin(t * 2 + i) * 4, y1 - h * 0.12 - age * h * 0.35, r, 0, Math.PI * 2);
|
|
2785
|
+
ctx.fill();
|
|
2786
|
+
}
|
|
2787
|
+
ctx.restore();
|
|
2788
|
+
}
|
|
2789
|
+
const where = this.destinations();
|
|
2790
|
+
this.board = [];
|
|
2791
|
+
if (!where.length) return;
|
|
2792
|
+
const W = this.place.g.frame.width;
|
|
2793
|
+
// With ?fill=1 the painting can run past the page's edges: the board sits at the page's own top right corner.
|
|
2794
|
+
const r = canvas.getBoundingClientRect();
|
|
2795
|
+
const [visRight, visTop] = fill ? this.unzoom(((Math.min(innerWidth, r.right) - r.left) / r.width) * W, (Math.max(0, -r.top) / r.height) * this.place.g.frame.height) : [W, 0];
|
|
2796
|
+
const [rowH, bw, x1, top] = [52, 330, Math.min(W, visRight) - 28, Math.max(0, visTop) + 36];
|
|
2797
|
+
const x0 = x1 - bw;
|
|
2798
|
+
ctx.save();
|
|
2799
|
+
ctx.fillStyle = 'rgba(12,10,18,0.78)';
|
|
2800
|
+
ctx.strokeStyle = 'rgba(255,60,170,0.9)';
|
|
2801
|
+
ctx.lineWidth = 2;
|
|
2802
|
+
ctx.beginPath();
|
|
2803
|
+
ctx.roundRect(x0, top, bw, 54 + where.length * rowH, 12);
|
|
2804
|
+
ctx.fill();
|
|
2805
|
+
ctx.stroke();
|
|
2806
|
+
ctx.fillStyle = '#ffd34d';
|
|
2807
|
+
ctx.font = '700 22px system-ui, sans-serif';
|
|
2808
|
+
ctx.textBaseline = 'middle';
|
|
2809
|
+
ctx.fillText('Where to?', x0 + 18, top + 28);
|
|
2810
|
+
where.forEach((d, i) => {
|
|
2811
|
+
const y = top + 54 + i * rowH;
|
|
2812
|
+
const box: [number, number, number, number] = [x0 + 8, y + 3, x1 - 8, y + rowH - 3];
|
|
2813
|
+
this.board.push({ ...d, box });
|
|
2814
|
+
const lit = this.boardHover === d.to;
|
|
2815
|
+
ctx.fillStyle = lit ? 'rgba(255,60,170,0.85)' : 'rgba(255,255,255,0.07)';
|
|
2816
|
+
ctx.beginPath();
|
|
2817
|
+
ctx.roundRect(box[0], box[1], box[2] - box[0], box[3] - box[1], 8);
|
|
2818
|
+
ctx.fill();
|
|
2819
|
+
ctx.fillStyle = lit ? '#fff' : '#e8e4f0';
|
|
2820
|
+
ctx.font = '600 21px system-ui, sans-serif';
|
|
2821
|
+
ctx.fillText(displayName(d.to, this.data.world.names), box[0] + 14, (box[1] + box[3]) / 2);
|
|
2822
|
+
ctx.fillStyle = '#5ee6ff';
|
|
2823
|
+
ctx.fillText('›', box[2] - 26, (box[1] + box[3]) / 2);
|
|
2824
|
+
});
|
|
2825
|
+
ctx.restore();
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2695
2828
|
private climbs: Array<{ canvas: HTMLCanvasElement; box: [number, number, number, number]; dark: number; blur: string; shift: [number, number] }> = [];
|
|
2696
2829
|
private climbPool: HTMLCanvasElement[] = [];
|
|
2697
2830
|
private climbMask = document.createElement('canvas');
|
|
@@ -3124,9 +3257,12 @@ class Game {
|
|
|
3124
3257
|
const width = Math.max(...lines.map((l) => ctx.measureText(l).width)) + 40;
|
|
3125
3258
|
const height = lines.length * 28 + (name ? 30 : 22);
|
|
3126
3259
|
const head = (left + right) / 2;
|
|
3127
|
-
|
|
3260
|
+
// With ?fill=1 the canvas can run past the page's edges: the bubble stays on the page's own part of it.
|
|
3261
|
+
const r = canvas.getBoundingClientRect();
|
|
3262
|
+
const [visL, visR, visT] = fill && r.width ? [(Math.max(0, -r.left) / r.width) * canvas.width, ((Math.min(innerWidth, r.right) - r.left) / r.width) * canvas.width, (Math.max(0, -r.top) / r.height) * canvas.height] : [0, canvas.width, 0];
|
|
3263
|
+
const cx = Math.min(visR - width / 2 - 12, Math.max(visL + width / 2 + 12, head));
|
|
3128
3264
|
const tail = 22;
|
|
3129
|
-
const y = Math.max(name ? 24 : 10, top - height - tail - 6);
|
|
3265
|
+
const y = Math.max(visT + (name ? 24 : 10), top - height - tail - 6);
|
|
3130
3266
|
const ink = '#2b2118';
|
|
3131
3267
|
ctx.lineJoin = 'round';
|
|
3132
3268
|
ctx.lineWidth = 2.5;
|
|
@@ -3404,10 +3540,11 @@ canvas.addEventListener('pointermove', (e) => {
|
|
|
3404
3540
|
usePad(false);
|
|
3405
3541
|
const r = canvas.getBoundingClientRect();
|
|
3406
3542
|
pointer = { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height };
|
|
3407
|
-
game.
|
|
3543
|
+
game.boardHover = game.destinationAt(...toFrame(e));
|
|
3544
|
+
game.hover = game.boardHover ? undefined : game.thingAt(...toFrame(e));
|
|
3408
3545
|
// Off the floor (a wall, the sky, the water) the cursor says no, so nobody clicks blind.
|
|
3409
|
-
const off = !game.hover && !game.place.grid.walkable(...game.floorAt(...toFrame(e)));
|
|
3410
|
-
canvas.style.cursor = hideCursor || game.holding ? 'none' : game.hover ? 'pointer' : off ? 'not-allowed' : 'default';
|
|
3546
|
+
const off = !game.hover && !game.boardHover && !game.place.grid.walkable(...game.floorAt(...toFrame(e)));
|
|
3547
|
+
canvas.style.cursor = hideCursor || game.holding ? 'none' : game.hover || game.boardHover ? 'pointer' : off ? 'not-allowed' : 'default';
|
|
3411
3548
|
game.bag.point(e.clientX, e.clientY, game.hover?.thing.key.replace(/-/g, ' '));
|
|
3412
3549
|
document.getElementById('hover')!.textContent = game.hover ? game.hover.thing.key.replace(/-/g, ' ') : '';
|
|
3413
3550
|
});
|
|
@@ -3439,6 +3576,8 @@ canvas.addEventListener('click', (e) => {
|
|
|
3439
3576
|
if (e.detail >= 2 && game.hero.path.length) return void (game.hero.run = true);
|
|
3440
3577
|
game.hero.run = false;
|
|
3441
3578
|
const [x, y] = toFrame(e);
|
|
3579
|
+
const to = game.destinationAt(x, y);
|
|
3580
|
+
if (to) return void game.driveTo(to);
|
|
3442
3581
|
const hot = game.thingAt(x, y);
|
|
3443
3582
|
if (hot) void game.use(hot.thing.key);
|
|
3444
3583
|
else void game.walkTo(...game.floorAt(x, y));
|
package/docs/scenes.md
CHANGED
|
@@ -29,6 +29,15 @@ Walk geometry still comes from the accepted painting, not the rough plan. The bu
|
|
|
29
29
|
- **Rim light.** Each person gets a thin warm edge on the side the light comes from (against the way shadows fall; a place with no measured light gets none), as illustrators separate a figure from a busy background. `?rim=0` off, `?rim=1.5` wider.
|
|
30
30
|
- **Focus** (off by default). `?focus=1` softens and pales what is behind the hero with its depth, like a close-up camera. It only separates far things, so it stays opt-in.
|
|
31
31
|
|
|
32
|
+
## Drive: the hero's car
|
|
33
|
+
|
|
34
|
+
A place with `"kind": "drive"` in world.json is the hero's car: it stands at the kerb with the engine running and the hero at the wheel, and from it the player picks where to drive. Its one thing is the car (role exit) with an exit through it to every place it drives to; each of those places has the car parked in it (a thing with role exit) leading back. The world designer makes one only when the idea gives the hero a car.
|
|
35
|
+
|
|
36
|
+
- **Layout and paint.** The car stands in the middle, seen from behind at three-quarters, the hero painted at the wheel (the critic and the composition check let that one person stay); the top right quarter stays calm for the board. Masks, derive and verify run as anywhere: the street is the floor, and every thing there is a card of its own.
|
|
37
|
+
- **One car everywhere.** Each place is painted on its own, so the car as painted in the drive place (cut out by its mask, `scene/vehicle.ts`) goes to every place it is parked in as the last image of its painting, and Muse parks that same car there. A place painted before the drive place keeps its car until it is painted again.
|
|
38
|
+
- **In the page.** The car's card trembles with the idling engine and exhaust puffs from behind it; its lights glow as the painting's lights do. A board in the top right lists the places it drives to (their names). Picking one revs the engine (harder shaking, more smoke), the picture fades and the hero steps out beside the car where it is parked there. Clicking the car in a place walks the hero to it and in. There is no driving animation. The hero is the one painted at the wheel, so no figure is drawn and their lines come from the car.
|
|
39
|
+
- **The story.** One thing that leads to several places makes `go` actions with `to` (the destination); `playthrough` and `clip` click the board's row for it (`window.diorama.destinationPoint(to)`).
|
|
40
|
+
|
|
32
41
|
## Weather: rain, snow and drips
|
|
33
42
|
|
|
34
43
|
`diorama weather <slug>` (a build step, after `backdrop`) reads what falls on each place and where water drips, into `places/<key>/weather.json`; the page animates it all, so nothing in the painting is a frozen streak:
|
package/docs/setup.md
CHANGED
|
@@ -8,7 +8,7 @@ Without a checkout, from npm. Run it in an empty folder: worlds are written to `
|
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
mkdir my-worlds && cd my-worlds
|
|
11
|
-
npx -y @gtanczyk/diorama setup # what this machine still needs; asks for keys, hidden
|
|
11
|
+
npx -y @gtanczyk/diorama setup # what this machine still needs; asks for keys, hidden, and offers to download Chromium
|
|
12
12
|
npx -y @gtanczyk/diorama doctor # one live call to each model
|
|
13
13
|
npx -y @gtanczyk/diorama studio # the GUI
|
|
14
14
|
```
|
package/docs/studio.md
CHANGED
|
@@ -34,7 +34,7 @@ The agent can use `job_inspect` to see a job's timestamped recent activity, how
|
|
|
34
34
|
|
|
35
35
|
Every fix is kept in a file next to what it fixes and applied again by every later build, so a rebuild never loses it: `places/<place>/corrections.json` (rebuilding only that place from the step it feeds), `story-fixes.json`, `cast/fixes.json`. A place's thing list is edited the same way: Remove takes a prop off it (kept in the picture, or painted out with one Muse edit) and Add makes something painted a thing of its own (`removed` and `added` in `corrections.json`, which every step reads with world.json); exits, items and people belong to the story. A small repaint rebuilds only what it reaches (`app/src/scene/carry.ts`): each place step keeps the painting it last ran on (`places/<place>/.ran/`), compares the new one with it tile by tile, and asks again only about what the changed tiles reach (a thing's or floor's mask, a plate, the backdrop's cards, the light, the stairs); everything else is asked about the old painting, which the memo answers for nothing, and the place keeps its scale. Lighting the lanterns of a harbour takes about 100 s instead of 270 s, and a rebuild on the same painting asks nothing new. A change over a third of the painting rebuilds everything. A place whose steps have not kept their painting (built before this, or put back by an Undo) takes the painting on disk as theirs before a repaint, so that repaint carries too. **History** lists the jobs the world went through, the agent's included, each with who ran it, when and which places it changed, and puts the world back as it was before any of them (Redo takes that back right after); a locked world or place is left alone by the agent.
|
|
36
36
|
|
|
37
|
-
The conversation is genaicode's chat with Muse Code (default), Claude Code or Codex, picked in its header before the first message (Setup's **Default agent**, or a pick here, is remembered in `~/.config/diorama/studio.json` for new worlds; a world keeps the agent it last talked with, and `DIORAMA_AGENT=codex` overrides both); after it, "Hand over…" passes the conversation to another agent and a line marks where it changed hands. After a Studio restart the earlier turns come back on screen from `studio/sessions/`. The conversation is also kept in words in `studio/conversation.md`: a new agent session (another agent, or after the Studio restarts) is told to read it first, so nothing agreed is lost. The agent works through the same actions as the page, so its steps show live and its changes survive rebuilds; when a build leaves places unplayable it is woken to repair them, three tries a place. Every conversation is recorded in `studio/sessions/`: open one from "Recorded sessions", or `/w/<slug>?replay=<file>&speed=2`, to play it back for a film.
|
|
37
|
+
The conversation is genaicode's chat with Muse Code (default), Claude Code or Codex, picked in its header before the first message (Setup's **Default agent**, or a pick here, is remembered in `~/.config/diorama/studio.json` for new worlds; a world keeps the agent it last talked with, and `DIORAMA_AGENT=codex` overrides both; `DIORAMA_AGY_MODEL=gemini-4-argon-high`, `DIORAMA_CODEX_MODEL`, `DIORAMA_MUSE_MODEL`, `DIORAMA_AGENT_CLAUDE_MODEL` or `DIORAMA_AGENT_MODEL` picks the agent's model); after it, "Hand over…" passes the conversation to another agent and a line marks where it changed hands. After a Studio restart the earlier turns come back on screen from `studio/sessions/`. The conversation is also kept in words in `studio/conversation.md`: a new agent session (another agent, or after the Studio restarts) is told to read it first, so nothing agreed is lost. The agent works through the same actions as the page, so its steps show live and its changes survive rebuilds; when a build leaves places unplayable it is woken to repair them, three tries a place. Every conversation is recorded in `studio/sessions/`: open one from "Recorded sessions", or `/w/<slug>?replay=<file>&speed=2`, to play it back for a film.
|
|
38
38
|
|
|
39
39
|
**Setup** shows what this machine still needs, which agents it found, and the model calls sent across all worlds, priced once you enter the per-call prices from your billing pages. Keys are only ever typed in the terminal (`diorama setup`).
|
|
40
40
|
|
|
@@ -50,6 +50,8 @@ The Story view shows conditional dialogue, optional walks and departure effects.
|
|
|
50
50
|
|
|
51
51
|
A world can be made from what children drew and said: `diorama build <slug> --drawings photos/ --told recordings/ --places 3`, with no idea, or an idea of your own instead of the recordings. In the Studio, photograph the drawings on the home screen (📷 Camera keeps taking them; Spark tells drawings from a room or a person) and build, or add them to a world's conversation (**+ Picture** or **Camera**) and ask the agent; it uses the same workshop actions. Each drawing is kept in `drawings/`, each clip in `told/` with what Muse Voice Transcribe heard. The world keeps every drawing under its key (`drawings.json`): characters are painted from their drawing (`cast`), places and the things drawn in them follow theirs (`paint`), and the style is their crayons come alive. A world with drawings or recordings is for kids (`audience: kids` in world.json): the story is gentle and simple, every line is voiced since the players cannot read, and the text is in the language they spoke (`language`).
|
|
52
52
|
|
|
53
|
+
A grown-up's sketches work the same way: Spark tells a teen's or adult's sketch (pen, pencil, ink, watercolour, perspective, notes) from a child's drawing (`by` and `medium` in `drawings.json`). When every drawing is a grown-up's, the world is not for kids and its style is the sketches' own medium and hand come alive, not crayons; the painters are told it is the artist's sketch.
|
|
54
|
+
|
|
53
55
|
To add new drawings to an existing game, the agent saves chat pictures with `kids_from_picture` and starts `world_extend` with the resulting `drawings/` paths and the person's wish. It keeps the existing script and places, adds new places and characters, and rebuilds the connecting place (its painting changes) and the new places. It checks the combined story and builds in a private copy, installing it only on success; the job costs model calls and can be undone in History. A drawing identified as something already in the world cannot yet replace or update that existing thing.
|
|
54
56
|
|
|
55
57
|
On the start screen, **Video** sits beside **Photo**, **Camera** and **Say it** inside the prompt. Its button opens the native picker; the raw file input and technical instructions are hidden. After selection, the filename, player, progress and **Video tips** appear under the toolbar. It uploads an MP4, MOV, WebM, MKV, M4V or AVI brief (up to 2 GB / 4 hours). Show a drawing or object and explain its role. Uploads use 2 MB chunks; analysis works through 30-second intervals, samples a frame at the start of each 10-second window and pairs it with overlapping speech turns returned by Meta Muse Voice Transcribe’s file endpoint (`POST /v1/asr/transcribe`, DIARIZATION). Each 30-second audio clip is uploaded as mono 24 kHz PCM WAV, with a short silent tail to let the last utterance finish. Movies without audio use pictures alone; silent audio windows skip transcription. Hold each drawing steady for at least ten seconds: quick gestures or drawings between samples can be missed. Ambiguous references are marked for review, not guaranteed to be resolved.
|