/** * Utils to handle references to game state vars and manage their updates. * * The references are just strings in form `obj.field.subfield` * * @module utils/ref */ /** * Checks if one ref is parent of other * * `expect(isparentRef("foo.bar", "foo.bar.baz")` * * @param {string} parentref reference to parent object * @param {string} nestedref reference to nested field * @returns {boolean} */ function isparentRef(parentref, nestedref) { return nestedref.startsWith(parentref + "."); } /** * Strips common part of nested ref, making it local to parent * * `expect(getsubRef("foo.bar", "foo.bar.baz").to.be.eq("baz")` * * @param {string} parentref reference to parent object * @param {string} nestedref reference to nested field * @returns {boolean} */ function getsubRef(parentref, nestedref) { if (parentref == nestedref) { return ""; } else if (nestedref.startsWith(parentref + ".")) { return nestedref.slice(parentref.length + 1); } else { throw new Error(`Incompatible refs: ${parentref} / ${nestedref}`); } } /** * Extract a value from object by a ref * * ``` * let obj = {foo:{bar:"Bar"}}; * expect(extractByRef("foo.bar", obj).to.be.eq("Bar")` * ``` * * @param {object} data * @param {string} ref * @returns {boolean} */ function extractByRef(ref, data) { return ref.split(".").reduce((o, k) => (o && k in o ? o[k] : undefined), data); } /** * Sets a value in object by ref. * The original object is modified in place * * ``` * let obj = {foo:{bar:"Bar"}}; * updateByRef("foo.bar", obj, "newval"); * expect(obj.foo.bar).to.be.eq("newval"); * ``` * @param {object} data * @param {ref} ref * @param {*} value */ function updateByRef(ref, data, value) { function ins(obj, key) { return (obj[key] = {}); } const path = ref.split("."), objpath = path.slice(0, -1), fld = path[path.length - 1]; let obj = objpath.reduce((o, k) => (k in o ? o[k] : ins(o, k)), data); if (obj === undefined) throw new Error(`Incompatible ref ${ref}`); if (value === undefined) { delete obj[fld]; } else { obj[fld] = value; } return data; } const VAREXPR = new RegExp(/^[a-zA-Z]\w+(\.\w+)*$/); function parseVar(expr) { let match = VAREXPR.exec(expr); if (!match) { throw new Error(`Invalid expression for var: "${expr}"`); } let ref = match[0]; return { ref }; } function evalVar(parsed, changes) { const { ref } = parsed; return changes.pick(ref); } const CONDEXPR = new RegExp(/^([\w.]+)( ([!=]=) (.+))?$/); function parseCond(expr) { let match = CONDEXPR.exec(expr); if (!match) { throw new Error(`Invalid condition expression: "${expr}"`); } let varmatch = VAREXPR.exec(match[1]); if (!varmatch) { throw new Error(`Invalid variable in condition expression: "${expr}"`); } let [_0, ref, _2, eq, val] = match; if (val) { try { val = JSON.parse(val.replaceAll("'", '"')); } catch { throw new Error(`Invalid value in condition expression: ${expr}`); } } else { val = undefined; } return { ref, eq, val }; } function evalCond(parsed, changes) { const { ref, eq, val } = parsed; let value = changes.pick(ref); if (eq === undefined) return !!value; if (eq == "==") return value === val; if (eq == "!=") return value !== val; } const ASSIGNEXPR = new RegExp(/^([\w.]+) = (.+)?$/); function parseAssign(expr) { let match = ASSIGNEXPR.exec(expr); if (!match) { throw new Error(`Invalid input expression: "${expr}"`); } let varmatch = VAREXPR.exec(match[1]); if (!varmatch) { throw new Error(`Invalid variable in input expression: "${expr}"`); } let [_0, ref, val] = match; try { val = JSON.parse(match[2].replaceAll("'", '"')); } catch { throw new Error(`Invalid value in assignment expression: ${expr}`); } return { ref, val }; } /** * Checks if an event affects an expression * * @param {Event} event * @param {object} expr parsed expression containing ref to a var */ function affecting(parsed, event) { switch (event.type) { case "ot.reset": let topvars = event.detail; return topvars == null || topvars.some(v => v == parsed.ref || isparentRef(v, parsed.ref)); case "ot.update": let changes = event.detail; return changes.affects(parsed.ref); default: return false; } } /** * Utils to handle changes of game state data * * @module utils/changes */ /** * A set of references to vars and their new values. * * The references are in form `obj.field.subfield` and correspond to a game state. */ class Changes extends Map { /** * @param {object} obj plain object describing changes */ constructor(obj) { if (obj) { super(Array.from(Object.entries(obj))); } else { super(); } // validate keys this.forEach((v, k) => parseVar(k)); } prefix(pref) { let prefixed = new Changes(); for(let [k, v] of this.entries()) { prefixed.set(`${pref}.${k}`, v); } return prefixed; } /** * Checks if the changeset affects a var or a subvar * * ``` * let changes = new Changes({ 'foo.bar': something }); * expect(changes.affect("foo.bar")).to.be.true; * expect(changes.affect("foo.bar.anything")).to.be.true; * expect(changes.affect("foo.*")).to.be.true; * * @param {*} fld */ affects(fld) { if (fld.endsWith(".*")) { let top = fld.slice(0, -2); return this.has(top) || Array.from(this.keys()).some((key) => isparentRef(top, key)); } else { return this.has(fld) || Array.from(this.keys()).some((key) => isparentRef(key, fld)); } } /** * Picks single value from changeset, tracking reference across keys or nested objects. * * ``` * let change = new Changes({ 'foo.bar': { baz: "Baz"} }) * expect(change.pick('foo')).to.be.eq({ 'bar': { 'baz': "Baz" }}) * expect(change.pick('foo.bar')).to.be.eq({ 'baz': "Baz" }) * ``` * */ pick(fld) { if (this.has(fld)) { return this.get(fld); } // console.debug("picking", fld, "from", Array.from(this.keys())); // fld.subfld: something let nesting = Array.from(this.keys()).filter((k) => isparentRef(fld, k)); // console.debug("nesting", nesting); if (nesting.length) { let result = {}; for (let k of nesting) { let subfld = getsubRef(fld, k); result[subfld] = this.get(k); } return result; } // fld[top]: { fld[sub]: something } let splitting = Array.from(this.keys()).filter((k) => isparentRef(k, fld) && this.get(k) !== undefined); // console.debug("splitting", splitting); if (splitting.length) { for (let k of splitting) { let fldsub = getsubRef(k, fld); return extractByRef(fldsub, this.get(k)) } } } /** * Apply changes * * Modify an obj by all the changes. * * Example: * ``` * obj = { obj: { foo: { bar: "xxx" } } } * changes = new Changes({ 'obj.foo': { bar: "Bar" } }) * changes.patch(obj) * * obj == { obj: { foo: { bar: "Bar" } } } * ``` * * It works with arrays as well, when using indexes as subfields. * */ patch(obj) { this.forEach((v, k) => { updateByRef(k, obj, v); }); } } var changes = /*#__PURE__*/Object.freeze({ __proto__: null, Changes: Changes }); /** * Set of simple utils to manipulate DOM * @module utils/dom */ /** * Loads an image asynchronously * * Example: * ``` * img = await loadImage("http://example.org/image.png"); * ``` * * @param {string} url url or dataurl to load * @returns {Promise} resolving to Image object */ function loadImage(url) { const img = new Image(); return new Promise((resolve, reject) => { img.onload = () => resolve(img); img.onerror = reject; img.src = url; }); } /** * Toggles visibility by setting 'display' css property. * * @param {HTMLElement} elem * @param {boolean} display */ function toggleDisplay(elem, display) { elem.style.display = display ? null : "none"; } /** * Toggles disabled state by `.disabled` property (for inputs), and also `ot-disabled` class. * * @param {HTMLElement} elem * @param {boolean} disabled */ function toggleDisabled(elem, disabled) { elem.disabled = disabled; elem.classList.toggle("ot-disabled", disabled); } /** * Checks if elem is disabled * @param {HTMLElement} elem */ function isDisabled(elem) { return elem.classList.contains("ot-disabled"); } /** * Sets or deletes text content * @param {HTMLElement} elem * @param {string|null} text */ function setText(elem, text) { // NB: using `innerText` to render line breaks elem.innerText = text == null ? "" : text; } /** * Sets element classes * @param {HTMLElement} elem * @param {string[]} classes */ function setClasses(elem, classes) { elem.classList.remove(...elem.classList); elem.classList.add(...classes); } /** * Sets or deletes an attribute * * @param {HTMLElement} elem * @param {string} attr * @param {string|null} val */ function setAttr(elem, attr, val) { if (val == null) { elem.removeAttribute(attr); } else { elem.setAttribute(attr, val); } } /** * Inserts single child element or empties elem. * * @param {HTMLElement} elem * @param {HTMLElement|null} child */ function setChild(elem, child) { if (child == null) { elem.replaceChildren(); } else { elem.replaceChildren(child); } } const TEXTINPUTS = ['text', 'number', 'time', 'date']; /** * Checks if an elem is a text input or textarea * * @param {HTMLElement} elem * @returns {boolean} */ function isTextInput(elem) { return (elem.tagName == "INPUT" && TEXTINPUTS.includes(elem.type)); } var dom = /*#__PURE__*/Object.freeze({ __proto__: null, loadImage: loadImage, toggleDisplay: toggleDisplay, toggleDisabled: toggleDisabled, isDisabled: isDisabled, setText: setText, setClasses: setClasses, setAttr: setAttr, setChild: setChild, isTextInput: isTextInput }); /** @module utils/random */ /** * Makes random choice from an array * * @param {Array} choices */ function choice(choices) { return choices[Math.floor(Math.random() * choices.length)]; } var random = /*#__PURE__*/Object.freeze({ __proto__: null, choice: choice }); /** @module utils/timers */ /** * Async sleeping * * @param {number} time in ms * @returns {Promise} */ async function sleep(time) { return new Promise((resolve, reject) => { setTimeout(() => resolve(), time); }); } /** * Delays function call * * @param {Function} fn * @param {number} delay in ms * @returns {*} timer_id */ function delay(fn, delay=0) { return window.setTimeout(fn, delay); } /** * Cancels delayed call * * @param {*} id timer_id */ function cancel(id) { window.clearTimeout(id); } /** * Timers. * * A set of timers with names */ class Timers { constructor() { this.timers = new Map(); } /** * Delays function call * * @param {sting} name * @param {Function} fn * @param {number} timeout in ms */ delay(name, fn, timeout=0) { if (this.timers.has(name)) { cancel(this.timers.get(name)); } this.timers.set(name, delay(fn, timeout)); } /** * Cancels delayed calls by names. * * @param {...string} names one or more named calls to cancel, empty to cancel all */ cancel(...names) { if (names.length != 0) { names.forEach((n) => { cancel(this.timers.get(n)); this.timers.delete(n); }); } else { this.timers.forEach((v, k) => cancel(v)); this.timers.clear(); } } } var timers = /*#__PURE__*/Object.freeze({ __proto__: null, sleep: sleep, delay: delay, cancel: cancel, Timers: Timers }); /** * Preloading media accorfing to media_fields config of form: `{ field: 'image' }`. * Only images supported for now * * @param {*} trial * @param {*} media_fields */ async function preloadMedia(trial, media_fields) { for (let [fld, mediatype] of Object.entries(media_fields)) { switch (mediatype) { case 'image': try { trial[fld] = await loadImage(trial[fld]); } catch { throw new Error(`Failed to load media ${trial[fld]}`); } break; default: throw new Error("Unsupported media type to preload"); } } } var trials = /*#__PURE__*/Object.freeze({ __proto__: null, preloadMedia: preloadMedia }); /** * Begins measurement * * @param {string} name */ function begin(name) { const mark_beg = `otree.${name}.beg`; performance.clearMarks(mark_beg); performance.mark(mark_beg); } /** * Ends measurement * * @param {string} name * @returns {number} duration in mseconds */ function end(name) { const mark_end = `otree.${name}.end`; performance.clearMarks(mark_end); performance.mark(mark_end); const mark_beg = `otree.${name}.beg`; const measure = `otree.${name}.measure`; performance.clearMeasures(measure); performance.measure(measure, mark_beg, mark_end); const entry = performance.getEntriesByName(measure)[0]; return entry.duration; } var measurement = /*#__PURE__*/Object.freeze({ __proto__: null, begin: begin, end: end }); /* map of selector => class */ const registry = new Map(); /** * Registers a directive class. * * The {@link Page} sets up all registered directives on all found elements in html. * The elements a searched by provided selector, which is something like `[ot-something]` but actually can be anything. * * @param {string} selector a css selector for elements * @param {class} cls a class derived from {@link DirectiveBase} */ function registerDirective(selector, cls) { registry.set(selector, cls); } /** * Base class for directives. * * Used by all built-in directives and can be used to create custom directives. */ class DirectiveBase { /** * Returns a value from attribute `ot-name`. * * @param {string} [name=this.name] the param to get */ getParam(attr) { return this.elem.getAttribute(`ot-${attr}`); } hasParam(attr) { return this.elem.hasAttribute(`ot-${attr}`); } /** * A directive instance is created for each matching element. * * @param {Page} page * @param {HTMLElement} elem */ constructor(page, elem) { this.page = page; this.elem = elem; // this.handlers = new Map(); // TODO: cleaning up when detached this.init(); } /** * Initializes directive. * * Use it to parse parameters from the element, and to init all the state. */ init() {} /** * Binds an event handler for a global page event * * @param {String} eventype * @param {Function} handler either `this.something` or a standalone function */ onEvent(eventype, handler) { this.page.onEvent(eventype, handler.bind(this)); } /** * Binds an event handler for a element event * * @param {String} eventype * @param {Function} handler either `this.something` or a standalone function */ onElemEvent(eventype, handler) { this.page.onElemEvent(this.elem, eventype, handler.bind(this)); } /** * Sets up event handlers */ setup() { if (this.onReset) this.onEvent("ot.reset", this.onReset); if (this.onUpdate) this.onEvent("ot.update", this.onUpdate); } } /** * Base for input * * handles `ot-enabled` and freezing. */ class otEnablable extends DirectiveBase { init() { if (this.hasParam('enabled')) { this.cond = parseCond(this.getParam('enabled')); this.enabled = false; } else { this.cond = null; this.enabled = true; } } onReset(event, vars) { if (!this.cond) { this.enabled = true; } else if(affecting(this.cond, event)) { this.enabled = false; } toggleDisabled(this.elem, !this.enabled); } onUpdate(event, changes) { if (this.cond && affecting(this.cond, event)) { this.enabled = evalCond(this.cond, changes); toggleDisabled(this.elem, !this.enabled); } } onFreezing(event, frozen) { toggleDisabled(this.elem, !this.enabled || frozen); } } /** * Directive `ot-input="var"` for native inputs: ``, `