@novely/core 0.22.2 → 0.24.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.js CHANGED
@@ -12,12 +12,44 @@ var AUDIO_ACTIONS = /* @__PURE__ */ new Set([
12
12
  ]);
13
13
  var EMPTY_SET = /* @__PURE__ */ new Set();
14
14
  var DEFAULT_TYPEWRITER_SPEED = "Medium";
15
+ var HOWLER_SUPPORTED_FILE_FORMATS = /* @__PURE__ */ new Set([
16
+ "mp3",
17
+ "mpeg",
18
+ "opus",
19
+ "ogg",
20
+ "oga",
21
+ "wav",
22
+ "aac",
23
+ "caf",
24
+ "m4a",
25
+ "m4b",
26
+ "mp4",
27
+ "weba",
28
+ "webm",
29
+ "dolby",
30
+ "flac"
31
+ ]);
32
+ var SUPPORTED_IMAGE_FILE_FORMATS = /* @__PURE__ */ new Set([
33
+ "apng",
34
+ "avif",
35
+ "gif",
36
+ "jpg",
37
+ "jpeg",
38
+ "jfif",
39
+ "pjpeg",
40
+ "pjp",
41
+ "png",
42
+ "svg",
43
+ "webp",
44
+ "bmp"
45
+ ]);
15
46
  var MAIN_CONTEXT_KEY = "$MAIN";
16
47
 
17
48
  // src/shared.ts
18
49
  var STACK_MAP = /* @__PURE__ */ new Map();
19
50
 
20
51
  // src/utils.ts
52
+ import { DEV } from "esm-env";
21
53
  var matchAction = ({ getContext, push, forward }, values) => {
22
54
  return (action, props, { ctx, data }) => {
23
55
  const context = typeof ctx === "string" ? getContext(ctx) : ctx;
@@ -25,9 +57,13 @@ var matchAction = ({ getContext, push, forward }, values) => {
25
57
  ctx: context,
26
58
  data,
27
59
  push() {
60
+ if (context.meta.preview)
61
+ return;
28
62
  push(context);
29
63
  },
30
64
  forward() {
65
+ if (context.meta.preview)
66
+ return;
31
67
  forward(context);
32
68
  }
33
69
  }, props);
@@ -136,6 +172,9 @@ var isBlockExitStatement = (statement) => {
136
172
  var isSkippedDuringRestore = (item) => {
137
173
  return SKIPPED_DURING_RESTORE.has(item);
138
174
  };
175
+ var isAudioAction = (action) => {
176
+ return AUDIO_ACTIONS.has(action);
177
+ };
139
178
  var noop = () => {
140
179
  };
141
180
  var isAction = (element) => {
@@ -358,6 +397,53 @@ var createUseStackFunction = (renderer) => {
358
397
  };
359
398
  return useStack;
360
399
  };
400
+ var mapSet = (set, fn) => {
401
+ return [...set].map(fn);
402
+ };
403
+ var isImageAsset = (asset) => {
404
+ return isString(asset) && isCSSImage(asset);
405
+ };
406
+ var getUrlFileExtension = (address) => {
407
+ try {
408
+ const { pathname } = new URL(address, location.href);
409
+ return pathname.split(".").at(-1).split("!")[0].split(":")[0];
410
+ } catch (error) {
411
+ if (DEV) {
412
+ console.error(new Error(`Could not construct URL "${address}".`, { cause: error }));
413
+ }
414
+ return "";
415
+ }
416
+ };
417
+ var fetchContentType = async (request, url) => {
418
+ try {
419
+ const response = await request(url, {
420
+ method: "HEAD"
421
+ });
422
+ return response.headers.get("Content-Type") || "";
423
+ } catch (error) {
424
+ if (DEV) {
425
+ console.error(new Error(`Failed to fetch file at "${url}"`, { cause: error }));
426
+ }
427
+ return "";
428
+ }
429
+ };
430
+ var getResourseType = async (request, url) => {
431
+ const extension = getUrlFileExtension(url);
432
+ if (HOWLER_SUPPORTED_FILE_FORMATS.has(extension)) {
433
+ return "audio";
434
+ }
435
+ if (SUPPORTED_IMAGE_FILE_FORMATS.has(extension)) {
436
+ return "image";
437
+ }
438
+ const contentType = await fetchContentType(request, url);
439
+ if (contentType.includes("audio")) {
440
+ return "audio";
441
+ }
442
+ if (contentType.includes("image")) {
443
+ return "image";
444
+ }
445
+ return "other";
446
+ };
361
447
 
362
448
  // src/global.ts
363
449
  var PRELOADED_ASSETS = /* @__PURE__ */ new Set();
@@ -553,7 +639,7 @@ var localStorageStorage = (options) => {
553
639
 
554
640
  // src/novely.ts
555
641
  import pLimit from "p-limit";
556
- import { DEV } from "esm-env";
642
+ import { DEV as DEV2 } from "esm-env";
557
643
  var novely = ({
558
644
  characters,
559
645
  storage = localStorageStorage({ key: "novely-game-storage" }),
@@ -561,7 +647,6 @@ var novely = ({
561
647
  renderer: createRenderer,
562
648
  initialScreen = "mainmenu",
563
649
  translation,
564
- languages,
565
650
  state: defaultState,
566
651
  data: defaultData,
567
652
  autosaves = true,
@@ -570,9 +655,13 @@ var novely = ({
570
655
  getLanguage: getLanguage2 = getLanguage,
571
656
  overrideLanguage = false,
572
657
  askBeforeExit = true,
573
- preloadAssets = "lazy"
658
+ preloadAssets = "lazy",
659
+ parallelAssetsDownloadLimit = 15,
660
+ fetch: request = fetch
574
661
  }) => {
662
+ const languages = Object.keys(translation);
575
663
  const limitScript = pLimit(1);
664
+ const limitAssetsDownload = pLimit(parallelAssetsDownloadLimit);
576
665
  const story = {};
577
666
  const times = /* @__PURE__ */ new Set();
578
667
  const ASSETS_TO_PRELOAD = /* @__PURE__ */ new Set();
@@ -588,7 +677,23 @@ var novely = ({
588
677
  Object.assign(story, flattenStory(part));
589
678
  if (preloadAssets === "blocking" && ASSETS_TO_PRELOAD.size > 0) {
590
679
  renderer.ui.showScreen("loading");
591
- await renderer.misc.preloadImagesBlocking(ASSETS_TO_PRELOAD);
680
+ const { preloadAudioBlocking, preloadImageBlocking } = renderer.misc;
681
+ const list = mapSet(ASSETS_TO_PRELOAD, (asset) => {
682
+ return limitAssetsDownload(async () => {
683
+ const type = await getResourseType(request, asset);
684
+ switch (type) {
685
+ case "audio": {
686
+ await preloadAudioBlocking(asset);
687
+ break;
688
+ }
689
+ case "image": {
690
+ await preloadImageBlocking(asset);
691
+ break;
692
+ }
693
+ }
694
+ });
695
+ });
696
+ await Promise.allSettled(list);
592
697
  }
593
698
  const screen = renderer.ui.getScreen();
594
699
  const nextScreen = scriptCalled ? screen : initialScreen;
@@ -609,13 +714,25 @@ var novely = ({
609
714
  return limitScript(() => scriptBase(part));
610
715
  };
611
716
  const action = new Proxy({}, {
612
- get(_, prop) {
717
+ get(_, action2) {
613
718
  return (...props) => {
614
719
  if (preloadAssets === "blocking") {
615
- if (prop === "showBackground" && typeof props[0] === "string" && isCSSImage(props[0])) {
720
+ if (action2 === "showBackground") {
721
+ if (isImageAsset(props[0])) {
722
+ ASSETS_TO_PRELOAD.add(props[0]);
723
+ }
724
+ if (props[0] && typeof props[0] === "object") {
725
+ for (const value of Object.values(props[0])) {
726
+ if (!isImageAsset(value))
727
+ continue;
728
+ ASSETS_TO_PRELOAD.add(value);
729
+ }
730
+ }
731
+ }
732
+ if (isAudioAction(action2) && isString(props[0])) {
616
733
  ASSETS_TO_PRELOAD.add(props[0]);
617
734
  }
618
- if (prop === "showCharacter" && typeof props[0] === "string" && typeof props[1] === "string") {
735
+ if (action2 === "showCharacter" && isString(props[0]) && isString(props[1])) {
619
736
  const images = characters[props[0]].emotions[props[1]];
620
737
  if (Array.isArray(images)) {
621
738
  for (const asset of images) {
@@ -626,7 +743,7 @@ var novely = ({
626
743
  }
627
744
  }
628
745
  }
629
- return [prop, ...props];
746
+ return [action2, ...props];
630
747
  };
631
748
  }
632
749
  });
@@ -675,12 +792,10 @@ var novely = ({
675
792
  for (const migration of migrations) {
676
793
  stored = migration(stored);
677
794
  }
678
- stored.meta[1] ||= DEFAULT_TYPEWRITER_SPEED;
679
- if (overrideLanguage) {
795
+ if (overrideLanguage || !stored.meta[0]) {
680
796
  stored.meta[0] = getLanguageWithoutParameters();
681
- } else {
682
- stored.meta[0] ||= getLanguageWithoutParameters();
683
797
  }
798
+ stored.meta[1] ||= DEFAULT_TYPEWRITER_SPEED;
684
799
  stored.meta[2] ??= 1;
685
800
  stored.meta[3] ??= 1;
686
801
  stored.meta[4] ??= 1;
@@ -746,12 +861,11 @@ var novely = ({
746
861
  return;
747
862
  let latest = save2 || $.get().saves.at(-1);
748
863
  if (!latest) {
749
- $.set({
750
- saves: [initial],
751
- data: klona(defaultData),
752
- meta: [getLanguageWithoutParameters(), DEFAULT_TYPEWRITER_SPEED, 1, 1, 1]
753
- });
754
864
  latest = klona(initial);
865
+ $.update((prev) => {
866
+ prev.saves.push(latest);
867
+ return prev;
868
+ });
755
869
  }
756
870
  const context = renderer.getContext(MAIN_CONTEXT_KEY);
757
871
  const stack = useStack(context);
@@ -855,7 +969,7 @@ var novely = ({
855
969
  ctx.meta.preview = true;
856
970
  const processor = createQueueProcessor(queue);
857
971
  await processor.run((action2, props) => {
858
- if (AUDIO_ACTIONS.has(action2))
972
+ if (isAudioAction(action2))
859
973
  return;
860
974
  if (action2 === "vibrate")
861
975
  return;
@@ -959,7 +1073,7 @@ var novely = ({
959
1073
  push();
960
1074
  },
961
1075
  showCharacter({ ctx, push }, [character, emotion, className, style]) {
962
- if (DEV && !characters[character].emotions[emotion]) {
1076
+ if (DEV2 && !characters[character].emotions[emotion]) {
963
1077
  throw new Error(`Attempt to show character "${character}" with unknown emotion "${emotion}"`);
964
1078
  }
965
1079
  const handle = ctx.character(character);
@@ -1008,12 +1122,12 @@ var novely = ({
1008
1122
  question = "";
1009
1123
  }
1010
1124
  const unwrappedChoices = choices.map(([content, action2, visible]) => {
1011
- if (DEV && action2.length === 0 && (!visible || visible())) {
1125
+ if (DEV2 && action2.length === 0 && (!visible || visible())) {
1012
1126
  console.warn(`Choice children should not be empty, either add content there or make item not selectable`);
1013
1127
  }
1014
1128
  return [unwrap2(content, data2), action2, visible];
1015
1129
  });
1016
- if (DEV && unwrappedChoices.length === 0) {
1130
+ if (DEV2 && unwrappedChoices.length === 0) {
1017
1131
  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]`);
1018
1132
  }
1019
1133
  ctx.choices(unwrap2(question, data2), unwrappedChoices, (selected) => {
@@ -1022,7 +1136,7 @@ var novely = ({
1022
1136
  }
1023
1137
  const stack = useStack(ctx);
1024
1138
  const offset = isWithoutQuestion ? 0 : 1;
1025
- if (DEV && !unwrappedChoices[selected]) {
1139
+ if (DEV2 && !unwrappedChoices[selected]) {
1026
1140
  throw new Error("Choice children is empty, either add content there or make item not selectable");
1027
1141
  }
1028
1142
  stack.value[0].push(["choice", selected + offset], [null, 0]);
@@ -1031,10 +1145,10 @@ var novely = ({
1031
1145
  });
1032
1146
  },
1033
1147
  jump({ ctx, data: data2 }, [scene]) {
1034
- if (DEV && !story[scene]) {
1148
+ if (DEV2 && !story[scene]) {
1035
1149
  throw new Error(`Attempt to jump to unknown scene "${scene}"`);
1036
1150
  }
1037
- if (DEV && story[scene].length === 0) {
1151
+ if (DEV2 && story[scene].length === 0) {
1038
1152
  throw new Error(`Attempt to jump to empty scene "${scene}"`);
1039
1153
  }
1040
1154
  const stack = useStack(ctx);
@@ -1057,15 +1171,15 @@ var novely = ({
1057
1171
  );
1058
1172
  },
1059
1173
  condition({ ctx }, [condition, variants]) {
1060
- if (DEV && Object.values(variants).length === 0) {
1174
+ if (DEV2 && Object.values(variants).length === 0) {
1061
1175
  throw new Error(`Attempt to use Condition action with empty variants object`);
1062
1176
  }
1063
1177
  if (!ctx.meta.restoring) {
1064
1178
  const val = String(condition());
1065
- if (DEV && !variants[val]) {
1179
+ if (DEV2 && !variants[val]) {
1066
1180
  throw new Error(`Attempt to go to unknown variant "${val}"`);
1067
1181
  }
1068
- if (DEV && variants[val].length === 0) {
1182
+ if (DEV2 && variants[val].length === 0) {
1069
1183
  throw new Error(`Attempt to go to empty variant "${val}"`);
1070
1184
  }
1071
1185
  const stack = useStack(MAIN_CONTEXT_KEY);
@@ -1106,46 +1220,24 @@ var novely = ({
1106
1220
  ctx.vibrate(pattern);
1107
1221
  push();
1108
1222
  },
1109
- next({ ctx, push }) {
1223
+ next({ push }) {
1110
1224
  push();
1111
1225
  },
1112
- animateCharacter({ ctx, data: data2 }, [character, timeout, ...classes]) {
1113
- if (DEV && classes.length === 0) {
1226
+ animateCharacter({ ctx, push }, [character, timeout, ...classes]) {
1227
+ if (DEV2 && classes.length === 0) {
1114
1228
  throw new Error("Attempt to use AnimateCharacter without classes. Classes should be provided [https://novely.pages.dev/guide/actions/animateCharacter.html]");
1115
1229
  }
1116
- if (DEV && (timeout <= 0 || !Number.isFinite(timeout) || Number.isNaN(timeout))) {
1230
+ if (DEV2 && (timeout <= 0 || !Number.isFinite(timeout) || Number.isNaN(timeout))) {
1117
1231
  throw new Error("Attempt to use AnimateCharacter with unacceptable timeout. It should be finite and greater than zero");
1118
1232
  }
1119
1233
  if (ctx.meta.preview)
1120
1234
  return;
1121
- const handler = ({ get }) => {
1122
- const { clear } = get(false);
1123
- const char = ctx.getCharacter(character);
1124
- if (!char)
1125
- return;
1126
- const target = char.canvas;
1127
- if (!target)
1128
- return;
1129
- const classNames = classes.filter((className) => !target.classList.contains(className));
1130
- target.classList.add(...classNames);
1131
- const removeClassNames = () => {
1132
- target.classList.remove(...classNames);
1133
- };
1134
- const timeoutId = setTimeout(removeClassNames, timeout);
1135
- clear(() => {
1136
- removeClassNames();
1137
- clearTimeout(timeoutId);
1138
- });
1139
- };
1140
- handler.key = "@@internal-animate-character";
1141
- match("custom", [handler], {
1142
- ctx,
1143
- data: data2
1144
- });
1235
+ ctx.character(character).animate(timeout, classes);
1236
+ push();
1145
1237
  },
1146
1238
  text({ ctx, data: data2, forward }, text) {
1147
1239
  const string = text.map((content) => unwrap2(content, data2)).join(" ");
1148
- if (DEV && string.length === 0) {
1240
+ if (DEV2 && string.length === 0) {
1149
1241
  throw new Error(`Action Text was called with empty string or array`);
1150
1242
  }
1151
1243
  ctx.text(string, forward);
@@ -1204,10 +1296,10 @@ var novely = ({
1204
1296
  push();
1205
1297
  },
1206
1298
  block({ ctx }, [scene]) {
1207
- if (DEV && !story[scene]) {
1299
+ if (DEV2 && !story[scene]) {
1208
1300
  throw new Error(`Attempt to call Block action with unknown scene "${scene}"`);
1209
1301
  }
1210
- if (DEV && story[scene].length === 0) {
1302
+ if (DEV2 && story[scene].length === 0) {
1211
1303
  throw new Error(`Attempt to call Block action with empty scene "${scene}"`);
1212
1304
  }
1213
1305
  if (!ctx.meta.restoring) {