@novely/core 0.54.0-next.3 → 0.55.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.
package/dist/index.mjs ADDED
@@ -0,0 +1,2623 @@
1
+ import { dequal } from "dequal/lite";
2
+ import { memoize, once, throttle } from "es-toolkit/function";
3
+ import { merge } from "es-toolkit/object";
4
+ import { DEV } from "esm-env";
5
+ import { klona } from "klona/full";
6
+ import pLimit from "p-limit";
7
+
8
+ //#region src/constants.ts
9
+ const SKIPPED_DURING_RESTORE = new Set([
10
+ "dialog",
11
+ "choice",
12
+ "input",
13
+ "vibrate",
14
+ "text"
15
+ ]);
16
+ const BLOCK_EXIT_STATEMENTS = new Set([
17
+ "choice:exit",
18
+ "condition:exit",
19
+ "block:exit"
20
+ ]);
21
+ const BLOCK_STATEMENTS = new Set([
22
+ "choice",
23
+ "condition",
24
+ "block"
25
+ ]);
26
+ const AUDIO_ACTIONS = new Set([
27
+ "playMusic",
28
+ "stopMusic",
29
+ "playSound",
30
+ "stopSound",
31
+ "voice",
32
+ "stopVoice"
33
+ ]);
34
+ const EMPTY_SET = /* @__PURE__ */ new Set();
35
+ const DEFAULT_TYPEWRITER_SPEED = "Medium";
36
+ const HOWLER_SUPPORTED_FILE_FORMATS = new Set([
37
+ "mp3",
38
+ "mpeg",
39
+ "opus",
40
+ "ogg",
41
+ "oga",
42
+ "wav",
43
+ "aac",
44
+ "caf",
45
+ "m4a",
46
+ "m4b",
47
+ "mp4",
48
+ "weba",
49
+ "webm",
50
+ "dolby",
51
+ "flac"
52
+ ]);
53
+ const SUPPORTED_IMAGE_FILE_FORMATS = new Set([
54
+ "apng",
55
+ "avif",
56
+ "gif",
57
+ "jpg",
58
+ "jpeg",
59
+ "jfif",
60
+ "pjpeg",
61
+ "pjp",
62
+ "png",
63
+ "svg",
64
+ "webp",
65
+ "bmp"
66
+ ]);
67
+ /**
68
+ * @internal
69
+ */
70
+ const MAIN_CONTEXT_KEY = "$MAIN";
71
+
72
+ //#endregion
73
+ //#region src/shared.ts
74
+ /**
75
+ * @internal
76
+ */
77
+ const STACK_MAP = /* @__PURE__ */ new Map();
78
+ /**
79
+ * @internal
80
+ */
81
+ const CUSTOM_ACTION_MAP = /* @__PURE__ */ new Map();
82
+ /**
83
+ * @internal
84
+ */
85
+ const CUSTOM_ACTION_INSTANCES_MAP = /* @__PURE__ */ new Map();
86
+ const PRELOADED_ASSETS = /* @__PURE__ */ new Set();
87
+ const ASSETS_TO_PRELOAD = /* @__PURE__ */ new Set();
88
+
89
+ //#endregion
90
+ //#region src/utilities/assertions.ts
91
+ const isNumber = (val) => {
92
+ return typeof val === "number";
93
+ };
94
+ const isNull = (val) => {
95
+ return val === null;
96
+ };
97
+ const isString = (val) => {
98
+ return typeof val === "string";
99
+ };
100
+ const isFunction = (val) => {
101
+ return typeof val === "function";
102
+ };
103
+ const isPromise = (val) => {
104
+ return Boolean(val) && (typeof val === "object" || isFunction(val)) && isFunction(val.then);
105
+ };
106
+ const isEmpty = (val) => {
107
+ return typeof val === "object" && !isNull(val) && Object.keys(val).length === 0;
108
+ };
109
+ /**
110
+ * Determines if a given action requires user interaction based on its type and metadata.
111
+ */
112
+ const isUserRequiredAction = ([action, ...meta]) => {
113
+ return Boolean(action === "custom" && meta[0] && meta[0].requireUserAction);
114
+ };
115
+ const isBlockStatement = (statement) => {
116
+ return BLOCK_STATEMENTS.has(statement);
117
+ };
118
+ const isBlockExitStatement = (statement) => {
119
+ return BLOCK_EXIT_STATEMENTS.has(statement);
120
+ };
121
+ const isSkippedDuringRestore = (item) => {
122
+ return SKIPPED_DURING_RESTORE.has(item);
123
+ };
124
+ const isAudioAction = (action) => {
125
+ return AUDIO_ACTIONS.has(action);
126
+ };
127
+ const isAction = (element) => {
128
+ return Array.isArray(element) && isString(element[0]);
129
+ };
130
+ /**
131
+ * Is custom and requires user action or skipped during restoring
132
+ */
133
+ const isBlockingAction = (action) => {
134
+ return isUserRequiredAction(action) || isSkippedDuringRestore(action[0]) && action[0] !== "vibrate";
135
+ };
136
+ const isAsset = (suspect) => {
137
+ return suspect !== null && typeof suspect === "object" && "source" in suspect && "type" in suspect;
138
+ };
139
+
140
+ //#endregion
141
+ //#region src/utilities/match-action.ts
142
+ const matchAction = (handlers, values) => {
143
+ const { getContext, onBeforeActionCall, push, forward } = handlers;
144
+ const match = (action, props, { ctx, data }) => {
145
+ const context = typeof ctx === "string" ? getContext(ctx) : ctx;
146
+ onBeforeActionCall({
147
+ action,
148
+ props,
149
+ ctx: context
150
+ });
151
+ return values[action]({
152
+ ctx: context,
153
+ data,
154
+ async push() {
155
+ if (context.meta.preview) return;
156
+ await push(context);
157
+ },
158
+ async forward() {
159
+ if (context.meta.preview) return;
160
+ await forward(context);
161
+ }
162
+ }, props);
163
+ };
164
+ return {
165
+ match,
166
+ nativeActions: Object.keys(values)
167
+ };
168
+ };
169
+
170
+ //#endregion
171
+ //#region src/audio-codecs.ts
172
+ /**
173
+ * This code is adapted from the Howler.js source code.
174
+ * Howler.js: https://github.com/goldfire/howler.js
175
+ */
176
+ /**
177
+ * I guess some browsers will return "no". So it's better to be safe
178
+ */
179
+ const cut = (str) => str.replace(/^no$/, "");
180
+ const audio = new Audio();
181
+ const canPlay = (type) => !!cut(audio.canPlayType(type));
182
+ const canPlayMultiple = (...types) => types.some((type) => canPlay(type));
183
+ const supportsMap$1 = {
184
+ mp3: canPlayMultiple("audio/mpeg;", "audio/mp3;"),
185
+ mpeg: canPlay("audio/mpeg;"),
186
+ opus: canPlay("audio/ogg; codecs=\"opus\""),
187
+ ogg: canPlay("audio/ogg; codecs=\"vorbis\""),
188
+ oga: canPlay("audio/ogg; codecs=\"vorbis\""),
189
+ wav: canPlayMultiple("audio/wav; codecs=\"1\"", "audio/wav;"),
190
+ aac: canPlay("audio/aac;"),
191
+ caf: canPlay("audio/x-caf;"),
192
+ m4a: canPlayMultiple("audio/x-m4a;", "audio/m4a;", "audio/aac;"),
193
+ m4b: canPlayMultiple("audio/x-m4b;", "audio/m4b;", "audio/aac;"),
194
+ mp4: canPlayMultiple("audio/x-mp4;", "audio/mp4;", "audio/aac;"),
195
+ weba: canPlay("audio/webm; codecs=\"vorbis\""),
196
+ webm: canPlay("audio/webm; codecs=\"vorbis\""),
197
+ dolby: canPlay("audio/mp4; codecs=\"ec-3\""),
198
+ flac: canPlayMultiple("audio/x-flac;", "audio/flac;")
199
+ };
200
+
201
+ //#endregion
202
+ //#region src/image-formats.ts
203
+ const avif = "data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKCBgANogQEAwgMg8f8D///8WfhwB8+ErK42A=";
204
+ const jxl = "data:image/jxl;base64,/woIAAAMABKIAgC4AF3lEgAAFSqjjBu8nOv58kOHxbSN6wxttW1hSaLIODZJJ3BIEkkaoCUzGM6qJAE=";
205
+ const webp = "data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA";
206
+ const supportsFormat = (source) => {
207
+ const { promise, resolve } = Promise.withResolvers();
208
+ const img = Object.assign(document.createElement("img"), { src: source });
209
+ img.onload = img.onerror = () => {
210
+ resolve(img.height === 2);
211
+ };
212
+ return promise;
213
+ };
214
+ const supportsMap = {
215
+ avif: false,
216
+ jxl: false,
217
+ webp: false
218
+ };
219
+ const formatsMap = {
220
+ avif,
221
+ jxl,
222
+ webp
223
+ };
224
+ const loadImageFormatsSupport = async () => {
225
+ const promises = [];
226
+ for (const [format, source] of Object.entries(formatsMap)) {
227
+ const promise = supportsFormat(source).then((supported) => {
228
+ supportsMap[format] = supported;
229
+ });
230
+ promises.push(promise);
231
+ }
232
+ await Promise.all(promises);
233
+ };
234
+ loadImageFormatsSupport();
235
+
236
+ //#endregion
237
+ //#region src/asset.ts
238
+ const generateRandomId = () => Math.random().toString(36);
239
+ /**
240
+ * Function to get assets type. All assets must be of the same type. Only works with supported types.
241
+ */
242
+ const getType = memoize((extensions) => {
243
+ if (extensions.every((extension) => HOWLER_SUPPORTED_FILE_FORMATS.has(extension))) return "audio";
244
+ if (extensions.every((extension) => SUPPORTED_IMAGE_FILE_FORMATS.has(extension))) return "image";
245
+ if (DEV) throw new Error(`Unsupported file extensions: ${JSON.stringify(extensions)}`);
246
+ throw extensions;
247
+ }, { getCacheKey: (extensions) => extensions.join("~") });
248
+ const SUPPORT_MAPS = {
249
+ image: supportsMap,
250
+ audio: supportsMap$1
251
+ };
252
+ /**
253
+ * This function uses array instead of spread because memoize only works with first argument
254
+ */
255
+ const assetPrivate = memoize((variants) => {
256
+ if (DEV && variants.length === 0) throw new Error(`Attempt to use "asset" function without arguments`);
257
+ const map = {};
258
+ const extensions = [];
259
+ for (const v of variants) {
260
+ const e = getUrlFileExtension(v);
261
+ map[e] = v;
262
+ extensions.push(e);
263
+ }
264
+ const type = getType(extensions);
265
+ const getSource = once(() => {
266
+ const support = SUPPORT_MAPS[type];
267
+ for (const extension of extensions) if (extension in support) {
268
+ if (support[extension]) return map[extension];
269
+ } else return map[extension];
270
+ if (DEV) throw new Error(`No matching asset was found for ${variants.map((v) => `"${v}"`).join(", ")}`);
271
+ return "";
272
+ });
273
+ return {
274
+ get source() {
275
+ return getSource();
276
+ },
277
+ get type() {
278
+ return type;
279
+ },
280
+ id: generateRandomId()
281
+ };
282
+ }, { getCacheKey: (variants) => variants.join("~") });
283
+ /**
284
+ * Memoizes and returns an asset selection object based on provided file variants.
285
+ * The selected asset depends on the client's support for various formats.
286
+ *
287
+ * @param {...string} variants - A variable number of strings, each representing a potential asset file URL.
288
+ * @returns {NovelyAsset} An object representing the selected asset with `source` and `type` properties.
289
+ *
290
+ * @throws {Error} If in DEV mode and no arguments are provided.
291
+ * @example
292
+ * ```
293
+ * import { asset } from 'novely';
294
+ *
295
+ * // Passed first have higher priority
296
+ * const classroom = asset(
297
+ * 'classroom.avif',
298
+ * 'classroom.webp',
299
+ * 'classroom.jpeg'
300
+ * );
301
+ *
302
+ * setTimeout(() => {
303
+ * console.log(classroom.source);
304
+ * }, 100);
305
+ * ```
306
+ */
307
+ const asset = (...variants) => {
308
+ return assetPrivate(variants);
309
+ };
310
+ asset.image = (source) => {
311
+ if (assetPrivate.cache.has(source)) return assetPrivate.cache.get(source);
312
+ const asset = {
313
+ type: "image",
314
+ source,
315
+ id: generateRandomId()
316
+ };
317
+ assetPrivate.cache.set(source, asset);
318
+ return asset;
319
+ };
320
+ asset.audio = (source) => {
321
+ if (assetPrivate.cache.has(source)) return assetPrivate.cache.get(source);
322
+ const asset = {
323
+ type: "audio",
324
+ source,
325
+ id: generateRandomId()
326
+ };
327
+ assetPrivate.cache.set(source, asset);
328
+ return asset;
329
+ };
330
+ const unwrapAsset = (asset) => {
331
+ return isAsset(asset) ? asset.source : asset;
332
+ };
333
+ const unwrapAudioAsset = (asset) => {
334
+ if (DEV && isAsset(asset) && asset.type !== "audio") throw new Error("Attempt to use non-audio asset in audio action", { cause: asset });
335
+ return unwrapAsset(asset);
336
+ };
337
+ const unwrapImageAsset = (asset) => {
338
+ if (DEV && isAsset(asset) && asset.type !== "image") throw new Error("Attempt to use non-image asset in action that requires image assets", { cause: asset });
339
+ return unwrapAsset(asset);
340
+ };
341
+
342
+ //#endregion
343
+ //#region src/utilities/actions-processing.ts
344
+ const isExitImpossible = (path) => {
345
+ const blockStatements = path.filter(([item]) => isBlockStatement(item));
346
+ const blockExitStatements = path.filter(([item]) => isBlockExitStatement(item));
347
+ /**
348
+ * There were no blocks nor exits from blocks
349
+ */
350
+ if (blockStatements.length === 0 && blockExitStatements.length === 0) return true;
351
+ /**
352
+ * There is block that can be exited
353
+ */
354
+ if (blockStatements.length > blockExitStatements.length) return false;
355
+ return !blockExitStatements.every(([name], i) => name && name.startsWith(blockStatements[i][0]));
356
+ };
357
+ const createReferFunction = ({ story, onUnknownSceneHit }) => {
358
+ const refer = async (path) => {
359
+ /**
360
+ * Are we ready to return a value.
361
+ * We need to know are there any "unknown" scenes or not
362
+ */
363
+ const { promise: ready, resolve: setReady } = Promise.withResolvers();
364
+ let current = story;
365
+ let precurrent = story;
366
+ const blocks = [];
367
+ const refer = async () => {
368
+ for (const [type, val] of path) if (type === "jump") {
369
+ if (!current[val]) {
370
+ setReady(true);
371
+ await onUnknownSceneHit(val);
372
+ }
373
+ if (DEV && !story[val]) throw new Error(`Attempt to jump to unknown scene "${val}"`);
374
+ if (DEV && story[val].length === 0) throw new Error(`Attempt to jump to empty scene "${val}"`);
375
+ precurrent = story;
376
+ current = current[val];
377
+ } else if (type === null) {
378
+ precurrent = current;
379
+ current = current[val];
380
+ } else if (type === "choice") {
381
+ blocks.push(precurrent);
382
+ current = current[val + 1][1];
383
+ } else if (type === "condition") {
384
+ blocks.push(precurrent);
385
+ current = current[2][val];
386
+ } else if (type === "block") {
387
+ blocks.push(precurrent);
388
+ current = story[val];
389
+ } else if (type === "block:exit" || type === "choice:exit" || type === "condition:exit") current = blocks.pop();
390
+ setReady(false);
391
+ return current;
392
+ };
393
+ const value = refer();
394
+ return {
395
+ found: await ready,
396
+ value
397
+ };
398
+ };
399
+ const referGuarded = async (path) => {
400
+ return await (await refer(path)).value;
401
+ };
402
+ return {
403
+ refer,
404
+ referGuarded
405
+ };
406
+ };
407
+ const exitPath = async ({ path, refer, onExitImpossible }) => {
408
+ const last = path.at(-1);
409
+ const ignore = [];
410
+ let wasExitImpossible = false;
411
+ /**
412
+ * - should be an array
413
+ * - first element is action name
414
+ */
415
+ if (!isAction(await refer(path))) if (last && isNull(last[0]) && isNumber(last[1])) last[1]--;
416
+ else path.pop();
417
+ if (isExitImpossible(path)) {
418
+ const referred = await refer(path);
419
+ if (isAction(referred) && isSkippedDuringRestore(referred[0])) onExitImpossible?.();
420
+ wasExitImpossible = true;
421
+ return { exitImpossible: wasExitImpossible };
422
+ }
423
+ for (let i = path.length - 1; i > 0; i--) {
424
+ const [name] = path[i];
425
+ /**
426
+ * Remember already exited paths
427
+ */
428
+ if (isBlockExitStatement(name)) ignore.push(name);
429
+ /**
430
+ * Ignore everything that we do not need there
431
+ */
432
+ if (!isBlockStatement(name)) continue;
433
+ /**
434
+ * When we found an already exited path we remove it from the list
435
+ */
436
+ if (ignore.at(-1)?.startsWith(name)) {
437
+ ignore.pop();
438
+ continue;
439
+ }
440
+ /**
441
+ * Exit from the path
442
+ */
443
+ path.push([`${name}:exit`]);
444
+ const prev = findLastPathItemBeforeItemOfType(path.slice(0, i + 1), name);
445
+ /**
446
+ * When possible also go to the next action (or exit from one layer above)
447
+ */
448
+ if (prev) path.push([null, prev[1] + 1]);
449
+ /**
450
+ * If we added an `[null, int]` but it points not to action, then
451
+ *
452
+ * - remove that item
453
+ * - close another block
454
+ */
455
+ if (!isAction(await refer(path))) {
456
+ path.pop();
457
+ continue;
458
+ }
459
+ break;
460
+ }
461
+ return { exitImpossible: wasExitImpossible };
462
+ };
463
+ const nextPath = (path) => {
464
+ /**
465
+ * Last path element
466
+ */
467
+ const last = path.at(-1);
468
+ if (last && (isNull(last[0]) || last[0] === "jump") && isNumber(last[1])) last[1]++;
469
+ else path.push([null, 0]);
470
+ return path;
471
+ };
472
+ const collectActionsBeforeBlockingAction = async ({ path, refer, clone }) => {
473
+ const collection = [];
474
+ let action = await refer(path);
475
+ while (true) {
476
+ if (action == void 0) {
477
+ const { exitImpossible } = await exitPath({
478
+ path,
479
+ refer
480
+ });
481
+ if (exitImpossible) break;
482
+ }
483
+ if (!action) break;
484
+ if (isBlockingAction(action)) {
485
+ const [name, ...props] = action;
486
+ if (name === "choice") {
487
+ const choiceProps = props;
488
+ for (let i = 0; i < choiceProps.length; i++) {
489
+ const branchContent = choiceProps[i];
490
+ /**
491
+ * This is a title
492
+ */
493
+ if (!Array.isArray(branchContent)) continue;
494
+ const virtualPath = clone(path);
495
+ virtualPath.push(["choice", i], [null, 0]);
496
+ const innerActions = await collectActionsBeforeBlockingAction({
497
+ path: virtualPath,
498
+ refer,
499
+ clone
500
+ });
501
+ collection.push(...innerActions);
502
+ }
503
+ } else if (name === "condition") {
504
+ const conditionProps = props;
505
+ const conditions = Object.keys(conditionProps[1]);
506
+ for (const condition of conditions) {
507
+ const virtualPath = clone(path);
508
+ virtualPath.push(["condition", condition], [null, 0]);
509
+ const innerActions = await collectActionsBeforeBlockingAction({
510
+ path: virtualPath,
511
+ refer,
512
+ clone
513
+ });
514
+ collection.push(...innerActions);
515
+ }
516
+ }
517
+ break;
518
+ }
519
+ collection.push(action);
520
+ /**
521
+ * These special actions requires path change
522
+ */
523
+ if (action[0] === "jump") path = [["jump", action[1]], [null, 0]];
524
+ else if (action[0] == "block") path.push(["block", action[1]], [null, 0]);
525
+ else nextPath(path);
526
+ action = await refer(path);
527
+ }
528
+ return collection;
529
+ };
530
+ const findLastPathItemBeforeItemOfType = (path, name) => {
531
+ return path.findLast(([_name, _value], i, array) => {
532
+ const next = array[i + 1];
533
+ return isNull(_name) && isNumber(_value) && next != null && next[0] === name;
534
+ });
535
+ };
536
+ const getOppositeAction = (action) => {
537
+ return {
538
+ showCharacter: "hideCharacter",
539
+ playSound: "stopSound",
540
+ playMusic: "stopMusic",
541
+ voice: "stopVoice"
542
+ }[action];
543
+ };
544
+ const getActionsFromPath = async ({ story, path, filter, referGuarded }) => {
545
+ /**
546
+ * Current item in the story
547
+ */
548
+ let current = story;
549
+ /**
550
+ * Previous `current` value
551
+ */
552
+ let precurrent;
553
+ /**
554
+ * Should we ignore some actions
555
+ */
556
+ let ignoreNestedBefore = null;
557
+ /**
558
+ * Current item of type `[null, int]`
559
+ */
560
+ let index = 0;
561
+ /**
562
+ * Skipped action that should be preserved
563
+ */
564
+ let skipPreserve = void 0;
565
+ /**
566
+ * Actions that are either considered user action or skipped during restore process
567
+ */
568
+ const skip = /* @__PURE__ */ new Set();
569
+ /**
570
+ * Cound of items of type `[null, int]`
571
+ */
572
+ const max = path.reduce((acc, [type, val]) => {
573
+ if (isNull(type) && isNumber(val)) return acc + 1;
574
+ return acc;
575
+ }, 0);
576
+ const queue = [];
577
+ const blocks = [];
578
+ await referGuarded(path);
579
+ for (const [type, val] of path) if (type === "jump") {
580
+ precurrent = story;
581
+ current = current[val];
582
+ } else if (type === null) {
583
+ precurrent = current;
584
+ if (isNumber(val)) {
585
+ index++;
586
+ let startIndex = 0;
587
+ if (ignoreNestedBefore) {
588
+ const prev = findLastPathItemBeforeItemOfType(path.slice(0, index), ignoreNestedBefore);
589
+ if (prev) {
590
+ startIndex = prev[1];
591
+ ignoreNestedBefore = null;
592
+ }
593
+ }
594
+ /**
595
+ * Запустим все экшены которые идут в `[null, int]` от `0` до `int`
596
+ * Почему-то потребовалось изменить `<` на `<=`, чтобы последний action попадал сюда
597
+ */
598
+ for (let i = startIndex; i <= val; i++) {
599
+ const item = current[i];
600
+ /**
601
+ * In case of broken save at least not throw
602
+ * But is should not happen
603
+ */
604
+ if (!isAction(item)) continue;
605
+ const [action] = item;
606
+ const last = index === max && i === val;
607
+ const shouldSkip = isSkippedDuringRestore(action) || isUserRequiredAction(item);
608
+ if (shouldSkip) skip.add(item);
609
+ if (shouldSkip && last) skipPreserve = item;
610
+ if (filter && shouldSkip && !last) continue;
611
+ else queue.push(item);
612
+ }
613
+ }
614
+ current = current[val];
615
+ } else if (type === "choice") {
616
+ blocks.push(precurrent);
617
+ current = current[val + 1][1];
618
+ } else if (type === "condition") {
619
+ blocks.push(precurrent);
620
+ current = current[2][val];
621
+ } else if (type === "block") {
622
+ blocks.push(precurrent);
623
+ current = story[val];
624
+ } else if (type === "block:exit" || type === "choice:exit" || type === "condition:exit") {
625
+ current = blocks.pop();
626
+ ignoreNestedBefore = type.slice(0, -5);
627
+ }
628
+ return {
629
+ queue,
630
+ skip,
631
+ skipPreserve
632
+ };
633
+ };
634
+ const createQueueProcessor = (queue, options) => {
635
+ const processedQueue = [];
636
+ const keep = /* @__PURE__ */ new Set();
637
+ const characters = /* @__PURE__ */ new Set();
638
+ const audio = {
639
+ music: /* @__PURE__ */ new Set(),
640
+ sounds: /* @__PURE__ */ new Set()
641
+ };
642
+ /**
643
+ * Get the next actions array.
644
+ */
645
+ const next = (i) => queue.slice(i + 1);
646
+ for (const [i, item] of queue.entries()) {
647
+ const [action, ...params] = item;
648
+ if (options.skip.has(item) && item !== options.skipPreserve) continue;
649
+ keep.add(action);
650
+ if (action === "function" || action === "custom") {
651
+ if (action === "custom") {
652
+ const fn = params[0];
653
+ if (fn.callOnlyLatest) {
654
+ if (next(i).some(([name, func]) => {
655
+ if (name !== "custom") return;
656
+ const isIdenticalId = Boolean(func.id && fn.id && func.id === fn.id);
657
+ const isIdenticalByReference = func === fn;
658
+ const isIdenticalByCode = String(func) === String(fn);
659
+ return isIdenticalId || isIdenticalByReference || isIdenticalByCode;
660
+ })) continue;
661
+ } else if (fn.skipOnRestore) {
662
+ if (fn.skipOnRestore(next(i))) continue;
663
+ }
664
+ }
665
+ processedQueue.push(item);
666
+ } else if (action === "playSound") {
667
+ const closing = getOppositeAction(action);
668
+ if (next(i).some((item) => {
669
+ if (isUserRequiredAction(item) || isSkippedDuringRestore(item[0])) return true;
670
+ const [_action, target] = item;
671
+ if (target !== params[0]) return false;
672
+ return _action === closing || _action === action;
673
+ })) continue;
674
+ audio.sounds.add(unwrapAsset(params[0]));
675
+ processedQueue.push(item);
676
+ } else if (action === "showCharacter" || action === "playMusic" || action === "voice") {
677
+ const closing = getOppositeAction(action);
678
+ if (next(i).some(([_action, target]) => {
679
+ if (target !== params[0] && action !== "voice") return false;
680
+ /**
681
+ * It either will be closed OR same action will be ran again
682
+ */
683
+ return action === "playMusic" && _action === "pauseMusic" || _action === closing || _action === action;
684
+ })) continue;
685
+ /**
686
+ * Actually, we do not need check above to add there things to keep because if something was hidden already we could not keep it visible
687
+ */
688
+ if (action === "showCharacter") characters.add(params[0]);
689
+ else if (action === "playMusic") audio.music.add(unwrapAsset(params[0]));
690
+ processedQueue.push(item);
691
+ } else if (action === "showBackground" || action === "preload") {
692
+ if (next(i).some(([_action]) => action === _action)) continue;
693
+ processedQueue.push(item);
694
+ } else if (action === "animateCharacter") {
695
+ if (next(i).some(([_action, character], j, array) => {
696
+ if (action === _action && character === params[0]) return true;
697
+ const next = array.slice(j);
698
+ const characterWillAnimate = next.some(([__action, __character]) => action === __action);
699
+ const hasBlockingActions = next.some((item) => options.skip.has(item));
700
+ const differentCharacterWillAnimate = !hasBlockingActions && next.some(([__action, __character]) => __action === action && __character !== params[0]);
701
+ return characterWillAnimate && hasBlockingActions || differentCharacterWillAnimate;
702
+ })) continue;
703
+ processedQueue.push(item);
704
+ } else processedQueue.push(item);
705
+ }
706
+ const run = async (match) => {
707
+ for (const item of processedQueue) {
708
+ const result = match(item);
709
+ if (isPromise(result)) await result;
710
+ }
711
+ processedQueue.length = 0;
712
+ };
713
+ return {
714
+ run,
715
+ keep: {
716
+ keep,
717
+ characters,
718
+ audio
719
+ }
720
+ };
721
+ };
722
+
723
+ //#endregion
724
+ //#region src/utilities/controlled-promise.ts
725
+ const createControlledPromise = () => {
726
+ const object = {
727
+ resolve: null,
728
+ reject: null,
729
+ promise: null,
730
+ cancel: null
731
+ };
732
+ const init = () => {
733
+ object.promise = new Promise((resolve, reject) => {
734
+ object.reject = reject;
735
+ object.resolve = (value) => {
736
+ resolve({
737
+ cancelled: false,
738
+ value
739
+ });
740
+ };
741
+ object.cancel = () => {
742
+ resolve({
743
+ cancelled: true,
744
+ value: null
745
+ });
746
+ init();
747
+ };
748
+ });
749
+ };
750
+ return init(), object;
751
+ };
752
+
753
+ //#endregion
754
+ //#region src/utilities/resources.ts
755
+ const getUrlFileExtension = (address) => {
756
+ try {
757
+ const { pathname } = new URL(address, location.href);
758
+ /**
759
+ * By using pathname we remove search params from URL, but some things are still preserved
760
+ *
761
+ * Imagine pathname like `image.png!private:1230`
762
+ */
763
+ return pathname.split(".").at(-1).split("!")[0].split(":")[0];
764
+ } catch (error) {
765
+ if (DEV) console.error(new Error(`Could not construct URL "${address}".`, { cause: error }));
766
+ return "";
767
+ }
768
+ };
769
+ const fetchContentType = async (url, request) => {
770
+ try {
771
+ return (await request(url, { method: "HEAD" })).headers.get("Content-Type") || "";
772
+ } catch (error) {
773
+ if (DEV) console.error(new Error(`Failed to fetch file at "${url}"`, { cause: error }));
774
+ return "";
775
+ }
776
+ };
777
+ const getResourseType = memoize(async ({ url, request }) => {
778
+ const extension = getUrlFileExtension(url);
779
+ if (HOWLER_SUPPORTED_FILE_FORMATS.has(extension)) return "audio";
780
+ if (SUPPORTED_IMAGE_FILE_FORMATS.has(extension)) return "image";
781
+ /**
782
+ * If checks above didn't worked we will fetch content type
783
+ * This might not work because of CORS
784
+ */
785
+ const contentType = await fetchContentType(url, request);
786
+ if (contentType.includes("audio")) return "audio";
787
+ if (contentType.includes("image")) return "image";
788
+ return "other";
789
+ }, { getCacheKey: ({ url }) => url });
790
+
791
+ //#endregion
792
+ //#region src/utilities/stack.ts
793
+ const getStack = memoize((_) => {
794
+ return [];
795
+ }, {
796
+ cache: STACK_MAP,
797
+ getCacheKey: (ctx) => ctx.id
798
+ });
799
+ const createUseStackFunction = (renderer) => {
800
+ const useStack = (context) => {
801
+ const ctx = typeof context === "string" ? renderer.getContext(context) : context;
802
+ const stack = getStack(ctx);
803
+ return {
804
+ get previous() {
805
+ return stack.previous;
806
+ },
807
+ get value() {
808
+ return stack.at(-1);
809
+ },
810
+ set value(value) {
811
+ stack[stack.length - 1] = value;
812
+ },
813
+ back() {
814
+ stack.previous = stack.length > 1 ? stack.pop() : this.value;
815
+ ctx.meta.goingBack = true;
816
+ },
817
+ push(value) {
818
+ stack.push(value);
819
+ },
820
+ clear() {
821
+ stack.previous = void 0;
822
+ stack.length = 0;
823
+ stack.length = 1;
824
+ }
825
+ };
826
+ };
827
+ return useStack;
828
+ };
829
+
830
+ //#endregion
831
+ //#region src/utilities/story.ts
832
+ const flatActions = (item) => {
833
+ return item.flatMap((data) => {
834
+ const type = data[0];
835
+ /**
836
+ * This is not just an action like `['name', ...arguments]`, but an array of actions
837
+ */
838
+ if (Array.isArray(type)) return flatActions(data);
839
+ return [data];
840
+ });
841
+ };
842
+ /**
843
+ * Transforms `(ValidAction | ValidAction[])[]` to `ValidAction[]`. Mutates provided `Story`
844
+ */
845
+ const flatStory = (story) => {
846
+ for (const key in story) story[key] = flatActions(story[key]);
847
+ return story;
848
+ };
849
+
850
+ //#endregion
851
+ //#region src/utilities/internationalization.ts
852
+ const getLanguage = (languages) => {
853
+ let { language } = navigator;
854
+ if (languages.includes(language)) return language;
855
+ else if (languages.includes(language = language.slice(0, 2))) return language;
856
+ else if (language = languages.find((value) => navigator.languages.includes(value))) return language;
857
+ /**
858
+ * We'v checked the `en-GB` format, `en` format, and maybe any second languages, but there were no matches
859
+ */
860
+ return languages[0];
861
+ };
862
+ const getIntlLanguageDisplayName = memoize((lang) => {
863
+ /**
864
+ * When using Intl fails we just return language key.
865
+ */
866
+ try {
867
+ return new Intl.DisplayNames([lang], { type: "language" }).of(lang) || lang;
868
+ } catch {
869
+ return lang;
870
+ }
871
+ });
872
+ /**
873
+ * Capitalizes the string
874
+ * @param str String without emojis or complex graphemes
875
+ */
876
+ const capitalize = (str) => {
877
+ return str[0].toUpperCase() + str.slice(1);
878
+ };
879
+
880
+ //#endregion
881
+ //#region src/utilities/noop.ts
882
+ const noop = () => {};
883
+
884
+ //#endregion
885
+ //#region src/utilities/store.ts
886
+ const getLanguageFromStore = (store) => {
887
+ return store.get().meta[0];
888
+ };
889
+ const getVolumeFromStore = (store) => {
890
+ const { meta } = store.get();
891
+ return {
892
+ music: meta[2],
893
+ sound: meta[3],
894
+ voice: meta[4]
895
+ };
896
+ };
897
+
898
+ //#endregion
899
+ //#region src/utilities/array.ts
900
+ const mapSet = (set, fn) => {
901
+ return [...set].map(fn);
902
+ };
903
+ const toArray = (target) => {
904
+ return Array.isArray(target) ? target : [target];
905
+ };
906
+
907
+ //#endregion
908
+ //#region src/utilities/else.ts
909
+ const getCharactersData = (characters) => {
910
+ const mapped = Object.entries(characters).map(([key, value]) => [key, {
911
+ name: value.name,
912
+ emotions: Object.keys(value.emotions)
913
+ }]);
914
+ return Object.fromEntries(mapped);
915
+ };
916
+
917
+ //#endregion
918
+ //#region src/store.ts
919
+ const store = (current, subscribers = /* @__PURE__ */ new Set()) => {
920
+ const subscribe = (cb) => {
921
+ subscribers.add(cb), cb(current);
922
+ return () => {
923
+ subscribers.delete(cb);
924
+ };
925
+ };
926
+ const push = (value) => {
927
+ for (const cb of subscribers) cb(value);
928
+ };
929
+ const update = (fn) => {
930
+ push(current = fn(current));
931
+ };
932
+ const set = (val) => {
933
+ update(() => val);
934
+ };
935
+ const get = () => {
936
+ return current;
937
+ };
938
+ return {
939
+ subscribe,
940
+ update,
941
+ set,
942
+ get
943
+ };
944
+ };
945
+ const derive = (input, map) => {
946
+ return {
947
+ get: () => map(input.get()),
948
+ subscribe: (subscriber) => {
949
+ return input.subscribe((value) => {
950
+ return subscriber(map(value));
951
+ });
952
+ }
953
+ };
954
+ };
955
+ const immutable = (value) => {
956
+ return {
957
+ get: () => value,
958
+ subscribe: (subscriber) => {
959
+ subscriber(value);
960
+ return noop;
961
+ }
962
+ };
963
+ };
964
+
965
+ //#endregion
966
+ //#region src/custom-action.ts
967
+ const createCustomActionNode = (id) => {
968
+ const div = document.createElement("div");
969
+ div.setAttribute("data-id", id);
970
+ return div;
971
+ };
972
+ const getCustomActionHolder = (ctx, fn) => {
973
+ const cached = CUSTOM_ACTION_MAP.get(ctx.id + fn.key);
974
+ if (cached) return cached;
975
+ const holder = {
976
+ node: null,
977
+ fn,
978
+ localData: {}
979
+ };
980
+ CUSTOM_ACTION_MAP.set(ctx.id + fn.key, holder);
981
+ return holder;
982
+ };
983
+ const getCustomActionInstances = (ctx) => {
984
+ const existing = CUSTOM_ACTION_INSTANCES_MAP.get(ctx.id);
985
+ if (existing) return existing;
986
+ const array = [];
987
+ CUSTOM_ACTION_INSTANCES_MAP.set(ctx.id, array);
988
+ return array;
989
+ };
990
+ const cleanInstance = ({ list }) => {
991
+ while (list.length) try {
992
+ list.pop()();
993
+ } catch (e) {
994
+ console.error(e);
995
+ }
996
+ };
997
+ const handleCustomAction = (ctx, fn, { lang, state, setMountElement, remove: renderersRemove, getStack, templateReplace, paused, ticker, request }) => {
998
+ const holder = getCustomActionHolder(ctx, fn);
999
+ const instances = getCustomActionInstances(ctx);
1000
+ const cleanupNode = () => {
1001
+ if (!instances.some((item) => item.fn.id === fn.id && item.fn.key === fn.key)) {
1002
+ holder.node = null;
1003
+ setMountElement(null);
1004
+ }
1005
+ };
1006
+ const dispose = () => {
1007
+ if (instance.disposed) return;
1008
+ ticker.detach();
1009
+ instance.onBack = noop;
1010
+ instance.onForward = noop;
1011
+ instance.disposed = true;
1012
+ };
1013
+ const instance = {
1014
+ fn,
1015
+ disposed: false,
1016
+ list: [dispose],
1017
+ node: cleanupNode,
1018
+ onBack: noop,
1019
+ onForward: noop
1020
+ };
1021
+ instances.push(instance);
1022
+ const getDomNodes = (insert = true) => {
1023
+ if (holder.node || !insert) {
1024
+ setMountElement(holder.node);
1025
+ return {
1026
+ element: holder.node,
1027
+ root: ctx.root
1028
+ };
1029
+ }
1030
+ holder.node = insert ? createCustomActionNode(fn.key) : null;
1031
+ setMountElement(holder.node);
1032
+ return {
1033
+ element: holder.node,
1034
+ root: ctx.root
1035
+ };
1036
+ };
1037
+ const clear = (func) => {
1038
+ instance.list.push(once(func));
1039
+ };
1040
+ const data = (updatedData) => {
1041
+ if (updatedData) return holder.localData = updatedData;
1042
+ return holder.localData;
1043
+ };
1044
+ const remove = () => {
1045
+ cleanInstance(instance);
1046
+ holder.node = null;
1047
+ setMountElement(null);
1048
+ renderersRemove();
1049
+ };
1050
+ const stack = getStack(ctx);
1051
+ const getSave = () => {
1052
+ return stack.value;
1053
+ };
1054
+ return fn({
1055
+ flags: ctx.meta,
1056
+ lang,
1057
+ state,
1058
+ data,
1059
+ dataAtKey: (key) => CUSTOM_ACTION_MAP.get(ctx.id + key)?.localData || null,
1060
+ templateReplace,
1061
+ clear,
1062
+ remove,
1063
+ rendererContext: ctx,
1064
+ getDomNodes,
1065
+ getSave,
1066
+ contextKey: ctx.id,
1067
+ paused: ctx.meta.preview ? immutable(false) : paused,
1068
+ ticker,
1069
+ request,
1070
+ onBack: (fn) => {
1071
+ instance.onBack = fn;
1072
+ },
1073
+ onForward: (fn) => {
1074
+ instance.onForward = fn;
1075
+ }
1076
+ });
1077
+ };
1078
+
1079
+ //#endregion
1080
+ //#region src/preloading.ts
1081
+ const ACTION_NAME_TO_VOLUME_MAP = {
1082
+ playMusic: "music",
1083
+ playSound: "sound",
1084
+ voice: "voice"
1085
+ };
1086
+ /**
1087
+ * Adds asset to `ASSETS_TO_PRELOAD` firstly checking if is was already preloaded
1088
+ */
1089
+ const enqueueAssetForPreloading = (asset) => {
1090
+ if (!PRELOADED_ASSETS.has(asset)) ASSETS_TO_PRELOAD.add(asset);
1091
+ };
1092
+ /**
1093
+ * Preloads assets
1094
+ */
1095
+ const handleAssetsPreloading = async ({ request, limiter, preloadAudioBlocking, preloadImageBlocking }) => {
1096
+ const list = mapSet(ASSETS_TO_PRELOAD, (asset) => {
1097
+ return limiter(async () => {
1098
+ switch (await getResourseType({
1099
+ url: asset,
1100
+ request
1101
+ })) {
1102
+ case "audio":
1103
+ await preloadAudioBlocking(asset);
1104
+ break;
1105
+ case "image":
1106
+ await preloadImageBlocking(asset);
1107
+ break;
1108
+ }
1109
+ ASSETS_TO_PRELOAD.delete(asset);
1110
+ PRELOADED_ASSETS.add(asset);
1111
+ });
1112
+ });
1113
+ /**
1114
+ * `allSettled` is used because even if error happens game should run
1115
+ *
1116
+ * Ideally, there could be a notification for player, maybe developer could be also notified
1117
+ * But I don't think it's really needed
1118
+ */
1119
+ await Promise.allSettled(list);
1120
+ ASSETS_TO_PRELOAD.clear();
1121
+ };
1122
+ const huntAssets = async ({ volume, lang, characters, action, props, handle, request }) => {
1123
+ if (action === "showBackground") {
1124
+ if (isAsset(props[0]) || isString(props[0])) {
1125
+ handle(unwrapImageAsset(props[0]));
1126
+ return;
1127
+ }
1128
+ if (props[0] && typeof props[0] === "object") for (const value of Object.values(props[0])) if (isAsset(value)) handle(unwrapImageAsset(value));
1129
+ else handle(value);
1130
+ return;
1131
+ }
1132
+ const getVolumeFor = (action) => {
1133
+ if (action in ACTION_NAME_TO_VOLUME_MAP) return volume[ACTION_NAME_TO_VOLUME_MAP[action]];
1134
+ return 0;
1135
+ };
1136
+ /**
1137
+ * Here "stop" action also matches condition, but because `ASSETS_TO_PRELOAD` is a Set, there is no problem
1138
+ */
1139
+ if (isAudioAction(action) && isString(props[0])) {
1140
+ if (getVolumeFor(action) > 0) handle(unwrapAudioAsset(props[0]));
1141
+ return;
1142
+ }
1143
+ if (action === "voice" && typeof props[0] === "object") {
1144
+ /**
1145
+ * Early return in case of disabled voices
1146
+ */
1147
+ if (getVolumeFor("voice") == 0) return;
1148
+ for (const [language, value] of Object.entries(props[0])) if (language === lang)
1149
+ /**
1150
+ * todo: decide how to make language comparison (maybe use some function)
1151
+ *
1152
+ * We can use en-US for both en-US and en-GB. Same thing applies to `dialog` and `text` action.
1153
+ * Maybe voice over language can be selected separately
1154
+ */
1155
+ value && handle(unwrapAudioAsset(value));
1156
+ return;
1157
+ }
1158
+ /**
1159
+ * Load characters
1160
+ */
1161
+ if (action === "showCharacter" && isString(props[0]) && isString(props[1])) {
1162
+ const images = toArray(characters[props[0]].emotions[props[1]]);
1163
+ for (const asset of images) handle(unwrapImageAsset(asset));
1164
+ return;
1165
+ }
1166
+ /**
1167
+ * Custom action assets
1168
+ */
1169
+ if (action === "custom" && props[0].assets) {
1170
+ const assets = props[0].assets;
1171
+ let resolved = [];
1172
+ if (typeof assets === "function") {
1173
+ resolved = await Promise.race([assets({ request }), new Promise((resolve) => setTimeout(resolve, 250, []))]);
1174
+ Object.defineProperty(props[0], "assets", {
1175
+ value: async () => resolved,
1176
+ writable: false
1177
+ });
1178
+ } else resolved = assets;
1179
+ for (const asset of resolved) isAsset(asset) ? handle(asset.source) : handle(asset);
1180
+ return;
1181
+ }
1182
+ if (action === "choice") for (let i = 1; i < props.length; i++) {
1183
+ const data = props[i];
1184
+ if (Array.isArray(data)) {
1185
+ if (data[5]) handle(unwrapImageAsset(data[5]));
1186
+ }
1187
+ }
1188
+ };
1189
+
1190
+ //#endregion
1191
+ //#region src/storage.ts
1192
+ /**
1193
+ * Stores data in localStorage
1194
+ */
1195
+ const storageAdapterLocal = ({ key }) => {
1196
+ return {
1197
+ async get() {
1198
+ const fallback = {
1199
+ saves: [],
1200
+ data: {},
1201
+ meta: []
1202
+ };
1203
+ try {
1204
+ const value = localStorage.getItem(key);
1205
+ return value ? JSON.parse(value) : fallback;
1206
+ } catch {
1207
+ return fallback;
1208
+ }
1209
+ },
1210
+ async set(data) {
1211
+ try {
1212
+ localStorage.setItem(key, JSON.stringify(data));
1213
+ } catch {}
1214
+ }
1215
+ };
1216
+ };
1217
+
1218
+ //#endregion
1219
+ //#region src/translation.ts
1220
+ const RGX = /{{(.*?)}}/g;
1221
+ const split = (input, delimeters) => {
1222
+ const output = [];
1223
+ for (const delimeter of delimeters) {
1224
+ if (!input) break;
1225
+ const [start, end] = input.split(delimeter, 2);
1226
+ output.push(start);
1227
+ input = end;
1228
+ }
1229
+ output.push(input);
1230
+ return output;
1231
+ };
1232
+ /**
1233
+ * Turns any allowed content into string
1234
+ * @param c Content
1235
+ */
1236
+ const flattenAllowedContent = (c, state) => {
1237
+ if (Array.isArray(c)) return c.map((item) => flattenAllowedContent(item, state)).join("<br>");
1238
+ if (typeof c === "function") return flattenAllowedContent(c(state), state);
1239
+ return c;
1240
+ };
1241
+ const replace = (input, data, pluralization, actions, pr) => {
1242
+ return input.replaceAll(RGX, (x, key, y) => {
1243
+ x = 0;
1244
+ y = data;
1245
+ const [pathstr, plural, action] = split(key.trim(), ["@", "%"]);
1246
+ if (!pathstr) return "";
1247
+ const path = pathstr.split(".");
1248
+ while (y && x < path.length) y = y[path[x++]];
1249
+ if (plural && pluralization && y && pr) y = pluralization[plural][pr.select(y)];
1250
+ const actionHandler = actions && action ? actions[action] : void 0;
1251
+ if (actionHandler) y = actionHandler(y);
1252
+ return y == null ? "" : y;
1253
+ });
1254
+ };
1255
+
1256
+ //#endregion
1257
+ //#region src/utilities/actions.ts
1258
+ /**
1259
+ * In this case actions that get overwritten with another action
1260
+ */
1261
+ const VIRTUAL_ACTIONS = ["say"];
1262
+ const buildActionObject = ({ rendererActions, nativeActions, characters }) => {
1263
+ const allActions = [...nativeActions, ...VIRTUAL_ACTIONS];
1264
+ const object = { ...rendererActions };
1265
+ for (let action of allActions) object[action] = (...props) => {
1266
+ if (action === "say") {
1267
+ action = "dialog";
1268
+ const [character] = props;
1269
+ if (DEV && !characters[character]) throw new Error(`Attempt to call Say action with unknown character "${character}"`);
1270
+ } else if (action === "choice") if (props.slice(1).every((choice) => !Array.isArray(choice))) for (let i = 1; i < props.length; i++) {
1271
+ const choice = props[i];
1272
+ props[i] = [
1273
+ choice.title,
1274
+ flatActions(choice.children),
1275
+ choice.active,
1276
+ choice.visible,
1277
+ choice.onSelect,
1278
+ choice.image
1279
+ ];
1280
+ }
1281
+ else for (let i = 1; i < props.length; i++) {
1282
+ const choice = props[i];
1283
+ if (Array.isArray(choice)) choice[1] = flatActions(choice[1]);
1284
+ }
1285
+ else if (action === "condition") {
1286
+ const actions = props[1];
1287
+ for (const key in actions) actions[key] = flatActions(actions[key]);
1288
+ }
1289
+ return [action, ...props];
1290
+ };
1291
+ return object;
1292
+ };
1293
+
1294
+ //#endregion
1295
+ //#region src/utilities/dialog-overview.ts
1296
+ const getDialogOverview = async function() {
1297
+ /**
1298
+ * Dialog Overview is possible only in main context
1299
+ */
1300
+ const { value: save } = this.getStack();
1301
+ const stateSnapshots = save[3];
1302
+ /**
1303
+ * Easy mode
1304
+ */
1305
+ if (stateSnapshots.length == 0) return [];
1306
+ const { queue } = await getActionsFromPath({
1307
+ story: this.story,
1308
+ path: save[0],
1309
+ filter: false,
1310
+ referGuarded: this.referGuarded
1311
+ });
1312
+ const lang = this.getLanguage();
1313
+ const dialogItems = [];
1314
+ /**
1315
+ * For every available state snapshot find dialog corresponding to it
1316
+ */
1317
+ for (let p = 0, a = stateSnapshots.length, i = queue.length - 1; a > 0 && i > 0; i--) {
1318
+ const action = queue[i];
1319
+ if (action[0] === "dialog") {
1320
+ const [_, name, text] = action;
1321
+ let voice = void 0;
1322
+ /**
1323
+ * Search for the most recent `voice` action before current dialog
1324
+ */
1325
+ for (let j = i - 1; j > p && j > 0; j--) {
1326
+ const action = queue[j];
1327
+ if (isUserRequiredAction(action) || isSkippedDuringRestore(action[0])) break;
1328
+ if (action[0] === "stopVoice") break;
1329
+ if (action[0] === "voice") {
1330
+ voice = action[1];
1331
+ break;
1332
+ }
1333
+ }
1334
+ dialogItems.push({
1335
+ name,
1336
+ text,
1337
+ voice
1338
+ });
1339
+ p = i;
1340
+ a--;
1341
+ }
1342
+ }
1343
+ return dialogItems.reverse().map(({ name, text, voice }, i) => {
1344
+ const state = stateSnapshots[i];
1345
+ const audioSource = isString(voice) ? voice : isAsset(voice) ? voice : voice == void 0 ? voice : voice[lang];
1346
+ name = name ? this.getCharacterName(name) : "";
1347
+ return {
1348
+ name: this.templateReplace(name, state),
1349
+ text: this.templateReplace(text, state),
1350
+ voice: audioSource ? unwrapAudioAsset(audioSource) : ""
1351
+ };
1352
+ });
1353
+ };
1354
+
1355
+ //#endregion
1356
+ //#region src/utilities/document.ts
1357
+ const setDocumentLanguage = (language) => {
1358
+ document.documentElement.lang = language;
1359
+ };
1360
+
1361
+ //#endregion
1362
+ //#region src/ticker.ts
1363
+ var Ticker = class {
1364
+ listeners = /* @__PURE__ */ new Set();
1365
+ running = false;
1366
+ _factory;
1367
+ constructor(factory) {
1368
+ this._factory = factory;
1369
+ }
1370
+ get deltaTime() {
1371
+ return this._factory.deltaTime;
1372
+ }
1373
+ get lastTime() {
1374
+ return this._factory.lastTime;
1375
+ }
1376
+ add(cb) {
1377
+ this.listeners.add(cb);
1378
+ if (this.listeners.size === 1) this._factory.check(true);
1379
+ return () => {
1380
+ this.remove(cb);
1381
+ };
1382
+ }
1383
+ remove(cb) {
1384
+ this.listeners.delete(cb);
1385
+ if (this.listeners.size === 0) this._factory.check(false);
1386
+ }
1387
+ start = () => {
1388
+ this.running = true;
1389
+ if (this.listeners.size > 0) this._factory.check(true);
1390
+ };
1391
+ stop = () => {
1392
+ this.running = false;
1393
+ };
1394
+ detach = () => {
1395
+ this.listeners.clear();
1396
+ this.stop();
1397
+ this._factory.detach(this);
1398
+ };
1399
+ };
1400
+ var TickerFactory = class {
1401
+ _children = /* @__PURE__ */ new Set();
1402
+ _raf = -1;
1403
+ _running = false;
1404
+ _unsubscribe;
1405
+ deltaTime = 0;
1406
+ lastTime = performance.now();
1407
+ constructor(paused) {
1408
+ this._unsubscribe = paused.subscribe((paused) => {
1409
+ if (paused) this.stop();
1410
+ else if (Array.from(this._children).some((ticker) => ticker.running && ticker.listeners.size > 0)) this.start();
1411
+ });
1412
+ }
1413
+ start() {
1414
+ if (this._running) return;
1415
+ cancelAnimationFrame(this._raf);
1416
+ this.lastTime = performance.now();
1417
+ this._running = true;
1418
+ this._raf = requestAnimationFrame(this.update);
1419
+ }
1420
+ stop() {
1421
+ cancelAnimationFrame(this._raf);
1422
+ this._running = false;
1423
+ this._raf = -1;
1424
+ }
1425
+ fork() {
1426
+ const ticker = new Ticker(this);
1427
+ this._children.add(ticker);
1428
+ return ticker;
1429
+ }
1430
+ check(positive) {
1431
+ if (positive) this.start();
1432
+ else if (Array.from(this._children).every((ticker) => !ticker.running || ticker.listeners.size === 0)) this.stop();
1433
+ }
1434
+ destroy() {
1435
+ this._unsubscribe();
1436
+ this._children.forEach((child) => child.detach());
1437
+ }
1438
+ detach(ticker) {
1439
+ this._children.delete(ticker);
1440
+ this.check(false);
1441
+ }
1442
+ update = (currentTime) => {
1443
+ this.deltaTime = currentTime - this.lastTime;
1444
+ this._children.forEach((ticker) => {
1445
+ if (ticker.running) ticker.listeners.forEach((tick) => {
1446
+ tick(ticker);
1447
+ });
1448
+ });
1449
+ if (!this._running) return;
1450
+ this.lastTime = currentTime;
1451
+ this._raf = requestAnimationFrame(this.update);
1452
+ };
1453
+ };
1454
+
1455
+ //#endregion
1456
+ //#region src/novely.ts
1457
+ const novely = ({ characters, characterAssetSizes = {}, defaultEmotions = {}, storage = storageAdapterLocal({ key: "novely-game-storage" }), storageDelay = Promise.resolve(), renderer: createRenderer, initialScreen = "mainmenu", translation, state: defaultState = {}, data: defaultData = {}, autosaves = true, migrations = [], throttleTimeout = 850, getLanguage: getLanguage$1 = getLanguage, overrideLanguage = false, askBeforeExit = true, preloadAssets = "automatic", parallelAssetsDownloadLimit = 15, fetch: request = fetch, cloneFunction: clone = klona, saveOnUnload = true, startKey = "start", defaultTypewriterSpeed = DEFAULT_TYPEWRITER_SPEED, storyOptions = { mode: "static" }, onLanguageChange }) => {
1458
+ const languages = Object.keys(translation);
1459
+ const limitScript = pLimit(1);
1460
+ const limitAssetsDownload = pLimit(parallelAssetsDownloadLimit);
1461
+ const story = {};
1462
+ const times = /* @__PURE__ */ new Set();
1463
+ const dataLoaded = createControlledPromise();
1464
+ let initialScreenWasShown = false;
1465
+ let destroyed = false;
1466
+ if (storyOptions.mode === "dynamic") storyOptions.preloadSaves ??= 4;
1467
+ const storyLoad = storyOptions.mode === "static" ? noop : storyOptions.load;
1468
+ const onUnknownSceneHit = memoize(async (scene) => {
1469
+ const part = await storyLoad(scene);
1470
+ if (part) await script(part);
1471
+ });
1472
+ /**
1473
+ * Saves timestamps created in this session
1474
+ */
1475
+ const intime = (value) => {
1476
+ return times.add(value), value;
1477
+ };
1478
+ const scriptBase = async (part) => {
1479
+ if (destroyed) return;
1480
+ Object.assign(story, flatStory(part));
1481
+ if (!initialScreenWasShown) renderer.ui.showLoading();
1482
+ await dataLoaded.promise;
1483
+ renderer.ui.hideLoading();
1484
+ if (!initialScreenWasShown) {
1485
+ initialScreenWasShown = true;
1486
+ if (initialScreen === "game") restore(void 0);
1487
+ else renderer.ui.showScreen(initialScreen);
1488
+ }
1489
+ };
1490
+ /**
1491
+ * Setup your story here
1492
+ *
1493
+ * Call more than once to merge different story parts
1494
+ *
1495
+ * @example
1496
+ *
1497
+ * ```js
1498
+ * engine.script({
1499
+ * start: [action.jump('another-part')]
1500
+ * })
1501
+ *
1502
+ * engine.script({
1503
+ * 'another-part': []
1504
+ * })
1505
+ * ```
1506
+ */
1507
+ const script = (part) => {
1508
+ return limitScript(() => scriptBase(part));
1509
+ };
1510
+ const getDefaultSave = (state) => {
1511
+ return [
1512
+ [["jump", startKey], [null, 0]],
1513
+ state,
1514
+ [intime(Date.now()), "auto"],
1515
+ []
1516
+ ];
1517
+ };
1518
+ /**
1519
+ * Calls `getLanguage`, passes needed arguments
1520
+ * @returns language
1521
+ */
1522
+ const getLanguageWithoutParameters = () => {
1523
+ const language = getLanguage$1(languages, getLanguage);
1524
+ if (languages.includes(language)) {
1525
+ setDocumentLanguage(language);
1526
+ return language;
1527
+ }
1528
+ if (DEV) throw new Error(`Attempt to use unsupported language "${language}". Supported languages: ${languages.join(", ")}.`);
1529
+ throw 0;
1530
+ };
1531
+ const storageData = store({
1532
+ saves: [],
1533
+ data: clone(defaultData),
1534
+ meta: [
1535
+ getLanguageWithoutParameters(),
1536
+ DEFAULT_TYPEWRITER_SPEED,
1537
+ 1,
1538
+ 1,
1539
+ 1
1540
+ ]
1541
+ });
1542
+ const coreData = store({
1543
+ dataLoaded: false,
1544
+ paused: false,
1545
+ focused: document.visibilityState === "visible"
1546
+ });
1547
+ const paused = derive(coreData, (s) => s.paused || !s.focused);
1548
+ const onDataLoadedPromise = async ({ cancelled }) => {
1549
+ /**
1550
+ * Promise cancelled? Re-subscribe
1551
+ */
1552
+ if (cancelled) {
1553
+ dataLoaded.promise.then(onDataLoadedPromise);
1554
+ return;
1555
+ }
1556
+ const preload = () => {
1557
+ const sliced = [...storageData.get().saves].reverse().slice(0, storyOptions.mode === "dynamic" ? storyOptions.preloadSaves : 0);
1558
+ for (const [path] of sliced) referGuarded(path);
1559
+ };
1560
+ preload();
1561
+ /**
1562
+ * When promise is resolved data is marked loaded
1563
+ */
1564
+ coreData.update((data) => {
1565
+ data.dataLoaded = true;
1566
+ return data;
1567
+ });
1568
+ };
1569
+ dataLoaded.promise.then(onDataLoadedPromise);
1570
+ const onStorageDataChange = (value) => {
1571
+ if (!coreData.get().dataLoaded) return;
1572
+ const data = clone(value);
1573
+ /**
1574
+ * Empty out data snapshots
1575
+ */
1576
+ for (const save of data.saves) save[3] = [];
1577
+ storage.set(data);
1578
+ };
1579
+ /**
1580
+ * Short one is used in conditions like `beforeunload` when waiting for too long is not a case
1581
+ * Another one relies on short one to prevent double saving
1582
+ */
1583
+ const throttledShortOnStorageDataChange = throttle(() => onStorageDataChange(storageData.get()), 10);
1584
+ const throttledOnStorageDataChange = throttle(throttledShortOnStorageDataChange, throttleTimeout);
1585
+ storageData.subscribe(throttledOnStorageDataChange);
1586
+ if (saveOnUnload === true || saveOnUnload === "prod" && !DEV) addEventListener("beforeunload", throttledShortOnStorageDataChange);
1587
+ const getStoredData = async () => {
1588
+ let stored = await storage.get();
1589
+ for (const migration of migrations) {
1590
+ stored = migration(stored);
1591
+ if (DEV && !stored) throw new Error("Migrations should return a value.");
1592
+ }
1593
+ if (overrideLanguage || !stored.meta[0]) stored.meta[0] = getLanguageWithoutParameters();
1594
+ /**
1595
+ * Default `localStorageStorage` returns empty array
1596
+ */
1597
+ stored.meta[1] ||= defaultTypewriterSpeed;
1598
+ /**
1599
+ * Sound Volumes
1600
+ */
1601
+ stored.meta[2] ??= 1;
1602
+ stored.meta[3] ??= 1;
1603
+ stored.meta[4] ??= 1;
1604
+ /**
1605
+ * When data is empty replace it with `defaultData`
1606
+ * It also might be empty (default to empty)
1607
+ */
1608
+ if (isEmpty(stored.data)) stored.data = defaultData;
1609
+ /**
1610
+ * Now the next store updates will entail saving via storage.set
1611
+ */
1612
+ dataLoaded.resolve();
1613
+ storageData.set(stored);
1614
+ };
1615
+ /**
1616
+ * By default this is resolved immediately, but also can be delayed.
1617
+ * I.e. storage has not loaded yet
1618
+ */
1619
+ storageDelay.then(getStoredData);
1620
+ const initial = getDefaultSave(clone(defaultState));
1621
+ const save = (type) => {
1622
+ if (!coreData.get().dataLoaded) return;
1623
+ /**
1624
+ * When autosaves diabled just return
1625
+ */
1626
+ if (!autosaves && type === "auto") return;
1627
+ const current = clone(useStack(MAIN_CONTEXT_KEY).value);
1628
+ storageData.update((prev) => {
1629
+ const replace = () => {
1630
+ prev.saves[prev.saves.length - 1] = current;
1631
+ return prev;
1632
+ };
1633
+ const add = () => {
1634
+ prev.saves.push(current);
1635
+ return prev;
1636
+ };
1637
+ /**
1638
+ * Get latest
1639
+ */
1640
+ const last = prev.saves.at(-1);
1641
+ /**
1642
+ * We cannot compare anything here, thus just reutrn
1643
+ */
1644
+ if (!last) return add();
1645
+ /**
1646
+ * Update type and time information
1647
+ */
1648
+ current[2][0] = intime(Date.now());
1649
+ current[2][1] = type;
1650
+ /**
1651
+ * Empty out state snapshots
1652
+ */
1653
+ current[3] = [];
1654
+ const isIdentical = dequal(last[0], current[0]) && dequal(last[1], current[1]);
1655
+ const isLastMadeInCurrentSession = times.has(last[2][0]);
1656
+ /**
1657
+ * Even if override is false, we will replace auto save with manual, because they are the same thing basically
1658
+ */
1659
+ if (isLastMadeInCurrentSession && last[2][1] === "auto" && type === "manual") return replace();
1660
+ /**
1661
+ * Player has made a manual save, novely decided to make an auto save
1662
+ * But it is identical to previously created manual save so completely not wanted
1663
+ */
1664
+ if (last[2][1] === "manual" && type === "auto" && isIdentical) return prev;
1665
+ if (isLastMadeInCurrentSession && last[2][1] === "auto" && type === "auto") return replace();
1666
+ return add();
1667
+ });
1668
+ };
1669
+ const newGame = () => {
1670
+ if (!coreData.get().dataLoaded) return;
1671
+ const save = getDefaultSave(clone(defaultState));
1672
+ /**
1673
+ * Initial save is automatic, and should be ignored when autosaves is turned off
1674
+ */
1675
+ if (autosaves) storageData.update((prev) => {
1676
+ return prev.saves.push(save), prev;
1677
+ });
1678
+ const context = renderer.getContext(MAIN_CONTEXT_KEY);
1679
+ const stack = useStack(context);
1680
+ stack.value = save;
1681
+ context.meta.restoring = context.meta.goingBack = false;
1682
+ renderer.ui.showScreen("game");
1683
+ render(context);
1684
+ };
1685
+ /**
1686
+ * Set's the save and restores onto it
1687
+ */
1688
+ const set = (save, ctx) => {
1689
+ const stack = useStack(ctx || "$MAIN");
1690
+ stack.value = save;
1691
+ return restore(save);
1692
+ };
1693
+ let interacted = 0;
1694
+ /**
1695
+ * Restore save or if none is passed then look for latest save, if there is no saves will create a new save
1696
+ */
1697
+ const restore = async (save) => {
1698
+ if (isEmpty(story)) {
1699
+ if (DEV) throw new Error("Story is empty. You should call an `enine.script` function [https://novely.pages.dev/guide/story.html]");
1700
+ return;
1701
+ }
1702
+ if (!coreData.get().dataLoaded) return;
1703
+ let latest = save || storageData.get().saves.at(-1);
1704
+ /**
1705
+ * When there is no save, make a new save
1706
+ */
1707
+ if (!latest) {
1708
+ latest = clone(initial);
1709
+ storageData.update((prev) => {
1710
+ prev.saves.push(latest);
1711
+ return prev;
1712
+ });
1713
+ }
1714
+ const context = renderer.getContext(MAIN_CONTEXT_KEY);
1715
+ const stack = useStack(context);
1716
+ context.meta.restoring = true;
1717
+ const previous = stack.previous;
1718
+ const [path] = stack.value = latest;
1719
+ renderer.ui.showScreen("game");
1720
+ const { found } = await refer(path);
1721
+ if (found) context.loading(true);
1722
+ const { queue, skip, skipPreserve } = await getActionsFromPath({
1723
+ story,
1724
+ path,
1725
+ filter: false,
1726
+ referGuarded
1727
+ });
1728
+ const instances = getCustomActionInstances(context);
1729
+ if (previous) {
1730
+ const { queue: prevQueue } = await getActionsFromPath({
1731
+ story,
1732
+ path: previous[0],
1733
+ filter: false,
1734
+ referGuarded
1735
+ });
1736
+ const futures = [];
1737
+ const end = previous[0][0][1] !== path[0][1] ? 0 : queue.length - 1;
1738
+ for (let i = prevQueue.length - 1; i >= end; i--) {
1739
+ const [action, fn] = prevQueue[i];
1740
+ if (action === "custom") futures.push(fn);
1741
+ }
1742
+ futures.reverse();
1743
+ const nodeCleanup = /* @__PURE__ */ new Set();
1744
+ for (const future of futures) inner: for (let i = instances.length - 1; i >= 0; i--) {
1745
+ const instance = instances[i];
1746
+ if (future === instance.fn) {
1747
+ cleanInstance(instance);
1748
+ nodeCleanup.add(instance.node);
1749
+ instances.splice(i, 1);
1750
+ break inner;
1751
+ }
1752
+ }
1753
+ nodeCleanup.forEach((f) => f());
1754
+ for (const instance of instances.filter((i) => !i.disposed)) instance.onBack();
1755
+ }
1756
+ const { run, keep: { keep, characters, audio } } = createQueueProcessor(queue, {
1757
+ skip,
1758
+ skipPreserve
1759
+ });
1760
+ if (context.meta.goingBack)
1761
+ /**
1762
+ * Context is cleared at exit, so it is dirty only when goingBack
1763
+ */
1764
+ match("clear", [
1765
+ keep,
1766
+ characters,
1767
+ audio
1768
+ ], {
1769
+ ctx: context,
1770
+ data: latest[1]
1771
+ });
1772
+ context.loading(false);
1773
+ const lastQueueItem = queue.at(-1);
1774
+ const lastQueueItemRequiresUserAction = lastQueueItem && isBlockingAction(lastQueueItem);
1775
+ await run((item) => {
1776
+ if (!latest) return;
1777
+ /**
1778
+ * Skip because last item will be ran again by `render(context)` call
1779
+ */
1780
+ if (lastQueueItem === item && lastQueueItemRequiresUserAction) return;
1781
+ const [action, ...props] = item;
1782
+ if (action === "custom") {
1783
+ /**
1784
+ * We check if there's an existing cleanup function for this action.
1785
+ * If found, it means the previous run hasn't been cleaned up yet.
1786
+ * In that case, we avoid re-running.
1787
+ */
1788
+ if (instances.some((instance) => instance.fn === props[0] && !instance.disposed)) return;
1789
+ }
1790
+ return match(action, props, {
1791
+ ctx: context,
1792
+ data: latest[1]
1793
+ });
1794
+ });
1795
+ if (!context.meta.goingBack)
1796
+ /**
1797
+ * When not goingBack setting restoring to false is required to go forward
1798
+ * Because when restoring action do not call the resolve function which goes to next action but are controlled
1799
+ */
1800
+ context.meta.restoring = false;
1801
+ await render(context);
1802
+ context.meta.restoring = context.meta.goingBack = false;
1803
+ };
1804
+ const { refer, referGuarded } = createReferFunction({
1805
+ story,
1806
+ onUnknownSceneHit
1807
+ });
1808
+ /**
1809
+ * @param force Force exit
1810
+ */
1811
+ const exit = (force = false, saving = true) => {
1812
+ /**
1813
+ * Exit only possible in main context
1814
+ */
1815
+ const ctx = renderer.getContext(MAIN_CONTEXT_KEY);
1816
+ const stack = useStack(ctx);
1817
+ const current = stack.value;
1818
+ const isSaved = () => {
1819
+ const { saves } = storageData.get();
1820
+ const [currentPath, currentData] = stack.value;
1821
+ return saves.some(([path, data, [date, type]]) => type === "manual" && times.has(date) && dequal(path, currentPath) && dequal(data, currentData));
1822
+ };
1823
+ if (interacted > 1 && !force && askBeforeExit && !isSaved()) {
1824
+ renderer.ui.showExitPrompt();
1825
+ return;
1826
+ }
1827
+ /**
1828
+ * Imagine list of actions like
1829
+ *
1830
+ * [
1831
+ * ['input', ...args]
1832
+ * ['dialog', ...args]
1833
+ * ]
1834
+ *
1835
+ * When you have done with input, you will go to the dialog
1836
+ * And at that moment you exit the game
1837
+ *
1838
+ * What happens? Input was "enmemoried", but dialog not. So when you will open saves, you'll see input action.
1839
+ * We cannot "enmemory" dialog when it's just started, because goingBack is going to last enmemoried item, which will be that dialog, so impossible to go back.
1840
+ *
1841
+ * What we do is enmemory on exit.
1842
+ */
1843
+ if (interacted > 0 && saving) save("auto");
1844
+ stack.clear();
1845
+ clearCustomActionsAtContext(ctx);
1846
+ ctx.clear(EMPTY_SET, EMPTY_SET, {
1847
+ music: EMPTY_SET,
1848
+ sounds: EMPTY_SET
1849
+ }, noop);
1850
+ renderer.ui.showScreen("mainmenu");
1851
+ ctx.audio.destroy();
1852
+ const [time, type] = current[2];
1853
+ /**
1854
+ * This is auto save and belongs to the current session
1855
+ * Player did not interacted or did it once, so this is probably not-needed save
1856
+ */
1857
+ if (type === "auto" && interacted <= 1 && times.has(time)) storageData.update((prev) => {
1858
+ prev.saves = prev.saves.filter((save) => save !== current);
1859
+ return prev;
1860
+ });
1861
+ /**
1862
+ * Reset interactive value
1863
+ */
1864
+ interactivity(false);
1865
+ /**
1866
+ * Reset session times
1867
+ */
1868
+ times.clear();
1869
+ };
1870
+ const back = async () => {
1871
+ /**
1872
+ * Back also happens in main context only
1873
+ */
1874
+ const stack = useStack(MAIN_CONTEXT_KEY);
1875
+ const valueBeforeBack = stack.value;
1876
+ stack.back();
1877
+ /**
1878
+ * There was only one item in the stack so there is no `stack.previous`, also `ctx.meta.goingBack` did not changed
1879
+ */
1880
+ if (dequal(valueBeforeBack, stack.value) && !stack.previous) return;
1881
+ await restore(stack.value);
1882
+ };
1883
+ const t = (key, lang) => {
1884
+ return translation[lang].internal[key];
1885
+ };
1886
+ /**
1887
+ * Execute save in context named `name`
1888
+ * @param save Save
1889
+ * @param name Context name
1890
+ */
1891
+ const preview = async (save, name) => {
1892
+ if (isEmpty(story)) return Promise.resolve({ assets: [] });
1893
+ const [path, data] = save;
1894
+ const ctx = renderer.getContext(name);
1895
+ const { found } = await refer(path);
1896
+ if (found) ctx.loading(true);
1897
+ const { queue } = await getActionsFromPath({
1898
+ story,
1899
+ path,
1900
+ filter: true,
1901
+ referGuarded
1902
+ });
1903
+ ctx.loading(false);
1904
+ ctx.meta.restoring = true;
1905
+ ctx.meta.preview = true;
1906
+ const processor = createQueueProcessor(queue, { skip: EMPTY_SET });
1907
+ useStack(ctx).push(clone(save));
1908
+ const assets = [];
1909
+ const huntPromises = [];
1910
+ await processor.run(([action, ...props]) => {
1911
+ if (isAudioAction(action)) return;
1912
+ if (action === "vibrate") return;
1913
+ if (action === "end") return;
1914
+ const huntPromise = huntAssets({
1915
+ action,
1916
+ props,
1917
+ characters,
1918
+ lang: getLanguageFromStore(storageData),
1919
+ volume: getVolumeFromStore(storageData),
1920
+ handle: assets.push.bind(assets),
1921
+ request
1922
+ });
1923
+ huntPromises.push(huntPromise);
1924
+ return match(action, props, {
1925
+ ctx,
1926
+ data
1927
+ });
1928
+ });
1929
+ await Promise.all(huntPromises);
1930
+ return { assets };
1931
+ };
1932
+ const removeContext = (name) => {
1933
+ STACK_MAP.delete(name);
1934
+ };
1935
+ const getStateAtCtx = (context) => {
1936
+ return useStack(context).value[1];
1937
+ };
1938
+ const getStateFunction = (context) => {
1939
+ const stack = useStack(context);
1940
+ const state = ((value) => {
1941
+ const _state = getStateAtCtx(context);
1942
+ if (!value) return _state;
1943
+ const prev = _state;
1944
+ const val = isFunction(value) ? value(prev) : merge(prev, value);
1945
+ stack.value[1] = val;
1946
+ });
1947
+ return state;
1948
+ };
1949
+ const getLanguageDisplayName = (lang) => {
1950
+ const language = translation[lang];
1951
+ if (DEV && !language) throw new Error(`Attempt to use unsupported language "${language}". Supported languages: ${languages.join(", ")}.`);
1952
+ return capitalize(language.nameOverride || getIntlLanguageDisplayName(lang));
1953
+ };
1954
+ const clearCustomActionsAtContext = (ctx) => {
1955
+ const instances = getCustomActionInstances(ctx);
1956
+ const nodeCleanup = /* @__PURE__ */ new Set();
1957
+ for (const instance of instances) {
1958
+ cleanInstance(instance);
1959
+ nodeCleanup.add(instance.node);
1960
+ }
1961
+ instances.length = 0;
1962
+ nodeCleanup.forEach((fn) => fn());
1963
+ };
1964
+ const getResourseTypeWrapper = (url) => {
1965
+ return getResourseType({
1966
+ url,
1967
+ request
1968
+ });
1969
+ };
1970
+ const getCharacterColor = (c) => {
1971
+ return c in characters ? characters[c].color : "#000000";
1972
+ };
1973
+ const getCharacterAssets = (character, emotion) => {
1974
+ return toArray(characters[character].emotions[emotion]).map(unwrapImageAsset);
1975
+ };
1976
+ const getCharacterName = (character) => {
1977
+ const c = character;
1978
+ const cs = characters;
1979
+ const lang = getLanguageFromStore(storageData);
1980
+ if (c && c in cs) {
1981
+ const block = cs[c].name;
1982
+ if (typeof block === "string") return block;
1983
+ if (lang in block) return block[lang];
1984
+ }
1985
+ return String(c);
1986
+ };
1987
+ const setLanguage = (lang) => {
1988
+ storageData.update((prev) => {
1989
+ if (languages.includes(lang)) prev.meta[0] = lang;
1990
+ if (lang === prev.meta[0]) {
1991
+ setDocumentLanguage(lang);
1992
+ onLanguageChange?.(lang);
1993
+ }
1994
+ return prev;
1995
+ });
1996
+ };
1997
+ const renderer = createRenderer({
1998
+ mainContextKey: MAIN_CONTEXT_KEY,
1999
+ characters: getCharactersData(characters),
2000
+ characterAssetSizes,
2001
+ set,
2002
+ restore,
2003
+ save,
2004
+ newGame,
2005
+ exit,
2006
+ back,
2007
+ t,
2008
+ preview,
2009
+ removeContext,
2010
+ getStateFunction,
2011
+ clearCustomActionsAtContext,
2012
+ languages,
2013
+ storageData,
2014
+ coreData,
2015
+ getLanguageDisplayName,
2016
+ getCharacterColor,
2017
+ getCharacterAssets,
2018
+ getDialogOverview: getDialogOverview.bind({
2019
+ referGuarded,
2020
+ story,
2021
+ getCharacterName,
2022
+ getLanguage: () => getLanguageFromStore(storageData),
2023
+ getStack: () => useStack(MAIN_CONTEXT_KEY),
2024
+ templateReplace: (...args) => templateReplace(...args)
2025
+ }),
2026
+ getResourseType: getResourseTypeWrapper,
2027
+ setLanguage
2028
+ });
2029
+ const useStack = createUseStackFunction(renderer);
2030
+ /**
2031
+ * Initiate
2032
+ */
2033
+ useStack(MAIN_CONTEXT_KEY).push(initial);
2034
+ const UIInstance = renderer.ui.start();
2035
+ const enmemory = (ctx) => {
2036
+ if (ctx.meta.restoring) return;
2037
+ const stack = useStack(ctx);
2038
+ const current = clone(stack.value);
2039
+ current[2][1] = "auto";
2040
+ stack.push(current);
2041
+ save("auto");
2042
+ };
2043
+ const ticker = new TickerFactory(paused);
2044
+ const next = (ctx) => {
2045
+ const path = useStack(ctx).value[0];
2046
+ nextPath(path);
2047
+ };
2048
+ const matchActionHandlers = {
2049
+ getContext: renderer.getContext,
2050
+ async push(ctx) {
2051
+ if (ctx.meta.restoring) return;
2052
+ const stack = useStack(ctx);
2053
+ const instances = getCustomActionInstances(ctx).filter((i) => !i.disposed);
2054
+ next(ctx);
2055
+ if (stack.value && instances.length !== 0) {
2056
+ const action = await refer(stack.value[0]).then((r) => r.value);
2057
+ const params = {
2058
+ action,
2059
+ isBlockingAction: isBlockingAction(action),
2060
+ isUserRequiredAction: isUserRequiredAction(action)
2061
+ };
2062
+ for (const instance of instances) instance.onForward(params);
2063
+ }
2064
+ await render(ctx);
2065
+ },
2066
+ async forward(ctx) {
2067
+ if (!ctx.meta.preview) enmemory(ctx);
2068
+ await matchActionHandlers.push(ctx);
2069
+ if (!ctx.meta.preview) interactivity(true);
2070
+ },
2071
+ async onBeforeActionCall({ action, props, ctx }) {
2072
+ if (preloadAssets !== "automatic") return;
2073
+ if (ctx.meta.preview || ctx.meta.restoring) return;
2074
+ if (!isBlockingAction([action, ...props])) return;
2075
+ try {
2076
+ const queue = (await collectActionsBeforeBlockingAction({
2077
+ path: nextPath(clone(useStack(ctx).value[0])),
2078
+ refer: referGuarded,
2079
+ clone
2080
+ })).map(([action, ...props]) => {
2081
+ return huntAssets({
2082
+ action,
2083
+ props,
2084
+ characters,
2085
+ lang: getLanguageFromStore(storageData),
2086
+ volume: getVolumeFromStore(storageData),
2087
+ handle: enqueueAssetForPreloading,
2088
+ request
2089
+ });
2090
+ });
2091
+ await Promise.all(queue);
2092
+ handleAssetsPreloading({
2093
+ ...renderer.misc,
2094
+ request,
2095
+ limiter: limitAssetsDownload
2096
+ });
2097
+ } catch (cause) {
2098
+ console.error(cause);
2099
+ }
2100
+ }
2101
+ };
2102
+ const { match, nativeActions } = matchAction(matchActionHandlers, {
2103
+ wait({ ctx, data, push }, [time]) {
2104
+ if (ctx.meta.restoring) return;
2105
+ setTimeout(push, isFunction(time) ? time(data) : time);
2106
+ },
2107
+ showBackground({ ctx, push }, [background]) {
2108
+ if (isString(background) || isAsset(background)) ctx.background({ all: unwrapImageAsset(background) });
2109
+ else ctx.background(Object.fromEntries(Object.entries(background).map(([media, asset]) => [media, unwrapImageAsset(asset)])));
2110
+ push();
2111
+ },
2112
+ playMusic({ ctx, push }, [source]) {
2113
+ ctx.audio.music(unwrapAudioAsset(source), paused, "music").play(true);
2114
+ push();
2115
+ },
2116
+ pauseMusic({ ctx, push }, [source]) {
2117
+ ctx.audio.music(unwrapAudioAsset(source), paused, "music").pause();
2118
+ push();
2119
+ },
2120
+ stopMusic({ ctx, push }, [source]) {
2121
+ ctx.audio.music(unwrapAudioAsset(source), paused, "music").stop();
2122
+ push();
2123
+ },
2124
+ playSound({ ctx, push }, [source, loop]) {
2125
+ ctx.audio.music(unwrapAudioAsset(source), paused, "sound").play(loop || false);
2126
+ push();
2127
+ },
2128
+ pauseSound({ ctx, push }, [source]) {
2129
+ ctx.audio.music(unwrapAudioAsset(source), paused, "sound").pause();
2130
+ push();
2131
+ },
2132
+ stopSound({ ctx, push }, [source]) {
2133
+ ctx.audio.music(unwrapAudioAsset(source), paused, "sound").stop();
2134
+ push();
2135
+ },
2136
+ voice({ ctx, push }, [source]) {
2137
+ const lang = getLanguageFromStore(storageData);
2138
+ const audioSource = isString(source) ? source : isAsset(source) ? source : source[lang];
2139
+ /**
2140
+ * We allow ignoring voice because it is okay to not have voiceover for certain languages
2141
+ */
2142
+ if (!audioSource) {
2143
+ push();
2144
+ return;
2145
+ }
2146
+ ctx.audio.voice(unwrapAudioAsset(audioSource), paused);
2147
+ push();
2148
+ },
2149
+ stopVoice({ ctx, push }) {
2150
+ ctx.audio.voiceStop();
2151
+ push();
2152
+ },
2153
+ showCharacter({ ctx, push }, [character, emotion, className, style]) {
2154
+ emotion ??= defaultEmotions[character];
2155
+ if (DEV && !emotion) throw new Error(`Attemp to show character "${character}" without emotion provided.`);
2156
+ if (!emotion) return;
2157
+ if (DEV && !characters[character].emotions[emotion]) throw new Error(`Attempt to show character "${character}" with unknown emotion "${emotion}"`);
2158
+ const handle = ctx.character(character);
2159
+ handle.append(className, style, ctx.meta.restoring);
2160
+ handle.emotion(emotion, true);
2161
+ push();
2162
+ },
2163
+ hideCharacter({ ctx, push }, [character, className, style, duration]) {
2164
+ ctx.character(character).remove(className, style, duration, ctx.meta.restoring).then(push);
2165
+ },
2166
+ dialog({ ctx, data, forward }, [character, content, emotion]) {
2167
+ const name = getCharacterName(character);
2168
+ const stack = useStack(ctx);
2169
+ /**
2170
+ * For each "dialog" we save copy of current game state
2171
+ * It's used for dialog overview
2172
+ */
2173
+ if (!ctx.meta.restoring && !ctx.meta.goingBack) stack.value[3].push(clone(data));
2174
+ ctx.clearBlockingActions("dialog");
2175
+ ctx.dialog(templateReplace(content, data), templateReplace(name, data), character, emotion, forward);
2176
+ },
2177
+ function({ ctx, push }, [fn]) {
2178
+ const { restoring, goingBack, preview } = ctx.meta;
2179
+ const result = fn({
2180
+ lang: getLanguageFromStore(storageData),
2181
+ goingBack,
2182
+ restoring,
2183
+ preview,
2184
+ state: getStateFunction(ctx)
2185
+ });
2186
+ if (!ctx.meta.restoring) result ? result.then(push) : push();
2187
+ return result;
2188
+ },
2189
+ choice({ ctx, data }, [question, ...choices]) {
2190
+ const isWithoutQuestion = Array.isArray(question);
2191
+ if (isWithoutQuestion) {
2192
+ /**
2193
+ * Can be string or a choice
2194
+ */
2195
+ choices.unshift(question);
2196
+ /**
2197
+ * Omitted then
2198
+ */
2199
+ question = "";
2200
+ }
2201
+ const transformedChoices = choices.map(([content, _children, active, visible, onSelect, image]) => {
2202
+ const active$ = store(false);
2203
+ const visible$ = store(false);
2204
+ const lang = getLanguageFromStore(storageData);
2205
+ const getCheckValue = (fn) => {
2206
+ if (!fn) return true;
2207
+ return fn({
2208
+ lang,
2209
+ state: getStateAtCtx(ctx)
2210
+ });
2211
+ };
2212
+ const update = () => {
2213
+ active$.set(getCheckValue(active));
2214
+ visible$.set(getCheckValue(visible));
2215
+ };
2216
+ update();
2217
+ const onSelectGuarded = onSelect || noop;
2218
+ const onSelectWrapped = () => {
2219
+ onSelectGuarded({ recompute: update });
2220
+ };
2221
+ const imageValue = image ? unwrapImageAsset(image) : "";
2222
+ return [
2223
+ templateReplace(content, data),
2224
+ active$,
2225
+ visible$,
2226
+ onSelectWrapped,
2227
+ imageValue
2228
+ ];
2229
+ });
2230
+ if (DEV && transformedChoices.length === 0) throw new Error(`Running choice without variants to choose from, look at how to use Choice action properly [https://novely.pages.dev/guide/actions/choice#usage]`);
2231
+ ctx.clearBlockingActions("choice");
2232
+ ctx.choices(templateReplace(question, data), transformedChoices, (selected) => {
2233
+ if (!ctx.meta.preview) enmemory(ctx);
2234
+ const stack = useStack(ctx);
2235
+ /**
2236
+ * If there is a question, then `index` should be shifted by `1`
2237
+ */
2238
+ const offset = isWithoutQuestion ? 0 : 1;
2239
+ if (DEV && !transformedChoices[selected]) throw new Error("Choice children is empty, either add content there or make item not selectable");
2240
+ stack.value[0].push(["choice", selected + offset], [null, 0]);
2241
+ render(ctx);
2242
+ interactivity(true);
2243
+ });
2244
+ },
2245
+ jump({ ctx, data }, [scene]) {
2246
+ const stack = useStack(ctx);
2247
+ /**
2248
+ * `-1` index is used here because `clear` will run `next` that will increase index to `0`
2249
+ */
2250
+ stack.value[0] = [["jump", scene], [null, -1]];
2251
+ stack.value[3] = [];
2252
+ match("clear", [], {
2253
+ ctx,
2254
+ data
2255
+ });
2256
+ },
2257
+ clear({ ctx, push }, [keep, characters, audio]) {
2258
+ /**
2259
+ * Remove vibration
2260
+ */
2261
+ ctx.vibrate(0);
2262
+ /**
2263
+ * Call the actual `clear`
2264
+ */
2265
+ ctx.clear(keep || EMPTY_SET, characters || EMPTY_SET, audio || {
2266
+ music: EMPTY_SET,
2267
+ sounds: EMPTY_SET
2268
+ }, push);
2269
+ },
2270
+ condition({ ctx, data }, [condition, variants]) {
2271
+ if (DEV && Object.values(variants).length === 0) throw new Error(`Attempt to use Condition action with empty variants object`);
2272
+ if (!ctx.meta.restoring) {
2273
+ const val = String(condition(data));
2274
+ if (DEV && !variants[val]) throw new Error(`Attempt to go to unknown variant "${val}"`);
2275
+ if (DEV && variants[val].length === 0) throw new Error(`Attempt to go to empty variant "${val}"`);
2276
+ useStack(ctx).value[0].push(["condition", val], [null, 0]);
2277
+ render(ctx);
2278
+ }
2279
+ },
2280
+ end({ ctx }) {
2281
+ if (ctx.meta.preview) return;
2282
+ exit(true, false);
2283
+ },
2284
+ input({ ctx, data, forward }, [question, onInput, setup]) {
2285
+ ctx.clearBlockingActions("input");
2286
+ ctx.input(templateReplace(question, data), onInput, setup || noop, forward);
2287
+ },
2288
+ custom({ ctx, push }, [fn]) {
2289
+ if (fn.requireUserAction) ctx.clearBlockingActions(void 0);
2290
+ const state = getStateFunction(ctx);
2291
+ const lang = getLanguageFromStore(storageData);
2292
+ const result = handleCustomAction(ctx, fn, {
2293
+ ...ctx.custom(fn),
2294
+ state,
2295
+ lang,
2296
+ getStack: useStack,
2297
+ paused,
2298
+ ticker: ticker.fork(),
2299
+ templateReplace,
2300
+ request
2301
+ });
2302
+ const next = () => {
2303
+ if (fn.requireUserAction && !ctx.meta.preview) {
2304
+ enmemory(ctx);
2305
+ interactivity(true);
2306
+ }
2307
+ push();
2308
+ };
2309
+ if (!ctx.meta.restoring || ctx.meta.goingBack) if (isPromise(result)) result.then(next);
2310
+ else next();
2311
+ return result;
2312
+ },
2313
+ vibrate({ ctx, push }, pattern) {
2314
+ ctx.vibrate(pattern);
2315
+ push();
2316
+ },
2317
+ next({ push }) {
2318
+ push();
2319
+ },
2320
+ animateCharacter({ ctx, push }, [character, className]) {
2321
+ const classes = className.split(" ");
2322
+ if (DEV && classes.length === 0) throw new Error("Attempt to use AnimateCharacter without classes. Classes should be provided [https://novely.pages.dev/guide/actions/animateCharacter.html]");
2323
+ if (ctx.meta.preview) return;
2324
+ ctx.character(character).animate(classes);
2325
+ push();
2326
+ },
2327
+ text({ ctx, data, forward }, text) {
2328
+ const string = text.map((content) => templateReplace(content, data)).join(" ");
2329
+ if (DEV && string.length === 0) throw new Error(`Action Text was called with empty string or array`);
2330
+ ctx.clearBlockingActions("text");
2331
+ ctx.text(string, forward);
2332
+ },
2333
+ async exit({ ctx, data }) {
2334
+ if (ctx.meta.restoring) return;
2335
+ const { exitImpossible } = await exitPath({
2336
+ path: useStack(ctx).value[0],
2337
+ refer: referGuarded,
2338
+ onExitImpossible: () => {
2339
+ match("end", [], {
2340
+ ctx,
2341
+ data
2342
+ });
2343
+ }
2344
+ });
2345
+ if (exitImpossible) {
2346
+ ctx.clearBlockingActions(void 0);
2347
+ return;
2348
+ }
2349
+ render(ctx);
2350
+ },
2351
+ preload({ ctx, push }, [source]) {
2352
+ if (DEV && preloadAssets !== "lazy") {
2353
+ console.error(`You do not need a preload action because "preloadAssets" strategy was set to "${preloadAssets}"`);
2354
+ push();
2355
+ return;
2356
+ }
2357
+ const src = unwrapAsset(source);
2358
+ if (!ctx.meta.goingBack && !ctx.meta.restoring && !PRELOADED_ASSETS.has(src)) {
2359
+ const process = async () => {
2360
+ const type = isAsset(source) ? source.type : await getResourseTypeWrapper(src);
2361
+ if (type === "image") renderer.misc.preloadAudioBlocking(src);
2362
+ else if (type === "audio") renderer.misc.preloadImage(src);
2363
+ else {
2364
+ if (DEV) console.error(`Preload error: Unknown type of the following resource: `, source);
2365
+ return;
2366
+ }
2367
+ PRELOADED_ASSETS.add(src);
2368
+ };
2369
+ process();
2370
+ }
2371
+ push();
2372
+ },
2373
+ block({ ctx }, [scene]) {
2374
+ if (DEV && !story[scene]) throw new Error(`Attempt to call Block action with unknown scene "${scene}"`);
2375
+ if (DEV && story[scene].length === 0) throw new Error(`Attempt to call Block action with empty scene "${scene}"`);
2376
+ if (!ctx.meta.restoring) {
2377
+ useStack(ctx).value[0].push(["block", scene], [null, 0]);
2378
+ render(ctx);
2379
+ }
2380
+ }
2381
+ });
2382
+ const action = buildActionObject({
2383
+ rendererActions: renderer.actions,
2384
+ nativeActions,
2385
+ characters
2386
+ });
2387
+ const render = async (ctx) => {
2388
+ const [path, state] = useStack(ctx).value;
2389
+ const { found, value } = await refer(path);
2390
+ if (found) ctx.loading(true);
2391
+ const referred = await value;
2392
+ if (found) ctx.loading(false);
2393
+ if (isAction(referred)) {
2394
+ const [action, ...props] = referred;
2395
+ match(action, props, {
2396
+ ctx,
2397
+ data: state
2398
+ });
2399
+ } else if (Object.values(story).some((branch) => branch === referred))
2400
+ /**
2401
+ * Developer might not write the end action on their own, so we will catch situation when there are no other options than end the game.
2402
+ *
2403
+ * There are three options right now.
2404
+ * - We've got to the action — gonna render it
2405
+ * - We've got `undefined`. This means we are tried to go forward, but story array ended already, so we are gonna run exit
2406
+ * - We've got branch of story object. This means we exitied from where it's possible to exit and now we can only end the game
2407
+ */
2408
+ match("end", [], {
2409
+ ctx,
2410
+ data: state
2411
+ });
2412
+ else match("exit", [], {
2413
+ ctx,
2414
+ data: state
2415
+ });
2416
+ };
2417
+ const interactivity = (value = false) => {
2418
+ interacted = value ? interacted + 1 : 0;
2419
+ };
2420
+ /**
2421
+ * Replaces content inside of {{braces}}.
2422
+ */
2423
+ const templateReplace = (content, values) => {
2424
+ const { data, meta: [lang] } = storageData.get();
2425
+ const obj = values || data;
2426
+ const str = flattenAllowedContent(!isFunction(content) && !isString(content) ? content[lang] : content, obj);
2427
+ const t = translation[lang];
2428
+ const pluralRules = (t.plural || t.actions) && new Intl.PluralRules(t.tag || lang);
2429
+ return replace(str, obj, t.plural, t.actions, pluralRules);
2430
+ };
2431
+ const data = ((value) => {
2432
+ const _data = storageData.get().data;
2433
+ if (!value) return _data;
2434
+ const val = isFunction(value) ? value(_data) : merge(_data, value);
2435
+ storageData.update((prev) => {
2436
+ prev.data = val;
2437
+ return prev;
2438
+ });
2439
+ });
2440
+ const getCurrentStorageData = () => {
2441
+ return coreData.get().dataLoaded ? clone(storageData.get()) : null;
2442
+ };
2443
+ const setStorageData = (data) => {
2444
+ if (destroyed) {
2445
+ if (DEV) throw new Error(`function \`setStorageData\` was called after novely instance was destroyed. Data is not updater nor synced after destroy.`);
2446
+ return;
2447
+ }
2448
+ storageData.set(data);
2449
+ };
2450
+ return {
2451
+ script,
2452
+ action,
2453
+ state: getStateFunction(MAIN_CONTEXT_KEY),
2454
+ data,
2455
+ types: null,
2456
+ templateReplace(content) {
2457
+ return templateReplace(content);
2458
+ },
2459
+ templateReplaceState(content, state) {
2460
+ return templateReplace(content, state);
2461
+ },
2462
+ destroy() {
2463
+ if (destroyed) return;
2464
+ dataLoaded.cancel();
2465
+ UIInstance.unmount();
2466
+ ticker.destroy();
2467
+ removeEventListener("beforeunload", throttledShortOnStorageDataChange);
2468
+ destroyed = true;
2469
+ },
2470
+ getCurrentStorageData,
2471
+ setStorageData,
2472
+ setPaused: (paused) => {
2473
+ coreData.update((prev) => {
2474
+ prev.paused = paused;
2475
+ return prev;
2476
+ });
2477
+ },
2478
+ setFocused: (focused) => {
2479
+ coreData.update((prev) => {
2480
+ prev.focused = focused;
2481
+ return prev;
2482
+ });
2483
+ }
2484
+ };
2485
+ };
2486
+
2487
+ //#endregion
2488
+ //#region src/extend-actions.ts
2489
+ /**
2490
+ * Extens core action with custom actions
2491
+ * @param base Actions object you will extend, `engine.action`
2492
+ * @param extension Actions object you will extend with
2493
+ * @example
2494
+ * ```ts
2495
+ * const action = extendAction(engine.action, {
2496
+ * particles: (options: Parameters<typeof particles>[0]) => {
2497
+ * return ['custom', particles(options)]
2498
+ * }
2499
+ * })
2500
+ * ```
2501
+ */
2502
+ const extendAction = (base, extension) => {
2503
+ return {
2504
+ ...extension,
2505
+ ...base
2506
+ };
2507
+ };
2508
+
2509
+ //#endregion
2510
+ //#region src/translations.ts
2511
+ const RU = {
2512
+ NewGame: "Новая игра",
2513
+ HomeScreen: "Главный экран",
2514
+ ToTheGame: "К игре",
2515
+ Language: "Язык",
2516
+ NoSaves: "Сохранений нет",
2517
+ LoadSave: "Загрузить",
2518
+ Saves: "Сохранения",
2519
+ Settings: "Настройки",
2520
+ Sumbit: "Подтвердить",
2521
+ GoBack: "Назад",
2522
+ DoSave: "Сохранение",
2523
+ Auto: "Авто",
2524
+ Stop: "Стоп",
2525
+ Exit: "Выход",
2526
+ Automatic: "Автоматическое",
2527
+ Manual: "Ручное",
2528
+ Remove: "Удалить",
2529
+ LoadASaveFrom: "Загрузить сохранение от",
2530
+ DeleteASaveFrom: "Удалить сохранение от",
2531
+ TextSpeed: "Скорость текста",
2532
+ TextSpeedSlow: "Медленная",
2533
+ TextSpeedMedium: "Средняя",
2534
+ TextSpeedFast: "Быстрая",
2535
+ TextSpeedAuto: "Автоматическая",
2536
+ CompleteText: "Завершить текст",
2537
+ GoForward: "Перейти вперёд",
2538
+ ExitDialogWarning: "Вы уверены, что хотите выйти? Прогресс будет сохранён.",
2539
+ ExitDialogExit: "Выйти",
2540
+ ExitDialogBack: "Вернуться в игру",
2541
+ OpenMenu: "Открыть меню",
2542
+ CloseMenu: "Закрыть меню",
2543
+ MusicVolume: "Громкость музыки",
2544
+ SoundVolume: "Громкость звуков",
2545
+ VoiceVolume: "Громкость речи",
2546
+ Close: "Закрыть",
2547
+ DialogOverview: "Обзор диалога"
2548
+ };
2549
+ const EN = {
2550
+ NewGame: "New Game",
2551
+ HomeScreen: "Home Screen",
2552
+ ToTheGame: "To the Game",
2553
+ Language: "Language",
2554
+ NoSaves: "No saves",
2555
+ LoadSave: "Load",
2556
+ Saves: "Saves",
2557
+ Settings: "Settings",
2558
+ Sumbit: "Submit",
2559
+ GoBack: "Go back",
2560
+ DoSave: "Save",
2561
+ Auto: "Auto",
2562
+ Stop: "Stop",
2563
+ Exit: "Exit",
2564
+ Automatic: "Automatic",
2565
+ Manual: "Manual",
2566
+ Remove: "Remove",
2567
+ LoadASaveFrom: "Load a save from",
2568
+ DeleteASaveFrom: "Delete a save from",
2569
+ TextSpeed: "Text Speed",
2570
+ TextSpeedSlow: "Slow",
2571
+ TextSpeedMedium: "Medium",
2572
+ TextSpeedFast: "Fast",
2573
+ TextSpeedAuto: "Auto",
2574
+ CompleteText: "Complete text",
2575
+ GoForward: "Go forward",
2576
+ ExitDialogWarning: "Are you sure you want to exit? Progress will be saved.",
2577
+ ExitDialogExit: "Exit",
2578
+ ExitDialogBack: "Return to game",
2579
+ OpenMenu: "Open menu",
2580
+ CloseMenu: "Close menu",
2581
+ MusicVolume: "Music volume",
2582
+ SoundVolume: "Sound volume",
2583
+ VoiceVolume: "Voice volume",
2584
+ Close: "Close",
2585
+ DialogOverview: "Dialog Overview"
2586
+ };
2587
+
2588
+ //#endregion
2589
+ //#region src/browser-events.ts
2590
+ const BLUR_HANDLERS = /* @__PURE__ */ new Set();
2591
+ const FOCUS_HANDLERS = /* @__PURE__ */ new Set();
2592
+ const registerEventListeners = (listeners) => {
2593
+ BLUR_HANDLERS.add(listeners.blur);
2594
+ FOCUS_HANDLERS.add(listeners.focus);
2595
+ return () => {
2596
+ BLUR_HANDLERS.delete(listeners.blur);
2597
+ FOCUS_HANDLERS.delete(listeners.focus);
2598
+ };
2599
+ };
2600
+ addEventListener("focus", function(event) {
2601
+ for (const handler of FOCUS_HANDLERS) try {
2602
+ handler.call(this.document, event);
2603
+ } catch {}
2604
+ });
2605
+ addEventListener("blur", function(event) {
2606
+ for (const handler of BLUR_HANDLERS) try {
2607
+ handler.call(this.document, event);
2608
+ } catch {}
2609
+ });
2610
+ const pauseOnBlur = (engine) => {
2611
+ return { unsubscribe: registerEventListeners({
2612
+ focus: () => {
2613
+ engine.setFocused(true);
2614
+ },
2615
+ blur: () => {
2616
+ engine.setFocused(false);
2617
+ }
2618
+ }) };
2619
+ };
2620
+
2621
+ //#endregion
2622
+ export { EN, RU, asset, extendAction, novely, pauseOnBlur, storageAdapterLocal };
2623
+ //# sourceMappingURL=index.mjs.map