@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.
Files changed (299) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +157 -2
  3. package/app/bin/diorama.mjs +13 -0
  4. package/app/src/actions/budget.ts +152 -0
  5. package/app/src/actions/build.ts +158 -0
  6. package/app/src/actions/cast.ts +248 -0
  7. package/app/src/actions/depth.ts +95 -0
  8. package/app/src/actions/extend.ts +36 -0
  9. package/app/src/actions/faces.ts +160 -0
  10. package/app/src/actions/feedback.ts +87 -0
  11. package/app/src/actions/fix.ts +382 -0
  12. package/app/src/actions/history.ts +93 -0
  13. package/app/src/actions/idea-preview.ts +65 -0
  14. package/app/src/actions/idea-review.ts +102 -0
  15. package/app/src/actions/idea-sketch.ts +172 -0
  16. package/app/src/actions/index.ts +31 -0
  17. package/app/src/actions/items.ts +94 -0
  18. package/app/src/actions/kids.ts +215 -0
  19. package/app/src/actions/models.ts +64 -0
  20. package/app/src/actions/motion.ts +119 -0
  21. package/app/src/actions/pictures.ts +49 -0
  22. package/app/src/actions/reactions.ts +36 -0
  23. package/app/src/actions/registry.ts +101 -0
  24. package/app/src/actions/share.ts +32 -0
  25. package/app/src/actions/sound.ts +73 -0
  26. package/app/src/actions/start.ts +154 -0
  27. package/app/src/actions/step-work.ts +150 -0
  28. package/app/src/actions/story.ts +101 -0
  29. package/app/src/actions/studio.ts +133 -0
  30. package/app/src/actions/style.ts +72 -0
  31. package/app/src/actions/video.ts +46 -0
  32. package/app/src/actions/view.ts +109 -0
  33. package/app/src/actions/weather.ts +38 -0
  34. package/app/src/actions/world.ts +289 -0
  35. package/app/src/commands/action.ts +39 -0
  36. package/app/src/commands/backdrop.ts +188 -0
  37. package/app/src/commands/browsers.ts +21 -0
  38. package/app/src/commands/build.ts +503 -0
  39. package/app/src/commands/cast.ts +142 -0
  40. package/app/src/commands/clip.ts +431 -0
  41. package/app/src/commands/derive.ts +167 -0
  42. package/app/src/commands/doctor.ts +74 -0
  43. package/app/src/commands/explore.ts +123 -0
  44. package/app/src/commands/export.ts +142 -0
  45. package/app/src/commands/extend.ts +75 -0
  46. package/app/src/commands/faces.ts +80 -0
  47. package/app/src/commands/icons.ts +69 -0
  48. package/app/src/commands/index.ts +36 -0
  49. package/app/src/commands/layout.ts +31 -0
  50. package/app/src/commands/masks.ts +117 -0
  51. package/app/src/commands/mcp.ts +52 -0
  52. package/app/src/commands/model.ts +284 -0
  53. package/app/src/commands/paint.ts +182 -0
  54. package/app/src/commands/plates.ts +97 -0
  55. package/app/src/commands/play.ts +192 -0
  56. package/app/src/commands/playthrough.ts +111 -0
  57. package/app/src/commands/publish.ts +78 -0
  58. package/app/src/commands/reactions.ts +40 -0
  59. package/app/src/commands/reel.ts +46 -0
  60. package/app/src/commands/setup.ts +85 -0
  61. package/app/src/commands/sound.ts +90 -0
  62. package/app/src/commands/sprites.ts +232 -0
  63. package/app/src/commands/story.ts +37 -0
  64. package/app/src/commands/studio.ts +65 -0
  65. package/app/src/commands/style.ts +87 -0
  66. package/app/src/commands/verify.ts +45 -0
  67. package/app/src/commands/voice-retry.ts +14 -0
  68. package/app/src/commands/voices.ts +145 -0
  69. package/app/src/commands/weather.ts +52 -0
  70. package/app/src/commands/world.ts +104 -0
  71. package/app/src/film/clip.ts +386 -0
  72. package/app/src/film/reel.ts +126 -0
  73. package/app/src/film/screencast.ts +123 -0
  74. package/app/src/image/mask.ts +29 -0
  75. package/app/src/image/overlay.ts +38 -0
  76. package/app/src/image/raster.ts +224 -0
  77. package/app/src/main.ts +28 -0
  78. package/app/src/meta/choice.ts +27 -0
  79. package/app/src/meta/http.ts +99 -0
  80. package/app/src/meta/memo.ts +91 -0
  81. package/app/src/meta/muse.ts +70 -0
  82. package/app/src/meta/openrouter.ts +93 -0
  83. package/app/src/meta/prices.ts +25 -0
  84. package/app/src/meta/sam.ts +83 -0
  85. package/app/src/meta/schema.ts +63 -0
  86. package/app/src/meta/spark.ts +220 -0
  87. package/app/src/meta/step.ts +14 -0
  88. package/app/src/meta/voice-file.ts +78 -0
  89. package/app/src/meta/voice.ts +103 -0
  90. package/app/src/model/animation.ts +38 -0
  91. package/app/src/model/blender/relax.py +284 -0
  92. package/app/src/model/blender.ts +115 -0
  93. package/app/src/model/build.ts +50 -0
  94. package/app/src/model/face.ts +21 -0
  95. package/app/src/model/fit.ts +181 -0
  96. package/app/src/model/glb.ts +241 -0
  97. package/app/src/model/plan.ts +151 -0
  98. package/app/src/model/quality.ts +48 -0
  99. package/app/src/model/space.ts +168 -0
  100. package/app/src/model/sprites.ts +293 -0
  101. package/app/src/model/views.ts +163 -0
  102. package/app/src/play/creature.ts +43 -0
  103. package/app/src/play/depth-occlusion.ts +150 -0
  104. package/app/src/play/exit-outline.ts +48 -0
  105. package/app/src/play/face3d.ts +34 -0
  106. package/app/src/play/focus.ts +78 -0
  107. package/app/src/play/geometry.ts +272 -0
  108. package/app/src/play/gestures.ts +23 -0
  109. package/app/src/play/glow.ts +187 -0
  110. package/app/src/play/lipsync.ts +69 -0
  111. package/app/src/play/living.ts +83 -0
  112. package/app/src/play/outline-path.ts +130 -0
  113. package/app/src/play/parallax.ts +200 -0
  114. package/app/src/play/path.ts +262 -0
  115. package/app/src/play/preview.ts +71 -0
  116. package/app/src/play/reactions.ts +92 -0
  117. package/app/src/play/rig.ts +172 -0
  118. package/app/src/play/shadow.ts +253 -0
  119. package/app/src/play/sound.ts +51 -0
  120. package/app/src/play/weather.ts +58 -0
  121. package/app/src/qa/explore.ts +136 -0
  122. package/app/src/qa/probe.ts +139 -0
  123. package/app/src/qa/route.ts +118 -0
  124. package/app/src/registry.ts +23 -0
  125. package/app/src/scene/animation-source.ts +36 -0
  126. package/app/src/scene/assets.ts +37 -0
  127. package/app/src/scene/autofix.ts +133 -0
  128. package/app/src/scene/backdrop.ts +326 -0
  129. package/app/src/scene/blockin.ts +189 -0
  130. package/app/src/scene/brief.ts +74 -0
  131. package/app/src/scene/carry.ts +177 -0
  132. package/app/src/scene/cast-fixes.ts +31 -0
  133. package/app/src/scene/cast.ts +192 -0
  134. package/app/src/scene/corrections.ts +164 -0
  135. package/app/src/scene/critic.ts +169 -0
  136. package/app/src/scene/depthmap.ts +149 -0
  137. package/app/src/scene/derive.ts +475 -0
  138. package/app/src/scene/diagnose.ts +132 -0
  139. package/app/src/scene/face-calibration.ts +65 -0
  140. package/app/src/scene/face-registration.ts +21 -0
  141. package/app/src/scene/face-work.ts +71 -0
  142. package/app/src/scene/faces-data.ts +48 -0
  143. package/app/src/scene/faces-settings.ts +20 -0
  144. package/app/src/scene/faces.ts +435 -0
  145. package/app/src/scene/glows.ts +153 -0
  146. package/app/src/scene/held.ts +55 -0
  147. package/app/src/scene/icons.ts +185 -0
  148. package/app/src/scene/layers.ts +822 -0
  149. package/app/src/scene/layout.ts +216 -0
  150. package/app/src/scene/locate.ts +118 -0
  151. package/app/src/scene/maskcheck.ts +43 -0
  152. package/app/src/scene/masks.ts +392 -0
  153. package/app/src/scene/occluders.ts +123 -0
  154. package/app/src/scene/occlusion-check.ts +138 -0
  155. package/app/src/scene/paint-layout.ts +135 -0
  156. package/app/src/scene/paint.ts +81 -0
  157. package/app/src/scene/photo.ts +123 -0
  158. package/app/src/scene/plates.ts +256 -0
  159. package/app/src/scene/playbook.ts +121 -0
  160. package/app/src/scene/preview.ts +23 -0
  161. package/app/src/scene/reactions.ts +118 -0
  162. package/app/src/scene/restyle.ts +53 -0
  163. package/app/src/scene/rig-check.ts +68 -0
  164. package/app/src/scene/rig.ts +375 -0
  165. package/app/src/scene/scale.ts +148 -0
  166. package/app/src/scene/shadows.ts +207 -0
  167. package/app/src/scene/stairs.ts +183 -0
  168. package/app/src/scene/status.ts +32 -0
  169. package/app/src/scene/verify.ts +156 -0
  170. package/app/src/scene/walk.ts +326 -0
  171. package/app/src/scene/warp.ts +417 -0
  172. package/app/src/scene/weather.ts +118 -0
  173. package/app/src/scene/words.ts +65 -0
  174. package/app/src/secrets.ts +219 -0
  175. package/app/src/setup/checks.ts +110 -0
  176. package/app/src/sound/cost.ts +74 -0
  177. package/app/src/sound/data.ts +89 -0
  178. package/app/src/sound/fixes.ts +23 -0
  179. package/app/src/sound/issues.ts +21 -0
  180. package/app/src/sound/language.ts +11 -0
  181. package/app/src/sound/lipsync-make.ts +98 -0
  182. package/app/src/sound/lipsync.ts +24 -0
  183. package/app/src/sound/make.ts +229 -0
  184. package/app/src/sound/musicgen.py +20 -0
  185. package/app/src/sound/pcm.ts +280 -0
  186. package/app/src/sound/plan.ts +154 -0
  187. package/app/src/sound/retry.ts +43 -0
  188. package/app/src/sound/settings.ts +28 -0
  189. package/app/src/sound/sources.ts +81 -0
  190. package/app/src/sound/spoken.ts +70 -0
  191. package/app/src/sound/voices.ts +579 -0
  192. package/app/src/speech/transcribe.ts +38 -0
  193. package/app/src/speech/types.ts +18 -0
  194. package/app/src/story/check.ts +230 -0
  195. package/app/src/story/design.ts +108 -0
  196. package/app/src/story/engine.ts +233 -0
  197. package/app/src/story/fixes.ts +54 -0
  198. package/app/src/story/npc-guidance.ts +7 -0
  199. package/app/src/story/phrases.ts +4 -0
  200. package/app/src/story/schema.ts +92 -0
  201. package/app/src/studio/agent.ts +554 -0
  202. package/app/src/studio/discovery.ts +28 -0
  203. package/app/src/studio/events.ts +48 -0
  204. package/app/src/studio/history.ts +124 -0
  205. package/app/src/studio/jobs.ts +291 -0
  206. package/app/src/studio/locks.ts +33 -0
  207. package/app/src/studio/mcp.ts +86 -0
  208. package/app/src/studio/operation.ts +18 -0
  209. package/app/src/studio/repair.ts +31 -0
  210. package/app/src/studio/server.ts +421 -0
  211. package/app/src/studio/trouble.ts +48 -0
  212. package/app/src/world/design.ts +72 -0
  213. package/app/src/world/drawings.ts +198 -0
  214. package/app/src/world/extend-stage.ts +109 -0
  215. package/app/src/world/extend.ts +123 -0
  216. package/app/src/world/extension-progress.ts +10 -0
  217. package/app/src/world/files.ts +62 -0
  218. package/app/src/world/idea-review.ts +32 -0
  219. package/app/src/world/photo-limits.ts +2 -0
  220. package/app/src/world/pool.ts +41 -0
  221. package/app/src/world/schedule.ts +64 -0
  222. package/app/src/world/schema.ts +74 -0
  223. package/app/src/world/sketch.ts +81 -0
  224. package/app/src/world/style-plan.ts +90 -0
  225. package/app/src/world/style-reference.ts +16 -0
  226. package/app/src/world/styled.ts +51 -0
  227. package/app/src/world/told.ts +105 -0
  228. package/app/src/world/video-limits.ts +3 -0
  229. package/app/src/world/video.ts +98 -0
  230. package/app/studio/activity.tsx +124 -0
  231. package/app/studio/agent.tsx +384 -0
  232. package/app/studio/api.ts +111 -0
  233. package/app/studio/atelier.tsx +234 -0
  234. package/app/studio/audio-missing.tsx +25 -0
  235. package/app/studio/audio-setup.tsx +33 -0
  236. package/app/studio/blender-setup.tsx +30 -0
  237. package/app/studio/build.tsx +677 -0
  238. package/app/studio/camera.tsx +59 -0
  239. package/app/studio/cast-work.tsx +207 -0
  240. package/app/studio/cast.tsx +178 -0
  241. package/app/studio/concept-edit.tsx +47 -0
  242. package/app/studio/creator-idea.tsx +46 -0
  243. package/app/studio/custom-look.tsx +36 -0
  244. package/app/studio/faces-setup.tsx +28 -0
  245. package/app/studio/favicon.svg +11 -0
  246. package/app/studio/figure.tsx +314 -0
  247. package/app/studio/fonts/OFL.txt +93 -0
  248. package/app/studio/fonts/fonts.css +7 -0
  249. package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
  250. package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
  251. package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
  252. package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
  253. package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
  254. package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
  255. package/app/studio/icons.tsx +42 -0
  256. package/app/studio/idea-card.tsx +42 -0
  257. package/app/studio/idea-review.tsx +181 -0
  258. package/app/studio/idea-storyboard.tsx +134 -0
  259. package/app/studio/index.html +15 -0
  260. package/app/studio/job-words.ts +33 -0
  261. package/app/studio/lightbox.tsx +49 -0
  262. package/app/studio/main.tsx +1328 -0
  263. package/app/studio/nav.ts +14 -0
  264. package/app/studio/place.tsx +264 -0
  265. package/app/studio/review-sketch.tsx +47 -0
  266. package/app/studio/say-it.tsx +49 -0
  267. package/app/studio/step-work.tsx +134 -0
  268. package/app/studio/story.tsx +394 -0
  269. package/app/studio/style-menu.tsx +81 -0
  270. package/app/studio/style.css +1390 -0
  271. package/app/studio/sure.tsx +54 -0
  272. package/app/studio/tip.tsx +74 -0
  273. package/app/studio/tsconfig.json +5 -0
  274. package/app/studio/video.tsx +73 -0
  275. package/app/studio/world.tsx +188 -0
  276. package/app/tsconfig.json +13 -0
  277. package/app/web/audio.ts +315 -0
  278. package/app/web/face.ts +131 -0
  279. package/app/web/face3d.ts +35 -0
  280. package/app/web/glows.ts +387 -0
  281. package/app/web/hero3d.ts +728 -0
  282. package/app/web/index.html +118 -0
  283. package/app/web/living.ts +404 -0
  284. package/app/web/main.ts +3751 -0
  285. package/app/web/pad.ts +174 -0
  286. package/app/web/reactions.ts +79 -0
  287. package/app/web/sheet.ts +36 -0
  288. package/app/web/synth.ts +140 -0
  289. package/app/web/tsconfig.json +5 -0
  290. package/app/web/ui.ts +263 -0
  291. package/app/web/weather.ts +306 -0
  292. package/app/web/words.ts +12 -0
  293. package/docs/characters.md +58 -0
  294. package/docs/npc-motion.md +69 -0
  295. package/docs/scenes.md +75 -0
  296. package/docs/setup.md +60 -0
  297. package/docs/sound.md +23 -0
  298. package/docs/studio.md +62 -0
  299. package/package.json +70 -4
@@ -0,0 +1,172 @@
1
+ // rig.json: a cut-out figure (parts + joints) and the walk it can do. `diorama rig` writes it; the
2
+ // runtime poses it every frame. All coordinates are in the figure's own image, facing right, y down.
3
+ // No Node imports: the browser bundles this file.
4
+ import { z } from 'zod';
5
+
6
+ const XY = z.tuple([z.number(), z.number()]);
7
+
8
+ export const PART_NAMES = ['farArm', 'farThigh', 'farShin', 'farBoot', 'nearThigh', 'nearShin', 'nearBoot', 'torso', 'nearArm'] as const;
9
+ export type PartName = (typeof PART_NAMES)[number];
10
+
11
+ export const Rig = z.object({
12
+ size: XY,
13
+ /** Sole row: where the feet stand. */
14
+ ground: z.number(),
15
+ /** Head top to sole, px. */
16
+ height: z.number(),
17
+ hip: XY,
18
+ shoulder: z.object({ near: XY, far: XY }),
19
+ leg: z.object({
20
+ near: z.object({ knee: XY, ankle: XY }),
21
+ far: z.object({ knee: XY, ankle: XY }),
22
+ }),
23
+ /** Drawn back to front; `at` is the part image's top-left in the figure. */
24
+ parts: z.array(z.object({ name: z.enum(PART_NAMES), file: z.string(), at: XY })),
25
+ /**
26
+ * The walk drawn ahead as a sheet (src/scene/warp.ts): `frames` cells of one cycle, then the standing figure, each
27
+ * `cell` px (the rig's size times `scale`). Present, the parts are not used.
28
+ */
29
+ sheet: z.object({ file: z.string(), cols: z.number(), rows: z.number(), frames: z.number(), cell: XY, scale: z.number() }).optional(),
30
+ /** The walk measured when it was made (src/scene/rig-check.ts): not ok, the figure slides as one piece instead. */
31
+ check: z.object({ ok: z.boolean(), holes: z.number(), loose: z.number(), why: z.string().optional() }).optional(),
32
+ });
33
+ export type Rig = z.infer<typeof Rig>;
34
+
35
+ type P = [number, number];
36
+ /** Where each part goes this frame: rotate by `turn` about `from`, then move `from` to `to`. */
37
+ export type PartPose = { from: P; to: P; turn: number };
38
+ export type Pose = Record<PartName, PartPose>;
39
+
40
+ /** The sheet cell for walk phase `phase`, or the standing cell. */
41
+ export function sheetCell(sheet: NonNullable<Rig['sheet']>, phase: number, walking: boolean): number {
42
+ return walking ? Math.floor((((phase % 1) + 1) % 1) * sheet.frames) % sheet.frames : sheet.frames;
43
+ }
44
+
45
+ /** Distance the figure travels per walk cycle, as a share of its height. */
46
+ export const CYCLE = 0.55;
47
+ const STANCE = 0.6;
48
+ const LIFT = 0.06;
49
+ const ARM_SWING = 0.32;
50
+
51
+ const sub = (a: P, b: P): P => [a[0] - b[0], a[1] - b[1]];
52
+ const len = (a: P) => Math.hypot(a[0], a[1]);
53
+ const angle = (a: P) => Math.atan2(a[1], a[0]);
54
+ const smooth = (u: number) => u * u * (3 - 2 * u);
55
+
56
+ /** Ankle offset from the hip (dx) and lift above the ground for one leg at phase p (0 = heel strike). */
57
+ export function footAt(p: number, step: number): { dx: number; lift: number } {
58
+ p = ((p % 1) + 1) % 1;
59
+ if (p < STANCE) return { dx: step / 2 - (step * p) / STANCE, lift: 0 };
60
+ const u = (p - STANCE) / (1 - STANCE);
61
+ return { dx: -step / 2 + step * smooth(u), lift: Math.sin(Math.PI * u) };
62
+ }
63
+
64
+ /** Knee position for a two-bone leg from hip to ankle target, knee bending forward (+x). */
65
+ export function solveKnee(hip: P, target: P, thigh: number, shin: number): { knee: P; ankle: P } {
66
+ let d = sub(target, hip);
67
+ const reach = len(d);
68
+ const max = (thigh + shin) * 0.999;
69
+ if (reach > max) d = [(d[0] * max) / reach, (d[1] * max) / reach];
70
+ const r = Math.min(len(d), max);
71
+ const ankle: P = [hip[0] + d[0], hip[1] + d[1]];
72
+ // Law of cosines for the thigh's angle off the hip-ankle line.
73
+ const cos = Math.max(-1, Math.min(1, (thigh * thigh + r * r - shin * shin) / (2 * thigh * r || 1)));
74
+ const a = angle(d) - Math.acos(cos);
75
+ return { knee: [hip[0] + thigh * Math.cos(a), hip[1] + thigh * Math.sin(a)], ankle };
76
+ }
77
+
78
+ /**
79
+ * The pose at walk phase `phase` (cycles, any real), or standing when `walking` is false.
80
+ * The near leg strikes at phase 0, the far leg half a cycle later.
81
+ */
82
+ export function pose(rig: Rig, phase: number, walking: boolean): Pose {
83
+ const H = rig.height;
84
+ const step = CYCLE * STANCE * H;
85
+ const legs = (['near', 'far'] as const).map((side) => {
86
+ const rest = rig.leg[side];
87
+ const thigh = len(sub(rest.knee, rig.hip));
88
+ const shin = len(sub(rest.ankle, rest.knee));
89
+ const ankleUp = rig.ground - rest.ankle[1];
90
+ const foot = walking ? footAt(phase + (side === 'far' ? 0.5 : 0), step) : { dx: (side === 'near' ? 0.03 : -0.03) * H, lift: 0 };
91
+ return { side, rest, thigh, shin, ankleUp, foot };
92
+ });
93
+ // The hip sinks just enough for every planted foot to reach the ground: the bob of a walk.
94
+ let hipY = rig.hip[1];
95
+ for (const l of legs) {
96
+ if (l.foot.lift > 0) continue;
97
+ const vertical = Math.sqrt(Math.max(0, (0.985 * (l.thigh + l.shin)) ** 2 - l.foot.dx ** 2));
98
+ hipY = Math.max(hipY, rig.ground - l.ankleUp - vertical);
99
+ }
100
+ const hip: P = [rig.hip[0], hipY];
101
+ const drop = hipY - rig.hip[1];
102
+ const out = {} as Pose;
103
+ for (const l of legs) {
104
+ const target: P = [hip[0] + l.foot.dx, rig.ground - l.ankleUp - l.foot.lift * LIFT * H];
105
+ const { knee, ankle } = solveKnee(hip, target, l.thigh, l.shin);
106
+ const thighTurn = angle(sub(knee, hip)) - angle(sub(l.rest.knee, rig.hip));
107
+ const shinTurn = angle(sub(ankle, knee)) - angle(sub(l.rest.ankle, l.rest.knee));
108
+ const name = l.side === 'near' ? 'near' : 'far';
109
+ out[`${name}Thigh`] = { from: rig.hip, to: hip, turn: thighTurn };
110
+ out[`${name}Shin`] = { from: l.rest.knee, to: knee, turn: shinTurn };
111
+ // A planted boot stays flat on the ground; a swinging one follows the shin a little.
112
+ out[`${name}Boot`] = { from: l.rest.ankle, to: ankle, turn: l.foot.lift > 0 ? shinTurn * 0.5 : 0 };
113
+ }
114
+ const swing = (side: 'near' | 'far') => {
115
+ const dx = legs.find((l) => l.side === side)!.foot.dx;
116
+ return walking ? (ARM_SWING * dx) / (step / 2) : 0;
117
+ };
118
+ const lift = (p: P): P => [p[0], p[1] + drop];
119
+ out.torso = { from: rig.hip, to: hip, turn: 0 };
120
+ // Each arm swings against its own side's leg: near leg forward, near arm back (clockwise).
121
+ out.nearArm = { from: rig.shoulder.near, to: lift(rig.shoulder.near), turn: swing('near') };
122
+ out.farArm = { from: rig.shoulder.far, to: lift(rig.shoulder.far), turn: swing('far') };
123
+ return out;
124
+ }
125
+
126
+ const THIGH_SWING = 0.3;
127
+ const KNEE_BEND = 0.55;
128
+ const ARM_TURN = 0.22;
129
+
130
+ const turnAbout = (p: P, about: P, a: number): P => {
131
+ const c = Math.cos(a);
132
+ const s = Math.sin(a);
133
+ const d = sub(p, about);
134
+ return [about[0] + c * d[0] - s * d[1], about[1] + s * d[0] + c * d[1]];
135
+ };
136
+
137
+ /**
138
+ * A walk told in angles from the painted stance, not reached by solving for foot targets: each thigh swings a
139
+ * little either side of where it was painted and the knee bends while the foot is up. Whatever the painting's
140
+ * stance (feet wide apart, boots up to the knee), no limb turns further than THIGH_SWING + KNEE_BEND, so a figure
141
+ * warped as one mesh (src/scene/warp.ts) bends without folding over itself. The planted foot stays on the ground.
142
+ */
143
+ export function swingPose(rig: Rig, phase: number): Pose {
144
+ const out = {} as Pose;
145
+ const legs = (['near', 'far'] as const).map((side) => {
146
+ const p = (((phase + (side === 'far' ? 0.5 : 0)) % 1) + 1) % 1;
147
+ // Forward is anticlockwise for a figure facing right (y down): the thigh comes forward for heel strike at 0.
148
+ const thigh = -THIGH_SWING * Math.cos(2 * Math.PI * p);
149
+ const lifted = p > 0.5 ? Math.sin(Math.PI * (p - 0.5) / 0.5) : 0;
150
+ const shin = thigh + KNEE_BEND * lifted;
151
+ const rest = rig.leg[side];
152
+ const knee = turnAbout(rest.knee, rig.hip, thigh);
153
+ const ankleAt: P = [knee[0] + Math.cos(shin) * (rest.ankle[0] - rest.knee[0]) - Math.sin(shin) * (rest.ankle[1] - rest.knee[1]), knee[1] + Math.sin(shin) * (rest.ankle[0] - rest.knee[0]) + Math.cos(shin) * (rest.ankle[1] - rest.knee[1])];
154
+ return { side, thigh, shin, knee, ankle: ankleAt, lifted, rest };
155
+ });
156
+ // The body sinks so the planted foot (the one not lifted, or the lower) stays on the ground.
157
+ const planted = legs.reduce((a, b) => (a.lifted < b.lifted || (a.lifted === b.lifted && a.ankle[1] > b.ankle[1]) ? a : b));
158
+ const drop = planted.rest.ankle[1] - planted.ankle[1];
159
+ const down = (q: P): P => [q[0], q[1] + drop];
160
+ const hip = down(rig.hip);
161
+ for (const l of legs) {
162
+ out[`${l.side}Thigh`] = { from: rig.hip, to: hip, turn: l.thigh };
163
+ out[`${l.side}Shin`] = { from: l.rest.knee, to: down(l.knee), turn: l.shin };
164
+ // A planted boot stays flat; a lifted one tips with the shin a little.
165
+ out[`${l.side}Boot`] = { from: l.rest.ankle, to: down(l.ankle), turn: l.lifted * l.shin * 0.5 };
166
+ }
167
+ out.torso = { from: rig.hip, to: hip, turn: 0 };
168
+ const arm = ARM_TURN * Math.cos(2 * Math.PI * phase);
169
+ out.nearArm = { from: rig.shoulder.near, to: down(rig.shoulder.near), turn: arm };
170
+ out.farArm = { from: rig.shoulder.far, to: down(rig.shoulder.far), turn: -arm };
171
+ return out;
172
+ }
@@ -0,0 +1,253 @@
1
+ // shadow: a person's cast shadow on the painted ground, using its depth map (geometry.depth). Every ground
2
+ // pixel becomes a 3D point; a ray from it toward the sun either crosses the person or not. The person is a
3
+ // flat card standing at the feet, turned to face the sun, carrying the figure's silhouette. So the shadow
4
+ // breaks over each step, climbs a wall in its way and drops off a quay, where the flat shear can't.
5
+ // No Node imports: the browser bundles this file.
6
+ import type { Geometry } from './geometry.ts';
7
+
8
+ /** A 1.62 m person is personPx tall: the scale's unit (PERSON_M in src/scene/scale.ts). */
9
+ const PERSON_M = 1.62;
10
+ /** depth.png's layout (DEPTH_SCALE and DEPTH_UNIT in src/scene/depthmap.ts). */
11
+ export const DEPTH_SCALE = 2;
12
+ export const DEPTH_UNIT = 16;
13
+ /** Ground within about this many metres past the feet is taken as level with them (see shadowAt). */
14
+ const CONTACT_M = 0.25;
15
+ /**
16
+ * Away from the feet, a surface that is neither a thing nor floor-like catches no shadow: floor-like is no
17
+ * steeper than FLOOR_SLOPE (metres up per metre across, a few px up the screen) and, off the floor masks,
18
+ * within FLOOR_RISE_M of the feet's level (a puddle, the water below a quay). Within NEAR_M of the feet any
19
+ * surface does (the quay's edge they stand on, a wall at their back). So a fog bank or a ship in the mist,
20
+ * which the depth model reads as a wall, and the ferry's side a floor mask took in, stay clean.
21
+ */
22
+ const FLOOR_SLOPE = 1;
23
+ const FLOOR_RISE_M = 0.5;
24
+ const NEAR_M = 0.6;
25
+
26
+ /** The depth map as floor rows below the horizon per pixel, at 1 / DEPTH_SCALE of the frame. */
27
+ export type Depth = { w: number; h: number; rows: Float32Array };
28
+
29
+ /** Decodes depth.png's pixels (RGBA, R high byte, G low byte) into rows below the horizon. */
30
+ export function decodeDepth(rgba: Uint8ClampedArray | Uint8Array, w: number, h: number): Depth {
31
+ const rows = new Float32Array(w * h);
32
+ for (let i = 0; i < w * h; i++) rows[i] = ((rgba[i * 4] << 8) | rgba[i * 4 + 1]) / DEPTH_UNIT;
33
+ return { w, h, rows };
34
+ }
35
+
36
+ /** The sun for one person standing at (x, y): where they stand in 3D, and the light's way. */
37
+ export type Sun = {
38
+ /** Camera height in metres; f is the focal length in px (any fixed value: only ratios reach the screen). */
39
+ camH: number;
40
+ f: number;
41
+ hor: number;
42
+ halfW: number;
43
+ /** The feet: across, into the picture, up (metres). */
44
+ feet: [number, number, number];
45
+ /** Horizontal unit vector the light travels along, and the one across it (pointing screen right). */
46
+ along: [number, number];
47
+ across: [number, number];
48
+ /** Rise of a sun ray per metre along the ground. */
49
+ rise: number;
50
+ };
51
+
52
+ /** The camera the depth map was measured with: what pointAt needs. */
53
+ export type View = Pick<Sun, 'camH' | 'f' | 'hor' | 'halfW'>;
54
+
55
+ export function viewOf(g: Geometry): View {
56
+ return { camH: PERSON_M / g.scale.pxPerY, f: g.frame.width, hor: g.scale.horizonY, halfW: g.frame.width / 2 };
57
+ }
58
+
59
+ /** The frame pixel a 3D point (across, into, up) is seen at: pointAt the other way. */
60
+ export function toScreen(v: View, p: [number, number, number]): [number, number] | undefined {
61
+ if (p[1] <= 0.01) return undefined;
62
+ const zn = p[1] / v.f;
63
+ return [v.halfW + p[0] / zn, v.hor + (v.camH - p[2]) / zn];
64
+ }
65
+
66
+ /** Metres (across, into, up) of the depth pixel under frame pixel (x, y); undefined past the map. */
67
+ export function pointAt(d: Depth, sun: Pick<Sun, 'camH' | 'f' | 'hor' | 'halfW'>, x: number, y: number): [number, number, number] | undefined {
68
+ const c = Math.floor(x / DEPTH_SCALE);
69
+ const r = Math.floor(y / DEPTH_SCALE);
70
+ if (c < 0 || r < 0 || c >= d.w || r >= d.h) return undefined;
71
+ const rows = d.rows[r * d.w + c];
72
+ if (rows <= 0) return undefined;
73
+ const zn = sun.camH / rows;
74
+ return [(x - sun.halfW) * zn, zn * sun.f, sun.camH - (y - sun.hor) * zn];
75
+ }
76
+
77
+ /**
78
+ * The sun for a person with feet at (x, y) and px tall. light.dir is how shadows run on screen and
79
+ * light.length how long they are per height there, as the painting shows them: both are turned into a
80
+ * direction on the ground and a sun height by projecting the ground near the feet.
81
+ */
82
+ export function sunFor(g: Geometry, d: Depth, light: NonNullable<Geometry['light']>, x: number, y: number, px: number): Sun | undefined {
83
+ const hor = g.scale.horizonY;
84
+ const base = viewOf(g);
85
+ if (y - hor < 4) return undefined;
86
+ // The feet: the depth just above them (the floor they stand on), averaged across a few px.
87
+ const near = [-3, 0, 3].map((dx) => pointAt(d, base, x + dx, y - 2)).filter((p): p is [number, number, number] => !!p);
88
+ if (!near.length) return undefined;
89
+ const feet = [0, 1, 2].map((i) => near.reduce((s, p) => s + p[i], 0) / near.length) as [number, number, number];
90
+ // A point on the feet's level seen at screen (sx, sy), and back.
91
+ const drop = base.camH - feet[2];
92
+ const ground = (sx: number, sy: number): [number, number] => {
93
+ const zn = drop / Math.max(0.5, sy - hor);
94
+ return [(sx - base.halfW) * zn, zn * base.f];
95
+ };
96
+ const screen = (gx: number, gz: number): [number, number] => {
97
+ const zn = gz / base.f;
98
+ return [base.halfW + gx / zn, hor + drop / zn];
99
+ };
100
+ const n = Math.hypot(light.dir[0], light.dir[1]) || 1;
101
+ const [dx, dy] = [light.dir[0] / n, light.dir[1] / n];
102
+ const at = ground(x, y);
103
+ const step = ground(x + dx * 8, Math.max(hor + 1, y + dy * 8));
104
+ const len = Math.hypot(step[0] - at[0], step[1] - at[1]);
105
+ if (!(len > 0)) return undefined;
106
+ const along: [number, number] = [(step[0] - at[0]) / len, (step[1] - at[1]) / len];
107
+ let across: [number, number] = [-along[1], along[0]];
108
+ if (across[0] < 0) across = [-across[0], -across[1]];
109
+ // How far along the ground a head's shadow lands to sit length * px away on screen.
110
+ const want = light.length * px;
111
+ let [lo, hi] = [0, 400];
112
+ for (let i = 0; i < 30; i++) {
113
+ const s = (lo + hi) / 2;
114
+ const gz = at[1] + along[1] * s;
115
+ const [sx, sy] = gz > 0.01 ? screen(at[0] + along[0] * s, gz) : [Infinity, hor];
116
+ if (Math.hypot(sx - x, sy - y) < want) lo = s;
117
+ else hi = s;
118
+ }
119
+ const reach = (lo + hi) / 2;
120
+ if (!(reach > 0.01)) return undefined;
121
+ return { ...base, feet, along, across, rise: PERSON_M / reach };
122
+ }
123
+
124
+ /** The figure's silhouette (alpha only): w x h px, feet at (ox, oy), px per metre. */
125
+ export type Silhouette = { alpha: Uint8ClampedArray; w: number; h: number; ox: number; oy: number; perM: number };
126
+
127
+ /**
128
+ * Where a person's shadow can land, at the depth map's size: 2 on the things standing in the place (owner:
129
+ * their pixels, as thingShadows takes them), 1 on the floors (geometry.zone, else the walk cells), 0 on the
130
+ * rest, which catches it only where it looks like floor or right by the feet (see shadowAt). The depth model
131
+ * reads a fog bank or a ship in the mist as a wall a few metres away, and a shadow on it is a ghost in the air.
132
+ */
133
+ export function catchers(d: Depth, g: Geometry, owner?: Uint8Array): Uint8Array {
134
+ const on = owner ? owner.map((o) => (o ? 2 : 0)) : new Uint8Array(d.w * d.h);
135
+ const grid = g.zone ?? g.walk;
136
+ for (let r = 0; r < d.h; r++) {
137
+ const row = grid[Math.floor((r * DEPTH_SCALE + 1) / g.cell)];
138
+ if (!row) continue;
139
+ for (let c = 0; c < d.w; c++) {
140
+ const ch = row[Math.floor((c * DEPTH_SCALE + 1) / g.cell)];
141
+ if (ch && ch !== '.' && !on[r * d.w + c]) on[r * d.w + c] = 1;
142
+ }
143
+ }
144
+ return on;
145
+ }
146
+
147
+ /**
148
+ * Whether something stands between the 3D point p and the card the way the light comes (`past` metres
149
+ * back along it, rising with it): the ray is walked on the depth map, and a step that goes behind a surface
150
+ * seen there, by less than `thick` metres, is blocked. So a shadow that climbs a booth stops on it and
151
+ * never shows again on the floor behind the booth.
152
+ */
153
+ function blocked(d: Depth, sun: Sun, p: [number, number, number], past: number, thick = 1.2): boolean {
154
+ const e: [number, number, number] = [p[0] - sun.along[0] * past, p[1] - sun.along[1] * past, p[2] + past * sun.rise];
155
+ const [ps, es] = [toScreen(sun, p), toScreen(sun, e)];
156
+ if (!ps || !es) return false;
157
+ const n = Math.min(32, Math.max(4, Math.ceil(Math.hypot(es[0] - ps[0], es[1] - ps[1]) / (2 * DEPTH_SCALE))));
158
+ for (let k = 1; k < n; k++) {
159
+ const t = k / n;
160
+ // Not the point's own surface, nor the ground at the feet the card stands on.
161
+ if (t * past < 0.15 || (1 - t) * past < 0.3) continue;
162
+ const q: [number, number, number] = [p[0] + (e[0] - p[0]) * t, p[1] + (e[1] - p[1]) * t, p[2] + (e[2] - p[2]) * t];
163
+ const s = toScreen(sun, q);
164
+ if (!s) continue;
165
+ const surf = pointAt(d, sun, s[0], s[1]);
166
+ if (surf && q[1] > surf[1] + 0.1 && q[1] < surf[1] + thick) return true;
167
+ }
168
+ return false;
169
+ }
170
+
171
+ /**
172
+ * How much of the person's shadow falls on frame pixel (x, y), 0..1. With `on` (catchers) it lands on the
173
+ * things and floors, not on the backdrop. Anything standing between the pixel and the person keeps it off.
174
+ */
175
+ export function shadowAt(d: Depth, sun: Sun, sil: Silhouette, x: number, y: number, on?: Uint8Array): number {
176
+ const p = pointAt(d, sun, x, y);
177
+ if (!p) return 0;
178
+ const [rx, rz] = [p[0] - sun.feet[0], p[1] - sun.feet[1]];
179
+ const catches = on ? on[Math.floor(y / DEPTH_SCALE) * d.w + Math.floor(x / DEPTH_SCALE)] : 2;
180
+ if (catches !== 2 && Math.hypot(rx, rz) > NEAR_M) {
181
+ if (!catches && Math.abs(p[2] - sun.feet[2]) > FLOOR_RISE_M) return 0;
182
+ const q = pointAt(d, sun, x, y - 8 * DEPTH_SCALE);
183
+ if (q && q[2] - p[2] > FLOOR_SLOPE * Math.hypot(q[0] - p[0], q[1] - p[1])) return 0;
184
+ }
185
+ // How far past the person this point is, the way the light goes: points before the card are lit.
186
+ const past = rx * sun.along[0] + rz * sun.along[1];
187
+ if (past <= 0.02) return 0;
188
+ const side = rx * sun.across[0] + rz * sun.across[1];
189
+ // Where on the card the ray from this point back toward the sun crosses it.
190
+ // Close to the feet the ground counts as level with them: the depth map is soft at a ledge, and a shadow
191
+ // must stay joined to the feet; farther out every step and drop shapes it.
192
+ const near = Math.min(1, Math.max(0, (past - CONTACT_M) / CONTACT_M));
193
+ const up = (p[2] - sun.feet[2]) * near * near * (3 - 2 * near) + past * sun.rise;
194
+ if (up < 0) return 0;
195
+ const sx = Math.round(sil.ox + side * sil.perM);
196
+ const sy = Math.round(sil.oy - up * sil.perM);
197
+ if (sx < 0 || sy < 0 || sx >= sil.w || sy >= sil.h) return 0;
198
+ const a = sil.alpha[sy * sil.w + sx] / 255;
199
+ return a > 0 && blocked(d, sun, p, past) ? 0 : a;
200
+ }
201
+
202
+ /**
203
+ * The shadows painted things (a tent, a bench) cast from a fire or a lamp standing on the floor at frame
204
+ * pixel `at`, its light `up` metres high and `shift` metres to the side. Worked out on the depth map: each
205
+ * floor point looks back along its ray to the light and lies in shadow where the ray passes behind a
206
+ * caster's pixels (owner, at the depth map's size), within `thick` metres of the surface seen there. Every
207
+ * `cell` depth pixels one value 0..1, fading out `reach` metres from the light like the light itself.
208
+ */
209
+ export function thingShadows(
210
+ d: Depth,
211
+ g: Geometry,
212
+ owner: Uint8Array,
213
+ at: [number, number],
214
+ { up, shift = 0, cell = 2, reach = 4 * PERSON_M, thick = 1.2 }: { up: number; shift?: number; cell?: number; reach?: number; thick?: number },
215
+ ): { w: number; h: number; cell: number; alpha: Float32Array } {
216
+ const v = viewOf(g);
217
+ const S = DEPTH_SCALE;
218
+ const [w, h] = [Math.ceil(d.w / cell), Math.ceil(d.h / cell)];
219
+ const alpha = new Float32Array(w * h);
220
+ // The light on the floor plane under it, raised.
221
+ const zn = v.camH / Math.max(1, at[1] - v.hor);
222
+ const L: [number, number, number] = [(at[0] - v.halfW) * zn + shift, zn * v.f, up];
223
+ const Ls = toScreen(v, L);
224
+ if (!Ls) return { w, h, cell, alpha };
225
+ for (let r = 0; r < h; r++) for (let c = 0; c < w; c++) {
226
+ const [fx, fy] = [(c * cell + cell / 2) * S, (r * cell + cell / 2) * S];
227
+ const dc = Math.floor(fx / S);
228
+ const dr = Math.floor(fy / S);
229
+ // A caster's own pixels are drawn over its shadow anyway.
230
+ if (dr >= d.h || dc >= d.w || owner[dr * d.w + dc]) continue;
231
+ const p = pointAt(d, v, fx, fy);
232
+ if (!p || p[2] > up - 0.05) continue;
233
+ const dist = Math.hypot(L[0] - p[0], L[1] - p[1]);
234
+ if (dist >= reach || dist < 0.1) continue;
235
+ const n = Math.min(64, Math.max(6, Math.ceil(Math.hypot(Ls[0] - fx, Ls[1] - fy) / (1.5 * S))));
236
+ for (let k = 1; k < n; k++) {
237
+ const t = k / n;
238
+ // Not the floor point's own surface: a few cm along before anything counts.
239
+ if (t * dist < 0.08) continue;
240
+ const q: [number, number, number] = [p[0] + (L[0] - p[0]) * t, p[1] + (L[1] - p[1]) * t, p[2] + (L[2] - p[2]) * t];
241
+ const s = toScreen(v, q);
242
+ if (!s) continue;
243
+ const [qc, qr] = [Math.floor(s[0] / S), Math.floor(s[1] / S)];
244
+ if (qc < 0 || qr < 0 || qc >= d.w || qr >= d.h || !owner[qr * d.w + qc]) continue;
245
+ const surf = pointAt(d, v, s[0], s[1]);
246
+ if (surf && q[1] > surf[1] - 0.05 && q[1] < surf[1] + thick) {
247
+ alpha[r * w + c] = 1 - dist / reach;
248
+ break;
249
+ }
250
+ }
251
+ }
252
+ return { w, h, cell, alpha };
253
+ }
@@ -0,0 +1,51 @@
1
+ import type { LipSync } from './lipsync.ts';
2
+ // What the play page gets to mix a world's sound, and the mixing rules as pure functions.
3
+ // No Node imports: the browser bundles this file.
4
+
5
+ export type SoundKind = 'music' | 'bed' | 'loop' | 'step' | 'shot';
6
+
7
+ export type SoundData = {
8
+ /** Sound id → file (relative to the world) and how loud it sits. */
9
+ files: Record<string, { file: string; kind: SoundKind; level: number; slices?: Array<[number, number]> }>;
10
+ music?: string;
11
+ places: Record<string, {
12
+ bed?: string;
13
+ loops: Array<{ id: string; thing: string; level: number; when: string[] }>;
14
+ /** Footstep sound id per floor index (the digit in geometry.zone). */
15
+ zones: Array<string | null>;
16
+ }>;
17
+ /** Trigger ("take", "click", "exit:cliff-path", "use:cold-lamp", "talk", "ending") → sound id. */
18
+ shots: Record<string, string>;
19
+ /** Spoken lines (diorama voices): the text the page says → its recording. */
20
+ lines?: Record<string, { file: string; seconds: number; speaker?: string; lipsync?: LipSync }>;
21
+ /** Speaker-specific recordings; older worlds can still use lines keyed only by text. */
22
+ bySpeaker?: Record<string, NonNullable<SoundData['lines']>>;
23
+ };
24
+
25
+ export function spokenLine(sound: SoundData | undefined, text: string, speaker?: string) {
26
+ return (speaker ? sound?.bySpeaker?.[speaker]?.[text] : undefined) ?? sound?.lines?.[text];
27
+ }
28
+
29
+ /** Mixer gain per kind and level, before distance: the bed sits under loops, music under both. */
30
+ export const BUS: Record<SoundKind, number> = { music: 0.32, bed: 0.55, loop: 0.8, step: 0.55, shot: 0.85 };
31
+ const LEVEL = [0, 0.55, 1, 1.5];
32
+
33
+ export const levelGain = (kind: SoundKind, level: number) => BUS[kind] * (LEVEL[level] ?? 1);
34
+
35
+ /**
36
+ * Where a thing's loop sits for a hero at (hx, hy): pan from where the thing is on screen,
37
+ * gain falling off with distance in hero heights, never fully silent so the place still has it.
38
+ */
39
+ export function placeSound(thingX: number, thingY: number, hx: number, hy: number, viewX: number, width: number, heroPx: number): { pan: number; gain: number } {
40
+ const pan = Math.max(-0.8, Math.min(0.8, (thingX - viewX) / (width / 2)));
41
+ const d = Math.hypot(thingX - hx, (thingY - hy) * 1.6) / Math.max(40, heroPx);
42
+ const gain = 0.2 + 0.8 / (1 + (d / 2.5) ** 2);
43
+ return { pan: Math.round(pan * 1000) / 1000, gain: Math.round(gain * 1000) / 1000 };
44
+ }
45
+
46
+ /** The floor index under (x, y) from geometry.zone, or -1. */
47
+ export function zoneAt(zone: string[] | undefined, cell: number, x: number, y: number): number {
48
+ const row = zone?.[Math.floor(y / cell)];
49
+ const ch = row?.[Math.floor(x / cell)];
50
+ return ch && ch >= '0' && ch <= '9' ? Number(ch) : -1;
51
+ }
@@ -0,0 +1,58 @@
1
+ // weather.json: what falls on a place and where water drips, written by the weather step, read by the page.
2
+ // Rain and snow fall over the whole frame (the "all" pseudo-mask); drops ring on water and puddles, splash on
3
+ // the floors, and drip from the edges Spark names (eaves, a gutter, a cave ceiling).
4
+ // No Node imports: the browser bundles this file.
5
+ import { z } from 'zod';
6
+
7
+ const XY = z.tuple([z.number(), z.number()]);
8
+
9
+ export const Weather = z.object({
10
+ /** What falls from the sky now; none for a dry place (drips may still run, a leaking pipe in a cave). */
11
+ fall: z.enum(['none', 'rain', 'snow']),
12
+ /** 0.3 drizzle, 0.6 rain, 1 a downpour (or a blizzard). */
13
+ strength: z.number().min(0).max(1),
14
+ /** How far the fall slants: px across per px down, + to the right. */
15
+ wind: z.number().min(-1).max(1),
16
+ /** How much mist drifts over the place, 0 clear .. 1 thick fog. */
17
+ fog: z.number().min(0).max(1).default(0),
18
+ /** Edges water drips from: points along the edge, and where each drop lands (the floor row under it). */
19
+ drips: z.array(z.object({ phrase: z.string(), from: z.array(XY), to: z.array(z.number()) })).default([]),
20
+ /** Mask (relative to the place folder) of water and puddles, where drops ring. */
21
+ ripples: z.string().optional(),
22
+ });
23
+ export type Weather = z.infer<typeof Weather>;
24
+
25
+ /** How the author wants a place's weather: places/<key>/weather-wish.json, set by the weather_set action, kept across rebuilds. */
26
+ export const WeatherWish = z.object({
27
+ /** auto: as Spark reads the picture and the mood; otherwise this, whatever the picture says. */
28
+ fall: z.enum(['auto', 'none', 'rain', 'snow']).default('auto'),
29
+ strength: z.number().min(0).max(1).optional(),
30
+ /** Mist the author wants, 0..1; unset: as Spark reads it. */
31
+ fog: z.number().min(0).max(1).optional(),
32
+ /** What the author said ("water drips from the tavern sign"), handed to Spark. */
33
+ hint: z.string().optional(),
34
+ });
35
+ export type WeatherWish = z.infer<typeof WeatherWish>;
36
+
37
+ /** Whether a place's words say something falls (rain, snow, a storm), so its painting leaves the fall to the page. */
38
+ export function fallsIn(text: string): boolean {
39
+ return /\b(rain\w*|drizzl\w*|downpour|storm\w*|snow\w*|sleet|blizzard|shower\w*|deszcz\w*|ulew\w*|śnieg\w*|mżawk\w*)/i.test(text);
40
+ }
41
+
42
+ /** A small seeded random source (mulberry32), so a recorded run rains the same every time. */
43
+ export function seeded(seed: number): () => number {
44
+ let a = seed >>> 0;
45
+ return () => {
46
+ a = (a + 0x6d2b79f5) >>> 0;
47
+ let t = a;
48
+ t = Math.imul(t ^ (t >>> 15), t | 1);
49
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
50
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
51
+ };
52
+ }
53
+
54
+ export function hashSeed(text: string): number {
55
+ let h = 2166136261;
56
+ for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
57
+ return h >>> 0;
58
+ }