@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,475 @@
|
|
|
1
|
+
// derive: the masks become geometry.json, everything the runtime needs to walk a place.
|
|
2
|
+
// Walkable = floors and walk-on things, minus things in the way, plus the floor hidden behind a
|
|
3
|
+
// standing thing, minus each standing thing's footprint. Then the grid is joined up, the hero's
|
|
4
|
+
// size is measured, and every thing gets a spot to stand at.
|
|
5
|
+
import { emptyMask, maskArea, type Mask } from '../image/mask.ts';
|
|
6
|
+
import { dilate, maskBox, polygonMask, rectMask, subtract, union, unionAll } from '../image/raster.ts';
|
|
7
|
+
import type { Memo } from '../meta/memo.ts';
|
|
8
|
+
import type { Geometry } from '../play/geometry.ts';
|
|
9
|
+
import type { Place } from '../world/schema.ts';
|
|
10
|
+
import { FRAME, type Layout } from './layout.ts';
|
|
11
|
+
import type { Kind } from './locate.ts';
|
|
12
|
+
import type { MaskSource } from './masks.ts';
|
|
13
|
+
import { blobsOf, footDepth, lineYAt, simplifyLine, type Blob } from './occluders.ts';
|
|
14
|
+
import { findShadows, localLight, type Shadows } from './shadows.ts';
|
|
15
|
+
import { atFrame, findSteps, STAIRS } from './stairs.ts';
|
|
16
|
+
import { paintingFor, reaches, type Carry } from './carry.ts';
|
|
17
|
+
import { doorCheck, isDoor, PERSON_M, probePoints, scaleFrom, sparkPeople, type DoorRef, type Sample, type Scale } from './scale.ts';
|
|
18
|
+
import { CELL, connect, gridFromPixels, islands, nearestRoomy, reach, roomy, type Grid } from './walk.ts';
|
|
19
|
+
import { samFind } from '../meta/sam.ts';
|
|
20
|
+
|
|
21
|
+
export type ThingIn = { key: string; kind: Kind; phrase?: string; source: MaskSource; coarse: boolean; mask: Mask };
|
|
22
|
+
/** `plates`: things that have a clean plate (plates/<key>.png), for the runtime to hide them once taken. */
|
|
23
|
+
/**
|
|
24
|
+
* carry: after a small repaint, each question whose region the change does not reach is asked about the old painting; lastScale: the scale it had.
|
|
25
|
+
* previousScale: the scale the place had before this run, whatever ran; kept when Spark cannot be asked now.
|
|
26
|
+
*/
|
|
27
|
+
export type DeriveIn = { place: Place; layout: Layout; things: ThingIn[]; floors: Mask[]; painting: Buffer; plates?: string[]; carry?: Carry; lastScale?: Scale; previousScale?: Scale;
|
|
28
|
+
/** Where the person wants the hero to begin (hero_start_set): the nearest roomy cell of the main floor. */
|
|
29
|
+
start?: [number, number];
|
|
30
|
+
/** Where the painting stands upright (depthmap.ts uprightTest): no joining path is laid across it. */
|
|
31
|
+
upright?: (x: number, y: number) => boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Floor someone said is floor: brush strokes that add to a floor (the person's, the agent's, the free bridge's)
|
|
34
|
+
* and floor cells the person added. It is walked on whatever thing's mask lies over it.
|
|
35
|
+
*/
|
|
36
|
+
said?: Mask };
|
|
37
|
+
export type Derived = { geometry: Geometry; grid: Grid; blobs: Blob[]; footprint: Mask; probes: Sample[]; shadows: Shadows };
|
|
38
|
+
|
|
39
|
+
const { width: W, height: H } = FRAME;
|
|
40
|
+
/** The tallest the hero may be: three quarters of the picture. */
|
|
41
|
+
export const NEAR_MAX_PX = Math.round(0.75 * H);
|
|
42
|
+
/** How far (px) a layout light's point may lie outside the thing it belongs to: a glow drawn just off the lamp. */
|
|
43
|
+
const LIGHT_REACH = 24;
|
|
44
|
+
|
|
45
|
+
/** The floor hidden behind a standing blob: its pixels behind the footprint, in rows with floor on both sides. */
|
|
46
|
+
export function hiddenFloor(blob: Blob, floor: Mask, reachPx = 24): Mask {
|
|
47
|
+
const out = emptyMask(W, H);
|
|
48
|
+
const [x0, y0, , y1] = blob.box;
|
|
49
|
+
const depth = footDepth(blob.line[blob.line.length - 1][0] - blob.line[0][0]);
|
|
50
|
+
for (let y = y0; y < y1; y++) {
|
|
51
|
+
let left = -1;
|
|
52
|
+
let right = -1;
|
|
53
|
+
for (let x = blob.box[0]; x < blob.box[2]; x++) {
|
|
54
|
+
if (!blob.mask.bits[y * W + x]) continue;
|
|
55
|
+
if (left < 0) left = x;
|
|
56
|
+
right = x;
|
|
57
|
+
}
|
|
58
|
+
if (left < 0) continue;
|
|
59
|
+
const floorNear = (from: number, to: number) => {
|
|
60
|
+
for (let x = Math.max(0, from); x <= Math.min(W - 1, to); x++) if (floor.bits[y * W + x]) return true;
|
|
61
|
+
return false;
|
|
62
|
+
};
|
|
63
|
+
if (!floorNear(left - reachPx, left - 1) || !floorNear(right + 1, right + reachPx)) continue;
|
|
64
|
+
for (let x = left; x <= right; x++) {
|
|
65
|
+
if (x < x0) continue;
|
|
66
|
+
if (blob.mask.bits[y * W + x] && y < lineYAt(blob.line, x) - depth) out.bits[y * W + x] = 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The rows of each standing blob's foot: no one stands inside a barrel. */
|
|
73
|
+
export function footprintMask(blobs: Blob[]): Mask {
|
|
74
|
+
const out = emptyMask(W, H);
|
|
75
|
+
for (const blob of blobs) {
|
|
76
|
+
const from = blob.line[0][0];
|
|
77
|
+
const to = blob.line[blob.line.length - 1][0];
|
|
78
|
+
const depth = footDepth(to - from);
|
|
79
|
+
for (let x = from; x <= to && x < W; x++) {
|
|
80
|
+
const front = lineYAt(blob.line, x);
|
|
81
|
+
for (let y = Math.max(0, Math.round(front - depth)); y < Math.min(H, Math.round(front + 2)); y++) out.bits[y * W + x] = 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Doors standing on the floor, measured on their masks (the tallest piece): the person at a door's foot, and
|
|
89
|
+
* the door as a reference for Spark. A door cut off by the top of the frame only says how tall a person is at least.
|
|
90
|
+
*/
|
|
91
|
+
export function doorsOn(things: ThingIn[], layout: Layout, ground: Mask): Array<{ door: DoorRef; ref: { key: string; x: number; footY: number; px: number; metres: number } }> {
|
|
92
|
+
return things.flatMap((t) => {
|
|
93
|
+
const metres = layout.things.find((l) => l.key === t.key)?.metres;
|
|
94
|
+
if (t.kind === 'walk-on' || t.kind === 'flat' || t.coarse || !isDoor(`${t.key} ${t.phrase ?? ''}`, metres)) return [];
|
|
95
|
+
const b = blobsOf(t.key, t.mask).sort((p, q) => q.baseY - q.box[1] - (p.baseY - p.box[1]))[0];
|
|
96
|
+
if (!b) return [];
|
|
97
|
+
const tall = b.baseY - b.box[1];
|
|
98
|
+
const x = Math.round((b.box[0] + b.box[2]) / 2);
|
|
99
|
+
// On the floor: floor just under its foot, in the middle half of its width.
|
|
100
|
+
let floorBelow = false;
|
|
101
|
+
for (let y = Math.round(b.baseY); y < Math.min(H, b.baseY + 16) && !floorBelow; y++) {
|
|
102
|
+
for (let u = Math.round(b.box[0] + (b.box[2] - b.box[0]) / 4); u <= x + (b.box[2] - b.box[0]) / 4; u += 2) if (ground.bits[y * W + u]) floorBelow = true;
|
|
103
|
+
}
|
|
104
|
+
if (!floorBelow || tall < 40 || !metres) return [];
|
|
105
|
+
return [{ door: { key: t.key, y: b.baseY, px: (tall * PERSON_M) / metres, atLeast: b.box[1] <= 2 }, ref: { key: t.key, x, footY: b.baseY, px: tall, metres } }];
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Scale anchors from things with a real height in the layout, measured on their masks. */
|
|
110
|
+
function anchorSamples(blobs: Blob[], layout: Layout): Sample[] {
|
|
111
|
+
return blobs.flatMap((b) => {
|
|
112
|
+
const metres = layout.things.find((t) => t.key === b.thing)?.metres;
|
|
113
|
+
const tall = b.baseY - b.box[1];
|
|
114
|
+
return metres && tall > 10 ? [{ y: b.baseY, px: (tall * PERSON_M) / metres }] : [];
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What stands in the way of walking, and what is walked on anyway. A thing's mask blocks the floor, but not where
|
|
120
|
+
* someone brushed floor over it (the free bridge across a rock, the person's stroke up a slope), and not over a
|
|
121
|
+
* smaller walk-on thing inside it: stairs cut into a rock, a jetty running under a boathouse's mask. A thing smaller
|
|
122
|
+
* than the walk-on thing it stands on (a barrel on the jetty) still blocks it.
|
|
123
|
+
*/
|
|
124
|
+
export function wayClear(things: ThingIn[], blocking: ThingIn[], said: Mask | undefined): { inTheWay: Mask; walkedAnyway: Mask } {
|
|
125
|
+
const walkOns = things.filter((t) => t.kind === 'walk-on').map((t) => ({ mask: t.mask, px: maskArea(t.mask) }));
|
|
126
|
+
const masks = blocking.map((t) => {
|
|
127
|
+
const px = maskArea(t.mask);
|
|
128
|
+
const under = walkOns.filter((w) => w.px > 0 && w.px < px).map((w) => w.mask);
|
|
129
|
+
return under.length ? subtract(t.mask, unionAll(W, H, under)) : t.mask;
|
|
130
|
+
});
|
|
131
|
+
const raw = unionAll(W, H, blocking.map((t) => t.mask));
|
|
132
|
+
let inTheWay = unionAll(W, H, masks);
|
|
133
|
+
if (said) inTheWay = subtract(inTheWay, said);
|
|
134
|
+
// Pixels some thing's mask covers that are walked on all the same: the footprints there are lifted too.
|
|
135
|
+
const walkedAnyway = subtract(raw, inTheWay);
|
|
136
|
+
return { inTheWay, walkedAnyway };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function derive(memo: Memo, input: DeriveIn, log: (line: string) => void): Promise<Derived> {
|
|
140
|
+
const { place, layout, things, floors, painting, carry } = input;
|
|
141
|
+
const role = new Map(place.things.map((t) => [t.key, t.role]));
|
|
142
|
+
const floorAny = unionAll(W, H, floors);
|
|
143
|
+
const walkOn = unionAll(W, H, things.filter((t) => t.kind === 'walk-on').map((t) => t.mask));
|
|
144
|
+
// A coarse box wider than a third of the picture is a row or a group (mooring posts along a quay, a line of
|
|
145
|
+
// stalls), not one thing to walk round: as a box it would wall the place in two. It stays a thing to click, but
|
|
146
|
+
// only sharp masks and narrower boxes stand in the way.
|
|
147
|
+
// A sharp mask over a quarter of the picture is no prop either: SAM took the quay with the posts on it. Nor is a
|
|
148
|
+
// wide thing found only by its sketch colour (maze walls, a hedge round a yard): its pixels draw its outline, the
|
|
149
|
+
// floors drawn for the place say where to walk.
|
|
150
|
+
const sprawling = (t: ThingIn) => t.kind !== 'walk-on' && t.kind !== 'flat'
|
|
151
|
+
&& (((t.coarse || t.source === 'sketch-colour') && (() => { const b = maskBox(t.mask); return !!b && b[2] - b[0] > W / 3; })()) || maskArea(t.mask) > (W * H) / 4);
|
|
152
|
+
const { inTheWay, walkedAnyway } = wayClear(things, things.filter((t) => t.kind !== 'walk-on' && !sprawling(t)), input.said);
|
|
153
|
+
const ground = input.said ? unionAll(W, H, [floorAny, walkOn, input.said]) : union(floorAny, walkOn);
|
|
154
|
+
if (maskArea(walkedAnyway) > 0) log(`${maskArea(walkedAnyway)} px of things' masks walked on anyway (brushed floor, or stairs and walk-on things inside them)`);
|
|
155
|
+
|
|
156
|
+
// Standing things (and things on top of nothing) meet the floor; coarse masks still block it.
|
|
157
|
+
const standing = things.filter((t) => (t.kind === 'standing' || t.kind === 'on-top') && !sprawling(t));
|
|
158
|
+
const blobs = standing.flatMap((t) => blobsOf(t.key, t.mask));
|
|
159
|
+
const onFloor = blobs.filter((b) => things.find((t) => t.key === b.thing)!.kind === 'standing');
|
|
160
|
+
// A coarse mask is a box, not a silhouette: nobody walks behind it, and all of it is in the way.
|
|
161
|
+
const isCoarse = (b: Blob) => things.find((t) => t.key === b.thing)!.coarse;
|
|
162
|
+
// No footprint where the floor is walked on anyway: a rock's foot line must not cut the stairs cut into it.
|
|
163
|
+
const footprint = subtract(union(footprintMask(onFloor), unionAll(W, H, onFloor.filter(isCoarse).map((b) => b.mask))), input.said ? union(walkedAnyway, input.said) : walkedAnyway);
|
|
164
|
+
const behind = unionAll(W, H, onFloor.filter((b) => !isCoarse(b)).map((b) => hiddenFloor(b, ground)));
|
|
165
|
+
const walkPx = subtract(union(subtract(ground, inTheWay), behind), footprint);
|
|
166
|
+
|
|
167
|
+
const floorOf = (p: number) => floors.findIndex((f) => f.bits[p]);
|
|
168
|
+
let grid = gridFromPixels(walkPx, floorOf, floors.length);
|
|
169
|
+
const flagged: string[] = [];
|
|
170
|
+
const flag = (line: string) => {
|
|
171
|
+
flagged.push(line);
|
|
172
|
+
log(line);
|
|
173
|
+
};
|
|
174
|
+
for (const t of things) if (sprawling(t)) flag(`${t.key}: its mask is too big for one thing (a row, or the floor it stands on), so it does not block the floor`);
|
|
175
|
+
|
|
176
|
+
// Join up the floor: closed cells on a painted floor are cheap to open, the layout's floors dearer,
|
|
177
|
+
// things and footprints never.
|
|
178
|
+
const planned = unionAll(W, H, layout.floors.map((f) => polygonMask(W, H, f.outline)));
|
|
179
|
+
const blocked = union(inTheWay, footprint);
|
|
180
|
+
const groundTop = Math.min(...floors.map((f) => maskBox(f)?.[1] ?? H), ...layout.floors.flatMap((f) => f.outline.map(([, y]) => y)));
|
|
181
|
+
const nearFloor = dilate(union(ground, planned), 48);
|
|
182
|
+
let water: Mask | undefined;
|
|
183
|
+
// Cells the gates below shut stay shut: joining the floor up again must not reopen them.
|
|
184
|
+
const shut = new Uint8Array(grid.open.length);
|
|
185
|
+
let wallCost = Infinity;
|
|
186
|
+
const cost = (i: number) => {
|
|
187
|
+
if (shut[i]) return Infinity;
|
|
188
|
+
const [x, y] = grid.centre(i).map(Math.floor);
|
|
189
|
+
const p = Math.min(H - 1, y) * W + Math.min(W - 1, x);
|
|
190
|
+
if (blocked.bits[p] && !behind.bits[p]) return Infinity;
|
|
191
|
+
// Nobody walks on painted water, wherever the layout planned a floor.
|
|
192
|
+
if (water?.bits[p]) return Infinity;
|
|
193
|
+
// A path across the side of a bridge or the wall down to an underpass is thin air: first no path climbs a wall;
|
|
194
|
+
// only a floor no other way reaches is joined up one, at a price, and flagged (verify: WALK_ON_FACE).
|
|
195
|
+
const wall = wallCost && input.upright?.(x, y) ? wallCost : 0;
|
|
196
|
+
// Muse moves floors away from the layout, so the ground between them is fair game too, at a price.
|
|
197
|
+
// A corridor between floors stays near them: no paths cut across a lawn or open water.
|
|
198
|
+
return wall + (ground.bits[p] ? 1 : planned.bits[p] ? 3 : y >= groundTop && nearFloor.bits[p] ? 6 : Infinity);
|
|
199
|
+
};
|
|
200
|
+
// The start is clear of every thing (by about a hero's width), so nobody begins standing in a lamp.
|
|
201
|
+
// Walk-on and flat things (a jetty, a rug) are floor: near them is fine.
|
|
202
|
+
// NPCs are drawn by the game, not painted, so they have no mask: the ground just in front of one is kept clear
|
|
203
|
+
// too, or the hero starts standing over them and hides them in the first frame.
|
|
204
|
+
const npcZones = layout.things.filter((l) => role.get(l.key) === 'npc' && !things.some((t) => t.key === l.key))
|
|
205
|
+
.map((l) => rectMask(W, H, [Math.round(l.x - l.width / 2 - 60), Math.round(l.footY - l.height), Math.round(l.x + l.width / 2 + 60), Math.round(l.footY + 0.6 * l.height)]));
|
|
206
|
+
const nearThings = union(dilate(unionAll(W, H, things.filter((t) => t.kind !== 'walk-on' && t.kind !== 'flat').map((t) => t.mask)), 24), unionAll(W, H, npcZones));
|
|
207
|
+
// The start is on the biggest stretch of floor, as near the front middle as it can be: a strip of
|
|
208
|
+
// quay cut off by the river is not where the game begins.
|
|
209
|
+
const startOf = (): number | undefined => {
|
|
210
|
+
const clear = roomy(grid).map((v, i) => {
|
|
211
|
+
const [x, y] = grid.centre(i).map(Math.floor);
|
|
212
|
+
return v && !nearThings.bits[Math.min(H - 1, y) * W + Math.min(W - 1, x)] ? 1 : 0;
|
|
213
|
+
});
|
|
214
|
+
const biggest = islands(grid, clear).sort((a, b) => b.length - a.length)[0] ?? [];
|
|
215
|
+
const main = new Uint8Array(clear.length);
|
|
216
|
+
biggest.forEach((i) => (main[i] = 1));
|
|
217
|
+
const [sx, sy] = input.start ?? [W / 2, H - 40];
|
|
218
|
+
return nearestRoomy(grid, sx, sy, main) ?? nearestRoomy(grid, sx, sy, clear) ?? nearestRoomy(grid, sx, sy);
|
|
219
|
+
};
|
|
220
|
+
let startCell = startOf();
|
|
221
|
+
if (startCell === undefined) throw new Error(`${place.key}: no walkable floor at all`);
|
|
222
|
+
const walls = input.upright && { open: grid.open.slice(), zone: grid.zone.slice() };
|
|
223
|
+
let joined = connect(grid, startCell, cost);
|
|
224
|
+
// Joining floors may have laid a path across the water between them: SAM finds the water (only then),
|
|
225
|
+
// and the floor is joined again around it.
|
|
226
|
+
if (joined.reopened) {
|
|
227
|
+
// The water found last time stands when the change reaches none of it.
|
|
228
|
+
const ask = (image: Buffer) => samFind(memo, { label: `water:${place.key}/painting`, image, prompt: 'water' });
|
|
229
|
+
let hits = await ask(carry?.old ?? painting);
|
|
230
|
+
if (carry && reaches(carry, hits.map((f) => atFrame(f.mask)))) hits = await ask(painting);
|
|
231
|
+
const wet = subtract(unionAll(W, H, hits.map((f) => atFrame(f.mask))), ground);
|
|
232
|
+
const fresh = gridFromPixels(walkPx, floorOf, floors.length);
|
|
233
|
+
const wetAt = (i: number) => {
|
|
234
|
+
const [x, y] = grid.centre(i).map(Math.floor);
|
|
235
|
+
return wet.bits[Math.min(H - 1, y) * W + Math.min(W - 1, x)] === 1;
|
|
236
|
+
};
|
|
237
|
+
if (maskArea(wet) >= 4000 && grid.open.some((v, i) => v && !fresh.open[i] && wetAt(i))) {
|
|
238
|
+
water = wet;
|
|
239
|
+
grid = fresh;
|
|
240
|
+
startCell = startOf()!;
|
|
241
|
+
joined = connect(grid, startCell, cost);
|
|
242
|
+
log(`water between the floors: joined again around it`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (joined.stranded.length && input.upright && walls) {
|
|
246
|
+
// A floor no path reaches but up a wall (a plaza above an underpass, a bridge's far bank): joined as it was before
|
|
247
|
+
// walls counted, and flagged, when that reaches more. Verify then names the cells over the wall (WALK_ON_FACE).
|
|
248
|
+
const kept = { open: grid.open.slice(), zone: grid.zone.slice(), joined };
|
|
249
|
+
grid.open.set(walls.open);
|
|
250
|
+
grid.zone.set(walls.zone);
|
|
251
|
+
wallCost = 0;
|
|
252
|
+
const climbed = connect(grid, startCell, cost);
|
|
253
|
+
wallCost = Infinity;
|
|
254
|
+
if (climbed.stranded.length < joined.stranded.length) {
|
|
255
|
+
const over = [...grid.open.keys()].filter((i) => grid.open[i] && !walls.open[i] && input.upright!(...grid.centre(i))).length;
|
|
256
|
+
flag(`no floor joins ${joined.stranded.length - climbed.stranded.length} part(s) of the floor but up a wall: the path opened ${over} cells on it (the hero walks over the wall there); a stair or a floor between them is better`);
|
|
257
|
+
joined = climbed;
|
|
258
|
+
} else {
|
|
259
|
+
grid.open.set(kept.open);
|
|
260
|
+
grid.zone.set(kept.zone);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (joined.reopened) flag(`opened ${joined.reopened} cells to join the floor up`);
|
|
264
|
+
if (joined.dropped) log(`closed ${joined.dropped} cells in specks cut off from the start`);
|
|
265
|
+
for (const island of joined.stranded) flag(`${island.length} walkable cells can't be reached from the start`);
|
|
266
|
+
|
|
267
|
+
// Scale: Spark's people first, then the things' heights, then the layout.
|
|
268
|
+
const open = [...grid.open.keys()].filter((i) => grid.open[i]);
|
|
269
|
+
const topY = Math.min(...open.map((i) => grid.centre(i)[1])) - CELL / 2;
|
|
270
|
+
// Probe points come from the painted floor, not the grid, so later gates don't move them
|
|
271
|
+
// (a moved point is a new question, and Spark's answers vary from question to question).
|
|
272
|
+
const byRow = new Map<number, number[]>();
|
|
273
|
+
for (let y = CELL / 2; y < H; y += CELL) {
|
|
274
|
+
for (let x = CELL / 2; x < W; x += CELL) if (floorAny.bits[y * W + x]) byRow.set(y, [...(byRow.get(y) ?? []), x]);
|
|
275
|
+
}
|
|
276
|
+
const sharpOnFloor = onFloor.filter((b) => !things.find((t) => t.key === b.thing)!.coarse);
|
|
277
|
+
// Doors on the floor (a wall thing, so not among the standing ones): the plainest measure of a person there is.
|
|
278
|
+
const doors = doorsOn(things, layout, ground);
|
|
279
|
+
const refs = [...sharpOnFloor.flatMap((b) => {
|
|
280
|
+
const metres = layout.things.find((t) => t.key === b.thing)?.metres;
|
|
281
|
+
return metres ? [{ key: b.thing, x: (b.box[0] + b.box[2]) / 2, footY: b.baseY, px: b.baseY - b.box[1], metres }] : [];
|
|
282
|
+
}), ...doors.map((d) => d.ref)];
|
|
283
|
+
// Spark's questions about one painting are asked side by side: they do not wait on each other.
|
|
284
|
+
// Painted shadows on the open floor, and the light they tell of.
|
|
285
|
+
// Casters: sharp things on the floor, and things resting on walkable ground (a basket on the steps) too.
|
|
286
|
+
const restsOnGround = (b: Blob) => ground.bits[Math.min(H - 1, Math.round(b.baseY) + 3) * W + Math.round((b.box[0] + b.box[2]) / 2)] === 1;
|
|
287
|
+
const casters = [...sharpOnFloor, ...blobs.filter((b) => !onFloor.includes(b) && !isCoarse(b) && restsOnGround(b))];
|
|
288
|
+
const lit = subtract(ground, dilate(inTheWay, 2));
|
|
289
|
+
// The painted shadows are measured where the change reaches them; where the sun is does not move with a repaint
|
|
290
|
+
// (a lamp lit by one is the light below), so Spark is asked about the painting before it.
|
|
291
|
+
const shadowsAsked = findShadows(memo, place.key, paintingFor(carry, painting, [lit, ...casters.map((b) => b.box)], 24), lit, casters, carry?.old ?? painting);
|
|
292
|
+
// A fire or a lamp outshining the sky throws each person's shadow away from itself.
|
|
293
|
+
// The layout names a light by its glow ("lamp-glow" at the lamp's head, "oven-fire") as often as by its
|
|
294
|
+
// thing ("street-lamp", "warm-oven"), so a light that names no thing goes to a thing near its point: one
|
|
295
|
+
// sharing a word with it, else one whose mask comes within reach of it. Floors and flat things hold no
|
|
296
|
+
// light, and a light no thing holds (a lantern the painting has no mask for) is left out.
|
|
297
|
+
const boxed = things.flatMap((t) => { const b = t.kind === 'walk-on' || t.kind === 'flat' ? undefined : maskBox(t.mask); return b ? [{ key: t.key, box: b, mask: t.mask }] : []; });
|
|
298
|
+
const near = (b: [number, number, number, number], x: number, y: number) => x >= b[0] - LIGHT_REACH && x <= b[2] + LIGHT_REACH && y >= b[1] - LIGHT_REACH && y <= b[3] + LIGHT_REACH;
|
|
299
|
+
const area = (b: [number, number, number, number]) => (b[2] - b[0]) * (b[3] - b[1]);
|
|
300
|
+
const words = (k: string) => k.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2);
|
|
301
|
+
const touches = (m: Mask, x: number, y: number) => {
|
|
302
|
+
for (let v = Math.max(0, Math.round(y) - LIGHT_REACH); v <= Math.min(H - 1, Math.round(y) + LIGHT_REACH); v += 2) {
|
|
303
|
+
for (let u = Math.max(0, Math.round(x) - LIGHT_REACH); u <= Math.min(W - 1, Math.round(x) + LIGHT_REACH); u += 2) if (m.bits[v * W + u]) return true;
|
|
304
|
+
}
|
|
305
|
+
return false;
|
|
306
|
+
};
|
|
307
|
+
const lights = [...new Map(layout.lights.flatMap((l) => {
|
|
308
|
+
const around = boxed.filter((t) => near(t.box, l.x, l.y)).sort((a, b) => area(a.box) - area(b.box));
|
|
309
|
+
const t = boxed.find((t) => t.key === l.key)
|
|
310
|
+
?? around.find((t) => words(t.key).some((w) => words(l.key).includes(w)))
|
|
311
|
+
?? around.find((t) => touches(t.mask, l.x, l.y));
|
|
312
|
+
return t ? [[t.key, { key: t.key, box: t.box }] as const] : [];
|
|
313
|
+
})).values()];
|
|
314
|
+
// A repaint of a lamp or of its glow is asked again; one elsewhere does not change what lights the ground.
|
|
315
|
+
const fireAsked = lights.length ? localLight(memo, `light-source:${place.key}`, paintingFor(carry, painting, lights.map((l) => l.box), 80), lights) : Promise.resolve(undefined);
|
|
316
|
+
// Stairs: the walk-on things and floors named as stairs get their steps, so feet land on the treads.
|
|
317
|
+
const looks = new Map(place.things.map((t) => [t.key, t.looks ?? '']));
|
|
318
|
+
const stairs = unionAll(W, H, [
|
|
319
|
+
...things.filter((t) => t.kind === 'walk-on' && STAIRS.test(`${t.key} ${t.phrase ?? ''} ${looks.get(t.key)}`)).map((t) => t.mask),
|
|
320
|
+
...layout.floors.flatMap((f, i) => (STAIRS.test(`${f.key} ${f.is}`) && floors[i] ? [floors[i]] : [])),
|
|
321
|
+
]);
|
|
322
|
+
const stepsAsked = findSteps(memo, place.key, paintingFor(carry, painting, stairs), stairs, log);
|
|
323
|
+
// Their failures surface where they are awaited, below, not as an unhandled rejection while the scale is asked.
|
|
324
|
+
for (const asked of [shadowsAsked, fireAsked, stepsAsked]) asked.catch(() => undefined);
|
|
325
|
+
|
|
326
|
+
const points = probePoints([...byRow].map(([y, xs]) => ({ y, xs })));
|
|
327
|
+
// A repaint changes how a place looks, not how big a person is in it, and Spark's sizes drift from one asking
|
|
328
|
+
// to the next: after a small repaint the place keeps the scale it had.
|
|
329
|
+
const kept = carry && input.lastScale;
|
|
330
|
+
let sparkFailed = '';
|
|
331
|
+
const probes = kept ? [] : await sparkPeople(memo, `scale:${place.key}`, painting, points, refs).catch((error) => ((sparkFailed = error instanceof Error ? error.message.slice(0, 160) : String(error)), [] as Sample[]));
|
|
332
|
+
let scale: Scale | undefined = kept || undefined;
|
|
333
|
+
if (kept) log(`scale kept from before the repaint (${kept.source})`);
|
|
334
|
+
// Spark could not be asked (a network or model hiccup): the scale Spark gave this place before beats a guess from
|
|
335
|
+
// object heights, which shrank a hero to a dwarf by a kitchen stove.
|
|
336
|
+
if (sparkFailed) {
|
|
337
|
+
flag(`scale from spark: the asks failed (${sparkFailed})`);
|
|
338
|
+
if (input.previousScale?.source === 'spark') {
|
|
339
|
+
scale = input.previousScale;
|
|
340
|
+
log('scale kept from the last build, when Spark answered');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
let fitted: Sample[] = [];
|
|
344
|
+
for (const [source, samples] of kept || scale ? [] : ([['spark', probes], ['anchors', anchorSamples(sharpOnFloor, layout)]] as const)) {
|
|
345
|
+
if (source === 'spark' && sparkFailed) continue;
|
|
346
|
+
const fit = scaleFrom(samples, topY, layout.horizon, source);
|
|
347
|
+
if (fit.scale) {
|
|
348
|
+
scale = fit.scale;
|
|
349
|
+
fitted = samples;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
flag(`scale from ${source}: ${fit.why}`);
|
|
353
|
+
}
|
|
354
|
+
// A person by the door is held against it: the nearest door (the tallest in the painting) is measured best.
|
|
355
|
+
const door = [...doors].sort((a, b) => b.door.px - a.door.px)[0]?.door;
|
|
356
|
+
if (scale && door && !kept) {
|
|
357
|
+
const checked = doorCheck(scale, fitted, door, topY);
|
|
358
|
+
if (checked?.scale) scale = checked.scale;
|
|
359
|
+
if (checked) flag(`scale: ${checked.why}`);
|
|
360
|
+
}
|
|
361
|
+
if (!scale) {
|
|
362
|
+
scale = { horizonY: Math.min(layout.horizon, topY - 40), pxPerY: 0.5, source: 'layout' };
|
|
363
|
+
flag('scale: guessed from the layout horizon');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// The frame gate: no standing where a good part of the hero would be outside the picture.
|
|
367
|
+
const sized = scale;
|
|
368
|
+
let framed = 0;
|
|
369
|
+
grid.open.forEach((v, i) => {
|
|
370
|
+
if (!v) return;
|
|
371
|
+
const [x, y] = grid.centre(i);
|
|
372
|
+
const h = Math.max(8, sized.pxPerY * (y - sized.horizonY));
|
|
373
|
+
if (x < 0.1 * h || x > W - 0.1 * h || y < 0.7 * h) {
|
|
374
|
+
grid.open[i] = 0;
|
|
375
|
+
shut[i] = 1;
|
|
376
|
+
framed++;
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
// The near gate: in a close-up interior the front rows sit right under the camera, where the hero would be
|
|
380
|
+
// taller than the picture (a bakery at 942 px). Those rows are foreground to look past, not floor to stand on.
|
|
381
|
+
const tooNear = [...grid.open.keys()].filter((i) => grid.open[i] && sized.pxPerY * (grid.centre(i)[1] - sized.horizonY) > NEAR_MAX_PX);
|
|
382
|
+
if (tooNear.length && grid.count() - tooNear.length >= 200) {
|
|
383
|
+
tooNear.forEach((i) => (grid.open[i] = 0, shut[i] = 1));
|
|
384
|
+
framed += tooNear.length;
|
|
385
|
+
flag(`closed ${tooNear.length} front cells where the hero would be over ${NEAR_MAX_PX} px tall (too near the camera)`);
|
|
386
|
+
}
|
|
387
|
+
if (framed) {
|
|
388
|
+
log(`closed ${framed} cells at the frame edge or too near the camera, where the hero would be cut off`);
|
|
389
|
+
if (!roomy(grid)[startCell]) startCell = startOf() ?? startCell;
|
|
390
|
+
const again = connect(grid, startCell, cost);
|
|
391
|
+
for (const island of again.stranded) flag(`${island.length} walkable cells can't be reached from the start after the frame gate`);
|
|
392
|
+
joined.stranded = again.stranded;
|
|
393
|
+
}
|
|
394
|
+
// Floor walled off by a thing (a dock strip behind a kayak) is painted, not walkable: close it, as long as
|
|
395
|
+
// it is a small part. A big part cut off means something else is wrong, and verify should say so.
|
|
396
|
+
const cutOff = joined.stranded.flat();
|
|
397
|
+
if (cutOff.length && cutOff.length < 0.25 * grid.count()) {
|
|
398
|
+
cutOff.forEach((i) => (grid.open[i] = 0));
|
|
399
|
+
flag(`closed ${cutOff.length} cells no path reaches (walled off by things)`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The standing blob a thing on top rests on: its middle column is over it, its base near or inside it. */
|
|
403
|
+
const support = (b: Blob) => {
|
|
404
|
+
const [mx] = b.line[Math.floor(b.line.length / 2)];
|
|
405
|
+
return onFloor.find((o) => o.thing !== b.thing && mx >= o.box[0] && mx <= o.box[2] && b.baseY >= o.box[1] - 10 && b.baseY <= o.baseY + 10);
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
// Spots to stand at: the reachable roomy cell nearest the front of each thing (of its support, for a thing on top).
|
|
409
|
+
const cells = roomy(grid);
|
|
410
|
+
const reached = reach(grid, startCell, cells);
|
|
411
|
+
const spot = (x: number, y: number): [number, number] => {
|
|
412
|
+
const i = nearestRoomy(grid, x, y, reached) ?? startCell;
|
|
413
|
+
return grid.centre(i);
|
|
414
|
+
};
|
|
415
|
+
const geoThings: Geometry['things'] = place.things.map((t) => {
|
|
416
|
+
const thing = things.find((m) => m.key === t.key);
|
|
417
|
+
if (!thing) {
|
|
418
|
+
const l = layout.things.find((b) => b.key === t.key)!;
|
|
419
|
+
return { key: t.key, role: t.role, box: [l.x - l.width / 2, l.footY - l.height, l.x + l.width / 2, l.footY], stand: spot(l.x, l.footY), coarse: true };
|
|
420
|
+
}
|
|
421
|
+
const own = blobs.filter((b) => b.thing === t.key);
|
|
422
|
+
const box = maskBox(thing.mask) ?? [0, 0, 0, 0];
|
|
423
|
+
const cx = (box[0] + box[2]) / 2;
|
|
424
|
+
const under = thing.kind === 'on-top' && own[0] ? support(own[0]) : undefined;
|
|
425
|
+
const baseY = under ? under.baseY : own.length ? Math.max(...own.map((b) => b.baseY)) : box[3];
|
|
426
|
+
const stand = thing.kind === 'walk-on' || thing.kind === 'flat' ? spot(cx, (box[1] + box[3]) / 2) : spot(cx, baseY + 14);
|
|
427
|
+
return { key: t.key, role: t.role, kind: thing.kind, box, mask: `masks/${t.key}.png`, stand, ...(thing.coarse && { coarse: true }), ...(input.plates?.includes(t.key) && { plate: `plates/${t.key}.png` }) };
|
|
428
|
+
});
|
|
429
|
+
for (const t of geoThings) {
|
|
430
|
+
// Npcs stand where the layout put them; wall things (a ship at sea) may be far from any floor.
|
|
431
|
+
if (role.get(t.key) === 'npc' || t.kind === 'wall' || t.kind === 'on-top') continue;
|
|
432
|
+
const [sx, sy] = t.stand;
|
|
433
|
+
const gap = Math.max(0, t.box[0] - sx, sx - t.box[2], t.box[1] - sy, sy - t.box[3] - 60);
|
|
434
|
+
if (gap > 80) flag(`${t.key}: the nearest reachable spot is ${Math.round(gap)} px away`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Occluders: sharp masks only. A coarse box would cut a rectangle out of the hero.
|
|
438
|
+
const occluders: Geometry['occluders'] = [];
|
|
439
|
+
for (const b of blobs) {
|
|
440
|
+
const thing = things.find((t) => t.key === b.thing)!;
|
|
441
|
+
if (thing.coarse) continue;
|
|
442
|
+
const base = { key: b.key, thing: b.thing, box: b.box, mask: `masks/${b.thing}.png`, baseY: b.baseY, floor: simplifyLine(b.line), ...(b.front && { front: true }) };
|
|
443
|
+
if (thing.kind === 'on-top') {
|
|
444
|
+
const under = support(b);
|
|
445
|
+
occluders.push(under ? { ...base, on: under.key } : base);
|
|
446
|
+
} else {
|
|
447
|
+
occluders.push({ ...base, foot: b.foot });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// The runtime gives each occluder pixel to the first box that holds it: small boxes first.
|
|
451
|
+
occluders.sort((a, b) => (a.box[2] - a.box[0]) * (a.box[3] - a.box[1]) - (b.box[2] - b.box[0]) * (b.box[3] - b.box[1]));
|
|
452
|
+
|
|
453
|
+
// The questions about shadows, lights and steps were asked alongside the scale's.
|
|
454
|
+
const [shadows, fire, steps] = await Promise.all([shadowsAsked, fireAsked, stepsAsked]);
|
|
455
|
+
if (fire) {
|
|
456
|
+
shadows.light = { dir: [0, 1], length: 1.5, strength: shadows.light?.strength ?? 0.45, at: fire.at, ...(fire.flame && { flame: true }), ...(fire.also && { also: fire.also }) };
|
|
457
|
+
log(`light from ${fire.key} at ${fire.at.join(', ')}${fire.flame ? ' (a flame)' : ''}${fire.also ? `, and ${fire.also.length} alike at ${fire.also.map((a) => a.join(', ')).join('; ')}` : ''}`);
|
|
458
|
+
}
|
|
459
|
+
if (shadows.light && !shadows.light.at) log(`light ${shadows.light.dir.join(', ')} x${shadows.light.length}, strength ${shadows.light.strength} (${shadows.source})`);
|
|
460
|
+
|
|
461
|
+
const { walk, zone } = grid.rowsOut();
|
|
462
|
+
const geometry: Geometry = {
|
|
463
|
+
frame: { width: W, height: H },
|
|
464
|
+
cell: CELL,
|
|
465
|
+
walk,
|
|
466
|
+
zone,
|
|
467
|
+
scale,
|
|
468
|
+
start: grid.centre(startCell),
|
|
469
|
+
things: geoThings,
|
|
470
|
+
occluders,
|
|
471
|
+
...(steps.length && { steps }),
|
|
472
|
+
flagged,
|
|
473
|
+
};
|
|
474
|
+
return { geometry, grid, blobs, footprint, probes, shadows };
|
|
475
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Why a place cannot be walked, in terms the fix tools take: which floor patches the hero cannot reach,
|
|
2
|
+
// the shortest gap from each to where he can go (as 60 px cell names, the ones floor_cells takes), the
|
|
3
|
+
// things standing in that gap, and the spots off the reachable floor. place_view draws the same.
|
|
4
|
+
import type { Geometry } from '../play/geometry.ts';
|
|
5
|
+
import { emptyMask, type Mask } from '../image/mask.ts';
|
|
6
|
+
import { PALETTE, svgLabel, tintOver } from '../image/overlay.ts';
|
|
7
|
+
import { cellName, gridImage, GRID } from './locate.ts';
|
|
8
|
+
import { CELL, islands, nearestRoomy, reach, roomy } from './walk.ts';
|
|
9
|
+
import { gridOf } from './verify.ts';
|
|
10
|
+
|
|
11
|
+
type XY = [number, number];
|
|
12
|
+
|
|
13
|
+
export type Gap = {
|
|
14
|
+
/** The nearest cell of the patch, and the nearest reached cell, in painting pixels. */
|
|
15
|
+
from: XY;
|
|
16
|
+
to: XY;
|
|
17
|
+
px: number;
|
|
18
|
+
/** 60 px cells the straight line between them crosses that are not walkable now. */
|
|
19
|
+
cells: string[];
|
|
20
|
+
/** Things whose box the line crosses, with their kind: a standing thing there is a wall to the hero. */
|
|
21
|
+
things: Array<{ key: string; kind?: string }>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type Diagnosis = {
|
|
25
|
+
start: { at: XY; onFloor: boolean };
|
|
26
|
+
floors: Array<{ floor: number; cells: number; reached: number }>;
|
|
27
|
+
/** Floor patches of 20 cells or more the hero cannot reach, largest first. */
|
|
28
|
+
patches: Array<{ floors: number[]; cells: number; area: string[]; gap: Gap | null }>;
|
|
29
|
+
/** Stand spots off the reachable floor, with the nearest reachable spot. */
|
|
30
|
+
spots: Array<{ thing: string; at: XY; nearest: XY | null }>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const cell60 = ([x, y]: XY) => cellName(Math.floor(x / GRID), Math.floor(y / GRID));
|
|
34
|
+
const round = ([x, y]: XY): XY => [Math.round(x), Math.round(y)];
|
|
35
|
+
|
|
36
|
+
export function diagnose(g: Geometry): Diagnosis {
|
|
37
|
+
const grid = gridOf(g);
|
|
38
|
+
const cells = roomy(grid);
|
|
39
|
+
const start = grid.cellOf(...g.start);
|
|
40
|
+
const reached = reach(grid, start, cells);
|
|
41
|
+
const zones = new Map<number, { cells: number; reached: number }>();
|
|
42
|
+
grid.zone.forEach((z, i) => {
|
|
43
|
+
if (z < 0 || !grid.open[i]) return;
|
|
44
|
+
const f = zones.get(z) ?? { cells: 0, reached: 0 };
|
|
45
|
+
f.cells++;
|
|
46
|
+
if (reached[i]) f.reached++;
|
|
47
|
+
zones.set(z, f);
|
|
48
|
+
});
|
|
49
|
+
// The edge of the reached floor: the only cells a bridge can land on.
|
|
50
|
+
const shore = [...reached.keys()].filter((i) => reached[i] && grid.neighbours(i).some((n) => !reached[n]));
|
|
51
|
+
const patches = islands(grid, cells).filter((p) => p.length >= 20 && !reached[p[0]]).map((p) => {
|
|
52
|
+
const edge = p.filter((i) => grid.neighbours(i).some((n) => !cells[n]));
|
|
53
|
+
let best: { a: number; b: number; d: number } | null = null;
|
|
54
|
+
for (const a of edge) {
|
|
55
|
+
const [ax, ay] = grid.centre(a);
|
|
56
|
+
for (const b of shore) {
|
|
57
|
+
const [bx, by] = grid.centre(b);
|
|
58
|
+
const d = Math.hypot(ax - bx, ay - by);
|
|
59
|
+
if (!best || d < best.d) best = { a, b, d };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const floors = [...new Set(p.map((i) => grid.zone[i]).filter((z) => z >= 0))].sort();
|
|
63
|
+
const area = [...new Set(p.map((i) => cell60(grid.centre(i))))].sort();
|
|
64
|
+
if (!best) return { floors, cells: p.length, area, gap: null };
|
|
65
|
+
const from = grid.centre(best.a);
|
|
66
|
+
const to = grid.centre(best.b);
|
|
67
|
+
const crossed = new Set<string>();
|
|
68
|
+
const things = new Map<string, string | undefined>();
|
|
69
|
+
const steps = Math.max(1, Math.ceil(best.d / (CELL / 2)));
|
|
70
|
+
for (let s = 0; s <= steps; s++) {
|
|
71
|
+
const at: XY = [from[0] + ((to[0] - from[0]) * s) / steps, from[1] + ((to[1] - from[1]) * s) / steps];
|
|
72
|
+
if (!grid.open[grid.cellOf(...at)]) crossed.add(cell60(at));
|
|
73
|
+
for (const t of g.things) {
|
|
74
|
+
const [x0, y0, x1, y1] = t.box;
|
|
75
|
+
if (at[0] >= x0 && at[0] <= x1 && at[1] >= y0 && at[1] <= y1) things.set(t.key, t.kind);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const gap: Gap = { from: round(from), to: round(to), px: Math.round(best.d), cells: [...crossed], things: [...things].map(([key, kind]) => ({ key, ...(kind && { kind }) })) };
|
|
79
|
+
return { floors, cells: p.length, area: area.slice(0, 24), gap };
|
|
80
|
+
});
|
|
81
|
+
const spots = g.things.filter((t) => !reached[grid.cellOf(...t.stand)]).map((t) => {
|
|
82
|
+
const near = nearestRoomy(grid, t.stand[0], t.stand[1], reached);
|
|
83
|
+
return { thing: t.key, at: round(t.stand), nearest: near === undefined ? null : round(grid.centre(near)) };
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
start: { at: round(g.start), onFloor: !!cells[start] },
|
|
87
|
+
floors: [...zones].sort(([a], [b]) => a - b).map(([floor, f]) => ({ floor, ...f })),
|
|
88
|
+
patches,
|
|
89
|
+
spots,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The painting with the 60 px cell grid, reached floor in green, unreached floor in red, gaps and spots drawn. */
|
|
94
|
+
export async function diagnosisPicture(painting: Buffer, g: Geometry, d: Diagnosis): Promise<Buffer> {
|
|
95
|
+
const grid = gridOf(g);
|
|
96
|
+
const reached = reach(grid, grid.cellOf(...g.start), roomy(grid));
|
|
97
|
+
const { width: W, height: H } = g.frame;
|
|
98
|
+
const fill = (pick: (i: number) => boolean): Mask => {
|
|
99
|
+
const m = emptyMask(W, H);
|
|
100
|
+
grid.open.forEach((v, i) => {
|
|
101
|
+
if (!v || !pick(i)) return;
|
|
102
|
+
const [cx, cy] = grid.centre(i);
|
|
103
|
+
for (let y = cy - CELL / 2; y < Math.min(H, cy + CELL / 2); y++) m.bits.fill(1, y * W + cx - CELL / 2, y * W + Math.min(W, cx + CELL / 2));
|
|
104
|
+
});
|
|
105
|
+
return m;
|
|
106
|
+
};
|
|
107
|
+
const svg: string[] = [];
|
|
108
|
+
for (const p of d.patches) {
|
|
109
|
+
if (!p.gap) continue;
|
|
110
|
+
const { from, to } = p.gap;
|
|
111
|
+
svg.push(`<line x1="${from[0]}" y1="${from[1]}" x2="${to[0]}" y2="${to[1]}" stroke="#ffe600" stroke-width="5" stroke-dasharray="10 6"/>`);
|
|
112
|
+
const label = `gap ${p.gap.px}px: ${p.gap.cells.join(' ') || 'open'}`;
|
|
113
|
+
// Kept inside the frame: a gap at the edge would push its words off the picture.
|
|
114
|
+
svg.push(svgLabel(Math.min((from[0] + to[0]) / 2 + 8, W - 9 * label.length), (from[1] + to[1]) / 2 - 8, label, [255, 230, 0]));
|
|
115
|
+
}
|
|
116
|
+
for (const s of d.spots) {
|
|
117
|
+
svg.push(`<circle cx="${s.at[0]}" cy="${s.at[1]}" r="8" fill="#f33" stroke="#000" stroke-width="2"/>`, svgLabel(s.at[0] + 10, s.at[1] + 5, s.thing, [255, 90, 90]));
|
|
118
|
+
if (s.nearest) svg.push(`<line x1="${s.at[0]}" y1="${s.at[1]}" x2="${s.nearest[0]}" y2="${s.nearest[1]}" stroke="#f33" stroke-width="3" stroke-dasharray="6 4"/>`);
|
|
119
|
+
}
|
|
120
|
+
// The steps derive found on stairs: each tread's back edge, so a missing or wrong flight shows.
|
|
121
|
+
for (const st of g.steps ?? []) {
|
|
122
|
+
const pts = st.top.flatMap((t, i) => (t >= 0 ? [`${st.x0 + i * st.every},${t}`] : []));
|
|
123
|
+
if (pts.length > 1) svg.push(`<polyline points="${pts.join(' ')}" fill="none" stroke="#3cf" stroke-width="3"/>`);
|
|
124
|
+
}
|
|
125
|
+
if (g.steps?.length) svg.push(svgLabel(g.steps[0].x0, Math.max(14, Math.min(...g.steps[0].top.filter((t) => t >= 0)) - 8), `${g.steps.length} steps`, [60, 200, 255]));
|
|
126
|
+
svg.push(`<circle cx="${g.start[0]}" cy="${g.start[1]}" r="10" fill="#3f6" stroke="#000" stroke-width="2"/>`, svgLabel(g.start[0] + 12, g.start[1] + 5, 'start', [80, 255, 120]));
|
|
127
|
+
const tinted = await tintOver(await gridImage(painting), [
|
|
128
|
+
{ mask: fill((i) => !!reached[i]), rgb: PALETTE[4], alpha: 0.28 },
|
|
129
|
+
{ mask: fill((i) => !reached[i]), rgb: PALETTE[0], alpha: 0.4 },
|
|
130
|
+
], svg.join(''));
|
|
131
|
+
return tinted;
|
|
132
|
+
}
|