@novely/core 0.54.0 → 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.d.mts +1319 -0
- package/dist/index.mjs +2623 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +12 -12
- package/dist/index.d.ts +0 -1260
- package/dist/index.js +0 -2771
- package/dist/index.js.map +0 -1
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
|