@lute-lang/player-web 0.0.0-stage → 0.41.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 (63) hide show
  1. package/README.md +50 -2
  2. package/dist/assets/pixiAssets.d.ts +47 -0
  3. package/dist/assets/pixiAssetsBackend.d.ts +3 -0
  4. package/dist/audio/pixiAudio.d.ts +77 -0
  5. package/dist/audio/pixiSoundBackend.d.ts +3 -0
  6. package/dist/choice/choiceMenu.d.ts +56 -0
  7. package/dist/effects/catalogue.d.ts +55 -0
  8. package/dist/engine/app.d.ts +14 -0
  9. package/dist/engine/behaviours.d.ts +35 -0
  10. package/dist/engine/easing.d.ts +24 -0
  11. package/dist/engine/types.d.ts +70 -0
  12. package/dist/engine/world.d.ts +10 -0
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.js +3409 -0
  15. package/dist/input/gamepad.d.ts +22 -0
  16. package/dist/input/index.d.ts +7 -0
  17. package/dist/input/map.d.ts +42 -0
  18. package/dist/mount.d.ts +31 -0
  19. package/dist/presenter/index.d.ts +1 -0
  20. package/dist/presenter/pixiPresenter.d.ts +75 -0
  21. package/dist/scene/actor.d.ts +26 -0
  22. package/dist/scene/background.d.ts +24 -0
  23. package/dist/scene/camera.d.ts +37 -0
  24. package/dist/scene/cgLayer.d.ts +21 -0
  25. package/dist/scene/index.d.ts +7 -0
  26. package/dist/scene/shared.d.ts +36 -0
  27. package/dist/scene/vfx.d.ts +14 -0
  28. package/dist/scene/videoLayer.d.ts +21 -0
  29. package/dist/text/dialogueWindow.d.ts +52 -0
  30. package/dist/text/richText.d.ts +37 -0
  31. package/dist/text/segments.d.ts +31 -0
  32. package/dist/theme.d.ts +7 -0
  33. package/dist/ui/index.d.ts +3 -0
  34. package/dist/ui/screens/backlog-screen.d.ts +1 -0
  35. package/dist/ui/screens/backlog.d.ts +25 -0
  36. package/dist/ui/screens/common.d.ts +68 -0
  37. package/dist/ui/screens/confirm.d.ts +1 -0
  38. package/dist/ui/screens/ended.d.ts +1 -0
  39. package/dist/ui/screens/halted.d.ts +1 -0
  40. package/dist/ui/screens/hud.d.ts +1 -0
  41. package/dist/ui/screens/idle.d.ts +1 -0
  42. package/dist/ui/screens/index.d.ts +4 -0
  43. package/dist/ui/screens/loading.d.ts +1 -0
  44. package/dist/ui/screens/menu.d.ts +1 -0
  45. package/dist/ui/screens/save-load-screen.d.ts +1 -0
  46. package/dist/ui/screens/save-load.d.ts +14 -0
  47. package/dist/ui/screens/settings-screen.d.ts +1 -0
  48. package/dist/ui/screens/settings.d.ts +13 -0
  49. package/dist/ui/screens/title.d.ts +1 -0
  50. package/dist/ui/ui.d.ts +65 -0
  51. package/dist/ui/widgets/base.d.ts +54 -0
  52. package/dist/ui/widgets/button.d.ts +15 -0
  53. package/dist/ui/widgets/controls.d.ts +47 -0
  54. package/dist/ui/widgets/focus-manager.d.ts +1 -0
  55. package/dist/ui/widgets/index.d.ts +4 -0
  56. package/dist/ui/widgets/label.d.ts +1 -0
  57. package/dist/ui/widgets/list.d.ts +50 -0
  58. package/dist/ui/widgets/panel.d.ts +1 -0
  59. package/dist/ui/widgets/scroll-list.d.ts +1 -0
  60. package/dist/ui/widgets/segmented.d.ts +1 -0
  61. package/dist/ui/widgets/slider.d.ts +1 -0
  62. package/dist/ui/widgets/toggle.d.ts +1 -0
  63. package/package.json +48 -4
package/dist/index.js ADDED
@@ -0,0 +1,3409 @@
1
+ // src/mount.ts
2
+ import { Context as Context5, Effect as Effect19, Exit, Layer as Layer5, ManagedRuntime, Scope as Scope2, Stream as Stream8, SubscriptionRef as SubscriptionRef3 } from "effect";
3
+ import * as KeyValueStore from "effect/persistence/KeyValueStore";
4
+ import * as BrowserKeyValueStore from "@effect/platform-browser/BrowserKeyValueStore";
5
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
6
+ import * as RuntimeBrowser from "@lute-lang/runtime/browser";
7
+ import * as Runtime from "@lute-lang/runtime";
8
+ import { LutePlayer, BindingsLoader } from "@lute-lang/player";
9
+
10
+ // src/engine/app.ts
11
+ import { Context, Effect, Layer, PubSub, Stream } from "effect";
12
+ import { Application, Container } from "pixi.js";
13
+ var makeLayers = () => {
14
+ const camera = new Container;
15
+ const actors = new Container;
16
+ camera.addChild(actors);
17
+ return {
18
+ background: new Container,
19
+ cg: new Container,
20
+ camera,
21
+ actors,
22
+ video: new Container,
23
+ dialogue: new Container,
24
+ choice: new Container,
25
+ overlay: new Container,
26
+ transition: new Container
27
+ };
28
+ };
29
+
30
+ class PixiApp extends Context.Service()("Lute.Web.PixiApp") {
31
+ static layer = (element, design, background = 0) => Layer.effect(PixiApp, Effect.gen(function* () {
32
+ const app = new Application;
33
+ yield* Effect.tryPromise({
34
+ try: () => app.init({
35
+ preference: "webgl",
36
+ antialias: true,
37
+ resizeTo: element,
38
+ backgroundColor: background,
39
+ autoDensity: true,
40
+ resolution: typeof window === "undefined" ? 1 : window.devicePixelRatio
41
+ }),
42
+ catch: (cause) => cause
43
+ });
44
+ element.appendChild(app.canvas);
45
+ const root = new Container;
46
+ const layers = makeLayers();
47
+ root.addChild(layers.background, layers.cg, layers.camera, layers.video, layers.dialogue, layers.choice, layers.overlay, layers.transition);
48
+ layers.camera.pivot.set(design.width / 2, design.height / 2);
49
+ layers.camera.position.set(design.width / 2, design.height / 2);
50
+ app.stage.addChild(root);
51
+ const resize = (width, height) => {
52
+ const viewWidth = (width ?? element.clientWidth) || app.screen.width / app.renderer.resolution;
53
+ const viewHeight = (height ?? element.clientHeight) || app.screen.height / app.renderer.resolution;
54
+ const scale = Math.min(viewWidth / design.width, viewHeight / design.height);
55
+ root.scale.set(scale);
56
+ root.position.set((viewWidth - design.width * scale) / 2, (viewHeight - design.height * scale) / 2);
57
+ };
58
+ app.renderer.on("resize", resize);
59
+ resize();
60
+ const framesPubSub = yield* PubSub.unbounded();
61
+ const frame = (ticker) => {
62
+ PubSub.publishUnsafe(framesPubSub, Math.min(0.1, Math.max(0, ticker.deltaMS / 1000)));
63
+ };
64
+ app.ticker.add(frame);
65
+ const frames = Stream.fromPubSub(framesPubSub);
66
+ const service = { app, layers, design, frames };
67
+ return yield* Effect.acquireRelease(Effect.succeed(service), () => Effect.sync(() => {
68
+ app.ticker.remove(frame);
69
+ app.renderer.off("resize", resize);
70
+ Effect.runPromise(PubSub.shutdown(framesPubSub));
71
+ app.destroy(true, {
72
+ children: true,
73
+ texture: true,
74
+ textureSource: true,
75
+ context: true
76
+ });
77
+ }));
78
+ }));
79
+ }
80
+
81
+ // src/engine/world.ts
82
+ import { Context as Context2, Deferred, Effect as Effect2, Layer as Layer2, Stream as Stream2 } from "effect";
83
+ var complete = (entry) => {
84
+ Effect2.runSync(Deferred.succeed(entry.done, undefined));
85
+ };
86
+
87
+ class World extends Context2.Service()("Lute.Web.World") {
88
+ static layer = Layer2.effect(World, Effect2.gen(function* () {
89
+ const pixi = yield* PixiApp;
90
+ const entries = [];
91
+ const world = {
92
+ run: (target, behaviour) => {
93
+ const done = Deferred.makeUnsafe();
94
+ entries.push({ target, behaviour, done });
95
+ return Deferred.await(done);
96
+ },
97
+ finishAll: (target) => Effect2.sync(() => {
98
+ let index = entries.length - 1;
99
+ while (index >= 0) {
100
+ const entry = entries[index];
101
+ if (entry === undefined) {
102
+ index -= 1;
103
+ continue;
104
+ }
105
+ if (target === undefined || entry.target === target) {
106
+ entry.behaviour.finish();
107
+ entry.behaviour.dispose();
108
+ complete(entry);
109
+ const last = entries.pop();
110
+ if (last !== undefined && index < entries.length)
111
+ entries[index] = last;
112
+ }
113
+ index -= 1;
114
+ }
115
+ }),
116
+ cancel: (target) => Effect2.sync(() => {
117
+ let index = entries.length - 1;
118
+ while (index >= 0) {
119
+ const entry = entries[index];
120
+ if (entry === undefined) {
121
+ index -= 1;
122
+ continue;
123
+ }
124
+ if (target === undefined || entry.target === target) {
125
+ entry.behaviour.dispose();
126
+ complete(entry);
127
+ const last = entries.pop();
128
+ if (last !== undefined && index < entries.length)
129
+ entries[index] = last;
130
+ }
131
+ index -= 1;
132
+ }
133
+ }),
134
+ size: Effect2.sync(() => entries.length)
135
+ };
136
+ const ticker = Stream2.runForEach(pixi.frames, (dt) => Effect2.sync(() => {
137
+ let index = entries.length - 1;
138
+ while (index >= 0) {
139
+ const entry = entries[index];
140
+ if (entry === undefined) {
141
+ index -= 1;
142
+ continue;
143
+ }
144
+ if (entry.behaviour.update(dt) === "done") {
145
+ entry.behaviour.dispose();
146
+ complete(entry);
147
+ const last = entries.pop();
148
+ if (last !== undefined && index < entries.length)
149
+ entries[index] = last;
150
+ }
151
+ index -= 1;
152
+ }
153
+ }));
154
+ yield* Effect2.forkScoped(ticker);
155
+ return world;
156
+ }));
157
+ }
158
+
159
+ // src/ui/ui.ts
160
+ import { Context as Context3, Effect as Effect3, Layer as Layer3, Queue, Stream as Stream3 } from "effect";
161
+ import { Container as Container6, Graphics as Graphics6 } from "pixi.js";
162
+
163
+ // src/ui/widgets/base.ts
164
+ import { Container as Container2, Graphics, Rectangle, Text } from "pixi.js";
165
+
166
+ class FocusManager {
167
+ items = [];
168
+ index = -1;
169
+ ring;
170
+ register(item) {
171
+ if (!this.items.includes(item))
172
+ this.items.push(item);
173
+ if (this.index < 0 && item.focusable)
174
+ this.focus(this.items.indexOf(item));
175
+ }
176
+ unregister(item) {
177
+ const at = this.items.indexOf(item);
178
+ if (at < 0)
179
+ return;
180
+ this.items.splice(at, 1);
181
+ if (this.index >= this.items.length)
182
+ this.index = this.items.length - 1;
183
+ }
184
+ move(delta) {
185
+ if (this.items.length === 0)
186
+ return;
187
+ let next = this.index;
188
+ for (let count = 0;count < this.items.length; count += 1) {
189
+ next = (next + delta + this.items.length) % this.items.length;
190
+ if (this.items[next]?.focusable) {
191
+ this.focus(next);
192
+ return;
193
+ }
194
+ }
195
+ }
196
+ handleKey(key) {
197
+ const current = this.items[this.index];
198
+ if (current?.handleKey?.(key) === true)
199
+ return true;
200
+ if (key === "Tab" || key === "ArrowDown" || key === "ArrowRight") {
201
+ this.move(1);
202
+ return true;
203
+ }
204
+ if (key === "ArrowUp" || key === "ArrowLeft") {
205
+ this.move(-1);
206
+ return true;
207
+ }
208
+ if (key === "Enter" || key === " ") {
209
+ current?.activate();
210
+ return true;
211
+ }
212
+ return false;
213
+ }
214
+ focus(index) {
215
+ const item = this.items[index];
216
+ if (!item?.focusable)
217
+ return;
218
+ if (this.index >= 0) {
219
+ const previous = this.items[this.index];
220
+ if (previous)
221
+ previous.focused = false;
222
+ }
223
+ this.index = index;
224
+ item.focused = true;
225
+ this.drawRing(item);
226
+ }
227
+ focusFirst() {
228
+ const index = this.items.findIndex((item) => item.focusable);
229
+ if (index >= 0)
230
+ this.focus(index);
231
+ }
232
+ get current() {
233
+ return this.items[this.index];
234
+ }
235
+ drawRing(item) {
236
+ const parent = item.focusNode.parent;
237
+ if (!parent)
238
+ return;
239
+ this.ring?.removeFromParent();
240
+ const bounds = item.focusNode.getLocalBounds();
241
+ const ring = new Graphics().roundRect(item.focusNode.x + bounds.x * item.focusNode.scale.x - 3, item.focusNode.y + bounds.y * item.focusNode.scale.y - 3, bounds.width * item.focusNode.scale.x + 6, bounds.height * item.focusNode.scale.y + 6, 5).stroke({ color: 16777215, width: 2, alpha: 0.9 });
242
+ parent.addChild(ring);
243
+ this.ring = ring;
244
+ }
245
+ }
246
+
247
+ class Widget extends Container2 {
248
+ focusable = true;
249
+ hasFocus = false;
250
+ hit;
251
+ theme;
252
+ enabled = true;
253
+ constructor(theme, width, height) {
254
+ super();
255
+ this.theme = theme;
256
+ this.eventMode = "static";
257
+ this.hitArea = new Rectangle(0, 0, width, height);
258
+ this.cursor = "pointer";
259
+ this.on("pointerdown", (event) => {
260
+ if ("preventDefault" in event.nativeEvent)
261
+ event.nativeEvent.preventDefault();
262
+ event.stopPropagation();
263
+ });
264
+ this.on("pointerup", (event) => {
265
+ if ("preventDefault" in event.nativeEvent)
266
+ event.nativeEvent.preventDefault();
267
+ event.stopPropagation();
268
+ });
269
+ this.hit = new Graphics().rect(0, 0, width, height).fill({ color: 16777215, alpha: 0.001 });
270
+ this.hit.eventMode = "static";
271
+ this.hit.cursor = "pointer";
272
+ this.addChild(this.hit);
273
+ this.hit.on("pointertap", () => this.activate());
274
+ this.on("pointerout", () => this.setHover(false));
275
+ }
276
+ get focused() {
277
+ return this.hasFocus;
278
+ }
279
+ set focused(value) {
280
+ this.hasFocus = value;
281
+ this.setHover(value);
282
+ }
283
+ get focusNode() {
284
+ return this;
285
+ }
286
+ setEnabled(enabled) {
287
+ this.enabled = enabled;
288
+ this.alpha = enabled ? 1 : 0.45;
289
+ this.eventMode = enabled ? "static" : "none";
290
+ this.hit.eventMode = enabled ? "static" : "none";
291
+ }
292
+ get isEnabled() {
293
+ return this.enabled;
294
+ }
295
+ setHover(_hovered) {}
296
+ setTheme(theme) {
297
+ this.theme = theme;
298
+ }
299
+ }
300
+ // src/ui/widgets/button.ts
301
+ import { Graphics as Graphics2, Text as Text2 } from "pixi.js";
302
+ class Button extends Widget {
303
+ background;
304
+ caption;
305
+ onPress;
306
+ hovered = false;
307
+ constructor(text, theme, onPress, icon) {
308
+ super(theme, 240, 48);
309
+ this.onPress = onPress;
310
+ this.background = new Graphics2;
311
+ this.caption = new Text2({
312
+ text: icon ? `${icon} ${text}` : text,
313
+ style: { fontFamily: theme.font, fontSize: 20, fill: theme.text }
314
+ });
315
+ this.caption.position.set(14, 11);
316
+ this.addChild(this.background, this.caption);
317
+ this.draw(false);
318
+ }
319
+ activate() {
320
+ if (this.isEnabled)
321
+ this.onPress();
322
+ }
323
+ setHover(hovered) {
324
+ this.hovered = hovered;
325
+ this.draw(hovered);
326
+ }
327
+ setTheme(theme) {
328
+ super.setTheme(theme);
329
+ this.caption.style = { ...this.caption.style, fontFamily: theme.font, fill: theme.text };
330
+ this.draw(this.hovered);
331
+ }
332
+ draw(hovered) {
333
+ const color = hovered || this.focused ? this.theme.accent : this.theme.panel;
334
+ this.background.clear().roundRect(0, 0, 240, 48, 8).fill({ color, alpha: this.focused ? 0.95 : 0.85 });
335
+ }
336
+ }
337
+ // src/ui/widgets/controls.ts
338
+ import { Graphics as Graphics3, Text as Text3 } from "pixi.js";
339
+ class Slider extends Widget {
340
+ track;
341
+ knob;
342
+ onChange;
343
+ value;
344
+ dragging = false;
345
+ trackWidth;
346
+ constructor(value, theme, onChange, width = 260) {
347
+ super(theme, width, 36);
348
+ this.trackWidth = width;
349
+ this.value = clamp(value);
350
+ this.onChange = onChange;
351
+ this.track = new Graphics3;
352
+ this.knob = new Graphics3;
353
+ this.addChild(this.track, this.knob);
354
+ this.on("pointerdown", (event) => {
355
+ this.dragging = true;
356
+ this.setFromPointer(event.global.x);
357
+ });
358
+ this.on("globalpointermove", (event) => {
359
+ if (this.dragging)
360
+ this.setFromPointer(event.global.x);
361
+ });
362
+ this.on("pointerup", () => {
363
+ this.dragging = false;
364
+ });
365
+ this.on("pointerupoutside", () => {
366
+ this.dragging = false;
367
+ });
368
+ this.draw();
369
+ }
370
+ get current() {
371
+ return this.value;
372
+ }
373
+ setValue(value) {
374
+ this.value = clamp(value);
375
+ this.draw();
376
+ }
377
+ activate() {
378
+ this.onChange(this.value);
379
+ }
380
+ handleKey(key) {
381
+ const amount = 0.05;
382
+ if (key === "ArrowLeft" || key === "ArrowDown")
383
+ this.change(this.value - amount);
384
+ else if (key === "ArrowRight" || key === "ArrowUp")
385
+ this.change(this.value + amount);
386
+ else if (key === "Home")
387
+ this.change(0);
388
+ else if (key === "End")
389
+ this.change(1);
390
+ else
391
+ return false;
392
+ return true;
393
+ }
394
+ setFromPointer(x) {
395
+ this.change(this.toLocal({ x, y: this.getGlobalPosition().y }).x / this.trackWidth);
396
+ }
397
+ change(value) {
398
+ this.value = clamp(value);
399
+ this.draw();
400
+ this.onChange(this.value);
401
+ }
402
+ draw() {
403
+ this.track.clear().roundRect(0, 15, this.trackWidth, 6, 3).fill({ color: this.theme.panel, alpha: 0.9 });
404
+ this.track.roundRect(0, 15, this.trackWidth * this.value, 6, 3).fill({ color: this.theme.accent });
405
+ this.knob.clear().circle(this.trackWidth * this.value, 18, 10).fill({ color: this.theme.accent });
406
+ }
407
+ }
408
+
409
+ class Toggle extends Widget {
410
+ caption;
411
+ background;
412
+ onChange;
413
+ value;
414
+ constructor(label, value, theme, onChange) {
415
+ super(theme, 260, 44);
416
+ this.value = value;
417
+ this.onChange = onChange;
418
+ this.background = new Graphics3;
419
+ this.caption = new Text3({ text: label, style: { fontFamily: theme.font, fontSize: 19, fill: theme.text } });
420
+ this.caption.position.set(0, 10);
421
+ this.addChild(this.background, this.caption);
422
+ this.draw();
423
+ }
424
+ get current() {
425
+ return this.value;
426
+ }
427
+ setValue(value) {
428
+ this.value = value;
429
+ this.draw();
430
+ }
431
+ activate() {
432
+ if (!this.isEnabled)
433
+ return;
434
+ this.value = !this.value;
435
+ this.draw();
436
+ this.onChange(this.value);
437
+ }
438
+ draw() {
439
+ this.background.clear().roundRect(205, 8, 48, 26, 13).fill({ color: this.value ? this.theme.accent : this.theme.panel });
440
+ this.background.circle(this.value ? 240 : 218, 21, 8).fill({ color: this.theme.text });
441
+ }
442
+ }
443
+
444
+ class Segmented extends Widget {
445
+ options;
446
+ buttons;
447
+ onChange;
448
+ value;
449
+ background;
450
+ constructor(options, value, theme, onChange) {
451
+ super(theme, Math.max(options.length, 1) * 130, 44);
452
+ this.options = options;
453
+ this.value = value;
454
+ this.onChange = onChange;
455
+ this.background = new Graphics3;
456
+ this.addChild(this.background);
457
+ this.buttons = options.map((option, index) => {
458
+ const text = new Text3({ text: option, style: { fontFamily: theme.font, fontSize: 18, fill: theme.text } });
459
+ text.position.set(index * 130 + 12, 10);
460
+ text.eventMode = "static";
461
+ text.on("pointertap", (event) => {
462
+ event.stopPropagation();
463
+ this.select(option);
464
+ });
465
+ this.addChild(text);
466
+ return text;
467
+ });
468
+ this.draw();
469
+ }
470
+ get current() {
471
+ return this.value;
472
+ }
473
+ activate() {
474
+ const index = this.options.indexOf(this.value);
475
+ const next = this.options[(index + 1) % this.options.length];
476
+ if (next !== undefined)
477
+ this.select(next);
478
+ }
479
+ select(value) {
480
+ this.value = value;
481
+ this.draw();
482
+ this.onChange(value);
483
+ }
484
+ draw() {
485
+ this.background.clear();
486
+ this.options.forEach((option, index) => {
487
+ const selected = option === this.value;
488
+ this.background.roundRect(index * 130, 0, 130, 44, 5).fill({ color: selected ? this.theme.accent : this.theme.panel, alpha: 0.9 });
489
+ });
490
+ this.buttons.forEach((text, index) => {
491
+ text.style = { ...text.style, fill: this.theme.text, fontFamily: this.theme.font };
492
+ text.alpha = this.options[index] === this.value ? 1 : 0.75;
493
+ });
494
+ }
495
+ }
496
+ function clamp(value) {
497
+ return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
498
+ }
499
+ // src/ui/widgets/list.ts
500
+ import { Container as Container3, Graphics as Graphics4, Text as Text4 } from "pixi.js";
501
+ function visibleRange(total, rowHeight, viewportHeight, offset) {
502
+ const safeHeight = Math.max(rowHeight, 1);
503
+ const start = Math.min(Math.max(0, Math.floor(Math.max(0, offset) / safeHeight)), Math.max(0, total));
504
+ const end = Math.min(total, Math.max(start, Math.ceil((Math.max(0, offset) + Math.max(0, viewportHeight)) / safeHeight) + 1));
505
+ return { start, end };
506
+ }
507
+
508
+ class ScrollList extends Widget {
509
+ viewport;
510
+ content;
511
+ scrollbar;
512
+ rows = new Map;
513
+ values;
514
+ renderRow;
515
+ rowHeight;
516
+ viewportHeight;
517
+ offset = 0;
518
+ dragging = false;
519
+ dragStart = 0;
520
+ dragOffset = 0;
521
+ constructor(values, theme, renderRow, rowHeight = 52, viewportHeight = 360, width = 600) {
522
+ super(theme, width, viewportHeight);
523
+ this.values = values;
524
+ this.renderRow = renderRow;
525
+ this.rowHeight = rowHeight;
526
+ this.viewportHeight = viewportHeight;
527
+ this.viewport = new Graphics4().rect(0, 0, width, viewportHeight).fill({ color: 16777215, alpha: 0.001 });
528
+ this.viewport.eventMode = "static";
529
+ this.content = new Container3;
530
+ this.scrollbar = new Graphics4;
531
+ this.addChild(this.content, this.viewport, this.scrollbar);
532
+ this.viewport.on("wheel", (event) => {
533
+ this.scrollBy(event.deltaY);
534
+ });
535
+ this.viewport.on("pointerdown", (event) => {
536
+ this.dragging = true;
537
+ this.dragStart = event.global.y;
538
+ this.dragOffset = this.offset;
539
+ });
540
+ this.viewport.on("pointermove", (event) => {
541
+ if (this.dragging)
542
+ this.setOffset(this.dragOffset + this.dragStart - event.global.y);
543
+ });
544
+ this.viewport.on("pointerup", () => {
545
+ this.dragging = false;
546
+ });
547
+ this.viewport.on("pointerupoutside", () => {
548
+ this.dragging = false;
549
+ });
550
+ this.refresh();
551
+ }
552
+ get scrollOffset() {
553
+ return this.offset;
554
+ }
555
+ get maxOffset() {
556
+ return Math.max(0, this.values.length * this.rowHeight - this.viewportHeight);
557
+ }
558
+ scrollBy(delta) {
559
+ this.setOffset(this.offset + delta);
560
+ }
561
+ setOffset(offset) {
562
+ this.offset = Math.max(0, Math.min(this.maxOffset, offset));
563
+ this.refresh();
564
+ }
565
+ handleKey(key) {
566
+ if (key === "ArrowDown")
567
+ this.scrollBy(this.rowHeight);
568
+ else if (key === "ArrowUp")
569
+ this.scrollBy(-this.rowHeight);
570
+ else if (key === "PageDown")
571
+ this.scrollBy(this.viewportHeight);
572
+ else if (key === "PageUp")
573
+ this.scrollBy(-this.viewportHeight);
574
+ else if (key === "Home")
575
+ this.setOffset(0);
576
+ else if (key === "End")
577
+ this.setOffset(this.maxOffset);
578
+ else
579
+ return false;
580
+ return true;
581
+ }
582
+ activate() {}
583
+ refresh() {
584
+ const range = visibleRange(this.values.length, this.rowHeight, this.viewportHeight, this.offset);
585
+ for (const [index, row] of this.rows) {
586
+ if (index < range.start || index >= range.end) {
587
+ row.removeFromParent();
588
+ this.rows.delete(index);
589
+ }
590
+ }
591
+ for (let index = range.start;index < range.end; index += 1) {
592
+ if (!this.rows.has(index)) {
593
+ const value = this.values[index];
594
+ if (value !== undefined) {
595
+ const row = this.renderRow(value, index);
596
+ row.position.y = index * this.rowHeight;
597
+ this.rows.set(index, row);
598
+ this.content.addChild(row);
599
+ }
600
+ }
601
+ }
602
+ this.content.position.y = -this.offset;
603
+ const ratio = this.values.length === 0 ? 1 : this.viewportHeight / (this.values.length * this.rowHeight);
604
+ const thumbHeight = Math.max(24, this.viewportHeight * Math.min(1, ratio));
605
+ const thumbY = this.maxOffset === 0 ? 0 : this.offset / this.maxOffset * (this.viewportHeight - thumbHeight);
606
+ this.scrollbar.clear().roundRect(this.width - 10, thumbY, 8, thumbHeight, 4).fill({ color: this.theme.accent, alpha: 0.8 });
607
+ }
608
+ }
609
+
610
+ class Panel extends Graphics4 {
611
+ panelWidth;
612
+ panelHeight;
613
+ theme;
614
+ constructor(width, height, theme, radius = 14) {
615
+ super();
616
+ this.panelWidth = width;
617
+ this.panelHeight = height;
618
+ this.theme = theme;
619
+ this.draw(radius);
620
+ }
621
+ setTheme(theme) {
622
+ this.theme = theme;
623
+ this.draw(14);
624
+ }
625
+ draw(radius) {
626
+ this.clear().roundRect(0, 0, this.panelWidth, this.panelHeight, radius).fill({ color: this.theme.panel, alpha: this.theme.panelAlpha });
627
+ }
628
+ }
629
+
630
+ class Label extends Text4 {
631
+ constructor(text, theme, size = 22) {
632
+ super({ text, style: { fontFamily: theme.font, fontSize: size, fill: theme.text } });
633
+ }
634
+ }
635
+ // src/ui/screens/backlog.ts
636
+ import { Container as Container5, Text as Text6 } from "pixi.js";
637
+
638
+ // src/ui/screens/common.ts
639
+ import { Container as Container4, Graphics as Graphics5, Sprite, Text as Text5 } from "pixi.js";
640
+ class ScreenView extends Container4 {
641
+ focus = new FocusManager;
642
+ theme;
643
+ emitAction;
644
+ panel;
645
+ title;
646
+ panelWidth;
647
+ panelHeight;
648
+ constructor(heading, theme, emit, width = 900, height = 650) {
649
+ super();
650
+ this.theme = theme;
651
+ this.emitAction = emit;
652
+ this.panelWidth = width;
653
+ this.panelHeight = height;
654
+ this.panel = new Panel(width, height, theme);
655
+ this.title = new Text5({ text: heading, style: { fontFamily: theme.font, fontSize: 34, fill: theme.text } });
656
+ this.title.position.set(32, 24);
657
+ this.addChild(this.panel, this.title);
658
+ }
659
+ layout(design, full = false) {
660
+ if (full) {
661
+ this.panel.clear().rect(0, 0, design.width, design.height).fill({ color: 527128, alpha: 0.94 });
662
+ this.position.set(0, 0);
663
+ return;
664
+ }
665
+ this.position.set((design.width - this.panelWidth) / 2, (design.height - this.panelHeight) / 2);
666
+ }
667
+ button(text, action, x, y, disabled = false) {
668
+ const button = new Button(text, this.theme, () => this.emitAction(action));
669
+ button.position.set(x, y);
670
+ button.setEnabled(!disabled);
671
+ this.addChild(button);
672
+ this.focus.register(button);
673
+ return button;
674
+ }
675
+ handleKey(key) {
676
+ return this.focus.handleKey(key);
677
+ }
678
+ clearContent() {
679
+ while (this.children.length > 2)
680
+ this.removeChildAt(2);
681
+ }
682
+ }
683
+
684
+ class TitleScreen extends ScreenView {
685
+ constructor(model, theme, emit, gameTitle = "Lute", textureFor) {
686
+ super(gameTitle, theme, emit, 1280, 720);
687
+ if (model.background !== undefined) {
688
+ const texture = textureFor?.(model.background);
689
+ if (texture !== undefined) {
690
+ const background = new Sprite(texture);
691
+ background.width = 1280;
692
+ background.height = 720;
693
+ background.alpha = 0.95;
694
+ this.addChildAt(background, 1);
695
+ }
696
+ }
697
+ const wash = new Graphics5().rect(0, 0, 1280, 720).fill({ color: 329492, alpha: 0.36 });
698
+ const gradient = new Graphics5().rect(0, 0, 1280, 720).fill({ color: 131850, alpha: 0.2 });
699
+ this.addChildAt(wash, 2);
700
+ this.addChildAt(gradient, 3);
701
+ this.title.position.set(84, 92);
702
+ this.title.style = { ...this.title.style, fontSize: 68, fontWeight: "700", dropShadow: { color: 0, alpha: 0.65, blur: 8, distance: 3 } };
703
+ this.button("New game", { type: "newGame" }, 84, 185);
704
+ this.button("Continue", { type: "continue" }, 84, 253, !model.canContinue);
705
+ this.button("Load", { type: "openLoad" }, 84, 321);
706
+ this.button("Settings", { type: "openSettings" }, 84, 389);
707
+ }
708
+ }
709
+
710
+ class MenuScreen extends ScreenView {
711
+ constructor(theme, emit) {
712
+ super("Menu", theme, emit, 620, 500);
713
+ this.button("Resume", { type: "close" }, 50, 100);
714
+ this.button("Save", { type: "openSave" }, 50, 160);
715
+ this.button("Load", { type: "openLoad" }, 50, 220);
716
+ this.button("Settings", { type: "openSettings" }, 50, 280);
717
+ this.button("Title", { type: "toTitle" }, 50, 340);
718
+ }
719
+ }
720
+
721
+ class ConfirmScreen extends ScreenView {
722
+ constructor(model, theme, emit) {
723
+ super("Confirm", theme, emit, 680, 320);
724
+ const message = new Text5({ text: model.message, style: { fontFamily: theme.font, fontSize: 22, fill: theme.text, wordWrap: true, wordWrapWidth: 600 } });
725
+ message.position.set(36, 90);
726
+ this.addChild(message);
727
+ this.button("Yes", { type: "confirm", yes: true }, 36, 220);
728
+ this.button("No", { type: "confirm", yes: false }, 300, 220);
729
+ }
730
+ }
731
+
732
+ class EndedScreen extends ScreenView {
733
+ constructor(model, theme, emit) {
734
+ super("The End", theme, emit);
735
+ this.title.anchor.set(0.5);
736
+ this.title.position.set(this.panelWidth / 2, 72);
737
+ this.title.style = { ...this.title.style, fontSize: 56, fontWeight: "700", align: "center" };
738
+ const subtitle = model.reason === "terminal" ? "Thank you for playing" : model.reason;
739
+ const text = new Text5({ text: subtitle, style: { fontFamily: theme.font, fontSize: 26, fill: theme.text, align: "center", wordWrap: true, wordWrapWidth: 780 } });
740
+ text.anchor.set(0.5);
741
+ text.position.set(this.panelWidth / 2, 220);
742
+ this.addChild(text);
743
+ this.button("Back to title", { type: "toTitle" }, (this.panelWidth - 240) / 2, 510);
744
+ }
745
+ }
746
+
747
+ class HaltedScreen extends ScreenView {
748
+ constructor(model, theme, emit) {
749
+ super("Playback halted", theme, emit);
750
+ this.title.anchor.set(0.5);
751
+ this.title.position.set(this.panelWidth / 2, 72);
752
+ this.title.style = { ...this.title.style, fontSize: 48, fontWeight: "700", align: "center" };
753
+ const text = new Text5({ text: model.message, style: { fontFamily: theme.font, fontSize: 24, fill: theme.text, align: "center", wordWrap: true, wordWrapWidth: 780 } });
754
+ text.anchor.set(0.5);
755
+ text.position.set(this.panelWidth / 2, 220);
756
+ this.addChild(text);
757
+ this.button("Back to title", { type: "toTitle" }, (this.panelWidth - 240) / 2, 510);
758
+ }
759
+ }
760
+
761
+ class LoadingScreen extends ScreenView {
762
+ constructor(model, theme, emit) {
763
+ super("Loading", theme, emit);
764
+ const bar = new Graphics5().roundRect(45, 220, 810, 26, 13).fill({ color: theme.panel });
765
+ bar.roundRect(45, 220, 810 * Math.max(0, Math.min(1, model.progress)), 26, 13).fill({ color: theme.accent });
766
+ this.addChild(bar);
767
+ }
768
+ }
769
+
770
+ class IdleScreen extends ScreenView {
771
+ constructor(model, theme, emit) {
772
+ super("Continue", theme, emit, 620, 420);
773
+ model.options.forEach((option, index) => {
774
+ const x = 190 + index % 2 * 270;
775
+ const y = 130 + Math.floor(index / 2) * 60;
776
+ this.button(option.label, { type: "pickIdle", index }, x, y);
777
+ });
778
+ }
779
+ }
780
+
781
+ // src/ui/screens/backlog.ts
782
+ class BacklogScreen extends ScreenView {
783
+ list;
784
+ constructor(model, theme, emit) {
785
+ super("Backlog", theme, emit, 1000, 660);
786
+ this.list = new ScrollList(model.entries, theme, (entry) => this.row(entry, theme, emit), 72, 520, 900);
787
+ this.list.position.set(36, 90);
788
+ this.addChild(this.list);
789
+ this.list.setOffset(this.list.maxOffset);
790
+ this.button("Close", { type: "close" }, 36, 600);
791
+ }
792
+ handleKey(key) {
793
+ return this.list.handleKey(key) || super.handleKey(key);
794
+ }
795
+ row(entry, theme, emit) {
796
+ const row = new Container5;
797
+ const name = new Label(entry.speakerName, theme, 18);
798
+ const text = new Text6({ text: entry.text, style: { fontFamily: theme.font, fontSize: 18, fill: theme.text, wordWrap: true, wordWrapWidth: entry.hasVoice ? 690 : 810 } });
799
+ text.position.set(0, 25);
800
+ row.addChild(name, text);
801
+ if (entry.hasVoice) {
802
+ const voice = new Button("Voice", theme, () => emit({ type: "replayVoice", lineId: entry.lineId }));
803
+ voice.position.set(720, 14);
804
+ row.addChild(voice);
805
+ }
806
+ return row;
807
+ }
808
+ }
809
+
810
+ class HudScreen extends Container5 {
811
+ static buttonWidth = 101;
812
+ static gap = 4;
813
+ static totalWidth = 9 * HudScreen.buttonWidth + 8 * HudScreen.gap;
814
+ constructor(theme, emit, state) {
815
+ super();
816
+ const actions = [
817
+ { label: state.auto ? "Auto: on" : "Auto", action: { type: "toggleAuto" } },
818
+ { label: state.skip ? "Skip: on" : "Skip", action: { type: "toggleSkip" } },
819
+ { label: "Log", action: { type: "openBacklog" } },
820
+ { label: "Save", action: { type: "openSave" } },
821
+ { label: "Load", action: { type: "openLoad" } },
822
+ { label: "Q.Save", action: { type: "quickSave" } },
823
+ { label: "Q.Load", action: { type: "quickLoad" } },
824
+ { label: "Config", action: { type: "openSettings" } },
825
+ { label: state.hidden ? "Show" : "Hide", action: { type: "hideUI" } }
826
+ ];
827
+ actions.forEach((item, index) => {
828
+ const button = new Button(item.label, theme, () => emit(item.action));
829
+ button.scale.set(HudScreen.buttonWidth / 240);
830
+ button.position.set(index * (HudScreen.buttonWidth + HudScreen.gap), 0);
831
+ this.addChild(button);
832
+ });
833
+ }
834
+ }
835
+
836
+ // src/ui/screens/save-load.ts
837
+ import { Sprite as Sprite2, Text as Text7, Texture as Texture2 } from "pixi.js";
838
+ var SLOTS_PER_PAGE = 6;
839
+ function pageSlots(slots, page) {
840
+ const offset = Math.max(0, page) * SLOTS_PER_PAGE;
841
+ return Array.from({ length: SLOTS_PER_PAGE }, (_, index) => slots[offset + index]);
842
+ }
843
+
844
+ class SaveLoadScreen extends ScreenView {
845
+ constructor(model, theme, emit) {
846
+ super(model.mode === "save" ? "Save game" : "Load game", theme, emit, 1000, 650);
847
+ pageSlots(model.slots, model.page).forEach((slot, index) => {
848
+ const x = 36 + index % 2 * 460;
849
+ const y = 90 + Math.floor(index / 2) * 130;
850
+ this.slot(slot, model.mode, theme, emit, x, y, model.page * SLOTS_PER_PAGE + index);
851
+ });
852
+ if (model.page > 0)
853
+ this.button("Previous", { type: "choose", option: `page:${model.page - 1}` }, 36, 595);
854
+ if ((model.page + 1) * SLOTS_PER_PAGE < model.slots.length)
855
+ this.button("Next", { type: "choose", option: `page:${model.page + 1}` }, 210, 595);
856
+ this.button("Close", { type: "close" }, 770, 595);
857
+ }
858
+ slot(slot, mode, theme, emit, x, y, emptyIndex) {
859
+ const panel = new Panel(420, 130, theme, 8);
860
+ panel.position.set(x, y);
861
+ this.addChild(panel);
862
+ if (slot?.thumbnail !== undefined) {
863
+ const image = new Sprite2(Texture2.from(slot.thumbnail));
864
+ image.width = 110;
865
+ image.height = 80;
866
+ image.position.set(x + 10, y + 10);
867
+ this.addChild(image);
868
+ }
869
+ const label = new Text7({ text: slot?.label ?? "Empty slot", style: { fontFamily: theme.font, fontSize: 18, fill: theme.text } });
870
+ label.position.set(x + 135, y + 20);
871
+ this.addChild(label);
872
+ const date = new Text7({ text: slot?.savedAt ?? "", style: { fontFamily: theme.font, fontSize: 14, fill: theme.text } });
873
+ date.position.set(x + 135, y + 48);
874
+ this.addChild(date);
875
+ if (slot !== undefined) {
876
+ const action = mode === "save" ? { type: "saveSlot", slot: slot.slot } : { type: "loadSlot", slot: slot.slot };
877
+ const button = new Button(mode === "save" ? "Save" : "Load", theme, () => emit(action));
878
+ button.scale.set(0.6);
879
+ button.position.set(x + 270, y + 72);
880
+ this.addChild(button);
881
+ this.focus.register(button);
882
+ if (mode === "save") {
883
+ const remove = new Button("Delete", theme, () => emit({ type: "deleteSlot", slot: slot.slot }));
884
+ remove.scale.set(0.6);
885
+ remove.position.set(x + 330, y + 72);
886
+ this.addChild(remove);
887
+ this.focus.register(remove);
888
+ }
889
+ } else if (mode === "save") {
890
+ const button = new Button("Save", theme, () => emit({ type: "saveSlot", slot: emptyIndex }));
891
+ button.scale.set(0.6);
892
+ button.position.set(x + 270, y + 72);
893
+ this.addChild(button);
894
+ this.focus.register(button);
895
+ }
896
+ }
897
+ }
898
+
899
+ // src/ui/screens/settings.ts
900
+ import { Text as Text8 } from "pixi.js";
901
+ class SettingsScreen extends ScreenView {
902
+ settings;
903
+ sample;
904
+ constructor(model, theme, emit) {
905
+ super("Settings", theme, emit, 1100, 680);
906
+ this.settings = model.settings;
907
+ this.sample = new Text8({ text: "The quick brown fox jumps over the lazy dog.", style: { fontFamily: theme.font, fontSize: 20, fill: theme.text } });
908
+ this.sample.position.set(40, 76);
909
+ this.addChild(this.sample);
910
+ this.row("Text speed", 125, new Slider(normalizeSpeed(model.settings.textSpeed), theme, (value) => {
911
+ const textSpeed = value === 0 ? 0 : Math.round(10 + value * 190);
912
+ emit({ type: "setSettings", settings: { textSpeed } });
913
+ this.sample.text = textSpeed === 0 ? "Instant text" : "The quick brown fox jumps over the lazy dog.";
914
+ }));
915
+ this.row("Auto delay", 180, new Slider(normalizeDelay(model.settings.autoDelay), theme, (value) => emit({ type: "setSettings", settings: { autoDelay: Math.round(value * 20) } })));
916
+ this.row("Skip mode", 235, new Segmented(["read", "all"], model.settings.skipMode, theme, (skipMode) => emit({ type: "setSettings", settings: { skipMode } })));
917
+ this.row("Master volume", 290, this.volumeSlider(model.settings.volumes.master, theme, emit, "master"));
918
+ this.row("Music volume", 345, this.volumeSlider(model.settings.volumes.music, theme, emit, "music"));
919
+ this.row("SFX volume", 400, this.volumeSlider(model.settings.volumes.sfx, theme, emit, "sfx"));
920
+ this.row("Voice volume", 455, this.volumeSlider(model.settings.volumes.voice, theme, emit, "voice"));
921
+ this.row("Window opacity", 510, new Slider(model.settings.windowOpacity, theme, (windowOpacity) => emit({ type: "setSettings", settings: { windowOpacity } })));
922
+ this.row("Read text colour", 565, new Toggle("Enabled", model.settings.readColour, theme, (readColour) => emit({ type: "setSettings", settings: { readColour } })));
923
+ this.row("Locale", 620, new Segmented(model.locales, model.settings.locale, theme, (locale) => emit({ type: "setSettings", settings: { locale } })));
924
+ this.button("Close", { type: "close" }, 850, 620);
925
+ }
926
+ row(name, y, control) {
927
+ const label = new Label(name, this.theme, 18);
928
+ label.position.set(40, y + 7);
929
+ control.position.set(250, y);
930
+ this.addChild(label, control);
931
+ this.focus.register(control);
932
+ }
933
+ volumeSlider(value, theme, emit, channel) {
934
+ return new Slider(value, theme, (next) => emit({ type: "setSettings", settings: { volumes: { ...this.settings.volumes, [channel]: next } } }));
935
+ }
936
+ }
937
+ function normalizeSpeed(speed) {
938
+ if (speed <= 0)
939
+ return 0;
940
+ return Math.max(0, Math.min(1, (speed - 10) / 190));
941
+ }
942
+ function normalizeDelay(delay) {
943
+ return Math.max(0, Math.min(1, delay / 20));
944
+ }
945
+
946
+ // src/ui/ui.ts
947
+ var UiTag = Context3.Service("Lute.Web.Ui");
948
+
949
+ class Ui {
950
+ actions;
951
+ root;
952
+ theme;
953
+ textureFor;
954
+ current;
955
+ base;
956
+ overlay;
957
+ hud;
958
+ hudState = "";
959
+ design = { width: 1280, height: 720 };
960
+ titleName = "Lute";
961
+ push = () => {};
962
+ constructor(root, theme, _world, textureFor) {
963
+ this.root = root;
964
+ this.theme = theme;
965
+ this.textureFor = textureFor;
966
+ this.actions = Stream3.callback((queue) => Effect3.sync(() => {
967
+ this.push = (action) => {
968
+ Queue.offerUnsafe(queue, action);
969
+ };
970
+ }));
971
+ }
972
+ show(screen) {
973
+ return Effect3.sync(() => {
974
+ const modal = ["Menu", "Backlog", "SaveLoad", "Settings", "Confirm"].includes(screen._tag);
975
+ if (this.overlay) {
976
+ this.overlay.removeFromParent();
977
+ this.overlay.visible = false;
978
+ this.overlay = undefined;
979
+ }
980
+ const next = this.build(screen);
981
+ if (modal) {
982
+ const overlay = new Container6;
983
+ const dim = new Graphics6().rect(0, 0, this.design.width, this.design.height).fill({ color: 0, alpha: 0.6 });
984
+ dim.eventMode = "static";
985
+ overlay.eventMode = "static";
986
+ overlay.on("pointerdown", (event) => {
987
+ if ("preventDefault" in event.nativeEvent)
988
+ event.nativeEvent.preventDefault();
989
+ if ("stopImmediatePropagation" in event.nativeEvent)
990
+ event.nativeEvent.stopImmediatePropagation();
991
+ event.stopPropagation();
992
+ });
993
+ overlay.on("pointerup", (event) => {
994
+ if ("preventDefault" in event.nativeEvent)
995
+ event.nativeEvent.preventDefault();
996
+ if ("stopImmediatePropagation" in event.nativeEvent)
997
+ event.nativeEvent.stopImmediatePropagation();
998
+ event.stopPropagation();
999
+ if (event.button === 2)
1000
+ this.push({ type: "close" });
1001
+ });
1002
+ next.layout(this.design, false);
1003
+ overlay.addChild(dim, next);
1004
+ this.root.addChild(overlay);
1005
+ this.overlay = overlay;
1006
+ } else {
1007
+ if (this.base) {
1008
+ this.base.removeFromParent();
1009
+ this.base.visible = false;
1010
+ }
1011
+ next.layout(this.design, true);
1012
+ this.root.addChild(next);
1013
+ this.base = next;
1014
+ }
1015
+ this.current = next;
1016
+ if (this.hud)
1017
+ this.hud.visible = !this.current;
1018
+ });
1019
+ }
1020
+ hide = Effect3.sync(() => {
1021
+ if (this.overlay) {
1022
+ this.overlay.removeFromParent();
1023
+ this.overlay.visible = false;
1024
+ this.overlay = undefined;
1025
+ this.current = this.base;
1026
+ } else {
1027
+ if (this.base) {
1028
+ this.base.removeFromParent();
1029
+ this.base.visible = false;
1030
+ }
1031
+ this.base = undefined;
1032
+ this.current = undefined;
1033
+ }
1034
+ if (this.hud)
1035
+ this.hud.visible = !this.current;
1036
+ });
1037
+ setTheme(theme) {
1038
+ this.theme = theme;
1039
+ }
1040
+ setDesign(design, title = "Lute") {
1041
+ this.design = { width: Math.max(1, design.width), height: Math.max(1, design.height) };
1042
+ this.titleName = title;
1043
+ }
1044
+ resize(viewWidth, viewHeight) {
1045
+ const scale = Math.min(viewWidth / this.design.width, viewHeight / this.design.height);
1046
+ this.root.scale.set(Math.max(0, scale));
1047
+ this.root.position.set((viewWidth - this.design.width * scale) / 2, (viewHeight - this.design.height * scale) / 2);
1048
+ }
1049
+ showHud(state) {
1050
+ if (this.hud) {
1051
+ this.hud.removeFromParent();
1052
+ this.hud.visible = false;
1053
+ }
1054
+ this.hud = new HudScreen(this.theme, this.push, state);
1055
+ this.hud.position.set(Math.max(16, this.design.width - HudScreen.totalWidth - 20), this.design.height - 32);
1056
+ this.root.addChild(this.hud);
1057
+ }
1058
+ setPlaying(playing, state) {
1059
+ if (playing && this.base) {
1060
+ this.base.removeFromParent();
1061
+ this.base.visible = false;
1062
+ if (this.current === this.base)
1063
+ this.current = undefined;
1064
+ this.base = undefined;
1065
+ }
1066
+ const key = `${state.auto}:${state.skip}:${state.hidden}`;
1067
+ if (playing && key !== this.hudState) {
1068
+ this.showHud(state);
1069
+ this.hudState = key;
1070
+ }
1071
+ if (this.hud)
1072
+ this.hud.visible = playing && !state.hidden && !this.current;
1073
+ }
1074
+ get hasScreen() {
1075
+ return this.current !== undefined;
1076
+ }
1077
+ pointerAction(clientX, clientY) {
1078
+ if (!this.current)
1079
+ return false;
1080
+ const visit = (node) => {
1081
+ const bounds = node.getBounds();
1082
+ if (node instanceof Button && node.isEnabled && clientX >= bounds.x && clientX <= bounds.x + bounds.width && clientY >= bounds.y && clientY <= bounds.y + bounds.height) {
1083
+ node.activate();
1084
+ return true;
1085
+ }
1086
+ return node.children.some((child) => child instanceof Container6 && visit(child));
1087
+ };
1088
+ return visit(this.current);
1089
+ }
1090
+ handleKey(key) {
1091
+ if (this.overlay && key === "Escape") {
1092
+ this.push({ type: "close" });
1093
+ return true;
1094
+ }
1095
+ return this.current?.handleKey(key) ?? false;
1096
+ }
1097
+ disposeLater(node) {
1098
+ node.removeFromParent();
1099
+ node.visible = false;
1100
+ }
1101
+ build(screen) {
1102
+ const emit = (action) => this.push(action);
1103
+ switch (screen._tag) {
1104
+ case "Title":
1105
+ return new TitleScreen(screen, this.theme, emit, this.titleName, this.textureFor);
1106
+ case "Backlog":
1107
+ return new BacklogScreen(screen, this.theme, emit);
1108
+ case "SaveLoad":
1109
+ return new SaveLoadScreen(screen, this.theme, emit);
1110
+ case "Settings":
1111
+ return new SettingsScreen(screen, this.theme, emit);
1112
+ case "Confirm":
1113
+ return new ConfirmScreen(screen, this.theme, emit);
1114
+ case "Loading":
1115
+ return new LoadingScreen(screen, this.theme, emit);
1116
+ case "Halted":
1117
+ return new HaltedScreen(screen, this.theme, emit);
1118
+ case "Idle":
1119
+ return new IdleScreen(screen, this.theme, emit);
1120
+ case "Menu":
1121
+ return new MenuScreen(this.theme, emit);
1122
+ case "Ended":
1123
+ return new EndedScreen(screen, this.theme, emit);
1124
+ }
1125
+ }
1126
+ }
1127
+
1128
+ // src/assets/pixiAssets.ts
1129
+ import { Effect as Effect4, Fiber, Stream as Stream4, SubscriptionRef } from "effect";
1130
+ import { AssetMissing } from "@lute-lang/player";
1131
+ var AUDIO_EXTENSIONS = /\.(?:aac|flac|m4a|mp3|ogg|wav|webm)(?:[?#].*)?$/i;
1132
+ var VIDEO_EXTENSIONS = /\.(?:avi|m4v|mov|mp4|webm)(?:[?#].*)?$/i;
1133
+ var uniqueFiles = (files) => {
1134
+ const result = [];
1135
+ const seen = new Set;
1136
+ for (const file of files) {
1137
+ if (file.length > 0 && !seen.has(file)) {
1138
+ seen.add(file);
1139
+ result.push(file);
1140
+ }
1141
+ }
1142
+ return result;
1143
+ };
1144
+ var pathKind = (file) => {
1145
+ if (VIDEO_EXTENSIONS.test(file))
1146
+ return "video";
1147
+ if (AUDIO_EXTENSIONS.test(file))
1148
+ return "audio";
1149
+ return "texture";
1150
+ };
1151
+ var resolveAssetUrl = (file, base) => {
1152
+ try {
1153
+ return new URL(file, base).href;
1154
+ } catch {
1155
+ const origin = typeof globalThis.location === "object" ? globalThis.location.href : "http://localhost/";
1156
+ return new URL(file, new URL(base, origin)).href;
1157
+ }
1158
+ };
1159
+
1160
+ class PixiAssets {
1161
+ progress;
1162
+ backend;
1163
+ base;
1164
+ maxConcurrency;
1165
+ graceSeconds;
1166
+ progressRef;
1167
+ records = new Map;
1168
+ unloadFibers = new Map;
1169
+ constructor(options) {
1170
+ const base = options.assetsBase ?? "assets/";
1171
+ this.base = base;
1172
+ this.backend = options.backend;
1173
+ this.maxConcurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
1174
+ this.graceSeconds = Math.max(0, options.graceSeconds ?? 0.25);
1175
+ this.progressRef = Effect4.runSync(SubscriptionRef.make(0));
1176
+ this.progress = Stream4.concat(Stream4.succeed(0), SubscriptionRef.changes(this.progressRef));
1177
+ }
1178
+ ensure = (files) => {
1179
+ const requested = uniqueFiles(files);
1180
+ return Effect4.gen({ self: this }, function* () {
1181
+ const self = this;
1182
+ yield* Effect4.addFinalizer(() => self.release(requested));
1183
+ yield* SubscriptionRef.set(self.progressRef, requested.length === 0 ? 1 : 0);
1184
+ let completed = 0;
1185
+ yield* Effect4.forEach(requested, (file) => self.acquire(file).pipe(Effect4.tap(() => Effect4.sync(() => {
1186
+ completed += 1;
1187
+ })), Effect4.tap(() => SubscriptionRef.set(self.progressRef, requested.length === 0 ? 1 : completed / requested.length))), { concurrency: self.maxConcurrency, discard: true });
1188
+ yield* SubscriptionRef.set(self.progressRef, 1);
1189
+ });
1190
+ };
1191
+ texture = (file) => {
1192
+ const record = this.records.get(file);
1193
+ return record?.kind === "texture" || record?.kind === "video" ? record.value : undefined;
1194
+ };
1195
+ video = (file) => {
1196
+ const record = this.records.get(file);
1197
+ return record?.kind === "video" ? record.value : undefined;
1198
+ };
1199
+ acquire = (file) => Effect4.gen({ self: this }, function* () {
1200
+ const self = this;
1201
+ const pending = self.unloadFibers.get(file);
1202
+ if (pending !== undefined) {
1203
+ yield* Fiber.interrupt(pending);
1204
+ self.unloadFibers.delete(file);
1205
+ }
1206
+ const existing = self.records.get(file);
1207
+ if (existing !== undefined) {
1208
+ existing.refs += 1;
1209
+ return;
1210
+ }
1211
+ const url = resolveAssetUrl(file, self.base);
1212
+ const kind = pathKind(file);
1213
+ const value = kind === "audio" ? undefined : yield* self.backend.loadTexture(url).pipe(Effect4.mapError((error) => new AssetMissing({
1214
+ file,
1215
+ message: error instanceof Error ? error.message : String(error)
1216
+ })));
1217
+ yield* kind === "audio" ? self.backend.loadAudio(url).pipe(Effect4.mapError((error) => new AssetMissing({
1218
+ file,
1219
+ message: error instanceof Error ? error.message : String(error)
1220
+ }))) : Effect4.void;
1221
+ self.records.set(file, { file, url, kind, value, refs: 1 });
1222
+ });
1223
+ release = (files) => Effect4.gen({ self: this }, function* () {
1224
+ const self = this;
1225
+ for (const file of files) {
1226
+ const record = self.records.get(file);
1227
+ if (record === undefined)
1228
+ continue;
1229
+ record.refs -= 1;
1230
+ if (record.refs > 0)
1231
+ continue;
1232
+ if (self.graceSeconds === 0) {
1233
+ yield* self.unload(record);
1234
+ continue;
1235
+ }
1236
+ const fiber = yield* Effect4.forkDetach(Effect4.sleep(self.graceSeconds).pipe(Effect4.flatMap(() => self.unload(record))));
1237
+ self.unloadFibers.set(file, fiber);
1238
+ }
1239
+ });
1240
+ unload = (record) => Effect4.gen({ self: this }, function* () {
1241
+ const self = this;
1242
+ const current = self.records.get(record.file);
1243
+ if (current !== record || record.refs > 0)
1244
+ return;
1245
+ self.records.delete(record.file);
1246
+ self.unloadFibers.delete(record.file);
1247
+ if (record.kind === "audio") {
1248
+ yield* self.backend.unloadAudio(record.url);
1249
+ } else if (record.value !== undefined) {
1250
+ yield* self.backend.unloadTexture(record.url, record.value);
1251
+ }
1252
+ });
1253
+ }
1254
+
1255
+ // src/assets/pixiAssetsBackend.ts
1256
+ import { Assets } from "pixi.js";
1257
+ import { Effect as Effect5 } from "effect";
1258
+ import { sound } from "@pixi/sound";
1259
+ var pixiAssetsBackend = {
1260
+ loadTexture: (url) => Effect5.tryPromise(() => Assets.load(url)),
1261
+ unloadTexture: (_url, _texture) => Effect5.void,
1262
+ loadAudio: (url) => Effect5.callback((resume) => {
1263
+ if (sound.exists(url)) {
1264
+ resume(Effect5.succeed(undefined));
1265
+ return;
1266
+ }
1267
+ try {
1268
+ sound.add(url, {
1269
+ url,
1270
+ preload: true,
1271
+ loaded: (error) => resume(error == null ? Effect5.succeed(undefined) : Effect5.fail(error))
1272
+ });
1273
+ } catch (error) {
1274
+ resume(Effect5.fail(error));
1275
+ }
1276
+ }),
1277
+ unloadAudio: (url) => Effect5.sync(() => {
1278
+ if (sound.exists(url, false))
1279
+ sound.remove(url);
1280
+ })
1281
+ };
1282
+
1283
+ // src/audio/pixiAudio.ts
1284
+ import { Deferred as Deferred2, Effect as Effect6, Fiber as Fiber2, Option, SubscriptionRef as SubscriptionRef2 } from "effect";
1285
+
1286
+ class AudioDecodeError extends Error {
1287
+ file;
1288
+ _tag = "AudioDecodeError";
1289
+ constructor(file, cause) {
1290
+ super(`Unable to decode or play audio ${file}: ${cause instanceof Error ? cause.message : String(cause)}`);
1291
+ this.file = file;
1292
+ }
1293
+ }
1294
+ var clamp2 = (value) => Math.max(0, Math.min(1, value));
1295
+ var positive = (value) => Number.isFinite(value) && value > 0 ? value : 0;
1296
+
1297
+ class PixiAudio {
1298
+ unlocked;
1299
+ unlock;
1300
+ backend;
1301
+ logger;
1302
+ active = {
1303
+ music: [],
1304
+ sfx: [],
1305
+ voice: []
1306
+ };
1307
+ currentVolumes;
1308
+ gate = Deferred2.makeUnsafe();
1309
+ constructor(options) {
1310
+ this.backend = options.backend;
1311
+ this.logger = options.logger ?? ((message, error) => console.warn(message, error));
1312
+ this.currentVolumes = options.initialVolumes ?? { master: 1, music: 1, sfx: 1, voice: 1 };
1313
+ this.unlocked = Effect6.runSync(SubscriptionRef2.make(false));
1314
+ const self = this;
1315
+ this.unlock = Effect6.gen(function* () {
1316
+ if (!SubscriptionRef2.getUnsafe(self.unlocked)) {
1317
+ yield* self.backend.unlock.pipe(Effect6.catch((error) => {
1318
+ self.logger("Audio context could not be resumed", error);
1319
+ return Effect6.void;
1320
+ }));
1321
+ yield* SubscriptionRef2.set(self.unlocked, true);
1322
+ yield* Deferred2.succeed(self.gate, undefined);
1323
+ }
1324
+ });
1325
+ }
1326
+ play = (channel, soundToPlay) => Effect6.gen({ self: this }, function* () {
1327
+ const self = this;
1328
+ if (!SubscriptionRef2.getUnsafe(self.unlocked)) {
1329
+ yield* Deferred2.await(self.gate);
1330
+ }
1331
+ if (channel === "music" && !soundToPlay.restart) {
1332
+ const playing = self.active.music.filter((record2) => !record2.stopping).at(-1);
1333
+ if (playing !== undefined && playing.file === soundToPlay.file) {
1334
+ yield* self.changeVolume(playing, clamp2(soundToPlay.volume), soundToPlay.fade);
1335
+ return;
1336
+ }
1337
+ }
1338
+ const loaded = yield* self.backend.load(soundToPlay.file).pipe(Effect6.catch((error) => {
1339
+ self.logger(`Audio file missing or undecodable: ${soundToPlay.file}`, error);
1340
+ return Effect6.fail(error);
1341
+ }), Effect6.option);
1342
+ if (Option.isNone(loaded))
1343
+ return;
1344
+ const inputVolume = clamp2(soundToPlay.volume);
1345
+ if (channel === "voice")
1346
+ yield* self.stopVoiceImmediately();
1347
+ const playback = yield* self.backend.play(soundToPlay.file, {
1348
+ loop: soundToPlay.loop,
1349
+ volume: self.effectiveVolume(channel, inputVolume)
1350
+ }).pipe(Effect6.catch((error) => {
1351
+ self.logger(`Audio playback failed: ${soundToPlay.file}`, error);
1352
+ return Effect6.fail(error);
1353
+ }), Effect6.option);
1354
+ if (Option.isNone(playback))
1355
+ return;
1356
+ if (channel === "music")
1357
+ yield* self.replaceMusic(soundToPlay.fade);
1358
+ const record = {
1359
+ channel,
1360
+ file: soundToPlay.file,
1361
+ inputVolume,
1362
+ factor: positive(soundToPlay.fade) > 0 ? 0 : 1,
1363
+ playback: playback.value,
1364
+ fade: undefined,
1365
+ stopping: false
1366
+ };
1367
+ self.active[channel].push(record);
1368
+ self.applyVolume(record);
1369
+ if (positive(soundToPlay.fade) > 0) {
1370
+ record.fade = yield* self.startFade(record, 1, soundToPlay.fade, false);
1371
+ }
1372
+ if (!soundToPlay.loop) {
1373
+ yield* record.playback.ended;
1374
+ self.removeRecord(record);
1375
+ }
1376
+ });
1377
+ stop = (channel, fade) => Effect6.gen({ self: this }, function* () {
1378
+ const self = this;
1379
+ const records = self.active[channel].slice();
1380
+ if (records.length === 0)
1381
+ return;
1382
+ for (const record of records) {
1383
+ record.stopping = true;
1384
+ yield* self.interruptFade(record);
1385
+ }
1386
+ const duration = positive(fade);
1387
+ if (duration === 0) {
1388
+ for (const record of records) {
1389
+ record.playback.stop();
1390
+ self.removeRecord(record);
1391
+ }
1392
+ return;
1393
+ }
1394
+ yield* Effect6.all(records.map((record) => self.fadeOutAndStop(record, duration)), { concurrency: "unbounded" });
1395
+ });
1396
+ setVolumes = (volumes) => Effect6.sync(() => {
1397
+ this.currentVolumes = {
1398
+ master: clamp2(volumes.master),
1399
+ music: clamp2(volumes.music),
1400
+ sfx: clamp2(volumes.sfx),
1401
+ voice: clamp2(volumes.voice)
1402
+ };
1403
+ for (const channel of ["music", "sfx", "voice"]) {
1404
+ for (const record of this.active[channel])
1405
+ this.applyVolume(record);
1406
+ }
1407
+ });
1408
+ effectiveVolume(channel, inputVolume) {
1409
+ return clamp2(inputVolume * this.currentVolumes.master * this.currentVolumes[channel]);
1410
+ }
1411
+ removeRecord(record) {
1412
+ const records = this.active[record.channel];
1413
+ const index = records.indexOf(record);
1414
+ if (index >= 0)
1415
+ records.splice(index, 1);
1416
+ }
1417
+ interruptFade = (record) => record.fade === undefined ? Effect6.void : Fiber2.interrupt(record.fade).pipe(Effect6.asVoid, Effect6.tap(() => Effect6.sync(() => {
1418
+ record.fade = undefined;
1419
+ })));
1420
+ applyVolume(record) {
1421
+ record.playback.setVolume(this.effectiveVolume(record.channel, record.inputVolume) * record.factor);
1422
+ }
1423
+ startFade = (record, to, duration, stopAtEnd) => {
1424
+ const from = record.factor;
1425
+ return this.ramp(record, duration, (progress) => {
1426
+ record.factor = from + (to - from) * progress;
1427
+ }, stopAtEnd);
1428
+ };
1429
+ ramp = (record, duration, advance, stopAtEnd) => {
1430
+ const steps = Math.max(1, Math.ceil(duration * 20));
1431
+ const interval = duration / steps;
1432
+ const fade = Effect6.forEach(Array.from({ length: steps }, (_, index) => index + 1), (step) => Effect6.sleep(interval).pipe(Effect6.flatMap(() => Effect6.sync(() => {
1433
+ advance(step / steps);
1434
+ this.applyVolume(record);
1435
+ }))), { discard: true }).pipe(Effect6.flatMap(() => Effect6.sync(() => {
1436
+ advance(1);
1437
+ this.applyVolume(record);
1438
+ if (stopAtEnd)
1439
+ record.playback.stop();
1440
+ })));
1441
+ return Effect6.forkDetach(fade);
1442
+ };
1443
+ changeVolume = (record, inputVolume, fade) => Effect6.gen({ self: this }, function* () {
1444
+ const self = this;
1445
+ yield* self.interruptFade(record);
1446
+ const duration = positive(fade);
1447
+ if (duration === 0) {
1448
+ record.inputVolume = inputVolume;
1449
+ record.factor = 1;
1450
+ self.applyVolume(record);
1451
+ return;
1452
+ }
1453
+ const fromVolume = record.inputVolume;
1454
+ const fromFactor = record.factor;
1455
+ record.fade = yield* self.ramp(record, duration, (progress) => {
1456
+ record.inputVolume = fromVolume + (inputVolume - fromVolume) * progress;
1457
+ record.factor = fromFactor + (1 - fromFactor) * progress;
1458
+ }, false);
1459
+ });
1460
+ fadeOutAndStop = (record, duration) => Effect6.gen({ self: this }, function* () {
1461
+ const self = this;
1462
+ const fiber = yield* self.startFade(record, 0, duration, true);
1463
+ yield* Fiber2.join(fiber);
1464
+ self.removeRecord(record);
1465
+ });
1466
+ stopVoiceImmediately = () => Effect6.gen({ self: this }, function* () {
1467
+ const self = this;
1468
+ const records = self.active.voice.slice();
1469
+ for (const record of records) {
1470
+ yield* self.interruptFade(record);
1471
+ record.playback.stop();
1472
+ self.removeRecord(record);
1473
+ }
1474
+ });
1475
+ replaceMusic = (fade) => Effect6.gen({ self: this }, function* () {
1476
+ const self = this;
1477
+ const old = self.active.music.slice();
1478
+ for (const record of old) {
1479
+ record.stopping = true;
1480
+ yield* self.interruptFade(record);
1481
+ if (positive(fade) === 0) {
1482
+ record.playback.stop();
1483
+ self.removeRecord(record);
1484
+ } else {
1485
+ const fiber = yield* self.startFade(record, 0, fade, true);
1486
+ record.fade = fiber;
1487
+ yield* Effect6.forkDetach(Fiber2.join(fiber).pipe(Effect6.flatMap(() => Effect6.sync(() => self.removeRecord(record)))));
1488
+ }
1489
+ }
1490
+ });
1491
+ }
1492
+
1493
+ // src/audio/pixiSoundBackend.ts
1494
+ import { Effect as Effect7 } from "effect";
1495
+ import { sound as sound2, webaudio } from "@pixi/sound";
1496
+ var clamp3 = (value) => Math.max(0, Math.min(1, value));
1497
+ var waitForMediaEnd = (instance) => Effect7.callback((resume) => {
1498
+ const finish = () => resume(Effect7.succeed(undefined));
1499
+ instance.once("end", finish);
1500
+ instance.once("stop", finish);
1501
+ return Effect7.sync(() => {
1502
+ instance.off("end", finish);
1503
+ instance.off("stop", finish);
1504
+ });
1505
+ });
1506
+ var pixiSoundBackend = {
1507
+ load: (file) => Effect7.callback((resume) => {
1508
+ if (sound2.exists(file)) {
1509
+ resume(Effect7.succeed(undefined));
1510
+ return;
1511
+ }
1512
+ try {
1513
+ sound2.add(file, {
1514
+ url: file,
1515
+ preload: true,
1516
+ loaded: (error) => resume(error == null ? Effect7.succeed(undefined) : Effect7.fail(new AudioDecodeError(file, error)))
1517
+ });
1518
+ } catch (error) {
1519
+ resume(Effect7.fail(new AudioDecodeError(file, error)));
1520
+ }
1521
+ }),
1522
+ play: (file, options) => Effect7.tryPromise({
1523
+ try: () => Promise.resolve(sound2.play(file, {
1524
+ loop: options.loop,
1525
+ volume: options.volume
1526
+ })).then((instance) => ({
1527
+ setVolume: (volume) => {
1528
+ instance.volume = clamp3(volume);
1529
+ },
1530
+ stop: () => instance.stop(),
1531
+ ended: options.loop ? Effect7.never : waitForMediaEnd(instance)
1532
+ })),
1533
+ catch: (error) => new AudioDecodeError(file, error)
1534
+ }),
1535
+ unlock: Effect7.tryPromise({
1536
+ try: () => {
1537
+ const context = sound2.context;
1538
+ if (context instanceof webaudio.WebAudioContext && context.audioContext.state === "suspended") {
1539
+ return context.audioContext.resume().then(() => {
1540
+ return;
1541
+ });
1542
+ }
1543
+ return Promise.resolve(undefined);
1544
+ },
1545
+ catch: (error) => new AudioDecodeError("<audio-context>", error)
1546
+ })
1547
+ };
1548
+
1549
+ // src/presenter/pixiPresenter.ts
1550
+ import { Context as Context4, Effect as Effect17, Layer as Layer4, PubSub as PubSub2, Stream as Stream6 } from "effect";
1551
+
1552
+ // src/choice/choiceMenu.ts
1553
+ import { Deferred as Deferred3, Effect as Effect8, Stream as Stream5 } from "effect";
1554
+ import { Container as Container7, Graphics as Graphics7, Text as Text9, TextStyle } from "pixi.js";
1555
+ var isChoiceEnabled = (verdict) => verdict === "open";
1556
+ var choiceNavigation = (options) => {
1557
+ const enabled = options.map((option) => isChoiceEnabled(option.verdict));
1558
+ const first = enabled.findIndex(Boolean);
1559
+ return { ids: options.map((option) => option.id), enabled, focused: first < 0 ? -1 : first };
1560
+ };
1561
+ var navigateChoice = (state, key) => {
1562
+ if (key === "enter")
1563
+ return state.focused >= 0 && state.enabled[state.focused] === true ? state.ids[state.focused] : undefined;
1564
+ if (/^[1-9]$/u.test(key)) {
1565
+ const index = Number(key) - 1;
1566
+ return state.enabled[index] === true ? state.ids[index] : undefined;
1567
+ }
1568
+ if (state.focused < 0 || key !== "up" && key !== "down")
1569
+ return state;
1570
+ const direction = key === "up" ? -1 : 1;
1571
+ let index = state.focused;
1572
+ for (let count = 0;count < state.ids.length; count += 1) {
1573
+ index = (index + direction + state.ids.length) % state.ids.length;
1574
+ if (state.enabled[index] === true)
1575
+ return { ...state, focused: index };
1576
+ }
1577
+ return state;
1578
+ };
1579
+ var hasLabel = (option) => typeof option.text === "string";
1580
+
1581
+ class ChoiceMenu {
1582
+ target;
1583
+ theme;
1584
+ panel;
1585
+ prompt;
1586
+ optionsLayer;
1587
+ countdown;
1588
+ rendered = [];
1589
+ navigation = { ids: [], enabled: [], focused: -1 };
1590
+ deferred;
1591
+ resolveChoice;
1592
+ maxRemaining = 0;
1593
+ remainingValue = 0;
1594
+ pulsePhase = 0;
1595
+ constructor(parent, theme) {
1596
+ this.theme = theme;
1597
+ this.target = new Container7;
1598
+ this.target.label = "ChoiceMenu";
1599
+ this.panel = new Graphics7;
1600
+ this.prompt = new Text9({ text: "", style: new TextStyle({ fontFamily: theme.font, fontSize: 24, fill: theme.text, wordWrap: true, wordWrapWidth: 632 }) });
1601
+ this.optionsLayer = new Container7;
1602
+ this.countdown = new Graphics7;
1603
+ this.target.addChild(this.panel, this.prompt, this.optionsLayer, this.countdown);
1604
+ parent.addChild(this.target);
1605
+ this.target.position.set(300, 0);
1606
+ this.target.eventMode = "static";
1607
+ this.target.on("pointerdown", (event) => {
1608
+ if ("preventDefault" in event.nativeEvent)
1609
+ event.nativeEvent.preventDefault();
1610
+ if ("stopImmediatePropagation" in event.nativeEvent)
1611
+ event.nativeEvent.stopImmediatePropagation();
1612
+ event.stopPropagation();
1613
+ });
1614
+ this.target.on("pointerup", (event) => {
1615
+ if ("preventDefault" in event.nativeEvent)
1616
+ event.nativeEvent.preventDefault();
1617
+ if ("stopImmediatePropagation" in event.nativeEvent)
1618
+ event.nativeEvent.stopImmediatePropagation();
1619
+ event.stopPropagation();
1620
+ });
1621
+ this.target.on("pointertap", (event) => this.pointerAt(event.global.y));
1622
+ if (typeof window !== "undefined")
1623
+ window.addEventListener("keydown", this.keydown);
1624
+ this.target.visible = false;
1625
+ }
1626
+ show = (choice, remaining) => (() => {
1627
+ const self = this;
1628
+ return Effect8.gen(function* () {
1629
+ yield* self.hide;
1630
+ self.target.visible = true;
1631
+ const options = choice.options.filter((option) => option.verdict !== "spent").map((option) => ({ ...option, text: "text" in option && typeof option.text === "string" ? option.text : option.id }));
1632
+ self.navigation = choiceNavigation(options);
1633
+ self.prompt.text = choice.prompt ?? "";
1634
+ self.renderOptions(options);
1635
+ self.deferred = yield* Deferred3.make();
1636
+ const deferred = self.deferred;
1637
+ self.resolveChoice = (id) => {
1638
+ if (self.deferred === deferred)
1639
+ Effect8.runSync(Deferred3.succeed(deferred, id));
1640
+ };
1641
+ yield* Stream5.runForEach(remaining, (seconds) => Effect8.sync(() => {
1642
+ if (self.maxRemaining === 0)
1643
+ self.maxRemaining = Math.max(0, seconds);
1644
+ self.remainingValue = Math.max(0, seconds);
1645
+ self.drawCountdown();
1646
+ })).pipe(Effect8.forkDetach);
1647
+ return yield* Deferred3.await(deferred);
1648
+ }).pipe(Effect8.ensuring(self.hide));
1649
+ })();
1650
+ hide = Effect8.sync(() => {
1651
+ this.target.visible = false;
1652
+ this.resolveChoice = undefined;
1653
+ this.deferred = undefined;
1654
+ this.maxRemaining = 0;
1655
+ this.remainingValue = 0;
1656
+ for (const item of this.rendered) {
1657
+ item.button.removeFromParent();
1658
+ item.button.visible = false;
1659
+ item.label.removeFromParent();
1660
+ item.label.visible = false;
1661
+ }
1662
+ this.rendered.length = 0;
1663
+ this.optionsLayer.removeChildren();
1664
+ });
1665
+ setRemaining = (seconds) => {
1666
+ if (this.maxRemaining === 0)
1667
+ this.maxRemaining = Math.max(0, seconds);
1668
+ this.remainingValue = Math.max(0, seconds);
1669
+ this.drawCountdown();
1670
+ };
1671
+ tick = (dt) => {
1672
+ this.pulsePhase += Math.max(0, dt);
1673
+ if (this.remainingValue < 3)
1674
+ this.drawCountdown();
1675
+ };
1676
+ keydown = (event) => {
1677
+ if (!this.target.visible)
1678
+ return;
1679
+ const key = event.key === "ArrowUp" ? "up" : event.key === "ArrowDown" ? "down" : event.key === "Enter" ? "enter" : event.key;
1680
+ const result = navigateChoice(this.navigation, key);
1681
+ if (typeof result === "string")
1682
+ this.resolveChoice?.(result);
1683
+ else if (result !== undefined) {
1684
+ this.navigation = result;
1685
+ this.updateFocus();
1686
+ }
1687
+ };
1688
+ pointerAt = (y) => {
1689
+ for (const [index, item] of this.rendered.entries()) {
1690
+ if (y >= item.button.y && y <= item.button.y + item.button.height && this.navigation.enabled[index] === true) {
1691
+ this.resolveChoice?.(item.option.id);
1692
+ return;
1693
+ }
1694
+ }
1695
+ };
1696
+ renderOptions = (options) => {
1697
+ const width = 680;
1698
+ this.panel.clear().roundRect(0, 0, width, 460, 12).fill({ color: this.theme.panel, alpha: this.theme.panelAlpha });
1699
+ this.prompt.x = 24;
1700
+ this.prompt.y = 20;
1701
+ options.forEach((option, index) => {
1702
+ const button = new Graphics7;
1703
+ const label = new Text9({
1704
+ text: hasLabel(option) ? option.text : option.id,
1705
+ style: new TextStyle({ fontFamily: this.theme.font, fontSize: 22, fill: this.theme.text })
1706
+ });
1707
+ button.y = 70 + index * 48;
1708
+ button.roundRect(20, 0, width - 40, 38, 6).fill({
1709
+ color: this.theme.accent,
1710
+ alpha: this.navigation.enabled[index] === true ? 0.35 : 0.25
1711
+ });
1712
+ label.x = 32;
1713
+ label.y = button.y + 6;
1714
+ button.eventMode = this.navigation.enabled[index] === true ? "static" : "none";
1715
+ button.on("pointertap", () => {
1716
+ if (this.navigation.enabled[index] === true)
1717
+ this.resolveChoice?.(option.id);
1718
+ });
1719
+ this.optionsLayer.addChild(button, label);
1720
+ this.rendered.push({ option, button, label });
1721
+ });
1722
+ this.updateFocus();
1723
+ this.drawCountdown();
1724
+ };
1725
+ updateFocus = () => {
1726
+ for (const [index, item] of this.rendered.entries()) {
1727
+ const active = index === this.navigation.focused;
1728
+ item.button.alpha = active ? 1 : 0.8;
1729
+ }
1730
+ };
1731
+ drawCountdown = () => {
1732
+ const ratio = this.maxRemaining <= 0 ? 0 : Math.min(1, this.remainingValue / this.maxRemaining);
1733
+ const pulse = this.remainingValue < 3 ? 0.7 + Math.sin(this.pulsePhase * 8) * 0.3 : 1;
1734
+ this.countdown.alpha = pulse;
1735
+ this.countdown.clear().rect(20, 450, 640 * ratio, 6).fill({ color: this.theme.accent, alpha: pulse });
1736
+ };
1737
+ }
1738
+
1739
+ // src/text/dialogueWindow.ts
1740
+ import { Effect as Effect9 } from "effect";
1741
+ import { Container as Container9, Graphics as Graphics8, Text as Text11, TextStyle as TextStyle3 } from "pixi.js";
1742
+
1743
+ // src/text/segments.ts
1744
+ var segmenter = (locale) => new Intl.Segmenter(locale || undefined, { granularity: "grapheme" });
1745
+ var buildRevealTimeline = (segments, charactersPerSecond, locale = "") => {
1746
+ const graphemes = [];
1747
+ const pauses = [];
1748
+ let time = 0;
1749
+ const speed = Math.max(0, charactersPerSecond);
1750
+ const iterator = segmenter(locale);
1751
+ segments.forEach((segment, segmentIndex) => {
1752
+ if (segment._tag === "Pause") {
1753
+ const seconds = Math.max(0, segment.seconds);
1754
+ pauses.push({ segment: segmentIndex, start: time, end: time + seconds });
1755
+ time += seconds;
1756
+ return;
1757
+ }
1758
+ const rate = segment.rate === undefined ? 1 : Math.max(0, segment.rate);
1759
+ const secondsPerGrapheme = speed === 0 || rate === 0 ? 0 : 1 / (speed * rate);
1760
+ for (const part of iterator.segment(segment.text)) {
1761
+ const start = time;
1762
+ time += secondsPerGrapheme;
1763
+ graphemes.push({
1764
+ grapheme: part.segment,
1765
+ styles: segment.styles,
1766
+ segment: segmentIndex,
1767
+ start,
1768
+ end: time
1769
+ });
1770
+ }
1771
+ });
1772
+ return { graphemes, pauses, duration: time, locale };
1773
+ };
1774
+
1775
+ // src/text/richText.ts
1776
+ import {
1777
+ CanvasTextMetrics,
1778
+ Text as Text10,
1779
+ TextStyle as TextStyle2
1780
+ } from "pixi.js";
1781
+ var noStart = new Set(["、", "。", "!", "?", "」", "』", ")", "】", "〉", "》", "〕", "]", "}"]);
1782
+ var noEnd = new Set(["「", "『", "(", "【", "〈", "《", "〔", "[", "{"]);
1783
+ var mapTextStyle = (names, bindings, base = {}) => {
1784
+ const result = { ...base };
1785
+ for (const name of names) {
1786
+ const binding = bindings[name];
1787
+ if (binding !== undefined)
1788
+ Object.assign(result, binding);
1789
+ }
1790
+ return result;
1791
+ };
1792
+
1793
+ // src/text/dialogueWindow.ts
1794
+ var textStyle = (font, fill, size, italic = false) => new TextStyle3({ fontFamily: font, fontSize: size, fill, fontStyle: italic ? "italic" : "normal" });
1795
+ var rolePrefix = (role) => role === "os" ? "(" : "";
1796
+ var roleSuffix = (role) => role === "os" ? ")" : "";
1797
+
1798
+ class DialogueWindow {
1799
+ target;
1800
+ panel;
1801
+ namePlate;
1802
+ nameText;
1803
+ textArea;
1804
+ indicator;
1805
+ world;
1806
+ theme;
1807
+ options;
1808
+ graphemeTexts = [];
1809
+ timeline = { graphemes: [], pauses: [], duration: 0, locale: "" };
1810
+ currentLine;
1811
+ revealed = false;
1812
+ panelOpacity = 1;
1813
+ constructor(parent, world, theme, options = {}) {
1814
+ this.world = world;
1815
+ this.theme = theme;
1816
+ this.options = options;
1817
+ this.target = new Container9;
1818
+ this.target.label = "DialogueWindow";
1819
+ this.panel = new Graphics8;
1820
+ this.namePlate = new Container9;
1821
+ this.nameText = new Text11({ text: "", style: textStyle(theme.font, theme.text, options.fontSize ?? 24) });
1822
+ this.textArea = new Container9;
1823
+ this.indicator = new Text11({ text: "▼", style: textStyle(theme.font, theme.accent, 18) });
1824
+ this.indicator.visible = false;
1825
+ this.namePlate.addChild(this.nameText);
1826
+ this.target.addChild(this.panel, this.namePlate, this.textArea, this.indicator);
1827
+ parent.addChild(this.target);
1828
+ this.target.visible = false;
1829
+ this.relayout({ width: 1280, height: 720 });
1830
+ }
1831
+ show = (line, settings, mode) => {
1832
+ const self = this;
1833
+ return Effect9.gen(function* () {
1834
+ yield* self.world.cancel(self.target);
1835
+ self.target.visible = true;
1836
+ self.currentLine = line;
1837
+ self.timeline = buildRevealTimeline(line.segments, settings.textSpeed, settings.locale);
1838
+ self.revealed = mode === "skip" || self.timeline.duration === 0;
1839
+ self.indicator.visible = false;
1840
+ self.namePlate.visible = line.speakerName.length > 0;
1841
+ const role = line.role;
1842
+ const displayName = `${rolePrefix(role)}${line.speakerName}${roleSuffix(role)}`;
1843
+ self.nameText.text = displayName;
1844
+ self.nameText.style = textStyle(self.theme.font, self.options.nameColors?.[line.speaker] ?? self.theme.accent, self.options.fontSize ?? 24, role === "mono" || role === "vo");
1845
+ self.clearTextChildren();
1846
+ self.buildTextChildren(line, settings);
1847
+ self.setOpacity(settings.windowOpacity);
1848
+ if (line.read && settings.readColour)
1849
+ self.tintText(self.theme.readText);
1850
+ else
1851
+ self.tintText(self.theme.text);
1852
+ const behaviour = self.typewriterBehaviour();
1853
+ if (self.revealed)
1854
+ behaviour.finish();
1855
+ yield* self.world.run(self.target, behaviour);
1856
+ self.indicator.visible = true;
1857
+ });
1858
+ };
1859
+ get reveal() {
1860
+ const self = this;
1861
+ return Effect9.gen(function* () {
1862
+ yield* self.world.finishAll(self.target);
1863
+ self.revealed = true;
1864
+ self.indicator.visible = self.currentLine !== undefined;
1865
+ });
1866
+ }
1867
+ get clear() {
1868
+ const self = this;
1869
+ return Effect9.gen(function* () {
1870
+ yield* self.world.cancel(self.target);
1871
+ self.target.visible = true;
1872
+ self.currentLine = undefined;
1873
+ self.timeline = { graphemes: [], pauses: [], duration: 0, locale: "" };
1874
+ self.clearTextChildren();
1875
+ self.nameText.text = "";
1876
+ self.namePlate.visible = false;
1877
+ self.indicator.visible = false;
1878
+ });
1879
+ }
1880
+ setOpacity = (alpha) => {
1881
+ this.panelOpacity = Math.min(1, Math.max(0, alpha));
1882
+ this.panel.alpha = this.panelOpacity;
1883
+ };
1884
+ setHidden = (hidden) => {
1885
+ this.target.visible = !hidden;
1886
+ };
1887
+ relayout = (design) => {
1888
+ const width = this.options.boxWidth ?? Math.min(1180, design.width - 100);
1889
+ const height = this.options.boxHeight ?? 190;
1890
+ const x = (design.width - width) / 2;
1891
+ const y = design.height - height - 30;
1892
+ this.panel.clear().roundRect(x, y, width, height, 18).fill({ color: this.theme.panel, alpha: this.panelOpacity * this.theme.panelAlpha });
1893
+ const existingPlate = this.namePlate.children[1];
1894
+ const plate = existingPlate instanceof Graphics8 ? existingPlate : new Graphics8;
1895
+ if (!(existingPlate instanceof Graphics8))
1896
+ this.namePlate.addChild(plate);
1897
+ plate.clear().roundRect(0, 0, 190, 38, 10).fill({ color: this.theme.panel, alpha: 0.92 });
1898
+ this.namePlate.x = x + 26;
1899
+ this.namePlate.y = y - 40;
1900
+ this.nameText.x = 14;
1901
+ this.nameText.y = 5;
1902
+ this.textArea.x = x + 28;
1903
+ this.textArea.y = y + 52;
1904
+ this.indicator.x = x + width - 42;
1905
+ this.indicator.y = y + height - 36;
1906
+ };
1907
+ clearTextChildren = () => {
1908
+ for (const child of this.textArea.removeChildren())
1909
+ child.visible = false;
1910
+ this.graphemeTexts.length = 0;
1911
+ };
1912
+ buildTextChildren = (line, settings) => {
1913
+ const fontSize = this.options.fontSize ?? 24;
1914
+ const byStyle = [];
1915
+ for (const segment of line.segments) {
1916
+ if (segment._tag !== "Text")
1917
+ continue;
1918
+ const previous = byStyle[byStyle.length - 1];
1919
+ if (previous !== undefined && previous.styles.length === segment.styles.length && previous.styles.every((s, i) => s === segment.styles[i])) {
1920
+ previous.text += segment.text;
1921
+ } else
1922
+ byStyle.push({ styles: segment.styles, text: segment.text });
1923
+ }
1924
+ for (const run of byStyle) {
1925
+ const style = mapTextStyle(run.styles, this.options.textStyles ?? {}, {
1926
+ fontFamily: this.theme.font,
1927
+ fontSize,
1928
+ color: this.theme.text
1929
+ });
1930
+ const text = new Text11({
1931
+ text: "",
1932
+ style: {
1933
+ ...textStyle(style.fontFamily ?? this.theme.font, typeof style.color === "number" ? style.color : this.theme.text, style.fontSize ?? fontSize, style.fontStyle === "italic"),
1934
+ wordWrap: true,
1935
+ wordWrapWidth: 1110,
1936
+ breakWords: true
1937
+ }
1938
+ });
1939
+ text.alpha = style.alpha ?? 1;
1940
+ text.scale.set(style.scale ?? 1);
1941
+ this.textArea.addChild(text);
1942
+ const count = Array.from(new Intl.Segmenter(settings.locale || undefined, { granularity: "grapheme" }).segment(run.text)).length;
1943
+ for (let i = 0;i < count; i += 1)
1944
+ this.graphemeTexts.push(text);
1945
+ }
1946
+ };
1947
+ tintText = (colour) => {
1948
+ for (const text of this.textArea.children) {
1949
+ if (text instanceof Text11)
1950
+ text.style.fill = colour;
1951
+ }
1952
+ };
1953
+ typewriterBehaviour = () => {
1954
+ let elapsed = 0;
1955
+ let revealedCount = 0;
1956
+ return {
1957
+ update: (dt) => {
1958
+ elapsed += Math.max(0, dt);
1959
+ while (revealedCount < this.timeline.graphemes.length) {
1960
+ const item = this.timeline.graphemes[revealedCount];
1961
+ if (item === undefined || item.end > elapsed)
1962
+ break;
1963
+ const text = this.graphemeTexts[revealedCount];
1964
+ if (text !== undefined)
1965
+ text.text += item.grapheme;
1966
+ revealedCount += 1;
1967
+ }
1968
+ if (revealedCount >= this.timeline.graphemes.length && elapsed >= this.timeline.duration) {
1969
+ this.revealed = true;
1970
+ return "done";
1971
+ }
1972
+ return "running";
1973
+ },
1974
+ finish: () => {
1975
+ for (let index = revealedCount;index < this.timeline.graphemes.length; index += 1) {
1976
+ const item = this.timeline.graphemes[index];
1977
+ const text = this.graphemeTexts[index];
1978
+ if (item !== undefined && text !== undefined)
1979
+ text.text += item.grapheme;
1980
+ }
1981
+ revealedCount = this.timeline.graphemes.length;
1982
+ elapsed = this.timeline.duration;
1983
+ this.revealed = true;
1984
+ },
1985
+ dispose: () => {
1986
+ return;
1987
+ }
1988
+ };
1989
+ };
1990
+ }
1991
+
1992
+ // src/scene/actor.ts
1993
+ import { Effect as Effect11 } from "effect";
1994
+ import { Container as Container13, Sprite as Sprite4 } from "pixi.js";
1995
+
1996
+ // src/effects/catalogue.ts
1997
+ import { Effect as Effect10, Schema } from "effect";
1998
+ import { BlurFilter } from "pixi.js";
1999
+
2000
+ // src/engine/behaviours.ts
2001
+ import { Graphics as Graphics9 } from "pixi.js";
2002
+
2003
+ // src/engine/easing.ts
2004
+ var clamp4 = (progress) => Math.min(1, Math.max(0, progress));
2005
+ var linear = (progress) => clamp4(progress);
2006
+ var quadOut = (progress) => {
2007
+ const value = clamp4(progress);
2008
+ return 1 - (1 - value) * (1 - value);
2009
+ };
2010
+
2011
+ // src/engine/behaviours.ts
2012
+ var pool = [];
2013
+ var applyTween = (target, props, starts, to, p) => {
2014
+ if (to.x !== undefined && starts.x !== undefined)
2015
+ target.x = starts.x + (to.x - starts.x) * p;
2016
+ if (to.y !== undefined && starts.y !== undefined)
2017
+ target.y = starts.y + (to.y - starts.y) * p;
2018
+ if (to.alpha !== undefined && starts.alpha !== undefined)
2019
+ target.alpha = starts.alpha + (to.alpha - starts.alpha) * p;
2020
+ if (to.scaleX !== undefined && starts.scaleX !== undefined)
2021
+ target.scale.x = starts.scaleX + (to.scaleX - starts.scaleX) * p;
2022
+ if (to.scaleY !== undefined && starts.scaleY !== undefined)
2023
+ target.scale.y = starts.scaleY + (to.scaleY - starts.scaleY) * p;
2024
+ if (to.rotation !== undefined && starts.rotation !== undefined)
2025
+ target.rotation = starts.rotation + (to.rotation - starts.rotation) * p;
2026
+ if (to.tint !== undefined && starts.tint !== undefined)
2027
+ target.tint = Math.round(starts.tint + (to.tint - starts.tint) * p);
2028
+ };
2029
+
2030
+ class TweenBehaviour {
2031
+ target;
2032
+ props;
2033
+ starts;
2034
+ to;
2035
+ duration = 0;
2036
+ elapsed = 0;
2037
+ easing = linear;
2038
+ pooled = false;
2039
+ update(dt) {
2040
+ this.elapsed = Math.min(this.duration, this.elapsed + Math.max(0, dt));
2041
+ const p = this.duration <= 0 ? 1 : this.easing(this.elapsed / this.duration);
2042
+ applyTween(this.target, this.props, this.starts, this.to, p);
2043
+ return this.elapsed >= this.duration ? "done" : "running";
2044
+ }
2045
+ finish() {
2046
+ applyTween(this.target, this.props, this.starts, this.to, 1);
2047
+ this.elapsed = this.duration;
2048
+ }
2049
+ dispose() {
2050
+ if (this.pooled)
2051
+ return;
2052
+ this.pooled = true;
2053
+ pool.push(this);
2054
+ }
2055
+ }
2056
+ var tween = (target, props, to, duration, easing = linear) => {
2057
+ const value = pool.pop() ?? new TweenBehaviour;
2058
+ value.target = target;
2059
+ value.props = props;
2060
+ const starts = {};
2061
+ if (props.x !== undefined)
2062
+ starts.x = target.x;
2063
+ if (props.y !== undefined)
2064
+ starts.y = target.y;
2065
+ if (props.alpha !== undefined)
2066
+ starts.alpha = target.alpha;
2067
+ if (props.scaleX !== undefined)
2068
+ starts.scaleX = target.scale.x;
2069
+ if (props.scaleY !== undefined)
2070
+ starts.scaleY = target.scale.y;
2071
+ if (props.rotation !== undefined)
2072
+ starts.rotation = target.rotation;
2073
+ if (props.tint !== undefined)
2074
+ starts.tint = target.tint;
2075
+ value.starts = starts;
2076
+ value.to = to;
2077
+ value.duration = Math.max(0, duration);
2078
+ value.elapsed = 0;
2079
+ value.easing = easing;
2080
+ value.pooled = false;
2081
+ return value;
2082
+ };
2083
+ var fade = (target, to, duration) => tween(target, { alpha: target.alpha }, { alpha: to }, duration, linear);
2084
+
2085
+ class CrossfadeBehaviour {
2086
+ from;
2087
+ to;
2088
+ fromStart = 1;
2089
+ toStart = 0;
2090
+ duration = 0;
2091
+ elapsed = 0;
2092
+ pooled = false;
2093
+ update(dt) {
2094
+ this.elapsed = Math.min(this.duration, this.elapsed + Math.max(0, dt));
2095
+ const p = this.duration <= 0 ? 1 : this.elapsed / this.duration;
2096
+ this.from.alpha = this.fromStart * (1 - p);
2097
+ this.to.alpha = this.toStart + (1 - this.toStart) * p;
2098
+ return this.elapsed >= this.duration ? "done" : "running";
2099
+ }
2100
+ finish() {
2101
+ this.from.alpha = 0;
2102
+ this.to.alpha = 1;
2103
+ this.elapsed = this.duration;
2104
+ }
2105
+ dispose() {
2106
+ if (this.pooled)
2107
+ return;
2108
+ this.pooled = true;
2109
+ crossfadePool.push(this);
2110
+ }
2111
+ }
2112
+ var crossfadePool = [];
2113
+ var crossfade = (from, to, duration) => {
2114
+ const value = crossfadePool.pop() ?? new CrossfadeBehaviour;
2115
+ value.from = from;
2116
+ value.to = to;
2117
+ value.fromStart = from.alpha;
2118
+ value.toStart = to.alpha;
2119
+ value.duration = Math.max(0, duration);
2120
+ value.elapsed = 0;
2121
+ value.pooled = false;
2122
+ return value;
2123
+ };
2124
+
2125
+ class ShakeBehaviour {
2126
+ target;
2127
+ baseX = 0;
2128
+ baseY = 0;
2129
+ strength = 0;
2130
+ duration = 0;
2131
+ frequency = 0;
2132
+ elapsed = 0;
2133
+ pooled = false;
2134
+ update(dt) {
2135
+ this.elapsed = Math.min(this.duration, this.elapsed + Math.max(0, dt));
2136
+ if (this.elapsed >= this.duration) {
2137
+ this.target.x = this.baseX;
2138
+ this.target.y = this.baseY;
2139
+ return "done";
2140
+ }
2141
+ const fadeOut = 1 - this.elapsed / this.duration;
2142
+ const angle = this.elapsed * this.frequency * Math.PI * 2;
2143
+ this.target.x = this.baseX + Math.sin(angle * 1.13) * this.strength * fadeOut;
2144
+ this.target.y = this.baseY + Math.cos(angle * 0.97) * this.strength * fadeOut;
2145
+ return "running";
2146
+ }
2147
+ finish() {
2148
+ this.elapsed = this.duration;
2149
+ this.target.x = this.baseX;
2150
+ this.target.y = this.baseY;
2151
+ }
2152
+ dispose() {
2153
+ if (this.pooled)
2154
+ return;
2155
+ this.pooled = true;
2156
+ shakePool.push(this);
2157
+ }
2158
+ }
2159
+ var shakePool = [];
2160
+ var shake = (target, strength, duration, frequency = 18) => {
2161
+ const value = shakePool.pop() ?? new ShakeBehaviour;
2162
+ value.target = target;
2163
+ value.baseX = target.x;
2164
+ value.baseY = target.y;
2165
+ value.strength = strength;
2166
+ value.duration = Math.max(0, duration);
2167
+ value.frequency = Math.max(0, frequency);
2168
+ value.elapsed = 0;
2169
+ value.pooled = false;
2170
+ return value;
2171
+ };
2172
+
2173
+ class HopBehaviour {
2174
+ target;
2175
+ baseY = 0;
2176
+ height = 0;
2177
+ duration = 0;
2178
+ elapsed = 0;
2179
+ pooled = false;
2180
+ update(dt) {
2181
+ this.elapsed = Math.min(this.duration, this.elapsed + Math.max(0, dt));
2182
+ const p = this.duration <= 0 ? 1 : this.elapsed / this.duration;
2183
+ this.target.y = this.baseY - Math.sin(Math.PI * p) * this.height;
2184
+ return this.elapsed >= this.duration ? "done" : "running";
2185
+ }
2186
+ finish() {
2187
+ this.target.y = this.baseY;
2188
+ this.elapsed = this.duration;
2189
+ }
2190
+ dispose() {
2191
+ if (this.pooled)
2192
+ return;
2193
+ this.pooled = true;
2194
+ hopPool.push(this);
2195
+ }
2196
+ }
2197
+ var hopPool = [];
2198
+ var hop = (target, height, duration) => {
2199
+ const value = hopPool.pop() ?? new HopBehaviour;
2200
+ value.target = target;
2201
+ value.baseY = target.y;
2202
+ value.height = height;
2203
+ value.duration = Math.max(0, duration);
2204
+ value.elapsed = 0;
2205
+ value.pooled = false;
2206
+ return value;
2207
+ };
2208
+ var slide = (target, fromX, toX, duration) => {
2209
+ target.x = fromX;
2210
+ return tween(target, { x: fromX }, { x: toX }, duration, quadOut);
2211
+ };
2212
+
2213
+ class WipeBehaviour {
2214
+ target;
2215
+ maskGraphic;
2216
+ direction = "left";
2217
+ reveal = true;
2218
+ width = 1;
2219
+ height = 1;
2220
+ duration = 0;
2221
+ elapsed = 0;
2222
+ pooled = false;
2223
+ updateMask(progress) {
2224
+ const p = this.reveal ? progress : 1 - progress;
2225
+ const x = this.direction === "right" ? this.width * (1 - p) : 0;
2226
+ const y = this.direction === "down" ? this.height * (1 - p) : 0;
2227
+ const width = this.direction === "left" || this.direction === "right" ? this.width * p : this.width;
2228
+ const height = this.direction === "up" || this.direction === "down" ? this.height * p : this.height;
2229
+ this.maskGraphic.clear().rect(x, y, Math.max(0, width), Math.max(0, height)).fill(16777215);
2230
+ }
2231
+ update(dt) {
2232
+ this.elapsed = Math.min(this.duration, this.elapsed + Math.max(0, dt));
2233
+ this.updateMask(this.duration <= 0 ? 1 : this.elapsed / this.duration);
2234
+ return this.elapsed >= this.duration ? "done" : "running";
2235
+ }
2236
+ finish() {
2237
+ this.elapsed = this.duration;
2238
+ this.updateMask(1);
2239
+ }
2240
+ dispose() {
2241
+ if (this.pooled)
2242
+ return;
2243
+ this.target.mask = null;
2244
+ this.maskGraphic.clear();
2245
+ this.pooled = true;
2246
+ wipePool.push(this);
2247
+ }
2248
+ }
2249
+ var wipePool = [];
2250
+ var wipe = (target, direction, duration, reveal = true) => {
2251
+ const value = wipePool.pop() ?? new WipeBehaviour;
2252
+ value.target = target;
2253
+ value.maskGraphic = value.maskGraphic ?? new Graphics9;
2254
+ value.direction = direction;
2255
+ value.reveal = reveal;
2256
+ value.width = Math.max(1, target.width);
2257
+ value.height = Math.max(1, target.height);
2258
+ value.duration = Math.max(0, duration);
2259
+ value.elapsed = 0;
2260
+ value.pooled = false;
2261
+ target.mask = value.maskGraphic;
2262
+ value.updateMask(0);
2263
+ return value;
2264
+ };
2265
+
2266
+ class FlashBehaviour {
2267
+ target;
2268
+ colour = 16777215;
2269
+ duration = 0;
2270
+ elapsed = 0;
2271
+ pooled = false;
2272
+ update(dt) {
2273
+ this.elapsed = Math.min(this.duration, this.elapsed + Math.max(0, dt));
2274
+ this.target.alpha = this.duration <= 0 ? 0 : 1 - this.elapsed / this.duration;
2275
+ return this.elapsed >= this.duration ? "done" : "running";
2276
+ }
2277
+ finish() {
2278
+ this.elapsed = this.duration;
2279
+ this.target.alpha = 0;
2280
+ }
2281
+ dispose() {
2282
+ if (this.pooled)
2283
+ return;
2284
+ this.pooled = true;
2285
+ flashPool.push(this);
2286
+ }
2287
+ }
2288
+ var flashPool = [];
2289
+ var flash = (overlay, colour, duration) => {
2290
+ const value = flashPool.pop() ?? new FlashBehaviour;
2291
+ value.target = overlay;
2292
+ value.colour = colour;
2293
+ value.duration = Math.max(0, duration);
2294
+ value.elapsed = 0;
2295
+ value.pooled = false;
2296
+ overlay.tint = colour;
2297
+ overlay.alpha = 1;
2298
+ return value;
2299
+ };
2300
+
2301
+ // src/effects/catalogue.ts
2302
+ class EffectDecodeError extends Schema.TaggedError()("EffectDecodeError", {
2303
+ effect: Schema.String,
2304
+ parameter: Schema.String,
2305
+ message: Schema.String
2306
+ }) {
2307
+ }
2308
+
2309
+ class UnknownEffect extends Schema.TaggedError()("UnknownEffect", { name: Schema.String }) {
2310
+ }
2311
+
2312
+ class UnknownEffectParameter extends Schema.TaggedError()("UnknownEffectParameter", {
2313
+ effect: Schema.String,
2314
+ parameter: Schema.String
2315
+ }) {
2316
+ }
2317
+
2318
+ class InvalidEffectParameter extends Schema.TaggedError()("InvalidEffectParameter", {
2319
+ effect: Schema.String,
2320
+ parameter: Schema.String,
2321
+ message: Schema.String
2322
+ }) {
2323
+ }
2324
+ var accepted = {
2325
+ fade: ["duration"],
2326
+ dissolve: ["duration"],
2327
+ wipeLeft: ["duration"],
2328
+ wipeRight: ["duration"],
2329
+ wipeUp: ["duration"],
2330
+ wipeDown: ["duration"],
2331
+ slideLeft: ["duration"],
2332
+ slideRight: ["duration"],
2333
+ cut: [],
2334
+ fadeIn: ["duration"],
2335
+ fadeOut: ["duration"],
2336
+ slideIn: ["duration"],
2337
+ slideOut: ["duration"],
2338
+ hop: ["duration"],
2339
+ shake: ["duration", "strength"],
2340
+ none: [],
2341
+ zoom: ["duration", "scale"],
2342
+ pan: ["duration", "x", "y"],
2343
+ flash: ["duration", "color"],
2344
+ blur: ["duration", "strength"],
2345
+ tint: ["duration", "color"]
2346
+ };
2347
+ var numberSchema = Schema.Number;
2348
+ var stringSchema = Schema.String;
2349
+ var decodeNumber = (effect, parameter, value) => typeof value !== "number" ? Effect10.fail(new EffectDecodeError({ effect, parameter, message: "expected a number" })) : Schema.decodeUnknownEffect(numberSchema)(value).pipe(Effect10.mapError(() => new EffectDecodeError({ effect, parameter, message: "expected a number" })));
2350
+ var decodeString = (effect, parameter, value) => typeof value !== "string" ? Effect10.fail(new EffectDecodeError({ effect, parameter, message: "expected a string" })) : Schema.decodeUnknownEffect(stringSchema)(value).pipe(Effect10.mapError(() => new EffectDecodeError({ effect, parameter, message: "expected a string" })));
2351
+ var parse = (name, params) => Effect10.gen(function* () {
2352
+ const allowed = accepted[name];
2353
+ for (const parameter of Object.keys(params)) {
2354
+ if (!allowed.includes(parameter)) {
2355
+ return yield* Effect10.fail(new EffectDecodeError({ effect: name, parameter, message: "unknown parameter" }));
2356
+ }
2357
+ }
2358
+ for (const [parameter, value] of Object.entries(params)) {
2359
+ if (parameter === "duration" || parameter === "strength" || parameter === "scale" || parameter === "x" || parameter === "y") {
2360
+ yield* decodeNumber(name, parameter, value);
2361
+ } else if (parameter === "color") {
2362
+ yield* decodeString(name, parameter, value);
2363
+ }
2364
+ }
2365
+ });
2366
+ var valueNumber = (effect, params, parameter, fallback) => {
2367
+ const value = params[parameter];
2368
+ return value === undefined ? Effect10.succeed(fallback) : decodeNumber(effect, parameter, value);
2369
+ };
2370
+ var colour = (effect, params) => decodeString(effect, "color", params.color ?? "#ffffff").pipe(Effect10.flatMap((source) => {
2371
+ const trimmed = source.trim();
2372
+ const text = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed;
2373
+ return /^[0-9a-fA-F]{6}$/.test(text) ? Effect10.succeed(Number.parseInt(text, 16)) : Effect10.fail(new EffectDecodeError({ effect, parameter: "color", message: "expected #RRGGBB" }));
2374
+ }));
2375
+ var dimension = (target, ctx) => Math.max(1, ctx.design?.width ?? target.width, ctx.design?.height ?? target.height);
2376
+
2377
+ class BlurBehaviour {
2378
+ filter;
2379
+ target;
2380
+ end = 0;
2381
+ duration = 0;
2382
+ elapsed = 0;
2383
+ constructor(target, strength, duration) {
2384
+ this.target = target;
2385
+ this.filter = new BlurFilter({ strength: 0 });
2386
+ this.end = strength;
2387
+ this.duration = Math.max(0, duration);
2388
+ this.target.filters = [this.filter];
2389
+ }
2390
+ update(dt) {
2391
+ this.elapsed = Math.min(this.duration, this.elapsed + Math.max(0, dt));
2392
+ const progress = this.duration <= 0 ? 1 : this.elapsed / this.duration;
2393
+ this.filter.strength = this.end * progress;
2394
+ return this.elapsed >= this.duration ? "done" : "running";
2395
+ }
2396
+ finish() {
2397
+ this.elapsed = this.duration;
2398
+ this.filter.strength = this.end;
2399
+ }
2400
+ dispose() {
2401
+ this.filter.strength = this.end;
2402
+ }
2403
+ }
2404
+ var parseName = (name) => {
2405
+ switch (name) {
2406
+ case "fade":
2407
+ case "dissolve":
2408
+ case "wipeLeft":
2409
+ case "wipeRight":
2410
+ case "wipeUp":
2411
+ case "wipeDown":
2412
+ case "slideLeft":
2413
+ case "slideRight":
2414
+ case "cut":
2415
+ case "fadeIn":
2416
+ case "fadeOut":
2417
+ case "slideIn":
2418
+ case "slideOut":
2419
+ case "hop":
2420
+ case "shake":
2421
+ case "none":
2422
+ case "zoom":
2423
+ case "pan":
2424
+ case "flash":
2425
+ case "blur":
2426
+ case "tint":
2427
+ return Effect10.succeed(name);
2428
+ default:
2429
+ return Effect10.fail(new EffectDecodeError({ effect: name, parameter: "", message: "unknown effect" }));
2430
+ }
2431
+ };
2432
+ var wipeDirection = (name) => {
2433
+ switch (name) {
2434
+ case "wipeLeft":
2435
+ return "left";
2436
+ case "wipeRight":
2437
+ return "right";
2438
+ case "wipeUp":
2439
+ return "up";
2440
+ case "wipeDown":
2441
+ return "down";
2442
+ default:
2443
+ return "left";
2444
+ }
2445
+ };
2446
+ var build = (name, target, params = {}, duration = 0, ctx = {}) => Effect10.gen(function* () {
2447
+ const effect = yield* parseName(name);
2448
+ yield* parse(effect, params);
2449
+ const seconds = Math.max(0, duration);
2450
+ switch (effect) {
2451
+ case "fade":
2452
+ case "dissolve":
2453
+ return [fade(target, 0, seconds)];
2454
+ case "wipeLeft":
2455
+ case "wipeRight":
2456
+ case "wipeUp":
2457
+ case "wipeDown":
2458
+ return [wipe(target, wipeDirection(effect), seconds, true)];
2459
+ case "slideLeft":
2460
+ return [slide(target, target.x + dimension(target, ctx), target.x, seconds)];
2461
+ case "slideRight":
2462
+ return [slide(target, target.x - dimension(target, ctx), target.x, seconds)];
2463
+ case "cut":
2464
+ case "none":
2465
+ return [];
2466
+ case "fadeIn":
2467
+ return [fade(target, 1, seconds)];
2468
+ case "fadeOut":
2469
+ return [fade(target, 0, seconds)];
2470
+ case "slideIn":
2471
+ return [slide(target, target.x + dimension(target, ctx), target.x, seconds)];
2472
+ case "slideOut":
2473
+ return [slide(target, target.x, target.x - dimension(target, ctx), seconds)];
2474
+ case "hop":
2475
+ return [hop(target, dimension(target, ctx) * 0.08, seconds)];
2476
+ case "shake":
2477
+ return [shake(target, yield* valueNumber(effect, params, "strength", 12), seconds)];
2478
+ case "zoom": {
2479
+ const scale = yield* valueNumber(effect, params, "scale", 1);
2480
+ return [tween(target, { scaleX: target.scale.x, scaleY: target.scale.y }, { scaleX: scale, scaleY: scale }, seconds, linear)];
2481
+ }
2482
+ case "pan": {
2483
+ const toX = yield* valueNumber(effect, params, "x", target.x);
2484
+ const toY = yield* valueNumber(effect, params, "y", target.y);
2485
+ return [tween(target, { x: target.x, y: target.y }, { x: toX, y: toY }, seconds, linear)];
2486
+ }
2487
+ case "flash":
2488
+ return [flash(ctx.overlay ?? target, yield* colour(effect, params), seconds)];
2489
+ case "blur":
2490
+ return [new BlurBehaviour(target, yield* valueNumber(effect, params, "strength", 8), seconds)];
2491
+ case "tint":
2492
+ return [tween(target, { tint: target.tint }, { tint: yield* colour(effect, params) }, seconds, linear)];
2493
+ }
2494
+ });
2495
+ var EffectCatalogue = { build };
2496
+
2497
+ // src/scene/shared.ts
2498
+ import { Container as Container12, Graphics as Graphics10, Text as Text12, TextStyle as TextStyle4 } from "pixi.js";
2499
+ var assetFile = (bindings, assetId) => assetId === undefined ? undefined : bindings.assets?.[assetId] ?? assetId;
2500
+ var finite = (value, fallback) => value !== null && value !== undefined && Number.isFinite(value) ? value : fallback;
2501
+ var textureFor = (assets, file) => file === undefined ? undefined : assets.texture(file);
2502
+ var placeholder = (parent, name, theme, width, height) => {
2503
+ const target = new Container12;
2504
+ target.label = `Placeholder:${name}`;
2505
+ const hash = Array.from(name).reduce((sum, char) => sum * 33 + char.charCodeAt(0) & 16777215, 0);
2506
+ const block = new Graphics10().rect(0, 0, Math.max(1, width), Math.max(1, height)).fill({ color: hash || theme.accent, alpha: 0.8 });
2507
+ const text = new Text12({ text: name, style: new TextStyle4({ fontFamily: theme.font, fontSize: Math.max(12, Math.min(32, height / 5)), fill: theme.text }) });
2508
+ text.anchor?.set?.(0.5);
2509
+ text.x = width / 2;
2510
+ text.y = height / 2;
2511
+ target.addChild(block, text);
2512
+ parent.addChild(target);
2513
+ return target;
2514
+ };
2515
+ var spriteTexture = (bindings, actor) => {
2516
+ const character = bindings.characters?.[actor.character];
2517
+ if (character === undefined)
2518
+ return;
2519
+ const sprite = actor.costume === undefined || actor.costume === null ? character.sprite : character.costumes?.[actor.costume] ?? character.sprite;
2520
+ if (actor.emotion !== undefined && actor.emotion !== null)
2521
+ return sprite.emotions?.[actor.emotion] ?? sprite.default;
2522
+ return sprite.default;
2523
+ };
2524
+ var anchorPlacement = (anchor, design, texture) => {
2525
+ const scale = finite(anchor?.scale, 1);
2526
+ const x = finite(anchor?.x, 0.5) * design.width - texture.width * scale / 2;
2527
+ const y = finite(anchor?.y, 1) * design.height - texture.height * scale;
2528
+ return { x, y, scale };
2529
+ };
2530
+ var cgPlacement = (fit, design, texture, scale = 1, x, y) => {
2531
+ const ratio = fit === "cover" ? Math.max(design.width / Math.max(1, texture.width), design.height / Math.max(1, texture.height)) : Math.min(design.width / Math.max(1, texture.width), design.height / Math.max(1, texture.height));
2532
+ const actualScale = ratio * Math.max(0, scale);
2533
+ const width = texture.width * actualScale;
2534
+ const height = texture.height * actualScale;
2535
+ return { x: x ?? (design.width - width) / 2, y: y ?? (design.height - height) / 2, scale: actualScale, width, height };
2536
+ };
2537
+ var clearContainer = (container) => {
2538
+ for (const child of container.removeChildren())
2539
+ child.destroy({ children: true });
2540
+ };
2541
+ var transitionDuration = (duration, fallback = 0) => Math.max(0, duration ?? fallback);
2542
+
2543
+ // src/scene/actor.ts
2544
+ var resolveActorTexture = spriteTexture;
2545
+
2546
+ class ActorSprite {
2547
+ target;
2548
+ actor;
2549
+ world;
2550
+ bindings;
2551
+ assets;
2552
+ design;
2553
+ theme;
2554
+ logger;
2555
+ logged = new Set;
2556
+ constructor(parent, actor, world, bindings, assets, design, theme, logger = console.warn) {
2557
+ this.actor = actor;
2558
+ this.world = world;
2559
+ this.bindings = bindings;
2560
+ this.assets = assets;
2561
+ this.design = design;
2562
+ this.theme = theme;
2563
+ this.logger = logger;
2564
+ this.target = new Container13;
2565
+ this.target.label = `Actor:${actor.character}`;
2566
+ parent.addChild(this.target);
2567
+ this.updateTexture(actor);
2568
+ }
2569
+ updateTexture(actor) {
2570
+ this.target.removeChildren().forEach((child) => child.destroy({ children: true }));
2571
+ const file = resolveActorTexture(this.bindings, actor);
2572
+ const texture = this.assets.texture(file ?? "");
2573
+ if (texture === undefined) {
2574
+ const key = `character:${actor.character}`;
2575
+ if (!this.logged.has(key)) {
2576
+ this.logged.add(key);
2577
+ this.logger(`Missing binding ${key}`);
2578
+ }
2579
+ placeholder(this.target, key, this.theme, this.design.width / 3, this.design.height * 0.8);
2580
+ } else {
2581
+ const sprite = new Sprite4(texture);
2582
+ sprite.label = `Sprite:${file}`;
2583
+ const anchor = actor.anchor === null || actor.anchor === undefined ? undefined : this.bindings.anchors?.[actor.anchor];
2584
+ const heightScale = Math.min(1, this.design.height * 0.85 / Math.max(1, texture.height));
2585
+ const placement = anchorPlacement(anchor === undefined ? { x: 0.5, y: 1, scale: heightScale } : { ...anchor, scale: anchor.scale ?? heightScale }, this.design, { width: texture.width, height: texture.height });
2586
+ sprite.position.set(placement.x, placement.y);
2587
+ sprite.scale.set(placement.scale);
2588
+ this.target.addChild(sprite);
2589
+ }
2590
+ }
2591
+ play = (action, instant = false, durationOverride) => {
2592
+ if (action === undefined || action.effect === "none" || action.effect === "cut" || instant)
2593
+ return Effect11.void;
2594
+ return EffectCatalogue.build(action.effect, this.target, action.params, durationOverride ?? (typeof action.params.duration === "number" ? action.params.duration : 0)).pipe(Effect11.catch(() => Effect11.succeed([])), Effect11.flatMap((behaviours) => Effect11.forEach(behaviours, (behaviour) => this.world.run(this.target, behaviour), { discard: true })));
2595
+ };
2596
+ destroy() {
2597
+ this.target.destroy({ children: true });
2598
+ }
2599
+ }
2600
+
2601
+ // src/scene/background.ts
2602
+ import { Effect as Effect12 } from "effect";
2603
+ import { Container as Container14, Sprite as Sprite5 } from "pixi.js";
2604
+ var backgroundFile = (bindings, bg) => {
2605
+ if (bg === null || bg === undefined)
2606
+ return;
2607
+ const bound = bg.location === undefined || bg.location === null ? undefined : bindings.backgrounds?.[bg.location];
2608
+ return bg.time !== undefined && bg.time !== null ? bound?.times?.[bg.time] ?? bound?.image ?? assetFile(bindings, bg.assetId ?? undefined) : bound?.image ?? assetFile(bindings, bg.assetId ?? undefined);
2609
+ };
2610
+
2611
+ class Background {
2612
+ target;
2613
+ world;
2614
+ bindings;
2615
+ assets;
2616
+ theme;
2617
+ design;
2618
+ logger;
2619
+ logged = new Set;
2620
+ current;
2621
+ constructor(parent, world, bindings, assets, design, theme, logger = console.warn) {
2622
+ this.target = parent;
2623
+ this.world = world;
2624
+ this.bindings = bindings;
2625
+ this.assets = assets;
2626
+ this.design = design;
2627
+ this.theme = theme;
2628
+ this.logger = logger;
2629
+ parent.label = parent.label || "BackgroundLayer";
2630
+ }
2631
+ set = (bg, transition, instant = false, durationOverride) => {
2632
+ const file = backgroundFile(this.bindings, bg);
2633
+ const texture = textureFor(this.assets, file);
2634
+ if (file === undefined || texture === undefined) {
2635
+ const key = `background:${bg?.location ?? bg?.assetId ?? "missing"}`;
2636
+ if (!this.logged.has(key)) {
2637
+ this.logged.add(key);
2638
+ this.logger(`Missing binding ${key}`);
2639
+ }
2640
+ clearContainer(this.target);
2641
+ const next2 = placeholder(this.target, key, this.theme, this.design.width, this.design.height);
2642
+ next2.alpha = 1;
2643
+ this.current = next2;
2644
+ return Effect12.void;
2645
+ }
2646
+ const next = new Container14;
2647
+ next.label = `Background:${file}`;
2648
+ const sprite = new Sprite5(texture);
2649
+ sprite.label = `Texture:${file}`;
2650
+ sprite.width = this.design.width;
2651
+ sprite.height = this.design.height;
2652
+ next.addChild(sprite);
2653
+ this.target.addChild(next);
2654
+ const previous = this.current;
2655
+ this.current = next;
2656
+ const duration = instant ? 0 : transitionDuration(durationOverride, typeof transition?.params.duration === "number" ? transition.params.duration : 0);
2657
+ if (previous === undefined || duration <= 0) {
2658
+ if (previous !== undefined)
2659
+ previous.destroy({ children: true });
2660
+ next.alpha = 1;
2661
+ return Effect12.void;
2662
+ }
2663
+ const behaviour = crossfade(previous, next, duration);
2664
+ return this.world.run(this.target, behaviour).pipe(Effect12.tap(() => Effect12.sync(() => previous.destroy({ children: true }))));
2665
+ };
2666
+ }
2667
+
2668
+ // src/scene/camera.ts
2669
+ import { Effect as Effect13 } from "effect";
2670
+ var framingPlacement = (framing, design, focus) => {
2671
+ const zoom = framing?.zoom ?? 1;
2672
+ const centreX = design.width / 2;
2673
+ const centreY = design.height / 2;
2674
+ const x = focus === undefined ? centreX + (framing?.x ?? 0) : centreX + (centreX - focus.x) * zoom + (framing?.x ?? 0);
2675
+ const y = focus === undefined ? centreY + (framing?.y ?? 0) : centreY + (centreY - focus.y) * zoom + (framing?.y ?? 0);
2676
+ return { scale: zoom, x, y };
2677
+ };
2678
+
2679
+ class Camera {
2680
+ target;
2681
+ world;
2682
+ bindings;
2683
+ design;
2684
+ actors;
2685
+ logger;
2686
+ constructor(target, actors, world, bindings, design, logger = console.warn) {
2687
+ this.target = target;
2688
+ this.actors = actors;
2689
+ this.world = world;
2690
+ this.bindings = bindings;
2691
+ this.design = design;
2692
+ this.logger = logger;
2693
+ }
2694
+ clear = () => {
2695
+ this.target.scale.set(1);
2696
+ this.target.position.set(this.design.width / 2, this.design.height / 2);
2697
+ this.target.pivot.set(this.design.width / 2, this.design.height / 2);
2698
+ };
2699
+ apply = (framingName, focusName) => {
2700
+ const framing = framingName === undefined ? undefined : this.bindings.framing?.[framingName];
2701
+ const focus = focusName === undefined ? undefined : this.findActor(focusName);
2702
+ if (focusName !== undefined && focus === undefined)
2703
+ this.logger(`Missing actor focus:${focusName}`);
2704
+ const placement = framingPlacement(framing, this.design, focus);
2705
+ this.target.scale.set(placement.scale);
2706
+ this.target.position.set(placement.x, placement.y);
2707
+ };
2708
+ move = (move, duration, instant = false) => {
2709
+ if (move === undefined || instant)
2710
+ return Effect13.void;
2711
+ return EffectCatalogue.build(move.effect, this.target, move.params, duration ?? (typeof move.params.duration === "number" ? move.params.duration : 0), { design: this.design, camera: this.target }).pipe(Effect13.catch(() => Effect13.succeed([])), Effect13.flatMap((behaviours) => Effect13.forEach(behaviours, (behaviour) => this.world.run(this.target, behaviour), { discard: true })));
2712
+ };
2713
+ findActor = (character) => {
2714
+ const actor = this.actors.children.find((child) => child.label === `Actor:${character}`);
2715
+ return actor === undefined ? undefined : { x: actor.x + actor.width / 2, y: actor.y + actor.height / 2 };
2716
+ };
2717
+ }
2718
+
2719
+ // src/scene/cgLayer.ts
2720
+ import { Effect as Effect14 } from "effect";
2721
+ import { Container as Container15, Sprite as Sprite6 } from "pixi.js";
2722
+ class CgLayer {
2723
+ target;
2724
+ world;
2725
+ bindings;
2726
+ assets;
2727
+ design;
2728
+ theme;
2729
+ logger;
2730
+ current;
2731
+ constructor(parent, world, bindings, assets, design, theme, logger = console.warn) {
2732
+ this.target = parent;
2733
+ this.world = world;
2734
+ this.bindings = bindings;
2735
+ this.assets = assets;
2736
+ this.design = design;
2737
+ this.theme = theme;
2738
+ this.logger = logger;
2739
+ }
2740
+ show = (cg, transition, instant = false, durationOverride) => {
2741
+ if (cg === null || cg === undefined) {
2742
+ const old = this.current;
2743
+ this.current = undefined;
2744
+ return old === undefined ? Effect14.void : Effect14.sync(() => {
2745
+ clearContainer(this.target);
2746
+ old.destroy({ children: true });
2747
+ });
2748
+ }
2749
+ const file = assetFile(this.bindings, cg.assetId);
2750
+ const texture = textureFor(this.assets, file);
2751
+ const next = new Container15;
2752
+ next.label = `Cg:${cg.assetId}`;
2753
+ if (texture === undefined) {
2754
+ this.logger(`Missing binding cg:${cg.assetId}`);
2755
+ placeholder(next, `cg:${cg.assetId}`, this.theme, this.design.width, this.design.height);
2756
+ } else {
2757
+ const config = cg.layout === null || cg.layout === undefined ? undefined : this.bindings.cgLayouts?.[cg.layout];
2758
+ const placement = cgPlacement(config?.fit ?? "contain", this.design, { width: texture.width, height: texture.height }, config?.scale ?? 1, config?.x, config?.y);
2759
+ const sprite = new Sprite6(texture);
2760
+ sprite.label = `Texture:${file}`;
2761
+ sprite.position.set(placement.x, placement.y);
2762
+ sprite.scale.set(placement.scale);
2763
+ next.addChild(sprite);
2764
+ }
2765
+ this.target.addChild(next);
2766
+ const previous = this.current;
2767
+ this.current = next;
2768
+ const duration = instant ? 0 : transitionDuration(durationOverride, typeof transition?.params.duration === "number" ? transition.params.duration : 0);
2769
+ if (previous === undefined || duration <= 0) {
2770
+ previous?.destroy({ children: true });
2771
+ return Effect14.void;
2772
+ }
2773
+ return this.world.run(this.target, crossfade(previous, next, duration)).pipe(Effect14.tap(() => Effect14.sync(() => previous.destroy({ children: true }))));
2774
+ };
2775
+ }
2776
+
2777
+ // src/scene/videoLayer.ts
2778
+ import { Effect as Effect15 } from "effect";
2779
+ import { Sprite as Sprite7 } from "pixi.js";
2780
+ class VideoLayer {
2781
+ target;
2782
+ assets;
2783
+ bindings;
2784
+ design;
2785
+ theme;
2786
+ logger;
2787
+ active;
2788
+ constructor(parent, _world, bindings, assets, design, theme, logger = console.warn) {
2789
+ this.target = parent;
2790
+ this.bindings = bindings;
2791
+ this.assets = assets;
2792
+ this.design = design;
2793
+ this.theme = theme;
2794
+ this.logger = logger;
2795
+ parent.label = parent.label || "VideoLayer";
2796
+ }
2797
+ show = (video, blocking, skip = false) => {
2798
+ clearContainer(this.target);
2799
+ this.active = undefined;
2800
+ if (video === null || video === undefined)
2801
+ return Effect15.void;
2802
+ const file = assetFile(this.bindings, video.assetId);
2803
+ const texture = file === undefined ? undefined : this.assets.video(file) ?? this.assets.texture(file);
2804
+ if (texture === undefined) {
2805
+ this.logger(`Missing binding video:${video.assetId}`);
2806
+ placeholder(this.target, `video:${video.assetId}`, this.theme, this.design.width, this.design.height);
2807
+ return Effect15.void;
2808
+ }
2809
+ const sprite = new Sprite7(texture);
2810
+ sprite.width = this.design.width;
2811
+ sprite.height = this.design.height;
2812
+ this.target.addChild(sprite);
2813
+ const resource = texture.source.resource;
2814
+ if (!(resource instanceof HTMLVideoElement))
2815
+ return Effect15.void;
2816
+ this.active = resource;
2817
+ resource.currentTime = 0;
2818
+ resource.loop = false;
2819
+ if (skip) {
2820
+ if (Number.isFinite(resource.duration) && resource.duration > 0)
2821
+ resource.currentTime = resource.duration;
2822
+ resource.pause();
2823
+ return Effect15.void;
2824
+ }
2825
+ const play = resource.play();
2826
+ if (!blocking)
2827
+ return Effect15.promise(() => play.then(() => {
2828
+ return;
2829
+ }).catch(() => {
2830
+ return;
2831
+ }));
2832
+ return Effect15.callback((resume) => {
2833
+ const finish = () => resume(Effect15.succeed(undefined));
2834
+ resource.addEventListener("ended", finish, { once: true });
2835
+ resource.addEventListener("error", finish, { once: true });
2836
+ play.catch(finish);
2837
+ return Effect15.sync(() => {
2838
+ resource.removeEventListener("ended", finish);
2839
+ resource.removeEventListener("error", finish);
2840
+ });
2841
+ });
2842
+ };
2843
+ hide = () => Effect15.sync(() => {
2844
+ if (this.active !== undefined)
2845
+ this.active.pause();
2846
+ clearContainer(this.target);
2847
+ this.active = undefined;
2848
+ });
2849
+ }
2850
+
2851
+ // src/scene/vfx.ts
2852
+ import { Effect as Effect16 } from "effect";
2853
+ import { Container as Container17 } from "pixi.js";
2854
+ class Vfx {
2855
+ target;
2856
+ world;
2857
+ design;
2858
+ constructor(parent, world, design) {
2859
+ this.world = world;
2860
+ this.design = design;
2861
+ this.target = new Container17;
2862
+ this.target.label = "Vfx";
2863
+ parent.addChild(this.target);
2864
+ }
2865
+ play = (effect, duration, instant = false) => {
2866
+ const target = new Container17;
2867
+ target.label = `Vfx:${effect.effect}`;
2868
+ this.target.alpha = 1;
2869
+ this.target.addChild(target);
2870
+ const seconds = duration ?? (typeof effect.params.duration === "number" ? effect.params.duration : 0);
2871
+ if (instant)
2872
+ return Effect16.sync(() => {
2873
+ target.alpha = 0;
2874
+ target.removeFromParent();
2875
+ target.visible = false;
2876
+ this.target.alpha = 0;
2877
+ });
2878
+ return EffectCatalogue.build(effect.effect, target, effect.params, seconds, { design: this.design, overlay: target }).pipe(Effect16.catch(() => Effect16.succeed([])), Effect16.flatMap((behaviours) => Effect16.forEach(behaviours, (behaviour) => this.world.run(target, behaviour), { discard: true })), Effect16.tap(() => Effect16.sync(() => {
2879
+ target.alpha = 0;
2880
+ target.removeFromParent();
2881
+ target.visible = false;
2882
+ this.target.alpha = 0;
2883
+ })));
2884
+ };
2885
+ }
2886
+
2887
+ // src/presenter/pixiPresenter.ts
2888
+ var PixiPresenterTag = Context4.Service("Lute.Web.PixiPresenter");
2889
+
2890
+ class PixiPresenter {
2891
+ static layer = (bindings, theme, assets) => Layer4.effect(PixiPresenterTag, Effect17.gen(function* () {
2892
+ const pixi = yield* PixiApp;
2893
+ const world = yield* World;
2894
+ const ui = yield* UiTag;
2895
+ return new PixiPresenter({ pixi, world, ui, assets, bindings, theme });
2896
+ }));
2897
+ stage;
2898
+ showLine;
2899
+ revealLine;
2900
+ clearLine;
2901
+ restore;
2902
+ showChoice;
2903
+ hideChoice;
2904
+ showScreen;
2905
+ hideScreen;
2906
+ setUiHidden;
2907
+ applySettings;
2908
+ capture;
2909
+ actions;
2910
+ pixi;
2911
+ world;
2912
+ ui;
2913
+ assets;
2914
+ bindings;
2915
+ theme;
2916
+ logger;
2917
+ debug;
2918
+ actors = new Map;
2919
+ background;
2920
+ cg;
2921
+ video;
2922
+ camera;
2923
+ vfx;
2924
+ dialogue;
2925
+ choice;
2926
+ choiceActions;
2927
+ missing = new Set;
2928
+ customVfx = new Set;
2929
+ uiHidden = false;
2930
+ constructor(options) {
2931
+ this.pixi = options.pixi;
2932
+ this.world = options.world;
2933
+ this.ui = options.ui;
2934
+ this.assets = options.assets;
2935
+ this.bindings = options.bindings;
2936
+ this.theme = options.theme;
2937
+ this.logger = options.logger ?? ((message, error) => console.warn(message, error));
2938
+ this.debug = options.debug ?? ((message) => console.debug(message));
2939
+ const design = this.pixi.design;
2940
+ this.background = new Background(this.pixi.layers.background, this.world, this.bindings, this.assets, design, this.theme, (message) => this.logMissing(message));
2941
+ this.cg = new CgLayer(this.pixi.layers.cg, this.world, this.bindings, this.assets, design, this.theme, (message) => this.logMissing(message));
2942
+ this.video = new VideoLayer(this.pixi.layers.video, this.world, this.bindings, this.assets, design, this.theme, (message) => this.logMissing(message));
2943
+ this.camera = new Camera(this.pixi.layers.camera, this.pixi.layers.actors, this.world, this.bindings, design, (message) => this.logMissing(message));
2944
+ this.vfx = new Vfx(this.pixi.layers.overlay, this.world, design);
2945
+ const nameColors = {};
2946
+ for (const [character, binding] of Object.entries(this.bindings.characters ?? {})) {
2947
+ if (binding.nameColor !== null && binding.nameColor !== undefined) {
2948
+ const text = binding.nameColor.replace(/^#/, "");
2949
+ if (/^[0-9a-f]{6}$/i.test(text))
2950
+ nameColors[character] = Number.parseInt(text, 16);
2951
+ }
2952
+ }
2953
+ const textStyles = Object.fromEntries(Object.entries(this.bindings.textStyles ?? {}).map(([name, style]) => [name, {
2954
+ ...style.fontWeight === null ? {} : { fontWeight: style.fontWeight },
2955
+ ...style.fontStyle === null ? {} : { fontStyle: style.fontStyle },
2956
+ ...style.color === null ? {} : { color: style.color },
2957
+ ...style.alpha === null ? {} : { alpha: style.alpha },
2958
+ ...style.scale === null ? {} : { scale: style.scale },
2959
+ ...style.letterSpacing === null ? {} : { letterSpacing: style.letterSpacing }
2960
+ }]));
2961
+ this.dialogue = new DialogueWindow(this.pixi.layers.dialogue, this.world, this.theme, { nameColors, textStyles });
2962
+ this.dialogue.relayout(design);
2963
+ this.choice = new ChoiceMenu(this.pixi.layers.choice, this.theme);
2964
+ this.choiceActions = Effect17.runSync(PubSub2.unbounded());
2965
+ this.actions = Stream6.merge(this.ui.actions, Stream6.fromPubSub(this.choiceActions));
2966
+ this.stage = (cue, mode) => this.stageCue(cue, mode);
2967
+ this.showLine = (line, mode) => this.dialogue.show(line, this.defaultSettings, mode);
2968
+ this.revealLine = this.dialogue.reveal;
2969
+ this.clearLine = this.dialogue.clear;
2970
+ this.restore = (stage) => this.restoreStage(stage);
2971
+ this.showChoice = (choice, remaining) => this.showChoiceInternal(choice, remaining);
2972
+ this.hideChoice = this.choice.hide;
2973
+ this.showScreen = (screen) => {
2974
+ const modal = ["Menu", "Backlog", "SaveLoad", "Settings", "Confirm"].includes(screen._tag);
2975
+ const hideDialogue = modal ? Effect17.void : Effect17.andThen(this.dialogue.clear, Effect17.sync(() => {
2976
+ this.dialogue.setHidden(true);
2977
+ }));
2978
+ return Effect17.andThen(hideDialogue, screen._tag === "Title" && options.titleBackground !== undefined ? this.ui.show({ ...screen, background: options.titleBackground }) : this.ui.show(screen));
2979
+ };
2980
+ this.hideScreen = Effect17.andThen(this.ui.hide, Effect17.sync(() => {
2981
+ this.dialogue.setHidden(this.uiHidden);
2982
+ }));
2983
+ this.setUiHidden = (hidden) => Effect17.sync(() => {
2984
+ this.uiHidden = hidden;
2985
+ this.dialogue.setHidden(hidden);
2986
+ });
2987
+ this.applySettings = (settings) => Effect17.sync(() => {
2988
+ this.defaultSettings = { ...this.defaultSettings, ...settings };
2989
+ this.dialogue.setOpacity(this.defaultSettings.windowOpacity);
2990
+ });
2991
+ this.capture = Effect17.tryPromise(() => this.pixi.app.renderer.extract.base64({ target: this.pixi.app.stage, format: "png", resolution: Math.min(320 / design.width, 180 / design.height) })).pipe(Effect17.catch(() => Effect17.succeed("data:image/png;base64,")));
2992
+ }
2993
+ defaultSettings = { locale: "", textSpeed: 30, windowOpacity: 1, autoDelay: 1, readColour: true, skipMode: "all", rollbackDepth: 0, volumes: { master: 1, music: 1, sfx: 1, voice: 1 } };
2994
+ stageCue = (cue, mode) => {
2995
+ const instant = mode === "skip";
2996
+ switch (cue._tag) {
2997
+ case "Background":
2998
+ return this.background.set(cue.bg, cue.transition, instant, cue.duration);
2999
+ case "ActorShow":
3000
+ return this.upsertActor(cue.actor, cue.action, instant, cue.duration);
3001
+ case "ActorExit":
3002
+ return this.exitActor(cue.character, cue.action, instant, cue.duration);
3003
+ case "Cg":
3004
+ return this.cg.show(cue.cg, cue.transition, instant, cue.duration);
3005
+ case "Video":
3006
+ return cue.video === null ? this.video.hide() : this.video.show(cue.video, cue.blocking, instant);
3007
+ case "Sequence": {
3008
+ const file = cue.video === undefined ? this.bindings.sequences?.[cue.name]?.video : cue.video;
3009
+ const stageVideo = file === undefined ? null : { assetId: file };
3010
+ return stageVideo === null ? this.video.hide() : this.video.show(stageVideo, true, instant);
3011
+ }
3012
+ case "Camera": {
3013
+ return Effect17.sync(() => this.camera.apply(cue.framing, cue.focus)).pipe(Effect17.flatMap(() => this.camera.move(cue.move ?? cue.transition, cue.duration, instant)));
3014
+ }
3015
+ case "Vfx":
3016
+ return cue.effect.effect === "custom" ? this.logCustomVfx(cue.effect.name ?? "") : this.vfx.play(cue.effect, cue.duration, instant);
3017
+ }
3018
+ };
3019
+ upsertActor = (actor, action, instant, duration) => {
3020
+ const current = this.actors.get(actor.character);
3021
+ if (current === undefined) {
3022
+ const created = new ActorSprite(this.pixi.layers.actors, actor, this.world, this.bindings, this.assets, this.pixi.design, this.theme, (message) => this.logMissing(message));
3023
+ this.actors.set(actor.character, created);
3024
+ return created.play(action, instant, duration);
3025
+ }
3026
+ current.updateTexture(actor);
3027
+ return current.play(action, instant, duration);
3028
+ };
3029
+ exitActor = (character, action, instant, duration) => {
3030
+ const actor = this.actors.get(character);
3031
+ if (actor === undefined)
3032
+ return Effect17.void;
3033
+ this.actors.delete(character);
3034
+ return actor.play(action, instant, duration).pipe(Effect17.tap(() => Effect17.sync(() => actor.destroy())));
3035
+ };
3036
+ restoreStage = (stage) => Effect17.sync(() => {
3037
+ clearContainer(this.pixi.layers.background);
3038
+ clearContainer(this.pixi.layers.cg);
3039
+ clearContainer(this.pixi.layers.video);
3040
+ clearContainer(this.pixi.layers.actors);
3041
+ this.actors.clear();
3042
+ this.camera.clear();
3043
+ }).pipe(Effect17.flatMap(() => this.background.set(stage.bg, undefined, true)), Effect17.flatMap(() => Effect17.forEach(stage.actors, (actor) => this.upsertActor(actor, undefined, true), { discard: true })), Effect17.flatMap(() => this.cg.show(stage.cg, undefined, true)), Effect17.flatMap(() => this.video.show(stage.video, false, true)));
3044
+ showChoiceInternal = (choice, remaining) => this.choice.show(choice, remaining).pipe(Effect17.catch(() => Effect17.succeed("")), Effect17.tap((option) => Effect17.sync(() => {
3045
+ Effect17.runSync(PubSub2.publish(this.choiceActions, { type: "choose", option }));
3046
+ })));
3047
+ logMissing = (message) => {
3048
+ if (this.missing.has(message))
3049
+ return;
3050
+ this.missing.add(message);
3051
+ this.logger(message);
3052
+ };
3053
+ logCustomVfx = (name) => Effect17.sync(() => {
3054
+ if (this.customVfx.has(name))
3055
+ return;
3056
+ this.customVfx.add(name);
3057
+ this.debug(`Custom vfx "${name}" has no Pixi effect; nothing is played`);
3058
+ });
3059
+ }
3060
+
3061
+ // src/input/map.ts
3062
+ import { Effect as Effect18, Queue as Queue2, Stream as Stream7 } from "effect";
3063
+ var defaultInputMap = {
3064
+ keyboard: {
3065
+ Enter: { type: "advance" },
3066
+ " ": { type: "advance" },
3067
+ a: { type: "toggleAuto" },
3068
+ A: { type: "toggleAuto" },
3069
+ s: { type: "toggleSkip" },
3070
+ S: { type: "toggleSkip" },
3071
+ Escape: { type: "openMenu" },
3072
+ PageUp: { type: "openBacklog" },
3073
+ F5: { type: "quickSave" },
3074
+ F9: { type: "quickLoad" },
3075
+ h: { type: "hideUI" },
3076
+ H: { type: "hideUI" },
3077
+ Backspace: { type: "rollback" }
3078
+ },
3079
+ pointer: { click: { type: "advance" }, contextmenu: { type: "openMenu" }, middle: { type: "hideUI" } }
3080
+ };
3081
+ function resolveKeyboardAction(event, map = defaultInputMap) {
3082
+ if (event.key === "Control")
3083
+ return;
3084
+ const binding = map.keyboard[event.key];
3085
+ if (binding === undefined)
3086
+ return;
3087
+ if (isKeyBinding(binding)) {
3088
+ if (binding.ctrl !== undefined && binding.ctrl !== event.ctrlKey)
3089
+ return;
3090
+ if (binding.shift !== undefined && binding.shift !== event.shiftKey)
3091
+ return;
3092
+ if (binding.alt !== undefined && binding.alt !== event.altKey)
3093
+ return;
3094
+ return binding.action;
3095
+ }
3096
+ return binding;
3097
+ }
3098
+
3099
+ class LongPressRecognizer {
3100
+ started = -1;
3101
+ emitted = false;
3102
+ start(at) {
3103
+ this.started = at;
3104
+ this.emitted = false;
3105
+ }
3106
+ end(at, threshold = 500) {
3107
+ const result = this.started >= 0 && at - this.started >= threshold && !this.emitted;
3108
+ this.started = -1;
3109
+ this.emitted = result;
3110
+ return result;
3111
+ }
3112
+ check(at, threshold = 500) {
3113
+ if (this.started < 0 || this.emitted || at - this.started < threshold)
3114
+ return false;
3115
+ this.emitted = true;
3116
+ return true;
3117
+ }
3118
+ cancel() {
3119
+ this.started = -1;
3120
+ this.emitted = false;
3121
+ }
3122
+ }
3123
+
3124
+ class SwipeRecognizer {
3125
+ startY = 0;
3126
+ active = false;
3127
+ start(y) {
3128
+ this.startY = y;
3129
+ this.active = true;
3130
+ }
3131
+ end(y, threshold = 48) {
3132
+ if (!this.active)
3133
+ return;
3134
+ this.active = false;
3135
+ const delta = y - this.startY;
3136
+ if (Math.abs(delta) < threshold)
3137
+ return;
3138
+ return delta < 0 ? "up" : "down";
3139
+ }
3140
+ }
3141
+ function layer(element, map = defaultInputMap, uiConsumes, uiPointer) {
3142
+ let emit = () => {};
3143
+ return Stream7.callback((queue) => Effect18.acquireRelease(Effect18.sync(() => {
3144
+ emit = (action) => {
3145
+ Queue2.offerUnsafe(queue, action);
3146
+ };
3147
+ const longPress = new LongPressRecognizer;
3148
+ const swipe = new SwipeRecognizer;
3149
+ let controlHeld = false;
3150
+ const down = (event) => {
3151
+ if (uiConsumes?.() === true || event.defaultPrevented) {
3152
+ longPress.cancel();
3153
+ return;
3154
+ }
3155
+ longPress.start(event.timeStamp);
3156
+ swipe.start(event.clientY);
3157
+ window.setTimeout(() => {
3158
+ if (longPress.check(performance.now()))
3159
+ emit({ type: "openMenu" });
3160
+ }, 500);
3161
+ };
3162
+ const up = (event) => {
3163
+ if (event.button !== 0)
3164
+ return;
3165
+ if (uiConsumes?.() === true) {
3166
+ longPress.cancel();
3167
+ uiPointer?.(event);
3168
+ return;
3169
+ }
3170
+ const long = longPress.end(event.timeStamp);
3171
+ if (long)
3172
+ emit({ type: "openMenu" });
3173
+ else if (swipe.end(event.clientY) === "down")
3174
+ emit({ type: "openBacklog" });
3175
+ else
3176
+ emit(map.pointer.click ?? { type: "advance" });
3177
+ };
3178
+ const key = (event) => {
3179
+ if (event.defaultPrevented)
3180
+ return;
3181
+ if (uiConsumes?.() === true) {
3182
+ event.preventDefault();
3183
+ return;
3184
+ }
3185
+ if (event.key === "Control" && !controlHeld) {
3186
+ controlHeld = true;
3187
+ emit({ type: "skipHold", held: true });
3188
+ }
3189
+ const action = resolveKeyboardAction(event, map);
3190
+ if (action !== undefined) {
3191
+ event.preventDefault();
3192
+ emit(action);
3193
+ }
3194
+ };
3195
+ const keyup = (event) => {
3196
+ if (event.key === "Control" && controlHeld) {
3197
+ controlHeld = false;
3198
+ emit({ type: "skipHold", held: false });
3199
+ }
3200
+ };
3201
+ const wheel = (event) => {
3202
+ if (event.deltaY < 0) {
3203
+ event.preventDefault();
3204
+ emit({ type: "openBacklog" });
3205
+ }
3206
+ if (event.defaultPrevented)
3207
+ return;
3208
+ };
3209
+ const pointer = (event) => {
3210
+ if (event.button === 1) {
3211
+ event.preventDefault();
3212
+ emit(map.pointer.middle ?? { type: "hideUI" });
3213
+ }
3214
+ if (event.button === 2) {
3215
+ event.preventDefault();
3216
+ emit(map.pointer.contextmenu ?? { type: "openMenu" });
3217
+ }
3218
+ };
3219
+ const cancel = () => longPress.cancel();
3220
+ const context = (event) => event.preventDefault();
3221
+ element.addEventListener("keydown", key);
3222
+ element.addEventListener("keyup", keyup);
3223
+ element.addEventListener("pointerdown", down);
3224
+ element.addEventListener("pointerup", up);
3225
+ element.addEventListener("pointercancel", cancel);
3226
+ element.addEventListener("pointerup", pointer);
3227
+ element.addEventListener("wheel", wheel, { passive: false });
3228
+ element.addEventListener("contextmenu", context);
3229
+ return { key, keyup, down, up, pointer, wheel, cancel, context, longPress };
3230
+ }), (registered) => Effect18.sync(() => {
3231
+ element.removeEventListener("keydown", registered.key);
3232
+ element.removeEventListener("keyup", registered.keyup);
3233
+ element.removeEventListener("pointerdown", registered.down);
3234
+ element.removeEventListener("pointerup", registered.up);
3235
+ element.removeEventListener("pointercancel", registered.cancel);
3236
+ element.removeEventListener("pointerup", registered.pointer);
3237
+ element.removeEventListener("wheel", registered.wheel);
3238
+ element.removeEventListener("contextmenu", registered.context);
3239
+ registered.longPress.cancel();
3240
+ emit = () => {};
3241
+ })));
3242
+ }
3243
+ function isKeyBinding(value) {
3244
+ return "action" in value;
3245
+ }
3246
+ // src/input/index.ts
3247
+ var Input = { layer };
3248
+
3249
+ // src/theme.ts
3250
+ var DEFAULTS = {
3251
+ font: "sans-serif",
3252
+ text: 16777215,
3253
+ accent: 14729344,
3254
+ panel: 1052696,
3255
+ panelAlpha: 0.82,
3256
+ readText: 10132138
3257
+ };
3258
+ var parseColour = (value, fallback) => {
3259
+ if (typeof value !== "string")
3260
+ return fallback;
3261
+ const text = value.trim().replace(/^#/, "").replace(/^0x/i, "");
3262
+ return /^[0-9a-f]{6}$/i.test(text) ? Number.parseInt(text, 16) : fallback;
3263
+ };
3264
+ var themeFromBindings = (bindings) => {
3265
+ const ui = bindings?.ui;
3266
+ const colours = ui?.theme;
3267
+ const alpha = typeof colours?.panelAlpha === "string" ? Number(colours.panelAlpha) : Number.NaN;
3268
+ return {
3269
+ font: ui?.font ?? DEFAULTS.font,
3270
+ text: parseColour(colours?.text, DEFAULTS.text),
3271
+ accent: parseColour(colours?.accent, DEFAULTS.accent),
3272
+ panel: parseColour(colours?.panel, DEFAULTS.panel),
3273
+ panelAlpha: Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : DEFAULTS.panelAlpha,
3274
+ readText: parseColour(colours?.readText, DEFAULTS.readText)
3275
+ };
3276
+ };
3277
+ var defaultTheme = DEFAULTS;
3278
+
3279
+ // src/mount.ts
3280
+ var slash = (value) => value.endsWith("/") ? value : `${value}/`;
3281
+ var isUrl = (value) => /^[a-z][a-z\d+.-]*:/i.test(value);
3282
+ var absoluteUrl = (value) => {
3283
+ try {
3284
+ return new URL(value, typeof globalThis.location === "object" ? globalThis.location.href : "http://localhost/").href;
3285
+ } catch {
3286
+ return value;
3287
+ }
3288
+ };
3289
+ var resolveBase = (source, bindings, assetsUrl) => {
3290
+ if (assetsUrl !== undefined)
3291
+ return slash(assetsUrl);
3292
+ const configured = bindings.assetsBase ?? "assets/";
3293
+ if (isUrl(configured))
3294
+ return slash(configured);
3295
+ if (typeof source === "object" && source !== null && "url" in source)
3296
+ return new URL(configured, source.url).href;
3297
+ const origin = typeof globalThis.location === "object" ? globalThis.location.href : "http://localhost/";
3298
+ return new URL(configured, origin).href;
3299
+ };
3300
+ var resolveAsset = (bindings, base, file) => {
3301
+ const mapped = bindings.assets?.[file] ?? file;
3302
+ if (isUrl(mapped))
3303
+ return mapped;
3304
+ try {
3305
+ return new URL(mapped.replace(/^\/+/, ""), slash(base)).href;
3306
+ } catch {
3307
+ const origin = typeof globalThis.location === "object" ? globalThis.location.href : "http://localhost/";
3308
+ return new URL(mapped.replace(/^\/+/, ""), new URL(slash(base), origin)).href;
3309
+ }
3310
+ };
3311
+ var loadBindings = (source) => {
3312
+ if (typeof source === "string")
3313
+ return BindingsLoader.decode(source);
3314
+ if (typeof source === "object" && source !== null && "url" in source) {
3315
+ return BindingsLoader.loadUrl(source.url).pipe(Effect19.provide(FetchHttpClient.layer));
3316
+ }
3317
+ return Effect19.succeed(source);
3318
+ };
3319
+ var namespacedStorage = (key) => Effect19.map(Layer5.build(BrowserKeyValueStore.layerLocalStorage), (context) => Layer5.succeed(KeyValueStore.KeyValueStore, KeyValueStore.prefix(Context5.get(context, KeyValueStore.KeyValueStore), `${key}:`)));
3320
+ var runMount = (element, options, bindings, assetsBase) => {
3321
+ const theme = themeFromBindings(bindings);
3322
+ const pixiLayer = PixiApp.layer(element, bindings.canvas);
3323
+ const worldLayer = World.layer.pipe(Layer5.provideMerge(pixiLayer));
3324
+ return Effect19.gen(function* () {
3325
+ const services = yield* Layer5.build(worldLayer);
3326
+ const pixi = Context5.get(services, PixiApp);
3327
+ const world = Context5.get(services, World);
3328
+ const assetsOptions = { assetsBase, backend: pixiAssetsBackend };
3329
+ const assets = new PixiAssets(assetsOptions);
3330
+ const ui = new Ui(pixi.layers.overlay, theme, world, (file) => assets.texture(file));
3331
+ ui.setDesign(bindings.canvas, "Lute");
3332
+ const resolveSound = (file) => resolveAsset(bindings, assetsBase, file);
3333
+ const audio = new PixiAudio({
3334
+ backend: {
3335
+ load: (file) => pixiSoundBackend.load(resolveSound(file)),
3336
+ play: (file, playOptions) => pixiSoundBackend.play(resolveSound(file), playOptions),
3337
+ unlock: pixiSoundBackend.unlock
3338
+ }
3339
+ });
3340
+ const title = bindings.ui?.title;
3341
+ const titleBackground = title === undefined || title === null ? undefined : resolveAsset(bindings, assetsBase, title.background);
3342
+ if (titleBackground !== undefined)
3343
+ yield* assets.ensure([titleBackground]);
3344
+ const presenterOptions = titleBackground === undefined ? { pixi, world, ui, assets, bindings, theme } : { pixi, world, ui, assets, bindings, theme, titleBackground };
3345
+ const presenter = new PixiPresenter(presenterOptions);
3346
+ const storage = yield* namespacedStorage(options.storageKey ?? "lute");
3347
+ const requirements = Layer5.mergeAll(RuntimeBrowser.runtime(absoluteUrl(options.wasmUrl)), RuntimeBrowser.bundle(absoluteUrl(slash(options.bundleUrl))), Runtime.InputLog.memory, storage);
3348
+ const playerOptions = options.host === undefined ? { bindings, presenter, audio, assets } : { bindings, presenter, audio, assets, host: options.host };
3349
+ const player = yield* LutePlayer.make(playerOptions).pipe(Effect19.provide(requirements));
3350
+ let unlocked = false;
3351
+ const dispatch = (action) => Effect19.gen(function* () {
3352
+ if (!unlocked) {
3353
+ unlocked = true;
3354
+ yield* audio.unlock.pipe(Effect19.catch(() => Effect19.void));
3355
+ }
3356
+ yield* player.actions(action);
3357
+ });
3358
+ const keys = (event) => {
3359
+ if (ui.handleKey(event.key))
3360
+ event.preventDefault();
3361
+ };
3362
+ element.addEventListener("keydown", keys, true);
3363
+ yield* Effect19.addFinalizer(() => Effect19.sync(() => element.removeEventListener("keydown", keys, true)));
3364
+ yield* Effect19.forkScoped(Stream8.runForEach(Input.layer(element, undefined, () => ui.hasScreen, (event) => ui.pointerAction(event.clientX, event.clientY)), dispatch));
3365
+ yield* Effect19.forkScoped(Stream8.runForEach(SubscriptionRef3.changes(player.view), (view) => Effect19.sync(() => {
3366
+ ui.setPlaying(view.flow === "playing" && view.story !== "idle", { auto: view.mode === "auto", skip: view.mode === "skip", hidden: view.uiHidden });
3367
+ })));
3368
+ if (title !== undefined && title !== null) {
3369
+ const file = resolveAsset(bindings, assetsBase, title.music);
3370
+ yield* Effect19.forkScoped(audio.play("music", { file, loop: true, volume: 1, fade: 0, restart: true }).pipe(Effect19.catch(() => Effect19.void)));
3371
+ }
3372
+ return player;
3373
+ });
3374
+ };
3375
+ var mount = (element, options) => Effect19.flatMap(loadBindings(options.bindings), (bindings) => runMount(element, options, bindings, resolveBase(options.bindings, bindings, options.assetsUrl)));
3376
+ var mountPlayer = async (element, options) => {
3377
+ const runtime = ManagedRuntime.make(Layer5.empty);
3378
+ const scope = Scope2.makeUnsafe("parallel");
3379
+ try {
3380
+ const handle = await runtime.runPromise(Effect19.provideService(mount(element, options), Scope2.Scope, scope));
3381
+ let closed = false;
3382
+ return {
3383
+ unmount: async () => {
3384
+ if (closed)
3385
+ return;
3386
+ closed = true;
3387
+ await runtime.runPromise(handle.shutdown);
3388
+ await runtime.runPromise(Scope2.close(scope, Exit.void));
3389
+ await runtime.dispose();
3390
+ },
3391
+ view: () => runtime.runPromise(SubscriptionRef3.get(handle.view)),
3392
+ act: (action) => runtime.runPromise(handle.actions(action))
3393
+ };
3394
+ } catch (error) {
3395
+ await runtime.dispose();
3396
+ await Scope2.close(scope, Exit.fail(error));
3397
+ throw error;
3398
+ }
3399
+ };
3400
+ var LuteWeb = { mount, mountPlayer };
3401
+ export {
3402
+ Input,
3403
+ LuteWeb,
3404
+ defaultTheme,
3405
+ mount,
3406
+ mountPlayer,
3407
+ parseColour,
3408
+ themeFromBindings
3409
+ };