@gtanczyk/diorama 0.0.0-stage → 0.2.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 +160 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +267 -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 +32 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/language.ts +52 -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 +290 -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 +161 -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 +158 -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 +37 -0
- package/app/src/commands/language.ts +87 -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 +195 -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 +100 -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 +105 -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 +33 -0
- package/app/src/scene/cast.ts +199 -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 +109 -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 +379 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +215 -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 +419 -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 +555 -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 +423 -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/language.ts +116 -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 +679 -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 +1329 -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 +3769 -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,43 @@
|
|
|
1
|
+
// How a creature moves that is not a person on two legs (a round eye, a frog, a teapot that lives): no legs to
|
|
2
|
+
// walk, so its whole body hops, squashes as it lands, stretches as it rises, leans the way it goes, breathes
|
|
3
|
+
// and now and then blinks (a quick squash). The 3D model (web/hero3d.ts) and the painted cut-out (web/main.ts)
|
|
4
|
+
// both move this way. No Node imports: the browser bundles this file.
|
|
5
|
+
|
|
6
|
+
/** lift: how high the feet are off the ground; stretch: height scale about the feet (width the inverse, so the
|
|
7
|
+
* volume stays); lean: forward tilt in radians, towards where it goes. lift is in body heights. */
|
|
8
|
+
export type CreatureMotion = { lift: number; stretch: number; lean: number };
|
|
9
|
+
|
|
10
|
+
/** One hop per walk cycle (src/play/rig.ts CYCLE), this high in body heights. */
|
|
11
|
+
const HOP = 0.32;
|
|
12
|
+
/** Share of the cycle on the ground: the squash as it lands and pushes off. */
|
|
13
|
+
const CONTACT = 0.3;
|
|
14
|
+
const BLINK_EVERY = 3.7;
|
|
15
|
+
const BLINK = 0.16;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* phase: walk cycles (as the person's walk); time: seconds, for breathing and blinking; seed: so two creatures
|
|
19
|
+
* side by side do not breathe in step. talk: speaking bounces, listening leans in a little.
|
|
20
|
+
*/
|
|
21
|
+
export function creatureMotion({ phase = 0, walking = false, time = 0, talk, seed = 0 }: {
|
|
22
|
+
phase?: number; walking?: boolean; time?: number; talk?: 'speak' | 'listen'; seed?: number;
|
|
23
|
+
}): CreatureMotion {
|
|
24
|
+
const breathe = 0.025 * Math.sin(time * 2.4 + seed);
|
|
25
|
+
if (walking) {
|
|
26
|
+
const p = ((phase % 1) + 1) % 1;
|
|
27
|
+
// On the ground (landing, then pushing off): squashed most in the middle of it.
|
|
28
|
+
if (p < CONTACT) return { lift: 0, stretch: 1 - 0.2 * Math.sin((Math.PI * p) / CONTACT), lean: 0.12 };
|
|
29
|
+
const u = (p - CONTACT) / (1 - CONTACT);
|
|
30
|
+
// In the air: up and down in an arc, stretched on the way up, round at the top, a little stretched falling.
|
|
31
|
+
return { lift: HOP * Math.sin(Math.PI * u), stretch: 1 + 0.12 * Math.cos(Math.PI * u) ** 2, lean: 0.12 + 0.08 * Math.cos(Math.PI * u) };
|
|
32
|
+
}
|
|
33
|
+
const t = time + seed;
|
|
34
|
+
// A blink: a quick squash and back.
|
|
35
|
+
const b = t % BLINK_EVERY;
|
|
36
|
+
const blink = b < BLINK ? 0.14 * Math.sin((Math.PI * b) / BLINK) : 0;
|
|
37
|
+
if (talk === 'speak') {
|
|
38
|
+
// Little bounces on the words.
|
|
39
|
+
const s = Math.abs(Math.sin(t * 7));
|
|
40
|
+
return { lift: 0.035 * s, stretch: 1 + 0.07 * s - blink + breathe, lean: 0.05 * Math.sin(t * 2.3) };
|
|
41
|
+
}
|
|
42
|
+
return { lift: 0, stretch: 1 + breathe - blink, lean: talk === 'listen' ? 0.06 : 0 };
|
|
43
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// depth-occlusion: the painting hides whoever stands behind it, also where nothing was masked. Things the
|
|
2
|
+
// story names get cards (geometry.occluders), but a painting is full of things nobody named: a lamp post,
|
|
3
|
+
// a railing, a bridge's parapet. The depth map (depth.png, src/scene/depthmap.ts) knows how far every pixel
|
|
4
|
+
// is, as the floor row at the same distance, so a pixel nearer than the person's feet stands in front of
|
|
5
|
+
// them and is put back over them. Cards stay in charge of their own pixels.
|
|
6
|
+
// No Node imports: the browser bundles this file.
|
|
7
|
+
import { stepAt, type Geometry } from './geometry.ts';
|
|
8
|
+
import { DEPTH_SCALE, type Depth } from './shadow.ts';
|
|
9
|
+
|
|
10
|
+
/** Floor rows the painting must be nearer than the feet by, at least, before it hides anyone: the depth model's noise. */
|
|
11
|
+
export const FRONT_MIN_ROWS = 6;
|
|
12
|
+
/** ... and as a share of the feet's own row (rows bunch up toward the horizon, metres don't). The depth model reads a wall right behind someone ~10% too near. */
|
|
13
|
+
export const FRONT_SHARE = 0.12;
|
|
14
|
+
/** How much the depth must grow per screen row under the feet for them to stand on a floor (1 on flat ground, ~0.3 up stairs, ~0 on a wall). */
|
|
15
|
+
export const FLOOR_SLOPE = 0.15;
|
|
16
|
+
/**
|
|
17
|
+
* The most of a person's box the depth map may hide. Standing on visible floor, more than
|
|
18
|
+
* this in front of them means the feet are really on what seems in front (a stair's riser, a slope the model
|
|
19
|
+
* flattened): then it hides nothing, and the place's check (src/scene/occlusion-check.ts) lists the spot.
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_HIDDEN = 0.75;
|
|
22
|
+
|
|
23
|
+
/** Within this many floor rows (or this share of the row) of the floor plane, a pixel is floor: where things stand. */
|
|
24
|
+
export const FLOOR_TOLERANCE = 6;
|
|
25
|
+
export const FLOOR_TOLERANCE_SHARE = 0.1;
|
|
26
|
+
|
|
27
|
+
const contacts = new WeakMap<Depth, Int16Array>();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Per depth-map pixel, the frame row where what is painted there meets the floor: going down its column, the
|
|
31
|
+
* first pixel no nearer than the floor plane at its own row. A lamp post meets it at its foot, a wall at its
|
|
32
|
+
* base, a bridge's side where the water shows under it; a lamp's head where its post does. Only what meets the floor in front of someone's
|
|
33
|
+
* feet can stand in front of them, whatever the depth model thinks of a wall behind them.
|
|
34
|
+
*/
|
|
35
|
+
export function contactRows(g: Pick<Geometry, 'scale'>, d: Depth): Int16Array {
|
|
36
|
+
let out = contacts.get(d);
|
|
37
|
+
if (out) return out;
|
|
38
|
+
out = new Int16Array(d.w * d.h);
|
|
39
|
+
for (let c = 0; c < d.w; c++) {
|
|
40
|
+
let below = d.h * DEPTH_SCALE;
|
|
41
|
+
for (let r = d.h - 1; r >= 0; r--) {
|
|
42
|
+
const y = (r + 0.5) * DEPTH_SCALE;
|
|
43
|
+
const flat = y - g.scale.horizonY;
|
|
44
|
+
if (flat > 0 && d.rows[r * d.w + c] <= flat + Math.max(FLOOR_TOLERANCE, FLOOR_TOLERANCE_SHARE * flat)) below = Math.round(y);
|
|
45
|
+
out[r * d.w + c] = below;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// An overhang (a lamp's head wider than its post, a roof's eave) has nothing under its edge but what is behind:
|
|
49
|
+
// along the row, what stands at the same distance shares the lowest foot it reaches.
|
|
50
|
+
const standing = (i: number, y: number) => d.rows[i] > y - g.scale.horizonY + Math.max(FLOOR_TOLERANCE, FLOOR_TOLERANCE_SHARE * (y - g.scale.horizonY));
|
|
51
|
+
const near = (a: number, b: number) => Math.abs(d.rows[a] - d.rows[b]) <= Math.max(2, 0.02 * d.rows[a]);
|
|
52
|
+
for (let r = 0; r < d.h; r++) {
|
|
53
|
+
const y = (r + 0.5) * DEPTH_SCALE;
|
|
54
|
+
const at = r * d.w;
|
|
55
|
+
for (let c = 1; c < d.w; c++) if (standing(at + c, y) && standing(at + c - 1, y) && near(at + c, at + c - 1)) out[at + c] = Math.max(out[at + c], out[at + c - 1]);
|
|
56
|
+
for (let c = d.w - 2; c >= 0; c--) if (standing(at + c, y) && standing(at + c + 1, y) && near(at + c, at + c + 1)) out[at + c] = Math.max(out[at + c], out[at + c + 1]);
|
|
57
|
+
}
|
|
58
|
+
contacts.set(d, out);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The depth map's floor row at a frame pixel (nearest sample). */
|
|
63
|
+
export function depthAt(d: Depth, x: number, y: number): number {
|
|
64
|
+
const c = Math.min(d.w - 1, Math.max(0, Math.floor(x / DEPTH_SCALE)));
|
|
65
|
+
const r = Math.min(d.h - 1, Math.max(0, Math.floor(y / DEPTH_SCALE)));
|
|
66
|
+
return d.rows[r * d.w + c];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* How far the feet are, as a floor row below the horizon. On the floor that is their screen row. On a raised
|
|
71
|
+
* floor (a deck, a step) the depth map says more, and the surface there still runs away from the camera:
|
|
72
|
+
* the depth there wins. On an upright face (the side of a bridge a floor mask took in) the person is behind
|
|
73
|
+
* that face, not on it: their screen row stays, so the face hides them.
|
|
74
|
+
*/
|
|
75
|
+
export function standRow(g: Pick<Geometry, 'scale' | 'steps'>, d: Depth, x: number, feetY: number): number {
|
|
76
|
+
const row = feetY - g.scale.horizonY;
|
|
77
|
+
// Stairs read from the painting (geometry.steps) are a raised floor by definition, risers and all.
|
|
78
|
+
if (g.steps?.length && stepAt(g as Geometry, x, feetY)) return Math.max(row, depthAt(d, x, feetY - 1));
|
|
79
|
+
// What is painted at the feet may stand in front of them (a post they are behind): that is not their floor.
|
|
80
|
+
const c = Math.min(d.w - 1, Math.max(0, Math.floor(x / DEPTH_SCALE)));
|
|
81
|
+
const r = Math.min(d.h - 1, Math.max(0, Math.floor((feetY - 1) / DEPTH_SCALE)));
|
|
82
|
+
if (contactRows(g, d)[r * d.w + c] > feetY + 2) return row;
|
|
83
|
+
return floorSlope(g, d, x, feetY) >= FLOOR_SLOPE ? Math.max(row, depthAt(d, x, feetY - 1)) : row;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Depth gained per screen row over the ground just behind the feet: the least of a short look (a few px) and a
|
|
88
|
+
* long one (a third of a person's height), so a low face with the sky just above it still reads as a face.
|
|
89
|
+
*/
|
|
90
|
+
export function floorSlope(g: Pick<Geometry, 'scale'>, d: Depth, x: number, feetY: number): number {
|
|
91
|
+
const at = depthAt(d, x, feetY - 1);
|
|
92
|
+
const slope = (span: number) => (at - depthAt(d, x, feetY - 1 - span)) / span;
|
|
93
|
+
return Math.min(slope(4 * DEPTH_SCALE), slope(Math.max(12, 0.3 * g.scale.pxPerY * (feetY - g.scale.horizonY))));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Width of a person's box for a height (a standing figure's sheet is about this wide). */
|
|
97
|
+
export const PERSON_WIDTH = 0.36;
|
|
98
|
+
|
|
99
|
+
/** The share of a person's box, at (x, feetY), that the painting stands in front of where no card (`owned`) does. */
|
|
100
|
+
export function hiddenShare(g: Pick<Geometry, 'scale' | 'steps'>, d: Depth, x: number, feetY: number, owned?: Uint8Array): number {
|
|
101
|
+
const h = Math.max(8, g.scale.pxPerY * (feetY - g.scale.horizonY));
|
|
102
|
+
const half = Math.max(2, (PERSON_WIDTH * h) / 2);
|
|
103
|
+
const limit = frontRow(standRow(g, d, x, feetY));
|
|
104
|
+
const contact = contactRows(g, d);
|
|
105
|
+
let hid = 0;
|
|
106
|
+
let n = 0;
|
|
107
|
+
for (let y = feetY - h; y < feetY - 2; y += DEPTH_SCALE) {
|
|
108
|
+
const r = Math.min(d.h - 1, Math.max(0, Math.floor(y / DEPTH_SCALE)));
|
|
109
|
+
for (let px = x - half; px <= x + half; px += DEPTH_SCALE) {
|
|
110
|
+
const i = r * d.w + Math.min(d.w - 1, Math.max(0, Math.floor(px / DEPTH_SCALE)));
|
|
111
|
+
if (owned?.[i]) continue;
|
|
112
|
+
n++;
|
|
113
|
+
if (d.rows[i] > limit && contact[i] > feetY + 2) hid++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return n ? hid / n : 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The floor row nearer than which the painting stands in front of feet at `stand`. */
|
|
120
|
+
export const frontRow = (stand: number) => stand + Math.max(FRONT_MIN_ROWS, FRONT_SHARE * stand);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Pixels of the frame rect [x0, y0, x1, y1) (whole px) where the painting stands in front of someone whose
|
|
124
|
+
* feet are at (x, feetY): 1 per pixel, row by row. `owned` (at the depth map's size) marks pixels a card
|
|
125
|
+
* looks after; they are left to it. Null when nothing is in front.
|
|
126
|
+
*/
|
|
127
|
+
export function frontMask(g: Pick<Geometry, 'scale' | 'frame' | 'steps'>, d: Depth, rect: [number, number, number, number], x: number, feetY: number, owned?: Uint8Array): Uint8Array | null {
|
|
128
|
+
const [x0, y0, x1, y1] = rect;
|
|
129
|
+
const w = x1 - x0;
|
|
130
|
+
const h = y1 - y0;
|
|
131
|
+
if (w <= 0 || h <= 0) return null;
|
|
132
|
+
if (hiddenShare(g, d, x, feetY, owned) > MAX_HIDDEN) return null;
|
|
133
|
+
const limit = frontRow(standRow(g, d, x, feetY));
|
|
134
|
+
const contact = contactRows(g, d);
|
|
135
|
+
const out = new Uint8Array(w * h);
|
|
136
|
+
let any = false;
|
|
137
|
+
for (let y = y0; y < y1; y++) {
|
|
138
|
+
if (y < 0 || y >= g.frame.height) continue;
|
|
139
|
+
const r = Math.min(d.h - 1, Math.floor(y / DEPTH_SCALE));
|
|
140
|
+
for (let px = x0; px < x1; px++) {
|
|
141
|
+
if (px < 0 || px >= g.frame.width) continue;
|
|
142
|
+
const c = Math.min(d.w - 1, Math.floor(px / DEPTH_SCALE));
|
|
143
|
+
const i = r * d.w + c;
|
|
144
|
+
if (owned?.[i] || d.rows[i] <= limit || contact[i] <= feetY + 2) continue;
|
|
145
|
+
out[(y - y0) * w + (px - x0)] = 1;
|
|
146
|
+
any = true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return any ? out : null;
|
|
150
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Visual coverage for a walkable exit. Never used as a collision or interaction mask.
|
|
2
|
+
import type { Geometry, GeoThing } from './geometry.ts';
|
|
3
|
+
export type OutlineMask = { bits: Uint8Array; area: number };
|
|
4
|
+
export function exitOutline(mask: OutlineMask, thing: GeoThing, g: Geometry): { mask: OutlineMask; box: [number,number,number,number] } | undefined {
|
|
5
|
+
if (thing.role !== 'exit' || !['walk-on','flat'].includes(thing.kind ?? '')) return;
|
|
6
|
+
const {width:W,height:H}=g.frame;
|
|
7
|
+
const left=new Int32Array(H).fill(W),right=new Int32Array(H).fill(-1);
|
|
8
|
+
for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(mask.bits[y*W+x]){left[y]=Math.min(left[y],x);right[y]=x;}
|
|
9
|
+
// Steps are measured independently of the exit's object mask. Only include bands
|
|
10
|
+
// that overlap its painted pixels, then follow that same run through neighbouring bands.
|
|
11
|
+
const bands=(g.steps ?? []).map(step=>{
|
|
12
|
+
const rows:Array<[number,number,number]>=[]; let overlap=0,total=0;
|
|
13
|
+
for(let i=0;i<step.top.length;i++) {
|
|
14
|
+
const top=step.top[i],bottom=step.bottom[i];if(top<0||bottom<top)continue;
|
|
15
|
+
for(let x=Math.max(0,Math.round(step.x0+i*step.every));x<Math.min(W,Math.round(step.x0+(i+1)*step.every));x++) {
|
|
16
|
+
const a=Math.max(0,Math.floor(top)),b=Math.min(H-1,Math.ceil(bottom));
|
|
17
|
+
rows.push([x,a,b]);for(let y=a;y<=b;y++){total++;if(mask.bits[y*W+x])overlap++;}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const box = rows.length ? [Math.min(...rows.map(r=>r[0])),Math.min(...rows.map(r=>r[1])),Math.max(...rows.map(r=>r[0]))+1,Math.max(...rows.map(r=>r[2]))+1] : [0,0,0,0];
|
|
21
|
+
return {rows,overlap,total,box};
|
|
22
|
+
});
|
|
23
|
+
// Seed from observed steps and follow adjacent bands with substantial horizontal
|
|
24
|
+
// overlap. This reaches an edge tread outside the object's truncated box.
|
|
25
|
+
const selected=new Set(bands.filter(b=>b.total && b.overlap/b.total>.1));
|
|
26
|
+
let added=true;
|
|
27
|
+
while(added){added=false;for(const band of bands)if(band.total&&!selected.has(band))for(const near of selected){
|
|
28
|
+
const a=band.box,b=near.box;
|
|
29
|
+
const overlap=Math.min(a[2],b[2])-Math.max(a[0],b[0]);
|
|
30
|
+
const gap=Math.max(0,a[1]-b[3],b[1]-a[3]);
|
|
31
|
+
if(overlap/Math.max(1,Math.min(a[2]-a[0],b[2]-b[0]))>.6 && gap<=Math.max(8,H*.03)) {selected.add(band);added=true;break;}
|
|
32
|
+
}}
|
|
33
|
+
for(const band of selected)for(const [x,a,b]of band.rows)for(let y=a;y<=b;y++){left[y]=Math.min(left[y],x);right[y]=Math.max(right[y],x);}
|
|
34
|
+
const rows=Array.from({length:H},(_,y)=>y).filter(y=>right[y]>=left[y]);
|
|
35
|
+
if(!rows.length)return;
|
|
36
|
+
// Bridge missing risers between observed rows, rather than outlining each tread.
|
|
37
|
+
for(let i=1;i<rows.length;i++) {
|
|
38
|
+
const a=rows[i-1],b=rows[i];
|
|
39
|
+
if(b-a>Math.max(32,H*.15))continue;
|
|
40
|
+
for(let y=a+1;y<b;y++){const t=(y-a)/(b-a);left[y]=Math.round(left[a]*(1-t)+left[b]*t);right[y]=Math.round(right[a]*(1-t)+right[b]*t);}
|
|
41
|
+
}
|
|
42
|
+
const bits=new Uint8Array(W*H);let area=0,x0=W,y0=H,x1=0,y1=0;
|
|
43
|
+
for(let y=0;y<H;y++)if(right[y]>=left[y]){
|
|
44
|
+
bits.fill(1,y*W+left[y],y*W+right[y]+1);area+=right[y]-left[y]+1;
|
|
45
|
+
x0=Math.min(x0,left[y]);x1=Math.max(x1,right[y]+1);y0=Math.min(y0,y);y1=y+1;
|
|
46
|
+
}
|
|
47
|
+
return {mask:{bits,area},box:[x0,y0,x1,y1]};
|
|
48
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Named facial poses shared by the GLB capability report and its browser renderer.
|
|
2
|
+
import { mouthOpening, mouthRoundness, type FaceState, type Mouth } from './lipsync.ts';
|
|
3
|
+
export type FaceControl = 'jaw' | 'blink' | 'blinkLeft' | 'blinkRight' | 'brow' | 'funnel' | 'pucker' | 'sil' | 'mbp' | 'ih' | 'eh' | 'aa' | 'oh' | 'ou' | 'fv' | 'l';
|
|
4
|
+
const aliases: Record<string, FaceControl> = {
|
|
5
|
+
jawopen: 'jaw', mouthopen: 'jaw', eyeblink: 'blink', blink: 'blink', eyeblinkleft: 'blinkLeft', blinkleft: 'blinkLeft', eyeblinkright: 'blinkRight', blinkright: 'blinkRight',
|
|
6
|
+
browinnerup: 'brow', browouterupleft: 'brow', browouterupright: 'brow', mouthfunnel: 'funnel', mouthpucker: 'pucker',
|
|
7
|
+
visemesil: 'sil', vrcvsil: 'sil', visemepp: 'mbp', vrcvpp: 'mbp', visemembp: 'mbp',
|
|
8
|
+
visemess: 'ih', vrcvss: 'ih', visemekk: 'ih', vrcvkk: 'ih', visemett: 'ih', vrcvtt: 'ih', visemech: 'ih', vrcvch: 'ih', visemeih: 'ih', visemei: 'ih', vrcvih: 'ih', vrcvi: 'ih', visemedd: 'ih', vrcvdd: 'ih', visemeth: 'ih', vrcvth: 'ih', visemerr: 'ih', vrcvrr: 'ih', i: 'ih', ih: 'ih',
|
|
9
|
+
visemee: 'eh', vrcve: 'eh', visemeeh: 'eh', e: 'eh', eh: 'eh',
|
|
10
|
+
visemeaa: 'aa', vrcvaa: 'aa', a: 'aa', aa: 'aa',
|
|
11
|
+
visemeo: 'oh', visemeoh: 'oh', vrcvoh: 'oh', vrcvo: 'oh', o: 'oh', oh: 'oh',
|
|
12
|
+
visemeu: 'ou', visemeou: 'ou', vrcvou: 'ou', vrcvu: 'ou', u: 'ou', ou: 'ou',
|
|
13
|
+
visemeff: 'fv', vrcvff: 'fv', visemefv: 'fv', visemenn: 'l', vrcvnn: 'l', visemel: 'l',
|
|
14
|
+
};
|
|
15
|
+
export const facialControl = (name: string): FaceControl | undefined => aliases[name.toLowerCase().replace(/[^a-z]/g, '')];
|
|
16
|
+
const viseme: Record<Mouth, FaceControl> = { X: 'sil', A: 'mbp', B: 'ih', C: 'eh', D: 'aa', E: 'oh', F: 'ou', G: 'fv', H: 'l' };
|
|
17
|
+
export const isViseme = (control: FaceControl) => ['sil', 'mbp', 'ih', 'eh', 'aa', 'oh', 'ou', 'fv', 'l'].includes(control);
|
|
18
|
+
export function faceWeight(control: FaceControl, state: FaceState, nativeVisemes: boolean | ReadonlySet<FaceControl> = false): number {
|
|
19
|
+
if (control.startsWith('blink')) return state.blink;
|
|
20
|
+
if (control === 'brow') return state.brow;
|
|
21
|
+
const native = (mouth: Mouth) => typeof nativeVisemes === 'boolean' ? nativeVisemes : nativeVisemes.has(viseme[mouth]);
|
|
22
|
+
const fallback = (amount: (mouth: Mouth) => number) => (native(state.previous) ? 0 : amount(state.previous) * (1 - state.mix)) + (native(state.mouth) ? 0 : amount(state.mouth) * state.mix);
|
|
23
|
+
if (control === 'jaw') return fallback(mouth => mouthOpening[mouth]);
|
|
24
|
+
if (control === 'funnel') return fallback(mouthRoundness) * 0.65;
|
|
25
|
+
if (control === 'pucker') return fallback(mouthRoundness) * 0.4;
|
|
26
|
+
return (viseme[state.previous] === control ? 1 - state.mix : 0) + (viseme[state.mouth] === control ? state.mix : 0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Rhubarb groups consonants; choose one representative pose instead of adding SS, KK and CH together. */
|
|
30
|
+
export function facialPriority(name: string): number {
|
|
31
|
+
const normal = name.toLowerCase().replace(/[^a-z]/g, '');
|
|
32
|
+
if (facialControl(name) !== 'ih') return 0;
|
|
33
|
+
return /(?:ih|visemei|vrcvi)$/.test(normal) || normal === 'i' ? 0 : /ss$/.test(normal) ? 1 : 2;
|
|
34
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// focus: what sets a person apart from the painted thing at their back. Both are painted equally sharp and
|
|
2
|
+
// equally lit, so the runtime adds the cues: a rim of light on the person's lit edge, a soft shade they throw
|
|
3
|
+
// on what stands just behind them (only there, by the depth map), and, opt-in, the camera's focus on the hero
|
|
4
|
+
// softening what is far behind.
|
|
5
|
+
// No Node imports: the browser bundles this file.
|
|
6
|
+
import type { Depth } from './shadow.ts';
|
|
7
|
+
|
|
8
|
+
/** Frame px of blur at full defocus (the sky, the far sea when the hero is near). */
|
|
9
|
+
export const FOCUS_BLUR = 1.4;
|
|
10
|
+
/** Behind the focus by this share of its depth, a thing is fully soft (five times as far as the hero). */
|
|
11
|
+
const FULL = 0.8;
|
|
12
|
+
/** The first few % behind the feet stay sharp: the floor the hero stands on. */
|
|
13
|
+
const SHARP = 0.03;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How soft each depth pixel is, 0..1, with the focus on a floor row `focus` px below the horizon. Depth rows
|
|
17
|
+
* are inverse distance, so the defocus of a pixel grows with how far its rows fall short of the focus (a thin
|
|
18
|
+
* lens's blur circle is linear in 1/z - 1/z_focus); scaled by the focus itself, so the look is the same whether
|
|
19
|
+
* the hero stands near or far. Nearer than the focus stays sharp: those things are drawn over the hero anyway.
|
|
20
|
+
* A pixel with no depth (rows 0, the sky) counts as far.
|
|
21
|
+
*/
|
|
22
|
+
export function softness(rows: number, focus: number): number {
|
|
23
|
+
if (focus <= 0) return 0;
|
|
24
|
+
const t = (focus * (1 - SHARP) - Math.max(0, rows)) / (focus * FULL);
|
|
25
|
+
if (t <= 0) return 0;
|
|
26
|
+
// Linear: just behind the hero it barely shows, only the far view goes really soft.
|
|
27
|
+
return Math.min(1, t);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The softness of every depth pixel as an alpha mask (RGBA, white), for compositing the soft frame. */
|
|
31
|
+
export function focusMask(d: Depth, focus: number, out: Uint8ClampedArray = new Uint8ClampedArray(d.w * d.h * 4)): Uint8ClampedArray {
|
|
32
|
+
for (let i = 0; i < d.w * d.h; i++) {
|
|
33
|
+
out[i * 4] = out[i * 4 + 1] = out[i * 4 + 2] = 255;
|
|
34
|
+
out[i * 4 + 3] = Math.round(255 * softness(d.rows[i], focus));
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A stand-in depth map for a place without depth.png (no fit, or built before it existed): the floor plane
|
|
41
|
+
* below the horizon, and above each column's back edge whatever stands there (a wall, a railing) as far as
|
|
42
|
+
* that edge. Above the horizon is the far view. Cards stamp their own floor row over it (see the runtime).
|
|
43
|
+
*/
|
|
44
|
+
export function planeDepth(frame: { width: number; height: number }, horizonY: number, cell: number, edge: ArrayLike<number>, scale: number): Depth {
|
|
45
|
+
const [w, h] = [Math.ceil(frame.width / scale), Math.ceil(frame.height / scale)];
|
|
46
|
+
const rows = new Float32Array(w * h);
|
|
47
|
+
for (let r = 0; r < h; r++) {
|
|
48
|
+
const y = r * scale + scale / 2;
|
|
49
|
+
for (let c = 0; c < w; c++) {
|
|
50
|
+
const e = edge[Math.min(edge.length - 1, Math.floor((c * scale) / cell))] ?? horizonY;
|
|
51
|
+
rows[r * w + c] = y <= horizonY ? 0 : Math.max(0, (y < e ? e : y) - horizonY);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { w, h, rows };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The separation shade: its darkness, blur and push away from the light, per person height. */
|
|
58
|
+
export const SEPARATE_ALPHA = 0.8;
|
|
59
|
+
export const SEPARATE_BLUR = 0.05;
|
|
60
|
+
export const SEPARATE_SHIFT = 0.12;
|
|
61
|
+
/** Things up to this share of the feet's depth farther still take the shade, fading to none there. */
|
|
62
|
+
const BEHIND = 0.3;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* How much of a person's separation shade lands on a depth pixel, for feet `feet` rows below the horizon:
|
|
66
|
+
* full on what stands right at or just behind them (the plinth at their back, the floor at their heels),
|
|
67
|
+
* fading over BEHIND of their depth, none on what is nearer (it is drawn over them) or has no depth (the sky).
|
|
68
|
+
*/
|
|
69
|
+
export function behindWeight(rows: number, feet: number): number {
|
|
70
|
+
if (rows <= 0 || feet <= 0 || rows > feet * 1.03) return 0;
|
|
71
|
+
const t = (feet - rows) / (feet * BEHIND);
|
|
72
|
+
return Math.max(0, 1 - Math.max(0, t));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The rim light on a person's lit edge: how wide per height, its colour and how strong. */
|
|
76
|
+
export const RIM_WIDTH = 0.022;
|
|
77
|
+
export const RIM_COLOUR = '#ffe9c8';
|
|
78
|
+
export const RIM_ALPHA = 0.85;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// geometry.json: what the game needs to know about a painted place to walk in it.
|
|
2
|
+
// derive writes it from the masks; the runtime only reads it. No Node imports: the browser bundles this file.
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const XY = z.tuple([z.number(), z.number()]);
|
|
6
|
+
const Box = z.tuple([z.number(), z.number(), z.number(), z.number()]).describe('x0, y0, x1, y1');
|
|
7
|
+
|
|
8
|
+
export const Geometry = z.object({
|
|
9
|
+
frame: z.object({ width: z.number().int().positive(), height: z.number().int().positive() }),
|
|
10
|
+
cell: z.number().int().positive(),
|
|
11
|
+
/** Grid rows from the top; '#' is walkable, anything else is not. */
|
|
12
|
+
walk: z.array(z.string()).min(1),
|
|
13
|
+
/** Same shape as walk; a digit is the floor's index in layout order, '.' is no floor. */
|
|
14
|
+
zone: z.array(z.string()).optional(),
|
|
15
|
+
/** A 1.62 m person standing with feet at y is pxPerY * (y - horizonY) px tall. */
|
|
16
|
+
scale: z.object({ horizonY: z.number(), pxPerY: z.number().positive(), source: z.enum(['spark', 'anchors', 'layout']).optional() }),
|
|
17
|
+
start: XY,
|
|
18
|
+
things: z.array(z.object({
|
|
19
|
+
key: z.string(),
|
|
20
|
+
role: z.enum(['prop', 'exit', 'item', 'npc']).optional(),
|
|
21
|
+
kind: z.enum(['standing', 'wall', 'on-top', 'walk-on', 'flat']).optional(),
|
|
22
|
+
box: Box,
|
|
23
|
+
mask: z.string().optional().describe('path relative to the place folder; npcs have none'),
|
|
24
|
+
stand: XY.describe('walkable spot to stand at when using it'),
|
|
25
|
+
coarse: z.boolean().optional(),
|
|
26
|
+
/** Where this thing was, painted empty (plates/<thing>.png: the patch only, transparent elsewhere), drawn once the story carries it off. */
|
|
27
|
+
plate: z.string().optional(),
|
|
28
|
+
})),
|
|
29
|
+
/** One per connected blob of a thing's mask; it hides the mask's pixels inside its box. */
|
|
30
|
+
occluders: z.array(z.object({
|
|
31
|
+
key: z.string().describe('"barrel#1"'),
|
|
32
|
+
thing: z.string().optional(),
|
|
33
|
+
box: Box,
|
|
34
|
+
mask: z.string().optional(),
|
|
35
|
+
/** Always drawn over the hero (a framing tree, an arch). */
|
|
36
|
+
front: z.boolean().optional(),
|
|
37
|
+
baseY: z.number(),
|
|
38
|
+
/** Where it meets the floor, per column, x ascending. Falls back to baseY. */
|
|
39
|
+
floor: z.array(XY).optional(),
|
|
40
|
+
/** Key of the occluder it sits on (a pot on a table): it hides exactly when that one does. */
|
|
41
|
+
on: z.string().optional(),
|
|
42
|
+
/** Floor footprint rows [x, back, front], for QA; derive already blocks those cells. */
|
|
43
|
+
foot: z.array(z.tuple([z.number(), z.number(), z.number()])).optional(),
|
|
44
|
+
})),
|
|
45
|
+
/**
|
|
46
|
+
* How the painting is lit, so people cast and catch shadows like the things around them.
|
|
47
|
+
* dir: the way shadows fall across the floor in screen px (unit-ish; +x right, +y towards the viewer).
|
|
48
|
+
* length: a shadow's floor length as a share of the caster's height (0.3 high sun, 1.5+ low sun).
|
|
49
|
+
* strength: how dark painted shadows are, 0..1 (fog ~0.15, hard sun ~0.5). Omitted: no cast shadow.
|
|
50
|
+
*/
|
|
51
|
+
/**
|
|
52
|
+
* The light the painting is lit by. dir/length: how shadows run on screen per height (the sun). With `at`
|
|
53
|
+
* the light is a fire or a lamp standing on the floor there: shadows run away from it, each its own way.
|
|
54
|
+
* flame: that light is a living flame (a fire, a torch, a candle): it flickers, and the shadows with it.
|
|
55
|
+
* also: where its twins stand (a row of the same lamp posts): no one of them is the key light, so a person
|
|
56
|
+
* casts no long shadow one way, only the soft one under the feet; the nearest lamp still rims them.
|
|
57
|
+
*/
|
|
58
|
+
light: z.object({ dir: XY, length: z.number().nonnegative(), strength: z.number().min(0).max(1), at: XY.optional(), flame: z.boolean().optional(), also: z.array(XY).optional() }).optional(),
|
|
59
|
+
/** Mask (relative to the place folder) of painted shadows on floors: a person standing in one is darkened. */
|
|
60
|
+
shadows: z.string().optional(),
|
|
61
|
+
/**
|
|
62
|
+
* How far every pixel is (depth.png, half the frame): as the floor row at the same distance, rows below
|
|
63
|
+
* horizonY * 16 in R (high byte) and G (low byte). With it a cast shadow follows steps, walls and drops.
|
|
64
|
+
*/
|
|
65
|
+
depth: z.string().optional(),
|
|
66
|
+
/**
|
|
67
|
+
* Stair steps, top to bottom: each one's band (its tread and the riser below it) per column, sampled every
|
|
68
|
+
* `every` px from x0 (-1 where the step doesn't reach). People's feet stay on a tread and step between them.
|
|
69
|
+
*/
|
|
70
|
+
steps: z.array(z.object({ x0: z.number(), every: z.number().positive(), top: z.array(z.number()), bottom: z.array(z.number()) })).optional(),
|
|
71
|
+
/**
|
|
72
|
+
* Where layout.lights were painted (src/scene/glows.ts): the runtime glows sit here, not on the planned
|
|
73
|
+
* points. A light missing from the list was painted nowhere near its point and gets no glow.
|
|
74
|
+
*/
|
|
75
|
+
glows: z.array(z.object({ key: z.string(), x: z.number(), y: z.number() })).optional(),
|
|
76
|
+
flagged: z.array(z.string()).optional(),
|
|
77
|
+
/** Made from layout.json, not measured from the painting. */
|
|
78
|
+
preview: z.boolean().optional(),
|
|
79
|
+
});
|
|
80
|
+
export type Geometry = z.infer<typeof Geometry>;
|
|
81
|
+
export type Occluder = Geometry['occluders'][number];
|
|
82
|
+
export type GeoThing = Geometry['things'][number];
|
|
83
|
+
|
|
84
|
+
/** A person's height in px with feet at y; never below a few px so far spots stay visible. */
|
|
85
|
+
export function personPx(g: Geometry, feetY: number): number {
|
|
86
|
+
return Math.max(8, g.scale.pxPerY * (feetY - g.scale.horizonY));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* How much more floor one screen pixel up or down covers than one sideways, at feet y: the floor is foreshortened,
|
|
91
|
+
* so a step into the picture moves the feet fewer pixels than a step across it. A ~50° lens (focal ≈ the frame's
|
|
92
|
+
* width) over the place's horizon; held to 1..3 so a far horizon cannot freeze anyone.
|
|
93
|
+
*/
|
|
94
|
+
export function depthStretch(g: Geometry, feetY: number): number {
|
|
95
|
+
const below = feetY - g.scale.horizonY;
|
|
96
|
+
return below > 0 ? Math.min(3, Math.max(1, 1100 / below)) : 3;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The occluder's floor line at column x: interpolated, flat past either end, baseY without a line. */
|
|
100
|
+
export function floorYAt(o: Occluder, x: number): number {
|
|
101
|
+
const line = o.floor;
|
|
102
|
+
if (!line?.length) return o.baseY;
|
|
103
|
+
if (x <= line[0][0]) return line[0][1];
|
|
104
|
+
for (let i = 1; i < line.length; i++) {
|
|
105
|
+
const [x1, y1] = line[i];
|
|
106
|
+
if (x <= x1) {
|
|
107
|
+
const [x0, y0] = line[i - 1];
|
|
108
|
+
return x1 === x0 ? y1 : y0 + ((y1 - y0) * (x - x0)) / (x1 - x0);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return line[line.length - 1][1];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Feet this close in front of a floor line still count as standing at it, not behind it. */
|
|
115
|
+
export const BEHIND_MARGIN = 6;
|
|
116
|
+
|
|
117
|
+
/** Whether the occluder hides a person whose feet are at feetY, in column x. `support` resolves `on`. */
|
|
118
|
+
export function hides(o: Occluder, x: number, feetY: number, support?: (key: string) => Occluder | undefined): boolean {
|
|
119
|
+
if (o.front) return true;
|
|
120
|
+
const under = o.on ? support?.(o.on) : undefined;
|
|
121
|
+
if (under && under !== o) return hides(under, x, feetY);
|
|
122
|
+
return feetY < floorYAt(o, x) - BEHIND_MARGIN;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Column runs [x0, x1) inside [left, right) where the occluder hides the person; a few runs per occluder. */
|
|
126
|
+
export function hiddenRuns(o: Occluder, left: number, right: number, feetY: number, support?: (key: string) => Occluder | undefined): Array<[number, number]> {
|
|
127
|
+
const runs: Array<[number, number]> = [];
|
|
128
|
+
const x0 = Math.max(Math.floor(left), Math.floor(o.box[0]));
|
|
129
|
+
const x1 = Math.min(Math.ceil(right), Math.ceil(o.box[2]));
|
|
130
|
+
let start = -1;
|
|
131
|
+
for (let x = x0; x < x1; x++) {
|
|
132
|
+
const hid = hides(o, x + 0.5, feetY, support);
|
|
133
|
+
if (hid && start < 0) start = x;
|
|
134
|
+
if (!hid && start >= 0) {
|
|
135
|
+
runs.push([start, x]);
|
|
136
|
+
start = -1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (start >= 0) runs.push([start, x1]);
|
|
140
|
+
return runs;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Where a tread puts the feet, as a share of the step's band from its top. */
|
|
144
|
+
const TREAD = 0.35;
|
|
145
|
+
|
|
146
|
+
/** The step bands [top, bottom] under column x, top first. */
|
|
147
|
+
export function stepBands(g: Geometry, x: number): Array<[number, number]> {
|
|
148
|
+
const out: Array<[number, number]> = [];
|
|
149
|
+
for (const s of g.steps ?? []) {
|
|
150
|
+
const i = Math.round((x - s.x0) / s.every);
|
|
151
|
+
if (i < 0 || i >= s.top.length || s.top[i] < 0 || s.bottom[i] <= s.top[i]) continue;
|
|
152
|
+
out.push([s.top[i], s.bottom[i]]);
|
|
153
|
+
}
|
|
154
|
+
return out.sort((a, b) => a[0] - b[0]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The step of g.steps a walker at (x, y) stands on, and how far down its band (0 top, 1 bottom). */
|
|
158
|
+
export function stepAt(g: Geometry, x: number, y: number): { step: number; at: number } | undefined {
|
|
159
|
+
let best: { step: number; at: number } | undefined;
|
|
160
|
+
(g.steps ?? []).forEach((s, step) => {
|
|
161
|
+
const i = Math.round((x - s.x0) / s.every);
|
|
162
|
+
if (i < 0 || i >= s.top.length || s.top[i] < 0 || s.bottom[i] <= s.top[i]) return;
|
|
163
|
+
const at = (y - s.top[i]) / (s.bottom[i] - s.top[i]);
|
|
164
|
+
// Neighbouring bands overlap a little: the one the walker is deeper inside wins.
|
|
165
|
+
if (at >= 0 && at <= 1 && (!best || Math.abs(at - 0.5) < Math.abs(best.at - 0.5))) best = { step, at };
|
|
166
|
+
});
|
|
167
|
+
return best;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The y at column x that is `at` of the way down step `step`'s band; undefined where the step isn't. */
|
|
171
|
+
export function onStep(g: Geometry, step: number, x: number, at: number): number | undefined {
|
|
172
|
+
const s = g.steps?.[step];
|
|
173
|
+
const i = s ? Math.round((x - s.x0) / s.every) : -1;
|
|
174
|
+
if (!s || i < 0 || i >= s.top.length || s.top[i] < 0 || s.bottom[i] <= s.top[i]) return undefined;
|
|
175
|
+
return s.top[i] + at * (s.bottom[i] - s.top[i]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Where feet stand on step `step` at column x: the same tread height stepFootY holds them at. */
|
|
179
|
+
export function treadY(g: Geometry, step: number, x: number): number | undefined {
|
|
180
|
+
return onStep(g, step, x, TREAD);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const smooth = (u: number) => u * u * (3 - 2 * u);
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Where the feet are drawn for a walker at (x, y) on stairs: held on each tread, then a quick step over the
|
|
187
|
+
* riser to the next, instead of gliding up a ramp. Off the stairs it is y. Size and depth still follow y.
|
|
188
|
+
*/
|
|
189
|
+
export function stepFootY(g: Geometry, x: number, y: number): number {
|
|
190
|
+
const bands = stepBands(g, x);
|
|
191
|
+
if (!bands.length || y <= bands[0][0] || y >= bands[bands.length - 1][1]) return y;
|
|
192
|
+
// Control points (walker y -> foot y): flat on each tread's middle half, eased between.
|
|
193
|
+
const pts: Array<[number, number]> = [[bands[0][0], bands[0][0]]];
|
|
194
|
+
for (const [t, b] of bands) {
|
|
195
|
+
const h = b - t;
|
|
196
|
+
const foot = t + TREAD * h;
|
|
197
|
+
const a = Math.max(t + 0.25 * h, pts[pts.length - 1][0]);
|
|
198
|
+
pts.push([a, foot], [Math.max(a, b - 0.25 * h), foot]);
|
|
199
|
+
}
|
|
200
|
+
const last = bands[bands.length - 1][1];
|
|
201
|
+
pts.push([last, last]);
|
|
202
|
+
for (let k = 1; k < pts.length; k++) {
|
|
203
|
+
const [y0, f0] = pts[k - 1];
|
|
204
|
+
const [y1, f1] = pts[k];
|
|
205
|
+
if (y > y1) continue;
|
|
206
|
+
return y1 > y0 ? f0 + (f1 - f0) * smooth((y - y0) / (y1 - y0)) : f1;
|
|
207
|
+
}
|
|
208
|
+
return y;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A walker's resting y on stairs: where it is when the feet stand on a tread, otherwise the next spot
|
|
213
|
+
* ahead (`dir` -1 walking up the picture, 1 down) where they do, so nobody stops mid-step or steps back.
|
|
214
|
+
*/
|
|
215
|
+
export function stepRestY(g: Geometry, x: number, y: number, dir = 1): number {
|
|
216
|
+
const flat = (v: number) => Math.abs(stepFootY(g, x, v + 1) - stepFootY(g, x, v - 1)) < 0.01;
|
|
217
|
+
const bands = stepBands(g, x);
|
|
218
|
+
if (!bands.length || flat(y)) return y;
|
|
219
|
+
const step = dir < 0 ? -1 : 1;
|
|
220
|
+
for (let v = y + step; v > bands[0][0] - 2 && v < bands[bands.length - 1][1] + 2; v += step) if (flat(v)) return v;
|
|
221
|
+
return y;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The light on a person standing at (x, y), px tall: the sun as it is, or for a fire or a lamp (light.at)
|
|
226
|
+
* a shadow running straight away from it, longer the farther they stand, fading out a few heights away.
|
|
227
|
+
* Undefined when the person is too far from the fire for it to throw a shadow.
|
|
228
|
+
*/
|
|
229
|
+
export function lightFor(g: Geometry, x: number, y: number, px: number, t?: number): Geometry['light'] {
|
|
230
|
+
const light = g.light;
|
|
231
|
+
if (!light?.at) return light;
|
|
232
|
+
// A row of alike lamps: the one lighting this person most.
|
|
233
|
+
return [flameAt(g, t), ...(light.also ?? [])].map((at) => lampLight(light, at, x, y, px, t)).reduce((a, b) => (!a || (b && b.strength > a.strength) ? b : a), undefined);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Whether a person's shadow is cast one way: not under a row of alike lamps, where no one of them is the key light. */
|
|
237
|
+
export function castsShadow(g: Geometry): boolean {
|
|
238
|
+
return !!g.light && !g.light.also?.length;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The light of a lamp or a fire standing at `at` on a person at (x, y), px tall; undefined out of its reach. */
|
|
242
|
+
function lampLight(light: NonNullable<Geometry['light']>, at: [number, number], x: number, y: number, px: number, t?: number): Geometry['light'] {
|
|
243
|
+
const [dx, dy] = [x - at[0], y - at[1]];
|
|
244
|
+
const d = Math.hypot(dx, dy);
|
|
245
|
+
const reach = 4 * px;
|
|
246
|
+
if (d >= reach) return undefined;
|
|
247
|
+
// Right at the fire the shadow still has a way: toward the viewer.
|
|
248
|
+
const dir: [number, number] = d > 1 ? [dx / d, dy / d] : [0, 1];
|
|
249
|
+
// A flame lower than the head throws a long shadow, longer the farther away the person is.
|
|
250
|
+
const length = Math.min(3, 0.9 + (1.2 * d) / px);
|
|
251
|
+
return { dir, length, strength: light.strength * (1 - d / reach) * (t === undefined || !light.flame ? 1 : 1 + FLAME_GLOW * flicker(t).glow), at, flame: light.flame };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** How far a flame sways (metres to either side) and how much its light breathes (share of its strength). */
|
|
255
|
+
export const FLAME_SWAY_M = 0.1;
|
|
256
|
+
export const FLAME_GLOW = 0.12;
|
|
257
|
+
|
|
258
|
+
/** A flame's flicker at time t (s): sway side to side and glow, each -1..1, smooth and never quite repeating. */
|
|
259
|
+
export function flicker(t: number): { sway: number; glow: number } {
|
|
260
|
+
return {
|
|
261
|
+
sway: 0.55 * Math.sin(t * 2.1) + 0.3 * Math.sin(t * 5.3 + 1.7) + 0.15 * Math.sin(t * 11.9 + 0.4),
|
|
262
|
+
glow: 0.5 * Math.sin(t * 7.7 + 0.9) + 0.3 * Math.sin(t * 13.1 + 2.3) + 0.2 * Math.sin(t * 3.1),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Where the light stands at time t: a flame sways a little side to side; any other light stays put. */
|
|
267
|
+
export function flameAt(g: Geometry, t?: number): [number, number] {
|
|
268
|
+
const light = g.light!;
|
|
269
|
+
const at = light.at!;
|
|
270
|
+
if (t === undefined || !light.flame) return at;
|
|
271
|
+
return [at[0] + flicker(t).sway * personPx(g, at[1]) * (FLAME_SWAY_M / 1.62), at[1]];
|
|
272
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Talking gestures: what a character does with the hands and head while a line is spoken. The story picks
|
|
2
|
+
// one per line; `diorama sprites` draws only those the story uses. No Node imports: the browser bundles this.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A standing pose: where each upper arm points (world, the figure facing +z, its left at +x), how far
|
|
6
|
+
* the elbow bends, and the head's nod and tilt. Elbows stay near the body: a long coat is skinned to
|
|
7
|
+
* the upper arms and flares into a cape otherwise.
|
|
8
|
+
*/
|
|
9
|
+
export type Pose = { left?: [number, number, number, number]; right?: [number, number, number, number]; nod?: number; tilt?: number; lean?: number };
|
|
10
|
+
|
|
11
|
+
export const GESTURES = [
|
|
12
|
+
{ name: 'listen', means: 'hands down, listening or saying something plain', pose: {} },
|
|
13
|
+
{ name: 'open-hand', means: 'one hand raised, palm open: offering, greeting, a friendly point', pose: { right: [-0.2, -0.9, 0.35, 1.7] } },
|
|
14
|
+
{ name: 'explain', means: 'both hands forward: explaining, describing, reasoning', pose: { left: [0.25, -0.92, 0.3, 1.3], right: [-0.25, -0.92, 0.3, 1.3] } },
|
|
15
|
+
{ name: 'point', means: 'pointing ahead: directions, "over there", "that one"', pose: { right: [-0.1, -0.75, 0.65, 0.9], lean: 0.04 } },
|
|
16
|
+
{ name: 'shrug', means: 'shrug, forearms out: not knowing, doubt, "who knows"', pose: { left: [0.2, -0.95, 0.15, 1.4], right: [-0.2, -0.95, 0.15, 1.4], tilt: 0.15 } },
|
|
17
|
+
{ name: 'chest', means: 'hand on the chest: about oneself, a promise, feeling', pose: { left: [0.1, -0.95, 0.3, 2.1], nod: 0.1 } },
|
|
18
|
+
{ name: 'nod', means: 'nodding: yes, agreeing, thanks', pose: { right: [-0.2, -0.92, 0.3, 1.2], nod: 0.25 } },
|
|
19
|
+
{ name: 'together', means: 'hands together: asking, pleading, worried', pose: { left: [0.08, -0.92, 0.38, 1.5], right: [-0.08, -0.92, 0.38, 1.5] } },
|
|
20
|
+
] as const satisfies readonly { name: string; means: string; pose: Pose }[];
|
|
21
|
+
|
|
22
|
+
export type GestureName = (typeof GESTURES)[number]['name'];
|
|
23
|
+
export const gestureIndex = (name: string) => GESTURES.findIndex((g) => g.name === name);
|