@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,3751 @@
1
+ import { exitOutline } from '../src/play/exit-outline.ts';
2
+ import { outlineLoops, softAlpha } from '../src/play/outline-path.ts';
3
+ // The browser runtime: a painted place, a hero who walks on its grid, hides behind things and uses them.
4
+ import { hashSeed } from '../src/play/weather.ts';
5
+ import { FLAME_GLOW, FLAME_SWAY_M, flameAt, depthStretch, flicker, floorYAt, hiddenRuns, castsShadow, lightFor, onStep, personPx, stepAt, stepFootY, stepRestY, treadY, type GeoThing, type Geometry, type Occluder } from '../src/play/geometry.ts';
6
+ import { backdropTransforms, backEdge, cardY, flatDisparities, flatsUnderFloats, shiftAtDisparity, type Layers, floorPoint, overscan, panTo, PARALLAX_PX, pivot, shiftAt, STACK_GAP, stackCards, type Camera, type Parallax } from '../src/play/parallax.ts';
7
+ import { WalkGrid, type Point } from '../src/play/path.ts';
8
+ import { behindWeight, FOCUS_BLUR, focusMask, planeDepth, RIM_ALPHA, RIM_COLOUR, RIM_WIDTH, SEPARATE_ALPHA, SEPARATE_BLUR, SEPARATE_SHIFT } from '../src/play/focus.ts';
9
+ import { frontMask } from '../src/play/depth-occlusion.ts';
10
+ import { catchers, decodeDepth, DEPTH_SCALE, shadowAt, sunFor, thingShadows, type Depth } from '../src/play/shadow.ts';
11
+ import { CYCLE, pose, sheetCell, type PartName, type Rig } from '../src/play/rig.ts';
12
+ import { actions, activeChanges, apply, hiddenThings, hintFor, holds, lockOn, pickDialogue, pickInteraction, speakerOf, startState, useFor, type Action, type State } from '../src/story/engine.ts';
13
+ import type { Story } from '../src/story/schema.ts';
14
+ import type { Place as PlaceSpec, World } from '../src/world/schema.ts';
15
+ import { Mixer, type Listened } from './audio.ts';
16
+ import { PLAYER_COLOURS, readEachPad, readPads, Turns, type PadButton, type PadFrame } from './pad.ts';
17
+ import { Answers, Bag, displayName, fitHud, showEnding, type ItemLook } from './ui.ts';
18
+ import { useLanguage, words } from './words.ts';
19
+ import { spokenLine, type SoundData } from '../src/play/sound.ts';
20
+ import type { Figure3D, ModelInfo } from './hero3d.ts';
21
+ import type { Weather } from '../src/play/weather.ts';
22
+ import { WeatherFx } from './weather.ts';
23
+ import { Clouds, Critter, drawSwell, Fall, Surf } from './living.ts';
24
+ import { stormOf } from '../src/play/living.ts';
25
+ import type { Reactions } from '../src/play/reactions.ts';
26
+ import { ReactionFx } from './reactions.ts';
27
+ import { GlowFx } from './glows.ts';
28
+ import type { Glow } from '../src/play/glow.ts';
29
+ import { creatureMotion } from '../src/play/creature.ts';
30
+ import { PaintedFace } from './face.ts';
31
+ import { mouthAt, offsetFace, faceAt, type FaceDefinition } from '../src/play/lipsync.ts';
32
+
33
+ type Data = {
34
+ slug: string;
35
+ world: World;
36
+ places: Record<string, { geometry: Geometry | null; painting: string | null; backdrop?: string; layers?: Layers; weather?: Weather; reactions?: Reactions; glows?: Glow[]; issue?: string }>;
37
+ /** Figure per hero or NPC key: a cut-out image, and its rig when `diorama cast` could cut it into parts. */
38
+ cast: Record<string, { image: string; height?: number; upright?: boolean; face?: { dir: string; info: FaceDefinition }; rig?: Rig; parts?: string; model?: { dir: string; info: ModelInfo }; sprites?: { dir: string; info: SpriteInfo } }>;
39
+ story: Story | null;
40
+ /** Index into story.changes → the painting as it looks once that change holds. */
41
+ changes?: Record<string, string>;
42
+ /** Index into story.changes → the changed thing's shape in it (a mask), which its hover outline follows. */
43
+ changeShapes?: Record<string, string>;
44
+ /** Music, ambience, loops, footsteps and one-shots (diorama sound); without it the page is silent. */
45
+ sound?: SoundData;
46
+ /** An icon per story item (diorama icons); an item without one shows its name. */
47
+ items?: Record<string, { icon: string }>;
48
+ /** Heroes for more gamepads (cast keys, drawings marked player): player 2 plays the first. */
49
+ players?: string[];
50
+ };
51
+
52
+ type Bits = { bits: Uint8Array; area: number };
53
+ type Who = { npc?: Hot; body?: Body; y: number };
54
+ type Hot = { thing: GeoThing; spec?: PlaceSpec['things'][number]; mask?: Bits; area: number; plate?: HTMLImageElement; outline?: Outline; npc?: Npc };
55
+ /** A hover outline drawn into a canvas whose top-left corner is painting point (x0, y0); `cuts` is what it was made of. */
56
+ type Outline = { canvas: HTMLCanvasElement; x0: number; y0: number; cuts?: string };
57
+ type Rect = [number, number, number, number];
58
+ /** sprites.json, written by `diorama sprites`: one walk cycle per facing, a cols x rows grid of frames per sheet. */
59
+ export type SpriteInfo = {
60
+ cols: number; rows: number; frames: number; width: number; height: number;
61
+ /** Feet at (cell width / 2, cell height * feetAt); the figure is about `px` tall. */
62
+ feetAt: number; px: number;
63
+ /** Walk facings the story's routes use (also patrols and travelling NPCs). */
64
+ facings: Record<string, { sheet: string; yaw: number; iou?: number[]; stand?: number; off?: number[] }>;
65
+ /** Talking sheets, same grid, usually one: cell i shows gestures[i] (cell 0 listens). */
66
+ talk?: Record<string, { sheet: string; yaw: number; gestures?: string[] }>;
67
+ /** The gesture the story gives each line this character says; a line not listed is said listening. */
68
+ lines?: Record<string, string>;
69
+ };
70
+ /** walk: the cells a walk steps through, without the ones the sprites step found off the gait (a crouch, a sit). */
71
+ type Sheet = { file: string; yaw: number; stand: number; img: HTMLImageElement; gestures: string[]; walk: number[] };
72
+ /** height: metres standing (a grown-up 1.62, a cat 0.3), so each is drawn at its own size; upright false: an animal, drawn as painted. */
73
+ type Figure = { img: HTMLImageElement; face?: PaintedFace; faceInfo?: FaceDefinition; height?: number; upright?: boolean; rig?: { rig: Rig; parts: Map<PartName, HTMLImageElement>; sheet?: HTMLImageElement; canvas: HTMLCanvasElement }; model?: Figure3D; sprites?: { info: SpriteInfo; sheets: Sheet[]; talk: Sheet[] } };
74
+ /** y: the floor row the cut stands on, which sets how far it slides with parallax. */
75
+ /** Things that float and ride the swell, by their names. */
76
+ const FLOATS = /\b(boat|rowboat|dinghy|skiff|canoe|kayak|raft|ship|barge|punt|gondola|sampan|junk|ferry|trawler|yacht|sailboat)s?\b/i;
77
+
78
+ /** canvas: the card as the place looks now (story changes on it); base: as painted, whose alpha is the card's shape. */
79
+ type Cut = { occ: Occluder; canvas: HTMLCanvasElement; base: HTMLCanvasElement; x0: number; y0: number; y: number };
80
+
81
+ type Place = {
82
+ key: string;
83
+ spec: PlaceSpec;
84
+ g: Geometry;
85
+ grid: WalkGrid;
86
+ bg?: HTMLImageElement;
87
+ /** The painting without its cards (backdrop.png): with it the place is drawn as a parallax diorama. */
88
+ backdrop?: HTMLImageElement;
89
+ /** The depth that stays put while the camera sways. */
90
+ pivot: number;
91
+ /** Per walk-grid column, the floor's back edge: what stands above it slides with it (see backEdge). */
92
+ edge: Float32Array;
93
+ /** The far view as flats: the sky drawn still, then each flat (farthest first) at its own disparity. */
94
+ /** Water set flowing: a texture over box (reaching past the banks), its soft mask, a canvas to compose in. */
95
+ water?: { tex: HTMLImageElement; mask: HTMLImageElement; box: [number, number, number, number]; flow: [number, number]; canvas: HTMLCanvasElement };
96
+ /** Painted fog set drifting: its veil over box (reaching past its edges), where it may show, which way (-1, 1; 0 with the wind) and how fast. */
97
+ mist?: { tex: HTMLImageElement; mask: HTMLImageElement; box: [number, number, number, number]; drift: number; speed: number; canvas: HTMLCanvasElement };
98
+ /** Painted smoke curling: each plume's feathered copy of the painting and where it sits. */
99
+ smoke?: Array<{ img: HTMLImageElement; box: [number, number, number, number] }>;
100
+ /** Painted boats rocking on the water. */
101
+ floats?: Array<{ img: HTMLImageElement; box: [number, number, number, number]; under?: HTMLCanvasElement }>;
102
+ /** The water's foam and its surf on the shore (see living.ts). */
103
+ surf?: Surf;
104
+ /** Painted waterfalls pouring, painted animals swimming and flying, cloud bands drifting (see living.ts). */
105
+ falls?: Fall[];
106
+ critters?: Critter[];
107
+ clouds?: Clouds;
108
+ far?: { sky?: HTMLImageElement; flats: Array<{ img: HTMLImageElement; box: [number, number, number, number]; d: number }> };
109
+ hots: Hot[];
110
+ cuts: Cut[];
111
+ byKey: Map<string, Occluder>;
112
+ /** Mean luma of the painting per LIGHT_CELL square, to light people like the place around them. */
113
+ light?: { cols: number; rows: number; luma: Float32Array };
114
+ /** Painted shadows on the floors (geometry.shadows): a person standing in one is darkened. */
115
+ shade?: Bits;
116
+ /** How far every pixel is (geometry.depth): cast shadows follow the ground's shape with it, and what nobody masked hides people. */
117
+ depth?: Depth;
118
+ /** The cards' pixels at the depth map's size: there the cards, not the depth map, decide who is hidden (made on first use). */
119
+ carded?: Uint8Array;
120
+ /** The story changes the cards were last cut with (see Game.recut). */
121
+ cutWith?: string;
122
+ /** How far every pixel is for focus and separation: the depth map, or the floor plane with the cards stood on it. */
123
+ focus?: Depth;
124
+ /** The water's soft mask over the frame, 0..255: a shadow barely shows on water. */
125
+ wet?: Uint8Array;
126
+ /** The fire or lamp's work here (see Game.fireOf), made on first use. */
127
+ fire?: Fire;
128
+ /** Where a person's shadow can land (see Game.catchesOf): the things not carried off when made, and the floors. */
129
+ catches?: { gone: string; on: Uint8Array };
130
+ /** Rain or snow, rings, splashes and drips (weather.json). */
131
+ weather?: WeatherFx;
132
+ reactions?: ReactionFx;
133
+ /** The painted lights breathing, flickering, twinkling or turning a beam (layout.lights). */
134
+ glows?: GlowFx;
135
+ };
136
+
137
+ /**
138
+ * What a fire or a lamp on the floor (light.at) does to the place: the shadows the painted things cast from
139
+ * it, worked out once for the flame swayed left, centred and right (one for a steady lamp) and mixed each
140
+ * frame as it sways; and for a flame, its bright pixels to set dancing.
141
+ */
142
+ type Fire = {
143
+ /** The things carried off when the shadows were worked out: they cast none. */
144
+ gone: string;
145
+ shadows: Array<ReturnType<typeof thingShadows>>;
146
+ /** This frame's mix of them, its pixels and the canvas they go up on. */
147
+ mix: Float32Array;
148
+ img: ImageData;
149
+ canvas: HTMLCanvasElement;
150
+ /** A person's height in px at the fire. */
151
+ px: number;
152
+ flame?: { canvas: HTMLCanvasElement; x0: number; y0: number };
153
+ };
154
+
155
+ const LIGHT_CELL = 40;
156
+ /** A whole line wrapped in one pair of quotes, without them. */
157
+ const unquoted = (text: string) => {
158
+ const m = text.trim().match(/^(['"\u2018\u201c])([\s\S]+)(['"\u2019\u201d])$/);
159
+ return m && !m[2].includes(m[1] === "'" || m[1] === '\u2018' ? "' " : '"') ? m[2].trim() : text;
160
+ };
161
+ const fract = (v: number) => v - Math.floor(v);
162
+
163
+ function lightMap(bg: HTMLImageElement, w: number, h: number): Place['light'] {
164
+ const cols = Math.ceil(w / LIGHT_CELL);
165
+ const rows = Math.ceil(h / LIGHT_CELL);
166
+ const c = Object.assign(document.createElement('canvas'), { width: cols, height: rows });
167
+ const cx = c.getContext('2d', { willReadFrequently: true })!;
168
+ cx.drawImage(bg, 0, 0, cols, rows);
169
+ const px = cx.getImageData(0, 0, cols, rows).data;
170
+ const luma = new Float32Array(cols * rows);
171
+ for (let i = 0; i < luma.length; i++) luma[i] = 0.2126 * px[i * 4] + 0.7152 * px[i * 4 + 1] + 0.0722 * px[i * 4 + 2];
172
+ return { cols, rows, luma };
173
+ }
174
+
175
+ /** The cards' pixels at the depth map's size: what stands there (thingShadows' casters, a shadow's catchers). */
176
+ function ownerMap(depth: Depth, cuts: Cut[]): Uint8Array {
177
+ const S = DEPTH_SCALE;
178
+ const owner = new Uint8Array(depth.w * depth.h);
179
+ for (const c of cuts) {
180
+ const a = c.canvas.getContext('2d', { willReadFrequently: true })!.getImageData(0, 0, c.canvas.width, c.canvas.height).data;
181
+ for (let r = Math.max(0, Math.floor(c.y0 / S)); r < Math.min(depth.h, (c.y0 + c.canvas.height) / S); r++) {
182
+ for (let k = Math.max(0, Math.floor(c.x0 / S)); k < Math.min(depth.w, (c.x0 + c.canvas.width) / S); k++) {
183
+ const [u, v] = [k * S + 1 - c.x0, r * S + 1 - c.y0];
184
+ if (u >= 0 && v >= 0 && u < c.canvas.width && v < c.canvas.height && a[(v * c.canvas.width + u) * 4 + 3] > 127) owner[r * depth.w + k] = 1;
185
+ }
186
+ }
187
+ }
188
+ return owner;
189
+ }
190
+
191
+ type Events = 'enter' | 'arrive' | 'say' | 'silent' | 'use' | 'exit' | 'ending' | 'npc-enter';
192
+ type Verb = 'look' | 'use' | 'take' | 'talk';
193
+
194
+ function lineChoice(story: Story, state: State, index: number): string {
195
+ const talk = state.talk!;
196
+ return story.dialogues[talk.dialogue].lines.find((l) => l.key === talk.line)?.choices[index]?.text ?? '…';
197
+ }
198
+
199
+ const canvas = document.getElementById('view') as HTMLCanvasElement;
200
+ const ctx = canvas.getContext('2d')!;
201
+ /**
202
+ * The canvas's pixels per painting pixel. A phone or a plain laptop that cannot keep up at full size drops to
203
+ * fewer pixels (see adapt in the frame loop); the picture is the same, a little softer. Frame size is in
204
+ * canvas.dataset.w/h, the drawing goes through ctx's transform, so nothing else needs to know.
205
+ */
206
+ let res = 1;
207
+ /** The painting's frame size, whatever the canvas's pixels. */
208
+ const frameW = () => Number(canvas.dataset.w) || canvas.width;
209
+ const frameH = () => Number(canvas.dataset.h) || canvas.height;
210
+ const params = new URLSearchParams(location.search);
211
+ /** A shared copy (diorama export) rather than the local player: no place keys or developer labels on screen. */
212
+ const shipped = document.body.hasAttribute('data-shipped');
213
+ // The key list at the bottom goes once the player has the hang of it: the first use, or half a minute in.
214
+ if (shipped) setTimeout(() => document.body.classList.add('played'), 30000);
215
+ const capture = params.has('capture');
216
+ const nobody = params.get('people') === '0';
217
+ const hideCursor = capture || params.get('cursor') === '0';
218
+ // ?parallax=0 turns the diorama sway off, ?parallax=1.5 makes it stronger. Capture runs (QA, the bot) stay flat
219
+ // unless they ask for it, since they read frame pixels at painting coordinates. ?sway=1 adds a slow idle drift.
220
+ const parallaxScale = Number(params.get('parallax') ?? (capture ? '0' : '1')) || 0;
221
+ const sway = params.get('sway') === '1';
222
+ // ?focus=1: the camera focuses on the hero, so what stands behind them softens and pales with its distance
223
+ // (off by default: it only tells apart things far behind, and blurs the painting doing it).
224
+ const focusScale = Number(params.get('focus') ?? '0') || 0;
225
+ // ?separate=0 turns off the soft shade a person throws on what stands just behind them; ?separate=1.5 darker.
226
+ // Capture runs keep it off unless asked, since they read frame pixels.
227
+ const separateScale = Number(params.get('separate') ?? (capture ? '0' : '1')) || 0;
228
+ // ?rim=0 turns off the light edge on each person on the side the light comes from; ?rim=1.5 wider.
229
+ // Capture runs keep it off unless asked.
230
+ const rimScale = Number(params.get('rim') ?? (capture ? '0' : '1')) || 0;
231
+ // ?track=1: walking along a place, the camera is closer in and pans after the hero, so near things pass faster
232
+ // than far ones, like a side-scroller. It only drives the parallax, so a flat place stays as it was.
233
+ const track = Number(params.get('track') ?? '0') || 0;
234
+ const TRACK_ZOOM = 0.2;
235
+ // ?fill=1: the painting covers the whole page, cut at its top and bottom (or its sides) instead of black bands beside
236
+ // it, and slides so the hero, where they walk and the thing they were sent to stay on the page (src/play/parallax.ts panTo).
237
+ const fill = params.get('fill') === '1';
238
+ if (fill) document.body.classList.add('fill');
239
+ /** How much closer tracking brings the camera: filling the page is already closer in, and slides with the hero too. */
240
+ const trackZoom = TRACK_ZOOM * track * (fill ? 0.5 : 1);
241
+ /** Water drifts this many px per second at its near edge (40% of it at its far edge). */
242
+ const FLOW = 20;
243
+ /** How far the water texture reaches past the banks (WATER_PAD in src/scene/layers.ts). */
244
+ const WATER_PAD = 64;
245
+ /** Painted fog drifts this many px per second (times its speed), and its texture reaches this far past it (MIST_PAD in src/scene/layers.ts). */
246
+ const MIST_DRIFT = 14;
247
+ const MIST_PAD = 160;
248
+ /** The pointer over the canvas, 0..1 across and down; null when it is elsewhere. */
249
+ let pointer: { x: number; y: number } | null = null;
250
+ const LONG_PRESS_MS = 500;
251
+ // Relative paths, so the same page runs from `diorama play` and from an exported folder.
252
+ const files = (p: string) => `files/${p}`;
253
+
254
+ // The loading card counts the pictures asked for and in, until the first place is drawn.
255
+ const loading = document.getElementById('loading');
256
+ const pieces = { asked: 0, in: 0 };
257
+ const countPiece = () => {
258
+ pieces.in++;
259
+ if (!loading || loading.classList.contains('done')) return;
260
+ loading.querySelector<HTMLElement>('.bar i')!.style.width = `${Math.round((100 * pieces.in) / Math.max(pieces.asked, 1))}%`;
261
+ loading.querySelector('.count')!.textContent = `${pieces.in} / ${pieces.asked} pieces`;
262
+ };
263
+
264
+ const loadImage = (src: string) =>
265
+ new Promise<HTMLImageElement>((resolve, reject) => {
266
+ const img = new Image();
267
+ pieces.asked++;
268
+ img.onload = () => (countPiece(), resolve(img));
269
+ img.onerror = () => (countPiece(), reject(new Error(`cannot load ${src}`)));
270
+ img.src = src;
271
+ });
272
+
273
+ /** depth.png as floor rows per pixel (src/play/shadow.ts). */
274
+ async function loadDepth(src: string): Promise<Depth> {
275
+ const img = await loadImage(src);
276
+ const c = Object.assign(document.createElement('canvas'), { width: img.width, height: img.height });
277
+ const cx = c.getContext('2d', { willReadFrequently: true, colorSpace: 'srgb' })!;
278
+ cx.drawImage(img, 0, 0);
279
+ return decodeDepth(cx.getImageData(0, 0, img.width, img.height).data, img.width, img.height);
280
+ }
281
+
282
+ /** Depth for the camera's focus without a depth map: the floor plane, each card standing at its floor row. */
283
+ function standIn(g: Geometry, edge: Float32Array, cuts: Cut[]): Depth {
284
+ const d = planeDepth(g.frame, g.scale.horizonY, g.cell, edge, DEPTH_SCALE);
285
+ for (const c of cuts) {
286
+ const { width: cw, height: ch } = c.canvas;
287
+ if (!cw || !ch) continue;
288
+ const a = c.canvas.getContext('2d', { willReadFrequently: true })!.getImageData(0, 0, cw, ch).data;
289
+ const rows = Math.max(0, c.y - g.scale.horizonY);
290
+ for (let r = Math.floor(c.y0 / DEPTH_SCALE); r < Math.min(d.h, (c.y0 + ch) / DEPTH_SCALE); r++) {
291
+ for (let k = Math.floor(c.x0 / DEPTH_SCALE); k < Math.min(d.w, (c.x0 + cw) / DEPTH_SCALE); k++) {
292
+ const [u, v] = [k * DEPTH_SCALE - c.x0, r * DEPTH_SCALE - c.y0];
293
+ if (r >= 0 && k >= 0 && u >= 0 && v >= 0 && u < cw && v < ch && a[(v * cw + u) * 4 + 3] > 127) d.rows[r * d.w + k] = rows;
294
+ }
295
+ }
296
+ }
297
+ return d;
298
+ }
299
+
300
+ /** The water's soft mask (drawn over its box) as one byte per frame pixel. */
301
+ async function loadWet(src: string, box: [number, number, number, number], w: number, h: number): Promise<Uint8Array> {
302
+ const img = await loadImage(src);
303
+ const c = Object.assign(document.createElement('canvas'), { width: w, height: h });
304
+ const cx = c.getContext('2d', { willReadFrequently: true })!;
305
+ cx.drawImage(img, box[0], box[1], box[2] - box[0], box[3] - box[1]);
306
+ const px = cx.getImageData(0, 0, w, h).data;
307
+ const out = new Uint8Array(w * h);
308
+ for (let i = 0; i < w * h; i++) out[i] = (px[i * 4] * px[i * 4 + 3]) / 255;
309
+ return out;
310
+ }
311
+
312
+ /** A mask image as one byte per frame pixel (set where the red channel is bright). */
313
+ async function loadBits(src: string, w: number, h: number): Promise<Bits> {
314
+ const img = await loadImage(src);
315
+ const c = Object.assign(document.createElement('canvas'), { width: w, height: h });
316
+ const cx = c.getContext('2d', { willReadFrequently: true })!;
317
+ cx.drawImage(img, 0, 0, w, h);
318
+ const px = cx.getImageData(0, 0, w, h).data;
319
+ const bits = new Uint8Array(w * h);
320
+ let area = 0;
321
+ for (let i = 0; i < bits.length; i++) if (px[i * 4] > 127) (bits[i] = 1), area++;
322
+ return { bits, area };
323
+ }
324
+
325
+ /** A walking person: where they are, the path ahead, and how they are drawn. */
326
+ /** run: hurrying (a double click, Shift held) until the walk ends. */
327
+ type Body = { x: number; y: number; path: Point[]; facing: number; phase: number; yaw: number; figure: Figure | undefined; foot: number | undefined; along: number | undefined; colour?: string; run?: boolean };
328
+ /** Walking and hurrying pace, in person heights a second (a brisk walk, about 1.7 m/s; a run). */
329
+ const WALK = 1.05;
330
+ const RUN = 2.1;
331
+ const newBody = (): Body => ({ x: 0, y: 0, path: [], facing: 1, phase: 0, yaw: Math.PI / 2, figure: undefined, foot: undefined, along: undefined });
332
+ type Npc = { key: string; home: string; place: string; body: Body; hot: Hot; stop: number; wait: number;
333
+ patrol?: NonNullable<Story['patrols']>[number]; target?: Place; journey?: Place[]; leaving?: Place; arriving?: boolean; blocked?: boolean };
334
+
335
+ class Game {
336
+ data!: Data;
337
+ place!: Place;
338
+ private cache = new Map<string, Place>();
339
+ private loads = new Map<string, Promise<Place>>();
340
+ private figures = new Map<string, Figure>();
341
+ /** Bodies keep their positions when the hero leaves a place. Off-screen patrols pause. */
342
+ private npcs = new Map<string, Npc>();
343
+ private meeting?: Npc;
344
+ /** phase counts walk cycles; it only advances with distance walked, so feet keep pace with the floor. */
345
+ hero: Body = newBody();
346
+ /**
347
+ * The other heroes on screen (?pads=party): one per extra gamepad, the hero's figure with a ring in its player's
348
+ * colour. They walk on their own; whoever uses something becomes `hero` for it (lead), so the story stays one.
349
+ */
350
+ party: Body[] = [];
351
+ /** A walk-and-use is under way: the lead does not change hands until it ends. */
352
+ busy = false;
353
+ speech: { text: string; until: number; who?: string; from: number; audioUntil?: number } | null = null;
354
+ state!: State;
355
+ /** The inventory item the next click uses. */
356
+ holding: string | undefined;
357
+ readonly bag = new Bag((key) => this.itemLook(key), (key) => this.hold(this.holding === key ? undefined : key), !hideCursor);
358
+ readonly answers = new Answers();
359
+ private endingShown = '';
360
+ hover: Hot | undefined;
361
+ grid = params.has('grid');
362
+ /** 0 clear, 1 black; eases toward darkTarget. */
363
+ dark = 0;
364
+ private darkTarget = 0;
365
+ time = 0;
366
+ /** The diorama camera, -1..1 each way; eases toward where the pointer and the hero pull it. */
367
+ cam: Camera = { x: 0, y: 0 };
368
+ /** The scene column at the middle of the view; W/2 unless the camera tracks the hero. */
369
+ viewX = 0;
370
+ /** The scene row at the middle of the view; H/2 unless tracking, which keeps the hero's feet in view. */
371
+ viewY = 0;
372
+ private pending: (() => void) | null = null;
373
+ /** The box of the thing the hero was sent to: a tracking camera keeps it in view until they are done with it. */
374
+ private sentTo: [number, number, number, number] | undefined;
375
+ private listeners: Array<(event: Events, detail: object) => void> = [];
376
+ frame = 0;
377
+
378
+ async start() {
379
+ this.data = await (await fetch('world-data.json')).json();
380
+ useLanguage(this.data.world.language);
381
+ if (loading) loading.querySelector('.what')!.textContent = `Unfolding ${this.data.world.title}…`;
382
+ const at = params.get('place');
383
+ // The first place loads beside the cast, not after it: the two are most of the wait.
384
+ const first = this.load(at ?? startState(this.data.world).place);
385
+ first.catch(() => undefined);
386
+ await Promise.all(Object.entries(this.data.cast).map(async ([key, cast]) => {
387
+ const img = await loadImage(files(cast.image)).catch(() => undefined);
388
+ if (!img) return;
389
+ const figure: Figure = { img, height: cast.height, upright: cast.upright };
390
+ if (cast.rig && cast.parts) {
391
+ const parts = new Map<PartName, HTMLImageElement>();
392
+ await Promise.all(cast.rig.parts.map(async (p) => parts.set(p.name, await loadImage(files(`${cast.parts}/${p.file}`)))));
393
+ const sheet = cast.rig.sheet ? await loadImage(files(`${cast.parts}/${cast.rig.sheet.file}`)) : undefined;
394
+ const canvas = Object.assign(document.createElement('canvas'), { width: cast.rig.size[0], height: cast.rig.size[1] });
395
+ figure.rig = { rig: cast.rig, parts, sheet, canvas };
396
+ }
397
+ if (cast.sprites && params.get('model') !== '1') {
398
+ const { dir, info } = cast.sprites;
399
+ const sheets = (list: Array<{ sheet: string; yaw: number; stand?: number; off?: number[]; gestures?: string[] }>) =>
400
+ Promise.all(list.map(async (f) => {
401
+ const img = await loadImage(files(`${dir}/${f.sheet}`));
402
+ const walk = [...Array(info.frames).keys()].filter((i) => !f.off?.includes(i));
403
+ return { file: f.sheet, yaw: f.yaw, stand: f.stand ?? 0, gestures: f.gestures ?? ['listen'], img, walk };
404
+ }));
405
+ const [walk, talk] = await Promise.all([sheets(Object.values(info.facings ?? {})), sheets(Object.values(info.talk ?? {})).catch(() => [])]);
406
+ if (walk.length || talk.length) figure.sprites = { info, sheets: walk, talk };
407
+ }
408
+ // A 3D figure needs WebGL; without it (or if the model fails) the 2D rig or cut-out stays.
409
+ else if (cast.model && params.get('model') !== '0') {
410
+ const dir = cast.model.dir;
411
+ figure.model = await import('./hero3d.ts').then(({ Figure3D }) => Figure3D.load(cast.model!.info, (f) => files(`${dir}/${f}`), params.get('modelPaint') === '0' ? 0 : 1))
412
+ .catch((e) => (console.warn(`no 3D figure for ${key}:`, e), undefined));
413
+ }
414
+ if (cast.face && params.get('lipsync') !== '0') {
415
+ try {
416
+ const atlas = await loadImage(files(`${cast.face.dir}/${cast.face.info.image}`));
417
+ const views=[cast.face.info.imagePose?.atlas ?? 'default',cast.face.info.rigPose?.atlas ?? 'default',...Object.entries(cast.face.info.sheets).filter(([name])=>!name.startsWith('model:')).flatMap(([,poses])=>poses.filter(Boolean).map(p=>p!.atlas ?? 'default'))];
418
+ figure.face = new PaintedFace(cast.face.info, atlas,views); figure.faceInfo = cast.face.info;
419
+ figure.model?.setFace(cast.face.info,atlas);
420
+ }
421
+ catch (e) { console.warn(`no facial animation for ${key}:`, e); }
422
+ }
423
+ this.figures.set(key, figure);
424
+ }));
425
+ this.hero.figure = this.figures.get(this.data.world.hero.key);
426
+ // NPCs of the first place were made while their figures still loaded.
427
+ await first.catch(() => undefined);
428
+ for (const n of this.npcs.values()) n.body.figure ??= this.figures.get(n.key);
429
+ this.state = startState(this.data.world);
430
+ if (at) this.state.place = at;
431
+ await this.enter(this.state.place);
432
+ this.ui();
433
+ if (!capture) void this.preload();
434
+ if (this.data.story && !at) await this.say(this.data.story.intro);
435
+ }
436
+
437
+ /** The other places, one by one in the background, so walking into one does not wait on its files. */
438
+ private async preload() {
439
+ for (const p of this.data.world.places) {
440
+ if (!this.data.places[p.key]?.geometry) continue;
441
+ await new Promise((r) => setTimeout(r, 300));
442
+ await this.load(p.key).catch(() => undefined);
443
+ }
444
+ }
445
+
446
+ on(fn: (event: Events, detail: object) => void) {
447
+ this.listeners.push(fn);
448
+ }
449
+
450
+ private emit(event: Events, detail: object) {
451
+ // The frame number lets a recorder cut on the fixed-step clock instead of wall time.
452
+ for (const fn of this.listeners) fn(event, { ...detail, frame: this.frame, time: this.time });
453
+ }
454
+
455
+ /** Plays a list of steps in order, for scripted runs and recordings; resolves when the last one is done. */
456
+ async play(steps: Array<{ walkTo: Point } | { use: string; verb?: Verb; with?: string } | { act: Action } | { wait: number }>) {
457
+ for (const step of steps) {
458
+ if ('walkTo' in step) await this.walkTo(...step.walkTo);
459
+ else if ('use' in step) {
460
+ this.holding = step.with;
461
+ await this.use(step.use, step.verb);
462
+ } else if ('act' in step) await this.act(step.act);
463
+ else await this.wait(step.wait);
464
+ }
465
+ }
466
+
467
+ /** For QA: a figure's walk drawn at `frames` evenly spaced phases (plus standing), side by side, as a PNG data URL. */
468
+ figureSheet(key: string, frames = 8): string | undefined {
469
+ const rig = this.figures.get(key)?.rig;
470
+ if (!rig) return undefined;
471
+ const [w, h] = rig.rig.size;
472
+ const sheet = Object.assign(document.createElement('canvas'), { width: w * (frames + 1), height: h });
473
+ const c = sheet.getContext('2d')!;
474
+ c.fillStyle = '#d8d4cc';
475
+ c.fillRect(0, 0, sheet.width, h);
476
+ for (let i = 0; i <= frames; i++) {
477
+ drawRig(rig, pose(rig.rig, i / frames, i < frames), i / frames, i < frames);
478
+ c.drawImage(rig.canvas, i * w, 0);
479
+ c.fillStyle = '#c33';
480
+ c.fillRect(i * w, rig.rig.ground, w, 1);
481
+ }
482
+ return sheet.toDataURL('image/png');
483
+ }
484
+
485
+ /** Waits on the game clock, so a wait lasts the same number of frames in capture mode. */
486
+ wait(ms: number): Promise<void> {
487
+ const until = this.time + ms / 1000;
488
+ return new Promise((resolve) => {
489
+ const check = () => (this.time >= until ? resolve() : setTimeout(check, 10));
490
+ check();
491
+ });
492
+ }
493
+
494
+ /** The waterfalls, animals and cloud bands a place's layers set moving. */
495
+ private async living(layers: Layers): Promise<Pick<Place, 'falls' | 'critters' | 'clouds'>> {
496
+ const img = (p: string) => loadImage(files(p)).catch(() => undefined);
497
+ const falls = (await Promise.all((layers.falls ?? []).map(async (f) => {
498
+ const i = await img(f.file);
499
+ return i && new Fall(i, f.box);
500
+ }))).filter((f): f is Fall => !!f);
501
+ const critters = (await Promise.all((layers.critters ?? []).map(async (c, k) => {
502
+ const i = await img(c.file);
503
+ return i && new Critter(i, c.box, c.medium, c.facing, k);
504
+ }))).filter((c): c is Critter => !!c);
505
+ let clouds: Clouds | undefined;
506
+ const cl = layers.clouds;
507
+ if (cl) {
508
+ const [clip, keep, ...bands] = await Promise.all([img(cl.clip), cl.keep ? img(cl.keep) : Promise.resolve(undefined), ...cl.bands.map((b) => img(b.file))]);
509
+ if (clip && bands.every(Boolean)) clouds = new Clouds(cl.bands.map((b, i) => ({ img: bands[i]!, box: b.box, speed: b.speed })), clip, keep, cl.box);
510
+ }
511
+ return { falls, critters, clouds };
512
+ }
513
+
514
+ /** A place, loaded once: a second ask while it loads (the preload, an NPC's walk) waits on the same files. */
515
+ load(key: string): Promise<Place> {
516
+ let p = this.loads.get(key);
517
+ if (!p) {
518
+ p = this.loadPlace(key);
519
+ this.loads.set(key, p);
520
+ p.catch(() => this.loads.delete(key));
521
+ }
522
+ return p;
523
+ }
524
+
525
+ private async loadPlace(key: string): Promise<Place> {
526
+ if (this.cache.has(key)) return this.cache.get(key)!;
527
+ const spec = this.data.world.places.find((p) => p.key === key);
528
+ const entry = this.data.places[key];
529
+ if (!spec || !entry?.geometry) throw new Error(`${key}: ${entry?.issue ?? 'not a place of this world'}`);
530
+ const g = entry.geometry;
531
+ const dir = (p: string) => files(`places/${key}/${p}`);
532
+ const bg = entry.painting ? await loadImage(files(entry.painting)) : undefined;
533
+ const backdrop = entry.backdrop && parallaxScale > 0 ? await loadImage(files(entry.backdrop)).catch(() => undefined) : undefined;
534
+ const edge = backEdge(g);
535
+ let far: Place['far'];
536
+ if (backdrop && entry.layers) {
537
+ const sky = entry.layers.sky ? await loadImage(files(entry.layers.sky)).catch(() => undefined) : undefined;
538
+ const imgs = await Promise.all(entry.layers.flats.map((f) => loadImage(files(f.file)).catch(() => undefined)));
539
+ const ds = flatDisparities(g, edge, imgs.length);
540
+ if (imgs.every(Boolean)) far = { sky, flats: entry.layers.flats.map((f, i) => ({ img: imgs[i]!, box: f.box, d: ds[i] })) };
541
+ }
542
+ let water: Place['water'];
543
+ const w = entry.layers?.water;
544
+ if (backdrop && w && !params.has('still')) {
545
+ const [tex, mask] = await Promise.all([loadImage(files(w.tex)), loadImage(files(w.mask))].map((p) => p.catch(() => undefined)));
546
+ if (tex && mask) water = { tex, mask, box: w.box, flow: w.flow ?? [1, 0], canvas: Object.assign(document.createElement('canvas'), { width: w.box[2] - w.box[0], height: w.box[3] - w.box[1] }) };
547
+ }
548
+ const smoke = backdrop && entry.layers?.smoke?.length && !params.has('still')
549
+ ? (await Promise.all(entry.layers.smoke.map(async (sm) => ({ img: await loadImage(files(sm.file)).catch(() => undefined), box: sm.box })))).filter((sm): sm is { img: HTMLImageElement; box: [number, number, number, number] } => !!sm.img)
550
+ : undefined;
551
+ const floats: Place['floats'] = backdrop && entry.layers?.floats?.length && !params.has('still')
552
+ ? (await Promise.all(entry.layers.floats.map(async (f) => ({ img: await loadImage(files(f.file)).catch(() => undefined), box: f.box })))).filter((f): f is { img: HTMLImageElement; box: [number, number, number, number] } => !!f.img)
553
+ : undefined;
554
+ // A flat cut around a rocking boat would hold a still copy of it under the float: the float alone carries it.
555
+ if (far && floats?.length) far = { ...far, flats: flatsUnderFloats(far.flats, floats) };
556
+ // The backdrop holds the boat too: under each float lies the sky it was painted out of, in the float's shape.
557
+ if (far?.sky) for (const f of floats ?? []) {
558
+ const [x0, y0, x1, y1] = f.box;
559
+ const under = Object.assign(document.createElement('canvas'), { width: x1 - x0, height: y1 - y0 });
560
+ const u = under.getContext('2d')!;
561
+ u.drawImage(far.sky, x0, y0, x1 - x0, y1 - y0, 0, 0, x1 - x0, y1 - y0);
562
+ u.globalCompositeOperation = 'destination-in';
563
+ u.drawImage(f.img, 0, 0, x1 - x0, y1 - y0);
564
+ f.under = under;
565
+ }
566
+ const living = backdrop && entry.layers && !params.has('still') ? await this.living(entry.layers) : {};
567
+ let mist: Place['mist'];
568
+ const m = entry.layers?.mist;
569
+ if (backdrop && m && !params.has('still')) {
570
+ const [tex, mask] = await Promise.all([loadImage(files(m.tex)), loadImage(files(m.mask))].map((p) => p.catch(() => undefined)));
571
+ if (tex && mask) mist = { tex, mask, box: m.box, drift: m.drift ?? 0, speed: m.speed ?? 1, canvas: Object.assign(document.createElement('canvas'), { width: m.box[2] - m.box[0], height: m.box[3] - m.box[1] }) };
572
+ }
573
+ const maskOf = (p?: string) => (p ? loadBits(dir(p), g.frame.width, g.frame.height).catch(() => undefined) : Promise.resolve(undefined));
574
+ const byPath = new Map<string, Promise<Bits | undefined>>();
575
+ const shared = (p?: string) => {
576
+ if (!p) return Promise.resolve(undefined);
577
+ if (!byPath.has(p)) byPath.set(p, maskOf(p));
578
+ return byPath.get(p)!;
579
+ };
580
+ const hots: Hot[] = await Promise.all(
581
+ g.things.map(async (thing) => {
582
+ const mask = await shared(thing.mask);
583
+ const [x0, y0, x1, y1] = thing.box;
584
+ const plate = thing.plate ? await loadImage(dir(thing.plate)).catch(() => undefined) : undefined;
585
+ return { thing, spec: spec.things.find((t) => t.key === thing.key), mask, plate, area: mask?.area ?? (x1 - x0) * (y1 - y0) };
586
+ }),
587
+ );
588
+ hots.sort((a, b) => a.area - b.area);
589
+ // With parallax, a thing cut into stacked pieces slides as one card (see stackCards).
590
+ const cards: Array<Occluder & { stacked?: boolean }> = backdrop ? stackCards(g.occluders) : g.occluders;
591
+ const cuts = await Promise.all(cards.map(async (occ) => this.cut(occ, bg, await shared(occ.mask), g, occ.stacked)));
592
+ const byKey = new Map(g.occluders.map((o) => [o.key, o]));
593
+ const kept = cuts.filter((c): c is Cut => !!c).map((c) => ({ ...c, y: cardY(g, c.occ, (k) => byKey.get(k)) }));
594
+ // Far cards first, so nearer ones cover them when they slide.
595
+ kept.sort((a, b) => a.y - b.y);
596
+ const shade = await shared(g.shadows);
597
+ const depth = g.depth ? await loadDepth(dir(g.depth)).catch(() => undefined) : undefined;
598
+ const wet = depth && entry.layers?.water ? await loadWet(files(entry.layers.water.mask), entry.layers.water.box, g.frame.width, g.frame.height).catch(() => undefined) : undefined;
599
+ // ?weather=0 (or ?still) keeps the place dry, for pixel checks.
600
+ const wx = entry.weather && !params.has('still') && params.get('weather') !== '0' ? entry.weather : undefined;
601
+ const ripples = wx?.ripples ? await loadBits(files(wx.ripples), g.frame.width, g.frame.height).catch(() => undefined) : undefined;
602
+ const weather = wx && new WeatherFx(wx, g, ripples?.bits, key);
603
+ const reactions = entry.reactions && params.get('reactions') !== '0' ? new ReactionFx(entry.reactions, g, key, matchMedia('(prefers-reduced-motion: reduce)').matches) : undefined;
604
+ // ?glow=0 (or ?still; capture unless ?glow=1) keeps the painted lights as painted, for pixel checks.
605
+ const glows = (entry.glows?.length || bg) && !params.has('still') && (params.get('glow') ?? (capture ? '0' : '1')) !== '0' ? new GlowFx(entry.glows ?? [], g.frame.height, bg, wet, backdrop ? kept.map((c) => c.occ.box) : []) : undefined;
606
+ const focus = focusScale > 0 || separateScale > 0 ? depth ?? standIn(g, edge, kept) : undefined;
607
+ const surf = water && !params.has('still') ? new Surf(water.tex, water.mask, water.box, backdrop) : undefined;
608
+ const place: Place = { key, spec, g, grid: new WalkGrid(g), bg, backdrop, pivot: pivot(g), edge, far, water, mist, smoke, floats, surf, ...living, light: bg && lightMap(bg, g.frame.width, g.frame.height), shade, depth, focus, wet, hots, cuts: kept, byKey, weather, reactions, glows };
609
+ this.cache.set(key, place);
610
+ for (const hot of hots.filter((h) => (h.spec?.role ?? h.thing.role) === 'npc')) {
611
+ const id = `${key}/${hot.thing.key}`;
612
+ if (this.npcs.has(id)) continue;
613
+ const body = newBody();
614
+ [body.x, body.y] = [(hot.thing.box[0] + hot.thing.box[2]) / 2, hot.thing.box[3]];
615
+ body.figure = this.figures.get(hot.thing.key);
616
+ const copy: Hot = { ...hot, thing: { ...hot.thing } };
617
+ const npc: Npc = { key: hot.thing.key, home: key, place: key, body, hot: copy, stop: 0, wait: 2 };
618
+ copy.npc = npc;
619
+ this.npcs.set(id, npc);
620
+ }
621
+ return place;
622
+ }
623
+
624
+ /** The painting's own pixels of an occluder, to put back over the hero where he stands behind it. */
625
+ private cut(occ: Occluder, bg: HTMLImageElement | undefined, mask: Bits | undefined, g: Geometry, closeGaps = false): Cut | undefined {
626
+ if (!bg) return undefined;
627
+ const x0 = Math.max(0, Math.floor(occ.box[0]));
628
+ const y0 = Math.max(0, Math.floor(occ.box[1]));
629
+ const w = Math.min(g.frame.width, Math.ceil(occ.box[2])) - x0;
630
+ const h = Math.min(g.frame.height, Math.ceil(occ.box[3])) - y0;
631
+ if (w <= 0 || h <= 0) return undefined;
632
+ const c = Object.assign(document.createElement('canvas'), { width: w, height: h });
633
+ const cx = c.getContext('2d')!;
634
+ cx.drawImage(bg, x0, y0, w, h, 0, 0, w, h);
635
+ if (mask) {
636
+ const img = cx.getImageData(0, 0, w, h);
637
+ const on = (x: number, y: number) => mask.bits[(y0 + y) * g.frame.width + x0 + x] === 1;
638
+ for (let x = 0; x < w; x++) {
639
+ // A stacked card fills short gaps down each column, so the piece SAM missed between two parts stays on it.
640
+ let last = -1;
641
+ for (let y = 0; y < h; y++) {
642
+ if (on(x, y)) {
643
+ if (closeGaps && last >= 0 && y - last - 1 <= STACK_GAP) for (let f = last + 1; f < y; f++) img.data[(f * w + x) * 4 + 3] = 255;
644
+ last = y;
645
+ } else img.data[(y * w + x) * 4 + 3] = 0;
646
+ }
647
+ }
648
+ cx.putImageData(img, 0, 0);
649
+ }
650
+ return { occ, canvas: c, base: c, x0, y0, y: occ.baseY };
651
+ }
652
+
653
+ /** Enters a place, at the thing that leads back to where the hero came from, or at its start. */
654
+ async enter(key: string, from?: string) {
655
+ const place = await this.load(key);
656
+ this.place = place;
657
+ this.drawn = [];
658
+ this.hover = undefined;
659
+ this.hinted = undefined;
660
+ await this.syncNpcs();
661
+ place.reactions?.reset();
662
+ const back = from ? place.spec.exits.find((e) => e.to === from) : undefined;
663
+ const arrive = back ? place.g.things.find((t) => t.key === back.via)?.stand : undefined;
664
+ [this.hero.x, this.hero.y] = arrive ?? place.g.start;
665
+ // Coming over a bridge or up the steps, the hero walks in from the exit's far end (by the frame's edge) to
666
+ // where one stands at it, instead of popping up in the middle of the place.
667
+ const edge = back && arrive ? this.exitEdge(back.via) : undefined;
668
+ if (edge) {
669
+ [this.hero.x, this.hero.y] = edge;
670
+ this.hero.path = this.place.grid.route(edge, arrive!);
671
+ }
672
+ // ?spawn=x,y,left|right places the hero exactly on the first entry, to match a shot cut into the game.
673
+ const spawn = !from && params.get('spawn')?.split(',');
674
+ if (spawn && spawn.length >= 2) {
675
+ [this.hero.x, this.hero.y] = [Number(spawn[0]), Number(spawn[1])];
676
+ if (spawn[2]) this.hero.facing = spawn[2] === 'left' ? -1 : 1;
677
+ this.hero.path = [];
678
+ }
679
+ if (!edge) this.hero.path = [];
680
+ this.hero.foot = undefined;
681
+ this.hero.along = undefined;
682
+ for (const b of this.party) this.beside(b);
683
+ this.speech = null;
684
+ this.darkTarget = 0;
685
+ this.size();
686
+ this.fillAt = undefined;
687
+ document.getElementById('where')!.textContent = shipped ? this.data.world.title : `${this.data.world.title} · ${key}${place.g.preview ? ' (layout preview)' : ''}`;
688
+ this.emit('enter', { place: key, from });
689
+ }
690
+
691
+ /**
692
+ * Where an exit leads off: its walkable spot nearest the frame's edge (a bridge's far end, the foot of the steps),
693
+ * when that is a few steps from where one stands to use it and the way there is open. Undefined otherwise (a boat
694
+ * out on the water, a door one stands right at).
695
+ */
696
+ exitEdge(via: string): Point | undefined {
697
+ const { g, grid } = this.place;
698
+ const t = g.things.find((th) => th.key === via);
699
+ if (!t?.stand || !t.box) return undefined;
700
+ const { width: W, height: H } = g.frame;
701
+ // The frame side the exit runs off toward, when it reaches near it (a bridge whose planks run on past its box).
702
+ const sides = [
703
+ { gap: t.box[0], box: [0, t.box[1], t.box[2], t.box[3]], d: (x: number) => x },
704
+ { gap: W - t.box[2], box: [t.box[0], t.box[1], W, t.box[3]], d: (x: number) => W - x },
705
+ { gap: H - t.box[3], box: [t.box[0], t.box[1], t.box[2], H], d: (_x: number, y: number) => H - y },
706
+ ].sort((a, b) => a.gap - b.gap);
707
+ const side = sides[0];
708
+ if (side.gap > 0.2 * W) return undefined;
709
+ const [x0, y0, x1, y1] = side.box;
710
+ let best: Point | undefined;
711
+ let bestD = Infinity;
712
+ for (let y = Math.max(0, y0) + g.cell / 2; y < Math.min(H, y1); y += g.cell) {
713
+ for (let x = Math.max(0, x0) + g.cell / 2; x < Math.min(W, x1); x += g.cell) {
714
+ if (!grid.walkable(x, y)) continue;
715
+ const d = side.d(x, y);
716
+ if (d < bestD) [best, bestD] = [[x, y], d];
717
+ }
718
+ }
719
+ if (!best || bestD > 0.08 * W || Math.hypot(best[0] - t.stand[0], best[1] - t.stand[1]) < 3 * g.cell) return undefined;
720
+ return grid.route(t.stand, best).length ? best : undefined;
721
+ }
722
+
723
+ /** Sizes the canvas for this place's frame at the current resolution. */
724
+ size() {
725
+ const { width: W, height: H } = this.place.g.frame;
726
+ Object.assign(canvas.dataset, { w: String(W), h: String(H) });
727
+ const [cw, ch] = [Math.round(W * res), Math.round(H * res)];
728
+ if (canvas.width !== cw || canvas.height !== ch) Object.assign(canvas, { width: cw, height: ch });
729
+ }
730
+
731
+ /** Walks to a point; resolves on arrival, or at once when there is no way there. Another body (`who`) just sets off. */
732
+ walkTo(x: number, y: number, who: Body = this.hero): Promise<boolean> {
733
+ if (who !== this.hero) return Promise.resolve(this.route(who, x, y).length > 0);
734
+ this.pending?.();
735
+ const path = this.route(this.hero, x, y);
736
+ if (!path.length) return Promise.resolve(false);
737
+ return new Promise((resolve) => {
738
+ this.pending = () => {
739
+ this.pending = null;
740
+ resolve(this.hero.path.length === 0);
741
+ };
742
+ });
743
+ }
744
+
745
+ /** Sets a body's path to a point and returns it (empty when there is no way there). */
746
+ private route(who: Body, x: number, y: number): Point[] {
747
+ // Along one step the walk follows it; elsewhere nobody stops on a riser, so on stairs the walk ends
748
+ // on the next tread ahead.
749
+ let path = this.alongStep([who.x, who.y], [x, y]);
750
+ who.along = path ? stepAt(this.place.g, who.x, who.y)?.step : undefined;
751
+ if (!path) {
752
+ const ry = stepRestY(this.place.g, x, y, y < who.y ? -1 : 1);
753
+ if (ry !== y && this.place.grid.walkable(x, ry)) y = ry;
754
+ path = this.place.grid.route([who.x, who.y], [x, y]);
755
+ }
756
+ who.path = path;
757
+ return path;
758
+ }
759
+
760
+ /**
761
+ * A walk along one stair step: it follows the step's curve instead of a straight line that would cut
762
+ * across to the next step. Undefined when the two points are not on the same step or the way is blocked.
763
+ */
764
+ private alongStep(from: Point, to: Point): Point[] | undefined {
765
+ const g = this.place.g;
766
+ const a = stepAt(g, ...from);
767
+ const b = stepAt(g, ...to);
768
+ if (!a || !b || a.step !== b.step) return undefined;
769
+ const n = Math.max(1, Math.ceil(Math.abs(to[0] - from[0]) / 12));
770
+ const path: Point[] = [];
771
+ for (let k = 1; k <= n; k++) {
772
+ const x = from[0] + ((to[0] - from[0]) * k) / n;
773
+ // It ends on the tread part of the band, where the feet stand still.
774
+ const end = Math.min(0.7, Math.max(0.3, b.at));
775
+ // Round a hole in the floor (a thing's foot) by moving across the tread, not off the step.
776
+ const want = a.at + ((end - a.at) * k) / n;
777
+ // A little past the band's edges too: a thing standing at the step's end may take its whole depth.
778
+ const ats = Array.from({ length: 45 }, (_, j) => -0.6 + j / 20).sort((p, q) => Math.abs(p - want) - Math.abs(q - want));
779
+ const y = [want, ...ats].map((at) => onStep(g, a.step, x, at)).find((v) => v !== undefined && this.place.grid.walkable(x, v));
780
+ if (y === undefined) return undefined;
781
+ path.push([x, y]);
782
+ }
783
+ return path;
784
+ }
785
+
786
+ /** Puts a party body on the floor next to the hero (or on the hero's spot when nothing beside is free). */
787
+ beside(b: Body) {
788
+ const g = this.place.g;
789
+ const gap = personPx(g, this.hero.y) * 0.45;
790
+ const k = this.party.indexOf(b) + 1;
791
+ const offsets = [k * gap, -k * gap, (k + 1) * gap, -(k + 1) * gap, 0];
792
+ const spot = offsets.map((dx) => this.place.grid.snap(this.hero.x + dx, this.hero.y)).find((p) => !!p) ?? [this.hero.x, this.hero.y];
793
+ [b.x, b.y] = spot;
794
+ Object.assign(b, { path: [], foot: undefined, along: undefined, facing: this.hero.facing, figure: b.figure ?? this.hero.figure });
795
+ }
796
+
797
+ /** The figure player n (from 1) plays: the hero's, then each drawn player's; the hero's when there are too few. */
798
+ playerFigure(n: number): Figure | undefined {
799
+ const key = n > 1 ? this.data.players?.[n - 2] : undefined;
800
+ return (key && this.figures.get(key)) || this.figures.get(this.data.world.hero.key);
801
+ }
802
+
803
+ /** A new party member next to the hero, in a player's colour, as its own drawn hero when it has one. */
804
+ join(colour: string, figure?: Figure): Body {
805
+ const b = { ...newBody(), colour, figure };
806
+ this.party.push(b);
807
+ if (this.place) this.beside(b);
808
+ return b;
809
+ }
810
+
811
+ /** Makes a party body the hero (the one the story, the camera and the bubbles follow); the old hero joins the party. */
812
+ lead(b: Body) {
813
+ if (b === this.hero || this.busy) return;
814
+ const i = this.party.indexOf(b);
815
+ if (i < 0) return;
816
+ this.pending?.();
817
+ this.party[i] = this.hero;
818
+ this.hero = b;
819
+ }
820
+
821
+ /** Walks to a thing, faces it and clicks it: through an exit, talk to someone, or the verb the story offers. */
822
+ async use(key: string, verb?: Verb) {
823
+ this.busy = true;
824
+ this.sentTo = this.hots().find((h) => h.thing.key === key)?.thing.box;
825
+ try {
826
+ await this.useNow(key, verb);
827
+ } finally {
828
+ this.sentTo = undefined;
829
+ this.busy = false;
830
+ }
831
+ }
832
+
833
+ private async useNow(key: string, verb?: Verb) {
834
+ const hot = this.hots().find((h) => h.thing.key === key);
835
+ if (!hot) throw new Error(`${this.place.key} has no thing "${key}"`);
836
+ if (hot.npc?.target) return;
837
+ this.meeting = hot.npc;
838
+ try {
839
+ const placeKey = this.place.key;
840
+ const [sx, sy] = this.approach(hot);
841
+ if (!(await this.walkTo(sx, sy)) && Math.hypot(this.hero.x - sx, this.hero.y - sy) > this.place.g.cell * 2) return;
842
+ if (this.place.key !== placeKey) return;
843
+ const [x0, , x1] = hot.thing.box;
844
+ this.hero.facing = (hot.npc?.body.x ?? (x0 + x1) / 2) >= this.hero.x ? 1 : -1;
845
+ // Three-quarter towards it, the way a painted figure talks to someone.
846
+ this.hero.yaw = this.hero.facing * Math.PI / 3;
847
+ this.emit('use', { thing: key, verb });
848
+ document.body.classList.add('played');
849
+ await this.act(this.actionFor(key, hot, verb));
850
+ } finally {
851
+ if (this.meeting) this.meeting.wait = 2;
852
+ this.meeting = undefined;
853
+ }
854
+ }
855
+
856
+ /** The thing the hero stands near enough to use from the keyboard: the one whose spot is nearest, within reach. */
857
+ within(who: Body = this.hero): Hot | undefined {
858
+ const gone = this.gone();
859
+ let best: Hot | undefined;
860
+ let bestD = personPx(this.place.g, who.y) * 1.2;
861
+ for (const hot of this.hots()) {
862
+ if (gone.has(hot.thing.key)) continue;
863
+ const [sx, sy] = this.approach(hot, who);
864
+ const d = Math.hypot(sx - who.x, sy - who.y);
865
+ if (d < bestD) [best, bestD] = [hot, d];
866
+ }
867
+ return best;
868
+ }
869
+
870
+ /** Where to stand to use a thing; beside a person rather than inside them, on the side the hero comes from. */
871
+ private approach(hot: Hot, who: Body = this.hero): Point {
872
+ if ((hot.spec?.role ?? hot.thing.role) !== 'npc') return hot.thing.stand;
873
+ const [x0, , x1, y1] = hot.thing.box;
874
+ const x = hot.npc?.body.x ?? (x0 + x1) / 2;
875
+ const y = hot.npc?.body.y ?? y1;
876
+ const side = who.x < x ? -1 : 1;
877
+ const gap = personPx(this.place.g, y) * 0.42;
878
+ for (const s of [side, -side]) {
879
+ const spot = this.place.grid.snap(x + s * gap, y);
880
+ if (spot && Math.abs(spot[0] - x) > gap * 0.6) return spot;
881
+ }
882
+ return this.place.grid.snap(x, y) ?? hot.thing.stand;
883
+ }
884
+
885
+ /** What a click on a thing means: the exit, the talk, the held item, or the first verb the story answers. */
886
+ private actionFor(key: string, hot: Hot, verb?: Verb): Action {
887
+ if (verb === 'look') return { kind: 'do', thing: key, verb: 'look' };
888
+ if (this.place.spec.exits.some((e) => e.via === key)) return { kind: 'go', via: key };
889
+ const role = hot.spec?.role ?? hot.thing.role;
890
+ // A person talks: in a conversation when the story has one for them now, else in their own talk lines.
891
+ const story = this.data.story;
892
+ if (role === 'npc' && !this.holding && (!story || pickDialogue(story, this.state, key, this.data.world) >= 0)) return { kind: 'talk', npc: key };
893
+ if (this.holding) return { kind: 'do', thing: key, verb: 'use', with: this.holding };
894
+ if (verb) return { kind: 'do', thing: key, verb };
895
+ const offered = story ? (['take', 'use', 'talk'] as const).find((v) => pickInteraction(story, this.state, { thing: key, verb: v }, this.data.world) >= 0) : undefined;
896
+ return { kind: 'do', thing: key, verb: offered ?? 'look' };
897
+ }
898
+
899
+ /** Runs one story action: says its lines, follows a change of place, shows an ending. */
900
+ async act(action: Action) {
901
+ const story = this.data.story;
902
+ const from = this.state.place;
903
+ const before = this.state;
904
+ // A new action supersedes speech from the previous one, including replies that have not started yet.
905
+ const speechTurn = ++this.speechTurn;
906
+ this.speech = null;
907
+ this.emit('silent', {});
908
+ let say: string[];
909
+ if (story) {
910
+ const matched = action.kind === 'do' ? pickInteraction(story, this.state, action, this.data.world) : -1;
911
+ const step = apply(this.data.world, story, this.state, action);
912
+ if (matched >= 0 && action.kind === 'do' && !this.gone().has(action.thing)) {
913
+ const target = this.hots().find((h) => h.thing.key === action.thing);
914
+ // act() is also exposed to scripts; physical effects require the hero to be in reach.
915
+ if (target) {
916
+ const [x, y] = this.approach(target);
917
+ if (Math.hypot(this.hero.x - x, this.hero.y - y) <= this.place.g.cell * 2)
918
+ this.place.reactions?.triggers.interact(JSON.stringify(story.interactions[matched]), this.hero, (k) => this.thingY(k));
919
+ }
920
+ }
921
+ this.state = step.state;
922
+ say = step.say;
923
+ // A new item flies into the bag from the thing it was, or from whoever handed it over.
924
+ const from = action.kind === 'do' ? action.thing : action.kind === 'talk' ? action.npc : before.talk ? story.dialogues[before.talk.dialogue]?.npc : undefined;
925
+ for (const key of this.state.items.filter((i) => !before.items.includes(i))) void this.bag.pickUp(key, from ? this.pageRect(from) : undefined);
926
+ } else {
927
+ // Without story.json the world alone answers: exits lead on, everything else is looked at.
928
+ const exit = action.kind === 'go' ? this.place.spec.exits.find((e) => e.via === action.via) : undefined;
929
+ if (exit) this.state = { ...this.state, place: exit.to };
930
+ const key = action.kind === 'do' ? action.thing : action.kind === 'talk' ? action.npc : '';
931
+ say = exit || !key ? [] : [this.hots().find((h) => h.thing.key === key)?.spec?.looks ?? key];
932
+ }
933
+ if (action.kind === 'do' && action.with) this.holding = undefined;
934
+ if (!say.length && action.kind === 'do') say = [action.with ? this.nopeLine(action.with) : this.lookLine(action.thing, action.verb)];
935
+ // Stuck at a lock, or an item tried on the wrong thing: where to go next glows, and the hero says so.
936
+ const nudge = story && this.nudge(before, action);
937
+ if (nudge) {
938
+ this.hinted = { key: nudge.thing, until: Infinity };
939
+ say = [...say, nudge.with ? words.hintWith(this.itemLook(nudge.with).name) : words.hint];
940
+ }
941
+ this.ui();
942
+ // Who says each line: in a conversation the NPC answers after the hero's choice; everything else is the hero's.
943
+ const hero = this.data.world.hero.key;
944
+ const npc = story && (action.kind === 'talk' ? action.npc : action.kind === 'choose' && before.talk ? story.dialogues[before.talk.dialogue]?.npc : undefined);
945
+ // A person talked to without a conversation answers with their own talk line.
946
+ const answers = action.kind === 'do' && speakerOf(this.data.world, { place: from, thing: action.thing, verb: action.verb }) !== hero;
947
+ const after = this.state;
948
+ // Keep action side effects in order, but skip outdated speech if another action was picked in the meantime.
949
+ const turn = this.saying.then(async () => {
950
+ for (const [i, line] of say.entries()) {
951
+ if (speechTurn !== this.speechTurn) break;
952
+ await this.say(line, answers ? (action as { thing: string }).thing : npc && (action.kind === 'talk' || i > 0) ? npc : hero);
953
+ }
954
+ // The hint keeps glowing a while after it is said.
955
+ if (nudge && this.hinted?.key === nudge.thing) this.hinted = { key: nudge.thing, until: this.time + 6 };
956
+ if (after.place !== from) {
957
+ // Out the same way: along the bridge or down the steps toward the frame's edge before the picture fades.
958
+ const edge = action.kind === 'go' ? this.exitEdge(action.via) : undefined;
959
+ if (edge) await Promise.race([this.walkTo(...edge), this.wait(2500)]);
960
+ this.emit('exit', { from, to: after.place });
961
+ await this.fadeOut();
962
+ await this.enter(after.place, from);
963
+ }
964
+ await this.syncNpcs();
965
+ if (after.ending) this.emit('ending', { ending: after.ending });
966
+ this.ui();
967
+ });
968
+ this.saying = turn.catch(() => undefined);
969
+ await turn;
970
+ }
971
+
972
+ /** A thing glowing for a while as the next step (see nudge), its outline blinking. */
973
+ private hinted: { key: string; until: number } | undefined;
974
+
975
+ /** The next step for a player stuck at a lock or with an item on the wrong thing, when it lies in this place. */
976
+ private nudge(before: State, action: Action): { thing: string; with?: string } | undefined {
977
+ const story = this.data.story;
978
+ if (!story || before.place !== this.state.place) return undefined;
979
+ if (action.kind === 'go') {
980
+ const lock = lockOn(story, before, action.via);
981
+ const hint = lock && hintFor(story, before, lock.until);
982
+ return hint && hint.place === before.place && hint.thing !== action.via && this.hots().some((h) => h.thing.key === hint.thing) ? hint : undefined;
983
+ }
984
+ if (action.kind === 'do' && action.with && this.state === before) {
985
+ const thing = useFor(story, before, action.with);
986
+ return thing && thing !== action.thing && this.hots().some((h) => h.thing.key === thing) ? { thing, with: action.with } : undefined;
987
+ }
988
+ return undefined;
989
+ }
990
+
991
+ /** Action effects stay ordered; a new action invalidates older speech. */
992
+ private saying: Promise<void> = Promise.resolve();
993
+ private speechTurn = 0;
994
+
995
+ /** An item tried where it does not help: the story's own line for it, else a plain one. */
996
+ private nopeLine(item: string): string {
997
+ const it = this.data.story?.items.find((i) => i.key === item);
998
+ return it?.nope ?? words.nope(it?.name ?? item.replace(/-/g, ' '));
999
+ }
1000
+
1001
+ private lookLine(thing: string, verb: string): string {
1002
+ const looks = this.hots().find((h) => h.thing.key === thing)?.spec?.looks ?? thing.replace(/-/g, ' ');
1003
+ return verb === 'look' ? looks : `Nothing happens. ${looks[0].toUpperCase()}${looks.slice(1)}.`;
1004
+ }
1005
+
1006
+ /** An item's name from the story and its icon from the icons step. */
1007
+ itemLook(key: string): ItemLook {
1008
+ const icon = this.data.items?.[key]?.icon;
1009
+ return { name: this.data.story?.items.find((i) => i.key === key)?.name ?? key.replace(/-/g, ' '), ...(icon && { icon: files(icon) }) };
1010
+ }
1011
+
1012
+ /** Takes an item in hand (it becomes the cursor), or puts it back with undefined. */
1013
+ hold(key: string | undefined) {
1014
+ this.holding = key;
1015
+ this.ui();
1016
+ }
1017
+
1018
+ /** Where a thing (or a person in this place) is on the page, for an item to fly from. */
1019
+ pageRect(key: string): DOMRect | undefined {
1020
+ const figure = this.drawn.find((d) => d.hot.thing.key === key)?.rect;
1021
+ const box = figure ?? this.hots().find((h) => h.thing.key === key)?.thing.box;
1022
+ if (!box) return undefined;
1023
+ const z = this.zoom();
1024
+ const { width: W, height: H } = this.place.g.frame;
1025
+ const [x0, y0] = [(box[0] - (this.viewX || W / 2)) * z + W / 2, (box[1] - (this.viewY || H / 2)) * z + H / 2];
1026
+ const r = canvas.getBoundingClientRect();
1027
+ const k = r.width / W;
1028
+ return new DOMRect(r.left + x0 * k, r.top + y0 * k, (box[2] - box[0]) * z * k, (box[3] - box[1]) * z * k);
1029
+ }
1030
+
1031
+ /** Ends the conversation as its Goodbye would: the player walked away, clicked elsewhere or pressed Esc. */
1032
+ leave(): boolean {
1033
+ if (!this.state.talk || this.state.ending) return false;
1034
+ void this.act({ kind: 'leave' });
1035
+ return true;
1036
+ }
1037
+
1038
+ /** Inventory, conversation choices and the ending, as plain HTML over the canvas. */
1039
+ ui() {
1040
+ const story = this.data.story;
1041
+ if (this.holding && !this.state.items.includes(this.holding)) this.holding = undefined;
1042
+ this.bag.show(this.state.items, this.holding);
1043
+ this.bag.quiet(!!this.state.talk);
1044
+ if (this.state.ending) document.getElementById('inventory')!.hidden = true;
1045
+ const options = story && this.state.talk ? actions(this.data.world, story, this.state) : [];
1046
+ const hero = this.data.world.hero.key;
1047
+ this.answers.show(options.map((a) => ({
1048
+ text: a.kind === 'choose' ? lineChoice(story!, this.state, a.index) : words.goodbye,
1049
+ action: a.kind === 'choose' ? `choose:${a.index}` : a.kind,
1050
+ pick: () => void this.act(a),
1051
+ })), { name: displayName(hero, this.data.world.names), portrait: this.data.cast[hero] && files(this.data.cast[hero].image) });
1052
+ const ending = story?.endings.find((e) => e.key === this.state.ending);
1053
+ // Drawn once per ending, so the card's entrance plays once.
1054
+ if ((ending?.key ?? '') !== this.endingShown) {
1055
+ this.endingShown = ending?.key ?? '';
1056
+ showEnding(ending, this.state.items.map((k) => this.itemLook(k)));
1057
+ }
1058
+ }
1059
+
1060
+ /** Shows (and voices) a line; `who` (a cast key) gestures while it lasts. */
1061
+ say(text: string, who?: string): Promise<void> {
1062
+ // A voiced line stays up until it has been said.
1063
+ const voiced = spokenLine(this.data.sound, text, who ?? 'narrator')?.seconds ?? 0;
1064
+ const seconds = Math.max(2.2 + text.length * 0.045, voiced + 0.5);
1065
+ // A line the story wrote inside quotes ('Off with you!') is shown without them: the bubble is the quotes.
1066
+ this.speech = { text: unquoted(text), until: this.time + seconds, who, from: this.time };
1067
+ this.emit('say', { text, who: who ?? 'narrator' });
1068
+ return new Promise((resolve) => {
1069
+ const done = this.speech;
1070
+ const check = () => (this.speech !== done ? resolve() : setTimeout(check, 50));
1071
+ check();
1072
+ });
1073
+ }
1074
+
1075
+ /** The audio clock owns voiced lines; slow rendering must not lengthen their pauses. */
1076
+ voiceStarted(speech: unknown) {
1077
+ if (this.speech && this.speech === speech) this.speech.audioUntil = Infinity;
1078
+ }
1079
+
1080
+ voiceEnded(speech: unknown) {
1081
+ // Ignore completion of an interrupted line and audio that was muted/suspended.
1082
+ if (this.speech && this.speech === speech && this.speech.audioUntil !== undefined) this.speech.audioUntil = performance.now() + 250;
1083
+ }
1084
+
1085
+ private fadeOut(): Promise<void> {
1086
+ this.darkTarget = 1;
1087
+ return new Promise((resolve) => {
1088
+ const tick = () => (this.dark >= 1 ? resolve() : setTimeout(tick, 16));
1089
+ tick();
1090
+ });
1091
+ }
1092
+
1093
+ private changeImgs = new Map<number, HTMLImageElement | null>();
1094
+
1095
+ /** Images of the story changes that hold now in this place; each loads on first use. */
1096
+ changed(): Array<{ img: HTMLImageElement; i: number }> {
1097
+ const story = this.data.story;
1098
+ if (!story || !this.data.changes) return [];
1099
+ const out: Array<{ img: HTMLImageElement; i: number }> = [];
1100
+ for (const i of activeChanges(story, this.state)) {
1101
+ if (story.changes[i].place !== this.place.key || !this.data.changes[i]) continue;
1102
+ if (!this.changeImgs.has(i)) {
1103
+ this.changeImgs.set(i, null);
1104
+ void loadImage(files(this.data.changes[i])).then((img) => this.changeImgs.set(i, img)).catch(() => undefined);
1105
+ }
1106
+ const img = this.changeImgs.get(i);
1107
+ if (img) out.push({ img, i });
1108
+ }
1109
+ return out;
1110
+ }
1111
+
1112
+ /**
1113
+ * Cards cut again from the painting with the story's changes on it. A card is put back over whoever stands
1114
+ * behind it: cut from the painting as first painted, a changed thing (a basket with a rope across) would show
1115
+ * its old look over them, a hard-edged patch where they stand. The card keeps its shape (base's alpha).
1116
+ */
1117
+ private recut() {
1118
+ const { cuts, bg } = this.place;
1119
+ const held = this.changed();
1120
+ const key = held.map((h) => h.i).join(',');
1121
+ if (!bg || (this.place.cutWith ?? '') === key) return;
1122
+ this.place.cutWith = key;
1123
+ for (const cut of cuts) {
1124
+ if (!held.length) {
1125
+ cut.canvas = cut.base;
1126
+ continue;
1127
+ }
1128
+ const { width: w, height: h } = cut.base;
1129
+ const c = Object.assign(document.createElement('canvas'), { width: w, height: h });
1130
+ const cx = c.getContext('2d')!;
1131
+ cx.drawImage(bg, cut.x0, cut.y0, w, h, 0, 0, w, h);
1132
+ for (const { img } of held) cx.drawImage(img, cut.x0, cut.y0, w, h, 0, 0, w, h);
1133
+ cx.globalCompositeOperation = 'destination-in';
1134
+ cx.drawImage(cut.base, 0, 0);
1135
+ cut.canvas = c;
1136
+ }
1137
+ }
1138
+
1139
+ private readonly shapeBits = new Map<number, Bits | null>();
1140
+
1141
+ /** The shapes of the story changes of `key` that hold now and have loaded (see Data.changeShapes). */
1142
+ private changeShapes(key: string): Array<{ i: number; bits: Bits }> {
1143
+ const story = this.data.story;
1144
+ const paths = this.data.changeShapes;
1145
+ if (!story || !paths) return [];
1146
+ const { width: W, height: H } = this.place.g.frame;
1147
+ return this.changed().flatMap(({ i }) => {
1148
+ if (story.changes[i].thing !== key || !paths[i]) return [];
1149
+ if (!this.shapeBits.has(i)) {
1150
+ this.shapeBits.set(i, null);
1151
+ void loadBits(files(paths[i]), W, H).then((b) => this.shapeBits.set(i, b)).catch(() => undefined);
1152
+ }
1153
+ const bits = this.shapeBits.get(i);
1154
+ return bits ? [{ i, bits }] : [];
1155
+ });
1156
+ }
1157
+
1158
+ /** Things of this place the story has carried off: drawn from their clean plate, not clickable, hiding no one. */
1159
+ gone(): Set<string> {
1160
+ const story = this.data.story;
1161
+ if (!story) return new Set();
1162
+ return new Set(hiddenThings(story, this.state).filter((h) => h.place === this.place.key).map((h) => h.thing));
1163
+ }
1164
+
1165
+ /** Where each NPC was drawn last frame, nearest last, so a click lands on the figure people see. */
1166
+ private drawn: Array<{ hot: Hot; rect: Rect }> = [];
1167
+
1168
+ /** Original NPC hotspots are replaced by the bodies actually in this place. */
1169
+ private hots(): Hot[] {
1170
+ return [...this.place.hots.filter((h) => (h.spec?.role ?? h.thing.role) !== 'npc'),
1171
+ ...[...this.npcs.values()].filter((n) => n.place === this.place.key).map((n) => n.hot)];
1172
+ }
1173
+
1174
+ /** Reconcile the deterministic story location after the action's lines have been heard. */
1175
+ private async syncNpcs() {
1176
+ for (const [key, to] of Object.entries(this.state.npcs ?? {})) {
1177
+ const home = this.data.world.places.find((p) => p.things.some((t) => t.key === key && t.role === 'npc'))?.key;
1178
+ if (!home) continue;
1179
+ await this.load(home);
1180
+ const npc = this.npcs.get(`${home}/${key}`);
1181
+ if (!npc) continue;
1182
+ if (npc.place === to) {
1183
+ if (npc.target) { npc.body.path = []; npc.leaving = undefined; }
1184
+ npc.target = undefined; npc.journey = undefined;
1185
+ continue;
1186
+ }
1187
+ npc.target = await this.load(to);
1188
+ npc.blocked = false;
1189
+ // Several events may fire before a departure finishes. Keep walking through real exits.
1190
+ const paths = [[npc.place]];
1191
+ const seen = new Set([npc.place]);
1192
+ let journey: string[] | undefined;
1193
+ for (let i = 0; i < paths.length; i++) {
1194
+ const path = paths[i];
1195
+ if (path.at(-1) === to) { journey = path.slice(1); break; }
1196
+ for (const e of this.data.world.places.find((p) => p.key === path.at(-1))?.exits ?? [])
1197
+ if (!seen.has(e.to)) { seen.add(e.to); paths.push([...path, e.to]); }
1198
+ }
1199
+ npc.journey = journey ? await Promise.all(journey.map((p) => this.load(p))) : undefined;
1200
+ // If the player has already left, finish the departure off screen.
1201
+ if (npc.place !== this.place.key) this.arriveNpc(npc, npc.target);
1202
+ }
1203
+ }
1204
+
1205
+ private arriveNpc(npc: Npc, target: Place) {
1206
+ const from = npc.place;
1207
+ const destination = npc.target;
1208
+ npc.journey = npc.journey?.slice(1);
1209
+ npc.place = target.key;
1210
+ npc.target = target.key === destination?.key ? undefined : destination;
1211
+ npc.leaving = undefined;
1212
+ npc.blocked = false;
1213
+ npc.patrol = undefined;
1214
+ npc.stop = 0;
1215
+ npc.body.path = [];
1216
+ npc.body.foot = undefined;
1217
+ npc.body.along = undefined;
1218
+ const back = target.spec.exits.find((e) => e.to === from);
1219
+ const entry = target.g.things.find((t) => t.key === back?.via)?.stand ?? target.g.start;
1220
+ [npc.body.x, npc.body.y] = target.grid.snap(...entry) ?? entry;
1221
+ npc.arriving = true;
1222
+ npc.wait = 0;
1223
+ // On return to a former home, use the original standing place rather than a second copy.
1224
+ this.emit('npc-enter', { npc: npc.key, from, to: target.key });
1225
+ if (npc.target && npc.place !== this.place.key) this.arriveNpc(npc, npc.target);
1226
+ }
1227
+
1228
+ private updateNpcs(dt: number) {
1229
+ if (this.state.ending || this.state.place !== this.place.key) return;
1230
+ for (const npc of this.npcs.values()) {
1231
+ if (npc.place !== this.place.key) continue;
1232
+ const body = npc.body;
1233
+ const speaking = this.speech?.who === npc.key;
1234
+ const talking = this.state.talk && this.data.story?.dialogues[this.state.talk.dialogue]?.npc === npc.key;
1235
+ if (npc === this.meeting || speaking || talking) { npc.wait = 2; continue; }
1236
+ const patrol = this.data.story?.patrols?.find((p) => p.npc === npc.key && p.place === npc.place && holds(this.state, p.when));
1237
+ if (patrol !== npc.patrol && !npc.target) { npc.patrol = patrol; npc.stop = 0; body.path = []; npc.wait = 0; }
1238
+ const nextPlace = npc.journey?.[0] ?? npc.target;
1239
+ if (nextPlace && npc.leaving !== nextPlace) {
1240
+ if (npc.blocked) continue;
1241
+ const exit = this.place.spec.exits.find((e) => e.to === nextPlace.key);
1242
+ const spot = this.place.g.things.find((t) => t.key === exit?.via)?.stand;
1243
+ if (!spot || !this.route(body, ...spot).length) {
1244
+ if (!npc.blocked) console.warn(`NPC ${npc.key} cannot walk from ${npc.place} to ${nextPlace.key}`);
1245
+ npc.blocked = true;
1246
+ continue;
1247
+ }
1248
+ npc.leaving = nextPlace;
1249
+ npc.blocked = false;
1250
+ }
1251
+ if (body.path.length) {
1252
+ this.move(body, dt);
1253
+ if (body.path.length) continue;
1254
+ npc.wait = patrol?.pause ?? 2;
1255
+ }
1256
+ if (npc.leaving) { this.arriveNpc(npc, npc.leaving); continue; }
1257
+ npc.wait -= dt;
1258
+ if (npc.wait > 0) continue;
1259
+ let spot: Point | undefined;
1260
+ if (npc.arriving) {
1261
+ const own = this.place.g.things.find((t) => t.key === npc.key);
1262
+ spot = patrol ? this.place.g.things.find((t) => t.key === patrol.via[0])?.stand
1263
+ : own ? [(own.box[0] + own.box[2]) / 2, own.box[3]] : this.place.g.start;
1264
+ npc.arriving = false;
1265
+ } else if (patrol) {
1266
+ const key = patrol.via[npc.stop++ % patrol.via.length];
1267
+ spot = this.place.g.things.find((t) => t.key === key)?.stand;
1268
+ }
1269
+ if (spot) this.route(body, ...spot);
1270
+ npc.wait = patrol?.pause ?? 2;
1271
+ }
1272
+ }
1273
+
1274
+ thingAt(x: number, y: number): Hot | undefined {
1275
+ const g = this.place.g;
1276
+ const gone = this.gone();
1277
+ for (let i = this.drawn.length - 1; i >= 0; i--) {
1278
+ const [x0, y0, x1, y1] = this.drawn[i].rect;
1279
+ // The rect includes the shadow; trim it and the transparent margin at the sides.
1280
+ const inset = (x1 - x0) * 0.12;
1281
+ if (x >= x0 + inset && x <= x1 - inset && y >= y0 && y <= y1 - 4) return this.drawn[i].hot;
1282
+ }
1283
+ const [sx0, sy0] = [x, y];
1284
+ for (const hot of this.hots()) {
1285
+ if (this.drawn.some((d) => d.hot === hot)) continue;
1286
+ if (gone.has(hot.thing.key)) continue;
1287
+ // Each thing is tested where it is drawn: moved by its own parallax shift.
1288
+ [x, y] = this.paintingPoint(hot, sx0, sy0);
1289
+ const [x0, y0, x1, y1] = hot.thing.box;
1290
+ if (x < x0 || x > x1 || y < y0 || y > y1) continue;
1291
+ if (!hot.mask || hot.mask.bits[Math.floor(y) * g.frame.width + Math.floor(x)]) return hot;
1292
+ }
1293
+ return undefined;
1294
+ }
1295
+
1296
+ update(dt: number) {
1297
+ this.time += dt;
1298
+ this.frame++;
1299
+ const hero = this.hero;
1300
+ const step = dt / 0.3;
1301
+ this.dark = this.darkTarget > this.dark ? Math.min(this.darkTarget, this.dark + step) : Math.max(this.darkTarget, this.dark - step);
1302
+ if (this.speech && (this.speech.audioUntil === undefined ? this.time > this.speech.until : performance.now() > this.speech.audioUntil)) {
1303
+ this.speech = null;
1304
+ this.emit('silent', {});
1305
+ }
1306
+ this.swayCamera(dt);
1307
+ this.place.weather?.update(dt);
1308
+ this.place.glows?.step(dt);
1309
+ this.place.reactions?.update(dt);
1310
+ this.updateNpcs(dt);
1311
+ for (const b of this.party) this.move(b, dt);
1312
+ this.move(hero, dt);
1313
+ }
1314
+
1315
+ /** Walks a body along its path for dt; the hero's arrival ends its walkTo. */
1316
+ private move(hero: Body, dt: number) {
1317
+ let budget = personPx(this.place.g, hero.y) * (hero.run ? RUN : WALK) * dt;
1318
+ const fromY = hero.y;
1319
+ // The budget is floor walked, not screen pixels: going into the picture or out of it covers more floor per
1320
+ // pixel than going across, so the body moves fewer pixels there and its feet do not skate over the floor.
1321
+ while (budget > 0 && hero.path.length) {
1322
+ const beforeStep = { x: hero.x, y: hero.y, phase: hero.phase, foot: hero.foot };
1323
+ const [tx, ty] = hero.path[0];
1324
+ const k = depthStretch(this.place.g, hero.y);
1325
+ const d = Math.hypot(tx - hero.x, (ty - hero.y) * k);
1326
+ const moved = Math.min(d, budget);
1327
+ if (Math.abs(tx - hero.x) > 1) hero.facing = tx > hero.x ? 1 : -1;
1328
+ if (d > 0.5) hero.yaw = turnToward(hero.yaw, Math.atan2(tx - hero.x, ty - hero.y), dt * 10);
1329
+ if (d <= budget) {
1330
+ [hero.x, hero.y] = [tx, ty];
1331
+ hero.path.shift();
1332
+ budget -= d;
1333
+ } else {
1334
+ hero.x += ((tx - hero.x) / d) * budget;
1335
+ hero.y += ((ty - hero.y) / d) * budget;
1336
+ budget = 0;
1337
+ }
1338
+ // One cycle per CYCLE heights walked: the rig's planted foot keeps pace with the floor.
1339
+ hero.phase += moved / (CYCLE * personPx(this.place.g, hero.y));
1340
+ this.place.reactions?.triggers.move(hero, beforeStep, { x: hero.x, y: hero.y, phase: hero.phase,
1341
+ foot: stepFootY(this.place.g, hero.x, hero.y) }, this.state);
1342
+ if (!hero.path.length) hero.run = false;
1343
+ if (!hero.path.length && hero === this.hero) {
1344
+ this.emit('arrive', { x: hero.x, y: hero.y });
1345
+ this.pending?.();
1346
+ }
1347
+ }
1348
+ // On stairs the feet only go the way the hero goes: a curved tread would otherwise sink them back
1349
+ // as he crosses it, and they stay put when he stops. Off the stairs (or after a jump) they follow.
1350
+ // Walking along one step, the feet stay on its tread all the way.
1351
+ const alongTread = hero.along === undefined ? undefined : treadY(this.place.g, hero.along, hero.x);
1352
+ const tread = alongTread ?? stepFootY(this.place.g, hero.x, hero.y);
1353
+ const dy = alongTread === undefined ? hero.y - fromY : 0;
1354
+ if (alongTread !== undefined) hero.foot = alongTread;
1355
+ else if (hero.foot === undefined || tread === hero.y || Math.abs(hero.foot - tread) > 0.3 * personPx(this.place.g, hero.y)) hero.foot = tread;
1356
+ else if (dy < 0) hero.foot = Math.min(hero.foot, tread);
1357
+ else if (dy > 0) hero.foot = Math.max(hero.foot, tread);
1358
+ }
1359
+
1360
+ /** ?fill=1: the canvas point at the middle of the page, eased after the hero; undefined until a place shows. */
1361
+ private fillAt: Point | undefined;
1362
+
1363
+ /** ?fill=1: slides the canvas (bigger than the page) so what the hero is doing stays on the page. */
1364
+ fillPan(dt: number) {
1365
+ if (!fill) return;
1366
+ const { width: W, height: H } = this.place.g.frame;
1367
+ const [cw, ch, w, h] = [canvas.offsetWidth, canvas.offsetHeight, innerWidth, innerHeight];
1368
+ if (!cw || !ch) return;
1369
+ const k = cw / W;
1370
+ const z = this.zoom();
1371
+ // Frame points as canvas pixels, through the camera's zoom and pan.
1372
+ const cx = (x: number) => (x - (this.viewX || W / 2)) * z + W / 2;
1373
+ const cy = (y: number) => (y - (this.viewY || H / 2)) * z + H / 2;
1374
+ const hero = this.hero;
1375
+ const tall = personPx(this.place.g, hero.y);
1376
+ const end = hero.path[hero.path.length - 1];
1377
+ const keep: Array<[number, number, number, number]> = [[hero.x - tall / 4, hero.y - tall * 1.1, hero.x + tall / 4, hero.y + 12]];
1378
+ if (end) keep.push([end[0] - tall / 4, end[1] - personPx(this.place.g, end[1]), end[0] + tall / 4, end[1] + 12]);
1379
+ if (this.sentTo) keep.push(this.sentTo);
1380
+ const spans = keep.map(([x0, y0, x1, y1]) => [cx(x0), cy(y0), cx(x1), cy(y1)]);
1381
+ const [hw, hh] = [w / (2 * k), h / (2 * k)];
1382
+ // Across: after the hero; up and down: the feet in the lower part of the page, the rest to the sky.
1383
+ const want: Point = [
1384
+ panTo(cx(hero.x), W / 2, Math.max(0, W / 2 - hw), hw, 24, spans.map((b) => [b[0], b[2]])),
1385
+ panTo(cy(hero.y) - 0.15 * hh, H / 2, Math.max(0, H / 2 - hh), hh, 24, spans.map((b) => [b[1], b[3]])),
1386
+ ];
1387
+ const e = 1 - Math.exp(-dt * 2);
1388
+ this.fillAt = this.fillAt ? [this.fillAt[0] + (want[0] - this.fillAt[0]) * e, this.fillAt[1] + (want[1] - this.fillAt[1]) * e] : want;
1389
+ // Where the page lays the canvas out before it is slid (the slide is the last translate set).
1390
+ const r = canvas.getBoundingClientRect();
1391
+ const [sx, sy] = (canvas.style.translate || '0px 0px').split(' ').map((v) => parseFloat(v) || 0);
1392
+ const t = `${(w / 2 - (r.left - sx) - this.fillAt[0] * k).toFixed(1)}px ${(h / 2 - (r.top - (sy ?? 0)) - this.fillAt[1] * k).toFixed(1)}px`;
1393
+ if (canvas.style.translate !== t) canvas.style.translate = t;
1394
+ }
1395
+
1396
+ /** The parallax of this frame, or undefined when the place is drawn flat (no backdrop, or turned off). */
1397
+ parallax(): Parallax | undefined {
1398
+ return this.place.backdrop ? { cam: this.cam, pivot: this.place.pivot, px: PARALLAX_PX * parallaxScale * (1 + 1.5 * track) } : undefined;
1399
+ }
1400
+
1401
+ /** How far something standing on the floor row y slides this frame. */
1402
+ shift(y: number): [number, number] {
1403
+ const p = this.parallax();
1404
+ return p ? shiftAt(this.place.g, p, y) : [0, 0];
1405
+ }
1406
+
1407
+ /** The floor row a thing stands on: its card's, or its box's bottom. */
1408
+ private thingY(key: string): number {
1409
+ const cut = this.place.cuts.find((c) => (c.occ.thing ?? c.occ.key) === key);
1410
+ return cut ? cut.y : (this.hots().find((h) => h.thing.key === key)?.thing.box[3] ?? this.place.g.frame.height);
1411
+ }
1412
+
1413
+ /** The zoom that keeps the frame's edges covered while the cards sway (and leaves room to pan when tracking); 1 when flat. */
1414
+ zoom(): number {
1415
+ const p = this.parallax();
1416
+ return p ? overscan(this.place.g, { ...p, cam: { x: 1, y: 1 } }) * (1 + trackZoom) : 1;
1417
+ }
1418
+
1419
+ /** How far the view's middle may pan from the frame's middle when tracking. */
1420
+ private panRoom(): number {
1421
+ const W = this.place.g.frame.width;
1422
+ return this.parallax() && track ? W / 2 - W / (2 * (1 + trackZoom)) : 0;
1423
+ }
1424
+
1425
+ /** A canvas point as a point of the zoomed (and panned) scene. */
1426
+ unzoom(x: number, y: number): Point {
1427
+ const z = this.zoom();
1428
+ const { width: W, height: H } = this.place.g.frame;
1429
+ return [(x - W / 2) / z + (this.viewX || W / 2), (y - H / 2) / z + (this.viewY || H / 2)];
1430
+ }
1431
+
1432
+ /** The parallax shift of a card at column x: a card with a floor line (a railing along the quay) slides
1433
+ * column by column as the floor under it, so its far end moves less than its near end. */
1434
+ private cardShift(cut: Cut, x: number): [number, number] {
1435
+ const o = cut.occ;
1436
+ if (!o.floor?.length || o.on || o.front) return this.shift(cut.y);
1437
+ // SAM often splits one thing into stacked blobs (a post's cap above its rope coil): in each column the
1438
+ // whole thing stands where its lowest blob meets the floor, so the pieces stay together.
1439
+ let y = floorYAt(o, x);
1440
+ for (const c of this.place.cuts) {
1441
+ const b = c.occ;
1442
+ if (b !== o && o.thing && b.thing === o.thing && !b.on && !b.front && x >= b.box[0] && x <= b.box[2]) y = Math.max(y, floorYAt(b, x));
1443
+ }
1444
+ return this.shift(y);
1445
+ }
1446
+
1447
+ /** Draws the card's columns [xa, xb) (frame x) in strips, each slid by the floor under it. */
1448
+ private drawCard(cut: Cut, xa = cut.x0, xb = cut.x0 + cut.canvas.width, ya = cut.y0, yb = cut.y0 + cut.canvas.height) {
1449
+ ctx.save();
1450
+ this.poseCard(cut);
1451
+ this.drawStrips(cut, xa, xb, ya, yb);
1452
+ ctx.restore();
1453
+ }
1454
+
1455
+ /**
1456
+ * Moves ctx as the card's own motion this frame (before its strips slide with the floor). A boat on the water
1457
+ * rides the swell: rises and sinks and rolls about its keel, each on its own phase, more in a storm. One moored at
1458
+ * the quay (land on one side) swings about its tied end, so its rope stays on the post. Everything drawn with a
1459
+ * card (its outline, its story change) goes through here, so it moves exactly as the card does.
1460
+ */
1461
+ private poseCard(cut: Cut) {
1462
+ const float = this.afloat(cut);
1463
+ if (float && !params.has('still')) {
1464
+ const t = this.time + (hashSeed(cut.occ.key) % 1000) / 160;
1465
+ const storm = stormOf(this.place.weather?.w);
1466
+ const [w, hgt] = [cut.canvas.width, cut.canvas.height];
1467
+ const tied = float.tied;
1468
+ const [px, py] = [tied ? (tied < 0 ? cut.x0 + w * 0.08 : cut.x0 + w * 0.92) : cut.x0 + w / 2, cut.y0 + hgt * (tied ? 0.45 : 0.85)];
1469
+ const big = 1 + 1.2 * storm;
1470
+ // Big enough to read at a glance: a few pixels of rise and fall and a clear roll, never a jitter.
1471
+ const heave = Math.sin(t * 1.3) * Math.max(2, Math.min(7, hgt * 0.03)) * big;
1472
+ const roll = (tied ? 0.045 : 0.04) * big * Math.sin(t * 0.9 + 1.1) + (tied ? heave * 0.004 * -tied : 0);
1473
+ ctx.translate(px + (tied ? 0 : Math.sin(t * 0.55) * Math.min(4, hgt * 0.012) * big), py + (tied ? heave * 0.35 : heave));
1474
+ ctx.rotate(roll);
1475
+ ctx.translate(-px, -py);
1476
+ }
1477
+ }
1478
+
1479
+ private readonly floating = new WeakMap<Cut, { tied: number } | false>();
1480
+
1481
+ /**
1482
+ * A boat whose hull meets water (around and under it), not one up on a stand on the quay: most points a little way
1483
+ * out from its hull are water. `tied`: -1 or 1 when the land is on that side only (moored at the quay), else 0.
1484
+ */
1485
+ private afloat(cut: Cut): { tied: number } | false {
1486
+ let known = this.floating.get(cut);
1487
+ if (known !== undefined) return known;
1488
+ const wet = this.place.wet;
1489
+ const { width: W, height: H } = this.place.g.frame;
1490
+ const [x0, y0, x1, y1] = cut.occ.box;
1491
+ const at = (x: number, y: number) => (wet ? wet[Math.max(0, Math.min(H - 1, Math.round(y))) * W + Math.max(0, Math.min(W - 1, Math.round(x)))] > 60 : false);
1492
+ // Past the few px the water keeps off every card, and along the hull, not at one point (a neighbouring boat
1493
+ // may stand right beside it).
1494
+ const hull = [0.7, 0.85, 1].map((k) => y0 + k * (y1 - y0));
1495
+ const left = hull.filter((y) => at(x0 - 16, y)).length;
1496
+ const right = hull.filter((y) => at(x1 + 16, y)).length;
1497
+ const under = [0.25, 0.5, 0.75].filter((k) => at(x0 + k * (x1 - x0), y1 + 12)).length;
1498
+ const wetSides = left + right + under;
1499
+ known = FLOATS.test(cut.occ.thing ?? cut.occ.key) && wetSides >= 4 ? { tied: !left && right >= 2 ? -1 : !right && left >= 2 ? 1 : 0 } : false;
1500
+ this.floating.set(cut, known);
1501
+ return known;
1502
+ }
1503
+
1504
+ private drawStrips(cut: Cut, xa: number, xb: number, ya: number, yb: number) {
1505
+ const STRIP = 6;
1506
+ const right = cut.x0 + cut.canvas.width;
1507
+ let [sx, sy] = this.cardShift(cut, xa + Math.min(STRIP, xb - xa) / 2);
1508
+ for (let x = xa; x < xb; x += STRIP) {
1509
+ const w = Math.min(STRIP, xb - x);
1510
+ const next = x + STRIP < xb ? this.cardShift(cut, x + STRIP + Math.min(STRIP, xb - x - STRIP) / 2) : undefined;
1511
+ // Wider than the strip by however far the next one slid right, and 2px more: under the zoom a strip's
1512
+ // edges land on fractions of a pixel and come out half see-through, and the backdrop showed through the
1513
+ // seams as thin vertical lines. The next strip is drawn over the overlap.
1514
+ const reach = Math.min(w + 2 + Math.ceil(Math.max(0, (next?.[0] ?? sx) - sx)), right - x);
1515
+ ctx.drawImage(cut.canvas, x - cut.x0, ya - cut.y0, reach, yb - ya, x + sx, ya + sy, reach, yb - ya);
1516
+ if (next) [sx, sy] = next;
1517
+ }
1518
+ }
1519
+
1520
+ /** Where a click on screen lands on the floor, in painting coordinates. */
1521
+ floorAt(x: number, y: number): Point {
1522
+ const p = this.parallax();
1523
+ return p ? floorPoint(this.place.g, p, x, y) : [x, y];
1524
+ }
1525
+
1526
+ private swayCamera(dt: number) {
1527
+ const W = this.place.g.frame.width;
1528
+ const k = 1 - Math.exp(-dt * 2.5);
1529
+ const H = this.place.g.frame.height;
1530
+ this.viewX ||= W / 2;
1531
+ this.viewY ||= H / 2;
1532
+ const room = this.panRoom();
1533
+ if (room > 0) {
1534
+ // Tracking: the view pans after the walking hero; how far it has panned is the camera's swing. Standing, it
1535
+ // eases back to the middle, so things at the frame's edges (an exit at the very side) are there to click.
1536
+ // Never so far that the hero, where they walk to or the thing they were sent to leaves the view.
1537
+ const z = this.zoom();
1538
+ const hero = this.hero;
1539
+ const tall = personPx(this.place.g, hero.y);
1540
+ const end = hero.path[hero.path.length - 1];
1541
+ const keep: Array<[number, number, number, number]> = [[hero.x - tall / 4, hero.y - tall, hero.x + tall / 4, hero.y + 8]];
1542
+ if (end) keep.push([end[0] - tall / 4, end[1] - personPx(this.place.g, end[1]), end[0] + tall / 4, end[1] + 8]);
1543
+ if (this.sentTo) keep.push(this.sentTo);
1544
+ // Then a strip of every exit, and of every other thing, as far as they fit: something to click at each edge.
1545
+ const role = (h: Hot) => h.spec?.role ?? h.thing.role;
1546
+ const strip = ([x0, y0, x1, y1]: Rect, s: number): Rect => [x1 - Math.min(s, x1 - x0), y1 - Math.min(s, y1 - y0), x0 + Math.min(s, x1 - x0), y0 + Math.min(s, y1 - y0)];
1547
+ // Someone who walks about is where they are drawn, not where the layout put them.
1548
+ const box = (h: Hot) => this.drawn.find((d) => d.hot.thing.key === h.thing.key)?.rect ?? h.thing.box;
1549
+ for (const h of this.hots().sort((a, b) => Number(role(b) === 'exit') - Number(role(a) === 'exit'))) keep.push(strip(box(h), role(h) === 'exit' ? 96 : 64));
1550
+ const margin = Math.min(48, W / (16 * z));
1551
+ const drift = sway ? Math.sin(this.time * 0.45) * room * 0.3 : 0;
1552
+ const want = panTo((hero.path.length ? hero.x : W / 2) + drift, W / 2, room, W / (2 * z), margin, keep.map((b) => [b[0], b[2]]));
1553
+ this.viewX += (want - this.viewX) * (1 - Math.exp(-dt * 1.6));
1554
+ // Up and down only as far as the frame allows, and only as low as it takes to keep the feet in view:
1555
+ // the rest of the view goes to the skyline.
1556
+ const roomY = (room * H) / W;
1557
+ const wantY = panTo(hero.y + 24 - H / (2 * z), H / 2, roomY, H / (2 * z), margin / 2, keep.map((b) => [b[1], b[3]]));
1558
+ this.viewY += (wantY - this.viewY) * (1 - Math.exp(-dt * 1.6));
1559
+ this.cam.x = -(this.viewX - W / 2) / room;
1560
+ const ty = sway ? Math.sin(this.time * 0.31 + 1) * 0.4 : 0;
1561
+ this.cam.y += (ty - this.cam.y) * k;
1562
+ return;
1563
+ }
1564
+ this.viewX = W / 2;
1565
+ this.viewY = H / 2;
1566
+ // The pointer looks around the diorama; the hero walking across pulls the view after him.
1567
+ let tx = -0.5 * (this.hero.x / W - 0.5) * 2;
1568
+ let ty = 0;
1569
+ if (pointer && !hideCursor) {
1570
+ tx += (0.5 - pointer.x) * 2 * 0.8;
1571
+ ty += (0.5 - pointer.y) * 2 * 0.8;
1572
+ }
1573
+ if (sway) {
1574
+ tx += Math.sin(this.time * 0.45) * 0.6;
1575
+ ty += Math.sin(this.time * 0.31 + 1) * 0.5;
1576
+ }
1577
+ this.cam.x += (Math.max(-1, Math.min(1, tx)) - this.cam.x) * k;
1578
+ this.cam.y += (Math.max(-1, Math.min(1, ty)) - this.cam.y) * k;
1579
+ }
1580
+
1581
+ /** The card a thing slides with at column x (the piece covering x, else its nearest), if it has one. */
1582
+ private cardOf(key: string, x: number): Cut | undefined {
1583
+ let best: Cut | undefined;
1584
+ let gap = Infinity;
1585
+ for (const c of this.place.cuts) {
1586
+ if ((c.occ.thing ?? c.occ.key) !== key) continue;
1587
+ const d = Math.max(0, c.occ.box[0] - x, x - c.occ.box[2]);
1588
+ if (d < gap) [best, gap] = [c, d];
1589
+ }
1590
+ return best;
1591
+ }
1592
+
1593
+ /** The painting point a thing shows at screen (x, y): on its card, or on the sheared floor for a flat thing. */
1594
+ private paintingPoint(hot: Hot, x: number, y: number): Point {
1595
+ const p = this.parallax();
1596
+ if (!p) return [x, y];
1597
+ const key = hot.thing.key;
1598
+ if (!this.cardOf(key, x)) return floorPoint(this.place.g, p, x, y);
1599
+ // The card's shift depends on the column, so step from the screen column to the painting one.
1600
+ let px = x;
1601
+ for (let i = 0; i < 3; i++) px = x - this.cardShift(this.cardOf(key, px)!, px)[0];
1602
+ const cut = this.cardOf(key, px)!;
1603
+ const [sx, sy] = this.cardShift(cut, px);
1604
+ return [x - sx, y - sy];
1605
+ }
1606
+
1607
+ /**
1608
+ * The hover outline, moved exactly as what it outlines. A thing with a card: the line round the card's own
1609
+ * cut-out, in the card's motion this frame (its swell) and slid strip by strip as the card's strips are. Anything
1610
+ * else lies on the floor or the backdrop and moves with it.
1611
+ */
1612
+ private drawLit(hot: Hot) {
1613
+ const key = hot.thing.key;
1614
+ const cuts = this.place.cuts.filter((c) => (c.occ.thing ?? c.occ.key) === key);
1615
+ if (cuts.length) {
1616
+ const o = this.cardOutline(hot, cuts);
1617
+ for (const cut of cuts) {
1618
+ ctx.save();
1619
+ this.poseCard(cut);
1620
+ this.outlineStrips(cut, o);
1621
+ ctx.restore();
1622
+ }
1623
+ return;
1624
+ }
1625
+ const p = this.parallax();
1626
+ if (!p) return this.drawOutline(hot);
1627
+ // A painted fish that swims (the living layer cut it out): its line swims with it.
1628
+ const shift = (y: number) => shiftAt(this.place.g, p, y);
1629
+ for (const c of this.crittersOf(hot)) c.drawPosed(ctx, this.time, shift, this.critterOutline(c), OUTLINE_PAD);
1630
+ const { above, below } = backdropTransforms(this.place.g, p);
1631
+ const h = this.place.g.scale.horizonY;
1632
+ const { width: W, height: H } = this.place.g.frame;
1633
+ for (const [m, y0, y1] of [[above, 0, h], [below, h, H]] as const) {
1634
+ ctx.save();
1635
+ ctx.transform(...(m as [number, number, number, number, number, number]));
1636
+ ctx.beginPath();
1637
+ ctx.rect(-W, y0, 3 * W, y1 - y0);
1638
+ ctx.clip();
1639
+ this.drawOutline(hot);
1640
+ ctx.restore();
1641
+ }
1642
+ }
1643
+
1644
+ private readonly critterLines = new WeakMap<Critter, HTMLCanvasElement>();
1645
+ private readonly critterThings = new WeakMap<Hot, Critter[]>();
1646
+
1647
+ /** The critters cut out of this thing's mask (their middle lies on it), drawn swimming instead of it. */
1648
+ private crittersOf(hot: Hot): Critter[] {
1649
+ let known = this.critterThings.get(hot);
1650
+ if (known) return known;
1651
+ const W = this.place.g.frame.width;
1652
+ const mask = hot.mask;
1653
+ known = !mask || !this.parallax() ? [] : (this.place.critters ?? []).filter((c) => {
1654
+ const [x, y] = [Math.round((c.box[0] + c.box[2]) / 2), Math.round((c.box[1] + c.box[3]) / 2)];
1655
+ return mask.bits[y * W + x] === 1;
1656
+ });
1657
+ this.critterThings.set(hot, known);
1658
+ return known;
1659
+ }
1660
+
1661
+ /** The outline of a critter's card, OUTLINE_PAD px bigger on every side, made once. */
1662
+ private critterOutline(c: Critter): HTMLCanvasElement {
1663
+ let line = this.critterLines.get(c);
1664
+ if (line) return line;
1665
+ const [w, h] = [c.box[2] - c.box[0] + 2 * OUTLINE_PAD, c.box[3] - c.box[1] + 2 * OUTLINE_PAD];
1666
+ const cv = Object.assign(document.createElement('canvas'), { width: w, height: h });
1667
+ const cx = cv.getContext('2d', { willReadFrequently: true })!;
1668
+ cx.drawImage(c.img, OUTLINE_PAD, OUTLINE_PAD, w - 2 * OUTLINE_PAD, h - 2 * OUTLINE_PAD);
1669
+ const rgba = cx.getImageData(0, 0, w, h).data;
1670
+ const alpha = new Uint8Array(w * h);
1671
+ for (let i = 0; i < alpha.length; i++) alpha[i] = rgba[i * 4 + 3];
1672
+ line = outlineCanvas(alpha, w, h);
1673
+ this.critterLines.set(c, line);
1674
+ return line;
1675
+ }
1676
+
1677
+ /**
1678
+ * The line round all of a thing's cards, as cut out (their alpha), made once. While a story change of it holds,
1679
+ * round the thing as the change draws it (its shape in the change plate: a boat redrawn with a lantern may be
1680
+ * longer than the one painted), once that shape has loaded.
1681
+ */
1682
+ private cardOutline(hot: Hot, cuts: Cut[]): Outline {
1683
+ const shapes = this.changeShapes(hot.thing.key);
1684
+ const id = `${cuts.map((c) => c.occ.key).join(',')}|${shapes.map((s) => s.i).join(',')}`;
1685
+ if (hot.outline?.cuts === id) return hot.outline;
1686
+ if (shapes.length) {
1687
+ const W = this.place.g.frame.width;
1688
+ const bits = new Uint8Array(shapes[0].bits.bits.length);
1689
+ for (const { bits: b } of shapes) for (let i = 0; i < bits.length; i++) bits[i] |= b.bits[i];
1690
+ hot.outline = { ...bitsOutline({ bits, area: 0 }, W, hot.thing.box), cuts: id };
1691
+ return hot.outline;
1692
+ }
1693
+ const x0 = Math.min(...cuts.map((c) => c.x0)) - OUTLINE_PAD;
1694
+ const y0 = Math.min(...cuts.map((c) => c.y0)) - OUTLINE_PAD;
1695
+ const w = Math.max(...cuts.map((c) => c.x0 + c.base.width)) + OUTLINE_PAD - x0;
1696
+ const h = Math.max(...cuts.map((c) => c.y0 + c.base.height)) + OUTLINE_PAD - y0;
1697
+ const c = Object.assign(document.createElement('canvas'), { width: w, height: h });
1698
+ const cx = c.getContext('2d', { willReadFrequently: true })!;
1699
+ for (const cut of cuts) cx.drawImage(cut.base, cut.x0 - x0, cut.y0 - y0);
1700
+ const rgba = cx.getImageData(0, 0, w, h).data;
1701
+ const alpha = new Uint8Array(w * h);
1702
+ for (let i = 0; i < alpha.length; i++) alpha[i] = rgba[i * 4 + 3];
1703
+ hot.outline = { canvas: outlineCanvas(alpha, w, h), x0, y0, cuts: id };
1704
+ return hot.outline;
1705
+ }
1706
+
1707
+ /**
1708
+ * Draws the outline's columns that belong to this card (the card nearest them) the way drawStrips draws the
1709
+ * card: in strips on the card's own grid, each slid by the card's shift at that strip.
1710
+ */
1711
+ private outlineStrips(cut: Cut, o: Outline) {
1712
+ const STRIP = 6;
1713
+ const key = cut.occ.thing ?? cut.occ.key;
1714
+ const { width: w, height: h } = o.canvas;
1715
+ for (let x = cut.x0 + Math.floor((o.x0 - cut.x0) / STRIP) * STRIP; x < o.x0 + w; x += STRIP) {
1716
+ if (this.cardOf(key, x + STRIP / 2) !== cut) continue;
1717
+ const a = Math.max(x, o.x0);
1718
+ const b = Math.min(x + STRIP, o.x0 + w);
1719
+ if (b <= a) continue;
1720
+ const [sx, sy] = this.cardShift(cut, x + STRIP / 2);
1721
+ ctx.drawImage(o.canvas, a - o.x0, 0, b - a, h, a + sx, o.y0 + sy, b - a, h);
1722
+ }
1723
+ }
1724
+
1725
+ /**
1726
+ * The water moves. Running water flows: two copies of its texture drift downstream, each fading in and out half a cycle apart, so
1727
+ * their sum always weighs one and no copy is seen jumping back. They drift along the flow Spark read (into the
1728
+ * depth too), near rows faster than far ones. The result is cut to the water's soft mask and slides with the
1729
+ * bank behind.
1730
+ */
1731
+ private drawWater(p: Parallax) {
1732
+ const water = this.place.water;
1733
+ if (!water) return;
1734
+ const { g, edge } = this.place;
1735
+ const [x0, y0, x1, y1] = water.box;
1736
+ const [ux, uy] = water.flow;
1737
+ const w = x1 - x0;
1738
+ const c = water.canvas.getContext('2d')!;
1739
+ c.globalCompositeOperation = 'source-over';
1740
+ c.globalAlpha = 1;
1741
+ c.clearRect(0, 0, w, y1 - y0);
1742
+ const PERIOD = 6;
1743
+ const STRIP = 3;
1744
+ const phase = (this.time / PERIOD) % 1;
1745
+ // Still water (the sea, a lake, a harbour basin) does not run, but it is never dead: it swells, waves rolling
1746
+ // toward the viewer, small far off and heaving near, more in a storm (see drawSwell).
1747
+ const storm = stormOf(this.place.weather?.w);
1748
+ if (!ux && !uy) {
1749
+ drawSwell(c, water.tex, w, y1 - y0, this.time, storm);
1750
+ } else for (const [k, alpha] of [[0, 1], [0.5, 1 - Math.abs(2 * ((phase + 0.5) % 1) - 1)]] as const) {
1751
+ const ph = (phase + k) % 1;
1752
+ c.globalAlpha = k === 0 ? 1 : alpha;
1753
+ for (let y = y0; y < y1; y += STRIP) {
1754
+ // By the row's place within the water, not the floor depth: a river near the horizon would barely move.
1755
+ // At most the texture's reach past the banks per cycle, so a shifted strip never runs out of texture.
1756
+ const near = (y - y0) / Math.max(1, y1 - y0);
1757
+ const travel = Math.min(2 * WATER_PAD - 4, FLOW * (0.4 + 0.6 * near) * PERIOD) * (ph - 0.5);
1758
+ // Running into the depth, a strip shows the water from further up (or down) the channel.
1759
+ const sy = Math.min(y1 - y0 - STRIP, Math.max(0, y - y0 - uy * travel));
1760
+ c.drawImage(water.tex, 0, sy, w, STRIP, ux * travel, y - y0, w, STRIP);
1761
+ }
1762
+ }
1763
+ // The foam rides the swell on its own, churning over the paper under it.
1764
+ this.place.surf?.churn(c, this.time, storm);
1765
+ // Glints: a few soft glimmers on the swell, each fading in and out on its own beat and drifting with the
1766
+ // water, so even a dark, smooth painted sea is seen to move. Few and soft: hard white dashes read as rain or
1767
+ // a broken picture. Seeded by index, the same every run.
1768
+ c.globalCompositeOperation = 'lighter';
1769
+ const hh = y1 - y0;
1770
+ const count = Math.round((w * hh) / 9000);
1771
+ for (let i = 0; i < count; i++) {
1772
+ const r1 = fract(Math.sin(i * 12.9898) * 43758.5453);
1773
+ const r2 = fract(Math.sin(i * 78.233) * 12543.123);
1774
+ const r3 = fract(Math.sin(i * 39.425) * 24634.632);
1775
+ const life = 2.4 + 3 * r3;
1776
+ const beat = (this.time + r1 * life * 7) / life;
1777
+ const a = Math.sin(fract(beat) * Math.PI) ** 2;
1778
+ if (a < 0.05) continue;
1779
+ const k = Math.floor(beat);
1780
+ const gy = fract(r2 + k * 0.618) * hh;
1781
+ const near = gy / hh;
1782
+ const gx = fract(r1 + k * 0.381) * w + (ux * FLOW + 4) * fract(beat) * (0.4 + near);
1783
+ const len = (6 + 16 * r3) * (0.4 + near);
1784
+ const glow = c.createRadialGradient(gx, gy, 0, gx, gy, len);
1785
+ glow.addColorStop(0, 'rgba(225,232,250,0.5)');
1786
+ glow.addColorStop(1, 'rgba(225,232,250,0)');
1787
+ c.globalAlpha = a * (0.25 + 0.35 * near);
1788
+ c.fillStyle = glow;
1789
+ c.save();
1790
+ c.translate(gx, gy);
1791
+ c.scale(1, 0.18);
1792
+ c.translate(-gx, -gy);
1793
+ c.fillRect(gx - len, gy - len, 2 * len, 2 * len);
1794
+ c.restore();
1795
+ }
1796
+ c.globalAlpha = 1;
1797
+ c.globalCompositeOperation = 'destination-in';
1798
+ c.drawImage(water.mask, 0, 0);
1799
+ // It lies behind the bank's back edge, so it slides as that edge does.
1800
+ const bank = Math.min(y1, edge.reduce((a, b) => a + b, 0) / edge.length);
1801
+ const [sx, sy] = shiftAt(g, p, bank);
1802
+ // A little of the still painting shows through, so reflections (a low sun, lit windows) hold their place.
1803
+ ctx.save();
1804
+ ctx.globalAlpha = 0.9;
1805
+ ctx.drawImage(water.canvas, x0 + sx, y0 + sy);
1806
+ ctx.restore();
1807
+ // Where it meets the shore, a rock or a hull, it breaks in foam and spray.
1808
+ this.place.surf?.break(ctx, this.time, storm, () => [sx, sy]);
1809
+ }
1810
+
1811
+ /** Painted boats (not cards) rock on the swell: rise and sink a pixel or two and roll about the waterline. */
1812
+ private drawFloats(p: Parallax) {
1813
+ for (const [k, { img, box, under }] of (this.place.floats ?? []).entries()) {
1814
+ const [x0, y0, x1, y1] = box;
1815
+ const [sx, sy] = shiftAt(this.place.g, p, y1);
1816
+ // Cover the boat still standing in the backdrop, so the rocking one is the only boat there.
1817
+ if (under) ctx.drawImage(under, x0 + sx, y0 + sy);
1818
+ const t = this.time + k * 1.7;
1819
+ const [px, py] = [(x0 + x1) / 2 + sx, y1 - (y1 - y0) * 0.2 + sy];
1820
+ ctx.save();
1821
+ // A far boat is a few dozen pixels tall: a share of its height would be under a pixel, so it gets a floor of
1822
+ // ~2 px of bob, a clear roll and a slow drift, enough for the eye to catch across the harbour.
1823
+ const hh = y1 - y0;
1824
+ ctx.translate(px + Math.sin(t * 0.5 + 0.3) * Math.min(2, Math.max(0.8, hh * 0.03)), py + Math.sin(t * 1.25) * Math.max(1.8, Math.min(4, hh * 0.07)));
1825
+ ctx.rotate(0.05 * Math.sin(t * 0.85 + 0.7));
1826
+ ctx.drawImage(img, x0 + sx - px, y0 + sy - py);
1827
+ ctx.restore();
1828
+ }
1829
+ }
1830
+
1831
+ /**
1832
+ * Painted smoke curls: each plume's rows are bent sideways by a wave that rises through it, wider the higher
1833
+ * it gets, so the smoke billows and leans with the wind while its foot (the chimney) stays where it was painted.
1834
+ */
1835
+ private drawSmoke(p: Parallax) {
1836
+ const smoke = this.place.smoke;
1837
+ if (!smoke?.length) return;
1838
+ const wind = this.place.weather?.w.wind ?? 0;
1839
+ const STRIP = 2;
1840
+ for (const [k, { img, box }] of smoke.entries()) {
1841
+ const [x0, y0, , y1] = box;
1842
+ const h = y1 - y0;
1843
+ const [sx, sy] = shiftAt(this.place.g, p, y1);
1844
+ for (let y = 0; y < h; y += STRIP) {
1845
+ // 0 at the foot of the plume, 1 at its top.
1846
+ const up = 1 - y / Math.max(1, h);
1847
+ const amp = 1.5 + 7 * up * up;
1848
+ const dx = amp * (0.65 * Math.sin(y * 0.045 + this.time * 1.4 + k * 2.1) + 0.35 * Math.sin(y * 0.11 - this.time * 0.9 + k)) + wind * 30 * up * up;
1849
+ ctx.drawImage(img, 0, y, img.width, STRIP, x0 + sx + dx, y0 + y + sy, img.width, STRIP);
1850
+ }
1851
+ }
1852
+ }
1853
+
1854
+ /**
1855
+ * The painted fog drifts. Like running water: two copies of its veil (its colour, shaped like its denser and
1856
+ * lighter patches) slide sideways half a cycle apart, each fading in and out, so their sum weighs one and none
1857
+ * is seen jumping back; with the wind (or the way the author set with fog_drift). Cut to where the fog may
1858
+ * show and laid over the still painting, so wisps roll across the land, breathing a little, and sliding with
1859
+ * the parallax as its lowest row.
1860
+ */
1861
+ private drawMist(p: Parallax) {
1862
+ const mist = this.place.mist;
1863
+ if (!mist) return;
1864
+ const [x0, y0, x1, y1] = mist.box;
1865
+ const dir = mist.drift || ((this.place.weather?.w.wind ?? 0) < 0 ? -1 : 1);
1866
+ const v = MIST_DRIFT * mist.speed;
1867
+ // At most the texture's reach past the fog per cycle, so a shifted copy never runs out of fog.
1868
+ const period = Math.min(16, (2 * MIST_PAD - 8) / v);
1869
+ const phase = (this.time / period) % 1;
1870
+ const c = mist.canvas.getContext('2d')!;
1871
+ c.globalCompositeOperation = 'source-over';
1872
+ c.clearRect(0, 0, x1 - x0, y1 - y0);
1873
+ for (const k of [0, 0.5]) {
1874
+ const ph = (phase + k) % 1;
1875
+ c.globalAlpha = k === 0 ? 1 : 1 - Math.abs(2 * ((phase + 0.5) % 1) - 1);
1876
+ c.drawImage(mist.tex, dir * v * period * (ph - 0.5), Math.sin(this.time * 0.21 + k * 3) * 3);
1877
+ }
1878
+ c.globalAlpha = 1;
1879
+ c.globalCompositeOperation = 'destination-in';
1880
+ c.drawImage(mist.mask, 0, 0);
1881
+ const [sx, sy] = shiftAt(this.place.g, p, y1);
1882
+ ctx.save();
1883
+ ctx.globalAlpha = 0.6 + 0.12 * Math.sin(this.time * 0.35);
1884
+ ctx.drawImage(mist.canvas, x0 + sx, y0 + sy);
1885
+ ctx.restore();
1886
+ }
1887
+
1888
+ /** Draws `draw` moved by the parallax shift of floor row y. */
1889
+ private at(y: number, draw: () => void) {
1890
+ const [dx, dy] = this.shift(y);
1891
+ ctx.save();
1892
+ ctx.translate(dx, dy);
1893
+ draw();
1894
+ ctx.restore();
1895
+ }
1896
+
1897
+ render() {
1898
+ const { g, bg, backdrop } = this.place;
1899
+ const W = g.frame.width;
1900
+ const H = g.frame.height;
1901
+ const p = this.parallax();
1902
+ const z = this.zoom();
1903
+ ctx.setTransform(res, 0, 0, res, 0, 0);
1904
+ ctx.fillStyle = '#20232a';
1905
+ ctx.fillRect(0, 0, W, H);
1906
+ ctx.setTransform(z * res, 0, 0, z * res, res * (W / 2 - z * (this.viewX || W / 2)), res * (H / 2 - z * (this.viewY || H / 2)));
1907
+ const gone = this.gone();
1908
+ this.recut();
1909
+ const npcs = this.hots().filter((h) => (h.spec?.role ?? h.thing.role) === 'npc' && !h.mask);
1910
+ const people: Who[] = [...npcs.map((h) => ({ npc: h, y: h.npc?.body.y ?? h.thing.box[3] })), ...[this.hero, ...this.party].map((body) => ({ body, y: body.y }))].sort((a, b) => a.y - b.y);
1911
+ const fire = this.fireOf(gone);
1912
+ this.climbs.length = 0;
1913
+ if (p && backdrop) {
1914
+ // A diorama: the backdrop's floor shears with depth, then every card slides as the floor row it stands on.
1915
+ const { above, below } = backdropTransforms(g, p);
1916
+ const h = Math.round(g.scale.horizonY);
1917
+ if (h > 0) {
1918
+ ctx.save();
1919
+ ctx.transform(...(above as [number, number, number, number, number, number]));
1920
+ // A few rows past the horizon, under the upright band, so a small vertical slide leaves no gap.
1921
+ const top = this.place.far?.sky ?? backdrop;
1922
+ ctx.drawImage(top, 0, 0, W, Math.min(H, h + 12), 0, 0, W, Math.min(H, h + 12));
1923
+ ctx.restore();
1924
+ }
1925
+ // The far view, flat behind flat, each sliding a little more than the one behind it.
1926
+ for (const f of this.place.far?.flats ?? []) {
1927
+ const [sx, sy] = shiftAtDisparity(p, f.d);
1928
+ ctx.drawImage(f.img, f.box[0] + sx, f.box[1] + sy);
1929
+ }
1930
+ ctx.save();
1931
+ ctx.transform(...(below as [number, number, number, number, number, number]));
1932
+ ctx.drawImage(backdrop, 0, h, W, H - h, 0, h, W, H - h);
1933
+ ctx.restore();
1934
+ // Between the horizon and the floor's back edge stands the upright band (a wall, a railing): column by
1935
+ // column it slides as one piece with the edge it stands on. With no far view (no sky, no flats: a room)
1936
+ // what is above the horizon is that same wall, so the band reaches the top: a door is not cut in two at eye level.
1937
+ const edge = this.place.edge;
1938
+ const top = this.place.far?.sky || this.place.far?.flats.length ? h : 0;
1939
+ // Neighbouring columns whose edges lie within a row or two of each other slide the same to a fraction of a
1940
+ // pixel, so they go down as one piece: ~150 full-height draws a frame were a big share of a slow laptop's frame.
1941
+ for (let c = 0; c < edge.length; ) {
1942
+ const e = Math.round(edge[c]);
1943
+ if (e <= h + 1) {
1944
+ c++;
1945
+ continue;
1946
+ }
1947
+ let n = 1;
1948
+ let lo = e;
1949
+ let hi = e;
1950
+ while (c + n < edge.length) {
1951
+ const f = Math.round(edge[c + n]);
1952
+ if (f <= h + 1 || Math.max(hi, f) - Math.min(lo, f) > 2) break;
1953
+ [lo, hi] = [Math.min(lo, f), Math.max(hi, f)];
1954
+ n++;
1955
+ }
1956
+ const x0 = c * g.cell;
1957
+ c += n;
1958
+ const w = Math.min(n * g.cell + 1, W - x0);
1959
+ if (w <= 0) break;
1960
+ const [sx, sy] = shiftAt(g, p, (lo + hi) / 2);
1961
+ ctx.drawImage(backdrop, x0, top, w, hi - top + 1, x0 + sx, top + sy, w, hi - top + 1);
1962
+ }
1963
+ this.place.clouds?.draw(ctx, this.time, this.place.weather?.w.wind ?? 0, (y) => shiftAt(g, p, y));
1964
+ this.drawWater(p);
1965
+ for (const f of this.place.falls ?? []) f.draw(ctx, this.time, (y) => shiftAt(g, p, y));
1966
+ for (const c of this.place.critters ?? []) c.draw(ctx, this.time, (y) => shiftAt(g, p, y));
1967
+ this.drawFloats(p);
1968
+ this.drawSmoke(p);
1969
+ this.drawMist(p);
1970
+ this.place.weather?.drawGround(ctx, (y) => this.shift(y));
1971
+ this.place.glows?.drawBehind(ctx, (y) => this.shift(y));
1972
+ const carded = new Set(this.place.cuts.map((c) => c.occ.thing ?? c.occ.key));
1973
+ for (const hot of this.hots()) {
1974
+ // A carried-off card is simply not drawn; a flat thing gets its clean plate, on the floor it lies on.
1975
+ if (!hot.plate || !gone.has(hot.thing.key) || carded.has(hot.thing.key)) continue;
1976
+ const [x0, y0, x1, y1] = hot.thing.box.map((v, i) => (i < 2 ? Math.max(0, v - 16) : v + 16));
1977
+ this.at(hot.thing.box[3], () => ctx.drawImage(hot.plate!, x0, y0, x1 - x0, y1 - y0, x0, y0, x1 - x0, y1 - y0));
1978
+ }
1979
+ this.drawFireShadows(fire, backdropTransforms(g, p).below as [number, number, number, number, number, number]);
1980
+ for (const who of people) this.drawWho(who, undefined, 'shadow');
1981
+ for (const cut of this.place.cuts) {
1982
+ if (cut.occ.thing && gone.has(cut.occ.thing)) continue;
1983
+ this.drawCard(cut);
1984
+ }
1985
+ this.drawClimbs();
1986
+ const story = this.data.story;
1987
+ // A change belongs to its thing's card: it slides with it and rides the same swell, so it never comes apart
1988
+ // from the card under it (nor from its outline).
1989
+ for (const { img, i } of this.changed()) {
1990
+ const cut = this.place.cuts.find((c) => (c.occ.thing ?? c.occ.key) === story!.changes[i].thing);
1991
+ ctx.save();
1992
+ if (cut) this.poseCard(cut);
1993
+ this.at(this.thingY(story!.changes[i].thing), () => ctx.drawImage(img, 0, 0, W, H));
1994
+ ctx.restore();
1995
+ }
1996
+ } else {
1997
+ if (bg) ctx.drawImage(bg, 0, 0, W, H);
1998
+ // What the story has changed here (a beacon lit): each change is the thing as it looks after, transparent elsewhere.
1999
+ // A carded thing's change rides the card's swell with it, as in the diorama.
2000
+ for (const { img, i } of this.changed()) {
2001
+ const cut = this.place.cuts.find((c) => (c.occ.thing ?? c.occ.key) === this.data.story!.changes[i].thing);
2002
+ ctx.save();
2003
+ if (cut) this.poseCard(cut);
2004
+ ctx.drawImage(img, 0, 0, W, H);
2005
+ ctx.restore();
2006
+ }
2007
+ this.place.weather?.drawGround(ctx, () => [0, 0]);
2008
+ this.place.glows?.drawBehind(ctx, () => [0, 0]);
2009
+ for (const hot of this.hots()) {
2010
+ if (!hot.plate || !gone.has(hot.thing.key)) continue;
2011
+ // The plate differs from the painting only near the thing, so its grown box is enough.
2012
+ const [x0, y0, x1, y1] = hot.thing.box.map((v, i) => (i < 2 ? Math.max(0, v - 16) : v + 16));
2013
+ ctx.drawImage(hot.plate, x0, y0, x1 - x0, y1 - y0, x0, y0, x1 - x0, y1 - y0);
2014
+ }
2015
+ // The shadows on the floor, then the things put back over them: shadows never climb onto a thing.
2016
+ this.drawFireShadows(fire);
2017
+ for (const who of people) this.drawWho(who, undefined, 'shadow');
2018
+ for (const cut of this.place.cuts) if (!cut.occ.thing || !gone.has(cut.occ.thing)) this.drawCard(cut);
2019
+ this.drawClimbs();
2020
+ }
2021
+ this.place.glows?.draw(ctx, (y) => this.shift(y));
2022
+ this.place.weather?.drawDrips(ctx, (y) => this.shift(y));
2023
+ this.drawFocus();
2024
+ // The place as drawn so far, without anyone: what the depth map puts back over a person (occludeDepth).
2025
+ this.snapped = false;
2026
+ if (this.place.depth && this.depthOcclusion && !nobody && people.length) {
2027
+ const snap = (this.snap ??= document.createElement('canvas'));
2028
+ if (snap.width !== canvas.width || snap.height !== canvas.height) Object.assign(snap, { width: canvas.width, height: canvas.height });
2029
+ const sc = snap.getContext('2d')!;
2030
+ sc.clearRect(0, 0, snap.width, snap.height);
2031
+ sc.drawImage(canvas, 0, 0);
2032
+ this.snapped = true;
2033
+ }
2034
+ // Everyone is drawn far to near, and each one gets the things in front of them put back.
2035
+ let rect: Rect = [0, 0, 0, 0];
2036
+ // A thing taken (or otherwise gone) loses its outline at once, though the pointer has not moved off where it lay.
2037
+ const lit = this.hover && !hideCursor && !gone.has(this.hover.thing.key) ? this.hover : undefined;
2038
+ // Under the people, so someone standing in front of a highlighted thing stays in front of its outline.
2039
+ if (lit && !this.drawn.some((d) => d.hot === lit)) this.drawLit(lit);
2040
+ const hint = this.hinted && this.time < this.hinted.until && !gone.has(this.hinted.key) ? this.hots().find((h) => h.thing.key === this.hinted!.key) : undefined;
2041
+ if (hint && hint !== lit && Math.sin(this.time * 6) > -0.3) this.drawLit(hint);
2042
+ this.drawn = [];
2043
+ // The flame burns among the people, far to near: over whoever stands behind it, under whoever stands in front.
2044
+ let burnt = !fire?.flame;
2045
+ const burn = () => {
2046
+ if (!burnt) this.drawFlame(fire!);
2047
+ burnt = true;
2048
+ };
2049
+ let reactionDepth = -Infinity;
2050
+ const drawReactions = (until: number) => {
2051
+ this.place.reactions?.draw(ctx, reactionDepth, until, (y) => this.shift(y), (r, y) => this.occlude(r, y, gone));
2052
+ reactionDepth = until;
2053
+ };
2054
+ for (const who of people) {
2055
+ drawReactions(who.y);
2056
+ if (!burnt && who.y > g.light!.at![1] + 2) burn();
2057
+ // ?people=0: the place alone, for a landscape take; everyone keeps playing, just unseen.
2058
+ if (nobody) continue;
2059
+ if (who.npc) {
2060
+ const drawn = this.drawWho(who, lit, 'body');
2061
+ this.drawn.push({ hot: who.npc, rect: drawn });
2062
+ const body = who.npc.npc?.body;
2063
+ this.occlude(drawn, who.y, gone, body?.x, body && (body.foot ?? stepFootY(g, body.x, who.y)));
2064
+ } else {
2065
+ const drawn = this.drawWho(who, lit, 'body');
2066
+ if (who.body === this.hero) rect = drawn;
2067
+ // The depth map goes by where the feet are drawn: on a stair's tread, not at the walker's row.
2068
+ this.occlude(drawn, who.y, gone, who.body?.x, who.body && (who.body.foot ?? stepFootY(g, who.body.x, who.y)));
2069
+ }
2070
+ }
2071
+ burn();
2072
+ drawReactions(Infinity);
2073
+ // The rain falls in front of everyone; the speech bubble stays over it.
2074
+ this.place.weather?.drawAir(ctx, this.place.glows?.pools);
2075
+ if (this.grid) {
2076
+ ctx.save();
2077
+ if (p) ctx.transform(...(backdropTransforms(g, p).below as [number, number, number, number, number, number]));
2078
+ this.drawGrid();
2079
+ ctx.restore();
2080
+ }
2081
+ // The bubble sits over whoever says the line; the narrator (no speaker) and the hero use the hero.
2082
+ const speaker = this.speech?.who ? this.drawn.find((d) => d.hot.thing.key === this.speech!.who)?.rect : undefined;
2083
+ this.drawSpeech(speaker ?? rect, this.speech?.who && displayName(this.speech.who, this.data.world.names));
2084
+ ctx.setTransform(res, 0, 0, res, 0, 0);
2085
+ if (this.dark > 0) {
2086
+ ctx.fillStyle = `rgba(0,0,0,${this.dark})`;
2087
+ ctx.fillRect(0, 0, W, H);
2088
+ }
2089
+ }
2090
+
2091
+ private rimA = document.createElement('canvas');
2092
+ private rimB = document.createElement('canvas');
2093
+
2094
+ /**
2095
+ * A rim of light on the person's edge facing the light: the silhouette minus itself shifted away from the
2096
+ * light, tinted warm and laid over the figure. ctx stands at the feet, not mirrored (paint mirrors itself).
2097
+ */
2098
+ private rim(h: number, paint: (c: CanvasRenderingContext2D) => number, bright: number, light: NonNullable<Geometry['light']>) {
2099
+ const [bw, bh] = [Math.ceil(h * 1.3), Math.ceil(h * 1.2)];
2100
+ const [ox, oy] = [Math.round(bw / 2), Math.ceil(h * 1.12)];
2101
+ for (const c of [this.rimA, this.rimB]) if (c.width < bw || c.height < bh) Object.assign(c, { width: Math.max(c.width, bw), height: Math.max(c.height, bh) });
2102
+ const a = this.rimA.getContext('2d')!;
2103
+ a.setTransform(1, 0, 0, 1, 0, 0);
2104
+ a.clearRect(0, 0, this.rimA.width, this.rimA.height);
2105
+ a.translate(ox, oy);
2106
+ paint(a);
2107
+ a.setTransform(1, 0, 0, 1, 0, 0);
2108
+ // Toward the light: against the way shadows fall. Without a measured light there is no rim at all.
2109
+ const dir = light.dir;
2110
+ const n = Math.hypot(dir[0], dir[1]) || 1;
2111
+ const k = Math.max(1.5, h * RIM_WIDTH * rimScale);
2112
+ const b = this.rimB.getContext('2d')!;
2113
+ b.setTransform(1, 0, 0, 1, 0, 0);
2114
+ b.globalCompositeOperation = 'copy';
2115
+ b.drawImage(this.rimA, 0, 0);
2116
+ b.globalCompositeOperation = 'source-in';
2117
+ b.fillStyle = RIM_COLOUR;
2118
+ b.fillRect(0, 0, bw, bh);
2119
+ b.globalCompositeOperation = 'destination-out';
2120
+ b.drawImage(this.rimA, (dir[0] / n) * k, (dir[1] / n) * k);
2121
+ b.globalCompositeOperation = 'source-over';
2122
+ ctx.save();
2123
+ ctx.filter = `blur(${Math.max(0.5, k * 0.35).toFixed(1)}px)`;
2124
+ ctx.globalAlpha = Math.min(1, RIM_ALPHA * Math.min(1.2, bright));
2125
+ ctx.globalCompositeOperation = 'screen';
2126
+ ctx.drawImage(this.rimB, 0, 0, bw, bh, -ox, -oy, bw, bh);
2127
+ ctx.restore();
2128
+ }
2129
+
2130
+ private sepCanvas = document.createElement('canvas');
2131
+ private sepMask = document.createElement('canvas');
2132
+
2133
+ /**
2134
+ * A soft shade the person throws on what stands just behind them (src/play/focus.ts separation): their
2135
+ * silhouette, blurred and pushed a little away from the light, darkening only pixels a short way farther
2136
+ * than the feet. Painted figures and painted things are equally lit and equally sharp; this occlusion is
2137
+ * the cue that puts the figure in front. ctx is already shifted for parallax.
2138
+ */
2139
+ private separate(x: number, y: number, h: number, paint: (c: CanvasRenderingContext2D) => number) {
2140
+ const { focus: d, g } = this.place;
2141
+ const feet = y - g.scale.horizonY;
2142
+ if (!d || separateScale <= 0 || feet < 4 || h < 8) return;
2143
+ const blur = h * SEPARATE_BLUR;
2144
+ const pad = Math.ceil(blur * 2.5 + h * SEPARATE_SHIFT);
2145
+ const [bw, bh] = [Math.ceil(h * 1.2) + 2 * pad, Math.ceil(h * 1.15) + 2 * pad];
2146
+ const [ox, oy] = [Math.round(bw / 2), Math.ceil(pad + h * 1.1)];
2147
+ const c = this.sepCanvas;
2148
+ if (c.width < bw || c.height < bh) Object.assign(c, { width: Math.max(c.width, bw), height: Math.max(c.height, bh) });
2149
+ const cc = c.getContext('2d')!;
2150
+ cc.setTransform(1, 0, 0, 1, 0, 0);
2151
+ cc.clearRect(0, 0, c.width, c.height);
2152
+ // Away from the light, as a shadow would fall on a wall behind; without a measured light, a little down-right.
2153
+ const dir = g.light?.dir ?? [0.6, 0.25];
2154
+ const n = Math.hypot(dir[0], dir[1]) || 1;
2155
+ const [dx, dy] = [(dir[0] / n) * h * SEPARATE_SHIFT, (Math.max(0, dir[1]) / n) * h * SEPARATE_SHIFT * 0.5];
2156
+ cc.filter = `blur(${blur.toFixed(1)}px)`;
2157
+ cc.translate(ox + dx, oy + dy);
2158
+ paint(cc);
2159
+ cc.setTransform(1, 0, 0, 1, 0, 0);
2160
+ cc.filter = 'none';
2161
+ cc.globalCompositeOperation = 'source-in';
2162
+ cc.fillStyle = '#121830';
2163
+ cc.fillRect(0, 0, bw, bh);
2164
+ // Only on what stands a short way behind: the depth mask, one value per depth pixel under the box.
2165
+ const S = DEPTH_SCALE;
2166
+ const [fx, fy] = [x - ox, y - oy];
2167
+ const [c0, r0] = [Math.floor(fx / S), Math.floor(fy / S)];
2168
+ const [mw, mh] = [Math.ceil(bw / S) + 1, Math.ceil(bh / S) + 1];
2169
+ const m = this.sepMask;
2170
+ if (m.width < mw || m.height < mh) Object.assign(m, { width: Math.max(m.width, mw), height: Math.max(m.height, mh) });
2171
+ const mc = m.getContext('2d')!;
2172
+ const img = mc.createImageData(mw, mh);
2173
+ for (let r = 0; r < mh; r++) {
2174
+ const dr = r0 + r;
2175
+ if (dr < 0 || dr >= d.h) continue;
2176
+ for (let k = 0; k < mw; k++) {
2177
+ const dc = c0 + k;
2178
+ if (dc < 0 || dc >= d.w) continue;
2179
+ const i = (r * mw + k) * 4;
2180
+ img.data[i] = img.data[i + 1] = img.data[i + 2] = 255;
2181
+ img.data[i + 3] = Math.round(255 * behindWeight(d.rows[dr * d.w + dc], feet));
2182
+ }
2183
+ }
2184
+ mc.clearRect(0, 0, m.width, m.height);
2185
+ mc.putImageData(img, 0, 0);
2186
+ cc.globalCompositeOperation = 'destination-in';
2187
+ cc.imageSmoothingEnabled = true;
2188
+ cc.drawImage(m, 0, 0, mw, mh, c0 * S - fx, r0 * S - fy, mw * S, mh * S);
2189
+ cc.globalCompositeOperation = 'source-over';
2190
+ ctx.save();
2191
+ ctx.globalAlpha = Math.min(0.8, SEPARATE_ALPHA * separateScale);
2192
+ ctx.drawImage(c, 0, 0, bw, bh, fx, fy, bw, bh);
2193
+ ctx.restore();
2194
+ }
2195
+
2196
+ private soft = document.createElement('canvas');
2197
+ private maskCanvas = document.createElement('canvas');
2198
+ private focusAt = { place: '', row: -1, eased: 0 };
2199
+
2200
+ /**
2201
+ * The camera focuses where the hero stands: the frame drawn so far (the painting and its cards, no people yet)
2202
+ * is laid over itself blurred and a little paler, through a mask of how far behind the hero each pixel is
2203
+ * (src/play/focus.ts). People and the cards put back in front of them are drawn after, sharp.
2204
+ */
2205
+ private drawFocus() {
2206
+ const { focus, g } = this.place;
2207
+ if (!focus || focusScale <= 0) return;
2208
+ const W = canvas.width;
2209
+ const H = canvas.height;
2210
+ // Eased, so walking toward or away from the camera pulls focus smoothly instead of stepping.
2211
+ const target = Math.max(1, this.hero.y - g.scale.horizonY);
2212
+ const f = this.focusAt;
2213
+ if (f.place !== this.place.key) Object.assign(f, { place: this.place.key, row: -1, eased: target });
2214
+ f.eased += (target - f.eased) * 0.15;
2215
+ // The mask is remade only when the focus has moved a few rows.
2216
+ const row = Math.round(f.eased / 3) * 3;
2217
+ const m = this.maskCanvas;
2218
+ if (row !== f.row) {
2219
+ if (m.width !== focus.w || m.height !== focus.h) Object.assign(m, { width: focus.w, height: focus.h });
2220
+ const mc = m.getContext('2d')!;
2221
+ const img = mc.createImageData(focus.w, focus.h);
2222
+ focusMask(focus, row, img.data);
2223
+ mc.putImageData(img, 0, 0);
2224
+ f.row = row;
2225
+ }
2226
+ // Blurred at half size: cheaper, and the upscale softens it further.
2227
+ const s = this.soft;
2228
+ const [sw, sh] = [Math.ceil(W / 2), Math.ceil(H / 2)];
2229
+ if (s.width !== sw || s.height !== sh) Object.assign(s, { width: sw, height: sh });
2230
+ const sc = s.getContext('2d')!;
2231
+ const z = ctx.getTransform();
2232
+ sc.setTransform(1, 0, 0, 1, 0, 0);
2233
+ sc.globalCompositeOperation = 'copy';
2234
+ sc.filter = `blur(${((FOCUS_BLUR * focusScale * z.a) / 2).toFixed(2)}px) saturate(0.85) contrast(0.92) brightness(1.04)`;
2235
+ sc.drawImage(canvas, 0, 0, sw, sh);
2236
+ sc.filter = 'none';
2237
+ // The mask in painting coordinates, under the same camera as the frame (zoom, tracking).
2238
+ sc.globalCompositeOperation = 'destination-in';
2239
+ sc.setTransform(z.a / 2, z.b / 2, z.c / 2, z.d / 2, z.e / 2, z.f / 2);
2240
+ sc.imageSmoothingEnabled = true;
2241
+ sc.drawImage(m, 0, 0, focus.w * DEPTH_SCALE, focus.h * DEPTH_SCALE);
2242
+ sc.globalCompositeOperation = 'source-over';
2243
+ ctx.save();
2244
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
2245
+ ctx.drawImage(s, 0, 0, W, H);
2246
+ ctx.restore();
2247
+ }
2248
+
2249
+ /** A glowing line round the thing's mask (a walk-on exit: round the steps it covers), as painted; its box without one. */
2250
+ private drawOutline(hot: Hot) {
2251
+ if (!hot.mask) {
2252
+ const [bx0, by0, bx1, by1] = hot.thing.box.map(Math.round);
2253
+ ctx.save();
2254
+ ctx.shadowColor = 'rgba(255,225,150,0.9)';
2255
+ ctx.shadowBlur = 8;
2256
+ ctx.strokeStyle = 'rgba(255,233,168,0.8)';
2257
+ ctx.lineWidth = 2;
2258
+ ctx.beginPath();
2259
+ ctx.roundRect(bx0, by0, bx1 - bx0, by1 - by0, 8);
2260
+ ctx.stroke();
2261
+ ctx.restore();
2262
+ return;
2263
+ }
2264
+ if (!hot.outline) {
2265
+ const g = this.place.g;
2266
+ const coverage = exitOutline(hot.mask, { ...hot.thing, role: hot.spec?.role ?? hot.thing.role }, g);
2267
+ let mask = coverage?.mask ?? hot.mask;
2268
+ // What swims (a critter) is outlined where it swims; only the rest stays where it was painted.
2269
+ const critters = this.crittersOf(hot);
2270
+ if (critters.length) {
2271
+ const W = g.frame.width;
2272
+ const bits = mask.bits.slice();
2273
+ for (const c of critters) {
2274
+ const [x0, y0, x1, y1] = c.box.map(Math.round);
2275
+ for (let y = Math.max(0, y0 - 6); y < Math.min(bits.length / W, y1 + 6); y++) bits.fill(0, y * W + Math.max(0, x0 - 6), y * W + Math.min(W, x1 + 6));
2276
+ }
2277
+ mask = { bits, area: 0 };
2278
+ }
2279
+ hot.outline = bitsOutline(mask, g.frame.width, hot.thing.box);
2280
+ }
2281
+ ctx.drawImage(hot.outline.canvas, hot.outline.x0, hot.outline.y0);
2282
+ }
2283
+
2284
+ /** Draws one person of the scene, an NPC or the hero: their shadow on the floor, or their body. */
2285
+ private drawWho(who: Who, lit: Hot | undefined, layer: 'shadow' | 'body'): Rect {
2286
+ if (who.npc) {
2287
+ const npc = who.npc.npc;
2288
+ if (npc) {
2289
+ const b = npc.body;
2290
+ const inConversation = this.state.talk && this.data.story?.dialogues[this.state.talk.dialogue]?.npc === npc.key;
2291
+ const talk = this.speech?.who === npc.key ? 'speak' : inConversation || this.meeting === npc ? 'listen' : undefined;
2292
+ const faceHero = talk || (!npc.patrol && !npc.target && !b.path.length);
2293
+ const facing = faceHero ? (b.x < this.hero.x ? 1 : -1) : b.facing;
2294
+ return this.drawPerson(b.x, b.y, facing, b.phase, b.path.length > 0 && !talk, b.figure, '#4b3b2a', lit === who.npc,
2295
+ this.shift(b.y), faceHero ? facing * Math.PI / 3 : b.yaw, talk, b.foot, layer);
2296
+ }
2297
+ const [x0, , x1, y1] = who.npc.thing.box;
2298
+ const x = (x0 + x1) / 2;
2299
+ return this.drawPerson(x, y1, x < this.hero.x ? 1 : -1, 0, false, this.figures.get(who.npc.thing.key), '#4b3b2a', lit === who.npc, this.shift(y1), undefined, this.talking(who.npc.thing.key), undefined, layer);
2300
+ }
2301
+ const h = who.body ?? this.hero;
2302
+ // In a party each hero stands on a ring in its player's colour, so a child finds theirs.
2303
+ if (h.colour && this.party.length && layer === 'shadow') this.drawRing(h);
2304
+ // Copies of one figure are dressed in their player's colours; a child's own drawn hero stays as drawn.
2305
+ const copies = h.figure && [this.hero, ...this.party].filter((b) => b.figure === h.figure).length > 1;
2306
+ const figure = h.colour && copies ? this.tinted(h.figure, h.colour) : h.figure;
2307
+ const rect = this.drawPerson(h.x, h.y, h.facing, h.phase, h.path.length > 0, figure, '#2b2733', false, this.shift(h.y), h.yaw, h === this.hero ? this.talking(this.data.world.hero.key) : undefined, h.foot, layer, this.party.length ? h.colour : undefined);
2308
+ if (h.colour && this.party.length && layer === 'body') this.drawTag(h, rect);
2309
+ return rect;
2310
+ }
2311
+
2312
+ /** The player's number in their colour over a party member's head. */
2313
+ private drawTag(h: Body, [x0, y0, x1]: Rect) {
2314
+ const r = Math.max(14, personPx(this.place.g, h.y) * 0.06);
2315
+ const [x, y] = [(x0 + x1) / 2, y0 - r * 1.6];
2316
+ ctx.save();
2317
+ ctx.fillStyle = h.colour!;
2318
+ ctx.strokeStyle = '#fff';
2319
+ ctx.lineWidth = r * 0.22;
2320
+ ctx.beginPath();
2321
+ ctx.arc(x, y, r, 0, Math.PI * 2);
2322
+ ctx.fill();
2323
+ ctx.stroke();
2324
+ ctx.fillStyle = '#fff';
2325
+ ctx.font = `bold ${Math.round(r * 1.3)}px sans-serif`;
2326
+ ctx.textAlign = 'center';
2327
+ ctx.textBaseline = 'middle';
2328
+ ctx.fillText(String(PLAYER_COLOURS.indexOf(h.colour!) + 1), x, y + r * 0.08);
2329
+ ctx.restore();
2330
+ }
2331
+
2332
+ private tints = new Map<string, Figure | null>();
2333
+
2334
+ /**
2335
+ * The figure dressed in a player's colour, so each child's hero looks their own: made once per colour in the
2336
+ * background (the plain figure stands in meanwhile). A rig or 3D figure keeps its look and only gets the edge.
2337
+ */
2338
+ private tinted(figure: Figure | undefined, colour: string): Figure | undefined {
2339
+ if (!figure || figure.rig || (figure.model && !figure.sprites)) return figure;
2340
+ const done = this.tints.get(colour);
2341
+ if (done !== undefined) return done ?? figure;
2342
+ this.tints.set(colour, null);
2343
+ setTimeout(() => {
2344
+ const sprites = figure.sprites;
2345
+ const sheet = (s: Sheet): Sheet => ({ ...s, img: dressSheet(s.img, colour, sprites!.info) });
2346
+ this.tints.set(colour, {
2347
+ ...figure,
2348
+ img: dressSheet(figure.img, colour),
2349
+ ...(sprites && { sprites: { ...sprites, sheets: sprites.sheets.map(sheet), talk: sprites.talk.map(sheet) } }),
2350
+ });
2351
+ }, 0);
2352
+ return figure;
2353
+ }
2354
+
2355
+ /** A coloured ring on the floor round a party member's feet. */
2356
+ private drawRing(h: Body) {
2357
+ const unit = personPx(this.place.g, h.y);
2358
+ const [sx, sy] = this.shift(h.y);
2359
+ const y = (h.foot ?? stepFootY(this.place.g, h.x, h.y)) + sy;
2360
+ ctx.save();
2361
+ ctx.strokeStyle = h.colour!;
2362
+ ctx.fillStyle = `${h.colour}55`;
2363
+ ctx.lineWidth = Math.max(3, unit * 0.022);
2364
+ ctx.beginPath();
2365
+ ctx.ellipse(h.x + sx, y, unit * 0.2, unit * 0.06, 0, 0, Math.PI * 2);
2366
+ ctx.fill();
2367
+ ctx.stroke();
2368
+ ctx.restore();
2369
+ }
2370
+
2371
+ /** Draws a person with feet at (x, y): their shadow (layer 'shadow', returns its box) or their body (returns the box it covers). */
2372
+ private drawPerson(x: number, y: number, facing: number, phase: number, walking: boolean, figure: Figure | undefined, colour: string, lit = false, [sx, sy]: [number, number] = [0, 0], yaw = facing * Math.PI / 3, talk?: 'speak' | 'listen', foot?: number, layer: 'shadow' | 'body' = 'body', glow?: string): Rect {
2373
+ const g = this.place.g;
2374
+ // Size comes from where the person stands on the floor and how tall they are (a cat, a child); on stairs
2375
+ // the feet sit on the tread under them.
2376
+ const unit = personPx(g, y);
2377
+ const h = unit * ((figure?.height ?? 1.62) / 1.62);
2378
+ y = foot ?? stepFootY(g, x, y);
2379
+ const articulation = talk === 'speak' ? mixer?.mouth(this.speech) ?? mouthAt(undefined, undefined) : mouthAt(undefined, undefined);
2380
+ const faceTime = performance.now() / 1000;
2381
+ // Older NPCs may have only a talking sheet. While walking, use their rig/model/cut-out instead.
2382
+ const sprites = figure?.sprites && (!walking || figure.sprites.sheets.length) ? figure.sprites : undefined;
2383
+ // A walk sheet warped from the painting looks like the painting; the bare 3D model (TRELLIS's textures up
2384
+ // close) does not, so it walks a person only with ?model=1. A creature has no sheet and keeps its model.
2385
+ let model = sprites || (figure?.rig?.rig.sheet && params.get('model') !== '1') ? undefined : figure?.model;
2386
+ let modelCanvas: HTMLCanvasElement | undefined;
2387
+ const modelDensity=Math.max(1,Math.min(4,this.zoom()));
2388
+ try {
2389
+ modelCanvas = model?.draw({ phase, walking, yaw, px: h*modelDensity, frame:this.frame, time: this.time, talk, face: params.get('lipsync') === '0' ? undefined : faceAt(articulation, faceTime, figure?.faceInfo?.seed) });
2390
+ } catch (error) {
2391
+ console.warn('3D figure failed; using its 2D figure:', error);
2392
+ model?.dispose();
2393
+ if (figure) figure.model = undefined;
2394
+ model = undefined;
2395
+ }
2396
+ const rig = model || sprites ? undefined : figure?.rig;
2397
+ // A creature's cut-out (not upright, no 3D model) moves as one body: hops, squashes, leans, breathes, blinks.
2398
+ const creature = figure?.upright === false && !model && !sprites ? creatureMotion({ phase, walking, time: this.time, talk, seed: x * 0.05 }) : undefined;
2399
+ // A rig walks its legs; a plain cut-out (or the stand-in) only bobs and rocks as it slides.
2400
+ const bob = creature ? creature.lift * h : rig || model || sprites || !walking ? 0 : Math.abs(Math.sin(phase * Math.PI * 2)) * h * 0.025;
2401
+ const rock = creature ? creature.lean * (facing < 0 ? -1 : 1) : rig || model || sprites || !walking ? 0 : Math.sin(phase * Math.PI * 2) * 0.035;
2402
+ let rigCanvas: CanvasImageSource | undefined;
2403
+ if (rig) {
2404
+ const at = pose(rig.rig, phase, walking);
2405
+ drawRig(rig, at, phase, walking);
2406
+ const sheet = rig.rig.sheet;
2407
+ const definition = figure?.faceInfo;
2408
+ // A legacy parts rig follows the torso; a baked mesh walk has registrations for its individual cells.
2409
+ const imagePose = definition?.rigPose;
2410
+ const mapped = !sheet && imagePose ? offsetFace(imagePose, at.torso.to[0] - at.torso.from[0], at.torso.to[1] - at.torso.from[1]) : undefined;
2411
+ rigCanvas = figure?.face && (sheet || mapped) ? figure.face.draw(rig.canvas, sheet ? `rig:${sheet.file}` : '$image', sheet ? sheetCell(sheet, phase, walking) : 0,
2412
+ 1, 1, articulation, faceTime, 0, mapped) : rig.canvas;
2413
+ }
2414
+ /** Draws the figure into c with its feet at c's origin; returns its half width. */
2415
+ const paintRaw = (c: CanvasRenderingContext2D): number => {
2416
+ if (sprites) {
2417
+ // The nearest painted facing; the left-facing ones are the right ones mirrored.
2418
+ const a = Math.atan2(Math.sin(yaw), Math.cos(yaw));
2419
+ const nearest = (list: Sheet[], to: number) => list.reduce((best, s) => (Math.abs(s.yaw - to) < Math.abs(best.yaw - to) ? s : best));
2420
+ // A figure with no walk sheets (an NPC) always stands on its talk sheet, listening when it isn't speaking.
2421
+ const onTalk = sprites.talk.length > 0 && ((talk && !walking) || sprites.sheets.length === 0);
2422
+ const sheet = onTalk ? nearest(sprites.talk, Math.abs(a)) : nearest(sprites.sheets, Math.abs(a));
2423
+ const { cols, rows, frames, feetAt, px } = sprites.info;
2424
+ const [cw, ch] = [sheet.img.width / cols, sheet.img.height / rows];
2425
+ const steps = sheet.walk.length ? sheet.walk : [...Array(frames).keys()];
2426
+ const i = onTalk ? (talk === 'speak' ? this.gestureCell(sheet, sprites.info) : 0) : walking ? steps[Math.floor((((phase % 1) + 1) % 1) * steps.length) % steps.length] : sheet.stand;
2427
+ const s = h / px;
2428
+ if (a < 0) c.scale(-1, 1);
2429
+ const face = figure?.face ? figure.face.draw(sheet.img, sheet.file, i, cols, rows,
2430
+ talk === 'speak' ? mixer?.mouth(this.speech) ?? mouthAt(undefined, undefined) : mouthAt(undefined, undefined), performance.now() / 1000) : undefined;
2431
+ if (face && face !== sheet.img) c.drawImage(face, (-cw / 2) * s, -ch * feetAt * s, cw * s, ch * s);
2432
+ else c.drawImage(sheet.img, (i % cols) * cw, Math.floor(i / cols) * ch, cw, ch, (-cw / 2) * s, -ch * feetAt * s, cw * s, ch * s);
2433
+ return (cw / 2) * s;
2434
+ }
2435
+ if (modelCanvas && model) {
2436
+ // yaw turns the model itself, so no mirroring; feet sit at (width / 2, height * feetAt).
2437
+ const w=modelCanvas.width/modelDensity,h=modelCanvas.height/modelDensity;
2438
+ c.drawImage(modelCanvas,-w/2,-h*model.feetAt,w,h);
2439
+ return w/2;
2440
+ }
2441
+ c.scale(facing, 1);
2442
+ if (rig) {
2443
+ const s = h / rig.rig.height;
2444
+ const [ax, ay] = [rig.rig.hip[0], rig.rig.ground];
2445
+ c.drawImage(rigCanvas ?? rig.canvas, -ax * s, -ay * s, rig.canvas.width * s, rig.canvas.height * s);
2446
+ return Math.max(ax, rig.canvas.width - ax) * s;
2447
+ }
2448
+ const img = figure?.img;
2449
+ const w = img ? (h * img.width) / img.height : h * 0.36;
2450
+ // A creature squashes and stretches from the feet up, keeping its volume.
2451
+ const [sx2, sy2] = creature ? [1 / Math.sqrt(creature.stretch), creature.stretch] : [1, 1];
2452
+ if (img) c.drawImage(figure?.face?.draw(img, '$image', 0, 1, 1, articulation, faceTime) ?? img, (-w / 2) * sx2, -h * sy2, w * sx2, h * sy2);
2453
+ else drawFigure(c, h, colour);
2454
+ return w / 2;
2455
+ };
2456
+ // Standing is never a still picture: the body bends a little from the planted feet, the head swaying most (a slow
2457
+ // weight shift plus a shorter drift), and the chest and shoulders rise with each breath. Each person keeps their
2458
+ // own phase; talking makes it livelier. ?idle=0 turns it off.
2459
+ const idle = !walking && !creature && params.get('idle') !== '0';
2460
+ // The body and its rim share one bent picture: it is painted once per person per frame.
2461
+ const token = {};
2462
+ const paint = (c: CanvasRenderingContext2D): number => {
2463
+ if (!idle) return paintRaw(c);
2464
+ const t = this.time + (Math.abs(x * 0.37 + y * 0.11) % 7);
2465
+ const k = talk === 'speak' ? 1.5 : 1;
2466
+ const sway = k * (0.024 * Math.sin(t * ((2 * Math.PI) / 5.3)) + 0.009 * Math.sin(t * ((2 * Math.PI) / 2.3) + 1.1));
2467
+ const breath = 0.5 + 0.5 * Math.sin(t * ((2 * Math.PI) / 3.6));
2468
+ const m = c.getTransform();
2469
+ const density = Math.max(1, Math.min(1.5, Math.hypot(m.a, m.b)));
2470
+ return idleWarp(c, paintRaw, token, h, density, sway, k * 0.02 * breath, 0.009 * k * Math.sin(t * ((2 * Math.PI) / 4.1) + 0.7));
2471
+ };
2472
+ // How much of the floor under the feet lies in a painted shadow: the person stands in it too.
2473
+ const shaded = Math.max(this.shadeAt(x, y, h), this.fireShadeAt(x, y, h));
2474
+ const strength = g.light?.strength ?? 0.35;
2475
+ ctx.save();
2476
+ // Parallax: the person slides with the floor under their feet; size and light still come from where they stand.
2477
+ ctx.translate(sx, sy);
2478
+ // One shadow per person. Under the sun the cast shadow is it, so the soft blob around the feet gives way to
2479
+ // it (two shadows read as two lights) and only a thin dark contact under the soles stays. In shade, fog or
2480
+ // no measured light, the blob is the whole shadow.
2481
+ // The sun, or a fire's light on this person (its own way, fading with distance). Under a row of alike lamps
2482
+ // (two lamp posts on a quay) no shadow runs one way: only the soft blob under the feet.
2483
+ const light = lightFor(g, x, y, unit, this.time);
2484
+ const cast = castsShadow(g) && light && light.length > 0 && light.strength > 0.02 && shaded < 0.98 ? 1 - shaded : 0;
2485
+ const blob = (0.12 + 0.4 * strength) * (1 - cast);
2486
+ // Shadows lie on the floor: every one is drawn before the things and the people (the shadow layer), so a
2487
+ // near person's long shadow never darkens someone farther away or a fire behind them.
2488
+ if (layer === 'shadow') {
2489
+ if (blob > 0.01) {
2490
+ ctx.fillStyle = `rgba(0,0,0,${blob.toFixed(3)})`;
2491
+ ctx.beginPath();
2492
+ ctx.ellipse(x, y, h * 0.16, Math.max(2, h * 0.035), 0, 0, Math.PI * 2);
2493
+ ctx.fill();
2494
+ }
2495
+ if (cast > 0) {
2496
+ ctx.fillStyle = `rgba(0,0,0,${(0.3 * cast).toFixed(3)})`;
2497
+ ctx.beginPath();
2498
+ ctx.ellipse(x, y, h * 0.07, Math.max(1.5, h * 0.014), 0, 0, Math.PI * 2);
2499
+ ctx.fill();
2500
+ }
2501
+ const rect = cast > 0 ? this.castShadow(x, y, h, unit / 1.62, light!, cast * light!.strength, paintRaw, [sx, sy]) : undefined;
2502
+ ctx.restore();
2503
+ return rect ?? [x + sx, y + sy, x + sx, y + sy];
2504
+ }
2505
+ // Near a flame a person's light breathes with it.
2506
+ const breath = light?.at && g.light?.flame && g.light.strength > 0 ? 1 + 0.5 * FLAME_GLOW * flicker(this.time).glow * Math.min(1, light.strength / g.light.strength) * (1 - shaded) : 1;
2507
+ const bright = this.lightAt(x, y - h * 0.5) * (1 - shaded * strength) * breath;
2508
+ // A party member is edged in its player's colour (twice, so the edge reads at a glance on a busy painting).
2509
+ const edge = glow ? ` drop-shadow(0 0 ${Math.max(2, h * 0.008).toFixed(1)}px ${glow}) drop-shadow(0 0 ${Math.max(2, h * 0.008).toFixed(1)}px ${glow})` : '';
2510
+ this.separate(x, y, h, paintRaw);
2511
+ ctx.filter = `brightness(${bright.toFixed(2)})${lit ? ' drop-shadow(0 0 5px rgba(255,225,150,0.95))' : ''}${edge}`;
2512
+ const frameAt = ctx.getTransform();
2513
+ ctx.translate(x, y - bob);
2514
+ ctx.rotate(rock);
2515
+ // paint mirrors ctx for a figure facing left; the rim is drawn already mirrored, so it goes on the unmirrored frame.
2516
+ const feetAt = ctx.getTransform();
2517
+ const half = paint(ctx);
2518
+ // The light on this person here (the sun, or a lamp's own way); none measured, no rim.
2519
+ if (rimScale > 0 && light && light.strength > 0.02 && Math.hypot(light.dir[0], light.dir[1]) > 0) {
2520
+ ctx.setTransform(feetAt);
2521
+ this.rim(h, paint, bright * (1 - shaded), light);
2522
+ }
2523
+ this.shadeOver(paint, frameAt, feetAt, [x - half - 4, y - h * 1.06 - bob - 4, x + half + 4, y + Math.max(3, h * 0.04) + 4]);
2524
+ ctx.restore();
2525
+ return [x - half - 2 + sx, y - h * 1.04 - bob - 2 + sy, x + half + 2 + sx, y + Math.max(3, h * 0.04) + sy];
2526
+ }
2527
+
2528
+ /**
2529
+ * The talk cell for the line being said: listening for its first moment, then the story's gesture for that
2530
+ * line, easing back to listening now and then so a long line has a rhythm.
2531
+ */
2532
+ private gestureCell(sheet: Sheet, info: SpriteInfo): number {
2533
+ const speech = this.speech;
2534
+ const cell = speech ? sheet.gestures.indexOf(info.lines?.[speech.text] ?? 'listen') : -1;
2535
+ if (!speech || cell <= 0) return 0;
2536
+ const t = this.time - speech.from;
2537
+ return t < 0.3 || (t - 0.3) % 1.5 > 1.0 ? 0 : cell;
2538
+ }
2539
+
2540
+ /** Whether `key` is speaking a line now, or listening in a conversation (between lines, both listen). */
2541
+ private talking(key: string): 'speak' | 'listen' | undefined {
2542
+ if (this.speech?.who === key) return 'speak';
2543
+ return this.speech?.who || this.state.talk ? 'listen' : undefined;
2544
+ }
2545
+
2546
+ private shadowCanvas = document.createElement('canvas');
2547
+
2548
+ /**
2549
+ * The figure's silhouette laid on the floor away from the light: its feet stay put and its head lands
2550
+ * length * h along dir. Drawn soft, at the painting's own shadow strength. ctx is already shifted for parallax.
2551
+ */
2552
+ private castShadow(x: number, y: number, h: number, perM: number, light: NonNullable<Geometry['light']>, alpha: number, paint: (c: CanvasRenderingContext2D) => number, [sx, sy]: [number, number]): Rect {
2553
+ const sc = this.shadowCanvas;
2554
+ const [w, hh] = [Math.ceil(h * 1.4), Math.ceil(h * 1.25)];
2555
+ const [ox, oy] = [w / 2, Math.ceil(h * 1.15)];
2556
+ // Sized once for the tallest person the place can hold: resizing a canvas mid-walk stalls a frame.
2557
+ if (sc.width < w || sc.height < hh) {
2558
+ const most = personPx(this.place.g, this.place.g.frame.height);
2559
+ Object.assign(sc, { width: Math.ceil(Math.max(w, most * 1.4)), height: Math.ceil(Math.max(hh, most * 1.25)) });
2560
+ }
2561
+ const c = sc.getContext('2d', { willReadFrequently: true })!;
2562
+ c.setTransform(1, 0, 0, 1, 0, 0);
2563
+ c.clearRect(0, 0, sc.width, sc.height);
2564
+ c.translate(ox, oy);
2565
+ const half = paint(c);
2566
+ c.setTransform(1, 0, 0, 1, 0, 0);
2567
+ c.globalCompositeOperation = 'source-in';
2568
+ // Painted shadows are cool, not black: a deep blue reads like the shade the painter used.
2569
+ c.fillStyle = '#121830';
2570
+ c.fillRect(0, 0, sc.width, sc.height);
2571
+ c.globalCompositeOperation = 'source-over';
2572
+ const onGround = this.place.depth && this.groundShadow(x, y, h, perM, light, alpha, sc, w, hh, ox, oy, half, [sx, sy]);
2573
+ if (onGround) return onGround;
2574
+ const [dx, dy] = light.dir;
2575
+ const n = Math.hypot(dx, dy) || 1;
2576
+ const [ux, uy] = [(dx / n) * light.length, (dy / n) * light.length];
2577
+ ctx.save();
2578
+ ctx.translate(x, y);
2579
+ // (u, v) in the figure (v up is negative) goes to (u - ux * v, -uy * v): a head at v = -h lands at h * (ux, uy).
2580
+ ctx.transform(1, 0, -ux, -uy, 0, 0);
2581
+ ctx.globalAlpha = Math.min(0.85, alpha * 1.35);
2582
+ // A little softer the farther it reaches, like a real penumbra, but as crisp as the painted ones.
2583
+ ctx.filter = `blur(${Math.max(0.6, h * 0.004 * (1 + light.length)).toFixed(1)}px)`;
2584
+ ctx.drawImage(sc, 0, 0, w, hh, -ox, -oy, w, hh);
2585
+ ctx.restore();
2586
+ const [hx, hy] = [x + ux * h * 1.15, y + uy * h * 1.15];
2587
+ return [Math.min(x, hx) - half - 4 + sx, Math.min(y, hy) - 4 + sy, Math.max(x, hx) + half + 4 + sx, Math.max(y, hy) + 4 + sy];
2588
+ }
2589
+
2590
+ private groundCanvas = document.createElement('canvas');
2591
+
2592
+ /**
2593
+ * The cast shadow laid on the ground as the depth map shapes it (src/play/shadow.ts): each ground pixel
2594
+ * asks whether the figure stands between it and the sun. Worked out at the depth map's half resolution
2595
+ * and drawn up soft. Painted shadow and water take it away. Undefined: no sun found here, use the flat one.
2596
+ */
2597
+ private groundShadow(x: number, y: number, h: number, perM: number, light: NonNullable<Geometry['light']>, alpha: number, sc: HTMLCanvasElement, w: number, hh: number, ox: number, oy: number, half: number, [sx, sy]: [number, number]): Rect | undefined {
2598
+ const { g, depth, shade, wet } = this.place;
2599
+ const sun = depth && sunFor(g, depth, light, x, y, h);
2600
+ if (!depth || !sun) return undefined;
2601
+ const px = sc.getContext('2d', { willReadFrequently: true })!.getImageData(0, 0, w, hh).data;
2602
+ const sil = { alpha: new Uint8ClampedArray(w * hh), w, h: hh, ox, oy, perM };
2603
+ for (let i = 0; i < w * hh; i++) sil.alpha[i] = px[i * 4 + 3];
2604
+ // Where it can fall: past the flat shadow's reach (a drop lengthens it), wide as the figure, inside the frame.
2605
+ const n = Math.hypot(light.dir[0], light.dir[1]) || 1;
2606
+ const reach = h * Math.max(1, light.length) * 2.2;
2607
+ const [ex, ey] = [x + (light.dir[0] / n) * reach, y + (light.dir[1] / n) * reach];
2608
+ const pad = half + h * 0.15;
2609
+ const S = DEPTH_SCALE;
2610
+ const x0 = Math.max(0, Math.floor((Math.min(x, ex) - pad) / S));
2611
+ const y0 = Math.max(0, Math.floor((Math.min(y, ey) - pad) / S));
2612
+ const x1 = Math.min(depth.w, Math.ceil((Math.max(x, ex) + pad) / S));
2613
+ const y1 = Math.min(depth.h, Math.ceil((Math.max(y, ey) + pad) / S));
2614
+ if (x1 <= x0 || y1 <= y0) return undefined;
2615
+ const gc = this.groundCanvas;
2616
+ const [bw, bh] = [x1 - x0, y1 - y0];
2617
+ if (gc.width < bw || gc.height < bh) Object.assign(gc, { width: Math.max(gc.width, bw), height: Math.max(gc.height, bh) });
2618
+ const out = new ImageData(bw, bh);
2619
+ const W = g.frame.width;
2620
+ const on = this.catchesOf(this.gone());
2621
+ let any = false;
2622
+ for (let r = y0; r < y1; r++) for (let c = x0; c < x1; c++) {
2623
+ const [fx, fy] = [c * S + S / 2, r * S + S / 2];
2624
+ let a = shadowAt(depth, sun, sil, fx, fy, on);
2625
+ if (a <= 0) continue;
2626
+ const i = Math.round(fy) * W + Math.round(fx);
2627
+ // Already in the painting's shade: no darker. On water a shadow hardly shows (it mirrors the sky).
2628
+ if (shade?.bits[i]) continue;
2629
+ if (wet) a *= 1 - 0.8 * (wet[i] / 255);
2630
+ const o = ((r - y0) * bw + (c - x0)) * 4;
2631
+ [out.data[o], out.data[o + 1], out.data[o + 2], out.data[o + 3]] = [0x12, 0x18, 0x30, Math.round(a * 255)];
2632
+ any = true;
2633
+ }
2634
+ if (!any) return [x + sx, y + sy, x + sx, y + sy];
2635
+ if (!keepAttached(out, Math.floor(x / S) - x0, Math.floor(y / S) - y0, Math.max(2, Math.round(h * 0.04 / S)), Math.round(h * 0.15 / S))) return undefined;
2636
+ const gx = gc.getContext('2d')!;
2637
+ gx.clearRect(0, 0, gc.width, gc.height);
2638
+ gx.putImageData(out, 0, 0);
2639
+ const [dark, blur] = [Math.min(0.85, alpha * 1.35), `blur(${Math.max(0.8, h * 0.004 * (1 + light.length)).toFixed(1)}px)`];
2640
+ ctx.save();
2641
+ ctx.globalAlpha = dark;
2642
+ ctx.filter = blur;
2643
+ ctx.imageSmoothingEnabled = true;
2644
+ ctx.drawImage(gc, 0, 0, bw, bh, x0 * S, y0 * S, bw * S, bh * S);
2645
+ ctx.restore();
2646
+ this.climb(gc, [x0 * S, y0 * S, bw, bh], y, dark, blur, [sx, sy]);
2647
+ return [x0 * S + sx, y0 * S + sy, x1 * S + sx, y1 * S + sy];
2648
+ }
2649
+
2650
+ /** Shadows that land on things standing behind their person (a tent wall): drawn over the cards. */
2651
+ private climbs: Array<{ canvas: HTMLCanvasElement; box: [number, number, number, number]; dark: number; blur: string; shift: [number, number] }> = [];
2652
+ private climbPool: HTMLCanvasElement[] = [];
2653
+ private climbMask = document.createElement('canvas');
2654
+
2655
+ /**
2656
+ * Keeps the part of a person's shadow (src: bw x bh cells of DEPTH_SCALE px from frame (x, y)) that falls on
2657
+ * the things standing behind them: the shadow layer lies under the cards, so this part goes back on top.
2658
+ */
2659
+ private climb(src: HTMLCanvasElement, [x, y, bw, bh]: [number, number, number, number], feetY: number, dark: number, blur: string, shift: [number, number]) {
2660
+ const S = DEPTH_SCALE;
2661
+ const gone = this.gone();
2662
+ const behind = this.place.cuts.filter((c) => !c.occ.front && c.y < feetY - 2 && !(c.occ.thing && gone.has(c.occ.thing)) && c.x0 < x + bw * S && c.x0 + c.canvas.width > x && c.y0 < y + bh * S && c.y0 + c.canvas.height > y);
2663
+ if (!behind.length) return;
2664
+ const canvas = (this.climbPool[this.climbs.length] ??= document.createElement('canvas'));
2665
+ if (canvas.width < bw || canvas.height < bh) Object.assign(canvas, { width: Math.max(canvas.width, bw), height: Math.max(canvas.height, bh) });
2666
+ const m = this.climbMask;
2667
+ if (m.width < bw || m.height < bh) Object.assign(m, { width: Math.max(m.width, bw), height: Math.max(m.height, bh) });
2668
+ const mc = m.getContext('2d')!;
2669
+ mc.clearRect(0, 0, m.width, m.height);
2670
+ for (const c of behind) mc.drawImage(c.canvas, (c.x0 - x) / S, (c.y0 - y) / S, c.canvas.width / S, c.canvas.height / S);
2671
+ const cc = canvas.getContext('2d')!;
2672
+ cc.clearRect(0, 0, canvas.width, canvas.height);
2673
+ cc.drawImage(src, 0, 0, bw, bh, 0, 0, bw, bh);
2674
+ cc.globalCompositeOperation = 'destination-in';
2675
+ cc.drawImage(m, 0, 0, bw, bh, 0, 0, bw, bh);
2676
+ cc.globalCompositeOperation = 'source-over';
2677
+ this.climbs.push({ canvas, box: [x, y, bw, bh], dark, blur, shift });
2678
+ }
2679
+
2680
+ private drawClimbs() {
2681
+ const S = DEPTH_SCALE;
2682
+ for (const { canvas, box: [x, y, bw, bh], dark, blur, shift } of this.climbs) {
2683
+ ctx.save();
2684
+ ctx.translate(...shift);
2685
+ ctx.globalAlpha = dark;
2686
+ ctx.filter = blur;
2687
+ ctx.drawImage(canvas, 0, 0, bw, bh, x, y, bw * S, bh * S);
2688
+ ctx.restore();
2689
+ }
2690
+ }
2691
+
2692
+ /**
2693
+ * Where a person's shadow can land on this place (src/play/shadow.ts catchers): its floors and the things
2694
+ * standing there, not the fog, sky or far backdrop. Made on first use and again when a thing is carried off.
2695
+ */
2696
+ private catchesOf(gone: Set<string>): Uint8Array | undefined {
2697
+ const { g, depth, cuts } = this.place;
2698
+ if (!depth) return undefined;
2699
+ const key = [...gone].sort().join(',');
2700
+ if (this.place.catches?.gone !== key) {
2701
+ const owner = ownerMap(depth, cuts.filter((c) => !c.occ.front && !(c.occ.thing && gone.has(c.occ.thing))));
2702
+ this.place.catches = { gone: key, on: catchers(depth, g, owner) };
2703
+ }
2704
+ return this.place.catches.on;
2705
+ }
2706
+
2707
+ /**
2708
+ * The fire or lamp standing on this place's floor (light.at), made on first use and again when the story
2709
+ * carries a thing off: the painted things' shadows from it (needs the depth map) and the flame to animate.
2710
+ */
2711
+ private fireOf(gone: Set<string>): Fire | undefined {
2712
+ const { g, depth, cuts, shade, wet, bg } = this.place;
2713
+ const at = g.light?.at;
2714
+ if (!at) return undefined;
2715
+ const key = [...gone].sort().join(',');
2716
+ if (this.place.fire?.gone === key) return this.place.fire;
2717
+ const px = personPx(g, at[1]);
2718
+ const holds = (b: number[]) => at[0] >= b[0] && at[0] <= b[2] && at[1] >= b[1] && at[1] <= b[3];
2719
+ // The light's own thing: the smallest one holding it. A tall one (a lamp post) shines from its top.
2720
+ const own = g.things.filter((t) => holds(t.box)).sort((a, b) => (a.box[2] - a.box[0]) * (a.box[3] - a.box[1]) - (b.box[2] - b.box[0]) * (b.box[3] - b.box[1]))[0];
2721
+ const tall = own && own.box[3] - own.box[1] > 1.5 * (own.box[2] - own.box[0]);
2722
+ const up = tall ? (0.9 * (own.box[3] - own.box[1]) * 1.62) / px : 1;
2723
+ const shadows: Fire['shadows'] = [];
2724
+ // Under a row of alike lamps no thing's shadow runs from one of them either.
2725
+ if (depth && castsShadow(g)) {
2726
+ const S = DEPTH_SCALE;
2727
+ const owner = ownerMap(depth, cuts.filter((c) => !c.occ.front && !holds(c.occ.box) && !(c.occ.thing && (gone.has(c.occ.thing) || c.occ.thing === own?.key))));
2728
+ for (const side of g.light!.flame ? [-1, 0, 1] : [0]) {
2729
+ const s = thingShadows(depth, g, owner, at, { up, shift: side * FLAME_SWAY_M });
2730
+ const W = g.frame.width;
2731
+ // Painted shade is dark already; on water a shadow hardly shows.
2732
+ for (let r = 0; r < s.h; r++) for (let k = 0; k < s.w; k++) {
2733
+ if (!s.alpha[r * s.w + k]) continue;
2734
+ const i = Math.min(g.frame.height - 1, Math.round((r + 0.5) * s.cell * S)) * W + Math.min(W - 1, Math.round((k + 0.5) * s.cell * S));
2735
+ if (shade?.bits[i]) s.alpha[r * s.w + k] = 0;
2736
+ else if (wet) s.alpha[r * s.w + k] *= 1 - 0.8 * (wet[i] / 255);
2737
+ }
2738
+ shadows.push(s);
2739
+ }
2740
+ }
2741
+ const [w, h] = shadows.length ? [shadows[0].w, shadows[0].h] : [1, 1];
2742
+ const fire: Fire = { gone: key, shadows, mix: new Float32Array(w * h), img: new ImageData(w, h), canvas: Object.assign(document.createElement('canvas'), { width: w, height: h }), px };
2743
+ if (g.light!.flame && bg) fire.flame = this.flamePixels(bg, at, px);
2744
+ this.place.fire = fire;
2745
+ return fire;
2746
+ }
2747
+
2748
+ /** The painting's flame over a fire at `at`: its bright warm pixels, to sway and flare on top of it. */
2749
+ private flamePixels(bg: HTMLImageElement, at: [number, number], px: number): Fire['flame'] {
2750
+ const { width: W, height: H } = this.place.g.frame;
2751
+ const x0 = Math.max(0, Math.round(at[0] - 0.35 * px));
2752
+ const y0 = Math.max(0, Math.round(at[1] - 0.8 * px));
2753
+ const [w, h] = [Math.min(W, Math.round(at[0] + 0.35 * px)) - x0, Math.min(H, Math.round(at[1] + 0.1 * px)) - y0];
2754
+ if (w <= 0 || h <= 0) return undefined;
2755
+ const canvas = Object.assign(document.createElement('canvas'), { width: w, height: h });
2756
+ const c = canvas.getContext('2d', { willReadFrequently: true })!;
2757
+ c.drawImage(bg, x0, y0, w, h, 0, 0, w, h);
2758
+ const img = c.getImageData(0, 0, w, h);
2759
+ const d = img.data;
2760
+ const ramp = (v: number, a: number, b: number) => Math.max(0, Math.min(1, (v - a) / (b - a)));
2761
+ for (let i = 0; i < d.length; i += 4) {
2762
+ const luma = 0.3 * d[i] + 0.59 * d[i + 1] + 0.11 * d[i + 2];
2763
+ d[i + 3] = Math.round(255 * ramp(luma, 140, 215) * ramp(d[i] - d[i + 2], 40, 100));
2764
+ }
2765
+ c.putImageData(img, 0, 0);
2766
+ return { canvas, x0, y0 };
2767
+ }
2768
+
2769
+ /** The things' shadows from the fire as it sways now; under the floor's own shear in a diorama. */
2770
+ private drawFireShadows(fire: Fire | undefined, below?: [number, number, number, number, number, number]) {
2771
+ const light = this.place.g.light;
2772
+ if (!fire?.shadows.length || !light) return;
2773
+ const { sway, glow } = flicker(this.time);
2774
+ const { mix, img, shadows } = fire;
2775
+ // Between the two swayed shadows nearest the flame's place now.
2776
+ const [a, b, k] = shadows.length === 1 ? [shadows[0], shadows[0], 0] : sway < 0 ? [shadows[1], shadows[0], -sway] : [shadows[1], shadows[2], sway];
2777
+ let any = false;
2778
+ for (let i = 0; i < mix.length; i++) {
2779
+ mix[i] = a.alpha[i] * (1 - k) + b.alpha[i] * k;
2780
+ img.data[i * 4] = 0x12;
2781
+ img.data[i * 4 + 1] = 0x18;
2782
+ img.data[i * 4 + 2] = 0x30;
2783
+ img.data[i * 4 + 3] = Math.round(mix[i] * 255);
2784
+ any ||= mix[i] > 0;
2785
+ }
2786
+ if (!any) return;
2787
+ fire.canvas.getContext('2d')!.putImageData(img, 0, 0);
2788
+ const cell = shadows[0].cell * DEPTH_SCALE;
2789
+ ctx.save();
2790
+ if (below) ctx.transform(...below);
2791
+ ctx.globalAlpha = Math.min(0.85, light.strength * 1.35 * (light.flame ? 1 + FLAME_GLOW * glow : 1));
2792
+ ctx.filter = `blur(${(cell * 0.6).toFixed(1)}px)`;
2793
+ ctx.imageSmoothingEnabled = true;
2794
+ ctx.drawImage(fire.canvas, 0, 0, fire.canvas.width * cell, fire.canvas.height * cell);
2795
+ ctx.restore();
2796
+ }
2797
+
2798
+ /** How much of the feet at (x, y) stand in a thing's shadow from the fire, 0..1. */
2799
+ private fireShadeAt(x: number, y: number, h: number): number {
2800
+ const fire = this.place.fire;
2801
+ if (!fire?.shadows.length) return 0;
2802
+ const { w, h: rows, cell } = fire.shadows[0];
2803
+ const S = cell * DEPTH_SCALE;
2804
+ let sum = 0;
2805
+ for (const dx of [-0.08, 0, 0.08]) {
2806
+ const [c, r] = [Math.floor((x + dx * h) / S), Math.floor((y - 2) / S)];
2807
+ if (c >= 0 && r >= 0 && c < w && r < rows) sum += fire.mix[r * w + c];
2808
+ }
2809
+ return Math.min(1, sum);
2810
+ }
2811
+
2812
+ /** The flame flickers: its bright pixels sway and flare over the painted ones, a warm glow breathes round it, sparks rise. */
2813
+ private drawFlame(fire: Fire) {
2814
+ const g = this.place.g;
2815
+ const at = g.light!.at!;
2816
+ const t = this.time;
2817
+ const { glow } = flicker(t);
2818
+ const [fx] = flameAt(g, t);
2819
+ const px = fire.px;
2820
+ this.at(at[1], () => {
2821
+ ctx.save();
2822
+ ctx.globalCompositeOperation = 'lighter';
2823
+ const [gx, gy] = [fx, at[1] - 0.2 * px];
2824
+ const halo = ctx.createRadialGradient(gx, gy, 0, gx, gy, 1.3 * px);
2825
+ halo.addColorStop(0, `rgba(255,150,60,${(0.07 + 0.04 * glow).toFixed(3)})`);
2826
+ halo.addColorStop(1, 'rgba(255,150,60,0)');
2827
+ ctx.fillStyle = halo;
2828
+ ctx.fillRect(gx - 1.3 * px, gy - 1.3 * px, 2.6 * px, 2.6 * px);
2829
+ const flame = fire.flame;
2830
+ if (flame) {
2831
+ const { canvas, x0, y0 } = flame;
2832
+ const base = at[1];
2833
+ const amp = 0.012 * px;
2834
+ ctx.globalAlpha = Math.max(0, 0.3 + 0.15 * glow);
2835
+ for (let y = 0; y < canvas.height; y += 3) {
2836
+ // Higher up it sways more, and the whole flame stretches and settles with its glow.
2837
+ const rise = Math.max(0, (base - (y0 + y)) / (0.8 * px));
2838
+ const dx = (0.6 * Math.sin((y0 + y) * 0.11 - t * 8) + 0.4 * Math.sin((y0 + y) * 0.05 - t * 5.3)) * amp * rise + (fx - at[0]) * rise;
2839
+ const ty = base - (base - (y0 + y)) * (1 + 0.05 * glow);
2840
+ ctx.drawImage(canvas, 0, y, canvas.width, 3, x0 + dx, ty, canvas.width, 3 * (1 + 0.05 * glow));
2841
+ }
2842
+ }
2843
+ // A few sparks, each on its own loop: up from the flames, drifting, dimming.
2844
+ ctx.globalAlpha = 1;
2845
+ for (let i = 0; i < 7; i++) {
2846
+ const life = 1.3 + ((i * 0.37) % 0.9);
2847
+ const age = (t + i * 0.61) % life;
2848
+ const loop = Math.floor((t + i * 0.61) / life);
2849
+ const seed = Math.sin(loop * 12.9898 + i * 78.233) * 43758.5453;
2850
+ const r = seed - Math.floor(seed);
2851
+ const k = age / life;
2852
+ const x = at[0] + (r - 0.5) * 0.25 * px + Math.sin(t * 3 + i) * 0.04 * px * k;
2853
+ const y = at[1] - 0.15 * px - k * (0.5 + 0.4 * r) * px;
2854
+ ctx.fillStyle = `rgba(255,${Math.round(170 + 60 * r)},90,${((1 - k) * 0.8).toFixed(3)})`;
2855
+ ctx.beginPath();
2856
+ ctx.arc(x, y, Math.max(0.8, 0.006 * px * (1 - 0.5 * k)), 0, Math.PI * 2);
2857
+ ctx.fill();
2858
+ }
2859
+ ctx.restore();
2860
+ });
2861
+ }
2862
+
2863
+ private shadeCanvas: { place: Place; canvas: HTMLCanvasElement } | undefined;
2864
+ private shadeScratch = document.createElement('canvas');
2865
+
2866
+ /**
2867
+ * The painting's own shadows (a beam of shade across a path) fall on a person standing in them, pixel by pixel:
2868
+ * the part of the body inside a painted shadow is darkened softly, the rest stays lit. ?shadeover=0 turns it off.
2869
+ */
2870
+ private shadeOver(paint: (c: CanvasRenderingContext2D) => number, frameAt: DOMMatrix, feetAt: DOMMatrix, box: Rect) {
2871
+ const shade = this.place.shade;
2872
+ if (!shade || params.get('shadeover') === '0') return;
2873
+ const { width: W } = this.place.g.frame;
2874
+ const H = shade.bits.length / W;
2875
+ // Only when some of the painted shadow lies inside the person's box.
2876
+ const [bx0, by0, bx1, by1] = [Math.max(0, Math.floor(box[0])), Math.max(0, Math.floor(box[1])), Math.min(W, Math.ceil(box[2])), Math.min(H, Math.ceil(box[3]))];
2877
+ let any = false;
2878
+ for (let y = by0; y < by1 && !any; y += 3) for (let x = bx0; x < bx1; x += 3) if (shade.bits[y * W + x]) { any = true; break; }
2879
+ if (!any) return;
2880
+ if (this.shadeCanvas?.place !== this.place) {
2881
+ const c = Object.assign(document.createElement('canvas'), { width: W, height: H });
2882
+ const img = new ImageData(W, H);
2883
+ // Painted shadows are cool, not black: the same deep blue as a person's cast shadow.
2884
+ for (let i = 0; i < W * H; i++) if (shade.bits[i]) [img.data[i * 4], img.data[i * 4 + 1], img.data[i * 4 + 2], img.data[i * 4 + 3]] = [0x12, 0x18, 0x30, 255];
2885
+ c.getContext('2d')!.putImageData(img, 0, 0);
2886
+ // Soft edges, as a painted shadow has.
2887
+ const soft = Object.assign(document.createElement('canvas'), { width: W, height: H });
2888
+ const sc = soft.getContext('2d')!;
2889
+ sc.filter = 'blur(3px)';
2890
+ sc.drawImage(c, 0, 0);
2891
+ this.shadeCanvas = { place: this.place, canvas: soft };
2892
+ }
2893
+ const o = this.shadeScratch;
2894
+ if (o.width !== canvas.width || o.height !== canvas.height) Object.assign(o, { width: canvas.width, height: canvas.height });
2895
+ const oc = o.getContext('2d')!;
2896
+ oc.setTransform(1, 0, 0, 1, 0, 0);
2897
+ oc.clearRect(0, 0, o.width, o.height);
2898
+ oc.setTransform(feetAt);
2899
+ paint(oc);
2900
+ oc.globalCompositeOperation = 'source-in';
2901
+ oc.setTransform(frameAt);
2902
+ oc.drawImage(this.shadeCanvas.canvas, 0, 0);
2903
+ oc.globalCompositeOperation = 'source-over';
2904
+ ctx.save();
2905
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
2906
+ ctx.filter = 'none';
2907
+ ctx.globalAlpha = 0.42;
2908
+ ctx.drawImage(o, 0, 0);
2909
+ ctx.restore();
2910
+ }
2911
+
2912
+ /** The share of the floor under the feet (an ellipse a person's shadow would cover) inside a painted shadow. */
2913
+ private shadeAt(x: number, y: number, h: number): number {
2914
+ const shade = this.place.shade;
2915
+ if (!shade) return 0;
2916
+ const W = this.place.g.frame.width;
2917
+ const H = shade.bits.length / W;
2918
+ let n = 0;
2919
+ let hit = 0;
2920
+ for (let j = -2; j <= 2; j++) for (let i = -4; i <= 4; i++) {
2921
+ const px = Math.round(x + (i / 4) * h * 0.12);
2922
+ const py = Math.round(y + (j / 2) * h * 0.03);
2923
+ if (px < 0 || py < 0 || px >= W || py >= H) continue;
2924
+ n++;
2925
+ hit += shade.bits[py * W + px];
2926
+ }
2927
+ return n ? hit / n : 0;
2928
+ }
2929
+
2930
+ /** How bright a person looks at (x, y): the painting's luma around that spot, softened. */
2931
+ private lightAt(x: number, y: number): number {
2932
+ const light = this.place.light;
2933
+ if (!light) return 1;
2934
+ const c = Math.min(light.cols - 1, Math.max(0, Math.floor(x / LIGHT_CELL)));
2935
+ const r = Math.min(light.rows - 1, Math.max(0, Math.floor(y / LIGHT_CELL)));
2936
+ let sum = 0;
2937
+ let n = 0;
2938
+ for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) {
2939
+ const rr = r + dr;
2940
+ const cc = c + dc;
2941
+ if (rr < 0 || cc < 0 || rr >= light.rows || cc >= light.cols) continue;
2942
+ sum += light.luma[rr * light.cols + cc];
2943
+ n++;
2944
+ }
2945
+ return Math.min(1.1, Math.max(0.4, 0.35 + (0.65 * sum) / n / 150));
2946
+ }
2947
+
2948
+ /** For the explorer: where the depth map puts the painting in front of feet at (x, feetY), over rect (src/qa/probe.ts). */
2949
+ frontOf(rect: [number, number, number, number], x: number, feetY: number): Uint8Array | null {
2950
+ return this.depthFront(rect, feetY, x)?.front ?? null;
2951
+ }
2952
+
2953
+ /** False while the explorer draws a person with nothing put back over them (src/qa/probe.ts). */
2954
+ depthOcclusion = true;
2955
+ private snap?: HTMLCanvasElement;
2956
+ private snapped = false;
2957
+ private depthMask?: HTMLCanvasElement;
2958
+ private depthPatch?: HTMLCanvasElement;
2959
+
2960
+ /**
2961
+ * Where, over a person's box (whole px), the place stands in front of them by the depth map and no card looks
2962
+ * after it: a lamp post, a railing, a bridge's parapet nobody named (src/play/depth-occlusion.ts). In screen
2963
+ * space: under parallax each painted pixel slides as the floor row at its own distance.
2964
+ */
2965
+ private depthFront(rect: [number, number, number, number], feetY: number, x?: number): { front: Uint8Array; r: [number, number, number, number] } | undefined {
2966
+ const { g, depth, cuts } = this.place;
2967
+ if (!depth) return undefined;
2968
+ const owned = (this.place.carded ??= ownerMap(depth, cuts));
2969
+ // Where the feet are: a figure's box need not be centred on them.
2970
+ const feetX = x ?? (rect[0] + rect[2]) / 2;
2971
+ const r: [number, number, number, number] = [Math.floor(rect[0]), Math.floor(rect[1]), Math.ceil(rect[2]), Math.ceil(Math.min(rect[3], feetY))];
2972
+ const w = r[2] - r[0];
2973
+ const h = r[3] - r[1];
2974
+ if (w <= 0 || h <= 0) return undefined;
2975
+ const p = this.parallax();
2976
+ if (!p) {
2977
+ const front = frontMask(g, depth, r, feetX, feetY, owned);
2978
+ return front ? { front, r } : undefined;
2979
+ }
2980
+ const pad = Math.ceil(Math.max(...[g.scale.horizonY, g.frame.height].map((y) => Math.max(...this.shift(y).map(Math.abs))))) + 2;
2981
+ const src: [number, number, number, number] = [r[0] - pad, r[1] - pad, r[2] + pad, r[3] + pad];
2982
+ const back = frontMask(g, depth, src, feetX, feetY, owned);
2983
+ if (!back) return undefined;
2984
+ const sw = src[2] - src[0];
2985
+ const front = new Uint8Array(w * h);
2986
+ const shifts = new Map<number, [number, number]>();
2987
+ let any = false;
2988
+ for (let v = src[1]; v < src[3]; v++) for (let u = src[0]; u < src[2]; u++) {
2989
+ if (!back[(v - src[1]) * sw + (u - src[0])]) continue;
2990
+ const row = Math.round(depth.rows[Math.min(depth.h - 1, Math.floor(v / DEPTH_SCALE)) * depth.w + Math.min(depth.w - 1, Math.floor(u / DEPTH_SCALE))]);
2991
+ let s = shifts.get(row);
2992
+ if (!s) shifts.set(row, (s = this.shift(g.scale.horizonY + row)));
2993
+ const x = Math.round(u + s[0]) - r[0];
2994
+ const y = Math.round(v + s[1]) - r[1];
2995
+ if (x < 0 || y < 0 || x >= w || y >= h) continue;
2996
+ front[y * w + x] = 1;
2997
+ any = true;
2998
+ }
2999
+ return any ? { front, r } : undefined;
3000
+ }
3001
+
3002
+ /** Puts the place, as drawn this frame without anyone, back over a person where depthFront says it is in front. */
3003
+ private occludeDepth(rect: [number, number, number, number], feetY: number, feetX?: number) {
3004
+ if (!this.snapped || !this.snap) return;
3005
+ const found = this.depthFront(rect, feetY, feetX);
3006
+ if (!found) return;
3007
+ const { front, r } = found;
3008
+ const w = r[2] - r[0];
3009
+ const h = r[3] - r[1];
3010
+ const mask = (this.depthMask ??= document.createElement('canvas'));
3011
+ Object.assign(mask, { width: w, height: h });
3012
+ const mc = mask.getContext('2d')!;
3013
+ const img = mc.createImageData(w, h);
3014
+ for (let i = 0; i < front.length; i++) if (front[i]) img.data[i * 4 + 3] = 255;
3015
+ mc.putImageData(img, 0, 0);
3016
+ // In the canvas's own pixels: the snapshot was taken there, zoomed and panned.
3017
+ const t = ctx.getTransform();
3018
+ const dx = Math.floor(t.a * r[0] + t.e);
3019
+ const dy = Math.floor(t.d * r[1] + t.f);
3020
+ const dw = Math.ceil(t.a * w) + 1;
3021
+ const dh = Math.ceil(t.d * h) + 1;
3022
+ if (dw <= 0 || dh <= 0) return;
3023
+ const patch = (this.depthPatch ??= document.createElement('canvas'));
3024
+ Object.assign(patch, { width: dw, height: dh });
3025
+ const pc = patch.getContext('2d')!;
3026
+ pc.globalCompositeOperation = 'source-over';
3027
+ pc.drawImage(this.snap, dx, dy, dw, dh, 0, 0, dw, dh);
3028
+ pc.globalCompositeOperation = 'destination-in';
3029
+ pc.imageSmoothingEnabled = true;
3030
+ pc.drawImage(mask, t.a * r[0] + t.e - dx, t.d * r[1] + t.f - dy, t.a * w, t.d * h);
3031
+ ctx.save();
3032
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
3033
+ ctx.drawImage(patch, dx, dy);
3034
+ ctx.restore();
3035
+ }
3036
+
3037
+ /** Puts the painting back over the hero, column by column, wherever an occluder stands in front of him. */
3038
+ private occlude(rect: [number, number, number, number], feetY: number, gone: Set<string>, feetX?: number, drawnFeetY?: number) {
3039
+ if (this.depthOcclusion) this.occludeDepth(rect, drawnFeetY ?? feetY, feetX);
3040
+ const support = (k: string) => this.place.byKey.get(k);
3041
+ for (const cut of this.place.cuts) {
3042
+ if (cut.occ.thing && gone.has(cut.occ.thing)) continue;
3043
+ // The person's box as seen from the card, which slid by its own amount.
3044
+ const [sx, sy] = this.cardShift(cut, (rect[0] + rect[2]) / 2);
3045
+ const [left, top, right, bottom] = [rect[0] - sx, rect[1] - sy, rect[2] - sx, rect[3] - sy];
3046
+ const [bx0, by0, bx1, by1] = cut.occ.box;
3047
+ if (bx1 < left || bx0 > right || by1 < top || by0 > bottom) continue;
3048
+ const y0 = Math.max(top, cut.y0);
3049
+ const y1 = Math.min(bottom, cut.y0 + cut.canvas.height);
3050
+ if (y1 <= y0) continue;
3051
+ for (const [a, b] of hiddenRuns(cut.occ, left, right, feetY, support)) {
3052
+ const xa = Math.max(a, cut.x0);
3053
+ const xb = Math.min(b, cut.x0 + cut.canvas.width);
3054
+ if (xb > xa) this.drawCard(cut, xa, xb, y0, y1);
3055
+ }
3056
+ }
3057
+ }
3058
+
3059
+ /** A paper bubble over the speaker with their name on a tag; its tail points down at them. The narrator's has no tag. */
3060
+ private drawSpeech([left, top, right]: [number, number, number, number], name?: string) {
3061
+ if (!this.speech) return;
3062
+ if (!this.speech.who) return this.drawCaption(this.speech.text);
3063
+ ctx.save();
3064
+ ctx.font = '22px Georgia, serif';
3065
+ const words = this.speech.text.split(/\s+/);
3066
+ const lines: string[] = [];
3067
+ for (const word of words) {
3068
+ const last = lines[lines.length - 1];
3069
+ if (last && ctx.measureText(`${last} ${word}`).width < 420) lines[lines.length - 1] = `${last} ${word}`;
3070
+ else lines.push(word);
3071
+ }
3072
+ const width = Math.max(...lines.map((l) => ctx.measureText(l).width)) + 40;
3073
+ const height = lines.length * 28 + (name ? 30 : 22);
3074
+ const head = (left + right) / 2;
3075
+ const cx = Math.min(canvas.width - width / 2 - 12, Math.max(width / 2 + 12, head));
3076
+ const tail = 22;
3077
+ const y = Math.max(name ? 24 : 10, top - height - tail - 6);
3078
+ const ink = '#2b2118';
3079
+ ctx.lineJoin = 'round';
3080
+ ctx.lineWidth = 2.5;
3081
+ ctx.strokeStyle = ink;
3082
+ ctx.fillStyle = '#fbf4e2';
3083
+ ctx.shadowColor = 'rgba(43,33,24,0.25)';
3084
+ ctx.shadowOffsetY = 4;
3085
+ ctx.beginPath();
3086
+ ctx.roundRect(cx - width / 2, y, width, height, 20);
3087
+ ctx.fill();
3088
+ ctx.shadowColor = 'transparent';
3089
+ ctx.stroke();
3090
+ // The tail leans from under the bubble toward the speaker's head; it is filled after the stroke so no line crosses its root.
3091
+ const tx = Math.min(cx + width / 2 - 34, Math.max(cx - width / 2 + 34, head));
3092
+ const tipX = Math.max(tx - 40, Math.min(tx + 40, head));
3093
+ ctx.beginPath();
3094
+ ctx.moveTo(tx - 13, y + height - 1.5);
3095
+ ctx.lineTo(tipX, y + height + tail);
3096
+ ctx.lineTo(tx + 13, y + height - 1.5);
3097
+ ctx.fill();
3098
+ ctx.stroke();
3099
+ ctx.fillRect(tx - 11.5, y + height - 4, 23, 3);
3100
+ if (name) {
3101
+ ctx.font = 'bold 16px Georgia, serif';
3102
+ const tw = ctx.measureText(name).width + 24;
3103
+ const tagX = cx - width / 2 + 16;
3104
+ ctx.fillStyle = this.speech.who === this.data.world.hero.key ? '#c9774a' : '#5f8f8a';
3105
+ ctx.lineWidth = 2;
3106
+ ctx.beginPath();
3107
+ ctx.roundRect(tagX, y - 14, tw, 26, 10);
3108
+ ctx.fill();
3109
+ ctx.stroke();
3110
+ ctx.fillStyle = '#fbf4e2';
3111
+ ctx.textAlign = 'left';
3112
+ ctx.textBaseline = 'middle';
3113
+ ctx.fillText(name, tagX + 12, y - 1);
3114
+ }
3115
+ ctx.font = '22px Georgia, serif';
3116
+ ctx.fillStyle = ink;
3117
+ ctx.textAlign = 'center';
3118
+ ctx.textBaseline = 'top';
3119
+ lines.forEach((l, i) => ctx.fillText(l, cx, y + (name ? 18 : 12) + i * 28));
3120
+ ctx.restore();
3121
+ }
3122
+
3123
+ /** The narrator (the intro) speaks in a caption across the top of the frame, not from anyone's mouth. */
3124
+ private drawCaption(text: string) {
3125
+ ctx.save();
3126
+ ctx.setTransform(res, 0, 0, res, 0, 0);
3127
+ ctx.font = 'italic 23px Georgia, serif';
3128
+ const max = Math.min(820, frameW() - 80);
3129
+ const lines: string[] = [];
3130
+ for (const word of text.split(/\s+/)) {
3131
+ const last = lines[lines.length - 1];
3132
+ if (last && ctx.measureText(`${last} ${word}`).width < max - 56) lines[lines.length - 1] = `${last} ${word}`;
3133
+ else lines.push(word);
3134
+ }
3135
+ const width = Math.max(...lines.map((l) => ctx.measureText(l).width)) + 56;
3136
+ const height = lines.length * 30 + 26;
3137
+ const x = (frameW() - width) / 2;
3138
+ const y = 22;
3139
+ ctx.fillStyle = 'rgba(251,244,226,0.95)';
3140
+ ctx.strokeStyle = '#2b2118';
3141
+ ctx.lineWidth = 2.5;
3142
+ ctx.shadowColor = 'rgba(43,33,24,0.3)';
3143
+ ctx.shadowOffsetY = 4;
3144
+ ctx.beginPath();
3145
+ ctx.roundRect(x, y, width, height, 16);
3146
+ ctx.fill();
3147
+ ctx.shadowColor = 'transparent';
3148
+ ctx.stroke();
3149
+ // A fold mark at each end, like a pinned paper strip.
3150
+ ctx.fillStyle = '#c9774a';
3151
+ for (const cx of [x + 16, x + width - 16]) {
3152
+ ctx.beginPath();
3153
+ ctx.arc(cx, y + 14, 4, 0, Math.PI * 2);
3154
+ ctx.fill();
3155
+ }
3156
+ ctx.fillStyle = '#2b2118';
3157
+ ctx.textAlign = 'center';
3158
+ ctx.textBaseline = 'top';
3159
+ lines.forEach((l, i) => ctx.fillText(l, frameW() / 2, y + 14 + i * 30));
3160
+ ctx.restore();
3161
+ }
3162
+
3163
+ private drawGrid() {
3164
+ const { g, grid, hots } = this.place;
3165
+ ctx.save();
3166
+ ctx.fillStyle = 'rgba(80,255,140,0.22)';
3167
+ for (let r = 0; r < grid.rows; r++) for (let c = 0; c < grid.cols; c++) if (grid.isOpen(c, r)) ctx.fillRect(c * g.cell, r * g.cell, g.cell - 1, g.cell - 1);
3168
+ ctx.strokeStyle = '#ff5050';
3169
+ ctx.lineWidth = 2;
3170
+ for (const o of g.occluders) {
3171
+ ctx.beginPath();
3172
+ const line = o.floor?.length ? o.floor : [[o.box[0], o.baseY], [o.box[2], o.baseY]];
3173
+ line.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
3174
+ ctx.stroke();
3175
+ }
3176
+ ctx.fillStyle = '#ffd34d';
3177
+ for (const h of hots) ctx.fillRect(h.thing.stand[0] - 4, h.thing.stand[1] - 4, 8, 8);
3178
+ ctx.strokeStyle = 'rgba(120,180,255,0.7)';
3179
+ ctx.setLineDash([6, 6]);
3180
+ ctx.beginPath();
3181
+ ctx.moveTo(0, g.scale.horizonY);
3182
+ ctx.lineTo(g.frame.width, g.scale.horizonY);
3183
+ ctx.stroke();
3184
+ ctx.restore();
3185
+ }
3186
+ }
3187
+
3188
+ /** Poses a rig's parts into its own canvas, back to front, in the figure's coordinates. */
3189
+ function drawRig(fig: NonNullable<Figure['rig']>, at: ReturnType<typeof pose>, phase = 0, walking = false) {
3190
+ const c = fig.canvas.getContext('2d')!;
3191
+ c.clearRect(0, 0, fig.canvas.width, fig.canvas.height);
3192
+ const sheet = fig.rig.sheet;
3193
+ if (sheet && fig.sheet) {
3194
+ // The walk drawn ahead (src/scene/warp.ts): one cell, scaled back up to the rig's size.
3195
+ const i = sheetCell(sheet, phase, walking);
3196
+ const [cw, ch] = sheet.cell;
3197
+ c.drawImage(fig.sheet, (i % sheet.cols) * cw, Math.floor(i / sheet.cols) * ch, cw, ch, 0, 0, fig.canvas.width, fig.canvas.height);
3198
+ return;
3199
+ }
3200
+ for (const part of fig.rig.parts) {
3201
+ const img = fig.parts.get(part.name);
3202
+ const p = at[part.name];
3203
+ if (!img || !p) continue;
3204
+ c.save();
3205
+ c.translate(p.to[0], p.to[1]);
3206
+ c.rotate(p.turn);
3207
+ c.translate(-p.from[0], -p.from[1]);
3208
+ c.drawImage(img, part.at[0], part.at[1]);
3209
+ c.restore();
3210
+ }
3211
+ }
3212
+
3213
+ /**
3214
+ * A figure (one image, or a sprite sheet of cells) with its clothes in a player's colour: everything but skin and the
3215
+ * head (the top fifth of each figure, so hair and face stay) takes the colour's hue, keeping its own light and shade.
3216
+ */
3217
+ function dressSheet(img: HTMLImageElement, colour: string, grid?: SpriteInfo): HTMLImageElement {
3218
+ const c = Object.assign(document.createElement('canvas'), { width: img.width, height: img.height });
3219
+ const cx = c.getContext('2d', { willReadFrequently: true })!;
3220
+ cx.drawImage(img, 0, 0);
3221
+ const data = cx.getImageData(0, 0, c.width, c.height);
3222
+ const px = data.data;
3223
+ const [hp, sp] = hsl(parseInt(colour.slice(1, 3), 16), parseInt(colour.slice(3, 5), 16), parseInt(colour.slice(5, 7), 16));
3224
+ const rows = grid ? grid.rows : 1;
3225
+ const ch = c.height / rows;
3226
+ // Where the head ends in a cell: a fifth of the figure down from its top (feet at feetAt, about px tall).
3227
+ const headEnd = grid ? ch * grid.feetAt - grid.px * 0.8 : 0;
3228
+ // A plain cut-out fills its image: its head is the top fifth of what is not transparent.
3229
+ let top = 0;
3230
+ if (!grid) {
3231
+ outer: for (let y = 0; y < c.height; y++) for (let x = 0; x < c.width; x++) if (px[(y * c.width + x) * 4 + 3] > 40) { top = y; break outer; }
3232
+ }
3233
+ const plainHead = top + (c.height - top) * 0.2;
3234
+ for (let y = 0; y < c.height; y++) {
3235
+ const inCell = y % ch;
3236
+ if (grid ? inCell < headEnd : y < plainHead) continue;
3237
+ for (let x = 0; x < c.width; x++) {
3238
+ const i = (y * c.width + x) * 4;
3239
+ if (px[i + 3] < 8) continue;
3240
+ const [h, s, l] = hsl(px[i], px[i + 1], px[i + 2]);
3241
+ // Skin (warm, a little saturated, not too dark or bright) keeps its colour: hands, arms, bare legs.
3242
+ if (h <= 50 && s >= 0.18 && s <= 0.8 && l >= 0.28 && l <= 0.88) continue;
3243
+ const [r, g, b] = rgb(hp, Math.max(0.45, sp * 0.85), 0.16 + l * 0.62);
3244
+ px[i] = px[i] * 0.15 + r * 0.85;
3245
+ px[i + 1] = px[i + 1] * 0.15 + g * 0.85;
3246
+ px[i + 2] = px[i + 2] * 0.15 + b * 0.85;
3247
+ }
3248
+ }
3249
+ cx.putImageData(data, 0, 0);
3250
+ return c as unknown as HTMLImageElement;
3251
+ }
3252
+
3253
+ /** Hue in degrees, saturation and lightness 0..1. */
3254
+ function hsl(r: number, g: number, b: number): [number, number, number] {
3255
+ [r, g, b] = [r / 255, g / 255, b / 255];
3256
+ const max = Math.max(r, g, b);
3257
+ const min = Math.min(r, g, b);
3258
+ const l = (max + min) / 2;
3259
+ if (max === min) return [0, 0, l];
3260
+ const d = max - min;
3261
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
3262
+ const h = max === r ? ((g - b) / d + (g < b ? 6 : 0)) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
3263
+ return [h * 60, s, l];
3264
+ }
3265
+
3266
+ function rgb(h: number, s: number, l: number): [number, number, number] {
3267
+ const k = (n: number) => (n + h / 30) % 12;
3268
+ const a = s * Math.min(l, 1 - l);
3269
+ const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
3270
+ return [f(0) * 255, f(8) * 255, f(4) * 255];
3271
+ }
3272
+
3273
+ /** Room round a cut-out for its outline's line and glow. */
3274
+ const OUTLINE_PAD = 12;
3275
+
3276
+ /** The outline of a frame-sized mask (round `box` when it is empty), in a canvas just big enough. */
3277
+ function bitsOutline(mask: Bits, W: number, box: Rect): Outline {
3278
+ const H = mask.bits.length / W;
3279
+ let [x0, y0, x1, y1] = [W, H, 0, 0];
3280
+ for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (mask.bits[y * W + x]) {
3281
+ if (x < x0) x0 = x;
3282
+ if (x >= x1) x1 = x + 1;
3283
+ if (y < y0) y0 = y;
3284
+ y1 = y + 1;
3285
+ }
3286
+ if (x1 <= x0) [x0, y0, x1, y1] = box.map(Math.round);
3287
+ [x0, y0] = [x0 - OUTLINE_PAD, y0 - OUTLINE_PAD];
3288
+ const [w, h] = [x1 + OUTLINE_PAD - x0, y1 + OUTLINE_PAD - y0];
3289
+ const alpha = new Uint8Array(w * h);
3290
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
3291
+ const [fx, fy] = [x0 + x, y0 + y];
3292
+ if (fx >= 0 && fy >= 0 && fx < W && fy < H && mask.bits[fy * W + fx]) alpha[y * w + x] = 255;
3293
+ }
3294
+ return { canvas: outlineCanvas(alpha, w, h), x0, y0 };
3295
+ }
3296
+
3297
+ /**
3298
+ * A glowing line round the shape of `alpha` (w x h), smoothed: the mask's pixel steps and hairline gaps are blurred
3299
+ * away, specks and pin holes dropped, and the contour cut round its corners, so the line follows the thing.
3300
+ */
3301
+ function outlineCanvas(alpha: Uint8Array, w: number, h: number): HTMLCanvasElement {
3302
+ const c = Object.assign(document.createElement('canvas'), { width: w, height: h });
3303
+ const loops = outlineLoops(softAlpha(alpha, w, h, 2), w, h, { threshold: 128, minArea: 120, minHole: 500, smooth: 3 });
3304
+ if (!loops.length) return c;
3305
+ const cx = c.getContext('2d')!;
3306
+ const path = new Path2D();
3307
+ for (const loop of loops) {
3308
+ path.moveTo(...loop[0]);
3309
+ for (const p of loop.slice(1)) path.lineTo(...p);
3310
+ path.closePath();
3311
+ }
3312
+ cx.lineJoin = 'round';
3313
+ cx.shadowColor = 'rgba(255,225,150,0.9)';
3314
+ cx.shadowBlur = 8;
3315
+ cx.strokeStyle = 'rgba(255,236,180,0.95)';
3316
+ cx.lineWidth = 2;
3317
+ cx.stroke(path);
3318
+ return c;
3319
+ }
3320
+
3321
+ /** A plain stand-in person, drawn facing right with feet at the origin. */
3322
+ function drawFigure(c: CanvasRenderingContext2D, h: number, colour: string) {
3323
+ c.fillStyle = colour;
3324
+ c.strokeStyle = '#f2e6c9';
3325
+ c.lineWidth = Math.max(1, h * 0.012);
3326
+ const body = new Path2D();
3327
+ body.ellipse(0, -h * 0.9, h * 0.075, h * 0.09, 0, 0, Math.PI * 2);
3328
+ body.roundRect(-h * 0.11, -h * 0.79, h * 0.22, h * 0.42, h * 0.05);
3329
+ body.roundRect(-h * 0.09, -h * 0.4, h * 0.075, h * 0.4, h * 0.02);
3330
+ body.roundRect(h * 0.015, -h * 0.4, h * 0.075, h * 0.4, h * 0.02);
3331
+ c.fill(body);
3332
+ c.stroke(body);
3333
+ }
3334
+
3335
+ const game = new Game();
3336
+ // ?sound=0 plays silent; m mutes.
3337
+ const mixer = params.get('sound') === '0' ? undefined : new Mixer(game as unknown as Listened, files);
3338
+ // Children who cannot read can hear answers by navigating with keys or a pad.
3339
+ game.answers.hear = (text) => {
3340
+ if (game.data?.world.audience === 'kids') mixer?.hear(text);
3341
+ };
3342
+ /** The scene point under the pointer: canvas pixels, then out of the parallax zoom. */
3343
+ const toFrame = (e: MouseEvent): Point => {
3344
+ const r = canvas.getBoundingClientRect();
3345
+ return game.unzoom(((e.clientX - r.left) / r.width) * frameW(), ((e.clientY - r.top) / r.height) * frameH());
3346
+ };
3347
+
3348
+ canvas.addEventListener('pointermove', (e) => {
3349
+ // A finger has no hover: nothing follows it, and the view does not look around after it.
3350
+ if (!game.place || e.pointerType === 'touch') return;
3351
+ keyboard = false;
3352
+ usePad(false);
3353
+ const r = canvas.getBoundingClientRect();
3354
+ pointer = { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height };
3355
+ game.hover = game.thingAt(...toFrame(e));
3356
+ // Off the floor (a wall, the sky, the water) the cursor says no, so nobody clicks blind.
3357
+ const off = !game.hover && !game.place.grid.walkable(...game.floorAt(...toFrame(e)));
3358
+ canvas.style.cursor = hideCursor || game.holding ? 'none' : game.hover ? 'pointer' : off ? 'not-allowed' : 'default';
3359
+ game.bag.point(e.clientX, e.clientY, game.hover?.thing.key.replace(/-/g, ' '));
3360
+ document.getElementById('hover')!.textContent = game.hover ? game.hover.thing.key.replace(/-/g, ' ') : '';
3361
+ });
3362
+ // A finger holds where a mouse right-clicks: a long press looks at a thing, or puts the item in hand back.
3363
+ let press: { timer: number; fired: boolean; x: number; y: number } | null = null;
3364
+ canvas.addEventListener('pointerdown', (e) => {
3365
+ if (e.pointerType !== 'touch' || !game.place) return;
3366
+ const at = toFrame(e);
3367
+ const p = { fired: false, x: e.clientX, y: e.clientY, timer: 0 };
3368
+ p.timer = window.setTimeout(() => {
3369
+ p.fired = true;
3370
+ if (game.holding) return game.hold(undefined);
3371
+ const hot = game.dark === 0 && !game.state.talk ? game.thingAt(...at) : undefined;
3372
+ if (hot) void game.use(hot.thing.key, 'look');
3373
+ }, LONG_PRESS_MS);
3374
+ press = p;
3375
+ });
3376
+ canvas.addEventListener('pointermove', (e) => {
3377
+ if (press && e.pointerType === 'touch' && Math.hypot(e.clientX - press.x, e.clientY - press.y) > 12) clearTimeout(press.timer);
3378
+ });
3379
+ for (const type of ['pointerup', 'pointercancel'] as const) canvas.addEventListener(type, () => press && clearTimeout(press.timer));
3380
+ canvas.addEventListener('click', (e) => {
3381
+ // The tap that ended a long press has done its work.
3382
+ if (press?.fired) return void (press = null);
3383
+ if (!game.place || game.dark > 0 || game.state.ending) return;
3384
+ // A click away from the answers says goodbye and goes where it points.
3385
+ game.leave();
3386
+ // A double click hurries the walk the first click set off; a single one walks.
3387
+ if (e.detail >= 2 && game.hero.path.length) return void (game.hero.run = true);
3388
+ game.hero.run = false;
3389
+ const [x, y] = toFrame(e);
3390
+ const hot = game.thingAt(x, y);
3391
+ if (hot) void game.use(hot.thing.key);
3392
+ else void game.walkTo(...game.floorAt(x, y));
3393
+ });
3394
+ canvas.addEventListener('contextmenu', (e) => {
3395
+ e.preventDefault();
3396
+ // With an item in hand, a right click puts it back in the bag.
3397
+ if (game.holding) return game.hold(undefined);
3398
+ const hot = game.place && game.dark === 0 ? game.thingAt(...toFrame(e)) : undefined;
3399
+ if (hot) void game.use(hot.thing.key, 'look');
3400
+ });
3401
+ canvas.addEventListener('mouseleave', () => (pointer = null));
3402
+ // The keyboard: WASD or the arrows walk, E, Space or Enter use the nearest thing in reach (it lights up),
3403
+ // 1 to 9 take an item from the bag when nobody is talking (in a conversation they pick an answer; Esc or a click away leaves it).
3404
+ const DIRS: Record<string, Point> = { w: [0, -1], arrowup: [0, -1], s: [0, 1], arrowdown: [0, 1], a: [-1, 0], arrowleft: [-1, 0], d: [1, 0], arrowright: [1, 0] };
3405
+ const keys = new Set<string>();
3406
+ let keyboard = false;
3407
+ const free = () => game.place && game.dark === 0 && !game.state.talk && !game.state.ending;
3408
+ addEventListener('keydown', (e) => {
3409
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
3410
+ const k = e.key.toLowerCase();
3411
+ if (k === 'shift') keys.add(k);
3412
+ // In a talk the keys go through the answers: arrows or WASD light one, E, Space or Enter say it, Esc says goodbye.
3413
+ if (game.state.talk && !game.state.ending) {
3414
+ if (k in DIRS) {
3415
+ e.preventDefault();
3416
+ game.answers.move(DIRS[k][0] + DIRS[k][1] < 0 ? -1 : 1, true);
3417
+ return;
3418
+ }
3419
+ if (k === 'e' || k === ' ' || k === 'enter') {
3420
+ e.preventDefault();
3421
+ game.answers.pick();
3422
+ return;
3423
+ }
3424
+ }
3425
+ if (k in DIRS) {
3426
+ e.preventDefault();
3427
+ keys.add(k);
3428
+ keyboard = true;
3429
+ return;
3430
+ }
3431
+ if (k === 'g') game.grid = !game.grid;
3432
+ if (k === 'm') mixer?.toggleMute();
3433
+ if (k === 'escape' && !game.leave()) game.hold(undefined);
3434
+ if ((k === 'e' || k === ' ' || k === 'enter') && free()) {
3435
+ e.preventDefault();
3436
+ const hot = game.within();
3437
+ if (hot) void game.use(hot.thing.key);
3438
+ }
3439
+ const n = Number(k);
3440
+ if (n >= 1 && n <= 9 && free() && game.state.items[n - 1]) game.hold(game.holding === game.state.items[n - 1] ? undefined : game.state.items[n - 1]);
3441
+ });
3442
+ addEventListener('keyup', (e) => keys.delete(e.key.toLowerCase()));
3443
+ addEventListener('blur', () => keys.clear());
3444
+
3445
+ // Gamepads, for a child who cannot use keys or a touchpad yet: the stick or d-pad walks, A uses the lit thing (or
3446
+ // picks the lit answer, or plays again at the end), B puts the item in hand back (or looks), the other face buttons and
3447
+ // the shoulders go through the bag one item at a time and back to empty hands. Any pad steers; several at once all do.
3448
+ let pad: PadFrame = { dir: null, pressed: [], pads: 0 };
3449
+ const soundless = document.getElementById('soundless')!;
3450
+ const KEY_HINT = document.querySelector('#bar .hint')?.textContent ?? '';
3451
+ /** Pad mode lights the answer and Play again cards and shows the pad's hints; the mouse moving ends it. */
3452
+ function usePad(on: boolean) {
3453
+ if (document.body.classList.contains('pad') === on) return;
3454
+ document.body.classList.toggle('pad', on);
3455
+ const hint = document.querySelector('#bar .hint');
3456
+ if (hint) hint.textContent = on ? words.padHint : KEY_HINT;
3457
+ }
3458
+ // ?pads=turns: the pads take turns (see Turns); ?turn=60 sets the seconds of a turn.
3459
+ const turns = params.get('pads') === 'turns' ? new Turns(Number(params.get('turn')) || 60) : undefined;
3460
+ // ?pads=party: every pad has its own hero on screen (Game.party); the first pad pressed steers the hero there was.
3461
+ const partyMode = params.get('pads') === 'party';
3462
+ /** Each pad's hero in party mode, by gamepad index, in the order they joined (player 1, 2, ...). */
3463
+ const bodies = new Map<number, Body>();
3464
+ /** The party member whose nearest thing is lit: the one that moved or pressed last. */
3465
+ let focus: Body | undefined;
3466
+ let talked = false;
3467
+ let talkEnded = false;
3468
+ /** The turn badge (whose turn, in the player's colour) and the hand-over card when it passes. */
3469
+ function showTurn(index: number | undefined, handOver: boolean) {
3470
+ const badge = document.getElementById('player')!;
3471
+ const many = !!turns && turns.connected().length > 1 && index !== undefined;
3472
+ badge.hidden = !many;
3473
+ if (!many) return;
3474
+ const n = turns!.player(index!);
3475
+ const colour = PLAYER_COLOURS[(n - 1) % PLAYER_COLOURS.length];
3476
+ badge.style.setProperty('--player', colour);
3477
+ badge.textContent = `${words.player} ${n}`;
3478
+ if (handOver) {
3479
+ const card = document.getElementById('handover')!;
3480
+ card.style.setProperty('--player', colour);
3481
+ card.textContent = `${words.player} ${n}`;
3482
+ card.hidden = false;
3483
+ card.animate([{ opacity: 0, transform: 'translate(-50%, -50%) scale(.6)' }, { opacity: 1, transform: 'translate(-50%, -50%) scale(1.05)', offset: 0.2 }, { opacity: 1, transform: 'translate(-50%, -50%) scale(1)', offset: 0.8 }, { opacity: 0, transform: 'translate(-50%, -50%) scale(1)' }], { duration: 2600, easing: 'ease-out' }).finished.then(() => (card.hidden = true), () => undefined);
3484
+ }
3485
+ }
3486
+ function padInput() {
3487
+ if (partyMode) return partyInput();
3488
+ let only: number | undefined;
3489
+ if (turns) {
3490
+ const now = performance.now();
3491
+ const before = turns.active;
3492
+ // A turn passes when a conversation has ended, or its time is up, but never mid-line, mid-talk or mid-walk.
3493
+ const talking = !!game.state?.talk;
3494
+ if (talked && !talking) talkEnded = true;
3495
+ talked = talking;
3496
+ const calm = free() && !game.speech && !game.hero?.path.length;
3497
+ if (calm && (talkEnded || turns.due(now))) {
3498
+ turns.pass(now);
3499
+ talkEnded = false;
3500
+ }
3501
+ only = turns.current(now);
3502
+ if (only !== before || ((turns.connected().length > 1) === document.getElementById('player')!.hidden)) showTurn(only, before !== undefined && only !== before);
3503
+ // Each player plays their own drawn hero, when the children drew one each.
3504
+ if (only !== before && only !== undefined && game.data?.players?.length) game.hero.figure = game.playerFigure(turns.player(only));
3505
+ if (only === undefined) return void (pad = { dir: null, pressed: [], pads: 0 });
3506
+ }
3507
+ pad = readPads(only);
3508
+ if (!pad.dir && !pad.pressed.length) return;
3509
+ turns?.touched(performance.now());
3510
+ woke();
3511
+ if (game.place) for (const b of pad.pressed) padPress(b, game.hero);
3512
+ }
3513
+
3514
+ /** Any pad input: pad mode on, the nearest thing lit, and the sound started where the browser allows it. */
3515
+ function woke() {
3516
+ usePad(true);
3517
+ keyboard = true;
3518
+ // A pad press is not a gesture that unlocks sound everywhere, but where it is allowed this starts it.
3519
+ mixer?.resume();
3520
+ }
3521
+
3522
+ /** One press from the pad steering `body`: at the end, in a talk (only the hero's player answers), or in play. */
3523
+ function padPress(b: PadButton, body: Body) {
3524
+ if (game.state.ending) {
3525
+ if (b === 'use' || b === 'start') document.querySelector<HTMLButtonElement>('#ending .again')?.click();
3526
+ } else if (game.state.talk) {
3527
+ if (body !== game.hero) return;
3528
+ if (b === 'up' || b === 'left' || b === 'prev') game.answers.move(-1);
3529
+ if (b === 'down' || b === 'right' || b === 'next') game.answers.move(1);
3530
+ if (b === 'use') game.answers.pick();
3531
+ if (b === 'back') game.leave();
3532
+ } else if (free()) {
3533
+ const hot = game.within(body);
3534
+ // Using or looking at something makes this player's hero the one the story follows, unless the hero is still busy
3535
+ // with a walk-and-use of its own.
3536
+ const act = (verb?: Verb) => {
3537
+ if (!hot || (game.busy && body !== game.hero)) return;
3538
+ game.lead(body);
3539
+ void game.use(hot.thing.key, verb);
3540
+ };
3541
+ if (b === 'use') act();
3542
+ if (b === 'back') {
3543
+ if (game.holding) game.hold(undefined);
3544
+ else act('look');
3545
+ }
3546
+ if (b === 'next' || b === 'prev') {
3547
+ // Empty hands, then each item, then empty hands again.
3548
+ const ring = [undefined, ...game.state.items];
3549
+ const i = ring.indexOf(game.holding);
3550
+ game.hold(ring[(i + (b === 'next' ? 1 : -1) + ring.length) % ring.length]);
3551
+ }
3552
+ }
3553
+ }
3554
+
3555
+ /** Party mode: each connected pad steers its own hero; a new pad brings a new hero in the next colour. */
3556
+ function partyInput() {
3557
+ pad = { dir: null, pressed: [], pads: 0 };
3558
+ for (const [index, f] of readEachPad()) {
3559
+ if (!f.dir && !f.pressed.length) continue;
3560
+ if (!game.place) continue;
3561
+ let body = bodies.get(index);
3562
+ if (!body) {
3563
+ const colour = PLAYER_COLOURS[bodies.size % PLAYER_COLOURS.length];
3564
+ // The first pad takes the hero already there; each next one a new hero beside it.
3565
+ body = bodies.size === 0 ? Object.assign(game.hero, { colour }) : game.join(colour, game.playerFigure(bodies.size + 1));
3566
+ bodies.set(index, body);
3567
+ }
3568
+ woke();
3569
+ focus = body;
3570
+ if (f.dir) walkStep(body, f.dir[0], f.dir[1]);
3571
+ for (const b of f.pressed) padPress(b, body);
3572
+ }
3573
+ }
3574
+
3575
+ /** Held direction keys (or a pad) walk the hero a short step at a time; along a wall it slides the way that is open. */
3576
+ function steer() {
3577
+ if (keyboard && game.place) {
3578
+ const near = game.within(focus ?? game.hero);
3579
+ if (near !== game.hover) {
3580
+ game.hover = near;
3581
+ document.getElementById('hover')!.textContent = near ? near.thing.key.replace(/-/g, ' ') : '';
3582
+ }
3583
+ // With an item in hand, its "Use X on Y" hangs over the lit thing, as it would under a mouse.
3584
+ const r = near && game.holding ? game.pageRect(near.thing.key) : undefined;
3585
+ if (game.holding) game.bag.point(r ? r.left + r.width / 2 : -300, r ? r.top : -300, near?.thing.key.replace(/-/g, ' '));
3586
+ }
3587
+ if ((!keys.size && !pad.dir) || !free()) return void headings.delete(game.hero);
3588
+ let [dx, dy] = pad.dir ?? [0, 0];
3589
+ for (const k of keys) if (k in DIRS) [dx, dy] = [dx + DIRS[k][0], dy + DIRS[k][1]];
3590
+ game.hero.run = keys.has('shift');
3591
+ if (dx || dy) walkStep(game.hero, dx, dy);
3592
+ }
3593
+
3594
+ /** The way each walker was last sent, so a held direction does not restart its step every frame. */
3595
+ const headings = new Map<Body, string>();
3596
+ /** Sends a walker one short step toward (dx, dy), sliding along walls and through gaps (WalkGrid.steer). */
3597
+ function walkStep(hero: Body, dx: number, dy: number) {
3598
+ if (!free()) return void headings.delete(hero);
3599
+ const dir = `${dx},${dy}`;
3600
+ // A new step only near the end of the last one, unless the direction changed.
3601
+ const left = hero.path.length ? Math.hypot(hero.path.at(-1)![0] - hero.x, hero.path.at(-1)![1] - hero.y) : 0;
3602
+ // At least a third of the body's height: a step of under a cell was walked in a single frame across the
3603
+ // picture (slower frames, a fine grid), the path was empty when the figure was drawn and it slid standing.
3604
+ const step = Math.max(game.place.g.cell * 0.8, personPx(game.place.g, hero.y) * 0.35);
3605
+ if (dir === headings.get(hero) && left > step * 0.4) return;
3606
+ headings.set(hero, dir);
3607
+ // Near a wall the long step may not fit where a short one still does.
3608
+ const to = game.place.grid.steer([hero.x, hero.y], [dx, dy], step) ?? game.place.grid.steer([hero.x, hero.y], [dx, dy], game.place.g.cell * 0.8);
3609
+ if (to) void game.walkTo(...to, hero);
3610
+ }
3611
+ if (capture) document.getElementById('bar')!.style.display = 'none';
3612
+
3613
+ // For scripts and recorders: drive the hero and listen to what happens.
3614
+ // diorama.sound.record() / stopRecording() give a recorder the page's audio.
3615
+ Object.assign(window, { diorama: Object.assign(game, { sound: mixer }) });
3616
+
3617
+ let last = performance.now();
3618
+ const frame = (now: number) => {
3619
+ requestAnimationFrame(frame);
3620
+ fitHud(canvas);
3621
+ if (!game.place) return;
3622
+ if (loading && !loading.classList.contains('done')) loading.classList.add('done');
3623
+ // Capture mode steps a fixed 1/60 s per frame so a recorded run is the same every time.
3624
+ const dt = capture ? 1 / 60 : Math.min(0.05, (now - last) / 1000);
3625
+ last = now;
3626
+ padInput();
3627
+ // A pad press cannot start the sound; while it is held back, the page asks for one click (pads only).
3628
+ soundless.hidden = !(mixer && mixer.ctx.state === 'suspended' && document.body.classList.contains('pad'));
3629
+ if (!soundless.hidden && soundless.textContent !== words.sound) soundless.textContent = words.sound;
3630
+ steer();
3631
+ game.fillPan(dt);
3632
+ game.update(dt);
3633
+ mixer?.update();
3634
+ game.render();
3635
+ adapt(now);
3636
+ };
3637
+
3638
+ // Fewer canvas pixels when the frames run slow (a phone, a work laptop without a strong GPU): two steps down,
3639
+ // judged on a couple of seconds of frames, never in capture mode (a recording keeps every pixel).
3640
+ const slow: number[] = [];
3641
+ let lastFrame = 0;
3642
+ const adapt = (now: number) => {
3643
+ const gap = now - lastFrame;
3644
+ lastFrame = now;
3645
+ if (capture || params.has('res') || res <= 0.6 || document.hidden || gap > 1000) return;
3646
+ slow.push(gap);
3647
+ if (slow.length < 90) return;
3648
+ const median = [...slow].sort((a, b) => a - b)[45];
3649
+ slow.length = 0;
3650
+ if (median < 26) return;
3651
+ res = res > 0.8 ? 0.75 : 0.6;
3652
+ game.size();
3653
+ };
3654
+ if (params.has('res')) res = Math.min(1, Math.max(0.3, Number(params.get('res')) || 1));
3655
+ requestAnimationFrame(frame);
3656
+ await game.start().catch((e: unknown) => {
3657
+ loading?.classList.add('failed');
3658
+ if (loading) loading.querySelector('.what')!.textContent = 'This world could not be loaded. Try reloading the page.';
3659
+ throw e;
3660
+ });
3661
+
3662
+ /** Turns angle `a` toward `b` by at most `step` radians, the short way round. */
3663
+ function turnToward(a: number, b: number, step: number): number {
3664
+ const d = Math.atan2(Math.sin(b - a), Math.cos(b - a));
3665
+ return a + Math.max(-step, Math.min(step, d));
3666
+ }
3667
+
3668
+ function union(a: Rect, b?: Rect): Rect {
3669
+ return b ? [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])] : a;
3670
+ }
3671
+
3672
+ /** One reusable scratch canvas for the idle bend: every standing person is drawn into it, then laid out in slices. */
3673
+ let idleScratch: HTMLCanvasElement | undefined;
3674
+ let idleBent: HTMLCanvasElement | undefined;
3675
+ /** Who painted the scratch last (and its half width), so a second draw of the same person this frame reuses it. */
3676
+ let idleOwner: { token: object; density: number; half: number } | undefined;
3677
+
3678
+ /**
3679
+ * Draws a standing figure (paint, feet at the origin) bent from its feet: each horizontal slice shifts sideways by
3680
+ * sway x height x u^1.7 (u = height above the feet as a share of the figure), so the feet stay planted and the head
3681
+ * moves most; the slices above the hips lift by up to `lift` x height (the breath), and the head nods by `nod`.
3682
+ */
3683
+ function idleWarp(c: CanvasRenderingContext2D, paint: (c: CanvasRenderingContext2D) => number, token: object, h: number, density: number, sway: number, lift: number, nod: number): number {
3684
+ const W = Math.ceil(h * 1.6 * density), H = Math.ceil(h * 1.35 * density);
3685
+ const foot = Math.ceil(h * 0.15 * density);
3686
+ if (!(W >= 8 && H >= 8 && W <= 4096 && H <= 4096)) return paint(c);
3687
+ const scratch = (idleScratch ??= document.createElement('canvas'));
3688
+ const bent = (idleBent ??= document.createElement('canvas'));
3689
+ let half: number;
3690
+ if (idleOwner?.token === token && idleOwner.density === density) half = idleOwner.half;
3691
+ else {
3692
+ for (const cv of [scratch, bent]) if (cv.width < W || cv.height < H) Object.assign(cv, { width: Math.max(cv.width, W), height: Math.max(cv.height, H) });
3693
+ const s = scratch.getContext('2d')!;
3694
+ s.setTransform(1, 0, 0, 1, 0, 0);
3695
+ s.clearRect(0, 0, W, H);
3696
+ s.setTransform(density, 0, 0, density, W / 2, H - foot);
3697
+ half = paint(s);
3698
+ s.setTransform(1, 0, 0, 1, 0, 0);
3699
+ // The slices go into a second canvas, so the person's light and edge filters run once, on the whole figure.
3700
+ const b = bent.getContext('2d')!;
3701
+ b.setTransform(1, 0, 0, 1, 0, 0);
3702
+ b.clearRect(0, 0, W, H);
3703
+ const slices = 28;
3704
+ const band = H / slices;
3705
+ const smooth = (a: number, z: number, v: number) => { const q = Math.max(0, Math.min(1, (v - a) / (z - a))); return q * q * (3 - 2 * q); };
3706
+ for (let i = 0; i < slices; i++) {
3707
+ const top = i * band;
3708
+ const mid = top + band / 2;
3709
+ const u = Math.max(0, (H - foot - mid) / density / h);
3710
+ const dx = (sway * Math.pow(u, 1.7) + nod * smooth(0.82, 1, u)) * h * density;
3711
+ const dy = -lift * h * density * smooth(0.45, 0.78, u);
3712
+ // A breath stretches the torso: a slice that lifts more than the one under it reaches down to it, so no gap opens.
3713
+ const stretch = lift * h * density * (smooth(0.45, 0.78, u) - smooth(0.45, 0.78, u - band / density / h));
3714
+ b.drawImage(scratch, 0, top, W, band + 1, dx, top + dy, W, band + 1 + stretch);
3715
+ }
3716
+ idleOwner = { token, density, half };
3717
+ }
3718
+ c.drawImage(bent, 0, 0, W, H, -W / 2 / density, -(H - foot) / density, W / density, H / density);
3719
+ return half + Math.abs(sway) * h;
3720
+ }
3721
+
3722
+ /**
3723
+ * A cast shadow starts at the feet. Cells of it that no chain from the feet reaches (hopping gaps up to `gap`
3724
+ * cells: a step, a crack) are noise in the depth map, specks far off on a wall or a roof: they are cleared.
3725
+ * False when nothing within `near` cells of the feet is in shadow.
3726
+ */
3727
+ function keepAttached(out: ImageData, fx: number, fy: number, gap: number, near: number): boolean {
3728
+ const { width: w, height: h, data } = out;
3729
+ const seen = new Uint8Array(w * h);
3730
+ const queue: number[] = [];
3731
+ const reach = Math.max(gap * 2, near);
3732
+ for (let y = Math.max(0, fy - reach); y <= Math.min(h - 1, fy + reach); y++) {
3733
+ for (let x = Math.max(0, fx - reach); x <= Math.min(w - 1, fx + reach); x++) {
3734
+ const i = y * w + x;
3735
+ if (data[i * 4 + 3] > 0) { seen[i] = 1; queue.push(i); }
3736
+ }
3737
+ }
3738
+ if (!queue.length) return false;
3739
+ while (queue.length) {
3740
+ const i = queue.pop()!;
3741
+ const [cx, cy] = [i % w, Math.floor(i / w)];
3742
+ for (let y = Math.max(0, cy - gap); y <= Math.min(h - 1, cy + gap); y++) {
3743
+ for (let x = Math.max(0, cx - gap); x <= Math.min(w - 1, cx + gap); x++) {
3744
+ const j = y * w + x;
3745
+ if (!seen[j] && data[j * 4 + 3] > 0) { seen[j] = 1; queue.push(j); }
3746
+ }
3747
+ }
3748
+ }
3749
+ for (let i = 0; i < w * h; i++) if (!seen[i]) data[i * 4 + 3] = 0;
3750
+ return true;
3751
+ }