@novely/renderer-toolkit
Version:
Toolkit for creating renderer for novely
655 lines (642 loc) • 15.7 kB
JavaScript
// src/index.ts
export * from "nanostores";
// src/atoms/deep-atom.ts
import { deepMap, setByKey } from "nanostores";
var usePath = (atomValue, getPath) => {
const targets = /* @__PURE__ */ new Set();
const path = [];
let current;
const proxyHandler = {
get(target, prop, receiver) {
if (targets.has(target)) {
throw new ReferenceError(`Attempted to access property on the same target multiple times.`);
}
const value = Reflect.get(target, prop, receiver);
targets.add(target);
path.push(prop);
current = value;
if (value === void 0) {
return new Proxy({}, proxyHandler);
}
if (value && typeof value === "object") {
return new Proxy(value, proxyHandler);
}
return value;
}
};
getPath(new Proxy(atomValue, proxyHandler));
if (path.length === 0) {
throw new Error("No valid path extracted from the provided getPath function.");
}
return {
path,
value: current
};
};
var deepAtom = (init) => {
const $atom = deepMap(init);
$atom.mutate = (getPath, setter) => {
const { path, value } = usePath($atom.get(), getPath);
const newValue = typeof setter === "function" ? setter(value) : setter;
if (newValue === value) {
return newValue;
}
const oldValue = $atom.value;
$atom.value = setByKey($atom.value, path, newValue);
$atom.notify(oldValue, path.join("."));
return newValue;
};
return $atom;
};
// src/state/context-state.ts
import { cleanStores, onMount } from "nanostores";
var getDefaultContextState = () => {
return {
background: {
background: "#0000"
},
characters: {},
choice: {
label: "",
visible: false,
choices: []
},
dialog: {
content: "",
name: "",
visible: false,
miniature: {}
},
input: {
element: null,
label: "",
error: "",
visible: false
},
text: {
content: ""
},
custom: {},
meta: {
restoring: false,
goingBack: false,
preview: false
},
loading: false
};
};
var createContextStateRoot = (getExtension = () => ({})) => {
const CACHE = /* @__PURE__ */ new Map();
const make = () => {
const contextState = deepAtom({
...getDefaultContextState(),
...getExtension()
});
return contextState;
};
const remove = (id) => {
const contextState = CACHE.get(id);
if (contextState) {
cleanStores(contextState);
}
CACHE.delete(id);
};
const use = (id) => {
const cached = CACHE.get(id);
if (cached) {
return cached;
}
const contextState = make();
CACHE.set(id, contextState);
onMount(contextState, () => {
return () => {
CACHE.delete(id);
};
});
return contextState;
};
return {
useContextState: use,
removeContextState: remove
};
};
// src/state/renderer-state.ts
var defaultEmpty = {};
var createRendererState = (extension = defaultEmpty) => {
const rendererState = deepAtom({
screen: "mainmenu",
loadingShown: false,
exitPromptShown: false,
...extension
});
return rendererState;
};
// src/utils/noop.ts
var noop = () => {
};
// src/utils/escape-html.ts
var escaped = {
'"': """,
"'": "'",
"&": "&",
"<": "<",
">": ">"
};
var escapeHTML = (str) => {
return String(str).replace(/["'&<>]/g, (match) => escaped[match]);
};
// src/renderer/start.ts
var createStartFunction = (fn) => {
let unmount = noop;
return () => {
unmount();
unmount = fn();
return {
unmount: () => {
unmount();
unmount = noop;
}
};
};
};
// src/audio/audio.ts
import { createAudio as createWebAudio, prefetchAudio } from "simple-web-audio";
var TYPE_META_MAP = {
music: 2,
sound: 3,
voice: 4
};
var createAudio = (storageData) => {
let started = false;
const store = {
music: {},
sound: {},
voices: {}
};
const getVolume = (type) => {
return storageData.get().meta[TYPE_META_MAP[type]];
};
const getAudio = (type, src) => {
const kind = type === "voice" ? "voices" : type;
const cached = store[kind][src];
if (cached) return cached;
const audio = createWebAudio({
src,
volume: getVolume(type)
});
store[kind][src] = audio;
return audio;
};
const cleanup = /* @__PURE__ */ new Set();
let voiceCleanup = noop;
const context = {
music(src, paused, method) {
const resource = getAudio(method, src);
this.start();
const unsubscribe = paused.subscribe((paused2) => {
if (paused2) {
resource.pause();
} else {
resource.play();
}
});
cleanup.add(unsubscribe);
return {
pause() {
resource.pause();
},
play(loop) {
if (resource.playing) {
resource.volume = getVolume(method);
resource.loop = loop;
return;
}
resource.reset().then(() => {
resource.volume = getVolume(method);
resource.loop = loop;
resource.play();
});
},
stop() {
resource.stop();
}
};
},
voice(source, paused) {
this.start();
this.voiceStop();
const resource = store.voice = getAudio("voice", source);
resource.volume = getVolume("voice");
resource.play();
voiceCleanup = paused.subscribe((paused2) => {
if (paused2) {
resource.pause();
} else {
resource.play();
}
});
},
voiceStop() {
if (!store.voice) return;
store.voice.stop();
voiceCleanup();
store.voice = void 0;
voiceCleanup = noop;
},
start() {
if (started) return;
started = true;
const unsubscribe = storageData.subscribe(() => {
for (const type of ["music", "sound", "voice"]) {
const volume = getVolume(type);
if (type === "music" || type === "sound") {
for (const audio of Object.values(store[type])) {
if (!audio) continue;
audio.volume = volume;
}
}
if (type === "voice" && store.voice) {
store.voice.volume = volume;
}
}
});
cleanup.add(unsubscribe);
},
clear() {
const musics = Object.values(store.music);
const sounds = Object.values(store.sound);
for (const music of [...musics, ...sounds]) {
if (!music) continue;
music.stop();
}
this.voiceStop();
},
destroy() {
cleanup.forEach((fn) => fn());
this.clear();
started = false;
}
};
const clear = (keepAudio) => {
context.voiceStop();
const entries = [
[store.music, keepAudio.music],
[store.sound, keepAudio.sounds]
];
const clearEntries = entries.flatMap(([incoming, keep]) => {
return Object.entries(incoming).filter(([name]) => !keep.has(name)).map(([_, a]) => a);
});
for (const music of clearEntries) {
if (!music) continue;
music.stop();
}
};
return {
context,
clear,
getVolume,
getAudio
};
};
var createAudioMisc = () => {
const misc = {
preloadAudioBlocking: async (src) => {
await prefetchAudio(src);
}
};
return misc;
};
// src/shared/create-shared.ts
var createShared = (get) => {
const CACHE = /* @__PURE__ */ new Map();
const use = (id) => {
const cached = CACHE.get(id);
if (cached) {
return cached;
}
const shared = get();
CACHE.set(id, shared);
return shared;
};
const remove = (id) => {
CACHE.delete(id);
};
return {
useShared: use,
removeShared: remove
};
};
// src/context/create-get-context.ts
var createGetContext = () => {
const CACHE = /* @__PURE__ */ new Map();
const getContextCached = (createContext) => {
return (key) => {
const cached = CACHE.get(key);
if (cached) {
return cached;
}
const context = createContext(key);
CACHE.set(key, context);
return context;
};
};
const removeContext = (key) => {
CACHE.delete(key);
};
return {
getContextCached,
removeContext
};
};
// src/root/root-setter.ts
var createRootSetter = (getContext) => {
let element;
return {
root() {
return element;
},
setRoot(root) {
element = root;
const context = getContext();
if (!context.root) {
context.root = root;
}
}
};
};
// src/context/background.ts
var useBackground = (backgrounds, set) => {
const mediaQueries = Object.keys(backgrounds).map((media) => matchMedia(media));
const allMedia = mediaQueries.find(({ media }) => media === "all");
const handle = () => {
const last = mediaQueries.findLast(({ matches, media }) => matches && media !== "all");
const bg = last ? backgrounds[last.media] : allMedia ? backgrounds["all"] : "";
set(bg);
};
for (const mq of mediaQueries) {
mq.onchange = handle;
}
let disposed = false;
Promise.resolve().then(() => {
if (disposed) return;
handle();
});
return {
/**
* Remove all listeners
*/
dispose() {
for (const mq of mediaQueries) {
mq.onchange = null;
}
disposed = true;
}
};
};
// src/context/vibrate.ts
var vibrationPossible = /* @__PURE__ */ (() => {
let possible = false;
const onPointerDown = () => {
possible = true;
};
const isPossible = () => {
return possible;
};
document.addEventListener("pointerdown", onPointerDown, { once: true });
return isPossible;
})();
var vibrate = (pattern) => {
if (vibrationPossible() && "vibrate" in navigator) {
try {
navigator.vibrate(pattern);
} catch {
}
}
};
// src/context/actions.ts
var allEmpty = (target) => {
if (typeof target === "string") {
return target == "";
}
if (typeof target === "number") {
return target == 0;
}
if (!target) {
return true;
}
if (Array.isArray(target) && target.length > 0) {
for (const inner of target) {
if (!allEmpty(inner)) {
return false;
}
}
}
for (const value of Object.values(target)) {
if (!allEmpty(value)) {
return false;
}
}
return true;
};
var handleBackgroundAction = ($contextState, background) => {
$contextState.get().background.clear?.();
const { dispose } = useBackground(background, (value) => {
$contextState.mutate((s) => s.background.background, value);
});
$contextState.mutate(
(s) => s.background.clear,
() => dispose
);
};
var handleDialogAction = ($contextState, content, name, character, emotion, resolve) => {
$contextState.mutate((s) => s.dialog, {
content,
name,
miniature: {
character,
emotion
},
visible: true,
resolve
});
};
var handleChoiceAction = ($contextState, label, choices, resolve) => {
$contextState.mutate((s) => s.choice, { choices, label, resolve, visible: true });
};
var handleClearAction = ($rendererState, $contextState, options, context, keep, keepCharacters) => {
$rendererState.mutate((s) => s.exitPromptShown, false);
if (!keep.has("showBackground")) {
$contextState.mutate((s) => s.background.background, "#000");
}
if (!keep.has("choice")) {
$contextState.mutate((s) => s.choice, {
choices: [],
visible: false,
label: ""
});
}
const inputCleanup = $contextState.get().input.cleanup;
if (inputCleanup) {
inputCleanup();
}
if (!keep.has("input")) {
$contextState.mutate((s) => s.input, {
element: null,
label: "",
visible: false,
error: ""
});
}
if (!keep.has("dialog")) {
$contextState.mutate((s) => s.dialog, {
visible: false,
content: "",
name: "",
miniature: {}
});
}
if (!keep.has("text")) {
$contextState.mutate((s) => s.text, { content: "" });
}
const { characters, custom } = $contextState.get();
for (const character of Object.keys(characters)) {
if (!keepCharacters.has(character)) {
$contextState.mutate((s) => s.characters[character], {
style: void 0,
visible: false
});
}
}
for (const [id, obj] of Object.entries(custom)) {
if (!obj) continue;
if (context.meta.goingBack && obj.fn.skipClearOnGoingBack) continue;
options.clearCustomAction(context, obj.fn);
$contextState.mutate((s) => s.custom[id], void 0);
}
};
var handleCustomAction = ($contextState, fn) => {
if (!$contextState.get().custom[fn.key]) {
$contextState.mutate((s) => s.custom[fn.key], {
fn,
node: null,
clear: noop
});
}
return {
setMountElement(node) {
$contextState.mutate(
(s) => s.custom[fn.key],
(state) => {
return {
...state,
node
};
}
);
},
setClear(clear) {
$contextState.mutate(
(s) => s.custom[fn.key],
(state) => {
return {
...state,
clear
};
}
);
},
remove() {
$contextState.mutate((s) => s.custom[fn.key], void 0);
}
};
};
var handleClearBlockingActions = ($contextState, preserve) => {
const current = $contextState.get();
if (preserve !== "choice" && !allEmpty(current.choice)) {
$contextState.mutate((s) => s.choice, {
choices: [],
visible: false,
label: ""
});
}
if (preserve !== "input" && !allEmpty(current.input)) {
$contextState.mutate((s) => s.input, {
element: null,
label: "",
visible: false,
error: ""
});
}
if (preserve !== "text" && !allEmpty(current.text)) {
$contextState.mutate((s) => s.text, { content: "" });
}
if (preserve !== "dialog" && !allEmpty(current.dialog)) {
$contextState.mutate((s) => s.dialog, {
visible: false,
content: "",
name: "",
miniature: {}
});
}
};
var handleTextAction = ($contextState, content, resolve) => {
$contextState.mutate((s) => s.text, { content, resolve });
};
var handleInputAction = ($contextState, options, context, label, onInput, setup, resolve) => {
const error = (value) => {
$contextState.mutate((s) => s.input.error, value);
};
const onInputHandler = (event) => {
let value;
onInput({
lang: options.storageData.get().meta[0],
input,
event,
error,
state: options.getStateFunction(context.id),
get value() {
if (value) return value;
return value = escapeHTML(input.value);
}
});
};
const input = document.createElement("input");
input.setAttribute("type", "text");
input.setAttribute("name", "novely-input");
input.setAttribute("id", "novely-input");
input.setAttribute("required", "true");
input.setAttribute("autocomplete", "off");
!context.meta.preview && input.addEventListener("input", onInputHandler);
$contextState.mutate((s) => s.input, {
element: input,
label,
error: "",
visible: true,
cleanup: setup(input) || noop,
resolve
});
!context.meta.preview && input.dispatchEvent(new InputEvent("input", { bubbles: true }));
};
var handleVibrateAction = vibrate;
export {
createAudio,
createAudioMisc,
createContextStateRoot,
createGetContext,
createRendererState,
createRootSetter,
createShared,
createStartFunction,
deepAtom,
handleBackgroundAction,
handleChoiceAction,
handleClearAction,
handleClearBlockingActions,
handleCustomAction,
handleDialogAction,
handleInputAction,
handleTextAction,
handleVibrateAction,
noop
};
//# sourceMappingURL=index.js.map