@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,118 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Diorama</title>
7
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M3 27 L3 12 L16 4 L29 12 L29 27 Z' fill='%2324365a'/%3E%3Cpath d='M3 27 L9 18 L14 22 L20 14 L29 27 Z' fill='%23efe6d6'/%3E%3Cpath d='M3 27 L12 23 L19 26 L29 21 L29 27 Z' fill='%235b8fd6'/%3E%3Ccircle cx='22' cy='10' r='2.6' fill='%23ffd23a'/%3E%3C/svg%3E">
8
+ <style>
9
+ html, body { margin: 0; height: 100%; background: #0b0b0e; color: #e8e2d6; font: 15px/1.35 Georgia, serif; overflow: hidden; }
10
+ #stage { position: absolute; inset: 0; display: grid; place-items: center; }
11
+ /* Fills the window on the long side, so a 1080p recording gets the game 1080 px tall, not its 896 px canvas. */
12
+ canvas { width: min(100vw, calc(100vh * 1200 / 896)); height: auto; aspect-ratio: 1200 / 896; image-rendering: auto; touch-action: manipulation; -webkit-touch-callout: none; user-select: none; -webkit-user-select: none; }
13
+ /* ?fill=1: the picture covers the page, no black bands; the game slides it so the hero stays on the page. */
14
+ body.fill canvas { width: max(100vw, calc(100vh * 1200 / 896)); }
15
+ #bar { position: absolute; left: 0; right: 0; bottom: 0; padding: 8px 14px; background: linear-gradient(transparent, #000a); font-size: 14px; pointer-events: none; display: flex; justify-content: space-between; }
16
+ #bar .hint { opacity: 0.6; }
17
+ /* A shared copy (export) is for players: the world's name only, and the key list fades once the game is under way. */
18
+ body[data-shipped] #bar { gap: 24px; }
19
+ body[data-shipped] #bar #hover { display: none; }
20
+ body[data-shipped] #bar .hint { transition: opacity 1.2s; }
21
+ body[data-shipped].played #bar .hint { opacity: 0; }
22
+ /* A finger has no hover and no keys: the bar's hints only cover the picture. */
23
+ @media (hover: none) { #bar { display: none; } }
24
+ /* Held upright, a phone shows the game a third of its height: it asks to be turned, once, and can be told no. */
25
+ #turn { position: fixed; inset: 0; z-index: 10; display: none; place-items: center; align-content: center; gap: 18px; padding: 24px; background: #0b0b0ef2; text-align: center; font-size: 20px; }
26
+ #turn button { padding: 8px 18px; color: #e8e2d6; background: none; border: 1.5px solid #e8e2d688; border-radius: 10px; font-size: 15px; text-align: center; }
27
+ @media (hover: none) and (orientation: portrait) { #turn:not([hidden]) { display: grid; } }
28
+ /* The HTML over the game sits in the canvas's own 1200 x 896 frame, scaled with it, so it lines up with the picture at any window size and in recordings. */
29
+ #hud { position: absolute; left: 0; top: 0; width: 1200px; height: 896px; transform-origin: 0 0; pointer-events: none; }
30
+ #hud > * { pointer-events: auto; }
31
+ /* What only shows (a toast, a flying icon, the held cursor) never takes a click, even faded out over the answers. */
32
+ #hud > .toast, #hud > .flying, #hud > .held-cursor, #hud > #handover { pointer-events: none; }
33
+ :root { --paper: #f3e8cf; --paper-hi: #fbf4e2; --ink: #2b2118; --glow: #ffe7a8; --clay: #c9774a; }
34
+ #inventory { position: absolute; left: 50%; bottom: 22px; transform: translateX(-50%); display: flex; align-items: center; gap: 10px; padding: 10px 14px 10px 10px; background: var(--paper); border: 2.5px solid var(--ink); border-radius: 18px; box-shadow: 0 5px 0 #2b211840, 0 12px 28px #0006; transition: opacity .3s, left .3s, transform .3s; }
35
+ #inventory[hidden] { display: none; }
36
+ /* In a talk the bag steps out of the answers' way: small, faint, top left (below the turn badge when there is one). */
37
+ #inventory.quiet { opacity: .6; left: 22px; top: 22px; bottom: auto; transform: scale(.7); transform-origin: 0 0; }
38
+ #hud:has(#player:not([hidden])) #inventory.quiet { top: 80px; }
39
+ #inventory .bag { width: 46px; display: flex; justify-content: center; border-right: 2px dashed #2b211855; padding-right: 6px; }
40
+ #inventory .slots { display: flex; gap: 8px; }
41
+ #inventory .slot { width: 72px; height: 72px; padding: 0; display: grid; place-items: center; background: #e7d8b6; border: 2px solid #2b211866; border-radius: 14px; transition: transform .15s; }
42
+ #inventory .slot:hover { transform: translateY(-4px); background: #efe1c0; border-color: var(--ink); }
43
+ #inventory .slot.held { background: var(--glow); border: 3px solid var(--clay); box-shadow: 0 0 0 4px #ffd37a88; transform: translateY(-6px); }
44
+ #inventory .slot.arriving img { visibility: hidden; }
45
+ #inventory .slot img { width: 58px; height: 58px; object-fit: contain; filter: drop-shadow(0 2px 1px #2b211855); }
46
+ #inventory .slot .name { color: var(--ink); font-size: 12px; text-align: center; padding: 4px; }
47
+ .held-cursor { position: fixed; left: 0; top: 0; pointer-events: none; z-index: 5; display: flex; flex-direction: column; align-items: flex-start; }
48
+ .held-cursor[hidden] { display: none; }
49
+ .held-cursor img { width: 52px; height: 52px; object-fit: contain; margin: -10px 0 0 -10px; filter: drop-shadow(0 3px 2px #0007); }
50
+ .held-cursor img[hidden] { display: none; }
51
+ .held-cursor .label { margin: 2px 0 0 12px; padding: 4px 10px; background: #2b2118e8; color: var(--paper); border-radius: 9px; font-size: 15px; white-space: nowrap; }
52
+ .flying { position: fixed; z-index: 6; pointer-events: none; object-fit: contain; }
53
+ .toast { position: absolute; left: 50%; bottom: 122px; transform: translateX(-50%); padding: 6px 14px; background: #2b2118e8; color: var(--paper); border-radius: 10px; font-size: 17px; opacity: 0; pointer-events: none; }
54
+ /* The answers: one paper panel at the bottom centre, in the eye line under the speakers, where the bag sits when nobody talks. */
55
+ #talk { position: absolute; left: 50%; bottom: 22px; transform: translateX(-50%); display: flex; flex-direction: column; gap: 8px; width: min(600px, 90%); box-sizing: border-box; padding: 12px 14px 10px; background: #f3e8cff2; border: 2.5px solid var(--ink); border-radius: 20px; box-shadow: 0 5px 0 #2b211840, 0 14px 34px #0009; }
56
+ #talk[hidden] { display: none; }
57
+ #talk .who { display: flex; align-items: center; gap: 10px; margin-top: -40px; }
58
+ #talk .face { width: 56px; height: 56px; border-radius: 50%; background: #e9c46a; border: 2.5px solid var(--ink); overflow: hidden; }
59
+ #talk .face img { width: 140%; margin: 0 0 0 -20%; display: block; }
60
+ #talk .tag { padding: 2px 10px; background: var(--paper-hi); border: 2px solid var(--ink); border-radius: 9px; color: var(--ink); font-weight: bold; font-size: 15px; }
61
+ #talk .answer { display: flex; align-items: center; gap: 12px; padding: 10px 14px; color: var(--ink); background: var(--paper-hi); border: 2px solid var(--ink); border-radius: 14px; box-shadow: 0 4px 0 #2b211840; font-size: 19px; transition: transform .12s; }
62
+ #talk .answer:hover { background: var(--glow); border-color: var(--clay); transform: translateY(-2px); }
63
+ body.pad #talk .answer.focus, #talk.lit .answer.focus { background: var(--glow); border: 3px solid var(--clay); box-shadow: 0 0 0 4px #ffd37a88, 0 4px 0 #2b211840; transform: scale(1.02); }
64
+ #talk .leave { color: #2b2118a0; font-size: 13px; text-align: center; }
65
+ #talk .leave .pads, #talk .leave .taps, body.pad #talk .leave .keys { display: none; }
66
+ body.pad #talk .leave .pads { display: inline; }
67
+ @media (hover: none) { #talk .leave .keys, body.pad #talk .leave .pads { display: none; } #talk .leave .taps { display: inline; } }
68
+ body.talking #bar .hint { visibility: hidden; }
69
+ body.pad #ending .again { box-shadow: 0 0 0 4px #ffd37a88, 0 4px 0 #2b211840; }
70
+ #player { position: absolute; left: 18px; top: 18px; padding: 6px 16px 6px 12px; display: flex; align-items: center; gap: 10px; background: var(--paper); border: 3px solid var(--player); border-radius: 999px; color: var(--ink); font-weight: bold; font-size: 22px; box-shadow: 0 4px 0 #2b211840; pointer-events: none; }
71
+ #player::before { content: ''; width: 22px; height: 22px; border-radius: 50%; background: var(--player); }
72
+ #player[hidden], #handover[hidden] { display: none; }
73
+ #soundless { position: fixed; top: 14px; right: 14px; z-index: 6; padding: 8px 14px; background: #2b2118e8; color: #f3e8cf; border: 2px solid #e9c46a; border-radius: 12px; font-size: 16px; pointer-events: none; animation: soundless 1.6s ease-in-out infinite alternate; }
74
+ #soundless[hidden] { display: none; }
75
+ @keyframes soundless { to { border-color: #e9c46a33; } }
76
+ #handover { position: absolute; left: 50%; top: 45%; transform: translate(-50%, -50%); padding: 22px 48px; background: var(--player); color: #fff; border: 5px solid #fff; border-radius: 28px; font-size: 64px; font-weight: bold; text-shadow: 0 3px 0 #0005; box-shadow: 0 10px 30px #0008; pointer-events: none; }
77
+ #talk .key { width: 26px; height: 26px; flex: none; display: grid; place-items: center; border: 2px solid var(--ink); border-radius: 7px; background: var(--paper); font-size: 14px; }
78
+ button { font: inherit; cursor: pointer; text-align: left; }
79
+ #ending { position: absolute; inset: 0; display: grid; place-items: center; background: radial-gradient(ellipse at center, #0006, #000b); padding: 24px; }
80
+ #ending[hidden] { display: none; }
81
+ #ending .card { max-width: 560px; padding: 28px 36px 26px; text-align: center; color: var(--ink); background: var(--paper-hi); border: 3px solid var(--ink); border-radius: 22px; box-shadow: 0 7px 0 #2b211855, 0 24px 60px #000a; }
82
+ #ending .over { font-size: 14px; letter-spacing: .2em; text-transform: uppercase; color: var(--clay); margin-bottom: 6px; }
83
+ #ending h1 { font-weight: normal; font-size: 40px; line-height: 1.1; margin: 0 0 12px; }
84
+ #ending p { font-size: 19px; line-height: 1.45; margin: 0 0 16px; }
85
+ #ending .carried { display: flex; justify-content: center; gap: 8px; margin-bottom: 18px; }
86
+ #ending .carried img { width: 52px; height: 52px; object-fit: contain; padding: 4px; background: #e7d8b6; border-radius: 12px; }
87
+ #ending .again { padding: 9px 20px; background: var(--glow); border: 2.5px solid var(--clay); border-radius: 12px; box-shadow: 0 4px 0 #2b211840; color: var(--ink); font-size: 17px; text-align: center; }
88
+ /* While the world's pictures come in: three paper sheets fold up into a little stage, with a count of the pieces under it. */
89
+ #loading { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; align-content: center; gap: 22px; background: radial-gradient(ellipse at 50% 40%, #1b2236, #0b0b0e 70%); text-align: center; transition: opacity .6s; }
90
+ #loading.done { opacity: 0; pointer-events: none; }
91
+ #loading .stage { position: relative; width: 150px; height: 104px; perspective: 500px; }
92
+ #loading .sheet { position: absolute; left: 0; right: 0; bottom: 0; height: 74px; border: 2.5px solid #2b2118; border-radius: 10px 10px 4px 4px; transform-origin: 50% 100%; animation: unfold 2.4s ease-in-out infinite; }
93
+ #loading .sheet:nth-child(1) { background: #3d5a8a; clip-path: polygon(0 40%, 18% 18%, 34% 34%, 52% 6%, 70% 30%, 86% 14%, 100% 32%, 100% 100%, 0 100%); }
94
+ #loading .sheet:nth-child(2) { background: #6c90b8; height: 52px; left: 10px; right: 10px; clip-path: polygon(0 30%, 25% 0, 50% 30%, 75% 0, 100% 30%, 100% 100%, 0 100%); animation-delay: .15s; }
95
+ #loading .sheet:nth-child(3) { background: #c99a5b; height: 28px; left: 20px; right: 20px; border-radius: 6px 6px 4px 4px; animation-delay: .3s; }
96
+ #loading .lamp { position: absolute; right: 26px; top: 6px; width: 14px; height: 14px; border-radius: 50%; background: #ffe7a8; box-shadow: 0 0 18px 6px #ffd37a88; animation: lamp 1.2s ease-in-out infinite alternate; }
97
+ @keyframes unfold { 0%, 12% { transform: rotateX(-88deg); } 38%, 78% { transform: rotateX(0); } 100% { transform: rotateX(-88deg); } }
98
+ @keyframes lamp { from { opacity: .45; } to { opacity: 1; } }
99
+ #loading .what { font-size: 22px; color: #f3e8cf; }
100
+ #loading .count { font: 12px/1 ui-monospace, Menlo, monospace; letter-spacing: .12em; text-transform: uppercase; color: #e8e2d699; }
101
+ #loading .bar { width: 180px; height: 4px; border-radius: 2px; background: #e8e2d622; overflow: hidden; }
102
+ #loading .bar i { display: block; height: 100%; width: 0; background: #e9c46a; transition: width .3s; }
103
+ #loading.failed .stage, #loading.failed .bar { display: none; }
104
+ /* A way back to the page the world was shared from (diorama export --home <url>). */
105
+ #home { position: fixed; top: 12px; right: 12px; z-index: 7; padding: 5px 12px; color: #f3e8cf; background: #0b0b0e99; border: 1.5px solid #e8e2d644; border-radius: 999px; font-size: 14px; text-decoration: none; opacity: .7; transition: opacity .2s; }
106
+ #home:hover { opacity: 1; border-color: #e9c46a; }
107
+ </style>
108
+ </head>
109
+ <body>
110
+ <div id="stage"><canvas id="view" width="1200" height="896"></canvas></div>
111
+ <div id="hud"><div id="inventory" hidden></div><div id="talk" hidden></div><div id="player" hidden></div><div id="handover" hidden></div><div id="ending" hidden></div></div>
112
+ <div id="turn"><svg width="64" height="64" viewBox="0 0 64 64" aria-hidden="true"><rect x="20" y="8" width="24" height="42" rx="4" fill="none" stroke="#e8e2d6" stroke-width="3"/><path d="M14 52 a22 22 0 0 0 36 0" fill="none" stroke="#e9c46a" stroke-width="3"/><path d="M46 46 l4 6 l-7 1" fill="none" stroke="#e9c46a" stroke-width="3" stroke-linejoin="round"/></svg><div>Turn your phone sideways to play.</div><button type="button" onclick="this.parentElement.hidden = true">Play upright anyway</button></div>
113
+ <div id="soundless" hidden></div>
114
+ <div id="loading" role="status"><div class="stage" aria-hidden="true"><div class="sheet"></div><div class="sheet"></div><div class="sheet"></div><div class="lamp"></div></div><div class="what">Unfolding the diorama…</div><div class="bar"><i></i></div><div class="count"></div></div>
115
+ <div id="bar"><span id="where"></span><span id="hover"></span><span class="hint">click to walk or use, double click to hurry · right click or long press to look or put back · WASD or arrows walk (Shift hurries), E uses · 1-9 items · g grid · m sound</span></div>
116
+ <script type="module" src="main.js"></script>
117
+ </body>
118
+ </html>
@@ -0,0 +1,404 @@
1
+ // living: the painted sea's swell and surf, waterfalls pouring, animals swimming and flying, and cloud bands
2
+ // drifting, drawn on the page's canvas. The numbers come from src/play/living.ts.
3
+ import { leapHeight, shorePoints, swellAt, swimAt } from '../src/play/living.ts';
4
+
5
+ type Box = [number, number, number, number];
6
+ type Shift = (y: number) => [number, number];
7
+
8
+ const canvasOf = (w: number, h: number) => Object.assign(document.createElement('canvas'), { width: Math.max(1, w), height: Math.max(1, h) });
9
+ const pixelsOf = (img: CanvasImageSource, w: number, h: number) => {
10
+ const c = canvasOf(w, h).getContext('2d', { willReadFrequently: true })!;
11
+ c.drawImage(img, 0, 0, w, h);
12
+ return c.getImageData(0, 0, w, h);
13
+ };
14
+ /** Seeded 0..1, the same every run. */
15
+ const rand = (i: number, salt = 0) => {
16
+ const v = Math.sin(i * 12.9898 + salt * 78.233) * 43758.5453;
17
+ return v - Math.floor(v);
18
+ };
19
+
20
+ /**
21
+ * The sea's surf: its foam (the light, greyish paint in the water texture: crumpled paper, white crests) as a layer
22
+ * of its own that slides over the swell, and where the water meets land, rock or a hull, puffs of foam and spray.
23
+ */
24
+ export class Surf {
25
+ readonly foam: HTMLCanvasElement;
26
+ /** Warm light painted over the water (a lighthouse's rays, a lamp's glow): it stays put while the sea heaves under it. */
27
+ private readonly lit?: HTMLCanvasElement;
28
+ private readonly shore: Array<{ x: number; y: number; nx: number; ny: number }>;
29
+ private readonly colour: string;
30
+
31
+ constructor(tex: HTMLImageElement, mask: HTMLImageElement, readonly box: Box, backdrop?: HTMLImageElement) {
32
+ const [w, h] = [box[2] - box[0], box[3] - box[1]];
33
+ const px = pixelsOf(tex, w, h);
34
+ const foam = new ImageData(w, h);
35
+ let [r, g, b, n] = [0, 0, 0, 0];
36
+ for (let i = 0; i < w * h; i++) {
37
+ const [R, G, B] = [px.data[i * 4], px.data[i * 4 + 1], px.data[i * 4 + 2]];
38
+ const max = Math.max(R, G, B);
39
+ const sat = max ? (max - Math.min(R, G, B)) / max : 0;
40
+ const light = 0.3 * R + 0.59 * G + 0.11 * B;
41
+ const a = Math.max(0, Math.min(1, (light - 150) / 50)) * Math.max(0, Math.min(1, (0.4 - sat) / 0.2));
42
+ foam.data.set([R, G, B, Math.round(255 * a)], i * 4);
43
+ if (a > 0.8) [r, g, b, n] = [r + R, g + G, b + B, n + 1];
44
+ }
45
+ this.foam = canvasOf(w, h);
46
+ this.foam.getContext('2d')!.putImageData(foam, 0, 0);
47
+ this.colour = n ? `${Math.round(r / n)},${Math.round(g / n)},${Math.round(b / n)}` : '236,240,246';
48
+ // Where the mask ends but the paint beyond is the same sea (a strip the mask missed), no surf: the dry side
49
+ // must look like something else (planks, rock, a hull).
50
+ const bg = backdrop && pixelsOf(backdrop, backdrop.width, backdrop.height);
51
+ const paint = (x: number, y: number) => {
52
+ const [X, Y] = [Math.max(0, Math.min(bg!.width - 1, Math.round(box[0] + x))), Math.max(0, Math.min(bg!.height - 1, Math.round(box[1] + y)))];
53
+ return bg!.data.slice((Y * bg!.width + X) * 4, (Y * bg!.width + X) * 4 + 3);
54
+ };
55
+ if (bg) {
56
+ const lit = new ImageData(w, h);
57
+ let any = 0;
58
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
59
+ const [r, g, b] = paint(x, y);
60
+ const max = Math.max(r, g, b);
61
+ const sat = max ? (max - Math.min(r, g, b)) / max : 0;
62
+ const a = max > 110 && r >= b + 30 && g >= b ? Math.max(0, Math.min(1, (sat - 0.3) / 0.15)) : 0;
63
+ if (a) any++;
64
+ lit.data.set([r, g, b, Math.round(255 * a)], (y * w + x) * 4);
65
+ }
66
+ if (any > 50) {
67
+ this.lit = canvasOf(w, h);
68
+ this.lit.getContext('2d')!.putImageData(lit, 0, 0);
69
+ }
70
+ }
71
+ this.shore = shorePoints(pixelsOf(mask, w, h).data, w, h, 11, 4).filter((p) => {
72
+ if (!bg) return true;
73
+ const [a, b] = [paint(p.x - p.nx * 6, p.y - p.ny * 6), paint(p.x + p.nx * 14, p.y + p.ny * 14)];
74
+ const blue = (c: Uint8ClampedArray) => c[2] > c[0] + 15;
75
+ return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) > 70 && !(blue(a) && blue(b));
76
+ });
77
+ }
78
+
79
+ /** The foam over the swell, on the water's own canvas (before its mask cuts it): it rides higher and further than the paper under it. */
80
+ churn(c: CanvasRenderingContext2D, t: number, storm: number) {
81
+ const { foam } = this;
82
+ const [w, h] = [foam.width, foam.height];
83
+ const STRIP = 4;
84
+ c.save();
85
+ c.globalAlpha = 0.9;
86
+ for (let y = 0; y < h; y += STRIP) {
87
+ const u = y / h;
88
+ const s = swellAt(0, u, t + 0.35, storm);
89
+ const dx = s.dx * 1.4 + Math.sin(t * 1.9 + y * 0.07) * (0.8 + 2.5 * u) * (0.6 + storm);
90
+ const sy = Math.max(0, Math.min(h - STRIP, y + s.dy * 1.25));
91
+ c.drawImage(foam, 0, sy, w, STRIP, dx, y, w, STRIP);
92
+ }
93
+ c.restore();
94
+ }
95
+
96
+ /** Puffs of foam and spray where the water breaks on the shore: each swells and fades on its own beat. */
97
+ break(ctx: CanvasRenderingContext2D, t: number, storm: number, shift: Shift) {
98
+ const [x0, y0] = this.box;
99
+ const h = this.box[3] - this.box[1];
100
+ if (this.lit) {
101
+ const [sx, sy] = shift(this.box[3]);
102
+ ctx.drawImage(this.lit, x0 + sx, y0 + sy);
103
+ }
104
+ ctx.save();
105
+ for (const [i, p] of this.shore.entries()) {
106
+ const u = p.y / h;
107
+ const life = 1.6 + 1.4 * rand(i, 1);
108
+ const beat = (t + rand(i, 2) * life * 5) / life;
109
+ const age = beat - Math.floor(beat);
110
+ // Not every point breaks every beat: more of them in a storm.
111
+ if (rand(Math.floor(beat) * 31 + i, 3) > 0.45 + 0.45 * storm) continue;
112
+ const [sx, sy] = shift(y0 + p.y);
113
+ const size = (7 + 18 * u) * (0.7 + 0.6 * storm) * (0.5 + 0.8 * rand(i, 4));
114
+ // Thrown up and back off the shore, toward the water.
115
+ const out = age * size * 0.8;
116
+ const cx = x0 + sx + p.x - p.nx * out;
117
+ const cy = y0 + sy + p.y - p.ny * out - age * size * 0.6;
118
+ const alpha = Math.sin(Math.PI * Math.min(1, age * 1.3)) * (0.55 + 0.3 * storm);
119
+ if (alpha <= 0.02) continue;
120
+ // A torn-paper puff: a ragged blob that opens up as it rises.
121
+ ctx.globalAlpha = alpha;
122
+ ctx.fillStyle = `rgb(${this.colour})`;
123
+ ctx.beginPath();
124
+ const r = size * (0.45 + 0.75 * age);
125
+ for (let k = 0; k < 9; k++) {
126
+ const a = (k / 9) * Math.PI * 2;
127
+ const rr = r * (0.65 + 0.45 * rand(i * 9 + k, 5 + Math.floor(beat)));
128
+ const [px, py] = [cx + Math.cos(a) * rr, cy + Math.sin(a) * rr * 0.6];
129
+ if (k) ctx.lineTo(px, py);
130
+ else ctx.moveTo(px, py);
131
+ }
132
+ ctx.closePath();
133
+ ctx.fill();
134
+ // Spray: a few drops flung up in arcs.
135
+ ctx.globalAlpha = alpha * 0.8;
136
+ for (let d = 0; d < 3; d++) {
137
+ const vx = (rand(i * 3 + d, 6) - 0.5) * size * 2.2 - p.nx * size;
138
+ const vy = -size * (1.2 + rand(i * 3 + d, 7));
139
+ const [dx, dy] = [vx * age, vy * age + 1.8 * size * age * age];
140
+ ctx.fillRect(cx + dx, cy + dy, 1.6 + u * 1.4, 1.6 + u * 1.4);
141
+ }
142
+ }
143
+ ctx.restore();
144
+ }
145
+ }
146
+
147
+ const swells = new WeakMap<object, { src: Uint32Array; sw: number; sh: number; out: ImageData; o32: Uint32Array }>();
148
+ /** The water texture's pixels (read once) and the w x h picture the swell is copied into. */
149
+ function swellPixels(tex: CanvasImageSource, w: number, h: number) {
150
+ let b = swells.get(tex);
151
+ if (!b || b.out.width !== w || b.out.height !== h) {
152
+ const [sw, sh] = tex instanceof HTMLImageElement ? [tex.naturalWidth, tex.naturalHeight] : [(tex as HTMLCanvasElement).width, (tex as HTMLCanvasElement).height];
153
+ const src = new Uint32Array(pixelsOf(tex, sw, sh).data.buffer);
154
+ const out = new ImageData(w, h);
155
+ b = { src, sw, sh, out, o32: new Uint32Array(out.data.buffer) };
156
+ swells.set(tex, b);
157
+ }
158
+ return b;
159
+ }
160
+
161
+ /**
162
+ * Still water swells: the texture is read in small tiles, each from a little aside and above or below (src/play
163
+ * living.ts swellAt), so waves roll toward the viewer and the near paper heaves; crests catch a little light.
164
+ */
165
+ export function drawSwell(c: CanvasRenderingContext2D, tex: CanvasImageSource, w: number, h: number, t: number, storm: number) {
166
+ const TW = 20;
167
+ const TH = 4;
168
+ // The tiles are copied pixel by pixel into one picture put down at once: as ~9000 drawImage calls a frame they
169
+ // were most of the game's frame time, and a plain laptop or phone dropped to a few frames a second.
170
+ const b = swellPixels(tex, w, h);
171
+ const { src, sw, sh, o32 } = b;
172
+ for (let y = 0; y < h; y += TH) {
173
+ const u = y / Math.max(1, h);
174
+ const rows = Math.min(TH, h - y);
175
+ for (let x = 0; x < w; x += TW) {
176
+ const s = swellAt(x, u, t, storm);
177
+ const sy = Math.max(0, Math.min(Math.min(h, sh) - TH, Math.round(y + s.dy)));
178
+ // One px wider than the tile, so neighbours read from a hair apart never leave a seam.
179
+ const sx = Math.max(0, Math.min(Math.min(w, sw) - TW - 1, Math.round(x + s.dx)));
180
+ const n = Math.min(TW + 1, w - x, sw - sx);
181
+ if (n <= 0 || sy < 0) continue;
182
+ for (let r = 0; r < rows; r++) {
183
+ const a = (sy + r) * sw + sx;
184
+ o32.set(src.subarray(a, a + n), (y + r) * w + x);
185
+ }
186
+ }
187
+ }
188
+ c.putImageData(b.out, 0, 0);
189
+ // The crests catch the light and the troughs darken a touch, so a smooth painted sea is seen to heave.
190
+ c.globalCompositeOperation = 'soft-light';
191
+ for (let y = 0; y < h; y += TH) {
192
+ const u = y / Math.max(1, h);
193
+ for (let x = 0; x < w; x += TW * 2) {
194
+ const { crest } = swellAt(x, u, t, storm);
195
+ if (Math.abs(crest) < 0.55) continue;
196
+ c.globalAlpha = (Math.abs(crest) - 0.55) * (0.5 + 0.6 * u) * (0.6 + 0.6 * storm);
197
+ c.fillStyle = crest > 0 ? '#ffffff' : '#000010';
198
+ c.fillRect(x, y, TW * 2, TH);
199
+ }
200
+ }
201
+ c.globalCompositeOperation = 'source-over';
202
+ c.globalAlpha = 1;
203
+ }
204
+
205
+ /** A painted waterfall pours: ripples and light run down its ribbons, foam churns at its foot and spray flies. */
206
+ export class Fall {
207
+ private readonly canvas: HTMLCanvasElement;
208
+ /** Per column, the fall's lowest row (its foot), -1 where the column holds none of it. */
209
+ private readonly foot: Int16Array;
210
+
211
+ constructor(readonly img: HTMLImageElement, readonly box: Box) {
212
+ const [w, h] = [box[2] - box[0], box[3] - box[1]];
213
+ this.canvas = canvasOf(w, h);
214
+ const a = pixelsOf(img, w, h).data;
215
+ this.foot = new Int16Array(w).fill(-1);
216
+ for (let x = 0; x < w; x++) for (let y = h - 1; y >= 0; y--) if (a[(y * w + x) * 4 + 3] > 128) {
217
+ this.foot[x] = y;
218
+ break;
219
+ }
220
+ }
221
+
222
+ draw(ctx: CanvasRenderingContext2D, t: number, shift: Shift) {
223
+ const { img, canvas } = this;
224
+ const [w, h] = [canvas.width, canvas.height];
225
+ const c = canvas.getContext('2d')!;
226
+ c.globalCompositeOperation = 'source-over';
227
+ c.clearRect(0, 0, w, h);
228
+ // Ripples run down the ribbons: each row is pushed aside by waves travelling down the fall (no scrolled copies,
229
+ // which doubled the paper ribbons into ghosts).
230
+ const STRIP = 2;
231
+ for (let y = 0; y < h; y += STRIP) {
232
+ const dx = 2.4 * Math.sin(y * 0.12 - t * 6.5) + 1.1 * Math.sin(y * 0.047 - t * 3.3);
233
+ c.drawImage(img, 0, y, w, STRIP, dx, y, w, STRIP);
234
+ }
235
+ // Cut to the fall's own shape, so only its water moves.
236
+ c.globalAlpha = 1;
237
+ c.globalCompositeOperation = 'destination-in';
238
+ c.drawImage(img, 0, 0);
239
+ c.globalCompositeOperation = 'source-over';
240
+ // Soft bands of light running down it.
241
+ c.globalCompositeOperation = 'source-atop';
242
+ for (let i = 0; i < 5; i++) {
243
+ const y = ((t * 120 + i * (h / 5)) % (h + 60)) - 30;
244
+ const band = c.createLinearGradient(0, y - 14, 0, y + 14);
245
+ band.addColorStop(0, 'rgba(255,255,255,0)');
246
+ band.addColorStop(0.5, 'rgba(255,255,255,0.22)');
247
+ band.addColorStop(1, 'rgba(255,255,255,0)');
248
+ c.fillStyle = band;
249
+ c.fillRect(0, y - 14, w, 28);
250
+ }
251
+ c.globalCompositeOperation = 'source-over';
252
+ c.globalAlpha = 1;
253
+ const [x0, y0] = this.box;
254
+ const [sx, sy] = shift(this.box[3]);
255
+ ctx.drawImage(canvas, x0 + sx, y0 + sy);
256
+ this.churn(ctx, t, x0 + sx, y0 + sy);
257
+ }
258
+
259
+ /** Foam boiling at the foot of the fall, and spray thrown up from it. */
260
+ private churn(ctx: CanvasRenderingContext2D, t: number, ox: number, oy: number) {
261
+ const { foot } = this;
262
+ const w = foot.length;
263
+ ctx.save();
264
+ ctx.fillStyle = '#f2f5fa';
265
+ for (let x = 4; x < w - 4; x += 9) {
266
+ const y = foot[x];
267
+ if (y < 0) continue;
268
+ for (let j = 0; j < 2; j++) {
269
+ const i = x * 2 + j;
270
+ const life = 0.9 + 0.8 * rand(i, 11);
271
+ const beat = (t + rand(i, 12) * 4) / life;
272
+ const age = beat - Math.floor(beat);
273
+ const r = (5 + 7 * rand(i, 13)) * (0.5 + age);
274
+ ctx.globalAlpha = Math.sin(Math.PI * age) * 0.7;
275
+ ctx.beginPath();
276
+ const [cx, cy] = [ox + x + (rand(i, 14 + Math.floor(beat)) - 0.5) * 10, oy + y - 2 - age * 14];
277
+ for (let k = 0; k < 8; k++) {
278
+ const a = (k / 8) * Math.PI * 2;
279
+ const rr = r * (0.6 + 0.5 * rand(i * 8 + k, Math.floor(beat)));
280
+ if (k) ctx.lineTo(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr * 0.7);
281
+ else ctx.moveTo(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr * 0.7);
282
+ }
283
+ ctx.closePath();
284
+ ctx.fill();
285
+ // Spray.
286
+ ctx.globalAlpha *= 0.9;
287
+ for (let d = 0; d < 2; d++) {
288
+ const vx = (rand(i * 2 + d, 15 + Math.floor(beat)) - 0.5) * 60;
289
+ const vy = -50 - 40 * rand(i * 2 + d, 16);
290
+ ctx.fillRect(cx + vx * age, cy + vy * age + 70 * age * age, 2, 2);
291
+ }
292
+ }
293
+ }
294
+ ctx.restore();
295
+ }
296
+ }
297
+
298
+ /** A painted animal (a fish in the river, a gull over the sea) as a small card that swims or flies. */
299
+ export class Critter {
300
+ constructor(readonly img: HTMLImageElement, readonly box: Box, readonly medium: 'water' | 'air', readonly facing: 1 | -1, readonly k: number) {}
301
+
302
+ draw(ctx: CanvasRenderingContext2D, t: number, shift: Shift) {
303
+ const { img, box, k } = this;
304
+ const [w, h] = [box[2] - box[0], box[3] - box[1]];
305
+ const s = swimAt(t, k, this.facing, w, this.medium);
306
+ const [sx, sy] = shift(box[3]);
307
+ // The water rings where it leaves and where it lands.
308
+ if (this.medium === 'water') {
309
+ const dir = Math.sign(s.scaleX || 1) * this.facing;
310
+ for (const [from, x] of [[0, s.dx - dir * w * 0.25], [1.05, s.dx + dir * w * 0.25]] as const) {
311
+ const age = (s.sinceLeap - from) / 0.8;
312
+ if (age < 0 || age > 1) continue;
313
+ ctx.save();
314
+ ctx.globalAlpha = (1 - age) * 0.75;
315
+ ctx.strokeStyle = '#eef3fa';
316
+ ctx.lineWidth = 2;
317
+ ctx.beginPath();
318
+ ctx.ellipse(box[0] + w / 2 + x + sx, box[3] - h * 0.3 + sy, w * (0.2 + 0.5 * age), h * (0.06 + 0.15 * age), 0, 0, Math.PI * 2);
319
+ ctx.stroke();
320
+ // A few drops thrown up.
321
+ ctx.fillStyle = '#eef3fa';
322
+ for (let d = 0; d < 5; d++) ctx.fillRect(box[0] + w / 2 + x + sx + (d - 2) * w * 0.08 * (1 + age), box[3] - h * 0.3 + sy - Math.sin(Math.PI * age) * h * (0.4 + 0.15 * d % 3), 2, 2);
323
+ ctx.restore();
324
+ }
325
+ }
326
+ this.drawPosed(ctx, t, shift, img, 0);
327
+ }
328
+
329
+ /**
330
+ * Draws `img` (the critter's card, or something cut to it with `pad` px of room on every side, such as its hover
331
+ * outline) where and how the critter is this moment: swum, leapt, turned, flapping, its tail wiggling.
332
+ */
333
+ drawPosed(ctx: CanvasRenderingContext2D, t: number, shift: Shift, img: CanvasImageSource, pad: number) {
334
+ const { box, k } = this;
335
+ const [w, h] = [box[2] - box[0], box[3] - box[1]];
336
+ const s = swimAt(t, k, this.facing, w, this.medium);
337
+ const [sx, sy] = shift(box[3]);
338
+ const up = s.leap ? leapHeight(s.leap, h) : 0;
339
+ const [cx, cy] = [box[0] + w / 2 + s.dx + sx, box[1] + h / 2 + s.dy + sy - up];
340
+ ctx.save();
341
+ ctx.translate(cx, cy);
342
+ ctx.rotate(s.rot);
343
+ // A bird flaps: its card squashes up and down; a fish's tail end wiggles.
344
+ const flap = this.medium === 'air' ? 1 + 0.18 * Math.sin(t * 9 + k) : 1;
345
+ ctx.scale(Math.abs(s.scaleX) < 0.06 ? 0.06 * Math.sign(s.scaleX || 1) : s.scaleX, flap);
346
+ const STRIP = 4;
347
+ // Strips on the card's own grid; the padding beside it moves with the strip at that edge.
348
+ for (let x = 0; x < w; x += STRIP) {
349
+ // Painted facing right, the tail is at the left (x 0): it swings most.
350
+ const tail = this.facing > 0 ? 1 - x / w : x / w;
351
+ const dy = this.medium === 'water' ? Math.sin(t * 7 + k - x * 0.08) * 2.4 * tail * tail : 0;
352
+ const [a, b] = [x === 0 ? -pad : x, x + STRIP >= w ? w + pad : x + STRIP + (pad ? 0 : 1)];
353
+ ctx.drawImage(img, a + pad, 0, b - a, h + 2 * pad, a - w / 2, -h / 2 - pad + dy, b - a, h + 2 * pad);
354
+ }
355
+ ctx.restore();
356
+ }
357
+ }
358
+
359
+ /**
360
+ * The painted clouds as bands, each drifting with the wind at its own speed (the top one fastest), the bands
361
+ * below first; each band's texture repeats mirrored, so it drifts on and on with no end showing. Cut to where
362
+ * the clouds are, with what stays put in the sky (a lamp's glow, rays) laid back over.
363
+ */
364
+ export class Clouds {
365
+ private readonly canvas: HTMLCanvasElement;
366
+ private readonly bands: Array<{ tile: HTMLCanvasElement; y: number; speed: number }>;
367
+
368
+ constructor(bands: Array<{ img: HTMLImageElement; box: Box; speed: number }>, readonly clip: HTMLImageElement, readonly keep: HTMLImageElement | undefined, readonly box: Box) {
369
+ this.canvas = canvasOf(box[2] - box[0], box[3] - box[1]);
370
+ this.bands = bands.map(({ img, box: b, speed }) => {
371
+ const tile = canvasOf(2 * img.width, img.height);
372
+ const c = tile.getContext('2d')!;
373
+ c.drawImage(img, 0, 0);
374
+ c.save();
375
+ c.translate(2 * img.width, 0);
376
+ c.scale(-1, 1);
377
+ c.drawImage(img, 0, 0);
378
+ c.restore();
379
+ return { tile, y: b[1] - box[1], speed };
380
+ });
381
+ }
382
+
383
+ draw(ctx: CanvasRenderingContext2D, t: number, wind: number, shift: Shift) {
384
+ const { canvas } = this;
385
+ const c = canvas.getContext('2d')!;
386
+ c.globalCompositeOperation = 'source-over';
387
+ c.clearRect(0, 0, canvas.width, canvas.height);
388
+ // A few px a second, more in a gale; with the wind.
389
+ const v = 4.5 * (1 + 2 * Math.abs(wind));
390
+ const dir = wind < 0 ? -1 : 1;
391
+ for (const b of this.bands) {
392
+ const span = b.tile.width;
393
+ const o = (((dir * v * b.speed * t) % span) + span) % span;
394
+ c.drawImage(b.tile, o - span, b.y);
395
+ c.drawImage(b.tile, o, b.y);
396
+ }
397
+ c.globalCompositeOperation = 'destination-in';
398
+ c.drawImage(this.clip, 0, 0);
399
+ c.globalCompositeOperation = 'source-over';
400
+ if (this.keep) c.drawImage(this.keep, 0, 0);
401
+ const [sx, sy] = shift(this.box[3]);
402
+ ctx.drawImage(canvas, this.box[0] + sx, this.box[1] + sy);
403
+ }
404
+ }