@gtanczyk/diorama 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/app/src/actions/fix.ts +28 -10
- package/app/src/actions/story.ts +28 -1
- package/app/src/actions/style.ts +3 -2
- package/app/src/commands/backdrop.ts +23 -3
- package/app/src/commands/clip.ts +7 -0
- package/app/src/commands/derive.ts +9 -0
- package/app/src/commands/export.ts +1 -0
- 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/play.ts +1 -0
- package/app/src/commands/playthrough.ts +2 -2
- package/app/src/commands/world.ts +9 -6
- package/app/src/play/car.ts +2 -0
- package/app/src/play/geometry.ts +12 -2
- package/app/src/play/parallax.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/derive.ts +46 -1
- package/app/src/scene/layers.ts +52 -0
- 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/scene/verify.ts +12 -0
- package/app/src/setup/checks.ts +2 -2
- 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 +21 -8
- package/app/src/studio/repair.ts +1 -1
- 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/web/living.ts +36 -0
- package/app/web/main.ts +192 -18
- package/docs/releasing.md +22 -0
- package/docs/scenes.md +10 -1
- package/docs/studio.md +3 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -113,6 +113,7 @@ Model answers are memoised by a hash of their inputs, so running a step again wi
|
|
|
113
113
|
- [Characters, style and icons](docs/characters.md)
|
|
114
114
|
- [NPC story stages, optional walks and travel](docs/npc-motion.md)
|
|
115
115
|
- [Sound and voices](docs/sound.md)
|
|
116
|
+
- [Releasing and the changelog](docs/releasing.md) ([CHANGELOG.md](CHANGELOG.md))
|
|
116
117
|
|
|
117
118
|
## Built at the Meta AI Global Hackathon
|
|
118
119
|
|
package/app/src/actions/fix.ts
CHANGED
|
@@ -22,19 +22,11 @@ async function fixPlace(ctx: ActionContext, slug: string, place: string, from: '
|
|
|
22
22
|
const dir = path.join(ctx.root, slug);
|
|
23
23
|
const blocked = agentBlocked(dir, ctx.by, place);
|
|
24
24
|
if (blocked) throw new ActionError(423, blocked);
|
|
25
|
-
|
|
26
|
-
if (ctx.by === 'agent') {
|
|
27
|
-
const since = new Date(Date.now() - 30 * 60_000).toISOString();
|
|
28
|
-
const tries = ctx.jobs.list(slug).filter((j) => j.by === 'agent' && j.args.includes('--place') && j.args[j.args.indexOf('--place') + 1] === place && (j.queuedAt ?? '') >= since);
|
|
29
|
-
const lastOk = tries.findLastIndex((j) => j.state === 'done');
|
|
30
|
-
if (tries.slice(lastOk + 1).filter((j) => j.state === 'failed').length >= 3) {
|
|
31
|
-
throw new ActionError(429, `three fixes of ${place} in a row left it unplayable: stop here and tell the person what you tried and what you would do next`);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
25
|
+
const note = ctx.by === 'agent' ? repairGuard(ctx, slug, place, label, from) : undefined;
|
|
34
26
|
const placeDir = path.join(dir, 'places', place);
|
|
35
27
|
const needs = { layout: null, paint: 'blockin.png', masks: 'painting.png', derive: 'masks.json' }[from];
|
|
36
28
|
if (needs && !fs.existsSync(path.join(placeDir, needs))) throw new ActionError(404, `${slug}/${place} has no ${needs} yet: build it first`);
|
|
37
|
-
|
|
29
|
+
const job = ctx.jobs.start(slug, ['build', slug, '--from', from, '--to', 'verify', '--place', place], `${label} · ${place}`, ctx.by, () => {
|
|
38
30
|
// The job snapshots first, then changes its source. Queued fixes must not change a running build.
|
|
39
31
|
fs.mkdirSync(placeDir, { recursive: true });
|
|
40
32
|
const fixes = readCorrections(placeDir);
|
|
@@ -42,6 +34,32 @@ async function fixPlace(ctx: ActionContext, slug: string, place: string, from: '
|
|
|
42
34
|
writeCorrections(placeDir, Corrections.parse(fixes));
|
|
43
35
|
fs.rmSync(path.join(placeDir, 'verify.json'), { force: true });
|
|
44
36
|
}, addedThing ? { place, key: addedThing } : undefined);
|
|
37
|
+
return note ? { ...job, note } : job;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Fixes that repaint or relayout cost a Muse call; cheap ones only rerun SAM and the walk grid. */
|
|
41
|
+
const COSTLY = new Set(['layout', 'paint']);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The agent's repair guardrail. A streak of fixes that left the place unplayable does not stop the agent: it steers it.
|
|
45
|
+
* After three, a fix that already failed in the streak is refused (it would fail the same way) and a new path goes
|
|
46
|
+
* ahead with a short reminder; after six, costly repaints and relayouts wait for the person, while cheap fixes stay open.
|
|
47
|
+
*/
|
|
48
|
+
function repairGuard(ctx: ActionContext, slug: string, place: string, label: string, from: string): string | undefined {
|
|
49
|
+
const since = new Date(Date.now() - 30 * 60_000).toISOString();
|
|
50
|
+
const tries = ctx.jobs.list(slug).filter((j) => j.by === 'agent' && j.args.includes('--place') && j.args[j.args.indexOf('--place') + 1] === place && (j.queuedAt ?? '') >= since);
|
|
51
|
+
const lastOk = tries.findLastIndex((j) => j.state === 'done');
|
|
52
|
+
const failed = tries.slice(lastOk + 1).filter((j) => j.state === 'failed');
|
|
53
|
+
if (failed.length < 3) return undefined;
|
|
54
|
+
const tried = [...new Set(failed.map((j) => j.label.replace(` · ${place}`, '')))];
|
|
55
|
+
const paths = 'other paths: mask_brush (paint or erase a thing\'s mask by hand), floor_cells (add or take back 60 px floor cells), stand_set or hero_start_set, thing_remove, corrections_clear of a fix that made it worse, or ask_person';
|
|
56
|
+
if (tried.includes(label)) {
|
|
57
|
+
throw new ActionError(429, `${place}: "${label}" already failed in this streak (${failed.length} fixes in a row left it unplayable: ${tried.join(', ')}). Do not repeat it; look with place_view (walk, masks) and take a different path. ${paths}.`);
|
|
58
|
+
}
|
|
59
|
+
if (failed.length >= 6 && COSTLY.has(from)) {
|
|
60
|
+
throw new ActionError(429, `${place}: ${failed.length} fixes in a row left it unplayable (${tried.join(', ')}). A repaint or relayout now costs credits on a guess: ask_person first, or keep to the cheap fixes. ${paths}.`);
|
|
61
|
+
}
|
|
62
|
+
return `${failed.length} fixes of ${place} in a row left it unplayable (${tried.join(', ')}); this one is a new path. Check place_status after it ends; if it fails too, tell the person briefly what you tried.`;
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
const thingOf = (c: Corrections, key: string) => (c.things[key] ??= { strokes: [] });
|
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
|
});
|
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,
|
|
@@ -9,7 +9,7 @@ import { Geometry } from '../play/geometry.ts';
|
|
|
9
9
|
import { defineCommand } from '../registry.ts';
|
|
10
10
|
import { BACKDROP_GROW, backdropFor, smearedBackdrop, type Card } from '../scene/backdrop.ts';
|
|
11
11
|
import { carryBetween, carryFrom, paintingFor, ran, remakes, tilesLine, withChange } from '../scene/carry.ts';
|
|
12
|
-
import { cloudSheet, CRITTER, critterSheet, Depth, FALLING, fallSheet, farFlats, floatSheet, cloneFill, mistSheet, smokeSheet, waterSheet } from '../scene/layers.ts';
|
|
12
|
+
import { cloudSheet, CRITTER, critterSheet, Depth, FALLING, fallSheet, STREAM, streamSheet, farFlats, floatSheet, cloneFill, mistSheet, smokeSheet, waterSheet } from '../scene/layers.ts';
|
|
13
13
|
import { maskFromPng as waterMaskOf } from '../image/mask.ts';
|
|
14
14
|
import { WorldFiles } from '../world/files.ts';
|
|
15
15
|
|
|
@@ -80,8 +80,22 @@ defineCommand({
|
|
|
80
80
|
png = await sharp(cloneFill(raw, FRAME.width, FRAME.height, dilate(critterMask, 3)), { raw: { width: FRAME.width, height: FRAME.height, channels: 3 } }).png().toBuffer();
|
|
81
81
|
files.write(at('backdrop.png'), png);
|
|
82
82
|
}
|
|
83
|
+
// Small things painted falling in a stream (code rain, snow, petals) fall on the page (layers/stream-<i>.png):
|
|
84
|
+
// cut out of the backdrop here, each column falling at its own pace. Other things' masks stay put (a doorway
|
|
85
|
+
// inside the rain keeps what is seen through it).
|
|
86
|
+
const looks = (k: string) => place.things.find((t) => t.key === k)?.looks ?? '';
|
|
87
|
+
const streamKeys = Object.keys(notes).filter((k) => !keys.includes(k) && !critterKeys.includes(k) && STREAM.test(`${k} ${notes[k].phrase} ${looks(k)}`) && fs.existsSync(at('masks', `${k}.png`)));
|
|
88
|
+
const streams: Array<{ png: Buffer; region: Buffer; box: [number, number, number, number] }> = [];
|
|
89
|
+
for (const k of streamKeys) {
|
|
90
|
+
const others = await Promise.all(Object.keys(notes).filter((o) => o !== k && fs.existsSync(at('masks', `${o}.png`))).map(async (o) => maskFromPng(fs.readFileSync(at('masks', `${o}.png`)))));
|
|
91
|
+
const made = await streamSheet(png, await maskFromPng(fs.readFileSync(at('masks', `${k}.png`))), dilate(unionAll(FRAME.width, FRAME.height, others), 2));
|
|
92
|
+
if (!made) continue;
|
|
93
|
+
png = made.backdrop;
|
|
94
|
+
streams.push(made);
|
|
95
|
+
}
|
|
96
|
+
if (streams.length) files.write(at('backdrop.png'), png);
|
|
83
97
|
// Painted waterfalls pour (layers/fall-<i>.png); they stay out of the flat water below them.
|
|
84
|
-
const fallKeys = Object.keys(notes).filter((k) => !keys.includes(k) && FALLING.test(`${k} ${notes[k].phrase}`) && fs.existsSync(at('masks', `${k}.png`)));
|
|
98
|
+
const fallKeys = Object.keys(notes).filter((k) => !keys.includes(k) && !streamKeys.includes(k) && FALLING.test(`${k} ${notes[k].phrase}`) && fs.existsSync(at('masks', `${k}.png`)));
|
|
85
99
|
const fallMasks = await Promise.all(fallKeys.map(async (k) => maskFromPng(fs.readFileSync(at('masks', `${k}.png`)))));
|
|
86
100
|
// The far view as flats one behind another (layers/), so it has depth of its own.
|
|
87
101
|
fs.rmSync(at('layers'), { recursive: true, force: true });
|
|
@@ -133,6 +147,11 @@ defineCommand({
|
|
|
133
147
|
const falls = (await Promise.all(fallMasks.map((m) => fallSheet(png, m)))).filter((f) => !!f);
|
|
134
148
|
falls.forEach((f, i) => files.write(at('layers', `fall-${i}.png`), f.png));
|
|
135
149
|
if (falls.length) console.log(` ${place.key}: ${falls.length} waterfall(s) pouring`);
|
|
150
|
+
streams.forEach((f, i) => {
|
|
151
|
+
files.write(at('layers', `stream-${i}.png`), f.png);
|
|
152
|
+
files.write(at('layers', `stream-${i}-region.png`), f.region);
|
|
153
|
+
});
|
|
154
|
+
if (streams.length) console.log(` ${place.key}: ${streams.length} stream(s) of small things falling (${streamKeys.join(', ')})`);
|
|
136
155
|
const critters = critterKeys.length ? await critterSheet(withCritters, critterMask, wetMask) : [];
|
|
137
156
|
critters.forEach((c, i) => files.write(at('layers', `critter-${i}.png`), c.png));
|
|
138
157
|
if (critters.length) console.log(` ${place.key}: ${critters.length} painted animal(s) ${critters.some((c) => c.medium === 'air') ? 'flying and ' : ''}swimming (${critterKeys.join(', ')})`);
|
|
@@ -162,7 +181,7 @@ defineCommand({
|
|
|
162
181
|
files.write(at('layers', 'mist-mask.png'), mist.mask);
|
|
163
182
|
console.log(` ${place.key}: painted ${mist.phrase} drifting ${mist.drift === null ? 'with the wind' : mist.drift < 0 ? 'left' : 'right'}${mist.speed !== 1 ? ` x${mist.speed}` : ''}`);
|
|
164
183
|
}
|
|
165
|
-
if (far || water || mist || smoke.length || falls.length || critters.length || clouds) {
|
|
184
|
+
if (far || water || mist || smoke.length || falls.length || streams.length || critters.length || clouds) {
|
|
166
185
|
if (far) {
|
|
167
186
|
files.write(at('layers', 'sky.png'), far.sky);
|
|
168
187
|
far.flats.forEach((f, i) => files.write(at('layers', `${i}.png`), f.png));
|
|
@@ -176,6 +195,7 @@ defineCommand({
|
|
|
176
195
|
...(smoke.length && { smoke: smoke.map((s, i) => ({ file: `layers/smoke-${i}.png`, box: s.box })) }),
|
|
177
196
|
...(floats.length && { floats: floats.map((s, i) => ({ file: `layers/float-${i}.png`, box: s.box })) }),
|
|
178
197
|
...(falls.length && { falls: falls.map((f, i) => ({ file: `layers/fall-${i}.png`, box: f.box })) }),
|
|
198
|
+
...(streams.length && { streams: streams.map((f, i) => ({ file: `layers/stream-${i}.png`, region: `layers/stream-${i}-region.png`, box: f.box })) }),
|
|
179
199
|
...(critters.length && { critters: critters.map((c, i) => ({ file: `layers/critter-${i}.png`, box: c.box, medium: c.medium, facing: c.facing })) }),
|
|
180
200
|
...(clouds && { clouds: { box: clouds.box, clip: 'layers/cloud-clip.png', ...(clouds.keep && { keep: 'layers/cloud-keep.png' }), bands: clouds.bands.map((b, i) => ({ file: `layers/cloud-${i}.png`, box: b.box, speed: b.speed })) } }),
|
|
181
201
|
});
|
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);
|
|
@@ -90,6 +90,7 @@ export async function exportWorld(slug: string, out: string, home?: string, url?
|
|
|
90
90
|
entry.layers.mist.mask = await copy(entry.layers.mist.mask);
|
|
91
91
|
}
|
|
92
92
|
for (const f of entry.layers.flats) f.file = await art(f.file); // transparent around the flat
|
|
93
|
+
for (const f of entry.layers.streams ?? []) [f.file, f.region] = [await art(f.file), await copy(f.region)];
|
|
93
94
|
for (const f of [...(entry.layers.smoke ?? []), ...(entry.layers.floats ?? []), ...(entry.layers.falls ?? []), ...(entry.layers.critters ?? []), ...(entry.layers.clouds?.bands ?? [])]) f.file = await art(f.file);
|
|
94
95
|
if (entry.layers.clouds) {
|
|
95
96
|
entry.layers.clouds.clip = await copy(entry.layers.clouds.clip);
|
|
@@ -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;
|
package/app/src/commands/play.ts
CHANGED
|
@@ -65,6 +65,7 @@ export function worldData(files: WorldFiles) {
|
|
|
65
65
|
...(flats.smoke?.length && { smoke: flats.smoke.map((f) => ({ ...f, file: rel(f.file) })) }),
|
|
66
66
|
...(flats.floats?.length && { floats: flats.floats.map((f) => ({ ...f, file: rel(f.file) })) }),
|
|
67
67
|
...(flats.falls?.length && { falls: flats.falls.map((f) => ({ ...f, file: rel(f.file) })) }),
|
|
68
|
+
...(flats.streams?.length && { streams: flats.streams.map((f) => ({ ...f, file: rel(f.file), region: rel(f.region) })) }),
|
|
68
69
|
...(flats.critters?.length && { critters: flats.critters.map((f) => ({ ...f, file: rel(f.file) })) }),
|
|
69
70
|
...(flats.clouds && { clouds: { ...flats.clouds, clip: rel(flats.clouds.clip), ...(flats.clouds.keep && { keep: rel(flats.clouds.keep) }), bands: flats.clouds.bands.map((b) => ({ ...b, file: rel(b.file) })) } }),
|
|
70
71
|
};
|
|
@@ -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 };
|
|
@@ -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/play/geometry.ts
CHANGED
|
@@ -122,11 +122,21 @@ export function hides(o: Occluder, x: number, feetY: number, support?: (key: str
|
|
|
122
122
|
return feetY < floorYAt(o, x) - BEHIND_MARGIN;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
/**
|
|
126
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Column runs [x0, x1) inside [left, right) where the occluder hides the person; a few runs per occluder.
|
|
127
|
+
* With `feetX` the person is one body at one distance: the floor line where their feet are decides for their
|
|
128
|
+
* whole box, so a slanted desk whose near leg is nearer than the feet and far leg farther hides all of them or
|
|
129
|
+
* none, never cuts them in half down one column. Past the occluder's ends its nearest column decides.
|
|
130
|
+
*/
|
|
131
|
+
export function hiddenRuns(o: Occluder, left: number, right: number, feetY: number, support?: (key: string) => Occluder | undefined, feetX?: number): Array<[number, number]> {
|
|
127
132
|
const runs: Array<[number, number]> = [];
|
|
128
133
|
const x0 = Math.max(Math.floor(left), Math.floor(o.box[0]));
|
|
129
134
|
const x1 = Math.min(Math.ceil(right), Math.ceil(o.box[2]));
|
|
135
|
+
if (feetX !== undefined) {
|
|
136
|
+
if (x1 <= x0) return runs;
|
|
137
|
+
const at = Math.min(o.box[2] - 0.5, Math.max(o.box[0] + 0.5, feetX));
|
|
138
|
+
return hides(o, at, feetY, support) ? [[x0, x1]] : runs;
|
|
139
|
+
}
|
|
130
140
|
let start = -1;
|
|
131
141
|
for (let x = x0; x < x1; x++) {
|
|
132
142
|
const hid = hides(o, x + 0.5, feetY, support);
|
package/app/src/play/parallax.ts
CHANGED
|
@@ -164,6 +164,8 @@ export type Layers = {
|
|
|
164
164
|
floats?: Array<{ file: string; box: [number, number, number, number] }>;
|
|
165
165
|
/** Painted waterfalls to set pouring: each a feathered copy of the fall over `box`. */
|
|
166
166
|
falls?: Array<{ file: string; box: [number, number, number, number] }>;
|
|
167
|
+
/** Small things painted falling (code rain, snow, petals): what stands out of the paint over `box`, to fall column by column inside `region` (a soft mask). */
|
|
168
|
+
streams?: Array<{ file: string; region: string; box: [number, number, number, number] }>;
|
|
167
169
|
/** Painted animals as small cards to swim (in the water) or fly (in the air), and which way each faces (1 right). */
|
|
168
170
|
critters?: Array<{ file: string; box: [number, number, number, number]; medium: 'water' | 'air'; facing: 1 | -1 }>;
|
|
169
171
|
/** The painted clouds as bands (bottom first) to drift with the wind at their own speeds, cut to `clip` over `box`; `keep`: what stays put in the sky. */
|
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),
|