@novely/core
Version:
Novely - powerful visual novel engine for creating interactive stories and games with branching narratives and rich multimedia content
1,765 lines (1,734 loc) • 79.8 kB
JavaScript
// ../../node_modules/.pnpm/dequal@2.0.3/node_modules/dequal/lite/index.mjs
var has = Object.prototype.hasOwnProperty;
function dequal(foo, bar) {
var ctor, len;
if (foo === bar) return true;
if (foo && bar && (ctor = foo.constructor) === bar.constructor) {
if (ctor === Date) return foo.getTime() === bar.getTime();
if (ctor === RegExp) return foo.toString() === bar.toString();
if (ctor === Array) {
if ((len = foo.length) === bar.length) {
while (len-- && dequal(foo[len], bar[len])) ;
}
return len === -1;
}
if (!ctor || typeof foo === "object") {
len = 0;
for (ctor in foo) {
if (has.call(foo, ctor) && ++len && !has.call(bar, ctor)) return false;
if (!(ctor in bar) || !dequal(foo[ctor], bar[ctor])) return false;
}
return Object.keys(bar).length === len;
}
}
return foo !== foo && bar !== bar;
}
// src/novely.ts
import { memoize as memoize5, throttle } from "es-toolkit/function";
import { merge as deepmerge } from "es-toolkit/object";
import { DEV as DEV5 } from "esm-env";
// ../../node_modules/.pnpm/klona@2.0.6/node_modules/klona/full/index.mjs
function set(obj, key, val) {
if (typeof val.value === "object") val.value = klona(val.value);
if (!val.enumerable || val.get || val.set || !val.configurable || !val.writable || key === "__proto__") {
Object.defineProperty(obj, key, val);
} else obj[key] = val.value;
}
function klona(x) {
if (typeof x !== "object") return x;
var i = 0, k, list, tmp, str = Object.prototype.toString.call(x);
if (str === "[object Object]") {
tmp = Object.create(x.__proto__ || null);
} else if (str === "[object Array]") {
tmp = Array(x.length);
} else if (str === "[object Set]") {
tmp = /* @__PURE__ */ new Set();
x.forEach(function(val) {
tmp.add(klona(val));
});
} else if (str === "[object Map]") {
tmp = /* @__PURE__ */ new Map();
x.forEach(function(val, key) {
tmp.set(klona(key), klona(val));
});
} else if (str === "[object Date]") {
tmp = /* @__PURE__ */ new Date(+x);
} else if (str === "[object RegExp]") {
tmp = new RegExp(x.source, x.flags);
} else if (str === "[object DataView]") {
tmp = new x.constructor(klona(x.buffer));
} else if (str === "[object ArrayBuffer]") {
tmp = x.slice(0);
} else if (str.slice(-6) === "Array]") {
tmp = new x.constructor(x);
}
if (tmp) {
for (list = Object.getOwnPropertySymbols(x); i < list.length; i++) {
set(tmp, list[i], Object.getOwnPropertyDescriptor(x, list[i]));
}
for (i = 0, list = Object.getOwnPropertyNames(x); i < list.length; i++) {
if (Object.hasOwnProperty.call(tmp, k = list[i]) && tmp[k] === x[k]) continue;
set(tmp, k, Object.getOwnPropertyDescriptor(x, k));
}
}
return tmp || x;
}
// src/novely.ts
import pLimit from "p-limit";
// src/constants.ts
var SKIPPED_DURING_RESTORE = /* @__PURE__ */ new Set(["dialog", "choice", "input", "vibrate", "text"]);
var BLOCK_EXIT_STATEMENTS = /* @__PURE__ */ new Set(["choice:exit", "condition:exit", "block:exit"]);
var BLOCK_STATEMENTS = /* @__PURE__ */ new Set(["choice", "condition", "block"]);
var AUDIO_ACTIONS = /* @__PURE__ */ new Set(["playMusic", "stopMusic", "playSound", "stopSound", "voice", "stopVoice"]);
var EMPTY_SET = /* @__PURE__ */ new Set();
var DEFAULT_TYPEWRITER_SPEED = "Medium";
var HOWLER_SUPPORTED_FILE_FORMATS = /* @__PURE__ */ new Set([
"mp3",
"mpeg",
"opus",
"ogg",
"oga",
"wav",
"aac",
"caf",
"m4a",
"m4b",
"mp4",
"weba",
"webm",
"dolby",
"flac"
]);
var SUPPORTED_IMAGE_FILE_FORMATS = /* @__PURE__ */ new Set([
"apng",
"avif",
"gif",
"jpg",
"jpeg",
"jfif",
"pjpeg",
"pjp",
"png",
"svg",
"webp",
"bmp"
]);
var MAIN_CONTEXT_KEY = "$MAIN";
// src/shared.ts
var STACK_MAP = /* @__PURE__ */ new Map();
var CUSTOM_ACTION_MAP = /* @__PURE__ */ new Map();
var CUSTOM_ACTION_CLEANUP_MAP = /* @__PURE__ */ new Map();
var PRELOADED_ASSETS = /* @__PURE__ */ new Set();
var ASSETS_TO_PRELOAD = /* @__PURE__ */ new Set();
// src/utilities/assertions.ts
var isNumber = (val) => {
return typeof val === "number";
};
var isNull = (val) => {
return val === null;
};
var isString = (val) => {
return typeof val === "string";
};
var isFunction = (val) => {
return typeof val === "function";
};
var isPromise = (val) => {
return Boolean(val) && (typeof val === "object" || isFunction(val)) && isFunction(val.then);
};
var isEmpty = (val) => {
return typeof val === "object" && !isNull(val) && Object.keys(val).length === 0;
};
var isUserRequiredAction = ([action, ...meta]) => {
return Boolean(action === "custom" && meta[0] && meta[0].requireUserAction);
};
var isBlockStatement = (statement) => {
return BLOCK_STATEMENTS.has(statement);
};
var isBlockExitStatement = (statement) => {
return BLOCK_EXIT_STATEMENTS.has(statement);
};
var isSkippedDuringRestore = (item) => {
return SKIPPED_DURING_RESTORE.has(item);
};
var isAudioAction = (action) => {
return AUDIO_ACTIONS.has(action);
};
var isAction = (element) => {
return Array.isArray(element) && isString(element[0]);
};
var isBlockingAction = (action) => {
return isUserRequiredAction(action) || isSkippedDuringRestore(action[0]) && action[0] !== "vibrate";
};
var isAsset = (suspect) => {
return suspect !== null && typeof suspect === "object" && "source" in suspect && "type" in suspect;
};
// src/utilities/match-action.ts
var matchAction = (callbacks, values) => {
const { getContext, onBeforeActionCall, push, forward } = callbacks;
const match = (action, props, { ctx, data }) => {
const context = typeof ctx === "string" ? getContext(ctx) : ctx;
onBeforeActionCall({
action,
props,
ctx: context
});
return values[action](
{
ctx: context,
data,
push() {
if (context.meta.preview) return;
push(context);
},
forward() {
if (context.meta.preview) return;
forward(context);
}
},
props
);
};
return {
match,
nativeActions: Object.keys(values)
};
};
// src/asset.ts
import { memoize, once } from "es-toolkit/function";
import { DEV } from "esm-env";
// src/audio-codecs.ts
var cut = (str) => str.replace(/^no$/, "");
var audio = new Audio();
var canPlay = (type) => !!cut(audio.canPlayType(type));
var canPlayMultiple = (...types) => types.some((type) => canPlay(type));
var supportsMap = {
mp3: canPlayMultiple("audio/mpeg;", "audio/mp3;"),
mpeg: canPlay("audio/mpeg;"),
opus: canPlay('audio/ogg; codecs="opus"'),
ogg: canPlay('audio/ogg; codecs="vorbis"'),
oga: canPlay('audio/ogg; codecs="vorbis"'),
wav: canPlayMultiple('audio/wav; codecs="1"', "audio/wav;"),
aac: canPlay("audio/aac;"),
caf: canPlay("audio/x-caf;"),
m4a: canPlayMultiple("audio/x-m4a;", "audio/m4a;", "audio/aac;"),
m4b: canPlayMultiple("audio/x-m4b;", "audio/m4b;", "audio/aac;"),
mp4: canPlayMultiple("audio/x-mp4;", "audio/mp4;", "audio/aac;"),
weba: canPlay('audio/webm; codecs="vorbis"'),
webm: canPlay('audio/webm; codecs="vorbis"'),
dolby: canPlay('audio/mp4; codecs="ec-3"'),
flac: canPlayMultiple("audio/x-flac;", "audio/flac;")
};
// src/image-formats.ts
var avif = "data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKCBgANogQEAwgMg8f8D///8WfhwB8+ErK42A=";
var jxl = "data:image/jxl;base64,/woIAAAMABKIAgC4AF3lEgAAFSqjjBu8nOv58kOHxbSN6wxttW1hSaLIODZJJ3BIEkkaoCUzGM6qJAE=";
var webp = "data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA";
var supportsFormat = (source) => {
const { promise, resolve } = Promise.withResolvers();
const img = Object.assign(document.createElement("img"), {
src: source
});
img.onload = img.onerror = () => {
resolve(img.height === 2);
};
return promise;
};
var supportsMap2 = {
avif: false,
jxl: false,
webp: false
};
var formatsMap = {
avif,
jxl,
webp
};
var loadImageFormatsSupport = async () => {
const promises = [];
for (const [format, source] of Object.entries(formatsMap)) {
const promise = supportsFormat(source).then((supported) => {
supportsMap2[format] = supported;
});
promises.push(promise);
}
await Promise.all(promises);
};
loadImageFormatsSupport();
// src/asset.ts
var generateRandomId = () => Math.random().toString(36);
var getType = memoize(
(extensions) => {
if (extensions.every((extension) => HOWLER_SUPPORTED_FILE_FORMATS.has(extension))) {
return "audio";
}
if (extensions.every((extension) => SUPPORTED_IMAGE_FILE_FORMATS.has(extension))) {
return "image";
}
if (DEV) {
throw new Error(`Unsupported file extensions: ${JSON.stringify(extensions)}`);
}
throw extensions;
},
{
getCacheKey: (extensions) => extensions.join("~")
}
);
var SUPPORT_MAPS = {
image: supportsMap2,
audio: supportsMap
};
var assetPrivate = memoize(
(variants) => {
if (DEV && variants.length === 0) {
throw new Error(`Attempt to use "asset" function without arguments`);
}
const map = {};
const extensions = [];
for (const v of variants) {
const e = getUrlFileExtension(v);
map[e] = v;
extensions.push(e);
}
const type = getType(extensions);
const getSource = once(() => {
const support = SUPPORT_MAPS[type];
for (const extension of extensions) {
if (extension in support) {
if (support[extension]) {
return map[extension];
}
} else {
return map[extension];
}
}
if (DEV) {
throw new Error(`No matching asset was found for ${variants.map((v) => `"${v}"`).join(", ")}`);
}
return "";
});
return {
get source() {
return getSource();
},
get type() {
return type;
},
id: generateRandomId()
};
},
{
getCacheKey: (variants) => variants.join("~")
}
);
var asset = (...variants) => {
return assetPrivate(variants);
};
asset.image = (source) => {
if (assetPrivate.cache.has(source)) {
return assetPrivate.cache.get(source);
}
const asset2 = {
type: "image",
source,
id: generateRandomId()
};
assetPrivate.cache.set(source, asset2);
return asset2;
};
asset.audio = (source) => {
if (assetPrivate.cache.has(source)) {
return assetPrivate.cache.get(source);
}
const asset2 = {
type: "audio",
source,
id: generateRandomId()
};
assetPrivate.cache.set(source, asset2);
return asset2;
};
var unwrapAsset = (asset2) => {
return isAsset(asset2) ? asset2.source : asset2;
};
var unwrapAudioAsset = (asset2) => {
if (DEV && isAsset(asset2) && asset2.type !== "audio") {
throw new Error("Attempt to use non-audio asset in audio action", { cause: asset2 });
}
return unwrapAsset(asset2);
};
var unwrapImageAsset = (asset2) => {
if (DEV && isAsset(asset2) && asset2.type !== "image") {
throw new Error("Attempt to use non-image asset in action that requires image assets", { cause: asset2 });
}
return unwrapAsset(asset2);
};
// src/utilities/actions-processing.ts
import { DEV as DEV2 } from "esm-env";
var isExitImpossible = (path) => {
const blockStatements = path.filter(([item]) => isBlockStatement(item));
const blockExitStatements = path.filter(([item]) => isBlockExitStatement(item));
if (blockStatements.length === 0 && blockExitStatements.length === 0) {
return true;
}
if (blockStatements.length > blockExitStatements.length) {
return false;
}
return !blockExitStatements.every(([name], i) => name && name.startsWith(blockStatements[i][0]));
};
var createReferFunction = ({ story, onUnknownSceneHit }) => {
const refer = async (path) => {
const { promise: ready, resolve: setReady } = Promise.withResolvers();
let current = story;
let precurrent = story;
const blocks = [];
const refer2 = async () => {
for (const [type, val] of path) {
if (type === "jump") {
if (!current[val]) {
setReady(true);
await onUnknownSceneHit(val);
}
if (DEV2 && !story[val]) {
throw new Error(`Attempt to jump to unknown scene "${val}"`);
}
if (DEV2 && story[val].length === 0) {
throw new Error(`Attempt to jump to empty scene "${val}"`);
}
precurrent = story;
current = current[val];
} else if (type === null) {
precurrent = current;
current = current[val];
} else if (type === "choice") {
blocks.push(precurrent);
current = current[val + 1][1];
} else if (type === "condition") {
blocks.push(precurrent);
current = current[2][val];
} else if (type === "block") {
blocks.push(precurrent);
current = story[val];
} else if (type === "block:exit" || type === "choice:exit" || type === "condition:exit") {
current = blocks.pop();
}
}
setReady(false);
return current;
};
const value = refer2();
const found = await ready;
return {
found,
value
};
};
const referGuarded = async (path) => {
return await (await refer(path)).value;
};
return {
refer,
referGuarded
};
};
var exitPath = async ({ path, refer, onExitImpossible }) => {
const last = path.at(-1);
const ignore = [];
let wasExitImpossible = false;
if (!isAction(await refer(path))) {
if (last && isNull(last[0]) && isNumber(last[1])) {
last[1]--;
} else {
path.pop();
}
}
if (isExitImpossible(path)) {
const referred = await refer(path);
if (isAction(referred) && isSkippedDuringRestore(referred[0])) {
onExitImpossible?.();
}
wasExitImpossible = true;
return {
exitImpossible: wasExitImpossible
};
}
for (let i = path.length - 1; i > 0; i--) {
const [name] = path[i];
if (isBlockExitStatement(name)) {
ignore.push(name);
}
if (!isBlockStatement(name)) continue;
if (ignore.at(-1)?.startsWith(name)) {
ignore.pop();
continue;
}
path.push([`${name}:exit`]);
const prev = findLastPathItemBeforeItemOfType(path.slice(0, i + 1), name);
if (prev) path.push([null, prev[1] + 1]);
if (!isAction(await refer(path))) {
path.pop();
continue;
}
break;
}
return {
exitImpossible: wasExitImpossible
};
};
var nextPath = (path) => {
const last = path.at(-1);
if (last && (isNull(last[0]) || last[0] === "jump") && isNumber(last[1])) {
last[1]++;
} else {
path.push([null, 0]);
}
return path;
};
var collectActionsBeforeBlockingAction = async ({
path,
refer,
clone
}) => {
const collection = [];
let action = await refer(path);
while (true) {
if (action == void 0) {
const { exitImpossible } = await exitPath({
path,
refer
});
if (exitImpossible) {
break;
}
}
if (!action) {
break;
}
if (isBlockingAction(action)) {
const [name, ...props] = action;
if (name === "choice") {
const choiceProps = props;
for (let i = 0; i < choiceProps.length; i++) {
const branchContent = choiceProps[i];
if (!Array.isArray(branchContent)) continue;
const virtualPath = clone(path);
virtualPath.push(["choice", i], [null, 0]);
const innerActions = await collectActionsBeforeBlockingAction({
path: virtualPath,
refer,
clone
});
collection.push(...innerActions);
}
} else if (name === "condition") {
const conditionProps = props;
const conditions = Object.keys(conditionProps[1]);
for (const condition of conditions) {
const virtualPath = clone(path);
virtualPath.push(["condition", condition], [null, 0]);
const innerActions = await collectActionsBeforeBlockingAction({
path: virtualPath,
refer,
clone
});
collection.push(...innerActions);
}
}
break;
}
collection.push(action);
if (action[0] === "jump") {
path = [
["jump", action[1]],
[null, 0]
];
} else if (action[0] == "block") {
path.push(["block", action[1]], [null, 0]);
} else {
nextPath(path);
}
action = await refer(path);
}
return collection;
};
var findLastPathItemBeforeItemOfType = (path, name) => {
const item = path.findLast(([_name, _value], i, array) => {
const next = array[i + 1];
return isNull(_name) && isNumber(_value) && next != null && next[0] === name;
});
return item;
};
var getOppositeAction = (action) => {
const MAP = {
showCharacter: "hideCharacter",
playSound: "stopSound",
playMusic: "stopMusic",
voice: "stopVoice"
};
return MAP[action];
};
var getActionsFromPath = async ({ story, path, filter, referGuarded }) => {
let current = story;
let precurrent;
let ignoreNestedBefore = null;
let index = 0;
let skipPreserve = void 0;
const skip = /* @__PURE__ */ new Set();
const max = path.reduce((acc, [type, val]) => {
if (isNull(type) && isNumber(val)) {
return acc + 1;
}
return acc;
}, 0);
const queue = [];
const blocks = [];
await referGuarded(path);
for (const [type, val] of path) {
if (type === "jump") {
precurrent = story;
current = current[val];
} else if (type === null) {
precurrent = current;
if (isNumber(val)) {
index++;
let startIndex = 0;
if (ignoreNestedBefore) {
const prev = findLastPathItemBeforeItemOfType(path.slice(0, index), ignoreNestedBefore);
if (prev) {
startIndex = prev[1];
ignoreNestedBefore = null;
}
}
for (let i = startIndex; i <= val; i++) {
const item = current[i];
if (!isAction(item)) continue;
const [action] = item;
const last = index === max && i === val;
const shouldSkip = isSkippedDuringRestore(action) || isUserRequiredAction(item);
if (shouldSkip) {
skip.add(item);
}
if (shouldSkip && last) {
skipPreserve = item;
}
if (filter && shouldSkip && !last) {
continue;
} else {
queue.push(item);
}
}
}
current = current[val];
} else if (type === "choice") {
blocks.push(precurrent);
current = current[val + 1][1];
} else if (type === "condition") {
blocks.push(precurrent);
current = current[2][val];
} else if (type === "block") {
blocks.push(precurrent);
current = story[val];
} else if (type === "block:exit" || type === "choice:exit" || type === "condition:exit") {
current = blocks.pop();
ignoreNestedBefore = type.slice(0, -5);
}
}
return {
queue,
skip,
skipPreserve
};
};
var createQueueProcessor = (queue, options) => {
const processedQueue = [];
const keep = /* @__PURE__ */ new Set();
const characters = /* @__PURE__ */ new Set();
const audio2 = {
music: /* @__PURE__ */ new Set(),
sounds: /* @__PURE__ */ new Set()
};
const next = (i) => queue.slice(i + 1);
for (const [i, item] of queue.entries()) {
const [action, ...params] = item;
if (options.skip.has(item) && item !== options.skipPreserve) {
continue;
}
keep.add(action);
if (action === "function" || action === "custom") {
if (action === "custom") {
const fn = params[0];
if (fn.callOnlyLatest) {
const notLatest = next(i).some(([name, func]) => {
if (name !== "custom") return;
const isIdenticalId = Boolean(func.id && fn.id && func.id === fn.id);
const isIdenticalByReference = func === fn;
const isIdenticalByCode = String(func) === String(fn);
return isIdenticalId || isIdenticalByReference || isIdenticalByCode;
});
if (notLatest) continue;
} else if (fn.skipOnRestore) {
if (fn.skipOnRestore(next(i))) {
continue;
}
}
}
processedQueue.push(item);
} else if (action === "playSound") {
const closing = getOppositeAction(action);
const skip = next(i).some((item2) => {
if (isUserRequiredAction(item2) || isSkippedDuringRestore(item2[0])) {
return true;
}
const [_action, target] = item2;
if (target !== params[0]) {
return false;
}
return _action === closing || _action === action;
});
if (skip) continue;
audio2.sounds.add(unwrapAsset(params[0]));
processedQueue.push(item);
} else if (action === "showCharacter" || action === "playMusic" || action === "voice") {
const closing = getOppositeAction(action);
const skip = next(i).some(([_action, target]) => {
if (target !== params[0] && action !== "voice") {
return false;
}
const musicWillBePaused = action === "playMusic" && _action === "pauseMusic";
return musicWillBePaused || _action === closing || _action === action;
});
if (skip) continue;
if (action === "showCharacter") {
characters.add(params[0]);
} else if (action === "playMusic") {
audio2.music.add(unwrapAsset(params[0]));
}
processedQueue.push(item);
} else if (action === "showBackground" || action === "preload") {
const skip = next(i).some(([_action]) => action === _action);
if (skip) continue;
processedQueue.push(item);
} else if (action === "animateCharacter") {
const skip = next(i).some(([_action, character], j, array) => {
if (action === _action && character === params[0]) {
return true;
}
const next2 = array.slice(j);
const characterWillAnimate = next2.some(([__action, __character]) => action === __action);
const hasBlockingActions = next2.some((item2) => options.skip.has(item2));
const differentCharacterWillAnimate = !hasBlockingActions && next2.some(([__action, __character]) => __action === action && __character !== params[0]);
return characterWillAnimate && hasBlockingActions || differentCharacterWillAnimate;
});
if (skip) continue;
processedQueue.push(item);
} else {
processedQueue.push(item);
}
}
const run = async (match) => {
for (const item of processedQueue) {
const result = match(item);
if (isPromise(result)) {
await result;
}
}
processedQueue.length = 0;
};
return {
run,
keep: {
keep,
characters,
audio: audio2
}
};
};
// src/utilities/controlled-promise.ts
var createControlledPromise = () => {
const object = {
resolve: null,
reject: null,
promise: null,
cancel: null
};
const init = () => {
const promise = new Promise((resolve, reject) => {
object.reject = reject;
object.resolve = (value) => {
resolve({ cancelled: false, value });
};
object.cancel = () => {
resolve({ cancelled: true, value: null });
init();
};
});
object.promise = promise;
};
return init(), object;
};
// src/utilities/resources.ts
import { memoize as memoize2 } from "es-toolkit/function";
import { DEV as DEV3 } from "esm-env";
var getUrlFileExtension = (address) => {
try {
const { pathname } = new URL(address, location.href);
return pathname.split(".").at(-1).split("!")[0].split(":")[0];
} catch (error) {
if (DEV3) {
console.error(new Error(`Could not construct URL "${address}".`, { cause: error }));
}
return "";
}
};
var fetchContentType = async (url, request) => {
try {
const response = await request(url, {
method: "HEAD"
});
return response.headers.get("Content-Type") || "";
} catch (error) {
if (DEV3) {
console.error(new Error(`Failed to fetch file at "${url}"`, { cause: error }));
}
return "";
}
};
var getResourseType = memoize2(
async ({ url, request }) => {
const extension = getUrlFileExtension(url);
if (HOWLER_SUPPORTED_FILE_FORMATS.has(extension)) {
return "audio";
}
if (SUPPORTED_IMAGE_FILE_FORMATS.has(extension)) {
return "image";
}
const contentType = await fetchContentType(url, request);
if (contentType.includes("audio")) {
return "audio";
}
if (contentType.includes("image")) {
return "image";
}
return "other";
},
{
getCacheKey: ({ url }) => url
}
);
// src/utilities/stack.ts
import { memoize as memoize3 } from "es-toolkit/function";
var getStack = memoize3(
(_) => {
return [];
},
{
cache: STACK_MAP,
getCacheKey: (ctx) => ctx.id
}
);
var createUseStackFunction = (renderer) => {
const useStack = (context) => {
const ctx = typeof context === "string" ? renderer.getContext(context) : context;
const stack = getStack(ctx);
return {
get previous() {
return stack.previous;
},
get value() {
return stack.at(-1);
},
set value(value) {
stack[stack.length - 1] = value;
},
back() {
stack.previous = stack.length > 1 ? stack.pop() : this.value;
ctx.meta.goingBack = true;
},
push(value) {
stack.push(value);
},
clear() {
stack.previous = void 0;
stack.length = 0;
stack.length = 1;
}
};
};
return useStack;
};
// src/utilities/story.ts
var flatActions = (item) => {
return item.flatMap((data) => {
const type = data[0];
if (Array.isArray(type)) return flatActions(data);
return [data];
});
};
var flatStory = (story) => {
for (const key in story) {
story[key] = flatActions(story[key]);
}
return story;
};
// src/utilities/internationalization.ts
import { memoize as memoize4 } from "es-toolkit/function";
var getLanguage = (languages) => {
let { language } = navigator;
if (languages.includes(language)) {
return language;
} else if (languages.includes(language = language.slice(0, 2))) {
return language;
} else if (language = languages.find((value) => navigator.languages.includes(value))) {
return language;
}
return languages[0];
};
var getIntlLanguageDisplayName = memoize4((lang) => {
try {
const intl = new Intl.DisplayNames([lang], {
type: "language"
});
return intl.of(lang) || lang;
} catch {
return lang;
}
});
var capitalize = (str) => {
return str[0].toUpperCase() + str.slice(1);
};
// src/utilities/noop.ts
var noop = () => {
};
// src/utilities/store.ts
var getLanguageFromStore = (store2) => {
return store2.get().meta[0];
};
var getVolumeFromStore = (store2) => {
const { meta } = store2.get();
return {
music: meta[2],
sound: meta[3],
voice: meta[4]
};
};
// src/utilities/array.ts
var mapSet = (set2, fn) => {
return [...set2].map(fn);
};
var toArray = (target) => {
return Array.isArray(target) ? target : [target];
};
// src/utilities/else.ts
var getCharactersData = (characters) => {
const entries = Object.entries(characters);
const mapped = entries.map(([key, value]) => [key, { name: value.name, emotions: Object.keys(value.emotions) }]);
return Object.fromEntries(mapped);
};
// src/store.ts
var store = (current, subscribers = /* @__PURE__ */ new Set()) => {
const subscribe = (cb) => {
subscribers.add(cb), cb(current);
return () => {
subscribers.delete(cb);
};
};
const push = (value) => {
for (const cb of subscribers) cb(value);
};
const update = (fn) => {
push(current = fn(current));
};
const set2 = (val) => {
update(() => val);
};
const get = () => {
return current;
};
return { subscribe, update, set: set2, get };
};
var derive = (input, map) => {
return {
get: () => map(input.get()),
subscribe: (subscriber) => {
return input.subscribe((value) => {
return subscriber(map(value));
});
}
};
};
var immutable = (value) => {
return {
get: () => value,
subscribe: (subscriber) => {
subscriber(value);
return noop;
}
};
};
// src/custom-action.ts
import { once as once2 } from "es-toolkit/function";
var createCustomActionNode = (id) => {
const div = document.createElement("div");
div.setAttribute("data-id", id);
return div;
};
var getCustomActionHolder = (ctx, fn) => {
const cached = CUSTOM_ACTION_MAP.get(ctx.id + fn.key);
if (cached) {
return cached;
}
const holder = {
node: null,
fn,
localData: {}
};
CUSTOM_ACTION_MAP.set(ctx.id + fn.key, holder);
return holder;
};
var getCustomActionCleanupHolder = (ctx) => {
const existing = CUSTOM_ACTION_CLEANUP_MAP.get(ctx.id);
if (existing) {
return existing;
}
const holder = [];
CUSTOM_ACTION_CLEANUP_MAP.set(ctx.id, holder);
return holder;
};
var cleanCleanupSource = ({ list }) => {
while (list.length) {
try {
list.pop()();
} catch (e) {
console.error(e);
}
}
};
var handleCustomAction = (ctx, fn, {
lang,
state,
setMountElement,
remove: renderersRemove,
getStack: getStack2,
templateReplace,
paused,
ticker,
request
}) => {
const holder = getCustomActionHolder(ctx, fn);
const cleanupHolder = getCustomActionCleanupHolder(ctx);
const cleanupNode = () => {
if (!cleanupHolder.some((item) => item.fn.id === fn.id && item.fn.key === fn.key)) {
holder.node = null;
setMountElement(null);
}
};
const cleanupSource = {
fn,
list: [ticker.detach],
node: cleanupNode
};
cleanupHolder.push(cleanupSource);
const getDomNodes = (insert = true) => {
if (holder.node || !insert) {
setMountElement(holder.node);
return {
element: holder.node,
root: ctx.root
};
}
holder.node = insert ? createCustomActionNode(fn.key) : null;
setMountElement(holder.node);
return {
element: holder.node,
root: ctx.root
};
};
const clear = (func) => {
cleanupSource.list.push(once2(func));
};
const data = (updatedData) => {
if (updatedData) {
return holder.localData = updatedData;
}
return holder.localData;
};
const remove = () => {
cleanCleanupSource(cleanupSource);
holder.node = null;
setMountElement(null);
renderersRemove();
};
const stack = getStack2(ctx);
const getSave = () => {
return stack.value;
};
return fn({
flags: ctx.meta,
lang,
state,
data,
dataAtKey: (key) => CUSTOM_ACTION_MAP.get(ctx.id + key)?.localData || null,
templateReplace,
clear,
remove,
rendererContext: ctx,
getDomNodes,
getSave,
contextKey: ctx.id,
paused: ctx.meta.preview ? immutable(false) : paused,
ticker,
request
});
};
// src/preloading.ts
var ACTION_NAME_TO_VOLUME_MAP = {
playMusic: "music",
playSound: "sound",
voice: "voice"
};
var enqueueAssetForPreloading = (asset2) => {
if (!PRELOADED_ASSETS.has(asset2)) {
ASSETS_TO_PRELOAD.add(asset2);
}
};
var handleAssetsPreloading = async ({
request,
limiter,
preloadAudioBlocking,
preloadImageBlocking
}) => {
const list = mapSet(ASSETS_TO_PRELOAD, (asset2) => {
return limiter(async () => {
const type = await getResourseType({
url: asset2,
request
});
switch (type) {
case "audio": {
await preloadAudioBlocking(asset2);
break;
}
case "image": {
await preloadImageBlocking(asset2);
break;
}
}
ASSETS_TO_PRELOAD.delete(asset2);
PRELOADED_ASSETS.add(asset2);
});
});
await Promise.allSettled(list);
ASSETS_TO_PRELOAD.clear();
};
var huntAssets = async ({ volume, lang, characters, action, props, handle, request }) => {
if (action === "showBackground") {
if (isAsset(props[0]) || isString(props[0])) {
handle(unwrapImageAsset(props[0]));
return;
}
if (props[0] && typeof props[0] === "object") {
for (const value of Object.values(props[0])) {
if (isAsset(value)) {
handle(unwrapImageAsset(value));
} else {
handle(value);
}
}
}
return;
}
const getVolumeFor = (action2) => {
if (action2 in ACTION_NAME_TO_VOLUME_MAP) {
return volume[ACTION_NAME_TO_VOLUME_MAP[action2]];
}
return 0;
};
if (isAudioAction(action) && isString(props[0])) {
if (getVolumeFor(action) > 0) {
handle(unwrapAudioAsset(props[0]));
}
return;
}
if (action === "voice" && typeof props[0] === "object") {
if (getVolumeFor("voice") == 0) {
return;
}
for (const [language, value] of Object.entries(props[0])) {
if (language === lang) {
value && handle(unwrapAudioAsset(value));
}
}
return;
}
if (action === "showCharacter" && isString(props[0]) && isString(props[1])) {
const images = toArray(characters[props[0]].emotions[props[1]]);
for (const asset2 of images) {
handle(unwrapImageAsset(asset2));
}
return;
}
if (action === "custom" && props[0].assets) {
const assets = props[0].assets;
let resolved = [];
if (typeof assets === "function") {
resolved = await Promise.race([
assets({ request }),
new Promise((resolve) => setTimeout(resolve, 250, []))
]);
Object.defineProperty(props[0], "assets", {
value: async () => resolved,
writable: false
});
} else {
resolved = assets;
}
for (const asset2 of resolved) {
isAsset(asset2) ? handle(asset2.source) : handle(asset2);
}
return;
}
if (action === "choice") {
for (let i = 1; i < props.length; i++) {
const data = props[i];
if (Array.isArray(data)) {
if (data[5]) {
handle(unwrapImageAsset(data[5]));
}
}
}
}
};
// src/storage.ts
var storageAdapterLocal = ({ key }) => {
return {
async get() {
const fallback = { saves: [], data: {}, meta: [] };
try {
const value = localStorage.getItem(key);
return value ? JSON.parse(value) : fallback;
} catch {
return fallback;
}
},
async set(data) {
try {
localStorage.setItem(key, JSON.stringify(data));
} catch {
}
}
};
};
// src/translation.ts
var RGX = /{{(.*?)}}/g;
var split = (input, delimeters) => {
const output = [];
for (const delimeter of delimeters) {
if (!input) break;
const [start, end] = input.split(delimeter, 2);
output.push(start);
input = end;
}
output.push(input);
return output;
};
var flattenAllowedContent = (c, state) => {
if (Array.isArray(c)) {
return c.map((item) => flattenAllowedContent(item, state)).join("<br>");
}
if (typeof c === "function") {
return flattenAllowedContent(c(state), state);
}
return c;
};
var replace = (input, data, pluralization, actions, pr) => {
return input.replaceAll(RGX, (x, key, y) => {
x = 0;
y = data;
const [pathstr, plural, action] = split(key.trim(), ["@", "%"]);
if (!pathstr) {
return "";
}
const path = pathstr.split(".");
while (y && x < path.length) y = y[path[x++]];
if (plural && pluralization && y && pr) {
y = pluralization[plural][pr.select(y)];
}
const actionHandler = actions && action ? actions[action] : void 0;
if (actionHandler) y = actionHandler(y);
return y == null ? "" : y;
});
};
// src/utilities/actions.ts
import { DEV as DEV4 } from "esm-env";
var VIRTUAL_ACTIONS = ["say"];
var buildActionObject = ({
rendererActions,
nativeActions,
characters
}) => {
const allActions = [...nativeActions, ...VIRTUAL_ACTIONS];
const object = { ...rendererActions };
for (let action of allActions) {
object[action] = (...props) => {
if (action === "say") {
action = "dialog";
const [character] = props;
if (DEV4 && !characters[character]) {
throw new Error(`Attempt to call Say action with unknown character "${character}"`);
}
} else if (action === "choice") {
if (props.slice(1).every((choice) => !Array.isArray(choice))) {
for (let i = 1; i < props.length; i++) {
const choice = props[i];
props[i] = [
choice.title,
flatActions(choice.children),
choice.active,
choice.visible,
choice.onSelect,
choice.image
];
}
} else {
for (let i = 1; i < props.length; i++) {
const choice = props[i];
if (Array.isArray(choice)) {
choice[1] = flatActions(choice[1]);
}
}
}
} else if (action === "condition") {
const actions = props[1];
for (const key in actions) {
actions[key] = flatActions(actions[key]);
}
}
return [action, ...props];
};
}
return object;
};
// src/utilities/dialog-overview.ts
var getDialogOverview = async function() {
const { value: save } = this.getStack();
const stateSnapshots = save[3];
if (stateSnapshots.length == 0) {
return [];
}
const { queue } = await getActionsFromPath({
story: this.story,
path: save[0],
filter: false,
referGuarded: this.referGuarded
});
const lang = this.getLanguage();
const dialogItems = [];
for (let p = 0, a = stateSnapshots.length, i = queue.length - 1; a > 0 && i > 0; i--) {
const action = queue[i];
if (action[0] === "dialog") {
const [_, name, text] = action;
let voice = void 0;
for (let j = i - 1; j > p && j > 0; j--) {
const action2 = queue[j];
if (isUserRequiredAction(action2) || isSkippedDuringRestore(action2[0])) break;
if (action2[0] === "stopVoice") break;
if (action2[0] === "voice") {
voice = action2[1];
break;
}
}
dialogItems.push({
name,
text,
voice
});
p = i;
a--;
}
}
const entries = dialogItems.reverse().map(({ name, text, voice }, i) => {
const state = stateSnapshots[i];
const audioSource = isString(voice) ? voice : isAsset(voice) ? voice : voice == void 0 ? voice : voice[lang];
name = name ? this.getCharacterName(name) : "";
return {
name: this.templateReplace(name, state),
text: this.templateReplace(text, state),
voice: audioSource ? unwrapAudioAsset(audioSource) : ""
};
});
return entries;
};
// src/utilities/document.ts
var setDocumentLanguage = (language) => {
document.documentElement.lang = language;
};
// src/ticker.ts
var Ticker = class {
listeners = /* @__PURE__ */ new Set();
running = false;
_factory;
constructor(factory) {
this._factory = factory;
}
get deltaTime() {
return this._factory.deltaTime;
}
get lastTime() {
return this._factory.lastTime;
}
add(cb) {
this.listeners.add(cb);
if (this.listeners.size === 1) {
this._factory.check(true);
}
return () => {
this.remove(cb);
};
}
remove(cb) {
this.listeners.delete(cb);
if (this.listeners.size === 0) {
this._factory.check(false);
}
}
start = () => {
this.running = true;
if (this.listeners.size > 0) {
this._factory.check(true);
}
};
stop = () => {
this.running = false;
};
detach = () => {
this.listeners.clear();
this.stop();
this._factory.detach(this);
};
};
var TickerFactory = class {
_children = /* @__PURE__ */ new Set();
_raf = -1;
_running = false;
_unsubscribe;
deltaTime = 0;
lastTime = performance.now();
constructor(paused) {
this._unsubscribe = paused.subscribe((paused2) => {
if (paused2) {
this.stop();
} else if (Array.from(this._children).some((ticker) => ticker.running && ticker.listeners.size > 0)) {
this.start();
}
});
}
start() {
if (this._running) {
return;
}
cancelAnimationFrame(this._raf);
this.lastTime = performance.now();
this._running = true;
this._raf = requestAnimationFrame(this.update);
}
stop() {
cancelAnimationFrame(this._raf);
this._running = false;
this._raf = -1;
}
fork() {
const ticker = new Ticker(this);
this._children.add(ticker);
return ticker;
}
check(positive) {
if (positive) {
this.start();
} else if (Array.from(this._children).every((ticker) => !ticker.running || ticker.listeners.size === 0)) {
this.stop();
}
}
destroy() {
this._unsubscribe();
this._children.forEach((child) => child.detach());
}
detach(ticker) {
this._children.delete(ticker);
this.check(false);
}
update = (currentTime) => {
this.deltaTime = currentTime - this.lastTime;
this._children.forEach((ticker) => {
if (ticker.running) {
ticker.listeners.forEach((tick) => {
tick(ticker);
});
}
});
if (!this._running) {
return;
}
this.lastTime = currentTime;
this._raf = requestAnimationFrame(this.update);
};
};
// src/novely.ts
var 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: getLanguage2 = 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
}) => {
const languages = Object.keys(translation);
const limitScript = pLimit(1);
const limitAssetsDownload = pLimit(parallelAssetsDownloadLimit);
const story = {};
const times = /* @__PURE__ */ new Set();
const dataLoaded = createControlledPromise();
let initialScreenWasShown = false;
let destroyed = false;
if (storyOptions.mode === "dynamic") {
storyOptions.preloadSaves ??= 4;
}
const storyLoad = storyOptions.mode === "static" ? noop : storyOptions.load;
const onUnknownSceneHit = memoize5(async (scene) => {
const part = await storyLoad(scene);
if (part) {
await script(part);
}
});
const intime = (value) => {
return times.add(value), value;
};
const scriptBase = async (part) => {
if (destroyed) return;
Object.assign(story, flatStory(part));
if (!initialScreenWasShown) {
renderer.ui.showLoading();
}
await dataLoaded.promise;
renderer.ui.hideLoading();
if (!initialScreenWasShown) {
initialScreenWasShown = true;
if (initialScreen === "game") {
restore(void 0);
} else {
renderer.ui.showScreen(initialScreen);
}
}
};
const script = (part) => {
return limitScript(() => scriptBase(part));
};
const getDefaultSave = (state) => {
return [
[
["jump", startKey],
[null, 0]
],
state,
[intime(Date.now()), "auto"],
[]
];
};
const getLanguageWithoutParameters = () => {
const language = getLanguage2(languages, getLanguage);
if (languages.includes(language)) {
setDocumentLanguage(language);
return language;
}
if (DEV5) {
throw new Error(
`Attempt to use unsupported language "${language}". Supported languages: ${languages.join(", ")}.`
);
}
throw 0;
};
const initialData = {
saves: [],
data: clone(defaultData),
meta: [getLanguageWithoutParameters(), DEFAULT_TYPEWRITER_SPEED, 1, 1, 1]
};
const storageData = store(initialData);
const coreData = store({
dataLoaded: false,
paused: false,
focused: document.visibilityState === "visible"
});
const paused = derive(coreData, (s) => s.paused || !s.focused);
const onDataLoadedPromise = async ({ cancelled }) => {
if (cancelled) {
dataLoaded.promise.then(onDataLoadedPromise);
return;
}
const preload = () => {
const saves = [...storageData.get().saves].reverse();
const sliced = saves.slice(0, storyOptions.mode === "dynamic" ? storyOptions.preloadSaves : 0);
for (const [path] of sliced) {
referGuarded(path);
}
};
preload();
coreData.update((data2) => {
data2.dataLoaded = true;
return data2;
});
};
dataLoaded.promise.then(onDataLoadedPromise);
const onStorageDataChange = (value) => {
if (!coreData.get().dataLoaded) return;
const data2 = clone(value);
for (const save2 of data2.saves) {
save2[3] = [];
}
storage.set(data2);
};
const throttledShortOnStorageDataChange = throttle(() => onStorageDataChange(storageData.get()), 10);
const throttledOnStorageDataChange = throttle(throttledShortOnStorageDataChange, throttleTimeout);
storageData.subscribe(throttledOnStorageDataChange);
if (saveOnUnload === true || saveOnUnload === "prod" && !DEV5) {
addEventListener("beforeunload", throttledShortOnStorageDataChange);
}
const getStoredData = async () => {
let stored = await storage.get();
for (const migration of migrations) {
stored = migration(stored);
if (DEV5 && !stored) {
throw new Error("Migrations should return a value.");
}
}
if (overrideLanguage || !stored.meta[0]) {
stored.meta[0] = getLanguageWithoutParameters();
}
stored.meta[1] ||= defaultTypewriterSpeed;
stored.meta[2] ??= 1;
stored.meta[3] ??= 1;
stored.meta[4] ??= 1;
if (isEmpty(stored.data)) {
stored.data = defaultData;
}
dataLoaded.resolve();
storageData.set(stored);
};
storageDelay.then(getStoredData);
const initial = getDefaultSave(clone(defaultState));
const save = (type) => {
if (!coreData.get().dataLoaded) return;
if (!autosaves && type === "auto") return;
const stack = useStack(MAIN_CONTEXT_KEY);
const current = clone(stack.value);
storageData.update((prev) => {
const replace2 = () => {
prev.saves[prev.saves.length - 1] = current;
return prev;
};
const add = () => {
prev.saves.push(current);
return prev;
};
const last = prev.saves.at(-1);
if (!last) return add();
current[2][0] = intime(Date.now());
current[2][1] = type;
current[3] = [];
const isIdentical = dequal(last[0], current[0]) && dequal(last[1], current[1]);
const isLastMadeInCurrentSession = times.has(last[2][0]);
if (isLastMadeInCurrentSession && last[2][1] === "auto" && type === "manual") {
return replace2();
}
if (last[2][1] === "manual" && type === "auto" && isIdentical) {
return prev;
}
if (isLastMadeInCurrentSession && last[2][1] === "auto" && type === "auto") {
return replace2();
}
return add();
});
};
const newGame = () => {
if (!coreData.get().dataLoaded) return;
const save2 = getDefaultSave(clone(defaultState));
if (autosaves) {
storageData.update((prev) => {
return prev.saves.push(save2), prev;
});
}
const context = renderer.getContext(MAIN_CONTEXT_KEY);
const stack = useStack(context);
stack.value = save2;
context.meta.restoring = context.meta.goingBack = false;
renderer.ui.showScreen("game");
render(context);
};
const set2 = (save2, ctx) => {
const stack = useStack(ctx || MAIN_CONTEXT_KEY);
stack.value = save2;
return restore(save2);
};
let interacted = 0;
const restore = async (save2) => {
if (isEmpty(story)) {
if (DEV5) {
throw new Error(
"Story is empty. You should call an `enine.script` function [https://novely.pages.dev/guide/story.html]"
);
}
return;
}
if (!coreData.get().dataLoaded) return;
let latest = save2 || storageData.get().saves.at(-1);
if (!latest) {
latest = clone(initial);
storageData.update((prev) => {
prev.saves.push(latest);
return prev;
});
}
const context = renderer.getContext(MAIN_CONTEXT_KEY);
const stack = useStack(context);
context.meta.restoring = true;
const previous = stack.previous;
const [path] = stack.value = latest;
renderer.ui.showScreen("game");
const { found } = await refer(path);
if (found) context.loading(true);
const { queue, skip, skipPreserve } = await getActionsFromPath({
story,
path,
filter: false,
referGuarded
});
const cleanupHolder = getCustomActionCleanupHolder(context);
if (previous) {
const { queue: prevQueue } = await getActionsFromPath({
story,
path: previous[0],
filter: false,
referGuarded
});
const futures = [];
const isFromDifferentBranches = previous[0][0][1] !== path[0][1];
const end = isFromDifferentBranches ? 0 : queue.length - 1;
for (let i = prevQueue.length - 1; i >= end; i--) {
const [action2, fn] = prevQueue[i];
if (action2 =