@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
package/app/src/actions/story.ts
CHANGED
|
@@ -82,6 +82,33 @@ defineAction({
|
|
|
82
82
|
},
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
defineAction({
|
|
86
|
+
name: 'story_look',
|
|
87
|
+
description: 'Set what the hero says looking at one thing (a sign, a prop, a car), adding the look line where the story has none and rewording it where it has one. '
|
|
88
|
+
+ 'Nothing else in the story changes (story_redo would write it all again). Kept across rebuilds. A spoken line needs run_step voices afterwards.',
|
|
89
|
+
input: z.object({ slug: Slug, place: Slug, thing: Slug, text: z.string().trim().min(1).max(400) }),
|
|
90
|
+
async run({ slug, place, thing, text }, ctx) {
|
|
91
|
+
const files = open(slug, ctx);
|
|
92
|
+
const blocked = agentBlocked(files.dir, ctx.by);
|
|
93
|
+
if (blocked) throw new ActionError(423, blocked);
|
|
94
|
+
const story = readStory(files);
|
|
95
|
+
if (!story) throw new ActionError(409, `${slug} has no story yet: run_step story first`);
|
|
96
|
+
const spot = files.world().places.find((p) => p.key === place);
|
|
97
|
+
if (!spot) throw new ActionError(400, `${place} is not a place of ${slug}`);
|
|
98
|
+
if (!spot.things.some((t) => t.key === thing)) throw new ActionError(400, `${thing} is not a thing in ${place}: ${spot.things.map((t) => t.key).join(', ')}`);
|
|
99
|
+
const busy = ctx.jobs.busy(slug);
|
|
100
|
+
if (busy) throw new ActionError(409, `${busy.label} is ${busy.state}; wait before editing story text`);
|
|
101
|
+
new History(files.dir).snapshot(`story look ${place}/${thing}`, ctx.by);
|
|
102
|
+
const fixes = readStoryFixes(files);
|
|
103
|
+
const look = { text, at: new Date().toISOString() };
|
|
104
|
+
fixes.looks = { ...fixes.looks, [`${place}/${thing}`]: look };
|
|
105
|
+
files.write(storyFixesFile(files), StoryFixes.parse(fixes));
|
|
106
|
+
const now = applyTexts(story, { texts: {}, looks: { [`${place}/${thing}`]: look } }).story;
|
|
107
|
+
files.write(files.path('story.json'), now);
|
|
108
|
+
return { place, thing, text, spoken: storyLines(files.world(), now).some((l) => l.text === text) };
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
85
112
|
defineAction({
|
|
86
113
|
name: 'story_redo',
|
|
87
114
|
description: 'Have Spark write the story again to a wish ("the ferrywoman should want something in return", "after receiving the key she goes to the garden and thanks me there"); '
|
|
@@ -95,7 +122,7 @@ defineAction({
|
|
|
95
122
|
const blocked = agentBlocked(files.dir, ctx.by);
|
|
96
123
|
if (blocked) throw new ActionError(423, blocked);
|
|
97
124
|
return ctx.jobs.start(slug, ['story', slug], wish ? 'rewrite story' : 'first story', ctx.by, () => {
|
|
98
|
-
files.write(storyFixesFile(files), StoryFixes.parse({ ...(wish && { wish }), texts: {} }));
|
|
125
|
+
files.write(storyFixesFile(files), StoryFixes.parse({ ...(wish && { wish }), texts: {}, looks: readStoryFixes(files).looks }));
|
|
99
126
|
});
|
|
100
127
|
},
|
|
101
128
|
});
|
|
@@ -8,6 +8,8 @@ import { soundQuality } from '../sound/cost.ts';
|
|
|
8
8
|
import { writeSoundQuality } from '../sound/settings.ts';
|
|
9
9
|
import { blenderEnv, blenderState, writeBlenderSettings } from '../model/blender.ts';
|
|
10
10
|
import { facesState, writeFacesSettings } from '../scene/faces-settings.ts';
|
|
11
|
+
import { installChromium } from '../commands/browsers.ts';
|
|
12
|
+
import { cli } from '../cli.ts';
|
|
11
13
|
|
|
12
14
|
const audioState = () => ({ quality: soundQuality(), env: process.env.DIORAMA_SOUND ?? null });
|
|
13
15
|
|
|
@@ -87,6 +89,29 @@ defineAction({
|
|
|
87
89
|
},
|
|
88
90
|
});
|
|
89
91
|
|
|
92
|
+
defineAction({
|
|
93
|
+
name: 'setup_cli',
|
|
94
|
+
reads: true,
|
|
95
|
+
description: 'The command that runs diorama in a terminal on this machine (diorama, npx @gtanczyk/diorama or npm run diorama --), for hints to the person.',
|
|
96
|
+
input: z.object({}),
|
|
97
|
+
async run() {
|
|
98
|
+
return { command: cli(), setup: cli('setup') };
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
let downloading: Promise<unknown> | null = null;
|
|
103
|
+
|
|
104
|
+
defineAction({
|
|
105
|
+
name: 'setup_browsers',
|
|
106
|
+
description: 'Download Chromium for Explore, the playthrough bot and sprites (Playwright, ~150 MB, once), then check the machine again. Ask the person first.',
|
|
107
|
+
input: z.object({}),
|
|
108
|
+
async run(_input, ctx) {
|
|
109
|
+
downloading ??= installChromium([], (line) => /\d%/.test(line) && !/100%/.test(line) ? undefined : ctx.events.publish('log', { stream: 'out', line: `chromium: ${line}` })).finally(() => (downloading = null));
|
|
110
|
+
await downloading;
|
|
111
|
+
return runChecks();
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
90
115
|
defineAction({
|
|
91
116
|
name: 'ui_focus',
|
|
92
117
|
description: 'Point the Studio view at a world, a screen or a place, so the person sees what the agent is talking about (when they follow the agent).',
|
package/app/src/actions/style.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
import { readCastFixes } from '../scene/cast-fixes.ts';
|
|
5
5
|
import { readCorrections } from '../scene/corrections.ts';
|
|
6
6
|
import { agentBlocked } from '../studio/locks.ts';
|
|
7
|
+
import { kidsWorld } from '../world/drawings.ts';
|
|
7
8
|
import { WorldFiles } from '../world/files.ts';
|
|
8
9
|
import { Slug } from '../world/schema.ts';
|
|
9
10
|
import { castKeys, planLine, SCOPES, stylePlan } from '../world/style-plan.ts';
|
|
@@ -22,7 +23,7 @@ defineAction({
|
|
|
22
23
|
const fixes = readCastFixes(files);
|
|
23
24
|
return {
|
|
24
25
|
style: world.style,
|
|
25
|
-
kids:
|
|
26
|
+
kids: kidsWorld(files, world),
|
|
26
27
|
places: Object.fromEntries(world.places.map((p) => [p.key, readCorrections(files.place(p.key)).style ?? null])),
|
|
27
28
|
cast: Object.fromEntries(castKeys(world, files).map((k) => [k, fixes[k]?.style ?? null])),
|
|
28
29
|
painted: readStyled(files),
|
|
@@ -53,7 +54,7 @@ defineAction({
|
|
|
53
54
|
if (scope === 'cast' && key && !castKeys(world, files).includes(key)) throw new ActionError(404, `${slug} has no character ${key}: ${castKeys(world, files).join(', ')}`);
|
|
54
55
|
if (scope === 'world' && key) throw new ActionError(400, 'scope world takes no key');
|
|
55
56
|
const plan = stylePlan(files, world, scope, key);
|
|
56
|
-
const kids =
|
|
57
|
+
const kids = kidsWorld(files, world);
|
|
57
58
|
if (!go) {
|
|
58
59
|
return {
|
|
59
60
|
estimate: plan,
|
package/app/src/cli.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// How to run diorama again from this shell, so every hint (setup fixes, errors, the Studio's Setup cards, the
|
|
2
|
+
// agent's prompt) shows a command that works: `npx @gtanczyk/diorama` when it was started through npx, `diorama`
|
|
3
|
+
// when the global install is on PATH, `npm run diorama --` from a checkout.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
9
|
+
const BIN = path.join(ROOT, 'app', 'bin', 'diorama.mjs');
|
|
10
|
+
const PACKAGE = '@gtanczyk/diorama';
|
|
11
|
+
|
|
12
|
+
const real = (p: string) => {
|
|
13
|
+
try {
|
|
14
|
+
return fs.realpathSync(p);
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** A `diorama` on PATH that is this very package (npx and npm scripts put their own node_modules/.bin first: not those). */
|
|
21
|
+
function globalOnPath(): boolean {
|
|
22
|
+
const mine = real(BIN);
|
|
23
|
+
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
|
|
24
|
+
if (!dir || dir.split(/[\\/]/).slice(-2).join('/') === 'node_modules/.bin') continue;
|
|
25
|
+
if (real(path.join(dir, 'diorama')) === mine) return true;
|
|
26
|
+
// Windows: npm's global shim sits next to the package's node_modules folder, not as a link.
|
|
27
|
+
if (process.platform === 'win32' && fs.existsSync(path.join(dir, 'diorama.cmd')) && real(path.join(dir, 'node_modules', '@gtanczyk', 'diorama')) === real(ROOT)) return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function detect(): string {
|
|
33
|
+
if (process.env.DIORAMA_CLI) return process.env.DIORAMA_CLI;
|
|
34
|
+
const parts = ROOT.split(/[\\/]/);
|
|
35
|
+
if (parts.includes('_npx')) return `npx ${PACKAGE}`;
|
|
36
|
+
if (globalOnPath()) return 'diorama';
|
|
37
|
+
if (!parts.includes('node_modules') && fs.existsSync(path.join(ROOT, '.git'))) return 'npm run diorama --';
|
|
38
|
+
return `npx ${PACKAGE}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let prefix: string | null = null;
|
|
42
|
+
|
|
43
|
+
/** The command to type for `diorama <rest>` here, e.g. cli('browsers') → `npx @gtanczyk/diorama browsers`. */
|
|
44
|
+
export function cli(rest = ''): string {
|
|
45
|
+
prefix ??= detect();
|
|
46
|
+
return rest ? `${prefix} ${rest}` : prefix;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Rewrites the `diorama <command>` a message names (a usage line, an error) into the command that works here. */
|
|
50
|
+
export function cliText(text: string): string {
|
|
51
|
+
if (cli() === 'diorama') return text;
|
|
52
|
+
return text.replace(/(^|usage: |`|run )diorama (?=[a-z])/g, (_, lead: string) => `${lead}${cli()} `);
|
|
53
|
+
}
|
|
@@ -1,21 +1,37 @@
|
|
|
1
1
|
// diorama browsers: downloads Chromium for the Playwright that ships with diorama, so explore, playthrough and
|
|
2
2
|
// sprites work the same from a checkout, a global install or npx (a bare `npx playwright` may fetch another version).
|
|
3
|
+
// `diorama setup` and the Studio's Setup page offer the same download, so nobody has to find this command.
|
|
3
4
|
import { spawn } from 'node:child_process';
|
|
4
5
|
import { createRequire } from 'node:module';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { defineCommand } from '../registry.ts';
|
|
7
8
|
|
|
9
|
+
/** Runs Playwright's installer for Chromium; `onLine` gets its output (the terminal gets it as is when absent). */
|
|
10
|
+
export async function installChromium(args: string[] = [], onLine?: (line: string) => void): Promise<void> {
|
|
11
|
+
const cli = path.join(path.dirname(createRequire(import.meta.url).resolve('playwright/package.json')), 'cli.js');
|
|
12
|
+
const code = await new Promise<number>((resolve) => {
|
|
13
|
+
// Loaded through -e so argv[1] is not the npx cache path, which makes Playwright warn about missing project dependencies.
|
|
14
|
+
const child = spawn(process.execPath, ['-e', `require(${JSON.stringify(cli)})`, 'diorama-browsers', 'install', 'chromium', ...args], { stdio: onLine ? ['ignore', 'pipe', 'pipe'] : 'inherit' });
|
|
15
|
+
if (onLine) {
|
|
16
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
17
|
+
let rest = '';
|
|
18
|
+
stream?.setEncoding('utf8').on('data', (chunk: string) => {
|
|
19
|
+
const lines = (rest + chunk).split(/\r?\n|\r/);
|
|
20
|
+
rest = lines.pop() ?? '';
|
|
21
|
+
for (const line of lines) if (line.trim()) onLine(line);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
child.on('error', () => resolve(1));
|
|
26
|
+
child.on('close', (c) => resolve(c ?? 1));
|
|
27
|
+
});
|
|
28
|
+
if (code !== 0) throw new Error(`playwright install chromium exited with ${code}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
8
31
|
defineCommand({
|
|
9
32
|
name: 'browsers',
|
|
10
33
|
usage: 'browsers download Chromium for explore, playthrough and sprites (Playwright, ~150 MB, once)',
|
|
11
34
|
async run(args) {
|
|
12
|
-
|
|
13
|
-
const code = await new Promise<number>((resolve) => {
|
|
14
|
-
// Loaded through -e so argv[1] is not the npx cache path, which makes Playwright warn about missing project dependencies.
|
|
15
|
-
const child = spawn(process.execPath, ['-e', `require(${JSON.stringify(cli)})`, 'diorama-browsers', 'install', 'chromium', ...args], { stdio: 'inherit' });
|
|
16
|
-
child.on('error', () => resolve(1));
|
|
17
|
-
child.on('close', (c) => resolve(c ?? 1));
|
|
18
|
-
});
|
|
19
|
-
if (code !== 0) throw new Error(`playwright install chromium exited with ${code}`);
|
|
35
|
+
await installChromium(args);
|
|
20
36
|
},
|
|
21
37
|
});
|
package/app/src/commands/clip.ts
CHANGED
|
@@ -149,6 +149,13 @@ async function gameTake(port: number, work: string, actions: Action[], zoom: boo
|
|
|
149
149
|
clicks.push({ wall: await rec.click(at.x, at.y, { ms: 450, dwell: 150 }), ...at, place: here, action: a });
|
|
150
150
|
continue;
|
|
151
151
|
}
|
|
152
|
+
if (a.kind === 'go' && a.to) {
|
|
153
|
+
// A car's destination is picked on the board the page shows over it.
|
|
154
|
+
const at = await page.evaluate(`window.diorama.destinationPoint(${JSON.stringify(a.to)})`) as { x: number; y: number } | null;
|
|
155
|
+
if (!at) throw new Error(`no destination ${a.to} on the board`);
|
|
156
|
+
clicks.push({ wall: await rec.click(at.x, at.y, { ms: 650 }), ...at, place: here, action: a });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
152
159
|
const step: { use: string; verb?: string; with?: string } = a.kind === 'go' ? { use: a.via } : a.kind === 'talk' ? { use: a.npc } : { use: a.thing, verb: a.verb, ...(a.with ? { with: a.with } : {}) };
|
|
153
160
|
// The bag is hidden in the clip: the item is lifted from it unseen and rides the cursor to its target.
|
|
154
161
|
const pick = async () => {
|
|
@@ -156,6 +156,15 @@ defineCommand({
|
|
|
156
156
|
});
|
|
157
157
|
console.log(` ${place.key}: closed ${faced.closed} walk cells on an upright face (feet over thin air)${faced.beyond ? ` and ${faced.beyond} only they led to` : ''}`);
|
|
158
158
|
}
|
|
159
|
+
if (place.kind === 'drive') {
|
|
160
|
+
// A drive's scenery streams past, so everything one clicks stays put as a card of its own (a road sign
|
|
161
|
+
// left in the scenery would slide away from where it is clicked).
|
|
162
|
+
const carded = new Set(geometry.occluders.map((o) => o.thing ?? o.key));
|
|
163
|
+
for (const t of geometry.things) {
|
|
164
|
+
if (carded.has(t.key) || !t.mask || t.role === 'npc') continue;
|
|
165
|
+
geometry.occluders.push({ key: `${t.key}#1`, thing: t.key, box: t.box, mask: t.mask, baseY: t.box[3] });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
159
168
|
files.write(at('geometry.json'), geometry);
|
|
160
169
|
files.write(at('walk.png'), await walkPicture(painting, geometry, out.footprint));
|
|
161
170
|
ran(at(), 'derive', carry?.old ?? painting);
|
|
@@ -9,6 +9,7 @@ import { CROP_JS, PROBE_JS, type ProbeHit } from '../qa/probe.ts';
|
|
|
9
9
|
import { defineCommand, takeOption } from '../registry.ts';
|
|
10
10
|
import { WorldFiles } from '../world/files.ts';
|
|
11
11
|
import { serveWorld } from './play.ts';
|
|
12
|
+
import { cli } from '../cli.ts';
|
|
12
13
|
|
|
13
14
|
export type Browser = { newPage(o?: object): Promise<Page>; close(): Promise<void> };
|
|
14
15
|
export type Page = {
|
|
@@ -26,7 +27,7 @@ export async function chromium(): Promise<{ launch(o?: object): Promise<Browser>
|
|
|
26
27
|
try {
|
|
27
28
|
return (createRequire(import.meta.url)('playwright') as { chromium: never }).chromium;
|
|
28
29
|
} catch {
|
|
29
|
-
throw new Error(
|
|
30
|
+
throw new Error(`this command needs Playwright with Chromium: ${cli('browsers')}`);
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
}
|
|
@@ -8,6 +8,7 @@ import { castFixesFile, readCastFixes } from '../scene/cast-fixes.ts';
|
|
|
8
8
|
import { SoundPlan } from '../sound/plan.ts';
|
|
9
9
|
import { readStoryFixes, StoryFixes, storyFixesFile, textAt } from '../story/fixes.ts';
|
|
10
10
|
import { Story } from '../story/schema.ts';
|
|
11
|
+
import { kidsWorld } from '../world/drawings.ts';
|
|
11
12
|
import { WorldFiles } from '../world/files.ts';
|
|
12
13
|
import { applyWorldTexts, languagePlan, planLine, relanguage, translateTexts, worldTexts } from '../world/language.ts';
|
|
13
14
|
import { placeSources, soundPlanFrom } from './sound.ts';
|
|
@@ -32,7 +33,7 @@ defineCommand({
|
|
|
32
33
|
|
|
33
34
|
if (!plan.same) {
|
|
34
35
|
const texts = worldTexts(world, story);
|
|
35
|
-
const kids =
|
|
36
|
+
const kids = kidsWorld(files, world);
|
|
36
37
|
console.log(`translating ${Object.keys(texts).length} texts from ${plan.from} to ${plan.to}`);
|
|
37
38
|
const translated = await translateTexts(files.memo, texts, plan.from, plan.to, kids);
|
|
38
39
|
const now = applyWorldTexts(world, story, translated, plan.to);
|
|
@@ -3,8 +3,10 @@ import fs from 'node:fs';
|
|
|
3
3
|
import { renderBlockin, renderPlan } from '../scene/blockin.ts';
|
|
4
4
|
import { readCorrections } from '../scene/corrections.ts';
|
|
5
5
|
import { layOut } from '../scene/layout.ts';
|
|
6
|
+
import { framePhoto, placePicture } from '../scene/photo.ts';
|
|
6
7
|
import { defineCommand } from '../registry.ts';
|
|
7
8
|
import { WorldFiles } from '../world/files.ts';
|
|
9
|
+
import { placeSketch } from '../world/drawings.ts';
|
|
8
10
|
import { approvedConcept } from '../world/sketch.ts';
|
|
9
11
|
|
|
10
12
|
defineCommand({
|
|
@@ -16,10 +18,13 @@ defineCommand({
|
|
|
16
18
|
const world = files.world();
|
|
17
19
|
const places = world.places.filter((p) => only.length === 0 || only.includes(p.key));
|
|
18
20
|
await Promise.all(places.map(async (place) => {
|
|
19
|
-
// A place made from a photo is measured from it, and the photo stands in for the block-in.
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
21
|
+
// A place made from a photo is measured from it, and the photo stands in for the block-in. So does a
|
|
22
|
+
// grown-up's sketch of the place: drawn in perspective, it is the camera Muse would paint anyway.
|
|
23
|
+
const sketch = !fs.existsSync(files.place(place.key, 'photo.png')) && !fs.existsSync(files.place(place.key, 'sketch.png')) ? placeSketch(files, place.key) : undefined;
|
|
24
|
+
if (sketch) files.write(files.place(place.key, 'sketch.png'), (await framePhoto(sketch)).image);
|
|
25
|
+
const picture = placePicture(files.place(place.key));
|
|
26
|
+
const photo = picture && fs.readFileSync(picture.file);
|
|
27
|
+
const layout = await layOut(files.memo, world, place, photo, readCorrections(files.place(place.key)).layout?.guidance, approvedConcept(files, 'place', place.key), picture?.kind);
|
|
23
28
|
files.write(files.place(place.key, 'layout.json'), layout);
|
|
24
29
|
const blockin = photo ?? await renderBlockin(layout, place);
|
|
25
30
|
files.write(files.place(place.key, 'blockin.png'), blockin);
|
|
@@ -11,6 +11,7 @@ import { locateThings, markFloors } from '../scene/locate.ts';
|
|
|
11
11
|
import { checkMasks, type MaskCheck } from '../scene/maskcheck.ts';
|
|
12
12
|
import { maskFloors, maskThings } from '../scene/masks.ts';
|
|
13
13
|
import { paintingLayoutHash } from '../scene/paint-layout.ts';
|
|
14
|
+
import { placePicture } from '../scene/photo.ts';
|
|
14
15
|
import { maskBox, type Rect } from '../image/raster.ts';
|
|
15
16
|
import { WorldFiles } from '../world/files.ts';
|
|
16
17
|
|
|
@@ -30,7 +31,7 @@ defineCommand({
|
|
|
30
31
|
const painting = fs.readFileSync(at('painting.png'));
|
|
31
32
|
// A rejected/stale checked painting must not be rescued by masks and invented floor strokes.
|
|
32
33
|
// Legacy worlds without a report remain editable; rebuilding from paint checks them too.
|
|
33
|
-
if (!
|
|
34
|
+
if (!placePicture(at()) && fs.existsSync(at('painting-layout.json'))) {
|
|
34
35
|
const report = files.readJson(at('painting-layout.json')) as { ok?: boolean; inputs?: string };
|
|
35
36
|
if (!report.ok || report.inputs !== paintingLayoutHash(place, layout, fs.readFileSync(at('blockin.png')), painting)) {
|
|
36
37
|
throw new Error(`PAINT_LAYOUT_MISMATCH ${place.key}: painting/layout changed or its composition check failed; rebuild this place from paint to inspect and repair it before masks`);
|
|
@@ -8,8 +8,9 @@ import { addsPerson, critique, paintedPeople, repaint } from '../scene/critic.ts
|
|
|
8
8
|
import { readCorrections, writeCorrections } from '../scene/corrections.ts';
|
|
9
9
|
import { Layout } from '../scene/layout.ts';
|
|
10
10
|
import { paintPlace } from '../scene/paint.ts';
|
|
11
|
+
import { heroVehicle } from '../scene/vehicle.ts';
|
|
11
12
|
import { alignPainting, PaintingLayoutError, paintingLayoutHash } from '../scene/paint-layout.ts';
|
|
12
|
-
import { paintPhoto } from '../scene/photo.ts';
|
|
13
|
+
import { paintPhoto, placePicture } from '../scene/photo.ts';
|
|
13
14
|
import { restylePlace } from '../scene/restyle.ts';
|
|
14
15
|
import { refusedByFilter, reword, withWords, worded, type Words } from '../scene/words.ts';
|
|
15
16
|
import { defineCommand, takeOption } from '../registry.ts';
|
|
@@ -21,6 +22,9 @@ import { markStyled, placeStyle, readStyled } from '../world/styled.ts';
|
|
|
21
22
|
/** Rewords Spark may try after the image safety filter refuses a painting, before the place is set aside. */
|
|
22
23
|
const REWORDS = 2;
|
|
23
24
|
|
|
25
|
+
/** From this new take on, a place is painted without its approved concept (see paint below). */
|
|
26
|
+
const CONCEPT_TAKES = 2;
|
|
27
|
+
|
|
24
28
|
defineCommand({
|
|
25
29
|
name: 'paint',
|
|
26
30
|
usage: 'paint <slug> [place...] brief, paint, critique and repaint laid-out places [--take N]',
|
|
@@ -39,8 +43,12 @@ defineCommand({
|
|
|
39
43
|
const world = { ...base, style };
|
|
40
44
|
const layout = Layout.parse(files.readJson(files.place(place.key, 'layout.json')));
|
|
41
45
|
const blockin = fs.readFileSync(files.place(place.key, 'blockin.png'));
|
|
42
|
-
const
|
|
43
|
-
const
|
|
46
|
+
const picture = placePicture(files.place(place.key));
|
|
47
|
+
const fromPhoto = !!picture;
|
|
48
|
+
// An approved concept steers Muse's camera toward its own; when it keeps pulling the painting off the plan
|
|
49
|
+
// (a second new take was asked for), the place is painted from the block-in alone.
|
|
50
|
+
const concept = take >= CONCEPT_TAKES ? undefined : approvedConcept(files, 'place', place.key);
|
|
51
|
+
if (take >= CONCEPT_TAKES && approvedConcept(files, 'place', place.key)) console.log(` ${place.key}: take ${take}, painted from the block-in without its concept`);
|
|
44
52
|
const styleReference = worldStyleReference(files, style);
|
|
45
53
|
// The first painting is kept while its block-in, layout and take stay the same, even without the model
|
|
46
54
|
// cache (a world copied without memo/): a person's region repaint must not repaint the whole place.
|
|
@@ -90,7 +98,7 @@ defineCommand({
|
|
|
90
98
|
const again = await critique(files.memo, worded(place, words), brief, first);
|
|
91
99
|
// People are looked for on the painting as the game shows it, with every fix in, not only the first one.
|
|
92
100
|
const shown = fs.existsSync(files.place(place.key, 'painting.png')) ? fs.readFileSync(files.place(place.key, 'painting.png')) : first;
|
|
93
|
-
for (const p of shown === first ? [] : await paintedPeople(files.memo, place.key, shown)) {
|
|
101
|
+
for (const p of shown === first || place.kind === 'drive' ? [] : await paintedPeople(files.memo, place.key, shown)) {
|
|
94
102
|
again.issues.push({ key: 'person', problem: 'unwanted', region: p.box, fix: `Paint out the ${p.what}: continue what is behind them (planks, water, wall), add nothing new` });
|
|
95
103
|
}
|
|
96
104
|
const had = new Set(found.issues.map((i) => JSON.stringify([i.region, i.fix])));
|
|
@@ -110,9 +118,9 @@ defineCommand({
|
|
|
110
118
|
brief = withWords(await writeBrief(files.memo, world, told, layout), words);
|
|
111
119
|
files.write(files.place(place.key, 'brief.json'), brief);
|
|
112
120
|
try {
|
|
113
|
-
first =
|
|
114
|
-
? await paintPhoto(files.memo, world, told, blockin, brief, take, concept, styleReference)
|
|
115
|
-
: await paintPlace(files.memo, world, told, layout, blockin, brief, take, drawingsIn(files, place), concept, styleReference);
|
|
121
|
+
first = picture
|
|
122
|
+
? await paintPhoto(files.memo, world, told, blockin, brief, take, concept, styleReference, picture.kind)
|
|
123
|
+
: await paintPlace(files.memo, world, told, layout, blockin, brief, take, drawingsIn(files, place), concept, styleReference, await heroVehicle(files, world, place));
|
|
116
124
|
break;
|
|
117
125
|
} catch (error) {
|
|
118
126
|
if (!refusedByFilter(error)) throw error;
|
|
@@ -13,12 +13,12 @@ import { WorldFiles } from '../world/files.ts';
|
|
|
13
13
|
import { chromium } from './explore.ts';
|
|
14
14
|
import { serveWorld } from './play.ts';
|
|
15
15
|
|
|
16
|
-
type Step = { use: string; verb?: string; with?: string } | { act: Action } | { wait: number };
|
|
16
|
+
type Step = { use: string; verb?: string; with?: string; to?: string } | { act: Action } | { wait: number };
|
|
17
17
|
|
|
18
18
|
/** A story action as what a player does on the page: click the thing, or pick an answer. */
|
|
19
19
|
export function toStep(action: Action): Step {
|
|
20
20
|
switch (action.kind) {
|
|
21
|
-
case 'go': return { use: action.via };
|
|
21
|
+
case 'go': return { use: action.via, ...(action.to && { to: action.to }) };
|
|
22
22
|
case 'talk': return { use: action.npc };
|
|
23
23
|
case 'do': return { use: action.thing, verb: action.verb, ...(action.with ? { with: action.with } : {}) };
|
|
24
24
|
default: return { act: action };
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { defineCommand, takeOption } from '../registry.ts';
|
|
3
3
|
import { findVault, NO_VAULT, SECRET_NAMES, SECRETS, SecretStore, type SecretName } from '../secrets.ts';
|
|
4
4
|
import { runChecks, type Check } from '../setup/checks.ts';
|
|
5
|
+
import { installChromium } from './browsers.ts';
|
|
6
|
+
import { cli } from '../cli.ts';
|
|
5
7
|
|
|
6
8
|
const MARK: Record<Check['state'], string> = { ok: ' ok ', missing: ' MISSING ', optional: ' - ', fail: ' FAIL ' };
|
|
7
9
|
|
|
@@ -15,7 +17,7 @@ export function printChecks(checks: Check[]) {
|
|
|
15
17
|
/** Reads a line without echoing it. Only from a terminal: a pasted key never lands in shell history or argv. */
|
|
16
18
|
function askHidden(question: string): Promise<string> {
|
|
17
19
|
const stdin = process.stdin;
|
|
18
|
-
if (!stdin.isTTY) throw new Error('
|
|
20
|
+
if (!stdin.isTTY) throw new Error(`${cli('setup')} reads keys from a terminal only; or set them as environment variables`);
|
|
19
21
|
process.stdout.write(question);
|
|
20
22
|
return new Promise((resolve, reject) => {
|
|
21
23
|
let value = '';
|
|
@@ -42,6 +44,17 @@ function askHidden(question: string): Promise<string> {
|
|
|
42
44
|
});
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
/** A yes/no question in the terminal; Enter means yes. */
|
|
48
|
+
async function askYes(question: string): Promise<boolean> {
|
|
49
|
+
const { createInterface } = await import('node:readline/promises');
|
|
50
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
51
|
+
try {
|
|
52
|
+
return !/^n/i.test((await rl.question(question)).trim());
|
|
53
|
+
} finally {
|
|
54
|
+
rl.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
async function storeKey(name: SecretName, remove: boolean) {
|
|
46
59
|
const vault = await findVault();
|
|
47
60
|
if (!vault) throw new Error(NO_VAULT);
|
|
@@ -79,6 +92,17 @@ defineCommand({
|
|
|
79
92
|
console.log('');
|
|
80
93
|
printChecks(checks);
|
|
81
94
|
}
|
|
95
|
+
// Chromium is one download: offer it here instead of sending the person off to find another command.
|
|
96
|
+
if (process.stdin.isTTY && checks.some((c) => c.id === 'chromium' && c.state === 'missing') && (await askYes('\nDownload Chromium for Explore and Playthrough now (~150 MB, once)? [Y/n] '))) {
|
|
97
|
+
try {
|
|
98
|
+
await installChromium();
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.log(`Chromium download failed (${error instanceof Error ? error.message : error}); try again with ${cli('browsers')}`);
|
|
101
|
+
}
|
|
102
|
+
checks = await runChecks({ live });
|
|
103
|
+
console.log('');
|
|
104
|
+
printChecks(checks);
|
|
105
|
+
}
|
|
82
106
|
const blocking = checks.filter((c) => c.state === 'missing' || c.state === 'fail');
|
|
83
107
|
console.log(blocking.length ? `\n${blocking.length} to fix before a full build: ${blocking.map((c) => c.label).join(', ')}` : '\nready to build');
|
|
84
108
|
},
|
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import { defineCommand, takeOption } from '../registry.ts';
|
|
4
4
|
import { describePerson, describePhoto, framePhoto, normalisePhoto } from '../scene/photo.ts';
|
|
5
5
|
import { designWorld, type Extra } from '../world/design.ts';
|
|
6
|
-
import { drawingFiles, drawingsBrief, drawnIssues, fitPlaces, keepDrawings, KIDS_STYLE, oneHero, readDrawings, readKinds } from '../world/drawings.ts';
|
|
6
|
+
import { drawingFiles, drawingsBrief, drawnIssues, fitPlaces, grownUpDrawings, keepDrawings, KIDS_STYLE, oneHero, readDrawings, readKinds, sketchStyle } from '../world/drawings.ts';
|
|
7
7
|
import { WorldFiles } from '../world/files.ts';
|
|
8
8
|
import { hearAll, Idea, ideaFromTold, keepTold } from '../world/told.ts';
|
|
9
9
|
import { CreativeChoices } from '../world/idea-review.ts';
|
|
@@ -11,7 +11,7 @@ import { CreativeChoices } from '../world/idea-review.ts';
|
|
|
11
11
|
defineCommand({
|
|
12
12
|
name: 'world',
|
|
13
13
|
usage: 'world <slug> "<idea>" design a world (world.json) with Muse Spark [--places N] [--photo room.jpg: a real room is the first place] [--hero-photo me.jpg: a real person is the hero] '
|
|
14
|
-
+ '[--drawings DIR: photos of children\'s
|
|
14
|
+
+ '[--drawings DIR: photos of drawings (children\'s, or a grown-up\'s sketches, which keep their own medium and make no kids\' game) become the hero, NPCs, items and places] [--told DIR: recordings of them telling the story; without an idea, the idea is written from them] [--kids: a game for 5-7 year olds; on with drawings or recordings] [--language Polish: the game\'s text and voices; with recordings, the language spoken]',
|
|
15
15
|
async run(args) {
|
|
16
16
|
const places = Number(takeOption(args, 'places', '4'));
|
|
17
17
|
const photoFile = takeOption(args, 'photo', '');
|
|
@@ -41,7 +41,10 @@ export async function designInitialWorld(files: WorldFiles, { places, photoFile
|
|
|
41
41
|
// A drawn place over the places asked for goes into a place as a prop: places are what makes a build long.
|
|
42
42
|
const drawn = fitPlaces(await readDrawings(files.memo, files), places);
|
|
43
43
|
for (const d of drawn) console.log(`drawing ${d.file}: ${d.kind} ${d.key}, ${d.looks}`);
|
|
44
|
-
|
|
44
|
+
// A grown-up's sketches make a world in their own medium, not a kids' game.
|
|
45
|
+
const sketched = grownUpDrawings(drawn);
|
|
46
|
+
const drawnStyle = sketched ? sketchStyle(drawn) : KIDS_STYLE;
|
|
47
|
+
const kids = kidsFlag || (drawn.length > 0 && !sketched) || said.length > 0;
|
|
45
48
|
|
|
46
49
|
let idea = given;
|
|
47
50
|
// The story says who leads, unless the grown-up already picked a drawing as the hero.
|
|
@@ -83,11 +86,11 @@ export async function designInitialWorld(files: WorldFiles, { places, photoFile
|
|
|
83
86
|
const characters = drawn.filter(d => d.kind === 'hero' || d.kind === 'npc').map(d => d.key);
|
|
84
87
|
const chooseHero = !!(choices?.answers.length || choices?.note) && characters.length > 1 && !drawn.some(d => readKinds(files)[d.file] === 'hero');
|
|
85
88
|
const asks = [
|
|
86
|
-
drawn.length ? drawingsBrief(drawn, chosenStyle ??
|
|
89
|
+
drawn.length ? drawingsBrief(drawn, chosenStyle ?? drawnStyle, chooseHero) : '',
|
|
87
90
|
chosenStyle ? `The creator explicitly chose this visual style: ${chosenStyle}. Preserve recognizable shapes and identities from all source drawings.` : '',
|
|
88
91
|
other ? `Write the title and pitch in ${other}, the language of the players; everything else (style, shows, mood, looks, keys) stays in English for the painters.` : '',
|
|
89
92
|
// The game shows a name over each speaker: in the players' language, as they call them.
|
|
90
|
-
other || kids ? `Add "names": {"<key>": "name"} for the hero and every npc: a short name ${other ? `in ${other} ` : ''}the players would call them by (the name the children gave them, if they gave one; else a short friendly name a child would give).${drawn.length ? ` Name every drawn item and place too, as a child would say it${other ? ` in ${other}` : ''} ("the blue hat", "the mushroom forest").` : ''}` : '',
|
|
93
|
+
other || kids ? `Add "names": {"<key>": "name"} for the hero and every npc: a short name ${other ? `in ${other} ` : ''}the players would call them by (the name ${kids ? 'the children' : 'the creator'} gave them, if they gave one; else a short ${kids ? 'friendly name a child would give' : 'name that fits the world'}).${drawn.length ? ` Name every drawn item and place too, as ${kids ? 'a child' : 'a player'} would say it${other ? ` in ${other}` : ''} ("the blue hat", "the mushroom forest").` : ''}` : '',
|
|
91
94
|
].filter(Boolean);
|
|
92
95
|
const extra: Extra | undefined = asks.length ? { brief: asks.join('\n\n'), issues: (w) => {
|
|
93
96
|
if (!chooseHero) return drawnIssues(w, drawn);
|
|
@@ -96,7 +99,7 @@ export async function designInitialWorld(files: WorldFiles, { places, photoFile
|
|
|
96
99
|
} } : undefined;
|
|
97
100
|
const designed = await designWorld(files.memo, hero ? `${idea}\nThe hero is ${hero.looks}` : idea, places, room, extra);
|
|
98
101
|
if (chooseHero) oneHero(drawn, designed.hero.key);
|
|
99
|
-
const world = { ...designed, ...(drawn.length && { style:
|
|
102
|
+
const world = { ...designed, ...(drawn.length && { style: drawnStyle }), ...(chosenStyle && { style: chosenStyle }), ...(hero && { hero }), ...(other && { language: other }), ...(kids && { audience: 'kids' as const }) };
|
|
100
103
|
files.write(files.path('world.json'), world);
|
|
101
104
|
if (drawingFiles(files).length) files.write(files.path('drawings.json'), drawn);
|
|
102
105
|
console.log(`${world.title}: ${world.places.map((p) => p.key).join(' · ')}`);
|
package/app/src/main.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import './commands/index.ts';
|
|
2
2
|
import { registry } from './registry.ts';
|
|
3
3
|
import { loadSecrets, redact } from './secrets.ts';
|
|
4
|
+
import { cli, cliText } from './cli.ts';
|
|
4
5
|
|
|
5
6
|
function help() {
|
|
6
|
-
console.log(
|
|
7
|
+
console.log(`diorama: a story prompt becomes painted scenes you can walk in\n\nusage: ${cli('<command> [args]')}\n`);
|
|
7
8
|
for (const cmd of registry.values()) console.log(` ${cmd.usage}`);
|
|
8
|
-
console.log(
|
|
9
|
+
console.log(`\nfirst time: ${cli('setup')} (keys, what is missing), then ${cli('studio')}. Worlds are written to ./worlds here.`);
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
const [name, ...args] = process.argv.slice(2);
|
|
@@ -15,7 +16,7 @@ if (!cmd) {
|
|
|
15
16
|
process.exit(!name || name === 'help' ? 0 : 2);
|
|
16
17
|
}
|
|
17
18
|
if (args.includes('--help') || args.includes('-h')) {
|
|
18
|
-
console.log(`usage:
|
|
19
|
+
console.log(`usage: ${cli(cmd.usage)}`);
|
|
19
20
|
process.exit(0);
|
|
20
21
|
}
|
|
21
22
|
try {
|
|
@@ -23,6 +24,6 @@ try {
|
|
|
23
24
|
if (name !== 'setup') await loadSecrets();
|
|
24
25
|
await cmd.run(args);
|
|
25
26
|
} catch (error) {
|
|
26
|
-
console.error(redact(`diorama ${name}: ${error instanceof Error ? error.message : error}`));
|
|
27
|
+
console.error(redact(`diorama ${name}: ${cliText(error instanceof Error ? error.message : String(error))}`));
|
|
27
28
|
process.exit(1);
|
|
28
29
|
}
|
package/app/src/qa/route.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Page } from 'playwright';
|
|
2
2
|
import type { Action } from '../story/engine.ts';
|
|
3
3
|
|
|
4
|
-
type Step = { use: string; verb?: string; with?: string } | { act: Action } | { wait: number };
|
|
4
|
+
type Step = { use: string; verb?: string; with?: string; to?: string } | { act: Action } | { wait: number };
|
|
5
5
|
|
|
6
6
|
/** Follow a route through the same pointer targets, inventory and answers as a person. */
|
|
7
7
|
export async function playRoute(page: Page, steps: Step[]): Promise<{ ending: string | null; frames: number; time: number }> {
|
|
@@ -20,6 +20,14 @@ export async function playRoute(page: Page, steps: Step[]): Promise<{ ending: st
|
|
|
20
20
|
await page.locator(`#talk button[data-action=${JSON.stringify(action)}]`).click();
|
|
21
21
|
} else {
|
|
22
22
|
if (step.with) await page.locator(`#inventory button[data-item=${JSON.stringify(step.with)}]`).click();
|
|
23
|
+
if (step.to) {
|
|
24
|
+
// One thing that leads to several places (a car): its destination is picked on the board the page shows.
|
|
25
|
+
const at = await page.evaluate(`window.diorama.destinationPoint(${JSON.stringify(step.to)})`) as { x: number; y: number } | null;
|
|
26
|
+
if (!at) throw new Error(`No destination ${step.to} on the board for ${step.use}`);
|
|
27
|
+
await page.mouse.click(at.x, at.y);
|
|
28
|
+
await settled();
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
23
31
|
const point = await aimPoint(page, step);
|
|
24
32
|
if (!point) throw new Error(`No clickable pixel for ${step.use}: its pointer target is hidden or covered`);
|
|
25
33
|
await page.mouse.click(point.x, point.y, { button: step.verb === 'look' ? 'right' : 'left' });
|
|
@@ -83,7 +91,9 @@ export function aimPoint(page: Page, step: { use: string; verb?: string; with?:
|
|
|
83
91
|
const rect = canvas.getBoundingClientRect();
|
|
84
92
|
const box = game.pageRect(step.use) ?? rect;
|
|
85
93
|
const hits = (x, y) => {
|
|
86
|
-
|
|
94
|
+
// In frame pixels: the canvas holds fewer when the page lowers its resolution on a slow machine.
|
|
95
|
+
const frame = game.place.g.frame;
|
|
96
|
+
const at = game.unzoom((x - rect.left) / rect.width * frame.width, (y - rect.top) / rect.height * frame.height);
|
|
87
97
|
return game.thingAt(...at)?.thing.key === step.use && document.elementFromPoint(x, y) === canvas;
|
|
88
98
|
};
|
|
89
99
|
const cx = box.left + box.width / 2, cy = box.top + box.height / 2;
|
package/app/src/scene/cast.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { STYLE_REFERENCE_PROMPT } from '../world/style-reference.ts';
|
|
2
|
+
import { drawers } from '../world/drawings.ts';
|
|
2
3
|
// The hero and every NPC as one painted figure each: Muse paints them alone on a plain ground, SAM cuts them out.
|
|
3
4
|
import sharp from 'sharp';
|
|
4
5
|
import type { Mask } from '../image/mask.ts';
|
|
@@ -76,9 +77,10 @@ Style: ${world.style}
|
|
|
76
77
|
Nothing they sat or lay on: no bed, headboard, pillow, chair or blanket. Plain flat light grey background, no floor, no shadow, no other people, no props around, no text.`;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
/** For a child's
|
|
80
|
+
/** For a drawing (a child's, or an artist's sketch): the character as drawn, made whole enough to walk. */
|
|
80
81
|
export function drawingPrompt(world: World, who: Who): string {
|
|
81
|
-
|
|
82
|
+
const drawn = drawers(world);
|
|
83
|
+
return `This is ${drawn.one} on paper. Paint the character in it as a single full-body character for a 2D point-and-click adventure game, so ${drawn.who} knows it at once: the same colours, the same clothes and features, the same shapes and proportions (a big head stays big), only cleaner and whole: every part coloured in solidly, with clear outlines and no white paper showing through, a finished character, not a sketch. Standing in a relaxed walking-ready pose, seen from the side in three-quarter view, facing to the right, the whole figure visible from the top of the head down to both feet (or paws), centred, about two thirds of the image tall.
|
|
82
84
|
|
|
83
85
|
Character: ${who.looks}
|
|
84
86
|
Style: ${world.style}
|
package/app/src/scene/critic.ts
CHANGED
|
@@ -32,7 +32,7 @@ List at most 4 problems a player would notice, worst first:
|
|
|
32
32
|
- missing: a listed thing is not there, or a big part of the scene above is (a tall tower, a castle with two towers, a house on top): key "scene" for that
|
|
33
33
|
- wrong: it is there but does not look like its description
|
|
34
34
|
- flat: it is a plain block or smear instead of a painted object
|
|
35
|
-
- unwanted: people (any person or figure at all${cast.length ? `, even one that looks like ${cast.join(' or ')}: they are added over the picture later, so a painted one would be a double` : ''}), ${paintedAnimals(brief).length ? 'animals other than the listed ones' : 'animals'}, text, letters or a border that should not be there
|
|
35
|
+
- unwanted: people (any person or figure at all${place.kind === 'drive' ? ' other than the hero at the wheel of the car, who belongs there' : ''}${cast.length ? `, even one that looks like ${cast.join(' or ')}: they are added over the picture later, so a painted one would be a double` : ''}), ${paintedAnimals(brief).length ? 'animals other than the listed ones' : 'animals'}, text, letters or a border that should not be there
|
|
36
36
|
For each give the region to repaint as [x0, y0, x1, y1] in pixels and one sentence on what to paint there. Never ask for text or letters. If the painting is fine, give no issues.
|
|
37
37
|
|
|
38
38
|
Never ask to paint a person in: the game adds its people over the picture.
|
|
@@ -42,7 +42,8 @@ Reply with JSON only: {"issues": [{"key": "...", "problem": "missing | wrong | f
|
|
|
42
42
|
const found = { issues: parsed.issues.filter((i) => !addsPerson(i)) };
|
|
43
43
|
// The critic weighs the whole picture and lets a figure pass when the place's words name one ("a child floats
|
|
44
44
|
// candle boats"); people are asked about on their own, as every one of them is a double or a stranger.
|
|
45
|
-
|
|
45
|
+
// A drive's hero is painted at the wheel on purpose: no one is looked for there.
|
|
46
|
+
const people = place.kind === 'drive' ? [] : await paintedPeople(memo, place.key, painting);
|
|
46
47
|
const near = (a: number[], b: number[]) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) < 120;
|
|
47
48
|
const extra = people.filter((p) => !found.issues.some((i) => i.problem === 'unwanted' && near(i.region, p.box))).map((p) => ({
|
|
48
49
|
key: 'person', problem: 'unwanted' as const, region: pad(p.box, 24),
|