UNPKG

@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
// ../../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 =