@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,200 @@
1
+ // Parallax: a place drawn as a real diorama, a painted backdrop and cut-out cards standing on its floor.
2
+ // The floor is a plane, so how far a spot moves when the camera sways grows with its distance below the
3
+ // horizon; a card moves as the floor row it stands on. No Node imports: the browser bundles this file.
4
+ import type { Geometry, Occluder } from './geometry.ts';
5
+
6
+ /** How far (px) the bottom row moves against the horizon at a full camera swing. Kept small: a diorama, not a flyby. */
7
+ export const PARALLAX_PX = 22;
8
+ /** Vertical swing is half the horizontal one; eyes move sideways more than up and down. */
9
+ const VERTICAL = 0.5;
10
+
11
+ export type Camera = { x: number; y: number };
12
+
13
+ /** 0 at the horizon (and above it, the painted backdrop), 1 at the bottom of the frame. */
14
+ export function disparity(g: Geometry, y: number): number {
15
+ const { horizonY } = g.scale;
16
+ const span = Math.max(1, g.frame.height - horizonY);
17
+ return Math.min(1.25, Math.max(0, (y - horizonY) / span));
18
+ }
19
+
20
+ /** The depth that stays put while the camera sways: the middle of the walkable floor, where the hero lives. */
21
+ export function pivot(g: Geometry): number {
22
+ let sum = 0;
23
+ let n = 0;
24
+ g.walk.forEach((row, r) => {
25
+ for (let c = 0; c < row.length; c++) if (row[c] === '#') (sum += disparity(g, (r + 0.5) * g.cell)), n++;
26
+ });
27
+ return n ? sum / n : 0.5;
28
+ }
29
+
30
+ export type Parallax = { cam: Camera; pivot: number; px: number };
31
+
32
+ /** The shift of anything standing on the floor at feetY (its own pixels all move with it). */
33
+ export function shiftAt(g: Geometry, p: Parallax, feetY: number): [number, number] {
34
+ const d = disparity(g, feetY) - p.pivot;
35
+ return [p.cam.x * p.px * d, p.cam.y * p.px * VERTICAL * d];
36
+ }
37
+
38
+ /** The floor row a card stands on: its base, its support's for a thing on top, the frame's bottom for a framing one. */
39
+ export function cardY(g: Geometry, occ: Occluder, byKey: (key: string) => Occluder | undefined): number {
40
+ if (occ.front) return g.frame.height;
41
+ const under = occ.on ? byKey(occ.on) : undefined;
42
+ return under && under !== occ ? under.baseY : occ.baseY;
43
+ }
44
+
45
+ /**
46
+ * The backdrop's two pieces as canvas transforms [a, b, c, d, e, f] (x' = a x + c y + e, y' = b x + d y + f):
47
+ * above the horizon it only slides; below, the floor shears, near rows sliding more than far ones.
48
+ */
49
+ export function backdropTransforms(g: Geometry, p: Parallax): { above: number[]; below: number[] } {
50
+ const h = g.scale.horizonY;
51
+ const span = Math.max(1, g.frame.height - h);
52
+ const kx = (p.cam.x * p.px) / span;
53
+ const ky = (p.cam.y * p.px * VERTICAL) / span;
54
+ const [ax, ay] = [-p.cam.x * p.px * p.pivot, -p.cam.y * p.px * VERTICAL * p.pivot];
55
+ return { above: [1, 0, 0, 1, ax, ay], below: [1, 0, kx, 1 + ky, ax - kx * h, ay - ky * h] };
56
+ }
57
+
58
+ /** Where a point of the floor seen on screen at (x, y) lies in the painting. */
59
+ export function floorPoint(g: Geometry, p: Parallax, x: number, y: number): [number, number] {
60
+ const { above, below } = backdropTransforms(g, p);
61
+ let py = (y - below[5]) / below[3];
62
+ if (py < g.scale.horizonY) py = y - above[5];
63
+ const [dx] = py < g.scale.horizonY ? [above[4]] : [below[2] * py + below[4]];
64
+ return [x - dx, py];
65
+ }
66
+
67
+ /** The zoom that keeps the frame's edges covered at a full swing in any direction. */
68
+ export function overscan(g: Geometry, p: Parallax): number {
69
+ const reach = Math.max(p.pivot, disparity(g, g.frame.height) - p.pivot) * p.px;
70
+ return 1 + Math.max((2 * reach) / g.frame.width, (2 * reach * VERTICAL) / g.frame.height);
71
+ }
72
+
73
+ /**
74
+ * Where a panning camera's middle goes along one axis: `want`, moved as little as it takes to keep each span of
75
+ * `keep` ([from, to], the hero first, then where they walk and the thing they were sent to) inside the view, `half`
76
+ * either side of the middle, `margin` in from its edges, and never further than `room` from `centre`. A span that
77
+ * cannot fit with the ones before it is let go: the hero is kept first.
78
+ */
79
+ export function panTo(want: number, centre: number, room: number, half: number, margin: number, keep: Array<[number, number]>): number {
80
+ let lo = centre - room;
81
+ let hi = centre + room;
82
+ for (const [a, b] of keep) {
83
+ const l = Math.max(lo, b - half + margin);
84
+ const h = Math.min(hi, a + half - margin);
85
+ if (l > h) continue;
86
+ [lo, hi] = [l, h];
87
+ }
88
+ return Math.max(lo, Math.min(hi, want));
89
+ }
90
+
91
+ /** Gaps up to this many px between stacked pieces of one thing are painted over by its card. */
92
+ export const STACK_GAP = 48;
93
+
94
+ /**
95
+ * Cards for a parallax place: SAM often cuts one thing into pieces stacked in the same columns (a post's
96
+ * cap and its foot, with the rope coil between them missed). Each such stack becomes one card, standing
97
+ * where its lowest piece meets the floor; `stacked` marks cards whose gaps the runtime fills in.
98
+ */
99
+ export function stackCards(occluders: Occluder[]): Array<Occluder & { stacked?: boolean }> {
100
+ const out: Array<Occluder & { stacked?: boolean }> = occluders.map((o) => ({ ...o }));
101
+ const overlap = (a: Occluder, b: Occluder) => Math.min(a.box[2], b.box[2]) - Math.max(a.box[0], b.box[0]);
102
+ for (let merged = true; merged; ) {
103
+ merged = false;
104
+ for (const lo of out) {
105
+ const up = out.find((u) => u !== lo && u.thing && u.thing === lo.thing && !u.front && !lo.front && !u.on && !lo.on
106
+ && u.box[3] <= lo.box[1] + 4 && lo.box[1] - u.box[3] <= STACK_GAP
107
+ && overlap(u, lo) > 0.5 * Math.min(u.box[2] - u.box[0], lo.box[2] - lo.box[0]));
108
+ if (!up) continue;
109
+ lo.box = [Math.min(lo.box[0], up.box[0]), up.box[1], Math.max(lo.box[2], up.box[2]), lo.box[3]];
110
+ lo.stacked = true;
111
+ out.splice(out.indexOf(up), 1);
112
+ merged = true;
113
+ break;
114
+ }
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /**
120
+ * Per walk-grid column, the row where the walkable floor ends at the back (smoothed). The backdrop between
121
+ * the horizon and that row is taken as standing upright there (a quay wall, a railing, the far side of a
122
+ * room), so it slides as one piece with the floor's back edge instead of leaning with the floor's shear.
123
+ */
124
+ export function backEdge(g: Geometry, smooth = 7): Float32Array {
125
+ const cols = Math.max(...g.walk.map((r) => r.length));
126
+ const raw = new Float32Array(cols).fill(NaN);
127
+ for (let c = 0; c < cols; c++) {
128
+ const r = g.walk.findIndex((row) => row[c] === '#');
129
+ if (r >= 0) raw[c] = r * g.cell;
130
+ }
131
+ const valid = [...raw.keys()].filter((c) => !Number.isNaN(raw[c]));
132
+ if (!valid.length) return raw.fill(g.scale.horizonY);
133
+ for (let c = 0; c < cols; c++) {
134
+ if (!Number.isNaN(raw[c])) continue;
135
+ const near = valid.reduce((a, b) => (Math.abs(b - c) < Math.abs(a - c) ? b : a));
136
+ raw[c] = raw[near];
137
+ }
138
+ // A wide moving average: neighbouring columns must slide by nearly the same amount, or the wall tears.
139
+ const out = new Float32Array(cols);
140
+ for (let c = 0; c < cols; c++) {
141
+ let sum = 0;
142
+ let n = 0;
143
+ for (let d = -smooth; d <= smooth; d++) {
144
+ const k = Math.min(cols - 1, Math.max(0, c + d));
145
+ sum += raw[k];
146
+ n++;
147
+ }
148
+ out[c] = Math.max(g.scale.horizonY, sum / n);
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /** The far view as flats: the sky (drawn still) and flats farthest first, each a cut-out at `box` in the frame. */
154
+ export type Layers = {
155
+ sky: string | null;
156
+ flats: Array<{ file: string; box: [number, number, number, number] }>;
157
+ /** Water to set flowing: an opaque texture over `box` (carried past the banks), its soft mask, and which way it runs on screen (unit vector; [0, 0] still; left to right when missing). */
158
+ water?: { tex: string; mask: string; box: [number, number, number, number]; flow?: [number, number] };
159
+ /** Painted fog to set drifting: a soft copy over `box` (carried past its edges), its mask, which way (-1 left, 1 right, null with the wind) and how fast. */
160
+ mist?: { tex: string; mask: string; box: [number, number, number, number]; drift?: -1 | 0 | 1 | null; speed?: number };
161
+ /** Painted smoke to set curling: each plume a feathered copy of the painting over `box`. */
162
+ smoke?: Array<{ file: string; box: [number, number, number, number] }>;
163
+ /** Boats painted on the water (not cards), each a feathered copy of the painting over `box`, to rock on the swell. */
164
+ floats?: Array<{ file: string; box: [number, number, number, number] }>;
165
+ /** Painted waterfalls to set pouring: each a feathered copy of the fall over `box`. */
166
+ falls?: Array<{ file: string; box: [number, number, number, number] }>;
167
+ /** Painted animals as small cards to swim (in the water) or fly (in the air), and which way each faces (1 right). */
168
+ critters?: Array<{ file: string; box: [number, number, number, number]; medium: 'water' | 'air'; facing: 1 | -1 }>;
169
+ /** The painted clouds as bands (bottom first) to drift with the wind at their own speeds, cut to `clip` over `box`; `keep`: what stays put in the sky. */
170
+ clouds?: { box: [number, number, number, number]; clip: string; keep?: string; bands: Array<{ file: string; box: [number, number, number, number]; speed: number }> };
171
+ };
172
+
173
+ /**
174
+ * How far each far flat slides, as a disparity: spread evenly between the horizon (the sky, still) and the
175
+ * nearest upright band, so each flat sits clearly behind the next. Stage flats, not measured distances.
176
+ */
177
+ export function flatDisparities(g: Geometry, edge: Float32Array, count: number): number[] {
178
+ const nearest = Math.max(0.2, disparity(g, Math.min(...edge)));
179
+ return Array.from({ length: count }, (_, k) => (0.85 * nearest * (k + 1)) / (count + 1));
180
+ }
181
+
182
+ /**
183
+ * The far flats left to draw once boats rock: a flat cut around the same boat as a float (most of its box
184
+ * inside the float's) would stay still under it, so the eye sees the painted boat stand and only a ghost
185
+ * of its edges move. The float carries the boat; the sky under it already has the boat painted out.
186
+ */
187
+ export function flatsUnderFloats<F extends { box: [number, number, number, number] }>(
188
+ flats: F[], floats: Array<{ box: [number, number, number, number] }> | undefined,
189
+ ): F[] {
190
+ if (!floats?.length) return flats;
191
+ const area = (b: number[]) => Math.max(0, b[2] - b[0]) * Math.max(0, b[3] - b[1]);
192
+ const inside = (a: number[], b: number[]) =>
193
+ area([Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.min(a[2], b[2]), Math.min(a[3], b[3])]) / Math.max(1, area(a));
194
+ return flats.filter((f) => !floats.some((fl) => inside(f.box, fl.box) > 0.6));
195
+ }
196
+
197
+ /** The shift of something at disparity d (see shiftAt, which takes a floor row). */
198
+ export function shiftAtDisparity(p: Parallax, d: number): [number, number] {
199
+ return [p.cam.x * p.px * (d - p.pivot), p.cam.y * p.px * VERTICAL * (d - p.pivot)];
200
+ }
@@ -0,0 +1,262 @@
1
+ // Walking on the geometry grid: snap a click to the floor, find a route, straighten it.
2
+ // No Node imports: the browser bundles this file.
3
+ import type { Geometry } from './geometry.ts';
4
+
5
+ export type Point = [number, number];
6
+
7
+ /** Cells in from the floor's edge that count as roomy: a walk keeps this far in where the floor allows. */
8
+ const ROOMY = 3;
9
+ /** The extra cost of a step onto a cell 0, 1, 2... cells in from the edge. */
10
+ const RIM_COST = [0, 2, 0.6, 0];
11
+
12
+ export class WalkGrid {
13
+ readonly cols: number;
14
+ readonly rows: number;
15
+ readonly cell: number;
16
+ private open: Uint8Array;
17
+
18
+ constructor(g: Pick<Geometry, 'cell' | 'walk'>) {
19
+ this.cell = g.cell;
20
+ this.rows = g.walk.length;
21
+ this.cols = Math.max(...g.walk.map((r) => r.length));
22
+ this.open = new Uint8Array(this.cols * this.rows);
23
+ g.walk.forEach((row, r) => {
24
+ for (let c = 0; c < row.length; c++) if (row[c] === '#') this.open[r * this.cols + c] = 1;
25
+ });
26
+ }
27
+
28
+ isOpen(c: number, r: number): boolean {
29
+ return c >= 0 && r >= 0 && c < this.cols && r < this.rows && this.open[r * this.cols + c] === 1;
30
+ }
31
+
32
+ walkable(x: number, y: number): boolean {
33
+ return this.isOpen(Math.floor(x / this.cell), Math.floor(y / this.cell));
34
+ }
35
+
36
+ centre(c: number, r: number): Point {
37
+ return [(c + 0.5) * this.cell, (r + 0.5) * this.cell];
38
+ }
39
+
40
+ /** The walkable cell nearest to (x, y), as a cell centre, or the point itself when it is walkable. */
41
+ snap(x: number, y: number): Point | undefined {
42
+ if (this.walkable(x, y)) return [x, y];
43
+ const c0 = Math.floor(x / this.cell);
44
+ const r0 = Math.floor(y / this.cell);
45
+ const limit = Math.max(this.cols, this.rows);
46
+ for (let ring = 1; ring < limit; ring++) {
47
+ let best: Point | undefined;
48
+ let bestD = Infinity;
49
+ for (let r = r0 - ring; r <= r0 + ring; r++) {
50
+ for (let c = c0 - ring; c <= c0 + ring; c++) {
51
+ if (Math.max(Math.abs(r - r0), Math.abs(c - c0)) !== ring || !this.isOpen(c, r)) continue;
52
+ const [cx, cy] = this.centre(c, r);
53
+ const d = (cx - x) ** 2 + (cy - y) ** 2;
54
+ if (d < bestD) [best, bestD] = [[cx, cy], d];
55
+ }
56
+ }
57
+ if (best) return best;
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ /** True when every sample along the segment is on walkable floor. */
63
+ clear(a: Point, b: Point): boolean {
64
+ const steps = Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]) / (this.cell / 3));
65
+ for (let i = 0; i <= steps; i++) {
66
+ const t = steps ? i / steps : 0;
67
+ if (!this.walkable(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)) return false;
68
+ }
69
+ return true;
70
+ }
71
+
72
+ /**
73
+ * Where a held direction (keys or a pad) takes a walker next, `step` long, or undefined when nothing is open that way.
74
+ * Straight on if the floor is clear; else it slides along the wall (the two axes, then 45 degrees either side); else,
75
+ * facing a gap a little to one side, it sidesteps toward the gap (up to two cells), so a doorway or a narrow way
76
+ * between two things lets the walker through instead of snagging on a corner.
77
+ */
78
+ steer(from: Point, dir: Point, step: number): Point | undefined {
79
+ const n = Math.hypot(dir[0], dir[1]);
80
+ if (!n) return undefined;
81
+ const [ux, uy] = [dir[0] / n, dir[1] / n];
82
+ // A walker standing just off the floor (on a cell's edge, or left there by a talk) steers from the floor beside it.
83
+ const at = this.walkable(...from) ? from : this.snap(...from);
84
+ if (!at) return undefined;
85
+ const to = (p: Point, vx: number, vy: number, len: number): Point => [p[0] + vx * len, p[1] + vy * len];
86
+ const ways: Point[] = [[ux, uy], [Math.sign(ux), 0], [0, Math.sign(uy)], [ux - uy, uy + ux], [ux + uy, uy - ux]];
87
+ for (const [x, y] of ways) {
88
+ const l = Math.hypot(x, y);
89
+ if (!l) continue;
90
+ const t = to(at, x / l, y / l, step);
91
+ if (this.clear(at, t)) return t;
92
+ }
93
+ // A gap to one side: the nearest sidestep from which the way ahead is clear.
94
+ for (let d = this.cell / 4; d <= this.cell * 2; d += this.cell / 4) {
95
+ for (const [px, py] of [[-uy, ux], [uy, -ux]] as Point[]) {
96
+ const side = to(at, px, py, d);
97
+ if (this.clear(at, side) && this.clear(side, to(side, ux, uy, step))) return to(at, px, py, Math.min(d, step));
98
+ }
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ private edges: Uint8Array | undefined;
104
+
105
+ /** Per cell, how many cells it is from the floor's edge (1 on the edge), up to ROOMY. */
106
+ private edge(): Uint8Array {
107
+ if (this.edges) return this.edges;
108
+ const out = new Uint8Array(this.cols * this.rows);
109
+ for (let r = 0; r < this.rows; r++) {
110
+ for (let c = 0; c < this.cols; c++) {
111
+ if (!this.isOpen(c, r)) continue;
112
+ let d = 1;
113
+ for (; d < ROOMY; d++) {
114
+ let shut = false;
115
+ for (let k = -d; k <= d && !shut; k++) shut = !this.isOpen(c + k, r - d) || !this.isOpen(c + k, r + d) || !this.isOpen(c - d, r + k) || !this.isOpen(c + d, r + k);
116
+ if (shut) break;
117
+ }
118
+ out[r * this.cols + c] = d;
119
+ }
120
+ }
121
+ return (this.edges = out);
122
+ }
123
+
124
+ private edgeAt(x: number, y: number): number {
125
+ const c = Math.floor(x / this.cell);
126
+ const r = Math.floor(y / this.cell);
127
+ return this.isOpen(c, r) ? this.edge()[r * this.cols + c] : 0;
128
+ }
129
+
130
+ /**
131
+ * True when the segment is on the floor and, but within a cell or two of `ends`, `need` cells or more in from its
132
+ * edge: a walk across a bridge keeps to the middle of the deck, not its rim over the water.
133
+ */
134
+ private roomyLine(a: Point, b: Point, need: number, ends: Point[]): boolean {
135
+ const steps = Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]) / (this.cell / 3));
136
+ for (let i = 0; i <= steps; i++) {
137
+ const t = steps ? i / steps : 0;
138
+ const [x, y] = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
139
+ const e = this.edgeAt(x, y);
140
+ if (!e) return false;
141
+ if (e < need && !ends.some(([ex, ey]) => Math.hypot(ex - x, ey - y) <= 2.5 * this.cell)) return false;
142
+ }
143
+ return true;
144
+ }
145
+
146
+ /**
147
+ * A* over 8 neighbours without cutting corners, then string-pulled. Empty when unreachable. Cells on the floor's
148
+ * edge cost more, so the walk keeps off the rim of a deck or a bank, and straightening keeps the room it found.
149
+ */
150
+ route(from: Point, to: Point): Point[] {
151
+ const a = this.snap(...from);
152
+ const b = this.snap(...to);
153
+ if (!a || !b) return [];
154
+ if (this.roomyLine(a, b, ROOMY, [a, b])) return [b];
155
+ const edge = this.edge();
156
+ const cols = this.cols;
157
+ const start = Math.floor(a[1] / this.cell) * cols + Math.floor(a[0] / this.cell);
158
+ const goal = Math.floor(b[1] / this.cell) * cols + Math.floor(b[0] / this.cell);
159
+ const g = new Float64Array(cols * this.rows).fill(Infinity);
160
+ const came = new Int32Array(cols * this.rows).fill(-1);
161
+ const closed = new Uint8Array(cols * this.rows);
162
+ const heap = new MinHeap();
163
+ const h = (i: number) => Math.hypot((i % cols) - (goal % cols), Math.floor(i / cols) - Math.floor(goal / cols));
164
+ g[start] = 0;
165
+ heap.push(start, h(start));
166
+ while (heap.size) {
167
+ const i = heap.pop();
168
+ if (i === goal) break;
169
+ if (closed[i]) continue;
170
+ closed[i] = 1;
171
+ const c = i % cols;
172
+ const r = Math.floor(i / cols);
173
+ for (let dr = -1; dr <= 1; dr++) {
174
+ for (let dc = -1; dc <= 1; dc++) {
175
+ if ((!dr && !dc) || !this.isOpen(c + dc, r + dr)) continue;
176
+ if (dr && dc && (!this.isOpen(c + dc, r) || !this.isOpen(c, r + dr))) continue;
177
+ const j = (r + dr) * cols + c + dc;
178
+ const cost = g[i] + (dr && dc ? Math.SQRT2 : 1) * (1 + RIM_COST[edge[j]]);
179
+ if (cost < g[j]) {
180
+ g[j] = cost;
181
+ came[j] = i;
182
+ heap.push(j, cost + h(j));
183
+ }
184
+ }
185
+ }
186
+ }
187
+ if (came[goal] < 0) return [];
188
+ const cells: Point[] = [];
189
+ for (let i = goal; i !== start; i = came[i]) cells.push(this.centre(i % cols, Math.floor(i / cols)));
190
+ cells.reverse();
191
+ cells[cells.length - 1] = b;
192
+ return this.pull(a, cells);
193
+ }
194
+
195
+ /** Drops every waypoint the walker can skip in a straight line that keeps as far in from the edge as the route did. */
196
+ private pull(from: Point, points: Point[]): Point[] {
197
+ const out: Point[] = [];
198
+ const ends = [from, points[points.length - 1]];
199
+ const room = points.map(([x, y]) => (ends.some(([ex, ey]) => Math.hypot(ex - x, ey - y) <= 2.5 * this.cell) ? ROOMY : this.edgeAt(x, y)));
200
+ let at = from;
201
+ let i = 0;
202
+ while (i < points.length) {
203
+ let far = i;
204
+ for (let j = points.length - 1; j > i; j--) {
205
+ let need = ROOMY;
206
+ for (let k = i; k <= j; k++) need = Math.min(need, room[k]);
207
+ if (this.roomyLine(at, points[j], need, ends)) {
208
+ far = j;
209
+ break;
210
+ }
211
+ }
212
+ out.push(points[far]);
213
+ at = points[far];
214
+ i = far + 1;
215
+ }
216
+ return out;
217
+ }
218
+ }
219
+
220
+ class MinHeap {
221
+ private items: number[] = [];
222
+ private keys: number[] = [];
223
+ get size() {
224
+ return this.items.length;
225
+ }
226
+ push(item: number, key: number) {
227
+ this.items.push(item);
228
+ this.keys.push(key);
229
+ let i = this.items.length - 1;
230
+ while (i > 0) {
231
+ const p = (i - 1) >> 1;
232
+ if (this.keys[p] <= key) break;
233
+ this.swap(i, p);
234
+ i = p;
235
+ }
236
+ }
237
+ pop(): number {
238
+ const top = this.items[0];
239
+ const lastItem = this.items.pop()!;
240
+ const lastKey = this.keys.pop()!;
241
+ if (this.items.length) {
242
+ this.items[0] = lastItem;
243
+ this.keys[0] = lastKey;
244
+ let i = 0;
245
+ for (;;) {
246
+ const l = 2 * i + 1;
247
+ const r = l + 1;
248
+ let m = i;
249
+ if (l < this.keys.length && this.keys[l] < this.keys[m]) m = l;
250
+ if (r < this.keys.length && this.keys[r] < this.keys[m]) m = r;
251
+ if (m === i) break;
252
+ this.swap(i, m);
253
+ i = m;
254
+ }
255
+ }
256
+ return top;
257
+ }
258
+ private swap(a: number, b: number) {
259
+ [this.items[a], this.items[b]] = [this.items[b], this.items[a]];
260
+ [this.keys[a], this.keys[b]] = [this.keys[b], this.keys[a]];
261
+ }
262
+ }
@@ -0,0 +1,71 @@
1
+ // A rough geometry.json made from layout.json alone, so a place can be walked before its masks exist.
2
+ // Everything here is the plan, not the painting, so it is marked preview and every thing coarse.
3
+ import type { Layout } from '../scene/layout.ts';
4
+ import type { Place } from '../world/schema.ts';
5
+ import type { Geometry } from './geometry.ts';
6
+ import { WalkGrid, type Point } from './path.ts';
7
+
8
+ const CELL = 8;
9
+
10
+ function inside(poly: Array<[number, number]>, x: number, y: number): boolean {
11
+ let hit = false;
12
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
13
+ const [xi, yi] = poly[i];
14
+ const [xj, yj] = poly[j];
15
+ if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit;
16
+ }
17
+ return hit;
18
+ }
19
+
20
+ /** px per y of a 1.62 m person, fitted to the things' heights with the horizon fixed. */
21
+ function fitScale(layout: Layout): number {
22
+ let num = 0;
23
+ let den = 0;
24
+ for (const t of layout.things) {
25
+ const d = t.footY - layout.horizon;
26
+ if (d <= 20 || t.metres <= 0) continue;
27
+ const want = (t.height * 1.62) / t.metres;
28
+ num += want * d;
29
+ den += d * d;
30
+ }
31
+ const c = den ? num / den : 0.5;
32
+ return Math.min(4, Math.max(0.15, c));
33
+ }
34
+
35
+ export function previewGeometry(layout: Layout, place: Place, frame = { width: 1200, height: 896 }): Geometry {
36
+ const cols = Math.ceil(frame.width / CELL);
37
+ const rows = Math.ceil(frame.height / CELL);
38
+ const roles = new Map(place.things.map((t) => [t.key, t.role]));
39
+ const solid = layout.things.filter((t) => roles.get(t.key) !== 'npc');
40
+ const walk: string[] = [];
41
+ const zone: string[] = [];
42
+ for (let r = 0; r < rows; r++) {
43
+ let w = '';
44
+ let z = '';
45
+ for (let c = 0; c < cols; c++) {
46
+ const x = (c + 0.5) * CELL;
47
+ const y = (r + 0.5) * CELL;
48
+ const floor = layout.floors.findIndex((f) => inside(f.outline, x, y));
49
+ // A thing's footprint: the floor under its lower third, as deep as a third of its width.
50
+ const blocked = solid.some((t) => Math.abs(x - t.x) < t.width * 0.45 && y <= t.footY + 2 && y >= t.footY - Math.max(10, t.width / 3));
51
+ w += floor >= 0 && !blocked ? '#' : '.';
52
+ z += floor >= 0 ? String(floor) : '.';
53
+ }
54
+ walk.push(w);
55
+ zone.push(z);
56
+ }
57
+ const grid = new WalkGrid({ cell: CELL, walk });
58
+ const near = (x: number, y: number): Point => grid.snap(x, y) ?? [x, y];
59
+ const box = (t: Layout['things'][number]): [number, number, number, number] => [t.x - t.width / 2, t.footY - t.height, t.x + t.width / 2, t.footY];
60
+ return {
61
+ frame,
62
+ cell: CELL,
63
+ walk,
64
+ zone,
65
+ scale: { horizonY: layout.horizon, pxPerY: fitScale(layout), source: 'layout' },
66
+ start: near(frame.width / 2, frame.height - 120),
67
+ things: layout.things.map((t) => ({ key: t.key, role: roles.get(t.key), box: box(t), stand: near(t.x, t.footY + 14), coarse: true })),
68
+ occluders: solid.map((t) => ({ key: t.key, thing: t.key, box: box(t), baseY: t.footY })),
69
+ preview: true,
70
+ };
71
+ }
@@ -0,0 +1,92 @@
1
+ // Shared, serializable reaction rules and movement triggers. No DOM, audio or model calls at play time.
2
+ import { z } from 'zod';
3
+ import { When } from '../story/schema.ts';
4
+ import { holds, type State } from '../story/engine.ts';
5
+ import type { Geometry } from './geometry.ts';
6
+
7
+ export const Burst = z.object({
8
+ kind: z.enum(['dust', 'splash', 'snow', 'leaves', 'sparks']),
9
+ colour: z.string().regex(/^#[0-9a-fA-F]{6}$/),
10
+ strength: z.number().min(0.1).max(1),
11
+ });
12
+ export type Burst = z.infer<typeof Burst>;
13
+ export const AreaRule = z.object({
14
+ key: z.string().min(1),
15
+ cells: z.array(z.string().regex(/^[.#]+$/)).min(1),
16
+ step: Burst.optional(),
17
+ enter: Burst.optional(),
18
+ when: When,
19
+ once: z.boolean().default(false),
20
+ cooldown: z.number().min(0.2).max(30).default(1.5),
21
+ });
22
+ export const Reactions = z.object({
23
+ version: z.literal(1),
24
+ source: z.string(),
25
+ scene: z.string(),
26
+ areas: z.array(AreaRule).max(8),
27
+ interactions: z.array(z.object({
28
+ key: z.string(), thing: z.string(),
29
+ // Exact matched story interaction, so a story edit cannot silently retarget an old effect.
30
+ action: z.string(),
31
+ at: z.tuple([z.number().min(0).max(1), z.number().min(0).max(1)]),
32
+ burst: Burst,
33
+ cooldown: z.number().min(0.2).max(30).default(1),
34
+ })).max(12),
35
+ });
36
+ export type Reactions = z.infer<typeof Reactions>;
37
+ export type ReactionEvent = { rule: string; trigger: 'step' | 'enter' | 'interaction'; burst: Burst; x: number; y: number; depth: number; actor: object; step?: number };
38
+ export type WalkPoint = { x: number; y: number; phase: number; foot?: number };
39
+
40
+ export function inArea(area: z.infer<typeof AreaRule>, cell: number, x: number, y: number): boolean {
41
+ return area.cells[Math.floor(y / cell)]?.[Math.floor(x / cell)] === '#';
42
+ }
43
+
44
+ export class ReactionTriggers {
45
+ private clock = 0;
46
+ private used = new Set<string>();
47
+ private cooldowns = new Map<string, number>();
48
+ constructor(readonly rules: Reactions, readonly g: Geometry, private readonly emit: (event: ReactionEvent) => void) {}
49
+ update(dt: number) { this.clock += dt; }
50
+ reset() { this.cooldowns.clear(); }
51
+ private fire(rule: string, seconds: number, once: boolean, event: Omit<ReactionEvent, 'rule'>) {
52
+ if (this.used.has(rule) || this.clock < (this.cooldowns.get(rule) ?? -Infinity)) return;
53
+ if (once) this.used.add(rule);
54
+ this.cooldowns.set(rule, this.clock + seconds);
55
+ this.emit({ rule, ...event });
56
+ }
57
+ /** Called for each actually travelled path segment, including the last segment before stopping. */
58
+ move(actor: object, from: WalkPoint, to: WalkPoint, state: State) {
59
+ if (from.x === to.x && from.y === to.y) return;
60
+ const point = (t: number) => ({ x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t });
61
+ // Sample crossings more finely than the walk grid: even a small patch crossed in one frame fires.
62
+ const n = Math.max(1, Math.ceil(Math.hypot(to.x - from.x, to.y - from.y) / (this.g.cell / 2)));
63
+ for (const a of this.rules.areas) {
64
+ if (!holds(state, a.when)) continue;
65
+ if (a.enter) {
66
+ let inside = inArea(a, this.g.cell, from.x, from.y);
67
+ for (let i = 1; i <= n; i++) {
68
+ const p = point(i / n), next = inArea(a, this.g.cell, p.x, p.y);
69
+ if (next && !inside) this.fire(a.key, a.cooldown, a.once, { trigger: 'enter', burst: a.enter, ...p, depth: p.y, actor });
70
+ inside = next;
71
+ }
72
+ }
73
+ if (a.step && to.phase > from.phase) {
74
+ for (let step = Math.floor(from.phase * 2) + 1; step <= Math.floor(to.phase * 2); step++) {
75
+ const t = (step / 2 - from.phase) / (to.phase - from.phase), p = point(t);
76
+ if (inArea(a, this.g.cell, p.x, p.y)) this.emit({ rule: a.key, trigger: 'step', burst: a.step, step, ...p,
77
+ y: (from.foot ?? from.y) + ((to.foot ?? to.y) - (from.foot ?? from.y)) * t, depth: p.y, actor });
78
+ }
79
+ }
80
+ }
81
+ }
82
+ interact(action: string, actor: object, depth: (key: string) => number) {
83
+ for (const r of this.rules.interactions) {
84
+ if (r.action !== action) continue;
85
+ const thing = this.g.things.find((t) => t.key === r.thing);
86
+ if (!thing) continue;
87
+ const [x0, y0, x1, y1] = thing.box;
88
+ this.fire(r.key, r.cooldown, false, { trigger: 'interaction', burst: r.burst,
89
+ x: x0 + r.at[0] * (x1 - x0), y: y0 + r.at[1] * (y1 - y0), depth: depth(r.thing) + 8, actor });
90
+ }
91
+ }
92
+ }