@gtanczyk/diorama 0.0.0-stage → 0.2.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 (302) 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 +160 -0
  5. package/app/src/actions/build.ts +158 -0
  6. package/app/src/actions/cast.ts +267 -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 +32 -0
  17. package/app/src/actions/items.ts +94 -0
  18. package/app/src/actions/kids.ts +215 -0
  19. package/app/src/actions/language.ts +52 -0
  20. package/app/src/actions/models.ts +64 -0
  21. package/app/src/actions/motion.ts +119 -0
  22. package/app/src/actions/pictures.ts +49 -0
  23. package/app/src/actions/reactions.ts +36 -0
  24. package/app/src/actions/registry.ts +101 -0
  25. package/app/src/actions/share.ts +32 -0
  26. package/app/src/actions/sound.ts +73 -0
  27. package/app/src/actions/start.ts +154 -0
  28. package/app/src/actions/step-work.ts +150 -0
  29. package/app/src/actions/story.ts +101 -0
  30. package/app/src/actions/studio.ts +133 -0
  31. package/app/src/actions/style.ts +72 -0
  32. package/app/src/actions/video.ts +46 -0
  33. package/app/src/actions/view.ts +109 -0
  34. package/app/src/actions/weather.ts +38 -0
  35. package/app/src/actions/world.ts +290 -0
  36. package/app/src/commands/action.ts +39 -0
  37. package/app/src/commands/backdrop.ts +188 -0
  38. package/app/src/commands/browsers.ts +21 -0
  39. package/app/src/commands/build.ts +503 -0
  40. package/app/src/commands/cast.ts +161 -0
  41. package/app/src/commands/clip.ts +431 -0
  42. package/app/src/commands/derive.ts +167 -0
  43. package/app/src/commands/doctor.ts +74 -0
  44. package/app/src/commands/explore.ts +123 -0
  45. package/app/src/commands/export.ts +158 -0
  46. package/app/src/commands/extend.ts +75 -0
  47. package/app/src/commands/faces.ts +80 -0
  48. package/app/src/commands/icons.ts +69 -0
  49. package/app/src/commands/index.ts +37 -0
  50. package/app/src/commands/language.ts +87 -0
  51. package/app/src/commands/layout.ts +31 -0
  52. package/app/src/commands/masks.ts +117 -0
  53. package/app/src/commands/mcp.ts +52 -0
  54. package/app/src/commands/model.ts +284 -0
  55. package/app/src/commands/paint.ts +182 -0
  56. package/app/src/commands/plates.ts +97 -0
  57. package/app/src/commands/play.ts +195 -0
  58. package/app/src/commands/playthrough.ts +111 -0
  59. package/app/src/commands/publish.ts +78 -0
  60. package/app/src/commands/reactions.ts +40 -0
  61. package/app/src/commands/reel.ts +46 -0
  62. package/app/src/commands/setup.ts +85 -0
  63. package/app/src/commands/sound.ts +100 -0
  64. package/app/src/commands/sprites.ts +232 -0
  65. package/app/src/commands/story.ts +37 -0
  66. package/app/src/commands/studio.ts +65 -0
  67. package/app/src/commands/style.ts +87 -0
  68. package/app/src/commands/verify.ts +45 -0
  69. package/app/src/commands/voice-retry.ts +14 -0
  70. package/app/src/commands/voices.ts +145 -0
  71. package/app/src/commands/weather.ts +52 -0
  72. package/app/src/commands/world.ts +104 -0
  73. package/app/src/film/clip.ts +386 -0
  74. package/app/src/film/reel.ts +126 -0
  75. package/app/src/film/screencast.ts +123 -0
  76. package/app/src/image/mask.ts +29 -0
  77. package/app/src/image/overlay.ts +38 -0
  78. package/app/src/image/raster.ts +224 -0
  79. package/app/src/main.ts +28 -0
  80. package/app/src/meta/choice.ts +27 -0
  81. package/app/src/meta/http.ts +99 -0
  82. package/app/src/meta/memo.ts +105 -0
  83. package/app/src/meta/muse.ts +70 -0
  84. package/app/src/meta/openrouter.ts +93 -0
  85. package/app/src/meta/prices.ts +25 -0
  86. package/app/src/meta/sam.ts +83 -0
  87. package/app/src/meta/schema.ts +63 -0
  88. package/app/src/meta/spark.ts +220 -0
  89. package/app/src/meta/step.ts +14 -0
  90. package/app/src/meta/voice-file.ts +78 -0
  91. package/app/src/meta/voice.ts +103 -0
  92. package/app/src/model/animation.ts +38 -0
  93. package/app/src/model/blender/relax.py +284 -0
  94. package/app/src/model/blender.ts +115 -0
  95. package/app/src/model/build.ts +50 -0
  96. package/app/src/model/face.ts +21 -0
  97. package/app/src/model/fit.ts +181 -0
  98. package/app/src/model/glb.ts +241 -0
  99. package/app/src/model/plan.ts +151 -0
  100. package/app/src/model/quality.ts +48 -0
  101. package/app/src/model/space.ts +168 -0
  102. package/app/src/model/sprites.ts +293 -0
  103. package/app/src/model/views.ts +163 -0
  104. package/app/src/play/creature.ts +43 -0
  105. package/app/src/play/depth-occlusion.ts +150 -0
  106. package/app/src/play/exit-outline.ts +48 -0
  107. package/app/src/play/face3d.ts +34 -0
  108. package/app/src/play/focus.ts +78 -0
  109. package/app/src/play/geometry.ts +272 -0
  110. package/app/src/play/gestures.ts +23 -0
  111. package/app/src/play/glow.ts +187 -0
  112. package/app/src/play/lipsync.ts +69 -0
  113. package/app/src/play/living.ts +83 -0
  114. package/app/src/play/outline-path.ts +130 -0
  115. package/app/src/play/parallax.ts +200 -0
  116. package/app/src/play/path.ts +262 -0
  117. package/app/src/play/preview.ts +71 -0
  118. package/app/src/play/reactions.ts +92 -0
  119. package/app/src/play/rig.ts +172 -0
  120. package/app/src/play/shadow.ts +253 -0
  121. package/app/src/play/sound.ts +51 -0
  122. package/app/src/play/weather.ts +58 -0
  123. package/app/src/qa/explore.ts +136 -0
  124. package/app/src/qa/probe.ts +139 -0
  125. package/app/src/qa/route.ts +118 -0
  126. package/app/src/registry.ts +23 -0
  127. package/app/src/scene/animation-source.ts +36 -0
  128. package/app/src/scene/assets.ts +37 -0
  129. package/app/src/scene/autofix.ts +133 -0
  130. package/app/src/scene/backdrop.ts +326 -0
  131. package/app/src/scene/blockin.ts +189 -0
  132. package/app/src/scene/brief.ts +74 -0
  133. package/app/src/scene/carry.ts +177 -0
  134. package/app/src/scene/cast-fixes.ts +33 -0
  135. package/app/src/scene/cast.ts +199 -0
  136. package/app/src/scene/corrections.ts +164 -0
  137. package/app/src/scene/critic.ts +169 -0
  138. package/app/src/scene/depthmap.ts +149 -0
  139. package/app/src/scene/derive.ts +475 -0
  140. package/app/src/scene/diagnose.ts +132 -0
  141. package/app/src/scene/face-calibration.ts +65 -0
  142. package/app/src/scene/face-registration.ts +21 -0
  143. package/app/src/scene/face-work.ts +71 -0
  144. package/app/src/scene/faces-data.ts +48 -0
  145. package/app/src/scene/faces-settings.ts +20 -0
  146. package/app/src/scene/faces.ts +435 -0
  147. package/app/src/scene/glows.ts +153 -0
  148. package/app/src/scene/held.ts +109 -0
  149. package/app/src/scene/icons.ts +185 -0
  150. package/app/src/scene/layers.ts +822 -0
  151. package/app/src/scene/layout.ts +216 -0
  152. package/app/src/scene/locate.ts +118 -0
  153. package/app/src/scene/maskcheck.ts +43 -0
  154. package/app/src/scene/masks.ts +392 -0
  155. package/app/src/scene/occluders.ts +123 -0
  156. package/app/src/scene/occlusion-check.ts +138 -0
  157. package/app/src/scene/paint-layout.ts +135 -0
  158. package/app/src/scene/paint.ts +81 -0
  159. package/app/src/scene/photo.ts +123 -0
  160. package/app/src/scene/plates.ts +256 -0
  161. package/app/src/scene/playbook.ts +121 -0
  162. package/app/src/scene/preview.ts +23 -0
  163. package/app/src/scene/reactions.ts +118 -0
  164. package/app/src/scene/restyle.ts +53 -0
  165. package/app/src/scene/rig-check.ts +68 -0
  166. package/app/src/scene/rig.ts +379 -0
  167. package/app/src/scene/scale.ts +148 -0
  168. package/app/src/scene/shadows.ts +215 -0
  169. package/app/src/scene/stairs.ts +183 -0
  170. package/app/src/scene/status.ts +32 -0
  171. package/app/src/scene/verify.ts +156 -0
  172. package/app/src/scene/walk.ts +326 -0
  173. package/app/src/scene/warp.ts +419 -0
  174. package/app/src/scene/weather.ts +118 -0
  175. package/app/src/scene/words.ts +65 -0
  176. package/app/src/secrets.ts +219 -0
  177. package/app/src/setup/checks.ts +110 -0
  178. package/app/src/sound/cost.ts +74 -0
  179. package/app/src/sound/data.ts +89 -0
  180. package/app/src/sound/fixes.ts +23 -0
  181. package/app/src/sound/issues.ts +21 -0
  182. package/app/src/sound/language.ts +11 -0
  183. package/app/src/sound/lipsync-make.ts +98 -0
  184. package/app/src/sound/lipsync.ts +24 -0
  185. package/app/src/sound/make.ts +229 -0
  186. package/app/src/sound/musicgen.py +20 -0
  187. package/app/src/sound/pcm.ts +280 -0
  188. package/app/src/sound/plan.ts +154 -0
  189. package/app/src/sound/retry.ts +43 -0
  190. package/app/src/sound/settings.ts +28 -0
  191. package/app/src/sound/sources.ts +81 -0
  192. package/app/src/sound/spoken.ts +70 -0
  193. package/app/src/sound/voices.ts +579 -0
  194. package/app/src/speech/transcribe.ts +38 -0
  195. package/app/src/speech/types.ts +18 -0
  196. package/app/src/story/check.ts +230 -0
  197. package/app/src/story/design.ts +108 -0
  198. package/app/src/story/engine.ts +233 -0
  199. package/app/src/story/fixes.ts +54 -0
  200. package/app/src/story/npc-guidance.ts +7 -0
  201. package/app/src/story/phrases.ts +4 -0
  202. package/app/src/story/schema.ts +92 -0
  203. package/app/src/studio/agent.ts +555 -0
  204. package/app/src/studio/discovery.ts +28 -0
  205. package/app/src/studio/events.ts +48 -0
  206. package/app/src/studio/history.ts +124 -0
  207. package/app/src/studio/jobs.ts +291 -0
  208. package/app/src/studio/locks.ts +33 -0
  209. package/app/src/studio/mcp.ts +86 -0
  210. package/app/src/studio/operation.ts +18 -0
  211. package/app/src/studio/repair.ts +31 -0
  212. package/app/src/studio/server.ts +423 -0
  213. package/app/src/studio/trouble.ts +48 -0
  214. package/app/src/world/design.ts +72 -0
  215. package/app/src/world/drawings.ts +198 -0
  216. package/app/src/world/extend-stage.ts +109 -0
  217. package/app/src/world/extend.ts +123 -0
  218. package/app/src/world/extension-progress.ts +10 -0
  219. package/app/src/world/files.ts +62 -0
  220. package/app/src/world/idea-review.ts +32 -0
  221. package/app/src/world/language.ts +116 -0
  222. package/app/src/world/photo-limits.ts +2 -0
  223. package/app/src/world/pool.ts +41 -0
  224. package/app/src/world/schedule.ts +64 -0
  225. package/app/src/world/schema.ts +74 -0
  226. package/app/src/world/sketch.ts +81 -0
  227. package/app/src/world/style-plan.ts +90 -0
  228. package/app/src/world/style-reference.ts +16 -0
  229. package/app/src/world/styled.ts +51 -0
  230. package/app/src/world/told.ts +105 -0
  231. package/app/src/world/video-limits.ts +3 -0
  232. package/app/src/world/video.ts +98 -0
  233. package/app/studio/activity.tsx +124 -0
  234. package/app/studio/agent.tsx +384 -0
  235. package/app/studio/api.ts +111 -0
  236. package/app/studio/atelier.tsx +234 -0
  237. package/app/studio/audio-missing.tsx +25 -0
  238. package/app/studio/audio-setup.tsx +33 -0
  239. package/app/studio/blender-setup.tsx +30 -0
  240. package/app/studio/build.tsx +679 -0
  241. package/app/studio/camera.tsx +59 -0
  242. package/app/studio/cast-work.tsx +207 -0
  243. package/app/studio/cast.tsx +178 -0
  244. package/app/studio/concept-edit.tsx +47 -0
  245. package/app/studio/creator-idea.tsx +46 -0
  246. package/app/studio/custom-look.tsx +36 -0
  247. package/app/studio/faces-setup.tsx +28 -0
  248. package/app/studio/favicon.svg +11 -0
  249. package/app/studio/figure.tsx +314 -0
  250. package/app/studio/fonts/OFL.txt +93 -0
  251. package/app/studio/fonts/fonts.css +7 -0
  252. package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
  253. package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
  254. package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
  255. package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
  256. package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
  257. package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
  258. package/app/studio/icons.tsx +42 -0
  259. package/app/studio/idea-card.tsx +42 -0
  260. package/app/studio/idea-review.tsx +181 -0
  261. package/app/studio/idea-storyboard.tsx +134 -0
  262. package/app/studio/index.html +15 -0
  263. package/app/studio/job-words.ts +33 -0
  264. package/app/studio/lightbox.tsx +49 -0
  265. package/app/studio/main.tsx +1329 -0
  266. package/app/studio/nav.ts +14 -0
  267. package/app/studio/place.tsx +264 -0
  268. package/app/studio/review-sketch.tsx +47 -0
  269. package/app/studio/say-it.tsx +49 -0
  270. package/app/studio/step-work.tsx +134 -0
  271. package/app/studio/story.tsx +394 -0
  272. package/app/studio/style-menu.tsx +81 -0
  273. package/app/studio/style.css +1390 -0
  274. package/app/studio/sure.tsx +54 -0
  275. package/app/studio/tip.tsx +74 -0
  276. package/app/studio/tsconfig.json +5 -0
  277. package/app/studio/video.tsx +73 -0
  278. package/app/studio/world.tsx +188 -0
  279. package/app/tsconfig.json +13 -0
  280. package/app/web/audio.ts +315 -0
  281. package/app/web/face.ts +131 -0
  282. package/app/web/face3d.ts +35 -0
  283. package/app/web/glows.ts +387 -0
  284. package/app/web/hero3d.ts +728 -0
  285. package/app/web/index.html +118 -0
  286. package/app/web/living.ts +404 -0
  287. package/app/web/main.ts +3769 -0
  288. package/app/web/pad.ts +174 -0
  289. package/app/web/reactions.ts +79 -0
  290. package/app/web/sheet.ts +36 -0
  291. package/app/web/synth.ts +140 -0
  292. package/app/web/tsconfig.json +5 -0
  293. package/app/web/ui.ts +263 -0
  294. package/app/web/weather.ts +306 -0
  295. package/app/web/words.ts +12 -0
  296. package/docs/characters.md +58 -0
  297. package/docs/npc-motion.md +69 -0
  298. package/docs/scenes.md +75 -0
  299. package/docs/setup.md +60 -0
  300. package/docs/sound.md +23 -0
  301. package/docs/studio.md +62 -0
  302. package/package.json +70 -4
@@ -0,0 +1,315 @@
1
+ // The play page's sound: a music bed, the place's ambience, loops that pan and fade with where the hero
2
+ // stands, footsteps on the floor under him, and one-shots for what happens in the story. Web Audio only.
3
+ import { mouthAt, type LipSync } from '../src/play/lipsync.ts';
4
+ import { personPx, type GeoThing, type Geometry } from '../src/play/geometry.ts';
5
+ import { levelGain, placeSound, spokenLine, zoneAt, type SoundData } from '../src/play/sound.ts';
6
+ import { holds, type State } from '../src/story/engine.ts';
7
+ import { Synth } from './synth.ts';
8
+
9
+ /** What the mixer reads from the game each frame. */
10
+ export type Listened = {
11
+ data: { sound?: SoundData; world?: { hero: { key: string } } };
12
+ place?: { key: string; g: Geometry; hots: Array<{ thing: GeoThing }>; weather?: { w: { fall: string; strength: number } }; water?: unknown };
13
+ hero: { x: number; y: number; phase: number; path: unknown[] };
14
+ state: State;
15
+ viewX: number;
16
+ speech: unknown;
17
+ voiceStarted?(speech: unknown): void;
18
+ voiceEnded?(speech: unknown): void;
19
+ gone(): Set<string>;
20
+ on(fn: (event: string, detail: Record<string, unknown>) => void): void;
21
+ };
22
+
23
+ type Voice = { src: AudioBufferSourceNode; gain: GainNode; pan: StereoPannerNode };
24
+
25
+ const FADE = 0.6;
26
+
27
+ export class Mixer {
28
+ readonly ctx = new AudioContext();
29
+ private master = this.ctx.createGain();
30
+ private musicBus = this.ctx.createGain();
31
+ private voiceBus = this.ctx.createGain();
32
+ private synth = new Synth(this.ctx, this.musicBus, this.master);
33
+ private limiter = new DynamicsCompressorNode(this.ctx, { threshold: -14, knee: 8, ratio: 8, attack: 0.004, release: 0.25 });
34
+ private buffers = new Map<string, AudioBuffer>();
35
+ private loading = new Map<string, Promise<AudioBuffer | undefined>>();
36
+ /** Running loops by id: the music, the bed, and the things of this place. */
37
+ private voices = new Map<string, Voice>();
38
+ private lastStep = 0;
39
+ private lastUsed: string | undefined;
40
+ private seen: { items: string[]; flags: string[]; talk: boolean } | undefined;
41
+ private recorder: MediaRecorder | undefined;
42
+ private chunks: Blob[] = [];
43
+ muted = false;
44
+ /** What started when, on the audio clock: for QA and for laying sound under a recording. */
45
+ readonly log: Array<{ at: number; id: string; kind: string }> = [];
46
+
47
+ constructor(private game: Listened, private url: (file: string) => string) {
48
+ // A soft limiter at the end: one-shots on top of loops and music never clip.
49
+ this.musicBus.connect(this.master);
50
+ this.voiceBus.gain.value = 1.1;
51
+ this.voiceBus.connect(this.master);
52
+ this.master.connect(this.limiter).connect(this.ctx.destination);
53
+ // Browsers start audio only after a gesture; the first click or key resumes it.
54
+ const resume = () => void this.ctx.resume();
55
+ addEventListener('pointerdown', resume, { capture: true });
56
+ addEventListener('keydown', resume, { capture: true });
57
+ // Buttons (inventory, conversation choices) click.
58
+ addEventListener('click', (e) => {
59
+ if ((e.target as HTMLElement | null)?.closest?.('button')) this.shot('click');
60
+ }, { capture: true });
61
+ game.on((event, detail) => {
62
+ if (event === 'enter') void this.enter(String(detail.place));
63
+ if (event === 'use') this.lastUsed = String(detail.thing);
64
+ if (event === 'exit' && this.lastUsed) this.shot(`exit:${this.lastUsed}`);
65
+ if (event === 'ending') this.shot('ending');
66
+ if (event === 'say') void this.speak(String(detail.text), false, typeof detail.who === 'string' ? detail.who : 'narrator');
67
+ if (event === 'silent') this.hush();
68
+ if (event === 'enter') this.hush();
69
+ });
70
+ }
71
+
72
+ get sound(): SoundData | undefined {
73
+ return this.game.data.sound;
74
+ }
75
+
76
+ private buffer(id: string): Promise<AudioBuffer | undefined> {
77
+ const entry = this.sound?.files[id];
78
+ if (!entry) return Promise.resolve(undefined);
79
+ if (!this.loading.has(id)) {
80
+ this.loading.set(id, fetch(this.url(entry.file))
81
+ .then((r) => r.arrayBuffer())
82
+ .then((bytes) => this.ctx.decodeAudioData(bytes))
83
+ .then((buf) => (this.buffers.set(id, buf), buf))
84
+ .catch(() => undefined));
85
+ }
86
+ return this.loading.get(id)!;
87
+ }
88
+
89
+ private async start(id: string, gain: number, bus: AudioNode = this.master) {
90
+ if (this.voices.has(id)) return;
91
+ const buf = await this.buffer(id);
92
+ if (!buf || this.voices.has(id)) return;
93
+ const src = this.ctx.createBufferSource();
94
+ src.buffer = buf;
95
+ src.loop = true;
96
+ const g = this.ctx.createGain();
97
+ const pan = this.ctx.createStereoPanner();
98
+ g.gain.value = 0;
99
+ g.gain.setTargetAtTime(gain, this.ctx.currentTime, FADE / 3);
100
+ src.connect(g).connect(pan).connect(bus);
101
+ // Start somewhere inside the loop, so two places with the same take never sound in step.
102
+ src.start(0, Math.random() * buf.duration);
103
+ this.voices.set(id, { src, gain: g, pan });
104
+ this.log.push({ at: this.ctx.currentTime, id, kind: 'loop' });
105
+ }
106
+
107
+ private stop(id: string) {
108
+ const v = this.voices.get(id);
109
+ if (!v) return;
110
+ this.voices.delete(id);
111
+ v.gain.gain.setTargetAtTime(0, this.ctx.currentTime, FADE / 3);
112
+ v.src.stop(this.ctx.currentTime + FADE * 2);
113
+ }
114
+
115
+ /** A new place: its bed and loops fade in, the last place's fade out; the music carries on. */
116
+ async enter(place: string) {
117
+ const sound = this.sound;
118
+ // No music of its own (none made, or every take rejected): a quiet one made here. No bed for the place: its
119
+ // rain and its sea, from what the page already knows of it.
120
+ if (!sound?.music) this.synth.startMusic();
121
+ const at = this.game.place?.key === place ? this.game.place : undefined;
122
+ const bedless = !sound?.places[place]?.bed;
123
+ this.synth.ambience(bedless && at?.weather?.w.fall === 'rain' ? at.weather.w.strength : 0, bedless && !!at?.water);
124
+ if (!sound) return;
125
+ // Everything loads on the first place, so later one-shots and footsteps are ready when they fire.
126
+ if (!this.loading.size) for (const id of Object.keys(sound.files)) void this.buffer(id);
127
+ if (sound.music) void this.start(sound.music, levelGain('music', 2), this.musicBus);
128
+ const here = sound.places[place];
129
+ const keep = new Set([sound.music, here?.bed].filter(Boolean));
130
+ for (const id of [...this.voices.keys()]) if (!keep.has(id)) this.stop(id);
131
+ if (here?.bed) void this.start(here.bed, levelGain('bed', 2));
132
+ }
133
+
134
+ private speaking: AudioBufferSourceNode | undefined;
135
+ private articulation: { speech: unknown; start: number; sync: LipSync } | undefined;
136
+
137
+ /** The active source owns both articulation and time; loading, mute, pause and interruption close the lips. */
138
+ mouth(speech: unknown) {
139
+ const active = this.articulation;
140
+ return mouthAt(active?.sync, active && active.speech === speech && !this.muted && this.ctx.state === 'running'
141
+ ? this.ctx.currentTime - active.start : undefined);
142
+ }
143
+ private lines = new Map<string, Promise<AudioBuffer | undefined>>();
144
+
145
+ /** ?voices=npc: only the people the hero meets speak (for a film with a voice-over); ?voices=0: nobody. */
146
+ private voiced = new URLSearchParams(location.search).get('voices') ?? 'all';
147
+
148
+ /** Says an answer card aloud as it is pointed at, even with no line up (the cards are voiced as the hero's lines). */
149
+ hear(text: string) {
150
+ void this.speak(text, true, this.game.data.world?.hero.key);
151
+ }
152
+
153
+ /** Says a line in its cast voice, cutting off the one before. */
154
+ private async speak(text: string, card = false, speaker?: string) {
155
+ this.hush();
156
+ const request = this.speechRequest;
157
+ const speech = this.game.speech;
158
+ const line = spokenLine(this.sound, text, speaker);
159
+ if (!line || this.voiced === '0') return;
160
+ if (this.voiced === 'npc' && (line.speaker === 'narrator' || line.speaker === this.game.data.world?.hero.key)) return;
161
+ if (!this.lines.has(line.file)) this.lines.set(line.file, fetch(this.url(line.file)).then((r) => r.arrayBuffer()).then((b) => this.ctx.decodeAudioData(b)).catch(() => undefined));
162
+ const buf = await this.lines.get(line.file);
163
+ if (!buf || request !== this.speechRequest || (this.game.speech === null && !card)) return;
164
+ const src = this.ctx.createBufferSource();
165
+ src.buffer = buf;
166
+ src.connect(this.voiceBus);
167
+ const start = this.ctx.currentTime;
168
+ src.start(start);
169
+ this.speaking = src;
170
+ if (!card && line.lipsync) this.articulation = { speech, start, sync: line.lipsync };
171
+ if (!card && !this.muted && this.ctx.state === 'running') this.game.voiceStarted?.(speech);
172
+ src.onended = () => {
173
+ if (this.speaking === src) { this.speaking = undefined; this.articulation = undefined; }
174
+ if (!card) this.game.voiceEnded?.(speech);
175
+ };
176
+ this.log.push({ at: this.ctx.currentTime, id: text.slice(0, 40), kind: 'line' });
177
+ }
178
+
179
+ private speechRequest = 0;
180
+ private hush() {
181
+ this.speechRequest++;
182
+ this.articulation = undefined;
183
+ try {
184
+ this.speaking?.stop();
185
+ } catch {
186
+ // already ended
187
+ }
188
+ this.speaking = undefined;
189
+ }
190
+
191
+ /** A one-shot, panned to where it happens. */
192
+ shot(trigger: string, x?: number) {
193
+ const id = this.sound?.shots[trigger];
194
+ const buf = id ? this.buffers.get(id) : undefined;
195
+ if (!id || !buf) return;
196
+ this.play(id, buf, levelGain('shot', this.sound!.files[id].level), x === undefined ? 0 : this.panAt(x));
197
+ }
198
+
199
+ private play(id: string, buf: AudioBuffer, gain: number, pan: number, offset = 0, duration?: number, rate = 1) {
200
+ this.log.push({ at: this.ctx.currentTime, id, kind: duration ? 'step' : 'shot' });
201
+ const src = this.ctx.createBufferSource();
202
+ src.buffer = buf;
203
+ src.playbackRate.value = rate;
204
+ const g = this.ctx.createGain();
205
+ g.gain.value = gain;
206
+ const p = this.ctx.createStereoPanner();
207
+ p.pan.value = pan;
208
+ src.connect(g).connect(p).connect(this.master);
209
+ if (duration) {
210
+ // A short fade at the cut, so a sliced footstep never clicks.
211
+ g.gain.setValueAtTime(gain, this.ctx.currentTime + duration / rate - 0.03);
212
+ g.gain.linearRampToValueAtTime(0, this.ctx.currentTime + duration / rate);
213
+ }
214
+ src.start(0, offset, duration);
215
+ }
216
+
217
+ private panAt(x: number): number {
218
+ const g = this.game.place?.g;
219
+ if (!g) return 0;
220
+ return placeSound(x, 0, x, 0, this.game.viewX || g.frame.width / 2, g.frame.width, 1).pan;
221
+ }
222
+
223
+ /** Every frame: loops follow the hero, footsteps follow his walk, story changes sound. */
224
+ update() {
225
+ const { game } = this;
226
+ const sound = this.sound;
227
+ const place = game.place;
228
+ if (!sound || !place || this.ctx.state !== 'running') return;
229
+ const here = sound.places[place.key];
230
+ const g = place.g;
231
+ const now = this.ctx.currentTime;
232
+ const heroPx = personPx(g, game.hero.y);
233
+ const gone = game.gone();
234
+
235
+ for (const loop of here?.loops ?? []) {
236
+ const hot = place.hots.find((h) => h.thing.key === loop.thing);
237
+ const on = !!hot && !gone.has(loop.thing) && holds(game.state, loop.when);
238
+ if (!on) {
239
+ this.stop(loop.id);
240
+ continue;
241
+ }
242
+ const [x0, , x1, y1] = hot.thing.box;
243
+ const at = placeSound((x0 + x1) / 2, y1, game.hero.x, game.hero.y, game.viewX || g.frame.width / 2, g.frame.width, heroPx);
244
+ const voice = this.voices.get(loop.id);
245
+ if (!voice) void this.start(loop.id, at.gain * levelGain('loop', loop.level));
246
+ else {
247
+ voice.gain.gain.setTargetAtTime(at.gain * levelGain('loop', loop.level), now, 0.15);
248
+ voice.pan.pan.setTargetAtTime(at.pan, now, 0.15);
249
+ }
250
+ }
251
+ // Music steps back while someone speaks, further when the line is voiced.
252
+ this.musicBus.gain.setTargetAtTime(game.speech ? (this.speaking ? 0.35 : 0.55) : 1, now, 0.3);
253
+
254
+ // Two footsteps per walk cycle; the floor under the hero picks the surface.
255
+ const step = Math.floor(game.hero.phase * 2);
256
+ if (step !== this.lastStep) {
257
+ this.lastStep = step;
258
+ const id = here?.zones[zoneAt(g.zone, g.cell, game.hero.x, game.hero.y)] ?? here?.zones.find(Boolean);
259
+ const buf = id ? this.buffers.get(id) : undefined;
260
+ const slices = id ? sound.files[id].slices : undefined;
261
+ if (buf && slices?.length) {
262
+ const [offset, duration] = slices[Math.floor(Math.random() * slices.length)];
263
+ // Farther away is quieter; a little pitch and level jitter so no two steps repeat exactly.
264
+ const depth = Math.min(1, heroPx / (g.frame.height * 0.45));
265
+ this.play(id!, buf, levelGain('step', 2) * (0.45 + 0.55 * depth) * (0.85 + Math.random() * 0.3), this.panAt(game.hero.x) * 0.6, offset, duration, 0.93 + Math.random() * 0.14);
266
+ }
267
+ }
268
+
269
+ // What the story just did: an item taken, a thing used to change something, a conversation opened.
270
+ const s = game.state;
271
+ const seen = this.seen;
272
+ if (seen) {
273
+ const hot = place.hots.find((h) => h.thing.key === this.lastUsed);
274
+ const x = hot ? (hot.thing.box[0] + hot.thing.box[2]) / 2 : undefined;
275
+ if (s.items.some((i) => !seen.items.includes(i))) this.shot('take', x);
276
+ else if (s.flags.some((f) => !seen.flags.includes(f)) && this.lastUsed) this.shot(`use:${this.lastUsed}`, x);
277
+ if (s.talk && !seen.talk) this.shot('talk', x);
278
+ }
279
+ this.seen = { items: [...s.items], flags: [...s.flags], talk: !!s.talk };
280
+ }
281
+
282
+ /** Starts the sound if the browser lets the page do it without a click (a gamepad press is not one everywhere). */
283
+ resume() {
284
+ if (this.ctx.state === 'suspended') void this.ctx.resume().catch(() => undefined);
285
+ }
286
+
287
+ toggleMute() {
288
+ this.muted = !this.muted;
289
+ this.master.gain.setTargetAtTime(this.muted ? 0 : 1, this.ctx.currentTime, 0.05);
290
+ }
291
+
292
+ /** For recordings: everything the page plays, into a webm the recorder can lay under its video. */
293
+ record() {
294
+ const out = this.ctx.createMediaStreamDestination();
295
+ this.limiter.connect(out);
296
+ this.chunks = [];
297
+ this.recorder = new MediaRecorder(out.stream, { mimeType: 'audio/webm;codecs=opus' });
298
+ this.recorder.ondataavailable = (e) => this.chunks.push(e.data);
299
+ this.recorder.start(1000);
300
+ }
301
+
302
+ /** Stops recording; the webm as base64. */
303
+ async stopRecording(): Promise<string> {
304
+ const rec = this.recorder;
305
+ if (!rec) return '';
306
+ await new Promise<void>((resolve) => {
307
+ rec.onstop = () => resolve();
308
+ rec.stop();
309
+ });
310
+ const bytes = new Uint8Array(await new Blob(this.chunks, { type: 'audio/webm' }).arrayBuffer());
311
+ let bin = '';
312
+ for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
313
+ return btoa(bin);
314
+ }
315
+ }
@@ -0,0 +1,131 @@
1
+ // A small, registered mouth patch keeps the existing painted face, cap and body intact.
2
+ import { faceAt, type Articulation, type Box, type FaceDefinition, type FacePose, type FaceState, type Mouth } from '../src/play/lipsync.ts';
3
+
4
+ type Surface = HTMLImageElement | HTMLCanvasElement;
5
+
6
+ /** Draw a full-size mask so pixels outside the gradient's rectangle are cleared too. */
7
+ function feather(c: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number) {
8
+ const mask = Object.assign(document.createElement('canvas'), { width: c.canvas.width, height: c.canvas.height });
9
+ const m = mask.getContext('2d')!;
10
+ m.translate(cx, cy); m.scale(rx, ry);
11
+ const fade = m.createRadialGradient(0, 0, 0.68, 0, 0, 1);
12
+ fade.addColorStop(0, '#fff'); fade.addColorStop(1, '#fff0');
13
+ m.fillStyle = fade; m.fillRect(-1, -1, 2, 2);
14
+ c.globalCompositeOperation = 'destination-in'; c.drawImage(mask, 0, 0);
15
+ c.globalCompositeOperation = 'source-over';
16
+ }
17
+ export class PaintedFace {
18
+ private patches = new Map<string, Map<Mouth, HTMLCanvasElement>>();
19
+ private canvas = document.createElement('canvas');
20
+ private cache = '';
21
+ private revision = 0;
22
+ get version() { return this.revision; }
23
+ private blend = document.createElement('canvas');
24
+ private neutral = new Map<string, HTMLCanvasElement>();
25
+ private closed = new Map<string, HTMLCanvasElement>();
26
+ private closure = document.createElement('canvas');
27
+
28
+ constructor(private info: FaceDefinition, atlas: HTMLImageElement, only?: readonly string[]) {
29
+ for (const [view, mouths] of Object.entries({ default: info.mouths, ...info.atlases })) {
30
+ if (only && !only.includes(view)) continue;
31
+ const patches = new Map<Mouth, HTMLCanvasElement>();
32
+ for (const [mouth, [x, y, width, height]] of Object.entries(mouths)) {
33
+ const patch = Object.assign(document.createElement('canvas'), { width, height });
34
+ const c = patch.getContext('2d')!;
35
+ const anchor = info.anchors?.[view], frame = info.frames?.[view]?.[mouth as Mouth] ?? anchor;
36
+ const dx = anchor && frame ? (anchor[0] + anchor[2] / 2 - frame[0] - frame[2] / 2) * width : 0;
37
+ const dy = anchor && frame ? (anchor[1] - frame[1]) * height : 0;
38
+ c.drawImage(atlas, x, y, width, height, dx, dy, width, height);
39
+ if(mouth==='X'){
40
+ const raw=Object.assign(document.createElement('canvas'),{width,height});
41
+ raw.getContext('2d')!.drawImage(patch,0,0);this.neutral.set(view,raw);
42
+ }
43
+ // Feather only the outer beard/skin; the lips in the centre stay opaque.
44
+ const [cx, cy, rx, ry] = anchor && frame ? [
45
+ (anchor[0] + anchor[2] / 2) * width, (anchor[1] + frame[3] / 2) * height,
46
+ Math.max(frame[2] / 2 + anchor[2] * 0.12, anchor[2] * 0.65) * width,
47
+ (frame[3] / 2 + anchor[2] * 0.12) * height,
48
+ ] : [width / 2, height / 2, width / 2, height / 2];
49
+ feather(c, cx, cy, rx, ry);
50
+ patches.set(mouth as Mouth, patch);
51
+ }
52
+ this.patches.set(view, patches);
53
+ }
54
+ }
55
+
56
+ draw(image: Surface, sheet: string, cell: number, cols: number, rows: number, articulation: Articulation, time: number, sourceCell = cell, override?: FacePose, performance?: FaceState): CanvasImageSource {
57
+ const pose = override ?? (sheet === '$image' ? this.info.imagePose : this.info.sheets[sheet]?.[cell]);
58
+ const width = image.width / cols, height = image.height / rows;
59
+ // Only the explicitly registered poses are touched.
60
+ if (!pose) return image;
61
+ const state = performance ?? faceAt(articulation, time, this.info.seed);
62
+ const { blink, brow: lift } = state;
63
+ const patches = this.patches.get(pose.atlas ?? 'default');
64
+ if (!patches) return image;
65
+ const key = [sheet, cell, pose.atlas, image instanceof HTMLCanvasElement ? Math.floor(time * 60) : '', pose.mouth.join(','), pose.lips?.join(','), pose.sourceOpen, articulation.mouth, articulation.previous, articulation.mix.toFixed(2), blink.toFixed(2), lift.toFixed(2)].join(':');
66
+ if (key === this.cache) return this.canvas;
67
+ this.cache = key;
68
+ this.revision++;
69
+ if (this.canvas.width !== width || this.canvas.height !== height) Object.assign(this.canvas, { width, height });
70
+ const c = this.canvas.getContext('2d')!;
71
+ c.clearRect(0, 0, width, height);
72
+ const ox = (sourceCell % cols) * width, oy = Math.floor(sourceCell / cols) * height;
73
+ c.drawImage(image, ox, oy, width, height, 0, 0, width, height);
74
+ const [x, y, w, h] = pose.mouth;
75
+ const view = pose.atlas ?? 'default', anchor = this.info.anchors?.[view];
76
+ const neutral = patches.get('X')!;
77
+ const destination: Box = pose.lips && anchor ? (() => {
78
+ // Uniform scale from the ORIGINAL mouth corners. The nose, jaw and beard stay as painted.
79
+ const [lx,ly,lw] = pose.lips, scale = lw / (anchor[2] * neutral.width);
80
+ const pw = neutral.width * scale, ph = neutral.height * scale;
81
+ return [lx + lw / 2 - (anchor[0] + anchor[2] / 2) * pw, ly - anchor[1] * ph, pw, ph];
82
+ })() : [x,y,w,h];
83
+ if(pose.sourceOpen && pose.lips && anchor && this.info.frames?.[view]){
84
+ const height=pose.lips[3]/(destination[2]/neutral.width),key=`${view}:${height.toFixed(2)}`;
85
+ let closed=this.closed.get(key);
86
+ if(!closed){
87
+ const raw=this.neutral.get(view)!;
88
+ closed=Object.assign(document.createElement('canvas'),{width:raw.width,height:raw.height});
89
+ const n=closed.getContext('2d')!;n.drawImage(raw,0,0);
90
+ const h=Math.max(height,anchor[3]*raw.height);
91
+ feather(n, (anchor[0]+anchor[2]/2)*raw.width, anchor[1]*raw.height+h/2,
92
+ anchor[2]*raw.width*.7, h/2+anchor[2]*raw.width*.12);
93
+ this.closed.set(key,closed);
94
+ }
95
+ if(this.closure.width!==neutral.width||this.closure.height!==neutral.height)Object.assign(this.closure,{width:neutral.width,height:neutral.height});
96
+ const n=this.closure.getContext('2d')!;n.clearRect(0,0,this.closure.width,this.closure.height);n.drawImage(closed,0,0);
97
+ n.globalCompositeOperation='destination-in';n.drawImage(image,ox+destination[0],oy+destination[1],destination[2],destination[3],0,0,this.closure.width,this.closure.height);n.globalCompositeOperation='source-over';
98
+ c.drawImage(this.closure,...destination);
99
+ }
100
+ if (this.blend.width !== neutral.width || this.blend.height !== neutral.height) Object.assign(this.blend, { width: neutral.width, height: neutral.height });
101
+ const blend = this.blend.getContext('2d')!;
102
+ blend.clearRect(0,0,this.blend.width,this.blend.height);
103
+ // A single premultiplied crossfade avoids stacking two feathered lower faces over the original.
104
+ blend.globalCompositeOperation = 'lighter';
105
+ for (const [mouth,weight] of [[articulation.previous,1-articulation.mix],[articulation.mouth,articulation.mix]] as Array<[Mouth,number]>) {
106
+ // Closed source art stays exact; originally open gestures have the neutral underlay above.
107
+ if (!pose.lips || !anchor || !this.info.frames?.[view] || mouth === 'X' || mouth === 'A' || weight <= 0) continue;
108
+ blend.globalAlpha = weight;
109
+ blend.drawImage(patches.get(mouth)!,0,0,this.blend.width,this.blend.height);
110
+ }
111
+ blend.globalAlpha = 1;
112
+ // Lip paint cannot grow past the source silhouette, including a puckered profile.
113
+ blend.globalCompositeOperation = 'destination-in';
114
+ blend.drawImage(image,ox+destination[0],oy+destination[1],destination[2],destination[3],0,0,this.blend.width,this.blend.height);
115
+ blend.globalCompositeOperation = 'source-over';
116
+ c.drawImage(this.blend, ...destination);
117
+ for (const eye of pose.eyes) this.eye(c, image, ox, oy, eye, blink, lift);
118
+ return this.canvas;
119
+ }
120
+
121
+ /** Compress the original painted eye between its lids; retain the surrounding skin and brow texture. */
122
+ private eye(c: CanvasRenderingContext2D, image: Surface, ox: number, oy: number, [x, y, w, h]: Box, blink: number, lift: number) {
123
+ if (blink < 0.01 && lift < 0.01) return;
124
+ const middle = h * 0.5, half = h * 0.18, open = Math.max(0.025, half * (1 - blink));
125
+ const source = [0, h * 0.2, middle - half, middle + half, h];
126
+ const target = [0, h * 0.2 - lift, middle - open, middle + open, h];
127
+ for (let i = 0; i < source.length - 1; i++) {
128
+ c.drawImage(image, ox + x, oy + y + source[i], w, source[i + 1] - source[i], x, y + target[i], w, target[i + 1] - target[i]);
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,35 @@
1
+ import * as THREE from 'three';
2
+ import { facialControl, faceWeight, isViseme, facialPriority, type FaceControl } from '../src/play/face3d.ts';
3
+ import type { FaceState } from '../src/play/lipsync.ts';
4
+
5
+ /** Only authored, recognized morph targets are driven. A body skeleton alone does not pretend to articulate. */
6
+ export class ModelFace {
7
+ private bindings: Array<{ mesh: THREE.Mesh; index: number; control: FaceControl; name: string; selected: boolean }> = [];
8
+ readonly mouth: boolean;
9
+ readonly eyes: boolean;
10
+ private nativeVisemes = new Map<THREE.Mesh, Set<FaceControl>>();
11
+ constructor(root: THREE.Object3D) {
12
+ root.traverse(object => {
13
+ if (!(object as THREE.Mesh).isMesh) return;
14
+ const mesh = object as THREE.Mesh;
15
+ for (const [name, index] of Object.entries(mesh.morphTargetDictionary ?? {})) {
16
+ const control = facialControl(name);
17
+ if (control && mesh.morphTargetInfluences) this.bindings.push({ mesh, index, control, name, selected: true });
18
+ }
19
+ });
20
+ for (const binding of this.bindings.filter(b => isViseme(b.control))) {
21
+ const group = this.bindings.filter(b => b.mesh === binding.mesh && b.control === binding.control).sort((a,b) => facialPriority(a.name) - facialPriority(b.name));
22
+ binding.selected = group[0] === binding;
23
+ if (binding.selected) {
24
+ const controls = this.nativeVisemes.get(binding.mesh) ?? new Set<FaceControl>();
25
+ controls.add(binding.control);
26
+ this.nativeVisemes.set(binding.mesh, controls);
27
+ }
28
+ }
29
+ this.mouth = [...this.nativeVisemes.values()].some(controls => [...controls].some(c => c !== 'sil')) || this.bindings.some(b => b.control === 'jaw');
30
+ this.eyes = this.bindings.some(b => b.control.startsWith('blink'));
31
+ }
32
+ apply(state: FaceState) {
33
+ for (const { mesh, index, control, selected } of this.bindings) mesh.morphTargetInfluences![index] = selected ? faceWeight(control, state, this.nativeVisemes.get(mesh) ?? false) : 0;
34
+ }
35
+ }