@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
package/app/web/pad.ts ADDED
@@ -0,0 +1,174 @@
1
+ // Gamepads on the play page: read every frame through the Gamepad API, so a child can play with a pad alone.
2
+ // One pad or six, they all steer the same hero; a press is reported once, on the frame it goes down.
3
+
4
+ export type Point = [number, number];
5
+ /** What a press means on the page, whatever pad and layout it came from. */
6
+ export type PadButton = 'use' | 'back' | 'next' | 'prev' | 'start' | 'up' | 'down' | 'left' | 'right';
7
+
8
+ /** Where the stick or d-pad points, one of eight ways, or null; and the presses that began this frame. */
9
+ export type PadFrame = { dir: Point | null; pressed: PadButton[]; pads: number };
10
+
11
+ // The standard layout (Xbox names): A 0, B 1, X 2, Y 3, LB 4, RB 5, LT 6, RT 7, select 8, start 9, d-pad 12-15.
12
+ const STANDARD: Array<[number, PadButton]> = [[0, 'use'], [1, 'back'], [2, 'prev'], [3, 'next'], [4, 'prev'], [5, 'next'], [6, 'prev'], [7, 'next'], [9, 'start'], [12, 'up'], [13, 'down'], [14, 'left'], [15, 'right']];
13
+ // Another pad the browser does not map: the face buttons come first, in no fixed order, so the two lowest both use (a
14
+ // child presses whichever), the rest go through the bag (its last step is empty hands), and the highest start.
15
+ const LOOSE: Array<[number, PadButton]> = [[0, 'use'], [1, 'use'], [2, 'next'], [3, 'next'], [4, 'next'], [5, 'prev'], [6, 'prev'], [7, 'next'], [8, 'prev'], [9, 'next'], [10, 'start'], [11, 'start']];
16
+ const DEAD = 0.4;
17
+ const IDLE_S = 25;
18
+
19
+ const seen = new Map<number, Set<PadButton>>();
20
+
21
+ /** Reads every connected pad once, by gamepad index; call it once a frame (presses are edges since the last call). */
22
+ export function readEachPad(): Map<number, PadFrame> {
23
+ const out = new Map<number, PadFrame>();
24
+ for (const pad of navigator.getGamepads?.() ?? []) {
25
+ if (!pad?.connected) continue;
26
+ const down = new Set<PadButton>();
27
+ // The 8BitDo Micro (D-input or the Mac's MFi profile) is seen as a standard pad, its buttons by face position.
28
+ const micro = microButtons(pad);
29
+ const map = pad.mapping === 'standard' || micro ? STANDARD : LOOSE;
30
+ for (const [i, b] of map) if (micro ? micro[i] : pad.buttons[i]?.pressed) down.add(b);
31
+ // The left stick (the Micro has none), then the d-pad as buttons, then a d-pad reported as one "hat" axis.
32
+ let [x, y] = micro ? [0, 0] : [pad.axes[0] ?? 0, pad.axes[1] ?? 0];
33
+ if (Math.hypot(x, y) < DEAD) [x, y] = [0, 0];
34
+ if (down.has('left')) x = -1;
35
+ if (down.has('right')) x = 1;
36
+ if (down.has('up')) y = -1;
37
+ if (down.has('down')) y = 1;
38
+ const hat = pad.mapping === 'standard' || micro ? undefined : hatDir(pad.axes[9]);
39
+ if (hat) [x, y] = hat;
40
+ // Directions as presses too, for moving through answer cards.
41
+ if (x < -0.5) down.add('left');
42
+ if (x > 0.5) down.add('right');
43
+ if (y < -0.5) down.add('up');
44
+ if (y > 0.5) down.add('down');
45
+ const was = seen.get(pad.index) ?? new Set();
46
+ seen.set(pad.index, down);
47
+ out.set(pad.index, { dir: Math.hypot(x, y) < DEAD ? null : eighth(x, y), pressed: [...down].filter((b) => !was.has(b)), pads: 1 });
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /** All pads (or only pad `only`) as one: their directions summed, their presses together. */
53
+ export function readPads(only?: number): PadFrame {
54
+ const each = readEachPad();
55
+ const pressed = new Set<PadButton>();
56
+ let [dx, dy] = [0, 0];
57
+ for (const [index, f] of each) {
58
+ if (only !== undefined && index !== only) continue;
59
+ for (const b of f.pressed) pressed.add(b);
60
+ if (f.dir) [dx, dy] = [dx + f.dir[0], dy + f.dir[1]];
61
+ }
62
+ const dir: Point | null = Math.hypot(dx, dy) < DEAD ? null : eighth(dx, dy);
63
+ return { dir, pressed: [...pressed], pads: each.size };
64
+ }
65
+
66
+ // The 8BitDo Micro, as the gamedev.pl site projects it (apps/web/src/gamepad.ts): standard slot to raw button.
67
+ const MICRO_ORDER = {
68
+ hid: [1, 0, 4, 3, 6, 7, 8, 9, 10, 11, 13, 14, -1, -1, -1, -1, 12],
69
+ mfi: [0, 1, 5, 6, 11, 13, 12, 14, 4, 3, -1, -1, 10, 7, 8, 9, 2],
70
+ } as const;
71
+
72
+ /** An 8BitDo Micro's buttons in the standard slots (A 0, B 1, ... d-pad 12-15); undefined for any other pad. */
73
+ export function microButtons(pad: Pick<Gamepad, 'id' | 'mapping' | 'axes' | 'buttons'>): boolean[] | undefined {
74
+ if (pad.mapping === 'standard') return undefined;
75
+ if (!/^8bitdo micro(?: gamepad)?(?:$|[ (])/i.test(pad.id) && !/vendor:\s*2dc8\s+product:\s*9020/i.test(pad.id)) return undefined;
76
+ // D-input reports 10 axes and 16 buttons; the Mac's MFi profile no axes and 15 buttons.
77
+ const layout = pad.axes.length === 10 && pad.buttons.length === 16 ? 'hid' : pad.axes.length === 0 && pad.buttons.length === 15 ? 'mfi' : undefined;
78
+ if (!layout) return undefined;
79
+ const slots = MICRO_ORDER[layout].map((n) => n >= 0 && !!pad.buttons[n]?.pressed);
80
+ if (layout === 'hid') {
81
+ // Chrome on a Mac puts the cross on the first two axes; others use the hat.
82
+ const cross: number[] = [];
83
+ if (pad.axes[0] < -0.5) cross.push(14);
84
+ if (pad.axes[0] > 0.5) cross.push(15);
85
+ if (pad.axes[1] < -0.5) cross.push(12);
86
+ if (pad.axes[1] > 0.5) cross.push(13);
87
+ for (const n of cross.length ? cross : microHat(pad.axes[9])) slots[n] = true;
88
+ }
89
+ return slots;
90
+ }
91
+
92
+ /** A hat value as d-pad slots (12 up, 13 down, 14 left, 15 right); none when let go (the value is then outside -1..1). */
93
+ function microHat(value: number | undefined): number[] {
94
+ if (value === undefined || !Number.isFinite(value) || value < -1.01 || value > 1.01) return [];
95
+ const step = (value + 1) * 3.5;
96
+ const n = Math.round(step);
97
+ if (Math.abs(step - n) > 0.08 || n < 0 || n > 7) return [];
98
+ return [[12], [12, 15], [15], [13, 15], [13], [13, 14], [14], [12, 14]][n];
99
+ }
100
+
101
+ /** The nearest of the eight ways (straight or diagonal), so a wobbly thumb does not turn the hero every frame. */
102
+ function eighth(x: number, y: number): Point {
103
+ const a = Math.round(Math.atan2(y, x) / (Math.PI / 4)) * (Math.PI / 4);
104
+ return [Math.round(Math.cos(a)), Math.round(Math.sin(a))];
105
+ }
106
+
107
+ /** A hat axis: -1 up, then clockwise in steps of 2/7 to 1 up-left; anything above that is let go. */
108
+ function hatDir(v: number | undefined): Point | null {
109
+ if (v === undefined || v > 1.05 || v < -1.05) return null;
110
+ const step = Math.round((v + 1) / (2 / 7));
111
+ return ([[0, -1], [1, -1], [1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1]] as Point[])[step] ?? null;
112
+ }
113
+
114
+ /** Colours for players 1 to 6, the same on the badge and the hand-over card; stickers on the pads can match. */
115
+ export const PLAYER_COLOURS = ['#d9534f', '#3b7dd8', '#3fa34d', '#e8b923', '#9b59b6', '#e67e22'];
116
+
117
+ /**
118
+ * Pads take turns with one hero (?pads=turns): only the pad whose turn it is steers. The turn passes to the next
119
+ * connected pad after a conversation ends, or after `seconds` from the player's first press (a player who never
120
+ * presses loses the turn after IDLE_S), never in the middle of a line or a talk.
121
+ */
122
+ export class Turns {
123
+ /** The gamepad index whose turn it is; undefined until a pad is pressed. */
124
+ active: number | undefined;
125
+ /** When the turn began, and when its player first pressed (undefined until then). */
126
+ private given = 0;
127
+ private started: number | undefined;
128
+
129
+ constructor(private readonly seconds: number) {}
130
+
131
+ /** Connected pads, by index. */
132
+ connected(): number[] {
133
+ return [...(navigator.getGamepads?.() ?? [])].filter((p): p is Gamepad => !!p?.connected).map((p) => p.index).sort((a, b) => a - b);
134
+ }
135
+
136
+ /** Who plays now: keeps the turn while its pad is there, else the first pad that presses anything takes it. */
137
+ current(now: number): number | undefined {
138
+ const pads = this.connected();
139
+ if (this.active !== undefined && pads.includes(this.active)) return this.active;
140
+ this.active = undefined;
141
+ for (const p of navigator.getGamepads?.() ?? []) {
142
+ if (p?.connected && p.buttons.some((b) => b.pressed)) {
143
+ [this.active, this.given, this.started] = [p.index, now, now];
144
+ break;
145
+ }
146
+ }
147
+ return this.active;
148
+ }
149
+
150
+ /** The player whose turn it is pressed or steered. */
151
+ touched(now: number) {
152
+ this.started ??= now;
153
+ }
154
+
155
+ /** Whether the time is up (only counted while more than one pad is there). */
156
+ due(now: number): boolean {
157
+ if (this.active === undefined || this.connected().length < 2) return false;
158
+ return this.started === undefined ? now - this.given > IDLE_S * 1000 : now - this.started > this.seconds * 1000;
159
+ }
160
+
161
+ /** Passes the turn to the next connected pad; returns it when it changed. */
162
+ pass(now: number): number | undefined {
163
+ const pads = this.connected();
164
+ if (pads.length < 2 || this.active === undefined) return undefined;
165
+ const next = pads.find((i) => i > this.active!) ?? pads[0];
166
+ [this.active, this.given, this.started] = [next, now, undefined];
167
+ return next;
168
+ }
169
+
170
+ /** "Player N" is the pad's place among the connected ones, so it stays 1 to 6 whatever indexes the browser gave. */
171
+ player(index: number): number {
172
+ return this.connected().indexOf(index) + 1 || index + 1;
173
+ }
174
+ }
@@ -0,0 +1,79 @@
1
+ import { ReactionTriggers, type ReactionEvent, type Reactions } from '../src/play/reactions.ts';
2
+ import { personPx, type Geometry } from '../src/play/geometry.ts';
3
+ import { hashSeed, seeded } from '../src/play/weather.ts';
4
+
5
+ type Particle = { x: number; y: number; depth: number; vx: number; vy: number; size: number; life: number; age: number; angle: number; spin: number; kind: ReactionEvent['burst']['kind']; colour: string; alpha: number };
6
+ type Rect = [number, number, number, number];
7
+ const LIMIT = 96;
8
+
9
+ /** Small pooled-in-count particles, drawn in the scene's depth order, below UI and weather. */
10
+ export class ReactionFx {
11
+ readonly triggers: ReactionTriggers;
12
+ readonly particles: Particle[] = [];
13
+ readonly counts = { step: 0, enter: 0, interaction: 0 };
14
+ private rand: () => number;
15
+ private textures = new Map<string, HTMLCanvasElement>();
16
+ constructor(rules: Reactions, private readonly g: Geometry, key: string, private readonly reduced = false) {
17
+ this.rand = seeded(hashSeed(`reactions:${key}`));
18
+ this.triggers = new ReactionTriggers(rules, g, (e) => this.spawn(e));
19
+ }
20
+ reset() { this.particles.length = 0; this.triggers.reset(); }
21
+ private spawn(e: ReactionEvent) {
22
+ this.counts[e.trigger]++;
23
+ if (this.reduced) return;
24
+ const body = e.actor as { yaw?: number; phase?: number; figure?: { height?: number } };
25
+ const h = personPx(this.g, e.depth) * (e.trigger === 'interaction' ? 1 : (body.figure?.height ?? 1.62) / 1.62);
26
+ const scale = e.trigger === 'step' ? 0.85 : 1.15;
27
+ const strength = e.burst.strength;
28
+ const n = Math.ceil((e.burst.kind === 'dust' ? 5 : 7) * (0.5 + strength) * scale);
29
+ const foot = e.trigger === 'step' ? Math.cos(body.yaw ?? 0) * h * 0.035 * ((e.step ?? 0) % 2 ? -1 : 1) : 0;
30
+ for (let i = 0; i < n; i++) {
31
+ if (this.particles.length >= LIMIT) this.particles.shift();
32
+ const dust = e.burst.kind === 'dust';
33
+ this.particles.push({ x: e.x + foot + (this.rand() - .5) * h * .025, y: e.y, depth: e.depth,
34
+ vx: (this.rand() - .5) * h * (dust ? .22 : .28), vy: -h * (dust ? .045 + this.rand() * .045 : .12 + this.rand() * .17),
35
+ size: h * (dust ? .036 + this.rand() * .0225 : .008 + this.rand() * .01) * scale,
36
+ life: dust ? .65 + this.rand() * .3 : .45 + this.rand() * .3, age: 0,
37
+ angle: this.rand() * Math.PI, spin: (this.rand() - .5) * 7,
38
+ kind: e.burst.kind, colour: e.burst.colour, alpha: (dust ? .48 + .2 * strength : .65 + .25 * strength) });
39
+ }
40
+ }
41
+ update(dt: number) {
42
+ this.triggers.update(dt);
43
+ for (let i = this.particles.length - 1; i >= 0; i--) {
44
+ const p = this.particles[i];
45
+ p.age += dt;
46
+ if (p.age >= p.life) { this.particles.splice(i, 1); continue; }
47
+ p.x += p.vx * dt; p.y += p.vy * dt; p.angle += p.spin * dt;
48
+ if (p.kind !== 'dust' && p.kind !== 'sparks') p.vy += personPx(this.g, p.depth) * .5 * dt;
49
+ }
50
+ this.particles.sort((a, b) => a.depth - b.depth);
51
+ }
52
+ private blob(colour: string) {
53
+ let c = this.textures.get(colour);
54
+ if (c) return c;
55
+ c = Object.assign(document.createElement('canvas'), { width: 48, height: 48 });
56
+ const ctx = c.getContext('2d')!, grad = ctx.createRadialGradient(24, 24, 1, 24, 24, 24);
57
+ // A readable body with a soft edge: a tiny Gaussian vanished against detailed painted floors.
58
+ const shade = '#' + colour.slice(1).match(/../g)!.map((v) => Math.round(parseInt(v, 16) * .74).toString(16).padStart(2, '0')).join('');
59
+ grad.addColorStop(0, colour); grad.addColorStop(.4, colour);
60
+ grad.addColorStop(.7, shade + 'bb'); grad.addColorStop(1, shade + '00');
61
+ ctx.fillStyle = grad; ctx.fillRect(0, 0, 48, 48);
62
+ this.textures.set(colour, c); return c;
63
+ }
64
+ draw(ctx: CanvasRenderingContext2D, lo: number, hi: number, shift: (y: number) => [number, number], occlude: (r: Rect, depth: number) => void) {
65
+ for (const p of this.particles) {
66
+ if (p.depth < lo || p.depth >= hi) continue;
67
+ const t = p.age / p.life, [sx, sy] = shift(p.depth), x = p.x + sx, y = p.y + sy;
68
+ const r = p.size * (p.kind === 'dust' ? .6 + t : 1);
69
+ ctx.save(); ctx.translate(x, y); ctx.rotate(p.angle);
70
+ ctx.globalAlpha = p.alpha * Math.min(1, t * 16) * (1 - t * t);
71
+ ctx.fillStyle = p.colour;
72
+ if (p.kind === 'dust') ctx.drawImage(this.blob(p.colour), -r, -r * .6, 2 * r, 1.2 * r);
73
+ else if (p.kind === 'leaves') { ctx.beginPath(); ctx.ellipse(0, 0, r, r * .4, 0, 0, Math.PI * 2); ctx.fill(); }
74
+ else { ctx.beginPath(); ctx.ellipse(0, 0, r * .6, p.kind === 'splash' ? r : r * .6, 0, 0, Math.PI * 2); ctx.fill(); }
75
+ ctx.restore();
76
+ occlude([x - r * 1.3, y - r * 1.3, x + r * 1.3, y + r * 1.3], p.depth);
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,36 @@
1
+ // A walk-cycle (or talking) sheet of the 3D figure for `diorama sprites`: one facing, the frames in a grid on a
2
+ // transparent canvas, each figure's feet at the same spot of its cell. Muse paints over it later.
3
+ import { Figure3D, type ModelInfo } from './hero3d.ts';
4
+
5
+ export type SheetAsk = { info: ModelInfo; base: string; yaw: number; cols: number; rows: number; width: number; height: number; feetAt: number; px: number; elevation: number;
6
+ /** Talking: the gesture (index into src/play/gestures.ts) for each cell, in order; cells past the end stay empty. */
7
+ gestures?: number[];
8
+ /** Walk cycle length, independent of spare grid cells. Omitted: fill the grid (legacy guides). */
9
+ frames?: number;
10
+ /** Append a standing pose after the walk; remaining grid cells stay transparent. */
11
+ stand?: boolean;
12
+ /** Plain grey clay with one key light instead of the painted textures. */
13
+ plain?: boolean };
14
+
15
+ let figure: Promise<Figure3D> | undefined;
16
+ const CLAY = { sky: 0xffffff, ground: 0x777777, ambient: 1.1, key: { dir: [-1, 2, 3] as [number, number, number], color: 0xffffff, intensity: 2.2 } };
17
+
18
+ (window as unknown as { renderSheet: (a: SheetAsk) => Promise<string> }).renderSheet = async (a) => {
19
+ // UV textures already carry their painted detail; the guide's heavy painterly filter blurs faces.
20
+ figure ??= Figure3D.load(a.info, (f) => `${a.base}/${f}`, a.info.texture === 'model' ? 0 : 2);
21
+ const fig = await figure;
22
+ if (a.plain) fig.plain();
23
+ const out = document.createElement('canvas');
24
+ [out.width, out.height] = [a.width, a.height];
25
+ const ctx = out.getContext('2d')!;
26
+ const [cw, ch] = [a.width / a.cols, a.height / a.rows];
27
+ const n = a.frames ?? a.cols * a.rows;
28
+ const count = a.gestures?.length ?? n + (a.stand ? 1 : 0);
29
+ if (!Number.isInteger(n) || n < 1 || count > a.cols * a.rows) throw new Error('Sprite poses do not fit the sheet');
30
+ for (let i = 0; i < count; i++) {
31
+ const c = fig.draw({ phase: i < n ? i / n : 0, walking: !a.gestures && i < n, time: 0, gesture: a.gestures?.[i], yaw: a.yaw, elevation: a.elevation, px: a.px, light: a.plain ? CLAY : undefined });
32
+ const [x, y] = [(i % a.cols) * cw + cw / 2, Math.floor(i / a.cols) * ch + ch * a.feetAt];
33
+ ctx.drawImage(c, x - c.width / 2, y - c.height * fig.feetAt);
34
+ }
35
+ return out.toDataURL('image/png');
36
+ };
@@ -0,0 +1,140 @@
1
+ // Sound made on the spot for a world that has none of its own: a quiet music bed when the world has no music,
2
+ // and rain and sea when a place has no ambience but its weather rains or its painting holds water. A player who
3
+ // found a silent game asked three times where the music was; a missing or rejected take should never mean silence.
4
+
5
+ /** A, then F, C, G: a slow, kind minor loop that sits under any story without telling it how to feel. */
6
+ const CHORDS = [
7
+ [57, 60, 64],
8
+ [53, 57, 60],
9
+ [48, 52, 55],
10
+ [55, 59, 62],
11
+ ];
12
+ const BAR = 4.8;
13
+ const hz = (midi: number) => 440 * 2 ** ((midi - 69) / 12);
14
+
15
+ export class Synth {
16
+ private noise: AudioBuffer;
17
+ private rain?: { gain: GainNode };
18
+ private sea?: { gain: GainNode };
19
+ private music?: { out: GainNode; timer: number; next: number; bar: number };
20
+
21
+ constructor(private ctx: AudioContext, private musicBus: AudioNode, private master: AudioNode) {
22
+ const n = ctx.sampleRate * 3;
23
+ this.noise = ctx.createBuffer(1, n, ctx.sampleRate);
24
+ const d = this.noise.getChannelData(0);
25
+ // Pink-ish noise (Paul Kellet's filter): softer than white, so rain is not a hiss.
26
+ let [b0, b1, b2] = [0, 0, 0];
27
+ for (let i = 0; i < n; i++) {
28
+ const w = Math.random() * 2 - 1;
29
+ b0 = 0.99765 * b0 + w * 0.099046;
30
+ b1 = 0.963 * b1 + w * 0.2965164;
31
+ b2 = 0.57 * b2 + w * 1.0526913;
32
+ d[i] = (b0 + b1 + b2 + w * 0.1848) * 0.18;
33
+ }
34
+ }
35
+
36
+ private noiseThrough(filters: BiquadFilterNode[], out: AudioNode): AudioBufferSourceNode {
37
+ const src = this.ctx.createBufferSource();
38
+ src.buffer = this.noise;
39
+ src.loop = true;
40
+ let node: AudioNode = src;
41
+ for (const f of filters) node = node.connect(f);
42
+ node.connect(out);
43
+ src.start(0, Math.random() * 2);
44
+ return src;
45
+ }
46
+
47
+ /** The place's own air: rain by how hard it falls, the sea's wash rolling in and out. 0 leaves each out. */
48
+ ambience(rain: number, sea: boolean) {
49
+ const now = this.ctx.currentTime;
50
+ if (rain > 0 && !this.rain) {
51
+ const gain = this.ctx.createGain();
52
+ gain.gain.value = 0;
53
+ gain.connect(this.master);
54
+ this.noiseThrough([new BiquadFilterNode(this.ctx, { type: 'highpass', frequency: 700 }), new BiquadFilterNode(this.ctx, { type: 'lowpass', frequency: 6500 })], gain);
55
+ this.rain = { gain };
56
+ }
57
+ this.rain?.gain.gain.setTargetAtTime(rain > 0 ? 0.025 + 0.05 * rain : 0, now, 0.8);
58
+ if (sea && !this.sea) {
59
+ const gain = this.ctx.createGain();
60
+ gain.gain.value = 0;
61
+ const swell = this.ctx.createGain();
62
+ swell.gain.value = 0.6;
63
+ // Waves: the wash swells and falls back every seven seconds or so.
64
+ const lfo = new OscillatorNode(this.ctx, { frequency: 0.14 });
65
+ const depth = new GainNode(this.ctx, { gain: 0.4 });
66
+ lfo.connect(depth).connect(swell.gain);
67
+ lfo.start();
68
+ swell.connect(gain).connect(this.master);
69
+ this.noiseThrough([new BiquadFilterNode(this.ctx, { type: 'lowpass', frequency: 520, Q: 0.4 })], swell);
70
+ this.sea = { gain };
71
+ }
72
+ this.sea?.gain.gain.setTargetAtTime(sea ? 0.12 : 0, now, 0.8);
73
+ }
74
+
75
+ /** The music bed: soft pad chords and a few music-box notes over them, scheduled a little ahead. */
76
+ startMusic() {
77
+ if (this.music) return;
78
+ const out = this.ctx.createGain();
79
+ out.gain.value = 0;
80
+ out.gain.setTargetAtTime(2, this.ctx.currentTime, 2);
81
+ // A short echo for the music box, so the notes hang in the air.
82
+ const echo = new DelayNode(this.ctx, { delayTime: 0.36 });
83
+ const back = new GainNode(this.ctx, { gain: 0.32 });
84
+ const wet = new GainNode(this.ctx, { gain: 0.4 });
85
+ echo.connect(back).connect(echo);
86
+ echo.connect(wet).connect(out);
87
+ out.connect(this.musicBus);
88
+ const music = { out, timer: 0, next: this.ctx.currentTime + 0.3, bar: 0 };
89
+ const box = (midi: number, at: number, level: number) => {
90
+ const o = new OscillatorNode(this.ctx, { type: 'sine', frequency: hz(midi) });
91
+ const o2 = new OscillatorNode(this.ctx, { type: 'sine', frequency: hz(midi) * 3.01 });
92
+ const g = new GainNode(this.ctx, { gain: 0 });
93
+ const g2 = new GainNode(this.ctx, { gain: 0.12 });
94
+ o.connect(g);
95
+ o2.connect(g2).connect(g);
96
+ g.connect(out);
97
+ g.connect(echo);
98
+ g.gain.setValueAtTime(0, at);
99
+ g.gain.linearRampToValueAtTime(level, at + 0.006);
100
+ g.gain.exponentialRampToValueAtTime(0.0001, at + 1.8);
101
+ o.start(at);
102
+ o2.start(at);
103
+ o.stop(at + 1.9);
104
+ o2.stop(at + 1.9);
105
+ };
106
+ const pad = (notes: number[], at: number) => {
107
+ const lp = new BiquadFilterNode(this.ctx, { type: 'lowpass', frequency: 900 });
108
+ const g = new GainNode(this.ctx, { gain: 0 });
109
+ lp.connect(g).connect(out);
110
+ g.gain.setValueAtTime(0, at);
111
+ g.gain.linearRampToValueAtTime(0.022, at + 1.6);
112
+ g.gain.setValueAtTime(0.022, at + BAR - 0.4);
113
+ g.gain.linearRampToValueAtTime(0, at + BAR + 1.4);
114
+ for (const n of [notes[0] - 12, ...notes]) for (const detune of [-6, 6]) {
115
+ const o = new OscillatorNode(this.ctx, { type: 'triangle', frequency: hz(n), detune });
116
+ o.connect(lp);
117
+ o.start(at);
118
+ o.stop(at + BAR + 1.5);
119
+ }
120
+ };
121
+ const tick = () => {
122
+ while (music.next < this.ctx.currentTime + 0.5) {
123
+ const at = music.next;
124
+ const chord = CHORDS[music.bar % CHORDS.length];
125
+ pad(chord, at);
126
+ // Eight slots a bar, about half of them a note: chord tones an octave or two up, now and then the ninth.
127
+ for (let k = 0; k < 8; k++) {
128
+ if (Math.random() > (k % 2 ? 0.25 : 0.55)) continue;
129
+ const tones = [...chord.map((n) => n + 12), chord[0] + 14, chord[0] + 24];
130
+ box(tones[Math.floor(Math.random() * tones.length)], at + (k * BAR) / 8 + Math.random() * 0.02, 0.05 + Math.random() * 0.03);
131
+ }
132
+ music.next += BAR;
133
+ music.bar++;
134
+ }
135
+ };
136
+ tick();
137
+ music.timer = window.setInterval(tick, 250);
138
+ this.music = music;
139
+ }
140
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../tsconfig.json",
3
+ "compilerOptions": { "lib": ["ES2023", "DOM", "DOM.Iterable"], "types": [] },
4
+ "include": ["."]
5
+ }