@novely/core 0.22.2 → 0.23.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.ts CHANGED
@@ -214,6 +214,7 @@ interface CharacterHandle {
214
214
  emotion: (emotion: string, render: boolean) => void;
215
215
  append: (className?: string, style?: string, restoring?: boolean) => void;
216
216
  remove: (className?: string, style?: string, duration?: number, restoring?: boolean) => Promise<void>;
217
+ animate: (timeout: number, classes: string[]) => void;
217
218
  emotions: Record<string, HTMLImageElement[]>;
218
219
  }
219
220
  type AudioHandle = {
@@ -223,23 +224,23 @@ type AudioHandle = {
223
224
  };
224
225
  type Renderer = {
225
226
  misc: {
226
- /**
227
- * Function to preload images async and await for all images to load or fail
228
- * @param images Set of images to load
229
- */
230
- preloadImagesBlocking: (images: Set<string>) => Promise<PromiseSettledResult<unknown>[]>;
231
227
  /**
232
228
  * Function to preload image sync
233
229
  * @param image Image URL
234
230
  * @returns Image URL
235
231
  */
236
232
  preloadImage: <T extends string>(image: T) => T;
233
+ /**
234
+ * Function to preload image async
235
+ * @param image Image URL
236
+ * @returns Promise
237
+ */
238
+ preloadImageBlocking: (image: string) => Promise<void>;
237
239
  /**
238
240
  * Function to preload audio
239
- * @param type kind of audio
240
241
  * @param source <url> pointing to the audio
241
242
  */
242
- preloadAudioBlocking: (type: 'music', source: string) => Promise<void>;
243
+ preloadAudioBlocking: (source: string) => Promise<void>;
243
244
  };
244
245
  ui: {
245
246
  /**
@@ -308,7 +309,6 @@ type Renderer = {
308
309
  };
309
310
  store: unknown;
310
311
  setStore: unknown;
311
- getCharacter: (character: string) => CharacterHandle | undefined;
312
312
  };
313
313
  removeContext: (context: string) => void;
314
314
  };
@@ -418,6 +418,11 @@ interface NovelyInit<Languages extends string, Characters extends Record<string,
418
418
  * @default 799
419
419
  */
420
420
  throttleTimeout?: number;
421
+ /**
422
+ * Limits how many assets can be downloaded parallelly
423
+ * @default 15
424
+ */
425
+ parallelAssetsDownloadLimit?: number;
421
426
  /**
422
427
  * Custom language detector
423
428
  * @param languages Supported languages aka `languages: []` in the config
@@ -445,8 +450,12 @@ interface NovelyInit<Languages extends string, Characters extends Record<string,
445
450
  * @default "lazy"
446
451
  */
447
452
  preloadAssets?: 'lazy' | 'blocking';
453
+ /**
454
+ * Fetching function
455
+ */
456
+ fetch?: typeof fetch;
448
457
  }
449
- declare const novely: <Languages extends string, Characters extends Record<string, Character<Languages>>, StateScheme extends State, DataScheme extends Data>({ characters, storage, storageDelay, renderer: createRenderer, initialScreen, translation, languages, state: defaultState, data: defaultData, autosaves, migrations, throttleTimeout, getLanguage, overrideLanguage, askBeforeExit, preloadAssets, }: NovelyInit<Languages, Characters, StateScheme, DataScheme>) => {
458
+ declare const novely: <Languages extends string, Characters extends Record<string, Character<Languages>>, StateScheme extends State, DataScheme extends Data>({ characters, storage, storageDelay, renderer: createRenderer, initialScreen, translation, languages, state: defaultState, data: defaultData, autosaves, migrations, throttleTimeout, getLanguage, overrideLanguage, askBeforeExit, preloadAssets, parallelAssetsDownloadLimit, fetch: request }: NovelyInit<Languages, Characters, StateScheme, DataScheme>) => {
450
459
  /**
451
460
  * Function to set game script
452
461
  */
@@ -472,7 +481,7 @@ declare const novely: <Languages extends string, Characters extends Record<strin
472
481
  /**
473
482
  * Unwraps translatable content to a string value
474
483
  */
475
- unwrap(content: string | (() => string) | Record<Languages, string> | Exclude<Record<Languages, string | (() => string)>, Record<string, string>>): string;
484
+ unwrap(content: string | (() => string) | Exclude<Record<Languages, string | (() => string)>, Record<string, string>> | Record<Languages, string>): string;
476
485
  /**
477
486
  * Cancel data loading, hide UI, ignore page change events
478
487
  * Data updates still will work in case Novely already was loaded
@@ -174,11 +174,45 @@ var Novely = (() => {
174
174
  ]);
175
175
  var EMPTY_SET = /* @__PURE__ */ new Set();
176
176
  var DEFAULT_TYPEWRITER_SPEED = "Medium";
177
+ var HOWLER_SUPPORTED_FILE_FORMATS = /* @__PURE__ */ new Set([
178
+ "mp3",
179
+ "mpeg",
180
+ "opus",
181
+ "ogg",
182
+ "oga",
183
+ "wav",
184
+ "aac",
185
+ "caf",
186
+ "m4a",
187
+ "m4b",
188
+ "mp4",
189
+ "weba",
190
+ "webm",
191
+ "dolby",
192
+ "flac"
193
+ ]);
194
+ var SUPPORTED_IMAGE_FILE_FORMATS = /* @__PURE__ */ new Set([
195
+ "apng",
196
+ "avif",
197
+ "gif",
198
+ "jpg",
199
+ "jpeg",
200
+ "jfif",
201
+ "pjpeg",
202
+ "pjp",
203
+ "png",
204
+ "svg",
205
+ "webp",
206
+ "bmp"
207
+ ]);
177
208
  var MAIN_CONTEXT_KEY = "$MAIN";
178
209
 
179
210
  // src/shared.ts
180
211
  var STACK_MAP = /* @__PURE__ */ new Map();
181
212
 
213
+ // ../../node_modules/.pnpm/esm-env@1.0.0/node_modules/esm-env/prod-ssr.js
214
+ var DEV = false;
215
+
182
216
  // src/utils.ts
183
217
  var matchAction = ({ getContext, push, forward }, values) => {
184
218
  return (action, props, { ctx, data }) => {
@@ -187,9 +221,13 @@ var Novely = (() => {
187
221
  ctx: context,
188
222
  data,
189
223
  push() {
224
+ if (context.meta.preview)
225
+ return;
190
226
  push(context);
191
227
  },
192
228
  forward() {
229
+ if (context.meta.preview)
230
+ return;
193
231
  forward(context);
194
232
  }
195
233
  }, props);
@@ -298,6 +336,9 @@ var Novely = (() => {
298
336
  var isSkippedDuringRestore = (item) => {
299
337
  return SKIPPED_DURING_RESTORE.has(item);
300
338
  };
339
+ var isAudioAction = (action) => {
340
+ return AUDIO_ACTIONS.has(action);
341
+ };
301
342
  var noop = () => {
302
343
  };
303
344
  var isAction = (element) => {
@@ -520,6 +561,53 @@ var Novely = (() => {
520
561
  };
521
562
  return useStack;
522
563
  };
564
+ var mapSet = (set, fn) => {
565
+ return [...set].map(fn);
566
+ };
567
+ var isImageAsset = (asset) => {
568
+ return isString(asset) && isCSSImage(asset);
569
+ };
570
+ var getUrlFileExtension = (address) => {
571
+ try {
572
+ const { pathname } = new URL(address, location.href);
573
+ return pathname.split(".").at(-1).split("!")[0].split(":")[0];
574
+ } catch (error) {
575
+ if (DEV) {
576
+ console.error(new Error(`Could not construct URL "${address}".`, { cause: error }));
577
+ }
578
+ return "";
579
+ }
580
+ };
581
+ var fetchContentType = async (request, url) => {
582
+ try {
583
+ const response = await request(url, {
584
+ method: "HEAD"
585
+ });
586
+ return response.headers.get("Content-Type") || "";
587
+ } catch (error) {
588
+ if (DEV) {
589
+ console.error(new Error(`Failed to fetch file at "${url}"`, { cause: error }));
590
+ }
591
+ return "";
592
+ }
593
+ };
594
+ var getResourseType = async (request, url) => {
595
+ const extension = getUrlFileExtension(url);
596
+ if (HOWLER_SUPPORTED_FILE_FORMATS.has(extension)) {
597
+ return "audio";
598
+ }
599
+ if (SUPPORTED_IMAGE_FILE_FORMATS.has(extension)) {
600
+ return "image";
601
+ }
602
+ const contentType = await fetchContentType(request, url);
603
+ if (contentType.includes("audio")) {
604
+ return "audio";
605
+ }
606
+ if (contentType.includes("image")) {
607
+ return "image";
608
+ }
609
+ return "other";
610
+ };
523
611
 
524
612
  // src/global.ts
525
613
  var PRELOADED_ASSETS = /* @__PURE__ */ new Set();
@@ -715,11 +803,6 @@ var Novely = (() => {
715
803
 
716
804
  // src/novely.ts
717
805
  var import_p_limit = __toESM(require_p_limit(), 1);
718
-
719
- // ../../node_modules/.pnpm/esm-env@1.0.0/node_modules/esm-env/prod-ssr.js
720
- var DEV = false;
721
-
722
- // src/novely.ts
723
806
  var novely = ({
724
807
  characters,
725
808
  storage = localStorageStorage({ key: "novely-game-storage" }),
@@ -736,9 +819,12 @@ var Novely = (() => {
736
819
  getLanguage: getLanguage2 = getLanguage,
737
820
  overrideLanguage = false,
738
821
  askBeforeExit = true,
739
- preloadAssets = "lazy"
822
+ preloadAssets = "lazy",
823
+ parallelAssetsDownloadLimit = 15,
824
+ fetch: request = fetch
740
825
  }) => {
741
826
  const limitScript = (0, import_p_limit.default)(1);
827
+ const limitAssetsDownload = (0, import_p_limit.default)(parallelAssetsDownloadLimit);
742
828
  const story = {};
743
829
  const times = /* @__PURE__ */ new Set();
744
830
  const ASSETS_TO_PRELOAD = /* @__PURE__ */ new Set();
@@ -754,7 +840,23 @@ var Novely = (() => {
754
840
  Object.assign(story, flattenStory(part));
755
841
  if (preloadAssets === "blocking" && ASSETS_TO_PRELOAD.size > 0) {
756
842
  renderer.ui.showScreen("loading");
757
- await renderer.misc.preloadImagesBlocking(ASSETS_TO_PRELOAD);
843
+ const { preloadAudioBlocking, preloadImageBlocking } = renderer.misc;
844
+ const list = mapSet(ASSETS_TO_PRELOAD, (asset) => {
845
+ return limitAssetsDownload(async () => {
846
+ const type = await getResourseType(request, asset);
847
+ switch (type) {
848
+ case "audio": {
849
+ await preloadAudioBlocking(asset);
850
+ break;
851
+ }
852
+ case "image": {
853
+ await preloadImageBlocking(asset);
854
+ break;
855
+ }
856
+ }
857
+ });
858
+ });
859
+ await Promise.allSettled(list);
758
860
  }
759
861
  const screen = renderer.ui.getScreen();
760
862
  const nextScreen = scriptCalled ? screen : initialScreen;
@@ -775,13 +877,25 @@ var Novely = (() => {
775
877
  return limitScript(() => scriptBase(part));
776
878
  };
777
879
  const action = new Proxy({}, {
778
- get(_, prop) {
880
+ get(_, action2) {
779
881
  return (...props) => {
780
882
  if (preloadAssets === "blocking") {
781
- if (prop === "showBackground" && typeof props[0] === "string" && isCSSImage(props[0])) {
883
+ if (action2 === "showBackground") {
884
+ if (isImageAsset(props[0])) {
885
+ ASSETS_TO_PRELOAD.add(props[0]);
886
+ }
887
+ if (props[0] && typeof props[0] === "object") {
888
+ for (const value of Object.values(props[0])) {
889
+ if (!isImageAsset(value))
890
+ continue;
891
+ ASSETS_TO_PRELOAD.add(value);
892
+ }
893
+ }
894
+ }
895
+ if (isAudioAction(action2) && isString(props[0])) {
782
896
  ASSETS_TO_PRELOAD.add(props[0]);
783
897
  }
784
- if (prop === "showCharacter" && typeof props[0] === "string" && typeof props[1] === "string") {
898
+ if (action2 === "showCharacter" && isString(props[0]) && isString(props[1])) {
785
899
  const images = characters[props[0]].emotions[props[1]];
786
900
  if (Array.isArray(images)) {
787
901
  for (const asset of images) {
@@ -792,7 +906,7 @@ var Novely = (() => {
792
906
  }
793
907
  }
794
908
  }
795
- return [prop, ...props];
909
+ return [action2, ...props];
796
910
  };
797
911
  }
798
912
  });
@@ -912,12 +1026,11 @@ var Novely = (() => {
912
1026
  return;
913
1027
  let latest = save2 || $.get().saves.at(-1);
914
1028
  if (!latest) {
915
- $.set({
916
- saves: [initial],
917
- data: klona(defaultData),
918
- meta: [getLanguageWithoutParameters(), DEFAULT_TYPEWRITER_SPEED, 1, 1, 1]
919
- });
920
1029
  latest = klona(initial);
1030
+ $.update((prev) => {
1031
+ prev.saves.push(latest);
1032
+ return prev;
1033
+ });
921
1034
  }
922
1035
  const context = renderer.getContext(MAIN_CONTEXT_KEY);
923
1036
  const stack = useStack(context);
@@ -1021,7 +1134,7 @@ var Novely = (() => {
1021
1134
  ctx.meta.preview = true;
1022
1135
  const processor = createQueueProcessor(queue);
1023
1136
  await processor.run((action2, props) => {
1024
- if (AUDIO_ACTIONS.has(action2))
1137
+ if (isAudioAction(action2))
1025
1138
  return;
1026
1139
  if (action2 === "vibrate")
1027
1140
  return;
@@ -1272,10 +1385,10 @@ var Novely = (() => {
1272
1385
  ctx.vibrate(pattern);
1273
1386
  push();
1274
1387
  },
1275
- next({ ctx, push }) {
1388
+ next({ push }) {
1276
1389
  push();
1277
1390
  },
1278
- animateCharacter({ ctx, data: data2 }, [character, timeout, ...classes]) {
1391
+ animateCharacter({ ctx, push }, [character, timeout, ...classes]) {
1279
1392
  if (DEV && classes.length === 0) {
1280
1393
  throw new Error("Attempt to use AnimateCharacter without classes. Classes should be provided [https://novely.pages.dev/guide/actions/animateCharacter.html]");
1281
1394
  }
@@ -1284,30 +1397,8 @@ var Novely = (() => {
1284
1397
  }
1285
1398
  if (ctx.meta.preview)
1286
1399
  return;
1287
- const handler = ({ get }) => {
1288
- const { clear } = get(false);
1289
- const char = ctx.getCharacter(character);
1290
- if (!char)
1291
- return;
1292
- const target = char.canvas;
1293
- if (!target)
1294
- return;
1295
- const classNames = classes.filter((className) => !target.classList.contains(className));
1296
- target.classList.add(...classNames);
1297
- const removeClassNames = () => {
1298
- target.classList.remove(...classNames);
1299
- };
1300
- const timeoutId = setTimeout(removeClassNames, timeout);
1301
- clear(() => {
1302
- removeClassNames();
1303
- clearTimeout(timeoutId);
1304
- });
1305
- };
1306
- handler.key = "@@internal-animate-character";
1307
- match("custom", [handler], {
1308
- ctx,
1309
- data: data2
1310
- });
1400
+ ctx.character(character).animate(timeout, classes);
1401
+ push();
1311
1402
  },
1312
1403
  text({ ctx, data: data2, forward }, text) {
1313
1404
  const string = text.map((content) => unwrap2(content, data2)).join(" ");