@gtanczyk/diorama 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +152 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +248 -0
- package/app/src/actions/depth.ts +95 -0
- package/app/src/actions/extend.ts +36 -0
- package/app/src/actions/faces.ts +160 -0
- package/app/src/actions/feedback.ts +87 -0
- package/app/src/actions/fix.ts +382 -0
- package/app/src/actions/history.ts +93 -0
- package/app/src/actions/idea-preview.ts +65 -0
- package/app/src/actions/idea-review.ts +102 -0
- package/app/src/actions/idea-sketch.ts +172 -0
- package/app/src/actions/index.ts +31 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/models.ts +64 -0
- package/app/src/actions/motion.ts +119 -0
- package/app/src/actions/pictures.ts +49 -0
- package/app/src/actions/reactions.ts +36 -0
- package/app/src/actions/registry.ts +101 -0
- package/app/src/actions/share.ts +32 -0
- package/app/src/actions/sound.ts +73 -0
- package/app/src/actions/start.ts +154 -0
- package/app/src/actions/step-work.ts +150 -0
- package/app/src/actions/story.ts +101 -0
- package/app/src/actions/studio.ts +133 -0
- package/app/src/actions/style.ts +72 -0
- package/app/src/actions/video.ts +46 -0
- package/app/src/actions/view.ts +109 -0
- package/app/src/actions/weather.ts +38 -0
- package/app/src/actions/world.ts +289 -0
- package/app/src/commands/action.ts +39 -0
- package/app/src/commands/backdrop.ts +188 -0
- package/app/src/commands/browsers.ts +21 -0
- package/app/src/commands/build.ts +503 -0
- package/app/src/commands/cast.ts +142 -0
- package/app/src/commands/clip.ts +431 -0
- package/app/src/commands/derive.ts +167 -0
- package/app/src/commands/doctor.ts +74 -0
- package/app/src/commands/explore.ts +123 -0
- package/app/src/commands/export.ts +142 -0
- package/app/src/commands/extend.ts +75 -0
- package/app/src/commands/faces.ts +80 -0
- package/app/src/commands/icons.ts +69 -0
- package/app/src/commands/index.ts +36 -0
- package/app/src/commands/layout.ts +31 -0
- package/app/src/commands/masks.ts +117 -0
- package/app/src/commands/mcp.ts +52 -0
- package/app/src/commands/model.ts +284 -0
- package/app/src/commands/paint.ts +182 -0
- package/app/src/commands/plates.ts +97 -0
- package/app/src/commands/play.ts +192 -0
- package/app/src/commands/playthrough.ts +111 -0
- package/app/src/commands/publish.ts +78 -0
- package/app/src/commands/reactions.ts +40 -0
- package/app/src/commands/reel.ts +46 -0
- package/app/src/commands/setup.ts +85 -0
- package/app/src/commands/sound.ts +90 -0
- package/app/src/commands/sprites.ts +232 -0
- package/app/src/commands/story.ts +37 -0
- package/app/src/commands/studio.ts +65 -0
- package/app/src/commands/style.ts +87 -0
- package/app/src/commands/verify.ts +45 -0
- package/app/src/commands/voice-retry.ts +14 -0
- package/app/src/commands/voices.ts +145 -0
- package/app/src/commands/weather.ts +52 -0
- package/app/src/commands/world.ts +104 -0
- package/app/src/film/clip.ts +386 -0
- package/app/src/film/reel.ts +126 -0
- package/app/src/film/screencast.ts +123 -0
- package/app/src/image/mask.ts +29 -0
- package/app/src/image/overlay.ts +38 -0
- package/app/src/image/raster.ts +224 -0
- package/app/src/main.ts +28 -0
- package/app/src/meta/choice.ts +27 -0
- package/app/src/meta/http.ts +99 -0
- package/app/src/meta/memo.ts +91 -0
- package/app/src/meta/muse.ts +70 -0
- package/app/src/meta/openrouter.ts +93 -0
- package/app/src/meta/prices.ts +25 -0
- package/app/src/meta/sam.ts +83 -0
- package/app/src/meta/schema.ts +63 -0
- package/app/src/meta/spark.ts +220 -0
- package/app/src/meta/step.ts +14 -0
- package/app/src/meta/voice-file.ts +78 -0
- package/app/src/meta/voice.ts +103 -0
- package/app/src/model/animation.ts +38 -0
- package/app/src/model/blender/relax.py +284 -0
- package/app/src/model/blender.ts +115 -0
- package/app/src/model/build.ts +50 -0
- package/app/src/model/face.ts +21 -0
- package/app/src/model/fit.ts +181 -0
- package/app/src/model/glb.ts +241 -0
- package/app/src/model/plan.ts +151 -0
- package/app/src/model/quality.ts +48 -0
- package/app/src/model/space.ts +168 -0
- package/app/src/model/sprites.ts +293 -0
- package/app/src/model/views.ts +163 -0
- package/app/src/play/creature.ts +43 -0
- package/app/src/play/depth-occlusion.ts +150 -0
- package/app/src/play/exit-outline.ts +48 -0
- package/app/src/play/face3d.ts +34 -0
- package/app/src/play/focus.ts +78 -0
- package/app/src/play/geometry.ts +272 -0
- package/app/src/play/gestures.ts +23 -0
- package/app/src/play/glow.ts +187 -0
- package/app/src/play/lipsync.ts +69 -0
- package/app/src/play/living.ts +83 -0
- package/app/src/play/outline-path.ts +130 -0
- package/app/src/play/parallax.ts +200 -0
- package/app/src/play/path.ts +262 -0
- package/app/src/play/preview.ts +71 -0
- package/app/src/play/reactions.ts +92 -0
- package/app/src/play/rig.ts +172 -0
- package/app/src/play/shadow.ts +253 -0
- package/app/src/play/sound.ts +51 -0
- package/app/src/play/weather.ts +58 -0
- package/app/src/qa/explore.ts +136 -0
- package/app/src/qa/probe.ts +139 -0
- package/app/src/qa/route.ts +118 -0
- package/app/src/registry.ts +23 -0
- package/app/src/scene/animation-source.ts +36 -0
- package/app/src/scene/assets.ts +37 -0
- package/app/src/scene/autofix.ts +133 -0
- package/app/src/scene/backdrop.ts +326 -0
- package/app/src/scene/blockin.ts +189 -0
- package/app/src/scene/brief.ts +74 -0
- package/app/src/scene/carry.ts +177 -0
- package/app/src/scene/cast-fixes.ts +31 -0
- package/app/src/scene/cast.ts +192 -0
- package/app/src/scene/corrections.ts +164 -0
- package/app/src/scene/critic.ts +169 -0
- package/app/src/scene/depthmap.ts +149 -0
- package/app/src/scene/derive.ts +475 -0
- package/app/src/scene/diagnose.ts +132 -0
- package/app/src/scene/face-calibration.ts +65 -0
- package/app/src/scene/face-registration.ts +21 -0
- package/app/src/scene/face-work.ts +71 -0
- package/app/src/scene/faces-data.ts +48 -0
- package/app/src/scene/faces-settings.ts +20 -0
- package/app/src/scene/faces.ts +435 -0
- package/app/src/scene/glows.ts +153 -0
- package/app/src/scene/held.ts +55 -0
- package/app/src/scene/icons.ts +185 -0
- package/app/src/scene/layers.ts +822 -0
- package/app/src/scene/layout.ts +216 -0
- package/app/src/scene/locate.ts +118 -0
- package/app/src/scene/maskcheck.ts +43 -0
- package/app/src/scene/masks.ts +392 -0
- package/app/src/scene/occluders.ts +123 -0
- package/app/src/scene/occlusion-check.ts +138 -0
- package/app/src/scene/paint-layout.ts +135 -0
- package/app/src/scene/paint.ts +81 -0
- package/app/src/scene/photo.ts +123 -0
- package/app/src/scene/plates.ts +256 -0
- package/app/src/scene/playbook.ts +121 -0
- package/app/src/scene/preview.ts +23 -0
- package/app/src/scene/reactions.ts +118 -0
- package/app/src/scene/restyle.ts +53 -0
- package/app/src/scene/rig-check.ts +68 -0
- package/app/src/scene/rig.ts +375 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +207 -0
- package/app/src/scene/stairs.ts +183 -0
- package/app/src/scene/status.ts +32 -0
- package/app/src/scene/verify.ts +156 -0
- package/app/src/scene/walk.ts +326 -0
- package/app/src/scene/warp.ts +417 -0
- package/app/src/scene/weather.ts +118 -0
- package/app/src/scene/words.ts +65 -0
- package/app/src/secrets.ts +219 -0
- package/app/src/setup/checks.ts +110 -0
- package/app/src/sound/cost.ts +74 -0
- package/app/src/sound/data.ts +89 -0
- package/app/src/sound/fixes.ts +23 -0
- package/app/src/sound/issues.ts +21 -0
- package/app/src/sound/language.ts +11 -0
- package/app/src/sound/lipsync-make.ts +98 -0
- package/app/src/sound/lipsync.ts +24 -0
- package/app/src/sound/make.ts +229 -0
- package/app/src/sound/musicgen.py +20 -0
- package/app/src/sound/pcm.ts +280 -0
- package/app/src/sound/plan.ts +154 -0
- package/app/src/sound/retry.ts +43 -0
- package/app/src/sound/settings.ts +28 -0
- package/app/src/sound/sources.ts +81 -0
- package/app/src/sound/spoken.ts +70 -0
- package/app/src/sound/voices.ts +579 -0
- package/app/src/speech/transcribe.ts +38 -0
- package/app/src/speech/types.ts +18 -0
- package/app/src/story/check.ts +230 -0
- package/app/src/story/design.ts +108 -0
- package/app/src/story/engine.ts +233 -0
- package/app/src/story/fixes.ts +54 -0
- package/app/src/story/npc-guidance.ts +7 -0
- package/app/src/story/phrases.ts +4 -0
- package/app/src/story/schema.ts +92 -0
- package/app/src/studio/agent.ts +554 -0
- package/app/src/studio/discovery.ts +28 -0
- package/app/src/studio/events.ts +48 -0
- package/app/src/studio/history.ts +124 -0
- package/app/src/studio/jobs.ts +291 -0
- package/app/src/studio/locks.ts +33 -0
- package/app/src/studio/mcp.ts +86 -0
- package/app/src/studio/operation.ts +18 -0
- package/app/src/studio/repair.ts +31 -0
- package/app/src/studio/server.ts +421 -0
- package/app/src/studio/trouble.ts +48 -0
- package/app/src/world/design.ts +72 -0
- package/app/src/world/drawings.ts +198 -0
- package/app/src/world/extend-stage.ts +109 -0
- package/app/src/world/extend.ts +123 -0
- package/app/src/world/extension-progress.ts +10 -0
- package/app/src/world/files.ts +62 -0
- package/app/src/world/idea-review.ts +32 -0
- package/app/src/world/photo-limits.ts +2 -0
- package/app/src/world/pool.ts +41 -0
- package/app/src/world/schedule.ts +64 -0
- package/app/src/world/schema.ts +74 -0
- package/app/src/world/sketch.ts +81 -0
- package/app/src/world/style-plan.ts +90 -0
- package/app/src/world/style-reference.ts +16 -0
- package/app/src/world/styled.ts +51 -0
- package/app/src/world/told.ts +105 -0
- package/app/src/world/video-limits.ts +3 -0
- package/app/src/world/video.ts +98 -0
- package/app/studio/activity.tsx +124 -0
- package/app/studio/agent.tsx +384 -0
- package/app/studio/api.ts +111 -0
- package/app/studio/atelier.tsx +234 -0
- package/app/studio/audio-missing.tsx +25 -0
- package/app/studio/audio-setup.tsx +33 -0
- package/app/studio/blender-setup.tsx +30 -0
- package/app/studio/build.tsx +677 -0
- package/app/studio/camera.tsx +59 -0
- package/app/studio/cast-work.tsx +207 -0
- package/app/studio/cast.tsx +178 -0
- package/app/studio/concept-edit.tsx +47 -0
- package/app/studio/creator-idea.tsx +46 -0
- package/app/studio/custom-look.tsx +36 -0
- package/app/studio/faces-setup.tsx +28 -0
- package/app/studio/favicon.svg +11 -0
- package/app/studio/figure.tsx +314 -0
- package/app/studio/fonts/OFL.txt +93 -0
- package/app/studio/fonts/fonts.css +7 -0
- package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
- package/app/studio/icons.tsx +42 -0
- package/app/studio/idea-card.tsx +42 -0
- package/app/studio/idea-review.tsx +181 -0
- package/app/studio/idea-storyboard.tsx +134 -0
- package/app/studio/index.html +15 -0
- package/app/studio/job-words.ts +33 -0
- package/app/studio/lightbox.tsx +49 -0
- package/app/studio/main.tsx +1328 -0
- package/app/studio/nav.ts +14 -0
- package/app/studio/place.tsx +264 -0
- package/app/studio/review-sketch.tsx +47 -0
- package/app/studio/say-it.tsx +49 -0
- package/app/studio/step-work.tsx +134 -0
- package/app/studio/story.tsx +394 -0
- package/app/studio/style-menu.tsx +81 -0
- package/app/studio/style.css +1390 -0
- package/app/studio/sure.tsx +54 -0
- package/app/studio/tip.tsx +74 -0
- package/app/studio/tsconfig.json +5 -0
- package/app/studio/video.tsx +73 -0
- package/app/studio/world.tsx +188 -0
- package/app/tsconfig.json +13 -0
- package/app/web/audio.ts +315 -0
- package/app/web/face.ts +131 -0
- package/app/web/face3d.ts +35 -0
- package/app/web/glows.ts +387 -0
- package/app/web/hero3d.ts +728 -0
- package/app/web/index.html +118 -0
- package/app/web/living.ts +404 -0
- package/app/web/main.ts +3751 -0
- package/app/web/pad.ts +174 -0
- package/app/web/reactions.ts +79 -0
- package/app/web/sheet.ts +36 -0
- package/app/web/synth.ts +140 -0
- package/app/web/tsconfig.json +5 -0
- package/app/web/ui.ts +263 -0
- package/app/web/weather.ts +306 -0
- package/app/web/words.ts +12 -0
- package/docs/characters.md +58 -0
- package/docs/npc-motion.md +69 -0
- package/docs/scenes.md +75 -0
- package/docs/setup.md +60 -0
- package/docs/sound.md +23 -0
- package/docs/studio.md +62 -0
- package/package.json +70 -4
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// shadows: the painted cast shadows on the floor, and the light they tell of, so the hero casts a
|
|
2
|
+
// shadow the same way and darkens when standing in one. SAM finds no shadows in a painting (asked
|
|
3
|
+
// "shadow", "cast shadow", "shade": nothing), so they are cut by brightness: floor clearly darker
|
|
4
|
+
// than the lit floor around it, touching the base of a standing thing. The shape of each thing's
|
|
5
|
+
// shadow gives the direction and length. Spark says whether there is sun at all (fog darkens wet
|
|
6
|
+
// planks too). No thing with a shadow of its own: no light, so the hero casts none either.
|
|
7
|
+
import sharp from 'sharp';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { emptyMask, maskArea, type Mask } from '../image/mask.ts';
|
|
10
|
+
import { components, dilate, intersect, subtract, unionAll } from '../image/raster.ts';
|
|
11
|
+
import type { Memo } from '../meta/memo.ts';
|
|
12
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
13
|
+
import { FRAME } from './layout.ts';
|
|
14
|
+
import type { Blob } from './occluders.ts';
|
|
15
|
+
|
|
16
|
+
const { width: W, height: H } = FRAME;
|
|
17
|
+
|
|
18
|
+
/** Light as the runtime takes it: where shadows fall on screen (+y toward the viewer), how long they are
|
|
19
|
+
* per px of the caster's height, and how much darker the floor is in shadow (0 none, 1 black). */
|
|
20
|
+
export type Light = { dir: [number, number]; length: number; strength: number; at?: [number, number]; also?: Array<[number, number]> };
|
|
21
|
+
export type CasterShadow = { thing: string; px: number; dir?: [number, number]; length?: number };
|
|
22
|
+
export type Shadows = { mask: Mask; light?: Light; casters: CasterShadow[]; source: 'casters' | 'spark' | 'none' };
|
|
23
|
+
|
|
24
|
+
/** Darker than this share of the lit floor around it is shadow. */
|
|
25
|
+
export const SHADOW_DARK = 0.74;
|
|
26
|
+
const REF_CELL = 16;
|
|
27
|
+
const REF_RADIUS = 6;
|
|
28
|
+
|
|
29
|
+
/** The painting's luma, lightly blurred so plank grain and pebbles don't count. */
|
|
30
|
+
export async function lumaOf(painting: Buffer): Promise<Uint8Array> {
|
|
31
|
+
const raw = await sharp(painting).resize(W, H, { fit: 'fill' }).removeAlpha().blur(3).toColourspace('b-w').raw().toBuffer();
|
|
32
|
+
return new Uint8Array(raw.buffer, raw.byteOffset, W * H);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** How bright lit floor is around each pixel: a high percentile of the floor cells nearby. */
|
|
36
|
+
export function litFloor(luma: Uint8Array, floor: Mask): Float32Array {
|
|
37
|
+
const cw = Math.ceil(W / REF_CELL), ch = Math.ceil(H / REF_CELL);
|
|
38
|
+
const mean = new Float32Array(cw * ch).fill(-1);
|
|
39
|
+
for (let cy = 0; cy < ch; cy++) for (let cx = 0; cx < cw; cx++) {
|
|
40
|
+
let sum = 0, n = 0;
|
|
41
|
+
for (let y = cy * REF_CELL; y < Math.min(H, (cy + 1) * REF_CELL); y++) for (let x = cx * REF_CELL; x < Math.min(W, (cx + 1) * REF_CELL); x++) {
|
|
42
|
+
if (floor.bits[y * W + x]) { sum += luma[y * W + x]; n++; }
|
|
43
|
+
}
|
|
44
|
+
if (n > 0.3 * REF_CELL * REF_CELL) mean[cy * cw + cx] = sum / n;
|
|
45
|
+
}
|
|
46
|
+
const ref = new Float32Array(cw * ch);
|
|
47
|
+
for (let cy = 0; cy < ch; cy++) for (let cx = 0; cx < cw; cx++) {
|
|
48
|
+
const near: number[] = [];
|
|
49
|
+
for (let dy = -REF_RADIUS; dy <= REF_RADIUS; dy++) for (let dx = -REF_RADIUS; dx <= REF_RADIUS; dx++) {
|
|
50
|
+
const x = cx + dx, y = cy + dy;
|
|
51
|
+
if (x >= 0 && y >= 0 && x < cw && y < ch && mean[y * cw + x] >= 0) near.push(mean[y * cw + x]);
|
|
52
|
+
}
|
|
53
|
+
near.sort((a, b) => a - b);
|
|
54
|
+
ref[cy * cw + cx] = near.length ? near[Math.floor(0.8 * (near.length - 1))] : 0;
|
|
55
|
+
}
|
|
56
|
+
const out = new Float32Array(W * H);
|
|
57
|
+
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) out[y * W + x] = ref[Math.floor(y / REF_CELL) * cw + Math.floor(x / REF_CELL)];
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Floor clearly darker than the lit floor around it, without thin cracks between planks. */
|
|
62
|
+
export function darkFloor(luma: Uint8Array, lit: Float32Array, floor: Mask): Mask {
|
|
63
|
+
const dark = emptyMask(W, H);
|
|
64
|
+
for (let i = 0; i < W * H; i++) if (floor.bits[i] && luma[i] < SHADOW_DARK * lit[i]) dark.bits[i] = 1;
|
|
65
|
+
return intersect(dilate(dilate(dark, -2), 2), floor);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function centroid(m: Mask): [number, number] | undefined {
|
|
69
|
+
let sx = 0, sy = 0, n = 0;
|
|
70
|
+
for (let i = 0; i < m.bits.length; i++) if (m.bits[i]) { sx += i % W; sy += Math.floor(i / W); n++; }
|
|
71
|
+
return n ? [sx / n, sy / n] : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A thing's shadow measured from its base: where its middle points, and how far its far end is. */
|
|
75
|
+
export function measure(shadow: Mask, base: [number, number], height: number): { dir: [number, number]; length: number } | undefined {
|
|
76
|
+
const mid = centroid(shadow);
|
|
77
|
+
if (!mid) return undefined;
|
|
78
|
+
const vx = mid[0] - base[0], vy = mid[1] - base[1];
|
|
79
|
+
const reach = Math.hypot(vx, vy);
|
|
80
|
+
// A dark ring around the foot is contact shade, not a cast shadow: it says nothing of the light.
|
|
81
|
+
if (reach < 0.15 * height) return undefined;
|
|
82
|
+
const dir: [number, number] = [vx / reach, vy / reach];
|
|
83
|
+
const along: number[] = [];
|
|
84
|
+
for (let i = 0; i < W * H; i++) if (shadow.bits[i]) along.push((i % W - base[0]) * dir[0] + (Math.floor(i / W) - base[1]) * dir[1]);
|
|
85
|
+
along.sort((a, b) => a - b);
|
|
86
|
+
return { dir, length: along[Math.floor(0.95 * (along.length - 1))] / height };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Without sun Spark often leaves the direction out.
|
|
90
|
+
const SparkLight = z.object({ sunlit: z.boolean(), dx: z.number().default(0), dy: z.number().default(0), length: z.number().default(1) });
|
|
91
|
+
|
|
92
|
+
/** Is there sun? (Its direction is not used: painted shadows are measured, not guessed.) */
|
|
93
|
+
async function sparkLight(memo: Memo, label: string, painting: Buffer): Promise<Light | undefined> {
|
|
94
|
+
const prompt = `This is a painted background for a 2D adventure game (${W}x${H} px, x right, y down). A person will be walking on its floor.
|
|
95
|
+
Is the scene lit by direct sun or another strong light that casts clear shadows (not fog, overcast or night without a lamp)? If it is, which way on the screen would a person's shadow fall from their feet, and how long would it be compared with their height?
|
|
96
|
+
Reply with JSON only: {"sunlit": true, "dx": 0.5, "dy": 0.5, "length": 0.8} (dx, dy: direction on screen, +dy toward the viewer)`;
|
|
97
|
+
const a = SparkLight.parse(await askSparkJson(memo, { schema: SparkLight, label, prompt, images: [painting] }));
|
|
98
|
+
const d = Math.hypot(a.dx, a.dy);
|
|
99
|
+
if (!a.sunlit || d < 1e-3) return undefined;
|
|
100
|
+
const r = (v: number) => Math.round(v * 1000) / 1000;
|
|
101
|
+
return { dir: [r(a.dx / d), r(a.dy / d)], length: r(Math.min(3, Math.max(0.2, a.length))), strength: 0.35 };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The painted shadows on `floor` (ground the hero walks on, minus things) and the light behind them.
|
|
105
|
+
* `casters` are the sharp standing blobs on the floor. */
|
|
106
|
+
/** `askWith`: the painting to ask Spark where the sun is (after a small repaint, the one before it). */
|
|
107
|
+
export async function findShadows(memo: Memo, label: string, painting: Buffer, floor: Mask, casters: Blob[], askWith = painting): Promise<Shadows> {
|
|
108
|
+
const luma = await lumaOf(painting);
|
|
109
|
+
const lit = litFloor(luma, floor);
|
|
110
|
+
const blobs = components(darkFloor(luma, lit, floor)).filter((c) => maskArea(c) >= 120);
|
|
111
|
+
const kept: Mask[] = [];
|
|
112
|
+
const measured: CasterShadow[] = [];
|
|
113
|
+
for (const b of casters) {
|
|
114
|
+
const height = b.baseY - b.box[1];
|
|
115
|
+
if (height < 24) continue;
|
|
116
|
+
// Only shadow at the thing's foot is its own: a lamp post rising through dark pebbles casts no pebbles.
|
|
117
|
+
const foot = emptyMask(W, H);
|
|
118
|
+
const from = Math.max(0, Math.round(b.baseY - Math.max(8, 0.1 * height)));
|
|
119
|
+
for (let i = from * W; i < Math.min(H, b.baseY + 1) * W; i++) foot.bits[i] = b.mask.bits[i];
|
|
120
|
+
const touch = dilate(foot, 8);
|
|
121
|
+
const own = blobs.filter((c) => maskArea(intersect(c, touch)) >= 20);
|
|
122
|
+
if (!own.length) continue;
|
|
123
|
+
// A long shadow can run into the next one: only the part within a few heights of the base is this thing's.
|
|
124
|
+
const near = emptyMask(W, H);
|
|
125
|
+
const r = 4 * height, cx = (b.box[0] + b.box[2]) / 2;
|
|
126
|
+
for (let y = Math.max(0, Math.round(b.baseY - r)); y < Math.min(H, b.baseY + r); y++) for (let x = Math.max(0, Math.round(cx - r)); x < Math.min(W, cx + r); x++) near.bits[y * W + x] = 1;
|
|
127
|
+
const shadow = intersect(unionAll(W, H, own), near);
|
|
128
|
+
// A shadow many times the thing's size is shade it stands in (the risers of stairs, a tree), not its own.
|
|
129
|
+
if (maskArea(shadow) > 3 * maskArea(b.mask)) {
|
|
130
|
+
measured.push({ thing: b.key, px: maskArea(shadow) });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
kept.push(...own);
|
|
134
|
+
// Measured from where the shadow meets the foot: a wide thing's base is a line, not its box's corner.
|
|
135
|
+
const contact = centroid(intersect(shadow, touch)) ?? [cx, b.baseY];
|
|
136
|
+
measured.push({ thing: b.key, px: maskArea(shadow), ...measure(shadow, contact, height) });
|
|
137
|
+
}
|
|
138
|
+
const mask = unionAll(W, H, kept);
|
|
139
|
+
const directed = measured.filter((m) => m.dir);
|
|
140
|
+
// Dark wet planks next to a teapot look like a shadow too: Spark says whether there is sun at all.
|
|
141
|
+
const sun = await sparkLight(memo, `light:${label}`, askWith);
|
|
142
|
+
if (!sun) return { mask: emptyMask(W, H), casters: measured, source: 'none' };
|
|
143
|
+
// Sun, but no thing whose own shadow could be measured: the hero casts the shadow Spark sees, and no
|
|
144
|
+
// painted shadow is masked (nothing says which dark patches are shadows).
|
|
145
|
+
if (!directed.length) return { mask: emptyMask(W, H), light: sun, casters: measured, source: 'spark' };
|
|
146
|
+
// The things agree on the light when their directions add up; if they don't, the biggest shadow wins.
|
|
147
|
+
const weight = (m: CasterShadow) => Math.sqrt(m.px);
|
|
148
|
+
const total = directed.reduce((s, m) => s + weight(m), 0);
|
|
149
|
+
const sum = directed.reduce(([x, y], m) => [x + weight(m) * m.dir![0], y + weight(m) * m.dir![1]], [0, 0]);
|
|
150
|
+
const agree = Math.hypot(sum[0], sum[1]) / total;
|
|
151
|
+
const biggest = directed.reduce((a, b) => (b.px > a.px ? b : a));
|
|
152
|
+
const dir: [number, number] = agree >= 0.8 ? [sum[0] / (agree * total), sum[1] / (agree * total)] : biggest.dir!;
|
|
153
|
+
const lengths = directed.map((m) => m.length!).sort((a, b) => a - b);
|
|
154
|
+
const length = agree >= 0.8 ? lengths[Math.floor(lengths.length / 2)] : biggest.length!;
|
|
155
|
+
// Strength: how much darker the shadow is than the lit floor right around it.
|
|
156
|
+
const ring = subtract(intersect(dilate(mask, 20), floor), mask);
|
|
157
|
+
const median = (m: Mask, of: (i: number) => number) => {
|
|
158
|
+
const v: number[] = [];
|
|
159
|
+
for (let i = 0; i < W * H; i++) if (m.bits[i]) v.push(of(i));
|
|
160
|
+
v.sort((a, b) => a - b);
|
|
161
|
+
return v.length ? v[Math.floor(v.length / 2)] : 0;
|
|
162
|
+
};
|
|
163
|
+
const inShadow = median(mask, (i) => luma[i]);
|
|
164
|
+
const around = Math.max(median(ring, (i) => luma[i]), median(mask, (i) => lit[i]));
|
|
165
|
+
const strength = around > 0 ? Math.min(0.85, Math.max(0.1, 1 - inShadow / around)) : 0.35;
|
|
166
|
+
const round = (v: number) => Math.round(v * 1000) / 1000;
|
|
167
|
+
return { mask, light: { dir: [round(dir[0]), round(dir[1])], length: round(Math.min(4, length)), strength: round(strength) }, casters: measured, source: 'casters' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const SparkSource = z.object({ source: z.string(), flame: z.boolean().optional() });
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A fire or a lamp that lights the scene more than the sky does (a camp at dusk, a lamp-lit room): Spark
|
|
174
|
+
* names the main light among the place's own `lights`. Asked only where the layout has lights. The light
|
|
175
|
+
* stands where its thing does: the foot of a tall one (a lamp post), the middle of a low one (a fire ring).
|
|
176
|
+
*/
|
|
177
|
+
export async function localLight(memo: Memo, label: string, painting: Buffer, lights: Array<{ key: string; box?: [number, number, number, number] }>): Promise<{ key: string; at: [number, number]; flame: boolean; also?: Array<[number, number]> } | undefined> {
|
|
178
|
+
const placed = lights.filter((l) => l.box);
|
|
179
|
+
if (!placed.length) return undefined;
|
|
180
|
+
const prompt = `This is a painted background for a 2D adventure game (${W}x${H} px). It shows these light sources: ${placed.map((l) => `"${l.key}"`).join(', ')}.
|
|
181
|
+
What mainly lights the ground where people walk, and so decides which way their shadows fall: the sun or daylight, or one of those light sources (at dusk or at night a fire or a lamp usually does)?
|
|
182
|
+
Reply with JSON only: {"source": "sun"} or {"source": "<one of the names above>", "flame": true if that light is a living flame (a fire, a torch, a candle, an oil lamp), false if not (an electric lamp, a glowing window)}`;
|
|
183
|
+
const a = SparkSource.parse(await askSparkJson(memo, { schema: SparkSource, label, prompt, images: [painting] }));
|
|
184
|
+
const hit = placed.find((l) => l.key === a.source);
|
|
185
|
+
if (!hit?.box) return undefined;
|
|
186
|
+
const also = placed.filter((l) => l !== hit && alikeLights(l.key, hit.key)).map((l) => lightAt(l.box!));
|
|
187
|
+
return { key: hit.key, at: lightAt(hit.box), flame: a.flame === true, ...(also.length && { also }) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Where a light stands: the foot of a tall one (a lamp post), the middle of a low one (a fire ring). */
|
|
191
|
+
function lightAt([x0, y0, x1, y1]: [number, number, number, number]): [number, number] {
|
|
192
|
+
const tall = y1 - y0 > 1.5 * (x1 - x0);
|
|
193
|
+
return [Math.round((x0 + x1) / 2), Math.round(tall ? y1 : (y0 + y1) / 2)];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Words that tell twins apart, not what they are. */
|
|
197
|
+
const WHERE = new Set(['near', 'far', 'left', 'right', 'front', 'back', 'middle', 'centre', 'center', 'big', 'small', 'one', 'two', 'other', 'first', 'second']);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Whether two lights are the same kind of thing (lamp-post-near and lamp-post-far): they share a word that
|
|
201
|
+
* says what they are. Two alike lamps around the walk area leave no single key light.
|
|
202
|
+
*/
|
|
203
|
+
export function alikeLights(a: string, b: string): boolean {
|
|
204
|
+
const words = (k: string) => k.toLowerCase().split(/[^a-z]+/).filter((w) => w.length > 2 && !WHERE.has(w));
|
|
205
|
+
const theirs = words(b);
|
|
206
|
+
return words(a).some((w) => theirs.includes(w));
|
|
207
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// stairs: the steps of a staircase, so people's feet stay on a tread and step over each riser instead
|
|
2
|
+
// of gliding up a ramp. Only places whose walk-on things or floors are stairs ask (one SAM call): SAM
|
|
3
|
+
// "stair step" returns one instance per step, tread and riser together, which is exactly the band a
|
|
4
|
+
// foot belongs to. Kept: wide, flat bands inside the stairs, at least two of them, stacked top to bottom.
|
|
5
|
+
import { emptyMask, maskArea, type Mask } from '../image/mask.ts';
|
|
6
|
+
import sharp from 'sharp';
|
|
7
|
+
import { dilate, intersect, maskBox, rectMask } from '../image/raster.ts';
|
|
8
|
+
import type { Memo } from '../meta/memo.ts';
|
|
9
|
+
import { samFind } from '../meta/sam.ts';
|
|
10
|
+
import type { Geometry } from '../play/geometry.ts';
|
|
11
|
+
import { FRAME } from './layout.ts';
|
|
12
|
+
|
|
13
|
+
const { width: W, height: H } = FRAME;
|
|
14
|
+
/** Column spacing of the stored step edges, px. */
|
|
15
|
+
export const STEP_EVERY = 8;
|
|
16
|
+
|
|
17
|
+
/** Words that make a floor or walk-on thing a staircase (English and Polish, as worlds are written in both). */
|
|
18
|
+
export const STAIRS = /stair|step|staircase|schod|stopn|stopień/i;
|
|
19
|
+
|
|
20
|
+
/** A mask at frame size (SAM may answer at the painting's own size). */
|
|
21
|
+
export function atFrame(m: Mask): Mask {
|
|
22
|
+
if (m.width === W && m.height === H) return m;
|
|
23
|
+
const out = emptyMask(W, H);
|
|
24
|
+
for (let y = 0; y < H; y++) {
|
|
25
|
+
const sy = Math.min(m.height - 1, Math.floor((y * m.height) / H));
|
|
26
|
+
for (let x = 0; x < W; x++) out.bits[y * W + x] = m.bits[sy * m.width + Math.min(m.width - 1, Math.floor((x * m.width) / W))];
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One step's band per column: its first and last pixel, every STEP_EVERY px. */
|
|
32
|
+
export function stepEdges(m: Mask): NonNullable<Geometry['steps']>[number] | undefined {
|
|
33
|
+
const box = maskBox(m);
|
|
34
|
+
if (!box) return undefined;
|
|
35
|
+
const x0 = Math.floor(box[0] / STEP_EVERY) * STEP_EVERY;
|
|
36
|
+
const top: number[] = [];
|
|
37
|
+
const bottom: number[] = [];
|
|
38
|
+
for (let x = x0; x <= box[2]; x += STEP_EVERY) {
|
|
39
|
+
let t = -1;
|
|
40
|
+
let b = -1;
|
|
41
|
+
for (let y = box[1]; y <= box[3] && y < H; y++) {
|
|
42
|
+
if (!m.bits[y * W + Math.min(W - 1, x)]) continue;
|
|
43
|
+
if (t < 0) t = y;
|
|
44
|
+
b = y;
|
|
45
|
+
}
|
|
46
|
+
top.push(t);
|
|
47
|
+
bottom.push(b);
|
|
48
|
+
}
|
|
49
|
+
return { x0, every: STEP_EVERY, top, bottom };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Least squares y = a + b x + c x² through the points, or undefined when there are too few. */
|
|
53
|
+
function quadratic(xs: number[], ys: number[]): ((x: number) => number) | undefined {
|
|
54
|
+
if (xs.length < 4) return undefined;
|
|
55
|
+
// Centred and scaled, so the normal equations stay well conditioned.
|
|
56
|
+
const m = xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
57
|
+
const u = xs.map((x) => (x - m) / 100);
|
|
58
|
+
const S = (f: (i: number) => number) => u.reduce((acc, _, i) => acc + f(i), 0);
|
|
59
|
+
const [s0, s1, s2, s3, s4] = [0, 1, 2, 3, 4].map((k) => S((i) => u[i] ** k));
|
|
60
|
+
const [t0, t1, t2] = [0, 1, 2].map((k) => S((i) => ys[i] * u[i] ** k));
|
|
61
|
+
const det = (a: number[][]) => a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0]) + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
|
|
62
|
+
const A = [[s0, s1, s2], [s1, s2, s3], [s2, s3, s4]];
|
|
63
|
+
const D = det(A);
|
|
64
|
+
if (Math.abs(D) < 1e-9) return undefined;
|
|
65
|
+
const col = (k: number) => det(A.map((row, r) => row.map((v, c) => (c === k ? [t0, t1, t2][r] : v))));
|
|
66
|
+
const [a, b, c] = [col(0) / D, col(1) / D, col(2) / D];
|
|
67
|
+
return (x: number) => { const v = (x - m) / 100; return a + b * v + c * v * v; };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A step partly hidden by something on or in front of it (a basket on the tread, a boat at its end) is
|
|
72
|
+
* mended: the back edge of a flight's step is a smooth line or arc, so columns far off that curve get the
|
|
73
|
+
* curve instead, and a band much shallower than the step's usual depth gets its depth back.
|
|
74
|
+
*/
|
|
75
|
+
export function mendStep(s: NonNullable<Geometry['steps']>[number]): NonNullable<Geometry['steps']>[number] {
|
|
76
|
+
const cols = s.top.flatMap((t, i) => (t >= 0 && s.bottom[i] > t ? [i] : []));
|
|
77
|
+
const depths = cols.map((i) => s.bottom[i] - s.top[i]).sort((a, b) => a - b);
|
|
78
|
+
if (!depths.length) return s;
|
|
79
|
+
const usual = depths[Math.floor(depths.length / 2)];
|
|
80
|
+
// Fit the back edge, dropping the worst columns until everything left lies close to it.
|
|
81
|
+
let keep = cols;
|
|
82
|
+
let curve = quadratic(keep.map((i) => i), keep.map((i) => s.top[i]));
|
|
83
|
+
for (let round = 0; curve && round < 6; round++) {
|
|
84
|
+
const fit = curve;
|
|
85
|
+
const near = keep.filter((i) => Math.abs(s.top[i] - fit(i)) <= Math.max(6, 0.15 * usual));
|
|
86
|
+
if (near.length === keep.length || near.length < 0.5 * cols.length) break;
|
|
87
|
+
keep = near;
|
|
88
|
+
curve = quadratic(keep.map((i) => i), keep.map((i) => s.top[i]));
|
|
89
|
+
}
|
|
90
|
+
const top = [...s.top];
|
|
91
|
+
const bottom = [...s.bottom];
|
|
92
|
+
for (const i of cols) {
|
|
93
|
+
if (curve && Math.abs(top[i] - curve(i)) > Math.max(6, 0.15 * usual)) {
|
|
94
|
+
top[i] = Math.round(curve(i));
|
|
95
|
+
bottom[i] = Math.min(H - 1, top[i] + usual);
|
|
96
|
+
} else if (bottom[i] - top[i] < 0.75 * usual) bottom[i] = Math.min(H - 1, top[i] + usual);
|
|
97
|
+
}
|
|
98
|
+
return { ...s, top, bottom };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The steps of `stairs` read off the painting: a tread is lit from above, so its front edge is a row where the
|
|
103
|
+
* picture turns from dark (the riser above's shadow) to light, all the way across the stairs. Rows like that,
|
|
104
|
+
* evenly spaced, split the stairs into its steps, each from one tread's edge to the next. Empty when there is no
|
|
105
|
+
* such run of at least three edges (a slope, a ladder, one big block): the stairs stay a ramp.
|
|
106
|
+
*/
|
|
107
|
+
export async function treadBands(painting: Buffer, stairs: Mask): Promise<Mask[]> {
|
|
108
|
+
const box = maskBox(stairs);
|
|
109
|
+
if (!box) return [];
|
|
110
|
+
const L = await sharp(painting).resize(W, H, { fit: 'fill' }).greyscale().raw().toBuffer();
|
|
111
|
+
// Per row: how much lighter the row below is than the row above, across the stairs' columns there.
|
|
112
|
+
const rows: number[] = [];
|
|
113
|
+
const width: number[] = [];
|
|
114
|
+
for (let y = 0; y < H; y++) {
|
|
115
|
+
if (y < box[1] + 2 || y > box[3] - 3) {
|
|
116
|
+
rows.push(0);
|
|
117
|
+
width.push(0);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
let n = 0;
|
|
121
|
+
let sum = 0;
|
|
122
|
+
for (let x = box[0]; x < box[2]; x++) {
|
|
123
|
+
if (!stairs.bits[(y - 2) * W + x] || !stairs.bits[(y + 2) * W + x]) continue;
|
|
124
|
+
n++;
|
|
125
|
+
sum += L[(y + 1) * W + x] - L[(y - 1) * W + x];
|
|
126
|
+
}
|
|
127
|
+
rows.push(n ? sum / n : 0);
|
|
128
|
+
width.push(n);
|
|
129
|
+
}
|
|
130
|
+
const widest = Math.max(...width);
|
|
131
|
+
const edge = rows.map((v, y) => (y + 1 < H && Math.max(width[y], width[y + 1]) >= 0.4 * widest ? v + rows[y + 1] : 0));
|
|
132
|
+
const strongest = Math.max(...edge);
|
|
133
|
+
if (strongest < 40) return [];
|
|
134
|
+
// Peaks: the lightest turn within 6 px either way, at least 60% of the strongest.
|
|
135
|
+
const peaks = edge.flatMap((v, y) => (v >= 0.6 * strongest && edge.slice(Math.max(0, y - 6), y + 7).every((u, k) => u < v || (u === v && Math.max(0, y - 6) + k >= y)) ? [y] : []));
|
|
136
|
+
if (peaks.length < 3) return [];
|
|
137
|
+
const gaps = peaks.slice(1).map((y, i) => y - peaks[i]).sort((a, b) => a - b);
|
|
138
|
+
const usual = gaps[Math.floor(gaps.length / 2)];
|
|
139
|
+
if (usual < 8 || gaps.some((g) => g < 0.65 * usual || g > 1.5 * usual)) return [];
|
|
140
|
+
// The top step's lit edge is the stairs' own top when there is a whole step above the first edge found.
|
|
141
|
+
const cuts = [...(peaks[0] - box[1] >= 0.65 * usual ? [box[1]] : []), ...peaks, Math.min(box[3], peaks[peaks.length - 1] + usual)];
|
|
142
|
+
return cuts.slice(1).map((y1, i) => intersect(stairs, rectMask(W, H, [box[0], cuts[i], box[2], y1]))).filter((m) => maskArea(m) > 0);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The steps inside `stairs` (the walk-on things and floors named as stairs), top step first. Empty when
|
|
147
|
+
* SAM finds no clear run of steps there: the stairs then stay a ramp, as before.
|
|
148
|
+
*/
|
|
149
|
+
export async function findSteps(memo: Memo, placeKey: string, painting: Buffer, stairs: Mask, log: (line: string) => void): Promise<NonNullable<Geometry['steps']>> {
|
|
150
|
+
if (!maskArea(stairs)) return [];
|
|
151
|
+
const found = await samFind(memo, { label: `steps:${placeKey}`, image: painting, prompt: 'stair step' });
|
|
152
|
+
const region = dilate(stairs, 6);
|
|
153
|
+
const bands: Mask[] = [];
|
|
154
|
+
for (const f of found) {
|
|
155
|
+
const m = atFrame(f.mask);
|
|
156
|
+
const area = maskArea(m);
|
|
157
|
+
const inside = intersect(m, region);
|
|
158
|
+
const box = maskBox(inside);
|
|
159
|
+
// A step is a wide, flat band, mostly inside the stairs.
|
|
160
|
+
if (!box || maskArea(inside) < 0.6 * area || box[2] - box[0] < 1.5 * (box[3] - box[1])) continue;
|
|
161
|
+
bands.push(inside);
|
|
162
|
+
}
|
|
163
|
+
if (bands.length < 2) {
|
|
164
|
+
// SAM sees no single step on many painted stairs (a stack of cardboard boards, steps drawn in a few strokes):
|
|
165
|
+
// the treads' lit edges, row after row, split the stairs instead.
|
|
166
|
+
const lit = await treadBands(painting, stairs);
|
|
167
|
+
if (lit.length < 2) {
|
|
168
|
+
log(`stairs: ${bands.length} step(s) found, kept as a ramp`);
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
log(`stairs: SAM found ${bands.length} step(s); ${lit.length} read off the treads' painted edges`);
|
|
172
|
+
bands.length = 0;
|
|
173
|
+
bands.push(...lit);
|
|
174
|
+
}
|
|
175
|
+
const steps = bands.map(stepEdges).filter((s): s is NonNullable<typeof s> => !!s).map(mendStep);
|
|
176
|
+
const mid = (s: (typeof steps)[number]) => {
|
|
177
|
+
const tops = s.top.filter((t) => t >= 0);
|
|
178
|
+
return tops.reduce((a, b) => a + b, 0) / tops.length;
|
|
179
|
+
};
|
|
180
|
+
steps.sort((a, b) => mid(a) - mid(b));
|
|
181
|
+
log(`stairs: ${steps.length} steps`);
|
|
182
|
+
return steps;
|
|
183
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Recheck stored reports against the current scene without writing or calling a model.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Geometry } from '../play/geometry.ts';
|
|
5
|
+
import { withThingEdits } from '../world/files.ts';
|
|
6
|
+
import { World, type Place } from '../world/schema.ts';
|
|
7
|
+
import { verifyPlace, type BackdropNotes, type MaskNotes, type PlateNotes, type Verdict } from './verify.ts';
|
|
8
|
+
import { Layout } from './layout.ts';
|
|
9
|
+
import { paintingLayoutHash } from './paint-layout.ts';
|
|
10
|
+
|
|
11
|
+
export function currentVerdict(worldDir: string, key: string, place?: Place): Verdict | null {
|
|
12
|
+
const dir = path.join(worldDir, 'places', key);
|
|
13
|
+
if (!fs.existsSync(path.join(dir, 'verify.json'))) return null;
|
|
14
|
+
let stored: Verdict | undefined;
|
|
15
|
+
try {
|
|
16
|
+
stored = JSON.parse(fs.readFileSync(path.join(dir, 'verify.json'), 'utf8')) as Verdict;
|
|
17
|
+
place ??= withThingEdits(World.parse(JSON.parse(fs.readFileSync(path.join(worldDir, 'world.json'), 'utf8'))), worldDir).places.find((p) => p.key === key);
|
|
18
|
+
if (!place) throw new Error(`no place ${key}`);
|
|
19
|
+
const read = (file: string) => JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
|
|
20
|
+
const optional = <T>(file: string) => (fs.existsSync(path.join(dir, file)) ? read(file) as T : {} as T);
|
|
21
|
+
if (!fs.existsSync(path.join(dir, 'photo.png')) && fs.existsSync(path.join(dir, 'painting-layout.json'))) {
|
|
22
|
+
const r = read('painting-layout.json') as { ok?: boolean; inputs?: string; attempts?: Array<{ issues: string[] }> };
|
|
23
|
+
if (!r.ok || r.inputs !== paintingLayoutHash(place, Layout.parse(read('layout.json')), fs.readFileSync(path.join(dir, 'blockin.png')), fs.readFileSync(path.join(dir, 'painting.png')))) {
|
|
24
|
+
return { ok: false, errors: [{ code: 'PAINT_LAYOUT_MISMATCH', detail: r.attempts?.at(-1)?.issues.join('; ') || 'Composition check is pending or stale: rebuild from paint.' }], flags: [] };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return verifyPlace(place, Geometry.parse(read('geometry.json')), read('masks.json') as MaskNotes, optional<PlateNotes>('plates.json'), optional<BackdropNotes>('backdrop.json'));
|
|
28
|
+
} catch {
|
|
29
|
+
if (stored?.ok === false) return stored;
|
|
30
|
+
return { ok: false, errors: [{ code: 'VERIFY_INPUTS', detail: 'Current geometry or mask data is missing or invalid: rebuild this place and verify it again.' }], flags: [] };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// verify: checks a place's geometry before anyone walks it. An error means the place is not
|
|
2
|
+
// playable as is; a flag is something a person should look at, listed in the build report.
|
|
3
|
+
// Nothing is flagged and passed silently: every finding is in verify.json with a code.
|
|
4
|
+
import type { Geometry } from '../play/geometry.ts';
|
|
5
|
+
import { personPx } from '../play/geometry.ts';
|
|
6
|
+
import type { Place } from '../world/schema.ts';
|
|
7
|
+
import { Grid, reach, roomy } from './walk.ts';
|
|
8
|
+
import { rectIou } from '../image/raster.ts';
|
|
9
|
+
|
|
10
|
+
export type Finding = { code: string; key?: string; detail: string; /** Where the thing really is, when a check found it elsewhere. */ box?: [number, number, number, number] };
|
|
11
|
+
export type Verdict = { ok: boolean; errors: Finding[]; flags: Finding[] };
|
|
12
|
+
|
|
13
|
+
/** What masks.json says about each mask. */
|
|
14
|
+
export type MaskNotes = {
|
|
15
|
+
things: Record<string, { coarse: boolean; source: string; check?: { ok: boolean; seen?: string; box?: [number, number, number, number] | null } }>;
|
|
16
|
+
floors: Record<string, { coarse: boolean; source: string; iouLayout: number }>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function gridOf(g: Pick<Geometry, 'walk' | 'zone'>): Grid {
|
|
20
|
+
const grid = new Grid(Math.max(...g.walk.map((r) => r.length)), g.walk.length);
|
|
21
|
+
g.walk.forEach((row, r) => [...row].forEach((ch, c) => {
|
|
22
|
+
const i = grid.at(c, r);
|
|
23
|
+
grid.open[i] = ch === '#' ? 1 : 0;
|
|
24
|
+
const z = g.zone?.[r]?.[c];
|
|
25
|
+
grid.zone[i] = z && z !== '.' ? Number(z) : -1;
|
|
26
|
+
}));
|
|
27
|
+
return grid;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** plates.json: whether SAM still finds the thing on its plate. */
|
|
31
|
+
export type PlateNotes = Record<string, { stillThere: boolean }>;
|
|
32
|
+
|
|
33
|
+
/** backdrop.json: per card, whether its outline cuts through a painted part of its thing. */
|
|
34
|
+
export type BackdropNotes = Record<string, { cut?: { px: number; box: [number, number, number, number] | null } }>;
|
|
35
|
+
|
|
36
|
+
const words = (text: string) => text.toLowerCase().match(/[a-z]{3,}/g)?.map((w) => w.replace(/(es|s)$/, '')) ?? [];
|
|
37
|
+
|
|
38
|
+
/** What a thing is, in a word or two: its key's words and the head noun of its looks ("a chestnut horse in its stall" -> horse). */
|
|
39
|
+
function kindWords(key: string, looks = ''): Set<string> {
|
|
40
|
+
const head = looks.toLowerCase().split(/\b(?:with|in|on|of|at|by|that|which|under|over|near|beside)\b|[,;(]/)[0];
|
|
41
|
+
return new Set([...words(key.replace(/[-_]/g, ' ')), ...words(head).slice(-1)]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Does Spark's word for what it sees name the same kind of thing? */
|
|
45
|
+
export function sameKind(seen: string, key: string, looks?: string): boolean {
|
|
46
|
+
const kind = kindWords(key, looks);
|
|
47
|
+
// What Spark says is not there does not count: "the hull of a rowboat, no can in its mask" saw no can.
|
|
48
|
+
const said = seen.replace(/\b(?:no|not|without|nor|missing)\s+(?:a|an|the|any)?\s*[a-z-]+/gi, ' ');
|
|
49
|
+
return words(said).some((w) => kind.has(w));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function verifyPlace(place: Place, g: Geometry, notes: MaskNotes, plates: PlateNotes = {}, backdrop: BackdropNotes = {}): Verdict {
|
|
53
|
+
const errors: Finding[] = [];
|
|
54
|
+
const flags: Finding[] = [];
|
|
55
|
+
const grid = gridOf(g);
|
|
56
|
+
const cells = roomy(grid);
|
|
57
|
+
const open = grid.count();
|
|
58
|
+
const cell = (x: number, y: number) => grid.cellOf(x, y);
|
|
59
|
+
|
|
60
|
+
if (open < 200) errors.push({ code: 'NO_FLOOR', detail: `only ${open} walkable cells` });
|
|
61
|
+
const start = cell(...g.start);
|
|
62
|
+
if (!cells[start]) errors.push({ code: 'START_BLOCKED', detail: `start (${g.start.join(', ')}) is not on open floor` });
|
|
63
|
+
const reached = reach(grid, start, cells);
|
|
64
|
+
// Thin fringes next to reached floor are fine; a whole patch out of reach is not.
|
|
65
|
+
const cut = [...cells.keys()].filter((i) => cells[i] && !reached[i]).length;
|
|
66
|
+
if (cut >= 20) errors.push({ code: 'UNREACHABLE', detail: `${cut} walkable cells can't be reached from the start` });
|
|
67
|
+
|
|
68
|
+
// Every floor that has cells is reachable.
|
|
69
|
+
const zones = new Set<number>();
|
|
70
|
+
grid.zone.forEach((z, i) => grid.open[i] && z >= 0 && zones.add(z));
|
|
71
|
+
for (const z of zones) {
|
|
72
|
+
const any = [...grid.zone.keys()].some((i) => grid.zone[i] === z && reached[i]);
|
|
73
|
+
if (!any) errors.push({ code: 'FLOOR_UNREACHABLE', key: String(z), detail: `floor ${z} has walkable cells but none reachable` });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const thing of place.things) {
|
|
77
|
+
const t = g.things.find((x) => x.key === thing.key);
|
|
78
|
+
if (!t) {
|
|
79
|
+
errors.push({ code: 'THING_MISSING', key: thing.key, detail: 'not in geometry.json' });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (thing.role !== 'npc' && !t.mask) errors.push({ code: 'MASK_MISSING', key: thing.key, detail: 'painted thing without a mask' });
|
|
83
|
+
const at = cell(...t.stand);
|
|
84
|
+
if (!reached[at]) {
|
|
85
|
+
const finding = { code: 'SPOT_UNREACHABLE', key: thing.key, detail: `its spot (${t.stand.map(Math.round).join(', ')}) can't be reached` };
|
|
86
|
+
// A prop can stay a thing to look at; exits, items and people must be usable.
|
|
87
|
+
(thing.role === 'prop' ? flags : errors).push(finding);
|
|
88
|
+
}
|
|
89
|
+
const check = notes.things[thing.key]?.check;
|
|
90
|
+
if (check && !check.ok) {
|
|
91
|
+
const seen = check.seen ?? 'something else';
|
|
92
|
+
// Spark points to where the thing really is, and it is where the mask is, and what it sees there is that kind
|
|
93
|
+
// of thing: a doubt about details ("but no long rays in its mask", when the painting has none), not a wrong
|
|
94
|
+
// mask. Brushing a disc of sky onto the mask to please it only gave the thing a round outline.
|
|
95
|
+
if (check.box && check.seen && rectIou(check.box, t.box) >= 0.6 && sameKind(check.seen, thing.key, thing.looks)) flags.push({ code: 'MASK_DOUBT', key: thing.key, detail: `Spark sees ${seen} in its mask, where the mask is` });
|
|
96
|
+
// Spark points to where the thing really is: the mask is wrong for sure, whatever the thing's role.
|
|
97
|
+
else if (check.box) errors.push({ code: 'MASK_WRONG', key: thing.key, detail: `Spark sees ${seen} in its mask; it is at [${check.box.join(', ')}]`, box: check.box });
|
|
98
|
+
// Spark sees no such thing anywhere, and what it sees is not even the same kind of thing: it was never
|
|
99
|
+
// painted. A doubt about details ("a bronze bell" for "a small brass bell") stays a flag to look at.
|
|
100
|
+
else if (check.seen && !sameKind(check.seen, thing.key, thing.looks)) errors.push({ code: 'THING_NOT_PAINTED', key: thing.key, detail: `Spark sees ${seen} where it should be, and no ${thing.key} anywhere` });
|
|
101
|
+
else flags.push({ code: 'MASK_DOUBT', key: thing.key, detail: `Spark sees ${seen} in its mask` });
|
|
102
|
+
}
|
|
103
|
+
if (notes.things[thing.key]?.source === 'colour-cut') flags.push({ code: 'COLOUR_CUT', key: thing.key, detail: 'SAM missed it; its mask was cut out of its box by colour' });
|
|
104
|
+
if (notes.things[thing.key]?.source === 'sketch-colour') flags.push({ code: 'COLOUR_CUT', key: thing.key, detail: 'SAM missed it; its mask is the pixels of its sketch colour in its box' });
|
|
105
|
+
if (t.coarse && thing.role !== 'npc') flags.push({ code: 'COARSE_MASK', key: thing.key, detail: `mask is a ${notes.things[thing.key]?.source ?? 'box'}, so it hides no one` });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A floor painted nowhere near its layout that the hero cannot walk on at all: the painting left the plan (stairs
|
|
109
|
+
// or a trail drawn somewhere else, or not drawn as floor), and whatever exit leads off it is only a click at an edge.
|
|
110
|
+
Object.entries(notes.floors).forEach(([key, f], z) => {
|
|
111
|
+
if (!g.zone || f.coarse || f.iouLayout >= 0.05) return;
|
|
112
|
+
if ([...grid.zone.keys()].some((i) => grid.zone[i] === z && reached[i])) return;
|
|
113
|
+
// When every exit, item and person can still be reached, the lost floor costs the game nothing (a boat's deck
|
|
114
|
+
// the hero never needs to step into): a flag to look at, not a reason the place cannot be played.
|
|
115
|
+
const needed = g.things.filter((t) => t.role === 'exit' || t.role === 'item' || t.role === 'npc');
|
|
116
|
+
const spare = needed.length > 0 && needed.every((t) => reached[cell(...t.stand)]);
|
|
117
|
+
(spare ? flags : errors).push({ code: 'FLOOR_LOST', key, detail: `painted floor overlaps its layout only ${f.iouLayout.toFixed(2)} and none of it can be walked on${spare ? '; every exit, item and person is reached without it' : ''}` });
|
|
118
|
+
});
|
|
119
|
+
for (const [key, f] of Object.entries(notes.floors)) {
|
|
120
|
+
if (f.coarse) flags.push({ code: 'FLOOR_COARSE', key, detail: `floor taken from ${f.source}` });
|
|
121
|
+
else if (f.iouLayout < 0.3) flags.push({ code: 'FLOOR_MOVED', key, detail: `painted floor overlaps its layout only ${f.iouLayout.toFixed(2)} (the painting wins)` });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Scale: the horizon above every floor, the hero neither a dot at the back nor a giant at the front.
|
|
125
|
+
const rows = g.walk.map((row, r) => (row.includes('#') ? r : -1)).filter((r) => r >= 0);
|
|
126
|
+
if (rows.length) {
|
|
127
|
+
const topY = rows[0] * g.cell;
|
|
128
|
+
const bottomY = (rows[rows.length - 1] + 1) * g.cell;
|
|
129
|
+
if (g.scale.horizonY >= topY) errors.push({ code: 'HORIZON_LOW', detail: `horizon ${g.scale.horizonY} is not above the floor top ${topY}` });
|
|
130
|
+
const near = personPx(g, bottomY);
|
|
131
|
+
const far = personPx(g, topY + g.cell);
|
|
132
|
+
if (near < 60 || near > 760) errors.push({ code: 'SCALE_NEAR', detail: `a person at the front is ${Math.round(near)} px tall` });
|
|
133
|
+
if (far < 14) errors.push({ code: 'SCALE_FAR', detail: `a person at the back is ${Math.round(far)} px tall` });
|
|
134
|
+
if (g.scale.source !== 'spark') flags.push({ code: 'SCALE_FALLBACK', detail: `scale from ${g.scale.source}` });
|
|
135
|
+
// Where his head would leave the frame.
|
|
136
|
+
let clipped = 0;
|
|
137
|
+
grid.open.forEach((v, i) => {
|
|
138
|
+
if (!v) return;
|
|
139
|
+
const [, y] = grid.centre(i);
|
|
140
|
+
if (y - personPx(g, y) < -0.3 * personPx(g, y)) clipped++;
|
|
141
|
+
});
|
|
142
|
+
if (clipped > 0.05 * open) flags.push({ code: 'HEAD_OFF_FRAME', detail: `${clipped} cells put over 30% of the hero above the frame` });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const [key, p] of Object.entries(plates)) {
|
|
146
|
+
if (p.stillThere) flags.push({ code: 'PLATE_NOT_CLEAN', key, detail: 'SAM still finds it on its plate; taking it may leave it painted' });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// With parallax a card slides against the backdrop: a part its mask left out stays behind and splits off.
|
|
150
|
+
for (const [key, b] of Object.entries(backdrop)) {
|
|
151
|
+
if (b.cut) flags.push({ code: 'CARD_CUT', key, detail: `its mask leaves out a painted part of it (${b.cut.px} px of its outline cut through it), which stays on the backdrop when the card slides; mask it again with a box around the whole thing`, ...(b.cut.box && { box: b.cut.box }) });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const line of g.flagged ?? []) flags.push({ code: 'DERIVE', detail: line });
|
|
155
|
+
return { ok: errors.length === 0, errors, flags };
|
|
156
|
+
}
|