diff --git a/.gitignore b/.gitignore index b4f2935..72e870c 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,6 @@ dist/ docs/ jsconfig.json -foundry.jsconfig +foundry.js /client /common \ No newline at end of file diff --git a/foundry.js b/foundry.js deleted file mode 100644 index aaf14df..0000000 --- a/foundry.js +++ /dev/null @@ -1,81298 +0,0 @@ -/** @module client */ - -/** - * The string prefix used to prepend console logging - * @type {string} - */ -const vtt = globalThis.vtt = "Foundry VTT"; - -/** - * The singleton Game instance - * @type {Game} - */ -let game = globalThis.game = {}; - -// Utilize SmoothGraphics by default -PIXI.LegacyGraphics = PIXI.Graphics; -PIXI.Graphics = PIXI.smooth.SmoothGraphics; -PIXI.LegacyGraphics.nextRoundedRectBehavior = true; // Needed until PIXI v7 -PIXI.Graphics.nextRoundedRectBehavior = true; // Needed until PIXI v7 - -/** - * The global boolean for whether the EULA is signed - */ -globalThis.SIGNED_EULA = SIGNED_EULA; - -/** - * The global route prefix which is applied to this game - * @type {string} - */ -globalThis.ROUTE_PREFIX = ROUTE_PREFIX; - -/** - * Critical server-side startup messages which need to be displayed to the client. - * @type {Array<{type: string, message: string, options: object}>} - */ -globalThis.MESSAGES = MESSAGES || []; - -/** - * A collection of application instances - * @type {Object} - * @alias ui - */ -globalThis.ui = { - windows: {} -}; - -/** - * The client side console logger - * @type {Console} - * @alias logger - */ -logger = globalThis.logger = console; - -/** - * The Color management and manipulation class - * @alias {foundry.utils.Color} - */ -globalThis.Color = foundry.utils.Color; - -/** - * A helper class to manage requesting clipboard permissions and provide common functionality for working with the - * clipboard. - */ -class ClipboardHelper { - constructor() { - if ( game.clipboard instanceof this.constructor ) { - throw new Error("You may not re-initialize the singleton ClipboardHelper. Use game.clipboard instead."); - } - } - - /* -------------------------------------------- */ - - /** - * Copies plain text to the clipboard in a cross-browser compatible way. - * @param {string} text The text to copy. - * @returns {Promise} - */ - async copyPlainText(text) { - // The clipboard-write permission name is not supported in Firefox. - try { - const result = await navigator.permissions.query({name: "clipboard-write"}); - if ( ["granted", "prompt"].includes(result.state) ) { - return navigator.clipboard.writeText(text); - } - } catch(err) {} - - // Fallback to deprecated execCommand here if writeText is not supported in this browser or security context. - document.addEventListener("copy", event => { - event.clipboardData.setData("text/plain", text); - event.preventDefault(); - }, {once: true}); - document.execCommand("copy"); - } -} - -/** - * A data structure for quickly retrieving objects by a string prefix. - * Note that this works well for languages with alphabets (latin, cyrillic, korean, etc.), but may need more nuanced - * handling for languages that compose characters and letters. - */ -class WordTree { - /** - * A leaf entry in the tree. - * @typedef {object} WordTreeEntry - * @property {Document|object} entry An object that this entry represents. - * @property {string} documentName The document type. - * @property {string} uuid The document's UUID. - * @property {string} [pack] The pack ID. - */ - - /** - * A word tree node consists of zero or more 1-character keys, and a leaves property that contains any objects that - * terminate at the current string prefix. - * @typedef {object} WordTreeNode - * @property {WordTreeEntry[]} leaves Any leaves at this node. - */ - - /** - * The tree's root. - * @type {WordTreeNode} - * @private - */ - #root = this.node; - - /* -------------------------------------------- */ - - /** - * Create a new node. - * @returns {WordTreeNode} - */ - get node() { - return {leaves: []}; - } - - /* -------------------------------------------- */ - - /** - * Insert an entry into the tree. - * @param {string} string The string key for the entry. - * @param {WordTreeEntry} entry The entry to store. - * @returns {WordTreeNode} The node the entry was added to. - */ - addLeaf(string, entry) { - let node = this.#root; - string = string.toLocaleLowerCase(game.i18n.lang); - // Use Array.from here to make sure the string is split up along UTF-8 codepoints rather than individual UTF-16 - // chunks. - for ( const c of Array.from(string) ) { - node[c] ??= this.node; - node = node[c]; - } - - // Once we've traversed the tree, we add our entry. - node.leaves.push(entry); - return node; - } - - /* -------------------------------------------- */ - - /** - * Return entries that match the given string prefix. - * @param {string} prefix The prefix. - * @param {object} [options] Additional options to configure behaviour. - * @param {number} [options.limit=10] The maximum number of items to retrieve. It is important to set this value as - * very short prefixes will naturally match large numbers of entries. - * @returns {WordTreeEntry[]} A number of entries that have the given prefix. - */ - lookup(prefix, {limit=10}={}) { - const entries = []; - const node = this.nodeAtPrefix(prefix); - if ( !node ) return []; // No matching entries. - const queue = [node]; - while ( queue.length ) { - if ( entries.length >= limit ) break; - this._breadthFirstSearch(queue.shift(), entries, queue, {limit}); - } - return entries; - } - - /* -------------------------------------------- */ - - /** - * Returns the node at the given prefix. - * @param {string} prefix The prefix. - * @returns {WordTreeNode} - */ - nodeAtPrefix(prefix) { - prefix = prefix.toLocaleLowerCase(game.i18n.lang); - let node = this.#root; - for ( const c of Array.from(prefix) ) { - node = node[c]; - if ( !node ) return; - } - return node; - } - - /* -------------------------------------------- */ - - /** - * Perform a breadth-first search starting from the given node and retrieving any entries along the way, until we - * reach the limit. - * @param {WordTreeNode} node The starting node. - * @param {WordTreeEntry[]} entries The accumulated entries. - * @param {WordTreeNode[]} queue The working queue of nodes to search. - * @param {object} [options] Additional options for the search. - * @param {number} [options.limit=10] The maximum number of entries to retrieve before stopping. - * @protected - */ - _breadthFirstSearch(node, entries, queue, {limit=10}={}) { - // Retrieve the entries at this node. - entries.push(...node.leaves); - if ( entries.length >= limit ) return; - // Push this node's children onto the end of the queue. - for ( const c of Object.keys(node) ) { - if ( c === "leaves" ) continue; - queue.push(node[c]); - } - } -} - -/** - * This class is responsible for indexing all documents available in the world and storing them in a word tree structure - * that allows for fast searching. - */ -class DocumentIndex { - constructor() { - /** - * A collection of WordTree structures for each document type. - * @type {Object} - */ - Object.defineProperty(this, "trees", {value: {}}); - - /** - * A reverse-lookup of a document's UUID to its parent node in the word tree. - * @type {Object} - */ - Object.defineProperty(this, "uuids", {value: {}}); - } - - /** - * While we are indexing, we store a Promise that resolves when the indexing is complete. - * @type {Promise|null} - * @private - */ - #ready = null; - - /* -------------------------------------------- */ - - /** - * Returns a Promise that resolves when the indexing process is complete. - * @returns {Promise|null} - */ - get ready() { - return this.#ready; - } - - /* -------------------------------------------- */ - - /** - * Index all available documents in the world and store them in a word tree. - * @returns {Promise} - */ - async index() { - // Conclude any existing indexing. - await this.#ready; - const indexedCollections = CONST.DOCUMENT_TYPES.filter(c => CONFIG[c].documentClass.metadata.indexed); - // TODO: Consider running this process in a web worker. - const start = performance.now(); - return this.#ready = new Promise(resolve => { - for ( const documentName of indexedCollections ) { - this._indexWorldCollection(documentName); - } - - for ( const pack of game.packs ) { - if ( !indexedCollections.includes(pack.documentName) ) continue; - this._indexCompendium(pack); - } - - resolve(); - console.debug(`${vtt} | Document indexing complete in ${performance.now() - start}ms.`); - }); - } - - /* -------------------------------------------- */ - - /** - * Return entries that match the given string prefix. - * @param {string} prefix The prefix. - * @param {object} [options] Additional options to configure behaviour. - * @param {string[]} [options.documentTypes] Optionally provide an array of document types. Only entries of that type - * will be searched for. - * @param {number} [options.limit=10] The maximum number of items per document type to retrieve. It is - * important to set this value as very short prefixes will naturally match - * large numbers of entries. - * @returns {Object} A number of entries that have the given prefix, grouped by document - * type. - */ - lookup(prefix, {limit=10, documentTypes=[]}={}) { - const types = documentTypes.length ? documentTypes : Object.keys(this.trees); - const results = {}; - for ( const type of types ) { - results[type] = []; - const tree = this.trees[type]; - if ( !tree ) continue; - results[type].push(...tree.lookup(prefix, {limit})); - } - return results; - } - - /* -------------------------------------------- */ - - /** - * Add an entry to the index. - * @param {Document} doc The document entry. - */ - addDocument(doc) { - if ( doc.pack ) { - if ( doc.isEmbedded ) return; // Only index primary documents inside compendium packs - const pack = game.packs.get(doc.pack); - const index = pack.index.get(doc.id); - this._addLeaf(index, {pack}); - } - else this._addLeaf(doc); - } - - /* -------------------------------------------- */ - - /** - * Remove an entry from the index. - * @param {Document} doc The document entry. - */ - removeDocument(doc) { - const node = this.uuids[doc.uuid]; - if ( !node ) return; - node.leaves.findSplice(e => e.uuid === doc.uuid); - delete this.uuids[doc.uuid]; - } - - /* -------------------------------------------- */ - - /** - * Replace an entry in the index with an updated one. - * @param {Document} doc The document entry. - */ - replaceDocument(doc) { - this.removeDocument(doc); - this.addDocument(doc); - } - - /* -------------------------------------------- */ - - /** - * Add a leaf node to the word tree index. - * @param {Document|object} doc The document or compendium index entry to add. - * @param {object} [options] Additional information for indexing. - * @param {CompendiumCollection} [options.pack] The compendium that the index belongs to. - * @protected - */ - _addLeaf(doc, {pack}={}) { - const entry = {entry: doc, documentName: doc.documentName, uuid: doc.uuid}; - if ( pack ) foundry.utils.mergeObject(entry, { - documentName: pack.documentName, - uuid: `Compendium.${pack.collection}.${doc._id}`, - pack: pack.collection - }); - const tree = this.trees[entry.documentName] ??= new WordTree(); - this.uuids[entry.uuid] = tree.addLeaf(doc.name, entry); - } - - /* -------------------------------------------- */ - - /** - * Aggregate the compendium index and add it to the word tree index. - * @param {CompendiumCollection} pack The compendium pack. - * @protected - */ - _indexCompendium(pack) { - for ( const entry of pack.index ) { - this._addLeaf(entry, {pack}); - } - } - - /* -------------------------------------------- */ - - /** - * Add all of a parent document's embedded documents to the index. - * @param {Document} parent The parent document. - * @protected - */ - _indexEmbeddedDocuments(parent) { - const embedded = parent.constructor.metadata.embedded; - for ( const embeddedName of Object.keys(embedded) ) { - if ( !CONFIG[embeddedName].documentClass.metadata.indexed ) continue; - for ( const doc of parent[embedded[embeddedName]] ) { - this._addLeaf(doc); - } - } - } - - /* -------------------------------------------- */ - - /** - * Aggregate all documents and embedded documents in a world collection and add them to the index. - * @param {string} documentName The name of the documents to index. - * @protected - */ - _indexWorldCollection(documentName) { - const cls = CONFIG[documentName].documentClass; - const collection = cls.metadata.collection; - for ( const doc of game[collection] ) { - this._addLeaf(doc); - this._indexEmbeddedDocuments(doc); - } - } -} - -/** - * Management class for Gamepad events - */ -class GamepadManager { - constructor() { - this._gamepadPoller = null; - - /** - * The connected Gamepads - * @type {Map} - * @private - */ - this._connectedGamepads = new Map(); - - window.addEventListener("gamepadconnected", this._onGamepadConnect.bind(this)); - window.addEventListener("gamepaddisconnected", this._onGamepadDisconnect.bind(this)); - } - - /** - * How often Gamepad polling should check for button presses - * @type {number} - */ - static GAMEPAD_POLLER_INTERVAL_MS = 100; - - /* -------------------------------------------- */ - - /** - * Handles a Gamepad Connection event, adding its info to the poll list - * @param {GamepadEvent} event The originating Event - * @private - */ - _onGamepadConnect(event) { - if ( CONFIG.debug.gamepad ) console.log(`Gamepad ${event.gamepad.id} connected`); - this._connectedGamepads.set(event.gamepad.id, { - axes: new Map(), - activeButtons: new Set() - }); - if ( !this._gamepadPoller ) this._gamepadPoller = setInterval(() => { - this._pollGamepads() - }, GamepadManager.GAMEPAD_POLLER_INTERVAL_MS); - // Immediately poll to try and capture the action that connected the Gamepad - this._pollGamepads(); - } - - /* -------------------------------------------- */ - - /** - * Handles a Gamepad Disconnect event, removing it from consideration for polling - * @param {GamepadEvent} event The originating Event - * @private - */ - _onGamepadDisconnect(event) { - if ( CONFIG.debug.gamepad ) console.log(`Gamepad ${event.gamepad.id} disconnected`); - this._connectedGamepads.delete(event.gamepad.id); - if ( this._connectedGamepads.length === 0 ) { - clearInterval(this._gamepadPoller); - this._gamepadPoller = null; - } - } - - /* -------------------------------------------- */ - - /** - * Polls all Connected Gamepads for updates. If they have been updated, checks status of Axis and Buttons, - * firing off Keybinding Contexts as appropriate - * @private - */ - _pollGamepads() { - // Joysticks are not very precise and range from -1 to 1, so we need to ensure we avoid drift due to low (but not zero) values - const AXIS_PRECISION = 0.15; - const MAX_AXIS = 1; - for ( let gamepad of navigator.getGamepads() ) { - if ( !gamepad || !this._connectedGamepads.has(gamepad?.id) ) continue; - const id = gamepad.id; - let gamepadData = this._connectedGamepads.get(id); - - // Check Active Axis - for ( let x = 0; x < gamepad.axes.length; x++ ) { - let axisValue = gamepad.axes[x]; - - // Verify valid input and handle inprecise values - if ( Math.abs(axisValue) > MAX_AXIS ) continue; - if ( Math.abs(axisValue) <= AXIS_PRECISION ) axisValue = 0; - - // Store Axis data per Joystick as Numbers - const joystickId = `${id}_AXIS${x}`; - const priorValue = gamepadData.axes.get(joystickId) ?? 0; - - // An Axis exists from -1 to 1, with 0 being the center. - // We split an Axis into Negative and Positive zones to differentiate pressing it left / right and up / down - if ( axisValue !== 0 ) { - const sign = Math.sign(axisValue); - const repeat = sign === Math.sign(priorValue); - const emulatedKey = `${joystickId}_${sign > 0 ? "POSITIVE" : "NEGATIVE"}`; - this._handleGamepadInput(emulatedKey, false, repeat); - } - else if ( priorValue !== 0 ) { - const sign = Math.sign(priorValue); - const emulatedKey = `${joystickId}_${sign > 0 ? "POSITIVE" : "NEGATIVE"}`; - this._handleGamepadInput(emulatedKey, true); - } - - // Update value - gamepadData.axes.set(joystickId, axisValue); - } - - // Check Pressed Buttons - for ( let x = 0; x < gamepad.buttons.length; x++ ) { - const button = gamepad.buttons[x]; - const buttonId = `${id}_BUTTON${x}_PRESSED`; - if ( button.pressed ) { - const repeat = gamepadData.activeButtons.has(buttonId); - if ( !repeat ) gamepadData.activeButtons.add(buttonId); - this._handleGamepadInput(buttonId, false, repeat); - } - else if ( gamepadData.activeButtons.has(buttonId) ) { - gamepadData.activeButtons.delete(buttonId); - this._handleGamepadInput(buttonId, true); - } - } - } - } - - /* -------------------------------------------- */ - - /** - * Converts a Gamepad Input event into a KeyboardEvent, then fires it - * @param {string} gamepadId The string representation of the Gamepad Input - * @param {boolean} up True if the Input is pressed or active - * @param {boolean} repeat True if the Input is being held - * @private - */ - _handleGamepadInput(gamepadId, up, repeat = false) { - const key = gamepadId.replaceAll(" ", "").toUpperCase().trim(); - const event = new KeyboardEvent(`key${up ? "up" : "down"}`, {code: key, bubbles: true}); - window.dispatchEvent(event); - $(".binding-input:focus").get(0)?.dispatchEvent(event); - } -} - -/** - * @typedef {object} HookedFunction - * @property {string} hook - * @property {number} id - * @property {Function} fn - * @property {boolean} once - */ - -/** - * A simple event framework used throughout Foundry Virtual Tabletop. - * When key actions or events occur, a "hook" is defined where user-defined callback functions can execute. - * This class manages the registration and execution of hooked callback functions. - */ -class Hooks { - - /** - * A mapping of hook events which have functions registered to them. - * @type {Object} - */ - static get events() { - return this.#events; - } - - /** - * @type {Object} - * @private - * @ignore - */ - static #events = {}; - - /** - * A mapping of hooked functions by their assigned ID - * @type {Map} - */ - static #ids = new Map(); - - /** - * An incrementing counter for assigned hooked function IDs - * @type {number} - */ - static #id = 1; - - /* -------------------------------------------- */ - - /** - * Register a callback handler which should be triggered when a hook is triggered. - * @param {string} hook The unique name of the hooked event - * @param {Function} fn The callback function which should be triggered when the hook event occurs - * @param {object} options Options which customize hook registration - * @param {boolean} options.once Only trigger the hooked function once - * @returns {number} An ID number of the hooked function which can be used to turn off the hook later - */ - static on(hook, fn, {once=false}={}) { - console.debug(`${vtt} | Registered callback for ${hook} hook`); - const id = this.#id++; - if ( !(hook in this.#events) ) { - Object.defineProperty(this.#events, hook, {value: [], writable: false}); - } - const entry = {hook, id, fn, once}; - this.#events[hook].push(entry); - this.#ids.set(id, entry); - return id; - } - - /* -------------------------------------------- */ - - /** - * Register a callback handler for an event which is only triggered once the first time the event occurs. - * An alias for Hooks.on with {once: true} - * @param {string} hook The unique name of the hooked event - * @param {Function} fn The callback function which should be triggered when the hook event occurs - * @returns {number} An ID number of the hooked function which can be used to turn off the hook later - */ - static once(hook, fn) { - return this.on(hook, fn, {once: true}); - } - - /* -------------------------------------------- */ - - /** - * Unregister a callback handler for a particular hook event - * @param {string} hook The unique name of the hooked event - * @param {Function|number} fn The function, or ID number for the function, that should be turned off - */ - static off(hook, fn) { - let entry; - - // Provided an ID - if ( typeof fn === "number" ) { - const id = fn; - entry = this.#ids.get(id); - if ( !entry ) return; - this.#ids.delete(id); - const event = this.#events[entry.hook]; - event.findSplice(h => h.id === id); - } - - // Provided a Function - else { - const event = this.#events[hook]; - const entry = event.findSplice(h => h.fn === fn); - if ( !entry ) return; - this.#ids.delete(entry.id); - } - console.debug(`${vtt} | Unregistered callback for ${hook} hook`); - } - - /* -------------------------------------------- */ - - /** - * Call all hook listeners in the order in which they were registered - * Hooks called this way can not be handled by returning false and will always trigger every hook callback. - * - * @param {string} hook The hook being triggered - * @param {...*} args Arguments passed to the hook callback functions - * @returns {boolean} Were all hooks called without execution being prevented? - */ - static callAll(hook, ...args) { - if ( CONFIG.debug.hooks ) { - console.log(`DEBUG | Calling ${hook} hook with args:`); - console.log(args); - } - if ( !(hook in this.#events) ) return true; - for ( const entry of Array.from(this.#events[hook]) ) { - this.#call(entry, args); - } - return true; - } - - /* -------------------------------------------- */ - - /** - * Call hook listeners in the order in which they were registered. - * Continue calling hooks until either all have been called or one returns false. - * - * Hook listeners which return false denote that the original event has been adequately handled and no further - * hooks should be called. - * - * @param {string} hook The hook being triggered - * @param {...*} args Arguments passed to the hook callback functions - * @returns {boolean} Were all hooks called without execution being prevented? - */ - static call(hook, ...args) { - if ( CONFIG.debug.hooks ) { - console.log(`DEBUG | Calling ${hook} hook with args:`); - console.log(args); - } - if ( !(hook in this.#events) ) return true; - for ( const entry of Array.from(this.#events[hook]) ) { - let callAdditional = this.#call(entry, args); - if ( callAdditional === false ) return false; - } - return true; - } - - /* -------------------------------------------- */ - - /** - * Call a hooked function using provided arguments and perhaps unregister it. - * @param {HookedFunction} entry The hooked function entry - * @param {any[]} args Arguments to be passed - * @private - */ - static #call(entry, args) { - const {hook, id, fn, once} = entry; - if ( once ) this.off(hook, id); - try { - return entry.fn(...args); - } catch(err) { - const msg = `Error thrown in hooked function '${fn?.name}' for hook '${hook}'`; - console.warn(`${vtt} | ${msg}`); - if ( hook !== "error" ) this.onError("Hooks.#call", err, {msg, hook, fn, log: "error"}); - } - } - - /* --------------------------------------------- */ - - /** - * Notify subscribers that an error has occurred within foundry. - * @param {string} location The method where the error was caught. - * @param {Error} error The error. - * @param {object} [options={}] Additional options to configure behaviour. - * @param {string} [options.msg=""] A message which should prefix the resulting error or notification. - * @param {?string} [options.log=null] The level at which to log the error to console (if at all). - * @param {?string} [options.notify=null] The level at which to spawn a notification in the UI (if at all). - * @param {object} [options.data={}] Additional data to pass to the hook subscribers. - */ - static onError(location, error, {msg="", notify=null, log=null, ...data}={}) { - if ( !(error instanceof Error) ) return; - if ( msg ) error.message = `${msg}. ${error.message}`; - if ( log ) console[log]?.(error); - if ( notify ) ui.notifications[notify]?.(msg || error.message); - - /** - * A hook event that fires whenever foundry experiences an error. - * - * @function error - * @memberof hookEvents - * @param {string} location The method where the error was caught. - * @param {Error} err The error. - * @param {object} [data={}] Additional data that might be provided, based on the nature of the error. - */ - Hooks.callAll("error", location, error, data); - } -} - -/** - * A helper class to provide common functionality for working with Image objects - */ -class ImageHelper { - - /** - * Create thumbnail preview for a provided image path. - * @param {string|PIXI.DisplayObject} src The URL or display object of the texture to render to a thumbnail - * @param {object} options Additional named options passed to the compositeCanvasTexture function - * @param {number} [options.width] The desired width of the resulting thumbnail - * @param {number} [options.height] The desired height of the resulting thumbnail - * @param {number} [options.tx] A horizontal transformation to apply to the provided source - * @param {number} [options.ty] A vertical transformation to apply to the provided source - * @param {boolean} [options.center] Whether to center the object within the thumbnail - * @param {string} [options.format] The desired output image format - * @param {number} [options.quality] The desired output image quality - * @returns {Promise} The parsed and converted thumbnail data - */ - static async createThumbnail(src, {width, height, tx, ty, center, format, quality}) { - if ( !src ) return null; - - // Load the texture and create a Sprite - let object = src; - if ( !(src instanceof PIXI.DisplayObject) ) { - const texture = await loadTexture(src); - object = PIXI.Sprite.from(texture); - } - - // Reduce to the smaller thumbnail texture - if ( !canvas.ready && canvas.initializing ) await canvas.initializing; - const reduced = this.compositeCanvasTexture(object, {width, height, tx, ty, center}); - const thumb = this.textureToImage(reduced, {format, quality}); - reduced.destroy(true); - - // Return the image data - return { src, texture: reduced, thumb, width: object.width, height: object.height }; - } - - /* -------------------------------------------- */ - - /** - * Test whether a source file has a supported image extension type - * @param {string} src A requested image source path - * @returns {boolean} Does the filename end with a valid image extension? - */ - static hasImageExtension(src) { - return foundry.data.validators.hasFileExtension(src, Object.keys(CONST.IMAGE_FILE_EXTENSIONS)); - } - - /* -------------------------------------------- */ - - /** - * Composite a canvas object by rendering it to a single texture - * - * @param {PIXI.DisplayObject} object The object to render to a texture - * @param {object} [options] Options which configure the resulting texture - * @param {number} [options.width] The desired width of the output texture - * @param {number} [options.height] The desired height of the output texture - * @param {number} [options.tx] A horizontal translation to apply to the object - * @param {number} [options.ty] A vertical translation to apply to the object - * @param {boolean} [options.center] Center the texture in the rendered frame? - * - * @returns {PIXI.Texture} The composite Texture object - */ - static compositeCanvasTexture(object, {width, height, tx=0, ty=0, center=true}={}) { - if ( !canvas.app?.renderer ) throw new Error("Unable to compose texture because there is no game canvas"); - width = width ?? object.width; - height = height ?? object.height; - - // Downscale the object to the desired thumbnail size - const currentRatio = object.width / object.height; - const targetRatio = width / height; - const s = currentRatio > targetRatio ? (height / object.height) : (width / object.width); - - // Define a transform matrix - const transform = PIXI.Matrix.IDENTITY.clone(); - transform.scale(s, s); - - // Translate position - if ( center ) { - tx = (width - (object.width * s)) / 2; - ty = (height - (object.height * s)) / 2; - } else { - tx *= s; - ty *= s; - } - transform.translate(tx, ty); - - // Create and render a texture with the desired dimensions - const texture = PIXI.RenderTexture.create({ - width: width, - height: height, - scaleMode: PIXI.SCALE_MODES.LINEAR, - resolution: canvas.app.renderer.resolution - }); - canvas.app.renderer.render(object, texture, undefined, transform); - return texture; - } - - /* -------------------------------------------- */ - - /** - * Extract a texture to a base64 PNG string - * @param {PIXI.Texture} texture The texture object to extract - * @param {string} [format] Image format, e.g. "image/jpeg" or "image/webp". - * @param {number} [quality] JPEG or WEBP compression from 0 to 1. Default is 0.92. - * @return {string} A base64 png string of the texture - */ - static textureToImage(texture, {format, quality}={}) { - const s = new PIXI.Sprite(texture); - return canvas.app.renderer.extract.base64(s, format, quality); - } - - /* -------------------------------------------- */ - - /** - * Asynchronously convert a DisplayObject container to base64 using Canvas#toBlob and FileReader - * @param {PIXI.DisplayObject} target A PIXI display object to convert - * @param {string} type The requested mime type of the output, default is image/png - * @param {number} quality A number between 0 and 1 for image quality if image/jpeg or image/webp - * @returns {Promise} A processed base64 string - */ - static async pixiToBase64(target, type, quality) { - const extracted = canvas.app.renderer.extract.canvas(target); - return new Promise((resolve, reject) => { - extracted.toBlob(blob => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = reject; - reader.readAsDataURL(blob); - }, type, quality); - }); - } - - /* -------------------------------------------- */ - - /** - * Upload a base64 image string to a persisted data storage location - * @param {string} base64 The base64 string - * @param {string} fileName The file name to upload - * @param {string} filePath The file path where the file should be uploaded - * @param {object} [options] Additional options which affect uploading - * @param {string} [options.storage=data] The data storage location to which the file should be uploaded - * @param {string} [options.type] The MIME type of the file being uploaded - * @returns {Promise} A promise which resolves to the FilePicker upload response - */ - static async uploadBase64(base64, fileName, filePath, {storage="data", type}={}) { - type ||= base64.split(";")[0].split("data:")[1]; - const blob = await fetch(base64).then(r => r.blob()); - const file = new File([blob], fileName, {type}); - return FilePicker.upload(storage, filePath, file); - } -} - -/** - * A class responsible for managing defined game keybinding. - * Each keybinding is a string key/value pair belonging to a certain namespace and a certain store scope. - * - * When Foundry Virtual Tabletop is initialized, a singleton instance of this class is constructed within the global - * Game object as as game.keybindings. - * - * @see {@link Game#keybindings} - * @see {@link SettingKeybindingConfig} - * @see {@link KeybindingsConfig} - */ -class ClientKeybindings { - constructor() { - - /** - * Registered Keybinding actions - * @type {Map} - */ - this.actions = new Map(); - - /** - * A mapping of a string key to possible Actions that might execute off it - * @type {Map} - */ - this.activeKeys = new Map(); - - /** - * A stored cache of Keybind Actions Ids to Bindings - * @type {Map} - */ - this.bindings = undefined; - - /** - * A count of how many registered keybindings there are - * @type {number} - * @private - */ - this._registered = 0; - - /** - * A timestamp which tracks the last time a pan operation was performed - * @type {number} - * @private - */ - this._moveTime = 0; - } - - static MOVEMENT_DIRECTIONS = { - UP: "up", - LEFT: "left", - DOWN: "down", - RIGHT: "right" - }; - - static ZOOM_DIRECTIONS = { - IN: "in", - OUT: "out" - }; - - /** - * An alias of the movement key set tracked by the keyboard - * @returns {Set}> - */ - get moveKeys() { - return game.keyboard.moveKeys; - } - - /* -------------------------------------------- */ - - /** - * Initializes the keybinding values for all registered actions - */ - initialize() { - - // Create the bindings mapping for all actions which have been registered - this.bindings = new Map(Object.entries(game.settings.get("core", "keybindings"))); - for ( let k of Array.from(this.bindings.keys()) ) { - if ( !this.actions.has(k) ) this.bindings.delete(k); - } - - // Register bindings for all actions - for ( let [action, config] of this.actions) { - let bindings = config.uneditable; - bindings = config.uneditable.concat(this.bindings.get(action) ?? config.editable); - this.bindings.set(action, bindings); - } - - // Create a mapping of keys which trigger actions - this.activeKeys = new Map(); - for ( let [ key, action ] of this.actions ) { - let bindings = this.bindings.get(key); - for ( let binding of bindings ) { - if ( !binding ) continue; - if ( !this.activeKeys.has(binding.key) ) this.activeKeys.set(binding.key, []); - let actions = this.activeKeys.get(binding.key); - actions.push({ - action: key, - key: binding.key, - name: action.name, - requiredModifiers: binding.modifiers, - optionalModifiers: action.reservedModifiers, - onDown: action.onDown, - onUp: action.onUp, - precedence: action.precedence, - order: action.order, - repeat: action.repeat, - restricted: action.restricted - }); - this.activeKeys.set(binding.key, actions.sort(this.constructor._compareActions)); - } - } - } - - /* -------------------------------------------- */ - - /** - * Register a new keybinding - * - * @param {string} namespace The namespace the Keybinding Action belongs to - * @param {string} action A unique machine-readable id for the Keybinding Action - * @param {KeybindingActionConfig} data Configuration for keybinding data - * - * @example Define a keybinding which shows a notification - * ```js - * game.keybindings.register("myModule", "showNotification", { - * name: "My Settings Keybinding", - * hint: "A description of what will occur when the Keybinding is executed.", - * uneditable: [ - * { - * key: "Digit1", - * modifiers: ["Control"] - * } - * ], - * editable: [ - * { - * key: "F1" - * } - * ], - * onDown: () => { ui.notifications.info("Pressed!") }, - * onUp: () => {}, - * restricted: true, // Restrict this Keybinding to gamemaster only? - * reservedModifiers: ["Alt""], // If the ALT modifier is pressed, the notification is permanent instead of temporary - * precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL - * } - * ``` - */ - register(namespace, action, data) { - if ( this.bindings ) throw new Error("You cannot register a Keybinding after the init hook"); - if ( !namespace || !action ) throw new Error("You must specify both the namespace and action portion of the Keybinding action"); - action = `${namespace}.${action}`; - data.namespace = namespace; - data.precedence = data.precedence ?? CONST.KEYBINDING_PRECEDENCE.NORMAL; - data.order = this._registered++; - data.uneditable = this.constructor._validateBindings(data.uneditable ?? []); - data.editable = this.constructor._validateBindings(data.editable ?? []); - data.repeat = data.repeat ?? false; - data.reservedModifiers = this.constructor._validateModifiers(data.reservedModifiers ?? []); - this.actions.set(action, data); - } - - /* -------------------------------------------- */ - - /** - * Get the current Bindings of a given namespace's Keybinding Action - * - * @param {string} namespace The namespace under which the setting is registered - * @param {string} action The keybind action to retrieve - * @returns {KeybindingActionBinding[]} - * - * @example Retrieve the current Keybinding Action Bindings - * ```js - * game.keybindings.get("myModule", "showNotification"); - * ``` - */ - get(namespace, action) { - if ( !namespace || !action ) throw new Error("You must specify both namespace and key portions of the keybind"); - action = `${namespace}.${action}`; - const keybind = this.actions.get(action); - if ( !keybind ) throw new Error("This is not a registered keybind action"); - return this.bindings.get(action) || []; - } - - /* -------------------------------------------- */ - - /** - * Set the editable Bindings of a Keybinding Action for a certain namespace and Action - * - * @param {string} namespace The namespace under which the Keybinding is registered - * @param {string} action The Keybinding action to set - * @param {KeybindingActionBinding[]} bindings The Bindings to assign to the Keybinding - * - * @example Update the current value of a keybinding - * ```js - * game.keybindings.set("myModule", "showNotification", [ - * { - * key: "F2", - * modifiers: [ "CONTROL" ] - * } - * ]); - * ``` - */ - async set(namespace, action, bindings) { - if ( !namespace || !action ) throw new Error("You must specify both namespace and action portions of the Keybind"); - action = `${namespace}.${action}`; - const keybind = this.actions.get(action); - if ( !keybind ) throw new Error("This is not a registered keybind"); - if ( keybind.restricted && !game.user.isGM ) throw new Error("Only a GM can edit this keybind"); - const mapping = game.settings.get("core", "keybindings"); - - // Set to default if value is undefined and return - if ( bindings === undefined ) { - delete mapping[action]; - return game.settings.set("core", "keybindings", mapping); - } - bindings = this.constructor._validateBindings(bindings); - - // Verify no reserved Modifiers were set as Keys - for ( let binding of bindings ) { - if ( keybind.reservedModifiers.includes(binding.key) ) { - throw new Error(game.i18n.format("KEYBINDINGS.ErrorReservedModifier", {key: binding.key})); - } - } - - // Save editable bindings to setting - mapping[action] = bindings; - await game.settings.set("core", "keybindings", mapping); - } - - /* ---------------------------------------- */ - - /** - * Reset all client keybindings back to their default configuration. - */ - async resetDefaults() { - const setting = game.settings.settings.get("core.keybindings"); - return game.settings.set("core", "keybindings", setting.default); - } - - /* -------------------------------------------- */ - - /** - * A helper method that, when given a value, ensures that the returned value is a standardized Binding array - * @param {KeybindingActionBinding[]} values An array of keybinding assignments to be validated - * @return {KeybindingActionBinding[]} An array of keybinding assignments confirmed as valid - * @private - */ - static _validateBindings(values) { - if ( !(values instanceof Array) ) throw new Error(game.i18n.localize("KEYBINDINGS.MustBeArray")); - for ( let binding of values ) { - if ( !binding.key ) throw new Error("Each KeybindingActionBinding must contain a valid key designation"); - if ( KeyboardManager.PROTECTED_KEYS.includes(binding.key) ) { - throw new Error(game.i18n.format("KEYBINDINGS.ErrorProtectedKey", { key: binding.key })); - } - binding.modifiers = this._validateModifiers(binding.modifiers ?? []); - } - return values; - } - - /* -------------------------------------------- */ - - /** - * Validate that assigned modifiers are allowed - * @param {string[]} keys An array of modifiers which may be valid - * @returns {string[]} An array of modifiers which are confirmed as valid - * @private - */ - static _validateModifiers(keys) { - const modifiers = []; - for ( let key of keys ) { - if ( key in KeyboardManager.MODIFIER_KEYS ) key = KeyboardManager.MODIFIER_KEYS[key]; // backwards-compat - if ( !Object.values(KeyboardManager.MODIFIER_KEYS).includes(key) ) { - throw new Error(game.i18n.format("KEYBINDINGS.ErrorIllegalModifier", { key, allowed: modifiers.join(",") })); - } - modifiers.push(key); - } - return modifiers; - } - - /* -------------------------------------------- */ - - /** - * Compares two Keybinding Actions based on their Order - * @param {KeybindingAction} a The first Keybinding Action - * @param {KeybindingAction} b the second Keybinding Action - * @returns {number} - * @internal - */ - static _compareActions(a, b) { - if (a.precedence === b.precedence) return a.order - b.order; - return a.precedence - b.precedence; - } - - /* ---------------------------------------- */ - /* Core Keybinding Actions */ - /* ---------------------------------------- */ - - /** - * Register core keybindings - */ - _registerCoreKeybindings() { - const {SHIFT, CONTROL, ALT} = KeyboardManager.MODIFIER_KEYS; - game.keybindings.register("core", "cycleView", { - name: "KEYBINDINGS.CycleView", - editable: [ - {key: "Tab"} - ], - onDown: ClientKeybindings._onCycleView, - reservedModifiers: [SHIFT], - repeat: true - }); - game.keybindings.register("core", "dismiss", { - name: "KEYBINDINGS.Dismiss", - uneditable: [ - {key: "Escape"} - ], - onDown: ClientKeybindings._onDismiss, - precedence: CONST.KEYBINDING_PRECEDENCE.DEFERRED - }); - game.keybindings.register("core", "measuredRulerMovement", { - name: "KEYBINDINGS.MoveAlongMeasuredRuler", - editable: [ - {key: "Space"} - ], - onDown: ClientKeybindings._onMeasuredRulerMovement, - precedence: CONST.KEYBINDING_PRECEDENCE.PRIORITY, - reservedModifiers: [CONTROL] - }); - game.keybindings.register("core", "pause", { - name: "KEYBINDINGS.Pause", - restricted: true, - editable: [ - {key: "Space"} - ], - onDown: ClientKeybindings._onPause, - precedence: CONST.KEYBINDING_PRECEDENCE.DEFERRED - }); - game.keybindings.register("core", "delete", { - name: "KEYBINDINGS.Delete", - uneditable: [ - {key: "Delete"} - ], - editable: [ - {key: "Backspace"} - ], - onDown: ClientKeybindings._onDelete, - }); - game.keybindings.register("core", "highlight", { - name: "KEYBINDINGS.Highlight", - editable: [ - {key: "AltLeft"}, - {key: "AltRight"}, - ], - onUp: ClientKeybindings._onHighlight, - onDown: ClientKeybindings._onHighlight - }); - game.keybindings.register("core", "selectAll", { - name: "KEYBINDINGS.SelectAll", - uneditable: [ - {key: "KeyA", modifiers: [CONTROL]} - ], - onDown: ClientKeybindings._onSelectAllObjects - }); - game.keybindings.register("core", "undo", { - name: "KEYBINDINGS.Undo", - uneditable: [ - {key: "KeyZ", modifiers: [CONTROL]} - ], - onDown: ClientKeybindings._onUndo - }); - game.keybindings.register("core", "copy", { - name: "KEYBINDINGS.Copy", - uneditable: [ - {key: "KeyC", modifiers: [CONTROL]} - ], - onDown: ClientKeybindings._onCopy - }); - game.keybindings.register("core", "paste", { - name: "KEYBINDINGS.Paste", - uneditable: [ - {key: "KeyV", modifiers: [CONTROL]} - ], - onDown: ClientKeybindings._onPaste, - reservedModifiers: [ALT, SHIFT] - }); - game.keybindings.register("core", "target", { - name: "KEYBINDINGS.Target", - editable: [ - {key: "KeyT"} - ], - onDown: ClientKeybindings._onTarget, - reservedModifiers: [SHIFT] - }); - game.keybindings.register("core", "characterSheet", { - name: "KEYBINDINGS.ToggleCharacterSheet", - editable: [ - {key: "KeyC"} - ], - onDown: ClientKeybindings._onToggleCharacterSheet, - precedence: CONST.KEYBINDING_PRECEDENCE.PRIORITY - }); - game.keybindings.register("core", "panUp", { - name: "KEYBINDINGS.PanUp", - uneditable: [ - {key: "ArrowUp"}, - {key: "Numpad8"} - ], - editable: [ - {key: "KeyW"} - ], - onUp: (context) => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP]), - onDown: (context) => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP]), - reservedModifiers: [CONTROL, SHIFT], - repeat: true - }); - game.keybindings.register("core", "panLeft", { - name: "KEYBINDINGS.PanLeft", - uneditable: [ - {key: "ArrowLeft"}, - {key: "Numpad4"} - ], - editable: [ - {key: "KeyA"} - ], - onUp: (context) => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]), - onDown: (context) => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]), - reservedModifiers: [CONTROL, SHIFT], - repeat: true - }); - game.keybindings.register("core", "panDown", { - name: "KEYBINDINGS.PanDown", - uneditable: [ - {key: "ArrowDown"}, - {key: "Numpad2"} - ], - editable: [ - {key: "KeyS"} - ], - onUp: (context) => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN]), - onDown: (context) => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN]), - reservedModifiers: [CONTROL, SHIFT], - repeat: true - }); - game.keybindings.register("core", "panRight", { - name: "KEYBINDINGS.PanRight", - uneditable: [ - {key: "ArrowRight"}, - {key: "Numpad6"} - ], - editable: [ - {key: "KeyD"} - ], - onUp: (context) => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]), - onDown: (context) => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]), - reservedModifiers: [CONTROL, SHIFT], - repeat: true - }); - game.keybindings.register("core", "panUpLeft", { - name: "KEYBINDINGS.PanUpLeft", - uneditable: [ - {key: "Numpad7"} - ], - onUp: (context) => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]), - onDown: (context) => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]), - reservedModifiers: [CONTROL, SHIFT], - repeat: true - }); - game.keybindings.register("core", "panUpRight", { - name: "KEYBINDINGS.PanUpRight", - uneditable: [ - {key: "Numpad9"} - ], - onUp: (context) => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]), - onDown: (context) => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]), - reservedModifiers: [CONTROL, SHIFT], - repeat: true - }); - game.keybindings.register("core", "panDownLeft", { - name: "KEYBINDINGS.PanDownLeft", - uneditable: [ - {key: "Numpad1"} - ], - onUp: (context) => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]), - onDown: (context) => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]), - reservedModifiers: [CONTROL, SHIFT], - repeat: true - }); - game.keybindings.register("core", "panDownRight", { - name: "KEYBINDINGS.PanDownRight", - uneditable: [ - {key: "Numpad3"} - ], - onUp: (context) => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]), - onDown: (context) => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]), - reservedModifiers: [CONTROL, SHIFT], - repeat: true - }); - game.keybindings.register("core", "zoomIn", { - name: "KEYBINDINGS.ZoomIn", - uneditable: [ - {key: "NumpadAdd"} - ], - editable: [ - {key: "PageUp"} - ], - onDown: (context) => { ClientKeybindings._onZoom(context, ClientKeybindings.ZOOM_DIRECTIONS.IN); }, - repeat: true - }); - game.keybindings.register("core", "zoomOut", { - name: "KEYBINDINGS.ZoomOut", - uneditable: [ - {key: "NumpadSubtract"} - ], - editable: [ - {key: "PageDown"} - ], - onDown: (context) => { ClientKeybindings._onZoom(context, ClientKeybindings.ZOOM_DIRECTIONS.OUT); }, - repeat: true - }); - for ( const number of [1,2,3,4,5,6,7,8,9,0] ) { - game.keybindings.register("core", "executeMacro" + number, { - name: game.i18n.format("KEYBINDINGS.ExecuteMacro", { number }), - editable: [{key: `Digit${number}`}], - onDown: (context) => ClientKeybindings._onMacroExecute(context, number), - precedence: CONST.KEYBINDING_PRECEDENCE.DEFERRED - }); - } - for ( const page of [1,2,3,4,5] ) { - game.keybindings.register("core", "swapMacroPage" + page, { - name: game.i18n.format("KEYBINDINGS.SwapMacroPage", { page }), - editable: [{key: `Digit${page}`, modifiers: [ALT]}], - onDown: (context) => ClientKeybindings._onMacroPageSwap(context, page), - precedence: CONST.KEYBINDING_PRECEDENCE.DEFERRED - }); - } - game.keybindings.register("core", "pushToTalk", { - name: "KEYBINDINGS.PTTKey", - editable: [{key: "Backquote"}], - onDown: game.webrtc._onPTTStart.bind(game.webrtc), - onUp: game.webrtc._onPTTEnd.bind(game.webrtc), - precedence: CONST.KEYBINDING_PRECEDENCE.PRIORITY, - repeat: false - }); - game.keybindings.register("core", "focusChat", { - name: "KEYBINDINGS.FocusChat", - editable: [{key: "KeyC", modifiers: [SHIFT]}], - onDown: ClientKeybindings._onFocusChat, - precedence: CONST.KEYBINDING_PRECEDENCE.PRIORITY, - repeat: false - }); - } - - /* -------------------------------------------- */ - - /** - * Handle Select all action - * @param {KeyboardEvent} event The originating keyboard event - * @param {KeyboardEventContext} context The context data of the event - * @private - */ - static _onSelectAllObjects(event, context) { - if ( !canvas.ready) return false; - canvas.activeLayer.controlAll(); - return true; - } - - /* -------------------------------------------- */ - - /** - * Handle Cycle View actions - * @param {KeyboardEventContext} context The context data of the event - * @private - */ - static _onCycleView(context) { - if ( !canvas.ready ) return false; - - // Attempt to cycle tokens, otherwise re-center the canvas - if ( canvas.tokens.active ) { - let cycled = canvas.tokens.cycleTokens(!context.isShift, false); - if ( !cycled ) canvas.recenter(); - } - return true; - } - - /* -------------------------------------------- */ - - /** - * Handle Dismiss actions - * @param {KeyboardEventContext} context The context data of the event - * @private - */ - static _onDismiss(context) { - - // Save fog of war if there are pending changes - if ( canvas.ready ) canvas.fog.commit(); - - // Case 1 - dismiss an open context menu - if (ui.context && ui.context.menu.length) { - ui.context.close(); - return true; - } - - // Case 2 - dismiss an open Tour - if (Tour.tourInProgress) { - Tour.activeTour.exit(); - return true; - } - - // Case 3 - close open UI windows - if (Object.keys(ui.windows).length) { - Object.values(ui.windows).forEach(app => app.close()); - return true; - } - - // Case 4 (GM) - release controlled objects (if not in a preview) - if (game.user.isGM && canvas.activeLayer && canvas.activeLayer.controlled.length) { - if ( !canvas.activeLayer.preview?.children.length ) canvas.activeLayer.releaseAll(); - return true; - } - - // Case 5 - toggle the main menu - ui.menu.toggle(); - // Save the fog immediately rather than waiting for the 3s debounced save as part of commitFog. - if ( canvas.ready ) canvas.fog.save(); - return true; - } - - /* -------------------------------------------- */ - - /** - * Open Character sheet for current token or controlled actor - * @param {KeyboardEventContext} context The context data of the event - * @private - */ - static _onToggleCharacterSheet(event, context) { - return game.toggleCharacterSheet(); - } - - /* -------------------------------------------- */ - - /** - * Handle action to target the currently hovered token. - * @param {KeyboardEventContext} context The context data of the event - * @private - */ - static _onTarget(context) { - if ( !canvas.ready ) return false; - const layer = canvas.activeLayer; - if ( !(layer instanceof TokenLayer) ) return false; - const hovered = layer.hover; - if ( !hovered ) return false; - hovered.setTarget(!hovered.isTargeted, {releaseOthers: !context.isShift}); - return true; - } - - /* -------------------------------------------- */ - - /** - * Handle DELETE Keypress Events - * @param {KeyboardEvent} event The originating keyboard event - * @param {KeyboardEventContext} context The context data of the event - * @private - */ - static _onDelete(event, context) { - // Remove hotbar Macro - if ( ui.hotbar._hover ) { - game.user.assignHotbarMacro(null, ui.hotbar._hover); - return true; - } - - // Delete placeables from Canvas layer - else if ( canvas.ready && ( canvas.activeLayer instanceof PlaceablesLayer ) ) { - canvas.activeLayer._onDeleteKey(event); - return true; - } - } - - /* -------------------------------------------- */ - - /** - * Handle keyboard movement once a small delay has elapsed to allow for multiple simultaneous key-presses. - * @param {KeyboardEventContext} context The context data of the event - * @param {InteractionLayer} layer The active InteractionLayer instance - * @private - */ - _handleMovement(context, layer) { - if ( !this.moveKeys.size ) return; - - // Get controlled objects - let objects = layer.placeables.filter(o => o.controlled); - if ( objects.length === 0 ) return; - - // Define movement offsets and get moved directions - const directions = this.moveKeys; - let dx = 0; - let dy = 0; - - // Assign movement offsets - if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT) ) dx -= 1; - else if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT) ) dx += 1; - if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.UP) ) dy -= 1; - else if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN) ) dy += 1; - - // Perform the shift or rotation - layer.moveMany({dx, dy, rotate: context.isShift}); - } - - /* -------------------------------------------- */ - - /** - * Handle panning the canvas using CTRL + directional keys - */ - _handleCanvasPan() { - - // Determine movement offsets - let dx = 0; - let dy = 0; - if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT)) dx -= 1; - if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.UP)) dy -= 1; - if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT)) dx += 1; - if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN)) dy += 1; - - // Clear the pending set - this.moveKeys.clear(); - - // Pan by the grid size - const s = canvas.dimensions.size; - return canvas.animatePan({ - x: canvas.stage.pivot.x + (dx * s), - y: canvas.stage.pivot.y + (dy * s), - duration: 100 - }); - } - - /* -------------------------------------------- */ - - /** - * Handle Measured Ruler Movement Action - * @param {KeyboardEventContext} context The context data of the event - * @private - */ - static _onMeasuredRulerMovement(context) { - - // Move along a measured ruler - const ruler = canvas.controls?.ruler; - if ( canvas.ready && ruler.active ) { - ruler.moveToken(); - return true; - } - } - - /* -------------------------------------------- */ - - /** - * Handle Pause Action - * @param {KeyboardEventContext} context The context data of the event - * @private - */ - static _onPause(context) { - game.togglePause(undefined, true); - return true; - } - - /* -------------------------------------------- */ - - /** - * Handle Highlight action - * @param {KeyboardEventContext} context The context data of the event - * @private - */ - static _onHighlight(context) { - if ( !canvas.ready ) return false; - canvas.highlightObjects(!context.up); - return true; - } - - /* -------------------------------------------- */ - - /** - * Handle Pan action - * @param {KeyboardEventContext} context The context data of the event - * @param {string[]} movementDirections The Directions being panned in - * @private - */ - _onPan(context, movementDirections) { - - // Case 1: Check for Tour - if ( (Tour.tourInProgress) && (!context.repeat) && (!context.up) ) { - Tour.onMovementAction(movementDirections); - return true; - } - - // Case 2: Check for Canvas - if ( !canvas.ready ) return false; - - // Remove Keys on Up - if ( context.up ) { - for ( let d of movementDirections ) { - this.moveKeys.delete(d); - } - return true; - } - - // Keep track of when we last moved - const now = Date.now(); - const delta = now - this._moveTime; - - // Track the movement set - for ( let d of movementDirections ) { - this.moveKeys.add(d); - } - - // Handle canvas pan using CTRL - if ( context.isControl ) { - if ( ["KeyW", "KeyA", "KeyS", "KeyD"].includes(context.key) ) return false; - this._handleCanvasPan(); - return true; - } - - // Delay 50ms before shifting tokens in order to capture diagonal movements - const layer = canvas.activeLayer; - if ( (layer === canvas.tokens) || (layer === canvas.tiles) ) { - if ( delta < 100 ) return true; // Throttle keyboard movement once per 100ms - setTimeout(() => this._handleMovement(context, layer), 50); - } - this._moveTime = now; - return true; - } - - /* -------------------------------------------- */ - - /** - * Handle Macro executions - * @param {KeyboardEventContext} context The context data of the event - * @private - */ - static _onMacroExecute(context, number) { - const slot = ui.hotbar.macros.find(m => m.key === number); - if ( slot.macro ) { - slot.macro.execute(); - return true; - } - return false; - } - - /* -------------------------------------------- */ - - /** - * Handle Macro page swaps - * @param {KeyboardEventContext} context The context data of the event - * @private - */ - static _onMacroPageSwap(context, page) { - ui.hotbar.changePage(page); - return true; - } - - /* -------------------------------------------- */ - - /** - * Handle action to copy data to clipboard - * @param {KeyboardEventContext} context The context data of the event - * @private - */ - static _onCopy(context) { - // Case 1 - attempt a copy operation on the PlaceablesLayer - if (window.getSelection().toString() !== "") return false; - if ( !canvas.ready ) return false; - let layer = canvas.activeLayer; - if ( layer instanceof PlaceablesLayer ) layer.copyObjects(); - return true; - } - - /* -------------------------------------------- */ - - /** - * Handle Paste action - * @param {KeyboardEventContext} context The context data of the event - * @private - */ - static _onPaste(context ) { - if ( !canvas.ready ) return false; - let layer = canvas.activeLayer; - if ( (layer instanceof PlaceablesLayer) && layer._copy.length ) { - const pos = canvas.app.renderer.plugins.interaction.mouse.getLocalPosition(canvas.stage); - layer.pasteObjects(pos, {hidden: context.isAlt, snap: !context.isShift}); - return true; - } - } - - /* -------------------------------------------- */ - - /** - * Handle Undo action - * @param {KeyboardEventContext} context The context data of the event - * @private - */ - static _onUndo(context) { - if ( !canvas.ready ) return false; - - // Undo history for a PlaceablesLayer - const layer = canvas.activeLayer; - if ( !(layer instanceof PlaceablesLayer) ) return false; - if ( layer.history.length ) { - layer.undoHistory(); - return true; - } - } - - /* -------------------------------------------- */ - - /** - * Handle presses to keyboard zoom keys - * @param {KeyboardEventContext} context The context data of the event - * @param {ClientKeybindings.ZOOM_DIRECTIONS} zoomDirection The direction to zoom - * @private - */ - static _onZoom(context, zoomDirection ) { - if ( !canvas.ready ) return false; - const delta = zoomDirection === ClientKeybindings.ZOOM_DIRECTIONS.IN ? 1.05 : 0.95; - canvas.animatePan({scale: delta * canvas.stage.scale.x, duration: 100}); - return true; - } - - /* -------------------------------------------- */ - - /** - * Bring the chat window into view and focus the input - * @param {KeyboardEventContext} context The context data of the event - * @returns {boolean} - * @private - */ - static _onFocusChat(context) { - const sidebar = ui.sidebar._element[0]; - ui.sidebar.activateTab(ui.chat.tabName); - - // If the sidebar is collapsed and the chat popover is not visible, open it - if ( sidebar.classList.contains("collapsed") && !ui.chat._popout ) { - const popout = ui.chat.createPopout(); - popout._render(true).then(() => { - popout.element.find("#chat-message").focus(); - }); - } - else { - ui.chat.element.find("#chat-message").focus(); - } - return true; - } -} - -/** - * A set of helpers and management functions for dealing with user input from keyboard events. - * {@link https://keycode.info/} - */ -class KeyboardManager { - constructor() { - this._reset(); - window.addEventListener("keydown", event => this._handleKeyboardEvent(event, false)); - window.addEventListener("keyup", event => this._handleKeyboardEvent(event, true)); - window.addEventListener("visibilitychange", this._reset.bind(this)); - window.addEventListener("compositionend", this._onCompositionEnd.bind(this)); - window.addEventListener("focusin", this._onFocusIn.bind(this)); - } - - /* -------------------------------------------- */ - - /** - * The set of key codes which are currently depressed (down) - * @type {Set} - */ - downKeys = new Set(); - - /* -------------------------------------------- */ - - /** - * The set of movement keys which were recently pressed - * @type {Set} - */ - moveKeys = new Set(); - - /* -------------------------------------------- */ - - /** - * Allowed modifier keys - * @enum {string} - */ - static MODIFIER_KEYS = { - CONTROL: "Control", - SHIFT: "Shift", - ALT: "Alt" - }; - - /* -------------------------------------------- */ - - /** - * Track which KeyboardEvent#code presses associate with each modifier - * @enum {string[]} - */ - static MODIFIER_CODES = { - [this.MODIFIER_KEYS.ALT]: ["AltLeft", "AltRight"], - [this.MODIFIER_KEYS.CONTROL]: ["ControlLeft", "ControlRight", "MetaLeft", "MetaRight", "Meta", "OsLeft", "OsRight"], - [this.MODIFIER_KEYS.SHIFT]: ["ShiftLeft", "ShiftRight"] - }; - - /* -------------------------------------------- */ - - /** - * Key codes which are "protected" and should not be used because they are reserved for browser-level actions. - * @type {string[]} - */ - static PROTECTED_KEYS = ["F5", "F11", "F12", "PrintScreen", "ScrollLock", "NumLock", "CapsLock"]; - - /* -------------------------------------------- */ - - /** - * The OS-specific string display for what their Command key is - * @type {string} - */ - static CONTROL_KEY_STRING = navigator.appVersion.includes("Mac") ? "⌘" : "Control"; - - /* -------------------------------------------- */ - - /** - * An special mapping of how special KeyboardEvent#code values should map to displayed strings or symbols. - * Values in this configuration object override any other display formatting rules which may be applied. - * @type {Object} - */ - static KEYCODE_DISPLAY_MAPPING = (() => { - const isMac = navigator.appVersion.includes("Mac"); - return { - ArrowLeft: isMac ? "←" : "🡸", - ArrowRight: isMac ? "→" : "🡺", - ArrowUp: isMac ? "↑" : "🡹", - ArrowDown: isMac ? "↓" : "🡻", - Backquote: "`", - Backslash: "\\", - BracketLeft: "[", - BracketRight: "]", - Comma: ",", - Control: this.CONTROL_KEY_STRING, - Equal: "=", - Meta: isMac ? "⌘" : "⊞", - MetaLeft: isMac ? "⌘" : "⊞", - MetaRight: isMac ? "⌘" : "⊞", - OsLeft: isMac ? "⌘" : "⊞", - OsRight: isMac ? "⌘" : "⊞", - Minus: "-", - NumpadAdd: "Numpad+", - NumpadSubtract: "Numpad-", - Period: ".", - Quote: "'", - Semicolon: ";", - Slash: "/" - }; - })(); - - /* -------------------------------------------- */ - - /** - * Test whether a Form Element currently has focus - * @returns {boolean} - */ - get hasFocus() { - // Pulled from https://www.w3schools.com/html/html_form_elements.asp - const formElements = ["input", "select", "textarea", "option", "button", "[contenteditable]"]; - const selector = formElements.map(el => `${el}:focus`).join(", "); - return document.querySelectorAll(selector).length > 0; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Emulates a key being pressed, triggering the Keyboard event workflow. - * @param {boolean} up If True, emulates the `keyup` Event. Else, the `keydown` event - * @param {string} code The KeyboardEvent#code which is being pressed - * @param {object} [options] Additional options to configure behavior. - * @param {boolean} [options.altKey=false] Emulate the ALT modifier as pressed - * @param {boolean} [options.ctrlKey=false] Emulate the CONTROL modifier as pressed - * @param {boolean} [options.shiftKey=false] Emulate the SHIFT modifier as pressed - * @param {boolean} [options.repeat=false] Emulate this as a repeat event - * @param {boolean} [options.force=false] Force the event to be handled. - * @returns {KeyboardEventContext} - */ - static emulateKeypress(up, code, {altKey=false, ctrlKey=false, shiftKey=false, repeat=false, force=false}={}) { - const event = new KeyboardEvent(`key${up ? "up" : "down"}`, {code, altKey, ctrlKey, shiftKey, repeat}); - const context = this.getKeyboardEventContext(event, up); - game.keyboard._processKeyboardContext(context, {force}); - game.keyboard.downKeys.delete(context.key); - return context; - } - - /* -------------------------------------------- */ - - /** - * Format a KeyboardEvent#code into a displayed string. - * @param {string} code The input code - * @returns {string} The displayed string for this code - */ - static getKeycodeDisplayString(code) { - if ( code in this.KEYCODE_DISPLAY_MAPPING ) return this.KEYCODE_DISPLAY_MAPPING[code]; - if ( code.startsWith("Digit") ) return code.replace("Digit", ""); - if ( code.startsWith("Key") ) return code.replace("Key", ""); - return code; - } - - /* -------------------------------------------- */ - - /** - * Get a standardized keyboard context for a given event. - * Every individual keypress is uniquely identified using the KeyboardEvent#code property. - * A list of possible key codes is documented here: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values - * - * @param {KeyboardEvent} event The originating keypress event - * @param {boolean} up A flag for whether the key is down or up - * @return {KeyboardEventContext} The standardized context of the event - */ - static getKeyboardEventContext(event, up=false) { - let context = { - event: event, - key: event.code, - isShift: event.shiftKey, - isControl: event.ctrlKey || event.metaKey, - isAlt: event.altKey, - hasModifier: event.shiftKey || event.ctrlKey || event.metaKey || event.altKey, - modifiers: [], - up: up, - repeat: event.repeat - }; - if ( context.isShift ) context.modifiers.push(this.MODIFIER_KEYS.SHIFT); - if ( context.isControl ) context.modifiers.push(this.MODIFIER_KEYS.CONTROL); - if ( context.isAlt ) context.modifiers.push(this.MODIFIER_KEYS.ALT); - return context; - } - - /* -------------------------------------------- */ - - /** - * Report whether a modifier in KeyboardManager.MODIFIER_KEYS is currently actively depressed. - * @param {string} modifier A modifier in MODIFIER_KEYS - * @returns {boolean} Is this modifier key currently down (active)? - */ - isModifierActive(modifier) { - return this.constructor.MODIFIER_CODES[modifier].some(k => this.downKeys.has(k)); - } - - /* -------------------------------------------- */ - - /** - * Converts a Keyboard Context event into a string representation, such as "C" or "Control+C" - * @param {KeyboardEventContext} context The standardized context of the event - * @param {boolean} includeModifiers If True, includes modifiers in the string representation - * @return {string} - * @private - */ - static _getContextDisplayString(context, includeModifiers = true) { - const parts = [this.getKeycodeDisplayString(context.key)]; - if ( includeModifiers && context.hasModifier ) { - if ( context.isShift && context.event.key !== "Shift" ) parts.unshift(this.MODIFIER_KEYS.SHIFT); - if ( context.isControl && context.event.key !== "Control" ) parts.unshift(this.MODIFIER_KEYS.CONTROL); - if ( context.isAlt && context.event.key !== "Alt" ) parts.unshift(this.MODIFIER_KEYS.ALT); - } - return parts.join("+"); - } - - /* ----------------------------------------- */ - - /** - * Given a standardized pressed key, find all matching registered Keybind Actions. - * @param {KeyboardEventContext} context A standardized keyboard event context - * @return {KeybindingAction[]} The matched Keybind Actions. May be empty. - * @internal - */ - static _getMatchingActions(context) { - let possibleMatches = game.keybindings.activeKeys.get(context.key) ?? []; - if ( CONFIG.debug.keybindings ) console.dir(possibleMatches); - return possibleMatches.filter(action => KeyboardManager._testContext(action, context)); - } - - /* -------------------------------------------- */ - - /** - * Test whether a keypress context matches the registration for a keybinding action - * @param {KeybindingAction} action The keybinding action - * @param {KeyboardEventContext} context The keyboard event context - * @returns {boolean} Does the context match the action requirements? - * @private - */ - static _testContext(action, context) { - if ( context.repeat && !action.repeat ) return false; - if ( action.restricted && !game.user.isGM ) return false; - - // If the context includes no modifiers, we match if the binding has none - if ( !context.hasModifier ) return action.requiredModifiers.length === 0; - - // Test that modifiers match expectation - const modifiers = this.MODIFIER_KEYS; - const activeModifiers = { - [modifiers.CONTROL]: context.isControl, - [modifiers.SHIFT]: context.isShift, - [modifiers.ALT]: context.isAlt - }; - for (let [k, v] of Object.entries(activeModifiers)) { - - // Ignore exact matches to a modifier key - if ( this.MODIFIER_CODES[k].includes(context.key) ) continue; - - // Verify that required modifiers are present - if ( action.requiredModifiers.includes(k) ) { - if ( !v ) return false; - } - - // No unsupported modifiers can be present for a "down" event - else if ( !context.up && !action.optionalModifiers.includes(k) && v ) return false; - } - return true; - } - - /* -------------------------------------------- */ - - /** - * Given a registered Keybinding Action, executes the action with a given event and context - * - * @param {KeybindingAction} keybind The registered Keybinding action to execute - * @param {KeyboardEventContext} context The gathered context of the event - * @return {boolean} Returns true if the keybind was consumed - * @private - */ - static _executeKeybind(keybind, context) { - if ( CONFIG.debug.keybindings ) console.log("Executing " + game.i18n.localize(keybind.name)); - context.action = keybind.action; - let consumed = false; - if ( context.up && keybind.onUp ) consumed = keybind.onUp(context); - else if ( !context.up && keybind.onDown ) consumed = keybind.onDown(context); - return consumed; - } - - /* -------------------------------------------- */ - - /** - * Processes a keyboard event context, checking it against registered keybinding actions - * @param {KeyboardEventContext} context The keyboard event context - * @param {object} [options] Additional options to configure behavior. - * @param {boolean} [options.force=false] Force the event to be handled. - * @protected - */ - _processKeyboardContext(context, {force=false}={}) { - - // Track the current set of pressed keys - if ( context.up ) this.downKeys.delete(context.key); - else this.downKeys.add(context.key); - - // If an input field has focus, don't process Keybinding Actions - if ( this.hasFocus && !force ) return; - - // Open debugging group - if ( CONFIG.debug.keybindings ) { - console.group(`[${context.up ? 'UP' : 'DOWN'}] Checking for keybinds that respond to ${context.modifiers}+${context.key}`); - console.dir(context); - } - - // Check against registered Keybindings - const actions = KeyboardManager._getMatchingActions(context); - if (actions.length === 0) { - if ( CONFIG.debug.keybindings ) { - console.log("No matching keybinds"); - console.groupEnd(); - } - return; - } - - // Execute matching Keybinding Actions to see if any consume the event - let handled; - for ( const action of actions ) { - handled = KeyboardManager._executeKeybind(action, context); - if ( handled ) break; - } - - // Cancel event since we handled it - if ( handled && context.event ) { - if ( CONFIG.debug.keybindings ) console.log("Event was consumed"); - context.event?.preventDefault(); - context.event?.stopPropagation(); - } - if ( CONFIG.debug.keybindings ) console.groupEnd(); - } - - /* -------------------------------------------- */ - - /** - * Reset tracking for which keys are in the down and released states - * @private - */ - _reset() { - this.downKeys = new Set(); - this.moveKeys = new Set(); - } - - /* -------------------------------------------- */ - - /** - * Emulate a key-up event for any currently down keys. When emulating, we go backwards such that combinations such as - * "CONTROL + S" emulate the "S" first in order to capture modifiers. - * @param {object} [options] Options to configure behavior. - * @param {boolean} [options.force=true] Force the keyup events to be handled. - */ - releaseKeys({force=true}={}) { - const reverseKeys = Array.from(this.downKeys).reverse(); - for ( const key of reverseKeys ) { - this.constructor.emulateKeypress(true, key, { - force, - ctrlKey: this.isModifierActive(this.constructor.MODIFIER_KEYS.CONTROL), - shiftKey: this.isModifierActive(this.constructor.MODIFIER_KEYS.SHIFT), - altKey: this.isModifierActive(this.constructor.MODIFIER_KEYS.ALT) - }); - } - } - - /* -------------------------------------------- */ - /* Event Listeners and Handlers */ - /* -------------------------------------------- */ - - /** - * Handle a key press into the down position - * @param {KeyboardEvent} event The originating keyboard event - * @param {boolean} up A flag for whether the key is down or up - * @private - */ - _handleKeyboardEvent(event, up) { - if ( event.isComposing ) return; // Ignore IME composition - if ( !event.key && !event.code ) return; // Some browsers fire keyup and keydown events when autocompleting values. - let context = KeyboardManager.getKeyboardEventContext(event, up); - this._processKeyboardContext(context); - } - - /* -------------------------------------------- */ - - /** - * Input events do not fire with isComposing = false at the end of a composition event in Chrome - * See: https://github.com/w3c/uievents/issues/202 - * @param {CompositionEvent} event - */ - _onCompositionEnd(event) { - return this._handleKeyboardEvent(event, false); - } - - /* -------------------------------------------- */ - - /** - * Release any down keys when focusing a form element. - * @param {FocusEvent} event The focus event. - * @protected - */ - _onFocusIn(event) { - const formElements = [ - HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLOptionElement, HTMLButtonElement - ]; - if ( event.target.isContentEditable || formElements.some(cls => event.target instanceof cls) ) this.releaseKeys(); - } -} - -/** - * Management class for Mouse events - */ -class MouseManager { - constructor() { - this._wheelTime = 0; - window.addEventListener("wheel", this._onWheel.bind(this), {passive: false}); - } - - /** - * Specify a rate limit for mouse wheel to gate repeated scrolling. - * This is especially important for continuous scrolling mice which emit hundreds of events per second. - * This designates a minimum number of milliseconds which must pass before another wheel event is handled - * @type {number} - */ - static MOUSE_WHEEL_RATE_LIMIT = 50; - - /* -------------------------------------------- */ - - /** - * Master mouse-wheel event handler - * @param {WheelEvent} event The mouse wheel event - * @private - */ - _onWheel(event) { - - // Prevent zooming the entire browser window - if ( event.ctrlKey ) event.preventDefault(); - - // Interpret shift+scroll as vertical scroll - let dy = event.delta = event.deltaY; - if ( event.shiftKey && (dy === 0) ) { - dy = event.delta = event.deltaX; - } - if ( dy === 0 ) return; - - // Take no actions if the canvas is not hovered - if ( !canvas.ready ) return; - const hover = document.elementFromPoint(event.clientX, event.clientY); - if ( !hover || (hover.id !== "board") ) return; - event.preventDefault(); - - // Identify scroll modifiers - const isCtrl = event.ctrlKey || event.metaKey; - const isShift = event.shiftKey; - const layer = canvas.activeLayer; - - // Case 1 - rotate placeable objects - if ( layer?.options?.rotatableObjects && (isCtrl || isShift) ) { - const hasTarget = layer.options?.controllableObjects ? layer.controlled.length : !!layer.hover; - if ( hasTarget ) { - const t = Date.now(); - if ( (t - this._wheelTime) < this.constructor.MOUSE_WHEEL_RATE_LIMIT ) return; - this._wheelTime = t; - return layer._onMouseWheel(event); - } - } - - // Case 2 - zoom the canvas - canvas._onMouseWheel(event); - } -} - -/** - * Responsible for managing the New User Experience workflows. - */ -class NewUserExperience { - constructor() { - Hooks.on("renderChatMessage", this._activateListeners.bind(this)); - } - - /* -------------------------------------------- */ - - /** - * Initialize the new user experience. - * Currently, this generates some chat messages with hints for getting started if we detect this is a new world. - */ - initialize() { - // If there are no documents, we can reasonably assume this is a new World. - const isNewWorld = !(game.actors.size + game.scenes.size + game.items.size + game.journal.size); - - if ( !isNewWorld ) return; - this._createInitialChatMessages(); - // noinspection JSIgnoredPromiseFromCall - this._showNewWorldTour(); - } - - /* -------------------------------------------- */ - - /** - * Show chat tips for first launch. - * @private - */ - _createInitialChatMessages() { - if ( game.settings.get("core", "nue.shownTips") ) return; - - // Get GM's - const gms = ChatMessage.getWhisperRecipients("GM"); - - // Build Chat Messages - const content = [` -

${game.i18n.localize("NUE.FirstLaunchHeader")}

-

${game.i18n.localize("NUE.FirstLaunchBody")}

-

${game.i18n.localize("NUE.FirstLaunchKB")}

-
${game.i18n.localize("NUE.FirstLaunchHint")}
- `, ` -

${game.i18n.localize("NUE.FirstLaunchInvite")}

-

${game.i18n.localize("NUE.FirstLaunchInviteBody")}

-

${game.i18n.localize("NUE.FirstLaunchTroubleshooting")}

-
${game.i18n.localize("NUE.FirstLaunchHint")}
- `]; - const chatData = content.map(c => { - return { - whisper: gms, - speaker: {alias: game.i18n.localize("Foundry Virtual Tabletop")}, - flags: {core: {nue: true, canPopout: true}}, - content: c - }; - }); - ChatMessage.implementation.createDocuments(chatData); - - // Store flag indicating this was shown - game.settings.set("core", "nue.shownTips", true); - } - - /* -------------------------------------------- */ - - /** - * Create a default scene for the new world. - * @private - */ - async _createDefaultScene() { - if ( !game.user.isGM ) return; - const filePath = foundry.utils.getRoute("/nue/defaultscene/scene.json"); - const response = await fetchWithTimeout(filePath, { - method: "GET", - }); - const json = await response.json(); - const scene = await Scene.create(json); - await scene.activate(); - canvas.animatePan({scale: 0.7, duration: 100}); - } - - /* -------------------------------------------- */ - - /** - * Automatically show uncompleted Tours related to new worlds. - * @private - */ - async _showNewWorldTour() { - const tour = game.tours.get("core.welcome"); - if ( tour?.status === Tour.STATUS.UNSTARTED ) { - await this._createDefaultScene(); - tour.start(); - } - } - - /* -------------------------------------------- */ - - /** - * Add event listeners to the chat card links. - * @param {ChatMessage} msg The ChatMessage being rendered. - * @param {jQuery} html The HTML content of the message. - * @private - */ - _activateListeners(msg, html) { - if ( !msg.getFlag("core", "nue") ) return; - html.find(".nue-tab").click(this._onTabLink.bind(this)); - html.find(".nue-action").click(this._onActionLink.bind(this)); - } - - /* -------------------------------------------- */ - - /** - * Perform some special action triggered by clicking on a link in a NUE chat card. - * @param {TriggeredEvent} event The click event. - * @private - */ - _onActionLink(event) { - event.preventDefault(); - const action = event.currentTarget.dataset.action; - switch ( action ) { - case "invite": return new InvitationLinks().render(true); - } - } - - /* -------------------------------------------- */ - - /** - * Switch to the appropriate tab when a user clicks on a link in the chat message. - * @param {TriggeredEvent} event The click event. - * @private - */ - _onTabLink(event) { - event.preventDefault(); - const tab = event.currentTarget.dataset.tab; - ui.sidebar.activateTab(tab); - } -} - -/** - * A client-side mixin used for all Package types. - * @param {typeof BasePackage} BasePackage The parent BasePackage class being mixed - * @returns {typeof ClientPackage} A BasePackage subclass mixed with ClientPackage features - * @category - Mixins - */ -function ClientPackageMixin(BasePackage) { - class ClientPackage extends BasePackage { - /** - * Associate package availability with certain labels for client-side display. - * @returns {{[unavailable]: string, [incompatible]: string}} - */ - getAvailabilityLabels() { - const ac = CONST.PACKAGE_AVAILABILITY_CODES; - switch (this.availability) { - case ac.REQUIRES_SYSTEM: - return {unavailable: game.i18n.localize("SETUP.RequireSystem")}; - case ac.REQUIRES_DEPENDENCY: - return {unavailable: game.i18n.localize("SETUP.RequireDep")}; - case ac.REQUIRES_CORE_DOWNGRADE: - return {unavailable: game.i18n.localize("SETUP.RequireCoreDowngrade")}; - case ac.REQUIRES_CORE_UPGRADE_STABLE: - return {unavailable: game.i18n.localize("SETUP.RequireCoreUpgrade")}; - case ac.REQUIRES_CORE_UPGRADE_UNSTABLE: - return {incompatible: game.i18n.localize("SETUP.RequireCoreUnstable")}; - case ac.REQUIRES_UPDATE: - let v = this.compatibility.verified; - if ( this.type === "world" ) v ??= game.systems.get(this.system)?.compatibility?.verified; - if ( !v ) return {incompatible: game.i18n.format("SETUP.CompatibilityRiskUnknown")}; - if ( (this.type === "world") && !foundry.utils.isNewerVersion(game.release.generation, v) ) return {}; - return {incompatible: game.i18n.format("SETUP.CompatibilityRiskWithVersion", {version: v})}; - case ac.UNKNOWN: - return {incompatible: game.i18n.localize("SETUP.CompatibilityUnknown")}; - default: - return {}; - } - } - - /* ----------------------------------------- */ - - /** - * When a package has been installed, add it to the local game data. - */ - install() { - const collection = this.constructor.collection; - game.data[collection].push(this.toObject()); - game[collection].set(this.id, this); - } - - /* ----------------------------------------- */ - - /** - * When a package has been uninstalled, remove it from the local game data. - */ - uninstall() { - const collection = this.constructor.collection; - game.data[collection].findSplice(p => p.id === this.id); - game[collection].delete(this.id); - } - - /* -------------------------------------------- */ - - /** - * Writes the Package migration back to disk. Meant for developers to be able to commit an updated manifest. - * @param {boolean} v9Compatible If true, v9 required fields such as name will be retained - * @returns {Promise} - * - * @example Use a multi-track release workflow that has a v10-only track and want to commit to /v10/manifest.json - * ```js - * game.modules.get("1000-fish").migrateManifest() - * ``` - * @example You use a single-track release workflow and want to commit to /latest/manifest.json - * ```js - * game.modules.get("1000-fish").migrateManifest({v9Compatible: true}) - * ``` - */ - async migrateManifest({v9Compatible = false}={}) { - if ( game.view !== "setup" ) { - throw new Error("You may only migrate package manifests from the /setup view"); - } - const response = await ui.setup._post({ - action: "migratePackageManifest", - type: this.type, - id: this.id, - v9Compatible - }); - if ( v9Compatible ) { - ui.notifications.info(`Wrote migrated package manifest to "${response.path}" with minimum-viable V9 - compatibility. You may now commit the changes to your main branch, such as /latest/manifest.json.`); - } - else { - ui.notifications.info(`Wrote migrated package manifest to "${response.path}" in a V10-only format. You may - now commit the changes to a branch that does not get read for updates by V9, such as /v10/manifest.json.`); - } - ui.notifications.warn("If your Package code is both V9 and V10 compatible, you should leave your existing V9" - + " fields intact instead of overwriting entirely with this new file."); - } - - /* -------------------------------------------- */ - - /** - * Retrieve the latest Package manifest from a provided remote location. - * @param {string} manifest A remote manifest URL to load - * @param {object} options Additional options which affect package construction - * @param {boolean} [options.strict=true] Whether to construct the remote package strictly - * @returns {Promise} A Promise which resolves to a constructed ServerPackage instance - * @throws An error if the retrieved manifest data is invalid - */ - static async fromRemoteManifest(manifest, {strict=false}={}) { - try { - const data = await ui.setup._post({action: "getPackageFromRemoteManifest", type: this.type, manifest}); - return new this(data, {installed: false, strict: strict}); - } - catch(e) { - return null; - } - } - } - return ClientPackage; -} - -/** - * @extends foundry.packages.BaseModule - * @mixes ClientPackageMixin - * @category - Packages - */ -class Module extends ClientPackageMixin(foundry.packages.BaseModule) { - constructor(data, options = {}) { - const {active} = data; - super(data, options); - - /** - * Is this package currently active? - * @type {boolean} - */ - Object.defineProperty(this, "active", {value: active, writable: false}); - } -} - -/** - * @extends foundry.packages.BaseSystem - * @mixes ClientPackageMixin - * @category - Packages - */ -class System extends ClientPackageMixin(foundry.packages.BaseSystem) {} - -/** - * @extends foundry.packages.BaseWorld - * @mixes ClientPackageMixin - * @category - Packages - */ -class World extends ClientPackageMixin(foundry.packages.BaseWorld) {} - -const PACKAGE_TYPES = { - world: World, - system: System, - module: Module -}; - -/** - * A class responsible for managing defined game settings or settings menus. - * Each setting is a string key/value pair belonging to a certain namespace and a certain store scope. - * - * When Foundry Virtual Tabletop is initialized, a singleton instance of this class is constructed within the global - * Game object as as game.settings. - * - * @see {@link Game#settings} - * @see {@link Settings} - * @see {@link SettingsConfig} - */ -class ClientSettings { - constructor(worldSettings) { - - /** - * A object of registered game settings for this scope - * @type {Map} - */ - this.settings = new Map(); - - /** - * Registered settings menus which trigger secondary applications - * @type {Map} - */ - this.menus = new Map(); - - /** - * The storage interfaces used for persisting settings - * Each storage interface shares the same API as window.localStorage - */ - this.storage = new Map([ - ["client", window.localStorage], - ["world", new WorldSettings(worldSettings)] - ]); - } - - /** - * The types of settings which should be constructed as a function call rather than as a class constructor. - * @private - */ - static PRIMITIVE_TYPES = [String, Number, Boolean, Array, Symbol, BigInt]; - - /* -------------------------------------------- */ - - /** - * Return a singleton instance of the Game Settings Configuration app - * @returns {SettingsConfig} - */ - get sheet() { - if ( !this._sheet ) this._sheet = new SettingsConfig(); - return this._sheet; - } - - /* -------------------------------------------- */ - - /** - * Register a new game setting under this setting scope - * - * @param {string} namespace The namespace under which the setting is registered - * @param {string} key The key name for the setting under the namespace - * @param {SettingConfig} data Configuration for setting data - * - * @example Register a client setting - * ```js - * game.settings.register("myModule", "myClientSetting", { - * name: "Register a Module Setting with Choices", - * hint: "A description of the registered setting and its behavior.", - * scope: "client", // This specifies a client-stored setting - * config: true, // This specifies that the setting appears in the configuration view - * requiresReload: true // This will prompt the user to reload the application for the setting to take effect. - * type: String, - * choices: { // If choices are defined, the resulting setting will be a select menu - * "a": "Option A", - * "b": "Option B" - * }, - * default: "a", // The default value for the setting - * onChange: value => { // A callback function which triggers when the setting is changed - * console.log(value) - * } - * }); - * ``` - * - * @example Register a world setting - * ```js - * game.settings.register("myModule", "myWorldSetting", { - * name: "Register a Module Setting with a Range slider", - * hint: "A description of the registered setting and its behavior.", - * scope: "world", // This specifies a world-level setting - * config: true, // This specifies that the setting appears in the configuration view - * requiresReload: true // This will prompt the GM to have all clients reload the application for the setting to - * // take effect. - * type: Number, - * range: { // If range is specified, the resulting setting will be a range slider - * min: 0, - * max: 100, - * step: 10 - * } - * default: 50, // The default value for the setting - * onChange: value => { // A callback function which triggers when the setting is changed - * console.log(value) - * } - * }); - * ``` - */ - register(namespace, key, data) { - if ( !namespace || !key ) throw new Error("You must specify both namespace and key portions of the setting"); - data.key = key; - data.namespace = namespace; - data.scope = ["client", "world"].includes(data.scope) ? data.scope : "client"; - if ( data.type && !(data.type instanceof Function) ) { - throw new Error(`Setting ${key} type must be a constructable object or callable function`); - } - this.settings.set(`${namespace}.${key}`, data); - } - - /* -------------------------------------------- */ - - /** - * Register a new sub-settings menu - * - * @param {string} namespace The namespace under which the menu is registered - * @param {string} key The key name for the setting under the namespace - * @param {SettingSubmenuConfig} data Configuration for setting data - * - * @example Define a settings submenu which handles advanced configuration needs - * ```js - * game.settings.registerMenu("myModule", "mySettingsMenu", { - * name: "My Settings Submenu", - * label: "Settings Menu Label", // The text label used in the button - * hint: "A description of what will occur in the submenu dialog.", - * icon: "fas fa-bars", // A Font Awesome icon used in the submenu button - * type: MySubmenuApplicationClass, // A FormApplication subclass which should be created - * restricted: true // Restrict this submenu to gamemaster only? - * }); - * ``` - */ - registerMenu(namespace, key, data) { - if ( !namespace || !key ) throw new Error("You must specify both namespace and key portions of the menu"); - data.key = `${namespace}.${key}`; - data.namespace = namespace; - if ( !data.type || !(data.type.prototype instanceof FormApplication) ) { - throw new Error("You must provide a menu type that is FormApplication instance or subclass"); - } - this.menus.set(data.key, data); - } - - /* -------------------------------------------- */ - - /** - * Get the value of a game setting for a certain namespace and setting key - * - * @param {string} namespace The namespace under which the setting is registered - * @param {string} key The setting key to retrieve - * - * @example Retrieve the current setting value - * ```js - * game.settings.get("myModule", "myClientSetting"); - * ``` - */ - get(namespace, key) { - if ( !namespace || !key ) throw new Error("You must specify both namespace and key portions of the setting"); - key = `${namespace}.${key}`; - if ( !this.settings.has(key) ) throw new Error("This is not a registered game setting"); - - // Retrieve the setting configuration and its storage backend - const config = this.settings.get(key); - const storage = this.storage.get(config.scope); - - // Get the Setting instance - let setting; - switch ( config.scope ) { - case "client": - const value = storage.getItem(key) ?? config.default; - setting = new Setting({key, value}); - break; - case "world": - setting = storage.getSetting(key); - if ( !setting ) { - setting = new Setting({key, value: config.default}); - } - } - - // Null values are allowed - if ( setting.value === null ) return setting.value; - - // Cast the value to a requested type - if ( config.type && !(setting.value instanceof config.type) ) { - if ( this.constructor.PRIMITIVE_TYPES.includes(config.type) ) { - if ( (config.type === String) && (typeof setting.value !== "string") ) return JSON.stringify(setting.value); - setting.value = config.type(setting.value); - } - else if ( foundry.utils.isSubclass(config.type, foundry.abstract.DataModel) ) { - setting.value = config.type.fromSource(setting.value); - } else { - const isConstructed = config.type?.prototype?.constructor === config.type; - setting.value = isConstructed ? new config.type(setting.value) : config.type(setting.value); - } - } - return setting.value; - } - - /* -------------------------------------------- */ - - /** - * Set the value of a game setting for a certain namespace and setting key - * - * @param {string} namespace The namespace under which the setting is registered - * @param {string} key The setting key to retrieve - * @param {*} value The data to assign to the setting key - * @param {object} [options] Additional options passed to the server when updating world-scope settings - * - * @example Update the current value of a setting - * ```js - * game.settings.set("myModule", "myClientSetting", "b"); - * ``` - */ - async set(namespace, key, value, options={}) { - if ( !namespace || !key ) throw new Error("You must specify both namespace and key portions of the setting"); - key = `${namespace}.${key}`; - if ( !this.settings.has(key) ) throw new Error("This is not a registered game setting"); - - // Obtain the setting data and serialize the value - const setting = this.settings.get(key); - if ( value === undefined ) value = setting.default; - const json = JSON.stringify(value); - if ( foundry.utils.isSubclass(setting.type, foundry.abstract.DataModel) ) { - value = setting.type.fromSource(value, {strict: true}); - } - - // Submit World setting changes - switch (setting.scope) { - case "world": - if ( !game.ready ) throw new Error("You may not assign the value of a world-level Setting before the Game is ready."); - const doc = this.storage.get("world").getSetting(key); - if ( doc ) await doc.update({value: json}, options); - else await Setting.create({key, value: json}, options); - break; - case "client": - const storage = this.storage.get(setting.scope); - storage.setItem(key, json); - if ( setting.onChange instanceof Function ) setting.onChange(value); - break; - } - return value; - } -} - - -class SocketInterface { - - /** - * Standardize the way that socket messages are dispatched and their results are handled - * @param {string} eventName The socket event name being handled - * @param {SocketRequest} request Data provided to the Socket event - * @returns {Promise} A Promise which resolves to the SocketResponse - */ - static dispatch(eventName, request) { - return new Promise((resolve, reject) => { - game.socket.emit(eventName, request, response => { - if ( response.error ) { - const err = this._handleError(response.error); - reject(err); - } - else resolve(response); - }); - }); - } - - /* -------------------------------------------- */ - - /** - * Handle an error returned from the database, displaying it on screen and in the console - * @param {Error} err The provided Error message - * @private - */ - static _handleError(err) { - let error = err instanceof Error ? err : new Error(err.message); - if ( err.stack ) error.stack = err.stack; - if ( ui.notifications ) ui.notifications.error(error.message); - return error; - } -} - -/** - * A collection of functions related to sorting objects within a parent container. - */ -class SortingHelpers { - - /** - * Given a source object to sort, a target to sort relative to, and an Array of siblings in the container: - * Determine the updated sort keys for the source object, or all siblings if a reindex is required. - * Return an Array of updates to perform, it is up to the caller to dispatch these updates. - * Each update is structured as: - * { - * target: object, - * update: {sortKey: sortValue} - * } - * - * @param {object} source The source object being sorted - * @param {object} [options] Options which modify the sort behavior - * @param {object|null} [options.target] The target object relative which to sort - * @param {object[]} [options.siblings] The Array of siblings which the source should be sorted within - * @param {string} [options.sortKey=sort] The property name within the source object which defines the sort key - * @param {boolean} [options.sortBefore] Explicitly sort before (true) or sort after( false). - * If undefined the sort order will be automatically determined. - * @returns {object[]} An Array of updates for the caller of the helper function to perform - */ - static performIntegerSort(source, {target=null, siblings=[], sortKey="sort", sortBefore}={}) { - - // Automatically determine the sorting direction - if ( sortBefore === undefined ) { - sortBefore = (source[sortKey] || 0) > (target?.[sortKey] || 0); - } - - // Ensure the siblings are sorted - siblings.sort((a, b) => a[sortKey] - b[sortKey]); - - // Determine the index target for the sort - let defaultIdx = sortBefore ? siblings.length : 0; - let idx = target ? siblings.findIndex(sib => sib === target) : defaultIdx; - - // Determine the indices to sort between - let min, max; - if ( sortBefore ) [min, max] = this._sortBefore(siblings, idx, sortKey); - else [min, max] = this._sortAfter(siblings, idx, sortKey); - - // Easiest case - no siblings - if ( siblings.length === 0 ) { - return [{ - target: source, - update: {[sortKey]: CONST.SORT_INTEGER_DENSITY} - }]; - } - - // No minimum - sort to beginning - else if ( Number.isFinite(max) && (min === null) ) { - return [{ - target: source, - update: {[sortKey]: max - CONST.SORT_INTEGER_DENSITY} - }]; - } - - // No maximum - sort to end - else if ( Number.isFinite(min) && (max === null) ) { - return [{ - target: source, - update: {[sortKey]: min + CONST.SORT_INTEGER_DENSITY} - }]; - } - - // Sort between two - else if ( Number.isFinite(min) && Number.isFinite(max) && (Math.abs(max - min) > 1) ) { - return [{ - target: source, - update: {[sortKey]: Math.round(0.5 * (min + max))} - }]; - } - - // Reindex all siblings - else { - siblings.splice(idx, 0, source); - return siblings.map((sib, i) => { - return { - target: sib, - update: {[sortKey]: (i+1) * CONST.SORT_INTEGER_DENSITY} - } - }); - } - } - - /* -------------------------------------------- */ - - /** - * Given an ordered Array of siblings and a target position, return the [min,max] indices to sort before the target - * @private - */ - static _sortBefore(siblings, idx, sortKey) { - let max = siblings[idx] ? siblings[idx][sortKey] : null; - let min = siblings[idx-1] ? siblings[idx-1][sortKey] : null; - return [min, max]; - } - - /* -------------------------------------------- */ - - /** - * Given an ordered Array of siblings and a target position, return the [min,max] indices to sort after the target - * @private - */ - static _sortAfter(siblings, idx, sortKey) { - let min = siblings[idx] ? siblings[idx][sortKey] : null; - let max = siblings[idx+1] ? siblings[idx+1][sortKey] : null; - return [min, max]; - } - - /* -------------------------------------------- */ -} - -/** - * A singleton class {@link game#time} which keeps the official Server and World time stamps. - * Uses a basic implementation of https://www.geeksforgeeks.org/cristians-algorithm/ for synchronization. - */ -class GameTime { - constructor(socket) { - - /** - * The most recently synchronized timestamps retrieved from the server. - * @type {{clientTime: number, serverTime: number, worldTime: number}} - */ - this._time = {}; - - /** - * The average one-way latency across the most recent 5 trips - * @type {number} - */ - this._dt = 0; - - /** - * The most recent five synchronization durations - * @type {number[]} - */ - this._dts = []; - - // Perform an initial sync - if ( socket ) this.sync(socket); - } - - /** - * The amount of time to delay before re-syncing the official server time. - * @type {number} - */ - static SYNC_INTERVAL_MS = 1000 * 60 * 5; - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * The current server time based on the last synchronization point and the approximated one-way latency. - * @type {number} - */ - get serverTime() { - const t1 = Date.now(); - const dt = t1 - this._time.clientTime; - if ( dt > GameTime.SYNC_INTERVAL_MS ) this.sync(); - return this._time.serverTime + dt; - } - - /* -------------------------------------------- */ - - /** - * The current World time based on the last recorded value of the core.time setting - * @type {number} - */ - get worldTime() { - return this._time.worldTime; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Advance the game time by a certain number of seconds - * @param {number} seconds The number of seconds to advance (or rewind if negative) by - * @param {object} [options] Additional options passed to game.settings.set - * @returns {Promise} The new game time - */ - async advance(seconds, options) { - return game.settings.set("core", "time", this.worldTime + seconds, options); - } - - /* -------------------------------------------- */ - - /** - * Synchronize the local client game time with the official time kept by the server - * @param {Socket} socket The connected server Socket instance - * @returns {Promise} - */ - async sync(socket) { - socket = socket ?? game.socket; - - // Get the official time from the server - const t0 = Date.now(); - const time = await new Promise(resolve => socket.emit("time", resolve)); - const t1 = Date.now(); - - // Adjust for trip duration - if ( this._dts.length >= 5 ) this._dts.unshift(); - this._dts.push(t1 - t0); - - // Re-compute the average one-way duration - this._dt = Math.round(this._dts.reduce((total, t) => total + t, 0) / (this._dts.length * 2)); - - // Adjust the server time and return the adjusted time - time.clientTime = t1 - this._dt; - this._time = time; - console.log(`${vtt} | Synchronized official game time in ${this._dt}ms`); - return this; - } - - /* -------------------------------------------- */ - /* Event Handlers and Callbacks */ - /* -------------------------------------------- */ - - /** - * Handle follow-up actions when the official World time is changed - * @param {number} worldTime The new canonical World time. - * @param {object} options Options passed from the requesting client where the change was made - * @param {string} userId The ID of the User who advanced the time - */ - onUpdateWorldTime(worldTime, options, userId) { - const dt = worldTime - this._time.worldTime; - this._time.worldTime = worldTime; - Hooks.callAll("updateWorldTime", worldTime, dt, options, userId); - if ( CONFIG.debug.time ) console.log(`The world time advanced by ${dt} seconds, and is now ${worldTime}.`); - } -} - -/** - * A singleton Tooltip Manager class responsible for rendering and positioning a dynamic tooltip element which is - * accessible as `game.tooltip`. - * - * @see {@link Game.tooltip} - * - * @example API Usage - * ```js - * game.tooltip.activate(htmlElement, {text: "Some tooltip text", direction: "UP"}); - * game.tooltip.deactivate(); - * ``` - * - * @example HTML Usage - * ```html - * I have a tooltip - *
    - *
  1. One
  2. - *
  3. Two
  4. - *
  5. Three
  6. - *
- * ``` - */ -class TooltipManager { - - /** - * A cached reference to the global tooltip element - * @type {HTMLElement} - */ - tooltip = document.getElementById("tooltip"); - - /** - * A reference to the HTML element which is currently tool-tipped, if any. - * @type {HTMLElement|null} - */ - element = null; - - /** - * An amount of margin which is used to offset tooltips from their anchored element. - * @type {number} - */ - static TOOLTIP_MARGIN_PX = 5; - - /** - * The number of milliseconds delay which activates a tooltip on a "long hover". - * @type {number} - */ - static TOOLTIP_ACTIVATION_MS = 500; - - /** - * The directions in which a tooltip can extend, relative to its tool-tipped element. - * @enum {string} - */ - static TOOLTIP_DIRECTIONS = { - UP: "UP", - DOWN: "DOWN", - LEFT: "LEFT", - RIGHT: "RIGHT", - CENTER: "CENTER" - }; - - /** - * Is the tooltip currently active? - * @type {boolean} - */ - #active = false; - - /** - * A reference to a window timeout function when an element is activated. - * @private - */ - #activationTimeout; - - /** - * A reference to a window timeout function when an element is deactivated. - * @private - */ - #deactivationTimeout; - - /** - * An element which is pending tooltip activation if hover is sustained - * @type {HTMLElement|null} - */ - #pending; - - /* -------------------------------------------- */ - - /** - * Activate interactivity by listening for hover events on HTML elements which have a data-tooltip defined. - */ - activateEventListeners() { - document.body.addEventListener("pointerenter", this.#onActivate.bind(this), true); - document.body.addEventListener("pointerleave", this.#onDeactivate.bind(this), true); - } - - /* -------------------------------------------- */ - - /** - * Handle hover events which activate a tooltipped element. - * @param {PointerEvent} event The initiating pointerenter event - */ - #onActivate(event) { - if ( Tour.tourInProgress ) return; // Don't activate tooltips during a tour - const element = event.target; - if ( !element.dataset.tooltip ) { - // Check if the element has moved out from underneath the cursor and pointerenter has fired on a non-child of the - // tooltipped element. - if ( this.#active && !this.element.contains(element) ) this.#startDeactivation(); - return; - } - - // Don't activate tooltips if the element contains an active context menu - if ( element.matches("#context-menu") || element.querySelector("#context-menu") ) return; - - // If the tooltip is currently active, we can move it to a new element immediately - if ( this.#active ) this.activate(element); - else this.#clearDeactivation(); - - // Otherwise, delay activation to determine user intent - this.#pending = element; - this.#activationTimeout = window.setTimeout(() => { - this.activate(element); - }, this.constructor.TOOLTIP_ACTIVATION_MS); - } - - /* -------------------------------------------- */ - - /** - * Handle hover events which deactivate a tooltipped element. - * @param {PointerEvent} event The initiating pointerleave event - */ - #onDeactivate(event) { - if ( event.target !== (this.element ?? this.#pending) ) return; - this.#startDeactivation(); - } - - /* -------------------------------------------- */ - - /** - * Start the deactivation process. - */ - #startDeactivation() { - // Clear any existing activation workflow - window.clearTimeout(this.#activationTimeout); - this.#pending = this.#activationTimeout = null; - - // Delay deactivation to confirm whether some new element is now pending - window.clearTimeout(this.#deactivationTimeout); - this.#deactivationTimeout = window.setTimeout(() => { - if ( !this.#pending ) this.deactivate(); - }, this.constructor.TOOLTIP_ACTIVATION_MS); - } - - /* -------------------------------------------- */ - - /** - * Clear any existing deactivation workflow. - */ - #clearDeactivation() { - window.clearTimeout(this.#deactivationTimeout); - this.#pending = this.#deactivationTimeout = null; - } - - /* -------------------------------------------- */ - - /** - * Activate the tooltip for a hovered HTML element which defines a tooltip localization key. - * @param {HTMLElement} element The HTML element being hovered. - * @param {object} [options={}] Additional options which can override tooltip behavior. - * @param {string} [options.text] Explicit tooltip text to display. If this is not provided the tooltip text is - * acquired from the elements data-tooltip attribute. This text will be - * automatically localized - * @param {TooltipManager.TOOLTIP_DIRECTIONS} [options.direction] An explicit tooltip expansion direction. If this - * is not provided the direction is acquired from the data-tooltip-direction - * attribute of the element or one of its parents. - * @param {string} [options.cssClass] An optional CSS class to apply to the activated tooltip. - */ - activate(element, {text, direction, cssClass}={}) { - // Check if the element still exists in the DOM. - if ( !document.body.contains(element) ) return; - this.#clearDeactivation(); - - // Mark the element as active - this.#active = true; - this.element = element; - element.setAttribute("aria-describedby", "tooltip"); - this.tooltip.innerHTML = text || game.i18n.localize(element.dataset.tooltip); - - // Activate display of the tooltip - this.tooltip.removeAttribute("class"); - this.tooltip.classList.add("active"); - if ( cssClass ) this.tooltip.classList.add(cssClass); - - // Set tooltip position - direction = direction || element.closest("[data-tooltip-direction]")?.dataset.tooltipDirection; - if ( !direction ) direction = this._determineDirection(); - this._setAnchor(direction); - } - - /* -------------------------------------------- */ - - /** - * Deactivate the tooltip from a previously hovered HTML element. - * @private - */ - deactivate() { - - // Deactivate display of the tooltip - this.#active = false; - this.tooltip.classList.remove("active"); - - // Update the tooltipped element - if ( !this.element ) return; - this.element.removeAttribute("aria-describedby"); - this.element = null; - } - - /* -------------------------------------------- */ - - /** - * Clear any pending activation workflow. - * @internal - */ - clearPending() { - window.clearTimeout(this.#activationTimeout); - this.#pending = this.#activationTimeout = null; - } - - /* -------------------------------------------- */ - - /** - * If an explicit tooltip expansion direction was not specified, figure out a valid direction based on the bounds - * of the target element and the screen. - * @private - */ - _determineDirection() { - const pos = this.element.getBoundingClientRect(); - const dirs = this.constructor.TOOLTIP_DIRECTIONS; - return dirs[pos.y + this.tooltip.offsetHeight > window.innerHeight ? "UP" : "DOWN"]; - } - - /* -------------------------------------------- */ - - /** - * Set tooltip position relative to an HTML element using an explicitly provided data-tooltip-direction. - * @param {TooltipManager.TOOLTIP_DIRECTIONS} direction The tooltip expansion direction specified by the element - * or a parent element. - * @private - */ - _setAnchor(direction) { - const directions = this.constructor.TOOLTIP_DIRECTIONS; - const pad = this.constructor.TOOLTIP_MARGIN_PX; - const pos = this.element.getBoundingClientRect(); - let style = {}; - switch ( direction ) { - case directions.DOWN: - style.textAlign = "center"; - style.left = pos.left - (this.tooltip.offsetWidth / 2) + (pos.width / 2); - style.top = pos.bottom + pad; - break; - case directions.LEFT: - style.textAlign = "left"; - style.right = window.innerWidth - pos.left + pad; - style.top = pos.top + (pos.height / 2) - (this.tooltip.offsetHeight / 2); - break; - case directions.RIGHT: - style.textAlign = "right"; - style.left = pos.right + pad; - style.top = pos.top + (pos.height / 2) - (this.tooltip.offsetHeight / 2); - break; - case directions.UP: - style.textAlign = "center"; - style.left = pos.left - (this.tooltip.offsetWidth / 2) + (pos.width / 2); - style.bottom = window.innerHeight - pos.top + pad; - break; - case directions.CENTER: - style.textAlign = "center"; - style.left = pos.left - (this.tooltip.offsetWidth / 2) + (pos.width / 2); - style.top = pos.top + (pos.height / 2) - (this.tooltip.offsetHeight / 2); - break; - } - return this._setStyle(style); - } - - /* -------------------------------------------- */ - - /** - * Apply inline styling rules to the tooltip for positioning and text alignment. - * @param {object} [position={}] An object of positioning data, supporting top, right, bottom, left, and textAlign - * @private - */ - _setStyle(position={}) { - const pad = this.constructor.TOOLTIP_MARGIN_PX; - position = Object.assign({top: null, right: null, bottom: null, left: null, textAlign: "left"}, position); - const style = this.tooltip.style; - - // Left or Right - const maxW = window.innerWidth - this.tooltip.offsetWidth; - if ( position.left ) position.left = Math.clamped(position.left, pad, maxW - pad); - if ( position.right ) position.right = Math.clamped(position.right, pad, maxW - pad); - - // Top or Bottom - const maxH = window.innerHeight - this.tooltip.offsetHeight; - if ( position.top ) position.top = Math.clamped(position.top, pad, maxH - pad); - if ( position.bottom ) position.bottom = Math.clamped(position.bottom, pad, maxH - pad); - - // Assign styles - for ( let k of ["top", "right", "bottom", "left"] ) { - const v = position[k]; - style[k] = v ? `${v}px` : null; - } - style.textAlign = position.textAlign; - } -} - -/** - * @typedef {Object} TourStep A step in a Tour - * @property {string} id A machine-friendly id of the Tour Step - * @property {string} title The title of the step, displayed in the tooltip header - * @property {string} content Raw HTML content displayed during the step - * @property {string} [selector] A DOM selector which denotes an element to highlight during this step. - * If omitted, the step is displayed in the center of the screen. - * @property {TooltipManager.TOOLTIP_DIRECTIONS} [tooltipDirection] How the tooltip for the step should be displayed - * relative to the target element. If omitted, the best direction will be attempted to be auto-selected. - * @property {boolean} [restricted] Whether the Step is restricted to the GM only. Defaults to false. - */ - -/** - * @typedef {Object} TourConfig Tour configuration data - * @property {string} namespace The namespace this Tour belongs to. Typically, the name of the package which - * implements the tour should be used - * @property {string} id A machine-friendly id of the Tour, must be unique within the provided namespace - * @property {string} title A human-readable name for this Tour. Localized. - * @property {TourStep[]} steps The list of Tour Steps - * @property {string} [description] A human-readable description of this Tour. Localized. - * @property {object} [localization] A map of localizations for the Tour that should be merged into the default localizations - * @property {boolean} [restricted] Whether the Tour is restricted to the GM only. Defaults to false. - * @property {boolean} [display] Whether the Tour should be displayed in the Manage Tours UI. Defaults to false. - * @property {boolean} [canBeResumed] Whether the Tour can be resumed or if it always needs to start from the beginning. Defaults to false. - * @property {string[]} [suggestedNextTours] A list of namespaced Tours that might be suggested to the user when this Tour is completed. - * The first non-completed Tour in the array will be recommended. - */ - -/** - * A Tour that shows a series of guided steps. - * @param {TourConfig} config The configuration of the Tour - * @tutorial tours - */ -class Tour { - constructor(config, {id, namespace}={}) { - this.config = foundry.utils.deepClone(config); - if ( this.config.localization ) foundry.utils.mergeObject(game.i18n._fallback, this.config.localization); - this.#id = id ?? config.id; - this.#namespace = namespace ?? config.namespace; - this.#stepIndex = this._loadProgress(); - } - - /** - * A singleton reference which tracks the currently active Tour. - * @type {Tour|null} - */ - static #activeTour = null; - - /** - * @enum {string} - */ - static STATUS = { - UNSTARTED: "unstarted", - IN_PROGRESS: "in-progress", - COMPLETED: "completed" - }; - - /** - * Indicates if a Tour is currently in progress. - * @returns {boolean} - */ - static get tourInProgress() { - return !!Tour.#activeTour; - } - - /** - * Returns the active Tour, if any - * @returns {Tour|null} - */ - static get activeTour() { - return Tour.#activeTour; - } - - /* -------------------------------------------- */ - - /** - * Handle a movement action to either progress or regress the Tour. - * @param @param {string[]} movementDirections The Directions being moved in - * @returns {boolean} - */ - static onMovementAction(movementDirections) { - if ( (movementDirections.includes(ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT)) - && (Tour.activeTour.hasNext) ) { - Tour.activeTour.next(); - return true; - } - else if ( (movementDirections.includes(ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT)) - && (Tour.activeTour.hasPrevious) ) { - Tour.activeTour.previous(); - return true; - } - } - - /** - * Configuration of the tour. This object is cloned to avoid mutating the original configuration. - * @type {TourConfig} - */ - config; - - /** - * The HTMLElement which is the focus of the current tour step. - * @type {HTMLElement} - */ - targetElement; - - /** - * The HTMLElement that fades out the rest of the screen - * @type {HTMLElement} - */ - fadeElement; - - /** - * The HTMLElement that blocks input while a Tour is active - */ - overlayElement; - - /** - * Padding around a Highlighted Element - * @type {number} - */ - static HIGHLIGHT_PADDING = 10; - - /** - * The unique identifier of the tour. - * @type {string} - */ - get id() { - return this.#id; - } - - set id(value) { - if ( this.#id ) throw new Error("The Tour has already been assigned an ID"); - this.#id = value; - } - - #id; - - /** - * The human-readable title for the tour. - * @type {string} - */ - get title() { - return game.i18n.localize(this.config.title); - } - - /** - * The human-readable description of the tour. - * @type {string} - */ - get description() { - return game.i18n.localize(this.config.description); - } - - /** - * The package namespace for the tour. - * @type {string} - */ - get namespace() { - return this.#namespace; - } - - set namespace(value) { - if ( this.#namespace ) throw new Error("The Tour has already been assigned a namespace"); - this.#namespace = value; - } - - #namespace; - - /** - * The key the Tour is stored under in game.tours, of the form `${namespace}.${id}` - * @returns {string} - */ - get key() { - return `${this.#namespace}.${this.#id}`; - } - - /** - * The configuration of tour steps - * @type {TourStep[]} - */ - get steps() { - return this.config.steps.filter(step => !step.restricted || game.user.isGM); - } - - /** - * Return the current Step, or null if the tour has not yet started. - * @type {TourStep|null} - */ - get currentStep() { - return this.steps[this.#stepIndex] ?? null; - } - - /** - * The index of the current step; -1 if the tour has not yet started, or null if the tour is finished. - * @type {number|null} - */ - get stepIndex() { - return this.#stepIndex; - } - - /** @private */ - #stepIndex = -1; - - /** - * Returns True if there is a next TourStep - * @type {boolean} - */ - get hasNext() { - return this.#stepIndex < this.steps.length - 1; - } - - /** - * Returns True if there is a previous TourStep - * @type {boolean} - */ - get hasPrevious() { - return this.#stepIndex > 0; - } - - /** - * Return whether this Tour is currently eligible to be started? - * This is useful for tours which can only be used in certain circumstances, like if the canvas is active. - * @type {boolean} - */ - get canStart() { - return true; - } - - /** - * The current status of the Tour - * @returns {STATUS} - */ - get status() { - if ( this.#stepIndex === -1 ) return Tour.STATUS.UNSTARTED; - else if (this.#stepIndex === this.steps.length) return Tour.STATUS.COMPLETED; - else return Tour.STATUS.IN_PROGRESS; - } - - /* -------------------------------------------- */ - /* Tour Methods */ - /* -------------------------------------------- */ - - /** - * Advance the tour to a completed state. - */ - async complete() { - return this.progress(this.steps.length); - } - - /* -------------------------------------------- */ - - /** - * Exit the tour at the current step. - */ - exit() { - if ( this.currentStep ) this._postStep(); - Tour.#activeTour = null; - } - - /* -------------------------------------------- */ - - /** - * Reset the Tour to an un-started state. - */ - async reset() { - return this.progress(-1); - } - - /* -------------------------------------------- */ - - /** - * Start the Tour at its current step, or at the beginning if the tour has not yet been started. - */ - async start() { - game.tooltip.clearPending(); - switch ( this.status ) { - case Tour.STATUS.IN_PROGRESS: - return this.progress((this.config.canBeResumed && this.hasPrevious) ? this.#stepIndex : 0); - case Tour.STATUS.UNSTARTED: - case Tour.STATUS.COMPLETED: - return this.progress(0); - } - } - - /* -------------------------------------------- */ - - /** - * Progress the Tour to the next step. - */ - async next() { - if ( this.status === Tour.STATUS.COMPLETED ) { - throw new Error(`Tour ${this.id} has already been completed`); - } - if ( !this.hasNext ) return this.complete(); - return this.progress(this.#stepIndex + 1); - } - - /* -------------------------------------------- */ - - /** - * Rewind the Tour to the previous step. - */ - async previous() { - if ( !this.hasPrevious ) return; - return this.progress(this.#stepIndex - 1); - } - - /* -------------------------------------------- */ - - /** - * Progresses to a given Step - * @param {number} stepIndex The step to progress to - */ - async progress(stepIndex) { - - // Ensure we are provided a valid tour step - if ( !Number.between(stepIndex, -1, this.steps.length) ) { - throw new Error(`Step index ${stepIndex} is not valid for Tour ${this.id} with ${this.steps.length} steps.`); - } - - // Ensure that only one Tour is active at a given time - if ( Tour.#activeTour && (Tour.#activeTour !== this) ) { - if ( (stepIndex !== -1) && (stepIndex !== this.steps.length) ) throw new Error(`You cannot begin the ${this.title} Tour because the ` - + `${Tour.#activeTour.title} Tour is already in progress`); - else Tour.#activeTour = null; - } - else Tour.#activeTour = this; - - // Tear down the prior step - await this._postStep(); - console.debug(`Tour [${this.namespace}.${this.id}] | Completed step ${this.#stepIndex+1} of ${this.steps.length}`); - - // Change the step and save progress - this.#stepIndex = stepIndex; - this._saveProgress(); - - // If the TourManager is active, update the UI - const tourManager = Object.values(ui.windows).find(x => x instanceof ToursManagement); - if ( tourManager ) { - tourManager._cachedData = null; - tourManager._render(true); - } - - if ( this.status === Tour.STATUS.UNSTARTED ) return Tour.#activeTour = null; - if ( this.status === Tour.STATUS.COMPLETED ) { - Tour.#activeTour = null; - const suggestedTour = game.tours.get((this.config.suggestedNextTours || []).find(tourId => { - const tour = game.tours.get(tourId); - return tour && (tour.status !== Tour.STATUS.COMPLETED); - })); - - if ( !suggestedTour ) return; - return Dialog.confirm({ - title: game.i18n.localize("TOURS.SuggestedTitle"), - content: game.i18n.format("TOURS.SuggestedDescription", { currentTitle: this.title, nextTitle: suggestedTour.title }), - yes: () => suggestedTour.start(), - defaultYes: true - }); - } - - // Set up the next step - await this._preStep(); - - // Identify the target HTMLElement - this.targetElement = null; - const step = this.currentStep; - if ( step.selector ) { - this.targetElement = this._getTargetElement(step.selector); - if ( !this.targetElement ) console.warn(`Tour [${this.id}] target element "${step.selector}" was not found`); - } - - // Display the step - try { - await this._renderStep(); - } - catch(e) { - this.exit(); - throw e; - } - } - - /* -------------------------------------------- */ - - /** - * Query the DOM for the target element using the provided selector - * @param {string} selector A CSS selector - * @returns {Element|null} The target element, or null if not found - * @protected - */ - _getTargetElement(selector) { - return document.querySelector(selector); - } - - /* -------------------------------------------- */ - - /** - * Creates and returns a Tour by loading a JSON file - * @param {string} filepath The path to the JSON file - * @returns {Promise} - */ - static async fromJSON(filepath) { - const json = await foundry.utils.fetchJsonWithTimeout(foundry.utils.getRoute(filepath, {prefix: ROUTE_PREFIX})); - return new this(json); - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** - * Set-up operations performed before a step is shown. - * @abstract - * @protected - */ - async _preStep() {} - - /* -------------------------------------------- */ - - /** - * Clean-up operations performed after a step is completed. - * @abstract - * @protected - */ - async _postStep() { - if ( this.currentStep && !this.currentStep.selector ) this.targetElement?.remove(); - else game.tooltip.deactivate(); - if ( this.fadeElement ) { - this.fadeElement.remove(); - this.fadeElement = undefined; - } - if ( this.overlayElement ) this.overlayElement = this.overlayElement.remove(); - } - - /* -------------------------------------------- */ - - /** - * Renders the current Step of the Tour - * @protected - */ - async _renderStep() { - const step = this.currentStep; - const data = { - title: game.i18n.localize(step.title), - content: game.i18n.localize(step.content).split("\n"), - step: this.#stepIndex + 1, - totalSteps: this.steps.length, - hasNext: this.hasNext, - hasPrevious: this.hasPrevious - }; - const content = await renderTemplate("templates/apps/tour-step.html", data); - - if ( step.selector ) { - if ( !this.targetElement ) { - throw new Error(`The expected targetElement ${step.selector} does not exist`); - } - this.targetElement.scrollIntoView(); - game.tooltip.activate(this.targetElement, {text: content, cssClass: "tour", direction: step.tooltipDirection}); - } - else { - // Display a general mid-screen Step - const wrapper = document.createElement("aside"); - wrapper.innerHTML = content; - wrapper.classList.add("tour-center-step"); - wrapper.classList.add("tour"); - document.body.appendChild(wrapper); - this.targetElement = wrapper; - } - - // Fade out rest of screen - this.fadeElement = document.createElement("div"); - this.fadeElement.classList.add("tour-fadeout"); - const targetBoundingRect = this.targetElement.getBoundingClientRect(); - - this.fadeElement.style.width = `${targetBoundingRect.width + (step.selector ? Tour.HIGHLIGHT_PADDING : 0)}px`; - this.fadeElement.style.height = `${targetBoundingRect.height + (step.selector ? Tour.HIGHLIGHT_PADDING : 0)}px`; - this.fadeElement.style.top = `${targetBoundingRect.top - ((step.selector ? Tour.HIGHLIGHT_PADDING : 0) / 2)}px`; - this.fadeElement.style.left = `${targetBoundingRect.left - ((step.selector ? Tour.HIGHLIGHT_PADDING : 0) / 2)}px`; - document.body.appendChild(this.fadeElement); - - // Add Overlay to block input - this.overlayElement = document.createElement("div"); - this.overlayElement.classList.add("tour-overlay"); - document.body.appendChild(this.overlayElement); - - // Activate Listeners - const buttons = step.selector ? game.tooltip.tooltip.querySelectorAll(".step-button") - : this.targetElement.querySelectorAll(".step-button"); - for ( let button of buttons ) { - button.addEventListener("click", event => this._onButtonClick(event, buttons)); - } - } - - /* -------------------------------------------- */ - - /** - * Handle Tour Button clicks - * @param {Event} event A click event - * @param {HTMLElement[]} buttons The step buttons - * @private - */ - _onButtonClick(event, buttons) { - event.preventDefault(); - - // Disable all the buttons to prevent double-clicks - for ( let button of buttons ) { - button.classList.add("disabled"); - } - - // Handle action - const action = event.currentTarget.dataset.action; - switch ( action ) { - case "exit": return this.exit(); - case "previous": return this.previous(); - case "next": return this.next(); - default: throw new Error(`Unexpected Tour button action - ${action}`); - } - } - - /* -------------------------------------------- */ - - /** - * Saves the current progress of the Tour to a world setting - * @private - */ - _saveProgress() { - let progress = game.settings.get("core", "tourProgress"); - if ( !(this.namespace in progress) ) progress[this.namespace] = {}; - progress[this.namespace][this.id] = this.#stepIndex; - game.settings.set("core", "tourProgress", progress); - } - - /* -------------------------------------------- */ - - /** - * Returns the User's current progress of this Tour - * @returns {null|number} - * @private - */ - _loadProgress() { - let progress = game.settings.get("core", "tourProgress"); - return progress?.[this.namespace]?.[this.id] ?? -1; - } - - /* -------------------------------------------- */ - - /** - * Reloads the Tour's current step from the saved progress - * @internal - */ - _reloadProgress() { - this.#stepIndex = this._loadProgress(); - } -} - -/** - * A singleton Tour Collection class responsible for registering and activating Tours, accessible as game.tours - * @see {Game#tours} - * @extends Map - */ -class Tours extends foundry.utils.Collection { - - constructor() { - super(); - if ( game.tours ) throw new Error("You can only have one TourManager instance"); - } - - /* -------------------------------------------- */ - - /** - * Register a new Tour - * @param {string} namespace The namespace of the Tour - * @param {string} id The machine-readable id of the Tour - * @param {Tour} tour The constructed Tour - * @returns {void} - */ - register(namespace, id, tour) { - if ( !namespace || !id ) throw new Error("You must specify both the namespace and id portion of the Tour"); - if ( !(tour instanceof Tour) ) throw new Error("You must pass in a Tour instance"); - - // Set the namespace and id of the tour if not already set. - if ( id && !tour.id ) tour.id = id; - if ( namespace && !tour.namespace ) tour.namespace = namespace; - tour._reloadProgress(); - - // Register the Tour if it is not already registered, ensuring the key matches the config - if ( this.has(tour.key) ) throw new Error(`Tour "${key}" has already been registered`); - this.set(`${namespace}.${id}`, tour); - } - - /* -------------------------------------------- */ - - /** - * @inheritDoc - * @override - */ - set(key, tour) { - if ( key !== tour.key ) throw new Error(`The key "${key}" does not match what has been configured for the Tour`); - return super.set(key, tour); - } -} - - -/** - * Export data content to be saved to a local file - * @param {string} data Data content converted to a string - * @param {string} type The type of - * @param {string} filename The filename of the resulting download - */ -function saveDataToFile(data, type, filename) { - const blob = new Blob([data], {type: type}); - - // Create an element to trigger the download - let a = document.createElement('a'); - a.href = window.URL.createObjectURL(blob); - a.download = filename; - - // Dispatch a click event to the element - a.dispatchEvent(new MouseEvent("click", {bubbles: true, cancelable: true, view: window})); - setTimeout(() => window.URL.revokeObjectURL(a.href), 100); -} - - -/* -------------------------------------------- */ - - -/** - * Read text data from a user provided File object - * @param {File} file A File object - * @return {Promise.} A Promise which resolves to the loaded text data - */ -function readTextFromFile(file) { - const reader = new FileReader(); - return new Promise((resolve, reject) => { - reader.onload = ev => { - resolve(reader.result); - }; - reader.onerror = ev => { - reader.abort(); - reject(); - }; - reader.readAsText(file); - }); -} - -/* -------------------------------------------- */ - -/** - * Retrieve a Document by its Universally Unique Identifier (uuid). - * @param {string} uuid The uuid of the Document to retrieve. - * @param {ClientDocument} [relative] A document to resolve relative UUIDs against. - * @returns {Promise} Returns the Document if it could be found, otherwise null. - */ -async function fromUuid(uuid, relative) { - let {collection, documentId, embedded, doc} = _parseUuid(uuid, relative); - if ( collection instanceof CompendiumCollection ) doc = await collection.getDocument(documentId); - else doc = doc ?? collection?.get(documentId); - if ( embedded.length ) doc = _resolveEmbedded(doc, embedded); - return doc || null; -} - -/* -------------------------------------------- */ - -/** - * Retrieve a Document by its Universally Unique Identifier (uuid) synchronously. If the uuid resolves to a compendium - * document, that document's index entry will be returned instead. - * @param {string} uuid The uuid of the Document to retrieve. - * @param {ClientDocument} [relative] A document to resolve relative UUIDs against. - * @returns {Document|object|null} The Document or its index entry if it resides in a Compendium, otherwise null. - * @throws If the uuid resolves to a Document that cannot be retrieved synchronously. - */ -function fromUuidSync(uuid, relative) { - let {collection, documentId, embedded, doc} = _parseUuid(uuid, relative); - if ( (collection instanceof CompendiumCollection) && embedded.length ) { - throw new Error( - `fromUuidSync was invoked on UUID '${uuid}' which references an Embedded Document and cannot be retrieved ` - + "synchronously."); - } - - if ( collection instanceof CompendiumCollection ) { - doc = doc ?? collection.index.get(documentId); - if ( doc ) doc.pack = collection.collection; - } else { - doc = doc ?? collection?.get(documentId); - if ( embedded.length ) doc = _resolveEmbedded(doc, embedded); - } - return doc || null; -} - -/* -------------------------------------------- */ - -/** - * @typedef {object} ResolvedUUID - * @property {DocumentCollection} [collection] The parent collection. - * @property {string} [documentId] The parent document. - * @property {ClientDocument} [doc] An already-resolved document. - * @property {string[]} embedded Any remaining Embedded Document parts. - */ - -/** - * Parse a UUID into its constituent parts. - * @param {string} uuid The UUID to parse. - * @param {ClientDocument} [relative] A document to resolve relative UUIDs against. - * @returns {ResolvedUUID} Returns the Collection and the Document ID to resolve the parent document, as - * well as the remaining Embedded Document parts, if any. - * @private - */ -function _parseUuid(uuid, relative) { - if ( uuid.startsWith(".") && relative ) return _resolveRelativeUuid(uuid, relative); - let parts = uuid.split("."); - let collection; - let documentId; - - // Compendium Documents - if ( parts[0] === "Compendium" ) { - parts.shift(); - const [scope, packName, id] = parts.splice(0, 3); - collection = game.packs.get(`${scope}.${packName}`); - documentId = id; - } - - // World Documents - else { - const [documentName, id] = parts.splice(0, 2); - collection = CONFIG[documentName]?.collection.instance; - documentId = id; - } - - return {collection, documentId, embedded: parts}; -} - -/* -------------------------------------------- */ - -/** - * Resolve a series of embedded document UUID parts against a parent Document. - * @param {Document} parent The parent Document. - * @param {string[]} parts A series of Embedded Document UUID parts. - * @returns {Document} The resolved Embedded Document. - * @private - */ -function _resolveEmbedded(parent, parts) { - let doc = parent; - while ( doc && (parts.length > 1) ) { - const [embeddedName, embeddedId] = parts.splice(0, 2); - doc = doc.getEmbeddedDocument(embeddedName, embeddedId); - } - return doc; -} - -/* -------------------------------------------- */ - -/** - * Resolve a UUID relative to another document. - * The general-purpose algorithm for resolving relative UUIDs is as follows: - * 1. If the number of parts is odd, remove the first part and resolve it against the current document and update the - * current document. - * 2. If the number of parts is even, resolve embedded documents against the current document. - * @param {string} uuid The UUID to resolve. - * @param {ClientDocument} relative The document to resolve against. - * @returns {ResolvedUUID} - * @private - */ -function _resolveRelativeUuid(uuid, relative) { - uuid = uuid.substring(1); - const parts = uuid.split("."); - - // A child document. If we don't have a reference to an actual embedded collection, it will not be resolved in - // _resolveEmbedded. - if ( parts.length % 2 === 0 ) return {doc: relative, embedded: parts}; - - // A sibling document. - const documentId = parts.shift(); - const collection = (relative.compendium && !relative.isEmbedded) ? relative.compendium : relative.collection; - return {collection, documentId, embedded: parts}; -} - -/* -------------------------------------------- */ - -/** - * Return a reference to the Document class implementation which is configured for use. - * @param {string} documentName The canonical Document name, for example "Actor" - * @returns {typeof ClientDocument} The configured Document class implementation - */ -function getDocumentClass(documentName) { - return CONFIG[documentName]?.documentClass; -} - -/* -------------------------------------------- */ - -/** - * A helper class to provide common functionality for working with HTML5 video objects - * A singleton instance of this class is available as ``game.video`` - */ -class VideoHelper { - constructor() { - if ( game.video instanceof this.constructor ) { - throw new Error("You may not re-initialize the singleton VideoHelper. Use game.video instead."); - } - - /** - * A user gesture must be registered before video playback can begin. - * This Set records the video elements which await such a gesture. - * @type {Set} - */ - this.pending = new Set(); - - /** - * A mapping of base64 video thumbnail images - * @type {Map} - */ - this.thumbs = new Map(); - - /** - * A flag for whether video playback is currently locked by awaiting a user gesture - * @type {boolean} - */ - this.locked = true; - } - - /* -------------------------------------------- */ - - /** - * Store a Promise while the YouTube API is initializing. - * @type {Promise} - */ - #youTubeReady; - - /* -------------------------------------------- */ - - /** - * The YouTube URL regex. - * @type {RegExp} - */ - #youTubeRegex = /^https:\/\/(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=([^&]+)|(?:embed\/)?([^?]+))/; - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Return the HTML element which provides the source for a loaded texture. - * @param {PIXI.Sprite|SpriteMesh} mesh The rendered mesh - * @returns {HTMLImageElement|HTMLVideoElement|null} The source HTML element - */ - getSourceElement(mesh) { - if ( !mesh.texture.valid ) return null; - return mesh.texture.baseTexture.resource.source; - } - - /* -------------------------------------------- */ - - /** - * Get the video element source corresponding to a Sprite or SpriteMesh. - * @param {PIXI.Sprite|SpriteMesh|PIXI.Texture} object The PIXI source - * @returns {HTMLVideoElement|null} The source video element or null - */ - getVideoSource(object) { - const texture = object.texture || object; - if ( !texture.valid ) return null; - const source = texture.baseTexture.resource.source; - return source?.tagName === "VIDEO" ? source : null; - } - - /* -------------------------------------------- */ - - /** - * Clone a video texture so that it can be played independently of the original base texture. - * @param {HTMLVideoElement} source The video element source - * @returns {Promise} An unlinked PIXI.Texture which can be played independently - */ - async cloneTexture(source) { - const clone = source.cloneNode(); - await new Promise(resolve => clone.oncanplay = resolve); - return PIXI.Texture.from(clone, {resourceOptions: {autoPlay: false}}); - } - - /* -------------------------------------------- */ - - static hasVideoExtension(src) { - let rgx = new RegExp(`(\\.${Object.keys(CONST.VIDEO_FILE_EXTENSIONS).join("|\\.")})(\\?.*)?`, "i"); - return rgx.test(src); - } - - /* -------------------------------------------- */ - - /** - * Play a single video source - * If playback is not yet enabled, add the video to the pending queue - * @param {HTMLElement} video The VIDEO element to play - * @param {object} [options={}] Additional options for modifying video playback - * @param {boolean} [options.playing] Should the video be playing? Otherwise, it will be paused - * @param {boolean} [options.loop] Should the video loop? - * @param {number} [options.offset] A specific timestamp between 0 and the video duration to begin playback - * @param {number} [options.volume] Desired volume level of the video's audio channel (if any) - */ - async play(video, {playing=true, loop=true, offset, volume}={}) { - - // Video offset time and looping - video.loop = loop; - offset ??= video.currentTime; - - // Playback volume and muted state - if ( volume !== undefined ) { - video.volume = volume; - video.muted = video.volume === 0; - } - - // Pause playback - if ( !playing ) return video.pause(); - - // Wait for user gesture - if ( this.locked ) return this.pending.add([video, offset]); - - // Begin playback - video.currentTime = Math.clamped(offset, 0, video.duration); - return video.play(); - } - - /* -------------------------------------------- */ - - /** - * Stop a single video source - * @param {HTMLElement} video The VIDEO element to stop - */ - stop(video) { - video.pause(); - video.currentTime = 0; - } - - /* -------------------------------------------- */ - - /** - * Register an event listener to await the first mousemove gesture and begin playback once observed - * A user interaction must involve a mouse click or keypress. - * Listen for any of these events, and handle the first observed gesture. - */ - awaitFirstGesture() { - if ( !this.locked ) return; - const interactions = ["contextmenu", "auxclick", "mousedown", "mouseup", "keydown"]; - interactions.forEach(event => document.addEventListener(event, this._onFirstGesture.bind(this), {once: true})); - } - - /* -------------------------------------------- */ - - /** - * Handle the first observed user gesture - * We need a slight delay because unfortunately Chrome is stupid and doesn't always acknowledge the gesture fast enough. - * @param {Event} event The mouse-move event which enables playback - */ - _onFirstGesture(event) { - this.locked = false; - if ( !this.pending.size ) return; - console.log(`${vtt} | Activating pending video playback with user gesture.`); - for ( const [video, offset] of Array.from(this.pending) ) { - this.play(video, {offset, loop: video.loop}); - } - this.pending.clear(); - } - - /* -------------------------------------------- */ - - /** - * Create and cache a static thumbnail to use for the video. - * The thumbnail is cached using the video file path or URL. - * @param {string} src The source video URL - * @param {object} options Thumbnail creation options, including width and height - * @returns {Promise} The created and cached base64 thumbnail image, or a placeholder image if the canvas is - * disabled and no thumbnail can be generated. - */ - async createThumbnail(src, options) { - if ( game.settings.get("core", "noCanvas") ) return "icons/svg/video.svg"; - const t = await ImageHelper.createThumbnail(src, options); - this.thumbs.set(src, t.thumb); - return t.thumb; - } - - /* -------------------------------------------- */ - /* YouTube API */ - /* -------------------------------------------- */ - - /** - * Lazily-load the YouTube API and retrieve a Player instance for a given iframe. - * @param {string} id The iframe ID. - * @param {object} config A player config object. See {@link https://developers.google.com/youtube/iframe_api_reference} for reference. - * @returns {Promise} - */ - async getYouTubePlayer(id, config={}) { - this.#youTubeReady ??= this.#injectYouTubeAPI(); - await this.#youTubeReady; - return new Promise(resolve => new YT.Player(id, foundry.utils.mergeObject(config, { - events: { - onReady: event => resolve(event.target) - } - }))); - } - - /* -------------------------------------------- */ - - /** - * Retrieve a YouTube video ID from a URL. - * @param {string} url The URL. - * @returns {string} - */ - getYouTubeId(url) { - const [, id1, id2] = url?.match(this.#youTubeRegex) || []; - return id1 || id2 || ""; - } - - /* -------------------------------------------- */ - - /** - * Take a URL to a YouTube video and convert it into a URL suitable for embedding in a YouTube iframe. - * @param {string} url The URL to convert. - * @param {object} vars YouTube player parameters. - * @returns {string} The YouTube embed URL. - */ - getYouTubeEmbedURL(url, vars={}) { - const videoId = this.getYouTubeId(url); - if ( !videoId ) return ""; - const embed = new URL(`https://www.youtube.com/embed/${videoId}`); - embed.searchParams.append("enablejsapi", "1"); - Object.entries(vars).forEach(([k, v]) => embed.searchParams.append(k, v)); - // To loop a video with iframe parameters, we must additionally supply the playlist parameter that points to the - // same video: https://developers.google.com/youtube/player_parameters#Parameters - if ( vars.loop ) embed.searchParams.append("playlist", videoId); - return embed.href; - } - - /* -------------------------------------------- */ - - /** - * Test a URL to see if it points to a YouTube video. - * @param {string} url The URL to test. - * @returns {boolean} - */ - isYouTubeURL(url="") { - return this.#youTubeRegex.test(url); - } - - /* -------------------------------------------- */ - - /** - * Inject the YouTube API into the page. - * @returns {Promise} A Promise that resolves when the API has initialized. - */ - #injectYouTubeAPI() { - const script = document.createElement("script"); - script.src = "https://www.youtube.com/iframe_api"; - document.head.appendChild(script); - return new Promise(resolve => { - window.onYouTubeIframeAPIReady = () => { - delete window.onYouTubeIframeAPIReady; - resolve(); - }; - }); - } -} - -/** - * @typedef {Object} WorkerTask - * @property {number} [taskId] An incrementing task ID used to reference task progress - * @property {WorkerManager.WORKER_TASK_ACTIONS} action The task action being performed, from WorkerManager.WORKER_TASK_ACTIONS - * @property {function} [resolve] A Promise resolution handler - * @property {function} [reject] A Promise rejection handler - */ - -/** - * An asynchronous web Worker which can load user-defined functions and await execution using Promises. - * @param {string} name The worker name to be initialized - * @param {object} [options={}] Worker initialization options - * @param {boolean} [options.debug=false] Should the worker run in debug mode? - * @param {boolean} [options.loadPrimitives=false] Should the worker automatically load the primitives library? - */ -class AsyncWorker extends Worker { - constructor(name, {debug=false, loadPrimitives=false}={}) { - super(AsyncWorker.WORKER_HARNESS_JS); - this.name = name; - this.addEventListener("message", this._onMessage.bind(this)); - this.addEventListener("error", this._onError.bind(this)); - - /** - * A Promise which resolves once the Worker is ready to accept tasks - * @type {Promise} - */ - this.ready = this._dispatchTask({ - action: WorkerManager.WORKER_TASK_ACTIONS.INIT, - workerName: name, - debug, - loadPrimitives - }); - } - - /** - * A path reference to the JavaScript file which provides companion worker-side functionality. - * @type {string} - */ - static WORKER_HARNESS_JS = "scripts/worker.js"; - - /** - * A queue of active tasks that this Worker is executing. - * @type {Map} - */ - tasks = new Map(); - - /** - * An auto-incrementing task index. - * @type {number} - * @private - */ - _taskIndex = 0; - - /* -------------------------------------------- */ - /* Task Management */ - /* -------------------------------------------- */ - - /** - * Load a function onto a given Worker. - * The function must be a pure function with no external dependencies or requirements on global scope. - * @param {string} functionName The name of the function to load - * @param {function} functionRef A reference to the function that should be loaded - * @returns {Promise} A Promise which resolves once the Worker has loaded the function. - */ - async loadFunction(functionName, functionRef) { - return this._dispatchTask({ - action: WorkerManager.WORKER_TASK_ACTIONS.LOAD, - functionName, - functionBody: functionRef.toString() - }); - } - - /* -------------------------------------------- */ - - /** - * Execute a task on a specific Worker. - * @param {string} functionName The named function to execute on the worker. This function must first have been - * loaded. - * @param {Array<*>} params An array of parameters with which to call the requested function - * @returns {Promise} A Promise which resolves with the returned result of the function once complete. - */ - async executeFunction(functionName, ...params) { - return this._dispatchTask({ - action: WorkerManager.WORKER_TASK_ACTIONS.EXECUTE, - functionName, - args: params - }); - } - - /* -------------------------------------------- */ - - /** - * Dispatch a task to a named Worker, awaiting confirmation of the result. - * @param {WorkerTask} taskData Data to dispatch to the Worker as part of the task. - * @returns {Promise} A Promise which wraps the task transaction. - * @private - */ - async _dispatchTask(taskData={}) { - const taskId = taskData.taskId = this._taskIndex++; - return new Promise((resolve, reject) => { - this.tasks.set(taskId, {resolve, reject, ...taskData}); - this.postMessage(taskData); - }); - } - - /* -------------------------------------------- */ - - /** - * Handle messages emitted by the Worker thread. - * @param {MessageEvent} event The dispatched message event - * @private - */ - _onMessage(event) { - const response = event.data; - const task = this.tasks.get(response.taskId); - if ( !task ) return; - this.tasks.delete(response.taskId); - if ( response.error ) return task.reject(response.error); - return task.resolve(response.result); - } - - /* -------------------------------------------- */ - - /** - * Handle errors emitted by the Worker thread. - * @param {ErrorEvent} error The dispatched error event - * @private - */ - _onError(error) { - error.message = `An error occurred in Worker ${this.name}: ${error.message}`; - console.error(error); - } -} - -/* -------------------------------------------- */ - -/** - * A client-side class responsible for managing a set of web workers. - * This interface is accessed as a singleton instance via game.workers. - * @see Game#workers - */ -class WorkerManager { - constructor() { - if ( game.workers instanceof WorkerManager ) { - throw new Error("The singleton WorkerManager instance has already been constructed as Game#workers"); - } - } - - /** - * The currently active workforce. - * @type {Map} - * @private - */ - workforce = new Map(); - - /** - * Supported worker task actions - * @enum {string} - */ - static WORKER_TASK_ACTIONS = { - INIT: "init", - LOAD: "load", - EXECUTE: "execute" - }; - - /* -------------------------------------------- */ - /* Worker Management */ - /* -------------------------------------------- */ - - /** - * Create a new named Worker. - * @param {string} name The named Worker to create - * @param {object} [config={}] Worker configuration parameters passed to the AsyncWorker constructor - * @returns {Promise} The created AsyncWorker which is ready to accept tasks - */ - async createWorker(name, config={}) { - if (this.workforce.has(name)) { - throw new Error(`A Worker already exists with the name "${name}"`); - } - const worker = new AsyncWorker(name, config); - this.workforce.set(name, worker); - await worker.ready; - return worker; - } - - /* -------------------------------------------- */ - - /** - * Get a currently active Worker by name. - * @param {string} name The named Worker to retrieve - * @returns {AsyncWorker} The AsyncWorker instance - */ - getWorker(name) { - const w = this.workforce.get(name); - if ( !w ) throw new Error(`No worker with name ${name} currently exists!`) - return w; - } - - /* -------------------------------------------- */ - - /** - * Retire a current Worker, terminating it immediately. - * @see Worker#terminate - * @param {string} name The named worker to terminate - */ - retireWorker(name) { - const worker = this.getWorker(name); - worker.terminate(); - this.workforce.delete(name); - } -} - -/* -------------------------------------------- */ - -/** - * A namespace containing the user interface applications which are defined throughout the Foundry VTT ecosystem. - * @namespace applications - */ - -let _appId = 0; -let _maxZ = 100; - -const MIN_WINDOW_WIDTH = 200; -const MIN_WINDOW_HEIGHT = 50; - -/** - * @typedef {object} ApplicationOptions - * @property {string|null} [baseApplication] A named "base application" which generates an additional hook - * @property {number|null} [width] The default pixel width for the rendered HTML - * @property {number|string|null} [height] The default pixel height for the rendered HTML - * @property {number|null} [top] The default offset-top position for the rendered HTML - * @property {number|null} [left] The default offset-left position for the rendered HTML - * @property {number|null} [scale] A transformation scale for the rendered HTML - * @property {boolean} [popOut] Whether to display the application as a pop-out container - * @property {boolean} [minimizable] Whether the rendered application can be minimized (popOut only) - * @property {boolean} [resizable] Whether the rendered application can be drag-resized (popOut only) - * @property {string} [id] The default CSS id to assign to the rendered HTML - * @property {string[]} [classes] An array of CSS string classes to apply to the rendered HTML - * @property {string} [title] A default window title string (popOut only) - * @property {string|null} [template] The default HTML template path to render for this Application - * @property {string[]} [scrollY] A list of unique CSS selectors which target containers that should have their - * vertical scroll positions preserved during a re-render. - * @property {TabsConfiguration[]} [tabs] An array of tabbed container configurations which should be enabled for the - * application. - * @property {DragDropConfiguration[]} dragDrop An array of CSS selectors for configuring the application's - * {@link DragDrop} behaviour. - * @property {SearchFilterConfiguration[]} filters An array of {@link SearchFilter} configuration objects. - */ - -/** - * The standard application window that is rendered for a large variety of UI elements in Foundry VTT. - * @abstract - * @param {ApplicationOptions} [options] Configuration options which control how the application is rendered. - * Application subclasses may add additional supported options, but these base - * configurations are supported for all Applications. The values passed to the - * constructor are combined with the defaultOptions defined at the class level. - */ -class Application { - constructor(options={}) { - - /** - * The options provided to this application upon initialization - * @type {object} - */ - this.options = foundry.utils.mergeObject(this.constructor.defaultOptions, options, { - insertKeys: true, - insertValues: true, - overwrite: true, - inplace: false - }); - - /** - * The application ID is a unique incrementing integer which is used to identify every application window - * drawn by the VTT - * @type {number} - */ - this.appId = _appId += 1; - - /** - * An internal reference to the HTML element this application renders - * @type {jQuery} - */ - this._element = null; - - /** - * Track the current position and dimensions of the Application UI - * @type {object} - */ - this.position = { - width: this.options.width, - height: this.options.height, - left: this.options.left, - top: this.options.top, - scale: this.options.scale, - zIndex: 0 - }; - - /** - * DragDrop workflow handlers which are active for this Application - * @type {DragDrop[]} - */ - this._dragDrop = this._createDragDropHandlers(); - - /** - * Tab navigation handlers which are active for this Application - * @type {Tabs[]} - */ - this._tabs = this._createTabHandlers(); - - /** - * SearchFilter handlers which are active for this Application - * @type {SearchFilter[]} - */ - this._searchFilters = this._createSearchFilters(); - - /** - * Track whether the Application is currently minimized - * @type {boolean} - */ - this._minimized = false; - - /** - * The current render state of the Application - * @see {Application.RENDER_STATES} - * @type {number} - * @protected - */ - this._state = Application.RENDER_STATES.NONE; - - /** - * The prior render state of this Application. - * This allows for rendering logic to understand if the application is being rendered for the first time. - * @see {Application.RENDER_STATES} - * @type {number} - * @protected - */ - this._priorState = this._state; - - /** - * Track the most recent scroll positions for any vertically scrolling containers - * @type {object | null} - */ - this._scrollPositions = null; - } - - /** - * The sequence of rendering states that track the Application life-cycle. - * @enum {number} - */ - static RENDER_STATES = Object.freeze({ - CLOSING: -2, - CLOSED: -1, - NONE: 0, - RENDERING: 1, - RENDERED: 2, - ERROR: 3 - }); - - /* -------------------------------------------- */ - - /** - * Create drag-and-drop workflow handlers for this Application - * @returns {DragDrop[]} An array of DragDrop handlers - * @private - */ - _createDragDropHandlers() { - return this.options.dragDrop.map(d => { - d.permissions = { - dragstart: this._canDragStart.bind(this), - drop: this._canDragDrop.bind(this) - }; - d.callbacks = { - dragstart: this._onDragStart.bind(this), - dragover: this._onDragOver.bind(this), - drop: this._onDrop.bind(this) - }; - return new DragDrop(d); - }); - } - - /* -------------------------------------------- */ - - /** - * Create tabbed navigation handlers for this Application - * @returns {Tabs[]} An array of Tabs handlers - * @private - */ - _createTabHandlers() { - return this.options.tabs.map(t => { - t.callback = this._onChangeTab.bind(this); - return new Tabs(t); - }); - } - - /* -------------------------------------------- */ - - /** - * Create search filter handlers for this Application - * @returns {SearchFilter[]} An array of SearchFilter handlers - * @private - */ - _createSearchFilters() { - return this.options.filters.map(f => { - f.callback = this._onSearchFilter.bind(this); - return new SearchFilter(f); - }); - } - - /* -------------------------------------------- */ - - /** - * Assign the default options configuration which is used by this Application class. The options and values defined - * in this object are merged with any provided option values which are passed to the constructor upon initialization. - * Application subclasses may include additional options which are specific to their usage. - * @returns {ApplicationOptions} - */ - static get defaultOptions() { - return { - baseApplication: null, - width: null, - height: null, - top: null, - left: null, - scale: null, - popOut: true, - minimizable: true, - resizable: false, - id: "", - classes: [], - dragDrop: [], - tabs: [], - filters: [], - title: "", - template: null, - scrollY: [] - }; - } - - /* -------------------------------------------- */ - - /** - * Return the CSS application ID which uniquely references this UI element - * @type {string} - */ - get id() { - return this.options.id ? this.options.id : `app-${this.appId}`; - } - - /* -------------------------------------------- */ - - /** - * Return the active application element, if it currently exists in the DOM - * @type {jQuery} - */ - get element() { - if ( this._element ) return this._element; - let selector = `#${this.id}`; - return $(selector); - } - - /* -------------------------------------------- */ - - /** - * The path to the HTML template file which should be used to render the inner content of the app - * @type {string} - */ - get template() { - return this.options.template; - } - - /* -------------------------------------------- */ - - /** - * Control the rendering style of the application. If popOut is true, the application is rendered in its own - * wrapper window, otherwise only the inner app content is rendered - * @type {boolean} - */ - get popOut() { - return this.options.popOut ?? true; - } - - /* -------------------------------------------- */ - - /** - * Return a flag for whether the Application instance is currently rendered - * @type {boolean} - */ - get rendered() { - return this._state === Application.RENDER_STATES.RENDERED; - } - - /* -------------------------------------------- */ - - /** - * An Application window should define its own title definition logic which may be dynamic depending on its data - * @type {string} - */ - get title() { - return game.i18n.localize(this.options.title); - } - - /* -------------------------------------------- */ - /* Application rendering - /* -------------------------------------------- */ - - /** - * An application should define the data object used to render its template. - * This function may either return an Object directly, or a Promise which resolves to an Object - * If undefined, the default implementation will return an empty object allowing only for rendering of static HTML - * @param {object} options - * @returns {object|Promise} - */ - getData(options={}) { - return {}; - } - - /* -------------------------------------------- */ - - /** - * Render the Application by evaluating it's HTML template against the object of data provided by the getData method - * If the Application is rendered as a pop-out window, wrap the contained HTML in an outer frame with window controls - * - * @param {boolean} force Add the rendered application to the DOM if it is not already present. If false, the - * Application will only be re-rendered if it is already present. - * @param {object} options Additional rendering options which are applied to customize the way that the Application - * is rendered in the DOM. - * - * @param {number} [options.left] The left positioning attribute - * @param {number} [options.top] The top positioning attribute - * @param {number} [options.width] The rendered width - * @param {number} [options.height] The rendered height - * @param {number} [options.scale] The rendered transformation scale - * @param {boolean} [options.focus=false] Apply focus to the application, maximizing it and bringing it to the top - * of the vertical stack. - * @param {string} [options.renderContext] A context-providing string which suggests what event triggered the render - * @param {object} [options.renderData] The data change which motivated the render request - * - * @returns {Application} The rendered Application instance - * - */ - render(force=false, options={}) { - this._render(force, options).catch(err => { - this._state = Application.RENDER_STATES.ERROR; - Hooks.onError("Application#render", err, { - msg: `An error occurred while rendering ${this.constructor.name} ${this.appId}`, - log: "error", - ...options - }); - }); - return this; - } - - /* -------------------------------------------- */ - - /** - * An asynchronous inner function which handles the rendering of the Application - * @param {boolean} force Render and display the application even if it is not currently displayed. - * @param {object} options Additional options which update the current values of the Application#options object - * @returns {Promise} A Promise that resolves to the Application once rendering is complete - * @protected - */ - async _render(force=false, options={}) { - - // Do not render under certain conditions - const states = Application.RENDER_STATES; - this._priorState = this._state; - if ( [states.CLOSING, states.RENDERING].includes(this._state) ) return; - - // Applications which are not currently rendered must be forced - if ( !force && (this._state <= states.NONE) ) return; - - // Begin rendering the application - if ( [states.NONE, states.CLOSED, states.ERROR].includes(this._state) ) { - console.log(`${vtt} | Rendering ${this.constructor.name}`); - } - this._state = states.RENDERING; - - // Merge provided options with those supported by the Application class - foundry.utils.mergeObject(this.options, options, { insertKeys: false }); - - // Get the existing HTML element and application data used for rendering - const element = this.element; - const data = await this.getData(this.options); - - // Store scroll positions - if ( element.length && this.options.scrollY ) this._saveScrollPositions(element); - - // Render the inner content - const inner = await this._renderInner(data); - let html = inner; - - // If the application already exists in the DOM, replace the inner content - if ( element.length ) this._replaceHTML(element, html); - - // Otherwise render a new app - else { - - // Wrap a popOut application in an outer frame - if ( this.popOut ) { - html = await this._renderOuter(); - html.find(".window-content").append(inner); - ui.windows[this.appId] = this; - } - - // Add the HTML to the DOM and record the element - this._injectHTML(html); - } - - if ( !this.popOut && this.options.resizable ) new Draggable(this, html, false, this.options.resizable); - - // Activate event listeners on the inner HTML - this._activateCoreListeners(inner); - this.activateListeners(inner); - - // Set the application position (if it's not currently minimized) - if ( !this._minimized ) { - foundry.utils.mergeObject(this.position, options, {insertKeys: false}); - this.setPosition(this.position); - } - - // Apply focus to the application, maximizing it and bringing it to the top - if ( options.focus === true ) { - this.maximize().then(() => this.bringToTop()); - } - - // Dispatch Hooks for rendering the base and subclass applications - for ( let cls of this.constructor._getInheritanceChain() ) { - Hooks.callAll(`render${cls.name}`, this, html, data); - } - - // Restore prior scroll positions - if ( this.options.scrollY ) this._restoreScrollPositions(html); - this._state = states.RENDERED; - } - - /* -------------------------------------------- */ - - /** - * Return the inheritance chain for this Application class up to (and including) it's base Application class. - * @returns {Function[]} - * @private - */ - static _getInheritanceChain() { - const parents = foundry.utils.getParentClasses(this); - const base = this.defaultOptions.baseApplication; - const chain = [this]; - for ( let cls of parents ) { - chain.push(cls); - if ( cls.name === base ) break; - } - return chain; - } - - /* -------------------------------------------- */ - - /** - * Persist the scroll positions of containers within the app before re-rendering the content - * @param {jQuery} html The HTML object being traversed - * @protected - */ - _saveScrollPositions(html) { - const selectors = this.options.scrollY || []; - this._scrollPositions = selectors.reduce((pos, sel) => { - const el = html.find(sel); - pos[sel] = Array.from(el).map(el => el.scrollTop); - return pos; - }, {}); - } - - /* -------------------------------------------- */ - - /** - * Restore the scroll positions of containers within the app after re-rendering the content - * @param {jQuery} html The HTML object being traversed - * @protected - */ - _restoreScrollPositions(html) { - const selectors = this.options.scrollY || []; - const positions = this._scrollPositions || {}; - for ( let sel of selectors ) { - const el = html.find(sel); - el.each((i, el) => el.scrollTop = positions[sel]?.[i] || 0); - } - } - - /* -------------------------------------------- */ - - /** - * Render the outer application wrapper - * @returns {Promise} A promise resolving to the constructed jQuery object - * @protected - */ - async _renderOuter() { - - // Gather basic application data - const classes = this.options.classes; - const windowData = { - id: this.id, - classes: classes.join(" "), - appId: this.appId, - title: this.title, - headerButtons: this._getHeaderButtons() - }; - - // Render the template and return the promise - let html = await renderTemplate("templates/app-window.html", windowData); - html = $(html); - - // Activate header button click listeners after a slight timeout to prevent immediate interaction - setTimeout(() => { - html.find(".header-button").click(event => { - event.preventDefault(); - const button = windowData.headerButtons.find(b => event.currentTarget.classList.contains(b.class)); - button.onclick(event); - }); - }, 500); - - // Make the outer window draggable - const header = html.find("header")[0]; - new Draggable(this, html, header, this.options.resizable); - - // Make the outer window minimizable - if ( this.options.minimizable ) { - header.addEventListener("dblclick", this._onToggleMinimize.bind(this)); - } - - // Set the outer frame z-index - if ( Object.keys(ui.windows).length === 0 ) _maxZ = 100 - 1; - this.position.zIndex = Math.min(++_maxZ, 9999); - html.css({zIndex: this.position.zIndex}); - ui.activeWindow = this; - - // Return the outer frame - return html; - } - - /* -------------------------------------------- */ - - /** - * Render the inner application content - * @param {object} data The data used to render the inner template - * @returns {Promise} A promise resolving to the constructed jQuery object - * @private - */ - async _renderInner(data) { - let html = await renderTemplate(this.template, data); - if ( html === "" ) throw new Error(`No data was returned from template ${this.template}`); - return $(html); - } - - /* -------------------------------------------- */ - - /** - * Customize how inner HTML is replaced when the application is refreshed - * @param {jQuery} element The original HTML processed as a jQuery object - * @param {jQuery} html New updated HTML as a jQuery object - * @private - */ - _replaceHTML(element, html) { - if ( !element.length ) return; - - // For pop-out windows update the inner content and the window title - if ( this.popOut ) { - element.find(".window-content").html(html); - let t = element.find(".window-title")[0]; - if ( t.hasChildNodes() ) t = t.childNodes[0]; - t.textContent = this.title; - } - - // For regular applications, replace the whole thing - else { - element.replaceWith(html); - this._element = html; - } - } - - /* -------------------------------------------- */ - - /** - * Customize how a new HTML Application is added and first appears in the DOM - * @param {jQuery} html The HTML element which is ready to be added to the DOM - * @private - */ - _injectHTML(html) { - $("body").append(html); - this._element = html; - html.hide().fadeIn(200); - } - - /* -------------------------------------------- */ - - /** - * Specify the set of config buttons which should appear in the Application header. - * Buttons should be returned as an Array of objects. - * The header buttons which are added to the application can be modified by the getApplicationHeaderButtons hook. - * @typedef {{label: string, class: string, icon: string, onclick: Function|null}} ApplicationHeaderButton - * @fires Application#hook:getApplicationHeaderButtons - * @returns {ApplicationHeaderButton[]} - * @private - */ - _getHeaderButtons() { - const buttons = [ - { - label: "Close", - class: "close", - icon: "fas fa-times", - onclick: () => this.close() - } - ]; - for ( let cls of this.constructor._getInheritanceChain() ) { - - /** - * A hook event that fires whenever this Application is first rendered to add buttons to its header. - * @function getApplicationHeaderButtons - * @memberof hookEvents - * @param {Application} app The Application instance being rendered - * @param {ApplicationHeaderButton[]} buttons The array of header buttons which will be displayed - */ - Hooks.call(`get${cls.name}HeaderButtons`, this, buttons); - } - return buttons; - } - - /* -------------------------------------------- */ - - /** - * Create a {@link ContextMenu} for this Application. - * @param {jQuery} html The Application's HTML. - * @private - */ - _contextMenu(html) {} - - /* -------------------------------------------- */ - /* Event Listeners and Handlers - /* -------------------------------------------- */ - - /** - * Activate required listeners which must be enabled on every Application. - * These are internal interactions which should not be overridden by downstream subclasses. - * @param {jQuery} html - * @protected - */ - _activateCoreListeners(html) { - const el = html[0]; - this._tabs.forEach(t => t.bind(el)); - this._dragDrop.forEach(d => d.bind(el)); - this._searchFilters.forEach(f => f.bind(el)); - } - - /* -------------------------------------------- */ - - /** - * After rendering, activate event listeners which provide interactivity for the Application. - * This is where user-defined Application subclasses should attach their event-handling logic. - * @param {JQuery} html - */ - activateListeners(html) {} - - /* -------------------------------------------- */ - - /** - * Change the currently active tab - * @param {string} tabName The target tab name to switch to - * @param {object} options Options which configure changing the tab - * @param {string} options.group A specific named tab group, useful if multiple sets of tabs are present - * @param {boolean} options.triggerCallback Whether to trigger tab-change callback functions - */ - activateTab(tabName, {group, triggerCallback=true}={}) { - if ( !this._tabs.length ) throw new Error(`${this.constructor.name} does not define any tabs`); - const tabs = group ? this._tabs.find(t => t.group === group) : this._tabs[0]; - if ( !tabs ) throw new Error(`Tab group "${group}" not found in ${this.constructor.name}`); - tabs.activate(tabName, {triggerCallback}); - } - - /* -------------------------------------------- */ - - /** - * Handle changes to the active tab in a configured Tabs controller - * @param {MouseEvent|null} event A left click event - * @param {Tabs} tabs The Tabs controller - * @param {string} active The new active tab name - * @protected - */ - _onChangeTab(event, tabs, active) { - this.setPosition(); - } - - /* -------------------------------------------- */ - - /** - * Handle changes to search filtering controllers which are bound to the Application - * @param {KeyboardEvent} event The key-up event from keyboard input - * @param {string} query The raw string input to the search field - * @param {RegExp} rgx The regular expression to test against - * @param {HTMLElement} html The HTML element which should be filtered - * @protected - */ - _onSearchFilter(event, query, rgx, html) {} - - /* -------------------------------------------- */ - - /** - * Define whether a user is able to begin a dragstart workflow for a given drag selector - * @param {string} selector The candidate HTML selector for dragging - * @returns {boolean} Can the current user drag this selector? - * @protected - */ - _canDragStart(selector) { - return game.user.isGM; - } - - /* -------------------------------------------- */ - - /** - * Define whether a user is able to conclude a drag-and-drop workflow for a given drop selector - * @param {string} selector The candidate HTML selector for the drop target - * @returns {boolean} Can the current user drop on this selector? - * @protected - */ - _canDragDrop(selector) { - return game.user.isGM; - } - - /* -------------------------------------------- */ - - /** - * Callback actions which occur at the beginning of a drag start workflow. - * @param {DragEvent} event The originating DragEvent - * @protected - */ - _onDragStart(event) {} - - /* -------------------------------------------- */ - - /** - * Callback actions which occur when a dragged element is over a drop target. - * @param {DragEvent} event The originating DragEvent - * @protected - */ - _onDragOver(event) {} - - /* -------------------------------------------- */ - - /** - * Callback actions which occur when a dragged element is dropped on a target. - * @param {DragEvent} event The originating DragEvent - * @protected - */ - _onDrop(event) {} - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Bring the application to the top of the rendering stack - */ - bringToTop() { - const element = this.element[0]; - const z = document.defaultView.getComputedStyle(element).zIndex; - if ( z < _maxZ ) { - this.position.zIndex = Math.min(++_maxZ, 99999); - element.style.zIndex = this.position.zIndex; - ui.activeWindow = this; - } - } - - /* -------------------------------------------- */ - - /** - * Close the application and un-register references to it within UI mappings - * This function returns a Promise which resolves once the window closing animation concludes - * @param {object} [options={}] Options which affect how the Application is closed - * @returns {Promise} A Promise which resolves once the application is closed - */ - async close(options={}) { - const states = Application.RENDER_STATES; - if ( !options.force && ![states.RENDERED, states.ERROR].includes(this._state) ) return; - this._state = states.CLOSING; - - // Get the element - let el = this.element; - if ( !el ) return this._state = states.CLOSED; - el.css({minHeight: 0}); - - // Dispatch Hooks for closing the base and subclass applications - for ( let cls of this.constructor._getInheritanceChain() ) { - - /** - * A hook event that fires whenever this Application is closed. - * @function closeApplication - * @memberof hookEvents - * @param {Application} app The Application instance being closed - * @param {jQuery[]} html The application HTML when it is closed - */ - Hooks.call(`close${cls.name}`, this, el); - } - - // Animate closing the element - return new Promise(resolve => { - el.slideUp(200, () => { - el.remove(); - - // Clean up data - this._element = null; - delete ui.windows[this.appId]; - this._minimized = false; - this._scrollPositions = null; - this._state = states.CLOSED; - resolve(); - }); - }); - } - - /* -------------------------------------------- */ - - /** - * Minimize the pop-out window, collapsing it to a small tab - * Take no action for applications which are not of the pop-out variety or apps which are already minimized - * @returns {Promise} A Promise which resolves once the minimization action has completed - */ - async minimize() { - if ( !this.rendered || !this.popOut || [true, null].includes(this._minimized) ) return; - this._minimized = null; - - // Get content - const window = this.element; - const header = window.find(".window-header"); - const content = window.find(".window-content"); - - // Remove minimum width and height styling rules - window.css({minWidth: 100, minHeight: 30}); - - // Slide-up content - content.slideUp(100); - - // Slide up window height - return new Promise(resolve => { - window.animate({height: `${header[0].offsetHeight+1}px`}, 100, () => { - window.animate({width: MIN_WINDOW_WIDTH}, 100, () => { - window.addClass("minimized"); - this._minimized = true; - resolve(); - }); - }); - }); - } - - /* -------------------------------------------- */ - - /** - * Maximize the pop-out window, expanding it to its original size - * Take no action for applications which are not of the pop-out variety or are already maximized - * @returns {Promise} A Promise which resolves once the maximization action has completed - */ - async maximize() { - if ( !this.popOut || [false, null].includes(this._minimized) ) return; - this._minimized = null; - - // Get content - let window = this.element; - let content = window.find(".window-content"); - - // Expand window - return new Promise(resolve => { - window.animate({width: this.position.width, height: this.position.height}, 100, () => { - content.slideDown(100, () => { - window.removeClass("minimized"); - this._minimized = false; - window.css({minWidth: "", minHeight: ""}); // Remove explicit dimensions - content.css({display: ""}); // Remove explicit "block" display - this.setPosition(this.position); - resolve(); - }); - }); - }); - } - - /* -------------------------------------------- */ - - /** - * Set the application position and store its new location. - * Returns the updated position object for the application containing the new values. - * @param {object} position Positional data - * @param {number|null} position.left The left offset position in pixels - * @param {number|null} position.top The top offset position in pixels - * @param {number|null} position.width The application width in pixels - * @param {number|string|null} position.height The application height in pixels - * @param {number|null} position.scale The application scale as a numeric factor where 1.0 is default - * @returns {{left: number, top: number, width: number, height: number, scale:number}|void} - */ - setPosition({left, top, width, height, scale}={}) { - if ( !this.popOut && !this.options.resizable ) return; // Only configure position for popout or resizable apps. - const el = this.element[0]; - const currentPosition = this.position; - const pop = this.popOut; - const styles = window.getComputedStyle(el); - if ( scale === null ) scale = 1; - scale = scale ?? currentPosition.scale ?? 1; - - // If Height is "auto" unset current preference - if ( (height === "auto") || (this.options.height === "auto") ) { - el.style.height = ""; - height = null; - } - - // Update width if an explicit value is passed, or if no width value is set on the element - if ( !el.style.width || width ) { - const tarW = width || el.offsetWidth; - const minW = parseInt(styles.minWidth) || (pop ? MIN_WINDOW_WIDTH : 0); - const maxW = el.style.maxWidth || (window.innerWidth / scale); - currentPosition.width = width = Math.clamped(tarW, minW, maxW); - el.style.width = `${width}px`; - if ( ((width * scale) + currentPosition.left) > window.innerWidth ) left = currentPosition.left; - } - width = el.offsetWidth; - - // Update height if an explicit value is passed, or if no height value is set on the element - if ( !el.style.height || height ) { - const tarH = height || (el.offsetHeight + 1); - const minH = parseInt(styles.minHeight) || (pop ? MIN_WINDOW_HEIGHT : 0); - const maxH = el.style.maxHeight || (window.innerHeight / scale); - currentPosition.height = height = Math.clamped(tarH, minH, maxH); - el.style.height = `${height}px`; - if ( ((height * scale) + currentPosition.top) > window.innerHeight + 1 ) top = currentPosition.top - 1; - } - height = el.offsetHeight; - - // Update Left - if ( (pop && !el.style.left) || Number.isFinite(left) ) { - const scaledWidth = width * scale; - const tarL = Number.isFinite(left) ? left : (window.innerWidth - scaledWidth) / 2; - const maxL = Math.max(window.innerWidth - scaledWidth, 0); - currentPosition.left = left = Math.clamped(tarL, 0, maxL); - el.style.left = `${left}px`; - } - - // Update Top - if ( (pop && !el.style.top) || Number.isFinite(top) ) { - const scaledHeight = height * scale; - const tarT = Number.isFinite(top) ? top : (window.innerHeight - scaledHeight) / 2; - const maxT = Math.max(window.innerHeight - scaledHeight, 0); - currentPosition.top = Math.clamped(tarT, 0, maxT); - el.style.top = `${currentPosition.top}px`; - } - - // Update Scale - if ( scale ) { - currentPosition.scale = Math.max(scale, 0); - if ( scale === 1 ) el.style.transform = ""; - else el.style.transform = `scale(${scale})`; - } - - // Return the updated position object - return currentPosition; - } - - /* -------------------------------------------- */ - - /** - * Handle application minimization behavior - collapsing content and reducing the size of the header - * @param {Event} ev - * @private - */ - _onToggleMinimize(ev) { - ev.preventDefault(); - if ( this._minimized ) this.maximize(ev); - else this.minimize(ev); - } - - /* -------------------------------------------- */ - - /** - * Additional actions to take when the application window is resized - * @param {Event} event - * @private - */ - _onResize(event) {} -} - -/** - * @typedef {ApplicationOptions} FormApplicationOptions - * @property {boolean} [closeOnSubmit=true] Whether to automatically close the application when it's contained - * form is submitted. - * @property {boolean} [submitOnChange=false] Whether to automatically submit the contained HTML form when an input - * or select element is changed. - * @property {boolean} [submitOnClose=false] Whether to automatically submit the contained HTML form when the - * application window is manually closed. - * @property {boolean} [editable=true] Whether the application form is editable - if true, it's fields will - * be unlocked and the form can be submitted. If false, all form fields - * will be disabled and the form cannot be submitted. - * @property {boolean} [sheetConfig=false] Support configuration of the sheet type used for this application. - */ - -/** - * An abstract pattern for defining an Application responsible for updating some object using an HTML form - * - * A few critical assumptions: - * 1) This application is used to only edit one object at a time - * 2) The template used contains one (and only one) HTML form as it's outer-most element - * 3) This abstract layer has no knowledge of what is being updated, so the implementation must define _updateObject - * - * @extends {Application} - * @abstract - * @interface - * - * @param {object} object Some object which is the target data structure to be updated by the form. - * @param {FormApplicationOptions} [options] Additional options which modify the rendering of the sheet. - */ -class FormApplication extends Application { - constructor(object={}, options={}) { - super(options); - - /** - * The object target which we are using this form to modify - * @type {*} - */ - this.object = object; - - /** - * A convenience reference to the form HTMLElement - * @type {HTMLElement} - */ - this.form = null; - - /** - * Keep track of any FilePicker instances which are associated with this form - * The values of this Array are inner-objects with references to the FilePicker instances and other metadata - * @type {FilePicker[]} - */ - this.filepickers = []; - - /** - * Keep track of any mce editors which may be active as part of this form - * The values of this object are inner-objects with references to the MCE editor and other metadata - * @type {Object} - */ - this.editors = {}; - } - - /* -------------------------------------------- */ - - /** - * Assign the default options which are supported by the document edit sheet. - * In addition to the default options object supported by the parent Application class, the Form Application - * supports the following additional keys and values: - * - * @returns {FormApplicationOptions} The default options for this FormApplication class - */ - static get defaultOptions() { - return foundry.utils.mergeObject(super.defaultOptions, { - classes: ["form"], - closeOnSubmit: true, - editable: true, - sheetConfig: false, - submitOnChange: false, - submitOnClose: false - }); - } - - /* -------------------------------------------- */ - - /** - * Is the Form Application currently editable? - * @type {boolean} - */ - get isEditable() { - return this.options.editable; - } - - /* -------------------------------------------- */ - /* Rendering */ - /* -------------------------------------------- */ - - /** - * @inheritdoc - * @returns {object|Promise} - */ - getData(options={}) { - return { - object: this.object, - options: this.options, - title: this.title - }; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _render(force, options) { - - // Identify the focused element - let focus = this.element.find(":focus"); - focus = focus.length ? focus[0] : null; - - // Render the application and restore focus - await super._render(force, options); - if ( focus && focus.name ) { - const input = this.form[focus.name]; - if ( input && (input.focus instanceof Function) ) input.focus(); - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _renderInner(...args) { - const html = await super._renderInner(...args); - this.form = html.filter((i, el) => el instanceof HTMLFormElement)[0]; - if ( !this.form ) this.form = html.find("form")[0]; - return html; - } - - /* -------------------------------------------- */ - /* Event Listeners and Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - _activateCoreListeners(html) { - super._activateCoreListeners(html); - if ( !this.form ) return; - if ( !this.isEditable ) { - return this._disableFields(this.form); - } - this.form.onsubmit = this._onSubmit.bind(this); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - activateListeners(html) { - super.activateListeners(html); - if ( !this.isEditable ) return; - html.on("change", "input,select,textarea", this._onChangeInput.bind(this)); - html.find(".editor-content[data-edit]").each((i, div) => this._activateEditor(div)); - for ( let fp of html.find("button.file-picker") ) { - fp.onclick = this._activateFilePicker.bind(this); - } - if ( this._priorState <= this.constructor.RENDER_STATES.NONE ) html.find("[autofocus]")[0]?.focus(); - } - - /* -------------------------------------------- */ - - /** - * If the form is not editable, disable its input fields - * @param {HTMLElement} form The form HTML - * @protected - */ - _disableFields(form) { - const inputs = ["INPUT", "SELECT", "TEXTAREA", "BUTTON"]; - for ( let i of inputs ) { - for ( let el of form.getElementsByTagName(i) ) { - if ( i === "TEXTAREA" ) el.readOnly = true; - else el.disabled = true; - } - } - } - - /* -------------------------------------------- */ - - /** - * Handle standard form submission steps - * @param {Event} event The submit event which triggered this handler - * @param {object | null} [updateData] Additional specific data keys/values which override or extend the contents of - * the parsed form. This can be used to update other flags or data fields at the - * same time as processing a form submission to avoid multiple database operations. - * @param {boolean} [preventClose] Override the standard behavior of whether to close the form on submit - * @param {boolean} [preventRender] Prevent the application from re-rendering as a result of form submission - * @returns {Promise} A promise which resolves to the validated update data - * @protected - */ - async _onSubmit(event, {updateData=null, preventClose=false, preventRender=false}={}) { - event.preventDefault(); - - // Prevent double submission - const states = this.constructor.RENDER_STATES; - if ( (this._state === states.NONE) || !this.isEditable || this._submitting ) return false; - this._submitting = true; - - // Process the form data - const formData = this._getSubmitData(updateData); - - // Handle the form state prior to submission - let closeForm = this.options.closeOnSubmit && !preventClose; - const priorState = this._state; - if ( preventRender ) this._state = states.RENDERING; - if ( closeForm ) this._state = states.CLOSING; - - // Trigger the object update - try { - await this._updateObject(event, formData); - } - catch(err) { - console.error(err); - closeForm = false; - this._state = priorState; - } - - // Restore flags and optionally close the form - this._submitting = false; - if ( preventRender ) this._state = priorState; - if ( closeForm ) await this.close({submit: false, force: true}); - return formData; - } - - /* -------------------------------------------- */ - - /** - * Get an object of update data used to update the form's target object - * @param {object} updateData Additional data that should be merged with the form data - * @returns {object} The prepared update data - * @protected - */ - _getSubmitData(updateData={}) { - if ( !this.form ) throw new Error("The FormApplication subclass has no registered form element"); - const fd = new FormDataExtended(this.form, {editors: this.editors}); - let data = fd.object; - if ( updateData ) data = foundry.utils.flattenObject(foundry.utils.mergeObject(data, updateData)); - return data; - } - - /* -------------------------------------------- */ - - /** - * Handle changes to an input element, submitting the form if options.submitOnChange is true. - * Do not preventDefault in this handler as other interactions on the form may also be occurring. - * @param {Event} event The initial change event - * @protected - */ - async _onChangeInput(event) { - // Do not fire change listeners for form inputs inside text editors. - if ( event.currentTarget.closest(".editor") ) return; - - // Handle changes to specific input types - const el = event.target; - if ( (el.type === "color") && el.dataset.edit ) this._onChangeColorPicker(event); - else if ( el.type === "range" ) this._onChangeRange(event); - - // Maybe submit the form - if ( this.options.submitOnChange ) { - return this._onSubmit(event); - } - } - - /* -------------------------------------------- */ - - /** - * Handle the change of a color picker input which enters it's chosen value into a related input field - * @param {Event} event The color picker change event - * @protected - */ - _onChangeColorPicker(event) { - const input = event.target; - const form = input.form; - form[input.dataset.edit].value = input.value; - } - - /* -------------------------------------------- */ - - /** - * Handle changes to a range type input by propagating those changes to the sibling range-value element - * @param {Event} event The initial change event - * @protected - */ - _onChangeRange(event) { - const field = event.target.parentElement.querySelector(".range-value"); - if ( field ) { - if ( field.tagName === "INPUT" ) field.value = event.target.value; - else field.innerHTML = event.target.value; - } - } - - /* -------------------------------------------- */ - - /** - * Additional handling which should trigger when a FilePicker contained within this FormApplication is submitted. - * @param {string} selection The target path which was selected - * @param {FilePicker} filePicker The FilePicker instance which was submitted - * @protected - */ - _onSelectFile(selection, filePicker) {} - - /* -------------------------------------------- */ - - /** - * This method is called upon form submission after form data is validated - * @param {Event} event The initial triggering submission event - * @param {object} formData The object of validated form data with which to update the object - * @returns {Promise} A Promise which resolves once the update operation has completed - * @abstract - */ - async _updateObject(event, formData) { - throw new Error("A subclass of the FormApplication must implement the _updateObject method."); - } - - /* -------------------------------------------- */ - /* TinyMCE Editor */ - /* -------------------------------------------- */ - - /** - * Activate a named TinyMCE text editor - * @param {string} name The named data field which the editor modifies. - * @param {object} options Editor initialization options passed to {@link TextEditor.create}. - * @param {string} initialContent Initial text content for the editor area. - * @returns {Promise} - */ - async activateEditor(name, options={}, initialContent="") { - const editor = this.editors[name]; - if ( !editor ) throw new Error(`${name} is not a registered editor name!`); - options = foundry.utils.mergeObject(editor.options, options); - if ( !options.fitToSize ) options.height = options.target.offsetHeight; - if ( editor.hasButton ) editor.button.style.display = "none"; - const instance = editor.instance = editor.mce = await TextEditor.create(options, initialContent || editor.initial); - options.target.closest(".editor")?.classList.add(options.engine ?? "tinymce"); - editor.changed = false; - editor.active = true; - /** @deprecated since v10 */ - if ( options.engine !== "prosemirror" ) { - instance.focus(); - instance.on("change", () => editor.changed = true); - } - return instance; - } - - /* -------------------------------------------- */ - - /** - * Handle saving the content of a specific editor by name - * @param {string} name The named editor to save - * @param {boolean} [remove] Remove the editor after saving its content - * @returns {Promise} - */ - async saveEditor(name, {remove=true}={}) { - const editor = this.editors[name]; - if ( !editor || !editor.instance ) throw new Error(`${name} is not an active editor name!`); - editor.active = false; - const instance = editor.instance; - await this._onSubmit(new Event("submit")); - - // Remove the editor - if ( remove ) { - instance.destroy(); - editor.instance = editor.mce = null; - if ( editor.hasButton ) editor.button.style.display = "block"; - this.render(); - } - editor.changed = false; - } - - /* -------------------------------------------- */ - - /** - * Activate an editor instance present within the form - * @param {HTMLElement} div The element which contains the editor - * @protected - */ - _activateEditor(div) { - - // Get the editor content div - const name = div.dataset.edit; - const engine = div.dataset.engine || "tinymce"; - const collaborate = div.dataset.collaborate === "true"; - const button = div.previousElementSibling; - const hasButton = button && button.classList.contains("editor-edit"); - const wrap = div.parentElement.parentElement; - const wc = div.closest(".window-content"); - - // Determine the preferred editor height - const heights = [wrap.offsetHeight, wc ? wc.offsetHeight : null]; - if ( div.offsetHeight > 0 ) heights.push(div.offsetHeight); - const height = Math.min(...heights.filter(h => Number.isFinite(h))); - - // Get initial content - const options = { - target: div, - fieldName: name, - save_onsavecallback: () => this.saveEditor(name), - height, engine, collaborate - }; - - if ( engine === "prosemirror" ) options.plugins = this._configureProseMirrorPlugins(name, {remove: hasButton}); - - /** - * Handle legacy data references. - * @deprecated since v10 - */ - const isDocument = this.object instanceof foundry.abstract.Document; - const data = (name?.startsWith("data.") && isDocument) ? this.object.data : this.object; - - // Define the editor configuration - const editor = this.editors[name] = { - options, - target: name, - button: button, - hasButton: hasButton, - mce: null, - instance: null, - active: !hasButton, - changed: false, - initial: foundry.utils.getProperty(data, name) - }; - - // Activate the editor immediately, or upon button click - const activate = () => { - editor.initial = foundry.utils.getProperty(data, name); - this.activateEditor(name, {}, editor.initial); - }; - if ( hasButton ) button.onclick = activate; - else activate(); - } - - /* -------------------------------------------- */ - - /** - * Configure ProseMirror plugins for this sheet. - * @param {string} name The name of the editor. - * @param {object} [options] Additional options to configure the plugins. - * @param {boolean} [options.remove=true] Whether the editor should destroy itself on save. - * @returns {object} - * @protected - */ - _configureProseMirrorPlugins(name, {remove=true}={}) { - return { - menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, { - destroyOnSave: remove, - onSave: () => this.saveEditor(name, {remove}) - }), - keyMaps: ProseMirror.ProseMirrorKeyMaps.build(ProseMirror.defaultSchema, { - onSave: () => this.saveEditor(name, {remove}) - }) - }; - } - - /* -------------------------------------------- */ - /* FilePicker UI - /* -------------------------------------------- */ - - /** - * Activate a FilePicker instance present within the form - * @param {PointerEvent} event The mouse click event on a file picker activation button - * @protected - */ - _activateFilePicker(event) { - event.preventDefault(); - const options = this._getFilePickerOptions(event); - const fp = new FilePicker(options); - this.filepickers.push(fp); - return fp.browse(); - } - - /* -------------------------------------------- */ - - /** - * Determine the configuration options used to initialize a FilePicker instance within this FormApplication. - * Subclasses can extend this method to customize the behavior of pickers within their form. - * @param {PointerEvent} event The initiating mouse click event which opens the picker - * @returns {object} Options passed to the FilePicker constructor - * @protected - */ - _getFilePickerOptions(event) { - const button = event.currentTarget; - const target = button.dataset.target; - const field = button.form[target] || null; - return { - field: field, - type: button.dataset.type, - current: field?.value ?? "", - button: button, - callback: this._onSelectFile.bind(this) - }; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - async close(options={}) { - const states = Application.RENDER_STATES; - if ( !options.force && ![states.RENDERED, states.ERROR].includes(this._state) ) return; - - // Trigger saving of the form - const submit = options.submit ?? this.options.submitOnClose; - if ( submit ) await this.submit({preventClose: true, preventRender: true}); - - // Close any open FilePicker instances - for ( let fp of this.filepickers ) { - fp.close(); - } - this.filepickers = []; - - // Close any open MCE editors - for ( let ed of Object.values(this.editors) ) { - if ( ed.mce ) ed.mce.destroy(); - } - this.editors = {}; - - // Close the application itself - return super.close(options); - } - - /* -------------------------------------------- */ - - /** - * Submit the contents of a Form Application, processing its content as defined by the Application - * @param {object} [options] Options passed to the _onSubmit event handler - * @returns {FormApplication} Return a self-reference for convenient method chaining - */ - async submit(options={}) { - if ( this._submitting ) return; - const submitEvent = new Event("submit"); - await this._onSubmit(submitEvent, options); - return this; - } -} - - -/* -------------------------------------------- */ - -/** - * @typedef {FormApplicationOptions} DocumentSheetOptions - * @property {number} viewPermission The default permissions required to view this Document sheet. - * @property {HTMLSecretConfiguration[]} [secrets] An array of {@link HTMLSecret} configuration objects. - */ - -/** - * Extend the FormApplication pattern to incorporate specific logic for viewing or editing Document instances. - * See the FormApplication documentation for more complete description of this interface. - * - * @extends {FormApplication} - * @abstract - * @interface - */ -class DocumentSheet extends FormApplication { - /** - * @param {Document} object A Document instance which should be managed by this form. - * @param {DocumentSheetOptions} [options={}] Optional configuration parameters for how the form behaves. - */ - constructor(object, options={}) { - super(object, options); - this._secrets = this._createSecretHandlers(); - } - - /* -------------------------------------------- */ - - /** - * The list of handlers for secret block functionality. - * @type {HTMLSecret[]} - * @protected - */ - _secrets = []; - - /* -------------------------------------------- */ - - /** - * @override - * @returns {DocumentSheetOptions} - */ - static get defaultOptions() { - return foundry.utils.mergeObject(super.defaultOptions, { - classes: ["sheet"], - template: `templates/sheets/${this.name.toLowerCase()}.html`, - viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED, - sheetConfig: true, - secrets: [] - }); - } - - /* -------------------------------------------- */ - - /** - * A semantic convenience reference to the Document instance which is the target object for this form. - * @type {ClientDocument} - */ - get document() { - return this.object; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get id() { - return `${this.constructor.name}-${this.document.uuid.replace(/\./g, "-")}`; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get isEditable() { - let editable = this.options.editable && this.document.isOwner; - if ( this.document.pack ) { - const pack = game.packs.get(this.document.pack); - if ( pack.locked ) editable = false; - } - return editable; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get title() { - const reference = this.document.name ? ` ${this.document.name}` : ""; - return `${game.i18n.localize(this.document.constructor.metadata.label)}${reference}`; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - async close(options={}) { - await super.close(options); - delete this.object.apps?.[this.appId]; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - getData(options={}) { - const data = this.document.toObject(false); - const isEditable = this.isEditable; - return { - cssClass: isEditable ? "editable" : "locked", - editable: isEditable, - document: this.document, - data: data, - limited: this.document.limited, - options: this.options, - owner: this.document.isOwner, - title: this.title - }; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _activateCoreListeners(html) { - super._activateCoreListeners(html); - if ( !this.document.isOwner ) return; - this._secrets.forEach(secret => secret.bind(html[0])); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async activateEditor(name, options={}, initialContent="") { - options.document = this.document; - return super.activateEditor(name, options, initialContent); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - render(force=false, options={}) { - if ( !this._canUserView(game.user) ) { - if ( !force ) return this; // If rendering is not being forced, fail silently - const err = game.i18n.format("SHEETS.DocumentSheetPrivate", { - type: game.i18n.localize(this.object.constructor.metadata.label) - }); - ui.notifications.warn(err); - return this; - } - - // Update editable permission - options.editable = options.editable ?? this.object.isOwner; - - // Register the active Application with the referenced Documents - this.object.apps[this.appId] = this; - return super.render(force, options); - } - - /* -------------------------------------------- */ - - /** @inheritDoc */ - async _renderOuter() { - const html = await super._renderOuter(); - this._createDocumentIdLink(html); - return html; - } - - /* -------------------------------------------- */ - - /** - * Create an ID link button in the document sheet header which displays the document ID and copies to clipboard - * @param {jQuery} html - * @protected - */ - _createDocumentIdLink(html) { - if ( !(this.object instanceof foundry.abstract.Document) || !this.object.id ) return; - const title = html.find(".window-title"); - const label = game.i18n.localize(this.object.constructor.metadata.label); - const idLink = document.createElement("a"); - idLink.classList.add("document-id-link"); - idLink.setAttribute("alt", "Copy document id"); - idLink.dataset.tooltip = `${label}: ${this.object.id}`; - idLink.dataset.tooltipDirection = "UP"; - idLink.innerHTML = ''; - idLink.addEventListener("click", event => { - event.preventDefault(); - game.clipboard.copyPlainText(this.object.id); - ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type: "id", id: this.object.id})); - }); - idLink.addEventListener("contextmenu", event => { - event.preventDefault(); - game.clipboard.copyPlainText(this.object.uuid); - ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type: "uuid", id: this.object.uuid})); - }); - title.append(idLink); - } - - /* -------------------------------------------- */ - - /** - * Test whether a certain User has permission to view this Document Sheet. - * @param {User} user The user requesting to render the sheet - * @returns {boolean} Does the User have permission to view this sheet? - * @protected - */ - _canUserView(user) { - if ( this.object.compendium ) return user.isGM || !this.object.compendium.private; - return this.object.testUserPermission(user, this.options.viewPermission); - } - - /* -------------------------------------------- */ - - /** - * Create objects for managing the functionality of secret blocks within this Document's content. - * @returns {HTMLSecret[]} - * @protected - */ - _createSecretHandlers() { - if ( !this.document.isOwner || this.document.compendium?.locked ) return []; - return this.options.secrets.map(config => { - config.callbacks = { - content: this._getSecretContent.bind(this), - update: this._updateSecret.bind(this) - }; - return new HTMLSecret(config); - }); - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - _getHeaderButtons() { - let buttons = super._getHeaderButtons(); - - // Compendium Import - if ( !this.document.isEmbedded && this.document.compendium && this.document.constructor.canUserCreate(game.user) ) { - buttons.unshift({ - label: "Import", - class: "import", - icon: "fas fa-download", - onclick: async () => { - await this.close(); - return this.document.collection.importFromCompendium(this.document.compendium, this.document.id); - } - }); - } - - // Sheet Configuration - if ( this.options.sheetConfig && this.isEditable ) { - buttons.unshift({ - label: "Sheet", - class: "configure-sheet", - icon: "fas fa-cog", - onclick: ev => this._onConfigureSheet(ev) - }); - } - return buttons; - } - - /* -------------------------------------------- */ - - /** - * Get the HTML content that a given secret block is embedded in. - * @param {HTMLElement} secret The secret block. - * @returns {string} - * @protected - */ - _getSecretContent(secret) { - const edit = secret.closest("[data-edit]")?.dataset.edit; - if ( edit ) return foundry.utils.getProperty(this.document, edit); - } - - /* -------------------------------------------- */ - - /** - * Update the HTML content that a given secret block is embedded in. - * @param {HTMLElement} secret The secret block. - * @param {string} content The new content. - * @returns {Promise} The updated Document. - * @protected - */ - _updateSecret(secret, content) { - const edit = secret.closest("[data-edit]")?.dataset.edit; - if ( edit ) return this.document.update({[edit]: content}); - } - - /* -------------------------------------------- */ - - /** - * Handle requests to configure the default sheet used by this Document - * @param event - * @private - */ - _onConfigureSheet(event) { - event.preventDefault(); - new DocumentSheetConfig(this.document, { - top: this.position.top + 40, - left: this.position.left + ((this.position.width - DocumentSheet.defaultOptions.width) / 2) - }).render(true); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _updateObject(event, formData) { - if ( !this.object.id ) return; - return this.object.update(formData); - } -} - -/** - * A helper class which assists with localization and string translation - * @param {string} serverLanguage The default language configuration setting for the server - */ -class Localization { - constructor(serverLanguage) { - - // Obtain the default language from application settings - const [defaultLanguage, defaultModule] = (serverLanguage || "en.core").split("."); - - /** - * The target language for localization - * @type {string} - */ - this.lang = defaultLanguage; - - /** - * The package authorized to provide default language configurations - * @type {string} - */ - this.defaultModule = defaultModule; - - /** - * The translation dictionary for the target language - * @type {Object} - */ - this.translations = {}; - - /** - * Fallback translations if the target keys are not found - * @type {Object} - */ - this._fallback = {}; - } - - /* -------------------------------------------- */ - - /** - * Initialize the Localization module - * Discover available language translations and apply the current language setting - * @returns {Promise} A Promise which resolves once languages are initialized - */ - async initialize() { - const clientLanguage = await game.settings.get("core", "language") || this.lang; - - // Discover which modules available to the client - this._discoverSupportedLanguages(); - - // Activate the configured language - if ( clientLanguage !== this.lang ) this.defaultModule = "core"; - await this.setLanguage(clientLanguage || this.lang); - - // Define type labels - if ( game.system ) { - for ( let [documentName, types] of Object.entries(game.documentTypes) ) { - const config = CONFIG[documentName]; - config.typeLabels = config.typeLabels || {}; - for ( let t of types ) { - if ( !(t in config.typeLabels) ) { - config.typeLabels[t] = `${documentName.toUpperCase()}.Type${t.titleCase()}`; - } - } - } - } - - Hooks.callAll("i18nInit"); - } - - /* -------------------------------------------- */ - - /** - * Set a language as the active translation source for the session - * @param {string} lang A language string in CONFIG.supportedLanguages - * @returns {Promise} A Promise which resolves once the translations for the requested language are ready - */ - async setLanguage(lang) { - if ( !Object.keys(CONFIG.supportedLanguages).includes(lang) ) { - console.error(`Cannot set language ${lang}, as it is not in the supported set. Falling back to English`); - lang = "en"; - } - this.lang = lang; - document.documentElement.setAttribute("lang", this.lang); - - // Load translations and English fallback strings - this.translations = await this._getTranslations(lang); - if ( lang !== "en" ) this._fallback = await this._getTranslations("en"); - } - - /* -------------------------------------------- */ - - /** - * Discover the available supported languages from the set of packages which are provided - * @returns {object} The resulting configuration of supported languages - * @private - */ - _discoverSupportedLanguages() { - const sl = CONFIG.supportedLanguages; - - // Define packages - const packages = Array.from(game.modules.values()); - if ( game.world ) packages.push(game.world); - if ( game.system ) packages.push(game.system); - if ( game.worlds ) packages.push(...game.worlds.values()); - if ( game.systems ) packages.push(...game.systems.values()); - - // Registration function - const register = pkg => { - if ( !pkg.languages.size ) return; - for ( let l of pkg.languages ) { - if ( !sl.hasOwnProperty(l.lang) ) sl[l.lang] = l.name; - } - }; - - // Register core translation languages first - for ( let m of game.modules ) { - if ( m.coreTranslation ) register(m); - } - - // Discover and register languages - for ( let p of packages ) { - if ( p.coreTranslation ) continue; - register(p); - } - return sl; - } - - /* -------------------------------------------- */ - - /** - * Prepare the dictionary of translation strings for the requested language - * @param {string} lang The language for which to load translations - * @returns {Promise} The retrieved translations object - * @private - */ - async _getTranslations(lang) { - const translations = {}; - const promises = []; - - // Include core supported translations - if ( CONST.CORE_SUPPORTED_LANGUAGES.includes(lang) ) { - promises.push(this._loadTranslationFile(`lang/${lang}.json`)); - } - - // Game system translations - if ( game.system ) { - this._filterLanguagePaths(game.system, lang).forEach(path => { - promises.push(this._loadTranslationFile(path)); - }); - } - - // Module translations - for ( let module of game.modules.values() ) { - if ( !module.active && (module.id !== this.defaultModule) ) continue; - this._filterLanguagePaths(module, lang).forEach(path => { - promises.push(this._loadTranslationFile(path)); - }); - } - - // Game world translations - if ( game.world ) { - this._filterLanguagePaths(game.world, lang).forEach(path => { - promises.push(this._loadTranslationFile(path)); - }); - } - - // Merge translations in load order and return the prepared dictionary - await Promise.all(promises); - for ( let p of promises ) { - let json = await p; - foundry.utils.mergeObject(translations, json, {inplace: true}); - } - return translations; - } - - /* -------------------------------------------- */ - - /** - * Reduce the languages array provided by a package to an array of file paths of translations to load - * @param {object} pkg The package data - * @param {string} lang The target language to filter on - * @returns {string[]} An array of translation file paths - * @private - */ - _filterLanguagePaths(pkg, lang) { - return pkg.languages.reduce((arr, l) => { - if ( l.lang !== lang ) return arr; - let checkSystem = !l.system || (game.system && (l.system === game.system.id)); - let checkModule = !l.module || game.modules.get(l.module)?.active; - if (checkSystem && checkModule) arr.push(l.path); - return arr; - }, []); - } - - /* -------------------------------------------- */ - - /** - * Load a single translation file and return its contents as processed JSON - * @param {string} src The translation file path to load - * @returns {Promise} The loaded translation dictionary - * @private - */ - async _loadTranslationFile(src) { - - // Load the referenced translation file - let err; - const resp = await fetch(src).catch(e => { - err = e; - return {}; - }); - if ( resp.status !== 200 ) { - const msg = `Unable to load requested localization file ${src}`; - console.error(`${vtt} | ${msg}`); - if ( err ) Hooks.onError("Localization#_loadTranslationFile", err, {msg, src}); - return {}; - } - - // Parse and expand the provided translation object - let json; - try { - json = await resp.json(); - console.log(`${vtt} | Loaded localization file ${src}`); - json = foundry.utils.expandObject(json); - } catch(err) { - Hooks.onError("Localization#_loadTranslationFile", err, { - msg: `Unable to parse localization file ${src}`, - log: "error", - src - }); - json = {}; - } - return json; - } - - /* -------------------------------------------- */ - /* Localization API */ - /* -------------------------------------------- */ - - /** - * Return whether a certain string has a known translation defined. - * @param {string} stringId The string key being translated - * @param {boolean} [fallback] Allow fallback translations to count? - * @returns {boolean} - */ - has(stringId, fallback=true) { - let v = foundry.utils.getProperty(this.translations, stringId); - if ( typeof v === "string" ) return true; - if ( !fallback ) return false; - v = foundry.utils.getProperty(this._fallback, stringId); - return typeof v === "string"; - } - - /* -------------------------------------------- */ - - /** - * Localize a string by drawing a translation from the available translations dictionary, if available - * If a translation is not available, the original string is returned - * @param {string} stringId The string ID to translate - * @returns {string} The translated string - * - * @example Localizing a simple string in JavaScript - * ```js - * { - * "MYMODULE.MYSTRING": "Hello, this is my module!" - * } - * game.i18n.localize("MYMODULE.MYSTRING"); // Hello, this is my module! - * ``` - * - * @example Localizing a simple string in Handlebars - * ```hbs - * {{localize "MYMODULE.MYSTRING"}} - * ``` - */ - localize(stringId) { - let v = foundry.utils.getProperty(this.translations, stringId); - if ( typeof v === "string" ) return v; - v = foundry.utils.getProperty(this._fallback, stringId); - return typeof v === "string" ? v : stringId; - } - - /* -------------------------------------------- */ - - /** - * Localize a string including variable formatting for input arguments. - * Provide a string ID which defines the localized template. - * Variables can be included in the template enclosed in braces and will be substituted using those named keys. - * - * @param {string} stringId The string ID to translate - * @param {object} data Provided input data - * @returns {string} The translated and formatted string - * - * @example Localizing a formatted string in JavaScript - * ```js - * { - * "MYMODULE.GREETING": "Hello {name}, this is my module!" - * } - * game.i18n.format("MYMODULE.GREETING" {name: "Andrew"}); // Hello Andrew, this is my module! - * ``` - * - * @example Localizing a formatted string in Handlebars - * ```hbs - * {{localize "MYMODULE.GREETING" name="Andrew"}} - * ``` - */ - format(stringId, data={}) { - let str = this.localize(stringId); - const fmt = /{[^}]+}/g; - str = str.replace(fmt, k => { - return data[k.slice(1, -1)]; - }); - return str; - } -} - - - -/* -------------------------------------------- */ -/* HTML Template Loading */ -/* -------------------------------------------- */ - -// Global template cache -_templateCache = {}; - -/** - * Get a template from the server by fetch request and caching the retrieved result - * @param {string} path The web-accessible HTML template URL - * @param {string} [id] An ID to register the partial with. - * @returns {Promise} A Promise which resolves to the compiled Handlebars template - */ -async function getTemplate(path, id) { - if ( !_templateCache.hasOwnProperty(path) ) { - await new Promise((resolve, reject) => { - game.socket.emit("template", path, resp => { - if ( resp.error ) return reject(new Error(resp.error)); - const compiled = Handlebars.compile(resp.html); - Handlebars.registerPartial(id ?? path, compiled); - _templateCache[path] = compiled; - console.log(`Foundry VTT | Retrieved and compiled template ${path}`); - resolve(compiled); - }); - }); - } - return _templateCache[path]; -} - -/* -------------------------------------------- */ - -/** - * Load and cache a set of templates by providing an Array of paths - * @param {string[]|Object} paths An array of template file paths to load, or an object of Handlebars partial - * IDs to paths. - * @returns {Promise} - * - * @example Loading a list of templates. - * ```js - * await loadTemplates(["templates/apps/foo.html", "templates/apps/bar.html"]); - * ``` - * ```hbs - * - * {{> "templates/apps/foo.html" }} - * ``` - * - * @example Loading an object of templates. - * ```js - * await loadTemplates({ - * foo: "templates/apps/foo.html", - * bar: "templates/apps/bar.html" - * }); - * ``` - * ```hbs - * - * {{> foo }} - * ``` - */ -async function loadTemplates(paths) { - let promises; - if ( foundry.utils.getType(paths) === "Object" ) promises = Object.entries(paths).map(([k, p]) => getTemplate(p, k)); - else promises = paths.map(p => getTemplate(p)); - return Promise.all(promises); -} - -/* -------------------------------------------- */ - - -/** - * Get and render a template using provided data and handle the returned HTML - * Support asynchronous file template file loading with a client-side caching layer - * - * Allow resolution of prototype methods and properties since this all occurs within the safety of the client. - * @see {@link https://handlebarsjs.com/api-reference/runtime-options.html#options-to-control-prototype-access} - * - * @param {string} path The file path to the target HTML template - * @param {Object} data A data object against which to compile the template - * - * @returns {Promise} Returns the compiled and rendered template as a string - */ -async function renderTemplate(path, data) { - const template = await getTemplate(path); - return template(data || {}, { - allowProtoMethodsByDefault: true, - allowProtoPropertiesByDefault: true - }); -} - - -/* -------------------------------------------- */ -/* Handlebars Template Helpers */ -/* -------------------------------------------- */ - -// Register Handlebars Extensions -HandlebarsIntl.registerWith(Handlebars); - -/** - * A collection of Handlebars template helpers which can be used within HTML templates. - */ -class HandlebarsHelpers { - - /** - * For checkboxes, if the value of the checkbox is true, add the "checked" property, otherwise add nothing. - * @returns {string} - * - * @example - * ```hbs - * - * - * ``` - */ - static checked(value) { - return Boolean(value) ? "checked" : ""; - } - - /* -------------------------------------------- */ - - /** - * For use in form inputs. If the supplied value is truthy, add the "disabled" property, otherwise add nothing. - * @returns {string} - * - * @example - * ```hbs - * - * ``` - */ - static disabled(value) { - return value ? "disabled" : ""; - } - - /* -------------------------------------------- */ - - /** - * Concatenate a number of string terms into a single string. - * This is useful for passing arguments with variable names. - * @param {string[]} values The values to concatenate - * @returns {Handlebars.SafeString} - * - * @example Concatenate several string parts to create a dynamic variable - * ```hbs - * {{filePicker target=(concat "faces." i ".img") type="image"}} - * ``` - */ - static concat(...values) { - const options = values.pop(); - const join = options.hash?.join || ""; - return new Handlebars.SafeString(values.join(join)); - } - - /* -------------------------------------------- */ - - /** - * Render a pair of inputs for selecting a color. - * @param {object} options Helper options - * @param {string} [options.name] The name of the field to create - * @param {string} [options.value] The current color value - * @param {string} [options.default] A default color string if a value is not provided - * @returns {Handlebars.SafeString} - * - * @example - * ```hbs - * {{colorPicker name="myColor" value=myColor default="#000000"}} - * ``` - */ - static colorPicker(options) { - let {name, value} = options.hash; - name = name || "color"; - value = value || ""; - const safeValue = Color.from(value || options.hash.default || "#000000").css; - const html = - ` - `; - return new Handlebars.SafeString(html); - } - - /* -------------------------------------------- */ - /** - * @typedef {object} TextEditorOptions - * @property {string} [target] The named target data element - * @property {boolean} [button] Include a button used to activate the editor later? - * @property {string} [class] A specific CSS class to add to the editor container - * @property {boolean} [editable=true] Is the text editor area currently editable? - * @property {string} [engine=tinymce] The editor engine to use, see {@link TextEditor.create}. - * @property {boolean} [collaborate=false] Whether to turn on collaborative editing features for ProseMirror. - * - * The below options are deprecated since v10 and should be avoided. - * @property {boolean} [owner] Is the current user an owner of the data? - * @property {boolean} [documents=true] Replace dynamic document links? - * @property {Object|Function} [rollData] The data object providing context for inline rolls - * @property {string} [content=""] The original HTML content as a string - */ - - /** - * Construct an editor element for rich text editing with TinyMCE or ProseMirror. - * @param {[string, TextEditorOptions]} args The content to display and edit, followed by handlebars options. - * @returns {Handlebars.SafeString} - * - * @example - * ```hbs - * {{editor world.description target="description" button=false engine="prosemirror" collaborate=false}} - * ``` - */ - static editor(...args) { - const options = args.pop(); - let content = args.pop() ?? ""; - const target = options.hash.target; - if ( !target ) throw new Error("You must define the name of a target field."); - const button = Boolean(options.hash.button); - const editable = "editable" in options.hash ? Boolean(options.hash.editable) : true; - - /** - * @deprecated since v10 - */ - if ( "content" in options.hash ) { - foundry.utils.logCompatibilityWarning("The content option for the editor handlebars helper has been deprecated. " - + "Please pass the content in as the first option to the helper and ensure it has already been enriched by " - + "TextEditor.enrichHTML if necessary", {since: 10, until: 12}); - // Enrich the content - const documents = options.hash.documents !== false; - const owner = Boolean(options.hash.owner); - const rollData = options.hash.rollData; - content = TextEditor.enrichHTML(options.hash.content, {secrets: owner, documents, rollData, async: false}); - } - - // Construct the HTML - const editorClasses = ["editor-content", options.hash.class ?? null].filterJoin(" "); - let editorHTML = '
'; - if ( button && editable ) editorHTML += ''; - let dataset = { - engine: options.hash.engine || "tinymce", - collaborate: !!options.hash.collaborate - }; - if ( editable ) dataset.edit = target; - dataset = Object.entries(dataset).map(([k, v]) => `data-${k}="${v}"`).join(" "); - editorHTML += `
${content}
`; - return new Handlebars.SafeString(editorHTML); - } - - /* -------------------------------------------- */ - - /** - * Render a file-picker button linked to an `` field - * @param {object} options Helper options - * @param {string} [options.type] The type of FilePicker instance to display - * @param {string} [options.target] The field name in the target data - * @returns {Handlebars.SafeString|string} - * - * @example - * ```hbs - * {{filePicker type="image" target="img"}} - * ``` - */ - static filePicker(options) { - const type = options.hash.type; - const target = options.hash.target; - if ( !target ) throw new Error("You must define the name of the target field."); - - // Do not display the button for users who do not have browse permission - if ( game.world && !game.user.can("FILES_BROWSE" ) ) return ""; - - // Construct the HTML - const tooltip = game.i18n.localize("FILES.BrowseTooltip"); - return new Handlebars.SafeString(` - `); - } - - /* -------------------------------------------- */ - - /** - * Translate a provided string key by using the loaded dictionary of localization strings. - * @returns {string} - * - * @example Translate a provided localization string, optionally including formatting parameters - * ```hbs - * - * - * ``` - */ - static localize(value, options) { - const data = options.hash; - return foundry.utils.isEmpty(data) ? game.i18n.localize(value) : game.i18n.format(value, data); - } - - /* -------------------------------------------- */ - - /** - * A string formatting helper to display a number with a certain fixed number of decimals and an explicit sign. - * @param {number} value A numeric value to format - * @param {object} options Additional options which customize the resulting format - * @param {number} [options.decimals=0] The number of decimal places to include in the resulting string - * @param {boolean} [options.sign=false] Whether to include an explicit "+" sign for positive numbers * - * @returns {Handlebars.SafeString} The formatted string to be included in a template - * - * @example - * ```hbs - * {{formatNumber 5.5}} - * {{formatNumber 5.5 decimals=2}} - * {{formatNumber 5.5 decimals=2 sign=true}} - * ``` - */ - static numberFormat(value, options) { - const dec = options.hash['decimals'] ?? 0; - const sign = options.hash['sign'] || false; - value = parseFloat(value).toFixed(dec); - if (sign ) return ( value >= 0 ) ? "+"+value : value; - return value; - } - - /* --------------------------------------------- */ - - /** - * Render a form input field of type number with value appropriately rounded to step size. - * @returns {Handlebars.SafeString} - * - * @example - * ```hbs - * {{numberInput value name="numberField" step=1 min=0 max=10}} - * ``` - */ - static numberInput(value, options) { - const properties = []; - for ( let k of ["class", "name", "placeholder", "min", "max"] ) { - if ( k in options.hash ) properties.push(`${k}="${options.hash[k]}"`); - } - const step = options.hash.step ?? "any"; - properties.unshift(`step="${step}"`); - if ( options.hash.disabled === true ) properties.push("disabled"); - let safe = Number.isNumeric(value) ? Number(value) : ""; - if ( Number.isNumeric(step) && (typeof safe === "number") ) safe = safe.toNearest(Number(step)); - return new Handlebars.SafeString(``); - } - - /* -------------------------------------------- */ - - /** - * A helper to create a set of radio checkbox input elements in a named set. - * The provided keys are the possible radio values while the provided values are human readable labels. - * - * @param {string} name The radio checkbox field name - * @param {object} choices A mapping of radio checkbox values to human readable labels - * @param {object} options Options which customize the radio boxes creation - * @param {string} options.checked Which key is currently checked? - * @param {boolean} options.localize Pass each label through string localization? - * @returns {Handlebars.SafeString} - * - * @example The provided input data - * ```js - * let groupName = "importantChoice"; - * let choices = {a: "Choice A", b: "Choice B"}; - * let chosen = "a"; - * ``` - * - * @example The template HTML structure - * ```hbs - *
- * - *
- * {{radioBoxes groupName choices checked=chosen localize=true}} - *
- *
- * ``` - */ - static radioBoxes(name, choices, options) { - const checked = options.hash['checked'] || null; - const localize = options.hash['localize'] || false; - let html = ""; - for ( let [key, label] of Object.entries(choices) ) { - if ( localize ) label = game.i18n.localize(label); - const isChecked = checked === key; - html += ``; - } - return new Handlebars.SafeString(html); - } - - /* -------------------------------------------- */ - - /** - * Render a pair of inputs for selecting a value in a range. - * @param {object} options Helper options - * @param {string} [options.name] The name of the field to create - * @param {number} [options.value] The current range value - * @param {number} [options.min] The minimum allowed value - * @param {number} [options.max] The maximum allowed value - * @param {number} [options.step] The allowed step size - * @returns {Handlebars.SafeString} - * - * @example - * ```hbs - * {{rangePicker name="foo" value=bar min=0 max=10 step=1}} - * ``` - */ - static rangePicker(options) { - let {name, value, min, max, step} = options.hash; - name = name || "range"; - value = value ?? ""; - if ( Number.isNaN(value) ) value = ""; - const html = - ` - ${value}`; - return new Handlebars.SafeString(html); - } - - /* -------------------------------------------- */ - - /** - * A helper to assign an ` - * - * {{/select}} - * - */ - static select(selected, options) { - const escapedValue = RegExp.escape(Handlebars.escapeExpression(selected)); - const rgx = new RegExp(' value=[\"\']' + escapedValue + '[\"\']'); - const html = options.fn(this); - return html.replace(rgx, "$& selected"); - } - - /* -------------------------------------------- */ - - /** - * A helper to create a set of <option> elements in a <select> block based on a provided dictionary. - * The provided keys are the option values while the provided values are human readable labels. - * This helper supports both single-select as well as multi-select input fields. - * - * @param {object|Array>} choices A mapping of radio checkbox values to human-readable labels - * @param {object} options Helper options - * @param {string|string[]} [options.selected] Which key or array of keys that are currently selected? - * @param {boolean} [options.localize=false] Pass each label through string localization? - * @param {string} [options.blank] Add a blank option as the first option with this label - * @param {boolean} [options.sort] Sort the options by their label after localization - * @param {string} [options.nameAttr] Look up a property in the choice object values to use as the option value - * @param {string} [options.labelAttr] Look up a property in the choice object values to use as the option label - * @param {boolean} [options.inverted=false] Use the choice object value as the option value, and the key as the label - * instead of vice-versa - * @returns {Handlebars.SafeString} - * - * @example The provided input data - * ```js - * let choices = {a: "Choice A", b: "Choice B"}; - * let value = "a"; - * ``` - * The template HTML structure - * ```hbs - * - * ``` - * The resulting HTML - * ```html - * - * ``` - * - * @example Using an Array as choices - * ```js - * let choices = [{a: "Choice A"}, {b: "Choice B"}]; - * let value = "a"; - * ``` - * The template HTML structure - * ```hbs - * - * ``` - * The resulting HTML - * ```html - * - * ``` - * - * @example Using inverted choices - * ```js - * let choices = {"Choice A": "a", "Choice B": "b"}; - * let value = "a"; - * ``` - * The template HTML structure - * ```hbs - * - * ``` - * - * @example Using nameAttr and labelAttr with objects - * ```js - * let choices = {foo: {key: "a", label: "Choice A"}, bar: {key: "b", label: "Choice B"}}; - * let value = "b"; - * ``` - * The template HTML structure - * ```hbs - * - * ``` - * - * @example Using nameAttr and labelAttr with arrays - * ```js - * let choices = [{key: "a", label: "Choice A"}, {key: "b", label: "Choice B"}]; - * let value = "b"; - * ``` - * The template HTML structure - * ```hbs - * - * ``` - */ - static selectOptions(choices, options) { - let {localize=false, selected=null, blank=null, sort=false, nameAttr, labelAttr, inverted} = options.hash; - selected = selected instanceof Array ? selected.map(String) : [String(selected)]; - - // Prepare the choices as an array of objects - const selectOptions = []; - if ( choices instanceof Array ) { - for ( const choice of choices ) { - const name = String(choice[nameAttr]); - let label = choice[labelAttr]; - if ( localize ) label = game.i18n.localize(label); - selectOptions.push({name, label}); - } - } - else { - for ( const choice of Object.entries(choices) ) { - const [key, value] = inverted ? choice.reverse() : choice; - const name = String(nameAttr ? value[nameAttr] : key); - let label = labelAttr ? value[labelAttr] : value; - if ( localize ) label = game.i18n.localize(label); - selectOptions.push({name, label}); - } - } - - // Sort the array of options - if ( sort ) selectOptions.sort((a, b) => a.label.localeCompare(b.label)); - - // Prepend a blank option - if ( blank !== null ) { - const label = localize ? game.i18n.localize(blank) : blank; - selectOptions.unshift({name: "", label}); - } - - // Create the HTML - let html = ""; - for ( const option of selectOptions ) { - const label = Handlebars.escapeExpression(option.label); - const isSelected = selected.includes(option.name); - html += ``; - } - return new Handlebars.SafeString(html); - } -} - -// Register all handlebars helpers -Handlebars.registerHelper({ - checked: HandlebarsHelpers.checked, - disabled: HandlebarsHelpers.disabled, - colorPicker: HandlebarsHelpers.colorPicker, - concat: HandlebarsHelpers.concat, - editor: HandlebarsHelpers.editor, - filePicker: HandlebarsHelpers.filePicker, - numberFormat: HandlebarsHelpers.numberFormat, - numberInput: HandlebarsHelpers.numberInput, - localize: HandlebarsHelpers.localize, - radioBoxes: HandlebarsHelpers.radioBoxes, - rangePicker: HandlebarsHelpers.rangePicker, - select: HandlebarsHelpers.select, - selectOptions: HandlebarsHelpers.selectOptions, - timeSince: foundry.utils.timeSince, - eq: (v1, v2) => v1 === v2, - ne: (v1, v2) => v1 !== v2, - lt: (v1, v2) => v1 < v2, - gt: (v1, v2) => v1 > v2, - lte: (v1, v2) => v1 <= v2, - gte: (v1, v2) => v1 >= v2, - not: pred => !pred, - and() {return Array.prototype.every.call(arguments, Boolean);}, - or() {return Array.prototype.slice.call(arguments, 0, -1).some(Boolean);} -}); - -/** - * The core Game instance which encapsulates the data, settings, and states relevant for managing the game experience. - * The singleton instance of the Game class is available as the global variable game. - */ -class Game { - /** - * @param {string} view The named view which is active for this game instance. - * @param {object} data An object of all the World data vended by the server when the client first connects - * @param {string} sessionId The ID of the currently active client session retrieved from the browser cookie - * @param {Socket} socket The open web-socket which should be used to transact game-state data - */ - constructor(view, data, sessionId, socket) { - - /** - * The named view which is currently active. - * Game views include: join, setup, players, license, game, stream - * @type {string} - */ - this.view = view; - - /** - * The object of world data passed from the server - * @type {object} - */ - this.data = data; - - /** - * The Release data for this version of Foundry - * @type {config.ReleaseData} - */ - this.release = new foundry.config.ReleaseData(this.data.release); - - /** - * The id of the active World user, if any - * @type {string} - */ - this.userId = data.userId || null; - - // Set up package data - this.setupPackages(data); - - /** - * A mapping of WorldCollection instances, one per primary Document type. - * @type {Collection} - */ - this.collections = new foundry.utils.Collection(); - - /** - * A mapping of CompendiumCollection instances, one per Compendium pack. - * @type {Collection} - */ - this.packs = new foundry.utils.Collection(); - - /** - * A singleton web Worker manager. - * @type {WorkerManager} - */ - this.workers = new WorkerManager(); - - /** - * Localization support - * @type {Localization} - */ - this.i18n = new Localization(data?.options?.language); - - /** - * The Keyboard Manager - * @type {KeyboardManager} - */ - this.keyboard = null; - - /** - * The Mouse Manager - * @type {MouseManager} - */ - this.mouse = null; - - /** - * The Gamepad Manager - * @type {GamepadManager} - */ - this.gamepad = null; - - /** - * The New User Experience manager. - * @type {NewUserExperience} - */ - this.nue = new NewUserExperience(); - - /** - * The user role permissions setting - * @type {object} - */ - this.permissions = null; - - /** - * The client session id which is currently active - * @type {string} - */ - this.sessionId = sessionId; - - /** - * Client settings which are used to configure application behavior - * @type {ClientSettings} - */ - this.settings = new ClientSettings(data.settings || []); - - /** - * Client keybindings which are used to configure application behavior - * @type {ClientKeybindings} - */ - this.keybindings = new ClientKeybindings(); - - /** - * A reference to the open Socket.io connection - * @type {WebSocket|null} - */ - this.socket = socket; - - /** - * A singleton GameTime instance which manages the progression of time within the game world. - * @type {GameTime} - */ - this.time = new GameTime(socket); - - /** - * A singleton reference to the Canvas object which may be used. - * @type {Canvas} - */ - this.canvas = globalThis.canvas = new Canvas(); - - /** - * A singleton instance of the Audio Helper class - * @type {AudioHelper} - */ - this.audio = new AudioHelper(); - - /** - * A singleton instance of the Video Helper class - * @type {VideoHelper} - */ - this.video = new VideoHelper(); - - /** - * A singleton instance of the TooltipManager class - * @type {TooltipManager} - */ - this.tooltip = new TooltipManager(); - - /** - * A singleton instance of the Clipboard Helper class. - * @type {ClipboardHelper} - */ - this.clipboard = new ClipboardHelper(); - - /** - * A singleton instance of the Tour collection class - * @type {Tours} - */ - this.tours = new Tours(); - - /** - * The global document index. - * @type {DocumentIndex} - */ - this.documentIndex = new DocumentIndex(); - - /** - * Whether the Game is running in debug mode - * @type {boolean} - */ - this.debug = false; - - /** - * A flag for whether texture assets for the game canvas are currently loading - * @type {boolean} - */ - this.loading = false; - - /** - * A flag for whether the Game has successfully reached the "ready" hook - * @type {boolean} - */ - this.ready = false; - } - - /** - * The game World which is currently active. - * @type {World} - */ - world; - - /** - * The System which is used to power this game World. - * @type {System} - */ - system; - - /** - * A Map of active Modules which are currently eligible to be enabled in this World. - * The subset of Modules which are designated as active are currently enabled. - * @type {Map} - */ - modules; - - /** - * Returns the current version of the Release, usable for comparisons using isNewerVersion - * @type {string} - */ - get version() { - return this.release.version; - } - - /* -------------------------------------------- */ - - /** - * Fetch World data and return a Game instance - * @param {string} view The named view being created - * @param {string|null} sessionId The current sessionId of the connecting client - * @returns {Promise} A Promise which resolves to the created Game instance - */ - static async create(view, sessionId) { - const socket = sessionId ? await this.connect(sessionId) : null; - const gameData = socket ? await this.getData(socket, view) : {}; - return new this(view, gameData, sessionId, socket); - } - - /* -------------------------------------------- */ - - /** - * Establish a live connection to the game server through the socket.io URL - * @param {string} sessionId The client session ID with which to establish the connection - * @returns {Promise} A promise which resolves to the connected socket, if successful - */ - static async connect(sessionId) { - return new Promise((resolve, reject) => { - const socket = io.connect({ - path: foundry.utils.getRoute("socket.io"), - transports: ["websocket"], // Require websocket transport instead of XHR polling - upgrade: false, // Prevent "upgrading" to websocket since it is enforced - reconnection: true, // Automatically reconnect - reconnectionDelay: 500, // Time before reconnection is attempted - reconnectionAttempts: 10, // Maximum reconnection attempts - reconnectionDelayMax: 500, // The maximum delay between reconnection attempts - query: {session: sessionId}, // Pass session info - cookie: false - }); - - // Confirm successful session creation - socket.on("session", response => { - socket.session = response; - const id = response.sessionId; - if ( !id || (sessionId && (sessionId !== id)) ) return foundry.utils.debouncedReload(); - console.log(`${vtt} | Connected to server socket using session ${id}`); - resolve(socket); - }); - - // Fail to establish an initial connection - socket.on("connectTimeout", () => { - reject(new Error("Failed to establish a socket connection within allowed timeout.")); - }); - socket.on("connectError", err => reject(err)); - }); - } - - /* -------------------------------------------- */ - - /** - * Retrieve the cookies which are attached to the client session - * @returns {object} The session cookies - */ - static getCookies() { - const cookies = {}; - for (let cookie of document.cookie.split("; ")) { - let [name, value] = cookie.split("="); - cookies[name] = decodeURIComponent(value); - } - return cookies; - } - - /* -------------------------------------------- */ - - /** - * Request World data from server and return it - * @param {Socket} socket The active socket connection - * @param {string} view The view for which data is being requested - * @returns {Promise} - */ - static async getData(socket, view) { - if ( !socket.session.userId ) { - socket.disconnect(); - window.location.href = foundry.utils.getRoute("join"); - } - return new Promise(resolve => { - socket.emit("world", resolve); - }); - } - - /* -------------------------------------------- */ - - /** - * Get the current World status upon initial connection. - * @param {Socket} socket The active client socket connection - * @returns {Promise} - */ - static async getWorldStatus(socket) { - const status = await new Promise(resolve => { - socket.emit("getWorldStatus", resolve); - }); - console.log(`${vtt} | The game World is currently ${status ? "active" : "not active"}`); - return status; - } - - /* -------------------------------------------- */ - - /** - * Configure package data that is currently enabled for this world - * @param {object} data Game data provided by the server socket - */ - setupPackages(data) { - if ( data.world ) { - this.world = new World(data.world); - } - if ( data.system ) { - this.system = new System(data.system); - if ( data.documentTypes ) this.documentTypes = data.documentTypes; - if ( data.template ) this.template = data.template; - if ( data.model ) this.model = data.model; - } - this.modules = new foundry.utils.Collection(data.modules.map(m => [m.id, new Module(m)])); - } - - /* -------------------------------------------- */ - - /** - * Return the named scopes which can exist for packages. - * Scopes are returned in the prioritization order that their content is loaded. - * @returns {string[]} An array of string package scopes - */ - getPackageScopes() { - return CONFIG.DatabaseBackend.getFlagScopes(); - } - - /* -------------------------------------------- */ - - /** - * Initialize the Game for the current window location - */ - async initialize() { - console.log(`${vtt} | Initializing Foundry Virtual Tabletop Game`); - this.ready = false; - - Hooks.callAll("init"); - - // Register game settings - this.registerSettings(); - - // Initialize language translations - await this.i18n.initialize(); - - // Register Tours - await this.registerTours(); - - // Activate event listeners - this.activateListeners(); - - // Initialize the current view - await this._initializeView(); - - // Display usability warnings or errors - this._displayUsabilityErrors(); - } - - /* -------------------------------------------- */ - - /** - * Display certain usability error messages which are likely to result in the player having a bad experience. - * @private - */ - _displayUsabilityErrors() { - - // Validate required resolution - const MIN_WIDTH = 1024; - const MIN_HEIGHT = 700; - if ( window.innerHeight < MIN_HEIGHT || window.innerWidth < MIN_WIDTH ) { - if ( ui.notifications && !game.data.options.debug ) { - ui.notifications.error(game.i18n.format("ERROR.LowResolution", { - width: window.innerWidth, - reqWidth: MIN_WIDTH, - height: window.innerHeight, - reqHeight: MIN_HEIGHT - }), {permanent: true}); - } - } - - // Display browser compatibility error - const browserError = (browser, version, minimum) => { - if ( parseInt(version) < minimum ) { - const err = game.i18n.format("ERROR.BrowserVersion", {browser, version, minimum}); - if ( ui.notifications ) ui.notifications.error(err, {permanent: true}); - console.error(err); - } - }; - - // Electron Version - const electron = navigator.userAgent.match(/Electron\/(\d+)\./); - if ( electron && parseInt(electron[1]) < 15 ) { - const err = game.i18n.localize("ERROR.ElectronVersion"); - if ( ui.notifications ) ui.notifications.error(err, {permanent: true}); - console.error(err); - return; - } - - // Chromium Version - const chromium = navigator.userAgent.match(/Chrom(?:e|ium)\/([0-9]+)\./); - if ( chromium ) return browserError("Chromium", chromium[1], 80); - - // Firefox Version - const firefox = navigator.userAgent.match(/Firefox\/([0-9]+)\./); - if ( firefox ) return browserError("Firefox", firefox[1], 78); - - // Safari Version - const safari = navigator.userAgent.match(/Version\/([0-9]+)\.(?:.*)Safari\//); - if ( safari ) return browserError("Safari", safari[1], 14); - } - - /* -------------------------------------------- */ - - /** - * Shut down the currently active Game. Requires GameMaster user permission. - * @returns {Promise} - */ - async shutDown() { - if ( !game.ready || !game.user.isGM ) { - throw new Error("Only a GM user may shut down the currently active world"); - } - const setupUrl = foundry.utils.getRoute("setup"); - const response = await fetchWithTimeout(setupUrl, { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({shutdown: true}), - redirect: "manual" - }); - setTimeout(() => window.location.href = setupUrl, 1000); - } - - /* -------------------------------------------- */ - /* Primary Game Initialization - /* -------------------------------------------- */ - - /** - * Fully set up the game state, initializing Documents, UI applications, and the Canvas - * @returns {Promise} - */ - async setupGame() { - Hooks.callAll("setup"); - - // Store permission settings - this.permissions = await this.settings.get("core", "permissions"); - - // Data initialization - this.initializePacks(); // Do this first since documents may reference compendium content - this.initializeDocuments(); // Next initialize world-level documents - this.initializeRTC(); // Intentionally async - - // Interface initialization - this.initializeMouse(); - this.initializeGamepads(); - this.initializeKeyboard(); - - // Call this here to set up a promise that dependent UI elements can await. - this.canvas.initializing = this.initializeCanvas(); - - this.initializeUI(); - DocumentSheetConfig.initializeSheets(); - - // Canvas initialization - await this.canvas.initializing; - this.activateSocketListeners(); - - // If the player is not a GM and does not have an impersonated character, prompt for selection - if ( !this.user.isGM && !this.user.character ) { - this.user.sheet.render(true); - } - - // Call all game ready hooks - this.ready = true; - - // Initialize New User Experience - this.nue.initialize(); - - // Begin indexing available documents. - this.documentIndex.index(); - - Hooks.callAll("ready"); - } - - /* -------------------------------------------- */ - - /** - * Initialize game state data by creating WorldCollection instances for every primary Document type - */ - initializeDocuments() { - const initOrder = ["User", "Folder", "Actor", "Item", "Scene", "Combat", "JournalEntry", "Macro", "Playlist", - "RollTable", "Cards", "ChatMessage"]; - if ( initOrder.length !== CONST.DOCUMENT_TYPES.length ) { - throw new Error("Missing Document initialization type!"); - } - - // Warn developers about collision with V10 DataModel changes - const v10DocumentMigrationErrors = []; - for ( const documentName of CONST.DOCUMENT_TYPES ) { - const cls = getDocumentClass(documentName); - for ( const key of cls.schema.keys() ) { - if ( key in cls.prototype ) { - const err = `The ${cls.name} class defines the "${key}" attribute which collides with the "${key}" key in ` - + `the ${cls.documentName} data schema`; - v10DocumentMigrationErrors.push(err); - } - } - } - if ( v10DocumentMigrationErrors.length ) { - v10DocumentMigrationErrors.unshift("Version 10 Compatibility Failure", - "-".repeat(90), - "Several Document class definitions include properties which collide with the new V10 DataModel:", - "-".repeat(90)); - throw new Error(v10DocumentMigrationErrors.join("\n")); - } - - // Initialize world document collections - this._documentsReady = false; - const t0 = performance.now(); - for ( let documentName of initOrder ) { - const documentClass = CONFIG[documentName].documentClass; - const collectionClass = CONFIG[documentName].collection; - const collectionName = documentClass.metadata.collection; - this[collectionName] = new collectionClass(this.data[collectionName]); - this.collections.set(documentName, this[collectionName]); - } - this._documentsReady = true; - - // Prepare data for all world documents (this was skipped at construction-time) - for ( const collection of this.collections.values() ) { - for ( let document of collection ) { - document._safePrepareData(); - } - } - - // Special-case - world settings - this.collections.set("Setting", this.settings.storage.get("world")); - - // Special case - fog explorations - const fogCollectionCls = CONFIG.FogExploration.collection; - this.collections.set("FogExploration", new fogCollectionCls()); - const dt = performance.now() - t0; - console.debug(`${vtt} | Prepared World Documents in ${Math.round(dt)}ms`); - } - - /* -------------------------------------------- */ - - /** - * Initialize the Compendium packs which are present within this Game - * Create a Collection which maps each Compendium pack using it's collection ID - * @returns {Collection} - */ - initializePacks() { - const prior = this.packs; - const packs = new foundry.utils.Collection(); - for ( let metadata of this.data.packs ) { - let pack = prior?.get(metadata.id); - - // Update the compendium collection - if ( !pack ) pack = new CompendiumCollection(metadata); - packs.set(pack.collection, pack); - - // Re-render any applications associated with pack content - for ( let document of pack.contents ) { - document.render(false, {editable: !pack.locked}); - } - - // Re-render any open Compendium applications - pack.apps.forEach(app => app.render(false)); - } - return this.packs = packs; - } - - /* -------------------------------------------- */ - - /** - * Initialize the WebRTC implementation - */ - initializeRTC() { - this.webrtc = new AVMaster(); - return this.webrtc.connect(); - } - - /* -------------------------------------------- */ - - /** - * Initialize core UI elements - */ - initializeUI() { - - // Initialize all singleton applications - for ( let [k, cls] of Object.entries(CONFIG.ui) ) { - ui[k] = new cls(); - } - - // Render some applications (asynchronously) - ui.nav.render(true); - ui.notifications.render(true); - ui.sidebar.render(true); - ui.players.render(true); - ui.hotbar.render(true); - ui.webrtc.render(true); - ui.pause.render(true); - ui.controls.render(true); - this.scaleFonts(); - } - - /* -------------------------------------------- */ - - /** - * Initialize the game Canvas - * @returns {Promise} - */ - async initializeCanvas() { - - // Ensure that necessary fonts have fully loaded - await FontConfig._loadFonts(); - - // Identify the current scene - const scene = game.scenes.current; - - // Attempt to initialize the canvas and draw the current scene - try { - this.canvas.initialize(); - if ( scene ) await scene.view(); - else if ( this.canvas.initialized ) await this.canvas.draw(null); - } catch(err) { - Hooks.onError("Game#initializeCanvas", err, { - msg: "Failed to render WebGL canvas", - log: "error" - }); - } - } - - /* -------------------------------------------- */ - - /** - * Initialize Keyboard controls - */ - initializeKeyboard() { - window.keyboard = this.keyboard = new KeyboardManager(); - try { - game.keybindings._registerCoreKeybindings(); - game.keybindings.initialize(); - } - catch(e) { - console.error(e); - } - } - - /* -------------------------------------------- */ - - /** - * Initialize Mouse controls - */ - initializeMouse() { - this.mouse = new MouseManager(); - } - - /* -------------------------------------------- */ - - /** - * Initialize Gamepad controls - */ - initializeGamepads() { - this.gamepad = new GamepadManager(); - } - - /* -------------------------------------------- */ - - /** - * Register core game settings - */ - registerSettings() { - - // Permissions Control Menu - game.settings.registerMenu("core", "permissions", { - name: "PERMISSION.Configure", - label: "PERMISSION.ConfigureLabel", - hint: "PERMISSION.ConfigureHint", - icon: "fas fa-user-lock", - type: PermissionConfig, - restricted: true - }); - - // User Role Permissions - game.settings.register("core", "permissions", { - name: "Permissions", - scope: "world", - default: {}, - type: Object, - config: false, - onChange: permissions => { - game.permissions = permissions; - if ( ui.controls ) ui.controls.initialize(); - if ( ui.sidebar ) ui.sidebar.render(); - } - }); - - // WebRTC Control Menu - game.settings.registerMenu("core", "webrtc", { - name: "WEBRTC.Title", - label: "WEBRTC.MenuLabel", - hint: "WEBRTC.MenuHint", - icon: "fas fa-headset", - type: AVConfig, - restricted: false - }); - - // RTC World Settings - game.settings.register("core", "rtcWorldSettings", { - name: "WebRTC (Audio/Video Conferencing) World Settings", - scope: "world", - default: AVSettings.DEFAULT_WORLD_SETTINGS, - type: Object, - onChange: () => game.webrtc.settings.changed() - }); - - // RTC Client Settings - game.settings.register("core", "rtcClientSettings", { - name: "WebRTC (Audio/Video Conferencing) Client specific Configuration", - scope: "client", - default: AVSettings.DEFAULT_CLIENT_SETTINGS, - type: Object, - onChange: () => game.webrtc.settings.changed() - }); - - // Default Token Configuration - game.settings.registerMenu("core", DefaultTokenConfig.SETTING, { - name: "SETTINGS.DefaultTokenN", - label: "SETTINGS.DefaultTokenL", - hint: "SETTINGS.DefaultTokenH", - icon: "fas fa-user-alt", - type: DefaultTokenConfig, - restricted: true - }); - - // Default Token Settings - game.settings.register("core", DefaultTokenConfig.SETTING, { - name: "SETTINGS.DefaultTokenN", - hint: "SETTINGS.DefaultTokenL", - scope: "world", - type: Object, - default: {} - }); - - // Font Configuration - game.settings.registerMenu("core", FontConfig.SETTING, { - name: "SETTINGS.FontConfigN", - label: "SETTINGS.FontConfigL", - hint: "SETTINGS.FontConfigH", - icon: "fa-solid fa-font", - type: FontConfig, - restricted: true - }); - - // Font Configuration Settings - game.settings.register("core", FontConfig.SETTING, { - scope: "world", - type: Object, - default: {} - }); - - // No-Canvas Mode - game.settings.register("core", "noCanvas", { - name: "SETTINGS.NoCanvasN", - hint: "SETTINGS.NoCanvasL", - scope: "client", - config: true, - type: Boolean, - default: false, - requiresReload: true - }); - - // Language preference - game.settings.register("core", "language", { - name: "SETTINGS.LangN", - hint: "SETTINGS.LangL", - scope: "client", - config: true, - default: game.i18n.lang, - type: String, - choices: CONFIG.supportedLanguages, - requiresReload: true - }); - - // Chat message roll mode - game.settings.register("core", "rollMode", { - name: "Default Roll Mode", - scope: "client", - config: false, - default: CONST.DICE_ROLL_MODES.PUBLIC, - type: String, - choices: CONFIG.Dice.rollModes, - onChange: ChatLog._setRollMode - }); - - // World time - game.settings.register("core", "time", { - name: "World Time", - scope: "world", - config: false, - default: 0, - type: Number, - onChange: this.time.onUpdateWorldTime.bind(this.time) - }); - - // Register module configuration settings - game.settings.register("core", ModuleManagement.CONFIG_SETTING, { - name: "Module Configuration Settings", - scope: "world", - config: false, - default: {}, - type: Object, - requiresReload: true - }); - - // Register compendium visibility setting - game.settings.register("core", CompendiumCollection.CONFIG_SETTING, { - name: "Compendium Configuration", - scope: "world", - config: false, - default: {}, - type: Object, - onChange: () => { - this.initializePacks(); - ui.compendium.render(); - } - }); - - // Combat Tracker Configuration - game.settings.register("core", Combat.CONFIG_SETTING, { - name: "Combat Tracker Configuration", - scope: "world", - config: false, - default: {}, - type: Object, - onChange: () => { - if (game.combat) { - game.combat.reset(); - game.combats.render(); - } - } - }); - - // Document Sheet Class Configuration - game.settings.register("core", "sheetClasses", { - name: "Sheet Class Configuration", - scope: "world", - config: false, - default: {}, - type: Object, - onChange: setting => DocumentSheetConfig.updateDefaultSheets(setting) - }); - - // Are Chat Bubbles Enabled? - game.settings.register("core", "chatBubbles", { - name: "SETTINGS.CBubN", - hint: "SETTINGS.CBubL", - scope: "client", - config: true, - default: true, - type: Boolean - }); - - // Pan to Token Speaker - game.settings.register("core", "chatBubblesPan", { - name: "SETTINGS.CBubPN", - hint: "SETTINGS.CBubPL", - scope: "client", - config: true, - default: true, - type: Boolean - }); - - // Scrolling Status Text - game.settings.register("core", "scrollingStatusText", { - name: "SETTINGS.ScrollStatusN", - hint: "SETTINGS.ScrollStatusL", - scope: "world", - config: true, - default: true, - type: Boolean - }); - - // Disable Resolution Scaling - game.settings.register("core", "pixelRatioResolutionScaling", { - name: "SETTINGS.ResolutionScaleN", - hint: "SETTINGS.ResolutionScaleL", - scope: "client", - config: true, - default: true, - type: Boolean, - requiresReload: true - }); - - // Left-Click Deselection - game.settings.register("core", "leftClickRelease", { - name: "SETTINGS.LClickReleaseN", - hint: "SETTINGS.LClickReleaseL", - scope: "client", - config: true, - default: false, - type: Boolean - }); - - // Canvas Performance Mode - game.settings.register("core", "performanceMode", { - name: "SETTINGS.PerformanceModeN", - hint: "SETTINGS.PerformanceModeL", - scope: "client", - config: true, - type: Number, - default: -1, - choices: { - [CONST.CANVAS_PERFORMANCE_MODES.LOW]: "SETTINGS.PerformanceModeLow", - [CONST.CANVAS_PERFORMANCE_MODES.MED]: "SETTINGS.PerformanceModeMed", - [CONST.CANVAS_PERFORMANCE_MODES.HIGH]: "SETTINGS.PerformanceModeHigh", - [CONST.CANVAS_PERFORMANCE_MODES.MAX]: "SETTINGS.PerformanceModeMax" - }, - onChange: () => { - canvas._configurePerformanceMode(); - return canvas.ready ? canvas.draw() : null; - } - }); - - // Maximum Framerate - game.settings.register("core", "maxFPS", { - name: "SETTINGS.MaxFPSN", - hint: "SETTINGS.MaxFPSL", - scope: "client", - config: true, - type: Number, - range: {min: 10, max: 60, step: 10}, - default: 60, - onChange: () => { - canvas._configurePerformanceMode(); - return canvas.ready ? canvas.draw() : null; - } - }); - - // FPS Meter - game.settings.register("core", "fpsMeter", { - name: "SETTINGS.FPSMeterN", - hint: "SETTINGS.FPSMeterL", - scope: "client", - config: true, - type: Boolean, - default: false, - onChange: enabled => { - if ( enabled ) return canvas.activateFPSMeter(); - else return canvas.deactivateFPSMeter(); - } - }); - - // Font scale - game.settings.register("core", "fontSize", { - name: "SETTINGS.FontSizeN", - hint: "SETTINGS.FontSizeL", - scope: "client", - config: true, - type: Number, - range: {min: 1, max: 10, step: 1}, - default: 5, - onChange: () => game.scaleFonts() - }); - - // Photosensitivity mode. - game.settings.register("core", "photosensitiveMode", { - name: "SETTINGS.PhotosensitiveModeN", - hint: "SETTINGS.PhotosensitiveModeL", - scope: "client", - config: true, - type: Boolean, - default: false - }); - - // Live Token Drag Preview - game.settings.register("core", "tokenDragPreview", { - name: "SETTINGS.TokenDragPreviewN", - hint: "SETTINGS.TokenDragPreviewL", - scope: "world", - config: true, - default: false, - type: Boolean - }); - - // Animated Token Vision - game.settings.register("core", "visionAnimation", { - name: "SETTINGS.AnimVisionN", - hint: "SETTINGS.AnimVisionL", - config: true, - type: Boolean, - default: true - }); - - // Light Source Flicker - game.settings.register("core", "lightAnimation", { - name: "SETTINGS.AnimLightN", - hint: "SETTINGS.AnimLightL", - config: true, - type: Boolean, - default: true, - onChange: () => canvas.effects?.activateAnimation() - }); - - // Mipmap Antialiasing - game.settings.register("core", "mipmap", { - name: "SETTINGS.MipMapN", - hint: "SETTINGS.MipMapL", - config: true, - type: Boolean, - default: true, - onChange: () => canvas.ready ? canvas.draw() : null - }); - - // Default Drawing Configuration - game.settings.register("core", DrawingsLayer.DEFAULT_CONFIG_SETTING, { - name: "Default Drawing Configuration", - scope: "client", - config: false, - default: {}, - type: Object - }); - - // Keybindings - game.settings.register("core", "keybindings", { - scope: "client", - config: false, - type: Object, - default: {}, - onChange: () => game.keybindings.initialize() - }); - - // New User Experience - game.settings.register("core", "nue.shownTips", { - scope: "world", - type: Boolean, - default: false, - config: false - }); - - // Tours - game.settings.register("core", "tourProgress", { - scope: "client", - config: false, - type: Object, - default: {} - }); - - // Editor autosave. - game.settings.register("core", "editorAutosaveSecs", { - name: "SETTINGS.EditorAutosaveN", - hint: "SETTINGS.EditorAutosaveH", - scope: "world", - config: true, - type: Number, - default: 60, - range: {min: 30, max: 300, step: 10} - }); - - // Combat Theme - game.settings.register("core", "combatTheme", { - name: "SETTINGS.CombatThemeN", - hint: "SETTINGS.CombatThemeL", - scope: "client", - config: true, - type: String, - choices: Object.entries(CONFIG.Combat.sounds) - .reduce( (choices, s) => {choices[s[0]] = game.i18n.localize(s[1].label); return choices;} - , { "none": game.i18n.localize("SETTINGS.None") }), - default: "none" - }); - - // Document-specific settings - RollTables.registerSettings(); - - // Audio playback settings - AudioHelper.registerSettings(); - - // Register CanvasLayer settings - NotesLayer.registerSettings(); - TemplateLayer.registerSettings(); - } - - /* -------------------------------------------- */ - - /** - * Register core Tours - * @returns {Promise} - */ - async registerTours() { - try { - game.tours.register("core", "welcome", await SidebarTour.fromJSON("/tours/welcome.json")); - game.tours.register("core", "installingASystem", await SetupTour.fromJSON("/tours/installing-a-system.json")); - game.tours.register("core", "creatingAWorld", await SetupTour.fromJSON("/tours/creating-a-world.json")); - game.tours.register("core", "uiOverview", await Tour.fromJSON("/tours/ui-overview.json")); - game.tours.register("core", "sidebar", await SidebarTour.fromJSON("/tours/sidebar.json")); - game.tours.register("core", "canvasControls", await CanvasTour.fromJSON("/tours/canvas-controls.json")); - } - catch(err) { - console.error(err); - } - } - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * Is the current session user authenticated as an application administrator? - * @type {boolean} - */ - get isAdmin() { - return this.data.isAdmin; - } - - /* -------------------------------------------- */ - - /** - * The currently connected User document, or null if Users is not yet initialized - * @type {User|null} - */ - get user() { - return this.users ? this.users.current : null; - } - - /* -------------------------------------------- */ - - /** - * A convenience accessor for the currently viewed Combat encounter - * @type {Combat} - */ - get combat() { - return this.combats?.viewed; - } - - /* -------------------------------------------- */ - - /** - * A state variable which tracks whether the game session is currently paused - * @type {boolean} - */ - get paused() { - return this.data.paused; - } - - /* -------------------------------------------- */ - - /** - * A convenient reference to the currently active canvas tool - * @type {string} - */ - get activeTool() { - return ui.controls?.activeTool ?? "select"; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Toggle the pause state of the game - * Trigger the `pauseGame` Hook when the paused state changes - * @param {boolean} pause The desired pause state; true for paused, false for un-paused - * @param {boolean} [push=false] Push the pause state change to other connected clients? Requires an GM user. - * @returns {boolean} The new paused state - */ - togglePause(pause, push=false) { - this.data.paused = pause ?? !this.data.paused; - if (push && game.user.isGM) game.socket.emit("pause", this.data.paused); - ui.pause.render(); - Hooks.callAll("pauseGame", this.data.paused); - return this.data.paused; - } - - /* -------------------------------------------- */ - - /** - * Open Character sheet for current token or controlled actor - * @returns {ActorSheet|null} The ActorSheet which was toggled, or null if the User has no character - */ - toggleCharacterSheet() { - const token = canvas.ready && (canvas.tokens.controlled.length === 1) ? canvas.tokens.controlled[0] : null; - const actor = token ? token.actor : game.user.character; - if ( !actor ) return null; - const sheet = actor.sheet; - if ( sheet.rendered ) { - if ( sheet._minimized ) sheet.maximize(); - else sheet.close(); - } - else sheet.render(true); - return sheet; - } - - /* -------------------------------------------- */ - - /** - * Log out of the game session by returning to the Join screen - */ - logOut() { - if ( this.socket ) this.socket.disconnect(); - window.location.href = foundry.utils.getRoute("join"); - } - - /* -------------------------------------------- */ - - /** - * Scale the base font size according to the user's settings. - * @param {number} [index] Optionally supply a font size index to use, otherwise use the user's setting. - * Available font sizes, starting at index 1, are: 8, 10, 12, 14, 16, 18, 20, 24, 28, and 32. - */ - scaleFonts(index) { - const fontSizes = [8, 10, 12, 14, 16, 18, 20, 24, 28, 32]; - index = index ?? game.settings.get("core", "fontSize"); - const size = fontSizes[index - 1] || 16; - document.documentElement.style.fontSize = `${size}px`; - } - - /* -------------------------------------------- */ - /* Socket Listeners and Handlers */ - /* -------------------------------------------- */ - - /** - * Activate Socket event listeners which are used to transact game state data with the server - */ - activateSocketListeners() { - let disconnectedTime = 0; - let reconnectTimeRequireRefresh = 5000; - - // Disconnection and reconnection attempts - this.socket.on("disconnect", () => { - disconnectedTime = Date.now(); - ui.notifications.error("You have lost connection to the server, attempting to re-establish."); - }); - - // Reconnect attempt - this.socket.io.on("reconnect_attempt", () => { - const t = Date.now(); - console.log(`${vtt} | Attempting to re-connect: ${((t - disconnectedTime) / 1000).toFixed(2)} seconds`); - }); - - - // Reconnect failed - this.socket.io.on("reconnect_failed", () => { - ui.notifications.error(`${vtt} | Server connection lost.`); - window.location.href = foundry.utils.getRoute("no"); - }); - - // Reconnect succeeded - this.socket.io.on("reconnect", () => { - ui.notifications.info(`${vtt} | Server connection re-established.`); - if ( (Date.now() - disconnectedTime) >= reconnectTimeRequireRefresh ) { - foundry.utils.debouncedReload(); - } - }); - - // Game pause - this.socket.on("pause", pause => { - game.togglePause(pause, false); - }); - - // Game shutdown - this.socket.on("shutdown", () => { - ui.notifications.info("The game world is shutting down and you will be returned to the server homepage.", { - permanent: true - }); - setTimeout(() => window.location.href = foundry.utils.getRoute("/"), 1000); - }); - - // Application reload. - this.socket.on("reload", () => foundry.utils.debouncedReload()); - - // Database Operations - CONFIG.DatabaseBackend.activateSocketListeners(this.socket); - - // Additional events - AudioHelper._activateSocketListeners(this.socket); - Users._activateSocketListeners(this.socket); - Scenes._activateSocketListeners(this.socket); - Journal._activateSocketListeners(this.socket); - ChatBubbles._activateSocketListeners(this.socket); - ProseMirrorEditor._activateSocketListeners(this.socket); - } - - /* -------------------------------------------- */ - /* Event Listeners and Handlers */ - /* -------------------------------------------- */ - - /** - * Activate Event Listeners which apply to every Game View - */ - activateListeners() { - - // Disable touch zoom - document.addEventListener("touchmove", ev => { - if (ev.scale !== 1) ev.preventDefault(); - }); - - // Disable right-click - document.addEventListener("contextmenu", ev => ev.preventDefault()); - - // Disable mouse 3, 4, and 5 - document.addEventListener("pointerdown", this._onPointerDown); - document.addEventListener("pointerup", this._onPointerUp); - - // Prevent dragging and dropping unless a more specific handler allows it - document.addEventListener("dragstart", this._onPreventDragstart); - document.addEventListener("dragover", this._onPreventDragover); - document.addEventListener("drop", this._onPreventDrop); - - // Support mousewheel interaction for range input elements - window.addEventListener("wheel", Game._handleMouseWheelInputChange, {passive: false}); - - // Tooltip rendering - this.tooltip.activateEventListeners(); - - // Document links - TextEditor.activateListeners(); - - // Await gestures to begin audio and video playback - game.video.awaitFirstGesture(); - - // Handle changes to the state of the browser window - window.addEventListener("beforeunload", this._onWindowBeforeUnload); - window.addEventListener("blur", this._onWindowBlur); - window.addEventListener("resize", this._onWindowResize); - if ( this.view === "game" ) { - history.pushState(null, null, location.href); - window.addEventListener("popstate", this._onWindowPopState); - } - - // Force hyperlinks to a separate window/tab - document.addEventListener("click", this._onClickHyperlink); - } - - /* -------------------------------------------- */ - - /** - * Support mousewheel control for range type input elements - * @param {WheelEvent} event A Mouse Wheel scroll event - * @private - */ - static _handleMouseWheelInputChange(event) { - const r = event.target; - if ( (r.tagName !== "INPUT") || (r.type !== "range") || r.disabled ) return; - event.preventDefault(); - event.stopPropagation(); - - // Adjust the range slider by the step size - const step = (parseFloat(r.step) || 1.0) * Math.sign(-1 * event.deltaY); - r.value = Math.clamped(parseFloat(r.value) + step, parseFloat(r.min), parseFloat(r.max)); - - // Dispatch a change event that can bubble upwards to the parent form - const ev = new Event("change", {bubbles: true}); - r.dispatchEvent(ev); - } - - /* -------------------------------------------- */ - - /** - * On left mouse clicks, check if the element is contained in a valid hyperlink and open it in a new tab. - * @param {MouseEvent} event - * @private - */ - _onClickHyperlink(event) { - const a = event.target.closest("a[href]"); - if ( !a || (a.href === "javascript:void(0)") || a.closest(".editor-content.ProseMirror") ) return; - event.preventDefault(); - window.open(a.href, "_blank"); - } - - /* -------------------------------------------- */ - - /** - * Prevent starting a drag and drop workflow on elements within the document unless the element has the draggable - * attribute explicitly defined or overrides the dragstart handler. - * @param {DragEvent} event The initiating drag start event - * @private - */ - _onPreventDragstart(event) { - const target = event.target; - const inProseMirror = (target.nodeType === Node.TEXT_NODE) && target.parentElement.closest(".ProseMirror"); - if ( (target.getAttribute?.("draggable") === "true") || inProseMirror ) return; - event.preventDefault(); - return false; - } - - /* -------------------------------------------- */ - - /** - * Disallow dragging of external content onto anything but a file input element - * @param {DragEvent} event The requested drag event - * @private - */ - _onPreventDragover(event) { - const target = event.target; - if ( (target.tagName !== "INPUT") || (target.type !== "file") ) event.preventDefault(); - } - - /* -------------------------------------------- */ - - /** - * Disallow dropping of external content onto anything but a file input element - * @param {DragEvent} event The requested drag event - * @private - */ - _onPreventDrop(event) { - const target = event.target; - if ( (target.tagName !== "INPUT") || (target.type !== "file") ) event.preventDefault(); - } - - /* -------------------------------------------- */ - - /** - * On a left-click event, remove any currently displayed inline roll tooltip - * @param {PointerEvent} event The mousedown pointer event - * @private - */ - _onPointerDown(event) { - if ([3, 4, 5].includes(event.button)) event.preventDefault(); - const inlineRoll = document.querySelector(".inline-roll.expanded"); - if ( inlineRoll && !event.target.closest(".inline-roll") ) { - return Roll.defaultImplementation.collapseInlineResult(inlineRoll); - } - } - - /* -------------------------------------------- */ - - /** - * Fallback handling for mouse-up events which aren't handled further upstream. - * @param {PointerEvent} event The mouseup pointer event - * @private - */ - _onPointerUp(event) { - const cmm = canvas.currentMouseManager; - if ( !cmm || event.defaultPrevented ) return; - cmm.cancel(event); - } - - /* -------------------------------------------- */ - - /** - * Handle resizing of the game window by adjusting the canvas and repositioning active interface applications. - * @param {Event} event The window resize event which has occurred - * @private - */ - _onWindowResize(event) { - Object.values(ui.windows).forEach(app => { - app.setPosition({top: app.position.top, left: app.position.left}); - }); - ui.webrtc?.setPosition({height: "auto"}); - if (canvas && canvas.ready) return canvas._onResize(event); - } - - /* -------------------------------------------- */ - - /** - * Handle window unload operations to clean up any data which may be pending a final save - * @param {Event} event The window unload event which is about to occur - * @private - */ - _onWindowBeforeUnload(event) { - if ( canvas.ready ) { - canvas.fog.commit(); - // Save the fog immediately rather than waiting for the 3s debounced save as part of commitFog. - return canvas.fog.save(); - } - } - - /* -------------------------------------------- */ - - /** - * Handle cases where the browser window loses focus to reset detection of currently pressed keys - * @param {Event} event The originating window.blur event - * @private - */ - _onWindowBlur(event) { - game.keyboard?.releaseKeys(); - } - - /* -------------------------------------------- */ - - _onWindowPopState(event) { - if ( game._goingBack ) return; - history.pushState(null, null, location.href); - if ( confirm(game.i18n.localize("APP.NavigateBackConfirm")) ) { - game._goingBack = true; - history.back(); - history.back(); - } - } - - /* -------------------------------------------- */ - /* View Handlers */ - /* -------------------------------------------- */ - - /** - * Initialize elements required for the current view - * @private - */ - async _initializeView() { - switch (this.view) { - case "game": - return this._initializeGameView(); - case "stream": - return this._initializeStreamView(); - default: - throw new Error(`Unknown view URL ${this.view} provided`); - } - } - - /* -------------------------------------------- */ - - /** - * Initialization steps for the primary Game view - * @private - */ - async _initializeGameView() { - - // Require a valid user cookie and EULA acceptance - if ( !globalThis.SIGNED_EULA ) window.location.href = foundry.utils.getRoute("license"); - if (!this.userId) { - console.error("Invalid user session provided - returning to login screen."); - this.logOut(); - } - - // Set up the game - await this.setupGame(); - - // Set a timeout of 10 minutes before kicking the user off - if ( this.data.demoMode && !this.user.isGM ) { - setTimeout(() => { - console.log(`${vtt} | Ending demo session after 10 minutes. Thanks for testing!`); - this.logOut(); - }, 1000 * 60 * 10); - } - - // Context menu listeners - ContextMenu.eventListeners(); - } - - /* -------------------------------------------- */ - - /** - * Initialization steps for the Stream helper view - * @private - */ - async _initializeStreamView() { - if ( !globalThis.SIGNED_EULA ) window.location.href = foundry.utils.getRoute("license"); - this.initializeDocuments(); - ui.chat = new ChatLog({stream: true}); - ui.chat.render(true); - CONFIG.DatabaseBackend.activateSocketListeners(this.socket); - } -} - -/** - * An interface and API for constructing and evaluating dice rolls. - * The basic structure for a dice roll is a string formula and an object of data against which to parse it. - * - * @param {string} formula The string formula to parse - * @param {object} data The data object against which to parse attributes within the formula - * - * @example Attack with advantage - * ```js - * // Construct the Roll instance - * let r = new Roll("2d20kh + @prof + @strMod", {prof: 2, strMod: 4}); - * - * // The parsed terms of the roll formula - * console.log(r.terms); // [Die, OperatorTerm, NumericTerm, OperatorTerm, NumericTerm] - * - * // Execute the roll - * await r.evaluate(); - * - * // The resulting equation after it was rolled - * console.log(r.result); // 16 + 2 + 4 - * - * // The total resulting from the roll - * console.log(r.total); // 22 - * ``` - */ -class Roll { - constructor(formula, data={}, options={}) { - - /** - * The original provided data object which substitutes into attributes of the roll formula - * @type {Object} - */ - this.data = this._prepareData(data); - - /** - * Options which modify or describe the Roll - * @type {object} - */ - this.options = options; - - /** - * The identified terms of the Roll - * @type {RollTerm[]} - */ - this.terms = this.constructor.parse(formula, this.data); - - /** - * An array of inner DiceTerms which were evaluated as part of the Roll evaluation - * @type {DiceTerm[]} - */ - this._dice = []; - - /** - * Store the original cleaned formula for the Roll, prior to any internal evaluation or simplification - * @type {string} - */ - this._formula = this.constructor.getFormula(this.terms); - - /** - * Track whether this Roll instance has been evaluated or not. Once evaluated the Roll is immutable. - * @type {boolean} - */ - this._evaluated = false; - - /** - * Cache the numeric total generated through evaluation of the Roll. - * @type {number} - * @private - */ - this._total = undefined; - } - - /** - * A Proxy environment for safely evaluating a string using only available Math functions - * @type {Math} - */ - static MATH_PROXY = new Proxy(Math, {has: () => true, get: (t, k) => k === Symbol.unscopables ? undefined : t[k]}); - - /** - * The HTML template path used to render a complete Roll object to the chat log - * @type {string} - */ - static CHAT_TEMPLATE = "templates/dice/roll.html"; - - /** - * The HTML template used to render an expanded Roll tooltip to the chat log - * @type {string} - */ - static TOOLTIP_TEMPLATE = "templates/dice/tooltip.html"; - - /* -------------------------------------------- */ - - /** - * Prepare the data structure used for the Roll. - * This is factored out to allow for custom Roll classes to do special data preparation using provided input. - * @param {object} data Provided roll data - * @returns {object} The prepared data object - * @protected - */ - _prepareData(data) { - return data; - } - - /* -------------------------------------------- */ - /* Roll Attributes */ - /* -------------------------------------------- */ - - /** - * Return an Array of the individual DiceTerm instances contained within this Roll. - * @return {DiceTerm[]} - */ - get dice() { - return this._dice.concat(this.terms.reduce((dice, t) => { - if ( t instanceof DiceTerm ) dice.push(t); - else if ( t instanceof PoolTerm ) dice = dice.concat(t.dice); - return dice; - }, [])); - } - - /* -------------------------------------------- */ - - /** - * Return a standardized representation for the displayed formula associated with this Roll. - * @return {string} - */ - get formula() { - return this.constructor.getFormula(this.terms); - } - - /* -------------------------------------------- */ - - /** - * The resulting arithmetic expression after rolls have been evaluated - * @return {string} - */ - get result() { - return this.terms.map(t => t.total).join(""); - } - - /* -------------------------------------------- */ - - /** - * Return the total result of the Roll expression if it has been evaluated. - * @type {number} - */ - get total() { - return this._total; - } - - /* -------------------------------------------- */ - - /** - * Whether this Roll contains entirely deterministic terms or whether there is some randomness. - * @type {boolean} - */ - get isDeterministic() { - return this.terms.every(t => t.isDeterministic); - } - - /* -------------------------------------------- */ - /* Roll Instance Methods */ - /* -------------------------------------------- */ - - /** - * Alter the Roll expression by adding or multiplying the number of dice which are rolled - * @param {number} multiply A factor to multiply. Dice are multiplied before any additions. - * @param {number} add A number of dice to add. Dice are added after multiplication. - * @param {boolean} [multiplyNumeric] Apply multiplication factor to numeric scalar terms - * @return {Roll} The altered Roll expression - */ - alter(multiply, add, {multiplyNumeric=false}={}) { - if ( this._evaluated ) throw new Error("You may not alter a Roll which has already been evaluated"); - - // Alter dice and numeric terms - this.terms = this.terms.map(term => { - if ( term instanceof DiceTerm ) return term.alter(multiply, add); - else if ( (term instanceof NumericTerm) && multiplyNumeric ) term.number *= multiply; - return term; - }); - - // Update the altered formula and return the altered Roll - this._formula = this.constructor.getFormula(this.terms); - return this; - } - - /* -------------------------------------------- */ - - /** - * Clone the Roll instance, returning a new Roll instance that has not yet been evaluated. - * @return {Roll} - */ - clone() { - return new this.constructor(this._formula, this.data, this.options); - } - - /* -------------------------------------------- */ - - /** - * Execute the Roll, replacing dice and evaluating the total result - * @param {object} [options={}] Options which inform how the Roll is evaluated - * @param {boolean} [options.minimize=false] Minimize the result, obtaining the smallest possible value. - * @param {boolean} [options.maximize=false] Maximize the result, obtaining the largest possible value. - * @param {boolean} [options.async=true] Evaluate the roll asynchronously, receiving a Promise as the returned value. - * This will become the default behavior in version 10.x - * @returns {Roll|Promise} The evaluated Roll instance - * - * @example Evaluate a Roll expression - * ```js - * let r = new Roll("2d6 + 4 + 1d4"); - * await r.evaluate(); - * console.log(r.result); // 5 + 4 + 2 - * console.log(r.total); // 11 - * ``` - */ - evaluate({minimize=false, maximize=false, async}={}) { - if ( this._evaluated ) { - throw new Error(`The ${this.constructor.name} has already been evaluated and is now immutable`); - } - this._evaluated = true; - if ( CONFIG.debug.dice ) console.debug(`Evaluating roll with formula ${this.formula}`); - - // Migration path for async rolls - if ( minimize || maximize ) async = false; - if ( async === undefined ) { - foundry.utils.logCompatibilityWarning("Roll#evaluate is becoming asynchronous. In the short term, you may pass " - + "async=true or async=false to evaluation options to nominate your preferred behavior.", {since: 8, until: 10}); - async = true; - } - return async ? this._evaluate({minimize, maximize}) : this._evaluateSync({minimize, maximize}); - } - - /* -------------------------------------------- */ - - /** - * Evaluate the roll asynchronously. - * A temporary helper method used to migrate behavior from 0.7.x (sync by default) to 0.9.x (async by default). - * @param {object} [options] Options which inform how evaluation is performed - * @param {boolean} [options.minimize] Force the result to be minimized - * @param {boolean} [options.maximize] Force the result to be maximized - * @returns {Promise} - * @private - */ - async _evaluate({minimize=false, maximize=false}={}) { - - // Step 1 - Replace intermediate terms with evaluated numbers - const intermediate = []; - for ( let term of this.terms ) { - if ( !(term instanceof RollTerm) ) { - throw new Error("Roll evaluation encountered an invalid term which was not a RollTerm instance"); - } - if ( term.isIntermediate ) { - await term.evaluate({minimize, maximize, async: true}); - this._dice = this._dice.concat(term.dice); - term = new NumericTerm({number: term.total, options: term.options}); - } - intermediate.push(term); - } - this.terms = intermediate; - - // Step 2 - Simplify remaining terms - this.terms = this.constructor.simplifyTerms(this.terms); - - // Step 3 - Evaluate remaining terms - for ( let term of this.terms ) { - if ( !term._evaluated ) await term.evaluate({minimize, maximize, async: true}); - } - - // Step 4 - Evaluate the final expression - this._total = this._evaluateTotal(); - return this; - } - - /* -------------------------------------------- */ - - /** - * Evaluate the roll synchronously. - * A temporary helper method used to migrate behavior from 0.7.x (sync by default) to 0.9.x (async by default). - * @param {object} [options] Options which inform how evaluation is performed - * @param {boolean} [options.minimize] Force the result to be minimized - * @param {boolean} [options.maximize] Force the result to be maximized - * @returns {Roll} - * @private - */ - _evaluateSync({minimize=false, maximize=false}={}) { - - // Step 1 - Replace intermediate terms with evaluated numbers - this.terms = this.terms.map(term => { - if ( !(term instanceof RollTerm) ) { - throw new Error("Roll evaluation encountered an invalid term which was not a RollTerm instance"); - } - if ( term.isIntermediate ) { - term.evaluate({minimize, maximize, async: false}); - this._dice = this._dice.concat(term.dice); - return new NumericTerm({number: term.total, options: term.options}); - } - return term; - }); - - // Step 2 - Simplify remaining terms - this.terms = this.constructor.simplifyTerms(this.terms); - - // Step 3 - Evaluate remaining terms - for ( let term of this.terms ) { - if ( !term._evaluated ) term.evaluate({minimize, maximize, async: false}); - } - - // Step 4 - Evaluate the final expression - this._total = this._evaluateTotal(); - return this; - } - - /* -------------------------------------------- */ - - /** - * Safely evaluate the final total result for the Roll using its component terms. - * @returns {number} The evaluated total - * @private - */ - _evaluateTotal() { - const expression = this.terms.map(t => t.total).join(" "); - const total = this.constructor.safeEval(expression); - if ( !Number.isNumeric(total) ) { - throw new Error(game.i18n.format("DICE.ErrorNonNumeric", {formula: this.formula})); - } - return total; - } - - /* -------------------------------------------- */ - - /** - * Alias for evaluate. - * @see {Roll#evaluate} - */ - roll(options={}) { - return this.evaluate(options); - } - - /* -------------------------------------------- */ - - /** - * Create a new Roll object using the original provided formula and data. - * Each roll is immutable, so this method returns a new Roll instance using the same data. - * @param {object} [options={}] Evaluation options passed to Roll#evaluate - * @return {Roll} A new Roll object, rolled using the same formula and data - */ - reroll(options={}) { - const r = this.clone(); - return r.evaluate(options); - } - - /* -------------------------------------------- */ - /* Static Class Methods */ - /* -------------------------------------------- */ - - /** - * A factory method which constructs a Roll instance using the default configured Roll class. - * @param {string} formula The formula used to create the Roll instance - * @param {object} [data={}] The data object which provides component data for the formula - * @param {object} [options={}] Additional options which modify or describe this Roll - * @return {Roll} The constructed Roll instance - */ - static create(formula, data={}, options={}) { - const cls = CONFIG.Dice.rolls[0]; - return new cls(formula, data, options); - } - - /* -------------------------------------------- */ - - /** - * Get the default configured Roll class. - * @returns {typeof Roll} - */ - static get defaultImplementation() { - return CONFIG.Dice.rolls[0]; - } - - /* -------------------------------------------- */ - - /** - * Transform an array of RollTerm objects into a cleaned string formula representation. - * @param {RollTerm[]} terms An array of terms to represent as a formula - * @returns {string} The string representation of the formula - */ - static getFormula(terms) { - return terms.map(t => t.formula).join(""); - } - - /* -------------------------------------------- */ - - /** - * A sandbox-safe evaluation function to execute user-input code with access to scoped Math methods. - * @param {string} expression The input string expression - * @returns {number} The numeric evaluated result - */ - static safeEval(expression) { - let result; - try { - const src = 'with (sandbox) { return ' + expression + '}'; - const evl = new Function('sandbox', src); - result = evl(this.MATH_PROXY); - } catch { - result = undefined; - } - if ( !Number.isNumeric(result) ) { - throw new Error(`Roll.safeEval produced a non-numeric result from expression "${expression}"`); - } - return result; - }; - - /* -------------------------------------------- */ - - /** - * After parenthetical and arithmetic terms have been resolved, we need to simplify the remaining expression. - * Any remaining string terms need to be combined with adjacent non-operators in order to construct parsable terms. - * @param {RollTerm[]} terms An array of terms which is eligible for simplification - * @returns {RollTerm[]} An array of simplified terms - */ - static simplifyTerms(terms) { - - // Simplify terms by combining with pending strings - let simplified = terms.reduce((terms, term) => { - const prior = terms[terms.length - 1]; - const isOperator = term instanceof OperatorTerm; - - // Combine a non-operator term with prior StringTerm - if ( !isOperator && (prior instanceof StringTerm) ) { - prior.term += term.total; - foundry.utils.mergeObject(prior.options, term.options); - return terms; - } - - // Combine StringTerm with a prior non-operator term - const priorOperator = prior instanceof OperatorTerm; - if ( prior && !priorOperator && (term instanceof StringTerm) ) { - term.term = String(prior.total) + term.term; - foundry.utils.mergeObject(term.options, prior.options); - terms[terms.length - 1] = term; - return terms; - } - - // Otherwise continue - terms.push(term); - return terms; - }, []); - - // Convert remaining String terms to a RollTerm which can be evaluated - simplified = simplified.map(term => { - if ( !(term instanceof StringTerm) ) return term; - const t = this._classifyStringTerm(term.formula, {intermediate: false}); - t.options = foundry.utils.mergeObject(term.options, t.options, {inplace: false}); - return t; - }); - - // Eliminate leading or trailing arithmetic - if ( (simplified[0] instanceof OperatorTerm) && (simplified[0].operator !== "-") ) simplified.shift(); - if ( simplified.at(-1) instanceof OperatorTerm ) simplified.pop(); - return simplified; - } - - /* -------------------------------------------- */ - - /** - * Simulate a roll and evaluate the distribution of returned results - * @param {string} formula The Roll expression to simulate - * @param {number} n The number of simulations - * @return {Promise} The rolled totals - */ - static async simulate(formula, n=10000) { - const results = await Promise.all([...Array(n)].map(async () => { - const r = new this(formula); - return (await r.evaluate({async: true})).total; - }, [])); - const summary = results.reduce((sum, v) => { - sum.total = sum.total + v; - if ( (sum.min === null) || (v < sum.min) ) sum.min = v; - if ( (sum.max === null) || (v > sum.max) ) sum.max = v; - return sum; - }, {total: 0, min: null, max: null}); - summary.mean = summary.total / n; - console.log(`Formula: ${formula} | Iterations: ${n} | Mean: ${summary.mean} | Min: ${summary.min} | Max: ${summary.max}`); - return results; - } - - /* -------------------------------------------- */ - /* Roll Formula Parsing */ - /* -------------------------------------------- */ - - /** - * Parse a formula by following an order of operations: - * - * Step 1: Replace formula data - * Step 2: Split outer-most parenthetical groups - * Step 3: Further split outer-most dice pool groups - * Step 4: Further split string terms on arithmetic operators - * Step 5: Classify all remaining strings - * - * @param {string} formula The original string expression to parse - * @param {object} data A data object used to substitute for attributes in the formula - * @returns {RollTerm[]} A parsed array of RollTerm instances - */ - static parse(formula, data) { - if ( !formula ) return []; - - // Step 1: Replace formula data and remove all spaces - let replaced = this.replaceFormulaData(formula, data, {missing: "0"}); - - // Step 2: Split outer-most outer-most parenthetical groups - let terms = this._splitParentheses(replaced); - - // Step 3: Split additional dice pool groups which may contain inner rolls - terms = terms.flatMap(term => { - return typeof term === "string" ? this._splitPools(term) : term; - }); - - // Step 4: Further split string terms on arithmetic operators - terms = terms.flatMap(term => { - return typeof term === "string" ? this._splitOperators(term) : term; - }); - - // Step 5: Classify all remaining strings - terms = terms.map((t, i) => this._classifyStringTerm(t, { - intermediate: true, - prior: terms[i-1], - next: terms[i+1] - })); - return terms; - } - - /* -------------------------------------------- */ - - /** - * Replace referenced data attributes in the roll formula with values from the provided data. - * Data references in the formula use the @attr syntax and would reference the corresponding attr key. - * - * @param {string} formula The original formula within which to replace - * @param {object} data The data object which provides replacements - * @param {string} [missing] The value that should be assigned to any unmatched keys. - * If null, the unmatched key is left as-is. - * @param {boolean} [warn] Display a warning notification when encountering an un-matched key. - * @static - */ - static replaceFormulaData(formula, data, {missing, warn=false}={}) { - let dataRgx = new RegExp(/@([a-z.0-9_\-]+)/gi); - return formula.replace(dataRgx, (match, term) => { - let value = foundry.utils.getProperty(data, term); - if ( value == null ) { - if ( warn && ui.notifications ) ui.notifications.warn(game.i18n.format("DICE.WarnMissingData", {match})); - return (missing !== undefined) ? String(missing) : match; - } - return String(value).trim(); - }); - } - - /* -------------------------------------------- */ - - /** - * Validate that a provided roll formula can represent a valid - * @param {string} formula A candidate formula to validate - * @return {boolean} Is the provided input a valid dice formula? - */ - static validate(formula) { - - // Replace all data references with an arbitrary number - formula = formula.replace(/@([a-z.0-9_\-]+)/gi, "1"); - - // Attempt to evaluate the roll - try { - const r = new this(formula); - r.evaluate({async: false}); - return true; - } - - // If we weren't able to evaluate, the formula is invalid - catch(err) { - return false; - } - } - - /* -------------------------------------------- */ - - /** - * Split a formula by identifying its outer-most parenthetical and math terms - * @param {string} _formula The raw formula to split - * @returns {string[]} An array of terms, split on parenthetical terms - * @private - */ - static _splitParentheses(_formula) { - return this._splitGroup(_formula, { - openRegexp: ParentheticalTerm.OPEN_REGEXP, - closeRegexp: ParentheticalTerm.CLOSE_REGEXP, - openSymbol: "(", - closeSymbol: ")", - onClose: group => { - - // Extract group arguments - const fn = group.open.slice(0, -1); - const expression = group.terms.join(""); - const options = { flavor: group.flavor ? group.flavor.slice(1, -1) : undefined }; - - // Classify the resulting terms - const terms = []; - if ( fn in Math ) { - const args = this._splitMathArgs(expression); - terms.push(new MathTerm({fn, terms: args, options})); - } - else { - if ( fn ) terms.push(fn); - terms.push(new ParentheticalTerm({term: expression, options})); - } - return terms; - } - }); - } - - /* -------------------------------------------- */ - - /** - * Handle closing of a parenthetical term to create a MathTerm expression with a function and arguments - * @returns {MathTerm[]} - * @private - */ - static _splitMathArgs(expression) { - return expression.split(",").reduce((args, t) => { - t = t.trim(); - if ( !t ) return args; // Blank args - if ( !args.length ) { // First arg - args.push(t); - return args; - } - const p = args[args.length-1]; // Prior arg - const priorValid = this.validate(p); - if ( priorValid ) args.push(t); - else args[args.length-1] = [p, t].join(","); // Collect inner parentheses or pools - return args; - }, []); - } - - /* -------------------------------------------- */ - - /** - * Split a formula by identifying its outer-most dice pool terms - * @param {string} _formula The raw formula to split - * @returns {string[]} An array of terms, split on parenthetical terms - * @private - */ - static _splitPools(_formula) { - return this._splitGroup(_formula, { - openRegexp: PoolTerm.OPEN_REGEXP, - closeRegexp: PoolTerm.CLOSE_REGEXP, - openSymbol: "{", - closeSymbol: "}", - onClose: group => { - const terms = this._splitMathArgs(group.terms.join("")); - const modifiers = Array.from(group.close.slice(1).matchAll(DiceTerm.MODIFIER_REGEXP)).map(m => m[0]); - const options = { flavor: group.flavor ? group.flavor.slice(1, -1) : undefined }; - return [new PoolTerm({terms, modifiers, options})]; - } - }) - } - - /* -------------------------------------------- */ - - /** - * Split a formula by identifying its outer-most groups using a certain group symbol like parentheses or brackets. - * @param {string} _formula The raw formula to split - * @param {object} options Options that configure how groups are split - * @returns {string[]} An array of terms, split on dice pool terms - * @private - */ - static _splitGroup(_formula, {openRegexp, closeRegexp, openSymbol, closeSymbol, onClose}={}) { - let {formula, flavors} = this._extractFlavors(_formula); - - // Split the formula on parentheses - const parts = formula.replace(openRegexp, ";$&;").replace(closeRegexp, ";$&;").split(";"); - let terms = []; - let nOpen = 0; - let group = {openIndex: undefined, open: "", terms: [], close: "", closeIndex: undefined, flavor: undefined}; - - // Handle closing a group - const closeGroup = t => { - - // Identify closing flavor text (and remove it) - const flavor = t.match(/\$\$F[0-9]+\$\$/); - if ( flavor ) { - group.flavor = this._restoreFlavor(flavor[0], flavors); - t = t.slice(0, flavor.index); - } - - // Treat the remainder as the closing symbol - group.close = t; - - // Restore flavor to member terms - group.terms = group.terms.map(t => this._restoreFlavor(t, flavors)); - terms = terms.concat(onClose(group)); - }; - - // Map parts to parenthetical groups - for ( let t of parts ) { - t = t.trim(); - if ( !t ) continue; - - // New open group - if ( t.endsWith(openSymbol) ) { - nOpen++; - - // Open a new group - if ( nOpen === 1 ) { - group = {open: t, terms: [], close: "", flavor: undefined}; - continue; - } - } - - // Continue an opened group - if ( nOpen > 0 ) { - if ( t.startsWith(closeSymbol) ) { - nOpen--; - - // Close the group - if ( nOpen === 0 ) { - closeGroup(t); - continue; - } - } - group.terms.push(t); - continue; - } - - // Regular remaining terms - terms.push(t); - } - - // If the group was not completely closed, continue closing it - if ( nOpen !== 0 ) { - throw new Error(`Unbalanced group missing opening ${openSymbol} or closing ${closeSymbol}`); - } - - // Restore withheld flavor text and re-combine strings - terms = terms.reduce((terms, t) => { - if ( typeof t === "string" ) { // Re-combine string terms - t = this._restoreFlavor(t, flavors); - if ( typeof terms[terms.length-1] === "string" ) terms[terms.length-1] = terms[terms.length-1] + t; - else terms.push(t); - } - else terms.push(t); // Intermediate terms - return terms; - }, []); - return terms; - } - - /* -------------------------------------------- */ - - /** - * Split a formula by identifying arithmetic terms - * @param {string} _formula The raw formula to split - * @returns {Array<(string|OperatorTerm)>} An array of terms, split on arithmetic operators - * @private - */ - static _splitOperators(_formula) { - let {formula, flavors} = this._extractFlavors(_formula); - const parts = formula.replace(OperatorTerm.REGEXP, ";$&;").split(";"); - return parts.reduce((terms, t) => { - t = t.trim(); - if ( !t ) return terms; - const isOperator = OperatorTerm.OPERATORS.includes(t) - terms.push(isOperator ? new OperatorTerm({operator: t}) : this._restoreFlavor(t, flavors)); - return terms; - },[]); - } - - /* -------------------------------------------- */ - - /** - * Temporarily remove flavor text from a string formula allowing it to be accurately parsed. - * @param {string} formula The formula to extract - * @returns {{formula: string, flavors: object}} The cleaned formula and extracted flavor mapping - * @private - */ - static _extractFlavors(formula) { - const flavors = {}; - let fn = 0; - formula = formula.replace(RollTerm.FLAVOR_REGEXP, flavor => { - let key = `$$F${fn++}$$`; - flavors[key] = flavor; - return key; - }); - return {formula, flavors}; - } - - /* -------------------------------------------- */ - - /** - * Restore flavor text to a string term - * @param {string} term The string term possibly containing flavor symbols - * @param {object} flavors The extracted flavors object - * @returns {string} The restored term containing flavor text - * @private - */ - static _restoreFlavor(term, flavors) { - for ( let [key, flavor] of Object.entries(flavors) ) { - if ( term.indexOf(key) !== -1 ) { - delete flavors[key]; - term = term.replace(key, flavor); - } - } - return term; - } - - /* -------------------------------------------- */ - - /** - * Classify a remaining string term into a recognized RollTerm class - * @param {string} term A remaining un-classified string - * @param {object} [options={}] Options which customize classification - * @param {boolean} [options.intermediate=true] Allow intermediate terms - * @param {RollTerm|string} [options.prior] The prior classified term - * @param {RollTerm|string} [options.next] The next term to classify - * @returns {RollTerm} A classified RollTerm instance - * @internal - */ - static _classifyStringTerm(term, {intermediate=true, prior, next}={}) { - - // Terms already classified - if ( term instanceof RollTerm ) return term; - - // Numeric terms - const numericMatch = NumericTerm.matchTerm(term); - if ( numericMatch ) return NumericTerm.fromMatch(numericMatch); - - // Dice terms - const diceMatch = DiceTerm.matchTerm(term, {imputeNumber: !intermediate}); - if ( diceMatch ) { - if ( intermediate && (prior?.isIntermediate || next?.isIntermediate) ) return new StringTerm({term}); - return DiceTerm.fromMatch(diceMatch); - } - - // Remaining strings - return new StringTerm({term}); - } - - /* -------------------------------------------- */ - /* Chat Messages */ - /* -------------------------------------------- */ - - /** - * Render the tooltip HTML for a Roll instance - * @return {Promise} The rendered HTML tooltip as a string - */ - async getTooltip() { - const parts = this.dice.map(d => d.getTooltipData()); - return renderTemplate(this.constructor.TOOLTIP_TEMPLATE, { parts }); - } - - /* -------------------------------------------- */ - - /** - * Render a Roll instance to HTML - * @param {object} [options={}] Options which affect how the Roll is rendered - * @param {string} [options.flavor] Flavor text to include - * @param {string} [options.template] A custom HTML template path - * @param {boolean} [options.isPrivate=false] Is the Roll displayed privately? - * @returns {Promise} The rendered HTML template as a string - */ - async render({flavor, template=this.constructor.CHAT_TEMPLATE, isPrivate=false}={}) { - if ( !this._evaluated ) await this.evaluate({async: true}); - const chatData = { - formula: isPrivate ? "???" : this._formula, - flavor: isPrivate ? null : flavor, - user: game.user.id, - tooltip: isPrivate ? "" : await this.getTooltip(), - total: isPrivate ? "?" : Math.round(this.total * 100) / 100, - }; - return renderTemplate(template, chatData); - } - - /* -------------------------------------------- */ - - /** - * Transform a Roll instance into a ChatMessage, displaying the roll result. - * This function can either create the ChatMessage directly, or return the data object that will be used to create. - * - * @param {object} messageData The data object to use when creating the message - * @param {options} [options] Additional options which modify the created message. - * @param {string} [options.rollMode] The template roll mode to use for the message from CONFIG.Dice.rollModes - * @param {boolean} [options.create=true] Whether to automatically create the chat message, or only return the - * prepared chatData object. - * @returns {Promise} A promise which resolves to the created ChatMessage document if create is - * true, or the Object of prepared chatData otherwise. - */ - async toMessage(messageData={}, {rollMode, create=true}={}) { - - // Perform the roll, if it has not yet been rolled - if ( !this._evaluated ) await this.evaluate({async: true}); - - // Prepare chat data - messageData = foundry.utils.mergeObject({ - user: game.user.id, - type: CONST.CHAT_MESSAGE_TYPES.ROLL, - content: String(this.total), - sound: CONFIG.sounds.dice - }, messageData); - messageData.rolls = [this]; - - // Either create the message or just return the chat data - const cls = getDocumentClass("ChatMessage"); - const msg = new cls(messageData); - - // Either create or return the data - if ( create ) return cls.create(msg.toObject(), { rollMode }); - else { - if ( rollMode ) msg.applyRollMode(rollMode); - return msg.toObject(); - } - } - - /* -------------------------------------------- */ - /* Interface Helpers */ - /* -------------------------------------------- */ - - /** - * Expand an inline roll element to display it's contained dice result as a tooltip - * @param {HTMLAnchorElement} a The inline-roll button - * @returns {Promise} - */ - static async expandInlineResult(a) { - if ( !a.classList.contains("inline-roll") ) return; - if ( a.classList.contains("expanded") ) return; - - // Create a new tooltip - const roll = this.fromJSON(unescape(a.dataset.roll)); - const tip = document.createElement("div"); - tip.innerHTML = await roll.getTooltip(); - - // Add the tooltip - const tooltip = tip.children[0]; - a.appendChild(tooltip); - a.classList.add("expanded"); - - // Set the position - const pa = a.getBoundingClientRect(); - const pt = tooltip.getBoundingClientRect(); - tooltip.style.left = `${Math.min(pa.x, window.innerWidth - (pt.width + 3))}px`; - tooltip.style.top = `${Math.min(pa.y + pa.height + 3, window.innerHeight - (pt.height + 3))}px`; - const zi = getComputedStyle(a).zIndex; - tooltip.style.zIndex = Number.isNumeric(zi) ? zi + 1 : 100; - } - - /* -------------------------------------------- */ - - /** - * Collapse an expanded inline roll to conceal it's tooltip - * @param {HTMLAnchorElement} a The inline-roll button - */ - static collapseInlineResult(a) { - if ( !a.classList.contains("inline-roll") ) return; - if ( !a.classList.contains("expanded") ) return; - const tooltip = a.querySelector(".dice-tooltip"); - if ( tooltip ) tooltip.remove(); - return a.classList.remove("expanded"); - } - - /* -------------------------------------------- */ - - /** - * Construct an inline roll link for this Roll. - * @param {object} [options] Additional options to configure how the link is constructed. - * @param {string} [options.label] A custom label for the total. - * @param {object} [options.attrs] Attributes to set on the link. - * @param {object} [options.dataset] Custom data- attributes to set on the link. - * @param {string[]} [options.classes] Classes to add to the link. - * @param {string} [options.icon] A font-awesome icon class to use as the icon instead of a d20. - * @returns {HTMLAnchorElement} - */ - toAnchor({attrs={}, dataset={}, classes=[], label, icon}={}) { - dataset = foundry.utils.mergeObject({roll: escape(JSON.stringify(this))}, dataset); - const a = document.createElement("a"); - a.classList.add(...classes); - a.dataset.tooltip = this.formula; - Object.entries(attrs).forEach(([k, v]) => a.setAttribute(k, v)); - Object.entries(dataset).forEach(([k, v]) => a.dataset[k] = v); - label = label ? `${label}: ${this.total}` : this.total; - a.innerHTML = `${label}`; - return a; - } - - /* -------------------------------------------- */ - /* Serialization and Loading */ - /* -------------------------------------------- */ - - /** - * Represent the data of the Roll as an object suitable for JSON serialization. - * @returns {object} Structured data which can be serialized into JSON - */ - toJSON() { - return { - class: this.constructor.name, - options: this.options, - dice: this._dice, - formula: this._formula, - terms: this.terms.map(t => t.toJSON()), - total: this._total, - evaluated: this._evaluated - }; - } - - /* -------------------------------------------- */ - - /** - * Recreate a Roll instance using a provided data object - * @param {object} data Unpacked data representing the Roll - * @returns {Roll} A reconstructed Roll instance - */ - static fromData(data) { - - // Redirect to the proper Roll class definition - if ( data.class && (data.class !== this.name) ) { - const cls = CONFIG.Dice.rolls.find(cls => cls.name === data.class); - if ( !cls ) throw new Error(`Unable to recreate ${data.class} instance from provided data`); - return cls.fromData(data); - } - - // Create the Roll instance - const roll = new this(data.formula, data.data, data.options); - - // Expand terms - roll.terms = data.terms.map(t => { - if ( t.class ) { - if ( t.class === "DicePool" ) t.class = "PoolTerm"; // backwards compatibility - return RollTerm.fromData(t); - } - return t; - }); - - // Repopulate evaluated state - if ( data.evaluated ?? true ) { - roll._total = data.total; - roll._dice = (data.dice || []).map(t => DiceTerm.fromData(t)); - roll._evaluated = true; - } - return roll; - } - - /* -------------------------------------------- */ - - /** - * Recreate a Roll instance using a provided JSON string - * @param {string} json Serialized JSON data representing the Roll - * @returns {Roll} A reconstructed Roll instance - */ - static fromJSON(json) { - return this.fromData(JSON.parse(json)); - } - - /* -------------------------------------------- */ - - /** - * Manually construct a Roll object by providing an explicit set of input terms - * @param {RollTerm[]} terms The array of terms to use as the basis for the Roll - * @param {object} [options={}] Additional options passed to the Roll constructor - * @returns {Roll} The constructed Roll instance - * - * @example Construct a Roll instance from an array of component terms - * ```js - * const t1 = new Die({number: 4, faces: 8}; - * const plus = new OperatorTerm({operator: "+"}); - * const t2 = new NumericTerm({number: 8}); - * const roll = Roll.fromTerms([t1, plus, t2]); - * roll.formula; // 4d8 + 8 - * ``` - */ - static fromTerms(terms, options={}) { - - // Validate provided terms - if ( !terms.every(t => t instanceof RollTerm ) ) { - throw new Error("All provided terms must be RollTerm instances"); - } - const allEvaluated = terms.every(t => t._evaluated); - const noneEvaluated = !terms.some(t => t._evaluated); - if ( !(allEvaluated || noneEvaluated) ) { - throw new Error("You can only call Roll.fromTerms with an array of terms which are either all evaluated, or none evaluated"); - } - - // Construct the roll - const formula = this.getFormula(terms); - const roll = new this(formula, {}, options); - roll.terms = terms; - roll._evaluated = allEvaluated; - if ( roll._evaluated ) roll._total = roll._evaluateTotal(); - return roll; - } -} - -/** - * An abstract class which represents a single token that can be used as part of a Roll formula. - * Every portion of a Roll formula is parsed into a subclass of RollTerm in order for the Roll to be fully evaluated. - */ -class RollTerm { - constructor({options={}}={}) { - - /** - * An object of additional options which describes and modifies the term. - * @type {object} - */ - this.options = options; - - /** - * An internal flag for whether the term has been evaluated - * @type {boolean} - */ - this._evaluated = false; - } - - /** - * Is this term intermediate, and should be evaluated first as part of the simplification process? - * @type {boolean} - */ - isIntermediate = false; - - /** - * A regular expression pattern which identifies optional term-level flavor text - * @type {string} - */ - static FLAVOR_REGEXP_STRING = "(?:\\[([^\\]]+)\\])"; - - /** - * A regular expression which identifies term-level flavor text - * @type {RegExp} - */ - static FLAVOR_REGEXP = new RegExp(RollTerm.FLAVOR_REGEXP_STRING, "g"); - - /** - * A regular expression used to match a term of this type - * @type {RegExp} - */ - static REGEXP = undefined; - - /** - * An array of additional attributes which should be retained when the term is serialized - * @type {string[]} - */ - static SERIALIZE_ATTRIBUTES = []; - - /* -------------------------------------------- */ - /* RollTerm Attributes */ - /* -------------------------------------------- */ - - /** - * A string representation of the formula expression for this RollTerm, prior to evaluation. - * @type {string} - */ - get expression() { - throw new Error(`The ${this.constructor.name} class must implement the expression attribute`); - } - - /** - * A string representation of the formula, including optional flavor text. - * @type {string} - */ - get formula() { - let f = this.expression; - if ( this.flavor ) f += `[${this.flavor}]`; - return f; - } - - /** - * A string or numeric representation of the final output for this term, after evaluation. - * @type {number|string} - */ - get total() { - throw new Error(`The ${this.constructor.name} class must implement the total attribute`); - } - - /** - * Optional flavor text which modifies and describes this term. - * @type {string} - */ - get flavor() { - return this.options.flavor || ""; - } - - /** - * Whether this term is entirely deterministic or contains some randomness. - * @type {boolean} - */ - get isDeterministic() { - return true; - } - - /* -------------------------------------------- */ - /* RollTerm Methods */ - /* -------------------------------------------- */ - - /** - * Evaluate the term, processing its inputs and finalizing its total. - * @param {object} [options={}] Options which modify how the RollTerm is evaluated - * @param {boolean} [options.minimize=false] Minimize the result, obtaining the smallest possible value. - * @param {boolean} [options.maximize=false] Maximize the result, obtaining the largest possible value. - * @param {boolean} [options.async=false] Evaluate the term asynchronously, receiving a Promise as the returned value. - * This will become the default behavior in version 10.x - * @returns {RollTerm} The evaluated RollTerm - */ - evaluate({minimize=false, maximize=false, async=false}={}) { - if ( this._evaluated ) { - throw new Error(`The ${this.constructor.name} has already been evaluated and is now immutable`); - } - this._evaluated = true; - return async ? this._evaluate({minimize, maximize}) : this._evaluateSync({minimize, maximize}); - } - - /** - * Evaluate the term. - * @param {object} [options={}] Options which modify how the RollTerm is evaluated, see RollTerm#evaluate - * @returns {Promise} - * @private - */ - async _evaluate({minimize=false, maximize=false}={}) { - return this._evaluateSync({minimize, maximize}); - } - - /** - * This method is temporarily factored out in order to provide different behaviors synchronous evaluation. - * This will be removed in 0.10.x - * @private - */ - _evaluateSync({minimize=false, maximize=false}={}) { - return this; - } - - /* -------------------------------------------- */ - /* Serialization and Loading */ - /* -------------------------------------------- */ - - /** - * Construct a RollTerm from a provided data object - * @param {object} data Provided data from an un-serialized term - * @return {RollTerm} The constructed RollTerm - */ - static fromData(data) { - let cls = CONFIG.Dice.termTypes[data.class]; - if ( !cls ) cls = Object.values(CONFIG.Dice.terms).find(c => c.name === data.class) || Die; - return cls._fromData(data); - } - - /* -------------------------------------------- */ - - /** - * Define term-specific logic for how a de-serialized data object is restored as a functional RollTerm - * @param {object} data The de-serialized term data - * @returns {RollTerm} The re-constructed RollTerm object - * @protected - */ - static _fromData(data) { - const term = new this(data); - term._evaluated = data.evaluated ?? true; - return term; - } - - /* -------------------------------------------- */ - - /** - * Reconstruct a RollTerm instance from a provided JSON string - * @param {string} json A serialized JSON representation of a DiceTerm - * @return {RollTerm} A reconstructed RollTerm from the provided JSON - */ - static fromJSON(json) { - let data; - try { - data = JSON.parse(json); - } catch(err) { - throw new Error("You must pass a valid JSON string"); - } - return this.fromData(data); - } - - /* -------------------------------------------- */ - - /** - * Serialize the RollTerm to a JSON string which allows it to be saved in the database or embedded in text. - * This method should return an object suitable for passing to the JSON.stringify function. - * @return {object} - */ - toJSON() { - const data = { - class: this.constructor.name, - options: this.options, - evaluated: this._evaluated - }; - for ( let attr of this.constructor.SERIALIZE_ATTRIBUTES ) { - data[attr] = this[attr]; - } - return data; - } -} - -/** - * A standalone, pure JavaScript implementation of the Mersenne Twister pseudo random number generator. - * - * @author Raphael Pigulla - * @version 0.2.3 - * @license - * Copyright (C) 1997 - 2002, Makoto Matsumoto and Takuji Nishimura, - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions - * are met: - * - * 1. Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * - * 3. The names of its contributors may not be used to endorse or promote - * products derived from this software without specific prior written - * permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR - * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, - * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, - * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR - * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -class MersenneTwister { - /** - * Instantiates a new Mersenne Twister. - * @param {number} [seed] The initial seed value, if not provided the current timestamp will be used. - * @constructor - */ - constructor(seed) { - - // Initial values - this.MAX_INT = 4294967296.0; - this.N = 624; - this.M = 397; - this.UPPER_MASK = 0x80000000; - this.LOWER_MASK = 0x7fffffff; - this.MATRIX_A = 0x9908b0df; - - // Initialize sequences - this.mt = new Array(this.N); - this.mti = this.N + 1; - this.SEED = this.seed(seed ?? new Date().getTime()); - }; - - /** - * Initializes the state vector by using one unsigned 32-bit integer "seed", which may be zero. - * - * @since 0.1.0 - * @param {number} seed The seed value. - */ - seed(seed) { - this.SEED = seed; - let s; - this.mt[0] = seed >>> 0; - - for (this.mti = 1; this.mti < this.N; this.mti++) { - s = this.mt[this.mti - 1] ^ (this.mt[this.mti - 1] >>> 30); - this.mt[this.mti] = - (((((s & 0xffff0000) >>> 16) * 1812433253) << 16) + (s & 0x0000ffff) * 1812433253) + this.mti; - this.mt[this.mti] >>>= 0; - } - return seed; - }; - - /** - * Initializes the state vector by using an array key[] of unsigned 32-bit integers of the specified length. If - * length is smaller than 624, then each array of 32-bit integers gives distinct initial state vector. This is - * useful if you want a larger seed space than 32-bit word. - * - * @since 0.1.0 - * @param {array} vector The seed vector. - */ - seedArray(vector) { - let i = 1, j = 0, k = this.N > vector.length ? this.N : vector.length, s; - this.seed(19650218); - for (; k > 0; k--) { - s = this.mt[i - 1] ^ (this.mt[i - 1] >>> 30); - - this.mt[i] = (this.mt[i] ^ (((((s & 0xffff0000) >>> 16) * 1664525) << 16) + ((s & 0x0000ffff) * 1664525))) + - vector[j] + j; - this.mt[i] >>>= 0; - i++; - j++; - if (i >= this.N) { - this.mt[0] = this.mt[this.N-1]; - i = 1; - } - if (j >= vector.length) { - j = 0; - } - } - - for (k = this.N-1; k; k--) { - s = this.mt[i - 1] ^ (this.mt[i - 1] >>> 30); - this.mt[i] = - (this.mt[i] ^ (((((s & 0xffff0000) >>> 16) * 1566083941) << 16) + (s & 0x0000ffff) * 1566083941)) - i; - this.mt[i] >>>= 0; - i++; - if (i >= this.N) { - this.mt[0] = this.mt[this.N - 1]; - i = 1; - } - } - this.mt[0] = 0x80000000; - }; - - /** - * Generates a random unsigned 32-bit integer. - * - * @since 0.1.0 - * @returns {number} - */ - int() { - let y, kk, mag01 = [0, this.MATRIX_A]; - - if (this.mti >= this.N) { - if (this.mti === this.N+1) { - this.seed(5489); - } - - for (kk = 0; kk < this.N - this.M; kk++) { - y = (this.mt[kk] & this.UPPER_MASK) | (this.mt[kk + 1] & this.LOWER_MASK); - this.mt[kk] = this.mt[kk + this.M] ^ (y >>> 1) ^ mag01[y & 1]; - } - - for (; kk < this.N - 1; kk++) { - y = (this.mt[kk] & this.UPPER_MASK) | (this.mt[kk + 1] & this.LOWER_MASK); - this.mt[kk] = this.mt[kk + (this.M - this.N)] ^ (y >>> 1) ^ mag01[y & 1]; - } - - y = (this.mt[this.N - 1] & this.UPPER_MASK) | (this.mt[0] & this.LOWER_MASK); - this.mt[this.N - 1] = this.mt[this.M - 1] ^ (y >>> 1) ^ mag01[y & 1]; - this.mti = 0; - } - - y = this.mt[this.mti++]; - - y ^= (y >>> 11); - y ^= (y << 7) & 0x9d2c5680; - y ^= (y << 15) & 0xefc60000; - y ^= (y >>> 18); - - return y >>> 0; - }; - - /** - * Generates a random unsigned 31-bit integer. - * - * @since 0.1.0 - * @returns {number} - */ - int31() { - return this.int() >>> 1; - }; - - /** - * Generates a random real in the interval [0;1] with 32-bit resolution. - * - * @since 0.1.0 - * @returns {number} - */ - real() { - return this.int() * (1.0 / (this.MAX_INT - 1)); - }; - - /** - * Generates a random real in the interval ]0;1[ with 32-bit resolution. - * - * @since 0.1.0 - * @returns {number} - */ - realx() { - return (this.int() + 0.5) * (1.0 / this.MAX_INT); - }; - - /** - * Generates a random real in the interval [0;1[ with 32-bit resolution. - * - * @since 0.1.0 - * @returns {number} - */ - rnd() { - return this.int() * (1.0 / this.MAX_INT); - }; - - /** - * Generates a random real in the interval [0;1[ with 32-bit resolution. - * - * Same as .rnd() method - for consistency with Math.random() interface. - * - * @since 0.2.0 - * @returns {number} - */ - random() { - return this.rnd(); - }; - - /** - * Generates a random real in the interval [0;1[ with 53-bit resolution. - * - * @since 0.1.0 - * @returns {number} - */ - rndHiRes() { - const a = this.int() >>> 5; - const b = this.int() >>> 6; - return (a * 67108864.0 + b) * (1.0 / 9007199254740992.0); - }; - - /** - * A pseudo-normal distribution using the Box-Muller transform. - * @param {number} mu The normal distribution mean - * @param {number} sigma The normal distribution standard deviation - * @returns {number} - */ - normal(mu, sigma) { - let u = 0; - while (u === 0) u = this.random(); // Converting [0,1) to (0,1) - let v = 0; - while (v === 0) v = this.random(); // Converting [0,1) to (0,1) - let n = Math.sqrt( -2.0 * Math.log(u) ) * Math.cos(2.0 * Math.PI * v); - return (n * sigma) + mu; - } - - /** - * A factory method for generating random uniform rolls - * @returns {number} - */ - static random() { - return twist.random(); - } - - /** - * A factory method for generating random normal rolls - * @return {number} - */ - static normal(...args) { - return twist.normal(...args); - } -} - -// Global singleton -const twist = new MersenneTwister(Date.now()); - -/** - * @typedef {Object} DiceTermResult - * @property {number} result The numeric result - * @property {boolean} [active] Is this result active, contributing to the total? - * @property {number} [count] A value that the result counts as, otherwise the result is not used directly as - * @property {boolean} [success] Does this result denote a success? - * @property {boolean} [failure] Does this result denote a failure? - * @property {boolean} [discarded] Was this result discarded? - * @property {boolean} [rerolled] Was this result rerolled? - * @property {boolean} [exploded] Was this result exploded? - */ - -/** - * An abstract base class for any type of RollTerm which involves randomized input from dice, coins, or other devices. - * @extends RollTerm - * - * @param {object} termData Data used to create the Dice Term, including the following: - * @param {number} [termData.number=1] The number of dice of this term to roll, before modifiers are applied - * @param {number} termData.faces The number of faces on each die of this type - * @param {string[]} [termData.modifiers] An array of modifiers applied to the results - * @param {object[]} [termData.results] An optional array of pre-cast results for the term - * @param {object} [termData.options] Additional options that modify the term - */ -class DiceTerm extends RollTerm { - constructor({number=1, faces=6, modifiers=[], results=[], options={}}) { - super({options}); - - /** - * The number of dice of this term to roll, before modifiers are applied - * @type {number} - */ - this.number = number; - - /** - * The number of faces on the die - * @type {number} - */ - this.faces = faces; - - /** - * An Array of dice term modifiers which are applied - * @type {string[]} - */ - this.modifiers = modifiers; - - /** - * The array of dice term results which have been rolled - * @type {DiceTermResult[]} - */ - this.results = results; - - // If results were explicitly passed, the term has already been evaluated - if ( results.length ) this._evaluated = true; - } - - /* -------------------------------------------- */ - - /** - * Define the denomination string used to register this DiceTerm type in CONFIG.Dice.terms - * @type {string} - */ - static DENOMINATION = ""; - - /** - * Define the named modifiers that can be applied for this particular DiceTerm type. - * @type {{string: (string|Function)}} - */ - static MODIFIERS = {}; - - /** - * A regular expression pattern which captures the full set of term modifiers - * Anything until a space, group symbol, or arithmetic operator - * @type {string} - */ - static MODIFIERS_REGEXP_STRING = "([^ (){}[\\]+\\-*/]+)"; - - /** - * A regular expression used to separate individual modifiers - * @type {RegExp} - */ - static MODIFIER_REGEXP = /([A-z]+)([^A-z\s()+\-*\/]+)?/g - - - /** @inheritdoc */ - static REGEXP = new RegExp(`^([0-9]+)?[dD]([A-z]|[0-9]+)${DiceTerm.MODIFIERS_REGEXP_STRING}?${DiceTerm.FLAVOR_REGEXP_STRING}?$`); - - /** @inheritdoc */ - static SERIALIZE_ATTRIBUTES = ["number", "faces", "modifiers", "results"]; - - /* -------------------------------------------- */ - /* Dice Term Attributes */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - get expression() { - const x = this.constructor.DENOMINATION === "d" ? this.faces : this.constructor.DENOMINATION; - return `${this.number}d${x}${this.modifiers.join("")}`; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get total() { - if ( !this._evaluated ) return undefined; - return this.results.reduce((t, r) => { - if ( !r.active ) return t; - if ( r.count !== undefined ) return t + r.count; - else return t + r.result; - }, 0); - } - - /* -------------------------------------------- */ - - /** - * Return an array of rolled values which are still active within this term - * @type {number[]} - */ - get values() { - return this.results.reduce((arr, r) => { - if ( !r.active ) return arr; - arr.push(r.result); - return arr; - }, []); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get isDeterministic() { - return false; - } - - /* -------------------------------------------- */ - /* Dice Term Methods */ - /* -------------------------------------------- */ - - /** - * Alter the DiceTerm by adding or multiplying the number of dice which are rolled - * @param {number} multiply A factor to multiply. Dice are multiplied before any additions. - * @param {number} add A number of dice to add. Dice are added after multiplication. - * @return {DiceTerm} The altered term - */ - alter(multiply, add) { - if ( this._evaluated ) throw new Error(`You may not alter a DiceTerm after it has already been evaluated`); - multiply = Number.isFinite(multiply) && (multiply >= 0) ? multiply : 1; - add = Number.isInteger(add) ? add : 0; - if ( multiply >= 0 ) this.number = Math.round(this.number * multiply); - if ( add ) this.number += add; - return this; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _evaluateSync({minimize=false, maximize=false}={}) { - if ( (this.number > 999) ) { - throw new Error(`You may not evaluate a DiceTerm with more than 999 requested results`); - } - for ( let n=1; n <= this.number; n++ ) { - this.roll({minimize, maximize}); - } - this._evaluateModifiers(); - return this; - } - - /* -------------------------------------------- */ - - /** - * Roll the DiceTerm by mapping a random uniform draw against the faces of the dice term. - * @param {object} [options={}] Options which modify how a random result is produced - * @param {boolean} [options.minimize=false] Minimize the result, obtaining the smallest possible value. - * @param {boolean} [options.maximize=false] Maximize the result, obtaining the largest possible value. - * @return {DiceTermResult} The produced result - */ - roll({minimize=false, maximize=false}={}) { - const roll = {result: undefined, active: true}; - if ( minimize ) roll.result = Math.min(1, this.faces); - else if ( maximize ) roll.result = this.faces; - else roll.result = Math.ceil(CONFIG.Dice.randomUniform() * this.faces); - this.results.push(roll); - return roll; - } - - /* -------------------------------------------- */ - - /** - * Return a string used as the label for each rolled result - * @param {DiceTermResult} result The rolled result - * @return {string} The result label - */ - getResultLabel(result) { - return String(result.result); - } - - /* -------------------------------------------- */ - - /** - * Get the CSS classes that should be used to display each rolled result - * @param {DiceTermResult} result The rolled result - * @return {string[]} The desired classes - */ - getResultCSS(result) { - const hasSuccess = result.success !== undefined; - const hasFailure = result.failure !== undefined; - const isMax = result.result === this.faces; - const isMin = result.result === 1; - return [ - this.constructor.name.toLowerCase(), - "d" + this.faces, - result.success ? "success" : null, - result.failure ? "failure" : null, - result.rerolled ? "rerolled" : null, - result.exploded ? "exploded" : null, - result.discarded ? "discarded" : null, - !(hasSuccess || hasFailure) && isMin ? "min" : null, - !(hasSuccess || hasFailure) && isMax ? "max" : null - ] - } - - /* -------------------------------------------- */ - - /** - * Render the tooltip HTML for a Roll instance - * @return {object} The data object used to render the default tooltip template for this DiceTerm - */ - getTooltipData() { - return { - formula: this.expression, - total: this.total, - faces: this.faces, - flavor: this.flavor, - rolls: this.results.map(r => { - return { - result: this.getResultLabel(r), - classes: this.getResultCSS(r).filterJoin(" ") - } - }) - }; - } - - /* -------------------------------------------- */ - /* Modifier Methods */ - /* -------------------------------------------- */ - - /** - * Sequentially evaluate each dice roll modifier by passing the term to its evaluation function - * Augment or modify the results array. - * @private - */ - _evaluateModifiers() { - const cls = this.constructor; - const requested = foundry.utils.deepClone(this.modifiers); - this.modifiers = []; - - // Iterate over requested modifiers - for ( let m of requested ) { - let command = m.match(/[A-z]+/)[0].toLowerCase(); - - // Matched command - if ( command in cls.MODIFIERS ) { - this._evaluateModifier(command, m); - continue; - } - - // Unmatched compound command - // Sort modifiers from longest to shortest to ensure that the matching algorithm greedily matches the longest - // prefixes first. - const modifiers = Object.keys(cls.MODIFIERS).sort((a, b) => b.length - a.length); - while ( !!command ) { - let matched = false; - for ( let cmd of modifiers ) { - if ( command.startsWith(cmd) ) { - matched = true; - this._evaluateModifier(cmd, cmd); - command = command.replace(cmd, ""); - break; - } - } - if ( !matched ) command = ""; - } - } - } - - /* -------------------------------------------- */ - - /** - * Evaluate a single modifier command, recording it in the array of evaluated modifiers - * @param {string} command The parsed modifier command - * @param {string} modifier The full modifier request - * @private - */ - _evaluateModifier(command, modifier) { - let fn = this.constructor.MODIFIERS[command]; - if ( typeof fn === "string" ) fn = this[fn]; - if ( fn instanceof Function ) { - const result = fn.call(this, modifier); - const earlyReturn = (result === false) || (result === this); // handling this is backwards compatibility - if ( !earlyReturn ) this.modifiers.push(modifier.toLowerCase()); - } - } - - /* -------------------------------------------- */ - - /** - * A helper comparison function. - * Returns a boolean depending on whether the result compares favorably against the target. - * @param {number} result The result being compared - * @param {string} comparison The comparison operator in [=,<,<=,>,>=] - * @param {number} target The target value - * @return {boolean} Is the comparison true? - */ - static compareResult(result, comparison, target) { - switch ( comparison ) { - case "=": - return result === target; - case "<": - return result < target; - case "<=": - return result <= target; - case ">": - return result > target; - case ">=": - return result >= target; - } - } - - /* -------------------------------------------- */ - - /** - * A helper method to modify the results array of a dice term by flagging certain results are kept or dropped. - * @param {object[]} results The results array - * @param {number} number The number to keep or drop - * @param {boolean} [keep] Keep results? - * @param {boolean} [highest] Keep the highest? - * @return {object[]} The modified results array - */ - static _keepOrDrop(results, number, {keep=true, highest=true}={}) { - - // Sort remaining active results in ascending (keep) or descending (drop) order - const ascending = keep === highest; - const values = results.reduce((arr, r) => { - if ( r.active ) arr.push(r.result); - return arr; - }, []).sort((a, b) => ascending ? a - b : b - a); - - // Determine the cut point, beyond which to discard - number = Math.clamped(keep ? values.length - number : number, 0, values.length); - const cut = values[number]; - - // Track progress - let discarded = 0; - const ties = []; - let comp = ascending ? "<" : ">"; - - // First mark results on the wrong side of the cut as discarded - results.forEach(r => { - if ( !r.active ) return; // Skip results which have already been discarded - let discard = this.compareResult(r.result, comp, cut); - if ( discard ) { - r.discarded = true; - r.active = false; - discarded++; - } - else if ( r.result === cut ) ties.push(r); - }); - - // Next discard ties until we have reached the target - ties.forEach(r => { - if ( discarded < number ) { - r.discarded = true; - r.active = false; - discarded++; - } - }); - return results; - } - - /* -------------------------------------------- */ - - /** - * A reusable helper function to handle the identification and deduction of failures - */ - static _applyCount(results, comparison, target, {flagSuccess=false, flagFailure=false}={}) { - for ( let r of results ) { - let success = this.compareResult(r.result, comparison, target); - if (flagSuccess) { - r.success = success; - if (success) delete r.failure; - } - else if (flagFailure ) { - r.failure = success; - if (success) delete r.success; - } - r.count = success ? 1 : 0; - } - } - - /* -------------------------------------------- */ - - /** - * A reusable helper function to handle the identification and deduction of failures - */ - static _applyDeduct(results, comparison, target, {deductFailure=false, invertFailure=false}={}) { - for ( let r of results ) { - - // Flag failures if a comparison was provided - if (comparison) { - const fail = this.compareResult(r.result, comparison, target); - if ( fail ) { - r.failure = true; - delete r.success; - } - } - - // Otherwise treat successes as failures - else { - if ( r.success === false ) { - r.failure = true; - delete r.success; - } - } - - // Deduct failures - if ( deductFailure ) { - if ( r.failure ) r.count = -1; - } - else if ( invertFailure ) { - if ( r.failure ) r.count = -1 * r.result; - } - } - } - - /* -------------------------------------------- */ - /* Factory Methods */ - /* -------------------------------------------- */ - - /** - * Determine whether a string expression matches this type of term - * @param {string} expression The expression to parse - * @param {object} [options={}] Additional options which customize the match - * @param {boolean} [options.imputeNumber=true] Allow the number of dice to be optional, i.e. "d6" - * @return {RegExpMatchArray|null} - */ - static matchTerm(expression, {imputeNumber=true}={}) { - const match = expression.match(this.REGEXP); - if ( !match ) return null; - if ( (match[1] === undefined) && !imputeNumber ) return null; - return match; - } - - /* -------------------------------------------- */ - - /** - * Construct a term of this type given a matched regular expression array. - * @param {RegExpMatchArray} match The matched regular expression array - * @return {DiceTerm} The constructed term - */ - static fromMatch(match) { - let [number, denomination, modifiers, flavor] = match.slice(1); - - // Get the denomination of DiceTerm - denomination = denomination.toLowerCase(); - const cls = denomination in CONFIG.Dice.terms ? CONFIG.Dice.terms[denomination] : CONFIG.Dice.terms.d; - if ( !foundry.utils.isSubclass(cls, DiceTerm) ) { - throw new Error(`DiceTerm denomination ${denomination} not registered to CONFIG.Dice.terms as a valid DiceTerm class`); - } - - // Get the term arguments - number = Number.isNumeric(number) ? parseInt(number) : 1; - const faces = Number.isNumeric(denomination) ? parseInt(denomination) : null; - - // Match modifiers - modifiers = Array.from((modifiers || "").matchAll(DiceTerm.MODIFIER_REGEXP)).map(m => m[0]); - - // Construct a term of the appropriate denomination - return new cls({number, faces, modifiers, options: {flavor}}); - } -} - -/** - * A type of RollTerm used to apply a function from the Math library. - * @extends {RollTerm} - */ -class MathTerm extends RollTerm { - constructor({fn, terms=[], options}={}) { - super({options}); - - /** - * The named function in the Math environment which should be applied to the term - * @type {string} - */ - this.fn = fn; - - /** - * An array of string argument terms for the function - * @type {string[]} - */ - this.terms = terms; - } - - /** - * The cached Roll instances for each function argument - * @type {Roll[]} - */ - rolls = []; - - /** - * The cached result of evaluating the method arguments - * @type {number} - */ - result = undefined; - - /** @inheritdoc */ - isIntermediate = true; - - /** @inheritdoc */ - static SERIALIZE_ATTRIBUTES = ["fn", "terms"]; - - /* -------------------------------------------- */ - /* Math Term Attributes */ - /* -------------------------------------------- */ - - /** - * An array of evaluated DiceTerm instances that should be bubbled up to the parent Roll - * @type {DiceTerm[]} - */ - get dice() { - return this._evaluated ? this.rolls.reduce((arr, r) => arr.concat(r.dice), []) : undefined; - } - - /** @inheritdoc */ - get total() { - return this.result; - } - - /** @inheritdoc */ - get expression() { - return `${this.fn}(${this.terms.join(",")})`; - } - - /** @inheritdoc */ - get isDeterministic() { - return this.terms.every(t => Roll.create(t).isDeterministic); - } - - /* -------------------------------------------- */ - /* Math Term Methods */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - _evaluateSync({minimize=false, maximize=false}={}) { - this.rolls = this.terms.map(a => { - const roll = Roll.create(a); - roll.evaluate({minimize, maximize, async: false}); - if ( this.flavor ) roll.terms.forEach(t => t.options.flavor = t.options.flavor ?? this.flavor); - return roll; - }); - const args = this.rolls.map(r => r.total).join(", "); - this.result = Roll.defaultImplementation.safeEval(`${this.fn}(${args})`); - return this; - } - - /** @inheritdoc */ - async _evaluate({minimize=false, maximize=false}={}) { - for ( let term of this.terms ) { - const roll = Roll.create(term); - await roll.evaluate({minimize, maximize, async: true}); - if ( this.flavor ) roll.terms.forEach(t => t.options.flavor = t.options.flavor ?? this.flavor); - this.rolls.push(roll); - } - const args = this.rolls.map(r => r.total).join(", "); - this.result = Roll.defaultImplementation.safeEval(`${this.fn}(${args})`); - return this; - } -} - -/** - * A type of RollTerm used to represent static numbers. - * @extends {RollTerm} - */ -class NumericTerm extends RollTerm { - constructor({number, options}={}) { - super({options}); - this.number = Number(number); - } - - /** @inheritdoc */ - static REGEXP = new RegExp(`^([0-9]+(?:\\.[0-9]+)?)${RollTerm.FLAVOR_REGEXP_STRING}?$`); - - /** @inheritdoc */ - static SERIALIZE_ATTRIBUTES = ["number"]; - - /** @inheritdoc */ - get expression() { - return String(this.number); - } - - /** @inheritdoc */ - get total() { - return this.number; - } - - /* -------------------------------------------- */ - /* Factory Methods */ - /* -------------------------------------------- */ - - /** - * Determine whether a string expression matches a NumericTerm - * @param {string} expression The expression to parse - * @return {RegExpMatchArray|null} - */ - static matchTerm(expression) { - return expression.match(this.REGEXP) || null; - } - - /* -------------------------------------------- */ - - /** - * Construct a term of this type given a matched regular expression array. - * @param {RegExpMatchArray} match The matched regular expression array - * @return {NumericTerm} The constructed term - */ - static fromMatch(match) { - let [number, flavor] = match.slice(1); - return new this({number, options: {flavor}}); - } -} - -/** - * A type of RollTerm used to denote and perform an arithmetic operation. - * @extends {RollTerm} - */ -class OperatorTerm extends RollTerm { - constructor({operator, options}={}) { - super({options}); - this.operator = operator; - } - - /** - * An array of operators which represent arithmetic operations - * @type {string[]} - */ - static OPERATORS = ["+", "-", "*", "/", "%"]; - - /** @inheritdoc */ - static REGEXP = new RegExp(OperatorTerm.OPERATORS.map(o => "\\"+o).join("|"), "g"); - - /** @inheritdoc */ - static SERIALIZE_ATTRIBUTES = ["operator"]; - - /** @inheritdoc */ - get flavor() { - return ""; // Operator terms cannot have flavor text - } - - /** @inheritdoc */ - get expression() { - return ` ${this.operator} `; - } - - /** @inheritdoc */ - get total() { - return ` ${this.operator} `; - } -} - -/** - * A type of RollTerm used to enclose a parenthetical expression to be recursively evaluated. - * @extends {RollTerm} - */ -class ParentheticalTerm extends RollTerm { - constructor({term, roll, options}) { - super({options}); - - /** - * The original provided string term used to construct the parenthetical - * @type {string} - */ - this.term = term; - - /** - * Alternatively, an already-evaluated Roll instance may be passed directly - * @type {Roll} - */ - this.roll = roll; - - // If a roll was explicitly passed in, the parenthetical has already been evaluated - if ( this.roll ) { - this.term = roll.formula; - this._evaluated = this.roll._evaluated; - } - } - - /** @inheritdoc */ - isIntermediate = true; - - /** - * The regular expression pattern used to identify the opening of a parenthetical expression. - * This could also identify the opening of a math function. - * @type {RegExp} - */ - static OPEN_REGEXP = /([A-z][A-z0-9]+)?\(/g; - - /** - * A regular expression pattern used to identify the closing of a parenthetical expression. - * @type {RegExp} - */ - static CLOSE_REGEXP = new RegExp("\\)(?:\\$\\$F[0-9]+\\$\\$)?", "g"); - - /** @inheritdoc */ - static SERIALIZE_ATTRIBUTES = ["term"]; - - /* -------------------------------------------- */ - /* Parenthetical Term Attributes */ - /* -------------------------------------------- */ - - /** - * An array of evaluated DiceTerm instances that should be bubbled up to the parent Roll - * @type {DiceTerm[]} - */ - get dice() { - return this.roll?.dice; - } - - /** @inheritdoc */ - get total() { - return this.roll.total; - } - - /** @inheritdoc */ - get expression() { - return `(${this.term})`; - } - - /** @inheritdoc */ - get isDeterministic() { - return Roll.create(this.term).isDeterministic; - } - - /* -------------------------------------------- */ - /* Parenthetical Term Methods */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - _evaluateSync({minimize=false, maximize=false}={}) { - - // Evaluate the inner Roll - const roll = this.roll || Roll.create(this.term); - this.roll = roll.evaluate({minimize, maximize, async: false}); - - // Propagate flavor text to inner terms - if ( this.flavor ) this.roll.terms.forEach(t => t.options.flavor = t.options.flavor ?? this.flavor); - return this; - } - - /** @inheritdoc */ - async _evaluate({minimize=false, maximize=false}={}) { - - // Evaluate the inner Roll - const roll = this.roll || Roll.create(this.term); - this.roll = await roll.evaluate({minimize, maximize, async: true}); - - // Propagate flavor text to inner terms - if ( this.flavor ) this.roll.terms.forEach(t => t.options.flavor = t.options.flavor ?? this.flavor); - return this; - } - - /* -------------------------------------------- */ - - /** - * Construct a ParentheticalTerm from an Array of component terms which should be wrapped inside the parentheses. - * @param {RollTerm[]} terms The array of terms to use as internal parts of the parenthetical - * @param {object} [options={}] Additional options passed to the ParentheticalTerm constructor - * @returns {ParentheticalTerm} The constructed ParentheticalTerm instance - * - * @example Create a Parenthetical Term from an array of component RollTerm instances - * ```js - * const d6 = new Die({number: 4, faces: 6}); - * const plus = new OperatorTerm({operator: "+"}); - * const bonus = new NumericTerm({number: 4}); - * t = ParentheticalTerm.fromTerms([d6, plus, bonus]); - * t.formula; // (4d6 + 4) - * ``` - */ - static fromTerms(terms, options) { - const roll = Roll.defaultImplementation.fromTerms(terms); - return new this({roll, options}); - } -} - -/** - * A type of RollTerm which encloses a pool of multiple inner Rolls which are evaluated jointly. - * - * A dice pool represents a set of Roll expressions which are collectively modified to compute an effective total - * across all Rolls in the pool. The final total for the pool is defined as the sum over kept rolls, relative to any - * success count or margin. - * - * @example Keep the highest of the 3 roll expressions - * ```js - * let pool = new PoolTerm({ - * rolls: ["4d6", "3d8 - 1", "2d10 + 3"], - * modifiers: ["kh"] - * }); - * pool.evaluate(); - * ``` - */ -class PoolTerm extends RollTerm { - constructor({terms=[], modifiers=[], rolls=[], results=[], options={}}={}) { - super({options}); - - /** - * The original provided terms to the Dice Pool - * @type {string[]} - */ - this.terms = terms; - - /** - * The string modifiers applied to resolve the pool - * @type {string[]} - */ - this.modifiers = modifiers; - - /** - * Each component term of a dice pool is evaluated as a Roll instance - * @type {Roll[]} - */ - this.rolls = (rolls.length === terms.length) ? rolls : this.terms.map(t => Roll.create(t)); - - /** - * The array of dice pool results which have been rolled - * @type {DiceTermResult[]} - */ - this.results = results; - - // If rolls and results were explicitly passed, the term has already been evaluated - if ( rolls.length && results.length ) this._evaluated = true; - } - - /* -------------------------------------------- */ - - /** - * Define the modifiers that can be used for this particular DiceTerm type. - * @type {Object} - */ - static MODIFIERS = { - "k": "keep", - "kh": "keep", - "kl": "keep", - "d": "drop", - "dh": "drop", - "dl": "drop", - "cs": "countSuccess", - "cf": "countFailures" - }; - - /** - * The regular expression pattern used to identify the opening of a dice pool expression. - * @type {RegExp} - */ - static OPEN_REGEXP = /{/g; - - /** - * A regular expression pattern used to identify the closing of a dice pool expression. - * @type {RegExp} - */ - static CLOSE_REGEXP = new RegExp(`}${DiceTerm.MODIFIERS_REGEXP_STRING}?(?:\\$\\$F[0-9]+\\$\\$)?`, "g"); - - /** - * A regular expression pattern used to match the entirety of a DicePool expression. - * @type {RegExp} - */ - static REGEXP = new RegExp(`{([^}]+)}${DiceTerm.MODIFIERS_REGEXP_STRING}?(?:\\$\\$F[0-9]+\\$\\$)?`); - - /** @inheritdoc */ - static SERIALIZE_ATTRIBUTES = ["terms", "modifiers", "rolls", "results"]; - - /* -------------------------------------------- */ - /* Dice Pool Attributes */ - /* -------------------------------------------- */ - - /** - * Return an Array of each individual DiceTerm instances contained within the PoolTerm. - * @return {DiceTerm[]} - */ - get dice() { - return this.rolls.flatMap(r => r.dice); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get expression() { - return `{${this.terms.join(",")}}${this.modifiers.join("")}`; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get total() { - if ( !this._evaluated ) return undefined; - return this.results.reduce((t, r) => { - if ( !r.active ) return t; - if ( r.count !== undefined ) return t + r.count; - else return t + r.result; - }, 0); - } - - /* -------------------------------------------- */ - - /** - * Return an array of rolled values which are still active within the PoolTerm - * @type {number[]} - */ - get values() { - return this.results.reduce((arr, r) => { - if ( !r.active ) return arr; - arr.push(r.result); - return arr; - }, []); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get isDeterministic() { - return this.terms.every(t => Roll.create(t).isDeterministic); - } - - /* -------------------------------------------- */ - - /** - * Alter the DiceTerm by adding or multiplying the number of dice which are rolled - * @param {any[]} args Arguments passed to each contained Roll#alter method. - * @return {PoolTerm} The altered pool - */ - alter(...args) { - this.rolls.forEach(r => r.alter(...args)); - return this; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _evaluateSync({minimize=false, maximize=false}={}) { - for ( let roll of this.rolls ) { - roll.evaluate({minimize, maximize, async: false}); - if ( this.flavor ) roll.terms.forEach(t => t.options.flavor = t.options.flavor ?? this.flavor); - this.results.push({ - result: roll.total, - active: true - }); - } - this._evaluateModifiers(); - return this; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _evaluate({minimize=false, maximize=false}={}) { - for ( let roll of this.rolls ) { - await roll.evaluate({minimize, maximize, async: true}); - if ( this.flavor ) roll.terms.forEach(t => t.options.flavor = t.options.flavor ?? this.flavor); - this.results.push({ - result: roll.total, - active: true - }); - } - this._evaluateModifiers(); - return this; - } - - /* -------------------------------------------- */ - - /** - * Use the same logic as for the DiceTerm to avoid duplication - * @see DiceTerm#_evaluateModifiers - */ - _evaluateModifiers() { - return DiceTerm.prototype._evaluateModifiers.call(this); - } - - /* -------------------------------------------- */ - - /** - * Use the same logic as for the DiceTerm to avoid duplication - * @see DiceTerm#_evaluateModifier - */ - _evaluateModifier(command, modifier) { - return DiceTerm.prototype._evaluateModifier.call(this, command, modifier); - } - - /* -------------------------------------------- */ - /* Saving and Loading */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - static _fromData(data) { - data.rolls = (data.rolls || []).map(r => { - const cls = CONFIG.Dice.rolls.find(cls => cls.name === r.class) || Roll; - return cls.fromData(r) - }); - return super._fromData(data); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - toJSON() { - const data = super.toJSON(); - data.rolls = data.rolls.map(r => r.toJSON()); - return data; - } - - /* -------------------------------------------- */ - - /** - * Given a string formula, create and return an evaluated PoolTerm object - * @param {string} formula The string formula to parse - * @param {object} [options] Additional options applied to the PoolTerm - * @return {PoolTerm|null} The evaluated PoolTerm object or null if the formula is invalid - */ - static fromExpression(formula, options={}) { - const rgx = formula.trim().match(this.REGEXP); - if ( !rgx ) return null; - let [terms, modifiers] = rgx.slice(1); - terms = terms.split(","); - modifiers = Array.from((modifiers || "").matchAll(DiceTerm.MODIFIER_REGEXP)).map(m => m[0]); - return new this({terms, modifiers, options}); - } - - /* -------------------------------------------- */ - - /** - * Create a PoolTerm by providing an array of existing Roll objects - * @param {Roll[]} rolls An array of Roll objects from which to create the pool - * @returns {RollTerm} The constructed PoolTerm comprised of the provided rolls - */ - static fromRolls(rolls=[]) { - const allEvaluated = rolls.every(t => t._evaluated); - const noneEvaluated = !rolls.some(t => t._evaluated); - if ( !(allEvaluated || noneEvaluated) ) { - throw new Error("You can only call PoolTerm.fromRolls with an array of Roll instances which are either all evaluated, or none evaluated"); - } - const pool = new this({ - terms: rolls.map(r => r.formula), - modifiers: [], - rolls: rolls, - results: allEvaluated ? rolls.map(r => ({result: r.total, active: true})) : [] - }); - pool._evaluated = allEvaluated; - return pool; - } - - /* -------------------------------------------- */ - /* Modifiers */ - /* -------------------------------------------- */ - - /** - * Keep a certain number of highest or lowest dice rolls from the result set. - * - * {1d6,1d8,1d10,1d12}kh2 Keep the 2 best rolls from the pool - * {1d12,6}kl Keep the lowest result in the pool - * - * @param {string} modifier The matched modifier query - */ - keep(modifier) { - return Die.prototype.keep.call(this, modifier); - } - - /* -------------------------------------------- */ - - /** - * Keep a certain number of highest or lowest dice rolls from the result set. - * - * {1d6,1d8,1d10,1d12}dl3 Drop the 3 worst results in the pool - * {1d12,6}dh Drop the highest result in the pool - * - * @param {string} modifier The matched modifier query - */ - drop(modifier) { - return Die.prototype.drop.call(this, modifier); - } - - /* -------------------------------------------- */ - - /** - * Count the number of successful results which occurred in the pool. - * Successes are counted relative to some target, or relative to the maximum possible value if no target is given. - * Applying a count-success modifier to the results re-casts all results to 1 (success) or 0 (failure) - * - * 20d20cs Count the number of dice which rolled a 20 - * 20d20cs>10 Count the number of dice which rolled higher than 10 - * 20d20cs<10 Count the number of dice which rolled less than 10 - * - * @param {string} modifier The matched modifier query - */ - countSuccess(modifier) { - return Die.prototype.countSuccess.call(this, modifier); - } - - /* -------------------------------------------- */ - - /** - * Count the number of failed results which occurred in a given result set. - * Failures are counted relative to some target, or relative to the lowest possible value if no target is given. - * Applying a count-failures modifier to the results re-casts all results to 1 (failure) or 0 (non-failure) - * - * 6d6cf Count the number of dice which rolled a 1 as failures - * 6d6cf<=3 Count the number of dice which rolled less than 3 as failures - * 6d6cf>4 Count the number of dice which rolled greater than 4 as failures - * - * @param {string} modifier The matched modifier query - */ - countFailures(modifier) { - return Die.prototype.countFailures.call(this, modifier); - } -} - -/** - * A type of RollTerm used to represent strings which have not yet been matched. - * @extends {RollTerm} - */ -class StringTerm extends RollTerm { - constructor({term, options}={}) { - super({options}); - this.term = term; - } - - /** @inheritdoc */ - static SERIALIZE_ATTRIBUTES = ["term"]; - - /** @inheritdoc */ - get expression() { - return this.term; - } - - /** @inheritdoc */ - get total() { - return this.term; - } - - /** @inheritdoc */ - get isDeterministic() { - const classified = Roll.defaultImplementation._classifyStringTerm(this.term, {intermediate: false}); - if ( classified instanceof StringTerm ) return true; - return classified.isDeterministic; - } - - /** @inheritdoc */ - evaluate(options={}) { - throw new Error(`Unresolved StringTerm ${this.term} requested for evaluation`); - } -} - -/** - * A type of DiceTerm used to represent flipping a two-sided coin. - * @implements {DiceTerm} - */ -class Coin extends DiceTerm { - constructor(termData) { - super(termData); - this.faces = 2; - } - - /** @inheritdoc */ - static DENOMINATION = "c"; - - /** @inheritdoc */ - static MODIFIERS = { - "c": "call" - }; - - /* -------------------------------------------- */ - - /** @inheritdoc */ - roll({minimize=false, maximize=false}={}) { - const roll = {result: undefined, active: true}; - if ( minimize ) roll.result = 0; - else if ( maximize ) roll.result = 1; - else roll.result = Math.round(CONFIG.Dice.randomUniform()); - this.results.push(roll); - return roll; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - getResultLabel(result) { - return { - "0": "T", - "1": "H" - }[result.result]; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - getResultCSS(result) { - return [ - this.constructor.name.toLowerCase(), - result.result === 1 ? "heads" : "tails", - result.success ? "success" : null, - result.failure ? "failure" : null - ] - } - - /* -------------------------------------------- */ - /* Term Modifiers */ - /* -------------------------------------------- */ - - /** - * Call the result of the coin flip, marking any coins that matched the called target as a success - * 3dcc1 Flip 3 coins and treat "heads" as successes - * 2dcc0 Flip 2 coins and treat "tails" as successes - * @param {string} modifier The matched modifier query - */ - call(modifier) { - - // Match the modifier - const rgx = /c([01])/i; - const match = modifier.match(rgx); - if ( !match ) return false; - let [target] = match.slice(1); - target = parseInt(target); - - // Treat each result which matched the call as a success - for ( let r of this.results ) { - const match = r.result === target; - r.count = match ? 1 : 0; - r.success = match; - } - } -} - -/** - * A type of DiceTerm used to represent rolling a fair n-sided die. - * @implements {DiceTerm} - * - * @example Roll four six-sided dice - * ```js - * let die = new Die({faces: 6, number: 4}).evaluate(); - * ``` - */ -class Die extends DiceTerm { - constructor(termData={}) { - super(termData); - if ( typeof this.faces !== "number" ) { - throw new Error("A Die term must have a numeric number of faces."); - } - } - - /** @inheritdoc */ - static DENOMINATION = "d"; - - /** @inheritdoc */ - static MODIFIERS = { - r: "reroll", - rr: "rerollRecursive", - x: "explode", - xo: "explodeOnce", - k: "keep", - kh: "keep", - kl: "keep", - d: "drop", - dh: "drop", - dl: "drop", - min: "minimum", - max: "maximum", - even: "countEven", - odd: "countOdd", - cs: "countSuccess", - cf: "countFailures", - df: "deductFailures", - sf: "subtractFailures", - ms: "marginSuccess" - }; - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get total() { - const total = super.total; - if ( this.options.marginSuccess ) return total - parseInt(this.options.marginSuccess); - else if ( this.options.marginFailure ) return parseInt(this.options.marginFailure) - total; - else return total; - } - - /* -------------------------------------------- */ - /* Term Modifiers */ - /* -------------------------------------------- */ - - /** - * Re-roll the Die, rolling additional results for any values which fall within a target set. - * If no target number is specified, re-roll the lowest possible result. - * - * 20d20r reroll all 1s - * 20d20r1 reroll all 1s - * 20d20r=1 reroll all 1s - * 20d20r1=1 reroll a single 1 - * - * @param {string} modifier The matched modifier query - * @param {boolean} recursive Reroll recursively, continuing to reroll until the condition is no longer met - * @returns {boolean|void} False if the modifier was unmatched - */ - reroll(modifier, {recursive=false}={}) { - - // Match the re-roll modifier - const rgx = /rr?([0-9]+)?([<>=]+)?([0-9]+)?/i; - const match = modifier.match(rgx); - if ( !match ) return false; - let [max, comparison, target] = match.slice(1); - - // If no comparison or target are provided, treat the max as the target - if ( max && !(target || comparison) ) { - target = max; - max = null; - } - - // Determine target values - max = Number.isNumeric(max) ? parseInt(max) : null; - target = Number.isNumeric(target) ? parseInt(target) : 1; - comparison = comparison || "="; - - // Recursively reroll until there are no remaining results to reroll - let checked = 0; - let initial = this.results.length; - while ( checked < this.results.length ) { - let r = this.results[checked]; - checked++; - if (!r.active) continue; - - // Maybe we have run out of rerolls - if ( (max !== null) && (max <= 0) ) break; - - // Determine whether to re-roll the result - if ( DiceTerm.compareResult(r.result, comparison, target) ) { - r.rerolled = true; - r.active = false; - this.roll(); - if ( max !== null ) max -= 1; - } - - // Limit recursion - if ( !recursive && (checked >= initial) ) checked = this.results.length; - if ( checked > 1000 ) throw new Error("Maximum recursion depth for exploding dice roll exceeded"); - } - } - - /** - * @see {@link Die#reroll} - */ - rerollRecursive(modifier) { - return this.reroll(modifier, {recursive: true}); - } - - /* -------------------------------------------- */ - - /** - * Explode the Die, rolling additional results for any values which match the target set. - * If no target number is specified, explode the highest possible result. - * Explosion can be a "small explode" using a lower-case x or a "big explode" using an upper-case "X" - * - * @param {string} modifier The matched modifier query - * @param {boolean} recursive Explode recursively, such that new rolls can also explode? - */ - explode(modifier, {recursive=true}={}) { - - // Match the "explode" or "explode once" modifier - const rgx = /xo?([0-9]+)?([<>=]+)?([0-9]+)?/i; - const match = modifier.match(rgx); - if ( !match ) return false; - let [max, comparison, target] = match.slice(1); - - // If no comparison or target are provided, treat the max as the target value - if ( max && !(target || comparison) ) { - target = max; - max = null; - } - - // Determine target values - target = Number.isNumeric(target) ? parseInt(target) : this.faces; - comparison = comparison || "="; - - // Determine the number of allowed explosions - max = Number.isNumeric(max) ? parseInt(max) : null; - - // Recursively explode until there are no remaining results to explode - let checked = 0; - const initial = this.results.length; - while ( checked < this.results.length ) { - let r = this.results[checked]; - checked++; - if (!r.active) continue; - - // Maybe we have run out of explosions - if ( (max !== null) && (max <= 0) ) break; - - // Determine whether to explode the result and roll again! - if ( DiceTerm.compareResult(r.result, comparison, target) ) { - r.exploded = true; - this.roll(); - if ( max !== null ) max -= 1; - } - - // Limit recursion - if ( !recursive && (checked === initial) ) break; - if ( checked > 1000 ) throw new Error("Maximum recursion depth for exploding dice roll exceeded"); - } - } - - /** - * @see {@link Die#explode} - */ - explodeOnce(modifier) { - return this.explode(modifier, {recursive: false}); - } - - /* -------------------------------------------- */ - - /** - * Keep a certain number of highest or lowest dice rolls from the result set. - * - * 20d20k Keep the 1 highest die - * 20d20kh Keep the 1 highest die - * 20d20kh10 Keep the 10 highest die - * 20d20kl Keep the 1 lowest die - * 20d20kl10 Keep the 10 lowest die - * - * @param {string} modifier The matched modifier query - */ - keep(modifier) { - const rgx = /k([hl])?([0-9]+)?/i; - const match = modifier.match(rgx); - if ( !match ) return false; - let [direction, number] = match.slice(1); - direction = direction ? direction.toLowerCase() : "h"; - number = parseInt(number) || 1; - DiceTerm._keepOrDrop(this.results, number, {keep: true, highest: direction === "h"}); - } - - /* -------------------------------------------- */ - - /** - * Drop a certain number of highest or lowest dice rolls from the result set. - * - * 20d20d Drop the 1 lowest die - * 20d20dh Drop the 1 highest die - * 20d20dl Drop the 1 lowest die - * 20d20dh10 Drop the 10 highest die - * 20d20dl10 Drop the 10 lowest die - * - * @param {string} modifier The matched modifier query - */ - drop(modifier) { - const rgx = /d([hl])?([0-9]+)?/i; - const match = modifier.match(rgx); - if ( !match ) return false; - let [direction, number] = match.slice(1); - direction = direction ? direction.toLowerCase() : "l"; - number = parseInt(number) || 1; - DiceTerm._keepOrDrop(this.results, number, {keep: false, highest: direction !== "l"}); - } - - /* -------------------------------------------- */ - - /** - * Count the number of successful results which occurred in a given result set. - * Successes are counted relative to some target, or relative to the maximum possible value if no target is given. - * Applying a count-success modifier to the results re-casts all results to 1 (success) or 0 (failure) - * - * 20d20cs Count the number of dice which rolled a 20 - * 20d20cs>10 Count the number of dice which rolled higher than 10 - * 20d20cs<10 Count the number of dice which rolled less than 10 - * - * @param {string} modifier The matched modifier query - */ - countSuccess(modifier) { - const rgx = /(?:cs)([<>=]+)?([0-9]+)?/i; - const match = modifier.match(rgx); - if ( !match ) return false; - let [comparison, target] = match.slice(1); - comparison = comparison || "="; - target = parseInt(target) ?? this.faces; - DiceTerm._applyCount(this.results, comparison, target, {flagSuccess: true}); - } - - /* -------------------------------------------- */ - - /** - * Count the number of failed results which occurred in a given result set. - * Failures are counted relative to some target, or relative to the lowest possible value if no target is given. - * Applying a count-failures modifier to the results re-casts all results to 1 (failure) or 0 (non-failure) - * - * 6d6cf Count the number of dice which rolled a 1 as failures - * 6d6cf<=3 Count the number of dice which rolled less than 3 as failures - * 6d6cf>4 Count the number of dice which rolled greater than 4 as failures - * - * @param {string} modifier The matched modifier query - */ - countFailures(modifier) { - const rgx = /(?:cf)([<>=]+)?([0-9]+)?/i; - const match = modifier.match(rgx); - if ( !match ) return false; - let [comparison, target] = match.slice(1); - comparison = comparison || "="; - target = parseInt(target) ?? 1; - DiceTerm._applyCount(this.results, comparison, target, {flagFailure: true}); - } - - /* -------------------------------------------- */ - - /** - * Count the number of even results which occurred in a given result set. - * Even numbers are marked as a success and counted as 1 - * Odd numbers are marked as a non-success and counted as 0. - * - * 6d6even Count the number of even numbers rolled - * - * @param {string} modifier The matched modifier query - */ - countEven(modifier) { - for ( let r of this.results ) { - r.success = ( (r.result % 2) === 0 ); - r.count = r.success ? 1 : 0; - } - } - - /* -------------------------------------------- */ - - /** - * Count the number of odd results which occurred in a given result set. - * Odd numbers are marked as a success and counted as 1 - * Even numbers are marked as a non-success and counted as 0. - * - * 6d6odd Count the number of odd numbers rolled - * - * @param {string} modifier The matched modifier query - */ - countOdd(modifier) { - for ( let r of this.results ) { - r.success = ( (r.result % 2) !== 0 ); - r.count = r.success ? 1 : 0; - } - } - - /* -------------------------------------------- */ - - /** - * Deduct the number of failures from the dice result, counting each failure as -1 - * Failures are identified relative to some target, or relative to the lowest possible value if no target is given. - * Applying a deduct-failures modifier to the results counts all failed results as -1. - * - * 6d6df Subtract the number of dice which rolled a 1 from the non-failed total. - * 6d6cs>3df Subtract the number of dice which rolled a 3 or less from the non-failed count. - * 6d6cf<3df Subtract the number of dice which rolled less than 3 from the non-failed count. - * - * @param {string} modifier The matched modifier query - */ - deductFailures(modifier) { - const rgx = /(?:df)([<>=]+)?([0-9]+)?/i; - const match = modifier.match(rgx); - if ( !match ) return false; - let [comparison, target] = match.slice(1); - if ( comparison || target ) { - comparison = comparison || "="; - target = parseInt(target) ?? 1; - } - DiceTerm._applyDeduct(this.results, comparison, target, {deductFailure: true}); - } - - /* -------------------------------------------- */ - - /** - * Subtract the value of failed dice from the non-failed total, where each failure counts as its negative value. - * Failures are identified relative to some target, or relative to the lowest possible value if no target is given. - * Applying a deduct-failures modifier to the results counts all failed results as -1. - * - * 6d6df<3 Subtract the value of results which rolled less than 3 from the non-failed total. - * - * @param {string} modifier The matched modifier query - */ - subtractFailures(modifier) { - const rgx = /(?:sf)([<>=]+)?([0-9]+)?/i; - const match = modifier.match(rgx); - if ( !match ) return false; - let [comparison, target] = match.slice(1); - if ( comparison || target ) { - comparison = comparison || "="; - target = parseInt(target) ?? 1; - } - DiceTerm._applyDeduct(this.results, comparison, target, {invertFailure: true}); - } - - /* -------------------------------------------- */ - - /** - * Subtract the total value of the DiceTerm from a target value, treating the difference as the final total. - * Example: 6d6ms>12 Roll 6d6 and subtract 12 from the resulting total. - * @param {string} modifier The matched modifier query - */ - marginSuccess(modifier) { - const rgx = /(?:ms)([<>=]+)?([0-9]+)?/i; - const match = modifier.match(rgx); - if ( !match ) return false; - let [comparison, target] = match.slice(1); - target = parseInt(target); - if ( [">", ">=", "=", undefined].includes(comparison) ) this.options.marginSuccess = target; - else if ( ["<", "<="].includes(comparison) ) this.options.marginFailure = target; - } - - /* -------------------------------------------- */ - - /** - * Constrain each rolled result to be at least some minimum value. - * Example: 6d6min2 Roll 6d6, each result must be at least 2 - * @param {string} modifier The matched modifier query - */ - minimum(modifier) { - const rgx = /(?:min)([0-9]+)/i; - const match = modifier.match(rgx); - if ( !match ) return false; - let [target] = match.slice(1); - target = parseInt(target); - for ( let r of this.results ) { - if ( r.result < target ) { - r.count = target; - r.rerolled = true; - } - } - } - - /* -------------------------------------------- */ - - /** - * Constrain each rolled result to be at most some maximum value. - * Example: 6d6max5 Roll 6d6, each result must be at most 5 - * @param {string} modifier The matched modifier query - */ - maximum(modifier) { - const rgx = /(?:max)([0-9]+)/i; - const match = modifier.match(rgx); - if ( !match ) return false; - let [target] = match.slice(1); - target = parseInt(target); - for ( let r of this.results ) { - if ( r.result > target ) { - r.count = target; - r.rerolled = true; - } - } - } -} - -/** - * A type of DiceTerm used to represent a three-sided Fate/Fudge die. - * Mathematically behaves like 1d3-2 - * @extends {DiceTerm} - */ -class FateDie extends DiceTerm { - constructor(termData) { - super(termData); - this.faces = 3; - } - - /** @inheritdoc */ - static DENOMINATION = "f"; - - /** @inheritdoc */ - static MODIFIERS = { - "r": Die.prototype.reroll, - "rr": Die.prototype.rerollRecursive, - "k": Die.prototype.keep, - "kh": Die.prototype.keep, - "kl": Die.prototype.keep, - "d": Die.prototype.drop, - "dh": Die.prototype.drop, - "dl": Die.prototype.drop - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - roll({minimize=false, maximize=false}={}) { - const roll = {result: undefined, active: true}; - if ( minimize ) roll.result = -1; - else if ( maximize ) roll.result = 1; - else roll.result = Math.ceil((CONFIG.Dice.randomUniform() * this.faces) - 2); - if ( roll.result === -1 ) roll.failure = true; - if ( roll.result === 1 ) roll.success = true; - this.results.push(roll); - return roll; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - getResultLabel(result) { - return { - "-1": "-", - "0": " ", - "1": "+" - }[result.result]; - } -} - -/** - * A specialized sub-class of the ClientDocumentMixin which is used for document types that are intended to be - * represented upon the game Canvas. - * @type {function(typeof ClientDocument)} - * @category - Mixins - */ -const CanvasDocumentMixin = Base => class extends ClientDocumentMixin(Base) { - constructor(data={}, context) { - super(data, context); - - /** - * A reference to the PlaceableObject instance which represents this Embedded Document. - * @type {PlaceableObject|null} - */ - this._object = null; - - /** - * Has this object been deliberately destroyed as part of the deletion workflow? - * @type {boolean} - * @private - */ - this._destroyed = false; - } - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * A lazily constructed PlaceableObject instance which can represent this Document on the game canvas. - * @type {PlaceableObject|null} - * @name CanvasDocumentMixin#object - */ - get object() { - if ( this._object || this._destroyed ) return this._object; - if ( !this.parent?.isView || !this.layer ) return null; - this._object = this.layer.createObject(this); - return this._object; - } - - /* -------------------------------------------- */ - - /** - * A reference to the CanvasLayer which contains Document objects of this type. - * @type {PlaceablesLayer|null} - */ - get layer() { - return canvas.getLayerByEmbeddedName(this.documentName); - } - - /* -------------------------------------------- */ - - /** - * An indicator for whether this document is currently rendered on the game canvas. - * @type {boolean} - * @name CanvasDocumentMixin#rendered - */ - get rendered() { - return this.object !== null; - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** - * @see abstract.Document#_onCreate - * @memberof CanvasDocumentMixin# - */ - _onCreate(data, options, userId) { - super._onCreate(data, options, userId); - if ( this.parent.isView ) this.object?._onCreate(data, options, userId); - } - - /* -------------------------------------------- */ - - /** - * @see abstract.Document#_onUpdate - * @memberof CanvasDocumentMixin# - */ - _onUpdate(changed, options, userId) { - super._onUpdate(changed, options, userId); - if ( this.rendered ) this.object._onUpdate(changed, options, userId); - } - - /* -------------------------------------------- */ - - /** - * @see abstract.Document#_onDelete - * @memberof CanvasDocumentMixin# - */ - _onDelete(options, userId) { - super._onDelete(options, userId); - if ( this.rendered ) this.object._onDelete(options, userId); - } -}; - - -/** - * The client-side database backend implementation which handles Document modification operations. - * @extends {abstract.DatabaseBackend} - * @implements {abstract.DatabaseBackend} - */ -class ClientDatabaseBackend extends foundry.abstract.DatabaseBackend { - - /* -------------------------------------------- */ - /* Socket Workflows */ - /* -------------------------------------------- */ - - /** - * Activate the Socket event listeners used to receive responses from events which modify database documents - * @param {Socket} socket The active game socket - */ - activateSocketListeners(socket) { - - // Document Operations - socket.on("modifyDocument", response => { - const { request } = response; - const isEmbedded = CONST.DOCUMENT_TYPES.includes(request.parentType); - switch ( request.action ) { - case "create": - if ( isEmbedded ) return this._handleCreateEmbeddedDocuments(response); - else return this._handleCreateDocuments(response); - case "update": - if ( isEmbedded ) return this._handleUpdateEmbeddedDocuments(response); - else return this._handleUpdateDocuments(response); - case "delete": - if ( isEmbedded ) return this._handleDeleteEmbeddedDocuments(response); - else return this._handleDeleteDocuments(response); - default: - throw new Error(`Invalid Document modification action ${request.action} provided`); - } - }); - } - - /* -------------------------------------------- */ - /* Get Operations */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _getDocuments(documentClass, {query, options, pack}, user) { - const type = documentClass.documentName; - - // Dispatch the request - const response = await SocketInterface.dispatch("modifyDocument", { - type: type, - action: "get", - query: query, - options: options, - pack: pack - }); - - // Return the index only - if ( options.index ) return response.result; - - // Create Document objects - return response.result.map(data => { - return documentClass.fromSource(data, {pack}); - }); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _getEmbeddedDocuments(documentClass, parent, {query, options, pack}, user) { - throw new Error("Get operations for embedded Documents are currently un-supported"); - } - - /* -------------------------------------------- */ - /* Create Operations */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _createDocuments(documentClass, {data, options, pack}, user) { - const toCreate = await this._preCreateDocumentArray(documentClass, {data, options, pack, user}); - if ( !toCreate.length || options.temporary ) return toCreate; - const response = await SocketInterface.dispatch("modifyDocument", { - type: documentClass.documentName, - action: "create", - data: toCreate, - options: options, - pack: pack - }); - return this._handleCreateDocuments(response); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _createEmbeddedDocuments(documentClass, parent, {data, options, pack}, user) { - - // Special Case - if ( parent.parent ) { - if ( !(parent.parent instanceof TokenDocument) ) { - throw new Error("Managing embedded Documents which are not direct descendants of a primary Document is " - + "un-supported at this time."); - } - if ( !options.temporary ) { - return parent.parent.createActorEmbeddedDocuments(documentClass.documentName, data, options); - } - } - - // Standard Case - const toCreate = await this._preCreateDocumentArray(documentClass, {data, options, pack, parent, user}); - if ( !toCreate.length || options.temporary ) return toCreate; - const response = await SocketInterface.dispatch("modifyDocument", { - action: "create", - type: documentClass.documentName, - parentType: parent.documentName, - parentId: parent.id, - data: toCreate, - options: options, - pack: pack - }); - return this._handleCreateEmbeddedDocuments(response); - } - - /* -------------------------------------------- */ - - /** - * Perform a standardized pre-creation workflow for all Document types. For internal use only. - * @private - */ - async _preCreateDocumentArray(documentClass, {data, options, pack, parent, user}) { - user = user || game.user; - const type = documentClass.documentName; - const toCreate = []; - for ( let d of data ) { - - // Handle DataModel instances - if ( d instanceof foundry.abstract.DataModel ) d = d.toObject(); - else if ( Object.keys(d).some(k => k.indexOf(".") !== -1) ) d = foundry.utils.expandObject(d); - else d = foundry.utils.deepClone(d); - - // Migrate the creation data specifically for downstream compatibility - const createData = foundry.utils.deepClone(documentClass.migrateData(d)); - - // Perform pre-creation operations - let doc; - try { - doc = new documentClass(d, {parent, pack}); - } catch(err) { - Hooks.onError("ClientDatabaseBackend#_preCreateDocumentArray", err, {id: d._id, log: "error", notify: "error"}); - continue; - } - await doc._preCreate(createData, options, user); - - const allowed = options.noHook || Hooks.call(`preCreate${type}`, doc, createData, options, user.id); - if ( allowed === false ) { - console.debug(`${vtt} | ${type} creation prevented by preCreate hook`); - continue; - } - toCreate.push(doc); - } - return toCreate; - } - - /* -------------------------------------------- */ - - /** - * Handle a SocketResponse from the server when one or multiple documents were created - * @param {SocketResponse} response The provided Socket response - * @param {SocketRequest} [response.request] The initial socket request - * @param {object[]} [response.result] An Array of created data objects - * @param {string} [response.userId] The id of the requesting User - * @returns {Document[]} An Array of created Document instances - * @private - */ - _handleCreateDocuments({request, result=[], userId}) { - const { type, options, pack } = request; - - // Pre-operation collection actions - const collection = pack ? game.packs.get(pack) : game.collections.get(type); - collection._preCreateDocuments(result, options, userId); - - // Perform creations and execute callbacks - const callbacks = this._postCreateDocumentCallbacks(type, collection, result, {options, userId, pack}); - const documents = callbacks.map(fn => fn()); - - // Post-operation collection actions - collection._onCreateDocuments(documents, result, options, userId); - this._logOperation("Created", type, documents, {level: "info", pack}); - return documents; - } - - /* -------------------------------------------- */ - - /** - * Handle a SocketResponse from the server when one or multiple documents were created - * @param {SocketResponse} response The provided Socket response - * @param {SocketRequest} [response.request] The initial socket request - * @param {object[]} [response.result] An Array of created data objects - * @param {string} [response.userId] The id of the requesting User - * @returns {Document[]} An Array of created Document instances - * @private - */ - _handleCreateEmbeddedDocuments({request, result=[], userId}) { - const {type, parentType, parentId, options, pack} = request; - const parentCollection = pack ? game.packs.get(pack) : game.collections.get(parentType); - const parent = parentCollection.get(parentId, {strict: !pack}); - if ( !parent || !result.length ) return []; - - // Pre-operation parent actions - const collection = parent.getEmbeddedCollection(type); - parent._preCreateEmbeddedDocuments(type, result, options, userId); - - // Perform creations and execute callbacks - const callbacks = this._postCreateDocumentCallbacks(type, collection, result, {options, userId, parent, pack}); - parent.reset(); - const documents = callbacks.map(fn => fn()); - - // Perform follow-up operations for the parent Document - parent._onCreateEmbeddedDocuments(type, documents, result, options, userId); - this._logOperation("Created", type, documents, {level: "info", parent, pack}); - return documents; - } - - /* -------------------------------------------- */ - - /** - * Perform a standardized post-creation workflow for all Document types. For internal use only. - * @returns {Function[]} An array of callback operations to perform once every Document is created - * @private - */ - _postCreateDocumentCallbacks(type, collection, result, {options, userId, parent, pack}) { - const cls = getDocumentClass(type); - const callback = (doc, data) => { - doc._onCreate(data, options, userId); - Hooks.callAll(`create${type}`, doc, options, userId); - return doc; - }; - return result.map(data => { - const doc = new cls(data, {parent, pack}); - collection.set(doc.id, doc); - return callback.bind(this, doc, data); - }); - } - - /* -------------------------------------------- */ - /* Update Operations */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _updateDocuments(documentClass, {updates, options, pack}, user) { - const collection = pack ? game.packs.get(pack) : game.collections.get(documentClass.documentName); - const toUpdate = await this._preUpdateDocumentArray(collection, {updates, options, user}); - if ( !toUpdate.length ) return []; - const response = await SocketInterface.dispatch("modifyDocument", { - type: documentClass.documentName, - action: "update", - updates: toUpdate, - options: options, - pack: pack - }); - return this._handleUpdateDocuments(response); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _updateEmbeddedDocuments(documentClass, parent, {updates, options, pack}, user) { - - // Special Cases - if ( (parent instanceof TokenDocument) && (updates.length === 1) ) { - return parent.modifyActorDocument(updates[0], options); - } - if ( parent.parent instanceof TokenDocument ) { - return parent.parent.updateActorEmbeddedDocuments(documentClass.documentName, updates, options); - } - if ( parent.parent ) { - throw new Error("Managing embedded Documents which are not direct descendants of a primary Document is " - + "un-supported at this time."); - } - - // Normal case - const collection = parent.getEmbeddedCollection(documentClass.documentName); - const toUpdate = await this._preUpdateDocumentArray(collection, {updates, options, user}); - if ( !toUpdate.length ) return []; - const response = await SocketInterface.dispatch("modifyDocument", { - action: "update", - type: documentClass.documentName, - parentType: parent.documentName, - parentId: parent.id, - updates: toUpdate, - options: options, - pack: pack - }); - return this._handleUpdateEmbeddedDocuments(response); - } - - /* -------------------------------------------- */ - - /** - * Perform a standardized pre-update workflow for all Document types. For internal use only. - * @private - */ - async _preUpdateDocumentArray(collection, {updates, options, user}) { - user = user || game.user; - const cls = collection.documentClass; - const toUpdate = []; - if ( collection instanceof CompendiumCollection ) { - const updateIds = updates.reduce((arr, u) => { - if ( u._id && !collection.has(u._id) ) arr.push(u._id); - return arr; - }, []); - await collection.getDocuments({_id: {$in: updateIds}}); - } - - // Iterate over requested changes - for ( let update of updates ) { - if ( !update._id ) throw new Error("You must provide an _id for every object in the update data Array."); - - // Retrieve the change object - let changes; - if ( update instanceof foundry.abstract.DataModel ) changes = update.toObject(); - else changes = foundry.utils.expandObject(update); - changes = cls.migrateData(changes); - - // Get the Document being updated - let doc; - try { - doc = collection.get(update._id, {strict: true}); - } catch(err) { - if ( collection.invalidDocumentIds?.has(update._id) ) doc = collection.getInvalid(update._id); - else throw err; - } - - // Clean and validate the proposed changes - try { - // Add type information to allow a system data model to be retrieved, if one exists. - const hasType = "type" in changes; - if ( !hasType && ("type" in doc) ) changes.type = doc.type; - doc.validate({changes, clean: true, strict: true, fallback: false}); - if ( !hasType ) delete changes.type; - } catch(err) { - ui.notifications.error(err.message.split("] ").pop()); - Hooks.onError("ClientDatabaseBackend#_preUpdateDocumentArray", err, {id: doc.id, log: "error"}); - continue; - } - - // Retain only the differences against the current source - if ( options.diff ) { - changes = foundry.utils.diffObject(doc._source, changes, {deletionKeys: true}); - if ( foundry.utils.isEmpty(changes) ) continue; - changes._id = doc.id; - changes = cls.shimData(changes); // Re-apply the shim for _preUpdate hooks - } - - // Perform pre-update operations - await doc._preUpdate(changes, options, user); - - const allowed = options.noHook || Hooks.call(`preUpdate${doc.documentName}`, doc, changes, options, user.id); - if ( allowed === false ) { - console.debug(`${vtt} | ${doc.documentName} update prevented by preUpdate hook`); - continue; - } - toUpdate.push(changes); - } - return toUpdate; - } - - /* -------------------------------------------- */ - - /** - * Handle a SocketResponse from the server when one or multiple documents were updated - * @param {SocketResponse} response The provided Socket response - * @param {SocketRequest} [response.request] The initial socket request - * @param {object[]} [response.result] An Array of incremental data objects - * @param {string} [response.userId] The id of the requesting User - * @returns {Document[]} An Array of updated Document instances - * @private - */ - _handleUpdateDocuments({request, result=[], userId}={}) { - const { type, options, pack } = request; - const collection = pack ? game.packs.get(pack) : game.collections.get(type); - - // Pre-operation collection actions - collection._preUpdateDocuments(result, options, userId); - - // Perform updates and execute callbacks - const callbacks = this._postUpdateDocumentCallbacks(collection, result, {options, userId}); - const documents = callbacks.map(fn => fn()); - - // Post-operation collection actions - collection._onUpdateDocuments(documents, result, options, userId); - if ( CONFIG.debug.documents ) this._logOperation("Updated", type, documents, {level: "debug", pack}); - return documents; - } - - /* -------------------------------------------- */ - - /** - * Handle a SocketResponse from the server when embedded Documents are updated in a parent Document. - * @param {SocketResponse} response The provided Socket response - * @param {SocketRequest} [response.request] The initial socket request - * @param {object[]} [response.result] An Array of incremental data objects - * @param {string} [response.userId] The id of the requesting User - * @returns {Document[]} An Array of updated Document instances - * @private - */ - _handleUpdateEmbeddedDocuments({request, result=[], userId}) { - const { type, parentType, parentId, options, pack } = request; - const parentCollection = pack ? game.packs.get(pack) : game.collections.get(parentType); - let parent; - try { - parent = parentCollection.get(parentId, {strict: true}); - } catch(err) { - if ( parentCollection.invalidDocumentIds.has(parentId) ) parent = parentCollection.getInvalid(parentId); - else if ( !pack ) throw err; - } - if ( !parent || !result.length ) return []; - - // Pre-operation parent actions - const collection = parent.getEmbeddedCollection(type); - parent._preUpdateEmbeddedDocuments(type, result, options, userId); - - // Perform updates and execute callbacks - const callbacks = this._postUpdateDocumentCallbacks(collection, result, {options, userId}); - parent.reset(); - const documents = callbacks.map(fn => fn()); - - // Perform follow-up operations for the parent Document - parent._onUpdateEmbeddedDocuments(type, documents, result, options, userId); - if ( CONFIG.debug.documents ) this._logOperation("Updated", type, documents, {level: "debug", parent, pack}); - return documents; - } - - /* -------------------------------------------- */ - - /** - * Perform a standardized post-update workflow for all Document types. For internal use only. - * @returns {Function[]} An array of callback operations to perform after every Document is updated - * @private - */ - _postUpdateDocumentCallbacks(collection, result, {options, userId}) { - const cls = collection.documentClass; - const callback = (doc, change) => { - change = cls.shimData(change); - doc._onUpdate(change, options, userId); - Hooks.callAll(`update${doc.documentName}`, doc, change, options, userId); - return doc; - }; - const callbacks = []; - for ( let change of result ) { - const doc = collection.get(change._id, {strict: false}); - if ( !doc ) continue; - doc.updateSource(change, options); - callbacks.push(callback.bind(this, doc, change)); - } - return callbacks; - } - - /* -------------------------------------------- */ - /* Delete Operations */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _deleteDocuments(documentClass, {ids, options, pack}, user) { - user = user || game.user; - const collection = pack ? game.packs.get(pack) : game.collections.get(documentClass.documentName); - if ( options.deleteAll ) ids = pack ? collection.index.keys() : collection.keys(); - const toDelete = await this._preDeleteDocumentArray(collection, {ids, options, user}); - if ( !toDelete.length ) return []; - const response = await SocketInterface.dispatch("modifyDocument", { - type: documentClass.documentName, - action: "delete", - ids: toDelete, - options: options, - pack: pack - }); - return this._handleDeleteDocuments(response); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _deleteEmbeddedDocuments(documentClass, parent, {ids, options, pack}, user) { - - // Special Cases - if ( parent.parent instanceof TokenDocument ) { - return parent.parent.deleteActorEmbeddedDocuments(documentClass.documentName, ids, options); - } - if ( parent.parent ) { - throw new Error("Managing embedded Documents which are not direct descendants of a primary Document is " - + "un-supported at this time."); - } - - // Normal case - const collection = parent.getEmbeddedCollection(documentClass.documentName); - const deleteIds = options.deleteAll ? collection.keys() : ids; - const toDelete = await this._preDeleteDocumentArray(collection, {ids: deleteIds, options, user}); - if ( !toDelete.length ) return []; - const response = await SocketInterface.dispatch("modifyDocument", { - action: "delete", - type: documentClass.documentName, - parentType: parent.documentName, - parentId: parent.id, - ids: toDelete, - options: options, - pack: pack - }); - return this._handleDeleteEmbeddedDocuments(response); - } - - /* -------------------------------------------- */ - - /** - * Perform a standardized pre-delete workflow for all Document types. For internal use only. - * @private - */ - async _preDeleteDocumentArray(collection, {ids, options, user}) { - user = user || game.user; - const toDelete = []; - if ( collection instanceof CompendiumCollection ) { - await collection.getDocuments({_id: {$in: ids.filter(id => !collection.has(id))}}); - } - - // Iterate over ids requested for deletion - for ( let id of ids ) { - - // Get the Document being deleted - let doc; - try { - doc = collection.get(id, {strict: true}); - } catch(err) { - if ( collection.invalidDocumentIds?.has(id) ) doc = collection.getInvalid(id); - else throw err; - } - - // Perform pre-deletion operations - await doc._preDelete(options, user); - - const allowed = options.noHook || Hooks.call(`preDelete${doc.documentName}`, doc, options, user.id); - if ( allowed === false ) { - console.debug(`${vtt} | ${doc.documentName} deletion prevented by preDelete hook`); - continue; - } - toDelete.push(id); - } - return toDelete; - } - - /* -------------------------------------------- */ - - /** - * Handle a SocketResponse from the server where Documents are deleted. - * @param {SocketResponse} response The provided Socket response - * @param {SocketRequest} [response.request] The initial socket request - * @param {string[]} [response.result] An Array of deleted Document ids - * @param {string} [response.userId] The id of the requesting User - * @returns {Document[]} An Array of deleted Document instances - * @private - */ - _handleDeleteDocuments({request, result=[], userId}={}) { - const {type, options, pack} = request; - const collection = pack ? game.packs.get(pack) : game.collections.get(type); - result = options.deleteAll ? Array.from(collection.keys()) : result; - - // Pre-operation collection actions - collection._preDeleteDocuments(result, options, userId); - - // Perform deletions and execute callbacks - const callbacks = this._postDeleteDocumentCallbacks(collection, result, {options, userId}); - const documents = callbacks.map(fn => fn()); - - // Post-operation collection actions - collection._onDeleteDocuments(documents, result, options, userId); - this._logOperation("Deleted", type, documents, {level: "info", pack}); - return documents; - } - - /* -------------------------------------------- */ - - /** - * Handle a SocketResponse from the server when embedded Documents are deleted from a parent Document. - * @param {SocketResponse} response The provided Socket response - * @param {SocketRequest} [response.request] The initial socket request - * @param {string[]} [response.result] An Array of deleted Document ids - * @param {string} [response.userId] The id of the requesting User - * @returns {Document[]} An Array of deleted Document instances - * @private - */ - _handleDeleteEmbeddedDocuments({request, result=[], userId}) { - const { type, parentType, parentId, options, pack } = request; - const parentCollection = pack ? game.packs.get(pack) : game.collections.get(parentType); - const parent = parentCollection.get(parentId, {strict: !pack}); - if ( !parent || !result.length ) return []; - - // Pre-operation parent actions - const collection = parent.getEmbeddedCollection(type); - parent._preDeleteEmbeddedDocuments(type, result, options, userId); - - // Perform updates and execute callbacks - const callbacks = this._postDeleteDocumentCallbacks(collection, result, {options, userId}); - parent.reset(); - const documents = callbacks.map(fn => fn()); - - // Perform follow-up operations for the parent Document - parent._onDeleteEmbeddedDocuments(type, documents, result, options, userId); - this._logOperation("Deleted", type, documents, {level: "info", parent, pack}); - return documents; - } - - /* -------------------------------------------- */ - - /** - * Perform a standardized post-deletion workflow for all Document types. For internal use only. - * @returns {Function[]} An array of callback operations to perform after every Document is deleted - * @private - */ - _postDeleteDocumentCallbacks(collection, result, {options, userId}) { - const callback = doc => { - doc._onDelete(options, userId); - Hooks.callAll(`delete${doc.documentName}`, doc, options, userId); - return doc; - }; - const callbacks = []; - for ( let id of result ) { - const doc = collection.get(id, {strict: false}); - if ( !doc ) continue; - collection.delete(id); - callbacks.push(callback.bind(this, doc)); - } - return callbacks; - } - - /* -------------------------------------------- */ - /* Helper Methods */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - getFlagScopes() { - if ( this.#flagScopes ) return this.#flagScopes; - const scopes = ["core", "world", game.system.id]; - for ( const module of game.modules ) { - if ( module.active ) scopes.push(module.id); - } - return this.#flagScopes = scopes; - } - - /** - * A cached array of valid flag scopes which can be read and written. - * @type {string[]} - */ - #flagScopes; - - /* -------------------------------------------- */ - - /** @inheritdoc */ - getCompendiumScopes() { - return Array.from(game.packs.keys()); - } -} - -/** - * @typedef {abstract.Document} ClientDocument - * @mixes {ClientDocumentMixin} - */ - -/** - * A mixin which extends each Document definition with specialized client-side behaviors. - * This mixin defines the client-side interface for database operations and common document behaviors. - * @type {function(Class)} - * @category - Mixins - * @mixin - */ -const ClientDocumentMixin = Base => class extends Base { - constructor(data, context) { - super(data, context); - - /** - * A collection of Application instances which should be re-rendered whenever this document is updated. - * The keys of this object are the application ids and the values are Application instances. Each - * Application in this object will have its render method called by {@link Document#render}. - * @type {Object} - * @see {@link Document#render} - * @memberof ClientDocumentMixin# - */ - Object.defineProperty(this, "apps", { - value: {}, - writable: false, - enumerable: false - }); - - /** - * A cached reference to the FormApplication instance used to configure this Document. - * @type {FormApplication|null} - * @private - */ - Object.defineProperty(this, "_sheet", {value: null, writable: true, enumerable: false}); - } - - /** @inheritdoc */ - static name = "ClientDocumentMixin"; - - /* -------------------------------------------- */ - - /** - * @inheritDoc - * @this {ClientDocument} - */ - _initialize(options={}) { - super._initialize(options); - if ( !game._documentsReady ) return; - return this._safePrepareData(); - } - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * Return a reference to the parent Collection instance which contains this Document. - * @memberof ClientDocumentMixin# - * @this {ClientDocument} - * @type {Collection} - */ - get collection() { - if ( this.isEmbedded ) return this.parent[this.constructor.metadata.collection]; - else return CONFIG[this.documentName].collection.instance; - } - - /* -------------------------------------------- */ - - /** - * A reference to the Compendium Collection which contains this Document, if any, otherwise undefined. - * @memberof ClientDocumentMixin# - * @this {ClientDocument} - * @type {CompendiumCollection} - */ - get compendium() { - return game.packs.get(this.pack); - } - - /* -------------------------------------------- */ - - /** - * A boolean indicator for whether or not the current game User has ownership rights for this Document. - * Different Document types may have more specialized rules for what constitutes ownership. - * @type {boolean} - * @memberof ClientDocumentMixin# - */ - get isOwner() { - return this.testUserPermission(game.user, "OWNER"); - } - - /* -------------------------------------------- */ - - /** - * Test whether this Document is owned by any non-Gamemaster User. - * @type {boolean} - * @memberof ClientDocumentMixin# - */ - get hasPlayerOwner() { - for ( let u of game.users ) { - if ( u.isGM ) continue; - if ( this.testUserPermission(u, "OWNER") ) return true; - } - return false; - } - - /* ---------------------------------------- */ - - /** - * A boolean indicator for whether the current game User has exactly LIMITED visibility (and no greater). - * @type {boolean} - * @memberof ClientDocumentMixin# - */ - get limited() { - return this.testUserPermission(game.user, "LIMITED", {exact: true}); - } - - /* -------------------------------------------- */ - - /** - * Return a string which creates a dynamic link to this Document instance. - * @returns {string} - * @memberof ClientDocumentMixin# - */ - get link() { - return `@UUID[${this.uuid}]{${this.name}}`; - } - - /* ---------------------------------------- */ - - /** - * Return the permission level that the current game User has over this Document. - * See the CONST.DOCUMENT_OWNERSHIP_LEVELS object for an enumeration of these levels. - * @type {number} - * @memberof ClientDocumentMixin# - * - * @example Get the permission level the current user has for a document - * ```js - * game.user.id; // "dkasjkkj23kjf" - * actor.data.permission; // {default: 1, "dkasjkkj23kjf": 2}; - * actor.permission; // 2 - * ``` - */ - get permission() { - if ( game.user.isGM ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER; - if ( this.isEmbedded ) return this.parent.permission; - return this.getUserLevel(game.user); - } - - /* -------------------------------------------- */ - - /** - * Lazily obtain a FormApplication instance used to configure this Document, or null if no sheet is available. - * @type {FormApplication|null} - * @memberof ClientDocumentMixin# - */ - get sheet() { - if ( !this._sheet ) { - const cls = this._getSheetClass(); - if ( !cls ) return null; - this._sheet = new cls(this, {editable: this.isOwner}); - } - return this._sheet; - } - - /* -------------------------------------------- */ - - /** - * A Universally Unique Identifier (uuid) for this Document instance. - * @type {string} - * @memberof ClientDocumentMixin# - */ - get uuid() { - let parts = [this.documentName, this.id]; - if ( this.parent ) parts = [this.parent.uuid].concat(parts); - else if ( this.pack ) parts = ["Compendium", this.pack].concat(parts.slice(1)); - return parts.join("."); - } - - /* -------------------------------------------- */ - - /** - * A boolean indicator for whether the current game User has at least limited visibility for this Document. - * Different Document types may have more specialized rules for what determines visibility. - * @type {boolean} - * @memberof ClientDocumentMixin# - */ - get visible() { - if ( this.isEmbedded ) return this.parent.visible; - return this.testUserPermission(game.user, "LIMITED"); - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Obtain the FormApplication class constructor which should be used to configure this Document. - * @returns {Function|null} - * @private - */ - _getSheetClass() { - const cfg = CONFIG[this.documentName]; - const type = this.type ?? CONST.BASE_DOCUMENT_TYPE; - const sheets = cfg.sheetClasses[type] || {}; - - // Sheet selection overridden at the instance level - const override = this.getFlag("core", "sheetClass"); - if ( sheets[override] ) return sheets[override].cls; - - // Default sheet selection for the type - const classes = Object.values(sheets); - if ( !classes.length ) return null; - return (classes.find(s => s.default) ?? classes.pop()).cls; - } - - /* -------------------------------------------- */ - - /** - * Safely prepare data for a Document, catching any errors. - * @internal - */ - _safePrepareData() { - try { - this.prepareData(); - } catch(err) { - Hooks.onError("ClientDocumentMixin#_initialize", err, { - msg: `Failed data preparation for ${this.uuid}`, - log: "error", - uuid: this.uuid - }); - } - } - - /* -------------------------------------------- */ - - /** - * Prepare data for the Document. This method is called automatically by the DataModel#_initialize workflow. - * This method provides an opportunity for Document classes to define special data preparation logic. - * The work done by this method should be idempotent. There are situations in which prepareData may be called more - * than once. - * @memberof ClientDocumentMixin# - */ - prepareData() { - this.prepareBaseData(); - this.prepareEmbeddedDocuments(); - this.prepareDerivedData(); - } - - /* -------------------------------------------- */ - - /** - * Prepare data related to this Document itself, before any embedded Documents or derived data is computed. - * @memberof ClientDocumentMixin# - */ - prepareBaseData() {} - - /* -------------------------------------------- */ - - /** - * Prepare all embedded Document instances which exist within this primary Document. - * @memberof ClientDocumentMixin# - */ - prepareEmbeddedDocuments() { - const embeddedTypes = this.constructor.metadata.embedded || {}; - for ( const collectionName of Object.values(embeddedTypes) ) { - for ( let e of this[collectionName] ) { - e._safePrepareData(); - } - } - } - - /* -------------------------------------------- */ - - /** - * Apply transformations or derivations to the values of the source data object. - * Compute data fields whose values are not stored to the database. - * @memberof ClientDocumentMixin# - */ - prepareDerivedData() {} - - /* -------------------------------------------- */ - - /** - * Render all of the Application instances which are connected to this document by calling their respective - * @see Application#render - * @param {boolean} [force=false] Force rendering - * @param {object} [context={}] Optional context - * @memberof ClientDocumentMixin# - */ - render(force=false, context={}) { - for ( let app of Object.values(this.apps) ) { - app.render(force, context); - } - } - - /* -------------------------------------------- */ - - /** - * Determine the sort order for this Document by positioning it relative a target sibling. - * See SortingHelper.performIntegerSort for more details - * @param {object} [options] Sorting options provided to SortingHelper.performIntegerSort - * @param {object} [updateData] Additional data changes which are applied to each sorted document - * @param {object} [sortOptions] Options which are passed to the SortingHelpers.performIntegerSort method - * @returns {Promise} The Document after it has been re-sorted - * @memberof ClientDocumentMixin# - */ - async sortRelative({updateData={}, ...sortOptions}={}) { - const sorting = SortingHelpers.performIntegerSort(this, sortOptions); - const updates = []; - for ( let s of sorting ) { - const doc = s.target; - const update = foundry.utils.mergeObject(updateData, s.update, {inplace: false}); - update._id = doc.id; - if ( doc.sheet && doc.sheet.rendered ) await doc.sheet.submit({updateData: update}); - else updates.push(update); - } - if ( updates.length ) await this.constructor.updateDocuments(updates, {parent: this.parent, pack: this.pack}); - return this; - } - - /* -------------------------------------------- */ - - /** - * Construct a UUID relative to another document. - * @param {ClientDocument} doc The document to compare against. - */ - getRelativeUUID(doc) { - if ( this.compendium && (this.compendium !== doc.compendium) ) return this.uuid; - // This Document is a child of the relative Document. - if ( doc === this.parent ) return `.${this.documentName}.${this.id}`; - // This Document is a sibling of the relative Document. - if ( this.isEmbedded && (this.collection === doc.collection) ) return `.${this.id}`; - return this.uuid; - } - - /* -------------------------------------------- */ - - /** - * Create a content link for this document. - * @param {object} eventData The parsed object of data provided by the drop transfer event. - * @param {object} [options] Additional options to configure link generation. - * @param {ClientDocument} [options.relativeTo] A document to generate a link relative to. - * @param {string} [options.label] A custom label to use instead of the document's name. - * @returns {string} - * @internal - */ - _createDocumentLink(eventData, {relativeTo, label}={}) { - if ( !relativeTo && !label ) return this.link; - label ??= this.name; - if ( relativeTo ) return `@UUID[${this.getRelativeUUID(relativeTo)}]{${label}}`; - return `@UUID[${this.uuid}]{${label}}`; - } - - /* -------------------------------------------- */ - - /** - * Handle clicking on a content link for this document. - * @param {MouseEvent} event The triggering click event. - * @returns {any} - * @protected - */ - _onClickDocumentLink(event) { - return this.sheet.render(true, {focus: true}); - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** - * @see abstract.Document#_onCreate - * @memberof ClientDocumentMixin# - */ - _onCreate(data, options, userId) { - if ( options.renderSheet && (userId === game.user.id) ) { - if ( this.sheet ) this.sheet.render(true, { - action: "create", - data: data - }); - } - if ( this.constructor.metadata.indexed ) game.documentIndex.addDocument(this); - } - - /* -------------------------------------------- */ - - /** - * @see abstract.Document#_onUpdate - * @memberof ClientDocumentMixin# - */ - _onUpdate(data, options, userId) { - - // Re-render associated applications - if (options.render !== false) { - this.render(false, { - action: "update", - data: data - }); - } - - // Update Compendium index - if ( this.pack && !this.isEmbedded ) { - this.compendium.indexDocument(this); - } - - // Update global index. - if ( "name" in data ) game.documentIndex.replaceDocument(this); - } - - /* -------------------------------------------- */ - - /** - * @see abstract.Document#_onDelete - * @memberof ClientDocumentMixin# - */ - _onDelete(options, userId) { - Object.values(this.apps).forEach(a => a.close({submit: false})); - game.documentIndex.removeDocument(this); - } - - /* -------------------------------------------- */ - - /** - * Preliminary actions taken before a set of embedded Documents in this parent Document are created. - * @param {string} embeddedName The name of the embedded Document type - * @param {object[]} result An Array of created data objects - * @param {object} options Options which modified the creation operation - * @param {string} userId The ID of the User who triggered the operation - * @memberof ClientDocumentMixin# - */ - _preCreateEmbeddedDocuments(embeddedName, result, options, userId) {} - - /* -------------------------------------------- */ - - /** - * Follow-up actions taken after a set of embedded Documents in this parent Document are created. - * @param {string} embeddedName The name of the embedded Document type - * @param {Document[]} documents An Array of created Documents - * @param {object[]} result An Array of created data objects - * @param {object} options Options which modified the creation operation - * @param {string} userId The ID of the User who triggered the operation - * @memberof ClientDocumentMixin# - */ - _onCreateEmbeddedDocuments(embeddedName, documents, result, options, userId) { - if ( options.render === false ) return; - this.render(false, {renderContext: `create${embeddedName}`}); - } - - /* -------------------------------------------- */ - - /** - * Preliminary actions taken before a set of embedded Documents in this parent Document are updated. - * @param {string} embeddedName The name of the embedded Document type - * @param {object[]} result An Array of incremental data objects - * @param {object} options Options which modified the update operation - * @param {string} userId The ID of the User who triggered the operation - * @memberof ClientDocumentMixin# - */ - _preUpdateEmbeddedDocuments(embeddedName, result, options, userId) {} - - /* -------------------------------------------- */ - - /** - * Follow-up actions taken after a set of embedded Documents in this parent Document are updated. - * @param {string} embeddedName The name of the embedded Document type - * @param {Document[]} documents An Array of updated Documents - * @param {object[]} result An Array of incremental data objects - * @param {object} options Options which modified the update operation - * @param {string} userId The ID of the User who triggered the operation - * @memberof ClientDocumentMixin# - */ - _onUpdateEmbeddedDocuments(embeddedName, documents, result, options, userId) { - if ( options.render === false ) return; - this.render(false, {renderContext: `update${embeddedName}`}); - } - - /* -------------------------------------------- */ - - /** - * Preliminary actions taken before a set of embedded Documents in this parent Document are deleted. - * @param {string} embeddedName The name of the embedded Document type - * @param {object[]} result An Array of document IDs being deleted - * @param {object} options Options which modified the deletion operation - * @param {string} userId The ID of the User who triggered the operation - * @memberof ClientDocumentMixin# - */ - _preDeleteEmbeddedDocuments(embeddedName, result, options, userId) {} - - /* -------------------------------------------- */ - - /** - * Follow-up actions taken after a set of embedded Documents in this parent Document are deleted. - * @param {string} embeddedName The name of the embedded Document type - * @param {Document[]} documents An Array of deleted Documents - * @param {object[]} result An Array of document IDs being deleted - * @param {object} options Options which modified the deletion operation - * @param {string} userId The ID of the User who triggered the operation - * @memberof ClientDocumentMixin# - */ - _onDeleteEmbeddedDocuments(embeddedName, documents, result, options, userId) { - if ( options.render === false ) return; - this.render(false, {renderContext: `delete${embeddedName}`}); - } - - /* -------------------------------------------- */ - - /** - * Gets the default new name for a Document - * @returns {string} - */ - static defaultName() { - const label = game.i18n.localize(this.metadata.label); - const documentName = this.metadata.name; - const count = game.collections.get(documentName)?.size; - let defaultName = game.i18n.format("DOCUMENT.New", {type: label}); - if ( count > 0 ) defaultName += ` (${count + 1})`; - return defaultName; - } - - /* -------------------------------------------- */ - /* Importing and Exporting */ - /* -------------------------------------------- */ - - /** - * Present a Dialog form to create a new Document of this type. - * Choose a name and a type from a select menu of types. - * @param {object} data Initial data with which to populate the creation form - * @param {object} [context={}] Additional context options or dialog positioning options - * @returns {Promise} A Promise which resolves to the created Document, or null if the dialog was - * closed. - * @memberof ClientDocumentMixin - */ - static async createDialog(data={}, {parent=null, pack=null, ...options}={}) { - - // Collect data - const documentName = this.metadata.name; - const types = game.documentTypes[documentName]; - const folders = parent ? [] : game.folders.filter(f => (f.type === documentName) && f.displayed); - const label = game.i18n.localize(this.metadata.label); - const title = game.i18n.format("DOCUMENT.Create", {type: label}); - - // Render the document creation form - const html = await renderTemplate("templates/sidebar/document-create.html", { - folders, - name: data.name || game.i18n.format("DOCUMENT.New", {type: label}), - folder: data.folder, - hasFolders: folders.length >= 1, - type: data.type || CONFIG[documentName]?.defaultType || types[0], - types: types.reduce((obj, t) => { - const label = CONFIG[documentName]?.typeLabels?.[t] ?? t; - obj[t] = game.i18n.has(label) ? game.i18n.localize(label) : t; - return obj; - }, {}), - hasTypes: types.length > 1 - }); - - // Render the confirmation dialog window - return Dialog.prompt({ - title: title, - content: html, - label: title, - callback: html => { - const form = html[0].querySelector("form"); - const fd = new FormDataExtended(form); - foundry.utils.mergeObject(data, fd.object, {inplace: true}); - if ( !data.folder ) delete data.folder; - if ( types.length === 1 ) data.type = types[0]; - if ( !data.name?.trim() ) data.name = this.defaultName(); - return this.create(data, {parent, pack, renderSheet: true}); - }, - rejectClose: false, - options - }); - } - - /* -------------------------------------------- */ - - /** - * Present a Dialog form to confirm deletion of this Document. - * @param {object} [options] Positioning and sizing options for the resulting dialog - * @return {Promise} A Promise which resolves to the deleted Document - */ - async deleteDialog(options={}) { - const type = game.i18n.localize(this.constructor.metadata.label); - return Dialog.confirm({ - title: `${game.i18n.format("DOCUMENT.Delete", {type})}: ${this.name}`, - content: `

${game.i18n.localize("AreYouSure")}

${game.i18n.format("SIDEBAR.DeleteWarning", {type})}

`, - yes: this.delete.bind(this), - options: options - }); - } - - /* -------------------------------------------- */ - - /** - * Export document data to a JSON file which can be saved by the client and later imported into a different session. - * @param {object} [options] Additional options passed to the {@link ClientDocumentMixin#toCompendium} method - * @memberof ClientDocumentMixin# - */ - exportToJSON(options) { - const data = this.toCompendium(null, options); - data.flags["exportSource"] = { - world: game.world.id, - system: game.system.id, - coreVersion: game.version, - systemVersion: game.system.version - }; - const filename = `fvtt-${this.documentName}-${this.name.slugify()}.json`; - saveDataToFile(JSON.stringify(data, null, 2), "text/json", filename); - } - - /* -------------------------------------------- */ - - /** - * Create a content link for this Document. - * @param {object} [options] Additional options to configure how the link is constructed. - * @param {object} [options.attrs] Attributes to set on the link. - * @param {object} [options.dataset] Custom data- attributes to set on the link. - * @param {string[]} [options.classes] Classes to add to the link. - * @param {string} [options.name] A name to use for the Document, if different from the Document's name. - * @param {string} [options.icon] A font-awesome icon class to use as the icon, if different to the - * Document's configured sidebarIcon. - * @returns {HTMLAnchorElement} - */ - toAnchor({attrs={}, dataset={}, classes=[], name, icon}={}) { - - // Build dataset - const documentConfig = CONFIG[this.documentName]; - const documentName = game.i18n.localize(`DOCUMENT.${this.documentName}`); - let anchorIcon = icon ?? documentConfig.sidebarIcon; - dataset = foundry.utils.mergeObject({ - uuid: this.uuid, - id: this.id, - type: this.documentName, - pack: this.pack, - tooltip: documentName - }, dataset); - - // If this is a typed document, add the type to the dataset - if ( this.type ) { - const typeLabel = documentConfig.typeLabels[this.type]; - const typeName = game.i18n.has(typeLabel) ? `${game.i18n.localize(typeLabel)} ` : ""; - dataset.tooltip = `${typeName}${documentName}`; - anchorIcon = icon ?? documentConfig.typeIcons?.[this.type] ?? documentConfig.sidebarIcon; - } - - // Construct Link - const a = document.createElement("a"); - a.classList.add(...classes); - Object.entries(attrs).forEach(([k, v]) => a.setAttribute(k, v)); - for ( const [k, v] of Object.entries(dataset) ) { - if ( v !== null ) a.dataset[k] = v; - } - a.innerHTML = `${name ?? this.name}`; - return a; - } - - /* -------------------------------------------- */ - - /** - * Serialize salient information about this Document when dragging it. - * @returns {object} An object of drag data. - */ - toDragData() { - const dragData = { type: this.documentName }; - if ( this.id ) dragData.uuid = this.uuid; - else dragData.data = this.toObject(); - return dragData; - } - - /* -------------------------------------------- */ - - /** - * A helper function to handle obtaining the relevant Document from dropped data provided via a DataTransfer event. - * The dropped data could have: - * 1. A data object explicitly provided - * 2. A UUID - * @memberof ClientDocumentMixin - * - * @param {object} data The data object extracted from a DataTransfer event - * @param {object} options Additional options which affect drop data behavior - * @returns {Promise} The resolved Document - * @throws If a Document could not be retrieved from the provided data. - */ - static async fromDropData(data, options={}) { - let document = null; - - /** - * @deprecated since v10 - */ - if ( options.importWorld ) { - const msg = "The importWorld option for ClientDocumentMixin.fromDropData is deprecated. The Document returned " - + "by fromDropData should instead be persisted using the normal Document creation API."; - foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12}); - } - - // Case 1 - Data explicitly provided - if ( data.data ) document = new this(data.data); - - // Case 2 - UUID provided - else if ( data.uuid ) document = await fromUuid(data.uuid); - - // Ensure that we retrieved a valid document - if ( !document ) { - throw new Error("Failed to resolve Document from provided DragData. Either data or a UUID must be provided."); - } - if ( document.documentName !== this.documentName ) { - throw new Error(`Invalid Document type '${document.type}' provided to ${this.name}.fromDropData.`); - } - - // Flag the source UUID - if ( document.id && !document.getFlag("core", "sourceId") ) { - document.updateSource({"flags.core.sourceId": document.uuid}); - } - return document; - } - - /* -------------------------------------------- */ - - /** - * Update this Document using a provided JSON string. - * @this {ClientDocument} - * @param {string} json Raw JSON data to import - * @returns {Promise} The updated Document instance - */ - async importFromJSON(json) { - - // Construct a document class to (strictly) clean and validate the source data - const doc = new this.constructor(JSON.parse(json), {strict: true}); - - // Treat JSON import using the same workflows that are used when importing from a compendium pack - const data = this.collection.fromCompendium(doc, {addFlags: false}); - - // Preserve certain fields from the destination document - const preserve = Object.fromEntries(this.constructor.metadata.preserveOnImport.map(k => { - return [k, foundry.utils.getProperty(this, k)]; - })); - preserve.folder = this.folder?.id; - foundry.utils.mergeObject(data, preserve); - - // Commit the import as an update to this document - await this.update(data, {diff: false, recursive: false, noHook: true}); - ui.notifications.info(game.i18n.format("DOCUMENT.Imported", {document: this.documentName, name: this.name})); - return this; - } - - /* -------------------------------------------- */ - - /** - * Render an import dialog for updating the data related to this Document through an exported JSON file - * @returns {Promise} - * @memberof ClientDocumentMixin# - */ - async importFromJSONDialog() { - new Dialog({ - title: `Import Data: ${this.name}`, - content: await renderTemplate("templates/apps/import-data.html", { - hint1: game.i18n.format("DOCUMENT.ImportDataHint1", {document: this.documentName}), - hint2: game.i18n.format("DOCUMENT.ImportDataHint2", {name: this.name}) - }), - buttons: { - import: { - icon: '', - label: "Import", - callback: html => { - const form = html.find("form")[0]; - if ( !form.data.files.length ) return ui.notifications.error("You did not upload a data file!"); - readTextFromFile(form.data.files[0]).then(json => this.importFromJSON(json)); - } - }, - no: { - icon: '', - label: "Cancel" - } - }, - default: "import" - }, { - width: 400 - }).render(true); - } - - /* -------------------------------------------- */ - - /** - * Transform the Document data to be stored in a Compendium pack. - * Remove any features of the data which are world-specific. - * @param {CompendiumCollection} [pack] A specific pack being exported to - * @param {object} [options] Additional options which modify how the document is converted - * @param {boolean} [options.clearFlags=false] Clear the flags object - * @param {boolean} [options.clearSort=true] Clear the currently assigned folder and sort order - * @param {boolean} [options.clearOwnership=true] Clear document ownership - * @param {boolean} [options.clearState=true] Clear fields which store document state - * @param {boolean} [options.keepId=false] Retain the current Document id - * @returns {object} A data object of cleaned data suitable for compendium import - * @memberof ClientDocumentMixin# - */ - toCompendium(pack, {clearSort=true, clearFlags=false, clearOwnership=true, clearState=true, keepId=false}={}) { - const data = this.toObject(); - if ( !keepId ) delete data._id; - if ( clearSort ) { - delete data.folder; - delete data.sort; - } - if ( clearFlags ) delete data.flags; - if ( clearOwnership ) delete data.ownership; - if ( clearState ) delete data.active; - return data; - } -}; - -/** - * An abstract subclass of the Collection container which defines a collection of Document instances. - * @extends {Collection} - * @abstract - * - * @param {object[]} data An array of data objects from which to create document instances - */ -class DocumentCollection extends foundry.utils.Collection { - constructor(data=[]) { - super(); - - /** - * The source data array from which the Documents in the WorldCollection are created - * @type {object[]} - * @private - */ - Object.defineProperty(this, "_source", { - value: data, - writable: false - }); - - /** - * An Array of application references which will be automatically updated when the collection content changes - * @type {Application[]} - */ - this.apps = []; - - // Initialize data - this._initialize(); - } - - /* -------------------------------------------- */ - - /** - * Initialize the DocumentCollection by constructing any initially provided Document instances - * @private - */ - _initialize() { - this.clear(); - for ( let d of this._source ) { - let doc; - try { - doc = this.documentClass.fromSource(d, {strict: true}); - super.set(doc.id, doc); - } catch(err) { - this.invalidDocumentIds.add(d._id); - Hooks.onError(`${this.constructor.name}#_initialize`, err, { - msg: `Failed to initialized ${this.documentName} [${d._id}]`, - log: "error", - id: d._id - }); - } - } - } - - /* -------------------------------------------- */ - /* Collection Properties */ - /* -------------------------------------------- */ - - /** - * A reference to the Document class definition which is contained within this DocumentCollection. - * @type {Function} - */ - get documentClass() { - return getDocumentClass(this.documentName); - } - - /** @inheritdoc */ - get documentName() { - const name = this.constructor.documentName; - if ( !name ) throw new Error("A subclass of DocumentCollection must define its static documentName"); - return name; - } - - /** - * The base Document type which is contained within this DocumentCollection - * @type {string} - */ - static documentName; - - /** - * Record the set of document ids where the Document was not initialized because of invalid source data - * @type {Set} - */ - invalidDocumentIds = new Set(); - - /** - * The Collection class name - * @type {string} - */ - get name() { - return this.constructor.name; - } - - /* -------------------------------------------- */ - /* Collection Methods */ - /* -------------------------------------------- */ - - /** - * Obtain a temporary Document instance for a document id which currently has invalid source data. - * @param {string} id A document ID with invalid source data. - * @returns {Document} An in-memory instance for the invalid Document - */ - getInvalid(id) { - if ( !this.invalidDocumentIds.has(id) ) { - throw new Error(`${this.constructor.documentName} id [${id}] is not in the set of invalid ids`); - } - const data = this._source.find(d => d._id === id); - return this.documentClass.fromSource(foundry.utils.deepClone(data)); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - set(id, document) { - const cls = this.documentClass; - if (!(document instanceof cls)) { - throw new Error(`You may only push instances of ${cls.documentName} to the ${this.name} collection`); - } - super.set(document.id, document); - this._source.push(document.toJSON()); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - delete(id) { - super.delete(id); - this._source.findSplice(e => e._id === id); - } - - /* -------------------------------------------- */ - - /** - * Render any Applications associated with this DocumentCollection. - */ - render(force, options) { - for (let a of this.apps) a.render(force, options); - } - - /* -------------------------------------------- */ - /* Database Operations */ - /* -------------------------------------------- */ - - /** - * Update all objects in this DocumentCollection with a provided transformation. - * Conditionally filter to only apply to Entities which match a certain condition. - * @param {Function|object} transformation An object of data or function to apply to all matched objects - * @param {Function|null} condition A function which tests whether to target each object - * @param {object} [options] Additional options passed to Document.update - * @return {Promise} An array of updated data once the operation is complete - */ - async updateAll(transformation, condition=null, options={}) { - const hasTransformer = transformation instanceof Function; - if ( !hasTransformer && (foundry.utils.getType(transformation) !== "Object") ) { - throw new Error("You must provide a data object or transformation function"); - } - const hasCondition = condition instanceof Function; - const updates = []; - for ( let doc of this ) { - if ( hasCondition && !condition(doc) ) continue; - const update = hasTransformer ? transformation(doc) : foundry.utils.deepClone(transformation); - update._id = doc.id; - updates.push(update); - } - return this.documentClass.updateDocuments(updates, options); - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** - * Preliminary actions taken before a set of Documents in this Collection are created. - * @param {object[]} result An Array of created data objects - * @param {object} options Options which modified the creation operation - * @param {string} userId The ID of the User who triggered the operation - * @protected - */ - _preCreateDocuments(result, options, userId) {} - - /* -------------------------------------------- */ - - /** - * Follow-up actions taken after a set of Documents in this Collection are created. - * @param {Document[]} documents An Array of created Documents - * @param {object[]} result An Array of created data objects - * @param {object} options Options which modified the creation operation - * @param {string} userId The ID of the User who triggered the operation - * @protected - */ - _onCreateDocuments(documents, result, options, userId) { - if ( options.render !== false ) this.render(false, this._getRenderContext("create", documents, result)); - } - - /* -------------------------------------------- */ - - /** - * Preliminary actions taken before a set of Documents in this Collection are updated. - * @param {object[]} result An Array of incremental data objects - * @param {object} options Options which modified the update operation - * @param {string} userId The ID of the User who triggered the operation - * @protected - */ - _preUpdateDocuments(result, options, userId) {} - - /* -------------------------------------------- */ - - /** - * Follow-up actions taken after a set of Documents in this Collection are updated. - * @param {Document[]} documents An Array of updated Documents - * @param {object[]} result An Array of incremental data objects - * @param {object} options Options which modified the update operation - * @param {string} userId The ID of the User who triggered the operation - * @protected - */ - _onUpdateDocuments(documents, result, options, userId) { - if ( options.render !== false ) this.render(false, this._getRenderContext("update", documents, result)); - } - - /* -------------------------------------------- */ - - /** - * Preliminary actions taken before a set of Documents in this Collection are deleted. - * @param {string[]} result An Array of document IDs being deleted - * @param {object} options Options which modified the deletion operation - * @param {string} userId The ID of the User who triggered the operation - * @protected - */ - _preDeleteDocuments(result, options, userId) {} - - /* -------------------------------------------- */ - - /** - * Follow-up actions taken after a set of Documents in this Collection are deleted. - * @param {Document[]} documents An Array of deleted Documents - * @param {string[]} result An Array of document IDs being deleted - * @param {object} options Options which modified the deletion operation - * @param {string} userId The ID of the User who triggered the operation - * @protected - */ - _onDeleteDocuments(documents, result, options, userId) { - if ( options.render !== false ) this.render(false, this._getRenderContext("delete", documents, result)); - } - - /* -------------------------------------------- */ - - /** - * Generate the render context information provided for CRUD operations. - * @param {string} action The CRUD operation. - * @param {Document[]} documents The documents being operated on. - * @param {object[]|string[]} data An array of creation or update objects, or an array of document IDs, depending on - * the operation. - * @returns {{action: string, documentType: string, documents: Document[], data: object[]|string[]}} - * @private - */ - _getRenderContext(action, documents, data) { - const documentType = this.documentName; - return {action, documentType, documents, data}; - } -} - -/** - * A collection of world-level Document objects with a singleton instance per primary Document type. - * Each primary Document type has an associated subclass of WorldCollection which contains them. - * @extends {DocumentCollection} - * @abstract - * @see {Game#collections} - * - * @param {object[]} data An array of data objects from which to create Document instances - */ -class WorldCollection extends DocumentCollection { - - /* -------------------------------------------- */ - /* Collection Properties */ - /* -------------------------------------------- */ - - /** - * Return a reference to the SidebarDirectory application for this WorldCollection. - * @type {SidebarDirectory} - */ - get directory() { - const doc = getDocumentClass(this.constructor.documentName); - return ui[doc.metadata.collection]; - } - - /* -------------------------------------------- */ - - /** - * Return a reference to the singleton instance of this WorldCollection, or null if it has not yet been created. - * @type {WorldCollection} - */ - static get instance() { - return game.collections.get(this.documentName); - } - - /* -------------------------------------------- */ - /* Collection Methods */ - /* -------------------------------------------- */ - - /** - * Import a Document from a Compendium collection, adding it to the current World. - * @param {CompendiumCollection} pack The CompendiumCollection instance from which to import - * @param {string} id The ID of the compendium entry to import - * @param {object} [updateData] Optional additional data used to modify the imported Document before it is created - * @param {object} [options] Optional arguments passed to the {@link WorldCollection#fromCompendium} and - * {@link Document.create} methods - * @returns {Promise} The imported Document instance - */ - async importFromCompendium(pack, id, updateData={}, options={}) { - const cls = this.documentClass; - if (pack.documentName !== cls.documentName) { - throw new Error(`The ${pack.documentName} Document type provided by Compendium ${pack.collection} is incorrect for this Collection`); - } - - // Prepare the source data from which to create the Document - const document = await pack.getDocument(id); - const sourceData = this.fromCompendium(document, options); - const createData = foundry.utils.mergeObject(sourceData, updateData); - - // Create the Document - console.log(`${vtt} | Importing ${cls.documentName} ${document.name} from ${pack.collection}`); - this.directory.activate(); - options.fromCompendium = true; - return this.documentClass.create(createData, options); - } - - /* -------------------------------------------- */ - - /** - * Apply data transformations when importing a Document from a Compendium pack - * @param {Document|object} document The source Document, or a plain data object - * @param {object} [options] Additional options which modify how the document is imported - * @param {boolean} [options.addFlags=false] Add flags which track the import source - * @param {boolean} [options.clearSort=true] Clear the currently assigned folder and sort order - * @param {boolean} [options.clearOwnership=true] Clear document ownership - * @param {boolean} [options.keepId=false] Retain the Document id from the source Compendium - * @returns {object} The processed data ready for world Document creation - */ - fromCompendium(document, {addFlags=true, clearSort=true, clearOwnership=true, keepId=false}={}) { - - // Prepare the data structure - let data = document; - if (document instanceof foundry.abstract.Document) { - data = document.toObject(); - if (!data.flags.core?.sourceId && addFlags) foundry.utils.setProperty(data, "flags.core.sourceId", document.uuid); - } - - // Eliminate certain fields - if (!keepId) delete data._id; - if (clearSort) { - delete data.folder; - delete data.sort; - } - if ( clearOwnership && ("ownership" in data) ) { - data.ownership = { - default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE, - [game.user.id]: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER - }; - } - return data; - } - - /* -------------------------------------------- */ - /* Sheet Registration Methods */ - /* -------------------------------------------- */ - - /** - * Register a Document sheet class as a candidate which can be used to display Documents of a given type. - * See {@link DocumentSheetConfig.registerSheet} for details. - * @static - * @param {Array<*>} args Arguments forwarded to the DocumentSheetConfig.registerSheet method - * - * @example Register a new ActorSheet subclass for use with certain Actor types. - * ```js - * Actors.registerSheet("dnd5e", ActorSheet5eCharacter, { types: ["character], makeDefault: true }); - * ``` - */ - static registerSheet(...args) { - DocumentSheetConfig.registerSheet(getDocumentClass(this.documentName), ...args); - } - - /* -------------------------------------------- */ - - /** - * Unregister a Document sheet class, removing it from the list of available sheet Applications to use. - * See {@link DocumentSheetConfig.unregisterSheet} for detauls. - * @static - * @param {Array<*>} args Arguments forwarded to the DocumentSheetConfig.unregisterSheet method - * - * @example Deregister the default ActorSheet subclass to replace it with others. - * ```js - * Actors.unregisterSheet("core", ActorSheet); - * ``` - */ - static unregisterSheet(...args) { - DocumentSheetConfig.unregisterSheet(getDocumentClass(this.documentName), ...args); - } - - /* -------------------------------------------- */ - - /** - * Return an array of currently registered sheet classes for this Document type. - * @static - * @type {DocumentSheet[]} - */ - static get registeredSheets() { - const sheets = new Set(); - for ( let t of Object.values(CONFIG[this.documentName].sheetClasses) ) { - for ( let s of Object.values(t) ) { - sheets.add(s.cls); - } - } - return Array.from(sheets); - } -} - -/** - * The singleton collection of Actor documents which exist within the active World. - * This Collection is accessible within the Game object as game.actors. - * @extends {WorldCollection} - * @category - Collections - * - * @see {@link Actor} The Actor document - * @see {@link ActorDirectory} The ActorDirectory sidebar directory - * - * @example Retrieve an existing Actor by its id - * ```js - * let actor = game.actors.get(actorId); - * ``` - */ -class Actors extends WorldCollection { - /** - * A mapping of synthetic Token Actors which are currently active within the viewed Scene. - * Each Actor is referenced by the Token.id. - * @type {Object} - */ - get tokens() { - if ( !canvas.ready || !canvas.scene ) return {}; - return canvas.scene.tokens.reduce((obj, t) => { - if ( t.actorLink ) return obj; - obj[t.id] = t.actor; - return obj; - }, {}); - } - - /* -------------------------------------------- */ - - /** @override */ - static documentName = "Actor"; - - /* -------------------------------------------- */ - - /** @inheritdoc */ - fromCompendium(document, options={}) { - const data = super.fromCompendium(document, options); - - // Re-associate imported Active Effects which are sourced to Items owned by this same Actor - if ( data._id ) { - const ownItemIds = new Set(data.items.map(i => i._id)); - for ( let effect of data.effects ) { - if ( !effect.origin ) continue; - const effectItemId = effect.origin.split(".").pop(); - if ( ownItemIds.has(effectItemId) ) { - effect.origin = `Actor.${data._id}.Item.${effectItemId}`; - } - } - } - return data; - } -} - -/** - * The collection of Cards documents which exist within the active World. - * This Collection is accessible within the Game object as game.cards. - * @extends {WorldCollection} - * @see {@link Cards} The Cards document - */ -class CardStacks extends WorldCollection { - - /** @override */ - static documentName = "Cards"; -} - -/** - * The singleton collection of Combat documents which exist within the active World. - * This Collection is accessible within the Game object as game.combats. - * @extends {WorldCollection} - * - * @see {@link Combat} The Combat document - * @see {@link CombatTracker} The CombatTracker sidebar directory - */ -class CombatEncounters extends WorldCollection { - - /** @override */ - static documentName = "Combat"; - - /* -------------------------------------------- */ - - /** - * Provide the settings object which configures the Combat document - * @type {object} - */ - static get settings() { - return game.settings.get("core", Combat.CONFIG_SETTING); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get directory() { - return ui.combat; - } - - /* -------------------------------------------- */ - - /** - * Get an Array of Combat instances which apply to the current canvas scene - * @type {Combat[]} - */ - get combats() { - return this.filter(c => (c.scene === null) || (c.scene === game.scenes.current)); - } - - /* -------------------------------------------- */ - - /** - * The currently active Combat instance - * @type {Combat} - */ - get active() { - return this.combats.find(c => c.active); - } - - /* -------------------------------------------- */ - - /** - * The currently viewed Combat encounter - * @type {Combat|null} - */ - get viewed() { - return ui.combat?.viewed ?? null; - } - - /* -------------------------------------------- */ - - /** - * When a Token is deleted, remove it as a combatant from any combat encounters which included the Token - * @param {string} sceneId The Scene id within which a Token is being deleted - * @param {string} tokenId The Token id being deleted - * @protected - */ - async _onDeleteToken(sceneId, tokenId) { - for ( let combat of this ) { - const toDelete = []; - for ( let c of combat.combatants ) { - if ( (c.sceneId === sceneId) && (c.tokenId === tokenId) ) toDelete.push(c.id); - } - if ( toDelete.length ) await combat.deleteEmbeddedDocuments("Combatant", toDelete); - } - } -} - -/** - * A collection of Document objects contained within a specific compendium pack. - * Each Compendium pack has its own associated instance of the CompendiumCollection class which contains its contents. - * @extends {DocumentCollection} - * @abstract - * @see {Game#packs} - * - * @param {object} metadata The compendium metadata, an object provided by game.data - */ -class CompendiumCollection extends DocumentCollection { - constructor(metadata) { - super([]); - - /** - * The compendium metadata which defines the compendium content and location - * @type {object} - */ - this.metadata = metadata; - - /** - * A subsidiary collection which contains the more minimal index of the pack - * @type {Collection} - */ - this.index = new foundry.utils.Collection(); - - /** - * A debounced function which will clear the contents of the Compendium pack if it is not accessed frequently. - * @type {Function} - * @private - */ - this._flush = foundry.utils.debounce(this.clear.bind(this), this.constructor.CACHE_LIFETIME_SECONDS * 1000); - - // Initialize a provided Compendium index - this.#indexedFields = new Set(this.documentClass.metadata.compendiumIndexFields); - if ( metadata.index ) { - for ( let i of metadata.index ) { - this.index.set(i._id, i); - } - delete metadata.index; - } - - // Define the Compendium directory application - this.apps.push(new Compendium(this)); - } - - /* -------------------------------------------- */ - - /** - * The amount of time that Document instances within this CompendiumCollection are held in memory. - * Accessing the contents of the Compendium pack extends the duration of this lifetime. - * @type {number} - */ - static CACHE_LIFETIME_SECONDS = 300; - - /** - * The named game setting which contains Compendium configurations. - * @type {string} - */ - static CONFIG_SETTING = "compendiumConfiguration"; - - /* -------------------------------------------- */ - - /** - * The canonical Compendium name - comprised of the originating package and the pack name - * @type {string} - */ - get collection() { - return this.metadata.id; - } - - /** - * Access the compendium configuration data for this pack - * @type {object} - */ - get config() { - const setting = game.settings.get("core", "compendiumConfiguration"); - return setting[this.collection] || {}; - } - - /** @inheritdoc */ - get documentName() { - return this.metadata.type; - } - - /** - * Track whether the Compendium Collection is locked for editing - * @type {boolean} - */ - get locked() { - return this.config.locked ?? (this.metadata.packageType !== "world"); - } - - /** - * Whether the compendium is currently open in the UI. - * @type {boolean} - */ - get isOpen() { - return this.apps.some(app => app._state > Application.RENDER_STATES.NONE); - } - - /** - * Track whether the Compendium Collection is private - * @type {boolean} - */ - get private() { - return this.config.private ?? this.metadata.private; - } - - /** - * A convenience reference to the label which should be used as the title for the Compendium pack. - * @type {string} - */ - get title() { - return this.metadata.label; - } - - /** - * The index fields which should be loaded for this compendium pack - * @type {Set} - */ - get indexFields() { - const coreFields = this.documentClass.metadata.compendiumIndexFields; - const configFields = CONFIG[this.documentName].compendiumIndexFields || []; - return new Set([...coreFields, ...configFields]); - } - - /** - * Track which document fields have been indexed for this compendium pack - * @type {Set} - * @private - */ - #indexedFields; - - /** - * Has this compendium pack been fully indexed? - * @type {boolean} - */ - get indexed() { - return this.indexFields.isSubset(this.#indexedFields); - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - get(key, options) { - this._flush(); - return super.get(key, options); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - set(id, document) { - this._flush(); - this.indexDocument(document); - return super.set(id, document); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - delete(id) { - this.index.delete(id); - return super.delete(id); - } - - /* -------------------------------------------- */ - - /** - * Load the Compendium index and cache it as the keys and values of the Collection. - * @param {object} [options] Options which customize how the index is created - * @param {string[]} [options.fields] An array of fields to return as part of the index - * @returns {Promise} - */ - async getIndex({fields=[]}={}) { - const cls = this.documentClass; - - // Maybe reuse the existing index if we have already indexed all fields - const indexFields = new Set([...this.indexFields, ...fields]); - if ( indexFields.isSubset(this.#indexedFields) ) return this.index; - - // Request the new index from the server - const index = await cls.database.get(cls, { - query: {}, - options: { index: true, indexFields: Array.from(indexFields) }, - pack: this.collection - }, game.user); - - // Assign the index to the collection - for ( let i of index ) { - const x = this.index.get(i._id); - this.index.set(i._id, x ? foundry.utils.mergeObject(x, i) : i); - } - - // Record that the pack has been indexed - console.log(`${vtt} | Constructed index of ${this.collection} Compendium containing ${this.index.size} entries`); - this.#indexedFields = indexFields; - return this.index; - } - - /* -------------------------------------------- */ - - /** - * Get a single Document from this Compendium by ID. - * The document may already be locally cached, otherwise it is retrieved from the server. - * @param {string} id The requested Document id - * @returns {Promise|undefined} The retrieved Document instance - */ - async getDocument(id) { - if ( !id ) return undefined; - const cached = this.get(id); - if ( cached instanceof foundry.abstract.Document ) return cached; - const documents = await this.getDocuments({_id: id}); - return documents.length ? documents.shift() : null; - } - - /* -------------------------------------------- */ - - /** - * Load multiple documents from the Compendium pack using a provided query object. - * @param {object} query A database query used to retrieve documents from the underlying database - * @returns {Promise} The retrieved Document instances - */ - async getDocuments(query={}) { - const cls = this.documentClass; - const documents = await cls.database.get(cls, {query, pack: this.collection}, game.user); - for ( let d of documents ) { - if ( d.invalid && !this.invalidDocumentIds.has(d.id) ) { - this.invalidDocumentIds.add(d.id); - this._source.push(d); - } - else this.set(d.id, d); - } - return documents; - } - - /* -------------------------------------------- */ - - /** - * Import a Document into this Compendium Collection. - * @param {Document} document The existing Document you wish to import - * @param {object} [options] Additional options which modify how the data is imported. - * See {@link ClientDocumentMixin#toCompendium} - * @returns {Promise} The imported Document instance - */ - importDocument(document, options={}) { - if ( !(document instanceof this.documentClass) ) { - const err = Error(`You may not import a ${document.constructor.name} Document into the ${this.collection} Compendium which contains ${this.documentClass.name} Documents.`); - ui.notifications.error(err.message); - throw err; - } - options.clearOwnership = options.clearOwnership ?? (this.metadata.packageType === "world"); - const data = document.toCompendium(this, options); - return this.documentClass.create(data, {pack: this.collection}); - } - - /* -------------------------------------------- */ - - /** - * Fully import the contents of a Compendium pack into a World folder. - * @param {object} [options={}] Options which modify the import operation. Additional options are forwarded to - * {@link WorldCollection#fromCompendium} and {@link Document.createDocuments} - * @param {string|null} [options.folderId] An existing Folder _id to use. - * @param {string} [options.folderName] A new Folder name to create. - * @returns {Promise} The imported Documents, now existing within the World - */ - async importAll({folderId=null, folderName="", ...options}={}) { - let folder; - - // Optionally, create a folder - if ( CONST.FOLDER_DOCUMENT_TYPES.includes(this.documentName) ) { - - // Re-use an existing folder - if ( folderId ) folder = game.folders.get(folderId, {strict: true}); - else if ( folderName ) folder = game.folders.find(f => (f.name === folderName) && (f.type === this.documentName)); - - // Create a new Folder - if ( !folder ) { - folder = await Folder.create({ - name: folderName || this.title, - type: this.documentName, - parent: null - }); - } - } - - // Load all content - const documents = await this.getDocuments(); - ui.notifications.info(game.i18n.format("COMPENDIUM.ImportAllStart", { - number: documents.length, - type: this.documentName, - folder: folder.name - })); - - // Prepare import data - const collection = game.collections.get(this.documentName); - const createData = documents.map(doc => { - const data = collection.fromCompendium(doc, options); - data.folder = folder.id; - return data; - }); - - // Create World Documents in batches - const chunkSize = 100; - const nBatches = Math.ceil(createData.length / chunkSize); - let created = []; - for ( let n=0; n} A promise which resolves in the following ways: an array of imported - * Documents if the "yes" button was pressed, false if the "no" button was pressed, or - * null if the dialog was closed without making a choice. - */ - async importDialog(options={}) { - - // Render the HTML form - const html = await renderTemplate("templates/sidebar/apps/compendium-import.html", { - folderName: this.title, - keepId: options.keepId ?? false - }); - - // Present the Dialog - options.jQuery = false; - return Dialog.confirm({ - title: `${game.i18n.localize("COMPENDIUM.ImportAll")}: ${this.title}`, - content: html, - yes: html => { - const form = html.querySelector("form"); - return this.importAll({ - folderName: form.folderName.value, - keepId: form.keepId.checked - }); - }, - options - }); - } - - /* -------------------------------------------- */ - - /** - * Add a Document to the index, capturing its relevant index attributes - * @param {Document} document The document to index - */ - indexDocument(document) { - let index = this.index.get(document.id); - const data = document.toObject(); - if ( index ) foundry.utils.mergeObject(index, data, {insertKeys: false, insertValues: false}); - else { - index = this.#indexedFields.reduce((obj, field) => { - foundry.utils.setProperty(obj, field, foundry.utils.getProperty(data, field)); - return obj; - }, {}); - } - if ( index.img ) index.img = data.thumb ?? data.img; - index._id = data._id; - this.index.set(document.id, index); - } - - /* -------------------------------------------- */ - /* Compendium Management */ - /* -------------------------------------------- */ - - /** - * Create a new Compendium Collection using provided metadata. - * @param {object} metadata The compendium metadata used to create the new pack - * @param {object} options Additional options which modify the Compendium creation request - * @returns {Promise} - */ - static async createCompendium(metadata, options={}) { - if ( !game.user.isGM ) return ui.notifications.error("You do not have permission to modify this compendium pack"); - const response = await SocketInterface.dispatch("manageCompendium", { - action: "create", - data: metadata, - options: options - }); - - // Add the new pack to the World - game.data.packs.push(response.result); - const pack = new CompendiumCollection(response.result); - game.packs.set(pack.collection, pack); - ui.compendium.render(); - return pack; - } - - /* ----------------------------------------- */ - - /** - * Assign configuration metadata settings to the compendium pack - * @param {object} settings The object of compendium settings to define - * @returns {Promise} A Promise which resolves once the setting is updated - */ - configure(settings={}) { - const config = game.settings.get("core", this.constructor.CONFIG_SETTING); - const pack = config[this.collection] || {private: false, locked: this.metadata.packageType !== "world"}; - config[this.collection] = foundry.utils.mergeObject(pack, settings); - return game.settings.set("core", this.constructor.CONFIG_SETTING, config); - } - /* ----------------------------------------- */ - - - /** - * Delete an existing world-level Compendium Collection. - * This action may only be performed for world-level packs by a Gamemaster User. - * @returns {Promise} - */ - async deleteCompendium() { - this._assertUserCanModify(); - this.apps.forEach(app => app.close()); - await SocketInterface.dispatch("manageCompendium", { - action: "delete", - data: this.metadata.name - }); - - // Remove the pack from the game World - game.data.packs.findSplice(p => p.id === this.collection); - game.packs.delete(this.collection); - ui.compendium.render(); - return this; - } - - /* ----------------------------------------- */ - - /** - * Duplicate a compendium pack to the current World. - * @param {string} label A new Compendium label - * @returns {Promise} - */ - async duplicateCompendium({label}={}) { - this._assertUserCanModify({requireUnlocked: false}); - label = label || this.title; - const metadata = foundry.utils.mergeObject(this.metadata, { - name: label.slugify({strict: true}), - label: label - }, {inplace: false}); - return this.constructor.createCompendium(metadata, {source: this.collection}); - } - - /* ----------------------------------------- */ - - /** - * Validate that the current user is able to modify content of this Compendium pack - * @returns {boolean} - * @private - */ - _assertUserCanModify({requireUnlocked=true}={}) { - const config = game.settings.get("core", this.constructor.CONFIG_SETTING)[this.collection] || {}; - let err; - if ( !game.user.isGM ) err = new Error("You do not have permission to modify this compendium pack"); - if ( requireUnlocked && config.locked ) { - err = new Error("You cannot modify content in this compendium pack because it is locked."); - } - if ( err ) { - ui.notifications.error(err.message); - throw err; - } - return true; - } - - /* -------------------------------------------- */ - - /** - * Migrate a compendium pack. - * This operation re-saves all documents within the compendium pack to disk, applying the current data model. - * If the document type has system data, the latest system data template will also be applied to all documents. - * @returns {Promise} - */ - async migrate() { - this._assertUserCanModify(); - ui.notifications.info(`Beginning migration for Compendium pack ${this.collection}, please be patient.`); - await SocketInterface.dispatch("manageCompendium", { - type: this.collection, - action: "migrate", - data: this.collection - }); - ui.notifications.info(`Successfully migrated Compendium pack ${this.collection}.`); - return this; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async updateAll(transformation, condition=null, options={}) { - await this.getDocuments(); - options.pack = this.collection; - return super.updateAll(transformation, condition, options); - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onCreateDocuments(documents, result, options, userId) { - super._onCreateDocuments(documents, result, options, userId); - this._onModifyContents(documents, options, userId); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onUpdateDocuments(documents, result, options, userId) { - super._onUpdateDocuments(documents, result, options, userId); - this._onModifyContents(documents, options, userId); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDeleteDocuments(documents, result, options, userId) { - super._onDeleteDocuments(documents, result, options, userId); - this._onModifyContents(documents, options, userId); - } - - /* -------------------------------------------- */ - - /** - * Follow-up actions taken when Documents within this Compendium pack are modified - * @private - */ - _onModifyContents(documents, options, userId) { - /** - * A hook event that fires whenever the contents of a Compendium pack were modified. - * This hook fires for all connected clients after the update has been processed. - * - * @function updateCompendium - * @memberof hookEvents - * @param {CompendiumCollection} pack The Compendium pack being modified - * @param {Document[]} documents The locally-cached Documents which were modified in the operation - * @param {object} options Additional options which modified the modification request - * @param {string} userId The ID of the User who triggered the modification workflow - */ - Hooks.callAll("updateCompendium", this, documents, options, userId); - } -} - -/** - * The singleton collection of FogExploration documents which exist within the active World. - * @extends {WorldCollection} - * @see {@link FogExploration} The FogExploration document - */ -class FogExplorations extends WorldCollection { - static documentName = "FogExploration"; - - /** @inheritDoc */ - _onDeleteDocuments(documents, result, options, userId) { - if ( result.includes(canvas.fog.exploration?.id) || (options.sceneId === canvas.id) ) { - canvas.fog._handleReset(); - } - } -} - -/** - * The singleton collection of Folder documents which exist within the active World. - * This Collection is accessible within the Game object as game.folders. - * @extends {WorldCollection} - * - * @see {@link Folder} The Folder document - */ -class Folders extends WorldCollection { - constructor(...args) { - super(...args); - - /** - * Track which Folders are currently expanded in the UI - */ - this._expanded = {}; - } - - /* -------------------------------------------- */ - - /** @override */ - static documentName = "Folder"; - - /* -------------------------------------------- */ - - /** @override */ - render(force, context) { - if ( context && context.documents.length ) { - const folder = context.documents[0]; - const collection = game.collections.get(folder.type); - collection.render(force, context); - if ( folder.type === "JournalEntry" ) { - this._refreshJournalEntrySheets(); - } - } - } - - /* -------------------------------------------- */ - - /** - * Refresh the display of any active JournalSheet instances where the folder list will change. - * @private - */ - _refreshJournalEntrySheets() { - for ( let app of Object.values(ui.windows) ) { - if ( !(app instanceof JournalSheet) ) continue; - app.submit(); - } - } -} - -/** - * The singleton collection of Item documents which exist within the active World. - * This Collection is accessible within the Game object as game.items. - * @extends {WorldCollection} - * - * @see {@link Item} The Item document - * @see {@link ItemDirectory} The ItemDirectory sidebar directory - */ -class Items extends WorldCollection { - - /** @override */ - static documentName = "Item"; -} - -/** - * The singleton collection of JournalEntry documents which exist within the active World. - * This Collection is accessible within the Game object as game.journal. - * @extends {WorldCollection} - * - * @see {@link JournalEntry} The JournalEntry document - * @see {@link JournalDirectory} The JournalDirectory sidebar directory - */ -class Journal extends WorldCollection { - - /** @override */ - static documentName = "JournalEntry"; - - /* -------------------------------------------- */ - /* Interaction Dialogs */ - /* -------------------------------------------- */ - - /** - * Display a dialog which prompts the user to show a JournalEntry or JournalEntryPage to other players. - * @param {JournalEntry|JournalEntryPage} doc The JournalEntry or JournalEntryPage to show. - * @returns {Promise} - */ - static async showDialog(doc) { - if ( !((doc instanceof JournalEntry) || (doc instanceof JournalEntryPage)) ) return; - if ( !doc.isOwner ) return ui.notifications.error("JOURNAL.ShowBadPermissions", {localize: true}); - if ( game.users.size < 2 ) return ui.notifications.warn("JOURNAL.ShowNoPlayers", {localize: true}); - - const users = game.users.filter(u => u.id !== game.userId); - const ownership = Object.entries(CONST.DOCUMENT_OWNERSHIP_LEVELS); - if ( !doc.isEmbedded ) ownership.shift(); - const levels = [ - {level: CONST.DOCUMENT_META_OWNERSHIP_LEVELS.NOCHANGE, label: "OWNERSHIP.NOCHANGE"}, - ...ownership.map(([name, level]) => ({level, label: `OWNERSHIP.${name}`})) - ]; - const isImage = (doc instanceof JournalEntryPage) && (doc.type === "image"); - const html = await renderTemplate("templates/journal/dialog-show.html", {users, levels, isImage}); - - return Dialog.prompt({ - title: game.i18n.format("JOURNAL.ShowEntry", {name: doc.name}), - label: game.i18n.localize("JOURNAL.ActionShow"), - content: html, - render: html => { - const form = html.querySelector("form"); - form.elements.allPlayers.addEventListener("change", event => { - const checked = event.currentTarget.checked; - form.querySelectorAll('[name="players"]').forEach(i => { - i.checked = checked; - i.disabled = checked; - }); - }); - }, - callback: async html => { - const form = html.querySelector("form"); - const fd = new FormDataExtended(form).object; - const users = fd.allPlayers ? game.users.filter(u => !u.isSelf) : fd.players.reduce((arr, id) => { - const u = game.users.get(id); - if ( u && !u.isSelf ) arr.push(u); - return arr; - }, []); - if ( !users.length ) return; - const userIds = users.map(u => u.id); - if ( fd.ownership > -2 ) { - const ownership = doc.ownership; - if ( fd.allPlayers ) ownership.default = fd.ownership; - for ( const id of userIds ) { - if ( fd.allPlayers ) { - if ( (id in ownership) && (ownership[id] <= fd.ownership) ) delete ownership[id]; - continue; - } - if ( ownership[id] === CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE ) ownership[id] = fd.ownership; - ownership[id] = Math.max(ownership[id] ?? -Infinity, fd.ownership); - } - await doc.update({ownership}, {diff: false, recursive: false, noHook: true}); - } - if ( fd.imageOnly ) return this.showImage(doc.src, { - users: userIds, - title: doc.name, - caption: fd.showImageCaption ? doc.image.caption : undefined, - showTitle: fd.showImageTitle, - uuid: doc.uuid - }); - return this.show(doc, {force: true, users: userIds}); - }, - rejectClose: false, - options: {jQuery: false} - }); - } - - /* -------------------------------------------- */ - - /** - * Show the JournalEntry or JournalEntryPage to connected players. - * By default, the document will only be shown to players who have permission to observe it. - * If the force parameter is passed, the document will be shown to all players regardless of normal permission. - * @param {JournalEntry|JournalEntryPage} doc The JournalEntry or JournalEntryPage to show. - * @param {object} [options] Additional options to configure behaviour. - * @param {boolean} [options.force=false] Display the entry to all players regardless of normal permissions. - * @param {string[]} [options.users] An optional list of user IDs to show the document to. Otherwise it will - * be shown to all connected clients. - * @returns {Promise} A Promise that resolves back to the shown document once the - * request is processed. - * @throws If the user does not own the document they are trying to show. - */ - static show(doc, {force=false, users=[]}={}) { - if ( !((doc instanceof JournalEntry) || (doc instanceof JournalEntryPage)) ) return; - if ( !doc.isOwner ) throw new Error(game.i18n.localize("JOURNAL.ShowBadPermissions")); - const strings = Object.fromEntries(["all", "authorized", "selected"].map(k => [k, game.i18n.localize(k)])); - return new Promise(resolve => { - game.socket.emit("showEntry", doc.uuid, {force, users}, () => { - Journal._showEntry(doc.uuid, force); - ui.notifications.info(game.i18n.format("JOURNAL.ActionShowSuccess", { - title: doc.name, - which: users.length ? strings.selected : force ? strings.all : strings.authorized - })); - return resolve(doc); - }); - }); - } - - /* -------------------------------------------- */ - - /** - * Share an image with connected players. - * @param {string} src The image URL to share. - * @param {ShareImageConfig} [config] Image sharing configuration. - */ - static showImage(src, {users=[], ...options}={}) { - game.socket.emit("shareImage", {image: src, users, ...options}); - const strings = Object.fromEntries(["all", "selected"].map(k => [k, game.i18n.localize(k)])); - ui.notifications.info(game.i18n.format("JOURNAL.ImageShowSuccess", { - which: users.length ? strings.selected : strings.all - })); - } - - /* -------------------------------------------- */ - /* Socket Listeners and Handlers */ - /* -------------------------------------------- */ - - /** - * Open Socket listeners which transact JournalEntry data - * @param {Socket} socket The open websocket - */ - static _activateSocketListeners(socket) { - socket.on("showEntry", this._showEntry.bind(this)); - socket.on("shareImage", ImagePopout._handleShareImage); - } - - /* -------------------------------------------- */ - - /** - * Handle a received request to show a JournalEntry or JournalEntryPage to the current client - * @param {string} uuid The UUID of the document to display for other players - * @param {boolean} [force=false] Display the document regardless of normal permissions - * @internal - */ - static async _showEntry(uuid, force=false) { - let entry = await fromUuid(uuid); - const options = {tempOwnership: force, mode: JournalSheet.VIEW_MODES.MULTIPLE, pageIndex: 0}; - if ( entry instanceof JournalEntryPage ) { - options.mode = JournalSheet.VIEW_MODES.SINGLE; - options.pageId = entry.id; - // Set temporary observer permissions for this page. - entry.ownership[game.userId] = CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER; - entry = entry.parent; - } - else if ( entry instanceof JournalEntry ) entry.ownership[game.userId] = CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER; - else return; - if ( !force && !entry.visible ) return; - - // Show the sheet with the appropriate mode - entry.sheet.render(true, options); - } -} - -/** - * The singleton collection of Macro documents which exist within the active World. - * This Collection is accessible within the Game object as game.macros. - * @extends {WorldCollection} - * - * @see {@link Macro} The Macro document - * @see {@link MacroDirectory} The MacroDirectory sidebar directory - */ -class Macros extends WorldCollection { - - /** @override */ - static documentName = "Macro"; - - /* -------------------------------------------- */ - - /** @override */ - get directory() { - return ui.macros; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - fromCompendium(document, options={}) { - const data = super.fromCompendium(document, options); - if ( options.clearOwnership ) data.author = game.user.id; - return data; - } -} - -/** - * The singleton collection of ChatMessage documents which exist within the active World. - * This Collection is accessible within the Game object as game.messages. - * @extends {WorldCollection} - * - * @see {@link ChatMessage} The ChatMessage document - * @see {@link ChatLog} The ChatLog sidebar directory - */ -class Messages extends WorldCollection { - - /** @override */ - static documentName = "ChatMessage"; - - /* -------------------------------------------- */ - - /** - * @override - * @returns {SidebarTab} - * */ - get directory() { - return ui.chat; - } - - /* -------------------------------------------- */ - - /** @override */ - render(force=false) {} - - /* -------------------------------------------- */ - - /** - * If requested, dispatch a Chat Bubble UI for the newly created message - * @param {ChatMessage} message The ChatMessage document to say - * @private - */ - sayBubble(message) { - const {content, type, speaker} = message; - if ( speaker.scene === canvas.scene.id ) { - const token = canvas.tokens.get(speaker.token); - if ( token ) canvas.hud.bubbles.say(token, content, { - cssClasses: type === CONST.CHAT_MESSAGE_TYPES.EMOTE ? ["emote"] : [] - }); - } - } - - /* -------------------------------------------- */ - - /** - * Handle export of the chat log to a text file - * @private - */ - export() { - const log = this.contents.map(m => m.export()).join("\n---------------------------\n"); - let date = new Date().toDateString().replace(/\s/g, "-"); - const filename = `fvtt-log-${date}.txt`; - saveDataToFile(log, "text/plain", filename); - } - - /* -------------------------------------------- */ - - /** - * Allow for bulk deletion of all chat messages, confirm first with a yes/no dialog. - * @see {@link Dialog.confirm} - */ - async flush() { - return Dialog.confirm({ - title: game.i18n.localize("CHAT.FlushTitle"), - content: `

${game.i18n.localize("AreYouSure")}

${game.i18n.localize("CHAT.FlushWarning")}

`, - yes: () => this.documentClass.deleteDocuments([], {deleteAll: true}), - options: { - top: window.innerHeight - 150, - left: window.innerWidth - 720 - } - }); - } -} - -/** - * The singleton collection of Playlist documents which exist within the active World. - * This Collection is accessible within the Game object as game.playlists. - * @extends {WorldCollection} - * - * @see {@link Playlist} The Playlist document - * @see {@link PlaylistDirectory} The PlaylistDirectory sidebar directory - */ -class Playlists extends WorldCollection { - constructor(...args) { - super(...args); - this.initialize(); - } - - /* -------------------------------------------- */ - - /** @override */ - static documentName = "Playlist"; - - /* -------------------------------------------- */ - - /** - * Return the subset of Playlist documents which are currently playing - * @type {Playlist[]} - */ - get playing() { - return this.filter(s => s.playing); - } - - /* -------------------------------------------- */ - - /** - * Perform one-time initialization to begin playback of audio - */ - initialize() { - for ( let playlist of this ) { - for ( let sound of playlist.sounds ) { - sound.sync(); - } - } - } - - /* -------------------------------------------- */ - - /** - * Handle changes to a Scene to determine whether to trigger changes to Playlist documents. - * @param {Scene} scene The Scene document being updated - * @param {Object} data The incremental update data - */ - async _onChangeScene(scene, data) { - const currentScene = game.scenes.active; - const p0 = currentScene?.playlist; - const s0 = currentScene?.playlistSound; - const p1 = ("playlist" in data) ? game.playlists.get(data.playlist) : scene.playlist; - const s1 = "playlistSound" in data ? p1?.sounds.get(data.playlistSound) : scene.playlistSound; - const soundChange = (p0 !== p1) || (s0 !== s1); - if ( soundChange ) { - if ( s0 ) await s0.update({playing: false}); - else if ( p0 ) await p0.stopAll(); - if ( s1 ) await s1.update({playing: true}); - else if ( p1 ) await p1.playAll(); - } - } -} - -/** - * The singleton collection of Scene documents which exist within the active World. - * This Collection is accessible within the Game object as game.scenes. - * @extends {WorldCollection} - * - * @see {@link Scene} The Scene document - * @see {@link SceneDirectory} The SceneDirectory sidebar directory - */ -class Scenes extends WorldCollection { - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** @override */ - static documentName = "Scene"; - - /* -------------------------------------------- */ - - /** - * Return a reference to the Scene which is currently active - * @type {Scene} - */ - get active() { - return this.find(s => s.active); - } - - /* -------------------------------------------- */ - - /** - * Return the current Scene target. - * This is the viewed scene if the canvas is active, otherwise it is the currently active scene. - * @type {Scene} - */ - get current() { - const canvasInitialized = canvas.ready || game.settings.get("core", "noCanvas"); - return canvasInitialized ? this.viewed : this.active; - } - - /* -------------------------------------------- */ - - /** - * Return a reference to the Scene which is currently viewed - * @type {Scene} - */ - get viewed() { - return this.find(s => s.isView); - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Handle preloading the art assets for a Scene - * @param {string} sceneId The Scene id to begin loading - * @param {boolean} push Trigger other connected clients to also preload Scene resources - */ - async preload(sceneId, push=false) { - if ( push ) return game.socket.emit("preloadScene", sceneId, () => this.preload(sceneId)); - let scene = this.get(sceneId); - const promises = []; - - // Preload sounds - if ( scene.playlistSound?.path ) promises.push(AudioHelper.preloadSound(scene.playlistSound.path)); - else if ( scene.playlist?.playbackOrder.length ) { - const first = scene.playlist.sounds.get(scene.playlist.playbackOrder[0]); - if ( first ) promises.push(AudioHelper.preloadSound(first.path)); - } - - // Preload textures without expiring current ones - promises.push(TextureLoader.loadSceneTextures(scene, {expireCache: false})); - return Promise.all(promises); - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @override */ - static _activateSocketListeners(socket) { - socket.on("preloadScene", sceneId => this.instance.preload(sceneId)); - socket.on("pullToScene", this._pullToScene); - } - - /* -------------------------------------------- */ - - /** - * Handle requests pulling the current User to a specific Scene - * @param {string} sceneId - * @private - */ - static _pullToScene(sceneId) { - const scene = game.scenes.get(sceneId); - if ( scene ) scene.view(); - } - - /* -------------------------------------------- */ - /* Importing and Exporting */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - fromCompendium(document, options={}) { - const data = super.fromCompendium(document, options); - if ( options.clearState ) delete data.active; - if ( options.clearSort ) { - delete data.navigation; - delete data.navOrder; - } - return data; - } -} - -/** - * The Collection of Setting documents which exist within the active World. - * This collection is accessible as game.settings.storage.get("world") - * @extends {WorldCollection} - * - * @see {@link Setting} The Setting document - */ -class WorldSettings extends WorldCollection { - - /** @override */ - static documentName = "Setting"; - - /* -------------------------------------------- */ - - /** @override */ - get directory() { - return null; - } - - /* -------------------------------------------- */ - /* World Settings Methods */ - /* -------------------------------------------- */ - - /** - * Return the Setting document with the given key. - * @param {string} key The setting key - * @returns {Setting} The Setting - */ - getSetting(key) { - return this.find(s => s.key === key); - } - - /** - * Return the serialized value of the world setting as a string - * @param {string} key The setting key - * @returns {string|null} The serialized setting string - */ - getItem(key) { - return this.getSetting(key)?.value ?? null; - } -} - -/** - * The singleton collection of RollTable documents which exist within the active World. - * This Collection is accessible within the Game object as game.tables. - * @extends {WorldCollection} - * - * @see {@link RollTable} The RollTable document - * @see {@link RollTableDirectory} The RollTableDirectory sidebar directory - */ -class RollTables extends WorldCollection { - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** @override */ - static documentName = "RollTable"; - - /* -------------------------------------------- */ - - /** @override */ - get directory() { - return ui.tables; - } - - /* -------------------------------------------- */ - - /** - * Register world settings related to RollTable documents - */ - static registerSettings() { - - // Show Player Cursors - game.settings.register("core", "animateRollTable", { - name: "TABLE.AnimateSetting", - hint: "TABLE.AnimateSettingHint", - scope: "world", - config: true, - default: true, - type: Boolean - }); - } -} - -/** - * The singleton collection of User documents which exist within the active World. - * This Collection is accessible within the Game object as game.users. - * @extends {WorldCollection} - * - * @see {@link User} The User document - */ -class Users extends WorldCollection { - constructor(...args) { - super(...args); - - /** - * The User document of the currently connected user - * @type {User|null} - */ - this.current = this.current || null; - } - - /* -------------------------------------------- */ - - /** - * Initialize the Map object and all its contained documents - * @private - * @override - */ - _initialize() { - super._initialize(); - - // Flag the current user - this.current = this.get(game.data.userId) || null; - if ( this.current ) this.current.active = true; - - // Set initial user activity state - for ( let activeId of game.data.activeUsers || [] ) { - this.get(activeId).active = true; - } - } - - /* -------------------------------------------- */ - - /** @override */ - static documentName = "User"; - - /* -------------------------------------------- */ - - /** - * Get the users with player roles - * @returns {User[]} - */ - get players() { - return this.filter(u => !u.isGM && u.hasRole("PLAYER")); - } - - /* -------------------------------------------- */ - /* Socket Listeners and Handlers */ - /* -------------------------------------------- */ - - static _activateSocketListeners(socket) { - socket.on("userActivity", this._handleUserActivity); - } - - /* -------------------------------------------- */ - - /** - * Handle receipt of activity data from another User connected to the Game session - * @param {string} userId The User id who generated the activity data - * @param {ActivityData} activityData The object of activity data - * @private - */ - static _handleUserActivity(userId, activityData={}) { - const user = game.users.get(userId); - if ( !user ) return; - - // Update User active state - const active = "active" in activityData ? activityData.active : true; - if ( user.active !== active ) { - user.active = active; - game.users.render(); - if ( (active === false) && ui.nav ) ui.nav.render(); - Hooks.callAll("userConnected", user, active); - } - - // Everything below here requires the game to be ready - if ( !game.ready ) return; - - // Set viewed scene - const sceneChange = ("sceneId" in activityData) && (activityData.sceneId !== user.viewedScene); - if ( sceneChange ) { - user.viewedScene = activityData.sceneId; - ui.nav.render(); - } - - if ( "av" in activityData ) { - game.webrtc.settings.handleUserActivity(userId, activityData.av); - } - - // Everything below requires an active canvas - if ( !canvas.ready ) return; - - // User control deactivation - if ( (active === false) || (user.viewedScene !== canvas.id) ) { - canvas.controls.updateCursor(user, null); - canvas.controls.updateRuler(user, null); - user.updateTokenTargets([]); - return; - } - - // Re-broadcast our targets if the user is switching to the scene we're on. - if ( sceneChange && (activityData.sceneId === canvas.id) ) { - game.user.broadcastActivity({targets: game.user.targets.ids}); - } - - // Cursor position - if ( "cursor" in activityData ) { - canvas.controls.updateCursor(user, activityData.cursor); - } - - // Was it a ping? - if ( "ping" in activityData ) { - canvas.controls.handlePing(user, activityData.cursor, activityData.ping); - } - - // Ruler measurement - if ( "ruler" in activityData ) { - canvas.controls.updateRuler(user, activityData.ruler); - } - - // Token targets - if ( "targets" in activityData ) { - user.updateTokenTargets(activityData.targets); - } - } -} - -/** - * The client-side ActiveEffect document which extends the common BaseActiveEffect model. - * Each ActiveEffect belongs to the effects collection of its parent Document. - * Each ActiveEffect contains a ActiveEffectData object which provides its source data. - * - * @extends documents.BaseActiveEffect - * @mixes ClientDocumentMixin - * - * @see {@link documents.Actor} The Actor document which contains ActiveEffect embedded documents - * @see {@link documents.Item} The Item document which contains ActiveEffect embedded documents - */ -class ActiveEffect extends ClientDocumentMixin(foundry.documents.BaseActiveEffect) { - - /** - * A cached reference to the source name to avoid recurring database lookups - * @type {string|null} - */ - _sourceName = null; - - /** - * Does this ActiveEffect correspond to a significant status effect ID? - * @type {string|null} - * @private - */ - _statusId = null; - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * Is there some system logic that makes this active effect ineligible for application? - * @type {boolean} - */ - get isSuppressed() { - return false; - } - - /* --------------------------------------------- */ - - /** - * Does this Active Effect currently modify an Actor? - * @type {boolean} - */ - get modifiesActor() { - return (this.parent instanceof Actor) && !this.disabled && !this.isSuppressed; - } - - /* --------------------------------------------- */ - - /** @inheritdoc */ - prepareDerivedData() { - const statusId = this.flags.core?.statusId; - this._statusId = Object.values(CONFIG.specialStatusEffects).includes(statusId) ? statusId : null; - this._prepareDuration(); - } - - /* --------------------------------------------- */ - - /** - * Prepare derived data related to active effect duration - * @internal - */ - _prepareDuration() { - const d = this.duration; - - // Time-based duration - if ( Number.isNumeric(d.seconds) ) { - const start = (d.startTime || game.time.worldTime); - const elapsed = game.time.worldTime - start; - const remaining = d.seconds - elapsed; - return foundry.utils.mergeObject(d, { - type: "seconds", - duration: d.seconds, - remaining: remaining, - label: `${remaining} Seconds` - }); - } - - // Turn-based duration - else if ( (d.rounds || d.turns) && game.combat ) { - - // Determine the current combat duration - const cbt = game.combat; - const c = {round: cbt.round ?? 0, turn: cbt.turn ?? 0, nTurns: cbt.turns.length || 1}; - const current = this._getCombatTime(c.round, c.turn); - const duration = this._getCombatTime(d.rounds, d.turns); - const start = this._getCombatTime(d.startRound, d.startTurn, c.nTurns); - - // If the effect has not started yet display the full duration - if ( current <= start ) { - return foundry.utils.mergeObject(d, { - type: "turns", - duration: duration, - remaining: duration, - label: this._getDurationLabel(d.rounds, d.turns) - }); - } - - // Some number of remaining rounds and turns (possibly zero) - const remaining = Math.max(((start + duration) - current).toNearest(0.01), 0); - const remainingRounds = Math.floor(remaining); - let nt = c.turn - d.startTurn; - while ( nt < 0 ) nt += c.nTurns; - const remainingTurns = nt > 0 ? c.nTurns - nt : 0; - return foundry.utils.mergeObject(d, { - type: "turns", - duration: duration, - remaining: remaining, - label: this._getDurationLabel(remainingRounds, remainingTurns) - }); - } - - // No duration - return foundry.utils.mergeObject(d, { - type: "none", - duration: null, - remaining: null, - label: game.i18n.localize("None") - }); - } - - /* -------------------------------------------- */ - - /** - * Format a round+turn combination as a decimal - * @param {number} round The round number - * @param {number} turn The turn number - * @param {number} [nTurns] The maximum number of turns in the encounter - * @returns {number} The decimal representation - * @private - */ - _getCombatTime(round, turn, nTurns) { - if ( nTurns !== undefined ) turn = Math.min(turn, nTurns); - round = Math.max(round, 0); - turn = Math.max(turn, 0); - return (round || 0) + ((turn || 0) / 100); - } - - /* -------------------------------------------- */ - - /** - * Format a number of rounds and turns into a human-readable duration label - * @param {number} rounds The number of rounds - * @param {number} turns The number of turns - * @returns {string} The formatted label - * @private - */ - _getDurationLabel(rounds, turns) { - const parts = []; - if ( rounds > 0 ) parts.push(`${rounds} ${game.i18n.localize(rounds === 1 ? "COMBAT.Round": "COMBAT.Rounds")}`); - if ( turns > 0 ) parts.push(`${turns} ${game.i18n.localize(turns === 1 ? "COMBAT.Turn": "COMBAT.Turns")}`); - if (( rounds + turns ) === 0 ) parts.push(game.i18n.localize("None")); - return parts.filterJoin(", "); - } - - /* -------------------------------------------- */ - - /** - * Describe whether the ActiveEffect has a temporary duration based on combat turns or rounds. - * @type {boolean} - */ - get isTemporary() { - const duration = this.duration.seconds ?? (this.duration.rounds || this.duration.turns) ?? 0; - return (duration > 0) || this.getFlag("core", "statusId"); - } - - /* -------------------------------------------- */ - - /** - * A cached property for obtaining the source name - * @type {string} - */ - get sourceName() { - if ( this._sourceName === null ) this._getSourceName(); - return this._sourceName ?? "Unknown"; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Apply this ActiveEffect to a provided Actor. - * TODO: This method is poorly conceived. Its functionality is static, applying a provided change to an Actor - * TODO: When we revisit this in Active Effects V2 this should become an Actor method, or a static method - * @param {Actor} actor The Actor to whom this effect should be applied - * @param {EffectChangeData} change The change data being applied - * @returns {*} The resulting applied value - */ - - apply(actor, change) { - - // Determine the data type of the target field - const current = foundry.utils.getProperty(actor, change.key) ?? null; - let target = current; - if ( current === null ) { - const model = game.model.Actor[actor.type] || {}; - target = foundry.utils.getProperty(model, change.key) ?? null; - } - let targetType = foundry.utils.getType(target); - - // Cast the effect change value to the correct type - let delta; - try { - if ( targetType === "Array" ) { - const innerType = target.length ? foundry.utils.getType(target[0]) : "string"; - delta = this._castArray(change.value, innerType); - } - else delta = this._castDelta(change.value, targetType); - } catch(err) { - console.warn(`Actor [${actor.id}] | Unable to parse active effect change for ${change.key}: "${change.value}"`); - return; - } - - // Apply the change depending on the application mode - const modes = CONST.ACTIVE_EFFECT_MODES; - const changes = {}; - switch ( change.mode ) { - case modes.ADD: - this._applyAdd(actor, change, current, delta, changes); - break; - case modes.MULTIPLY: - this._applyMultiply(actor, change, current, delta, changes); - break; - case modes.OVERRIDE: - this._applyOverride(actor, change, current, delta, changes); - break; - case modes.UPGRADE: - case modes.DOWNGRADE: - this._applyUpgrade(actor, change, current, delta, changes); - break; - default: - this._applyCustom(actor, change, current, delta, changes); - break; - } - - // Apply all changes to the Actor data - foundry.utils.mergeObject(actor, changes); - return changes; - } - - /* -------------------------------------------- */ - - /** - * Cast a raw EffectChangeData change string to the desired data type. - * @param {string} raw The raw string value - * @param {string} type The target data type that the raw value should be cast to match - * @returns {*} The parsed delta cast to the target data type - * @private - */ - _castDelta(raw, type) { - let delta; - switch ( type ) { - case "boolean": - delta = Boolean(this._parseOrString(raw)); - break; - case "number": - delta = Number.fromString(raw); - if ( Number.isNaN(delta) ) delta = 0; - break; - case "string": - delta = String(raw); - break; - default: - delta = this._parseOrString(raw); - } - return delta; - } - - /* -------------------------------------------- */ - - /** - * Cast a raw EffectChangeData change string to an Array of an inner type. - * @param {string} raw The raw string value - * @param {string} type The target data type of inner array elements - * @returns {Array<*>} The parsed delta cast as a typed array - * @private - */ - _castArray(raw, type) { - let delta; - try { - delta = this._parseOrString(raw); - delta = delta instanceof Array ? delta : [delta]; - } catch(e) { - delta = [raw]; - } - return delta.map(d => this._castDelta(d, type)); - } - - /* -------------------------------------------- */ - - /** - * Parse serialized JSON, or retain the raw string. - * @param {string} raw A raw serialized string - * @returns {*} The parsed value, or the original value if parsing failed - * @private - */ - _parseOrString(raw) { - try { - return JSON.parse(raw); - } catch(err) { - return raw; - } - } - - /* -------------------------------------------- */ - - /** - * Apply an ActiveEffect that uses an ADD application mode. - * The way that effects are added depends on the data type of the current value. - * - * If the current value is null, the change value is assigned directly. - * If the current type is a string, the change value is concatenated. - * If the current type is a number, the change value is cast to numeric and added. - * If the current type is an array, the change value is appended to the existing array if it matches in type. - * - * @param {Actor} actor The Actor to whom this effect should be applied - * @param {EffectChangeData} change The change data being applied - * @param {*} current The current value being modified - * @param {*} delta The parsed value of the change object - * @param {object} changes An object which accumulates changes to be applied - * @private - */ - _applyAdd(actor, change, current, delta, changes) { - let update; - const ct = foundry.utils.getType(current); - switch ( ct ) { - case "boolean": - update = current || delta; - break; - case "null": - update = delta; - break; - case "Array": - update = current.concat(delta); - break; - default: - update = current + delta; - break; - } - changes[change.key] = update; - } - - /* -------------------------------------------- */ - - /** - * Apply an ActiveEffect that uses a MULTIPLY application mode. - * Changes which MULTIPLY must be numeric to allow for multiplication. - * @param {Actor} actor The Actor to whom this effect should be applied - * @param {EffectChangeData} change The change data being applied - * @param {*} current The current value being modified - * @param {*} delta The parsed value of the change object - * @param {object} changes An object which accumulates changes to be applied - * @private - */ - _applyMultiply(actor, change, current, delta, changes) { - let update; - const ct = foundry.utils.getType(current); - switch ( ct ) { - case "boolean": - update = current && delta; - break; - case "number": - update = current * delta; - break; - } - changes[change.key] = update; - } - - /* -------------------------------------------- */ - - /** - * Apply an ActiveEffect that uses an OVERRIDE application mode. - * Numeric data is overridden by numbers, while other data types are overridden by any value - * @param {Actor} actor The Actor to whom this effect should be applied - * @param {EffectChangeData} change The change data being applied - * @param {*} current The current value being modified - * @param {*} delta The parsed value of the change object - * @param {object} changes An object which accumulates changes to be applied - * @private - */ - _applyOverride(actor, change, current, delta, changes) { - return changes[change.key] = delta; - } - - /* -------------------------------------------- */ - - /** - * Apply an ActiveEffect that uses an UPGRADE, or DOWNGRADE application mode. - * Changes which UPGRADE or DOWNGRADE must be numeric to allow for comparison. - * @param {Actor} actor The Actor to whom this effect should be applied - * @param {EffectChangeData} change The change data being applied - * @param {*} current The current value being modified - * @param {*} delta The parsed value of the change object - * @param {object} changes An object which accumulates changes to be applied - * @private - */ - _applyUpgrade(actor, change, current, delta, changes) { - let update; - const ct = foundry.utils.getType(current); - switch ( ct ) { - case "boolean": - case "number": - if ( (change.mode === CONST.ACTIVE_EFFECT_MODES.UPGRADE) && (delta > current) ) update = delta; - else if ( (change.mode === CONST.ACTIVE_EFFECT_MODES.DOWNGRADE) && (delta < current) ) update = delta; - break; - } - changes[change.key] = update; - } - - /* -------------------------------------------- */ - - /** - * Apply an ActiveEffect that uses a CUSTOM application mode. - * @param {Actor} actor The Actor to whom this effect should be applied - * @param {EffectChangeData} change The change data being applied - * @param {*} current The current value being modified - * @param {*} delta The parsed value of the change object - * @param {object} changes An object which accumulates changes to be applied - * @private - */ - _applyCustom(actor, change, current, delta, changes) { - const preHook = foundry.utils.getProperty(actor, change.key); - /** - * A hook event that fires when a custom active effect is applied. - * @function applyActiveEffect - * @memberof hookEvents - * @param {Actor} actor The actor the active effect is being applied to - * @param {EffectChangeData} change The change data being applied - * @param {*} current The current value being modified - * @param {*} delta The parsed value of the change object - * @param {object} changes An object which accumulates changes to be applied - */ - Hooks.call("applyActiveEffect", actor, change, current, delta, changes); - const postHook = foundry.utils.getProperty(actor, change.key); - if ( postHook !== preHook ) changes[change.key] = postHook; - } - - /* -------------------------------------------- */ - - /** - * Get the name of the source of the Active Effect - * @type {string} - */ - async _getSourceName() { - if ( this._sourceName ) return this._sourceName; - if ( !this.origin ) return this._sourceName = game.i18n.localize("None"); - const source = await fromUuid(this.origin); - return this._sourceName = source?.name ?? "Unknown"; - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _preCreate(data, options, user) { - await super._preCreate(data, options, user); - - // Set initial duration data for Actor-owned effects - if ( this.parent instanceof Actor ) { - const updates = {duration: {startTime: game.time.worldTime}, transfer: false}; - if ( game.combat ) { - updates.duration.startRound = game.combat.round; - updates.duration.startTurn = game.combat.turn ?? 0; - } - this.updateSource(updates); - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onCreate(data, options, userId) { - super._onCreate(data, options, userId); - if ( this.modifiesActor ) { - this._displayScrollingStatus(true); - if ( this._statusId ) this.#dispatchTokenStatusChange(this._statusId, true); - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onUpdate(data, options, userId) { - super._onUpdate(data, options, userId); - if ( ("disabled" in data) && this.modifiesActor ) { - this._displayScrollingStatus(!data.disabled); - if ( this._statusId ) this.#dispatchTokenStatusChange(this._statusId, !data.disabled); - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDelete(options, userId) { - super._onDelete(options, userId); - if ( this.modifiesActor ) { - this._displayScrollingStatus(false); - if ( this._statusId ) this.#dispatchTokenStatusChange(this._statusId, false); - } - } - - /* -------------------------------------------- */ - - /** - * Dispatch changes to a significant status effect to active Tokens on the Scene. - * @param {string} statusId The status effect ID being applied, from CONFIG.specialStatusEffects - * @param {boolean} active Is the special status effect now active? - */ - #dispatchTokenStatusChange(statusId, active) { - const tokens = this.parent.getActiveTokens(); - for ( const token of tokens ) token._onApplyStatusEffect(statusId, active); - } - - /* -------------------------------------------- */ - - /** - * Display changes to active effects as scrolling Token status text. - * @param {boolean} enabled Is the active effect currently enabled? - * @private - */ - _displayScrollingStatus(enabled) { - if ( !(this.flags.core?.statusId || this.changes.length) ) return; - const actor = this.parent; - const tokens = actor.isToken ? [actor.token?.object] : actor.getActiveTokens(true); - const label = `${enabled ? "+" : "-"}(${this.label})`; - for ( let t of tokens ) { - if ( !t.visible || !t.renderable ) continue; - canvas.interface.createScrollingText(t.center, label, { - anchor: CONST.TEXT_ANCHOR_POINTS.CENTER, - direction: enabled ? CONST.TEXT_ANCHOR_POINTS.TOP : CONST.TEXT_ANCHOR_POINTS.BOTTOM, - distance: (2 * t.h), - fontSize: 28, - stroke: 0x000000, - strokeThickness: 4, - jitter: 0.25 - }); - } - } -} - -/** - * The client-side Actor document which extends the common BaseActor model. - * - * @extends foundry.documents.BaseActor - * @mixes ClientDocumentMixin - * @category - Documents - * - * @see {@link documents.Actors} The world-level collection of Actor documents - * @see {@link applications.ActorSheet} The Actor configuration application - * - * @example Create a new Actor - * ```js - * let actor = await Actor.create({ - * name: "New Test Actor", - * type: "character", - * img: "artwork/character-profile.jpg" - * }); - * ``` - * - * @example Retrieve an existing Actor - * ```js - * let actor = game.actors.get(actorId); - * ``` - */ -class Actor extends ClientDocumentMixin(foundry.documents.BaseActor) { - - /** - * An object that tracks which tracks the changes to the data model which were applied by active effects - * @type {object} - */ - overrides = {}; - - /** - * A cached array of image paths which can be used for this Actor's token. - * Null if the list has not yet been populated. - * @type {string[]|null} - * @private - */ - _tokenImages = null; - - /** - * Cache the last drawn wildcard token to avoid repeat draws - * @type {string|null} - */ - _lastWildcard = null; - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * Provide a thumbnail image path used to represent this document. - * @type {string} - */ - get thumbnail() { - return this.img; - } - - /* -------------------------------------------- */ - - /** - * Provide an object which organizes all embedded Item instances by their type - * @type {Object} - */ - get itemTypes() { - const types = Object.fromEntries(game.documentTypes.Item.map(t => [t, []])); - for ( const item of this.items.values() ) { - types[item.type].push(item); - } - return types; - } - - /* -------------------------------------------- */ - - /** - * Test whether an Actor document is a synthetic representation of a Token (if true) or a full Document (if false) - * @type {boolean} - */ - get isToken() { - if ( !this.parent ) return false; - return this.parent instanceof TokenDocument; - } - - /* -------------------------------------------- */ - - /** - * An array of ActiveEffect instances which are present on the Actor which have a limited duration. - * @type {ActiveEffect[]} - */ - get temporaryEffects() { - return this.effects.filter(e => e.isTemporary && !e.disabled); - } - - /* -------------------------------------------- */ - - /** - * Return a reference to the TokenDocument which owns this Actor as a synthetic override - * @type {TokenDocument|null} - */ - get token() { - return this.parent instanceof TokenDocument ? this.parent : null; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get uuid() { - if ( this.isToken ) return this.token.uuid; - return super.uuid; - } - - /* -------------------------------------------- */ - - /** - * Request wildcard token images from the server and return them. - * @param {string} actorId The actor whose prototype token contains the wildcard image path. - * @param {object} [options] - * @param {string} [options.pack] The name of the compendium the actor is in. - * @returns {Promise} The list of filenames to token images that match the wildcard search. - * @private - */ - static _requestTokenImages(actorId, options={}) { - return new Promise((resolve, reject) => { - game.socket.emit("requestTokenImages", actorId, options, result => { - if ( result.error ) return reject(new Error(result.error)); - resolve(result.files); - }); - }); - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Apply any transformations to the Actor data which are caused by ActiveEffects. - */ - applyActiveEffects() { - const overrides = {}; - - // Organize non-disabled effects by their application priority - const changes = this.effects.reduce((changes, e) => { - if ( e.disabled || e.isSuppressed ) return changes; - return changes.concat(e.changes.map(c => { - c = foundry.utils.duplicate(c); - c.effect = e; - c.priority = c.priority ?? (c.mode * 10); - return c; - })); - }, []); - changes.sort((a, b) => a.priority - b.priority); - - // Apply all changes - for ( let change of changes ) { - if ( !change.key ) continue; - const changes = change.effect.apply(this, change); - Object.assign(overrides, changes); - } - - // Expand the set of final overrides - this.overrides = foundry.utils.expandObject(overrides); - } - - /* -------------------------------------------- */ - - /** - * Retrieve an Array of active tokens which represent this Actor in the current canvas Scene. - * If the canvas is not currently active, or there are no linked actors, the returned Array will be empty. - * If the Actor is a synthetic token actor, only the exact Token which it represents will be returned. - * - * @param {boolean} [linked=false] Limit results to Tokens which are linked to the Actor. Otherwise, return all - * Tokens even those which are not linked. - * @param {boolean} [document=false] Return the Document instance rather than the PlaceableObject - * @returns {Token[]} An array of Token instances in the current Scene which reference this Actor. - */ - getActiveTokens(linked=false, document=false) { - if ( !canvas.ready ) return []; - - // Synthetic token actors are, themselves, active tokens - if ( this.isToken ) { - if ( this.token.parent !== canvas.scene ) return []; - return document ? [this.token] : [this.token.object]; - } - - // Otherwise, find tokens within the current scene - const tokens = []; - for ( let t of canvas.scene.tokens ) { - if ( t.actorId !== this.id ) continue; - if ( !linked || t.actorLink ) tokens.push(document ? t : t.object); - } - return tokens; - } - - /* -------------------------------------------- */ - - /** - * Prepare a data object which defines the data schema used by dice roll commands against this Actor - * @returns {object} - */ - getRollData() { - return this.system; - } - - /* -------------------------------------------- */ - - /** - * Create a new Token document, not yet saved to the database, which represents the Actor. - * @param {object} [data={}] Additional data, such as x, y, rotation, etc. for the created token data - * @returns {Promise} The created TokenDocument instance - */ - async getTokenDocument(data={}) { - const tokenData = this.prototypeToken.toObject(); - tokenData.actorId = this.id; - - if ( tokenData.randomImg && !data.texture?.src ) { - let images = await this.getTokenImages(); - if ( (images.length > 1) && this._lastWildcard ) { - images = images.filter(i => i !== this._lastWildcard); - } - const image = images[Math.floor(Math.random() * images.length)]; - tokenData.texture.src = this._lastWildcard = image; - } - foundry.utils.mergeObject(tokenData, data); - const cls = getDocumentClass("Token"); - return new cls(tokenData, {actor: this}); - } - - /* -------------------------------------------- */ - - /** - * Get an Array of Token images which could represent this Actor - * @returns {Promise} - */ - async getTokenImages() { - if ( !this.prototypeToken.randomImg ) return [this.prototypeToken.texture.src]; - if ( this._tokenImages ) return this._tokenImages; - try { - this._tokenImages = await this.constructor._requestTokenImages(this.id, {pack: this.pack}); - } catch(err) { - this._tokenImages = []; - Hooks.onError("Actor#getTokenImages", err, { - msg: "Error retrieving wildcard tokens", - log: "error", - notify: "error" - }); - } - return this._tokenImages; - } - - /* -------------------------------------------- */ - - /** - * Handle how changes to a Token attribute bar are applied to the Actor. - * This allows for game systems to override this behavior and deploy special logic. - * @param {string} attribute The attribute path - * @param {number} value The target attribute value - * @param {boolean} isDelta Whether the number represents a relative change (true) or an absolute change (false) - * @param {boolean} isBar Whether the new value is part of an attribute bar, or just a direct value - * @returns {Promise} The updated Actor document - */ - async modifyTokenAttribute(attribute, value, isDelta=false, isBar=true) { - const current = foundry.utils.getProperty(this.system, attribute); - - // Determine the updates to make to the actor data - let updates; - if ( isBar ) { - if (isDelta) value = Math.clamped(0, Number(current.value) + value, current.max); - updates = {[`system.${attribute}.value`]: value}; - } else { - if ( isDelta ) value = Number(current) + value; - updates = {[`system.${attribute}`]: value}; - } - - /** - * A hook event that fires when a token's resource bar attribute has been modified. - * @function modifyTokenAttribute - * @memberof hookEvents - * @param {object} data An object describing the modification - * @param {string} data.attribute The attribute path - * @param {number} data.value The target attribute value - * @param {boolean} data.isDelta Does number represents a relative change (true) or an absolute change (false) - * @param {boolean} data.isBar Whether the new value is part of an attribute bar, or just a direct value - * @param {objects} updates The update delta that will be applied to the Token's actor - */ - const allowed = Hooks.call("modifyTokenAttribute", {attribute, value, isDelta, isBar}, updates); - return allowed !== false ? this.update(updates) : this; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - prepareEmbeddedDocuments() { - super.prepareEmbeddedDocuments(); - this.applyActiveEffects(); - } - - /* -------------------------------------------- */ - - /** - * Roll initiative for all Combatants in the currently active Combat encounter which are associated with this Actor. - * If viewing a full Actor document, all Tokens which map to that actor will be targeted for initiative rolls. - * If viewing a synthetic Token actor, only that particular Token will be targeted for an initiative roll. - * - * @param {object} options Configuration for how initiative for this Actor is rolled. - * @param {boolean} [options.createCombatants=false] Create new Combatant entries for Tokens associated with - * this actor. - * @param {boolean} [options.rerollInitiative=false] Re-roll the initiative for this Actor if it has already - * been rolled. - * @param {object} [options.initiativeOptions={}] Additional options passed to the Combat#rollInitiative method. - * @returns {Promise} A promise which resolves to the Combat document once rolls - * are complete. - */ - async rollInitiative({createCombatants=false, rerollInitiative=false, initiativeOptions={}}={}) { - - // Obtain (or create) a combat encounter - let combat = game.combat; - if ( !combat ) { - if ( game.user.isGM && canvas.scene ) { - const cls = getDocumentClass("Combat"); - combat = await cls.create({scene: canvas.scene.id, active: true}); - } - else { - ui.notifications.warn("COMBAT.NoneActive", {localize: true}); - return null; - } - } - - // Create new combatants - if ( createCombatants ) { - const tokens = this.getActiveTokens(); - const toCreate = []; - if ( tokens.length ) { - for ( let t of tokens ) { - if ( t.inCombat ) continue; - toCreate.push({tokenId: t.id, sceneId: t.scene.id, actorId: this.id, hidden: t.document.hidden}); - } - } else toCreate.push({actorId: this.id, hidden: false}); - await combat.createEmbeddedDocuments("Combatant", toCreate); - } - - // Roll initiative for combatants - const combatants = combat.combatants.reduce((arr, c) => { - if ( c.actor.id !== this.id ) return arr; - if ( !rerollInitiative && (c.initiative !== null) ) return arr; - arr.push(c.id); - return arr; - }, []); - - await combat.rollInitiative(combatants, initiativeOptions); - return combat; - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _preCreate(data, options, userId) { - await super._preCreate(data, options, userId); - this._applyDefaultTokenSettings(data, options); - } - - /* -------------------------------------------- */ - - /** - * When an Actor is being created, apply default token configuration settings to its prototype token. - * @param {object} data Data explicitly provided to the creation workflow - * @param {object} options Options which configure creation - * @param {boolean} [options.fromCompendium] Does this creation workflow originate via compendium import? - * @protected - */ - _applyDefaultTokenSettings(data, {fromCompendium=false}={}) { - const defaults = foundry.utils.deepClone(game.settings.get("core", DefaultTokenConfig.SETTING)); - - // System bar attributes - const {primaryTokenAttribute, secondaryTokenAttribute} = game.system; - if ( primaryTokenAttribute && !("bar1" in defaults) ) defaults.bar1 = {attribute: primaryTokenAttribute}; - if ( secondaryTokenAttribute && !("bar2" in defaults) ) defaults.bar2 = {attribute: secondaryTokenAttribute}; - - // If the creation originates from a compendium, prefer default token settings - if ( fromCompendium ) return this.updateSource({prototypeToken: defaults}); - - // Otherwise, prefer explicitly provided data - const prototypeToken = foundry.utils.mergeObject(defaults, data.prototypeToken || {}); - return this.updateSource({prototypeToken}); - } - - /* -------------------------------------------- */ - - /** @override */ - _onUpdate(data, options, userId) { - super._onUpdate(data, options, userId); - - // Update references to original state so that resetting the preview does not clobber these updates in-memory. - Object.values(ui.windows).forEach(app => { - if ( !(app.object instanceof foundry.data.PrototypeToken) || (app.object.parent !== this) ) return; - app.original = this.prototypeToken.toObject(); - }); - - // Get the changed attributes - const keys = Object.keys(data).filter(k => k !== "_id"); - const changed = new Set(keys); - - // Additional options only apply to Actors which are not synthetic Tokens - if ( this.isToken ) return; - - // If the prototype token was changed, expire any cached token images - if ( changed.has("prototypeToken") ) this._tokenImages = null; - - // Update the active TokenDocument instances which represent this Actor - const tokens = this.getActiveTokens(false, true); - for ( let t of tokens ) { - t._onUpdateBaseActor(data, options); - } - - // If ownership changed for the actor reset token control - if ( changed.has("permission") && tokens.length ) { - canvas.tokens.releaseAll(); - canvas.tokens.cycleTokens(true, true); - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onCreateEmbeddedDocuments(embeddedName, ...args) { - super._onCreateEmbeddedDocuments(embeddedName, ...args); - this._onEmbeddedDocumentChange(embeddedName); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onUpdateEmbeddedDocuments(embeddedName, ...args) { - super._onUpdateEmbeddedDocuments(embeddedName, ...args); - this._onEmbeddedDocumentChange(embeddedName); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDeleteEmbeddedDocuments(embeddedName, ...args) { - super._onDeleteEmbeddedDocuments(embeddedName, ...args); - this._onEmbeddedDocumentChange(embeddedName); - } - - /* -------------------------------------------- */ - - /** - * Perform various actions on active tokens if embedded documents were changed. - * @param {string} embeddedName The type of embedded document that was modified. - * @private - */ - _onEmbeddedDocumentChange(embeddedName) { - - // Refresh the display of the CombatTracker UI - let refreshCombat = false; - if ( this.isToken ) refreshCombat = this.token.inCombat; - else if ( game.combat?.getCombatantByActor(this.id) ) refreshCombat = true; - if ( refreshCombat ) ui.combat.render(); - - // Refresh the display of active Tokens - const tokens = this.getActiveTokens(); - for ( let token of tokens ) { - if ( token.hasActiveHUD ) canvas.tokens.hud.render(); - if ( token.document.parent.isView ) { - token.drawEffects(); - token.drawBars(); - } - } - } - - /* -------------------------------------------- */ - /* Deprecations and Compatibility */ - /* -------------------------------------------- */ - - /** - * @deprecated since v10 - * @ignore - */ - async getTokenData(data) { - foundry.utils.logCompatibilityWarning("The Actor#getTokenData method has been renamed to Actor#getTokenDocument", - {since: 10, until: 12}); - return this.getTokenDocument(data); - } -} - - -/** - * The client-side Adventure document which extends the common {@link foundry.documents.BaseAdventure} model. - * @extends documents.BaseAdventure - * @mixes ClientDocumentMixin - */ -class Adventure extends ClientDocumentMixin(foundry.documents.BaseAdventure) {} - -/** - * The client-side AmbientLight document which extends the common BaseAmbientLight document model. - * @extends documents.BaseAmbientLight - * @mixes ClientDocumentMixin - * - * @see {@link Scene} The Scene document type which contains AmbientLight documents - * @see {@link AmbientLightConfig} The AmbientLight configuration application - */ -class AmbientLightDocument extends CanvasDocumentMixin(foundry.documents.BaseAmbientLight) { - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onUpdate(data, options, userId) { - // Update references to original state so that resetting the preview does not clobber these updates in-memory. - if ( !options.preview ) Object.values(this.apps).forEach(app => app.original = this.toObject()); - return super._onUpdate(data, options, userId); - } - - /* -------------------------------------------- */ - /* Model Properties */ - /* -------------------------------------------- */ - - /** - * Is this ambient light source global in nature? - * @type {boolean} - */ - get isGlobal() { - return !this.walls; - } -} - -/** - * The client-side AmbientSound document which extends the common BaseAmbientSound document model. - * @extends abstract.BaseAmbientSound - * @mixes ClientDocumentMixin - * - * @see {@link Scene} The Scene document type which contains AmbientSound documents - * @see {@link AmbientSoundConfig} The AmbientSound configuration application - */ -class AmbientSoundDocument extends CanvasDocumentMixin(foundry.documents.BaseAmbientSound) {} - -/** - * The client-side Card document which extends the common BaseCard document model. - * @extends documents.BaseCard - * @mixes ClientDocumentMixin - * - * @see {@link Cards} The Cards document type which contains Card embedded documents - * @see {@link CardConfig} The Card configuration application - */ -class Card extends ClientDocumentMixin(foundry.documents.BaseCard) { - - /** - * The current card face - * @type {CardFaceData|null} - */ - get currentFace() { - if ( this.face === null ) return null; - const n = Math.clamped(this.face, 0, this.faces.length-1); - return this.faces[n] || null; - } - - /** - * The image of the currently displayed card face or back - * @type {string} - */ - get img() { - return this.currentFace?.img || this.back.img || Card.DEFAULT_ICON; - } - - /** - * A reference to the source Cards document which defines this Card. - * @type {Cards|null} - */ - get source() { - return this.parent?.type === "deck" ? this.parent : this.origin; - } - - /** - * A convenience property for whether the Card is within its source Cards stack. Cards in decks are always - * considered home. - * @type {boolean} - */ - get isHome() { - return (this.parent?.type === "deck") || (this.origin === this.parent); - } - - /** - * Whether to display the face of this card? - * @type {boolean} - */ - get showFace() { - return this.faces[this.face] !== undefined; - } - - /** - * Does this Card have a next face available to flip to? - * @type {boolean} - */ - get hasNextFace() { - return (this.face === null) || (this.face < this.faces.length - 1); - } - - /** - * Does this Card have a previous face available to flip to? - * @type {boolean} - */ - get hasPreviousFace() { - return this.face !== null; - } - - /* -------------------------------------------- */ - /* Core Methods */ - /* -------------------------------------------- */ - - /** @override */ - prepareDerivedData() { - super.prepareDerivedData(); - this.back.img ||= this.source.img || Card.DEFAULT_ICON; - this.name = (this.showFace ? this.currentFace.name : this.back.name) // Explicit face or back name - || (this._source.name || game.i18n.format("CARD.Unknown", {source: this.source.name})); // Fallback card name - } - - /* -------------------------------------------- */ - /* API Methods */ - /* -------------------------------------------- */ - - /** - * Flip this card to some other face. A specific face may be requested, otherwise: - * If the card currently displays a face the card is flipped to the back. - * If the card currently displays the back it is flipped to the first face. - * @param {number|null} [face] A specific face to flip the card to - * @returns {Promise} A reference to this card after the flip operation is complete - */ - async flip(face) { - - // Flip to an explicit face - if ( Number.isNumeric(face) || (face === null) ) return this.update({face}); - - // Otherwise, flip to default - return this.update({face: this.face === null ? 0 : null}); - } - - /* -------------------------------------------- */ - - /** - * Pass this Card to some other Cards document. - * @param {Cards} to A new Cards document this card should be passed to - * @param {object} [options={}] Options which modify the pass operation - * @param {object} [options.updateData={}] Modifications to make to the Card as part of the pass operation, - * for example the displayed face - * @returns {Promise} A reference to this card after it has been passed to another parent document - */ - async pass(to, {updateData={}, ...options}={}) { - const created = await this.parent.pass(to, [this.id], {updateData, action: "pass", ...options}); - return created[0]; - } - - /* -------------------------------------------- */ - - /** - * @alias Card#pass - * @see Card#pass - * @inheritdoc - */ - async play(to, {updateData={}, ...options}={}) { - const created = await this.parent.pass(to, [this.id], {updateData, action: "play", ...options}); - return created[0]; - } - - /* -------------------------------------------- */ - - /** - * @alias Card#pass - * @see Card#pass - * @inheritdoc - */ - async discard(to, {updateData={}, ...options}={}) { - const created = await this.parent.pass(to, [this.id], {updateData, action: "discard", ...options}); - return created[0]; - } - - /* -------------------------------------------- */ - - /** - * Recall this Card to its original Cards parent. - * @param {object} [options={}] Options which modify the recall operation - * @returns {Promise} A reference to the recalled card belonging to its original parent - */ - async recall(options={}) { - - // Mark the original card as no longer drawn - const original = this.isHome ? this : this.source?.cards.get(this.id); - if ( original ) await original.update({drawn: false}); - - // Delete this card if it's not the original - if ( !this.isHome ) await this.delete(); - return original; - } - - /* -------------------------------------------- */ - - /** - * Create a chat message which displays this Card. - * @param {object} [messageData={}] Additional data which becomes part of the created ChatMessageData - * @param {object} [options={}] Options which modify the message creation operation - * @returns {Promise} The created chat message - */ - async toMessage(messageData={}, options={}) { - messageData = foundry.utils.mergeObject({ - content: `
- ${this.name} -

${this.name}

-
` - }, messageData); - return ChatMessage.create(messageData, options); - } -} - -/** - * The client-side Cards document which extends the common BaseCards model. - * Each Cards document contains CardsData which defines its data schema. - * @extends documents.BaseCards - * @mixes ClientDocumentMixin - * - * @see {@link CardStacks} The world-level collection of Cards documents - * @see {@link CardsConfig} The Cards configuration application - */ -class Cards extends ClientDocumentMixin(foundry.documents.BaseCards) { - - /** - * Provide a thumbnail image path used to represent this document. - * @type {string} - */ - get thumbnail() { - return this.img; - } - - /** - * The Card documents within this stack which are available to be drawn. - * @type {Card[]} - */ - get availableCards() { - return this.cards.filter(c => (this.type !== "deck") || !c.drawn); - } - - /** - * The Card documents which belong to this stack but have already been drawn. - * @type {Card[]} - */ - get drawnCards() { - return this.cards.filter(c => c.drawn); - } - - /** - * Returns the localized Label for the type of Card Stack this is - * @type {string} - */ - get typeLabel() { - switch ( this.type ) { - case "deck": return game.i18n.localize("CARDS.TypeDeck"); - case "hand": return game.i18n.localize("CARDS.TypeHand"); - case "pile": return game.i18n.localize("CARDS.TypePile"); - default: throw new Error(`Unexpected type ${this.type}`); - } - } - - /** - * Can this Cards document be cloned in a duplicate workflow? - * @type {boolean} - */ - get canClone() { - if ( this.type === "deck" ) return true; - else return this.cards.size === 0; - } - - /* -------------------------------------------- */ - /* API Methods */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - static async createDocuments(data=[], context={}) { - if ( context.keepEmbeddedIds === undefined ) context.keepEmbeddedIds = false; - return super.createDocuments(data, context); - } - - /* -------------------------------------------- */ - - /** @inheritDoc */ - async _preCreate(data, options, user) { - await super._preCreate(data, options, user); - for ( const card of this.cards ) { - card.updateSource({drawn: false}); - } - } - - /* -------------------------------------------- */ - - /** - * Deal one or more cards from this Cards document to each of a provided array of Cards destinations. - * Cards are allocated from the top of the deck in cyclical order until the required number of Cards have been dealt. - * @param {Cards[]} to An array of other Cards documents to which cards are dealt - * @param {number} [number=1] The number of cards to deal to each other document - * @param {object} [options={}] Options which modify how the deal operation is performed - * @param {number} [options.how=0] How to draw, a value from CONST.CARD_DRAW_MODES - * @param {object} [options.updateData={}] Modifications to make to each Card as part of the deal operation, - * for example the displayed face - * @param {string} [options.action=deal] The name of the action being performed, used as part of the dispatched - * Hook event - * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred - * @returns {Promise} This Cards document after the deal operation has completed - */ - async deal(to, number=1, {action="deal", how=0, updateData={}, chatNotification=true}={}) { - - // Validate the request - if ( !to.every(d => d instanceof Cards) ) { - throw new Error("You must provide an array of Cards documents as the destinations for the Cards#deal operation"); - } - - // Draw from the sorted stack - const total = number * to.length; - const drawn = this._drawCards(total, how); - - // Allocate cards to each destination - const toCreate = to.map(() => []); - const toUpdate = []; - const toDelete = []; - for ( let i=0; i} context.toCreate An array of Card creation operations to be performed in each - * destination Cards document - * @param {object[]} context.fromUpdate Card update operations to be performed in the origin Cards document - * @param {object[]} context.fromDelete Card deletion operations to be performed in the origin Cards document - * - */ - const allowed = Hooks.call("dealCards", this, to, { - action: action, - toCreate: toCreate, - fromUpdate: toUpdate, - fromDelete: toDelete - }); - if ( allowed === false ) { - console.debug(`${vtt} | The Cards#deal operation was prevented by a hooked function`); - return this; - } - - // Perform database operations - const promises = to.map((cards, i) => { - return cards.createEmbeddedDocuments("Card", toCreate[i], {keepId: true}); - }); - promises.push(this.updateEmbeddedDocuments("Card", toUpdate)); - promises.push(this.deleteEmbeddedDocuments("Card", toDelete)); - await Promise.all(promises); - - // Dispatch chat notification - if ( chatNotification ) { - const chatActions = { - deal: "CARDS.NotifyDeal", - pass: "CARDS.NotifyPass" - }; - this._postChatNotification(this, chatActions[action], {number, link: to.map(t => t.link).join(", ")}); - } - return this; - } - - /* -------------------------------------------- */ - - /** - * Pass an array of specific Card documents from this document to some other Cards stack. - * @param {Cards} to Some other Cards document that is the destination for the pass operation - * @param {string[]} ids The embedded Card ids which should be passed - * @param {object} [options={}] Additional options which modify the pass operation - * @param {object} [options.updateData={}] Modifications to make to each Card as part of the pass operation, - * for example the displayed face - * @param {string} [options.action=pass] The name of the action being performed, used as part of the dispatched - * Hook event - * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred - * @returns {Promise} An array of the Card embedded documents created within the destination stack - */ - async pass(to, ids, {updateData={}, action="pass", chatNotification=true}={}) { - if ( !(to instanceof Cards) ) { - throw new Error("You must provide a Cards document as the recipient for the Cards#pass operation"); - } - - // Allocate cards to different required operations - const toCreate = []; - const toUpdate = []; - const fromUpdate = []; - const fromDelete = []; - - // Validate the provided cards - for ( let id of ids ) { - const card = this.cards.get(id, {strict: true}); - - // Prevent drawing cards from decks multiple times - if ( (this.type === "deck") && card.isHome && card.drawn ) { - throw new Error(`You may not pass Card ${id} which has already been drawn`); - } - - // Return drawn cards to their origin deck - if ( card.origin === to ) { - toUpdate.push({_id: card.id, drawn: false}); - } - - // Create cards in a new destination - else { - const createData = foundry.utils.mergeObject(card.toObject(), updateData); - const copyCard = card.isHome && (to.type === "deck"); - if ( copyCard ) createData.origin = to.id; - else if ( card.isHome || !createData.origin ) createData.origin = this.id; - if ( !copyCard ) createData.drawn = true; - toCreate.push(createData); - } - - // Update cards in their home deck - if ( card.isHome && (to.type !== "deck") ) fromUpdate.push({_id: card.id, drawn: true}); - - // Remove cards from their current stack - else if ( !card.isHome ) fromDelete.push(card.id); - } - - /** - * A hook event that fires when Cards are passed from one stack to another - * @function passCards - * @memberof hookEvents - * @param {Cards} origin The origin Cards document - * @param {Cards} destination The destination Cards document - * @param {object} context Additional context which describes the operation - * @param {string} context.action The action name being performed, i.e. "pass", "play", "discard", "draw" - * @param {object[]} context.toCreate Card creation operations to be performed in the destination Cards document - * @param {object[]} context.toUpdate Card update operations to be performed in the destination Cards document - * @param {object[]} context.fromUpdate Card update operations to be performed in the origin Cards document - * @param {object[]} context.fromDelete Card deletion operations to be performed in the origin Cards document - * - */ - const allowed = Hooks.call("passCards", this, to, {action, toCreate, toUpdate, fromUpdate, fromDelete}); - if ( allowed === false ) { - console.debug(`${vtt} | The Cards#pass operation was prevented by a hooked function`); - return []; - } - - // Perform database operations - const created = to.createEmbeddedDocuments("Card", toCreate, {keepId: true}); - await Promise.all([ - created, - to.updateEmbeddedDocuments("Card", toUpdate), - this.updateEmbeddedDocuments("Card", fromUpdate), - this.deleteEmbeddedDocuments("Card", fromDelete) - ]); - - // Dispatch chat notification - if ( chatNotification ) { - const chatActions = { - pass: "CARDS.NotifyPass", - play: "CARDS.NotifyPlay", - discard: "CARDS.NotifyDiscard", - draw: "CARDS.NotifyDraw" - }; - const chatFrom = action === "draw" ? to : this; - const chatTo = action === "draw" ? this : to; - this._postChatNotification(chatFrom, chatActions[action], {number: ids.length, link: chatTo.link}); - } - return created; - } - - /* -------------------------------------------- */ - - /** - * Draw one or more cards from some other Cards document. - * @param {Cards} from Some other Cards document from which to draw - * @param {number} [number=1] The number of cards to draw - * @param {object} [options={}] Options which modify how the draw operation is performed - * @param {number} [options.how=0] How to draw, a value from CONST.CARD_DRAW_MODES - * @param {object} [options.updateData={}] Modifications to make to each Card as part of the draw operation, - * for example the displayed face - * @returns {Promise} An array of the Card documents which were drawn - */ - async draw(from, number=1, {how=0, updateData={}, ...options}={}) { - if ( !(from instanceof Cards) || (from === this) ) { - throw new Error("You must provide some other Cards document as the source for the Cards#draw operation"); - } - const toDraw = from._drawCards(number, how); - return from.pass(this, toDraw.map(c => c.id), {updateData, action: "draw", ...options}); - } - - /* -------------------------------------------- */ - - /** - * Shuffle this Cards stack, randomizing the sort order of all the cards it contains. - * @param {object} [options={}] Options which modify how the shuffle operation is performed. - * @param {object} [options.updateData={}] Modifications to make to each Card as part of the shuffle operation, - * for example the displayed face. - * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred - * @returns {Promise} The Cards document after the shuffle operation has completed - */ - async shuffle({updateData={}, chatNotification=true}={}) { - const order = this.cards.map(c => [twist.random(), c]); - order.sort((a, b) => a[0] - b[0]); - const toUpdate = order.map((x, i) => { - const card = x[1]; - return foundry.utils.mergeObject({_id: card.id, sort: i}, updateData); - }); - - // Post a chat notification and return - await this.updateEmbeddedDocuments("Card", toUpdate); - if ( chatNotification ) { - this._postChatNotification(this, "CARDS.NotifyShuffle", {link: this.link}); - } - return this; - } - - /* -------------------------------------------- */ - - /** - * Recall the Cards stack, retrieving all original cards from other stacks where they may have been drawn if this is a - * deck, otherwise returning all the cards in this stack to the decks where they originated. - * @param {object} [options={}] Options which modify the recall operation - * @param {object} [options.updateData={}] Modifications to make to each Card as part of the recall operation, - * for example the displayed face - * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred - * @returns {Promise} The Cards document after the recall operation has completed. - */ - async recall(options) { - if ( this.type === "deck" ) return this._resetDeck(options); - return this._resetStack(options); - } - - /* -------------------------------------------- */ - - /** - * Perform a reset operation for a deck, retrieving all original cards from other stacks where they may have been - * drawn. - * @param {object} [options={}] Options which modify the reset operation. - * @param {object} [options.updateData={}] Modifications to make to each Card as part of the reset operation - * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred - * @returns {Promise} The Cards document after the reset operation has completed. - * @private - */ - async _resetDeck({updateData={}, chatNotification=true}={}) { - - // Recover all cards which belong to this stack - for ( let cards of game.cards ) { - if ( cards === this ) continue; - const toDelete = []; - for ( let c of cards.cards ) { - if ( c.origin === this ) { - toDelete.push(c.id); - } - } - if ( toDelete.length ) await cards.deleteEmbeddedDocuments("Card", toDelete); - } - - // Mark all cards as not drawn - const cards = this.cards.contents; - cards.sort(this.sortStandard.bind(this)); - const toUpdate = cards.map(card => { - return foundry.utils.mergeObject({_id: card.id, drawn: false}, updateData); - }); - - // Post a chat notification and return - await this.updateEmbeddedDocuments("Card", toUpdate); - if ( chatNotification ) { - this._postChatNotification(this, "CARDS.NotifyReset", {link: this.link}); - } - return this; - } - - /* -------------------------------------------- */ - - /** - * Return all cards in this stack to their original decks. - * @param {object} [options={}] Options which modify the return operation. - * @param {object} [options.updateData={}] Modifications to make to each Card as part of the return operation - * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred - * @returns {Promise} The Cards document after the return operation has completed. - * @private - */ - async _resetStack({updateData={}, chatNotification=true}={}) { - - // Allocate cards to different required operations. - const toUpdate = {}; - const fromDelete = []; - for ( const card of this.cards ) { - if ( card.isHome ) continue; - - // Return drawn cards to their origin deck. - if ( !toUpdate[card.origin.id] ) toUpdate[card.origin.id] = []; - const update = foundry.utils.mergeObject(updateData, {_id: card.id, drawn: false}, {inplace: false}); - toUpdate[card.origin.id].push(update); - - // Remove cards from the current stack. - fromDelete.push(card.id); - } - - /** - * A hook event that fires when a stack of Cards are returned to the decks they originally came from. - * @function returnCards - * @memberof hookEvents - * @param {Cards} origin The origin Cards document. - * @param {Card[]} returned The cards being returned. - * @param {object} context Additional context which describes the operation. - * @param {Object} context.toUpdate A mapping of Card deck IDs to the update operations that - * will be performed on them. - * @param {object[]} context.fromDelete Card deletion operations to be performed on the origin Cards - * document. - */ - const allowed = Hooks.call("returnCards", this, fromDelete.map(id => this.cards.get(id)), {toUpdate, fromDelete}); - if ( allowed === false ) { - console.debug(`${vtt} | The Cards#return operation was prevented by a hooked function.`); - return this; - } - - // Perform database operations. - const updates = Object.entries(toUpdate).map(([origin, u]) => { - return game.cards.get(origin).updateEmbeddedDocuments("Card", u); - }); - await Promise.all([...updates, this.deleteEmbeddedDocuments("Card", fromDelete)]); - - // Dispatch chat notification - if ( chatNotification ) this._postChatNotification(this, "CARDS.NotifyReturn", {link: this.link}); - return this; - } - - /* -------------------------------------------- */ - - /** - * A sorting function that is used to determine the standard order of Card documents within an un-shuffled stack. - * @param {Card} a The card being sorted - * @param {Card} b Another card being sorted against - * @returns {number} - * @protected - */ - sortStandard(a, b) { - if ( a.suit === b.suit ) return a.value - b.value; - return a.suit.localeCompare(b.suit); - } - - /* -------------------------------------------- */ - - /** - * A sorting function that is used to determine the order of Card documents within a shuffled stack. - * @param {Card} a The card being sorted - * @param {Card} b Another card being sorted against - * @returns {number} - * @protected - */ - sortShuffled(a, b) { - return a.sort - b.sort; - } - - /* -------------------------------------------- */ - - /** - * An internal helper method for drawing a certain number of Card documents from this Cards stack. - * @param {number} number The number of cards to draw - * @param {number} how A draw mode from CONST.CARD_DRAW_MODES - * @returns {Card[]} An array of drawn Card documents - * @protected - */ - _drawCards(number, how) { - - // Confirm that sufficient cards are available - let available = this.availableCards; - if ( available.length < number ) { - throw new Error(`There are not ${number} available cards remaining in Cards [${this.id}]`); - } - - // Draw from the stack - let drawn; - switch ( how ) { - case CONST.CARD_DRAW_MODES.FIRST: - available.sort(this.sortShuffled.bind(this)); - drawn = available.slice(0, number); - break; - case CONST.CARD_DRAW_MODES.LAST: - available.sort(this.sortShuffled.bind(this)); - drawn = available.slice(-number); - break; - case CONST.CARD_DRAW_MODES.RANDOM: - const shuffle = available.map(c => [Math.random(), c]); - shuffle.sort((a, b) => a[0] - b[0]); - drawn = shuffle.slice(-number).map(x => x[1]); - break; - } - return drawn; - } - - /* -------------------------------------------- */ - - /** - * Create a ChatMessage which provides a notification of the operation which was just performed. - * Visibility of the resulting message is linked to the default roll mode selected in the chat log dropdown. - * @param {Cards} source The source Cards document from which the action originated - * @param {string} action The localization key which formats the chat message notification - * @param {object} context Data passed to the Localization#format method for the localization key - * @returns {ChatMessage} A created ChatMessage document - * @private - */ - _postChatNotification(source, action, context) { - const messageData = { - type: CONST.CHAT_MESSAGE_TYPES.OTHER, - speaker: {user: game.user}, - content: ` -
- ${source.name} -

${game.i18n.format(action, context)}

-
` - }; - ChatMessage.applyRollMode(messageData, game.settings.get("core", "rollMode")); - return ChatMessage.create(messageData); - } - - /* -------------------------------------------- */ - /* Event Listeners and Handlers */ - /* -------------------------------------------- */ - - /** @override */ - _onUpdate(data, options, userId) { - if ( "type" in data ) { - this.sheet?.close(); - this._sheet = undefined; - } - super._onUpdate(data, options, userId); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _preDelete(options, user) { - await this.recall(); - return super._preDelete(options, user); - } - - /* -------------------------------------------- */ - /* Interaction Dialogs */ - /* -------------------------------------------- */ - - /** - * Display a dialog which prompts the user to deal cards to some number of hand-type Cards documents. - * @see {@link Cards#deal} - * @returns {Promise} - */ - async dealDialog() { - const hands = game.cards.filter(c => (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED")); - if ( !hands.length ) return ui.notifications.warn("CARDS.DealWarnNoTargets", {localize: true}); - - // Construct the dialog HTML - const html = await renderTemplate("templates/cards/dialog-deal.html", { - hands: hands, - modes: { - [CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop", - [CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom", - [CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom" - } - }); - - // Display the prompt - return Dialog.prompt({ - title: game.i18n.localize("CARDS.DealTitle"), - label: game.i18n.localize("CARDS.Deal"), - content: html, - callback: html => { - const form = html.querySelector("form.cards-dialog"); - const fd = new FormDataExtended(form).object; - if ( !fd.to ) return this; - const toIds = fd.to instanceof Array ? fd.to : [fd.to]; - const to = toIds.reduce((arr, id) => { - const c = game.cards.get(id); - if ( c ) arr.push(c); - return arr; - }, []); - const options = {how: fd.how, updateData: fd.down ? {face: null} : {}}; - return this.deal(to, fd.number, options).catch(err => { - ui.notifications.error(err.message); - return this; - }); - }, - rejectClose: false, - options: {jQuery: false} - }); - } - - /* -------------------------------------------- */ - - /** - * Display a dialog which prompts the user to draw cards from some other deck-type Cards documents. - * @see {@link Cards#draw} - * @returns {Promise} - */ - async drawDialog() { - const decks = game.cards.filter(c => (c.type === "deck") && c.testUserPermission(game.user, "LIMITED")); - if ( !decks.length ) return ui.notifications.warn("CARDS.DrawWarnNoSources", {localize: true}); - - // Construct the dialog HTML - const html = await renderTemplate("templates/cards/dialog-draw.html", { - decks: decks, - modes: { - [CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop", - [CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom", - [CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom" - } - }); - - // Display the prompt - return Dialog.prompt({ - title: game.i18n.localize("CARDS.DrawTitle"), - label: game.i18n.localize("CARDS.Draw"), - content: html, - callback: html => { - const form = html.querySelector("form.cards-dialog"); - const fd = new FormDataExtended(form).object; - const from = game.cards.get(fd.from); - const options = {how: fd.how, updateData: fd.down ? {face: null} : {}}; - return this.draw(from, fd.number, options).catch(err => { - ui.notifications.error(err.message); - return []; - }); - }, - rejectClose: false, - options: {jQuery: false} - }); - } - - /* -------------------------------------------- */ - - /** - * Display a dialog which prompts the user to pass cards from this document to some other Cards document. - * @see {@link Cards#deal} - * @returns {Promise} - */ - async passDialog() { - const cards = game.cards.filter(c => (c !== this) && (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED")); - if ( !cards.length ) return ui.notifications.warn("CARDS.PassWarnNoTargets", {localize: true}); - - // Construct the dialog HTML - const html = await renderTemplate("templates/cards/dialog-pass.html", { - cards: cards, - modes: { - [CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop", - [CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom", - [CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom" - } - }); - - // Display the prompt - return Dialog.prompt({ - title: game.i18n.localize("CARDS.PassTitle"), - label: game.i18n.localize("CARDS.Pass"), - content: html, - callback: html => { - const form = html.querySelector("form.cards-dialog"); - const fd = new FormDataExtended(form).object; - const to = game.cards.get(fd.to); - const options = {action: "pass", how: fd.how, updateData: fd.down ? {face: null} : {}}; - return this.deal([to], fd.number, options).catch(err => { - ui.notifications.error(err.message); - return this; - }); - }, - rejectClose: false, - options: {jQuery: false} - }); - } - - /* -------------------------------------------- */ - - /** - * Display a dialog which prompts the user to play a specific Card to some other Cards document - * @see {@link Cards#pass} - * @param {Card} card The specific card being played as part of this dialog - * @returns {Promise} - */ - async playDialog(card) { - const cards = game.cards.filter(c => (c !== this) && (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED")); - if ( !cards.length ) return ui.notifications.warn("CARDS.PassWarnNoTargets", {localize: true}); - - // Construct the dialog HTML - const html = await renderTemplate("templates/cards/dialog-play.html", {card, cards}); - - // Display the prompt - return Dialog.prompt({ - title: game.i18n.localize("CARD.Play"), - label: game.i18n.localize("CARD.Play"), - content: html, - callback: html => { - const form = html.querySelector("form.cards-dialog"); - const fd = new FormDataExtended(form).object; - const to = game.cards.get(fd.to); - const options = {action: "play", updateData: fd.down ? {face: null} : {}}; - return this.pass(to, [card.id], options).catch(err => { - return ui.notifications.error(err.message); - }); - }, - rejectClose: false, - options: {jQuery: false} - }); - } - - /* -------------------------------------------- */ - - /** - * Display a confirmation dialog for whether or not the user wishes to reset a Cards stack - * @see {@link Cards#recall} - * @returns {Promise} - */ - async resetDialog() { - return Dialog.confirm({ - title: game.i18n.localize("CARDS.Reset"), - content: `

${game.i18n.format(`CARDS.${this.type === "deck" ? "Reset" : "Return"}Confirm`, {name: this.name})}

`, - yes: () => this.recall() - }); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async deleteDialog(options={}) { - if ( !this.drawnCards.length ) return super.deleteDialog(options); - const type = this.typeLabel; - return new Promise(resolve => { - const dialog = new Dialog({ - title: `${game.i18n.format("DOCUMENT.Delete", {type})}: ${this.name}`, - content: ` -

${game.i18n.localize("CARDS.DeleteCannot")}

-

${game.i18n.format("CARDS.DeleteMustReset", {type})}

- `, - buttons: { - reset: { - icon: '', - label: game.i18n.localize("CARDS.DeleteReset"), - callback: () => resolve(this.delete()) - }, - cancel: { - icon: '', - label: game.i18n.localize("Cancel"), - callback: () => resolve(false) - } - }, - close: () => resolve(null), - default: "reset" - }, options); - dialog.render(true); - }); - } - - /* -------------------------------------------- */ - - /** @override */ - static async createDialog(data={}, {parent=null, pack=null, ...options}={}) { - - // Collect data - const types = game.documentTypes[this.documentName]; - const folders = parent ? [] : game.folders.filter(f => (f.type === this.documentName) && f.displayed); - const label = game.i18n.localize(this.metadata.label); - const title = game.i18n.format("DOCUMENT.Create", {type: label}); - - // Render the document creation form - const html = await renderTemplate("templates/sidebar/cards-create.html", { - folders, - name: data.name || game.i18n.format("DOCUMENT.New", {type: label}), - folder: data.folder, - hasFolders: folders.length >= 1, - type: data.type || types[0], - types: types.reduce((obj, t) => { - const label = CONFIG[this.documentName]?.typeLabels?.[t] ?? t; - obj[t] = game.i18n.has(label) ? game.i18n.localize(label) : t; - return obj; - }, {}), - hasTypes: types.length > 1, - presets: CONFIG.Cards.presets - }); - - // Render the confirmation dialog window - return Dialog.prompt({ - title: title, - content: html, - label: title, - callback: async html => { - const form = html[0].querySelector("form"); - const fd = new FormDataExtended(form); - foundry.utils.mergeObject(data, fd.object, {inplace: true}); - if ( !data.folder ) delete data.folder; - if ( !data.name?.trim() ) data.name = this.defaultName(); - const preset = CONFIG.Cards.presets[data.preset]; - if ( preset && (preset.type === data.type) ) { - const presetData = await fetch(preset.src).then(r => r.json()); - data = foundry.utils.mergeObject(presetData, data); - } - return this.create(data, {parent, pack, renderSheet: true}); - }, - rejectClose: false, - options - }); - } -} - -/** - * The client-side ChatMessage document which extends the common BaseChatMessage model. - * - * @extends documents.BaseChatMessage - * @mixes ClientDocumentMixin - * - * @see {@link documents.Messages} The world-level collection of ChatMessage documents - */ -class ChatMessage extends ClientDocumentMixin(foundry.documents.BaseChatMessage) { - - /** - * Is the display of dice rolls in this message collapsed (false) or expanded (true) - * @type {boolean} - * @private - */ - _rollExpanded = false; - - /** - * Is this ChatMessage currently displayed in the sidebar ChatLog? - * @type {boolean} - */ - logged = false; - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * Return the recommended String alias for this message. - * The alias could be a Token name in the case of in-character messages or dice rolls. - * Alternatively it could be the name of a User in the case of OOC chat or whispers. - * @type {string} - */ - get alias() { - const speaker = this.speaker; - if ( speaker.alias ) return speaker.alias; - else if ( game.actors.has(speaker.actor) ) return game.actors.get(speaker.actor).name; - else return this.user?.name ?? game.i18n.localize("CHAT.UnknownUser"); - } - - /* -------------------------------------------- */ - - /** - * Is the current User the author of this message? - * @type {boolean} - */ - get isAuthor() { - return !!this.user?.isSelf; - } - - /* -------------------------------------------- */ - - /** - * Return whether the content of the message is visible to the current user. - * For certain dice rolls, for example, the message itself may be visible while the content of that message is not. - * @type {boolean} - */ - get isContentVisible() { - if ( this.isRoll ) { - const whisper = this.whisper || []; - const isBlind = whisper.length && this.blind; - if ( whisper.length ) return whisper.includes(game.user.id) || (this.isAuthor && !isBlind); - return true; - } - else return this.visible; - } - - /* -------------------------------------------- */ - - /** - * Test whether the chat message contains a dice roll - * @type {boolean} - */ - get isRoll() { - return this.type === CONST.CHAT_MESSAGE_TYPES.ROLL; - } - - /* -------------------------------------------- */ - - /** - * Return whether the ChatMessage is visible to the current User. - * Messages may not be visible if they are private whispers. - * @type {boolean} - */ - get visible() { - if ( this.whisper.length ) { - if ( this.type === CONST.CHAT_MESSAGE_TYPES.ROLL ) return true; - return this.isAuthor || (this.whisper.indexOf(game.user.id) !== -1); - } - return true; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - prepareDerivedData() { - super.prepareDerivedData(); - - // Create Roll instances for contained dice rolls - this.rolls = this.rolls.reduce((rolls, rollData) => { - try { - rolls.push(Roll.fromData(rollData)); - } catch(err) { - Hooks.onError("ChatMessage#rolls", err, {rollData, log: "error"}); - } - return rolls; - }, []); - } - - /* -------------------------------------------- */ - - /** - * Transform a provided object of ChatMessage data by applying a certain rollMode to the data object. - * @param {object} chatData The object of ChatMessage data prior to applying a rollMode preference - * @param {string} rollMode The rollMode preference to apply to this message data - * @returns {object} The modified ChatMessage data with rollMode preferences applied - */ - static applyRollMode(chatData, rollMode) { - const modes = CONST.DICE_ROLL_MODES; - if ( rollMode === "roll" ) rollMode = game.settings.get("core", "rollMode"); - if ( [modes.PRIVATE, modes.BLIND].includes(rollMode) ) { - chatData.whisper = ChatMessage.getWhisperRecipients("GM").map(u => u.id); - } - else if ( rollMode === modes.SELF ) chatData.whisper = [game.user.id]; - else if ( rollMode === modes.PUBLIC ) chatData.whisper = []; - chatData.blind = rollMode === modes.BLIND; - return chatData; - } - - /* -------------------------------------------- */ - - /** - * Update the data of a ChatMessage instance to apply a requested rollMode - * @param {string} rollMode The rollMode preference to apply to this message data - */ - applyRollMode(rollMode) { - const updates = {}; - this.constructor.applyRollMode(updates, rollMode); - this.updateSource(updates); - } - - /* -------------------------------------------- */ - - /** - * Attempt to determine who is the speaking character (and token) for a certain Chat Message - * First assume that the currently controlled Token is the speaker - * - * @param {object} [options={}] Options which affect speaker identification - * @param {Scene} [options.scene] The Scene in which the speaker resides - * @param {Actor} [options.actor] The Actor who is speaking - * @param {TokenDocument} [options.token] The Token who is speaking - * @param {string} [options.alias] The name of the speaker to display - * - * @returns {object} The identified speaker data - */ - static getSpeaker({scene, actor, token, alias}={}) { - - // CASE 1 - A Token is explicitly provided - const hasToken = (token instanceof Token) || (token instanceof TokenDocument); - if ( hasToken ) return this._getSpeakerFromToken({token, alias}); - const hasActor = actor instanceof Actor; - if ( hasActor && actor.isToken ) return this._getSpeakerFromToken({token: actor.token, alias}); - - // CASE 2 - An Actor is explicitly provided - if ( hasActor ) { - alias = alias || actor.name; - const tokens = actor.getActiveTokens(); - if ( !tokens.length ) return this._getSpeakerFromActor({scene, actor, alias}); - const controlled = tokens.filter(t => t.controlled); - token = controlled.length ? controlled.shift() : tokens.shift(); - return this._getSpeakerFromToken({token: token.document, alias}); - } - - // CASE 3 - Not the viewed Scene - else if ( ( scene instanceof Scene ) && !scene.isView ) { - const char = game.user.character; - if ( char ) return this._getSpeakerFromActor({scene, actor: char, alias}); - return this._getSpeakerFromUser({scene, user: game.user, alias}); - } - - // CASE 4 - Infer from controlled tokens - if ( canvas.ready ) { - let controlled = canvas.tokens.controlled; - if (controlled.length) return this._getSpeakerFromToken({token: controlled.shift().document, alias}); - } - - // CASE 5 - Infer from impersonated Actor - const char = game.user.character; - if ( char ) { - const tokens = char.getActiveTokens(false, true); - if ( tokens.length ) return this._getSpeakerFromToken({token: tokens.shift(), alias}); - return this._getSpeakerFromActor({actor: char, alias}); - } - - // CASE 6 - From the alias and User - return this._getSpeakerFromUser({scene, user: game.user, alias}); - } - - /* -------------------------------------------- */ - - /** - * A helper to prepare the speaker object based on a target TokenDocument - * @param {object} [options={}] Options which affect speaker identification - * @param {TokenDocument} options.token The TokenDocument of the speaker - * @param {string} [options.alias] The name of the speaker to display - * @returns {object} The identified speaker data - * @private - */ - static _getSpeakerFromToken({token, alias}) { - if ( token instanceof Token ) { - token = token.document; - foundry.utils.logCompatibilityWarning("You are passing a Token instance to _getSpeakerFromToken which now" - + " expects a TokenDocument instance instead", {since: 9, until: 11}); - } - return { - scene: token.parent?.id || null, - token: token.id, - actor: token.actor?.id || null, - alias: alias || token.name - }; - } - - /* -------------------------------------------- */ - - /** - * A helper to prepare the speaker object based on a target Actor - * @param {object} [options={}] Options which affect speaker identification - * @param {Scene} [options.scene] The Scene is which the speaker resides - * @param {Actor} [options.actor] The Actor that is speaking - * @param {string} [options.alias] The name of the speaker to display - * @returns {Object} The identified speaker data - * @private - */ - static _getSpeakerFromActor({scene, actor, alias}) { - return { - scene: (scene || canvas.scene)?.id || null, - actor: actor.id, - token: null, - alias: alias || actor.name - }; - } - /* -------------------------------------------- */ - - /** - * A helper to prepare the speaker object based on a target User - * @param {object} [options={}] Options which affect speaker identification - * @param {Scene} [options.scene] The Scene in which the speaker resides - * @param {User} [options.user] The User who is speaking - * @param {string} [options.alias] The name of the speaker to display - * @returns {Object} The identified speaker data - * @private - */ - static _getSpeakerFromUser({scene, user, alias}) { - return { - scene: (scene || canvas.scene)?.id || null, - actor: null, - token: null, - alias: alias || user.name - }; - } - - /* -------------------------------------------- */ - - /** - * Obtain an Actor instance which represents the speaker of this message (if any) - * @param {Object} speaker The speaker data object - * @returns {Actor|null} - */ - static getSpeakerActor(speaker) { - if ( !speaker ) return null; - let actor = null; - - // Case 1 - Token actor - if ( speaker.scene && speaker.token ) { - const scene = game.scenes.get(speaker.scene); - const token = scene ? scene.tokens.get(speaker.token) : null; - actor = token?.actor; - } - - // Case 2 - explicit actor - if ( speaker.actor && !actor ) { - actor = game.actors.get(speaker.actor); - } - return actor || null; - } - - /* -------------------------------------------- */ - - /** - * Obtain a data object used to evaluate any dice rolls associated with this particular chat message - * @returns {object} - */ - getRollData() { - const actor = this.constructor.getSpeakerActor(this.speaker) ?? this.user?.character; - return actor ? actor.getRollData() : {}; - } - - /* -------------------------------------------- */ - - /** - * Given a string whisper target, return an Array of the user IDs which should be targeted for the whisper - * - * @param {string} name The target name of the whisper target - * @returns {User[]} An array of User instances - */ - static getWhisperRecipients(name) { - - // Whisper to groups - if (["GM", "DM"].includes(name.toUpperCase())) { - return game.users.filter(u => u.isGM); - } - else if (name.toLowerCase() === "players") { - return game.users.players; - } - - // Whisper to a single person - const lowerName = name.toLowerCase(); - let user = game.users.find(u => u.name.toLowerCase() === lowerName); - if (user) return [user]; - let actor = game.users.find(a => a.character && a.character.name.toLowerCase() === lowerName); - if (actor) return [actor]; - - // Otherwise, return an empty array - return []; - } - - /* -------------------------------------------- */ - - /** - * Render the HTML for the ChatMessage which should be added to the log - * @returns {Promise} - */ - async getHTML() { - - // Determine some metadata - const data = this.toObject(false); - data.content = await TextEditor.enrichHTML(this.content, {async: true, rollData: this.getRollData()}); - const isWhisper = this.whisper.length; - - // Construct message data - const messageData = { - message: data, - user: game.user, - author: this.user, - alias: this.alias, - cssClass: [ - this.type === CONST.CHAT_MESSAGE_TYPES.IC ? "ic" : null, - this.type === CONST.CHAT_MESSAGE_TYPES.EMOTE ? "emote" : null, - isWhisper ? "whisper" : null, - this.blind ? "blind": null - ].filterJoin(" "), - isWhisper: this.whisper.length, - canDelete: game.user.isGM, // Only GM users are allowed to have the trash-bin icon in the chat log itself - whisperTo: this.whisper.map(u => { - let user = game.users.get(u); - return user ? user.name : null; - }).filterJoin(", ") - }; - - // Render message data specifically for ROLL type messages - if ( this.isRoll ) { - await this._renderRollContent(messageData); - } - - // Define a border color - if ( this.type === CONST.CHAT_MESSAGE_TYPES.OOC ) { - messageData.borderColor = this.user?.color; - } - - // Render the chat message - let html = await renderTemplate(CONFIG.ChatMessage.template, messageData); - html = $(html); - - // Flag expanded state of dice rolls - if ( this._rollExpanded ) html.find(".dice-tooltip").addClass("expanded"); - - /** - * A hook event that fires for each ChatMessage which is rendered for addition to the ChatLog. - * This hook allows for final customization of the message HTML before it is added to the log. - * @function renderChatMessage - * @memberof hookEvents - * @param {ChatMessage} message The ChatMessage document being rendered - * @param {jQuery} html The pending HTML as a jQuery object - * @param {object} data The input data provided for template rendering - */ - Hooks.call("renderChatMessage", this, html, messageData); - return html; - } - - /* -------------------------------------------- */ - - /** - * Render the inner HTML content for ROLL type messages. - * @param {object} messageData The chat message data used to render the message HTML - * @returns {Promise} - * @private - */ - async _renderRollContent(messageData) { - const data = messageData.message; - const renderRolls = async isPrivate => { - let html = ""; - for ( const r of this.rolls ) { - html += await r.render({isPrivate}); - } - return html; - }; - - // Suppress the "to:" whisper flavor for private rolls - if ( this.blind || this.whisper.length ) messageData.isWhisper = false; - - // Display standard Roll HTML content - if ( this.isContentVisible ) { - const el = document.createElement("div"); - el.innerHTML = data.content; // Ensure the content does not already contain custom HTML - if ( !el.childElementCount && this.rolls.length ) data.content = await renderRolls(false); - } - - // Otherwise, show "rolled privately" messages for Roll content - else { - const name = this.user?.name ?? game.i18n.localize("CHAT.UnknownUser"); - data.flavor = game.i18n.format("CHAT.PrivateRollContent", {user: name}); - data.content = await renderRolls(true); - messageData.alias = name; - } - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @override */ - async _preCreate(data, options, user) { - await super._preCreate(data, options, user); - if ( foundry.utils.getType(data.content) === "string" ) { - // Evaluate any immediately-evaluated inline rolls. - const matches = data.content.matchAll(/\[\[[^/].*?]{2,3}/g); - let content = data.content; - for ( const [expression] of matches ) { - content = content.replace(expression, await TextEditor.enrichHTML(expression, { - async: true, - documents: false, - secrets: false, - links: false, - rolls: true, - rollData: this.getRollData() - })); - } - this.updateSource({content}); - } - if ( this.isRoll ) { - if ( !("sound" in data) ) this.updateSource({sound: CONFIG.Dice.sound}); - const rollMode = options.rollMode || data.rollMode || game.settings.get("core", "rollMode"); - if ( rollMode ) this.applyRollMode(rollMode); - } - } - - /* -------------------------------------------- */ - - /** @override */ - _onCreate(data, options, userId) { - super._onCreate(data, options, userId); - if ( options.temporary ) return; - ui.chat.postOne(this, {notify: true}); - if ( options.chatBubble && canvas.ready ) { - game.messages.sayBubble(this); - } - } - - /* -------------------------------------------- */ - - /** @override */ - _onUpdate(data, options, userId) { - if ( !this.visible ) ui.chat.deleteMessage(this.id); - else ui.chat.updateMessage(this); - super._onUpdate(data, options, userId); - } - - /* -------------------------------------------- */ - - /** @override */ - _onDelete(options, userId) { - ui.chat.deleteMessage(this.id, options); - super._onDelete(options, userId); - } - - /* -------------------------------------------- */ - /* Importing and Exporting */ - /* -------------------------------------------- */ - - /** - * Export the content of the chat message into a standardized log format - * @returns {string} - */ - export() { - let content = []; - - // Handle HTML content - if ( this.content ) { - const html = $("
").html(this.content.replace(/<\/div>/g, "|n")); - const text = html.length ? html.text() : this.content; - const lines = text.replace(/\n/g, "").split(" ").filter(p => p !== "").join(" "); - content = lines.split("|n").map(l => l.trim()); - } - - // Add Roll content - for ( const roll of this.rolls ) { - content.push(`${roll.formula} = ${roll.result} = ${roll.total}`); - } - - // Author and timestamp - const time = new Date(this.timestamp).toLocaleDateString("en-US", { - hour: "numeric", - minute: "numeric", - second: "numeric" - }); - - // Format logged result - return `[${time}] ${this.alias}\n${content.filterJoin("\n")}`; - } - - /* -------------------------------------------- */ - /* Deprecations */ - /* -------------------------------------------- */ - - /** - * Return the first Roll instance contained in this chat message, if one is present - * @deprecated since v10 - * @ignore - * @type {Roll|null} - */ - get roll() { - const msg = "You are calling ChatMessage#roll which is deprecated in V10 in favor of ChatMessage#rolls."; - foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12}); - return this.rolls[0] || null; - } -} - -/** - * The client-side Combat document which extends the common BaseCombat model. - * - * @extends documents.BaseCombat - * @mixes ClientDocumentMixin - * - * @see {@link documents.Combats} The world-level collection of Combat documents - * @see {@link Combatant} The Combatant embedded document which exists within a Combat document - * @see {@link CombatConfig} The Combat configuration application - */ -class Combat extends ClientDocumentMixin(foundry.documents.BaseCombat) { - constructor(data, context) { - super(data, context); - - /** - * Track the sorted turn order of this combat encounter - * @type {Combatant[]} - */ - this.turns = this.turns || []; - - /** - * Record the current round, turn, and tokenId to understand changes in the encounter state - * @type {{round: number|null, turn: number|null, tokenId: string|null, combatantId: string|null}} - * @private - */ - this.current = this.current || { - round: null, - turn: null, - tokenId: null, - combatantId: null - }; - - /** - * Track the previous round, turn, and tokenId to understand changes in the encounter state - * @type {{round: number|null, turn: number|null, tokenId: string|null, combatantId: string|null}} - * @private - */ - this.previous = this.previous || { - round: null, - turn: null, - tokenId: null, - combatantId: null - }; - } - - /** - * The configuration setting used to record Combat preferences - * @type {string} - */ - static CONFIG_SETTING = "combatTrackerConfig"; - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * Get the Combatant who has the current turn. - * @type {Combatant} - */ - get combatant() { - return this.turns[this.turn]; - } - - /* -------------------------------------------- */ - - /** - * Get the Combatant who has the next turn. - * @type {Combatant} - */ - get nextCombatant() { - if ( this.turn === this.turns.length - 1 ) return this.turns[0]; - return this.turns[this.turn + 1]; - } - - /* -------------------------------------------- */ - - /** - * Return the object of settings which modify the Combat Tracker behavior - * @type {object} - */ - get settings() { - return CombatEncounters.settings; - } - - /* -------------------------------------------- */ - - /** - * Has this combat encounter been started? - * @type {boolean} - */ - get started() { - return ( this.turns.length > 0 ) && ( this.round > 0 ); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get visible() { - return true; - } - - /* -------------------------------------------- */ - - /** - * Is this combat active in the current scene? - * @type {boolean} - */ - get isActive() { - if ( !this.scene ) return this.active; - return this.scene.isView && this.active; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Set the current Combat encounter as active within the Scene. - * Deactivate all other Combat encounters within the viewed Scene and set this one as active - * @param {object} [options] Additional context to customize the update workflow - * @returns {Promise} - */ - async activate(options) { - const updates = this.collection.reduce((arr, c) => { - if ( c.isActive ) arr.push({_id: c.id, active: false}); - return arr; - }, []); - updates.push({_id: this.id, active: true}); - return this.constructor.updateDocuments(updates, options); - } - - /* -------------------------------------------- */ - - /** @override */ - prepareDerivedData() { - if ( this.combatants.size && !this.turns?.length ) this.setupTurns(); - } - - /* -------------------------------------------- */ - - /** - * Get a Combatant using its Token id - * @param {string} tokenId The id of the Token for which to acquire the combatant - * @returns {Combatant} - */ - getCombatantByToken(tokenId) { - return this.combatants.find(c => c.tokenId === tokenId); - } - - /* -------------------------------------------- */ - - /** - * Get a Combatant using its Actor id - * @param {string} actorId The id of the Actor for which to acquire the combatant - * @returns {Combatant} - */ - getCombatantByActor(actorId) { - return this.combatants.find(c => c.actorId === actorId); - } - - /* -------------------------------------------- */ - - /** - * Begin the combat encounter, advancing to round 1 and turn 1 - * @returns {Promise} - */ - async startCombat() { - this._playCombatSound("startEncounter"); - const updateData = {round: 1, turn: 0}; - Hooks.callAll("combatStart", this, updateData); - return this.update(updateData); - } - - /* -------------------------------------------- */ - - /** - * Advance the combat to the next round - * @returns {Promise} - */ - async nextRound() { - let turn = this.turn === null ? null : 0; // Preserve the fact that it's no-one's turn currently. - if ( this.settings.skipDefeated && (turn !== null) ) { - turn = this.turns.findIndex(t => !t.isDefeated); - if (turn === -1) { - ui.notifications.warn("COMBAT.NoneRemaining", {localize: true}); - turn = 0; - } - } - let advanceTime = Math.max(this.turns.length - this.turn, 0) * CONFIG.time.turnTime; - advanceTime += CONFIG.time.roundTime; - let nextRound = this.round + 1; - - // Update the document, passing data through a hook first - const updateData = {round: nextRound, turn}; - const updateOptions = {advanceTime, direction: 1}; - Hooks.callAll("combatRound", this, updateData, updateOptions); - return this.update(updateData, updateOptions); - } - - /* -------------------------------------------- */ - - /** - * Rewind the combat to the previous round - * @returns {Promise} - */ - async previousRound() { - let turn = ( this.round === 0 ) ? 0 : Math.max(this.turns.length - 1, 0); - if ( this.turn === null ) turn = null; - let round = Math.max(this.round - 1, 0); - let advanceTime = -1 * (this.turn || 0) * CONFIG.time.turnTime; - if ( round > 0 ) advanceTime -= CONFIG.time.roundTime; - - // Update the document, passing data through a hook first - const updateData = {round, turn}; - const updateOptions = {advanceTime, direction: -1}; - Hooks.callAll("combatRound", this, updateData, updateOptions); - return this.update(updateData, updateOptions); - } - - /* -------------------------------------------- */ - - /** - * Advance the combat to the next turn - * @returns {Promise} - */ - async nextTurn() { - let turn = this.turn ?? -1; - let skip = this.settings.skipDefeated; - - // Determine the next turn number - let next = null; - if ( skip ) { - for ( let [i, t] of this.turns.entries() ) { - if ( i <= turn ) continue; - if ( t.isDefeated ) continue; - next = i; - break; - } - } - else next = turn + 1; - - // Maybe advance to the next round - let round = this.round; - if ( (this.round === 0) || (next === null) || (next >= this.turns.length) ) { - return this.nextRound(); - } - - // Update the document, passing data through a hook first - const updateData = {round, turn: next}; - const updateOptions = {advanceTime: CONFIG.time.turnTime, direction: 1}; - Hooks.callAll("combatTurn", this, updateData, updateOptions); - return this.update(updateData, updateOptions); - } - - /* -------------------------------------------- */ - - /** - * Rewind the combat to the previous turn - * @returns {Promise} - */ - async previousTurn() { - if ( (this.turn === 0) && (this.round === 0) ) return this; - else if ( (this.turn <= 0) && (this.turn !== null) ) return this.previousRound(); - let advanceTime = -1 * CONFIG.time.turnTime; - let previousTurn = (this.turn ?? this.turns.length) - 1; - - // Update the document, passing data through a hook first - const updateData = {round: this.round, turn: previousTurn}; - const updateOptions = {advanceTime, direction: -1}; - Hooks.callAll("combatTurn", this, updateData, updateOptions); - return this.update(updateData, updateOptions); - } - - /* -------------------------------------------- */ - - /** - * Display a dialog querying the GM whether they wish to end the combat encounter and empty the tracker - * @returns {Promise} - */ - async endCombat() { - return Dialog.confirm({ - title: game.i18n.localize("COMBAT.EndTitle"), - content: `

${game.i18n.localize("COMBAT.EndConfirmation")}

`, - yes: () => this.delete() - }); - } - - /* -------------------------------------------- */ - - /** - * Toggle whether this combat is linked to the scene or globally available. - * @returns {Promise} - */ - async toggleSceneLink() { - const scene = this.scene ? null : (game.scenes.current?.id || null); - return this.update({scene}); - } - - /* -------------------------------------------- */ - - /** - * Reset all combatant initiative scores, setting the turn back to zero - * @returns {Promise} - */ - async resetAll() { - for ( let c of this.combatants ) { - c.updateSource({initiative: null}); - } - return this.update({turn: 0, combatants: this.combatants.toObject()}, {diff: false}); - } - - /* -------------------------------------------- */ - - /** - * Roll initiative for one or multiple Combatants within the Combat document - * @param {string|string[]} ids A Combatant id or Array of ids for which to roll - * @param {object} [options={}] Additional options which modify how initiative rolls are created or presented. - * @param {string|null} [options.formula] A non-default initiative formula to roll. Otherwise, the system - * default is used. - * @param {boolean} [options.updateTurn=true] Update the Combat turn after adding new initiative scores to - * keep the turn on the same Combatant. - * @param {object} [options.messageOptions={}] Additional options with which to customize created Chat Messages - * @returns {Promise} A promise which resolves to the updated Combat document once updates are complete. - */ - async rollInitiative(ids, {formula=null, updateTurn=true, messageOptions={}}={}) { - - // Structure input data - ids = typeof ids === "string" ? [ids] : ids; - const currentId = this.combatant?.id; - const chatRollMode = game.settings.get("core", "rollMode"); - - // Iterate over Combatants, performing an initiative roll for each - const updates = []; - const messages = []; - for ( let [i, id] of ids.entries() ) { - - // Get Combatant data (non-strictly) - const combatant = this.combatants.get(id); - if ( !combatant?.isOwner ) continue; - - // Produce an initiative roll for the Combatant - const roll = combatant.getInitiativeRoll(formula); - await roll.evaluate({async: true}); - updates.push({_id: id, initiative: roll.total}); - - // Construct chat message data - let messageData = foundry.utils.mergeObject({ - speaker: ChatMessage.getSpeaker({ - actor: combatant.actor, - token: combatant.token, - alias: combatant.name - }), - flavor: game.i18n.format("COMBAT.RollsInitiative", {name: combatant.name}), - flags: {"core.initiativeRoll": true} - }, messageOptions); - const chatData = await roll.toMessage(messageData, {create: false}); - - // If the combatant is hidden, use a private roll unless an alternative rollMode was explicitly requested - chatData.rollMode = "rollMode" in messageOptions ? messageOptions.rollMode - : (combatant.hidden ? CONST.DICE_ROLL_MODES.PRIVATE : chatRollMode ); - - // Play 1 sound for the whole rolled set - if ( i > 0 ) chatData.sound = null; - messages.push(chatData); - } - if ( !updates.length ) return this; - - // Update multiple combatants - await this.updateEmbeddedDocuments("Combatant", updates); - - // Ensure the turn order remains with the same combatant - if ( updateTurn && currentId ) { - await this.update({turn: this.turns.findIndex(t => t.id === currentId)}); - } - - // Create multiple chat messages - await ChatMessage.implementation.create(messages); - return this; - } - - /* -------------------------------------------- */ - - /** - * Roll initiative for all combatants which have not already rolled - * @param {object} [options={}] Additional options forwarded to the Combat.rollInitiative method - */ - async rollAll(options) { - const ids = this.combatants.reduce((ids, c) => { - if ( c.isOwner && (c.initiative === null) ) ids.push(c.id); - return ids; - }, []); - return this.rollInitiative(ids, options); - } - - /* -------------------------------------------- */ - - /** - * Roll initiative for all non-player actors who have not already rolled - * @param {object} [options={}] Additional options forwarded to the Combat.rollInitiative method - */ - async rollNPC(options={}) { - const ids = this.combatants.reduce((ids, c) => { - if ( c.isOwner && c.isNPC && (c.initiative === null) ) ids.push(c.id); - return ids; - }, []); - return this.rollInitiative(ids, options); - } - - /* -------------------------------------------- */ - - /** - * Assign initiative for a single Combatant within the Combat encounter. - * Update the Combat turn order to maintain the same combatant as the current turn. - * @param {string} id The combatant ID for which to set initiative - * @param {number} value A specific initiative value to set - */ - async setInitiative(id, value) { - const combatant = this.combatants.get(id, {strict: true}); - await combatant.update({initiative: value}); - } - - /* -------------------------------------------- */ - - /** - * Return the Array of combatants sorted into initiative order, breaking ties alphabetically by name. - * @returns {Combatant[]} - */ - setupTurns() { - - // Determine the turn order and the current turn - const turns = this.combatants.contents.sort(this._sortCombatants); - if ( this.turn !== null) this.turn = Math.clamped(this.turn, 0, turns.length-1); - - // Update state tracking - let c = turns[this.turn]; - this.current = { - round: this.round, - turn: this.turn, - combatantId: c ? c.id : null, - tokenId: c ? c.tokenId : null - }; - - // Return the array of prepared turns - return this.turns = turns; - } - - /* -------------------------------------------- */ - - /** - * Update active effect durations for all actors present in this Combat encounter. - */ - updateEffectDurations() { - for ( const combatant of this.combatants ) { - const actor = combatant.actor; - if ( !actor?.effects.size ) continue; - for ( const effect of actor.effects ) { - effect._prepareDuration(); - } - actor.render(false); - } - } - - /* -------------------------------------------- */ - - /** - * Loads the registered Combat Theme (if any) and plays the requested type of sound. - * If multiple exist for that type, one is chosen at random. - * @param {string} type One of [ "startEncounter", "nextUp", "yourTurn" ] - * @private - */ - _playCombatSound(type) { - const theme = CONFIG.Combat.sounds[game.settings.get("core", "combatTheme")]; - if ( !theme || theme === "none" ) return; - const options = theme[type]; - if ( !options ) return; - const src = options[Math.floor(Math.random() * options.length)]; - try { - const volume = AudioHelper.volumeToInput(game.settings.get("core", "globalInterfaceVolume")); - game.audio.create({ - src: src, - preload: true, - autoplay: true, - singleton: false, - autoplayOptions: {volume} - }); - } - catch(e) { - console.error(e); - } - } - - /* -------------------------------------------- */ - - /** - * Define how the array of Combatants is sorted in the displayed list of the tracker. - * This method can be overridden by a system or module which needs to display combatants in an alternative order. - * The default sorting rules sort in descending order of initiative using combatant IDs for tiebreakers. - * @param {Combatant} a Some combatant - * @param {Combatant} b Some other combatant - * @protected - */ - _sortCombatants(a, b) { - const ia = Number.isNumeric(a.initiative) ? a.initiative : -Infinity; - const ib = Number.isNumeric(b.initiative) ? b.initiative : -Infinity; - return (ib - ia) || (a.id > b.id ? 1 : -1); - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onCreate(data, options, userId) { - super._onCreate(data, options, userId); - if ( !this.collection.viewed ) ui.combat.initialize({combat: this, render: false}); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onUpdate(data, options, userId) { - super._onUpdate(data, options, userId); - - // Set up turn data - if ( ["combatants", "round", "turn"].some(k => data.hasOwnProperty(k)) ) { - if ( data.combatants ) this.setupTurns(); - else { - const c = this.combatant; - this.previous = this.current; - this.current = { - round: this.round, - turn: this.turn, - combatantId: c ? c.id : null, - tokenId: c ? c.tokenId : null - }; - - // Update effect durations - this.updateEffectDurations(); - - // Play sounds - if ( game.user.character ) { - if ( this.combatant?.actorId === game.user.character._id ) this._playCombatSound("yourTurn"); - else if ( this.nextCombatant?.actorId === game.user.character._id ) this._playCombatSound("nextUp"); - } - } - return ui.combat.scrollToTurn(); - } - - // Render the sidebar - if ( (data.active === true) && this.isActive ) ui.combat.initialize({combat: this}); - else if ( "scene" in data ) ui.combat.initialize(); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDelete(options, userId) { - super._onDelete(options, userId); - if ( this.collection.viewed === this ) ui.combat.initialize({render: false}); - if ( userId === game.userId ) this.collection.viewed?.activate(); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onCreateEmbeddedDocuments(type, documents, result, options, userId) { - super._onCreateEmbeddedDocuments(type, documents, result, options, userId); - - // Update the turn order and adjust the combat to keep the combatant the same - const current = this.combatant; - this.setupTurns(); - - // Keep the current Combatant the same after adding new Combatants to the Combat - if ( current ) { - let turn = Math.max(this.turns.findIndex(t => t.id === current.id), 0); - if ( game.user.id === userId ) this.update({turn}); - else this.updateSource({turn}); - } - - // Render the collection - if ( this.active && (options.render !== false) ) this.collection.render(); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onUpdateEmbeddedDocuments(embeddedName, documents, result, options, userId) { - super._onUpdateEmbeddedDocuments(embeddedName, documents, result, options, userId); - const current = this.combatant; - this.setupTurns(); - const turn = current ? this.turns.findIndex(t => t.id === current.id) : this.turn; - if ( turn !== this.turn ) { - if ( game.user.id === userId ) this.update({turn}); - else this.updateSource({turn}); - } - if ( this.active && (options.render !== false) ) this.collection.render(); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDeleteEmbeddedDocuments(embeddedName, documents, result, options, userId) { - super._onDeleteEmbeddedDocuments(embeddedName, documents, result, options, userId); - - // Update the turn order and adjust the combat to keep the combatant the same (unless they were deleted) - const current = this.combatant; - const {prevSurvivor, nextSurvivor} = this.turns.reduce((obj, t, i) => { - let valid = !result.includes(t.id); - if ( this.settings.skipDefeated ) valid &&= !t.isDefeated; - if ( !valid ) return obj; - if ( i < this.turn ) obj.prevSurvivor = t; - if ( !obj.nextSurvivor && (i >= this.turn) ) obj.nextSurvivor = t; - return obj; - }, {}); - this.setupTurns(); - - // If the current combatant was removed, update the turn order to the next survivor - let turn = this.turn; - if ( result.includes(current?.id) ) { - const survivor = nextSurvivor || prevSurvivor; - if ( survivor ) turn = this.turns.findIndex(t => t.id === survivor.id); - } - - // Otherwise, keep the combatant the same - else turn = this.turns.findIndex(t => t.id === current?.id); - - // Update database or perform a local override - turn = Math.max(turn, 0); - if ( current ) { - if ( game.user.id === userId ) this.update({turn}); - else this.updateSource({turn}); - } - - // Render the collection - if ( this.active && (options.render !== false) ) this.collection.render(); - } -} - -/** - * The client-side Combatant document which extends the common BaseCombatant model. - * - * @extends documents.BaseCombatant - * @mixes ClientDocumentMixin - * - * @see {@link Combat} The Combat document which contains Combatant embedded documents - * @see {@link CombatantConfig} The application which configures a Combatant. - */ -class Combatant extends ClientDocumentMixin(foundry.documents.BaseCombatant) { - - /** - * The token video source image (if any) - * @type {string|null} - * @internal - */ - _videoSrc = null; - - /** - * The current value of the special tracked resource which pertains to this Combatant - * @type {object|null} - */ - resource = null; - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * A convenience alias of Combatant#parent which is more semantically intuitive - * @type {Combat|null} - */ - get combat() { - return this.parent; - } - - /* -------------------------------------------- */ - - /** - * This is treated as a non-player combatant if it has no associated actor and no player users who can control it - * @type {boolean} - */ - get isNPC() { - return !this.actor || !this.players.length; - } - - /* -------------------------------------------- */ - - /** @override */ - get isOwner() { - return game.user.isGM || this.actor?.isOwner || false; - } - - /* -------------------------------------------- */ - - /** @override */ - get visible() { - return this.isOwner || !this.hidden; - } - - /* -------------------------------------------- */ - - /** - * A reference to the Actor document which this Combatant represents, if any - * @type {Actor|null} - */ - get actor() { - if ( this.token ) return this.token.actor; - return game.actors.get(this.actorId) || null; - } - - /* -------------------------------------------- */ - - /** - * A reference to the Token document which this Combatant represents, if any - * @type {TokenDocument|null} - */ - get token() { - const scene = this.sceneId ? game.scenes.get(this.sceneId) : this.parent?.scene; - return scene?.tokens.get(this.tokenId) || null; - } - - /* -------------------------------------------- */ - - /** - * An array of User documents who have ownership of this Document - * @type {User[]} - */ - get players() { - const playerOwners = []; - for ( let u of game.users ) { - if ( u.isGM ) continue; - if ( this.testUserPermission(u, "OWNER") ) playerOwners.push(u); - } - return playerOwners; - } - - /* -------------------------------------------- */ - - /** - * Has this combatant been marked as defeated? - * @type {boolean} - */ - get isDefeated() { - return this.defeated - || this.actor?.effects.some(e => e.getFlag("core", "statusId") === CONFIG.specialStatusEffects.DEFEATED); - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - testUserPermission(user, permission, {exact=false}={}) { - if ( user.isGM ) return true; - - // Combatants should be controlled by anyone who can update the Actor they represent - return this.actor?.canUserModify(user, "update") || false; - } - - /* -------------------------------------------- */ - - /** - * Get a Roll object which represents the initiative roll for this Combatant. - * @param {string} formula An explicit Roll formula to use for the combatant. - * @returns {Roll} The unevaluated Roll instance to use for the combatant. - */ - getInitiativeRoll(formula) { - formula = formula || this._getInitiativeFormula(); - const rollData = this.actor?.getRollData() || {}; - return Roll.create(formula, rollData); - } - - /* -------------------------------------------- */ - - /** - * Roll initiative for this particular combatant. - * @param {string} [formula] A dice formula which overrides the default for this Combatant. - * @returns {Promise} The updated Combatant. - */ - async rollInitiative(formula) { - const roll = this.getInitiativeRoll(formula); - await roll.evaluate({async: true}); - return this.update({initiative: roll.total}); - } - - /* -------------------------------------------- */ - - /** @override */ - prepareDerivedData() { - // Check for video source and save it if present - this._videoSrc = VideoHelper.hasVideoExtension(this.token?.texture.src) ? this.token.texture.src : null; - - // Assign image for combatant (undefined if the token src image is a video) - this.img ||= (this._videoSrc ? undefined : (this.token?.texture.src || this.actor?.img)); - this.name ||= this.token?.name || this.actor?.name || game.i18n.localize("COMBAT.UnknownCombatant"); - - this.updateResource(); - } - - /* -------------------------------------------- */ - - /** - * Update the value of the tracked resource for this Combatant. - * @returns {null|object} - */ - updateResource() { - if ( !this.actor || !this.combat ) return this.resource = null; - return this.resource = foundry.utils.getProperty(this.actor.system, this.parent.settings.resource) || null; - } - - /* -------------------------------------------- */ - - /** - * Acquire the default dice formula which should be used to roll initiative for this combatant. - * Modules or systems could choose to override or extend this to accommodate special situations. - * @returns {string} The initiative formula to use for this combatant. - * @protected - */ - _getInitiativeFormula() { - return String(CONFIG.Combat.initiative.formula || game.system.initiative); - } -} - -/** - * The client-side Drawing document which extends the common BaseDrawing model. - * - * @extends documents.BaseDrawing - * @mixes ClientDocumentMixin - * - * @see {@link Scene} The Scene document type which contains Drawing embedded documents - * @see {@link DrawingConfig} The Drawing configuration application - */ -class DrawingDocument extends CanvasDocumentMixin(foundry.documents.BaseDrawing) { - - /** - * Define an elevation property on the Drawing Document which in the future will become a part of its data schema. - * @type {number} - */ - get elevation() { - return this.#elevation ??= (this.z ?? 0); - } - - set elevation(value) { - this.#elevation = value; - if ( this.rendered ) canvas.primary.sortChildren(); - } - - #elevation; - - /* -------------------------------------------- */ - - /** - * Define a sort property on the Drawing Document which in the future will become a core part of its data schema. - * @type {number} - */ - get sort() { - return this.z; - } -} - -/** - * The client-side FogExploration document which extends the common BaseFogExploration model. - * @extends documents.BaseFogExploration - * @mixes ClientDocumentMixin - */ -class FogExploration extends ClientDocumentMixin(foundry.documents.BaseFogExploration) { - - /** - * Explore fog of war for a new point source position. - * @param {PointSource} source The candidate source of exploration - * @param {boolean} [force=false] Force the position to be re-explored - * @returns {boolean} Is the source position newly explored? - */ - explore(source, force=false) { - const r = source.radius; - const coords = canvas.grid.getCenter(source.x, source.y).map(Math.round).join("_"); - const position = this.positions[coords]; - - // Check whether the position has already been explored - let explored = position && (position.limit !== true) && (position.radius >= r); - if ( explored && !force ) return false; - - // Update explored positions - if ( CONFIG.debug.fog ) console.debug("SightLayer | Updating fog exploration for new explored position."); - this.updateSource({positions: { - [coords]: {radius: r, limit: source.los.isConstrained} - }}); - return true; - } - - /* -------------------------------------------- */ - - /** - * Obtain the fog of war exploration progress for a specific Scene and User. - * @param {object} [query] Parameters for which FogExploration document is retrieved - * @param {string} [query.scene] A certain Scene ID - * @param {string} [query.user] A certain User ID - * @param {object} [options={}] Additional options passed to DatabaseBackend#get - * @returns {Promise} - */ - static async get({scene, user}={}, options={}) { - const collection = game.collections.get("FogExploration"); - const sceneId = (scene || canvas.scene)?.id || null; - const userId = (user || game.user)?.id; - if ( !sceneId || !userId ) return null; - if ( !(game.user.isGM || (userId === game.user.id)) ) { - throw new Error("You do not have permission to access the FogExploration object of another user"); - } - - // Return cached exploration - let exploration = collection.find(x => (x.user === userId) && (x.scene === sceneId)); - if ( exploration ) return exploration; - - // Return persisted exploration - const response = await this.database.get(this, { - query: {scene: sceneId, user: userId}, - options: options - }); - exploration = response.length ? response.shift() : null; - if ( exploration ) collection.set(exploration.id, exploration); - return exploration; - } - - /* -------------------------------------------- */ - - /** - * Transform the explored base64 data into a PIXI.Texture object - * @returns {PIXI.Texture|null} - */ - getTexture() { - if ( !this.explored ) return null; - const bt = new PIXI.BaseTexture(this.explored); - return new PIXI.Texture(bt); - } -} - -/** - * The client-side Folder document which extends the common BaseFolder model. - * @extends documents.BaseFolder - * @mixes ClientDocumentMixin - * - * @see {@link Folders} The world-level collection of Folder documents - * @see {@link FolderConfig} The Folder configuration application - */ -class Folder extends ClientDocumentMixin(foundry.documents.BaseFolder) { - - /** - * The depth of this folder in its sidebar tree - * @type {number} - */ - depth; - - /** - * An array of other Folders which are the displayed children of this one. This differs from the results of - * {@link Folder.getSubfolders} because reports the subset of child folders which are displayed to the current User - * in the UI. - * @type {Folder[]} - */ - children; - - /** - * Return whether the folder is displayed in the sidebar to the current User. - * @type {boolean} - */ - displayed = false; - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * Return an array of the Document instances which are contained within this Folder. - * @type {ClientDocument[]} - */ - get contents() { - if ( this.#contents ) return this.#contents; - return this.documentCollection.filter(d => d.folder === this); - } - - set contents(value) { - this.#contents = value; - } - - #contents; - - /* -------------------------------------------- */ - - /** - * Return a reference to the Document type which is contained within this Folder. - * @returns {Function} - */ - get documentClass() { - return CONFIG[this.type].documentClass; - } - - /* -------------------------------------------- */ - - /** - * Return a reference to the WorldCollection instance which provides Documents to this Folder. - * @returns {WorldCollection} - */ - get documentCollection() { - return game.collections.get(this.type); - } - - /* -------------------------------------------- */ - - /** - * Return whether the folder is currently expanded within the sidebar interface. - * @type {boolean} - */ - get expanded() { - return game.folders._expanded[this.id] || false; - } - - /* -------------------------------------------- */ - - /** - * Return the list of ancestors of this folder, starting with the parent. - * @type {Folder[]} - */ - get ancestors() { - if ( !this.folder ) return []; - return [this.folder, ...this.folder.ancestors]; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Present a Dialog form to create a new Folder. - * @see ClientDocumentMixin.createDialog - * @param {object} data Initial data with which to populate the creation form - * @param {object} [context={}] Additional context options or dialog positioning options - * @param {object} [context.options={}] Dialog options - * @returns {Promise} A Promise which resolves to the created Folder, or null if the dialog was - * closed. - */ - static async createDialog(data={}, options={}) { - const folder = new Folder(foundry.utils.mergeObject({ - name: Folder.defaultName(), - sorting: "a" - }, data)); - return new Promise(resolve => { - options.resolve = resolve; - new FolderConfig(folder, options).render(true); - }); - } - - /* -------------------------------------------- */ - - /** - * Export all Documents contained in this Folder to a given Compendium pack. - * Optionally update existing Documents within the Pack by name, otherwise append all new entries. - * @param {CompendiumCollection} pack A Compendium pack to which the documents will be exported - * @param {object} [options] Additional options which customize how content is exported. - * See {@link ClientDocumentMixin#toCompendium} - * @param {boolean} [options.updateByName=false] Update existing entries in the Compendium pack, matching by name - * @returns {Promise} The updated Compendium Collection instance - */ - async exportToCompendium(pack, options={}) { - const updateByName = options.updateByName ?? false; - const index = await pack.getIndex(); - const documents = this.contents; - ui.notifications.info(game.i18n.format("FOLDER.Exporting", { - n: documents.length, - type: this.type, - compendium: pack.collection - })); - - // Classify creations and updates - const creations = []; - const updates = []; - for ( let d of this.contents ) { - const data = d.toCompendium(pack, options); - let existing = updateByName ? index.find(i => i.name === d.name) : index.find(i => i._id === d.id); - if (existing) { - if ( this.type === "Scene" ) { - const thumb = await d.createThumbnail({img: data.background.src}); - data.thumb = thumb.thumb; - } - data._id = existing._id; - updates.push(data); - } - else creations.push(data); - console.log(`Prepared ${d.name} for export to ${pack.collection}`); - } - - // Create new Documents - const cls = pack.documentClass; - if ( creations.length ) await cls.createDocuments(creations, { - pack: pack.collection, - keepId: options.keepId - }); - - // Update existing Documents - if ( updates.length ) await cls.updateDocuments(updates, { - pack: pack.collection, - diff: false, - recursive: false, - render: false - }); - - // Re-render the pack - ui.notifications.info(game.i18n.format("FOLDER.ExportDone", {type: this.type, compendium: pack.collection})); - pack.render(false); - return pack; - } - - /* -------------------------------------------- */ - - /** - * Provide a dialog form that allows for exporting the contents of a Folder into an eligible Compendium pack. - * @param {string} pack A pack ID to set as the default choice in the select input - * @param {object} options Additional options passed to the Dialog.prompt method - * @returns {Promise} A Promise which resolves or rejects once the dialog has been submitted or closed - */ - async exportDialog(pack, options={}) { - - // Get eligible pack destinations - const packs = game.packs.filter(p => (p.documentName === this.type) && !p.locked); - if ( !packs.length ) { - return ui.notifications.warn(game.i18n.format("FOLDER.ExportWarningNone", {type: this.type})); - } - - // Render the HTML form - const html = await renderTemplate("templates/sidebar/apps/folder-export.html", { - packs: packs.reduce((obj, p) => { - obj[p.collection] = p.title; - return obj; - }, {}), - pack: options.pack ?? null, - merge: options.merge ?? true, - keepId: options.keepId ?? true - }); - - // Display it as a dialog prompt - return Dialog.prompt({ - title: `${game.i18n.localize("FOLDER.ExportTitle")}: ${this.name}`, - content: html, - label: game.i18n.localize("FOLDER.ExportTitle"), - callback: html => { - const form = html[0].querySelector("form"); - const pack = game.packs.get(form.pack.value); - return this.exportToCompendium(pack, { - updateByName: form.merge.checked, - keepId: form.keepId.checked - }); - }, - rejectClose: false, - options - }); - } - - /* -------------------------------------------- */ - - /** - * Get the Folder documents which are sub-folders of the current folder, either direct children or recursively. - * @param {boolean} [recursive=false] Identify child folders recursively, if false only direct children are returned - * @returns {Folder[]} An array of Folder documents which are subfolders of this one - */ - getSubfolders(recursive=false) { - let subfolders = game.folders.filter(f => f._source.folder === this.id); - if ( recursive && subfolders.length ) { - for ( let f of subfolders ) { - const children = f.getSubfolders(true); - subfolders = subfolders.concat(children); - } - } - return subfolders; - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDelete(options, userId) { - const parentFolder = this.folder; - const db = CONFIG.DatabaseBackend; - const {deleteSubfolders, deleteContents} = options; - - // Delete or move sub-Folders - const deleteFolderIds = []; - for ( let f of this.getSubfolders() ) { - if ( deleteSubfolders ) deleteFolderIds.push(f.id); - else f.updateSource({folder: parentFolder}); - } - if ( deleteFolderIds.length ) { - db._handleDeleteDocuments({ - request: { type: "Folder", options: { deleteSubfolders, deleteContents, render: false } }, - result: deleteFolderIds, - userId - }); - } - - // Delete or move contained Documents - const deleteDocumentIds = []; - for ( let d of this.documentCollection ) { - if ( d._source.folder !== this.id ) continue; - if ( deleteContents ) deleteDocumentIds.push(d.id); - else d.updateSource({folder: parentFolder}); - } - if ( deleteDocumentIds.length ) { - db._handleDeleteDocuments({ - request: { type: this.type, options: { render: false } }, - result: deleteDocumentIds, - userId - }); - } - return super._onDelete(options, userId); - } - - /* -------------------------------------------- */ - /* Deprecations */ - /* -------------------------------------------- */ - - /** - * @deprecated since v10 - * @ignore - */ - get content() { - foundry.utils.logCompatibilityWarning("Folder#content is deprecated in favor of Folder#contents.", - {since: 10, until: 12}); - return this.contents; - } -} - -/** - * The client-side Item document which extends the common BaseItem model. - * @extends documents.BaseItem - * @mixes ClientDocumentMixin - * - * @see {@link documents.Items} The world-level collection of Item documents - * @see {@link applications.ItemSheet} The Item configuration application - */ -class Item extends ClientDocumentMixin(foundry.documents.BaseItem) { - - /** - * A convenience alias of Item#parent which is more semantically intuitive - * @type {Actor|null} - */ - get actor() { - return this.parent instanceof Actor ? this.parent : null; - } - - /* -------------------------------------------- */ - - /** - * Provide a thumbnail image path used to represent this document. - * @type {string} - */ - get thumbnail() { - return this.img; - } - - /* -------------------------------------------- */ - - /** - * A convenience alias of Item#isEmbedded which is preserves legacy support - * @type {boolean} - */ - get isOwned() { - return this.isEmbedded; - } - - /* -------------------------------------------- */ - - /** - * Return an array of the Active Effect instances which originated from this Item. - * The returned instances are the ActiveEffect instances which exist on the Item itself. - * @type {ActiveEffect[]} - */ - get transferredEffects() { - return this.effects.filter(e => e.transfer === true); - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Prepare a data object which defines the data schema used by dice roll commands against this Item - * @returns {object} - */ - getRollData() { - return this.system; - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - static async _onCreateDocuments(items, context) { - if ( !(context.parent instanceof Actor) ) return; - const toCreate = []; - for ( let item of items ) { - for ( let e of item.effects ) { - if ( !e.transfer ) continue; - const effectData = e.toJSON(); - effectData.origin = item.uuid; - toCreate.push(effectData); - } - } - if ( !toCreate.length ) return []; - const cls = getDocumentClass("ActiveEffect"); - return cls.createDocuments(toCreate, context); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - static async _onDeleteDocuments(items, context) { - if ( !(context.parent instanceof Actor) ) return; - const actor = context.parent; - const deletedUUIDs = new Set(items.map(i => { - if ( actor.isToken ) return i.uuid.split(".").slice(-2).join("."); - return i.uuid; - })); - const toDelete = []; - for ( const e of actor.effects ) { - let origin = e.origin || ""; - if ( actor.isToken ) origin = origin.split(".").slice(-2).join("."); - if ( deletedUUIDs.has(origin) ) toDelete.push(e.id); - } - if ( !toDelete.length ) return []; - const cls = getDocumentClass("ActiveEffect"); - return cls.deleteDocuments(toDelete, context); - } -} - -/** - * The client-side JournalEntryPage document which extends the common BaseJournalEntryPage document model. - * @extends documents.BaseJournalEntryPage - * @mixes ClientDocumentMixin - * - * @see {@link JournalEntry} The JournalEntry document type which contains JournalEntryPage embedded documents. - */ -class JournalEntryPage extends ClientDocumentMixin(foundry.documents.BaseJournalEntryPage) { - /** - * @typedef {object} JournalEntryPageHeading - * @property {number} level The heading level, 1-6. - * @property {string} text The raw heading text with any internal tags omitted. - * @property {string} slug The generated slug for this heading. - * @property {HTMLHeadingElement} [element] The currently rendered element for this heading, if it exists. - * @property {string[]} children Any child headings of this one. - */ - - /** - * The cached table of contents for this JournalEntryPage. - * @type {Object} - * @protected - */ - _toc; - - /* -------------------------------------------- */ - - /** - * The table of contents for this JournalEntryPage. - * @type {Object} - */ - get toc() { - if ( this.type !== "text" ) return {}; - if ( this._toc ) return this._toc; - const renderTarget = document.createElement("template"); - renderTarget.innerHTML = this.text.content; - this._toc = this.constructor.buildTOC(Array.from(renderTarget.content.children), {includeElement: false}); - return this._toc; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get permission() { - if ( game.user.isGM ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER; - return this.getUserLevel(game.user); - } - - /* -------------------------------------------- */ - - /** - * Return a reference to the Note instance for this Journal Entry Page in the current Scene, if any. - * If multiple notes are placed for this Journal Entry, only the first will be returned. - * @type {Note|null} - */ - get sceneNote() { - if ( !canvas.ready ) return null; - return canvas.notes.placeables.find(n => { - return (n.document.entryId === this.parent.id) && (n.document.pageId === this.id); - }) || null; - } - - /* -------------------------------------------- */ - /* Table of Contents */ - /* -------------------------------------------- */ - - /** - * Convert a heading into slug suitable for use as an identifier. - * @param {HTMLHeadingElement|string} heading The heading element or some text content. - * @returns {string} - */ - static slugifyHeading(heading) { - if ( heading instanceof HTMLElement ) heading = heading.textContent; - return heading.slugify().replace(/["']/g, "").substring(0, 64); - } - - /* -------------------------------------------- */ - - /** - * Build a table of contents for the given HTML content. - * @param {HTMLElement[]} html The HTML content to generate a ToC outline for. - * @param {object} [options] Additional options to configure ToC generation. - * @param {boolean} [options.includeElement=true] Include references to the heading DOM elements in the returned ToC. - * @returns {Object} - */ - static buildTOC(html, {includeElement=true}={}) { - // A pseudo root heading element to start at. - const root = {level: 0, children: []}; - // Perform a depth-first-search down the DOM to locate heading nodes. - const stack = [root]; - const searchHeadings = element => { - if ( element instanceof HTMLHeadingElement ) { - const node = this._makeHeadingNode(element, {includeElement}); - let parent = stack.at(-1); - if ( node.level <= parent.level ) { - stack.pop(); - parent = stack.at(-1); - } - parent.children.push(node); - stack.push(node); - } - for ( const child of (element.children || []) ) { - searchHeadings(child); - } - }; - html.forEach(searchHeadings); - return this._flattenTOC(root.children); - } - - /* -------------------------------------------- */ - - /** - * Flatten the tree structure into a single object with each node's slug as the key. - * @param {JournalEntryPageHeading[]} nodes The root ToC nodes. - * @returns {Object} - * @protected - */ - static _flattenTOC(nodes) { - const toc = {}; - const addNode = node => { - if ( toc[node.slug] ) { - let i = 1; - while ( toc[`${node.slug}$${i}`] ) i++; - node.slug = `${node.slug}$${i}`; - } - toc[node.slug] = node; - return node.slug; - }; - const flattenNode = node => { - const slug = addNode(node); - while ( node.children.length ) { - if ( typeof node.children[0] === "string" ) break; - const child = node.children.shift(); - node.children.push(flattenNode(child)); - } - return slug; - }; - nodes.forEach(flattenNode); - return toc; - } - - /* -------------------------------------------- */ - - /** - * Construct a table of contents node from a heading element. - * @param {HTMLHeadingElement} heading The heading element. - * @param {object} [options] Additional options to configure the returned node. - * @param {boolean} [options.includeElement=true] Whether to include the DOM element in the returned ToC node. - * @returns {JournalEntryPageHeading} - * @protected - */ - static _makeHeadingNode(heading, {includeElement=true}={}) { - const node = { - text: heading.innerText, - level: Number(heading.tagName[1]), - slug: heading.id || this.slugifyHeading(heading), - children: [] - }; - if ( includeElement ) node.element = heading; - return node; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - _createDocumentLink(eventData, {relativeTo, label}={}) { - const uuid = relativeTo ? this.getRelativeUUID(relativeTo) : this.uuid; - if ( eventData.anchor?.slug ) { - label ??= eventData.anchor.name; - return `@UUID[${uuid}#${eventData.anchor.slug}]{${label}}`; - } - return super._createDocumentLink(eventData, {relativeTo, label}); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onClickDocumentLink(event) { - const target = event.currentTarget; - return this.parent.sheet.render(true, {pageId: this.id, anchor: target.dataset.hash, focus: true}); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onUpdate(changed, options, userId) { - if ( "text.content" in foundry.utils.flattenObject(changed) ) this._toc = null; - if ( !canvas.ready ) return; - if ( ["name", "ownership"].some(k => k in changed) ) { - canvas.notes.placeables.filter(n => n.page === this).forEach(n => n.draw()); - } - } -} - -/** - * The client-side JournalEntry document which extends the common BaseJournalEntry model. - * @extends documents.BaseJournalEntry - * @mixes ClientDocumentMixin - * - * @see {@link Journal} The world-level collection of JournalEntry documents - * @see {@link JournalSheet} The JournalEntry configuration application - */ -class JournalEntry extends ClientDocumentMixin(foundry.documents.BaseJournalEntry) { - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * A boolean indicator for whether the JournalEntry is visible to the current user in the directory sidebar - * @type {boolean} - */ - get visible() { - return this.testUserPermission(game.user, "OBSERVER"); - } - - /* -------------------------------------------- */ - - /** - * Return a reference to the Note instance for this Journal Entry in the current Scene, if any. - * If multiple notes are placed for this Journal Entry, only the first will be returned. - * @type {Note|null} - */ - get sceneNote() { - if ( !canvas.ready ) return null; - return canvas.notes.placeables.find(n => n.document.entryId === this.id) || null; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Show the JournalEntry to connected players. - * By default, the entry will only be shown to players who have permission to observe it. - * If the parameter force is passed, the entry will be shown to all players regardless of normal permission. - * - * @param {boolean} [force=false] Display the entry to all players regardless of normal permissions - * @returns {Promise} A Promise that resolves back to the shown entry once the request is processed - * @alias Journal.show - */ - async show(force=false) { - return Journal.show(this, {force}); - } - - /* -------------------------------------------- */ - - /** - * If the JournalEntry has a pinned note on the canvas, this method will animate to that note - * The note will also be highlighted as if hovered upon by the mouse - * @param {object} [options={}] Options which modify the pan operation - * @param {number} [options.scale=1.5] The resulting zoom level - * @param {number} [options.duration=250] The speed of the pan animation in milliseconds - * @returns {Promise} A Promise which resolves once the pan animation has concluded - */ - panToNote(options={}) { - return canvas.notes.panToNote(this.sceneNote, options); - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _preCreate(data, options, user) { - /** - * Migrate content to pages. - * @deprecated since v10 - */ - if ( (("img" in data) || ("content" in data)) && !this.pages.size ) { - this.updateSource({pages: this.constructor.migrateContentToPages(data)}); - } - return super._preCreate(data, options, user); - } - - /* ---------------------------------------- */ - - /** @inheritdoc */ - async _preUpdate(changed, options, user) { - /** - * Migrate content to pages. - * @deprecated since v10 - */ - if ( ("img" in changed) || ("content" in changed) ) { - const pages = this.toObject().pages; - const addPages = this.constructor.migrateContentToPages(changed); - if ( "img" in changed ) { - const addImgPage = addPages.shift(); - const imgPage = pages.find(p => p.type === "image"); - if ( imgPage ) foundry.utils.mergeObject(imgPage, addImgPage); - else pages.push(addImgPage); - } - if ( "content" in changed ) { - const addContentPage = addPages.shift(); - const contentPage = pages.find(p => p.type === "text"); - if ( contentPage ) foundry.utils.mergeObject(contentPage, addContentPage); - else pages.push(addContentPage); - } - this.updateSource({pages}); - } - return super._preUpdate(changed, options, user); - } - - /* -------------------------------------------- */ - - /** @override */ - _onUpdate(data, options, userId) { - super._onUpdate(data, options, userId); - if ( !canvas.ready ) return; - if ( ["name", "ownership"].some(k => k in data) ) { - canvas.notes.placeables.filter(n => n.document.entryId === this.id).forEach(n => n.draw()); - } - } - - /* -------------------------------------------- */ - - /** @override */ - _onDelete(options, userId) { - super._onDelete(options, userId); - if ( !canvas.ready ) return; - for ( let n of canvas.notes.placeables ) { - if ( n.document.entryId === this.id ) n.draw(); - } - } -} - -/** - * The client-side Macro document which extends the common BaseMacro model. - * @extends documents.BaseMacro - * @mixes ClientDocumentMixin - * - * @see {@link Macros} The world-level collection of Macro documents - * @see {@link MacroConfig} The Macro configuration application - */ -class Macro extends ClientDocumentMixin(foundry.documents.BaseMacro) { - - /* -------------------------------------------- */ - /* Model Properties */ - /* -------------------------------------------- */ - - /** - * Is the current User the author of this macro? - * @type {boolean} - */ - get isAuthor() { - return game.user === this.author; - } - - /* -------------------------------------------- */ - - /** - * Test whether the current user is capable of executing a Macro script - * @type {boolean} - */ - get canExecute() { - if ( !this.testUserPermission(game.user, "LIMITED") ) return false; - return this.type === "script" ? game.user.can("MACRO_SCRIPT") : true; - } - - /* -------------------------------------------- */ - - /** - * Provide a thumbnail image path used to represent this document. - * @type {string} - */ - get thumbnail() { - return this.img; - } - - /* -------------------------------------------- */ - /* Model Methods */ - /* -------------------------------------------- */ - - /** - * Execute the Macro command. - * @param {object} [scope={}] Provide some additional scope configuration for the Macro - * @param {Actor} [scope.actor] An Actor who is the protagonist of the executed action - * @param {Token} [scope.token] A Token which is the protagonist of the executed action - */ - execute({actor, token}={}) { - if ( !this.canExecute ) { - return ui.notifications.warn(`You do not have permission to execute Macro "${this.name}".`); - } - switch ( this.type ) { - case "chat": - return this._executeChat(); - case "script": - return this._executeScript({actor, token}); - } - } - - /* -------------------------------------------- */ - - /** - * Execute the command as a chat macro. - * Chat macros simulate the process of the command being entered into the Chat Log input textarea. - * @private - */ - _executeChat() { - ui.chat.processMessage(this.command).catch(err => { - Hooks.onError("Macro#_executeChat", err, { - msg: "There was an error in your chat message syntax.", - log: "error", - notify: "error", - command: this.command - }); - }); - } - - /* -------------------------------------------- */ - - /** - * Execute the command as a script macro. - * Script Macros are wrapped in an async IIFE to allow the use of asynchronous commands and await statements. - * @param {object} [options={}] - * @param {Actor} [options.actor] - * @param {Token} [options.token] - * @private - */ - _executeScript({actor, token}={}) { - - // Add variables to the evaluation scope - const speaker = ChatMessage.implementation.getSpeaker(); - const character = game.user.character; - actor = actor || game.actors.get(speaker.actor); - token = token || (canvas.ready ? canvas.tokens.get(speaker.token) : null); - - // Attempt script execution - const AsyncFunction = (async function(){}).constructor; - // eslint-disable-next-line no-new-func - const fn = new AsyncFunction("speaker", "actor", "token", "character", this.command); - try { - return fn.call(this, speaker, actor, token, character); - } catch(err) { - ui.notifications.error("There was an error in your macro syntax. See the console (F12) for details"); - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onClickDocumentLink(event) { - return this.execute(); - } -} - -/** - * The client-side MeasuredTemplate document which extends the common BaseMeasuredTemplate document model. - * @extends documents.BaseMeasuredTemplate - * @mixes ClientDocumentMixin - * - * @see {@link Scene} The Scene document type which contains MeasuredTemplate documents - * @see {@link MeasuredTemplateConfig} The MeasuredTemplate configuration application - */ -class MeasuredTemplateDocument extends CanvasDocumentMixin(foundry.documents.BaseMeasuredTemplate) { - - /* -------------------------------------------- */ - /* Model Properties */ - /* -------------------------------------------- */ - - /** - * A reference to the User who created the MeasuredTemplate document. - * @type {User} - */ - get author() { - return game.users.get(this.user); - } - - /** - * Rotation is an alias for direction - * @returns {number} - */ - get rotation() { - return this.direction; - } -} - -/** - * The client-side Note document which extends the common BaseNote document model. - * @extends documents.BaseNote - * @mixes ClientDocumentMixin - * - * @see {@link Scene} The Scene document type which contains Note documents - * @see {@link NoteConfig} The Note configuration application - */ -class NoteDocument extends CanvasDocumentMixin(foundry.documents.BaseNote) { - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * The associated JournalEntry which is referenced by this Note - * @type {JournalEntry} - */ - get entry() { - return game.journal.get(this.entryId); - } - - /* -------------------------------------------- */ - - /** - * The specific JournalEntryPage within the associated JournalEntry referenced by this Note. - * @type {JournalEntryPage} - */ - get page() { - return this.entry?.pages.get(this.pageId); - } - - /* -------------------------------------------- */ - - /** - * The text label used to annotate this Note - * @type {string} - */ - get label() { - return this.text || this.page?.name || this.entry?.name || game?.i18n?.localize("NOTE.Unknown") || "Unknown"; - } -} - -/** - * The client-side PlaylistSound document which extends the common BasePlaylistSound model. - * Each PlaylistSound belongs to the sounds collection of a Playlist document. - * @extends documents.BasePlaylistSound - * @mixes ClientDocumentMixin - * - * @see {@link Playlist} The Playlist document which contains PlaylistSound embedded documents - * @see {@link PlaylistSoundConfig} The PlaylistSound configuration application - * @see {@link Sound} The Sound API which manages web audio playback - */ -class PlaylistSound extends ClientDocumentMixin(foundry.documents.BasePlaylistSound) { - constructor(data, context) { - super(data, context); - - /** - * The Sound which manages playback for this playlist sound - * @type {Sound|null} - */ - this.sound = this._createSound(); - - /** - * A debounced function, accepting a single volume parameter to adjust the volume of this sound - * @type {Function} - * @param {number} volume The desired volume level - */ - this.debounceVolume = foundry.utils.debounce(volume => { - this.update({volume}, {diff: false, render: false}); - }, PlaylistSound.VOLUME_DEBOUNCE_MS); - } - - /** - * The debounce tolerance for processing rapid volume changes into database updates in milliseconds - * @type {number} - */ - static VOLUME_DEBOUNCE_MS = 100; - - /* -------------------------------------------- */ - - /** - * Create a Sound used to play this PlaylistSound document - * @returns {Sound|null} - * @private - */ - _createSound() { - if ( !this.id || !this.path ) return null; - const sound = game.audio.create({ - src: this.path, - preload: false, - singleton: false - }); - sound.on("start", this._onStart.bind(this)); - sound.on("end", this._onEnd.bind(this)); - sound.on("stop", this._onStop.bind(this)); - return sound; - } - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * The effective volume at which this playlist sound is played, incorporating the global playlist volume setting. - * @type {number} - */ - get effectiveVolume() { - return this.volume * game.settings.get("core", "globalPlaylistVolume"); - } - - /* -------------------------------------------- */ - - /** - * Determine the fade duration for this PlaylistSound based on its own configuration and that of its parent. - * @type {number} - */ - get fadeDuration() { - if ( !this.sound.duration ) return 0; - const halfDuration = Math.ceil(this.sound.duration / 2) * 1000; - return Math.clamped(this.fade ?? this.parent.fade ?? 0, 0, halfDuration); - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Synchronize playback for this particular PlaylistSound instance - */ - sync() { - if ( !this.sound || this.sound.failed ) return; - const fade = this.fadeDuration; - - // Conclude current playback - if ( !this.playing ) { - if ( fade && !this.pausedTime && this.sound.playing ) { - return this.sound.fade(0, {duration: fade}).then(() => this.sound.stop()); - } - else return this.sound.stop(); - } - - // Determine playback configuration - const playback = { - loop: this.repeat, - volume: this.effectiveVolume, - fade: fade - }; - if ( this.pausedTime && this.playing && !this.sound.playing ) playback.offset = this.pausedTime; - - // Load and autoplay, or play directly if already loaded - if ( this.sound.loaded ) return this.sound.play(playback); - return this.sound.load({autoplay: true, autoplayOptions: playback}); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - toAnchor({classes=[], ...options}={}) { - if ( this.playing ) classes.push("playing"); - if ( !game.user.isGM ) classes.push("disabled"); - return super.toAnchor({classes, ...options}); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onClickDocumentLink(event) { - if ( this.playing ) return this.parent.stopSound(this); - return this.parent.playSound(this); - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @override */ - _onCreate(data, options, userId) { - super._onCreate(data, options, userId); - if ( this.parent ) this.parent._playbackOrder = undefined; - } - - /* -------------------------------------------- */ - - /** @override */ - _onUpdate(changed, options, userId) { - super._onUpdate(changed, options, userId); - if ( "path" in changed ) { - if ( this.sound ) this.sound.stop(); - this.sound = this._createSound(); - } - if ( ("sort" in changed) && this.parent ) { - this.parent._playbackOrder = undefined; - } - this.sync(); - } - - /* -------------------------------------------- */ - - /** @override */ - _onDelete(options, userId) { - super._onDelete(options, userId); - if ( this.parent ) this.parent._playbackOrder = undefined; - this.playing = false; - this.sync(); - } - - /* -------------------------------------------- */ - - /** - * Special handling that occurs when a PlaylistSound reaches the natural conclusion of its playback. - * @private - */ - async _onEnd() { - if (!game.user.isGM) return; - return this.parent._onSoundEnd(this); - } - - /* -------------------------------------------- */ - - /** - * Special handling that occurs when playback of a PlaylistSound is started. - * @private - */ - async _onStart() { - if ( !this.playing ) return this.sound.stop(); - - // Apply fade timings - const fade = this.fadeDuration; - if ( fade ) { - this._fadeIn(this.sound); - if ( !this.repeat && Number.isFinite(this.sound.duration) ) { - // noinspection ES6MissingAwait - this.sound.schedule(this._fadeOut.bind(this), this.sound.duration - (fade / 1000)); - } - } - - // Playlist-level orchestration actions - return this.parent._onSoundStart(this); - } - - /* -------------------------------------------- */ - - /** - * Special handling that occurs when a PlaylistSound is manually stopped before its natural conclusion. - * @private - */ - async _onStop() {} - - /* -------------------------------------------- */ - - /** - * Handle fading in the volume for this sound when it begins to play (or loop) - * @param {Sound} sound The sound fading-in - * @private - */ - _fadeIn(sound) { - if ( !sound.node ) return; - const fade = this.fadeDuration; - if ( !fade || sound.pausedTime ) return; - sound.fade(this.effectiveVolume, {duration: fade, from: 0}); - } - - /* -------------------------------------------- */ - - /** - * Handle fading out the volume for this sound when it begins to play (or loop) - * @param {Sound} sound The sound fading-out - * @private - */ - _fadeOut(sound) { - if ( !sound.node ) return; - const fade = this.fadeDuration; - if ( !fade ) return; - sound.fade(0, {duration: fade}); - } -} - -/** - * The client-side Playlist document which extends the common BasePlaylist model. - * @extends documents.BasePlaylist - * @mixes ClientDocumentMixin - * - * @see {@link Playlists} The world-level collection of Playlist documents - * @see {@link PlaylistSound} The PlaylistSound embedded document within a parent Playlist - * @see {@link PlaylistConfig} The Playlist configuration application - */ -class Playlist extends ClientDocumentMixin(foundry.documents.BasePlaylist) { - - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * Playlists may have a playback order which defines the sequence of Playlist Sounds - * @type {string[]} - */ - _playbackOrder; - - /** - * The order in which sounds within this playlist will be played (if sequential or shuffled) - * Uses a stored seed for randomization to guarantee that all clients generate the same random order. - * @type {string[]} - */ - get playbackOrder() { - if ( this._playbackOrder !== undefined ) return this._playbackOrder; - switch ( this.mode ) { - - // Shuffle all tracks - case CONST.PLAYLIST_MODES.SHUFFLE: - let ids = this.sounds.map(s => s.id); - const mt = new MersenneTwister(this.seed ?? 0); - let shuffle = ids.reduce((shuffle, id) => { - shuffle[id] = mt.random(); - return shuffle; - }, {}); - ids.sort((a, b) => shuffle[a] - shuffle[b]); - return this._playbackOrder = ids; - - // Sorted sequential playback - default: - const sorted = this.sounds.contents.sort(this._sortSounds.bind(this)); - return this._playbackOrder = sorted.map(s => s.id); - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get visible() { - return game.user.isGM || this.playing; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Find all content links belonging to a given {@link Playlist} or {@link PlaylistSound}. - * @param {Playlist|PlaylistSound} doc The Playlist or PlaylistSound. - * @returns {NodeListOf} - * @protected - */ - static _getSoundContentLinks(doc) { - return document.querySelectorAll(`a.content-link[data-uuid="${doc.uuid}"]`); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - prepareDerivedData() { - this.playing = this.sounds.some(s => s.playing); - } - - /* -------------------------------------------- */ - - /** - * Begin simultaneous playback for all sounds in the Playlist. - * @returns {Promise} The updated Playlist document - */ - async playAll() { - if ( this.sounds.size === 0 ) return this; - const updateData = { playing: true }; - const order = this.playbackOrder; - - // Handle different playback modes - switch (this.mode) { - - // Soundboard Only - case CONST.PLAYLIST_MODES.DISABLED: - updateData.playing = false; - break; - - // Sequential or Shuffled Playback - case CONST.PLAYLIST_MODES.SEQUENTIAL: - case CONST.PLAYLIST_MODES.SHUFFLE: - const paused = this.sounds.find(s => s.pausedTime); - const nextId = paused?.id || order[0]; - updateData.sounds = this.sounds.map(s => { - return {_id: s.id, playing: s.id === nextId}; - }); - break; - - // Simultaneous - play all tracks - case CONST.PLAYLIST_MODES.SIMULTANEOUS: - updateData.sounds = this.sounds.map(s => { - return {_id: s.id, playing: true}; - }); - break; - } - - // Update the Playlist - return this.update(updateData); - } - - /* -------------------------------------------- */ - - /** - * Play the next Sound within the sequential or shuffled Playlist. - * @param {string} [soundId] The currently playing sound ID, if known - * @param {object} [options={}] Additional options which configure the next track - * @param {number} [options.direction=1] Whether to advance forward (if 1) or backwards (if -1) - * @returns {Promise} The updated Playlist document - */ - async playNext(soundId, {direction=1}={}) { - if ( ![CONST.PLAYLIST_MODES.SEQUENTIAL, CONST.PLAYLIST_MODES.SHUFFLE].includes(this.mode) ) return null; - - // Determine the next sound - if ( !soundId ) { - const current = this.sounds.find(s => s.playing); - soundId = current?.id || null; - } - let next = direction === 1 ? this._getNextSound(soundId) : this._getPreviousSound(soundId); - if ( !this.playing ) next = null; - - // Enact playlist updates - const sounds = this.sounds.map(s => { - return {_id: s.id, playing: s.id === next?.id, pausedTime: null}; - }); - return this.update({sounds}); - } - - /* -------------------------------------------- */ - - /** - * Begin playback of a specific Sound within this Playlist. - * Determine which other sounds should remain playing, if any. - * @param {PlaylistSound} sound The desired sound that should play - * @returns {Promise} The updated Playlist - */ - async playSound(sound) { - const updates = {playing: true}; - switch ( this.mode ) { - case CONST.PLAYLIST_MODES.SEQUENTIAL: - case CONST.PLAYLIST_MODES.SHUFFLE: - updates.sounds = this.sounds.map(s => { - let isPlaying = s.id === sound.id; - return {_id: s.id, playing: isPlaying, pausedTime: isPlaying ? s.pausedTime : null}; - }); - break; - default: - updates.sounds = [{_id: sound.id, playing: true}]; - } - return this.update(updates); - } - - /* -------------------------------------------- */ - - /** - * Stop playback of a specific Sound within this Playlist. - * Determine which other sounds should remain playing, if any. - * @param {PlaylistSound} sound The desired sound that should play - * @returns {Promise} The updated Playlist - */ - async stopSound(sound) { - return this.update({ - playing: this.sounds.some(s => (s.id !== sound.id) && s.playing), - sounds: [{_id: sound.id, playing: false, pausedTime: null}] - }); - } - - /* -------------------------------------------- */ - - /** - * End playback for any/all currently playing sounds within the Playlist. - * @returns {Promise} The updated Playlist document - */ - async stopAll() { - return this.update({ - playing: false, - sounds: this.sounds.map(s => { - return {_id: s.id, playing: false}; - }) - }); - } - - /* -------------------------------------------- */ - - /** - * Cycle the playlist mode - * @return {Promise.} A promise which resolves to the updated Playlist instance - */ - async cycleMode() { - const modes = Object.values(CONST.PLAYLIST_MODES); - let mode = this.mode + 1; - mode = mode > Math.max(...modes) ? modes[0] : mode; - for ( let s of this.sounds ) { - s.playing = false; - } - return this.update({sounds: this.sounds.toJSON(), mode: mode}); - } - - /* -------------------------------------------- */ - - /** - * Get the next sound in the cached playback order. For internal use. - * @private - */ - _getNextSound(soundId) { - const order = this.playbackOrder; - let idx = order.indexOf(soundId); - if (idx === order.length - 1) idx = -1; - return this.sounds.get(order[idx+1]); - } - - /* -------------------------------------------- */ - - /** - * Get the previous sound in the cached playback order. For internal use. - * @private - */ - _getPreviousSound(soundId) { - const order = this.playbackOrder; - let idx = order.indexOf(soundId); - if ( idx === -1 ) idx = 1; - else if (idx === 0) idx = order.length; - return this.sounds.get(order[idx-1]); - } - - /* -------------------------------------------- */ - - /** - * Define the sorting order for the Sounds within this Playlist. For internal use. - * @private - */ - _sortSounds(a, b) { - switch ( this.sorting ) { - case CONST.PLAYLIST_SORT_MODES.ALPHABETICAL: return a.name.localeCompare(b.name); - case CONST.PLAYLIST_SORT_MODES.MANUAL: return a.sort - b.sort; - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - toAnchor({classes=[], ...options}={}) { - if ( this.playing ) classes.push("playing"); - if ( !game.user.isGM ) classes.push("disabled"); - return super.toAnchor({classes, ...options}); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onClickDocumentLink(event) { - if ( this.playing ) return this.stopAll(); - return this.playAll(); - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _preUpdate(changed, options, user) { - if ((("mode" in changed) || ("playing" in changed)) && !("seed" in changed)) { - changed.seed = Math.floor(Math.random() * 1000); - } - return super._preUpdate(changed, options, user); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onUpdate(changed, options, userId) { - super._onUpdate(changed, options, userId); - if ( "seed" in changed || "mode" in changed || "sorting" in changed ) this._playbackOrder = undefined; - if ( "sounds" in changed ) this.sounds.forEach(s => s.sync()); - this._updateContentLinkPlaying(changed); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDelete(options, userId) { - super._onDelete(options, userId); - this.sounds.forEach(s => s.sound.stop()); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onCreateEmbeddedDocuments(embeddedName, documents, createData, options, userId) { - super._onCreateEmbeddedDocuments(embeddedName, documents, createData, options, userId); - if ( options.render !== false ) this.collection.render(); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onUpdateEmbeddedDocuments(embeddedName, documents, changes, options, userId) { - super._onUpdateEmbeddedDocuments(embeddedName, documents, changes, options, userId); - if ( options.render !== false ) this.collection.render(); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDeleteEmbeddedDocuments(embeddedName, documents, ids, options, userId) { - super._onDeleteEmbeddedDocuments(embeddedName, documents, ids, options, userId); - if ( options.render !== false ) this.collection.render(); - } - - /* -------------------------------------------- */ - - /** - * Handle callback logic when an individual sound within the Playlist concludes playback naturally - * @param {PlaylistSound} sound - * @private - */ - async _onSoundEnd(sound) { - switch ( this.mode ) { - case CONST.PLAYLIST_MODES.SEQUENTIAL: - case CONST.PLAYLIST_MODES.SHUFFLE: - return this.playNext(sound.id); - case CONST.PLAYLIST_MODES.SIMULTANEOUS: - case CONST.PLAYLIST_MODES.DISABLED: - const updates = {playing: true, sounds: [{_id: sound.id, playing: false, pausedTime: null}]}; - for ( let s of this.sounds ) { - if ( (s !== sound) && s.playing ) break; - updates.playing = false; - } - return this.update(updates); - } - } - - /* -------------------------------------------- */ - - /** - * Handle callback logic when playback for an individual sound within the Playlist is started. - * Schedule auto-preload of next track - * @param {PlaylistSound} sound - * @private - */ - async _onSoundStart(sound) { - if ( ![CONST.PLAYLIST_MODES.SEQUENTIAL, CONST.PLAYLIST_MODES.SHUFFLE].includes(this.mode) ) return; - const apl = CONFIG.Playlist.autoPreloadSeconds; - if ( Number.isNumeric(apl) && Number.isFinite(sound.sound.duration) ) { - setTimeout(() => { - if ( !sound.playing ) return; - const next = this._getNextSound(sound.id); - if ( next ) next.sound.load(); - }, (sound.sound.duration - apl) * 1000); - } - } - - /* -------------------------------------------- */ - - /** - * Update the playing status of this Playlist in content links. - * @param {object} changed The data changes. - * @private - */ - _updateContentLinkPlaying(changed) { - if ( "playing" in changed ) { - this.constructor._getSoundContentLinks(this).forEach(el => el.classList.toggle("playing", changed.playing)); - } - if ( "sounds" in changed ) changed.sounds.forEach(update => { - const sound = this.sounds.get(update._id); - if ( !("playing" in update) || !sound ) return; - this.constructor._getSoundContentLinks(sound).forEach(el => el.classList.toggle("playing", update.playing)); - }); - } - - /* -------------------------------------------- */ - /* Importing and Exporting */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - toCompendium(pack, options={}) { - const data = super.toCompendium(pack, options); - if ( options.clearState ) { - data.playing = false; - for ( let s of data.sounds ) { - s.playing = false; - } - } - return data; - } -} - -/** - * The client-side Scene document which extends the common BaseScene model. - * @extends documents.BaseItem - * @mixes ClientDocumentMixin - * - * @see {@link Scenes} The world-level collection of Scene documents - * @see {@link SceneConfig} The Scene configuration application - */ -class Scene extends ClientDocumentMixin(foundry.documents.BaseScene) { - - /** - * Track the viewed position of each scene (while in memory only, not persisted) - * When switching back to a previously viewed scene, we can automatically pan to the previous position. - * @type {CanvasViewPosition} - */ - _viewPosition = {}; - - /** - * Track whether the scene is the active view - * @type {boolean} - */ - _view = this.active; - - /** - * Determine the canvas dimensions this Scene would occupy, if rendered - * @type {object} - */ - dimensions = this.dimensions; // Workaround for subclass property instantiation issue. - - /* -------------------------------------------- */ - /* Scene Properties */ - /* -------------------------------------------- */ - - /** - * Provide a thumbnail image path used to represent this document. - * @type {string} - */ - get thumbnail() { - return this.thumb; - } - - /* -------------------------------------------- */ - - /** - * A convenience accessor for whether the Scene is currently viewed - * @type {boolean} - */ - get isView() { - return this._view; - } - - /* -------------------------------------------- */ - /* Scene Methods */ - /* -------------------------------------------- */ - - /** - * Set this scene as currently active - * @returns {Promise} A Promise which resolves to the current scene once it has been successfully activated - */ - async activate() { - if ( this.active ) return this; - return this.update({active: true}); - } - - /* -------------------------------------------- */ - - /** - * Set this scene as the current view - * @returns {Promise} - */ - async view() { - - // Do not switch if the loader is still running - if ( canvas.loading ) { - return ui.notifications.warn("You cannot switch Scenes until resources finish loading for your current view."); - } - - // Switch the viewed scene - for ( let scene of game.scenes ) { - scene._view = scene.id === this.id; - } - - // Notify the user in no-canvas mode - if ( game.settings.get("core", "noCanvas") ) { - ui.notifications.info(game.i18n.format("INFO.SceneViewCanvasDisabled", { - name: this.navName ? this.navName : this.name - })); - } - - // Re-draw the canvas if the view is different - if ( canvas.initialized && (canvas.id !== this.id) ) { - console.log(`Foundry VTT | Viewing Scene ${this.name}`); - await canvas.draw(this); - } - - // Render apps for the collection - this.collection.render(); - ui.combat.initialize(); - return this; - } - - /* -------------------------------------------- */ - - /** @override */ - clone(createData={}, options={}) { - createData.active = false; - createData.navigation = false; - if ( !foundry.data.validators.isBase64Data(createData.thumb) ) delete createData.thumb; - if ( !options.save ) return super.clone(createData, options); - return this.createThumbnail().then(data => { - createData.thumb = data.thumb; - return super.clone(createData, options); - }); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - prepareBaseData() { - this.dimensions = this.getDimensions(); - this.playlistSound = this.playlist ? this.playlist.sounds.get(this._source.playlistSound) : null; - // A temporary assumption until a more robust long-term solution when we implement Scene Levels. - this.foregroundElevation = this.foregroundElevation || (this.grid.distance * 4); - } - - /* -------------------------------------------- */ - - /** - * @typedef {object} SceneDimensions - * @property {number} width The width of the canvas. - * @property {number} height The height of the canvas. - * @property {number} size The grid size. - * @property {Rectangle} rect The canvas rectangle. - * @property {number} sceneX The X coordinate of the scene rectangle within the larger canvas. - * @property {number} sceneY The Y coordinate of the scene rectangle within the larger canvas. - * @property {number} sceneWidth The width of the scene. - * @property {number} sceneHeight The height of the scene. - * @property {Rectangle} sceneRect The scene rectangle. - * @property {number} distance The number of distance units in a single grid space. - * @property {number} ratio The aspect ratio of the scene rectangle. - * @property {number} maxR The length of the longest line that can be drawn on the canvas. - */ - - /** - * Get the Canvas dimensions which would be used to display this Scene. - * Apply padding to enlarge the playable space and round to the nearest 2x grid size to ensure symmetry. - * The rounding accomplishes that the padding buffer around the map always contains whole grid spaces. - * @returns {SceneDimensions} - */ - getDimensions() { - - // Get Scene data - const grid = this.grid; - const size = grid.size || 100; - const sceneWidth = this.width || (size * 30); - const sceneHeight = this.height || (size * 20); - - // Compute the correct grid sizing - const gridType = grid.type ?? CONST.GRID_TYPES.SQUARE; - const gridCls = BaseGrid.implementationFor(gridType); - const gridPadding = gridCls.calculatePadding(gridType, sceneWidth, sceneHeight, grid.size, this.padding, { - legacy: this.flags.core?.legacyHex - }); - const {width, height} = gridPadding; - const sceneX = gridPadding.x - this.background.offsetX; - const sceneY = gridPadding.y - this.background.offsetY; - - // Define Scene dimensions - return { - width, height, size, - rect: new PIXI.Rectangle(0, 0, width, height), - sceneX, sceneY, sceneWidth, sceneHeight, - sceneRect: new PIXI.Rectangle(sceneX, sceneY, sceneWidth, sceneHeight), - distance: this.grid.distance, - ratio: sceneWidth / sceneHeight, - maxR: Math.hypot(width, height) - }; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onClickDocumentLink(event) { - if ( this.journal ) return this.journal._onClickDocumentLink(event); - return super._onClickDocumentLink(event); - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @override */ - async _preCreate(data, options, user) { - await super._preCreate(data, options, user); - - // Set a Scene as active if none currently are - if ( !("active" in data) && !game.scenes.active ) this.updateSource({active: true}); - - // Create a base64 thumbnail for the scene - if ( !("thumb" in data) && canvas.ready && this.background.src ) { - const t = await this.createThumbnail({img: this.background.src}); - this.updateSource({thumb: t.thumb}); - } - - // Trigger Playlist Updates - if ( this.active ) return game.playlists._onChangeScene(this, data); - } - - /* -------------------------------------------- */ - - /** @override */ - _onCreate(data, options, userId) { - super._onCreate(data, options, userId); - if ( data.active === true ) this._onActivate(true); - } - - /* -------------------------------------------- */ - - /** @override */ - async _preUpdate(data, options, user) { - await super._preUpdate(data, options, user); - if ( "thumb" in data ) { - options.thumb ??= []; - options.thumb.push(this.id); - } - const audioChange = ("active" in data) || (this.active && ["playlist", "playlistSound"].some(k => k in data)); - if ( audioChange ) return game.playlists._onChangeScene(this, data); - } - - /* -------------------------------------------- */ - - /** @override */ - _onUpdate(data, options, userId) { - if ( !("thumb" in data) && (options.thumb ?? []).includes(this.id) ) data.thumb = this.thumb; - super._onUpdate(data, options, userId); - const changed = new Set(Object.keys(foundry.utils.flattenObject(data)).filter(k => k !== "_id")); - - // If the Scene became active, go through the full activation procedure - if ( changed.has("active") ) this._onActivate(data.active); - - // If the Thumbnail was updated, bust the image cache - if ( changed.has("thumb") && this.thumb ) { - this.thumb = `${this.thumb.split("?")[0]}?${Date.now()}`; - } - - // If the scene is already active, maybe re-draw the canvas - if ( canvas.scene === this ) { - const redraw = [ - "foreground", "fogOverlay", "width", "height", "padding", // Scene Dimensions - "grid.type", "grid.size", "grid.distance", "grid.units", // Grid Configuration - "drawings", "lights", "sounds", "templates", "tiles", "tokens", "walls", // Placeable Objects - "weather" // Ambience - ]; - if ( redraw.some(k => changed.has(k)) || ("background" in data) ) return canvas.draw(); - if ( ["grid.color", "grid.alpha"].some(k => changed.has(k)) ) canvas.grid.grid.draw(); - - // Modify vision conditions - const perceptionAttrs = ["globalLight", "globalLightThreshold", "tokenVision", "fogExploration"]; - if ( perceptionAttrs.some(k => changed.has(k)) ) canvas.perception.initialize(); - - // Progress darkness level - if ( changed.has("darkness") && options.animateDarkness ) { - return canvas.effects.animateDarkness(data.darkness, { - duration: typeof options.animateDarkness === "number" ? options.animateDarkness : undefined - }); - } - - // Initialize the color manager with the new darkness level and/or scene background color - if ( ["darkness", "backgroundColor", "fogUnexploredColor", "fogExploredColor"].some(k => changed.has(k)) ) { - canvas.colorManager.initialize(); - } - - // New initial view position - if ( ["initial.x", "initial.y", "initial.scale"].some(k => changed.has(k)) ) { - this._viewPosition = {}; - canvas.initializeCanvasPosition(); - } - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _preDelete(options, user) { - await super._preDelete(options, user); - if ( this.active ) game.playlists._onChangeScene(this, {active: false}); - } - - /* -------------------------------------------- */ - - /** @override */ - _onDelete(options, userId) { - super._onDelete(options, userId); - if ( canvas.scene?.id === this.id ) canvas.draw(null); - } - - /* -------------------------------------------- */ - - /** - * Handle Scene activation workflow if the active state is changed to true - * @param {boolean} active Is the scene now active? - * @protected - */ - _onActivate(active) { - - // Deactivate other scenes - for ( let s of game.scenes ) { - if ( s.active && (s !== this) ) { - s.updateSource({active: false}); - s._initialize(); - } - } - - // Update the Canvas display - if ( canvas.initialized && !active ) return canvas.draw(null); - return this.view(); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _preCreateEmbeddedDocuments(embeddedName, result, options, userId) { - super._preCreateEmbeddedDocuments(embeddedName, result, options, userId); - if ( (userId === game.userId) && this.isView && !options.isUndo ) { - const layer = canvas.getLayerByEmbeddedName(embeddedName); - layer?.storeHistory("create", result); - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onCreateEmbeddedDocuments(...args) { - super._onCreateEmbeddedDocuments(...args); - if ( this.isView ) canvas.triggerPendingOperations(); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _preUpdateEmbeddedDocuments(embeddedName, result, options, userId) { - super._preUpdateEmbeddedDocuments(embeddedName, result, options, userId); - if ( (userId === game.userId) && this.isView && !options.isUndo ) { - const layer = canvas.getLayerByEmbeddedName(embeddedName); - const updatedIds = new Set(result.map(r => r._id)); - const originals = this.getEmbeddedCollection(embeddedName).reduce((arr, d) => { - if ( updatedIds.has(d.id) ) arr.push(d.toJSON()); - return arr; - }, []); - layer?.storeHistory("update", originals); - } - } - - /* -------------------------------------------- */ - - /** @override */ - _onUpdateEmbeddedDocuments(...args) { - super._onUpdateEmbeddedDocuments(...args); - if ( this.isView ) canvas.triggerPendingOperations(); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _preDeleteEmbeddedDocuments(embeddedName, result, options, userId) { - super._preDeleteEmbeddedDocuments(embeddedName, result, options, userId); - if ( (userId === game.userId) && this.isView && !options.isUndo ) { - const layer = canvas.getLayerByEmbeddedName(embeddedName); - const originals = this.getEmbeddedCollection(embeddedName).reduce((arr, d) => { - if ( result.includes(d.id) ) arr.push(d.toJSON()); - return arr; - }, []); - layer?.storeHistory("delete", originals); - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDeleteEmbeddedDocuments(...args) { - super._onDeleteEmbeddedDocuments(...args); - if ( this.isView ) canvas.triggerPendingOperations(); - } - - /* -------------------------------------------- */ - /* Importing and Exporting */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - toCompendium(pack, options={}) { - const data = super.toCompendium(pack, options); - if ( options.clearState ) delete data.fogReset; - if ( options.clearSort ) { - delete data.navigation; - delete data.navOrder; - } - return data; - } - - /* -------------------------------------------- */ - - /** - * Create a 300px by 100px thumbnail image for this scene background - * @param {object} [options] Options which modify thumbnail creation - * @param {string|null} [options.img] A background image to use for thumbnail creation, otherwise the current scene - * background is used. - * @param {number} [options.width] The desired thumbnail width. Default is 300px - * @param {number} [options.height] The desired thumbnail height. Default is 100px; - * @param {string} [options.format] Which image format should be used? image/png, image/jpg, or image/webp - * @param {number} [options.quality] What compression quality should be used for jpeg or webp, between 0 and 1 - * @returns {Promise} The created thumbnail data. - */ - async createThumbnail({img, width=300, height=100, format="image/webp", quality=0.8}={}) { - if ( game.settings.get("core", "noCanvas") ) throw new Error(game.i18n.localize("SCENES.GenerateThumbNoCanvas")); - - // Create counter-factual scene data - const newImage = img !== undefined; - img = img ?? this.background.src; - const scene = this.clone({"background.src": img}); - - // Load required textures to create the thumbnail - const tiles = this.tiles.filter(t => t.texture.src && !t.hidden).sort((a, b) => a.z - b.z); - const toLoad = tiles.map(t => t.texture.src); - if ( img ) toLoad.push(img); - if ( this.foreground ) toLoad.push(this.foreground); - await TextureLoader.loader.load(toLoad); - - // Update the cloned image with new background image dimensions - const backgroundTexture = img ? getTexture(img) : null; - if ( newImage && backgroundTexture ) { - scene.updateSource({width: backgroundTexture.width, height: backgroundTexture.height}); - } - const d = scene.getDimensions(); - - // Create a container and add a transparent graphic to enforce the size - const baseContainer = new PIXI.Container(); - const sceneRectangle = new PIXI.Rectangle(0, 0, d.sceneWidth, d.sceneHeight); - const baseGraphics = baseContainer.addChild(new PIXI.LegacyGraphics()); - baseGraphics.beginFill(0xFFFFFF, 1.0).drawShape(sceneRectangle).endFill(); - baseGraphics.zIndex = -1; - baseContainer.mask = baseGraphics; - baseContainer.sortableChildren = true; - - // Simulate the way a TileMesh is drawn - const drawTile = async tile => { - const tex = getTexture(tile.texture.src); - if ( !tex ) return; - const s = new PIXI.Sprite(tex); - const {x, y, rotation, width, height} = tile; - const {scaleX, scaleY, tint} = tile.texture; - s.anchor.set(0.5, 0.5); - s.width = Math.abs(width); - s.height = Math.abs(height); - s.scale.x *= scaleX; - s.scale.y *= scaleY; - s.tint = Color.from(tint ?? 0xFFFFFF); - s.position.set(x + (width/2) - d.sceneRect.x, y + (height/2) - d.sceneRect.y); - s.angle = rotation; - s.zIndex = tile.elevation; - return s; - }; - - // Background container - if ( backgroundTexture ) { - const bg = new PIXI.Sprite(backgroundTexture); - bg.width = d.sceneWidth; - bg.height = d.sceneHeight; - bg.zIndex = 0; - baseContainer.addChild(bg); - } - - // Foreground container - if ( this.foreground ) { - const fgTex = getTexture(this.foreground); - const fg = new PIXI.Sprite(fgTex); - fg.width = d.sceneWidth; - fg.height = d.sceneHeight; - fg.zIndex = scene.foregroundElevation; - baseContainer.addChild(fg); - } - - // Tiles - for ( let t of tiles ) { - const sprite = await drawTile(t); - if ( sprite ) baseContainer.addChild(sprite); - } - - // Render the container to a thumbnail - const stage = new PIXI.Container(); - stage.addChild(baseContainer); - return ImageHelper.createThumbnail(stage, {width, height, format, quality}); - } - - /* -------------------------------------------- */ - /* Deprecations and Compatibility */ - /* -------------------------------------------- */ - - /** - * @deprecated since v10 - * @ignore - */ - static getDimensions(data) { - throw new Error("The Scene.getDimensions static method is deprecated in favor of the Scene#getDimensions " - + "instance method"); - } -} - -/** - * The client-side Setting document which extends the common BaseSetting model. - * @extends documents.BaseSetting - * @mixes ClientDocumentMixin - * - * @see {@link WorldSettings} The world-level collection of Setting documents - */ -class Setting extends ClientDocumentMixin(foundry.documents.BaseSetting) { - - /** @override */ - _onCreate(data, options, userId) { - super._onCreate(data, options, userId); - const config = game.settings.settings.get(this.key); - if ( config.onChange instanceof Function ) config.onChange(this.value, options, userId); - } - - /* -------------------------------------------- */ - - /** @override */ - _onUpdate(changed, options, userId) { - super._onUpdate(changed, options, userId); - const config = game.settings.settings.get(this.key); - if ( config.onChange instanceof Function ) config.onChange(this.value, options, userId); - } -} - -/** - * The client-side TableResult document which extends the common BaseTableResult document model. - * @extends documents.BaseTableResult - * @mixes ClientDocumentMixin - * - * @see {@link RollTable} The RollTable document type which contains TableResult documents - */ -class TableResult extends ClientDocumentMixin(foundry.documents.BaseTableResult) { - - /** - * A path reference to the icon image used to represent this result - */ - get icon() { - return this.img || CONFIG.RollTable.resultIcon; - } - - /** - * Prepare a string representation for the result which (if possible) will be a dynamic link or otherwise plain text - * @returns {string} The text to display - */ - getChatText() { - switch (this.type) { - case CONST.TABLE_RESULT_TYPES.DOCUMENT: - return `@${this.documentCollection}[${this.documentId}]{${this.text}}`; - case CONST.TABLE_RESULT_TYPES.COMPENDIUM: - return `@Compendium[${this.documentCollection}.${this.documentId}]{${this.text}}`; - default: - return this.text; - } - } -} - -/** - * @typedef {Object} RollTableDraw An object containing the executed Roll and the produced results - * @property {Roll} roll The Dice roll which generated the draw - * @property {TableResult[]} results An array of drawn TableResult documents - */ - -/** - * The client-side RollTable document which extends the common BaseRollTable model. - * @extends documents.BaseRollTable - * @mixes ClientDocumentMixin - * - * @see {@link RollTables} The world-level collection of RollTable documents - * @see {@link TableResult} The embedded TableResult document - * @see {@link RollTableConfig} The RollTable configuration application - */ -class RollTable extends ClientDocumentMixin(foundry.documents.BaseRollTable) { - - /** - * Provide a thumbnail image path used to represent this document. - * @type {string} - */ - get thumbnail() { - return this.img; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Display a result drawn from a RollTable in the Chat Log along. - * Optionally also display the Roll which produced the result and configure aspects of the displayed messages. - * - * @param {TableResult[]} results An Array of one or more TableResult Documents which were drawn and should - * be displayed. - * @param {object} [options={}] Additional options which modify message creation - * @param {Roll} [options.roll] An optional Roll instance which produced the drawn results - * @param {Object} [options.messageData={}] Additional data which customizes the created messages - * @param {Object} [options.messageOptions={}] Additional options which customize the created messages - */ - async toMessage(results, {roll=null, messageData={}, messageOptions={}}={}) { - const speaker = ChatMessage.getSpeaker(); - - // Construct chat data - const flavorKey = `TABLE.DrawFlavor${results.length > 1 ? "Plural" : ""}`; - messageData = foundry.utils.mergeObject({ - flavor: game.i18n.format(flavorKey, {number: results.length, name: this.name}), - user: game.user.id, - speaker: speaker, - type: roll ? CONST.CHAT_MESSAGE_TYPES.ROLL : CONST.CHAT_MESSAGE_TYPES.OTHER, - roll: roll, - sound: roll ? CONFIG.sounds.dice : null, - flags: {"core.RollTable": this.id} - }, messageData); - - // Render the chat card which combines the dice roll with the drawn results - messageData.content = await renderTemplate(CONFIG.RollTable.resultTemplate, { - description: await TextEditor.enrichHTML(this.description, {documents: true, async: true}), - results: results.map(result => { - const r = result.toObject(false); - r.text = result.getChatText(); - r.icon = result.icon; - return r; - }), - rollHTML: this.displayRoll && roll ? await roll.render() : null, - table: this - }); - - // Create the chat message - return ChatMessage.create(messageData, messageOptions); - } - - /* -------------------------------------------- */ - - /** - * Draw a result from the RollTable based on the table formula or a provided Roll instance - * @param {object} [options={}] Optional arguments which customize the draw behavior - * @param {Roll} [options.roll] An existing Roll instance to use for drawing from the table - * @param {boolean} [options.recursive=true] Allow drawing recursively from inner RollTable results - * @param {TableResult[]} [options.results] One or more table results which have been drawn - * @param {boolean} [options.displayChat=true] Whether to automatically display the results in chat - * @param {string} [options.rollMode] The chat roll mode to use when displaying the result - * @returns {Promise<{RollTableDraw}>} A Promise which resolves to an object containing the executed roll and the - * produced results. - */ - async draw({roll, recursive=true, results=[], displayChat=true, rollMode}={}) { - - // If an array of results were not already provided, obtain them from the standard roll method - if ( !results.length ) { - const r = await this.roll({roll, recursive}); - roll = r.roll; - results = r.results; - } - if ( !results.length ) return { roll, results }; - - // Mark results as drawn, if replacement is not used, and we are not in a Compendium pack - if ( !this.replacement && !this.pack) { - const draws = this.getResultsForRoll(roll.total); - await this.updateEmbeddedDocuments("TableResult", draws.map(r => { - return {_id: r.id, drawn: true}; - })); - } - - // Mark any nested table results as drawn too. - let updates = results.reduce((obj, r) => { - const parent = r.parent; - if ( (parent === this) || parent.replacement || parent.pack ) return obj; - if ( !obj[parent.id] ) obj[parent.id] = []; - obj[parent.id].push({_id: r.id, drawn: true}); - return obj; - }, {}); - - if ( Object.keys(updates).length ) { - updates = Object.entries(updates).map(([id, results]) => { - return {_id: id, results}; - }); - await RollTable.implementation.updateDocuments(updates); - } - - // Forward drawn results to create chat messages - if ( displayChat ) { - await this.toMessage(results, { - roll: roll, - messageOptions: {rollMode} - }); - } - - // Return the roll and the produced results - return {roll, results}; - } - - /* -------------------------------------------- */ - - /** - * Draw multiple results from a RollTable, constructing a final synthetic Roll as a dice pool of inner rolls. - * @param {number} number The number of results to draw - * @param {object} [options={}] Optional arguments which customize the draw - * @param {Roll} [options.roll] An optional pre-configured Roll instance which defines the dice - * roll to use - * @param {boolean} [options.recursive=true] Allow drawing recursively from inner RollTable results - * @param {boolean} [options.displayChat=true] Automatically display the drawn results in chat? Default is true - * @param {string} [options.rollMode] Customize the roll mode used to display the drawn results - * @returns {Promise<{RollTableDraw}>} The drawn results - */ - async drawMany(number, {roll=null, recursive=true, displayChat=true, rollMode}={}) { - let results = []; - let updates = []; - const rolls = []; - - // Roll the requested number of times, marking results as drawn - for ( let n=0; n { - r.drawn = true; - return {_id: r.id, drawn: true}; - })); - } - } - - // Construct a Roll object using the constructed pool - const pool = PoolTerm.fromRolls(rolls); - roll = Roll.defaultImplementation.fromTerms([pool]); - - // Commit updates to child results - if ( updates.length ) { - await this.updateEmbeddedDocuments("TableResult", updates, {diff: false}); - } - - // Forward drawn results to create chat messages - if ( displayChat && results.length ) { - await this.toMessage(results, { - roll: roll, - messageOptions: {rollMode} - }); - } - - // Return the Roll and the array of results - return {roll, results}; - } - - /* -------------------------------------------- */ - - /** - * Normalize the probabilities of rolling each item in the RollTable based on their assigned weights - * @returns {Promise} - */ - async normalize() { - let totalWeight = 0; - let counter = 1; - const updates = []; - for ( let result of this.results ) { - const w = result.weight; - totalWeight += w; - updates.push({_id: result.id, range: [counter, counter + w - 1]}); - counter = counter + w; - } - return this.update({results: updates, formula: `1d${totalWeight}`}); - } - - /* -------------------------------------------- */ - - /** - * Reset the state of the RollTable to return any drawn items to the table - * @returns {Promise} - */ - async resetResults() { - const updates = this.results.map(result => ({_id: result.id, drawn: false})); - return this.updateEmbeddedDocuments("TableResult", updates, {diff: false}); - } - - /* -------------------------------------------- */ - - /** - * Evaluate a RollTable by rolling its formula and retrieving a drawn result. - * - * Note that this function only performs the roll and identifies the result, the RollTable#draw function should be - * called to formalize the draw from the table. - * - * @param {object} [options={}] Options which modify rolling behavior - * @param {Roll} [options.roll] An alternative dice Roll to use instead of the default table formula - * @param {boolean} [options.recursive=true] If a RollTable document is drawn as a result, recursively roll it - * @param {number} [options._depth] An internal flag used to track recursion depth - * @returns {Promise} The Roll and results drawn by that Roll - * - * @example Draw results using the default table formula - * ```js - * const defaultResults = await table.roll(); - * ``` - * - * @example Draw results using a custom roll formula - * ```js - * const roll = new Roll("1d20 + @abilities.wis.mod", actor.getRollData()); - * const customResults = await table.roll({roll}); - * ``` - */ - async roll({roll, recursive=true, _depth=0}={}) { - - // Prevent excessive recursion - if ( _depth > 5 ) { - throw new Error(`Maximum recursion depth exceeded when attempting to draw from RollTable ${this.id}`); - } - - // Reference the provided roll formula - roll = roll instanceof Roll ? roll : Roll.create(this.formula); - let results = []; - - // Ensure that at least one non-drawn result remains - const available = this.results.filter(r => !r.drawn); - if ( !this.formula || !available.length ) { - ui.notifications.warn("There are no available results which can be drawn from this table."); - return {roll, results}; - } - - // Ensure that results are available within the minimum/maximum range - const minRoll = (await roll.reroll({minimize: true, async: true})).total; - const maxRoll = (await roll.reroll({maximize: true, async: true})).total; - const availableRange = available.reduce((range, result) => { - const r = result.range; - if ( !range[0] || (r[0] < range[0]) ) range[0] = r[0]; - if ( !range[1] || (r[1] > range[1]) ) range[1] = r[1]; - return range; - }, [null, null]); - if ( (availableRange[0] > maxRoll) || (availableRange[1] < minRoll) ) { - ui.notifications.warn("No results can possibly be drawn from this table and formula."); - return {roll, results}; - } - - // Continue rolling until one or more results are recovered - let iter = 0; - while ( !results.length ) { - if ( iter >= 10000 ) { - ui.notifications.error(`Failed to draw an available entry from Table ${this.name}, maximum iteration reached`); - break; - } - roll = await roll.reroll({async: true}); - results = this.getResultsForRoll(roll.total); - iter++; - } - - // Draw results recursively from any inner Roll Tables - if ( recursive ) { - let inner = []; - for ( let result of results ) { - let pack; - let documentName; - if ( result.type === CONST.TABLE_RESULT_TYPES.DOCUMENT ) documentName = result.documentCollection; - else if ( result.type === CONST.TABLE_RESULT_TYPES.COMPENDIUM ) { - pack = game.packs.get(result.documentCollection); - documentName = pack?.documentName; - } - if ( documentName === "RollTable" ) { - const id = result.documentId; - const innerTable = pack ? await pack.getDocument(id) : game.tables.get(id); - if (innerTable) { - const innerRoll = await innerTable.roll({_depth: _depth + 1}); - inner = inner.concat(innerRoll.results); - } - } - else inner.push(result); - } - results = inner; - } - - // Return the Roll and the results - return { roll, results }; - } - - /* -------------------------------------------- */ - - /** - * Get an Array of valid results for a given rolled total - * @param {number} value The rolled value - * @returns {TableResult[]} An Array of results - */ - getResultsForRoll(value) { - return this.results.filter(r => !r.drawn && Number.between(value, ...r.range)); - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onCreateEmbeddedDocuments(embeddedName, documents, result, options, userId) { - super._onCreateEmbeddedDocuments(embeddedName, documents, result, options, userId); - this.collection.render(); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDeleteEmbeddedDocuments(embeddedName, documents, result, options, userId) { - super._onDeleteEmbeddedDocuments(embeddedName, documents, result, options, userId); - this.collection.render(); - } - - /* -------------------------------------------- */ - /* Importing and Exporting */ - /* -------------------------------------------- */ - - /** @override */ - toCompendium(pack, options={}) { - const data = super.toCompendium(pack, options); - if ( options.clearState ) { - for ( let r of data.results ) { - r.drawn = false; - } - } - return data; - } - - /* -------------------------------------------- */ - - /** - * Create a new RollTable document using all of the Documents from a specific Folder as new results. - * @param {Folder} folder The Folder document from which to create a roll table - * @param {object} options Additional options passed to the RollTable.create method - * @returns {Promise} - */ - static async fromFolder(folder, options={}) { - const results = folder.contents.map((e, i) => { - return { - text: e.name, - type: CONST.TABLE_RESULT_TYPES.DOCUMENT, - collection: folder.type, - resultId: e.id, - img: e.thumbnail || e.img, - weight: 1, - range: [i+1, i+1], - drawn: false - }; - }); - options.renderSheet = options.renderSheet ?? true; - return this.create({ - name: folder.name, - description: `A random table created from the contents of the ${folder.name} Folder.`, - results: results, - formula: `1d${results.length}` - }, options); - } -} - -/** - * The client-side Tile document which extends the common BaseTile document model. - * @extends documents.BaseTile - * @mixes ClientDocumentMixin - * - * @see {@link Scene} The Scene document type which contains Tile documents - * @see {@link TileConfig} The Tile configuration application - */ -class TileDocument extends CanvasDocumentMixin(foundry.documents.BaseTile) { - - /** - * Define an elevation property on the Tile Document which in the future will become a core part of its data schema. - * @type {number} - */ - get elevation() { - return this.#elevation ??= this.overhead ? this.parent.foregroundElevation : 0; - } - - set elevation(value) { - if ( !Number.isFinite(value) ) throw new Error("Elevation must be a finite Number"); - this.#elevation = value; - if ( this.rendered ) { - canvas.primary.sortChildren(); - canvas.perception.update({refreshTiles: true}, true); - } - } - - #elevation; - - /* -------------------------------------------- */ - - /** - * Define a sort property on the Tile Document which in the future will become a core part of its data schema. - * @type {number} - */ - get sort() { - return this.z; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - prepareDerivedData() { - super.prepareDerivedData(); - const d = this.parent?.dimensions; - if ( !d ) return; - const securityBuffer = Math.max(d.size / 5, 20).toNearest(0.1); - const maxX = d.width - securityBuffer; - const maxY = d.height - securityBuffer; - const minX = (this.width - securityBuffer) * -1; - const minY = (this.height - securityBuffer) * -1; - this.x = Math.clamped(this.x.toNearest(0.1), minX, maxX); - this.y = Math.clamped(this.y.toNearest(0.1), minY, maxY); - } - - /* -------------------------------------------- */ - - /** @inheritDoc */ - _onUpdate(changed, options, user) { - super._onUpdate(changed, options, user); - if ( "overhead" in changed ) { - this.#elevation = this.overhead ? this.parent.foregroundElevation : 0; - } - } -} - -/** - * The client-side Token document which extends the common BaseToken document model. - * @extends documents.BaseToken - * @mixes ClientDocumentMixin - * - * @see {@link Scene} The Scene document type which contains Token documents - * @see {@link TokenConfig} The Token configuration application - */ -class TokenDocument extends CanvasDocumentMixin(foundry.documents.BaseToken) { - constructor(data, context={}) { - super(data, context); - - /** - * A cached reference to the Actor document that this Token modifies. - * This may be a "synthetic" unlinked Token Actor which does not exist in the World. - * @type {Actor|null} - */ - this._actor = context.actor || null; - } - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * A lazily evaluated reference to the Actor this Token modifies. - * If actorLink is true, then the document is the primary Actor document. - * Otherwise, the Actor document is a synthetic (ephemeral) document constructed using the Token's actorData. - * @returns {Actor|null} - */ - get actor() { - if ( !this._actor ) this._actor = this.getActor(); - return this._actor; - } - - /* -------------------------------------------- */ - - /** - * An indicator for whether the current User has full control over this Token document. - * @type {boolean} - */ - get isOwner() { - if ( game.user.isGM ) return true; - return this.actor?.isOwner ?? false; - } - - /* -------------------------------------------- */ - - /** - * A convenient reference for whether this TokenDocument is linked to the Actor it represents, or is a synthetic copy - * @type {boolean} - */ - get isLinked() { - return this.actorLink; - } - - /* -------------------------------------------- */ - - /** - * Return a reference to a Combatant that represents this Token, if one is present in the current encounter. - * @type {Combatant|null} - */ - get combatant() { - return game.combat?.getCombatantByToken(this.id) || null; - } - - /* -------------------------------------------- */ - - /** - * An indicator for whether this Token is currently involved in the active combat encounter. - * @type {boolean} - */ - get inCombat() { - return !!this.combatant; - } - - /* -------------------------------------------- */ - - /** - * Define a sort order for this TokenDocument. - * This controls its rendering order in the PrimaryCanvasGroup relative to siblings at the same elevation. - * In the future this will be replaced with a persisted database field for permanent adjustment of token stacking. - * In case of ties, Tokens will be sorted above other types of objects. - * @type {number} - */ - get sort() { - return this.#sort; - } - - set sort(value) { - if ( !Number.isFinite(value) ) throw new Error("TokenDocument sort must be a finite Number"); - this.#sort = value; - if ( this.rendered ) { - canvas.primary.sortChildren(); - canvas.tokens.objects.sortChildren(); - } - } - - #sort = 0; - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - prepareBaseData() { - this.name ||= this.actor?.name || "Unknown"; - if ( this.hidden ) this.alpha = Math.min(this.alpha, 0.5); - this._prepareDetectionModes(); - } - - /* -------------------------------------------- */ - - /** - * Prepare detection modes which are available to the Token. - * Ensure that every Token has the basic sight detection mode configured. - * @protected - */ - _prepareDetectionModes() { - if ( !this.sight.enabled ) return; - const basicId = DetectionMode.BASIC_MODE_ID; - const basicMode = this.detectionModes.find(m => m.id === basicId); - if ( !basicMode ) this.detectionModes.push({id: basicId, enabled: true, range: this.sight.range}); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - clone(data={}, options={}) { - const cloned = super.clone(data, options); - cloned._actor = this._actor; - return cloned; - } - - /* -------------------------------------------- */ - - /** - * Create a synthetic Actor using a provided Token instance - * If the Token data is linked, return the true Actor document - * If the Token data is not linked, create a synthetic Actor using the Token's actorData override - * @returns {Actor} - */ - getActor() { - const baseActor = game.actors.get(this.actorId); - if ( !baseActor ) return null; - if ( !this.id || this.isLinked ) return baseActor; - - // Get base actor data - const cls = getDocumentClass("Actor"); - const actorData = baseActor.toObject(); - - // Clean and validate the override data - const overrides = cls.schema.clean(this.actorData, {partial: true}); - const error = cls.schema.validate(this.actorData, {partial: true}); - if ( !error ) foundry.utils.mergeObject(actorData, overrides); - - // Create a synthetic token Actor - const actor = new cls(actorData, {parent: this}); - actor.reset(); // FIXME why is this necessary? - return actor; - } - - /* -------------------------------------------- */ - - /** - * A helper method to retrieve the underlying data behind one of the Token's attribute bars - * @param {string} barName The named bar to retrieve the attribute for - * @param {string} alternative An alternative attribute path to get instead of the default one - * @returns {object|null} The attribute displayed on the Token bar, if any - */ - getBarAttribute(barName, {alternative}={}) { - const attr = alternative || this[barName]?.attribute; - if ( !attr || !this.actor ) return null; - let data = foundry.utils.getProperty(this.actor.system, attr); - if ( (data === null) || (data === undefined) ) return null; - const model = game.model.Actor[this.actor.type]; - - // Single values - if ( Number.isNumeric(data) ) { - return { - type: "value", - attribute: attr, - value: Number(data), - editable: foundry.utils.hasProperty(model, attr) - }; - } - - // Attribute objects - else if ( ("value" in data) && ("max" in data) ) { - return { - type: "bar", - attribute: attr, - value: parseInt(data.value || 0), - max: parseInt(data.max || 0), - editable: foundry.utils.hasProperty(model, `${attr}.value`) - }; - } - - // Otherwise null - return null; - } - - /* -------------------------------------------- */ - - /** - * A helper function to toggle a status effect which includes an Active Effect template - * @param {{id: string, label: string, icon: string}} effectData The Active Effect data, including statusId - * @param {object} [options] Options to configure application of the Active Effect - * @param {boolean} [options.overlay=false] Should the Active Effect icon be displayed as an - * overlay on the token? - * @param {boolean} [options.active] Force a certain active state for the effect. - * @returns {Promise} Whether the Active Effect is now on or off - */ - async toggleActiveEffect(effectData, {overlay=false, active}={}) { - if ( !this.actor || !effectData.id ) return false; - - // Remove an existing effect - const existing = this.actor.effects.find(e => e.getFlag("core", "statusId") === effectData.id); - const state = active ?? !existing; - if ( !state && existing ) await existing.delete(); - - // Add a new effect - else if ( state ) { - const createData = foundry.utils.deepClone(effectData); - createData.label = game.i18n.localize(effectData.label); - createData["flags.core.statusId"] = effectData.id; - if ( overlay ) createData["flags.core.overlay"] = true; - delete createData.id; - const cls = getDocumentClass("ActiveEffect"); - await cls.create(createData, {parent: this.actor}); - } - return state; - } - - /* -------------------------------------------- */ - - /** - * Test whether a Token has a specific status effect. - * @param {string} statusId The status effect ID as defined in CONFIG.statusEffects - * @returns {boolean} Does the Token have this status effect? - */ - hasStatusEffect(statusId) { - - // Case 1 - No Actor - if ( !this.actor ) { - const icon = CONFIG.statusEffects.find(e => e.id === statusId)?.icon; - if ( this.effects.includes(icon) ) return true; - } - - // Case 2 - Actor Active Effects - else { - const activeEffect = this.actor.effects.find(effect => effect.getFlag("core", "statusId") === statusId); - if ( activeEffect && !activeEffect.disabled ) return true; - } - return false; - } - - /* -------------------------------------------- */ - /* Actor Data Operations */ - /* -------------------------------------------- */ - - /** - * Convenience method to change a token vision mode. - * @param {string} visionMode The vision mode to apply to this token. - * @param {boolean} [defaults=true] If the vision mode should be updated with its defaults. - * @returns {Promise<*>} - */ - async updateVisionMode(visionMode, defaults=true) { - if ( !(visionMode in CONFIG.Canvas.visionModes) ) { - throw new Error("The provided vision mode does not exist in CONFIG.Canvas.visionModes"); - } - let update = {sight: {visionMode: visionMode}}; - if ( defaults ) foundry.utils.mergeObject(update.sight, CONFIG.Canvas.visionModes[visionMode].vision.defaults); - return this.update(update); - } - - /* -------------------------------------------- */ - - /** - * Redirect updates to a synthetic Token Actor to instead update the tokenData override object. - * Once an attribute in the Token has been overridden, it must always remain overridden. - * - * @param {object} update The provided differential update data which should update the Token Actor - * @param {object} options Provided options which modify the update request - * @returns {Promise} The updated un-linked Actor instance - */ - async modifyActorDocument(update, options) { - delete update._id; - update = this.actor.constructor.migrateData(foundry.utils.expandObject(update)); - const delta = foundry.utils.diffObject(this.actor.toObject(), update); - await this.update({actorData: delta}, options); - return [this.actor]; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - getEmbeddedCollection(embeddedName) { - if ( this.isLinked ) return super.getEmbeddedCollection(embeddedName); - switch ( embeddedName ) { - case "Item": - return this.actor.items; - case "ActiveEffect": - return this.actor.effects; - } - } - - /* -------------------------------------------- */ - - /** - * Redirect creation of Documents within a synthetic Token Actor to instead update the tokenData override object. - * @param {string} embeddedName The named embedded Document type being modified - * @param {object[]} data The provided initial data with which to create the embedded Documents - * @param {object} options Provided options which modify the creation request - * @returns {Promise} The created Embedded Document instances - */ - async createActorEmbeddedDocuments(embeddedName, data, options) { - - // Get the current embedded collection data - const cls = getDocumentClass(embeddedName); - const collection = this.actor.getEmbeddedCollection(embeddedName); - const collectionData = collection.toObject(); - - // Apply proposed creations to the collection data - const hookData = []; // An array of created data - for ( let d of data ) { - if ( d instanceof foundry.abstract.DataModel ) d = d.toObject(); - d = foundry.utils.expandObject(d); - if ( !d._id || !options.keepId ) d._id = foundry.utils.randomID(16); - collectionData.push(d); - hookData.push(d); - } - - // Perform a TokenDocument update, replacing the entire embedded collection in actorData - options.action = "create"; - options.embedded = {embeddedName, hookData}; - await this.update({ - actorData: { - [cls.metadata.collection]: collectionData - } - }, options); - return hookData.map(d => this.actor.getEmbeddedDocument(embeddedName, d._id)); - } - - /* -------------------------------------------- */ - - /** - * Redirect updating of Documents within a synthetic Token Actor to instead update the tokenData override object. - * @param {string} embeddedName The named embedded Document type being modified - * @param {object[]} updates The provided differential data with which to update the embedded Documents - * @param {object} options Provided options which modify the update request - * @returns {Promise} The updated Embedded Document instances - */ - async updateActorEmbeddedDocuments(embeddedName, updates, options) { - - // Get the current embedded collection data - const cls = getDocumentClass(embeddedName); - const collection = this.actor.getEmbeddedCollection(embeddedName); - const collectionData = collection.toObject(); - - // Apply proposed updates to the collection data - const hookData = {}; // A mapping of changes - for ( let update of updates ) { - const current = collectionData.find(x => x._id === update._id); - if ( !current ) continue; - if ( options.diff ) { - update = foundry.utils.diffObject(current, foundry.utils.expandObject(update), {deletionKeys: true}); - if ( foundry.utils.isEmpty(update) ) continue; - update._id = current._id; - } - hookData[update._id] = update; - foundry.utils.mergeObject(current, update, {performDeletions: true}); - } - - // Perform a TokenDocument update, replacing the entire embedded collection in actorData - if ( !Object.values(hookData).length ) return []; - options.action = "update"; - options.embedded = {embeddedName, hookData}; - await this.update({ - actorData: { - [cls.metadata.collection]: collectionData - } - }, options); - return Object.keys(hookData).map(id => this.actor.getEmbeddedDocument(embeddedName, id)); - } - - /* -------------------------------------------- */ - - /** - * Redirect deletion of Documents within a synthetic Token Actor to instead update the tokenData override object. - * @param {string} embeddedName The named embedded Document type being deleted - * @param {string[]} ids The IDs of Documents to delete - * @param {object} options Provided options which modify the deletion request - * @returns {Promise} The deleted Embedded Document instances - */ - async deleteActorEmbeddedDocuments(embeddedName, ids, options) { - const cls = getDocumentClass(embeddedName); - const collection = this.actor.getEmbeddedCollection(embeddedName); - - // Remove proposed deletions from the collection - const collectionData = collection.toObject(); - const deleted = []; - const hookData = []; // An array of deleted ids - for ( let id of ids ) { - const doc = collection.get(id); - if ( !doc ) continue; - deleted.push(doc); - hookData.push(id); - collectionData.findSplice(d => d._id === id); - } - - // Perform a TokenDocument update, replacing the entire embedded collection in actorData - options.action = "delete"; - options.embedded = {embeddedName, hookData}; - await this.update({ - actorData: { - [cls.metadata.collection]: collectionData - } - }, options); - return deleted; - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _preUpdate(data, options, user) { - await super._preUpdate(data, options, user); - if ( "width" in data ) data.width = Math.max((data.width || 1).toNearest(0.5), 0.5); - if ( "height" in data ) data.height = Math.max((data.height || 1).toNearest(0.5), 0.5); - if ( ("actorData" in data) && !this.isLinked ) { - await this._preUpdateTokenActor(data.actorData, options, user); - } - } - - /* -------------------------------------------- */ - - /** - * When the Actor data overrides change for an un-linked Token Actor, simulate the pre-update process. - * @param {object} data - * @param {object} options - * @param {User} user - * @returns {Promise} - * @private - */ - async _preUpdateTokenActor(data, options, user) { - const embeddedKeys = new Set(["_id"]); - - // Simulate modification of embedded documents - if ( options.embedded ) { - const {embeddedName, hookData} = options.embedded; - const cls = getDocumentClass(embeddedName); - const documents = data[cls.metadata.collection]; - embeddedKeys.add(cls.metadata.collection); - const result = []; - - // Handle different embedded operations - switch (options.action) { - case "create": - for ( const createData of hookData ) { - const original = foundry.utils.deepClone(createData); - const doc = new cls(createData, {parent: this.actor}); - await doc._preCreate(createData, options, user); - const allowed = options.noHook || Hooks.call(`preCreate${embeddedName}`, doc, original, options, user.id); - if ( allowed === false ) { - documents.findSplice(toCreate => toCreate._id === createData._id); - hookData.findSplice(toCreate => toCreate._id === createData._id); - console.debug(`${vtt} | ${embeddedName} creation prevented by preCreate hook`); - } else { - const d = data[doc.collectionName].find(d => d._id === doc.id); - foundry.utils.mergeObject(d, createData, {performDeletions: true}); - result.push(d); - } - } - this.actor._preCreateEmbeddedDocuments(embeddedName, result, options, user.id); - break; - - case "update": - for ( const [i, d] of documents.entries() ) { - const update = hookData[d._id]; - if ( !update ) continue; - const doc = this.actor.getEmbeddedDocument(embeddedName, d._id); - await doc._preUpdate(update, options, user); - const allowed = options.noHook || Hooks.call(`preUpdate${embeddedName}`, doc, update, options, user.id); - if ( allowed === false ) { - documents[i] = doc.toObject(); - delete hookData[doc.id]; - console.debug(`${vtt} | ${embeddedName} update prevented by preUpdate hook`); - } - else { - const d = data[doc.collectionName].find(d => d._id === doc.id); - // Re-apply update data which may have changed in a preUpdate hook - foundry.utils.mergeObject(d, update, {performDeletions: true}); - result.push(update); - } - } - this.actor._preUpdateEmbeddedDocuments(embeddedName, result, options, user.id); - break; - - case "delete": - for ( const id of hookData ) { - const doc = this.actor.getEmbeddedDocument(embeddedName, id); - await doc._preDelete(options, user); - const allowed = options.noHook || Hooks.call(`preDelete${embeddedName}`, doc, options, user.id); - if ( allowed === false ) { - documents.push(doc.toObject()); - hookData.findSplice(toDelete => toDelete === doc.id); - console.debug(`${vtt} | ${embeddedName} deletion prevented by preDelete hook`); - } - else result.push(id); - } - this.actor._preDeleteEmbeddedDocuments(embeddedName, result, options, user.id); - break; - } - } - - // Simulate updates to the Actor itself - if ( Object.keys(data).some(k => !embeddedKeys.has(k)) ) { - await this.actor._preUpdate(data, options, user); - Hooks.callAll("preUpdateActor", this.actor, data, options, user.id); - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onUpdate(data, options, userId) { - // Update references to original state so that resetting the preview does not clobber these updates in-memory. - if ( !options.preview ) Object.values(this.apps).forEach(app => app.original = this.toObject()); - - // If the Actor association has changed, expire the cached Token actor - if ( ("actorId" in data) || ("actorLink" in data) ) { - if ( this._actor ) Object.values(this._actor.apps).forEach(app => app.close({submit: false})); - this._actor = null; - } - - // If the Actor data override changed, simulate updating the synthetic Actor - if ( ("actorData" in data) && !this.isLinked ) { - this._onUpdateTokenActor(data.actorData, options, userId); - } - - // Post-update the Token itself - return super._onUpdate(data, options, userId); - } - - /* -------------------------------------------- */ - - /** - * When the base Actor for a TokenDocument changes, we may need to update its Actor instance - * @param {object} update - * @param {object} options - * @private - */ - _onUpdateBaseActor(update={}, options={}) { - - // Update synthetic Actor data - if ( !this.isLinked ) { - update = foundry.utils.mergeObject(update, this.actorData, { - insertKeys: false, - insertValues: false, - inplace: false - }); - this.actor.updateSource(update, options); - this.actor.sheet.render(false); - } - - // Update tracked Combat resource - const c = this.combatant; - if ( c && foundry.utils.hasProperty(update.system || {}, game.combat.settings.resource) ) { - c.updateResource(); - ui.combat.render(); - } - - // Trigger redraws on the token - if ( this.parent.isView ) { - this.object.drawBars(); - if ( "effects" in update ) this.object.drawEffects(); - } - } - - /* -------------------------------------------- */ - - /** - * When the Actor data overrides change for an un-linked Token Actor, simulate the post-update process. - * @param {object} data - * @param {object} options - * @param {string} userId - * @private - */ - _onUpdateTokenActor(data, options, userId) { - const embeddedKeys = new Set(["_id"]); - if ( this.isLinked ) return; // Don't do this for linked tokens - - // Obtain references to any embedded documents which will be deleted - let deletedDocuments = []; - if ( options.embedded && (options.action === "delete") ) { - const {embeddedName, hookData} = options.embedded; - const collection = this.actor.getEmbeddedCollection(embeddedName); - deletedDocuments = hookData.map(id => collection.get(id)); - } - - // Embedded collections can be updated directly - if ( options.embedded ) { - this.actor.updateSource(data, {recursive: false}); - } - - // Otherwise, handle non-embedded updates - else { - const embeddedUpdates = {}; - for ( const k of Object.keys(data) ) { - const field = this.actor.schema.get(k); - if ( field instanceof foundry.data.fields.EmbeddedCollectionField ) { - embeddedUpdates[k] = this.actorData[k]; - delete data[k]; - } - } - if ( !foundry.utils.isEmpty(embeddedUpdates ) ) this.actor.updateSource(embeddedUpdates, {recursive: false}); - if ( !foundry.utils.isEmpty(data) ) this.actor.updateSource(data, {recursive: true}); - } - - // Simulate modification of embedded documents - if ( options.embedded ) { - const {embeddedName, hookData} = options.embedded; - const collectionName = Actor.metadata.embedded[embeddedName]; - const changes = data[collectionName]; - const collection = this.actor.getEmbeddedCollection(embeddedName); - embeddedKeys.add(collectionName); - const result = []; - - switch (options.action) { - case "create": - const created = []; - for ( const d of hookData ) { - result.push(d); - const doc = collection.get(d._id); - if ( !doc ) continue; - created.push(doc); - doc._onCreate(d, options, userId); - Hooks.callAll(`create${embeddedName}`, doc, options, userId); - } - this.actor._onCreateEmbeddedDocuments(embeddedName, created, result, options, userId); - break; - - case "update": - const documents = []; - for ( let d of changes ) { - const update = hookData[d._id]; - if ( !update ) continue; - result.push(update); - const doc = collection.get(d._id); - documents.push(doc); - doc._onUpdate(update, options, userId); - Hooks.callAll(`update${embeddedName}`, doc, update, options, userId); - } - this.actor._onUpdateEmbeddedDocuments(embeddedName, documents, result, options, userId); - break; - - case "delete": - for ( let doc of deletedDocuments ) { - doc._onDelete(options, userId); - Hooks.callAll(`delete${embeddedName}`, doc, options, userId); - } - this.actor._onDeleteEmbeddedDocuments(embeddedName, deletedDocuments, hookData, options, userId); - break; - } - } - - // Update tracked Combat resource - const c = this.combatant; - if ( c && foundry.utils.hasProperty(data.system || {}, game.combat.settings.resource) ) { - c.updateResource(); - ui.combat.render(); - } - - // Simulate updates to the Actor itself - if ( Object.keys(data).some(k => !embeddedKeys.has(k)) ) { - this.actor._onUpdate(data, options, userId); - Hooks.callAll("updateActor", this.actor, data, options, userId); - } - } - - /* -------------------------------------------- */ - - /** - * Get an Array of attribute choices which could be tracked for Actors in the Combat Tracker - * @param {object} data - * @param {string[]} _path - * @returns {object} - */ - static getTrackedAttributes(data, _path=[]) { - if ( !data ) { - data = {}; - for ( let model of Object.values(game.model.Actor) ) { - foundry.utils.mergeObject(data, model); - } - } - - // Track the path and record found attributes - const attributes = {bar: [], value: []}; - - // Recursively explore the object - for ( let [k, v] of Object.entries(data) ) { - let p = _path.concat([k]); - - // Check objects for both a "value" and a "max" - if ( v instanceof Object ) { - if ( k === "_source" ) continue; - const isBar = ("value" in v) && ("max" in v); - if ( isBar ) attributes.bar.push(p); - else { - const inner = this.getTrackedAttributes(data[k], p); - attributes.bar.push(...inner.bar); - attributes.value.push(...inner.value); - } - } - - // Otherwise, identify values which are numeric or null - else if ( Number.isNumeric(v) || (v === null) ) { - attributes.value.push(p); - } - } - return attributes; - } - - /* -------------------------------------------- */ - - /** - * Inspect the Actor data model and identify the set of attributes which could be used for a Token Bar - * @param {object} attributes The tracked attributes which can be chosen from - * @returns {object} A nested object of attribute choices to display - */ - static getTrackedAttributeChoices(attributes) { - attributes = attributes || this.getTrackedAttributes(); - attributes.bar = attributes.bar.map(v => v.join(".")); - attributes.bar.sort((a, b) => a.localeCompare(b)); - attributes.value = attributes.value.map(v => v.join(".")); - attributes.value.sort((a, b) => a.localeCompare(b)); - return { - [game.i18n.localize("TOKEN.BarAttributes")]: attributes.bar, - [game.i18n.localize("TOKEN.BarValues")]: attributes.value - }; - } -} - -/* -------------------------------------------- */ -/* Proxy Prototype Token Methods */ -/* -------------------------------------------- */ - -foundry.data.PrototypeToken.prototype.getBarAttribute = TokenDocument.prototype.getBarAttribute; - -/** - * @deprecated since v10 - * @see data.PrototypeToken - * @ignore - */ -class PrototypeTokenDocument extends foundry.data.PrototypeToken { - constructor(...args) { - foundry.utils.logCompatibilityWarning("You are using the PrototypeTokenDocument class which has been deprecated in" - + " favor of using foundry.data.PrototypeToken directly.", {since: 10, until: 12}); - super(...args); - } -} - -/** - * The client-side User document which extends the common BaseUser model. - * Each User document contains UserData which defines its data schema. - * - * @extends documents.BaseUser - * @mixes ClientDocumentMixin - * - * @see {@link documents.Users} The world-level collection of User documents - * @see {@link applications.UserConfig} The User configuration application - */ -class User extends ClientDocumentMixin(foundry.documents.BaseUser) { - - /** - * Track whether the user is currently active in the game - * @type {boolean} - */ - active = false; - - /** - * Track references to the current set of Tokens which are targeted by the User - * @type {Set} - */ - targets = new UserTargets(this); - - /** - * Track the ID of the Scene that is currently being viewed by the User - * @type {string|null} - */ - viewedScene = null; - - /** - * A flag for whether the current User is a Trusted Player - * @type {boolean} - */ - get isTrusted() { - return this.hasRole("TRUSTED"); - } - - /** - * A flag for whether this User is the connected client - * @type {boolean} - */ - get isSelf() { - return game.userId === this.id; - } - - /* ---------------------------------------- */ - - /** @inheritdoc */ - prepareDerivedData() { - super.prepareDerivedData(); - this.avatar = this.avatar || this.character?.img || CONST.DEFAULT_TOKEN; - const rgb = Color.from(this.color).rgb; - this.border = Color.fromRGB(rgb.map(c => Math.min(c * 2, 1))); - } - - /* ---------------------------------------- */ - /* User Methods */ - /* ---------------------------------------- */ - - /** - * Assign a Macro to a numbered hotbar slot between 1 and 50 - * @param {Macro|null} macro The Macro document to assign - * @param {number|string} [slot] A specific numbered hotbar slot to fill - * @param {number} [fromSlot] An optional origin slot from which the Macro is being shifted - * @returns {Promise} A Promise which resolves once the User update is complete - */ - async assignHotbarMacro(macro, slot, {fromSlot}={}) { - if ( !(macro instanceof Macro) && (macro !== null) ) throw new Error("Invalid Macro provided"); - const hotbar = this.hotbar; - - // If a slot was not provided, get the first available slot - if (Number.isNumeric(slot)) slot = Number(slot); - else { - for ( let i=1; i<=50; i++ ) { - if ( !(i in hotbar ) ) { - slot = i; - break; - } - } - } - if ( !slot ) throw new Error("No available Hotbar slot exists"); - if ( slot < 1 || slot > 50 ) throw new Error("Invalid Hotbar slot requested"); - if ( macro && (hotbar[slot] === macro.id) ) return this; - - // Update the hotbar data - const update = foundry.utils.deepClone(hotbar); - if ( macro ) update[slot] = macro.id; - else delete update[slot]; - if ( Number.isNumeric(fromSlot) && (fromSlot in hotbar) ) delete update[fromSlot]; - return this.update({hotbar: update}, {diff: false, recursive: false, noHook: true}); - } - - /* -------------------------------------------- */ - - /** - * Assign a specific boolean permission to this user. - * Modifies the user permissions to grant or restrict access to a feature. - * - * @param {string} permission The permission name from USER_PERMISSIONS - * @param {boolean} allowed Whether to allow or restrict the permission - */ - assignPermission(permission, allowed) { - if ( !game.user.isGM ) throw new Error(`You are not allowed to modify the permissions of User ${this.id}`); - const permissions = {[permission]: allowed}; - return this.update({permissions}); - } - - /* -------------------------------------------- */ - - /** - * @typedef {object} PingData - * @property {boolean} [pull=false] Pulls all connected clients' views to the pinged co-ordinates. - * @property {string} style The ping style, see CONFIG.Canvas.pings. - * @property {string} scene The ID of the scene that was pinged. - * @property {number} zoom The zoom level at which the ping was made. - */ - - /** - * @typedef {object} ActivityData - * @property {string|null} [sceneId] The ID of the scene that the user is viewing. - * @property {{x: number, y: number}} [cursor] The position of the user's cursor. - * @property {RulerData|null} [ruler] The state of the user's ruler, if they are currently using one. - * @property {string[]} [targets] The IDs of the tokens the user has targeted in the currently viewed - * scene. - * @property {boolean} [active] Whether the user has an open WS connection to the server or not. - * @property {boolean} [focus] Is the user pulling focus to the cursor coordinates? - * @property {PingData} [ping] Is the user emitting a ping at the cursor coordinates? - * @property {AVSettingsData} [av] The state of the user's AV settings. - */ - - /** - * Submit User activity data to the server for broadcast to other players. - * This type of data is transient, persisting only for the duration of the session and not saved to any database. - * - * @param {ActivityData} activityData An object of User activity data to submit to the server for broadcast. - */ - broadcastActivity(activityData={}) { - if ( !this.active ) { - this.active = true; - ui.players.render(); - } - activityData.sceneId = canvas.ready ? canvas.scene.id : null; - if ( this.viewedScene !== activityData.sceneId ) { - this.viewedScene = activityData.sceneId; - ui.nav.render(); - } - game.socket.emit("userActivity", this.id, activityData); - } - - /* -------------------------------------------- */ - - /** - * Get an Array of Macro Documents on this User's Hotbar by page - * @param {number} page The hotbar page number - * @returns {Array<{slot: number, macro: Macro|null}>} - */ - getHotbarMacros(page=1) { - const macros = Array.from({length: 50}, () => ""); - for ( let [k, v] of Object.entries(this.hotbar) ) { - macros[parseInt(k)-1] = v; - } - const start = (page-1) * 10; - return macros.slice(start, start+10).map((m, i) => { - return { - slot: start + i + 1, - macro: m ? game.macros.get(m) : null - }; - }); - } - - /* -------------------------------------------- */ - - /** - * Update the set of Token targets for the user given an array of provided Token ids. - * @param {string[]} targetIds An array of Token ids which represents the new target set - */ - updateTokenTargets(targetIds=[]) { - - // Clear targets outside of the viewed scene - if ( this.viewedScene !== canvas.scene.id ) { - for ( let t of this.targets ) { - t.setTarget(false, {user: this, releaseOthers: false, groupSelection: true}); - } - return; - } - - // Update within the viewed Scene - const targets = new Set(targetIds); - if ( this.targets.equals(targets) ) return; - - // Remove old targets - for ( let t of this.targets ) { - if ( !targets.has(t.id) ) t.setTarget(false, {user: this, releaseOthers: false, groupSelection: true}); - } - - // Add new targets - for ( let id of targets ) { - const token = canvas.tokens.get(id); - if ( !token || this.targets.has(token) ) continue; - token.setTarget(true, {user: this, releaseOthers: false, groupSelection: true}); - } - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onUpdate(data, options, userId) { - super._onUpdate(data, options, userId); - - // If the user role changed, we need to re-build the immutable User object - if ( this._source.role !== this.role ) { - const user = new User.implementation(this.data); - game.users.delete(user.id); - game.users.set(user.id, user); - return user._onUpdate(data, options, userId); - } - - // Get the changed attributes - let changed = Object.keys(data).filter(k => k !== "_id"); - - // If your own password or role changed - you must re-authenticate - const isSelf = data._id === game.userId; - if ( isSelf && changed.some(p => ["password", "role"].includes(p) ) ) return game.logOut(); - if ( !game.ready ) return; - - // Redraw Navigation - if ( changed.some(p => ["active", "color", "role"].includes(p)) ) ui.nav?.render(); - - // Redraw Players UI - if ( changed.some(p => ["active", "character", "color", "role"].includes(p)) ) ui.players?.render(); - - // Redraw Hotbar - if ( isSelf && changed.includes("hotbar") ) ui.hotbar?.render(); - - // Reconnect to Audio/Video conferencing, or re-render camera views - const webRTCReconnect = ["permissions", "role"].some(k => k in data); - if ( webRTCReconnect && (data._id === game.userId) ) { - game.webrtc?.client.updateLocalStream().then(() => game.webrtc.render()); - } else if ( ["name", "avatar", "character"].some(k => k in data) ) game.webrtc?.render(); - - // Update Canvas - if ( canvas.ready ) { - - // Redraw Cursor - if ( changed.includes("color") ) { - canvas.controls.drawCursor(this); - const ruler = canvas.controls.getRulerForUser(this.id); - if ( ruler ) ruler.color = Color.from(data.color); - } - if ( changed.includes("active") ) canvas.controls.updateCursor(this, null); - - // Modify impersonated character - if ( isSelf && changed.includes("character") ) { - canvas.perception.initialize(); - canvas.tokens.cycleTokens(true, true); - } - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDelete(options, userId) { - super._onDelete(options, userId); - if ( this.id === game.user.id ) return game.logOut(); - } -} - -/** - * The client-side Wall document which extends the common BaseWall document model. - * @extends documents.BaseWall - * @mixes ClientDocumentMixin - * - * @see {@link Scene} The Scene document type which contains Wall documents - * @see {@link WallConfig} The Wall configuration application - */ -class WallDocument extends CanvasDocumentMixin(foundry.documents.BaseWall) {} - -/** - * The virtual tabletop environment is implemented using a WebGL powered HTML 5 canvas using the powerful PIXI.js - * library. The canvas is comprised by an ordered sequence of layers which define rendering groups and collections of - * objects that are drawn on the canvas itself. - * - * ### Hook Events - * {@link hookEvents.canvasConfig} - * {@link hookEvents.canvasInit} - * {@link hookEvents.canvasReady} - * {@link hookEvents.canvasPan} - * {@link hookEvents.canvasTearDown} - * - * @category - Canvas - * - * @example Canvas State - * ```js - * canvas.ready; // Is the canvas ready for use? - * canvas.scene; // The currently viewed Scene document. - * canvas.dimensions; // The dimensions of the current Scene. - * ``` - * @example Canvas Methods - * ```js - * canvas.draw(); // Completely re-draw the game canvas (this is usually unnecessary). - * canvas.pan(x, y, zoom); // Pan the canvas to new coordinates and scale. - * canvas.recenter(); // Re-center the canvas on the currently controlled Token. - * ``` - */ -class Canvas { - constructor() { - - /** - * An Array of pending canvas operations which should trigger on the next re-paint - * @type {object[]} - */ - this.pendingOperations = []; - - /** - * A perception manager interface for batching lighting, sight, and sound updates - * @type {PerceptionManager} - */ - this.perception = new PerceptionManager(); - - /** - * A flag to indicate whether a new Scene is currently being drawn. - * @type {boolean} - */ - this.loading = false; - - /** - * A promise that resolves when the canvas is first initialized and ready. - * @type {Promise|null} - */ - this.initializing = null; - - /** - * Track the last automatic pan time to throttle - * @type {number} - * @private - */ - this._panTime = 0; - - /** - * A Set of unique pending operation names to ensure operations are only performed once - * @type {Set} - */ - this._pendingOperationNames = new Set(); - - // Define an immutable object for the canvas dimensions - Object.defineProperty(this, "dimensions", {value: {}, writable: false}); - } - - /** - * An set of blur filter instances which are modified by the zoom level and the "soft shadows" setting - * @type {Set} - */ - blurFilters = new Set(); - - /** - * A reference to the MouseInteractionManager that is currently controlling pointer-based interaction, or null. - * @type {MouseInteractionManager|null} - */ - currentMouseManager = null; - - /** - * The current pixel dimensions of the displayed Scene, or null if the Canvas is blank. - * @type {SceneDimensions} - */ - dimensions; - - /** - * Record framerate performance data. - * @type {{average: number, values: number[], element: HTMLElement, render: number}} - */ - fps = { - average: 0, - values: [], - render: 0, - element: document.getElementById("fps") - }; - - /** - * The singleton interaction manager instance which handles mouse interaction on the Canvas. - * @type {MouseInteractionManager} - */ - mouseInteractionManager; - - /** - * @typedef {Object} CanvasPerformanceSettings - * @property {number} mode The performance mode in CONST.CANVAS_PERFORMANCE_MODES - * @property {{enabled: boolean, illumination: boolean}} blur Blur filter configuration - * @property {string} mipmap Whether to use mipmaps, "ON" or "OFF" - * @property {boolean} msaa Whether to apply MSAA at the overall canvas level - * @property {number} fps Maximum framerate which should be the render target - * @property {boolean} tokenAnimation Whether to display token movement animation - * @property {boolean} lightAnimation Whether to display light source animation - * @property {boolean} lightSoftEdges Whether to render soft edges for light sources - * @property {{enabled: boolean, maxSize: number, p2Steps: number, p2StepsMax: 2}} textures Texture configuration - */ - - /** - * Configured performance settings which affect the behavior of the Canvas and its renderer. - * @type {CanvasPerformanceSettings} - */ - performance; - - /** - * The renderer screen dimensions. - * @type {number[]} - */ - screenDimensions = [0, 0]; - - /** - * The singleton Fog of War manager instance. - * @type {FogManager} - * @private - */ - _fog = new CONFIG.Canvas.fogManager(); - - /** - * The singleton color manager instance. - * @type {CanvasColorManager} - */ - #colorManager = new CONFIG.Canvas.colorManager(); - - /** - * The DragDrop instance which handles interactivity resulting from DragTransfer events. - * @type {DragDrop} - * @private - */ - #dragDrop; - - /** - * An object of data which caches data which should be persisted across re-draws of the game canvas. - * @type {{scene: string, layer: string, controlledTokens: string[], targetedTokens: string[]}} - * @private - */ - #reload = {}; - - /* -------------------------------------------- */ - - /** - * Track the timestamp when the last mouse move event was captured - * @type {number} - */ - #mouseMoveTime = 0; - - /** - * The debounce timer in milliseconds for tracking mouse movements on the Canvas. - * @type {number} - */ - #mouseMoveDebounceMS = 100; - - /** - * A debounced function which tracks movements of the mouse on the game canvas. - * @type {function(PIXI.InteractionEvent)} - */ - #debounceMouseMove = foundry.utils.debounce(this._onMouseMove.bind(this), this.#mouseMoveDebounceMS); - - /* -------------------------------------------- */ - /* Canvas Groups and Layers */ - /* -------------------------------------------- */ - - /** - * The singleton PIXI.Application instance rendered on the Canvas. - * @type {PIXI.Application} - */ - app; - - /** - * The primary stage container of the PIXI.Application. - * @type {PIXI.Container} - */ - stage; - - /** - * The primary Canvas group which generally contains tangible physical objects which exist within the Scene. - * This group is a {@link CachedContainer} which is rendered to the Scene as a {@link SpriteMesh}. - * This allows the rendered result of the Primary Canvas Group to be affected by a {@link BaseSamplerShader}. - * @type {PrimaryCanvasGroup} - */ - primary; - - /** - * The effects Canvas group which modifies the result of the {@link PrimaryCanvasGroup} by adding special effects. - * This includes lighting, weather, vision, and other visual effects which modify the appearance of the Scene. - * @type {EffectsCanvasGroup} - */ - effects; - - /** - * The interface Canvas group which is rendered above other groups and contains all interactive elements. - * The various {@link InteractionLayer} instances of the interface group provide different control sets for - * interacting with different types of {@link Document}s which can be represented on the Canvas. - * @type {InterfaceCanvasGroup} - */ - interface; - - /** - * The singleton HeadsUpDisplay container which overlays HTML rendering on top of this Canvas. - * @type {HeadsUpDisplay} - */ - hud; - - /* -------------------------------------------- */ - /* Properties and Attributes - /* -------------------------------------------- */ - - /** - * A flag for whether the game Canvas is fully initialized and ready for additional content to be drawn. - * @type {boolean} - */ - get initialized() { - return this.#initialized; - } - - /** @ignore */ - #initialized = false; - - /* -------------------------------------------- */ - - /** - * A reference to the currently displayed Scene document, or null if the Canvas is currently blank. - * @type {Scene|null} - */ - get scene() { - return this.#scene; - } - - /** @ignore */ - #scene = null; - - /* -------------------------------------------- */ - - /** - * A flag for whether the game Canvas is ready to be used. False if the canvas is not yet drawn, true otherwise. - * @type {boolean} - */ - get ready() { - return this.#ready; - } - - /** @ignore */ - #ready = false; - - /* -------------------------------------------- */ - - /** - * The fog of war bound to this canvas - * @type {FogManager} - */ - get fog() { - return this._fog; - } - - /* -------------------------------------------- */ - - /** - * The color manager class bound to this canvas - * @type {CanvasColorManager} - */ - get colorManager() { - return this.#colorManager; - } - - /* -------------------------------------------- */ - - /** - * The colors bound to this scene and handled by the color manager. - * @type {Color} - */ - get colors() { - return this.#colorManager.colors; - } - - /* -------------------------------------------- */ - - /** - * Shortcut to get the masks container from HiddenCanvasGroup. - * @type {PIXI.Container} - */ - get masks() { - return this.hidden.masks; - } - - /* -------------------------------------------- */ - - /** - * The id of the currently displayed Scene. - * @type {string|null} - */ - get id() { - return this.#scene?.id || null; - } - - /* -------------------------------------------- */ - - /** - * A mapping of named CanvasLayer classes which defines the layers which comprise the Scene. - * @type {Object} - */ - static get layers() { - return CONFIG.Canvas.layers; - } - - /* -------------------------------------------- */ - - /** - * An Array of all CanvasLayer instances which are active on the Canvas board - * @type {CanvasLayer[]} - */ - get layers() { - return Object.keys(this.constructor.layers).map(k => this[k]); - } - - /* -------------------------------------------- */ - - /** - * Return a reference to the active Canvas Layer - * @type {CanvasLayer} - */ - get activeLayer() { - for ( let name of Object.keys(this.constructor.layers) ) { - const layer = this[name]; - if ( layer?.active ) return layer; - } - return null; - } - - /* -------------------------------------------- */ - - /** - * The currently displayed darkness level, which may override the saved Scene value. - * @type {number} - */ - get darknessLevel() { - return this.#colorManager.darknessLevel; - } - - /* -------------------------------------------- */ - /* Initialization */ - /* -------------------------------------------- */ - - /** - * Initialize the Canvas by creating the HTML element and PIXI application. - * This step should only ever be performed once per client session. - * Subsequent requests to reset the canvas should go through Canvas#draw - */ - initialize() { - if ( this.#initialized ) throw new Error("The Canvas is already initialized and cannot be re-initialized"); - - // If the game canvas is disabled by "no canvas" mode, we don't need to initialize anything - if ( game.settings.get("core", "noCanvas") ) return; - - // Verify that WebGL is available - Canvas.#configureWebGL(); - - // Create the HTML Canvas element - const canvas = Canvas.#createHTMLCanvas(); - - // Configure canvas settings - const config = Canvas.#configureCanvasSettings(); - - // Create the PIXI Application - this.#createApplication(canvas, config); - - // Configure the desired performance mode - this._configurePerformanceMode(); - - // Display any performance warnings which suggest that the created Application will not function well - this.#displayPerformanceWarnings(); - - // Activate drop handling - this.#dragDrop = new DragDrop({ callbacks: { drop: this._onDrop.bind(this) } }).bind(canvas); - - // Create heads up display - Object.defineProperty(this, "hud", {value: new HeadsUpDisplay(), writable: false}); - - // Create groups - this.#createGroups("stage", this.stage); - - // Update state flags - this.#scene = null; - this.#initialized = true; - this.#ready = false; - } - - /* -------------------------------------------- */ - - /** - * Configure the usage of WebGL for the PIXI.Application that will be created. - * @throws an Error if WebGL is not supported by this browser environment. - * @private - */ - static #configureWebGL() { - if ( !PIXI.utils.isWebGLSupported() ) { - const err = new Error(game.i18n.localize("ERROR.NoWebGL")); - ui.notifications.error(err.message, {permanent: true}); - throw err; - } - PIXI.settings.PREFER_ENV = PIXI.ENV.WEBGL2; - } - - /* -------------------------------------------- */ - - /** - * Create the Canvas element which will be the render target for the PIXI.Application instance. - * Replace the template element which serves as a placeholder in the initially served HTML response. - * @returns {HTMLCanvasElement} - * @private - */ - static #createHTMLCanvas() { - const board = document.getElementById("board"); - const canvas = document.createElement("canvas"); - canvas.id = "board"; - canvas.style.display = "none"; - board.replaceWith(canvas); - return canvas; - } - - /* -------------------------------------------- */ - - /** - * Configure the settings used to initialize the PIXI.Application instance. - * @returns {object} Options passed to the PIXI.Application constructor. - * @private - */ - static #configureCanvasSettings() { - const config = { - width: window.innerWidth, - height: window.innerHeight, - transparent: false, - resolution: game.settings.get("core", "pixelRatioResolutionScaling") ? window.devicePixelRatio : 1, - autoDensity: true, - antialias: false, // Not needed because we use SmoothGraphics - powerPreference: "high-performance" // Prefer high performance GPU for devices with dual graphics cards - }; - Hooks.callAll("canvasConfig", config); - return config; - } - - /* -------------------------------------------- */ - - /** - * Initialize custom pixi plugins. - */ - #initializePlugins() { - MonochromaticSamplerShader.registerPlugin(); - OcclusionSamplerShader.registerPlugin(); - } - - /* -------------------------------------------- */ - - /** - * Create the PIXI.Application and update references to the created app and stage. - * @param {HTMLCanvasElement} canvas The target canvas view element - * @param {object} config Desired PIXI.Application configuration options - */ - #createApplication(canvas, config) { - this.#initializePlugins(); - - // Create the Application instance - const app = new PIXI.Application({view: canvas, ...config}); - Object.defineProperty(this, "app", {value: app, writable: false}); - - // Reference the Stage - Object.defineProperty(this, "stage", {value: this.app.stage, writable: false}); - Object.defineProperty(this.stage.constructor, "name", {value: "CanvasStage", writable: false}); - - // Additional PIXI configuration : Adding custom blend modes - this.app.renderer.plugins.interaction.moveWhenInside = true; - for ( let [k, v] of Object.entries(BLEND_MODES) ) { - const pos = this.app.renderer.state.blendModes.push(v) - 1; - PIXI.BLEND_MODES[k] = pos; - PIXI.BLEND_MODES[pos] = k; - } - // Fix a PIXI bug with custom blend modes - this.#mapPremultipliedBlendModes(); - - // Additional PIXI configuration : Adding the FramebufferSnapshot to the canvas - const snapshot = new FramebufferSnapshot(); - Object.defineProperty(this, "snapshot", {value: snapshot, writable: false}); - } - - /* -------------------------------------------- */ - - /** - * Remap premultiplied blend modes/non premultiplied blend modes to fix PIXI bug with custom BM. - */ - #mapPremultipliedBlendModes() { - const pm = []; - const npm = []; - - // Create the reference mapping - for ( let i = 0; i < canvas.app.renderer.state.blendModes.length; i++ ) { - pm[i] = i; - npm[i] = i; - } - - // Assign exceptions - pm[PIXI.BLEND_MODES.NORMAL_NPM] = PIXI.BLEND_MODES.NORMAL; - pm[PIXI.BLEND_MODES.ADD_NPM] = PIXI.BLEND_MODES.ADD; - pm[PIXI.BLEND_MODES.SCREEN_NPM] = PIXI.BLEND_MODES.SCREEN; - - npm[PIXI.BLEND_MODES.NORMAL] = PIXI.BLEND_MODES.NORMAL_NPM; - npm[PIXI.BLEND_MODES.ADD] = PIXI.BLEND_MODES.ADD_NPM; - npm[PIXI.BLEND_MODES.SCREEN] = PIXI.BLEND_MODES.SCREEN_NPM; - - // Keep the reference to PIXI.utils.premultiplyBlendMode! - // And recreate the blend modes mapping with the same object. - PIXI.utils.premultiplyBlendMode.splice(0, PIXI.utils.premultiplyBlendMode.length); - PIXI.utils.premultiplyBlendMode.push(npm); - PIXI.utils.premultiplyBlendMode.push(pm); - } - - /* -------------------------------------------- */ - - /** - * Display warnings for known performance issues which may occur due to the user's hardware or browser configuration. - * @private - */ - #displayPerformanceWarnings() { - const context = this.app.renderer.context; - const gl = context.gl; - try { - const rendererInfo = SupportDetails.getWebGLRendererInfo(gl); - if ( /swiftshader/i.test(rendererInfo) ) { - ui.notifications.warn("ERROR.NoHardwareAcceleration", {localize: true, permanent: true}); - } - } catch(err) { - ui.notifications.warn("ERROR.RendererNotDetected", {localize: true}); - } - - // Verify that WebGL2 is being used - if (context.webGLVersion !== 2 ) { - const err = new Error(game.i18n.localize("ERROR.NoWebGL2")); - ui.notifications.warn(err.message, {permanent: true}); - } - } - - /* -------------------------------------------- */ - - /** - * Initialize the group containers of the game Canvas. - * @param {string} parentName - * @param {PIXI.DisplayObject} parent - * @private - */ - #createGroups(parentName, parent) { - for ( const [name, config] of Object.entries(CONFIG.Canvas.groups) ) { - if ( config.parent !== parentName ) continue; - const group = new config.groupClass(); - Object.defineProperty(this, name, {value: group, writable: false}); // Reference on the Canvas - Object.defineProperty(parent, name, {value: group, writable: false}); // Reference on the parent - parent.addChild(group); - this.#createGroups(name, group); // Recursive - } - } - - /* -------------------------------------------- */ - - /** - * TODO: Add a quality parameter - * Compute the blur parameters according to grid size and performance mode. - * @private - */ - _initializeBlur() { - // Discard shared filters - this.blurFilters.clear(); - - // Compute base values from grid size - const blurStrength = this.grid.size / 25; - const blurFactor = this.grid.size / 100; - - // Lower stress for MEDIUM performance mode - const level = - Math.max(0, this.performance.mode - (this.performance.mode < CONST.CANVAS_PERFORMANCE_MODES.HIGH ? 1 : 0)); - const maxKernels = Math.max(5 + (level * 2), 5); - const maxPass = 2 + (level * 2); - - // Compute blur parameters - this.blur = new Proxy(Object.seal({ - enabled: this.performance.mode > CONST.CANVAS_PERFORMANCE_MODES.LOW, - strength: blurStrength, - passes: Math.clamped(level + Math.floor(blurFactor), 2, maxPass), - kernels: Math.clamped((2 * Math.ceil((1 + (2 * level) + Math.floor(blurFactor)) / 2)) - 1, 5, maxKernels) - }), { - set(obj, prop, value) { - if ( prop !== "strength" ) throw new Error(`canvas.blur.${prop} is immutable`); - const v = Reflect.set(obj, prop, value); - canvas.updateBlur(); - return v; - } - }); - - // Immediately update blur - this.updateBlur(); - } - - /* -------------------------------------------- */ - - /** - * Configure performance settings for hte canvas application based on the selected performance mode. - * @returns {CanvasPerformanceSettings} - * @internal - */ - _configurePerformanceMode() { - const modes = CONST.CANVAS_PERFORMANCE_MODES; - - // Get client settings - let mode = game.settings.get("core", "performanceMode"); - const fps = game.settings.get("core", "maxFPS"); - const mip = game.settings.get("core", "mipmap"); - - // Construct performance settings object - const settings = { - mode: mode, - mipmap: mip ? "ON" : "OFF", - msaa: false, - fps: Math.clamped(fps, 0, 60), - tokenAnimation: true, - lightAnimation: true, - lightSoftEdges: false, - }; - - // Deprecation shim for blur - settings.blur = new Proxy({ - enabled: false, - illumination: false - }, { - get(obj, prop, receiver) { - foundry.utils.logCompatibilityWarning("canvas.performance.blur is deprecated and replaced by canvas.blur", { - since: 10, - until: 11 - }); - return Reflect.get(obj, prop, receiver); - } - }); - - // Deprecation shim for textures - const gl = this.app.renderer.context.gl; - const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE); - - // Configure default performance mode if one is not set - if ( !Number.isFinite(mode) || (mode === -1) ) { - if ( maxTextureSize <= Math.pow(2, 12) ) mode = CONST.CANVAS_PERFORMANCE_MODES.LOW; - else if ( maxTextureSize <= Math.pow(2, 13) ) mode = CONST.CANVAS_PERFORMANCE_MODES.MED; - else if ( maxTextureSize <= Math.pow(2, 14) ) mode = CONST.CANVAS_PERFORMANCE_MODES.HIGH; - game.settings.storage.get("client").setItem("core.performanceMode", String(mode)); - } - - settings.textures = new Proxy({ - enabled: false, - maxSize: maxTextureSize, - p2Steps: 2, - p2StepsMax: 3 - }, { - get(obj, prop, receiver) { - foundry.utils.logCompatibilityWarning("canvas.performance.textures is deprecated and will be removed in V11", { - since: 10, - until: 11 - }); - return Reflect.get(obj, prop, receiver); - } - }); - - // Low settings - if ( mode >= modes.LOW ) { - settings.tokenAnimation = false; - settings.lightAnimation = false; - } - - // Medium settings - if ( mode >= modes.MED ) { - settings.blur.enabled = true; - settings.textures.enabled = true; - settings.textures.p2Steps = 3; - settings.lightSoftEdges = true; - } - - // High settings - if ( mode >= modes.HIGH ) { - settings.blur.illumination = true; - settings.textures.p2Steps = 2; - } - - // Max settings - if ( mode === modes.MAX ) { - settings.textures.p2Steps = 1; - if ( settings.fps === 60 ) settings.fps = 0; - } - - // Configure performance settings - PIXI.settings.MIPMAP_TEXTURES = PIXI.MIPMAP_MODES[settings.mipmap]; - PIXI.settings.FILTER_RESOLUTION = canvas.app.renderer.resolution; - this.app.ticker.maxFPS = PIXI.Ticker.shared.maxFPS = PIXI.Ticker.system.maxFPS = settings.fps; - return this.performance = settings; - } - - /* -------------------------------------------- */ - /* Rendering */ - /* -------------------------------------------- */ - - /** - * Draw the game canvas. - * @param {Scene} [scene] A specific Scene document to render on the Canvas - * @returns {Promise} A Promise which resolves once the Canvas is fully drawn - */ - async draw(scene) { - - // If the canvas had not yet been initialized, we have done something out of order - if ( !this.#initialized ) { - throw new Error("You may not call Canvas#draw before Canvas#initialize"); - } - - // Identify the Scene which should be drawn - if ( scene === undefined ) scene = game.scenes.current; - if ( !((scene instanceof Scene) || (scene === null)) ) { - throw new Error("You must provide a Scene Document to draw the Canvas."); - } - - // Assign status flags - const wasReady = this.#ready; - this.#ready = false; - this.stage.visible = false; - this.loading = true; - - // Tear down any existing scene - if ( wasReady ) { - try { - await this.tearDown(); - } catch(err) { - err.message = `Encountered an error while tearing down the previous scene: ${err.message}`; - logger.warn(err); - } - } - - // Record Scene changes - if ( this.#scene && (scene !== this.#scene) ) { - this.#scene._view = false; - if ( game.user.viewedScene === this.#scene.id ) game.user.viewedScene = null; - } - this.#scene = scene; - if ( this.#scene === null ) return this.#drawBlank(); // Draw a blank canvas - - // Updating color manager for this scene - this.colorManager.initialize(); - - // Configure Scene dimensions - foundry.utils.mergeObject(this.dimensions, scene.getDimensions()); - canvas.app.view.style.display = "block"; - document.documentElement.style.setProperty("--gridSize", `${this.dimensions.size}px`); - - // Call Canvas initialization hooks - console.log(`${vtt} | Drawing game canvas for scene ${this.#scene.name}`); - Hooks.callAll("canvasInit", this); - - // Configure attributes of the Stage - this.stage.position.set(window.innerWidth / 2, window.innerHeight / 2); - this.stage.hitArea = new PIXI.Rectangle(0, 0, this.dimensions.width, this.dimensions.height); - this.stage.interactive = this.stage.sortableChildren = true; - - // Initialize the camera view position (although the canvas is hidden) - this.initializeCanvasPosition(); - - // Initialize blur parameters - this._initializeBlur(); - - // Load required textures - try { - await TextureLoader.loadSceneTextures(this.#scene); - - // Initialize the Fog Manager - await this.fog.initialize(); - - // Draw canvas groups - try { - for ( const name of Object.keys(CONFIG.Canvas.groups) ) { - const group = this[name]; - await group.draw(); - } - } catch(err) { - Hooks.onError("Canvas#draw", err, { - msg: `Canvas drawing failed: ${err.message}`, - log: "error", - notify: "error" - }); - } - - // Mask primary and effects layers by the overall canvas - const cr = canvas.dimensions.rect; - this.masks.canvas.clear().beginFill(0xFFFFFF, 1.0).drawRect(cr.x, cr.y, cr.width, cr.height).endFill(); - this.primary.sprite.mask = this.primary.mask = this.effects.mask = this.interface.grid.mask = this.masks.canvas; - - // Initialize starting conditions - await this.#initialize(); - - this.#scene._view = true; - this.stage.visible = true; - Hooks.call("canvasReady", this); - } - - // Record that loading was complete and return - finally { - this.loading = false; - } - return this; - } - - /* -------------------------------------------- */ - - /** - * When re-drawing the canvas, first tear down or discontinue some existing processes - * @returns {Promise} - */ - async tearDown() { - this.stage.visible = false; - - // Track current data which should be restored on draw - this.#reload = { - scene: this.#scene.id, - layer: this.activeLayer?.options.name, - controlledTokens: this.tokens.controlled.map(t => t.id), - targetedTokens: Array.from(game.user.targets).map(t => t.id) - }; - - // Deactivate perception refresh - this.perception.deactivate(); - - // Cancel framerate tracking - this.deactivateFPSMeter(); - - // Deactivate every layer before teardown - for ( let l of this.layers.reverse() ) { - if ( l instanceof InteractionLayer ) l.deactivate(); - } - - // Call tear-down hooks - Hooks.callAll("canvasTearDown", this); - - // Tear down groups - for ( const name of Object.keys(CONFIG.Canvas.groups).reverse() ) { - const group = this[name]; - await group.tearDown(); - } - - // Tear down every layer - await this.effects.tearDown(); - for ( let l of this.layers.reverse() ) { - await l.tearDown(); - } - - // Discard shared filters - this.blurFilters.clear(); - } - - /* -------------------------------------------- */ - - /** - * A special workflow to perform when rendering a blank Canvas with no active Scene. - * @returns {Canvas} - */ - #drawBlank() { - console.log(`${vtt} | Skipping game canvas - no active scene.`); - canvas.app.view.style.display = "none"; - ui.controls.render(); - this.loading = this.#ready = false; - return this; - } - - /* -------------------------------------------- */ - - /** - * Get the value of a GL parameter - * @param {string} parameter The GL parameter to retrieve - * @returns {*} The GL parameter value - */ - getGLParameter(parameter) { - const gl = this.app.renderer.context.gl; - return gl.getParameter(gl[parameter]); - } - - /* -------------------------------------------- */ - - /** - * Once the canvas is drawn, initialize control, visibility, and audio states - * @returns {Promise} - * @private - */ - async #initialize() { - this.#ready = true; - - // Clear the set of targeted Tokens for the current user - game.user.targets.clear(); - - // Render the HUD layer - this.hud.render(true); - - // Compute Wall intersections and identify interior walls - canvas.walls.initialize(); - - // Initialize canvas conditions - this.#initializeCanvasLayer(); - this.#initializeTokenControl(); - this._onResize(); - this.#reload = {}; - - // Activate Perception Manager refresh - this.perception.activate(); - this.perception.initialize(); - - // Activate user interaction - this.#addListeners(); - - // Broadcast user presence in the Scene - game.user.broadcastActivity({sceneId: this.#scene.id}); - } - - /* -------------------------------------------- */ - - /** - * Initialize the starting view of the canvas stage - * If we are re-drawing a scene which was previously rendered, restore the prior view position - * Otherwise set the view to the top-left corner of the scene at standard scale - */ - initializeCanvasPosition() { - - // If we are re-drawing a Scene that was already visited, use it's cached view position - let position = this.#scene._viewPosition; - - // Use a saved position, or determine the default view based on the scene size - if ( foundry.utils.isEmpty(position) ) { - let {x, y, scale} = this.#scene.initial; - const r = this.dimensions.rect; - x ??= (r.right / 2); - y ??= (r.bottom / 2); - scale ??= Math.clamped(Math.min(window.innerHeight / r.height, window.innerWidth / r.width), 0.25, 3); - position = {x, y, scale}; - } - - // Pan to the initial view - this.pan(position); - } - - /* -------------------------------------------- */ - - /** - * Initialize a CanvasLayer in the activation state - * @private - */ - #initializeCanvasLayer() { - const layer = this[this.#reload.layer] ?? this.tokens; - layer.activate(); - } - - /* -------------------------------------------- */ - - /** - * Initialize a token or set of tokens which should be controlled. - * Restore controlled and targeted tokens from before the re-draw. - * @private - */ - #initializeTokenControl() { - let panToken = null; - let controlledTokens = []; - let targetedTokens = []; - - // Initial tokens based on reload data - let isReload = this.#reload.scene === this.#scene.id; - if ( isReload ) { - controlledTokens = this.#reload.controlledTokens.map(id => canvas.tokens.get(id)); - targetedTokens = this.#reload.targetedTokens.map(id => canvas.tokens.get(id)); - } - - // Initialize tokens based on player character - else if ( !game.user.isGM ) { - controlledTokens = game.user.character?.getActiveTokens() || []; - if (!controlledTokens.length) { - controlledTokens = canvas.tokens.placeables.filter(t => t.actor?.testUserPermission(game.user, "OWNER")); - } - if (!controlledTokens.length) { - const observed = canvas.tokens.placeables.filter(t => t.actor?.testUserPermission(game.user, "OBSERVER")); - panToken = observed.shift() || null; - } - } - - // Initialize Token Control - for ( let token of controlledTokens ) { - if ( !panToken ) panToken = token; - token?.control({releaseOthers: false}); - } - - // Display a warning if the player has no vision tokens in a visibility-restricted scene - if ( !game.user.isGM && this.#scene.tokenVision && !controlledTokens.some(t => t.document.sight.enabled) ) { - ui.notifications.warn("TOKEN.WarningNoVision", {localize: true}); - } - - // Initialize Token targets - for ( const token of targetedTokens ) { - token?.setTarget(true, {releaseOthers: false, groupSelection: true}); - } - - // Pan camera to controlled token - if ( panToken && !isReload ) this.pan({x: panToken.center.x, y: panToken.center.y, duration: 250}); - } - - /* -------------------------------------------- */ - - /** - * Given an embedded object name, get the canvas layer for that object - * @param {string} embeddedName - * @returns {PlaceablesLayer|null} - */ - getLayerByEmbeddedName(embeddedName) { - return { - AmbientLight: this.lighting, - AmbientSound: this.sounds, - Drawing: this.drawings, - Note: this.notes, - MeasuredTemplate: this.templates, - Tile: this.tiles, - Token: this.tokens, - Wall: this.walls - }[embeddedName] || null; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Activate framerate tracking by adding an HTML element to the display and refreshing it every frame. - */ - activateFPSMeter() { - this.deactivateFPSMeter(); - if ( !this.#ready ) return; - // Show element - this.fps.element.style.display = "block"; - // Activate ticker - this.app.ticker.add(this.#measureFPS, this, PIXI.UPDATE_PRIORITY.LOW); - } - - /* -------------------------------------------- */ - - /** - * Deactivate framerate tracking by canceling ticker updates and removing the HTML element. - */ - deactivateFPSMeter() { - // Deactivate ticker - this.app.ticker.remove(this.#measureFPS, this); - // Hide element - this.fps.element.style.display = "none"; - } - - /* -------------------------------------------- */ - - /** - * Measure average framerate per second over the past 30 frames - * @private - */ - #measureFPS() { - const lastTime = this.app.ticker.lastTime; - - // Push fps values every frame - this.fps.values.push(1000 / this.app.ticker.elapsedMS); - if ( this.fps.values.length > 60 ) this.fps.values.shift(); - - // Do some computations and rendering occasionally - if ( (lastTime - this.fps.render) < 250 ) return; - if ( !this.fps.element ) return; - - // Compute average fps - const total = this.fps.values.reduce((fps, total) => total + fps, 0); - this.fps.average = (total / this.fps.values.length); - - // Render it - this.fps.element.innerHTML = ` ${this.fps.average.toFixed(2)}`; - this.fps.render = lastTime; - } - - /* -------------------------------------------- */ - - /** - * @typedef {Object} CanvasViewPosition - * @property {number|null} x The x-coordinate which becomes stage.pivot.x - * @property {number|null} y The y-coordinate which becomes stage.pivot.y - * @property {number|null} scale The zoom level up to CONFIG.Canvas.maxZoom which becomes stage.scale.x and y - */ - - /** - * Pan the canvas to a certain {x,y} coordinate and a certain zoom level - * @param {CanvasViewPosition} position The canvas position to pan to - */ - pan({x=null, y=null, scale=null}={}) { - - // Constrain the resulting canvas view - const constrained = this.#constrainView({x, y, scale}); - const scaleChange = constrained.scale !== this.stage.scale.x; - - // Set the pivot point - this.stage.pivot.set(constrained.x, constrained.y); - - // Set the zoom level - if ( scaleChange ) { - this.stage.scale.set(constrained.scale, constrained.scale); - this.updateBlur(); - } - - // Update the scene tracked position - canvas.scene._viewPosition = constrained; - - // Call hooks - Hooks.callAll("canvasPan", this, constrained); - - // Update controls - this.controls._onCanvasPan(); - - // Align the HUD - this.hud.align(); - } - - /* -------------------------------------------- */ - - - /** - * Animate panning the canvas to a certain destination coordinate and zoom scale - * Customize the animation speed with additional options - * Returns a Promise which is resolved once the animation has completed - * - * @param {CanvasViewPosition} view The desired view parameters - * @param {number} [view.duration=250] The total duration of the animation in milliseconds; used if speed is not set - * @param {number} [view.speed] The speed of animation in pixels per second; overrides duration if set - * @returns {Promise} A Promise which resolves once the animation has been completed - */ - async animatePan({x, y, scale, duration=250, speed}={}) { - - // Determine the animation duration to reach the target - if ( speed ) { - let ray = new Ray(this.stage.pivot, {x, y}); - duration = Math.round(ray.distance * 1000 / speed); - } - - // Constrain the resulting dimensions and construct animation attributes - const constrained = this.#constrainView({x, y, scale}); - const attributes = [ - { parent: this.stage.pivot, attribute: "x", to: constrained.x }, - { parent: this.stage.pivot, attribute: "y", to: constrained.y }, - { parent: this.stage.scale, attribute: "x", to: constrained.scale }, - { parent: this.stage.scale, attribute: "y", to: constrained.scale } - ].filter(a => a.to !== undefined); - - // Trigger the animation function - const animation = await CanvasAnimation.animate(attributes, { - name: "canvas.animatePan", - duration: duration, - easing: CanvasAnimation.easeInOutCosine, - ontick: () => { - this.hud.align(); - const stage = this.stage; - Hooks.callAll("canvasPan", this, {x: stage.pivot.x, y: stage.pivot.y, scale: stage.scale.x}); - } - }); - - // Record final values - this.updateBlur(); - canvas.scene._viewPosition = constrained; - return animation; - } - - /* -------------------------------------------- */ - - /** - * Recenter the canvas with a pan animation that ends in the center of the canvas rectangle. - * @param {CanvasViewPosition} initial A desired initial position from which to begin the animation - * @returns {Promise} A Promise which resolves once the animation has been completed - */ - async recenter(initial) { - if ( initial ) this.pan(initial); - const r = this.dimensions.sceneRect; - return this.animatePan({ - x: r.x + (window.innerWidth / 2), - y: r.y + (window.innerHeight / 2), - duration: 250 - }); - } - - /* -------------------------------------------- */ - - /** - * Highlight objects on any layers which are visible - * @param {boolean} active - */ - highlightObjects(active) { - if ( !this.#ready ) return; - for ( let layer of this.layers ) { - if ( !layer.objects || !layer.interactiveChildren ) continue; - layer._highlight = active; - for ( let o of layer.placeables ) { - if ( (!active && o._isHoverIn) || !o.visible || !o.can(game.user, "hover") ) continue; - o.hover = active; - o.refresh(); - } - } - /** @see hookEvents.highlightObjects */ - Hooks.callAll("highlightObjects", active); - } - - /* -------------------------------------------- */ - - /** - * Displays a Ping both locally and on other connected client, following these rules: - * 1) Displays on the current canvas Scene - * 2) If ALT is held, becomes an ALERT ping - * 3) Else if the user is GM and SHIFT is held, becomes a PULL ping - * 4) Else is a PULSE ping - * @param {Point} origin Point to display Ping at - * @param {PingOptions} [options] Additional options to configure how the ping is drawn. - * @returns {Promise} - */ - async ping(origin, options) { - // Configure the ping to be dispatched - const types = CONFIG.Canvas.pings.types; - const isPull = game.user.isGM && game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.SHIFT); - const isAlert = game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.ALT); - let style = types.PULSE; - if ( isPull ) style = types.PULL; - else if ( isAlert ) style = types.ALERT; - let ping = {scene: this.scene?.id, pull: isPull, style, zoom: canvas.stage.scale.x}; - ping = foundry.utils.mergeObject(ping, options); - - // Broadcast the ping to other connected clients - /** @type ActivityData */ - const activity = {cursor: origin, ping}; - game.user.broadcastActivity(activity); - - // Display the ping locally - return this.controls.handlePing(game.user, origin, ping); - } - - /* -------------------------------------------- */ - - /** - * Get the constrained zoom scale parameter which is allowed by the maxZoom parameter - * @param {CanvasViewPosition} position The unconstrained position - * @returns {CanvasViewPosition} The constrained position - * @private - */ - #constrainView({x, y, scale}) { - const d = canvas.dimensions; - - // Constrain the maximum zoom level - if ( Number.isNumeric(scale) && (scale !== this.stage.scale.x) ) { - const max = CONFIG.Canvas.maxZoom; - const ratio = Math.max(d.width / window.innerWidth, d.height / window.innerHeight, max); - scale = Math.clamped(scale, 1 / ratio, max); - } else { - scale = this.stage.scale.x; - } - - // Constrain the pivot point using the new scale - if ( Number.isNumeric(x) && x !== this.stage.pivot.x ) { - const padw = 0.4 * (window.innerWidth / scale); - x = Math.clamped(x, -padw, d.width + padw); - } - else x = this.stage.pivot.x; - if ( Number.isNumeric(y) && y !== this.stage.pivot.y ) { - const padh = 0.4 * (window.innerHeight / scale); - y = Math.clamped(y, -padh, d.height + padh); - } - else y = this.stage.pivot.y; - - // Return the constrained view dimensions - return {x, y, scale}; - } - - /* -------------------------------------------- */ - - /** - * Create a BlurFilter instance and register it to the array for updates when the zoom level changes. - * @param {number} blurStrength The desired blur strength to use for this filter - * @returns {PIXI.filters.BlurFilter} - */ - createBlurFilter(blurStrength=CONFIG.Canvas.blurStrength) { - const f = new PIXI.filters.BlurFilter(blurStrength); - f.blur = this.blur.strength; - this.blurFilters.add(f); - return f; - } - - /* -------------------------------------------- */ - - /** - * Add a filter to the blur filter list. The filter must have the blur property - * @param {PIXI.filters.BlurFilter} filter The Filter instance to add - * @returns {PIXI.filters.BlurFilter} The added filter for method chaining - */ - addBlurFilter(filter) { - if ( filter.blur === undefined ) return; - filter.blur = this.blur.strength * this.stage.scale.x; - this.blurFilters.add(filter); // Save initial blur of the filter in the set - return filter; - } - - /* -------------------------------------------- */ - - /** - * Update the blur strength depending on the scale of the canvas stage. - * This number is zero if "soft shadows" are disabled - * @param {number} [strength] Optional blur strength to apply - * @private - */ - updateBlur(strength) { - for ( const filter of this.blurFilters ) { - filter.blur = (strength ?? this.blur.strength) * this.stage.scale.x; - } - } - - /* -------------------------------------------- */ - - /** - * Convert canvas co-ordinates to the client's viewport. - * @param {Point} origin The canvas coordinates. - * @returns {Point} The corresponding co-ordinates relative to the client's viewport. - */ - clientCoordinatesFromCanvas(origin) { - const t = this.stage.worldTransform; - return { - x: (origin.x * this.stage.scale.x) + t.tx, - y: (origin.y * this.stage.scale.y) + t.ty - }; - } - - /* -------------------------------------------- */ - - /** - * Convert client viewport co-ordinates to canvas co-ordinates. - * @param {Point} origin The client coordinates. - * @returns {Point} The corresponding canvas co-ordinates. - */ - canvasCoordinatesFromClient(origin) { - const t = this.stage.worldTransform; - return { - x: (origin.x - t.tx) / this.stage.scale.x, - y: (origin.y - t.ty) / this.stage.scale.y - }; - } - - /* -------------------------------------------- */ - - /** - * Determine whether given canvas co-ordinates are off-screen. - * @param {Point} position The canvas co-ordinates. - * @returns {boolean} Is the coordinate outside the screen bounds? - */ - isOffscreen(position) { - const { clientWidth, clientHeight } = document.documentElement; - const { x, y } = this.clientCoordinatesFromCanvas(position); - return (x < 0) || (y < 0) || (x >= clientWidth) || (y >= clientHeight); - } - - - /* -------------------------------------------- */ - - /** - * Remove all children of the display object and call one cleaning method: - * clean first, then tearDown, and destroy if no cleaning method is found. - * @param {PIXI.DisplayObject} displayObject The display object to clean. - * @param {boolean} destroy If textures should be destroyed. - */ - static clearContainer(displayObject, destroy=true) { - const children = displayObject.removeChildren(); - for ( const child of children ) { - if ( child.clear ) child.clear(destroy); - else if ( child.tearDown ) child.tearDown(); - else child.destroy(destroy); - } - } - - /* -------------------------------------------- */ - /* Event Handlers - /* -------------------------------------------- */ - - /** - * Attach event listeners to the game canvas to handle click and interaction events - * @private - */ - #addListeners() { - - // Remove all existing listeners - this.stage.removeAllListeners(); - - // Define callback functions for mouse interaction events - const callbacks = { - clickLeft: this._onClickLeft.bind(this), - clickLeft2: this._onClickLeft2.bind(this), - clickRight: this._onClickRight.bind(this), - clickRight2: this._onClickRight2.bind(this), - dragLeftStart: this._onDragLeftStart.bind(this), - dragLeftMove: this._onDragLeftMove.bind(this), - dragLeftDrop: this._onDragLeftDrop.bind(this), - dragLeftCancel: this._onDragLeftCancel.bind(this), - dragRightStart: null, - dragRightMove: this._onDragRightMove.bind(this), - dragRightDrop: this._onDragRightDrop.bind(this), - dragRightCancel: null, - longPress: this._onLongPress.bind(this) - }; - - // Create and activate the interaction manager - const permissions = { clickRight2: false }; - const mgr = new MouseInteractionManager(this.stage, this.stage, permissions, callbacks); - this.mouseInteractionManager = mgr.activate(); - - // Debug average FPS - if ( game.settings.get("core", "fpsMeter") ) this.activateFPSMeter(); - - // Add a listener for cursor movement - this.stage.on("mousemove", event => { - const now = event.data.now = Date.now(); - const dt = now - this.#mouseMoveTime; - if ( dt > this.#mouseMoveDebounceMS ) return this._onMouseMove(event); // Handle immediately - else return this.#debounceMouseMove(event); // Handle on debounced delay - }); - } - - /* -------------------------------------------- */ - - /** - * Handle mouse movement on the game canvas. - * This handler fires on both a throttle and a debounce, ensuring that the final update is always recorded. - * @param {PIXI.InteractionEvent} event - * @private - */ - _onMouseMove(event) { - this.#mouseMoveTime = event.data.now; - canvas.controls._onMouseMove(event); - canvas.sounds._onMouseMove(event); - } - - /* -------------------------------------------- */ - - /** - * Handle left mouse-click events occurring on the Canvas. - * @see {MouseInteractionManager#_handleClickLeft} - * @param {PIXI.InteractionEvent} event - * @private - */ - _onClickLeft(event) { - const layer = this.activeLayer; - if ( layer instanceof InteractionLayer ) layer._onClickLeft(event); - } - - /* -------------------------------------------- */ - - /** - * Handle double left-click events occurring on the Canvas. - * @see {MouseInteractionManager#_handleClickLeft2} - * @param {PIXI.InteractionEvent} event - */ - _onClickLeft2(event) { - const layer = this.activeLayer; - if ( layer instanceof InteractionLayer ) layer._onClickLeft2(event); - } - - /* -------------------------------------------- */ - - /** - * Handle long press events occurring on the Canvas. - * @see {MouseInteractionManager#_handleLongPress} - * @param {PIXI.InteractionEvent} event The triggering canvas interaction event. - * @param {PIXI.Point} origin The local canvas coordinates of the mousepress. - * @private - */ - _onLongPress(event, origin) { - canvas.controls._onLongPress(event, origin); - } - - /* -------------------------------------------- */ - - /** - * Handle the beginning of a left-mouse drag workflow on the Canvas stage or its active Layer. - * @see {MouseInteractionManager#_handleDragStart} - * @param {PIXI.InteractionEvent} event - * @internal - */ - _onDragLeftStart(event) { - - // Extract event data - const layer = this.activeLayer; - const isRuler = game.activeTool === "ruler"; - const isCtrlRuler = game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL) - && (layer instanceof TokenLayer); - - // Begin ruler measurement - if ( isRuler || isCtrlRuler ) return this.controls.ruler._onDragStart(event); - - // Activate select rectangle - const isSelect = ["select", "target"].includes(game.activeTool); - if ( isSelect ) { - // The event object appears to be reused, so delete any coords from a previous selection. - delete event.data.coords; - canvas.controls.select.active = true; - return; - } - - // Dispatch the event to the active layer - if ( layer instanceof InteractionLayer ) layer._onDragLeftStart(event); - } - - /* -------------------------------------------- */ - - /** - * Handle mouse movement events occurring on the Canvas. - * @see {MouseInteractionManager#_handleDragMove} - * @param {PIXI.InteractionEvent} event - * @internal - */ - _onDragLeftMove(event) { - const layer = this.activeLayer; - - // Pan the canvas if the drag event approaches the edge - this._onDragCanvasPan(event.data.originalEvent); - - // Continue a Ruler measurement - const ruler = this.controls.ruler; - if ( ruler._state > 0 ) return ruler._onMouseMove(event); - - // Continue a select event - const isSelect = ["select", "target"].includes(game.activeTool); - if ( isSelect && canvas.controls.select.active ) return this._onDragSelect(event); - - // Dispatch the event to the active layer - if ( layer instanceof InteractionLayer ) layer._onDragLeftMove(event); - } - - /* -------------------------------------------- */ - - /** - * Handle the conclusion of a left-mouse drag workflow when the mouse button is released. - * @see {MouseInteractionManager#_handleDragDrop} - * @param {PIXI.InteractionEvent} event - * @internal - */ - _onDragLeftDrop(event) { - - // Extract event data - const {coords, originalEvent} = event.data; - const tool = game.activeTool; - const layer = canvas.activeLayer; - - // Conclude a measurement event if we aren't holding the CTRL key - const ruler = canvas.controls.ruler; - if ( ruler.active ) { - if ( game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL) ) originalEvent.preventDefault(); - return ruler._onMouseUp(event); - } - - // Conclude a select event - const isSelect = ["select", "target"].includes(tool); - if ( isSelect && canvas.controls.select.active ) { - canvas.controls.select.clear(); - canvas.controls.select.active = false; - const releaseOthers = !originalEvent.shiftKey; - if ( !coords ) return; - if ( tool === "select" ) return layer.selectObjects(coords, {releaseOthers}); - if ( tool === "target" ) return layer.targetObjects(coords, {releaseOthers}); - } - - // Dispatch the event to the active layer - if ( layer instanceof InteractionLayer ) layer._onDragLeftDrop(event); - } - - /* -------------------------------------------- */ - - /** - * Handle the cancellation of a left-mouse drag workflow - * @see {MouseInteractionManager#_handleDragCancel} - * @param {PointerEvent} event - * @internal - */ - _onDragLeftCancel(event) { - const layer = canvas.activeLayer; - const tool = game.activeTool; - - // Don't cancel ruler measurement - const ruler = canvas.controls.ruler; - if ( ruler.active ) return event.preventDefault(); - - // Clear selection - const isSelect = ["select", "target"].includes(tool); - if ( isSelect ) return canvas.controls.select.clear(); - - // Dispatch the event to the active layer - if ( layer instanceof InteractionLayer ) layer._onDragLeftCancel(event); - } - - /* -------------------------------------------- */ - - /** - * Handle right mouse-click events occurring on the Canvas. - * @see {MouseInteractionManager#_handleClickRight} - * @param {PIXI.InteractionEvent} event - * @private - */ - _onClickRight(event) { - const ruler = canvas.controls.ruler; - if ( ruler.active ) return ruler._onClickRight(event); - - // Dispatch to the active layer - const layer = this.activeLayer; - if ( layer instanceof InteractionLayer ) layer._onClickRight(event); - } - - /* -------------------------------------------- */ - - /** - * Handle double right-click events occurring on the Canvas. - * @see {MouseInteractionManager#_handleClickRight} - * @param {PIXI.InteractionEvent} event - * @private - */ - _onClickRight2(event) { - const layer = this.activeLayer; - if ( layer instanceof InteractionLayer ) layer._onClickRight2(event); - } - - /* -------------------------------------------- */ - - /** - * Handle right-mouse drag events occurring on the Canvas. - * @see {MouseInteractionManager#_handleDragMove} - * @param {PIXI.InteractionEvent} event - * @private - */ - _onDragRightMove(event) { - - // Extract event data - const DRAG_SPEED_MODIFIER = 0.8; - const {cursorTime, origin, destination} = event.data; - const dx = destination.x - origin.x; - const dy = destination.y - origin.y; - - // Update the client's cursor position every 100ms - const now = Date.now(); - if ( (now - (cursorTime || 0)) > 100 ) { - if ( this.controls ) this.controls._onMouseMove(event, destination); - event.data.cursorTime = now; - } - - // Pan the canvas - this.pan({ - x: canvas.stage.pivot.x - (dx * DRAG_SPEED_MODIFIER), - y: canvas.stage.pivot.y - (dy * DRAG_SPEED_MODIFIER) - }); - - // Reset Token tab cycling - this.tokens._tabIndex = null; - } - - - /* -------------------------------------------- */ - - /** - * Handle the conclusion of a right-mouse drag workflow the Canvas stage. - * @see {MouseInteractionManager#_handleDragDrop} - * @param {PIXI.InteractionEvent} event - * @private - */ - _onDragRightDrop(event) {} - - /* -------------------------------------------- */ - - /** - * Determine selection coordinate rectangle during a mouse-drag workflow - * @param {PIXI.InteractionEvent} event - * @private - */ - _onDragSelect(event) { - - // Extract event data - const {origin, destination} = event.data; - - // Determine rectangle coordinates - let coords = { - x: Math.min(origin.x, destination.x), - y: Math.min(origin.y, destination.y), - width: Math.abs(destination.x - origin.x), - height: Math.abs(destination.y - origin.y) - }; - - // Draw the select rectangle - canvas.controls.drawSelect(coords); - event.data.coords = coords; - } - - /* -------------------------------------------- */ - - /** - * Pan the canvas view when the cursor position gets close to the edge of the frame - * @param {MouseEvent} event The originating mouse movement event - */ - _onDragCanvasPan(event) { - - // Throttle panning by 200ms - const now = Date.now(); - if ( now - (this._panTime || 0) <= 200 ) return; - this._panTime = now; - - // Shift by 3 grid spaces at a time - const {x, y} = event; - const pad = 50; - const shift = (this.dimensions.size * 3) / this.stage.scale.x; - - // Shift horizontally - let dx = 0; - if ( x < pad ) dx = -shift; - else if ( x > window.innerWidth - pad ) dx = shift; - - // Shift vertically - let dy = 0; - if ( y < pad ) dy = -shift; - else if ( y > window.innerHeight - pad ) dy = shift; - - // Enact panning - if ( dx || dy ) return this.animatePan({x: this.stage.pivot.x + dx, y: this.stage.pivot.y + dy, duration: 200}); - } - - /* -------------------------------------------- */ - /* Other Event Handlers */ - /* -------------------------------------------- */ - - /** - * Handle window resizing with the dimensions of the window viewport change - * @param {Event} event The Window resize event - * @private - */ - _onResize(event=null) { - if ( !this.#ready ) return false; - - // Resize the renderer to the current screen dimensions - this.app.renderer.resize(window.innerWidth, window.innerHeight); - - // Record the dimensions that were resized to (may be rounded, etc..) - const w = this.screenDimensions[0] = this.app.renderer.screen.width; - const h = this.screenDimensions[1] = this.app.renderer.screen.height; - - // Update the canvas position - this.stage.position.set(w/2, h/2); - this.pan(this.stage.pivot); - } - - /* -------------------------------------------- */ - - /** - * Handle mousewheel events which adjust the scale of the canvas - * @param {WheelEvent} event The mousewheel event that zooms the canvas - * @private - */ - _onMouseWheel(event) { - let dz = ( event.delta < 0 ) ? 1.05 : 0.95; - this.pan({scale: dz * canvas.stage.scale.x}); - } - - /* -------------------------------------------- */ - - /** - * Event handler for the drop portion of a drag-and-drop event. - * @param {DragEvent} event The drag event being dropped onto the canvas - * @private - */ - _onDrop(event) { - event.preventDefault(); - const data = TextEditor.getDragEventData(event); - if ( !data.type ) return; - - // Acquire the cursor position transformed to Canvas coordinates - const [x, y] = [event.clientX, event.clientY]; - const t = this.stage.worldTransform; - data.x = (x - t.tx) / canvas.stage.scale.x; - data.y = (y - t.ty) / canvas.stage.scale.y; - - /** - * A hook event that fires when some useful data is dropped onto the - * Canvas. - * @function dropCanvasData - * @memberof hookEvents - * @param {Canvas} canvas The Canvas - * @param {object} data The data that has been dropped onto the Canvas - */ - const allowed = Hooks.call("dropCanvasData", this, data); - if ( allowed === false ) return; - - // Handle different data types - switch ( data.type ) { - case "Actor": - return canvas.tokens._onDropActorData(event, data); - case "JournalEntry": case "JournalEntryPage": - return canvas.notes._onDropData(event, data); - case "Macro": - return game.user.assignHotbarMacro(null, Number(data.slot)); - case "PlaylistSound": - return canvas.sounds._onDropData(event, data); - case "Tile": - return canvas.tiles._onDropData(event, data); - } - } - - /* -------------------------------------------- */ - /* Pending Operations */ - /* -------------------------------------------- */ - - /** - * Add a pending canvas operation that should fire once the socket handling workflow concludes. - * This registers operations by a unique string name into a queue - avoiding repeating the same work multiple times. - * This is especially helpful for multi-object updates to avoid costly and redundant refresh operations. - * TODO: this should be deprecated - * @param {string} name A unique name for the pending operation, conventionally Class.method - * @param {Function} fn The unbound function to execute later - * @param {*} scope The scope to which the method should be bound when called - * @param {...*} args Arbitrary arguments to pass to the method when called - */ - addPendingOperation(name, fn, scope, args) { - if ( this._pendingOperationNames.has(name) ) return; - this._pendingOperationNames.add(name); - this.pendingOperations.push([fn, scope, args]); - } - - /* -------------------------------------------- */ - - /** - * Fire all pending functions that are registered in the pending operations queue and empty it. - * TODO: this should be deprecated - */ - triggerPendingOperations() { - for ( let [fn, scope, args] of this.pendingOperations ) { - if ( !fn ) continue; - args = args || []; - fn = fn.call(scope, ...args); - } - this.pendingOperations = []; - this._pendingOperationNames.clear(); - } - - /* -------------------------------------------- */ - /* Deprecations and Compatibility */ - /* -------------------------------------------- */ - - /** - * @deprecated since v10 - * @ignore - */ - get blurDistance() { - const msg = "canvas.blurDistance is deprecated in favor of canvas.blur.strength"; - foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12}); - return this.blur.strength; - } - - /** - * @deprecated since v10 - * @ignore - */ - set blurDistance(value) { - const msg = "Setting canvas.blurDistance is replaced by setting canvas.blur.strength"; - foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12}); - this.blur.strength = value; - } - - /** - * @deprecated since v10 - * @ignore - */ - activateLayer(layerName) { - const msg = "Canvas#activateLayer is deprecated in favor of CanvasLayer#activate"; - foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12}); - this[layerName].activate(); - } - - /* -------------------------------------------- */ - - /** - * @deprecated since v10 - * @ignore - */ - static getDimensions(scene) { - const msg = "Canvas.getDimensions is deprecated in favor of Scene#getDimensions"; - foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12}); - return scene.getDimensions(); - } - - /* -------------------------------------------- */ - - /** - * @deprecated since v10 - * @ignore - */ - setBackgroundColor(color) { - const msg = "Canvas#setBackgroundColor is deprecated in favor of Canvas#colorManager#initialize"; - foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12}); - this.#colorManager.initialize({backgroundColor: color}); - } -} - -/** - * An Abstract Base Class which defines a Placeable Object which represents a Document placed on the Canvas - * @extends {PIXI.Container} - * @abstract - * @interface - * - * @param {abstract.Document} document The Document instance which is represented by this object - */ -class PlaceableObject extends PIXI.Container { - constructor(document) { - super(); - if ( !(document instanceof foundry.abstract.Document) || !document.isEmbedded ) { - throw new Error("You must provide an embedded Document instance as the input for a PlaceableObject"); - } - - /** - * Retain a reference to the Scene within which this Placeable Object resides - * @type {Scene} - */ - this.scene = document.parent; - - /** - * A reference to the Scene embedded Document instance which this object represents - * @type {abstract.Document} - */ - this.document = document; - - /** - * The underlying data object which provides the basis for this placeable object - * @type {abstract.DataModel} - */ - Object.defineProperty(this, "data", { - get: () => { - const msg = "You are accessing PlaceableObject#data which is no longer used and instead the Document class " - + "should be referenced directly as PlaceableObject#document."; - foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12}); - return document; - } - }); - - /** - * Track the field of vision for the placeable object. - * This is necessary to determine whether a player has line-of-sight towards a placeable object or vice-versa - * @type {{fov: PIXI.Circle, los: PointSourcePolygon}} - */ - this.vision = {fov: undefined, los: undefined}; - - /** - * A control icon for interacting with the object - * @type {ControlIcon} - */ - this.controlIcon = null; - - /** - * A mouse interaction manager instance which handles mouse workflows related to this object. - * @type {MouseInteractionManager} - */ - this.mouseInteractionManager = null; - - // Allow objects to be culled when off-screen - this.cullable = true; - } - - /** - * Identify the official Document name for this PlaceableObject class - * @type {string} - */ - static embeddedName; - - /** - * Passthrough certain drag operations on locked objects. - * @type {boolean} - * @protected - */ - _dragPassthrough = false; - - /** - * Know if a placeable is in the hover-in state. - * @type {boolean} - * @internal - */ - _isHoverIn = false; - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * The mouse interaction state of this placeable. - * @type {MouseInteractionManager.INTERACTION_STATES} - */ - get interactionState() { - return this._original?.mouseInteractionManager?.state ?? this.mouseInteractionManager.state; - } - - /* -------------------------------------------- */ - - /** - * The bounding box for this PlaceableObject. - * This is required if the layer uses a Quadtree, otherwise it is optional - * @returns {Rectangle} - */ - get bounds() { - throw new Error("Each subclass of PlaceableObject must define its own bounds rectangle"); - } - - /* -------------------------------------------- */ - - /** - * The central coordinate pair of the placeable object based on it's own width and height - * @type {PIXI.Point} - */ - get center() { - const d = this.document; - if ( ("width" in d) && ("height" in d) ) { - return new PIXI.Point(d.x + (d.width / 2), d.y + (d.height / 2)); - } - return new PIXI.Point(d.x, d.y); - } - - /* -------------------------------------------- */ - - /** - * The id of the corresponding Document which this PlaceableObject represents. - * @type {string} - */ - get id() { - return this.document.id; - } - - /* -------------------------------------------- */ - - /** - * A unique identifier which is used to uniquely identify elements on the canvas related to this object. - * @type {string} - */ - get objectId() { - let id = `${this.document.documentName}.${this.document.id}`; - if ( this.isPreview ) id += ".preview"; - return id; - } - - /* -------------------------------------------- */ - - /** - * The named identified for the source object associated with this PlaceableObject. - * This differs from the objectId because the sourceId is the same for preview objects as for the original. - * @type {string} - */ - get sourceId() { - return `${this.document.documentName}.${this._original?.id ?? this.document.id ?? "preview"}`; - } - - /* -------------------------------------------- */ - - /** - * Is this placeable object a temporary preview? - * @type {boolean} - */ - get isPreview() { - return !!this._original; - } - - /* -------------------------------------------- */ - - /** - * The field-of-vision polygon for the object, if it has been computed - * @type {PIXI.Circle} - */ - get fov() { - return this.vision.fov; - } - - /* -------------------------------------------- */ - - /** - * Provide a reference to the CanvasLayer which contains this PlaceableObject. - * @type {PlaceablesLayer} - */ - get layer() { - return this.document.layer; - } - - /* -------------------------------------------- */ - - /** - * The line-of-sight polygon for the object, if it has been computed - * @type {PointSourcePolygon|null} - */ - get los() { - return this.vision.los; - } - - /* -------------------------------------------- */ - - /** - * A Form Application which is used to configure the properties of this Placeable Object or the Document it - * represents. - * @type {FormApplication} - */ - get sheet() { - return this.document.sheet; - } - - /** - * An indicator for whether the object is currently controlled - * @type {boolean} - */ - get controlled() { - return this.#controlled; - } - - #controlled = false; - - /* -------------------------------------------- */ - - /** - * An indicator for whether the object is currently a hover target - * @type {boolean} - */ - get hover() { - return this.#hover; - } - - set hover(state) { - this.#hover = typeof state === "boolean" ? state : false; - } - - #hover = false; - - /* -------------------------------------------- */ - /* Permission Controls */ - /* -------------------------------------------- */ - - /** - * Test whether a user can perform a certain interaction regarding a Placeable Object - * @param {User} user The User performing the action - * @param {string} action The named action being attempted - * @returns {boolean} Does the User have rights to perform the action? - */ - can(user, action) { - const fn = this[`_can${action.titleCase()}`]; - return fn ? fn.call(this, user) : false; - } - - /** - * Can the User access the HUD for this Placeable Object? - * @param {User} user The User performing the action. - * @param {object} event The event object. - * @returns {boolean} The returned status. - * @protected - */ - _canHUD(user, event) { - return this.controlled; - } - - /** - * Does the User have permission to configure the Placeable Object? - * @param {User} user The User performing the action. - * @param {object} event The event object. - * @returns {boolean} The returned status. - * @protected - */ - _canConfigure(user, event) { - return this.document.canUserModify(user, "update"); - } - - /** - * Does the User have permission to control the Placeable Object? - * @param {User} user The User performing the action. - * @param {object} event The event object. - * @returns {boolean} The returned status. - * @protected - */ - _canControl(user, event) { - if ( !this.layer.active ) return false; - return this.document.canUserModify(user, "update"); - } - - /** - * Does the User have permission to view details of the Placeable Object? - * @param {User} user The User performing the action. - * @param {object} event The event object. - * @returns {boolean} The returned status. - * @protected - */ - _canView(user, event) { - return this.document.testUserPermission(user, "LIMITED"); - } - - /** - * Does the User have permission to create the underlying Document? - * @param {User} user The User performing the action. - * @param {object} event The event object. - * @returns {boolean} The returned status. - * @protected - */ - _canCreate(user, event) { - return user.isGM; - } - - /** - * Does the User have permission to drag this Placeable Object? - * @param {User} user The User performing the action. - * @param {object} event The event object. - * @returns {boolean} The returned status. - * @protected - */ - _canDrag(user, event) { - return this._canControl(user, event); - } - - /** - * Does the User have permission to hover on this Placeable Object? - * @param {User} user The User performing the action. - * @param {object} event The event object. - * @returns {boolean} The returned status. - * @protected - */ - _canHover(user, event) { - return this._canControl(user, event); - } - - /** - * Does the User have permission to update the underlying Document? - * @param {User} user The User performing the action. - * @param {object} event The event object. - * @returns {boolean} The returned status. - * @protected - */ - _canUpdate(user, event) { - return this._canControl(user, event); - } - - /** - * Does the User have permission to delete the underlying Document? - * @param {User} user The User performing the action. - * @param {object} event The event object. - * @returns {boolean} The returned status. - * @protected - */ - _canDelete(user, event) { - return this._canControl(user, event); - } - - /* -------------------------------------------- */ - /* Rendering */ - /* -------------------------------------------- */ - - /** - * Clear the display of the existing object. - * @returns {PlaceableObject} The cleared object - */ - clear() { - this.removeChildren().forEach(c => c.destroy({children: true})); - return this; - } - - /* -------------------------------------------- */ - - /** - * Clone the placeable object, returning a new object with identical attributes. - * The returned object is non-interactive, and has no assigned ID. - * If you plan to use it permanently you should call the create method. - * @returns {PlaceableObject} A new object with identical data - */ - clone() { - const cloneDoc = this.document.clone({}, {keepId: true}); - const clone = new this.constructor(cloneDoc); - cloneDoc._object = clone; - clone._original = this; - clone.interactive = false; - clone.#controlled = this.#controlled; - return clone; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - destroy(options) { - this.document._object = null; - this.document._destroyed = true; - if ( this.controlIcon ) this.controlIcon.destroy(); - Hooks.callAll(`destroy${this.document.documentName}`, this); - this._destroy(options); - return super.destroy(options); - } - - /** - * The inner _destroy method which may optionally be defined by each PlaceableObject subclass. - * @param {object} [options] Options passed to the initial destroy call - * @protected - */ - _destroy(options) {} - - /* -------------------------------------------- */ - - /** - * Draw the placeable object into its parent container - * @returns {Promise} The drawn object - */ - async draw() { - this.clear(); - await this._draw(); - Hooks.callAll(`draw${this.document.documentName}`, this); - this.refresh(); - if ( this.id ) this.activateListeners(); - return this; - } - - /** - * The inner _draw method which must be defined by each PlaceableObject subclass. - * @abstract - * @protected - */ - async _draw() { - throw new Error(`The ${this.constructor.name} subclass of PlaceableObject must define the _draw method`); - } - - /* -------------------------------------------- */ - - /** - * Refresh the current display state of the Placeable Object - * @param {object} [options] Options which may modify the refresh workflow - * @returns {PlaceableObject} The refreshed object - */ - refresh(options={}) { - if ( this._destroyed ) return this; - this._refresh(options); - Hooks.callAll(`refresh${this.document.documentName}`, this, options); - return this; - } - - /** - * The inner _refresh method which must be defined by each PlaceableObject subclass. - * @param {object} options Options which may modify the refresh workflow - * @abstract - * @protected - */ - _refresh(options) {} - - /* -------------------------------------------- */ - - /** - * Register pending canvas operations which should occur after a new PlaceableObject of this type is created - * @param {object} data - * @param {object} options - * @param {string} userId - * @protected - */ - _onCreate(data, options, userId) { - this.draw(); - } - - /* -------------------------------------------- */ - - /** - * Define additional steps taken when an existing placeable object of this type is updated with new data - * @param {object} changed - * @param {object} options - * @param {string} userId - * @protected - */ - _onUpdate(changed, options, userId) { - const layer = this.layer; - - // Z-index sorting - if ( "z" in changed ) { - this.zIndex = parseInt(changed.z) || 0; - } - - // Quadtree location update - if ( layer.quadtree ) layer.quadtree.update({r: this.bounds, t: this}); - - // Refresh display - if ( !options?.skipRefresh ) this.refresh(); - } - - /* -------------------------------------------- */ - - /** - * Define additional steps taken when an existing placeable object of this type is deleted - * @param {object} options - * @param {string} userId - * @protected - */ - _onDelete(options, userId) { - this.release({trigger: false}); - const layer = this.layer; - if ( layer.hover === this ) layer.hover = null; - if ( layer.quadtree ) layer.quadtree.remove(this); - this.destroy({children: true}); - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** - * Assume control over a PlaceableObject, flagging it as controlled and enabling downstream behaviors - * @param {Object} options Additional options which modify the control request - * @param {boolean} options.releaseOthers Release any other controlled objects first - * @returns {boolean} A flag denoting whether control was successful - */ - control(options={}) { - if ( !this.layer.options.controllableObjects ) return false; - - // Release other controlled objects - if ( options.releaseOthers !== false ) { - for ( let o of this.layer.controlled ) { - if ( o !== this ) o.release(); - } - } - - // Bail out if this object is already controlled, or not controllable - if ( this.#controlled || !this.id ) return true; - if (!this.can(game.user, "control")) return false; - - // Toggle control status - this.#controlled = true; - this.layer.controlledObjects.set(this.id, this); - - // Trigger follow-up events and fire an on-control Hook - this._onControl(options); - /** - * A hook event that fires when any PlaceableObject is selected or - * deselected. Substitute the PlaceableObject name in the hook event to - * target a specific PlaceableObject type, for example "controlToken". - * @function controlPlaceableObject - * @memberof hookEvents - * @param {PlaceableObject} object The PlaceableObject - * @param {boolean} controlled Whether the PlaceableObject is selected or not - */ - Hooks.callAll(`control${this.constructor.embeddedName}`, this, this.#controlled); - canvas.triggerPendingOperations(); - return true; - } - - /* -------------------------------------------- */ - - /** - * Additional events which trigger once control of the object is established - * @param {Object} options Optional parameters which apply for specific implementations - * @protected - */ - _onControl(options) { - this.refresh(); - } - - /* -------------------------------------------- */ - - /** - * Release control over a PlaceableObject, removing it from the controlled set - * @param {object} options Options which modify the releasing workflow - * @returns {boolean} A Boolean flag confirming the object was released. - */ - release(options={}) { - this.layer.controlledObjects.delete(this.id); - if (!this.#controlled) return true; - this.#controlled = false; - - // Trigger follow-up events - this._onRelease(options); - - // Fire an on-release Hook - Hooks.callAll(`control${this.constructor.embeddedName}`, this, this.#controlled); - if ( options.trigger !== false ) canvas.triggerPendingOperations(); - return true; - } - - /* -------------------------------------------- */ - - /** - * Additional events which trigger once control of the object is released - * @param {object} options Options which modify the releasing workflow - * @protected - */ - _onRelease(options) { - const layer = this.layer; - this.hover = this._isHoverIn = false; - if ( this === layer.hover ) layer.hover = null; - if ( layer.hud && (layer.hud.object === this) ) layer.hud.clear(); - this.refresh(); - } - - /* -------------------------------------------- */ - - /** - * Rotate the PlaceableObject to a certain angle of facing - * @param {number} angle The desired angle of rotation - * @param {number} snap Snap the angle of rotation to a certain target degree increment - * @returns {Promise} The rotated object - */ - async rotate(angle, snap) { - if ( this.document.rotation === undefined ) return this; - const rotation = this._updateRotation({angle, snap}); - this.layer.hud?.clear(); - await this.document.update({rotation}); - return this; - } - - /* -------------------------------------------- */ - - /** - * Determine a new angle of rotation for a PlaceableObject either from an explicit angle or from a delta offset. - * @param {object} options An object which defines the rotation update parameters - * @param {number} [options.angle] An explicit angle, either this or delta must be provided - * @param {number} [options.delta=0] A relative angle delta, either this or the angle must be provided - * @param {number} [options.snap=0] A precision (in degrees) to which the resulting angle should snap. Default is 0. - * @returns {number} The new rotation angle for the object - */ - _updateRotation({angle, delta=0, snap=0}={}) { - let degrees = Number.isNumeric(angle) ? angle : this.document.rotation + delta; - if ( snap > 0 ) degrees = degrees.toNearest(snap); - let isHexRow = [CONST.GRID_TYPES.HEXODDR, CONST.GRID_TYPES.HEXEVENR].includes(canvas.grid.type); - const offset = isHexRow && (snap > 30) ? 30 : 0; - return Math.normalizeDegrees(degrees - offset); - } - - /* -------------------------------------------- */ - - /** - * Obtain a shifted position for the Placeable Object - * @param {number} dx The number of grid units to shift along the X-axis - * @param {number} dy The number of grid units to shift along the Y-axis - * @returns {{x:number, y:number}} The shifted target coordinates - */ - _getShiftedPosition(dx, dy) { - let [x, y] = canvas.grid.grid.shiftPosition(this.document.x, this.document.y, dx, dy); - return {x, y}; - } - - /* -------------------------------------------- */ - /* Interactivity */ - /* -------------------------------------------- */ - - /** - * Activate interactivity for the Placeable Object - */ - activateListeners() { - const mgr = this._createInteractionManager(); - this.mouseInteractionManager = mgr.activate(); - } - - /* -------------------------------------------- */ - - /** - * Create a standard MouseInteractionManager for the PlaceableObject - * @protected - */ - _createInteractionManager() { - - // Handle permissions to perform various actions - const permissions = { - hoverIn: this._canHover, - hoverOut: this._canHover, - clickLeft: this._canControl, - clickLeft2: this._canView, - clickRight: this._canHUD, - clickRight2: this._canConfigure, - dragStart: this._canDrag - }; - - // Define callback functions for each workflow step - const callbacks = { - hoverIn: this._onHoverIn, - hoverOut: this._onHoverOut, - clickLeft: this._onClickLeft, - clickLeft2: this._onClickLeft2, - clickRight: this._onClickRight, - clickRight2: this._onClickRight2, - dragLeftStart: this._onDragLeftStart, - dragLeftMove: this._onDragLeftMove, - dragLeftDrop: this._onDragLeftDrop, - dragLeftCancel: this._onDragLeftCancel, - dragRightStart: null, - dragRightMove: null, - dragRightDrop: null, - dragRightCancel: null, - longPress: this._onLongPress - }; - - // Define options - const options = { target: this.controlIcon ? "controlIcon" : null }; - - // Create the interaction manager - return new MouseInteractionManager(this, canvas.stage, permissions, callbacks, options); - } - - /* -------------------------------------------- */ - - /** - * Actions that should be taken for this Placeable Object when a mouseover event occurs - * @see MouseInteractionManager#_handleMouseOver - * @param {PIXI.InteractionEvent} event The triggering canvas interaction event - * @param {object} options Options which customize event handling - * @param {boolean} [options.hoverOutOthers=true] Trigger hover-out behavior on sibling objects - */ - _onHoverIn(event, {hoverOutOthers=true}={}) { - const layer = this.layer; - if ( !this.document.locked ) this._isHoverIn = true; - if ( this.hover || this.document.locked || layer._highlight ) return false; - layer.hover = this; - if ( hoverOutOthers ) { - for ( const o of layer.placeables ) { - if ( o !== this ) o._onHoverOut(event); - } - } - this.hover = true; - this.refresh(); - /** - * A hook event that fires when any PlaceableObject is hovered over or out. - * Substitute the PlaceableObject name in the hook event to target a specific - * PlaceableObject type, for example "hoverToken". - * @function hoverPlaceableObject - * @memberof hookEvents - * @param {PlaceableObject} object The PlaceableObject - * @param {boolean} hovered Whether the PlaceableObject is hovered over or not - */ - Hooks.callAll(`hover${this.constructor.embeddedName}`, this, this.hover); - } - - /* -------------------------------------------- */ - - /** - * Actions that should be taken for this Placeable Object when a mouseout event occurs - * @see MouseInteractionManager#_handleMouseOut - * @param {PIXI.InteractionEvent} event The triggering canvas interaction event - */ - _onHoverOut(event) { - const layer = this.layer; - if ( !this.document.locked ) this._isHoverIn = false; - if ( !this.hover || this.document.locked || layer._highlight ) return false; - layer.hover = null; - this.hover = false; - this.refresh(); - Hooks.callAll(`hover${this.constructor.embeddedName}`, this, this.hover); - } - - /* -------------------------------------------- */ - - /** - * Callback actions which occur on a single left-click event to assume control of the object - * @see MouseInteractionManager#_handleClickLeft - * @param {PIXI.InteractionEvent} event The triggering canvas interaction event - */ - _onClickLeft(event) { - const hud = this.layer.hud; - if ( hud ) hud.clear(); - - // Add or remove the Placeable Object from the currently controlled set - const oe = event.data.originalEvent; - if ( this.#controlled ) { - if ( oe.shiftKey ) return this.release(); - } else { - return this.control({releaseOthers: !oe.shiftKey}); - } - } - - /* -------------------------------------------- */ - - /** - * Callback actions which occur on a double left-click event to activate - * @see MouseInteractionManager#_handleClickLeft2 - * @param {PIXI.InteractionEvent} event The triggering canvas interaction event - */ - _onClickLeft2(event) { - const sheet = this.sheet; - if ( sheet ) sheet.render(true); - } - - /* -------------------------------------------- */ - - /** - * Callback actions which occur on a single right-click event to configure properties of the object - * @see MouseInteractionManager#_handleClickRight - * @param {PIXI.InteractionEvent} event The triggering canvas interaction event - */ - _onClickRight(event) { - const hud = this.layer.hud; - if ( hud ) { - const releaseOthers = !this.#controlled && !event.data.originalEvent.shiftKey; - this.control({releaseOthers}); - if ( hud.object === this) hud.clear(); - else hud.bind(this); - } - } - - /* -------------------------------------------- */ - - /** - * Callback actions which occur on a double right-click event to configure properties of the object - * @see MouseInteractionManager#_handleClickRight2 - * @param {PIXI.InteractionEvent} event The triggering canvas interaction event - */ - _onClickRight2(event) { - const sheet = this.sheet; - if ( sheet ) sheet.render(true); - } - - /* -------------------------------------------- */ - - /** - * Callback actions which occur when a mouse-drag action is first begun. - * @see MouseInteractionManager#_handleDragStart - * @param {PIXI.InteractionEvent} event The triggering canvas interaction event - */ - _onDragLeftStart(event) { - if ( this.document.locked ) { - this._dragPassthrough = true; - return canvas._onDragLeftStart(event); - } - const objects = this.layer.options.controllableObjects ? this.layer.controlled : [this]; - const clones = []; - for ( let o of objects ) { - if ( o.document.locked ) continue; - o.document.locked = true; - - // Clone the object - const c = o.clone(); - o._preview = c; - clones.push(c); - - // Draw the clone - c.draw().then(c => { - this.layer.preview.addChild(c); - c._onDragStart(); - }); - } - event.data.clones = clones; - } - - /* -------------------------------------------- */ - - /** - * Begin a drag operation from the perspective of the preview clone. - * Modify the appearance of both the clone (this) and the original (_original) object. - * @protected - */ - _onDragStart() { - const o = this._original; - this.alpha = 0.8; - o.alpha = 0.4; - if ( "locked" in o.document ) o.document.locked = true; - this.visible = true; - } - - /** - * Conclude a drag operation from the perspective of the preview clone. - * Modify the appearance of both the clone (this) and the original (_original) object. - * @protected - */ - _onDragEnd() { - this.visible = false; - const o = this._original; - if ( o ) { - if ( o.document?.locked ) o.document.locked = false; - o.alpha = 1.0; - } - } - - /* -------------------------------------------- */ - - /** - * Callback actions which occur on a mouse-move operation. - * @see MouseInteractionManager#_handleDragMove - * @param {PIXI.InteractionEvent} event The triggering canvas interaction event - */ - _onDragLeftMove(event) { - if ( this._dragPassthrough ) return canvas._onDragLeftMove(event); - const {clones, destination, origin, originalEvent} = event.data; - canvas._onDragCanvasPan(originalEvent); - const dx = destination.x - origin.x; - const dy = destination.y - origin.y; - for ( let c of clones || [] ) { - c.document.x = c._original.document.x + dx; - c.document.y = c._original.document.y + dy; - c.refresh(); - } - } - - /* -------------------------------------------- */ - - /** - * Callback actions which occur on a mouse-move operation. - * @see MouseInteractionManager#_handleDragDrop - * @param {PIXI.InteractionEvent} event The triggering canvas interaction event - * @returns {Promise<*>} - */ - async _onDragLeftDrop(event) { - if ( this._dragPassthrough ) { - this._dragPassthrough = false; - return canvas._onDragLeftDrop(event); - } - const {clones, destination, originalEvent} = event.data; - if ( !clones || !canvas.dimensions.rect.contains(destination.x, destination.y) ) return false; - const updates = clones.map(c => { - let dest = {x: c.document.x, y: c.document.y}; - if ( !originalEvent.shiftKey ) { - dest = canvas.grid.getSnappedPosition(c.document.x, c.document.y, this.layer.gridPrecision); - } - return {_id: c._original.id, x: dest.x, y: dest.y, rotation: c.document.rotation}; - }); - return canvas.scene.updateEmbeddedDocuments(this.document.documentName, updates); - } - - /* -------------------------------------------- */ - - /** - * Callback actions which occur on a mouse-move operation. - * @see MouseInteractionManager#_handleDragCancel - * @param {MouseEvent} event The triggering mouse click event - */ - _onDragLeftCancel(event) { - if ( this._dragPassthrough ) { - this._dragPassthrough = false; - return canvas._onDragLeftCancel(event); - } - this.layer.clearPreviewContainer(); - this.refresh(); - } - - /* -------------------------------------------- */ - - /** - * Callback action which occurs on a long press. - * @see MouseInteractionManager#_handleLongPress - * @param {PIXI.InteractionEvent} event The triggering canvas interaction event - * @param {PIXI.Point} origin The local canvas coordinates of the mousepress. - * @protected - */ - _onLongPress(event, origin) { - return canvas.controls._onLongPress(event, origin); - } -} - -/** - * A Loader class which helps with loading video and image textures. - */ -class TextureLoader { - - /** - * The duration in milliseconds for which a texture will remain cached - * @type {number} - */ - static CACHE_TTL = 1000 * 60 * 15; - - /** - * The cached mapping of textures - * @type {Map} - * @private - */ - #cache = new Map(); - - /* -------------------------------------------- */ - - /** - * Load all the textures which are required for a particular Scene - * @param {Scene} scene The Scene to load - * @param {object} [options={}] Additional options that configure texture loading - * @param {boolean} [options.expireCache=true] Destroy other expired textures - * @returns {Promise} - */ - static loadSceneTextures(scene, {expireCache=true}={}) { - let toLoad = []; - - // Scene background and foreground textures - if ( scene.background.src ) toLoad.push(scene.background.src); - if ( scene.foreground ) toLoad.push(scene.foreground); - - // Tiles - toLoad = toLoad.concat(scene.tiles.reduce((arr, t) => { - if ( t.texture.src ) arr.push(t.texture.src); - return arr; - }, [])); - - // Tokens - toLoad = toLoad.concat(scene.tokens.reduce((arr, t) => { - if ( t.texture.src ) arr.push(t.texture.src); - return arr; - }, [])); - - // Control Icons - toLoad = toLoad.concat(Object.values(CONFIG.controlIcons)).concat(CONFIG.statusEffects.map(e => e.icon ?? e)); - - // Load files - const showName = scene.active || scene.visible; - const loadName = showName ? (scene.navName || scene.name) : "..."; - return this.loader.load(toLoad, { - message: game.i18n.format("SCENES.Loading", {name: loadName}), - expireCache: expireCache - }); - } - - /* -------------------------------------------- */ - - /** - * Load an Array of provided source URL paths - * @param {string[]} sources The source URLs to load - * @param {object} [options={}] Additional options which modify loading - * @param {string} [options.message] The status message to display in the load bar - * @param {boolean} [options.expireCache=false] Expire other cached textures? - * @returns {Promise} A Promise which resolves once all textures are loaded - */ - async load(sources, {message, expireCache=false}={}) { - const seen = new Set(); - const promises = []; - const progress = {message: message, loaded: 0, failed: 0, total: 0, pct: 0}; - for ( const src of sources ) { - if ( seen.has(src) ) continue; - seen.add(src); - const promise = this.loadTexture(src) - .then(() => TextureLoader.#onProgress(src, progress)) - .catch(err => TextureLoader.#onError(src, progress, err)); - promises.push(promise); - } - progress.total = promises.length; - - // Expire any cached textures - if ( expireCache ) this.expireCache(); - - // Load all media - return Promise.all(promises); - } - - /* -------------------------------------------- */ - - /** - * Load a single texture on-demand from a given source URL path - * @param {string} src The source texture path to load - * @returns {Promise} The loaded texture object - */ - async loadTexture(src) { - let bt = this.getCache(src); - if ( bt?.valid ) return bt; - return VideoHelper.hasVideoExtension(src) ? this.loadVideoTexture(src) : this.loadImageTexture(src); - } - - /* -------------------------------------------- */ - - /** - * Load an image texture from a provided source url. - * @param {string} src The source image URL - * @returns {Promise} The loaded BaseTexture - */ - async loadImageTexture(src) { - const blob = await TextureLoader.fetchResource(src); - - // Create the Image element - const img = new Image(); - img.decoding = "async"; - img.loading = "eager"; - - // Wait for the image to load - return new Promise((resolve, reject) => { - - // Create the texture on successful load - img.onload = () => { - URL.revokeObjectURL(img.src); - img.height = img.naturalHeight; - img.width = img.naturalWidth; - const tex = PIXI.BaseTexture.from(img); - this.setCache(src, tex); - resolve(tex); - }; - - // Handle errors for valid URLs due to CORS - img.onerror = err => { - URL.revokeObjectURL(img.src); - reject(err); - }; - img.src = URL.createObjectURL(blob); - }); - } - - /* -------------------------------------------- */ - - /** - * Load a video texture from a provided source url - * @param {string} src The source video URL - * @returns {Promise} The loaded BaseTexture - */ - async loadVideoTexture(src) { - if ( !VideoHelper.hasVideoExtension(src) ) { - throw new Error(`${src} is not a valid video texture`); - } - const blob = await TextureLoader.fetchResource(src); - - // Create a Video element - const video = document.createElement("VIDEO"); - video.preload = "auto"; - video.autoplay = false; - video.crossOrigin = "anonymous"; - video.src = URL.createObjectURL(blob); - - // Begin loading and resolve or reject - return new Promise((resolve, reject) => { - video.oncanplay = () => { - video.height = video.videoHeight; - video.width = video.videoWidth; - const tex = PIXI.BaseTexture.from(video, {resourceOptions: {autoPlay: false}}); - this.setCache(src, tex); - video.oncanplay = null; - resolve(tex); - }; - video.onerror = err => { - URL.revokeObjectURL(video.src); - reject(err); - }; - video.load(); - }); - } - - /* --------------------------------------------- */ - - /** - * Use the Fetch API to retrieve a resource and return a Blob instance for it. - * @param {string} src - * @param {object} [options] Options to configure the loading behaviour. - * @param {boolean} [options.bustCache=false] Append a cache-busting query parameter to the request. - * @returns {Promise} A Blob containing the loaded data - */ - static async fetchResource(src, {bustCache=false}={}) { - const fail = `Failed to load texture ${src}`; - const req = bustCache ? TextureLoader.getCacheBustURL(src) : src; - if ( !req ) throw new Error(`${fail}: Invalid URL`); - let res; - try { - res = await fetch(req, {mode: "cors", credentials: "same-origin"}); - } catch(err) { - // We may have encountered a common CORS limitation: https://bugs.chromium.org/p/chromium/issues/detail?id=409090 - if ( !bustCache ) return this.fetchResource(src, {bustCache: true}); - throw new Error(`${fail}: CORS failure`); - } - if ( !res.ok ) throw new Error(`${fail}: Server responded with ${res.status}`); - return res.blob(); - } - - /* -------------------------------------------- */ - - /** - * Log texture loading progress in the console and in the Scene loading bar - * @param {string} src The source URL being loaded - * @param {object} progress Loading progress - * @private - */ - static #onProgress(src, progress) { - progress.loaded++; - progress.pct = Math.round((progress.loaded + progress.failed) * 100 / progress.total); - SceneNavigation.displayProgressBar({label: progress.message, pct: progress.pct}); - console.log(`${vtt} | Loaded ${src} (${progress.pct}%)`); - } - - /* -------------------------------------------- */ - - /** - * Log failed texture loading - * @param {string} src The source URL being loaded - * @param {object} progress Loading progress - * @param {Error} error The error which occurred - * @private - */ - static #onError(src, progress, error) { - progress.failed++; - progress.pct = Math.round((progress.loaded + progress.failed) * 100 / progress.total); - SceneNavigation.displayProgressBar({label: progress.message, pct: progress.pct}); - console.warn(`${vtt} | Loading failed for ${src} (${progress.pct}%): ${error.message}`); - } - - /* -------------------------------------------- */ - /* Cache Controls */ - /* -------------------------------------------- */ - - /** - * Add an image url to the texture cache - * @param {string} src The source URL - * @param {PIXI.BaseTexture} tex The loaded base texture - */ - setCache(src, tex) { - this.#cache.set(src, { - tex: tex, - time: Date.now() - }); - } - - /* -------------------------------------------- */ - - /** - * Retrieve a texture from the texture cache - * @param {string} src The source URL - * @returns {PIXI.BaseTexture} The cached texture, or undefined - */ - getCache(src) { - const val = this.#cache.get(src); - if ( !val || val?.tex.destroyed ) return undefined; - val.time = Date.now(); - return val?.tex; - } - - /* -------------------------------------------- */ - - /** - * Expire (and destroy) textures from the cache which have not been used for more than CACHE_TTL milliseconds. - */ - expireCache() { - const t = Date.now(); - for ( let [key, obj] of this.#cache.entries() ) { - if ( (t - obj.time) > TextureLoader.CACHE_TTL ) { - console.log(`${vtt} | Expiring cached texture: ${key}`); - const texture = obj.tex; - const srcURL = texture.resource?.source?.src; - if ( srcURL ) URL.revokeObjectURL(srcURL); - if ( !texture._destroyed ) texture.destroy(true); - this.#cache.delete(key); - } - } - } - - /* -------------------------------------------- */ - - /** - * Return a URL with a cache-busting query parameter appended. - * @param {string} src The source URL being attempted - * @returns {string|boolean} The new URL, or false on a failure. - */ - static getCacheBustURL(src) { - const url = URL.parseSafe(src); - if ( !url ) return false; - if ( url.origin === window.location.origin ) return false; - url.searchParams.append("cors-retry", Date.now().toString()); - return url.href; - } -} - -/** - * A global reference to the singleton texture loader - * @type {TextureLoader} - */ -TextureLoader.loader = new TextureLoader(); - - -/* -------------------------------------------- */ - - -/** - * Test whether a file source exists by performing a HEAD request against it - * @param {string} src The source URL or path to test - * @returns {Promise} Does the file exist at the provided url? - */ -async function srcExists(src) { - return foundry.utils.fetchWithTimeout(src, { method: "HEAD" }).then(resp => { - return resp.status < 400; - }).catch(() => false); -} - - -/* -------------------------------------------- */ - - -/** - * Get a single texture from the cache - * @param {string} src - * @returns {PIXI.Texture} - */ -function getTexture(src) { - let baseTexture = TextureLoader.loader.getCache(src); - if ( !baseTexture?.valid ) return null; - return new PIXI.Texture(baseTexture); -} - - -/* -------------------------------------------- */ - - -/** - * Load a single texture and return a Promise which resolves once the texture is ready to use - * @param {string} src The requested texture source - * @param {object} [options] Additional options which modify texture loading - * @param {string} [options.fallback] A fallback texture URL to use if the requested source is unavailable - * @returns {PIXI.Texture|null} The loaded Texture, or null if loading failed with no fallback - */ -async function loadTexture(src, {fallback}={}) { - let bt; - let error; - try { - bt = await TextureLoader.loader.loadTexture(src); - if ( !bt?.valid ) error = new Error(`Invalid BaseTexture ${src}`); - } - catch(err) { - err.message = `The requested texture ${src} could not be loaded: ${err.message}`; - error = err; - } - if ( error ) { - console.error(error); - return fallback ? loadTexture(fallback) : null; - } - return new PIXI.Texture(bt); -} - -/** - * A mixin which decorates any container with base canvas common properties. - * @category - Mixins - * @param {typeof Container} ContainerClass The parent Container class being mixed. - * @returns {typeof BaseCanvasMixin} A ContainerClass subclass mixed with BaseCanvasMixin features. - */ -const BaseCanvasMixin = ContainerClass => { - return class BaseCanvasMixin extends ContainerClass { - constructor(...args) { - super(...args); - this.sortableChildren = true; - this.layers = this.#createLayers(); - } - - /** - * The name of this canvas group - * @type {string} - * @abstract - */ - static groupName; - - /** - * A mapping of CanvasLayer classes which belong to this group. - * @type {Object} - */ - layers; - - /* -------------------------------------------- */ - - /** - * Create CanvasLayer instances which belong to the primary group. - * @private - */ - #createLayers() { - const layers = {}; - for ( let [name, config] of Object.entries(CONFIG.Canvas.layers) ) { - if ( config.group !== this.constructor.groupName ) continue; - const layer = layers[name] = new config.layerClass(); - Object.defineProperty(this, name, {value: layer, writable: false}); - Object.defineProperty(canvas, name, {value: layer, writable: false}); - } - return layers; - } - - /* -------------------------------------------- */ - /* Rendering */ - /* -------------------------------------------- */ - - /** - * Draw the canvas group and all its component layers. - * @returns {Promise} - */ - async draw() { - // Draw CanvasLayer instances - for ( const layer of Object.values(this.layers) ) { - this.addChild(layer); - await layer.draw(); - } - } - - /* -------------------------------------------- */ - /* Tear-Down */ - /* -------------------------------------------- */ - - /** - * Remove and destroy all layers from the base canvas. - * @param {object} [options={}] - * @returns {Promise} - */ - async tearDown(options={}) { - // Remove layers - for ( const layer of Object.values(this.layers).reverse() ) { - await layer.tearDown(); - this.removeChild(layer); - } - - // Check if we need to handle other children - if ( options.preserveChildren ) return; - - // Yes? Then proceed with children cleaning - for ( const child of this.removeChildren() ) { - if ( child instanceof CachedContainer ) child.clear(); - else child.destroy({children: true}); - } - } - }; -}; - -/** - * A special type of PIXI.Container which draws its contents to a cached RenderTexture. - * This is accomplished by overriding the Container#render method to draw to our own special RenderTexture. - */ -class CachedContainer extends PIXI.Container { - /** - * Construct a CachedContainer. - * @param {PIXI.Sprite|SpriteMesh} [sprite] A specific sprite to bind to this CachedContainer and its renderTexture. - */ - constructor(sprite) { - super(); - const renderer = canvas.app?.renderer; - - /** - * The RenderTexture that is the render destination for the contents of this Container - * @type {PIXI.RenderTexture} - */ - this.#renderTexture = this.createRenderTexture(); - - // Bind a sprite to the container - if ( sprite ) this.sprite = sprite; - - // Listen for resize events - this.#onResize = this.#resize.bind(this, renderer); - renderer.on("resize", this.#onResize); - } - - /** - * A bound resize function which fires on the renderer resize event. - * @type {function(PIXI.Renderer)} - * @private - */ - #onResize; - - /** - * An map of render textures, linked to their render function and an optional RGBA clear color. - * @type {Map>} - * @private - */ - #renderPaths = new Map(); - - /** - * An object which stores a reference to the normal renderer target and source frame. - * We track this so we can restore them after rendering our cached texture. - * @type {{sourceFrame: PIXI.Rectangle, renderTexture: PIXI.RenderTexture}} - * @private - */ - #backup = { - renderTexture: undefined, - sourceFrame: canvas.app.renderer.screen.clone() - }; - - /** - * An RGBA array used to define the clear color of the RenderTexture - * @type {number[]} - */ - clearColor = [0, 0, 0, 1]; - - /** - * Should our Container also be displayed on screen, in addition to being drawn to the cached RenderTexture? - * @type {boolean} - */ - displayed = false; - - /* ---------------------------------------- */ - - /** - * The primary render texture bound to this cached container. - * @type {PIXI.RenderTexture} - */ - get renderTexture() { - return this.#renderTexture; - } - - /** @private */ - #renderTexture; - - /* ---------------------------------------- */ - - /** - * Set the alpha mode of the cached container render texture. - * @param {PIXI.ALPHA_MODES} mode - */ - set alphaMode(mode) { - this.#renderTexture.baseTexture.alphaMode = mode; - this.#renderTexture.baseTexture.update(); - } - - /* ---------------------------------------- */ - - /** - * A PIXI.Sprite or SpriteMesh which is bound to this CachedContainer. - * The RenderTexture from this Container is associated with the Sprite which is automatically rendered. - * @type {PIXI.Sprite|SpriteMesh} - */ - get sprite() { - return this.#sprite; - } - - set sprite(sprite) { - if ( sprite instanceof PIXI.Sprite || sprite instanceof SpriteMesh ) { - sprite.texture = this.renderTexture; - this.#sprite = sprite; - } - else if ( sprite ) { - throw new Error("You may only bind a PIXI.Sprite or a SpriteMesh as the render target for a CachedContainer."); - } - } - - /** @private */ - #sprite; - - /* ---------------------------------------- */ - - /** - * Create a render texture, provide a render method and an optional clear color. - * @param {object} [options={}] Optional parameters. - * @param {Function} [options.renderFunction] Render function that will be called to render into the RT. - * @param {number[]} [options.clearColor] An optional clear color to clear the RT before rendering into it. - * @returns {PIXI.RenderTexture} A reference to the created render texture. - */ - createRenderTexture({renderFunction, clearColor}={}) { - const renderOptions = {}; - const renderer = canvas.app?.renderer; - - // Creating the render texture - const renderTexture = PIXI.RenderTexture.create({ - width: renderer?.screen.width ?? window.innerWidth, - height: renderer?.screen.height ?? window.innerHeight, - resolution: renderer.resolution ?? PIXI.settings.RESOLUTION - }); - renderOptions.renderFunction = renderFunction; // Binding the render function - renderOptions.clearColor = clearColor; // Saving the optional clear color - this.#renderPaths.set(renderTexture, renderOptions); // Push into the render paths - - // Return a reference to the render texture - return renderTexture; - } - - /* ---------------------------------------- */ - - /** - * Remove a previously created render texture. - * @param {PIXI.RenderTexture} renderTexture The render texture to remove. - * @param {boolean} [destroy=true] Should the render texture be destroyed? - */ - removeRenderTexture(renderTexture, destroy=true) { - this.#renderPaths.delete(renderTexture); - if ( destroy ) renderTexture?.destroy(true); - } - - /* ---------------------------------------- */ - - /** - * Clear the cached container, removing its current contents. - * @param {boolean} [destroy=true] Tell children that we should destroy texture as well. - * @returns {CachedContainer} A reference to the cleared container for chaining. - */ - clear(destroy=true) { - Canvas.clearContainer(this, destroy); - return this; - } - - /* ---------------------------------------- */ - - /** @inheritdoc */ - destroy(options) { - if ( this.#onResize ) canvas.app.renderer.off("resize", this.#onResize); - for ( const [rt] of this.#renderPaths ) rt?.destroy(true); - this.#renderPaths.clear(); - super.destroy(options); - } - - /* ---------------------------------------- */ - - /** @inheritdoc */ - render(renderer) { - if ( !this.renderable ) return; // Skip updating the cached texture - this.#bindPrimaryBuffer(renderer); // Bind the primary buffer (RT) - super.render(renderer); // Draw into the primary buffer - this.#renderSecondary(renderer); // Draw into the secondary buffer(s) - this.#bindOriginalBuffer(renderer); // Restore the original buffer - this.#sprite?.render(renderer); // Render the bound sprite - if ( this.displayed ) super.render(renderer); // Optionally draw to the screen - } - - /* ---------------------------------------- */ - - /** - * Custom rendering for secondary render textures - * @param {PIXI.Renderer} renderer The active canvas renderer. - * @protected - */ - #renderSecondary(renderer) { - if ( this.#renderPaths.size <= 1 ) return; - // Bind the render texture and call the custom render method for each render path - for ( const [rt, ro] of this.#renderPaths ) { - if ( !ro.renderFunction ) continue; - this.#bind(renderer, rt, ro.clearColor); - ro.renderFunction.call(this, renderer); - } - } - - /* ---------------------------------------- */ - - /** - * Bind the primary render texture to the renderer, replacing and saving the original buffer and source frame. - * @param {PIXI.Renderer} renderer The active canvas renderer. - * @private - */ - #bindPrimaryBuffer(renderer) { - - // Get the RenderTexture to bind - const tex = this.renderTexture; - const rt = renderer.renderTexture; - - // Backup the current render target - this.#backup.renderTexture = rt.current; - this.#backup.sourceFrame.copyFrom(rt.sourceFrame); - - // Bind the render texture - this.#bind(renderer, tex); - } - - /* ---------------------------------------- */ - - /** - * Bind a render texture to this renderer. - * Must be called after bindPrimaryBuffer and before bindInitialBuffer. - * @param {PIXI.Renderer} renderer The active canvas renderer. - * @param {PIXI.RenderTexture} tex The texture to bind. - * @param {number[]} [clearColor] A custom clear color. - * @protected - */ - #bind(renderer, tex, clearColor) { - const rt = renderer.renderTexture; - - // Bind our texture to the renderer - renderer.batch.flush(); - rt.bind(tex, undefined, undefined); - rt.clear(clearColor ?? this.clearColor); - - // Enable Filters which are applied to this Container to apply to our cached RenderTexture - const fs = renderer.filter.defaultFilterStack; - if ( fs.length > 1 ) { - fs[fs.length - 1].renderTexture = tex; - } - } - - /* ---------------------------------------- */ - - /** - * Remove the render texture from the Renderer, re-binding the original buffer. - * @param {PIXI.Renderer} renderer The active canvas renderer. - * @private - */ - #bindOriginalBuffer(renderer) { - renderer.batch.flush(); - - // Restore Filters to apply to the original RenderTexture - const fs = renderer.filter.defaultFilterStack; - if ( fs.length > 1 ) { - fs[fs.length - 1].renderTexture = this.#backup.renderTexture; - } - - // Re-bind the original RenderTexture to the renderer - renderer.renderTexture.bind(this.#backup.renderTexture, this.#backup.sourceFrame, undefined); - this.#backup.renderTexture = undefined; - } - - /* ---------------------------------------- */ - - /** - * Resize bound render texture(s) when the dimensions or resolution of the Renderer have changed. - * @param {PIXI.Renderer} renderer The active canvas renderer. - * @private - */ - #resize(renderer) { - for ( const [rt] of this.#renderPaths ) CachedContainer.resizeRenderTexture(renderer, rt); - if ( this.#sprite ) this.#sprite._boundsID++; // Inform PIXI that bounds need to be recomputed for this sprite mesh - } - - /* ---------------------------------------- */ - - /** - * Resize a render texture passed as a parameter with the renderer. - * @param {PIXI.Renderer} renderer The active canvas renderer. - * @param {PIXI.RenderTexture} rt The render texture to resize. - */ - static resizeRenderTexture(renderer, rt) { - const screen = renderer?.screen; - if ( !rt || !screen ) return; - if ( rt.baseTexture.resolution !== renderer.resolution ) rt.baseTexture.resolution = renderer.resolution; - if ( (rt.width !== screen.width) || (rt.height !== screen.height) ) rt.resize(screen.width, screen.height); - } -} - -/** - * A specialized container where bounds are not computed with children, but with canvas dimensions. - */ -class FullCanvasContainer extends PIXI.Container { - /** @override */ - calculateBounds() { - const bounds = this._bounds; - const { x, y, width, height } = canvas.dimensions.rect; - bounds.clear(); - bounds.addFrame(this.transform, x, y, x + width, y + height); - bounds.updateID = this._boundsID; - } -} - -/** - * Extension of a PIXI.Mesh, with the capabilities to provide a snapshot of the framebuffer. - * @extends PIXI.Mesh - */ -class PointSourceMesh extends PIXI.Mesh { - /** - * To store the previous blend mode of the last renderer PointSourceMesh. - * @type {PIXI.BLEND_MODES} - * @protected - */ - static _priorBlendMode; - - /** - * The current texture used by the mesh. - * @type {PIXI.Texture} - * @protected - */ - static _currentTexture; - - /* ---------------------------------------- */ - - /** @override */ - _render(renderer) { - if ( this.uniforms.framebufferTexture !== undefined ) { - if ( canvas.blur.enabled ) { - // We need to use the snapshot only if blend mode is changing - const requireUpdate = (this.state.blendMode !== PointSourceMesh._priorBlendMode) - && (PointSourceMesh._priorBlendMode !== undefined); - if ( requireUpdate ) PointSourceMesh._currentTexture = canvas.snapshot.getFramebufferTexture(renderer); - PointSourceMesh._priorBlendMode = this.state.blendMode; - } - this.uniforms.framebufferTexture = PointSourceMesh._currentTexture; - } - super._render(renderer); - } -} - -/** - * @typedef {object} QuadtreeObject - * @property {Rectangle} r - * @property {*} t - * @property {Set} [n] - */ - -/** - * A Quadtree implementation that supports collision detection for rectangles. - * - * @param {Rectangle} bounds The outer bounds of the region - * @param {object} [options] Additional options which configure the Quadtree - * @param {number} [options.maxObjects=20] The maximum number of objects per node - * @param {number} [options.maxDepth=4] The maximum number of levels within the root Quadtree - * @param {number} [options._depth=0] The depth level of the sub-tree. For internal use - * @param {number} [options._root] The root of the quadtree. For internal use - */ -class Quadtree { - constructor(bounds, {maxObjects=20, maxDepth=4, _depth=0, _root}={}) { - - /** - * The bounding rectangle of the region - * @type {Rectangle} - */ - this.bounds = bounds; - - /** - * The maximum number of objects allowed within this node before it must split - * @type {number} - */ - this.maxObjects = maxObjects; - - /** - * The maximum number of levels that the base quadtree is allowed - * @type {number} - */ - this.maxDepth = maxDepth; - - /** - * The depth of this node within the root Quadtree - * @type {number} - */ - this.depth = _depth; - - /** - * The objects contained at this level of the tree - * @type {QuadtreeObject[]} - */ - this.objects = []; - - /** - * Children of this node - * @type {Quadtree[]} - */ - this.nodes = []; - - /** - * The root Quadtree - * @type {Quadtree} - */ - this.root = _root || this; - } - - /** - * A constant that enumerates the index order of the quadtree nodes from top-left to bottom-right. - * @enum {number} - */ - static INDICES = {tl: 0, tr: 1, bl: 2, br: 3}; - - /* -------------------------------------------- */ - - /** - * Return an array of all the objects in the Quadtree (recursive) - * @returns {QuadtreeObject[]} - */ - get all() { - if ( this.nodes.length ) { - return this.nodes.reduce((arr, n) => arr.concat(n.all), []); - } - return this.objects; - } - - /* -------------------------------------------- */ - /* Tree Management */ - /* -------------------------------------------- */ - - /** - * Split this node into 4 sub-nodes. - * @returns {Quadtree} The split Quadtree - */ - split() { - const b = this.bounds; - const w = b.width / 2; - const h = b.height / 2; - const options = { - maxObjects: this.maxObjects, - maxDepth: this.maxDepth, - _depth: this.depth + 1, - _root: this.root - }; - - // Create child quadrants - this.nodes[Quadtree.INDICES.tl] = new Quadtree(new PIXI.Rectangle(b.x, b.y, w, h), options); - this.nodes[Quadtree.INDICES.tr] = new Quadtree(new PIXI.Rectangle(b.x+w, b.y, w, h), options); - this.nodes[Quadtree.INDICES.bl] = new Quadtree(new PIXI.Rectangle(b.x, b.y+h, w, h), options); - this.nodes[Quadtree.INDICES.br] = new Quadtree(new PIXI.Rectangle(b.x+w, b.y+h, w, h), options); - - // Assign current objects to child nodes - for ( let o of this.objects ) { - o.n.delete(this); - this.insert(o); - } - this.objects = []; - return this; - } - - /* -------------------------------------------- */ - /* Object Management */ - /* -------------------------------------------- */ - - /** - * Clear the quadtree of all existing contents - * @returns {Quadtree} The cleared Quadtree - */ - clear() { - this.objects = []; - for ( let n of this.nodes ) { - n.clear(); - } - this.nodes = []; - return this; - } - - /* -------------------------------------------- */ - - /** - * Add a rectangle object to the tree - * @param {QuadtreeObject} obj The object being inserted - * @returns {Quadtree[]} The Quadtree nodes the object was added to. - */ - insert(obj) { - obj.n = obj.n || new Set(); - - // If we will exceeded the maximum objects we need to split - if ( (this.objects.length === this.maxObjects - 1) && (this.depth < this.maxDepth) ) { - if ( !this.nodes.length ) this.split(); - } - - // If this node has children, recursively insert - if ( this.nodes.length ) { - let nodes = this.getChildNodes(obj.r); - return nodes.reduce((arr, n) => arr.concat(n.insert(obj)), []); - } - - // Otherwise store the object here - obj.n.add(this); - this.objects.push(obj); - return [this]; - } - - /* -------------------------------------------- */ - - /** - * Remove an object from the quadtree - * @param {*} target The quadtree target being removed - * @returns {Quadtree} The Quadtree for method chaining - */ - remove(target) { - this.objects.findSplice(o => o.t === target); - for ( let n of this.nodes ) { - n.remove(target); - } - return this; - } - - /* -------------------------------------------- */ - - /** - * Remove an existing object from the quadtree and re-insert it with a new position - * @param {QuadtreeObject} obj The object being inserted - * @returns {Quadtree[]} The Quadtree nodes the object was added to - */ - update(obj) { - this.remove(obj.t); - return this.insert(obj); - } - - /* -------------------------------------------- */ - /* Target Identification */ - /* -------------------------------------------- */ - - /** - * Get all the objects which could collide with the provided rectangle - * @param {Rectangle} rect The normalized target rectangle - * @param {object} [options] Options affecting the collision test. - * @param {Function} [options.collisionTest] Function to further refine objects to return - * after a potential collision is found. Parameters are the object and rect, and the - * function should return true if the object should be added to the result set. - * @param {Set} [options._s] The existing result set, for internal use. - * @returns {Set} The objects in the Quadtree which represent potential collisions - */ - getObjects(rect, { collisionTest, _s } = {}) { - const objects = _s || new Set(); - - // Recursively retrieve objects from child nodes - if ( this.nodes.length ) { - const nodes = this.getChildNodes(rect); - for ( let n of nodes ) { - n.getObjects(rect, {collisionTest, _s: objects}); - } - } - - // Otherwise, retrieve from this node - else { - for ( let o of this.objects) { - if ( rect.overlaps(o.r) && (!collisionTest || collisionTest(o, rect)) ) objects.add(o.t); - } - } - - // Return the result set - return objects; - } - - /* -------------------------------------------- */ - - /** - * Obtain the leaf nodes to which a target rectangle belongs. - * This traverses the quadtree recursively obtaining the final nodes which have no children. - * @param {Rectangle} rect The target rectangle. - * @returns {Quadtree[]} The Quadtree nodes to which the target rectangle belongs - */ - getLeafNodes(rect) { - if ( !this.nodes.length ) return [this]; - const nodes = this.getChildNodes(rect); - return nodes.reduce((arr, n) => arr.concat(n.getLeafNodes(rect)), []); - } - - /* -------------------------------------------- */ - - /** - * Obtain the child nodes within the current node which a rectangle belongs to. - * Note that this function is not recursive, it only returns nodes at the current or child level. - * @param {Rectangle} rect The target rectangle. - * @returns {Quadtree[]} The Quadtree nodes to which the target rectangle belongs - */ - getChildNodes(rect) { - - // If this node has no children, use it - if ( !this.nodes.length ) return [this]; - - // Prepare data - const nodes = []; - const hx = this.bounds.x + (this.bounds.width / 2); - const hy = this.bounds.y + (this.bounds.height / 2); - - // Determine orientation relative to the node - const startTop = rect.y <= hy; - const startLeft = rect.x <= hx; - const endBottom = (rect.y + rect.height) > hy; - const endRight = (rect.x + rect.width) > hx; - - // Top-left - if ( startLeft && startTop ) nodes.push(this.nodes[Quadtree.INDICES.tl]); - - // Top-right - if ( endRight && startTop ) nodes.push(this.nodes[Quadtree.INDICES.tr]); - - // Bottom-left - if ( startLeft && endBottom ) nodes.push(this.nodes[Quadtree.INDICES.bl]); - - // Bottom-right - if ( endRight && endBottom ) nodes.push(this.nodes[Quadtree.INDICES.br]); - return nodes; - } - - /* -------------------------------------------- */ - - /** - * Identify all nodes which are adjacent to this one within the parent Quadtree. - * @returns {Quadtree[]} - */ - getAdjacentNodes() { - const bounds = this.bounds.clone().pad(1); - return this.root.getLeafNodes(bounds); - } - - /* -------------------------------------------- */ - - /** - * Visualize the nodes and objects in the quadtree - * @param {boolean} [objects] Visualize the rectangular bounds of objects in the Quadtree. Default is false. - * @private - */ - visualize({objects=false}={}) { - const debug = canvas.controls.debug; - if ( this.depth === 0 ) debug.clear().endFill(); - debug.lineStyle(2, 0x00FF00, 0.5).drawRect(this.bounds.x, this.bounds.y, this.bounds.width, this.bounds.height); - if ( objects ) { - for ( let o of this.objects ) { - debug.lineStyle(2, 0xFF0000, 0.5).drawRect(o.r.x, o.r.y, Math.max(o.r.width, 1), Math.max(o.r.height, 1)); - } - } - for ( let n of this.nodes ) { - n.visualize({objects}); - } - } -} - -/* -------------------------------------------- */ - -/** - * A subclass of Quadtree specifically intended for classifying the location of objects on the game canvas. - */ -class CanvasQuadtree extends Quadtree { - constructor(options={}) { - super({}, options); - Object.defineProperty(this, "bounds", {get: () => canvas.dimensions.rect}); - } -} - -/** - * An extension of PIXI.Mesh which emulate a PIXI.Sprite with a specific shader. - * @param [texture=PIXI.Texture.EMPTY] Texture bound to this sprite mesh. - * @param [shaderClass=BaseSamplerShader] Shader class used by this sprite mesh. - * @extends PIXI.Mesh - */ -class SpriteMesh extends PIXI.Mesh { - constructor(texture, shaderCls = BaseSamplerShader) { - // Create geometry - const geometry = new PIXI.Geometry() - .addAttribute("aVertexPosition", new PIXI.Buffer(new Float32Array(8), false), 2) - .addAttribute("aTextureCoord", new PIXI.Buffer(new Float32Array(8), true), 2) - .addIndex([0, 1, 2, 0, 2, 3]); - - // Create shader program - if ( !AbstractBaseShader.isPrototypeOf(shaderCls) ) shaderCls = BaseSamplerShader; - const shader = shaderCls.create({ - sampler: texture ?? PIXI.Texture.EMPTY - }); - - // Create state - const state = new PIXI.State(); - - // Init draw mode - const drawMode = PIXI.DRAW_MODES.TRIANGLES; - - // Create the mesh - super(geometry, shader, state, drawMode); - - /** @override */ - this._cachedTint = [1, 1, 1, 1]; - - // Initialize other data to emulate sprite - this.vertexData = this.verticesBuffer.data; - this.uvs = this.uvBuffer.data; - this.indices = geometry.indexBuffer.data; - - this._texture = null; - this._anchor = new PIXI.ObservablePoint( - this._onAnchorUpdate, - this, - (texture ? texture.defaultAnchor.x : 0), - (texture ? texture.defaultAnchor.y : 0) - ); - - this.texture = texture || PIXI.Texture.EMPTY; - this.alpha = 1; - this.tint = 0xFFFFFF; - this.blendMode = PIXI.BLEND_MODES.NORMAL; - - // Assigning some batch data that will not change during the life of this sprite mesh - this._batchData.vertexData = this.vertexData; - this._batchData.indices = this.indices; - this._batchData.uvs = this.uvs; - this._batchData.object = this; - } - - /** - * Snapshot of some parameters of this display object to render in batched mode. - * TODO: temporary object until the upstream issue is fixed: https://github.com/pixijs/pixijs/issues/8511 - * @type {{_tintRGB: number, _texture: PIXI.Texture, indices: number[], - * uvs: number[], blendMode: PIXI.BLEND_MODES, vertexData: number[], worldAlpha: number}} - * @protected - */ - _batchData = { - _texture: undefined, - vertexData: undefined, - indices: undefined, - uvs: undefined, - worldAlpha: undefined, - _tintRGB: undefined, - blendMode: undefined, - object: undefined - }; - - /** @override */ - _transformID = -1; - - /** @override */ - _textureID = -1; - - /** @override */ - _textureTrimmedID = -1; - - /** @override */ - _transformTrimmedID = -1; - - /** @override */ - _roundPixels = false; // Forced to false for SpriteMesh - - /** @override */ - vertexTrimmedData = null; - - /** @override */ - isSprite = true; - - /** - * Used to track a tint or alpha change to execute a recomputation of _cachedTint. - * @type {boolean} - */ - #tintAlphaDirty = true; - - /** - * Used to force an alpha mode on this sprite mesh. - * If this property is non null, this value will replace the texture alphaMode when computing color channels. - * Affects how tint, worldAlpha and alpha are computed each others. - * @type {PIXI.ALPHA_MODES|undefined} - */ - get alphaMode() { - return this.#alphaMode ?? this._texture?.baseTexture.alphaMode; - } - - set alphaMode(mode) { - if ( this.#alphaMode === mode ) return; - this.#alphaMode = mode; - this.#tintAlphaDirty = true; - } - - #alphaMode = null; - - /* ---------------------------------------- */ - - /** - * Returns the SpriteMesh associated batch plugin. By default the returned plugin is that of the associated shader. - * If a plugin is forced, it will returns the forced plugin. - * @type {string} - */ - get pluginName() { - return this.#pluginName ?? this.shader.pluginName; - } - - set pluginName(name) { - this.#pluginName = name; - } - - #pluginName = null; - - /* ---------------------------------------- */ - - /** @override */ - get width() { - return Math.abs(this.scale.x) * this._texture.orig.width; - } - - set width(width) { - const s = Math.sign(this.scale.x) || 1; - this.scale.x = s * width / this._texture.orig.width; - this._width = width; - } - - _width; - - /* ---------------------------------------- */ - - /** @override */ - get height() { - return Math.abs(this.scale.y) * this._texture.orig.height; - } - - set height(height) { - const s = Math.sign(this.scale.y) || 1; - this.scale.y = s * height / this._texture.orig.height; - this._height = height; - } - - _height; - - /* ---------------------------------------- */ - - /** @override */ - get texture() { - return this._texture; - } - - set texture(texture) { - texture = texture ?? null; - if ( this._texture === texture ) return; - if ( this._texture ) this._texture.off("update", this._onTextureUpdate, this); - - this._texture = texture || PIXI.Texture.EMPTY; - this._textureID = this._textureTrimmedID = -1; - this.#tintAlphaDirty = true; - - if ( texture ) { - if ( this._texture.baseTexture.valid ) this._onTextureUpdate(); - else this._texture.once("update", this._onTextureUpdate, this); - this.updateUvs(); - } - this.shader.uniforms.sampler = this._texture; - } - - _texture; - - /* ---------------------------------------- */ - - /** @override */ - get anchor() { - return this._anchor; - } - - set anchor(anchor) { - this._anchor.copyFrom(anchor); - } - - _anchor; - - /* ---------------------------------------- */ - - /** @override */ - get tint() { - return this._tint; - } - - set tint(tint) { - if ( tint === this._tint ) return; - this._tint = tint; - this._tintRGB = (tint >> 16) + (tint & 0xff00) + ((tint & 0xff) << 16); - this.#tintAlphaDirty = true; - } - - _tint; - - _tintRGB; - - /* ---------------------------------------- */ - - /** - * The HTML source element for this SpriteMesh texture. - * @type {HTMLImageElement|HTMLVideoElement|null} - */ - get sourceElement() { - if ( !this.texture.valid ) return null; - return this.texture?.baseTexture.resource?.source || null; - } - - /* ---------------------------------------- */ - - /** - * Is this SpriteMesh rendering a video texture? - * @type {boolean} - */ - get isVideo() { - const source = this.sourceElement; - return source?.tagName === "VIDEO"; - } - - /* ---------------------------------------- */ - - /** @override */ - _onTextureUpdate() { - this._textureID = this._textureTrimmedID = this._transformID = this._transformTrimmedID = -1; - if ( this._width ) this.scale.x = Math.sign(this.scale.x) * this._width / this._texture.orig.width; - if ( this._height ) this.scale.y = Math.sign(this.scale.y) * this._height / this._texture.orig.height; - // Alpha mode of the texture could have changed - this.#tintAlphaDirty = true; - } - - /* ---------------------------------------- */ - - /** @override */ - _onAnchorUpdate() { - this._textureID = this._textureTrimmedID = this._transformID = this._transformTrimmedID = -1; - } - - /* ---------------------------------------- */ - - /** - * Update uvs and push vertices and uv buffers on GPU if necessary. - */ - updateUvs() { - if ( this._textureID !== this._texture._updateID ) { - this.uvs.set(this._texture._uvs.uvsFloat32); - this.uvBuffer.update(); - } - } - - /* ---------------------------------------- */ - - /** - * Initialize shader based on the shader class type. - * @param {class} shaderCls Shader class used. Must inherit from AbstractBaseShader. - */ - setShaderClass(shaderCls) { - // Escape conditions - if ( !AbstractBaseShader.isPrototypeOf(shaderCls) ) { - throw new Error("SpriteMesh shader class must inherit from AbstractBaseShader."); - } - if ( this.shader.constructor === shaderCls ) return; - - // Create shader program - this.shader = shaderCls.create({ - sampler: this.texture ?? PIXI.Texture.EMPTY - }); - } - - /* ---------------------------------------- */ - - /** @override */ - updateTransform(parentTransform) { - super.updateTransform(parentTransform); - - // We set tintAlphaDirty to true if the worldAlpha has changed - // It is needed to recompute the _cachedTint vec4 which is a combination of tint and alpha - if ( this.#worldAlpha !== this.worldAlpha ) { - this.#worldAlpha = this.worldAlpha; - this.#tintAlphaDirty = true; - } - } - - #worldAlpha; - - /* ---------------------------------------- */ - - /** @override */ - calculateVertices() { - if ( this._transformID === this.transform._worldID && this._textureID === this._texture._updateID ) return; - - // Update uvs if necessary - this.updateUvs(); - this._transformID = this.transform._worldID; - this._textureID = this._texture._updateID; - - // Set the vertex data - const {a, b, c, d, tx, ty} = this.transform.worldTransform; - const orig = this._texture.orig; - const trim = this._texture.trim; - - let w1; let w0; let h1; let h0; - if ( trim ) { - // If the sprite is trimmed and is not a tilingsprite then we need to add the extra - // space before transforming the sprite coords - w1 = trim.x - (this._anchor._x * orig.width); - w0 = w1 + trim.width; - h1 = trim.y - (this._anchor._y * orig.height); - h0 = h1 + trim.height; - } - else { - w1 = -this._anchor._x * orig.width; - w0 = w1 + orig.width; - h1 = -this._anchor._y * orig.height; - h0 = h1 + orig.height; - } - - this.vertexData[0] = (a * w1) + (c * h1) + tx; - this.vertexData[1] = (d * h1) + (b * w1) + ty; - this.vertexData[2] = (a * w0) + (c * h1) + tx; - this.vertexData[3] = (d * h1) + (b * w0) + ty; - this.vertexData[4] = (a * w0) + (c * h0) + tx; - this.vertexData[5] = (d * h0) + (b * w0) + ty; - this.vertexData[6] = (a * w1) + (c * h0) + tx; - this.vertexData[7] = (d * h0) + (b * w1) + ty; - - this.verticesBuffer.update(); - } - - /* ---------------------------------------- */ - - /** @override */ - calculateTrimmedVertices() { - return PIXI.Sprite.prototype.calculateTrimmedVertices.call(this, ...args); - } - - /* ---------------------------------------- */ - - /** @override */ - _render(renderer) { - this.calculateVertices(); - - // Update tint if necessary - if ( this.#tintAlphaDirty ) { - this._cachedTint = - PIXI.utils.premultiplyTintToRgba(this._tint, this.worldAlpha, this._cachedTint, this.alphaMode); - this.#tintAlphaDirty = false; - } - - // Render by batch if a batched plugin is defined (or do a standard rendering) - if ( this.pluginName in renderer.plugins ) this._renderToBatch(renderer); - else this._renderDefault(renderer); - } - - /* ---------------------------------------- */ - - /** @override */ - _renderToBatch(renderer) { - this._updateBatchData(); - const batchRenderer = renderer.plugins[this.pluginName]; - renderer.batch.setObjectRenderer(batchRenderer); - batchRenderer.render(this._batchData); - } - - /* ---------------------------------------- */ - - /** @override */ - _renderDefault(renderer) { - // Update properties of the shader - this.shader?._preRender(this); - - // Draw the SpriteMesh - renderer.batch.flush(); - renderer.shader.bind(this.shader); - renderer.state.set(this.state); - renderer.geometry.bind(this.geometry, this.shader); - renderer.geometry.draw(this.drawMode, this.size, this.start); - } - - /* ---------------------------------------- */ - - /** - * Update the batch data object. - * TODO: temporary method until the upstream issue is fixed: https://github.com/pixijs/pixijs/issues/8511 - * @protected - */ - _updateBatchData() { - this._batchData._texture = this._texture; - this._batchData.worldAlpha = this.worldAlpha; - this._batchData._tintRGB = this._tintRGB; - this._batchData.blendMode = this.blendMode; - } - - /* ---------------------------------------- */ - - /** @override */ - _calculateBounds(...args) { - return PIXI.Sprite.prototype._calculateBounds.call(this, ...args); - } - - /* ---------------------------------------- */ - - /** @override */ - getLocalBounds(...args) { - return PIXI.Sprite.prototype.getLocalBounds.call(this, ...args); - } - - /* ---------------------------------------- */ - - /** @override */ - containsPoint(...args) { - return PIXI.Sprite.prototype.containsPoint.call(this, ...args); - } - - /* ---------------------------------------- */ - - /** @override */ - destroy(...args) { - this.geometry = null; - return PIXI.Sprite.prototype.destroy.call(this, ...args); - } - - /* ---------------------------------------- */ - - /** - * Create a SpriteMesh from another source. - * You can specify texture options and a specific shader class derived from AbstractBaseShader. - * @param {string|PIXI.Texture|HTMLCanvasElement|HTMLVideoElement} source Source to create texture from. - * @param {object} [textureOptions] See {@link PIXI.BaseTexture}'s constructor for options. - * @param {AbstractBaseShader} [shaderCls] The shader class to use. BaseSamplerShader by default. - * @returns {SpriteMesh} - */ - static from(source, textureOptions, shaderCls) { - const texture = source instanceof PIXI.Texture ? source : PIXI.Texture.from(source, textureOptions); - return new SpriteMesh(texture, shaderCls); - } -} - -/** - * @typedef {Object} CanvasAnimationAttribute - * @property {string} attribute The attribute name being animated - * @property {Object} parent The object within which the attribute is stored - * @property {number} to The destination value of the attribute - * @property {number} [from] An initial value of the attribute, otherwise parent[attribute] is used - * @property {number} [delta] The computed delta between to and from - * @property {number} [done] The amount of the total delta which has been animated - */ - -/** - * @typedef {Object} CanvasAnimationOptions - * @property {PIXI.DisplayObject} [context] A DisplayObject which defines context to the PIXI.Ticker function - * @property {string} [name] A unique name which can be used to reference the in-progress animation - * @property {number} [duration] A duration in milliseconds over which the animation should occur - * @property {number} [priority] A priority in PIXI.UPDATE_PRIORITY which defines when the animation - * should be evaluated related to others - * @property {Function|string} [easing] An easing function used to translate animation time or the string name - * of a static member of the CanvasAnimation class - * @property {function(number, CanvasAnimationData)} [ontick] A callback function which fires after every frame - */ - -/** - * @typedef {CanvasAnimationOptions} CanvasAnimationData - * @property {Function} fn The animation function being executed each frame - * @property {number} time The current time of the animation, in milliseconds - * @property {CanvasAnimationAttribute[]} attributes The attributes being animated - * @property {Promise} [promise] A Promise which resolves once the animation is complete - * @property {Function} [resolve] The resolution function, allowing animation to be ended early - * @property {Function} [reject] The rejection function, allowing animation to be ended early - */ - -/** - * A helper class providing utility methods for PIXI Canvas animation - */ -class CanvasAnimation { - static get ticker() { - return canvas.app.ticker; - } - - /** - * Track an object of active animations by name, context, and function - * This allows a currently playing animation to be referenced and terminated - * @type {Object} - */ - static animations = {}; - - /* -------------------------------------------- */ - - /** - * Apply an animation from the current value of some attribute to a new value - * Resolve a Promise once the animation has concluded and the attributes have reached their new target - * - * @param {CanvasAnimationAttribute[]} attributes An array of attributes to animate - * @param {CanvasAnimationOptions} options Additional options which customize the animation - * - * @returns {Promise} A Promise which resolves to true once the animation has concluded - * or false if the animation was prematurely terminated - * - * @example Animate Token Position - * ```js - * let animation = [ - * { - * parent: token, - * attribute: "x", - * to: 1000 - * }, - * { - * parent: token, - * attribute: "y", - * to: 2000 - * } - * ]; - * CanvasAnimation.animate(attributes, {duration:500}); - * ``` - */ - static async animate(attributes, {context=canvas.stage, name, duration=1000, easing, ontick, priority}={}) { - priority ??= PIXI.UPDATE_PRIORITY.LOW; - if ( typeof easing === "string" ) easing = this[easing]; - - // If an animation with this name already exists, terminate it - if ( name ) this.terminateAnimation(name); - - // Define the animation and its animation function - attributes = attributes.map(a => { - a.from = a.from ?? a.parent[a.attribute]; - a.delta = a.to - a.from; - a.done = 0; - return a; - }); - if ( attributes.length && attributes.every(a => a.delta === 0) ) return; - const animation = {attributes, context, duration, easing, name, ontick, time: 0}; - animation.fn = dt => this._animateFrame(dt, animation); - - // Create a promise which manages the animation lifecycle - const promise = new Promise((resolve, reject) => { - animation.resolve = resolve; - animation.reject = reject; - this.ticker.add(animation.fn, context, priority); - }) - - // Log any errors - .catch(err => console.error(err)) - - // Remove the animation once completed - .finally(() => { - this.ticker.remove(animation.fn, context); - const wasCompleted = name && (this.animations[name]?.fn === animation.fn); - if ( wasCompleted ) delete this.animations[name]; - }); - - // Record the animation and return - if ( name ) { - animation.promise = promise; - this.animations[name] = animation; - } - return promise; - } - - /* -------------------------------------------- */ - - /** - * Retrieve an animation currently in progress by its name - * @param {string} name The animation name to retrieve - * @returns {CanvasAnimationData} The animation data, or undefined - */ - static getAnimation(name) { - return this.animations[name]; - } - - /* -------------------------------------------- */ - - /** - * If an animation using a certain name already exists, terminate it - * @param {string} name The animation name to terminate - */ - static terminateAnimation(name) { - let animation = this.animations[name]; - if (animation) animation.resolve(false); - } - - /* -------------------------------------------- */ - - /** - * Cosine based easing with smooth in-out. - * @param {number} pt The proportional animation timing on [0,1] - * @returns {number} The eased animation progress on [0,1] - */ - static easeInOutCosine(pt) { - return (1 - Math.cos(Math.PI * pt)) * 0.5; - } - - /* -------------------------------------------- */ - - /** - * Shallow ease out. - * @param {number} pt The proportional animation timing on [0,1] - * @returns {number} The eased animation progress on [0,1] - */ - static easeOutCircle(pt) { - return Math.sqrt(1 - Math.pow(pt - 1, 2)); - } - - /* -------------------------------------------- */ - - /** - * Shallow ease in. - * @param {number} pt The proportional animation timing on [0,1] - * @returns {number} The eased animation progress on [0,1] - */ - static easeInCircle(pt) { - return 1 - Math.sqrt(1 - Math.pow(pt, 2)); - } - - /* -------------------------------------------- */ - - /** - * Generic ticker function to implement the animation. - * This animation wrapper executes once per frame for the duration of the animation event. - * Once the animated attributes have converged to their targets, it resolves the original Promise. - * The user-provided ontick function runs each frame update to apply additional behaviors. - * - * @param {number} deltaTime The incremental time which has elapsed - * @param {CanvasAnimationData} animation The animation which is being performed - * @private - */ - static _animateFrame(deltaTime, animation) { - const {attributes, duration, ontick} = animation; - - // Compute animation timing and progress - const dt = this.ticker.elapsedMS; // Delta time in MS - animation.time += dt; // Total time which has elapsed - const pt = animation.time / duration; // Proportion of total duration - const complete = animation.time >= duration; - const pa = complete ? 1 : (animation.easing ? animation.easing(pt) : pt); - - // Update each attribute - try { - for ( let a of attributes ) { - - // Snap to final target - if ( complete ) { - a.parent[a.attribute] = a.to; - a.done = a.delta; - } - - // Continue animating - else { - const da = a.delta * pa; - a.parent[a.attribute] = a.from + da; - a.done = da; - } - } - - // Callback function - if ( ontick ) ontick(dt, animation); - } - - // Terminate the animation if any errors occur - catch(err) { - animation.reject(err); - } - - // Resolve the original promise once the animation is complete - if ( complete ) animation.resolve(true); - } - - /* -------------------------------------------- */ - /* DEPRECATIONS */ - /* -------------------------------------------- */ - - /** - * @alias CanvasAnimation.animate - * @see {CanvasAnimation.animate} - * @deprecated since v10 - * @ignore - */ - static async animateLinear(attributes, options) { - foundry.utils.logCompatibilityWarning("You are calling CanvasAnimation.animateLinear which is deprecated in favor " - + "of CanvasAnimation.animate", {since: 10, until: 12}); - return this.animate(attributes, options); - } -} - -/** - * A generic helper for drawing a standard Control Icon - * @type {PIXI.Container} - */ -class ControlIcon extends PIXI.Container { - constructor({texture, size=40, borderColor=0xFF5500, tint=null}={}, ...args) { - super(...args); - - // Define arguments - this.iconSrc = texture; - this.size = size; - this.rect = [-2, -2, size+4, size+4]; - this.borderColor = borderColor; - - /** - * The color of the icon tint, if any - * @type {number|null} - */ - this.tintColor = tint; - - // Define hit area - this.interactive = true; - this.interactiveChildren = false; - this.hitArea = new PIXI.Rectangle(...this.rect); - this.buttonMode = true; - - // Background - this.bg = this.addChild(new PIXI.Graphics()); - - // Icon - this.icon = this.addChild(new PIXI.Sprite()); - - // Border - this.border = this.addChild(new PIXI.Graphics()); - - // Draw asynchronously - this.draw(); - } - - /* -------------------------------------------- */ - - async draw() { - - // Load the icon texture - this.texture = this.texture ?? await loadTexture(this.iconSrc); - - // Don't draw a destroyed Control - if ( this.destroyed ) return this; - - // Draw background - this.bg.clear().beginFill(0x000000, 0.4).lineStyle(2, 0x000000, 1.0).drawRoundedRect(...this.rect, 5).endFill(); - - // Draw border - this.border.clear().lineStyle(2, this.borderColor, 1.0).drawRoundedRect(...this.rect, 5).endFill(); - this.border.visible = false; - - // Draw icon - this.icon.texture = this.texture; - this.icon.width = this.icon.height = this.size; - this.icon.tint = Number.isNumeric(this.tintColor) ? this.tintColor : 0xFFFFFF; - return this; - } -} - -/** - * Handle mouse interaction events for a Canvas object. - * There are three phases of events: hover, click, and drag - * - * Hover Events: - * _handleMouseOver - * action: hoverIn - * _handleMouseOut - * action: hoverOut - * - * Left Click and Double-Click - * _handleMouseDown - * action: clickLeft - * action: clickLeft2 - * - * Right Click and Double-Click - * _handleRightDown - * action: clickRight - * action: clickRight2 - * - * Drag and Drop - * _handleMouseMove - * action: dragLeftStart - * action: dragLeftMove - * action: dragRightStart - * action: dragLeftMove - * _handleMouseUp - * action: dragLeftDrop - * action: dragRightDrop - * _handleDragCancel - * action: dragLeftCancel - * action: dragRightCancel - */ -class MouseInteractionManager { - constructor(object, layer, permissions={}, callbacks={}, options={}) { - this.object = object; - this.layer = layer; - this.permissions = permissions; - this.callbacks = callbacks; - - /** - * Interaction options which configure handling workflows - * @type {{target: PIXI.DisplayObject, dragResistance: number}} - */ - this.options = options; - - /** - * The current interaction state - * @type {number} - */ - this.state = this.states.NONE; - - /** - * Bound handlers which can be added and removed - * @type {Object} - */ - this.handlers = {}; - - /** - * The drag handling time - * @type {number} - */ - this.dragTime = 0; - - /** - * The throttling time below which a mouse move event will not be handled - * @type {number} - * @private - */ - this._dragThrottleMS = Math.ceil(1000 / (canvas.app.ticker.maxFPS || 60)); - - /** - * The time of the last left-click event - * @type {number} - */ - this.lcTime = 0; - - /** - * The time of the last right-click event - * @type {number} - */ - this.rcTime = 0; - - /** - * A flag for whether we are right-click dragging - * @type {boolean} - */ - this._dragRight = false; - - /** - * An optional ControlIcon instance for the object - * @type {ControlIcon} - */ - this.controlIcon = this.options.target ? this.object[this.options.target] : undefined; - } - - /** - * Enumerate the states of a mouse interaction workflow. - * 0: NONE - the object is inactive - * 1: HOVER - the mouse is hovered over the object - * 2: CLICKED - the object is clicked - * 3: DRAG - the object is being dragged - * 4: DROP - the object is being dropped - * @enum {number} - */ - static INTERACTION_STATES = { - NONE: 0, - HOVER: 1, - CLICKED: 2, - DRAG: 3, - DROP: 4 - }; - - /** - * The number of milliseconds of mouse click depression to consider it a long press. - * @type {number} - */ - static LONG_PRESS_DURATION_MS = 500; - - /** - * Global timeout for the long-press event. - * @type {number|null} - */ - static longPressTimeout = null; - - /* -------------------------------------------- */ - - /** - * Get the target - * @return {*} - */ - get target() { - return this.options.target ? this.object[this.options.target] : this.object; - } - - /* -------------------------------------------- */ - - /** - * Activate interactivity for the handled object - */ - activate() { - - // Remove existing listeners - this.state = this.states.NONE; - this.target.removeAllListeners(); - - // Create bindings for all handler functions - this.handlers = { - mouseover: this._handleMouseOver.bind(this), - mouseout: this._handleMouseOut.bind(this), - mousedown: this._handleMouseDown.bind(this), - rightdown: this._handleRightDown.bind(this), - mousemove: this._handleMouseMove.bind(this), - mouseup: this._handleMouseUp.bind(this), - contextmenu: this._handleDragCancel.bind(this) - }; - - // Activate hover events to start the workflow - this._activateHoverEvents(); - - // Set the target as interactive - this.target.interactive = true; - return this; - } - - /* -------------------------------------------- */ - - /** - * Test whether the current user has permission to perform a step of the workflow - * @param {string} action The action being attempted - * @param {Event} event The event being handled - * @returns {boolean} Can the action be performed? - */ - can(action, event) { - const fn = this.permissions[action]; - if ( typeof fn === "boolean" ) return fn; - if ( fn instanceof Function ) return fn.call(this.object, game.user, event); - return true; - } - - /* -------------------------------------------- */ - - /** - * Execute a callback function associated with a certain action in the workflow - * @param {string} action The action being attempted - * @param {Event} event The event being handled - * @param {...*} args Additional callback arguments. - */ - callback(action, event, ...args) { - const fn = this.callbacks[action]; - if ( fn instanceof Function ) return fn.call(this.object, event, ...args); - } - - /* -------------------------------------------- */ - - /** - * A reference to the possible interaction states which can be observed - * @return {Object} - */ - get states() { - return this.constructor.INTERACTION_STATES; - } - - /* -------------------------------------------- */ - /* Listener Activation and Deactivation */ - /* -------------------------------------------- */ - - /** - * Activate a set of listeners which handle hover events on the target object - * @private - */ - _activateHoverEvents() { - - // Disable and re-register mouseover and mouseout handlers - this.target.off("mouseover", this.handlers.mouseover).on("mouseover", this.handlers.mouseover); - this.target.off("mouseout", this.handlers.mouseout).on("mouseout", this.handlers.mouseout); - - // Add a one-time mousemove event in case our cursor is already over the target element - this.target.once("mousemove", this.handlers.mouseover); - } - - /* -------------------------------------------- */ - - /** - * Activate a new set of listeners for click events on the target object - * @private - */ - _activateClickEvents() { - this._deactivateClickEvents(); - this.target.on("mousedown", this.handlers.mousedown); - this.target.on("mouseup", this.handlers.mouseup); - this.target.on("mouseupoutside", this.handlers.mouseup); - this.target.on("rightdown", this.handlers.rightdown); - this.target.on("rightup", this.handlers.mouseup); - this.target.on("rightupoutside", this.handlers.mouseup); - } - - /* -------------------------------------------- */ - - /** - * Deactivate event listeners for click events on the target object - * @private - */ - _deactivateClickEvents() { - this.target.off("mousedown", this.handlers.mousedown); - this.target.off("mouseup", this.handlers.mouseup); - this.target.off("mouseupoutside", this.handlers.mouseup); - this.target.off("rightdown", this.handlers.rightdown); - this.target.off("rightup", this.handlers.mouseup); - this.target.off("rightupoutside", this.handlers.mouseup); - } - - /* -------------------------------------------- */ - - /** - * Activate events required for handling a drag-and-drop workflow - * @private - */ - _activateDragEvents() { - this._deactivateDragEvents(); - if ( CONFIG.debug.mouseInteraction ) console.log(`${this.object.constructor.name} | activateDragEvents`); - this.layer.on("mousemove", this.handlers.mousemove); - if ( !this._dragRight ) { - canvas.app.view.addEventListener("contextmenu", this.handlers.contextmenu, {capture: true}); - } - } - - /* -------------------------------------------- */ - - /** - * Deactivate events required for handling drag-and-drop workflow. - * @private - */ - _deactivateDragEvents() { - if ( CONFIG.debug.mouseInteraction ) console.log(`${this.object.constructor.name} | deactivateDragEvents`); - this.layer.off("mousemove", this.handlers.mousemove); - canvas.app.view.removeEventListener("contextmenu", this.handlers.contextmenu, {capture: true}); - } - - /* -------------------------------------------- */ - /* Hover In and Hover Out */ - /* -------------------------------------------- */ - - /** - * Handle mouse-over events which activate downstream listeners and do not stop propagation. - * @private - */ - _handleMouseOver(event) { - - // Ignore hover events during a drag workflow - if ( this.state >= this.states.DRAG ) return; - - // Handle new hover events - const action = "hoverIn"; - if ( !this.object.controlled) this.state = this.states.NONE; - if ( this.state !== this.states.NONE ) return; - if ( !this.can(action, event) ) return; - if ( CONFIG.debug.mouseInteraction ) console.log(`${this.object.constructor.name} | ${action}`); - - // Activate click event listeners - this._activateClickEvents(); - - // Assign event data and call the provided handler - event.data.object = this.object; - this.state = Math.max(this.state || 0, this.states.HOVER); - - // Callback - return this.callback(action, event); - } - - /* -------------------------------------------- */ - - /** - * Handle mouse-out events which terminate hover workflows and do not stop propagation. - * @private - */ - _handleMouseOut(event) { - const action = "hoverOut"; - if ( (this.state === this.states.NONE) || (this.state >= this.states.DRAG) ) return; - - // Downgrade hovers by deactivating events - if ( this.state === this.states.HOVER ) { - this.state = this.states.NONE; - this._deactivateClickEvents(); - } - - // Handle callback actions if permitted - if ( !this.can(action, event) ) return; - if ( CONFIG.debug.mouseInteraction ) console.log(`${this.object.constructor.name} | ${action}`); - return this.callback(action, event); - } - - /* -------------------------------------------- */ - /* Left Click and Double Click */ - /* -------------------------------------------- */ - - /** - * Handle mouse-down events which activate downstream listeners. - * Stop further propagation only if the event is allowed by either single or double-click. - * @private - */ - _handleMouseDown(event) { - if ( ![this.states.HOVER, this.states.CLICKED, this.states.DRAG].includes(this.state) ) return; - if ( event.data.originalEvent.button !== 0 ) return; // Only support standard left-click - canvas.currentMouseManager = this; - - // Determine double vs single click - const now = Date.now(); - const isDouble = (now - this.lcTime) <= 250; - this.lcTime = now; - - // Update event data - event.data.object = this.object; - // We store the origin in a separate variable from the event here so that the setTimeout below can close around it. - // This is a workaround for what looks like a strange PIXI bug, where any interaction with an HTML - ${game.i18n.localize("NOTE.CreateJournal")} - - - ` - }); - let response; - try { - response = await Dialog.prompt({ - title, - content: html, - label: game.i18n.localize("NOTE.Create"), - callback: html => { - const form = html.querySelector("form"); - const fd = new FormDataExtended(form).object; - if ( !fd.folder ) delete fd.folder; - if ( fd.journal ) return JournalEntry.implementation.create(fd, {renderSheet: true}); - return fd.name; - }, - render: html => { - const form = html.querySelector("form"); - const folder = form.elements.folder; - if ( !folder ) return; - folder.disabled = true; - form.elements.journal.addEventListener("change", event => { - folder.disabled = !event.currentTarget.checked; - }); - }, - options: {jQuery: false} - }); - } catch(err) { - return; - } - - // Create a note for a created JournalEntry - const noteData = {x, y}; - if ( response.id ) { - noteData.entryId = response.id; - const cls = getDocumentClass("Note"); - return cls.create(noteData, {parent: canvas.scene}); - } - - // Create a preview un-linked Note - else { - noteData.text = response; - return this._createPreview(noteData, {top: event.clientY - 20, left: event.clientX + 40}); - } - } - - /* -------------------------------------------- */ - - /** - * Handle JournalEntry document drop data - * @param {DragEvent} event The drag drop event - * @param {object} data The dropped data transfer data - * @protected - */ - async _onDropData(event, data) { - let entry; - let {x, y} = data; - if ( (x === undefined) || (y === undefined) ) { - const coords = this._canvasCoordinatesFromDrop(event); - if ( !coords ) return false; - [x, y] = coords; - } - const noteData = {x, y}; - if ( data.type === "JournalEntry" ) entry = await JournalEntry.implementation.fromDropData(data); - if ( data.type === "JournalEntryPage" ) { - const page = await JournalEntryPage.implementation.fromDropData(data); - entry = page.parent; - noteData.pageId = page.id; - } - if ( entry?.compendium ) { - const journalData = game.journal.fromCompendium(entry); - entry = await JournalEntry.implementation.create(journalData); - } - noteData.entryId = entry?.id; - return this._createPreview(noteData, {top: event.clientY - 20, left: event.clientX + 40}); - } -} - -/** - * This Canvas Layer provides a container for AmbientSound objects. - * @category - Canvas - */ -class SoundsLayer extends PlaceablesLayer { - - /** - * Track whether to actively preview ambient sounds with mouse cursor movements - * @type {boolean} - */ - livePreview = false; - - /** - * A mapping of ambient audio sources which are active within the rendered Scene - * @type {Collection} - */ - sources = new foundry.utils.Collection(); - - /* -------------------------------------------- */ - - /** @inheritdoc */ - static get layerOptions() { - return foundry.utils.mergeObject(super.layerOptions, { - name: "sounds", - zIndex: 300 - }); - } - - /** @inheritdoc */ - static documentName = "AmbientSound"; - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get hookName() { - return SoundsLayer.name; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _tearDown(options) { - this.stopAll(); - return super._tearDown(options); - } - - /* -------------------------------------------- */ - - /** - * Initialize all AmbientSound sources which are present on this layer - */ - initializeSources() { - for ( let sound of this.placeables ) { - sound.updateSource({defer: true}); - } - } - - /* -------------------------------------------- */ - - /** - * Update all AmbientSound effects in the layer by toggling their playback status. - * Sync audio for the positions of tokens which are capable of hearing. - * @param {object} [options={}] Additional options forwarded to AmbientSound synchronization - */ - refresh(options={}) { - if ( !this.placeables.length ) return; - if ( game.audio.locked ) { - return game.audio.pending.push(() => this.refresh(options)); - } - let listeners = canvas.tokens.controlled.map(t => t.center); - if ( !listeners.length && !game.user.isGM ) listeners = canvas.tokens.placeables.reduce((arr, t) => { - if ( t.actor?.isOwner && t.isVisible ) arr.push(t.center); - return arr; - }, []); - this._syncPositions(listeners, options); - } - - /* -------------------------------------------- */ - - /** - * Preview ambient audio for a given mouse cursor position - * @param {Point} position The cursor position to preview - */ - previewSound(position) { - if ( !this.placeables.length || game.audio.locked ) return; - return this._syncPositions([position], {fade: 50}); - } - - /* -------------------------------------------- */ - - /** - * Terminate playback of all ambient audio sources - */ - stopAll() { - this.placeables.forEach(s => s.sync(false)); - } - - /* -------------------------------------------- */ - - /** - * Sync the playing state and volume of all AmbientSound objects based on the position of listener points - * @param {Point[]} listeners Locations of listeners which have the capability to hear - * @param {object} [options={}] Additional options forwarded to AmbientSound synchronization - * @private - */ - _syncPositions(listeners, options) { - if ( !this.placeables.length || game.audio.locked ) return; - const sounds = {}; - for ( let sound of this.placeables ) { - const p = sound.document.path; - const r = sound.radius; - if ( !p ) continue; - - // Track one audible object per unique sound path - if ( !(p in sounds) ) sounds[p] = {path: p, audible: false, volume: 0, sound}; - const s = sounds[p]; - if ( !sound.isAudible ) continue; // The sound may not be currently audible - - // Determine whether the sound is audible, and its greatest audible volume - for ( let l of listeners ) { - if ( !sound.source.active || !sound.source.los?.contains(l.x, l.y) ) continue; - s.audible = true; - const distance = Math.hypot(l.x - sound.x, l.y - sound.y); - let volume = sound.document.volume; - if ( sound.document.easing ) volume *= this._getEasingVolume(distance, r); - if ( !s.volume || (volume > s.volume) ) s.volume = volume; - } - } - - // For each audible sound, sync at the target volume - for ( let s of Object.values(sounds) ) { - s.sound.sync(s.audible, s.volume, options); - } - } - - /* -------------------------------------------- */ - - /** - * Define the easing function used to map radial distance to volume. - * Uses cosine easing which graduates from volume 1 at distance 0 to volume 0 at distance 1 - * @returns {number} The target volume level - * @private - */ - _getEasingVolume(distance, radius) { - const x = Math.clamped(distance, 0, radius) / radius; - return (Math.cos(Math.PI * x) + 1) * 0.5; - } - - /* -------------------------------------------- */ - - /** - * Actions to take when the darkness level of the Scene is changed - * @param {number} darkness The new darkness level - * @param {number} prior The prior darkness level - * @internal - */ - _onDarknessChange(darkness, prior) { - if ( !this.active ) return; - for ( let sound of this.placeables ) { - if ( sound.isAudible !== sound.source.active ) { - sound.updateSource({defer: true}); - sound.refresh(); - } - } - this.refresh(); - } - - /* -------------------------------------------- */ - /* Event Listeners and Handlers */ - /* -------------------------------------------- */ - - /** - * Handle mouse cursor movements which may cause ambient audio previews to occur - * @param {PIXI.InteractionEvent} event The initiating mouse move interaction event - */ - _onMouseMove(event) { - if ( !this.livePreview ) return; - if ( canvas.tokens.active && canvas.tokens.controlled.length ) return; - const position = event.data.getLocalPosition(this); - this.previewSound(position); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _onDragLeftStart(event) { - await super._onDragLeftStart(event); - - // Create a pending AmbientSoundDocument - const cls = getDocumentClass("AmbientSound"); - const doc = new cls({type: "l", ...event.data.origin}, {parent: canvas.scene}); - - // Create the preview AmbientSound object - const sound = new this.constructor.placeableClass(doc); - event.data.preview = this.preview.addChild(sound); - this.preview._creating = false; - return sound.draw(); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDragLeftMove(event) { - const { destination, createState, preview, origin } = event.data; - if ( createState === 0 ) return; - const d = canvas.dimensions; - const radius = Math.hypot(destination.x - origin.x, destination.y - origin.y); - preview.document.radius = radius * (d.distance / d.size); - preview.updateSource(); - preview.refresh(); - event.data.createState = 2; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _onDragLeftDrop(event) { - const { createState, destination, origin, preview } = event.data; - if ( createState !== 2 ) return; - - // Render the preview sheet for confirmation - const radius = Math.hypot(destination.x - origin.x, destination.y - origin.y); - if ( radius < (canvas.dimensions.size / 2) ) return; - - // Clean the data and render the creation sheet - preview.updateSource({ - x: Math.round(preview.document.x), - y: Math.round(preview.document.y), - radius: Math.floor(preview.document.radius * 100) / 100 - }); - preview.sheet.render(true); - this.preview._creating = true; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDragLeftCancel(event) { - if ( this.preview._creating ) return; - return super._onDragLeftCancel(event); - } - - /* -------------------------------------------- */ - - /** - * Handle PlaylistSound document drop data. - * @param {DragEvent} event The drag drop event - * @param {object} data The dropped transfer data. - */ - async _onDropData(event, data) { - const playlistSound = await PlaylistSound.implementation.fromDropData(data); - if ( !playlistSound ) return false; - - // Get the world-transformed drop position. - const coords = this._canvasCoordinatesFromDrop(event); - if ( !coords ) return false; - const soundData = { - path: playlistSound.path, - volume: playlistSound.volume, - x: coords[0], - y: coords[1], - radius: canvas.dimensions.distance * 2 - }; - return this._createPreview(soundData, {top: event.clientY - 20, left: event.clientX + 40}); - } -} - -/** - * This Canvas Layer provides a container for MeasuredTemplate objects. - * @category - Canvas - */ -class TemplateLayer extends PlaceablesLayer { - - /** @inheritdoc */ - static get layerOptions() { - return foundry.utils.mergeObject(super.layerOptions, { - name: "templates", - canDragCreate: true, - rotatableObjects: true, - sortActiveTop: true, // TODO this needs to be removed - zIndex: 50 - }); - } - - /** @inheritdoc */ - static documentName = "MeasuredTemplate"; - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get hookName() { - return TemplateLayer.name; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** @inheritDoc */ - _activate() { - super._activate(); - for ( const t of this.placeables ) t.controlIcon.visible = t.ruler.visible = t.isVisible; - } - - /* -------------------------------------------- */ - - /** @inheritDoc */ - _deactivate() { - super._deactivate(); - this.objects.visible = true; - for ( const t of this.placeables ) t.controlIcon.visible = t.ruler.visible = false; - } - - /* -------------------------------------------- */ - - /** - * Register game settings used by the TemplatesLayer - */ - static registerSettings() { - game.settings.register("core", "coneTemplateType", { - name: "TEMPLATE.ConeTypeSetting", - hint: "TEMPLATE.ConeTypeSettingHint", - scope: "world", - config: true, - default: "round", - type: String, - choices: { - flat: "TEMPLATE.ConeTypeFlat", - round: "TEMPLATE.ConeTypeRound" - }, - onChange: () => canvas.templates?.placeables.filter(t => t.document.t === "cone").forEach(t => t.draw()) - }); - } - - /* -------------------------------------------- */ - /* Event Listeners and Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _onDragLeftStart(event) { - await super._onDragLeftStart(event); - const {origin, originalEvent} = event.data; - - // Create a pending MeasuredTemplateDocument - const tool = game.activeTool; - const previewData = { - user: game.user.id, - t: tool, - x: origin.x, - y: origin.y, - distance: 1, - direction: 0, - fillColor: game.user.color || "#FF0000", - hidden: originalEvent.altKey - }; - const defaults = CONFIG.MeasuredTemplate.defaults; - if ( tool === "cone") previewData.angle = defaults.angle; - else if ( tool === "ray" ) previewData.width = (defaults.width * canvas.dimensions.distance); - const cls = getDocumentClass("MeasuredTemplate"); - const doc = new cls(previewData, {parent: canvas.scene}); - - // Create a preview MeasuredTemplate object - const template = new this.constructor.placeableClass(doc); - event.data.preview = this.preview.addChild(template); - return template.draw(); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDragLeftMove(event) { - const { destination, createState, preview, origin } = event.data; - if ( createState === 0 ) return; - - // Snap the destination to the grid - event.data.destination = canvas.grid.getSnappedPosition(destination.x, destination.y, this.gridPrecision); - - // Compute the ray - const ray = new Ray(origin, destination); - const ratio = (canvas.dimensions.size / canvas.dimensions.distance); - - // Update the preview object - preview.document.direction = Math.normalizeDegrees(Math.toDegrees(ray.angle)); - preview.document.distance = ray.distance / ratio; - preview.refresh(); - - // Confirm the creation state - event.data.createState = 2; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onMouseWheel(event) { - - // Determine whether we have a hovered template? - const template = this.hover; - if ( !template ) return; - - // Determine the incremental angle of rotation from event data - let snap = event.shiftKey ? 15 : 5; - let delta = snap * Math.sign(event.delta); - return template.rotate(template.document.direction + delta, snap); - } -} - -/** - * A PlaceablesLayer designed for rendering the visual Scene for a specific vertical cross-section. - * @category - Canvas - */ -class TilesLayer extends PlaceablesLayer { - - /** @inheritdoc */ - static documentName = "Tile"; - - /* -------------------------------------------- */ - /* Layer Attributes */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - static get layerOptions() { - return foundry.utils.mergeObject(super.layerOptions, { - name: "tiles", - zIndex: 0, - controllableObjects: true, - rotatableObjects: true, - elevationSorting: true - }); - } - - /* -------------------------------------------- */ - - /** - * A mapping of url to texture data - * @type {Map} - */ - textureDataMap = new Map(); - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get hookName() { - return TilesLayer.name; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get hud() { - return canvas.hud.tile; - } - - /* -------------------------------------------- */ - - /** - * An array of Tile objects which are rendered within the objects container - * @type {Tile[]} - */ - get tiles() { - return this.objects?.children || []; - } - - /* -------------------------------------------- */ - - /** - * Get an array of overhead Tile objects which are roofs - * @returns {Tile[]} - */ - get roofs() { - return this.placeables.filter(t => t.isRoof); - } - - /* -------------------------------------------- */ - - /** - * Determine whether to display roofs - * @type {boolean} - */ - get displayRoofs() { - const restrictVision = !game.user.isGM - || (canvas.tokens.controlled.length > 0) || (canvas.effects.visionSources.size > 0); - return (this.active && ui.controls.control.foreground) || restrictVision; - } - - /* -------------------------------------------- */ - - /** - * A convenience reference to the tile occlusion mask on the primary canvas group. - * @type {CachedContainer} - */ - get depthMask() { - return canvas.masks.depth; - } - - /* -------------------------------------------- */ - /* Layer Methods */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - _activate() { - super._activate(); - this._activateSubLayer(!!ui.controls.control.foreground); - canvas.perception.update({refreshLighting: true, refreshTiles: true}, true); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _deactivate() { - super._deactivate(); - this.objects.visible = true; - canvas.perception.update({refreshLighting: true, refreshTiles: true}, true); - } - - /* -------------------------------------------- */ - - /** - * Activate a sublayer of the tiles layer, which controls interactivity of placeables and release controlled objects. - * @param {boolean} [foreground=false] Which sublayer need to be activated? Foreground or background? - * @internal - */ - _activateSubLayer(foreground=false) { - for ( const tile of this.tiles ) { - tile.interactive = tile.document.overhead === foreground; - if ( tile.controlled ) tile.release(); - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _tearDown(options) { - for ( let tile of this.tiles ) { - if ( tile.isVideo ) { - game.video.stop(tile.sourceElement); - } - } - this.textureDataMap.clear(); - return super._tearDown(options); - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _onDragLeftStart(event) { - await super._onDragLeftStart(event); - const tile = this.constructor.placeableClass.createPreview(event.data.origin); - event.data.preview = this.preview.addChild(tile); - this.preview._creating = false; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDragLeftMove(event) { - const { destination, createState, preview, origin, originalEvent } = event.data; - if ( createState === 0 ) return; - - // Determine the drag distance - const dx = destination.x - origin.x; - const dy = destination.y - origin.y; - const dist = Math.min(Math.abs(dx), Math.abs(dy)); - - // Update the preview object - preview.document.width = (originalEvent.altKey ? dist * Math.sign(dx) : dx); - preview.document.height = (originalEvent.altKey ? dist * Math.sign(dy) : dy); - if ( !originalEvent.shiftKey ) { - const half = canvas.dimensions.size / 2; - preview.document.width = preview.document.width.toNearest(half); - preview.document.height = preview.document.height.toNearest(half); - } - preview.refresh(); - - // Confirm the creation state - event.data.createState = 2; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDragLeftDrop(event) { - const { createState, preview } = event.data; - if ( createState !== 2 ) return; - const doc = preview.document; - - // Re-normalize the dropped shape - const r = new PIXI.Rectangle(doc.x, doc.y, doc.width, doc.height).normalize(); - preview.document.updateSource(r); - - // Require a minimum created size - if ( Math.hypot(r.width, r.height) < (canvas.dimensions.size / 2) ) return; - - // Render the preview sheet for confirmation - preview.sheet.render(true, {preview: true}); - this.preview._creating = true; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDragLeftCancel(event) { - if ( this.preview._creating ) return; - return super._onDragLeftCancel(event); - } - - /* -------------------------------------------- */ - - /** - * Handle drop events for Tile data on the Tiles Layer - * @param {DragEvent} event The concluding drag event - * @param {object} data The extracted Tile data - * @private - */ - async _onDropData(event, data) { - if ( !data.texture?.src ) return; - if ( !this.active ) this.activate(); - - // Get the data for the tile to create - const createData = await this._getDropData(event, data); - - // Validate that the drop position is in-bounds and snap to grid - if ( !canvas.dimensions.rect.contains(createData.x, createData.y) ) return false; - - // Create the Tile Document - const cls = getDocumentClass(this.constructor.documentName); - return cls.create(createData, {parent: canvas.scene}); - } - - /* -------------------------------------------- */ - - /** - * Prepare the data object when a new Tile is dropped onto the canvas - * @param {DragEvent} event The concluding drag event - * @param {object} data The extracted Tile data - * @returns {object} The prepared data to create - */ - async _getDropData(event, data) { - - // Determine the tile size - const tex = await loadTexture(data.texture.src); - const ratio = canvas.dimensions.size / (data.tileSize || canvas.dimensions.size); - data.width = tex.baseTexture.width * ratio; - data.height = tex.baseTexture.height * ratio; - data.overhead = ui.controls.controls.find(c => c.layer === "tiles").foreground ?? false; - - // Determine the final position and snap to grid unless SHIFT is pressed - data.x = data.x - (data.width / 2); - data.y = data.y - (data.height / 2); - if ( !event.shiftKey ) { - const {x, y} = canvas.grid.getSnappedPosition(data.x, data.y); - data.x = x; - data.y = y; - } - - // Create the tile as hidden if the ALT key is pressed - if ( event.altKey ) data.hidden = true; - return data; - } -} - -/** - * The Tokens Container. - * @category - Canvas - */ -class TokenLayer extends PlaceablesLayer { - - /** - * The current index position in the tab cycle - * @type {number|null} - * @private - */ - _tabIndex = null; - - /* -------------------------------------------- */ - - /** @inheritdoc */ - static get layerOptions() { - return foundry.utils.mergeObject(super.layerOptions, { - name: "tokens", - canDragCreate: false, - controllableObjects: true, - rotatableObjects: true, - elevationSorting: true, - zIndex: 100 - }); - } - - /** @inheritdoc */ - static documentName = "Token"; - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get hookName() { - return TokenLayer.name; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get gridPrecision() { - return 1; // Snap tokens to top-left - } - - /* -------------------------------------------- */ - /* Properties - /* -------------------------------------------- */ - - /** - * Token objects on this layer utilize the TokenHUD - */ - get hud() { - return canvas.hud.token; - } - - /** - * An Array of tokens which belong to actors which are owned - * @type {Token[]} - */ - get ownedTokens() { - return this.placeables.filter(t => t.actor && t.actor.isOwner); - } - - /* -------------------------------------------- */ - /* Methods - /* -------------------------------------------- */ - - /** @inheritDoc */ - async _draw(options) { - await super._draw(options); - canvas.app.ticker.add(this._animateTargets, this); - } - - /* -------------------------------------------- */ - - /** @inheritDoc */ - async _tearDown(options) { - this.concludeAnimation(); - return super._tearDown(options); - } - - /* -------------------------------------------- */ - - /** @inheritDoc */ - _activate() { - super._activate(); - if ( canvas.controls ) canvas.controls.doors.visible = true; - this._tabIndex = null; - } - - /* -------------------------------------------- */ - - /** @inheritDoc */ - _deactivate() { - super._deactivate(); - if ( this.objects ) this.objects.visible = true; - if ( canvas.controls ) canvas.controls.doors.visible = false; - } - - /* -------------------------------------------- */ - - /** - * Target all Token instances which fall within a coordinate rectangle. - * - * @param {object} rectangle The selection rectangle. - * @param {number} rectangle.x The top-left x-coordinate of the selection rectangle - * @param {number} rectangle.y The top-left y-coordinate of the selection rectangle - * @param {number} rectangle.width The width of the selection rectangle - * @param {number} rectangle.height The height of the selection rectangle - * @param {object} [options] Additional options to configure targeting behaviour. - * @param {boolean} [options.releaseOthers=true] Whether or not to release other targeted tokens - * @returns {number} The number of Token instances which were targeted. - */ - targetObjects({x, y, width, height}, {releaseOthers=true}={}) { - const user = game.user; - - // Get the set of targeted tokens - const targets = this.placeables.filter(obj => { - if ( !obj.visible ) return false; - let c = obj.center; - return Number.between(c.x, x, x+width) && Number.between(c.y, y, y+height); - }); - - // Maybe release other targets - if ( releaseOthers ) { - for ( let t of user.targets ) { - if ( !targets.includes(t) ) t.setTarget(false, {releaseOthers: false, groupSelection: true}); - } - } - - // Acquire targets for tokens which are not yet targeted - targets.forEach(t => { - if ( !user.targets.has(t) ) t.setTarget(true, {releaseOthers: false, groupSelection: true}); - }); - - // Broadcast the target change - user.broadcastActivity({targets: user.targets.ids}); - - // Return the number of targeted tokens - return user.targets.size; - } - - /* -------------------------------------------- */ - - /** - * Cycle the controlled token by rotating through the list of Owned Tokens that are available within the Scene - * Tokens are currently sorted in order of their TokenID - * - * @param {boolean} forwards Which direction to cycle. A truthy value cycles forward, while a false value - * cycles backwards. - * @param {boolean} reset Restart the cycle order back at the beginning? - * @returns {Token|null} The Token object which was cycled to, or null - */ - cycleTokens(forwards, reset) { - let next = null; - if ( reset ) this._tabIndex = null; - const order = this._getCycleOrder(); - - // If we are not tab cycling, try and jump to the currently controlled or impersonated token - if ( this._tabIndex === null ) { - this._tabIndex = 0; - - // Determine the ideal starting point based on controlled tokens or the primary character - let current = this.controlled.length ? order.find(t => this.controlled.includes(t)) : null; - if ( !current && game.user.character ) { - const actorTokens = game.user.character.getActiveTokens(); - current = actorTokens.length ? order.find(t => actorTokens.includes(t)) : null; - } - current = current || order[this._tabIndex] || null; - - // Either start cycling, or cancel - if ( !current ) return null; - next = current; - } - - // Otherwise, cycle forwards or backwards - else { - if ( forwards ) this._tabIndex = this._tabIndex < (order.length - 1) ? this._tabIndex + 1 : 0; - else this._tabIndex = this._tabIndex > 0 ? this._tabIndex - 1 : order.length - 1; - next = order[this._tabIndex]; - if ( !next ) return null; - } - - // Pan to the token and control it (if possible) - canvas.animatePan({x: next.center.x, y: next.center.y, duration: 250}); - next.control(); - return next; - } - - /* -------------------------------------------- */ - - /** - * Add or remove the set of currently controlled Tokens from the active combat encounter - * @param {boolean} state The desired combat state which determines if each Token is added (true) or - * removed (false) - * @param {Combat|null} combat A Combat encounter from which to add or remove the Token - * @param {Token|null} [token] A specific Token which is the origin of the group toggle request - * @return {Promise} The Combatants added or removed - */ - async toggleCombat(state=true, combat=null, {token=null}={}) { - // Process each controlled token, as well as the reference token - const tokens = this.controlled.filter(t => t.inCombat !== state); - if ( token && !token.controlled && (token.inCombat !== state) ) tokens.push(token); - - // Reference the combat encounter displayed in the Sidebar if none was provided - combat = combat ?? game.combats.viewed; - if ( !combat ) { - if ( game.user.isGM ) { - const cls = getDocumentClass("Combat"); - combat = await cls.create({scene: canvas.scene.id, active: true}, {render: !state || !tokens.length}); - } else { - ui.notifications.warn("COMBAT.NoneActive", {localize: true}); - return []; - } - } - - // Add tokens to the Combat encounter - if ( state ) { - const createData = tokens.map(t => { - return { - tokenId: t.id, - sceneId: t.scene.id, - actorId: t.document.actorId, - hidden: t.document.hidden - } - }); - return combat.createEmbeddedDocuments("Combatant", createData); - } - - // Remove Tokens from combat - if ( !game.user.isGM ) return []; - const tokenIds = new Set(tokens.map(t => t.id)); - const combatantIds = combat.combatants.reduce((ids, c) => { - if (tokenIds.has(c.tokenId)) ids.push(c.id); - return ids; - }, []); - return combat.deleteEmbeddedDocuments("Combatant", combatantIds); - } - - /* -------------------------------------------- */ - - /** - * Get the tab cycle order for tokens by sorting observable tokens based on their distance from top-left. - * @returns {Token[]} - * @private - */ - _getCycleOrder() { - const observable = this.placeables.filter(token => { - if ( game.user.isGM ) return true; - if ( !token.actor?.testUserPermission(game.user, "OBSERVER") ) return false; - return !token.document.hidden; - }); - observable.sort((a, b) => Math.hypot(a.x, a.y) - Math.hypot(b.x, b.y)); - return observable; - } - - /* -------------------------------------------- */ - - /** - * Immediately conclude the animation of any/all tokens - */ - concludeAnimation() { - this.placeables.filter(t => t._animation).forEach(t => { - t.stopAnimation(); - t.document.reset(); - t.refresh(); - }); - canvas.app.ticker.remove(this._animateTargets, this); - } - - /* -------------------------------------------- */ - - /** - * Animate targeting arrows on targeted tokens. - * @private - */ - _animateTargets() { - if ( !game.user.targets.size ) return; - if ( this._t === undefined ) this._t = 0; - else this._t += canvas.app.ticker.elapsedMS; - const duration = 2000; - const pause = duration * .6; - const fade = (duration - pause) * .25; - const minM = .5; // Minimum margin is half the size of the arrow. - const maxM = 1; // Maximum margin is the full size of the arrow. - // The animation starts with the arrows halfway across the token bounds, then move fully inside the bounds. - const rm = maxM - minM; - const t = this._t % duration; - let dt = Math.max(0, t - pause) / (duration - pause); - dt = CanvasAnimation.easeOutCircle(dt); - const m = t < pause ? minM : minM + (rm * dt); - const ta = Math.max(0, t - duration + fade); - const a = 1 - (ta / fade); - - for ( const t of game.user.targets ) { - t._refreshTarget({ - margin: m, - alpha: a, - color: CONFIG.Canvas.targeting.color, - size: CONFIG.Canvas.targeting.size - }); - } - } - - /* -------------------------------------------- */ - - /** - * Provide an array of Tokens which are eligible subjects for overhead tile occlusion. - * By default, only tokens which are currently controlled or owned by a player are included as subjects. - * @protected - */ - _getOccludableTokens() { - return game.user.isGM ? canvas.tokens.controlled : canvas.tokens.ownedTokens; - } - - /* -------------------------------------------- */ - /* Event Listeners and Handlers */ - /* -------------------------------------------- */ - - /** - * Handle dropping of Actor data onto the Scene canvas - * @private - */ - async _onDropActorData(event, data) { - - // Ensure the user has permission to drop the actor and create a Token - if ( !game.user.can("TOKEN_CREATE") ) { - return ui.notifications.warn("You do not have permission to create new Tokens!"); - } - - // Acquire dropped data and import the actor - let actor = await Actor.implementation.fromDropData(data); - if ( !actor.isOwner ) { - return ui.notifications.warn(`You do not have permission to create a new Token for the ${actor.name} Actor.`); - } - if ( actor.compendium ) { - const actorData = game.actors.fromCompendium(actor); - actor = await Actor.implementation.create(actorData, {fromCompendium: true}); - } - - // Prepare the Token document - const td = await actor.getTokenDocument({x: data.x, y: data.y, hidden: event.altKey}); - - // Bypass snapping - if ( event.shiftKey ) td.updateSource({ - x: td.x - (td.width * canvas.grid.w / 2), - y: td.y - (td.height * canvas.grid.h / 2) - }); - - // Otherwise, snap to the nearest vertex, adjusting for large tokens - else { - const hw = canvas.grid.w/2; - const hh = canvas.grid.h/2; - td.updateSource(canvas.grid.getSnappedPosition(td.x - (td.width*hw), td.y - (td.height*hh))); - } - - // Validate the final position - if ( !canvas.dimensions.rect.contains(td.x, td.y) ) return false; - - // Submit the Token creation request and activate the Tokens layer (if not already active) - this.activate(); - return td.constructor.create(td, {parent: canvas.scene}); - } - - /* -------------------------------------------- */ - - /** @inheritDoc */ - _onClickLeft(event) { - let tool = game.activeTool; - - // If Control is being held, we always want the Tool to be Ruler - if ( game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL) ) tool = "ruler"; - switch ( tool ) { - // Clear targets if Left Click Release is set - case "target": - if ( game.settings.get("core", "leftClickRelease") ) { - game.user.updateTokenTargets([]); - game.user.broadcastActivity({targets: []}); - } - break; - - // Place Ruler waypoints - case "ruler": - return canvas.controls.ruler._onClickLeft(event); - break; - } - - // If we don't explicitly return from handling the tool, use the default behavior - super._onClickLeft(event); - } -} - -/** - * The Walls canvas layer which provides a container for Wall objects within the rendered Scene. - * @category - Canvas - */ -class WallsLayer extends PlaceablesLayer { - - /** - * Synthetic Wall instances which represent the outer boundaries of the game canvas. - * @type {Wall[]} - */ - outerBounds = []; - - /** - * Synthetic Wall instances which represent the inner boundaries of the scene rectangle. - * @type {Wall[]} - */ - innerBounds = []; - - /** - * A graphics layer used to display chained Wall selection - * @type {PIXI.Graphics} - */ - chain = null; - - /** - * Track whether we are currently within a chained placement workflow - * @type {boolean} - */ - _chain = false; - - /** - * Track whether the layer is currently toggled to snap at exact grid precision - * @type {boolean} - */ - _forceSnap = false; - - /** - * Track the most recently created or updated wall data for use with the clone tool - * @type {Object|null} - * @private - */ - _cloneType = null; - - /** - * Reference the last interacted wall endpoint for the purposes of chaining - * @type {{point: PointArray}} - * @private - */ - last = { - point: null - }; - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - static get layerOptions() { - return foundry.utils.mergeObject(super.layerOptions, { - name: "walls", - controllableObjects: true, - sortActiveTop: true, // TODO this needs to be removed - zIndex: 40 - }); - } - - /** @inheritdoc */ - static documentName = "Wall"; - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get hookName() { - return WallsLayer.name; - } - - /* -------------------------------------------- */ - - /** - * An Array of Wall instances in the current Scene which act as Doors. - * @type {Wall[]} - */ - get doors() { - return this.objects.children.filter(w => w.document.door > CONST.WALL_DOOR_TYPES.NONE); - } - - /* -------------------------------------------- */ - - /** - * Gate the precision of wall snapping to become less precise for small scale maps. - * @type {number} - */ - get gridPrecision() { - - // Force snapping to grid vertices - if ( this._forceSnap ) return canvas.grid.type <= CONST.GRID_TYPES.SQUARE ? 1 : 5; - - // Normal snapping precision - let size = canvas.dimensions.size; - if ( size >= 128 ) return 16; - else if ( size >= 64 ) return 8; - else if ( size >= 32 ) return 4; - return 1; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _draw(options) { - await super._draw(options); - this.#defineBoundaries(); - this.chain = this.addChildAt(new PIXI.Graphics(), 0); - this.last = {point: null}; - this.highlightControlledSegments(); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _deactivate() { - super._deactivate(); - this.chain?.clear(); - } - - /* -------------------------------------------- */ - - /** - * Perform initialization steps for the WallsLayer whenever the composition of walls in the Scene is changed. - * Cache unique wall endpoints and identify interior walls using overhead roof tiles. - */ - initialize() { - this.identifyWallIntersections(); - this.identifyInteriorWalls(); - } - - /* -------------------------------------------- */ - - /** - * Define the canvas boundaries for outer and inner regions - */ - #defineBoundaries() { - const d = canvas.dimensions; - const cls = getDocumentClass("Wall"); - const ctx = {parent: canvas.scene}; - const define = (name, r) => { - const docs = [ - new cls({_id: `Bound${name}Top`.padEnd(16, "0"), c: [r.x, r.y, r.right, r.y]}, ctx), - new cls({_id: `Bound${name}Right`.padEnd(16, "0"), c: [r.right, r.y, r.right, r.bottom]}, ctx), - new cls({_id: `Bound${name}Bottom`.padEnd(16, "0"), c: [r.right, r.bottom, r.x, r.bottom]}, ctx), - new cls({_id: `Bound${name}Left`.padEnd(16, "0"), c: [r.x, r.bottom, r.x, r.y]}, ctx) - ]; - return docs.map(d => new Wall(d)); - }; - this.outerBounds = define("Outer", d.rect); - this.innerBounds = d.rect.x === d.sceneRect.x ? this.outerBounds : define("Inner", d.sceneRect); - } - - /* -------------------------------------------- */ - - /** - * Initialization to identify all intersections between walls. - * These intersections are cached and used later when computing point source polygons. - */ - identifyWallIntersections() { - - // Preprocess wall segments and canvas boundaries - const segments = []; - const process = wall => { - const isNW = wall.A.key - wall.B.key < 0; - const nw = isNW ? wall.A : wall.B; - const se = isNW ? wall.B : wall.A; - segments.push({wall, nw, se}); - }; - for ( const wall of this.outerBounds ) process(wall); - - let boundaries = this.outerBounds; - if ( boundaries !== this.innerBounds ) boundaries = boundaries.concat(this.innerBounds); - for ( const wall of boundaries ) process(wall); - for ( const wall of this.placeables ) process(wall); - - // Sort segments by their north-west X value, breaking ties with the south-east X value - segments.sort((s1, s2) => (s1.nw.x - s2.nw.x) || (s1.se.x - s2.se.x)); - - // Iterate over all endpoints, identifying intersections - const ln = segments.length; - for ( let i=0; i s1.se.x ) break; // Segment s2 is entirely right of segment s1 - s1.wall._identifyIntersectionsWith(s2.wall); - } - } - } - - /* -------------------------------------------- */ - - /** - * Identify walls which are treated as "interior" because they are contained fully within a roof tile. - */ - identifyInteriorWalls() { - for ( const wall of this.placeables ) { - wall.identifyInteriorState(); - } - } - - /* -------------------------------------------- */ - - /** - * Given a point and the coordinates of a wall, determine which endpoint is closer to the point - * @param {Point} point The origin point of the new Wall placement - * @param {Wall} wall The existing Wall object being chained to - * @returns {PointArray} The [x,y] coordinates of the starting endpoint - */ - static getClosestEndpoint(point, wall) { - const c = wall.coords; - const a = [c[0], c[1]]; - const b = [c[2], c[3]]; - - // Exact matches - if ( a.equals([point.x, point.y]) ) return a; - else if ( b.equals([point.x, point.y]) ) return b; - - // Closest match - const da = Math.hypot(point.x - a[0], point.y - a[1]); - const db = Math.hypot(point.x - b[0], point.y - b[1]); - return da < db ? a : b; - } - - /* -------------------------------------------- */ - - /** - * Test whether movement along a given Ray collides with a Wall. - * @param {Ray} ray The attempted movement - * @param {object} [options={}] Options which customize how collision is tested. - * These options are passed to PointSourcePolygon.testCollision - */ - checkCollision(ray, options={}) { - return CONFIG.Canvas.losBackend.testCollision(ray.A, ray.B, options); - } - - /* -------------------------------------------- */ - - /** - * Highlight the endpoints of Wall segments which are currently group-controlled on the Walls layer - */ - highlightControlledSegments() { - if ( !this.chain ) return; - const drawn = new Set(); - const c = this.chain.clear(); - - // Determine circle radius and line width - let lw = 2; - if ( canvas.dimensions.size > 150 ) lw = 4; - else if ( canvas.dimensions.size > 100 ) lw = 3; - const cr = lw * 2; - let cr2 = cr * 2; - let cr4 = cr * 4; - - for ( let p of this.controlled ) { - let p1 = p.coords.slice(0, 2); - if ( !drawn.has(p1.join(".")) ) c.lineStyle(cr, 0xFF9829).drawRoundedRect(p1[0] - cr2, p1[1] - cr2, cr4, cr4, cr); - let p2 = p.coords.slice(2); - if ( !drawn.has(p2.join(".")) ) c.lineStyle(cr, 0xFF9829).drawRoundedRect(p2[0] - cr2, p2[1] - cr2, cr4, cr4, cr); - c.lineStyle(cr2, 0xFF9829).moveTo(...p1).lineTo(...p2); - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - releaseAll(options) { - if ( this.chain ) this.chain.clear(); - return super.releaseAll(options); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async pasteObjects(position, options) { - if ( !this._copy.length ) return []; - - // Transform walls to reference their upper-left coordinates as {x,y} - const [xs, ys] = this._copy.reduce((arr, w) => { - arr[0].push(Math.min(w.document.c[0], w.document.c[2])); - arr[1].push(Math.min(w.document.c[1], w.document.c[3])); - return arr; - }, [[], []]); - - // Get the top-left most coordinate - const topX = Math.min(...xs); - const topY = Math.min(...ys); - - // Get the magnitude of shift - const dx = Math.floor(topX - position.x); - const dy = Math.floor(topY - position.y); - const shift = [dx, dy, dx, dy]; - - // Iterate over objects - const toCreate = []; - for ( let w of this._copy ) { - let data = w.document.toJSON(); - data.c = data.c.map((c, i) => c - shift[i]); - delete data._id; - toCreate.push(data); - } - - // Call paste hooks - Hooks.call("pasteWall", this._copy, toCreate); - - // Create all objects - let created = await canvas.scene.createEmbeddedDocuments("Wall", toCreate); - ui.notifications.info(`Pasted data for ${toCreate.length} Wall objects.`); - return created; - } - - /* -------------------------------------------- */ - - /** - * Pan the canvas view when the cursor position gets close to the edge of the frame - * @param {MouseEvent} event The originating mouse movement event - * @param {number} x The x-coordinate - * @param {number} y The y-coordinate - * @private - */ - _panCanvasEdge(event, x, y) { - - // Throttle panning by 20ms - const now = Date.now(); - if ( now - (event.data.panTime || 0) <= 100 ) return; - event.data.panTime = now; - - // Determine the amount of shifting required - const pad = 50; - const shift = 500 / canvas.stage.scale.x; - - // Shift horizontally - let dx = 0; - if ( x < pad ) dx = -shift; - else if ( x > window.innerWidth - pad ) dx = shift; - - // Shift vertically - let dy = 0; - if ( y < pad ) dy = -shift; - else if ( y > window.innerHeight - pad ) dy = shift; - - // Enact panning - if (( dx || dy ) && !this._panning ) { - return canvas.animatePan({x: canvas.stage.pivot.x + dx, y: canvas.stage.pivot.y + dy, duration: 100}); - } - } - - /* -------------------------------------------- */ - - /** - * Get the endpoint coordinates for a wall placement, snapping to grid at a specified precision - * Require snap-to-grid until a redesign of the wall chaining system can occur. - * @param {Object} point The initial candidate point - * @param {boolean} [snap=true] Whether to snap to grid - * @return {number[]} The endpoint coordinates [x,y] - * @private - */ - _getWallEndpointCoordinates(point, {snap=true}={}) { - if ( snap ) point = canvas.grid.getSnappedPosition(point.x, point.y, this.gridPrecision); - return [point.x, point.y].map(Math.floor); - } - - /* -------------------------------------------- */ - - /** - * The Scene Controls tools provide several different types of prototypical Walls to choose from - * This method helps to translate each tool into a default wall data configuration for that type - * @param {string} tool The active canvas tool - * @private - */ - _getWallDataFromActiveTool(tool) { - - // Using the clone tool - if ( tool === "clone" && this._cloneType ) return this._cloneType; - - // Default wall data - const wallData = { - light: CONST.WALL_SENSE_TYPES.NORMAL, - sight: CONST.WALL_SENSE_TYPES.NORMAL, - sound: CONST.WALL_SENSE_TYPES.NORMAL, - move: CONST.WALL_SENSE_TYPES.NORMAL - }; - - // Tool-based wall restriction types - switch ( tool ) { - case "invisible": - wallData.sight = wallData.light = wallData.sound = CONST.WALL_SENSE_TYPES.NONE; break; - case "terrain": - wallData.sight = wallData.light = wallData.sound = CONST.WALL_SENSE_TYPES.LIMITED; break; - case "ethereal": - wallData.move = wallData.sound = CONST.WALL_SENSE_TYPES.NONE; break; - case "doors": - wallData.door = CONST.WALL_DOOR_TYPES.DOOR; break; - case "secret": - wallData.door = CONST.WALL_DOOR_TYPES.SECRET; break; - } - return wallData; - } - - /* -------------------------------------------- */ - /* Event Listeners and Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDragLeftStart(event) { - const { origin, originalEvent } = event.data; - event.data.createState = WallsLayer.CREATION_STATES.NONE; - - // Create a pending WallDocument - const data = this._getWallDataFromActiveTool(game.activeTool); - const snap = this._forceSnap || !originalEvent.shiftKey; - const isChain = this._chain || game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL); - const pt = (isChain && this.last.point) ? this.last.point : this._getWallEndpointCoordinates(origin, {snap}); - data.c = pt.concat(pt); - const cls = getDocumentClass("Wall"); - const doc = new cls(data, {parent: canvas.scene}); - - // Create the preview Wall object - const wall = new this.constructor.placeableClass(doc); - event.data.createState = WallsLayer.CREATION_STATES.POTENTIAL; - event.data.preview = this.preview.addChild(wall); - return wall.draw(); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDragLeftMove(event) { - const { destination, preview } = event.data; - const states = WallsLayer.CREATION_STATES; - if ( !preview || preview._destroyed || [states.NONE, states.COMPLETED].includes(event.data.createState) ) return; - if ( preview.parent === null ) { // In theory this should never happen, but rarely does - this.preview.addChild(preview); - } - preview.document.c = preview.document.c.slice(0, 2).concat([destination.x, destination.y]); - preview.refresh(); - event.data.createState = WallsLayer.CREATION_STATES.CONFIRMED; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _onDragLeftDrop(event) { - const { createState, destination, originalEvent, preview } = event.data; - - // Prevent default to allow chaining to continue - if ( game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL) ) { - originalEvent.preventDefault(); - this._chain = true; - if ( createState < WallsLayer.CREATION_STATES.CONFIRMED ) return; - } else this._chain = false; - - // Successful wall completion - if ( createState === WallsLayer.CREATION_STATES.CONFIRMED ) { - event.data.createState = WallsLayer.CREATION_STATES.COMPLETED; - - // Get final endpoint location - const snap = this._forceSnap || !originalEvent.shiftKey; - let dest = this._getWallEndpointCoordinates(destination, {snap}); - const coords = preview.document.c.slice(0, 2).concat(dest); - preview.document.c = coords; - - // Ignore walls which are collapsed - if ( (coords[0] === coords[2]) && (coords[1] === coords[3]) ) return this._onDragLeftCancel(originalEvent); - - // Create the Wall - this.last = {point: dest}; - const cls = getDocumentClass(this.constructor.documentName); - await cls.create(preview.document.toObject(false), {parent: canvas.scene}); - this.preview.removeChild(preview); - - // Maybe chain - if ( this._chain ) { - event.data.origin = {x: dest[0], y: dest[1]}; - return this._onDragLeftStart(event); - } - } - - // Partial wall completion - return this._onDragLeftCancel(event); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDragLeftCancel(event) { - this._chain = false; - this.last = {point: null}; - super._onDragLeftCancel(event); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onClickRight(event) { - if ( event.data.createState > WallsLayer.CREATION_STATES.NONE ) return this._onDragLeftCancel(event); - } - - /* -------------------------------------------- */ - /* Deprecations and Compatibility */ - /* -------------------------------------------- */ - - get boundaries() { - const msg = "WallsLayer#boundaries is deprecated in favor of WallsLayer#outerBounds and WallsLayer#innerBounds"; - foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12}); - return new Set(this.outerBounds); - } -} - - -/** - * An interface for defining particle-based weather effects - * @param {PIXI.Container} parent The parent container within which the effect is rendered - * @param {object} [options] Options passed to the getParticleEmitters method which can be used to customize - * values of the emitter configuration. - * @interface - */ -class ParticleEffect { - constructor(parent, options={}) { - - /** - * The parent particle container within which the effect is rendered - * @type {FullCanvasContainer} - */ - this.parent = parent; - - /** - * The array of emitters which are active for this particle effect - * @type {PIXI.particles.Emitter[]} - */ - this.emitters = this.getParticleEmitters(options); - } - - /** - * A human-readable label for the weather effect. This can be a localization string. - * @type {string} - */ - static label = "Particle Effect"; - - /* -------------------------------------------- */ - - /** - * Create an emitter instance which automatically updates using the shared PIXI.Ticker - * @param {PIXI.particles.EmitterConfigV3} config The emitter configuration - * @returns {PIXI.particles.Emitter} The created Emitter instance - */ - createEmitter(config) { - config.autoUpdate = true; - config.emit = false; - return new PIXI.particles.Emitter(this.parent, config); - } - - /* -------------------------------------------- */ - - /** - * Get the particle emitters which should be active for this particle effect. - * @param {object} [options] Options provided to the ParticleEffect constructor which can be used to customize - * configuration values for created emitters. - * @returns {PIXI.particles.Emitter[]} - */ - getParticleEmitters(options={}) { - return []; - } - - /* -------------------------------------------- */ - - /** - * Destroy all emitters related to this ParticleEffect - */ - destroy() { - for ( const e of this.emitters ) e.destroy(); - this.emitters = []; - } - - /* -------------------------------------------- */ - - /** - * Begin animation for the configured emitters. - */ - play() { - for ( let e of this.emitters ) { - e.emit = true; - } - } - - /* -------------------------------------------- */ - - /** - * Stop animation for the configured emitters. - */ - stop() { - for ( let e of this.emitters ) { - e.emit = false; - } - } -} - -/** - * @deprecated since v10 - * @ignore - */ -class SpecialEffect extends ParticleEffect { - constructor(parent) { - foundry.utils.logCompatibilityWarning("You are using the SpecialEffect class which is renamed to ParticleEffect.", - {since: 10, until: 12}); - super(parent); - } -} - - -/** - * A full-screen weather effect which renders gently falling autumn leaves. - * @extends {ParticleEffect} - */ -class AutumnLeavesWeatherEffect extends ParticleEffect { - - /** @inheritdoc */ - static label = "WEATHER.AutumnLeaves"; - - /** - * Configuration for the particle emitter for falling leaves - * @type {PIXI.particles.EmitterConfigV3} - */ - static LEAF_CONFIG = { - lifetime: {min: 10, max: 10}, - behaviors: [ - { - type: "alpha", - config: { - alpha: { - list: [{time: 0, value: 0.9}, {time: 1, value: 0.5}] - } - } - }, - { - type: "moveSpeed", - config: { - speed: { - list: [{time: 0, value: 20}, {time: 1, value: 60}] - }, - minMult: 0.6 - } - }, - { - type: "scale", - config: { - scale: { - list: [{time: 0, value: 0.2}, {time: 1, value: 0.4}] - }, - minMult: 0.5 - } - }, - { - type: "rotation", - config: {accel: 0, minSpeed: 100, maxSpeed: 200, minStart: 0, maxStart: 365} - }, - { - type: "textureRandom", - config: { - textures: Array.fromRange(6).map(n => `ui/particles/leaf${n + 1}.png`) - } - } - ] - }; - - /* -------------------------------------------- */ - - /** @inheritdoc */ - getParticleEmitters() { - const d = canvas.dimensions; - const maxParticles = (d.width / d.size) * (d.height / d.size) * 0.25; - const config = foundry.utils.deepClone(this.constructor.LEAF_CONFIG); - config.maxParticles = maxParticles; - config.frequency = config.lifetime.min / maxParticles; - config.behaviors.push({ - type: "spawnShape", - config: { - type: "rect", - data: {x: d.sceneRect.x, y: d.sceneRect.y, w: d.sceneRect.width, h: d.sceneRect.height} - } - }); - return [this.createEmitter(config)]; - } -} - -/** - * A full-screen weather effect which renders rain drops and splashes. - * @extends {ParticleEffect} - */ -class RainWeatherEffect extends ParticleEffect { - - /** @inheritdoc */ - static label = "WEATHER.Rain"; - - /** - * Configuration for the particle emitter for rain - * @type {PIXI.particles.EmitterConfigV3} - */ - static RAIN_CONFIG = { - behaviors: [ - { - type: "alpha", - config: { - alpha: { - list: [{time: 0, value: 0.7}, {time: 1, value: 0.1}] - } - } - }, - { - type: "moveSpeedStatic", - config: {min: 2800, max: 3500} - }, - { - type: "scaleStatic", - config: {min: 0.8, max: 1} - }, - { - type: "rotationStatic", - config: {min: 75, max: 75} - }, - { - type: "textureRandom", - config: { - textures: [ - "ui/particles/rain.png" - ] - } - } - ], - frequency: 0.002, - lifetime: {min: 0.5, max: 0.5}, - pos: {x: 0, y: 0} - }; - - /** - * Configuration for the particle emitter for splashes - * @type {PIXI.particles.EmitterConfigV3} - */ - static SPLASH_CONFIG = { - lifetime: {min: 0.5, max: 0.5}, - pos: {x: 0, y: 0}, - behaviors: [ - { - type: "moveSpeedStatic", - config: {min: 0, max: 0} - }, - { - type: "scaleStatic", - config: {min: 0.48, max: 0.6} - }, - { - type: "rotationStatic", - config: {min: -90, max: -90} - }, - { - type: "noRotation", - config: {} - }, - { - type: "textureRandom", - config: { - textures: [ - "ui/particles/drop.png" - ] - } - } - ] - }; - - /* -------------------------------------------- */ - - /** @inheritdoc */ - getParticleEmitters({maxParticles, ...options}) { - const d = canvas.dimensions; - maxParticles ??= (d.width / d.size) * (d.height / d.size) * 0.5; - - // Create an emitter for rain drops - const rainConfig = foundry.utils.deepClone(this.constructor.RAIN_CONFIG); - rainConfig.maxParticles = maxParticles; - rainConfig.frequency = 1 / maxParticles; - rainConfig.behaviors.push({ - type: "spawnShape", - config: { - type: "rect", - data: {x: -0.05 * d.width, y: -0.10 * d.height, w: d.width, h: 0.8 * d.height} - } - }); - - // Create a second emitter for splashes - const splashConfig = foundry.utils.deepClone(this.constructor.SPLASH_CONFIG); - splashConfig.maxParticles = maxParticles; - splashConfig.frequency = 2 / maxParticles; - splashConfig.behaviors.push({ - type: "spawnShape", - config: { - type: "rect", - data: { x: 0, y: 0.25 * d.height, w: d.width, h: 0.75 * d.height } - } - }); - - // Return both emitters - return [this.createEmitter(rainConfig), this.createEmitter(splashConfig)]; - } -} - -/** - * A full-screen weather effect which renders drifting snowflakes. - * @extends {ParticleEffect} - */ -class SnowWeatherEffect extends ParticleEffect { - - /** @inheritdoc */ - static label = "WEATHER.Snow" - - /** - * Configuration for the particle emitter for snow - * @type {PIXI.particles.EmitterConfigV3} - */ - static SNOW_CONFIG = { - lifetime: {min: 4, max: 4}, - behaviors: [ - { - type: "alpha", - config: { - alpha: { - list: [{time: 0, value: 0.9}, {time: 1, value: 0.5}] - } - } - }, - { - type: "moveSpeed", - config: { - speed: { - list: [{time: 0, value: 190}, {time: 1, value: 210}] - }, - minMult: 0.6 - } - }, - { - type: "scale", - config: { - scale: { - list: [{time: 0, value: 0.2}, {time: 1, value: 0.4}] - }, - minMult: 0.5 - } - }, - { - type: "rotation", - config: {accel: 0, minSpeed: 0, maxSpeed: 200, minStart: 50, maxStart: 75} - }, - { - type: "textureRandom", - config: { - textures: [ - "ui/particles/snow.png" - ] - } - } - ] - }; - - /* -------------------------------------------- */ - - /** @inheritdoc */ - getParticleEmitters() { - const d = canvas.dimensions; - const maxParticles = (d.width / d.size) * (d.height / d.size) * 0.5; - const config = foundry.utils.deepClone(this.constructor.SNOW_CONFIG); - config.maxParticles = maxParticles; - config.frequency = 1 / maxParticles; - config.behaviors.push({ - type: "spawnShape", - config: { - type: "rect", - data: {x: 0, y: -0.10 * d.height, w: d.width, h: d.height} - } - }); - return [this.createEmitter(config)]; - } -} - - -/** - * Approximate this PIXI.Circle as a PIXI.Polygon - * @param {object} [options] Options which affect how the circle is converted - * @param {number} [options.density] The number of points which defines the density of approximation - * @returns {PIXI.Polygon} The Circle expressed as a PIXI.Polygon - */ -PIXI.Circle.prototype.toPolygon = function({density}={}) { - density ??= this.constructor.approximateVertexDensity(this.radius); - const points = []; - const delta = (2 * Math.PI) / density; - for ( let i=0; i maxX ) maxX = x; - if ( y < minY ) minY = y; - else if ( y > maxY ) maxY = y; - } - return new PIXI.Rectangle(minX, minY, maxX - minX, maxY - minY); -}; - -/* -------------------------------------------- */ - -/** - * Construct a PIXI.Polygon instance from an array of clipper points [{X,Y}, ...]. - * @param {Array<{X: number, Y: number}>} points An array of points returned by clipper - * @param {object} [options] Options which affect how canvas points are generated - * @param {number} [options.scalingFactor=1] A scaling factor used to preserve floating point precision - * @returns {PIXI.Polygon} The resulting PIXI.Polygon - */ -PIXI.Polygon.fromClipperPoints = function(points, {scalingFactor=1}={}) { - const polygonPoints = []; - for ( const point of points ) { - polygonPoints.push(point.X / scalingFactor, point.Y / scalingFactor); - } - return new PIXI.Polygon(polygonPoints); -}; - -/* -------------------------------------------- */ - -/** - * Convert a PIXI.Polygon into an array of clipper points [{X,Y}, ...]. - * Note that clipper points must be rounded to integers. - * In order to preserve some amount of floating point precision, an optional scaling factor may be provided. - * @param {object} [options] Options which affect how clipper points are generated - * @param {number} [options.scalingFactor=1] A scaling factor used to preserve floating point precision - * @returns {Array<{X: number, Y: number}>} An array of points to be used by clipper - */ -PIXI.Polygon.prototype.toClipperPoints = function({scalingFactor=1}={}) { - const points = []; - for ( let i = 1; i < this.points.length; i += 2 ) { - points.push({ - X: Math.roundFast(this.points[i-1] * scalingFactor), - Y: Math.roundFast(this.points[i] * scalingFactor) - }); - } - return points; -}; - -/* -------------------------------------------- */ - -/** - * Determine whether the PIXI.Polygon is closed, defined by having the same starting and ending point. - * @type {boolean} - */ -Object.defineProperty(PIXI.Polygon.prototype, "isClosed", { - get: function() { - const ln = this.points.length; - if ( ln < 4 ) return false; - return (this.points[0] === this.points[ln-2]) && (this.points[1] === this.points[ln-1]); - }, - enumerable: false -}); - -/* -------------------------------------------- */ -/* Intersection Methods */ -/* -------------------------------------------- */ - -/** - * Intersect this PIXI.Polygon with another PIXI.Polygon using the clipper library. - * @param {PIXI.Polygon} other Another PIXI.Polygon - * @param {object} [options] Options which configure how the intersection is computed - * @param {number} [options.clipType] The clipper clip type - * @param {number} [options.scalingFactor] A scaling factor passed to Polygon#toClipperPoints to preserve precision - * @returns {PIXI.Polygon|null} The intersected polygon or null if no solution was present - */ -PIXI.Polygon.prototype.intersectPolygon = function(other, {clipType, scalingFactor}={}) { - clipType ??= ClipperLib.ClipType.ctIntersection; - const c = new ClipperLib.Clipper(); - c.AddPath(this.toClipperPoints({scalingFactor}), ClipperLib.PolyType.ptSubject, true); - c.AddPath(other.toClipperPoints({scalingFactor}), ClipperLib.PolyType.ptClip, true); - const solution = new ClipperLib.Paths(); - c.Execute(clipType, solution); - return PIXI.Polygon.fromClipperPoints(solution.length ? solution[0] : [], {scalingFactor}); -}; - -/* -------------------------------------------- */ - -/** - * Intersect this PIXI.Polygon with a PIXI.Circle. - * For now, convert the circle to a Polygon approximation and use intersectPolygon. - * In the future we may replace this with more specialized logic which uses the line-circle intersection formula. - * @param {PIXI.Circle} circle A PIXI.Circle - * @param {object} [options] Options which configure how the intersection is computed - * @param {number} [options.density] The number of points which defines the density of approximation - * @returns {PIXI.Polygon} The intersected polygon - */ -PIXI.Polygon.prototype.intersectCircle = function(circle, options) { - return circle.intersectPolygon(this, options); -}; - -/* -------------------------------------------- */ - -/** - * Intersect this PIXI.Polygon with a PIXI.Rectangle. - * For now, convert the rectangle to a Polygon and use intersectPolygon. - * In the future we may replace this with more specialized logic which uses the line-line intersection formula. - * @param {PIXI.Rectangle} rect A PIXI.Rectangle - * @param {object} [options] Options which configure how the intersection is computed - * @returns {PIXI.Polygon} The intersected polygon - */ -PIXI.Polygon.prototype.intersectRectangle = function(rect, options) { - return rect.intersectPolygon(this, options); -}; - -/* -------------------------------------------- */ - -/** - * Return the bounding box for a PIXI.Rectangle. - * The bounding rectangle is normalized such that the width and height are non-negative. - * @returns {PIXI.Rectangle} - */ -PIXI.Rectangle.prototype.getBounds = function() { - let {x, y, width, height} = this; - x = width > 0 ? x : x + width; - y = height > 0 ? y : y + height; - return new PIXI.Rectangle(x, y, Math.abs(width), Math.abs(height)); -}; - -/* -------------------------------------------- */ - -/** - * Compute the intersection of this Rectangle with some other Rectangle. - * @param {PIXI.Rectangle} other Some other rectangle which intersects this one - * @returns {PIXI.Rectangle} - */ -PIXI.Rectangle.prototype.intersection = function(other) { - const x0 = this.x < other.x ? other.x : this.x; - const x1 = this.right > other.right ? other.right : this.right; - const y0 = this.y < other.y ? other.y : this.y; - const y1 = this.bottom > other.bottom ? other.bottom : this.bottom; - return new PIXI.Rectangle(x0, y0, x1 - x0, y1 - y0); -}; - -/* -------------------------------------------- */ - -/** - * Convert this PIXI.Rectangle into a PIXI.Polygon - * @returns {PIXI.Polygon} The Rectangle expressed as a PIXI.Polygon - */ -PIXI.Rectangle.prototype.toPolygon = function() { - const points = [this.left, this.top, this.right, this.top, this.right, this.bottom, this.left, this.bottom]; - return new PIXI.Polygon(points); -}; - -/* -------------------------------------------- */ - -/** - * Get the left edge of this rectangle. - * The returned edge endpoints are oriented clockwise around the rectangle. - * @type {{A: Point, B: Point}} - */ -Object.defineProperty(PIXI.Rectangle.prototype, "leftEdge", { get: function() { - return { A: { x: this.left, y: this.bottom }, B: { x: this.left, y: this.top }}; -}}); - -/** - * Get the right edge of this rectangle. - * The returned edge endpoints are oriented clockwise around the rectangle. - * @type {{A: Point, B: Point}} - */ -Object.defineProperty(PIXI.Rectangle.prototype, "rightEdge", { get: function() { - return { A: { x: this.right, y: this.top }, B: { x: this.right, y: this.bottom }}; -}}); - -/** - * Get the top edge of this rectangle. - * The returned edge endpoints are oriented clockwise around the rectangle. - * @type {{A: Point, B: Point}} - */ -Object.defineProperty(PIXI.Rectangle.prototype, "topEdge", { get: function() { - return { A: { x: this.left, y: this.top }, B: { x: this.right, y: this.top }}; -}}); - -/** - * Get the bottom edge of this rectangle. - * The returned edge endpoints are oriented clockwise around the rectangle. - * @type {{A: Point, B: Point}} - */ -Object.defineProperty(PIXI.Rectangle.prototype, "bottomEdge", { get: function() { - return { A: { x: this.right, y: this.bottom }, B: { x: this.left, y: this.bottom }}; -}}); - -/* -------------------------------------------- */ - -/** - * Bit code labels splitting a rectangle into zones, based on the Cohen-Sutherland algorithm. - * See https://en.wikipedia.org/wiki/Cohen%E2%80%93Sutherland_algorithm - * left central right - * top 1001 1000 1010 - * central 0001 0000 0010 - * bottom 0101 0100 0110 - * @enum {number} - */ -PIXI.Rectangle.CS_ZONES = { - INSIDE: 0x0000, - LEFT: 0x0001, - RIGHT: 0x0010, - TOP: 0x1000, - BOTTOM: 0x0100, - TOPLEFT: 0x1001, - TOPRIGHT: 0x1010, - BOTTOMRIGHT: 0x0110, - BOTTOMLEFT: 0x0101 -}; - -/** - * Calculate the rectangle Zone for a given point located around or in the rectangle. - * https://en.wikipedia.org/wiki/Cohen%E2%80%93Sutherland_algorithm - * - * @param {Point} p Point to test for location relative to the rectangle - * @returns {integer} - */ -PIXI.Rectangle.prototype._getZone = function(p) { - const CSZ = PIXI.Rectangle.CS_ZONES; - let code = CSZ.INSIDE; - - if ( p.x < this.x ) code |= CSZ.LEFT; - else if ( p.x > this.right ) code |= CSZ.RIGHT; - - if ( p.y < this.y ) code |= CSZ.TOP; - else if ( p.y > this.bottom ) code |= CSZ.BOTTOM; - - return code; -}; - -/** - * Test whether a line segment AB intersects this rectangle. - * @param {Point} a The first endpoint of segment AB - * @param {Point} b The second endpoint of segment AB - * @param {object} [options] Options affecting the intersect test. - * @param {boolean} [options.inside] If true, a line contained within the rectangle will - * return true. - * @returns {boolean} True if intersects. - */ -PIXI.Rectangle.prototype.lineSegmentIntersects = function(a, b, { inside = false } = {}) { - const zoneA = this._getZone(a); - const zoneB = this._getZone(b); - - if ( !(zoneA | zoneB) ) return inside; // Bitwise OR is 0: both points inside rectangle. - if ( zoneA & zoneB ) return false; // Bitwise AND is not 0: both points share outside zone - if ( !(zoneA && zoneB) ) return true; // Regular AND: one point inside, one outside - - // Line likely intersects, but some possibility that the line starts at, say, center left - // and moves to center top which means it may or may not cross the rectangle - const CSZ = PIXI.Rectangle.CS_ZONES; - const lsi = foundry.utils.lineSegmentIntersects; - - // If the zone is a corner, like top left, test one side and then if not true, test - // the other. If the zone is on a side, like left, just test that side. - const leftEdge = this.leftEdge; - if ( (zoneA & CSZ.LEFT) && lsi(leftEdge.A, leftEdge.B, a, b) ) return true; - - const rightEdge = this.rightEdge; - if ( (zoneA & CSZ.RIGHT) && lsi(rightEdge.A, rightEdge.B, a, b) ) return true; - - const topEdge = this.topEdge; - if ( (zoneA & CSZ.TOP) && lsi(topEdge.A, topEdge.B, a, b) ) return true; - - const bottomEdge = this.bottomEdge; - if ( (zoneA & CSZ.BOTTOM ) && lsi(bottomEdge.A, bottomEdge.B, a, b) ) return true; - - return false; -}; - -/* -------------------------------------------- */ - -/** - * Intersect this PIXI.Rectangle with a PIXI.Polygon. - * Currently uses the clipper library. - * In the future we may replace this with more specialized logic which uses the line-line intersection formula. - * @param {PIXI.Polygon} polygon A PIXI.Polygon - * @param {object} [options] Options which configure how the intersection is computed - * @param {number} [options.clipType] The clipper clip type - * @param {number} [options.scalingFactor] A scaling factor passed to Polygon#toClipperPoints to preserve precision - * @returns {PIXI.Polygon|null} The intersected polygon or null if no solution was present - */ -PIXI.Rectangle.prototype.intersectPolygon = function(polygon, {clipType, scalingFactor}={}) { - if ( !this.width || !this.height ) return new PIXI.Polygon([]); - return polygon.intersectPolygon(this.toPolygon(), {clipType, scalingFactor}); -}; - -/* -------------------------------------------- */ - -/** - * Determine whether some other Rectangle overlaps with this one. - * This check differs from the parent class Rectangle#intersects test because it is true for adjacency (zero area). - * @param {PIXI.Rectangle} other Some other rectangle against which to compare - * @returns {boolean} Do the rectangles overlap? - */ -PIXI.Rectangle.prototype.overlaps = function(other) { - return (other.right >= this.left) - && (other.left <= this.right) - && (other.bottom >= this.top) - && (other.top <= this.bottom); -}; - -/* -------------------------------------------- */ - -/** - * Normalize the width and height of the rectangle in-place, enforcing that those dimensions be positive. - * @returns {PIXI.Rectangle} - */ -PIXI.Rectangle.prototype.normalize = function() { - if ( this.width < 0 ) { - this.x += this.width; - this.width = Math.abs(this.width); - } - if ( this.height < 0 ) { - this.y += this.height; - this.height = Math.abs(this.height); - } - return this; -}; - -/* -------------------------------------------- */ - -/** - * Generate a new rectangle by rotating this one clockwise about its center by a certain number of radians - * @param {number} radians The angle of rotation - * @returns {PIXI.Rectangle} A new rotated rectangle - */ -PIXI.Rectangle.prototyperotate = function(radians) { - return this.constructor.fromRotation(this.x, this.y, this.width, this.height, radians); -}; - -/* -------------------------------------------- */ - -/** - * Create normalized rectangular bounds given a rectangle shape and an angle of central rotation. - * @param {number} x The top-left x-coordinate of the un-rotated rectangle - * @param {number} y The top-left y-coordinate of the un-rotated rectangle - * @param {number} width The width of the un-rotated rectangle - * @param {number} height The height of the un-rotated rectangle - * @param {number} radians The angle of rotation about the center - * @returns {PIXI.Rectangle} The constructed rotated rectangle bounds - */ -PIXI.Rectangle.fromRotation = function(x, y, width, height, radians) { - const rh = (height * Math.abs(Math.cos(radians))) + (width * Math.abs(Math.sin(radians))); - const rw = (height * Math.abs(Math.sin(radians))) + (width * Math.abs(Math.cos(radians))); - const rx = x + ((width - rw) / 2); - const ry = y + ((height - rh) / 2); - return new PIXI.Rectangle(rx, ry, rw, rh); -}; - -/* -------------------------------------------- */ -/* Deprecations and Compatibility */ -/* -------------------------------------------- */ - -/** - * A PIXI.Rectangle where the width and height are always positive and the x and y are always the top-left - * @extends {PIXI.Rectangle} - */ -class NormalizedRectangle extends PIXI.Rectangle { - constructor(...args) { - super(...args); - foundry.utils.logCompatibilityWarning("You are using the NormalizedRectangle class which has been deprecated in" - + " favor of PIXI.Rectangle.prototype.normalize", {since: 10, until: 12}); - this.normalize(); - } -} - - -/** - * A container group which contains visual effects rendered above the primary group. - * - * ### Hook Events - * - {@link hookEvents.drawEffectsCanvasGroup} - * - {@link hookEvents.createEffectsCanvasGroup} - * - {@link hookEvents.lightingRefresh} - * - * @category - Canvas - */ -class EffectsCanvasGroup extends PIXI.Container { - constructor() { - super(); - this.#createLayers(); - } - - /** - * The current global light source. - * @type {LightSource} - */ - globalLightSource; - - /** - * Whether to currently animate light sources. - * @type {boolean} - */ - animateLightSources = true; - - /** - * Whether to currently animate vision sources. - * @type {boolean} - */ - animateVisionSources = true; - - /** - * A mapping of light sources which are active within the rendered Scene. - * @type {Collection} - */ - lightSources = new foundry.utils.Collection(); - - /** - * A Collection of vision sources which are currently active within the rendered Scene. - * @type {Collection} - */ - visionSources = new foundry.utils.Collection(); - - /* -------------------------------------------- */ - - /** - * Create the child layers of the effects group. - * @private - */ - #createLayers() { - - /** - * A set of vision mask filters used in visual effects group - * @type {Set} - */ - this.visualEffectsMaskingFilters = new Set(); - - /** - * A layer of background alteration effects which change the appearance of the primary group render texture. - * @type {CanvasBackgroundAlterationEffects} - */ - this.background = this.addChild(new CanvasBackgroundAlterationEffects()); - - /** - * A layer which adds illumination-based effects to the scene. - * @type {CanvasIlluminationEffects} - */ - this.illumination = this.addChild(new CanvasIlluminationEffects()); - - /** - * A layer which adds color-based effects to the scene. - * @type {CanvasColorationEffects} - */ - this.coloration = this.addChild(new CanvasColorationEffects()); - - /** - * A layer which controls the current visibility of the scene. - * @type {CanvasVisibility} - */ - this.visibility = this.addChild(new CanvasVisibility()); - - // Call hooks - Hooks.callAll("createEffectsCanvasGroup", this); - } - - /* -------------------------------------------- */ - - /** - * Clear all effects containers and animated sources. - */ - clearEffects() { - this.background.clear(); - this.illumination.clear(); - this.coloration.clear(); - } - - /* -------------------------------------------- */ - - /** - * Draw the component layers of the canvas group. - * @returns {Promise} - */ - async draw() { - // Create the global light source - this.globalLightSource = new GlobalLightSource(undefined); - - // Draw each component layer - await this.background.draw(); - await this.illumination.draw(); - await this.coloration.draw(); - await this.visibility.draw(); - - // Call hooks - Hooks.callAll("drawEffectsCanvasGroup", this); - - // Activate animation of drawn objects - this.activateAnimation(); - } - - /* -------------------------------------------- */ - - /** - * Initialize LightSource objects for all AmbientLightDocument instances which exist within the active Scene. - */ - initializeLightSources() { - this.lightSources.clear(); - - // Create the Global Light source (which may be disabled) - this.lightSources.set("globalLight", this._updateGlobalLightSource()); - - // Ambient Light sources - for ( let light of canvas.lighting.placeables ) { - light.updateSource({defer: true}); - } - - // Token light sources - for ( let token of canvas.tokens.placeables ) { - token.updateLightSource({defer: true}); - } - } - - /* -------------------------------------------- */ - - /** - * Update the global light source which provide global illumination to the Scene. - * @returns {GlobalLightSource} - * @protected - */ - _updateGlobalLightSource() { - const {sceneX, sceneY, maxR} = canvas.dimensions; - this.globalLightSource.initialize(foundry.utils.mergeObject({ - x: sceneX, - y: sceneY, - dim: maxR, - walls: false, - vision: false, - luminosity: 0 - }, CONFIG.Canvas.globalLightConfig)); - return this.globalLightSource; - } - - /* -------------------------------------------- */ - - /** - * Refresh the state and uniforms of all LightSource objects. - */ - refreshLightSources() { - // Force the refresh of all light sources - for ( const lightSource of this.lightSources ) lightSource.refreshSource(); - } - - /* -------------------------------------------- */ - - /** - * Refresh the state and uniforms of all LightSource objects. - */ - refreshVisionSources() { - // Force the refresh of all vision sources - for ( const visionSource of this.visionSources ) visionSource.refreshSource(); - } - - /* -------------------------------------------- */ - - /** - * Refresh the active display of lighting. - */ - refreshLighting() { - // Apply illumination and visibility background color change - this.illumination.backgroundColor = canvas.colors.background; - const v = this.visibility.filter; - if ( v ) Object.assign(v.uniforms, { - exploredColor: canvas.colors.fogExplored.rgb, - unexploredColor: canvas.colors.fogUnexplored.rgb, - backgroundColor: canvas.colors.background.rgb, - visionTexture: canvas.masks.vision.renderTexture, - primaryTexture: canvas.primary.renderTexture - }); - - // Track global illumination changes - const visionUpdate = {initializeVision: this.illumination.updateGlobalLight()}; - - // Clear effects - canvas.effects.clearEffects(); - - // Add lighting effects - for ( const lightSource of this.lightSources.values() ) { - - // Check the active state of the light source - const wasActive = lightSource.active; - lightSource.active = lightSource.updateVisibility(); - if ( lightSource.active !== wasActive ) visionUpdate.refreshVision = true; - if ( !lightSource.active ) continue; - - // Draw the light update - const meshes = lightSource.drawMeshes(); - if ( meshes.background ) this.background.lighting.addChild(meshes.background); - if ( meshes.light ) this.illumination.lights.addChild(meshes.light); - if ( meshes.color ) this.coloration.addChild(meshes.color); - } - - // Add effect meshes for active vision sources - this.#addVisionEffects(); - - // Refresh vision if necessary - canvas.perception.update(visionUpdate, true); - - // Call hooks - Hooks.callAll("lightingRefresh", this); - } - - /* -------------------------------------------- */ - - /** - * Add effect meshes for active vision sources. - * @private - */ - #addVisionEffects() { - for ( const visionSource of this.visionSources ) { - if ( visionSource.radius <= 0 ) continue; - const meshes = visionSource.drawMeshes(); - if ( meshes.background ) { - // Is this vision source background need to be rendered into the preferred vision container, over other VS? - const parent = visionSource.preferred ? this.background.visionPreferred : this.background.vision; - parent.addChild(meshes.background); - } - if ( meshes.vision ) this.illumination.lights.addChild(meshes.vision); - if ( meshes.color ) this.coloration.addChild(meshes.color); - } - - this.background.vision.filter.enabled = !!this.background.vision.children.length; - this.background.visionPreferred.filter.enabled = !!this.background.visionPreferred.children.length; - } - - /* -------------------------------------------- */ - - /** - * Perform a deconstruction workflow for this canvas group when the canvas is retired. - * @returns {Promise} - */ - async tearDown() { - this.deactivateAnimation(); - this.lightSources.clear(); - this.globalLightSource?.destroy(); - this.globalLightSource = undefined; - for ( const c of this.children ) { - if ( c.clear ) c.clear(); - else if ( c.tearDown ) await c.tearDown(); - else c.destroy(); - } - this.visualEffectsMaskingFilters.clear(); - Hooks.callAll("tearDownEffectsCanvasGroup", this); - } - - /* -------------------------------------------- */ - - /** - * Activate vision masking for visual effects - * @param {boolean} [enabled=true] Whether to enable or disable vision masking - */ - toggleMaskingFilters(enabled=true) { - for ( const f of this.visualEffectsMaskingFilters ) { - f.uniforms.enableVisionMasking = enabled; - } - } - - /* -------------------------------------------- */ - - /** - * Activate post-processing effects for a certain effects channel. - * @param {string} filterMode The filter mode to target. - * @param {string[]} [postProcessingModes=[]] The post-processing modes to apply to this filter. - * @param {Object} [uniforms={}] The uniforms to update. - */ - activatePostProcessingFilters(filterMode, postProcessingModes=[], uniforms={}) { - for ( const f of this.visualEffectsMaskingFilters ) { - if ( f.filterMode === filterMode ) { - f.updatePostprocessModes(postProcessingModes, uniforms); - } - } - } - - /* -------------------------------------------- */ - - /** - * Reset post-processing modes on all Visual Effects masking filters. - */ - resetPostProcessingFilters() { - for ( const f of this.visualEffectsMaskingFilters ) { - f.reset(); - } - } - - /* -------------------------------------------- */ - /* Animation Management */ - /* -------------------------------------------- */ - - /** - * Activate light source animation for AmbientLight objects within this layer - */ - activateAnimation() { - this.deactivateAnimation(); - if ( game.settings.get("core", "lightAnimation") === false ) return; - canvas.app.ticker.add(this.#animateSources, this); - } - - /* -------------------------------------------- */ - - /** - * Deactivate light source animation for AmbientLight objects within this layer - */ - deactivateAnimation() { - canvas.app.ticker.remove(this.#animateSources, this); - } - - /* -------------------------------------------- */ - - /** - * The ticker handler which manages animation delegation - * @param {number} dt Delta time - * @private - */ - #animateSources(dt) { - - // Animate Light Sources - if ( this.animateLightSources ) { - for ( const source of this.lightSources.values() ) { - source.animate(dt); - } - } - - // Animate Vision Sources - if ( this.animateVisionSources ) { - for ( const source of this.visionSources.values() ) { - if ( source.visionMode.animated ) source.animate(dt); - } - } - } - - /* -------------------------------------------- */ - - /** - * Animate a smooth transition of the darkness overlay to a target value. - * Only begin animating if another animation is not already in progress. - * @param {number} target The target darkness level between 0 and 1 - * @param {number} duration The desired animation time in milliseconds. Default is 10 seconds - * @returns {Promise} A Promise which resolves once the animation is complete - */ - async animateDarkness(target=1.0, {duration=10000}={}) { - const animationName = "lighting.animateDarkness"; - CanvasAnimation.terminateAnimation(animationName); - if ( target === canvas.darknessLevel ) return false; - if ( duration <= 0 ) return canvas.colorManager.initialize({darknessLevel: target}); - - // Update with an animation - const animationData = [{ - parent: {darkness: canvas.darknessLevel}, - attribute: "darkness", - to: Math.clamped(target, 0, 1) - }]; - return CanvasAnimation.animate(animationData, { - name: animationName, - duration: duration, - ontick: (dt, animation) => - canvas.colorManager.initialize({darknessLevel: animation.attributes[0].parent.darkness}) - }); - } -} - -/** - * A container group which contains the primary canvas group and the effects canvas group. - * - * @category - Canvas - */ -class EnvironmentCanvasGroup extends BaseCanvasMixin(PIXI.Container) { - /** @override */ - static groupName = "environment"; - - /* -------------------------------------------- */ - /* Tear-Down */ - /* -------------------------------------------- */ - - /** @override */ - async tearDown(options={}) { - // We don't want to destroy non-layers children (and destroying children is evil!) - options.preserveChildren = true; - await super.tearDown(options); - } -} - -/** - * A specialized canvas group for rendering hidden containers before all others (like masks). - * @extends {PIXI.Container} - */ -class HiddenCanvasGroup extends BaseCanvasMixin(PIXI.Container) { - constructor() { - super(); - this.interactive = this.interactiveChildren = false; - this.#createMasks(); - } - - /** - * The container which hold masks. - * @type {PIXI.Container} - */ - masks = new PIXI.Container(); - - /** @override */ - static groupName = "hidden"; - - /* -------------------------------------------- */ - - /** - * Add a mask to this group. - * @param {string} name Name of the mask. - * @param {PIXI.DisplayObject} displayObject Display object to add. - * @param {number|undefined} [position=undefined] Position of the mask. - */ - addMask(name, displayObject, position) { - if ( !((typeof name === "string") && (name.length > 0)) ) { - throw new Error(`Adding mask failed. Name ${name} is invalid.`); - } - if ( !displayObject.clear ) { - throw new Error("A mask container must implement a clear method."); - } - // Add the mask to the dedicated `masks` container - this.masks[name] = position - ? this.masks.addChildAt(displayObject, position) - : this.masks.addChild(displayObject); - } - - /* -------------------------------------------- */ - /* Rendering */ - /* -------------------------------------------- */ - - /** @override */ - async draw() { - this.addChild(this.masks); - await this.#drawMasks(); - await super.draw(); - } - - /* -------------------------------------------- */ - - /** - * Perform necessary draw operations. - */ - async #drawMasks() { - await this.masks.vision.draw(); - } - - /* -------------------------------------------- */ - - /** - * Attach masks container to this canvas layer and create tile occlusion, vision masks and depth mask. - */ - #createMasks() { - // The canvas scissor mask is the first thing to render - const canvas = new PIXI.LegacyGraphics(); - this.addMask("canvas", canvas); - - // Then we need to render vision mask - const vision = new CanvasVisionMask(); - this.addMask("vision", vision); - - // Then we need to render occlusion mask - const occlusion = new CanvasOcclusionMask(); - this.addMask("occlusion", occlusion); - - // Then the depth mask, which need occlusion - const depth = new CanvasDepthMask(); - this.addMask("depth", depth); - } - - /* -------------------------------------------- */ - /* Tear-Down */ - /* -------------------------------------------- */ - - /** @override */ - async tearDown() { - this.removeChild(this.masks); - - // Clear all masks (children of masks) - this.masks.children.forEach(c => c.clear()); - - // Then proceed normally - await super.tearDown(); - } -} - -/** - * A container group which displays interface elements rendered above other canvas groups. - * @extends {BaseCanvasMixin(PIXI.Container)} - */ -class InterfaceCanvasGroup extends BaseCanvasMixin(PIXI.Container) { - - /** @override */ - static groupName = "interface"; - - /** - * A container dedicated to the display of scrolling text. - * @type {PIXI.Container} - */ - #scrollingText; - - /** - * A graphics which represent the scene outline. - * @type {PIXI.Graphics} - */ - #outline; - - /* -------------------------------------------- */ - /* Rendering */ - /* -------------------------------------------- */ - - /** - * Draw the canvas group and all its component layers. - * @returns {Promise} - */ - async draw() { - this.#drawOutline(); - this.#drawScrollingText(); - await super.draw(); - - // Now, all placeables have been created on the primary canvas group - // We must sort them - canvas.primary.sortChildren(); - - // After the layers are drawn, assign the reverse mask filter. - this.grid.filters = [this.#createReverseMaskFilter()]; - } - - /* -------------------------------------------- */ - - /** - * Create the reverse mask filter. - * @returns {ReverseMaskFilter} The reference to the reverse mask. - */ - #createReverseMaskFilter() { - if ( !this.reverseMaskfilter ) { - this.reverseMaskfilter = ReverseMaskFilter.create({ - uMaskSampler: canvas.primary.tokensRenderTexture, - channel: "a" - }); - this.reverseMaskfilter.blendMode = PIXI.BLEND_MODES.NORMAL; - } - return this.reverseMaskfilter; - } - - /* -------------------------------------------- */ - - /** - * Draw a background outline which emphasizes what portion of the canvas is playable space and what is buffer. - */ - #drawOutline() { - // Create Canvas outline - const outline = this.#outline = this.addChild(new PIXI.Graphics()); - - const {scene, dimensions} = canvas; - const displayCanvasBorder = scene.padding !== 0; - const displaySceneOutline = !scene.background.src; - if ( !(displayCanvasBorder || displaySceneOutline) ) return; - if ( displayCanvasBorder ) outline.lineStyle({ - alignment: 1, - alpha: 0.75, - color: 0x000000, - join: PIXI.LINE_JOIN.BEVEL, - width: 4 - }).drawShape(dimensions.rect); - if ( displaySceneOutline ) outline.lineStyle({ - alignment: 1, - alpha: 0.25, - color: 0x000000, - join: PIXI.LINE_JOIN.BEVEL, - width: 4 - }).drawShape(dimensions.sceneRect).endFill(); - } - - /* -------------------------------------------- */ - /* Scrolling Text */ - /* -------------------------------------------- */ - - /** - * Draw the scrolling text. - */ - #drawScrollingText() { - this.#scrollingText = this.addChild(new PIXI.Container()); - - const {width, height} = canvas.dimensions; - this.#scrollingText.width = width; - this.#scrollingText.height = height; - this.#scrollingText.zIndex = 1000; - } - - /* -------------------------------------------- */ - - /** - * Display scrolling status text originating from this ObjectHUD container. - * @param {Point} origin An origin point where the text should first emerge - * @param {string} content The text content to display - * @param {object} [options] Options which customize the text animation - * @param {number} [options.duration=2000] The duration of the scrolling effect in milliseconds - * @param {number} [options.distance] The distance in pixels that the scrolling text should travel - * @param {TEXT_ANCHOR_POINTS} [options.anchor] The original anchor point where the text appears - * @param {TEXT_ANCHOR_POINTS} [options.direction] The direction in which the text scrolls - * @param {number} [options.jitter=0] An amount of randomization between [0, 1] applied to the initial position - * @param {object} [options.textStyle={}] Additional parameters of PIXI.TextStyle which are applied to the text - * @returns {Promise} The created PreciseText object which is scrolling - */ - async createScrollingText(origin, content, {duration=2000, distance, jitter=0, anchor, direction, ...textStyle}={}) { - if ( !game.settings.get("core", "scrollingStatusText") ) return null; - - // Create text object - const style = PreciseText.getTextStyle({anchor, ...textStyle}); - const text = this.#scrollingText.addChild(new PreciseText(content, style)); - text.visible = false; - - // Set initial coordinates - const jx = (jitter ? (Math.random()-0.5) * jitter : 0) * text.width; - const jy = (jitter ? (Math.random()-0.5) * jitter : 0) * text.height; - text.position.set(origin.x + jx, origin.y + jy); - - // Configure anchor point - text.anchor.set(...{ - [CONST.TEXT_ANCHOR_POINTS.CENTER]: [0.5, 0.5], - [CONST.TEXT_ANCHOR_POINTS.BOTTOM]: [0.5, 0], - [CONST.TEXT_ANCHOR_POINTS.TOP]: [0.5, 1], - [CONST.TEXT_ANCHOR_POINTS.LEFT]: [1, 0.5], - [CONST.TEXT_ANCHOR_POINTS.RIGHT]: [0, 0.5] - }[anchor ?? CONST.TEXT_ANCHOR_POINTS.CENTER]); - - // Configure animation distance - let dx = 0; - let dy = 0; - switch ( direction ?? CONST.TEXT_ANCHOR_POINTS.TOP ) { - case CONST.TEXT_ANCHOR_POINTS.BOTTOM: - dy = distance ?? (2 * text.height); break; - case CONST.TEXT_ANCHOR_POINTS.TOP: - dy = -1 * (distance ?? (2 * text.height)); break; - case CONST.TEXT_ANCHOR_POINTS.LEFT: - dx = -1 * (distance ?? (2 * text.width)); break; - case CONST.TEXT_ANCHOR_POINTS.RIGHT: - dx = distance ?? (2 * text.width); break; - } - - // Fade In - await CanvasAnimation.animate([ - {parent: text, attribute: "alpha", from: 0, to: 1.0}, - {parent: text.scale, attribute: "x", from: 0.6, to: 1.0}, - {parent: text.scale, attribute: "y", from: 0.6, to: 1.0} - ], { - context: this, - duration: duration * 0.25, - easing: this.easeInOutCosine, - ontick: () => text.visible = true - }); - - // Scroll - const scroll = [{parent: text, attribute: "alpha", to: 0.0}]; - if ( dx !== 0 ) scroll.push({parent: text, attribute: "x", to: text.position.x + dx}); - if ( dy !== 0 ) scroll.push({parent: text, attribute: "y", to: text.position.y + dy}); - await CanvasAnimation.animate(scroll, { - context: this, - duration: duration * 0.75, - easing: this.easeInOutCosine - }); - - // Clean-up - this.#scrollingText.removeChild(text); - text.destroy(); - } -} - -/** - * The primary Canvas group which generally contains tangible physical objects which exist within the Scene. - * This group is a {@link CachedContainer} which is rendered to the Scene as a {@link SpriteMesh}. - * This allows the rendered result of the Primary Canvas Group to be affected by a {@link BaseSamplerShader}. - * @extends {BaseCanvasMixin(CachedContainer)} - * @category - Canvas - */ -class PrimaryCanvasGroup extends BaseCanvasMixin(CachedContainer) { - constructor(sprite) { - sprite ||= new SpriteMesh(undefined, BaseSamplerShader); - super(sprite); - this.interactive = this.interactiveChildren = false; - this.tokensRenderTexture = - this.createRenderTexture({renderFunction: this._renderTokens.bind(this), clearColor: [0, 0, 0, 0]}); - } - - /* -------------------------------------------- */ - - /** @override */ - static groupName = "primary"; - - /** @override */ - clearColor = [0, 0, 0, 0]; - - /** - * Track the set of HTMLVideoElements which are currently playing as part of this group. - * @type {Set} - */ - videoMeshes = new Set(); - - /** - * Allow API users to override the default elevation of the background layer. - * This is a temporary solution until more formal support for scene levels is added in a future release. - * @type {number} - */ - static BACKGROUND_ELEVATION = 0; - - /* -------------------------------------------- */ - /* Group Attributes */ - /* -------------------------------------------- */ - - /** - * The primary background image configured for the Scene, rendered as a SpriteMesh. - * @type {SpriteMesh} - */ - background; - - /** - * The primary foreground image configured for the Scene, rendered as a SpriteMesh. - * @type {SpriteMesh} - */ - foreground; - - /** - * The collection of PrimaryDrawingContainer objects which are rendered in the Scene. - * @type {Collection} - */ - drawings = new foundry.utils.Collection(); - - /** - * The collection of SpriteMesh objects which are rendered in the Scene. - * @type {Collection} - */ - tokens = new foundry.utils.Collection(); - - /** - * The collection of SpriteMesh objects which are rendered in the Scene. - * @type {Collection} - */ - tiles = new foundry.utils.Collection(); - - /** - * Track the current elevation range which is present in the Scene. - * @type {{min: number, max: number}} - * @private - */ - #elevation = {min: 0, max: 1}; - - /* -------------------------------------------- */ - /* Custom Rendering */ - /* -------------------------------------------- */ - - /** - * Render all tokens in their own render texture. - * @param {PIXI.Renderer} renderer The renderer to use. - * @private - */ - _renderTokens(renderer) { - for ( const tokenMesh of this.tokens ) { - tokenMesh.render(renderer); - } - } - - /* -------------------------------------------- */ - /* Group Properties */ - /* -------------------------------------------- */ - - /** - * Return the base HTML image or video element which provides the background texture. - * @type {HTMLImageElement|HTMLVideoElement} - */ - get backgroundSource() { - if ( !this.background.texture.valid || this.background.texture === PIXI.Texture.WHITE ) return null; - return this.background.texture.baseTexture.resource.source; - } - - /* -------------------------------------------- */ - - /** - * Return the base HTML image or video element which provides the foreground texture. - * @type {HTMLImageElement|HTMLVideoElement} - */ - get foregroundSource() { - if ( !this.foreground.texture.valid ) return null; - return this.foreground.texture.baseTexture.resource.source; - } - - /* -------------------------------------------- */ - /* Rendering */ - /* -------------------------------------------- */ - - /** - * Refresh the primary mesh. - */ - refreshPrimarySpriteMesh() { - const singleSource = canvas.effects.visibility.visionModeData.source; - const vmOptions = singleSource?.visionMode.canvas; - const isBaseSampler = (this.sprite.shader.constructor.name === BaseSamplerShader.name); - - if ( !vmOptions && isBaseSampler ) return; - - // Update the primary sprite shader class (or reset to BaseSamplerShader) - this.sprite.setShaderClass(vmOptions?.shader ?? BaseSamplerShader); - this.sprite.shader.uniforms.sampler = this.renderTexture; - - // Need to update uniforms? - if ( !vmOptions?.uniforms ) return; - - vmOptions.uniforms.linkedToDarknessLevel = singleSource?.visionMode.vision.darkness.adaptive; - vmOptions.uniforms.darknessLevel = canvas.colorManager.darknessLevel; - - // Assigning color from source if any - vmOptions.uniforms.tint = singleSource?.colorRGB ?? this.sprite.shader.constructor.defaultUniforms.tint; - - // Updating uniforms in the primary sprite shader - for ( const [uniform, value] of Object.entries(vmOptions?.uniforms ?? {}) ) { - if ( uniform in this.sprite.shader.uniforms ) this.sprite.shader.uniforms[uniform] = value; - } - } - - /* -------------------------------------------- */ - - /** - * Draw the canvas group and all its component layers. - * @returns {Promise} - */ - async draw() { - // Initialize clear color for this cached container - this.clearColor = [...canvas.colors.sceneBackground.rgb, 1]; - - // Draw special meshes - this.#drawBackground(); - this.#drawForeground(); - - await super.draw(); - } - - /* -------------------------------------------- */ - - /** - * Draw the Scene background image. - */ - #drawBackground() { - const bg = this.background = this.addChild(new SpriteMesh()); - bg.elevation = this.constructor.BACKGROUND_ELEVATION; - bg.sort = -9999999999; - const tex = getTexture(canvas.scene.background.src); - this.#drawSceneMesh(this.background, tex); - } - - /* -------------------------------------------- */ - - /** - * Draw the Scene foreground image. - */ - #drawForeground() { - const fg = this.foreground = this.addChild(new SpriteMesh()); - fg.elevation = canvas.scene.foregroundElevation; - fg.sort = -9999999999; - const tex = getTexture(canvas.scene.foreground); - const bg = this.background.texture; - if ( tex && bg && ((tex.width !== bg.width) || (tex.height !== bg.height)) ) { - ui.notifications.warn("WARNING.ForegroundDimensionsMismatch", {localize: true}); - } - this.#drawSceneMesh(fg, tex); - } - - /* -------------------------------------------- */ - - /** - * Draw a SpriteMesh texture that fills the entire Scene rectangle. - * @param {SpriteMesh} mesh The target SpriteMesh - * @param {PIXI.Texture|null} texture The loaded Texture or null - */ - #drawSceneMesh(mesh, texture) { - // No background texture? In this case a PIXI.Texture.WHITE is assigned with alpha 0.025 - mesh.alpha = texture ? 1 : 0.025; - texture ??= PIXI.Texture.WHITE; - - // Assign the texture and configure dimensions - const d = canvas.dimensions; - mesh.texture = texture; - mesh.position.set(d.sceneX, d.sceneY); - mesh.width = d.sceneWidth; - mesh.height = d.sceneHeight; - - // Manage video playback - const video = game.video.getVideoSource(mesh); - if ( video ) { - this.videoMeshes.add(mesh); - game.video.play(video, {volume: game.settings.get("core", "globalAmbientVolume")}); - } - } - - /* -------------------------------------------- */ - /* Tear-Down */ - /* -------------------------------------------- */ - - /** - * Remove and destroy all children from the group. - * Clear container references to rendered objects. - * @returns {Promise} - */ - async tearDown() { - - // Stop video playback - for ( const mesh of this.videoMeshes ) { - game.video.stop(mesh.sourceElement); - mesh.texture.baseTexture.destroy(); - } - - await super.tearDown(); - - // Clear collections - this.videoMeshes.clear(); - this.tokens.clear(); - this.tiles.clear(); - } - - /* -------------------------------------------- */ - /* Token Management */ - /* -------------------------------------------- */ - - /** - * Draw the SpriteMesh for a specific Token object. - * @param {Token} token The Token being added - * @returns {TokenMesh} The added TokenMesh - */ - addToken(token) { - let mesh = this.tokens.get(token.sourceId); - if ( !mesh ) mesh = this.addChild(new TokenMesh(token)); - else mesh.object = token; - mesh.texture = token.texture ?? PIXI.Texture.EMPTY; - mesh.anchor.set(0.5, 0.5); - this.tokens.set(token.sourceId, mesh); - if ( mesh.isVideo ) this.videoMeshes.add(mesh); - return mesh; - } - - /* -------------------------------------------- */ - - /** - * Remove a TokenMesh from the group. - * @param {Token} token The Token being removed - */ - removeToken(token) { - const mesh = this.tokens.get(token.sourceId); - if ( mesh ) { - this.removeChild(mesh); - this.tokens.delete(token.sourceId); - this.videoMeshes.delete(mesh); - if ( !mesh._destroyed ) mesh.destroy({children: true}); - } - } - - /* -------------------------------------------- */ - /* Tile Management */ - /* -------------------------------------------- */ - - /** - * Draw the SpriteMesh for a specific Token object. - * @param {Tile} tile The Tile being added - * @returns {TileMesh|TileSprite} The added TileMesh or TileSprite - */ - addTile(tile) { - let mesh = this.tiles.get(tile.objectId); - if ( !mesh ) { - const cls = tile.document.getFlag("core", "isTilingSprite") ? TileSprite : TileMesh; - mesh = this.addChild(new cls(tile)); - } - else mesh.object = tile; - mesh.texture = tile.texture ?? PIXI.Texture.EMPTY; - mesh.anchor.set(0.5, 0.5); - this.tiles.set(tile.objectId, mesh); - if ( mesh.isVideo ) this.videoMeshes.add(mesh); - return mesh; - } - - /* -------------------------------------------- */ - - /** - * Remove a TokenMesh from the group. - * @param {Tile} tile The Tile being removed - */ - removeTile(tile) { - const mesh = this.tiles.get(tile.objectId); - if ( mesh ) { - this.removeChild(mesh); - this.tiles.delete(tile.objectId); - this.videoMeshes.delete(mesh); - if ( !mesh._destroyed ) mesh.destroy({children: true}); - } - } - - /* -------------------------------------------- */ - /* Drawing Management */ - /* -------------------------------------------- */ - - /** - * Add a DrawingShape to the group. - * @param {Drawing} drawing The Drawing being added - * @returns {DrawingShape} The created DrawingShape instance - */ - addDrawing(drawing) { - let shape = this.drawings.get(drawing.objectId); - if ( !shape ) shape = this.addChild(new DrawingShape(drawing)); - else shape.object = drawing; - shape.texture = drawing.texture ?? null; - this.drawings.set(drawing.objectId, shape); - return shape; - } - - /* -------------------------------------------- */ - - /** - * Remove a DrawingShape from the group. - * @param {Drawing} drawing The Drawing being removed - */ - removeDrawing(drawing) { - const shape = this.drawings.get(drawing.objectId); - if ( shape ) { - this.removeChild(shape); - this.drawings.delete(drawing.objectId); - if ( !shape._destroyed ) shape.destroy({children: true}); - } - } - - /* -------------------------------------------- */ - - /** - * Map a zIndex to an elevation ratio to draw as an intensity to the occlusion mask. - * @param {number} elevation A current elevation (or zIndex) in distance units. - * @returns {number} The color intensity for this elevation on the range [0.19, 1.0] - */ - mapElevationAlpha(elevation) { - const {min, max} = this.#elevation; - if ( min === max ) { - if ( elevation < max ) return 0.19; - else if ( elevation > max ) return 1; - return 0.5; - } - if ( elevation < min ) return 0.19; - const pct = Math.clamped((elevation - min) / (max - min), 0, 1); - const alpha = 0.2 + (0.8 * pct); - return (alpha || 0).toNearest(1 / 255); - } - - /* -------------------------------------------- */ - - /** - * Override the default PIXI.Container behavior for how objects in this container are sorted. - * @override - */ - sortChildren() { - this.#elevation.min = Infinity; - this.#elevation.max = -Infinity; - for ( let i=0; i this.#elevation.max ) this.#elevation.max = elevation; - } - this.children.sort(PrimaryCanvasGroup._sortObjects); - this.sortDirty = false; - } - - /* -------------------------------------------- */ - - /** - * The sorting function used to order objects inside the Primary Canvas Group. - * Overrides the default sorting function defined for the PIXI.Container. - * Sort TokenMesh above other objects, then DrawingShape, all else held equal. - * @param {PrimaryCanvasObject|PIXI.DisplayObject} a An object to display - * @param {PrimaryCanvasObject|PIXI.DisplayObject} b Some other object to display - * @returns {number} - * @private - */ - static _sortObjects(a, b) { - return ((a.elevation || 0) - (b.elevation || 0)) - || (a instanceof TokenMesh) - (b instanceof TokenMesh) - || (a instanceof DrawingShape) - (b instanceof DrawingShape) - || ((a.sort || 0) - (b.sort || 0)) - || (a._lastSortedIndex || 0) - (b._lastSortedIndex || 0); - } -} - -/** - * A container group which contains the environment canvas group and the interface canvas group. - * - * @category - Canvas - */ -class RenderedCanvasGroup extends BaseCanvasMixin(PIXI.Container) { - /** @override */ - static groupName = "rendered"; - - /* -------------------------------------------- */ - /* Tear-Down */ - /* -------------------------------------------- */ - - /** @override */ - async tearDown(options={}) { - // We don't want to destroy non-layers children (and destroying children is evil!) - options.preserveChildren = true; - await super.tearDown(options); - } -} - - -/** - * @typedef {Map} VertexMap - */ - -/** - * @typedef {Set} EdgeSet - */ - -/** - * @typedef {PointSourcePolygonConfig} ClockwiseSweepPolygonConfig - * @property {number} [density] The desired density of padding rays, a number per PI - * @property {number} [externalRadius] A secondary radius used in the case of a limited angle - * @property {number} [aMin] The minimum angle of emission - * @property {number} [aMax] The maximum angle of emission - * @property {boolean} [hasLimitedRadius] Does this polygon have a limited radius? - * @property {boolean} [hasLimitedAngle] Does this polygon have a limited angle? - * @property {number} [radiusE] A small epsilon used for avoiding floating point precision issues - * @property {boolean} [useInnerBounds] Whether to use the inner or outer bounding rectangle - */ - -/** - * @typedef {Ray} PolygonRay - * @property {CollisionResult} result - */ - -/** - * A PointSourcePolygon implementation that uses CCW (counter-clockwise) geometry orientation. - * Sweep around the origin, accumulating collision points based on the set of active walls. - * This algorithm was created with valuable contributions from https://github.com/caewok - * - * @extends PointSourcePolygon - */ -class ClockwiseSweepPolygon extends PointSourcePolygon { - - /** - * The configuration of this polygon. - * @type {ClockwiseSweepPolygonConfig} - */ - config = {}; - - /** - * A mapping of vertices which define potential collision points - * @type {VertexMap} - */ - vertices = new Map(); - - /** - * The set of edges which define potential boundaries of the polygon - * @type {EdgeSet} - */ - edges = new Set(); - - /** - * A collection of rays which are fired at vertices - * @type {PolygonRay[]} - */ - rays = []; - - /* -------------------------------------------- */ - /* Initialization */ - /* -------------------------------------------- */ - - /** @inheritDoc */ - initialize(origin, config) { - super.initialize(origin, config); - const cfg = this.config; - cfg.boundaryShapes ||= []; - - // Configure origin - origin.x = Math.roundFast(origin.x); - origin.y = Math.roundFast(origin.y); - - // Configure radius - cfg.hasLimitedRadius = cfg.radius > 0; - cfg.radius = cfg.radius ?? canvas.dimensions.maxR; - cfg.density = cfg.density ?? PIXI.Circle.approximateVertexDensity(cfg.radius); - cfg.rayDistance = Math.pow(canvas.dimensions.maxR, 2); - cfg.radiusE = 0.5 / cfg.radius; - - // Configure angle - cfg.angle = cfg.angle ?? 360; - cfg.rotation = cfg.rotation ?? 0; - cfg.hasLimitedAngle = cfg.angle !== 360; - - // Determine whether to use inner or outer bounds - cfg.useInnerBounds ??= (cfg.type === "sight") && canvas.dimensions.sceneRect.contains(origin.x, origin.y); - - // Configure custom boundary shapes - if ( cfg.hasLimitedAngle ) this.#configureLimitedAngle(); - else if ( cfg.hasLimitedRadius ) this.#configureLimitedRadius(); - } - - /* -------------------------------------------- */ - - /** - * Configure a limited angle and rotation into a triangular polygon boundary shape. - */ - #configureLimitedAngle() { - this.config.boundaryShapes.push(new LimitedAnglePolygon(this.origin, this.config)); - } - - /* -------------------------------------------- */ - - /** - * Configure a provided limited radius as a circular polygon boundary shape. - */ - #configureLimitedRadius() { - this.config.boundaryShapes.push(new PIXI.Circle(this.origin.x, this.origin.y, this.config.radius)); - } - - /* -------------------------------------------- */ - /* Computation */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - _compute() { - - // Clear prior data - this.points = []; - this.rays = []; - this.vertices.clear(); - this.edges.clear(); - - // Step 1 - Identify candidate edges - this._identifyEdges(); - - // Step 2 - Construct vertex mapping - this._identifyVertices(); - - // Step 3 - Radial sweep over endpoints - this._executeSweep(); - - // Step 4 - Constrain with boundary shapes - this._constrainBoundaryShapes(); - } - - /* -------------------------------------------- */ - /* Edge Configuration */ - /* -------------------------------------------- */ - - /** - * Translate walls and other obstacles into edges which limit visibility - * @private - */ - _identifyEdges() { - - // Add edges for placed Wall objects - const walls = this._getWalls(); - for ( let wall of walls ) { - const edge = PolygonEdge.fromWall(wall, this.config.type); - this.edges.add(edge); - } - - // Add edges for the canvas boundaries - const boundaries = this.config.useInnerBounds ? canvas.walls.innerBounds : canvas.walls.outerBounds; - for ( let boundary of boundaries ) { - const edge = PolygonEdge.fromWall(boundary, this.config.type); - edge._isBoundary = true; - this.edges.add(edge); - } - } - - /* -------------------------------------------- */ - - /** - * Get the super-set of walls which could potentially apply to this polygon. - * Define a custom collision test used by the Quadtree to obtain candidate Walls. - * @returns {Set} - * @protected - */ - _getWalls() { - const bounds = this.config.boundingBox = this._defineBoundingBox(); - const collisionTest = (o, rect) => this._testWallInclusion(o.t, rect); - return canvas.walls.quadtree.getObjects(bounds, { collisionTest }); - } - - /* -------------------------------------------- */ - - /** - * Compute the aggregate bounding box which is the intersection of all boundary shapes. - * Round and pad the resulting rectangle by 1 pixel to ensure it always contains the origin. - * @returns {PIXI.Rectangle} - * @protected - */ - _defineBoundingBox() { - let b = this.config.useInnerBounds ? canvas.dimensions.sceneRect : canvas.dimensions.rect; - for ( const shape of this.config.boundaryShapes ) { - b = b.intersection(shape.getBounds()); - } - return new PIXI.Rectangle(b.x, b.y, b.width, b.height).normalize().ceil().pad(1); - } - - /* -------------------------------------------- */ - - /** - * Test whether a wall should be included in the computed polygon for a given origin and type - * @param {Wall} wall The Wall being considered - * @param {PIXI.Rectangle} bounds The overall bounding box - * @returns {boolean} Should the wall be included? - * @protected - */ - _testWallInclusion(wall, bounds) { - const {type, boundaryShapes} = this.config; - - // First test for inclusion in our overall bounding box - if ( !bounds.lineSegmentIntersects(wall.A, wall.B, { inside: true }) ) return false; - - // Specific boundary shapes may impose additional requirements - for ( const shape of boundaryShapes ) { - if ( shape._includeEdge && !shape._includeEdge(wall.A, wall.B) ) return false; - } - - // Ignore walls which are nearly collinear with the origin, except for movement - const side = wall.orientPoint(this.origin); - if ( (type !== "move") && !side ) return false; - - // Always include interior walls underneath active roof tiles - if ( (type === "sight") && wall.hasActiveRoof ) return true; - - // Otherwise, ignore walls that are not blocking for this polygon type - else if ( !wall.document[type] || wall.isOpen ) return false; - - // Ignore one-directional walls which are facing away from the origin - return !wall.document.dir || (side !== wall.document.dir); - } - - /* -------------------------------------------- */ - /* Vertex Identification */ - /* -------------------------------------------- */ - - /** - * Consolidate all vertices from identified edges and register them as part of the vertex mapping. - * @private - */ - _identifyVertices() { - const wallEdgeMap = new Map(); - - // Register vertices for all edges - for ( let edge of this.edges ) { - - // Get unique vertices A and B - const ak = edge.A.key; - if ( this.vertices.has(ak) ) edge.A = this.vertices.get(ak); - else this.vertices.set(ak, edge.A); - const bk = edge.B.key; - if ( this.vertices.has(bk) ) edge.B = this.vertices.get(bk); - else this.vertices.set(bk, edge.B); - - // Learn edge orientation with respect to the origin - const o = foundry.utils.orient2dFast(this.origin, edge.A, edge.B); - - // Ensure B is clockwise of A - if ( o > 0 ) { - let a = edge.A; - edge.A = edge.B; - edge.B = a; - } - - // Attach edges to each vertex - edge.A.attachEdge(edge, -1); - edge.B.attachEdge(edge, 1); - - // Record the wall->edge mapping - if ( edge.wall ) wallEdgeMap.set(edge.wall.id, edge); - } - - // Add edge intersections - this._identifyIntersections(wallEdgeMap); - } - - /* -------------------------------------------- */ - - /** - * Add additional vertices for intersections between edges. - * @param {Map} wallEdgeMap A mapping of wall IDs to PolygonEdge instances - * @private - */ - _identifyIntersections(wallEdgeMap) { - const processed = new Set(); - for ( let edge of this.edges ) { - - // If the edge has no intersections, skip it - if ( !edge.wall?.intersectsWith.size ) continue; - - // Check each intersecting wall - for ( let [wall, i] of edge.wall.intersectsWith.entries() ) { - - // Some other walls may not be included in this polygon - const other = wallEdgeMap.get(wall.id); - if ( !other || processed.has(other) ) continue; - - // Register the intersection point as a vertex - let v = PolygonVertex.fromPoint(i); - v._intersectionCoordinates = i; - if ( this.vertices.has(v.key) ) v = this.vertices.get(v.key); - else this.vertices.set(v.key, v); - - // If the intersection is with a boundary edge, truncate the edge - if ( other._isBoundary && !v.edges.has(edge) ) this._truncateBoundaryEdge(edge, v); - - // Attach edges to the intersection vertex - // Due to rounding, it is possible for an edge to be completely cw or ccw or only one of the two - // We know from _identifyVertices that vertex B is clockwise of vertex A for every edge - if ( !v.edges.has(edge) ) { - const dir = foundry.utils.orient2dFast(this.origin, edge.B, v) < 0 ? 1 // Edge is fully CCW of v - : (foundry.utils.orient2dFast(this.origin, edge.A, v) > 0 ? -1 : 0); // Edge is fully CW of v - v.attachEdge(edge, dir); - } - if ( !v.edges.has(other) ) { - const dir = foundry.utils.orient2dFast(this.origin, other.B, v) < 0 ? 1 // Other is fully CCW of v - : (foundry.utils.orient2dFast(this.origin, other.A, v) > 0 ? -1 : 0); // Other is fully CW of v - v.attachEdge(other, dir); - } - } - processed.add(edge); - } - } - - /* -------------------------------------------- */ - - /** - * Truncate an edge which intersects with a boundary by replacing one of its vertices with the intersection point. - * @param {PolygonEdge} edge The edge which intersects with the boundary - * @param {PolygonVertex} v The vertex of the intersection point - * @private - */ - _truncateBoundaryEdge(edge, v) { - let replaceA = !this.config.boundingBox.contains(edge.A.x, edge.A.y); - let replaceB = !this.config.boundingBox.contains(edge.B.x, edge.B.y); - if ( replaceA && replaceB ) { // If both vertices are outside, we need to recover which to replace - const da = Math.pow(v.x - edge.A.x, 2) + Math.pow(v.y - edge.A.y, 2); - const db = Math.pow(v.x - edge.B.x, 2) + Math.pow(v.y - edge.B.y, 2); - if ( da < db ) replaceB = false; - else replaceA = false; - } - if ( replaceA ) { - this.vertices.delete(edge.A.key); - edge.A = v; - v.attachEdge(edge, -1); - } - else if ( replaceB ) { - this.vertices.delete(edge.B.key); - edge.B = v; - v.attachEdge(edge, 1); - } - } - - /* -------------------------------------------- */ - /* Radial Sweep */ - /* -------------------------------------------- */ - - /** - * Execute the sweep over wall vertices - * @private - */ - _executeSweep() { - - // Initialize the set of active walls - let activeEdges = this._initializeActiveEdges(); - - // Sort vertices from clockwise to counter-clockwise and begin the sweep - const vertices = this._sortVertices(); - - // Iterate through the vertices, adding polygon points - const ln = vertices.length; - for ( let i=0; i 0; - - this._updateActiveEdges(vertex, activeEdges); - this.#includeCollinearVertices(vertex, vertex.collinearVertices); - - // Look ahead and add any cw walls for vertices collinear with this one - for ( const cv of vertex.collinearVertices ) this._updateActiveEdges(cv, activeEdges); - i += vertex.collinearVertices.size; // Skip processing collinear vertices next loop iteration - - // Determine the result of the sweep for the given vertex - this._determineSweepResult(vertex, activeEdges, hasCollinear); - } - } - - /** - * Include collinear vertices until they have all been added. - * Do not include the original vertex in the set. - * @param {PolygonVertex} vertex The current vertex - * @param {PolygonVertexSet} collinearVertices - */ - #includeCollinearVertices(vertex, collinearVertices) { - for ( const cv of collinearVertices) { - for ( const ccv of cv.collinearVertices ) { - collinearVertices.add(ccv); - } - } - collinearVertices.delete(vertex); - } - - /* -------------------------------------------- */ - - /** - * Update active edges at a given vertex - * Must delete first, in case the edge is in both sets. - * @param {PolygonVertex} vertex The current vertex - * @param {EdgeSet} activeEdges A set of currently active edges - * @private - */ - _updateActiveEdges(vertex, activeEdges) { - for ( const ccw of vertex.ccwEdges ) activeEdges.delete(ccw); - for ( const cw of vertex.cwEdges ) activeEdges.add(cw); - } - - /* -------------------------------------------- */ - - /** - * Determine the initial set of active edges as those which intersect with the initial ray - * @returns {EdgeSet} A set of initially active edges - * @private - */ - _initializeActiveEdges() { - const initial = {x: Math.roundFast(this.origin.x - this.config.rayDistance), y: this.origin.y}; - const edges = new Set(); - for ( let edge of this.edges ) { - const x = foundry.utils.lineSegmentIntersects(this.origin, initial, edge.A, edge.B); - if ( x ) edges.add(edge); - } - return edges; - } - - /* -------------------------------------------- */ - - /** - * Sort vertices clockwise from the initial ray (due west). - * @returns {PolygonVertex[]} The array of sorted vertices - * @private - */ - _sortVertices() { - if ( !this.vertices.size ) return []; - let vertices = Array.from(this.vertices.values()); - const o = this.origin; - - // Sort vertices - vertices.sort((a, b) => { - - // Use true intersection coordinates if they are defined - let pA = a._intersectionCoordinates || a; - let pB = b._intersectionCoordinates || b; - - // Sort by hemisphere - const ya = pA.y > o.y ? 1 : -1; - const yb = pB.y > o.y ? 1 : -1; - if ( ya !== yb ) return ya; // Sort N, S - - // Sort by quadrant - const qa = pA.x < o.x ? -1 : 1; - const qb = pB.x < o.x ? -1 : 1; - if ( qa !== qb ) { // Sort NW, NE, SE, SW - if ( ya === -1 ) return qa; - else return -qa; - } - - // Sort clockwise within quadrant - const orientation = foundry.utils.orient2dFast(o, pA, pB); - if ( orientation !== 0 ) return orientation; - - // At this point, we know points are collinear; track for later processing. - a.collinearVertices.add(b); - b.collinearVertices.add(a); - - // Otherwise, sort closer points first - a._d2 ||= Math.pow(pA.x - o.x, 2) + Math.pow(pA.y - o.y, 2); - b._d2 ||= Math.pow(pB.x - o.x, 2) + Math.pow(pB.y - o.y, 2); - return a._d2 - b._d2; - }); - return vertices; - } - - /* -------------------------------------------- */ - - /** - * Test whether a target vertex is behind some closer active edge. - * If the vertex is to the left of the edge, is must be behind the edge relative to origin. - * If the vertex is collinear with the edge, it should be considered "behind" and ignored. - * We know edge.A is ccw to edge.B because of the logic in _identifyVertices. - * @param {PolygonVertex} vertex The target vertex - * @param {EdgeSet} activeEdges The set of active edges - * @returns {{isBehind: boolean, wasLimited: boolean}} Is the target vertex behind some closer edge? - * @private - */ - _isVertexBehindActiveEdges(vertex, activeEdges) { - let wasLimited = false; - for ( let edge of activeEdges ) { - if ( vertex.edges.has(edge) ) continue; - if ( foundry.utils.orient2dFast(edge.A, edge.B, vertex) > 0 ) { - if ( ( edge.isLimited ) && !wasLimited ) wasLimited = true; - else return {isBehind: true, wasLimited}; - } - } - return {isBehind: false, wasLimited}; - } - - /* -------------------------------------------- */ - - /** - * Determine the result for the sweep at a given vertex - * @param {PolygonVertex} vertex The target vertex - * @param {EdgeSet} activeEdges The set of active edges - * @param {boolean} hasCollinear Are there collinear vertices behind the target vertex? - * @private - */ - _determineSweepResult(vertex, activeEdges, hasCollinear=false) { - - // Determine whether the target vertex is behind some other active edge - const {isBehind, wasLimited} = this._isVertexBehindActiveEdges(vertex, activeEdges); - - // Case 1 - Some vertices can be ignored because they are behind other active edges - if ( isBehind ) return; - - // Construct the CollisionResult object - const result = new CollisionResult({ - target: vertex, - cwEdges: vertex.cwEdges, - ccwEdges: vertex.ccwEdges, - isLimited: vertex.isLimited, - isBehind, - wasLimited - }); - - // Case 2 - No counter-clockwise edge, so begin a new edge - // Note: activeEdges always contain the vertex edge, so never empty - const nccw = vertex.ccwEdges.size; - if ( !nccw ) { - this._switchEdge(result, activeEdges); - result.collisions.forEach(pt => this.addPoint(pt)); - return; - } - - // Case 3 - Limited edges in both directions - // We can only guarantee this case if we don't have collinear endpoints - const ccwLimited = !result.wasLimited && vertex.isLimitingCCW; - const cwLimited = !result.wasLimited && vertex.isLimitingCW; - if ( !hasCollinear && cwLimited && ccwLimited ) return; - - // Case 4 - Non-limited edges in both directions - if ( !ccwLimited && !cwLimited && nccw && vertex.cwEdges.size ) { - result.collisions.push(result.target); - this.addPoint(result.target); - return; - } - - // Case 5 - Otherwise switching edges or edge types - this._switchEdge(result, activeEdges); - result.collisions.forEach(pt => this.addPoint(pt)); - } - - /* -------------------------------------------- */ - - /** - * Switch to a new active edge. - * Moving from the origin, a collision that first blocks a side must be stored as a polygon point. - * Subsequent collisions blocking that side are ignored. Once both sides are blocked, we are done. - * - * Collisions that limit a side will block if that side was previously limited. - * - * If neither side is blocked and the ray internally collides with a non-limited edge, n skip without adding polygon - * endpoints. Sight is unaffected before this edge, and the internal collision can be ignored. - * @private - * - * @param {CollisionResult} result The pending collision result - * @param {EdgeSet} activeEdges The set of currently active edges - */ - _switchEdge(result, activeEdges) { - const origin = this.origin; - - // Construct the ray from the origin - const ray = Ray.towardsPointSquared(origin, result.target, this.config.rayDistance); - ray.result = result; - this.rays.push(ray); // For visualization and debugging - - // Construct sorted array of collisions, moving away from origin - // Collisions are either a collinear vertex or an internal collision to an edge. - const vertices = [result.target, ...result.target.collinearVertices]; - - // Set vertex distances for sorting - vertices.forEach(v => v._d2 ??= Math.pow(v.x - origin.x, 2) + Math.pow(v.y - origin.y, 2)); - - // Get all edge collisions for edges not already represented by a collinear vertex - const internalEdges = activeEdges.filter(e => { - return !vertices.some(v => v.equals(e.A) || v.equals(e.B)); - }); - let xs = this._getInternalEdgeCollisions(ray, internalEdges); - - // Combine the collisions and vertices - xs.push(...vertices); - - // Sort collisions on proximity to the origin - xs.sort((a, b) => a._d2 - b._d2); - - // As we iterate over intersection points we will define the insertion method - let insert = undefined; - const c = result.collisions; - for ( const x of xs ) { - - if ( x.isInternal ) { // Handle internal collisions - // If neither side yet blocked and this is a non-limited edge, return - if ( !result.blockedCW && !result.blockedCCW && !x.isLimited ) return; - - // Assume any edge is either limited or normal, so if not limited, must block. If already limited, must block - result.blockedCW ||= !x.isLimited || result.limitedCW; - result.blockedCCW ||= !x.isLimited || result.limitedCCW; - result.limitedCW = true; - result.limitedCCW = true; - - } else { // Handle true endpoints - result.blockedCW ||= (result.limitedCW && x.isLimitingCW) || x.isBlockingCW; - result.blockedCCW ||= (result.limitedCCW && x.isLimitingCCW) || x.isBlockingCCW; - result.limitedCW ||= x.isLimitingCW; - result.limitedCCW ||= x.isLimitingCCW; - } - - // Define the insertion method and record a collision point - if ( result.blockedCW ) { - insert ||= c.unshift; - if ( !result.blockedCWPrev ) insert.call(c, x); - } - if ( result.blockedCCW ) { - insert ||= c.push; - if ( !result.blockedCCWPrev ) insert.call(c, x); - } - - // Update blocking flags - if ( result.blockedCW && result.blockedCCW ) return; - result.blockedCWPrev ||= result.blockedCW; - result.blockedCCWPrev ||= result.blockedCCW; - } - } - - /* -------------------------------------------- */ - - /** - * Identify the collision points between an emitted Ray and a set of active edges. - * @param {PolygonRay} ray The candidate ray to test - * @param {EdgeSet} internalEdges The set of edges to check for collisions against the ray - * @returns {PolygonVertex[]} A sorted array of collision points - * @private - */ - _getInternalEdgeCollisions(ray, internalEdges) { - const collisions = []; - const A = ray.A; - const B = ray.B; - - for ( let edge of internalEdges ) { - const x = foundry.utils.lineLineIntersection(A, B, edge.A, edge.B); - if ( !x ) continue; - - const c = PolygonVertex.fromPoint(x); - c.attachEdge(edge, 0); - c.isInternal = true; - - // Use the true distance so that collisions can be distinguished from nearby vertices. - c._d2 = Math.pow(x.x - A.x, 2) + Math.pow(x.y - A.y, 2); - collisions.push(c); - } - - return collisions; - } - - /* -------------------------------------------- */ - /* Polygon Construction */ - /* -------------------------------------------- */ - - /** - * Constrain polygon points by applying boundary shapes. - * @private - */ - _constrainBoundaryShapes() { - const {density, boundaryShapes} = this.config; - if ( (this.points.length < 6) || !boundaryShapes.length ) return; - let constrained = this; - const intersectionOptions = {density, scalingFactor: 100}; - for ( const c of boundaryShapes ) { - constrained = c.intersectPolygon(constrained, intersectionOptions); - } - this.points = constrained.points; - } - - /* -------------------------------------------- */ - /* Collision Testing */ - /* -------------------------------------------- */ - - /** - * @deprecated since v10 - * @ignore - */ - static getRayCollisions(ray, config={}) { - const msg = "ClockwiseSweepPolygon.getRayCollisions has been renamed to ClockwiseSweepPolygon.testCollision"; - foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12}); - return this.testCollision(ray.A, ray.B, config); - } - - /** @override */ - _testCollision(ray, mode) { - - // Identify candidate edges - this._identifyEdges(); - - // Identify collision points - let collisions = new Map(); - for ( const edge of this.edges ) { - const x = foundry.utils.lineSegmentIntersection(this.origin, ray.B, edge.A, edge.B); - if ( !x || (x.t0 <= 0) ) continue; - if ( (mode === "any") && ((edge.type === CONST.WALL_SENSE_TYPES.NORMAL) || collisions.size) ) return true; - let c = PolygonVertex.fromPoint(x, {distance: x.t0}); - if ( collisions.has(c.key) ) c = collisions.get(c.key); - else collisions.set(c.key, c); - c.attachEdge(edge); - } - if ( mode === "any" ) return false; - - // Sort collisions - collisions = Array.from(collisions.values()).sort((a, b) => a._distance - b._distance); - if ( collisions[0]?.type === CONST.WALL_SENSE_TYPES.LIMITED ) collisions.shift(); - - // Visualize result - if ( this.config.debug ) this._visualizeCollision(ray, collisions); - - // Return collision result - if ( mode === "all" ) return collisions; - else return collisions[0] || null; - } - - /* -------------------------------------------- */ - /* Visualization */ - /* -------------------------------------------- */ - - /** @override */ - visualize() { - let dg = canvas.controls.debug; - dg.clear(); - - // Text debugging - if ( !canvas.controls.debug.debugText ) { - canvas.controls.debug.debugText = canvas.controls.addChild(new PIXI.Container()); - } - const text = canvas.controls.debug.debugText; - text.removeChildren().forEach(c => c.destroy({children: true})); - - // Define limitation colors - const limitColors = { - [CONST.WALL_SENSE_TYPES.NONE]: 0x77E7E8, - [CONST.WALL_SENSE_TYPES.NORMAL]: 0xFFFFBB, - [CONST.WALL_SENSE_TYPES.LIMITED]: 0x81B90C - }; - - // Draw boundary shapes - for ( const constraint of this.config.boundaryShapes ) { - dg.lineStyle(2, 0xFF4444, 1.0).beginFill(0xFF4444, 0.10).drawShape(constraint).endFill(); - } - - // Draw the final polygon shape - dg.beginFill(0x00AAFF, 0.25).drawShape(this).endFill(); - - // Draw candidate edges - for ( let edge of this.edges ) { - dg.lineStyle(4, limitColors[edge.type]).moveTo(edge.A.x, edge.A.y).lineTo(edge.B.x, edge.B.y); - } - - // Draw vertices - for ( let vertex of this.vertices.values() ) { - if ( vertex.type ) { - dg.lineStyle(1, 0x000000).beginFill(limitColors[vertex.type]).drawCircle(vertex.x, vertex.y, 8).endFill(); - } - if ( vertex._index ) { - let t = text.addChild(new PIXI.Text(String(vertex._index), CONFIG.canvasTextStyle)); - t.position.set(vertex.x, vertex.y); - } - } - - // Draw emitted rays - for ( let ray of this.rays ) { - const r = ray.result; - if ( r ) { - dg.lineStyle(2, 0x00FF00, r.collisions.length ? 1.0 : 0.33).moveTo(ray.A.x, ray.A.y).lineTo(ray.B.x, ray.B.y); - for ( let c of r.collisions ) { - dg.lineStyle(1, 0x000000).beginFill(0xFF0000).drawCircle(c.x, c.y, 6).endFill(); - } - } - } - } - - /* -------------------------------------------- */ - - /** - * Visualize the polygon, displaying its computed area, rays, and collision points - * @param {Ray} ray - * @param {PolygonVertex[]} collisions - * @private - */ - _visualizeCollision(ray, collisions) { - let dg = canvas.controls.debug; - dg.clear(); - const limitColors = { - [CONST.WALL_SENSE_TYPES.NONE]: 0x77E7E8, - [CONST.WALL_SENSE_TYPES.NORMAL]: 0xFFFFBB, - [CONST.WALL_SENSE_TYPES.LIMITED]: 0x81B90C - }; - - // Draw edges - for ( let edge of this.edges.values() ) { - dg.lineStyle(4, limitColors[edge.type]).moveTo(edge.A.x, edge.A.y).lineTo(edge.B.x, edge.B.y); - } - - // Draw the attempted ray - dg.lineStyle(4, 0x0066CC).moveTo(ray.A.x, ray.A.y).lineTo(ray.B.x, ray.B.y); - - // Draw collision points - for ( let x of collisions ) { - dg.lineStyle(1, 0x000000).beginFill(0xFF0000).drawCircle(x.x, x.y, 6).endFill(); - } - } -} - -/** - * A singleton class dedicated to manage the color spaces associated with the scene and the canvas. - * @category - Canvas - */ -class CanvasColorManager { - /** - * The scene darkness level. - * @type {number} - */ - #darknessLevel; - - /** - * Colors exposed by the manager. - * @enum {Color} - */ - colors = { - darkness: undefined, - halfdark: undefined, - background: undefined, - dim: undefined, - bright: undefined, - ambientBrightest: undefined, - ambientDaylight: undefined, - ambientDarkness: undefined, - sceneBackground: undefined, - fogExplored: undefined, - fogUnexplored: undefined - }; - - /** - * Weights used by the manager to compute colors. - * @enum {number} - */ - weights = { - dark: undefined, - halfdark: undefined, - dim: undefined, - bright: undefined - }; - - /** - * Fallback colors. - * @enum {Color} - */ - static #fallbackColors = { - darknessColor: 0x242448, - daylightColor: 0xEEEEEE, - brightestColor: 0xFFFFFF, - backgroundColor: 0x909090, - fogUnexplored: 0x000000, - fogExplored: 0x000000 - }; - - /* -------------------------------------------- */ - - /** - * Returns the darkness penalty for the actual scene configuration. - * @returns {number} - */ - get darknessPenalty() { - return this.darknessLevel * CONFIG.Canvas.darknessLightPenalty; - } - - /* -------------------------------------------- */ - - /** - * Get the darkness level of this scene. - * @returns {number} - */ - get darknessLevel() { - return this.#darknessLevel; - } - - /* -------------------------------------------- */ - - /** - * Initialize color space pertaining to a specific scene. - * @param {object} [colors={}] - * @param {Color|number|string} [colors.backgroundColor] The background canvas color - * @param {Color|number|string} [colors.brightestColor] The brightest ambient color - * @param {Color|number|string} [colors.darknessColor] The color of darkness - * @param {number} [colors.darknessLevel] A preview darkness level - * @param {Color|number|string} [colors.daylightColor] The ambient daylight color - * @param {number} [colors.fogExploredColor] The color applied to explored areas - * @param {number} [colors.fogUnexploredColor] The color applied to unexplored areas - */ - initialize({backgroundColor, brightestColor, darknessColor, darknessLevel, daylightColor, fogExploredColor, - fogUnexploredColor}={}) { - const scene = canvas.scene; - - // Update base ambient colors, and darkness level - const fbc = CanvasColorManager.#fallbackColors; - this.colors.ambientDarkness = Color.from(darknessColor ?? CONFIG.Canvas.darknessColor ?? fbc.darknessColor); - this.colors.ambientDaylight = Color.from(daylightColor - ?? (scene?.tokenVision ? (CONFIG.Canvas.daylightColor ?? fbc.daylightColor) : 0xFFFFFF)); - this.colors.ambientBrightest = Color.from(brightestColor ?? CONFIG.Canvas.brightestColor ?? fbc.brightestColor); - - // Darkness level control - const priorDarknessLevel = this.#darknessLevel ?? 0; - const dl = darknessLevel ?? scene?.darkness ?? 0; - const darknessChanged = (dl !== this.#darknessLevel); - this.#darknessLevel = scene.darkness = dl; - - // Update weights - Object.assign(this.weights, CONFIG.Canvas.lightLevels ?? { - dark: 0, - halfdark: 0.5, - dim: 0.25, - bright: 1 - }); - - // Compute colors - this.#configureColors(scene, {fogExploredColor, fogUnexploredColor, backgroundColor}); - - // Update primary cached container and renderer clear color with scene background color - canvas.app.renderer.backgroundColor = this.colors.rendererBackground; - canvas.primary.clearColor = [...this.colors.sceneBackground.rgb, 1]; - - // If darkness changed, activate some darkness handlers to refresh controls. - if ( darknessChanged ) { - canvas.lighting._onDarknessChange(this.#darknessLevel, priorDarknessLevel); - canvas.sounds._onDarknessChange(this.#darknessLevel, priorDarknessLevel); - } - - // Push a perception update to refresh lighting and sources with the new computed color values - canvas.perception.update({refreshTiles: true, refreshPrimary: true, refreshLighting: true}, true); - } - - /* -------------------------------------------- */ - - /** - * Configure all colors pertaining to a scene. - * @param {Scene} scene The scene document for which colors are configured. - * @param {object} [options={}] Preview options. - * @param {number} [options.fogExploredColor] A preview fog explored color. - * @param {number} [options.fogUnexploredColor] A preview fog unexplored color. - * @param {number} [options.backgroundColor] The background canvas color. - */ - #configureColors(scene, {fogExploredColor, fogUnexploredColor, backgroundColor}={}) { - const fbc = CanvasColorManager.#fallbackColors; - - // Compute the middle ambient color - this.colors.background = this.colors.ambientDarkness.mix(this.colors.ambientDaylight, 1.0 - this.darknessLevel); - - // Compute dark ambient colors - this.colors.darkness = this.colors.ambientDarkness.mix(this.colors.background, this.weights.dark); - this.colors.halfdark = this.colors.darkness.mix(this.colors.background, this.weights.halfdark); - - // Compute light ambient colors - this.colors.bright = - this.colors.background.mix(this.colors.ambientBrightest, (1 - this.darknessPenalty) * this.weights.bright); - this.colors.dim = this.colors.background.mix(this.colors.bright, this.weights.dim); - - // Compute fog colors - const cfg = CONFIG.Canvas; - const uc = Color.from(fogUnexploredColor ?? scene.fogUnexploredColor ?? cfg.unexploredColor ?? fbc.fogUnexplored); - this.colors.fogUnexplored = this.colors.background.multiply(uc); - const ec = Color.from(fogExploredColor ?? scene.fogExploredColor ?? cfg.exploredColor ?? fbc.fogExplored); - this.colors.fogExplored = this.colors.background.multiply(ec); - - // Compute scene background color - const sceneBG = Color.from(backgroundColor ?? scene?.backgroundColor ?? fbc.backgroundColor); - this.colors.sceneBackground = sceneBG; - this.colors.rendererBackground = sceneBG.multiply(this.colors.background); - } -} - -/** - * A Detection Mode which can be associated with any kind of sense/vision/perception. - * A token could have multiple detection modes. - */ -class DetectionMode extends foundry.abstract.DataModel { - - /** @inheritDoc */ - static defineSchema() { - const fields = foundry.data.fields; - return { - id: new fields.StringField({blank: false}), - label: new fields.StringField({blank: false}), - tokenConfig: new fields.BooleanField({initial: true}), // If this DM is available in Token Config UI - walls: new fields.BooleanField({initial: true}), // If this DM is constrained by walls - type: new fields.NumberField({ - initial: this.DETECTION_TYPES.SIGHT, - choices: Object.values(this.DETECTION_TYPES) - }) - }; - } - - /* -------------------------------------------- */ - - /** - * Get the detection filter pertaining to this mode. - * @returns {PIXI.Filter|undefined} - */ - static getDetectionFilter() { - return this._detectionFilter; - } - - /** - * An optional filter to apply on the target when it is detected with this mode. - * @type {PIXI.Filter|undefined} - */ - static _detectionFilter; - - /** - * The type of the detection mode. If its sight based, sound based, etc. - * It is related to wall's WALL_RESTRICTION_TYPES - * @see CONST.WALL_RESTRICTION_TYPES - * @enum {number} - */ - static DETECTION_TYPES = { - SIGHT: 0, // Sight, and anything depending on light perception - SOUND: 1, // What you can hear. Includes echolocation for bats per example - MOVE: 2, // This is mostly a sense for touch and vibration, like tremorsense, movement detection, etc. - OTHER: 3 // Can't fit in other types (smell, life sense, trans-dimensional sense, sense of humor...) - }; - - /** - * The identifier of the basic sight detection mode. - * @type {string} - */ - static BASIC_MODE_ID = "basicSight"; - - /* -------------------------------------------- */ - /* Visibility Testing */ - /* -------------------------------------------- */ - - /** - * Test visibility of a target object or array of points for a specific vision source. - * @param {VisionSource} visionSource The vision source being tested - * @param {TokenDetectionMode} mode The detection mode configuration - * @param {CanvasVisibilityTestConfig} config The visibility test configuration - * @returns {boolean} Is the test target visible? - */ - testVisibility(visionSource, mode, {object, tests}={}) { - if ( !mode.enabled ) return false; - if ( !this._canDetect(visionSource, object) ) return false; - return tests.some(test => this._testPoint(visionSource, mode, object, test)); - } - - /* -------------------------------------------- */ - - /** - * Can this VisionSource theoretically detect a certain object based on its properties? - * This check should not consider the relative positions of either object, only their state. - * @param {VisionSource} visionSource The vision source being tested - * @param {PlaceableObject} target The target object being tested - * @returns {boolean} Can the target object theoretically be detected by this vision source? - * @protected - */ - _canDetect(visionSource, target) { - const src = visionSource.object.document; - if ( (src instanceof TokenDocument) && src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) ) return false; - const tgt = target?.document; - const isInvisible = (tgt instanceof TokenDocument) && tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE); - return !isInvisible; - } - - /* -------------------------------------------- */ - - /** - * Evaluate a single test point to confirm whether it is visible. - * Standard detection rules require that the test point be both within LOS and within range. - * @param {VisionSource} visionSource The vision source being tested - * @param {TokenDetectionMode} mode The detection mode configuration - * @param {PlaceableObject} target The target object being tested - * @param {CanvasVisibilityTest} test The test case being evaluated - * @returns {boolean} - * @protected - */ - _testPoint(visionSource, mode, target, test) { - if ( !this._testLOS(visionSource, mode, target, test) ) return false; - return this._testRange(visionSource, mode, target, test); - } - - /* -------------------------------------------- */ - - /** - * Test whether the line-of-sight requirement for detection is satisfied. - * Always true if the detection mode bypasses walls, otherwise the test point must be contained by the LOS polygon. - * The result of is cached for the vision source so that later checks for other detection modes do not repeat it. - * @param {VisionSource} visionSource The vision source being tested - * @param {TokenDetectionMode} mode The detection mode configuration - * @param {PlaceableObject} target The target object being tested - * @param {CanvasVisibilityTest} test The test case being evaluated - * @returns {boolean} Is the LOS requirement satisfied for this test? - * @protected - */ - _testLOS(visionSource, mode, target, test) { - if ( !this.walls ) return true; - let hasLOS = test.los.get(visionSource); - if ( hasLOS === undefined ) { - hasLOS = visionSource.los.contains(test.point.x, test.point.y); - test.los.set(visionSource, hasLOS); - } - return hasLOS; - } - - /* -------------------------------------------- */ - - /** - * Verify that a target is in range of a source. - * @param {VisionSource} visionSource The vision source being tested - * @param {TokenDetectionMode} mode The detection mode configuration - * @param {PlaceableObject} target The target object being tested - * @param {CanvasVisibilityTest} test The test case being evaluated - * @returns {boolean} Is the target within range? - * @protected - */ - _testRange(visionSource, mode, target, test) { - if ( mode.range <= 0 ) return false; - const radius = visionSource.object.getLightRadius(mode.range); - const dx = test.point.x - visionSource.x; - const dy = test.point.y - visionSource.y; - return ((dx * dx) + (dy * dy)) <= (radius * radius); - } -} - -/* -------------------------------------------- */ - -/** - * A special detection mode which models standard human vision. - * This mode is the default case which is tested first when evaluating visibility of objects. - * It is also a special case, in that it is the only detection mode which considers the area of distant light sources. - */ -class DetectionModeBasicSight extends DetectionMode { - - /** @override */ - _testPoint(visionSource, mode, target, test) { - if ( !this._testLOS(visionSource, mode, target, test) ) return false; - if ( this._testRange(visionSource, mode, target, test) ) return true; - for ( const lightSource of canvas.effects.lightSources.values() ) { - if ( !lightSource.active || lightSource.disabled ) continue; - if ( lightSource.los.contains(test.point.x, test.point.y) ) return true; - } - return false; - } -} - -/* -------------------------------------------- */ - -/** - * Detection mode that see invisible creatures. - * This detection mode allows the source to: - * - See/Detect the invisible target as if visible. - * - The "See" version needs sight and is affected by blindness - */ -class DetectionModeInvisibility extends DetectionMode { - - /** @override */ - static getDetectionFilter() { - return this._detectionFilter ??= GlowOverlayFilter.create({ - glowColor: [0, 0.60, 0.33, 1] - }); - } - - /** @override */ - _canDetect(visionSource, target) { - - // See/Detect Invisibility can ONLY detect invisible status - const tgt = target?.document; - const isInvisible = (tgt instanceof TokenDocument) && tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE); - if ( !isInvisible ) return false; - - // The source may not be blind if the detection mode requires sight - const src = visionSource.object.document; - const isBlind = ( (src instanceof TokenDocument) && (this.type === DetectionMode.DETECTION_TYPES.SIGHT) - && src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) ); - return !isBlind; - } -} - -/* -------------------------------------------- */ - -/** - * Detection mode that see creatures in contact with the ground. - */ -class DetectionModeTremor extends DetectionMode { - /** @override */ - static getDetectionFilter() { - return this._detectionFilter ??= OutlineOverlayFilter.create({ - outlineColor: [1, 0, 1, 1], - knockout: true, - wave: true - }); - } - - /** @override */ - _canDetect(visionSource, target) { - const tgt = target?.document; - return (tgt instanceof TokenDocument) && (tgt.elevation <= canvas.primary.background.elevation); - } -} - -/* -------------------------------------------- */ - -/** - * Detection mode that see ALL creatures (no blockers). - * If not constrained by walls, see everything within the range. - */ -class DetectionModeAll extends DetectionMode { - /** @override */ - static getDetectionFilter() { - return this._detectionFilter ??= OutlineOverlayFilter.create({ - outlineColor: [0.85, 0.85, 1.0, 1], - knockout: true - }); - } - - /** @override */ - _canDetect(visionSource, target) { - // The source may not be blind if the detection mode requires sight - const src = visionSource.object.document; - const isBlind = ( (src instanceof TokenDocument) && (this.type === DetectionMode.DETECTION_TYPES.SIGHT) - && src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) ); - return !isBlind; - } -} - -/** - * A fog of war management class which is the singleton canvas.fog instance. - * @category - Canvas - */ -class FogManager { - - /** - * The FogExploration document which applies to this canvas view - * @type {FogExploration|null} - */ - exploration = null; - - /** - * A status flag for whether the layer initialization workflow has succeeded - * @type {boolean} - * @private - */ - #initialized = false; - - /** - * Track whether we have pending fog updates which have not yet been saved to the database - * @type {boolean} - * @private - */ - #updated = false; - - /** - * A pool of RenderTexture objects which can be cycled through to save fog exploration progress. - * @type {PIXI.RenderTexture[]} - * @private - */ - #textures = []; - - /** - * The maximum allowable fog of war texture size. - * @type {number} - */ - static #MAXIMUM_FOW_TEXTURE_SIZE = 4096; - - /** - * Define the number of positions that are explored before a set of fog updates are pushed to the server. - * @type {number} - */ - static COMMIT_THRESHOLD = 10; - - /** - * A debounced function to save fog of war exploration once a continuous stream of updates has concluded. - * @type {Function} - */ - #debouncedSave = foundry.utils.debounce(this.save.bind(this), 3000); - - /* -------------------------------------------- */ - /* Fog Manager Properties */ - /* -------------------------------------------- */ - - /** - * Vision containers for explored positions which have not yet been committed to the saved texture. - * @type {PIXI.Container} - */ - get pending() { - return this.#pending; - } - - /** @private */ - #pending = new PIXI.Container(); - - /* -------------------------------------------- */ - - /** - * The container of previously revealed exploration. - * @type {PIXI.Container} - */ - get revealed() { - return this.#revealed; - } - - /** @private */ - #revealed = new PIXI.Container(); - - /* -------------------------------------------- */ - - /** - * A sprite containing the saved fog exploration texture. - * @type {PIXI.Sprite} - */ - get sprite() { - return this.#sprite; - } - - /** @private */ - #sprite = new SpriteMesh(PIXI.Texture.EMPTY, FogSamplerShader); - - /* -------------------------------------------- */ - - /** - * The configured resolution used for the saved fog-of-war texture - * @type {FogResolution} - */ - get resolution() { - return this.#resolution; - } - - /** @private */ - #resolution; - - /* -------------------------------------------- */ - - /** - * Does the currently viewed Scene support Token field of vision? - * @type {boolean} - */ - get tokenVision() { - return canvas.scene.tokenVision; - } - - /* -------------------------------------------- */ - - /** - * Does the currently viewed Scene support fog of war exploration? - * @type {boolean} - */ - get fogExploration() { - return canvas.scene.fogExploration; - } - - /* -------------------------------------------- */ - /* Fog of War Management */ - /* -------------------------------------------- */ - - /** - * Initialize fog of war - resetting it when switching scenes or re-drawing the canvas - * @returns {Promise} - */ - async initialize() { - this.#initialized = false; - this.configureResolution(); - if ( this.tokenVision && !this.exploration ) await this.load(); - this.#initialized = true; - } - - /* -------------------------------------------- */ - - /** - * Clear the fog and reinitialize properties (commit and save in non reset mode) - * @returns {Promise} - */ - async clear() { - - // Save any pending exploration - const wasDeleted = !game.scenes.has(canvas.scene?.id); - if ( !wasDeleted ) { - this.commit(); - if ( this.#updated ) await this.save(); - } - - // Deactivate current fog exploration - this.#initialized = false; - this.#deactivate(); - } - - /* -------------------------------------------- */ - - /** - * Once a new Fog of War location is explored, composite the explored container with the current staging sprite - * Save that staging Sprite as the rendered fog exploration and swap it out for a fresh staging texture - * Do all this asynchronously, so it doesn't block token movement animation since this takes some extra time - */ - commit() { - if ( !this.#pending.children.length ) return; - if ( CONFIG.debug.fog ) console.debug("SightLayer | Committing fog exploration to render texture."); - - // Create a staging texture and render the entire fog container to it - const dims = canvas.dimensions; - const tex = this.#getTexture(); - const transform = new PIXI.Matrix(1, 0, 0, 1, -dims.sceneX, -dims.sceneY); - - // Render the currently revealed vision to the texture - canvas.app.renderer.render(this.#revealed, tex, undefined, transform); - - // Return reusable RenderTexture to the pool, destroy past exploration textures - if ( this.#sprite.texture instanceof PIXI.RenderTexture ) this.#textures.push(this.#sprite.texture); - else this.#sprite.texture?.destroy(true); - this.#sprite.texture = tex; - - // Clear the pending container - Canvas.clearContainer(this.#pending); - - // Schedule saving the texture to the database - this.#updated = true; - this.#debouncedSave(); - } - - /* -------------------------------------------- */ - - /** - * Load existing fog of war data from local storage and populate the initial exploration sprite - * @returns {Promise<(PIXI.Texture|void)>} - */ - async load() { - if ( CONFIG.debug.fog ) console.debug("SightLayer | Loading saved FogExploration for Scene."); - - // Remove the previous render texture if one exists - if ( this.#sprite?.texture?.valid ) { - this.#textures.push(this.#sprite.texture); - this.#sprite.texture = null; - } - - // Take no further action if token vision is not enabled - if ( !this.tokenVision ) return; - - // Load existing FOW exploration data or create a new placeholder - const fogExplorationCls = getDocumentClass("FogExploration"); - this.exploration = await fogExplorationCls.get(); - - // Create a brand new FogExploration document - if ( !this.exploration ) { - this.exploration = new fogExplorationCls(); - return this.#sprite.texture = PIXI.Texture.EMPTY; - } - - // Extract and assign the fog data image - const assign = (tex, resolve) => { - this.#sprite.texture = tex; - resolve(tex); - }; - return await new Promise(resolve => { - let tex = this.exploration.getTexture(); - if ( tex === null ) assign(PIXI.Texture.EMPTY, resolve); - else if ( tex.baseTexture.valid ) assign(tex, resolve); - else tex.on("update", tex => assign(tex, resolve)); - }); - } - - /* -------------------------------------------- */ - - /** - * Dispatch a request to reset the fog of war exploration status for all users within this Scene. - * Once the server has deleted existing FogExploration documents, the _onReset handler will re-draw the canvas. - */ - async reset() { - if ( CONFIG.debug.fog ) console.debug("SightLayer | Resetting fog of war exploration for Scene."); - game.socket.emit("resetFog", canvas.scene.id); - } - - /* -------------------------------------------- */ - - /** - * Save Fog of War exploration data to a base64 string to the FogExploration document in the database. - * Assumes that the fog exploration has already been rendered as fog.rendered.texture. - */ - async save() { - if ( !this.tokenVision || !this.fogExploration || !this.exploration ) return; - if ( !this.#updated ) return; - this.#updated = false; - if ( CONFIG.debug.fog ) console.debug("SightLayer | Saving exploration progress to FogExploration document."); - - // Use the existing rendered fog to create a Sprite and downsize to save with smaller footprint - const dims = canvas.dimensions; - const fog = new PIXI.Sprite(this.#sprite.texture); - - // Determine whether a downscaling factor should be used - const maxSize = FogManager.#MAXIMUM_FOW_TEXTURE_SIZE; - const scale = Math.min(maxSize / dims.sceneWidth, maxSize / dims.sceneHeight); - if ( scale < 1.0 ) fog.scale.set(scale, scale); - - // Add the fog to a temporary container to bound it's dimensions and export to base data - const stage = new PIXI.Container(); - stage.addChild(fog); - - // Extract fog exploration to a base64 image - const updateData = { - explored: await ImageHelper.pixiToBase64(stage, "image/jpeg", 0.8), - timestamp: Date.now() - }; - - // Create or update the FogExploration document - if ( !this.exploration.id ) { - this.exploration.updateSource(updateData); - this.exploration = await this.exploration.constructor.create(this.exploration.toJSON()); - } - else await this.exploration.update(updateData); - } - - /* -------------------------------------------- */ - - /** - * Update the fog layer when a player token reaches a board position which was not previously explored - * @param {VisionSource} source The vision source for which the fog layer should update - * @param {boolean} force Force fog to be updated even if the location is already explored - * @returns {boolean} Whether the source position represents a new fog exploration point - */ - update(source, force=false) { - if ( !this.fogExploration || source.isPreview ) return false; - if ( !this.exploration ) { - const cls = getDocumentClass("FogExploration"); - this.exploration = new cls(); - } - return this.exploration.explore(source, force); - } - - /* -------------------------------------------- */ - - /** - * @typedef {object} FogResolution - * @property {number} resolution - * @property {number} width - * @property {number} height - * @property {number} mipmap - * @property {number} scaleMode - * @property {number} multisample - */ - - /** - * Choose an adaptive fog rendering resolution which downscales the saved fog textures for larger dimension Scenes. - * It is important that the width and height of the fog texture is evenly divisible by the downscaling resolution. - * @returns {FogResolution} - * @private - */ - configureResolution() { - const dims = canvas.dimensions; - let width = dims.sceneWidth; - let height = dims.sceneHeight; - const maxSize = FogManager.#MAXIMUM_FOW_TEXTURE_SIZE; - - // Adapt the fog texture resolution relative to some maximum size, and ensure that multiplying the scene dimensions - // by the resolution results in an integer number in order to avoid fog drift. - let resolution = 1.0; - if ( (width >= height) && (width > maxSize) ) { - resolution = maxSize / width; - height = Math.ceil(height * resolution) / resolution; - } else if ( height > maxSize ) { - resolution = maxSize / height; - width = Math.ceil(width * resolution) / resolution; - } - - // Determine the fog texture dimensions that is evenly divisible by the scaled resolution - return this.#resolution = { - resolution, - width, - height, - mipmap: PIXI.MIPMAP_MODES.OFF, - scaleMode: PIXI.SCALE_MODES.LINEAR, - multisample: PIXI.MSAA_QUALITY.NONE - }; - } - - /* -------------------------------------------- */ - - /** - * Deactivate fog of war. - * Clear all shared containers by unlinking them from their parent. - * Destroy all stored textures and graphics. - */ - #deactivate() { - - // Remove the current exploration document - this.exploration = null; - canvas.masks.vision.clear(); - - // Un-stage fog containers from the visibility layer - if ( this.#pending.parent ) this.#pending.parent.removeChild(this.#pending); - if ( this.#revealed.parent ) this.#revealed.parent.removeChild(this.#revealed); - if ( this.#sprite.parent ) this.#sprite.parent.removeChild(this.#sprite); - - // Clear the pending container - Canvas.clearContainer(this.#pending); - - // Destroy fog exploration textures - while ( this.#textures.length ) { - const t = this.#textures.pop(); - t.destroy(true); - } - this.#sprite.texture.destroy(true); - this.#sprite.texture = PIXI.Texture.EMPTY; - } - - /* -------------------------------------------- */ - - /** - * If fog of war data is reset from the server, re-draw the canvas - * @returns {Promise} - * @internal - */ - async _handleReset() { - ui.notifications.info("Fog of War exploration progress was reset for this Scene"); - - // Deactivate the existing fog containers and re-draw CanvasVisibility - this.#deactivate(); - - // Create new fog exploration - const cls = getDocumentClass("FogExploration"); - this.exploration = new cls(); - - // Re-draw the canvas visibility layer - await canvas.effects.visibility.draw(); - canvas.perception.initialize(); - } - - /* -------------------------------------------- */ - - /** - * Get a usable RenderTexture from the textures pool - * @returns {PIXI.RenderTexture} - */ - #getTexture() { - if ( this.#textures.length ) { - const tex = this.#textures.pop(); - if ( tex.valid ) return tex; - } - return PIXI.RenderTexture.create(this.#resolution); - } -} - -/** - * A helper class which manages the refresh workflow for perception layers on the canvas. - * This controls the logic which batches multiple requested updates to minimize the amount of work required. - * A singleton instance is available as canvas#perception. - * @see {Canvas#perception} - */ -class PerceptionManager { - - /** - * The set of state flags which are supported by the Perception Manager. - * When a refresh occurs, operations associated with each true flag are executed and the state is reset. - * @enum {{propagate: string[], reset: string[]}} - */ - static FLAGS = { - initializeLighting: {propagate: ["refreshLighting"], reset: []}, - refreshLighting: {propagate: ["refreshLightSources"], reset: []}, - refreshLightSources: {propagate: [], reset: []}, - refreshVisionSources: {propagate: [], reset: []}, - refreshPrimary: {propagate: [], reset: []}, - initializeVision: {propagate: ["refreshVision", "refreshTiles", - "refreshLighting", "refreshLightSources", "refreshPrimary"], reset: []}, - refreshVision: {propagate: ["refreshVisionSources"], reset: []}, - initializeSounds: {propagate: ["refreshSounds"], reset: []}, - refreshSounds: {propagate: [], reset: []}, - refreshTiles: {propagate: ["refreshLightSources", "refreshVisionSources"], reset: []}, - soundFadeDuration: {propagate: [], reset: []}, - forceUpdateFog: {propagate: [], reset: []} - }; - - /** - * A shim mapping which supports backwards compatibility for old-style (V9 and before) perception manager flags. - * @enum {string} - */ - static COMPATIBILITY_MAPPING = { - "lighting.initialize": "initializeLighting", - "lighting.refresh": "refreshLighting", - "sight.initialize": "initializeVision", - "sight.refresh": "refreshVision", - "sight.forceUpdateFog": "forceUpdateFog", - "sounds.initialize": "initializeSounds", - "sounds.refresh": "refreshSounds", - "sounds.fade": "soundFadeDuration", - "foreground.refresh": "refreshTiles" - }; - - /** - * A top-level boolean which records whether any flag has changed. - * @type {boolean} - */ - #changed = false; - - /** - * Flags which are scheduled to be enacted with the next frame. - * @enum {boolean} - */ - #flags = this.#getFlags(); - - /* -------------------------------------------- */ - /* Perception Manager Methods */ - /* -------------------------------------------- */ - - /** - * Activate perception management by registering the update function to the Ticker. - */ - activate() { - this.deactivate(); - canvas.app.ticker.add(this.#update, this, PIXI.UPDATE_PRIORITY.HIGH); - } - - /* -------------------------------------------- */ - - /** - * Deactivate perception management by un-registering the update function from the Ticker. - */ - deactivate() { - canvas.app.ticker.remove(this.#update, this); - this.#reset(); - } - - /* -------------------------------------------- */ - - /** - * Update perception manager flags which configure which behaviors occur on the next frame render. - * @param {object} flags Flag values (true) to assign where the keys belong to PerceptionManager.FLAGS - * @param {boolean} [v2=false] Opt-in to passing v2 flags, otherwise a backwards compatibility shim will be applied - */ - update(flags, v2=false) { - - // Backwards compatibility for V1 flags - let _flags = v2 ? flags : {}; - if ( !v2 ) { - const msg = "The data structure of PerceptionManager flags have changed. You are assigning flags with the old " - + "data structure and must migrate to assigning new flags."; - foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12}); - flags = foundry.utils.flattenObject(flags); - for ( const [flag, value] of Object.entries(flags) ) { - _flags[PerceptionManager.COMPATIBILITY_MAPPING[flag]] = value; - } - } - - // Assign flags - for ( const [flag, value] of Object.entries(_flags) ) { - if ( value !== true ) continue; - const cfg = PerceptionManager.FLAGS[flag]; - this.#flags[flag] = this.#changed = true; - for ( const p of cfg.propagate ) this.#flags[p] = true; - for ( const r of cfg.reset ) this.#flags[r] = false; - } - } - - /* -------------------------------------------- */ - - /** - * A helper function to perform an immediate initialization plus incremental refresh. - */ - initialize() { - return this.update({ - initializeLighting: true, - initializeVision: true, - initializeSounds: true - }, true); - } - - /* -------------------------------------------- */ - - /** - * A helper function to perform an incremental refresh only. - */ - refresh() { - return this.update({ - refreshLighting: true, - refreshVision: true, - refreshSounds: true, - refreshTiles: true - }, true); - } - - /* -------------------------------------------- */ - /* Internal Helpers */ - /* -------------------------------------------- */ - - /** - * Perform the perception update workflow. - * @private - */ - #update() { - if ( !this.#changed ) return; - - // When an update occurs, immediately reset refresh parameters - const flags = this.#flags; - this.#reset(); - - // Initialize perception sources for each layer - if ( flags.initializeLighting ) canvas.effects.initializeLightSources(); - if ( flags.initializeVision ) canvas.effects.visibility.initializeSources(); - if ( flags.initializeSounds ) canvas.sounds.initializeSources(); - - // Update roof occlusion states based on token positions and vision - if ( flags.refreshTiles ) canvas.masks.occlusion.updateOcclusion(); - - // Next refresh sources uniforms and states - if ( flags.refreshLightSources ) canvas.effects.refreshLightSources(); - if ( flags.refreshVisionSources ) canvas.effects.refreshVisionSources(); - if ( flags.refreshPrimary ) canvas.primary.refreshPrimarySpriteMesh(); - - // Next refresh lighting to establish the coloration channels for the Scene - if ( flags.refreshLighting ) canvas.effects.refreshLighting(); - - // Next refresh vision and fog of war - if ( flags.refreshVision ) canvas.effects.visibility.refresh({forceUpdateFog: flags.forceUpdateFog}); - - // Lastly update the playback of ambient sounds - if ( flags.refreshSounds ) canvas.sounds.refresh({fade: flags.soundFadeDuration ? 250 : 0}); - } - - /* -------------------------------------------- */ - - /** - * Reset the values of a pending refresh back to their default states. - * @private - */ - #reset() { - this.#changed = false; - this.#flags = this.#getFlags(); - } - - /* -------------------------------------------- */ - - /** - * Construct the data structure of boolean flags which are supported by the Perception Manager. - * @returns {Object} - */ - #getFlags() { - const flags = {}; - for ( const flag of Object.keys(PerceptionManager.FLAGS) ) { - flags[flag] = false; - } - return flags; - } - - /* -------------------------------------------- */ - /* Deprecations and Compatibility */ - /* -------------------------------------------- */ - - /** - * @deprecated since v10 - * @ignore - */ - cancel() { - foundry.utils.logCompatibilityWarning("PerceptionManager#cancel is renamed to PerceptionManager#deactivate", { - since: 10, - until: 12 - }); - return this.deactivate(); - } - - /* -------------------------------------------- */ - - /** - * @deprecated since v10 - * @ignore - */ - schedule(options={}) { - foundry.utils.logCompatibilityWarning("PerceptionManager#schedule is replaced by PerceptionManager#update", { - since: 10, - until: 12 - }); - this.update(options); - } - - /* -------------------------------------------- */ - - /** - * @deprecated since v10 - * @ignore - */ - static get DEFAULTS() { - const msg = "PerceptionManager#DEFAULTS is deprecated in favor of PerceptionManager#FLAGS"; - foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12}); - return this.FLAGS; - } -} - -/** - * A special subclass of DataField used to reference an AbstractBaseShader definition. - */ -class ShaderField extends foundry.data.fields.DataField { - - /** @inheritdoc */ - static get _defaults() { - const defaults = super._defaults; - defaults.nullable = true; - defaults.initial = undefined; - return defaults; - } - - /** @override */ - _cast(value) { - if ( !foundry.utils.isSubclass(value, AbstractBaseShader) ) { - throw new Error("The value provided to a ShaderField must be an AbstractBaseShader subclass."); - } - return value; - } -} - -/** - * A Vision Mode which can be selected for use by a Token. - * The selected Vision Mode alters the appearance of various aspects of the canvas while that Token is the POV. - */ -class VisionMode extends foundry.abstract.DataModel { - /** - * Construct a Vision Mode using provided configuration parameters and callback functions. - * @param {object} data Data which fulfills the model defined by the VisionMode schema. - * @param {object} [options] Additional options passed to the DataModel constructor. - */ - constructor(data={}, options={}) { - super(data, options); - this.animated = options.animated ?? false; - } - - /** @inheritDoc */ - static defineSchema() { - const fields = foundry.data.fields; - const shaderSchema = () => new fields.SchemaField({ - shader: new ShaderField(), - uniforms: new fields.ObjectField() - }); - const lightingSchema = () => new fields.SchemaField({ - visibility: new fields.NumberField({ - initial: this.LIGHTING_VISIBILITY.ENABLED, - choices: Object.values(this.LIGHTING_VISIBILITY) - }), - postProcessingModes: new fields.ArrayField(new fields.StringField()), - uniforms: new fields.ObjectField() - }); - - // Return model schema - return { - id: new fields.StringField({blank: false}), - label: new fields.StringField({blank: false}), - tokenConfig: new fields.BooleanField({initial: true}), - canvas: new fields.SchemaField({ - shader: new ShaderField(), - uniforms: new fields.ObjectField() - }), - lighting: new fields.SchemaField({ - background: lightingSchema(), - coloration: lightingSchema(), - illumination: lightingSchema(), - levels: new fields.ObjectField({ - validate: o => { - const values = Object.values(this.LIGHTING_LEVELS); - return Object.entries(o).every(([k, v]) => values.includes(Number(k)) && values.includes(v)); - }, - validationError: "may only contain a mapping of keys from VisionMode.LIGHTING_LEVELS" - }), - multipliers: new fields.ObjectField({ - validate: o => { - const values = Object.values(this.LIGHTING_LEVELS); - return Object.entries(o).every(([k, v]) => values.includes(Number(k)) && Number.isFinite(v)); - }, - validationError: "must provide a mapping of keys from VisionMode.LIGHTING_LEVELS to numeric multiplier values" - }) - }), - vision: new fields.SchemaField({ - background: shaderSchema(), - coloration: shaderSchema(), - illumination: shaderSchema(), - darkness: new fields.SchemaField({ - adaptive: new fields.BooleanField({initial: true}) - }), - defaults: new fields.ObjectField(), - preferred: new fields.BooleanField({initial: false}) - }) - }; - } - - /** - * The lighting illumination levels which are supported. - * @enum {number} - */ - static LIGHTING_LEVELS = { - DARKNESS: -2, - HALFDARK: -1, - UNLIT: 0, - DIM: 1, - BRIGHT: 2, - BRIGHTEST: 3 - }; - - /** - * Flags for how each lighting channel should be rendered for the currently active vision modes: - * - Disabled: this lighting layer is not rendered, the shaders does not decide. - * - Enabled: this lighting layer is rendered normally, and the shaders can choose if they should be rendered or not. - * - Required: the lighting layer is rendered, the shaders does not decide. - * @enum {number} - */ - static LIGHTING_VISIBILITY = { - DISABLED: 0, - ENABLED: 1, - REQUIRED: 2 - }; - - /** - * A flag for whether this vision source is animated - * @type {boolean} - */ - animated = false; - - /** - * Special handling which is needed when this Vision Mode is activated for a VisionSource. - * @param {VisionSource} source Activate this VisionMode for a specific source - */ - activate(source) {} - - /** - * An animation function which runs every frame while this Vision Mode is active. - * @param {number} dt The deltaTime passed by the PIXI Ticker - */ - animate(dt) { - return VisionSource.prototype.animateTime.call(this, dt); - } - - /** - * Special handling which is needed when this Vision Mode is deactivated for a VisionSource. - * @param {VisionSource} source Deactivate this VisionMode for a specific source - */ - deactivate(source) {} -} - -/** - * The Drawing object is an implementation of the PlaceableObject container. - * Each Drawing is a placeable object in the DrawingsLayer. - * @category - Canvas - * @see {@link DrawingDocument} - * @see {@link DrawingsLayer} - */ -class Drawing extends PlaceableObject { - - /** - * The border frame and resizing handles for the drawing. - * @type {PIXI.Container} - */ - frame; - - /** - * A text label that may be displayed as part of the interface layer for the Drawing. - * @type {PreciseText|null} - */ - text = null; - - /** - * The drawing shape which is rendered as a PIXI.Graphics subclass in the PrimaryCanvasGroup. - * @type {DrawingShape} - */ - shape; - - /** - * An internal timestamp for the previous freehand draw time, to limit sampling. - * @type {number} - * @private - */ - _drawTime = 0; - - /** - * An internal flag for the permanent points of the polygon. - * @type {number[]} - * @private - */ - _fixedPoints = foundry.utils.deepClone(this.document.shape.points); - - /* -------------------------------------------- */ - - /** @inheritdoc */ - static embeddedName = "Drawing"; - - /* -------------------------------------------- */ - - /** - * The rate at which points are sampled (in milliseconds) during a freehand drawing workflow - * @type {number} - */ - static FREEHAND_SAMPLE_RATE = 75; - - /** - * A convenience reference to the possible shape types. - * @enum {string} - */ - static SHAPE_TYPES = foundry.data.ShapeData.TYPES; - - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** @override */ - get bounds() { - const {x, y, shape, rotation} = this.document; - if ( rotation !== 0 ) { - return new PIXI.Rectangle.fromRotation(x, y, shape.width, shape.height, Math.toRadians(rotation)).normalize(); - } - return new PIXI.Rectangle(x, y, shape.width, shape.height).normalize(); - } - - /* -------------------------------------------- */ - - /** @override */ - get center() { - const {x, y, shape} = this.document; - return new PIXI.Point(x + (shape.width / 2), y + (shape.height / 2)); - } - - /* -------------------------------------------- */ - - /** - * A Boolean flag for whether the Drawing utilizes a tiled texture background? - * @type {boolean} - */ - get isTiled() { - return this.document.fillType === CONST.DRAWING_FILL_TYPES.PATTERN; - } - - /* -------------------------------------------- */ - - /** - * A Boolean flag for whether the Drawing is a Polygon type (either linear or freehand)? - * @type {boolean} - */ - get isPolygon() { - return this.type === Drawing.SHAPE_TYPES.POLYGON; - } - - /* -------------------------------------------- */ - - /** - * Does the Drawing have text that is displayed? - * @type {boolean} - */ - get hasText() { - return this.document.text && (this.document.fontSize > 0); - } - - /* -------------------------------------------- */ - - /** - * The shape type that this Drawing represents. A value in Drawing.SHAPE_TYPES. - * @see {@link Drawing.SHAPE_TYPES} - * @type {string} - */ - get type() { - return this.document.shape.type; - } - - /* -------------------------------------------- */ - /* Rendering */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - clear() { - this._pendingText = this.document.text ?? ""; - this.text = undefined; - return super.clear(); - } - - /* -------------------------------------------- */ - - /** @inheritDoc */ - _destroy(options) { - canvas.primary.removeDrawing(this); - this.texture?.destroy(); - } - - /* -------------------------------------------- */ - - /** @override */ - async _draw() { - - // Load the background texture, if one is defined - const texture = this.document.texture; - if ( this.isPreview ) this.texture = this._original.texture?.clone(); - else this.texture = texture ? await loadTexture(texture, {fallback: "icons/svg/hazard.svg"}) : null; - - // Create the primary group drawing container - this.shape = canvas.primary.addDrawing(this); - - // Control Border - this.frame = this.addChild(this.#drawFrame()); - - // Drawing text - this.text = this.hasText ? this.addChild(this.#drawText()) : null; - } - - /* -------------------------------------------- */ - - /** - * Create elements for the Drawing border and handles - * @returns {PIXI.Container} - * @private - */ - #drawFrame() { - const frame = new PIXI.Container(); - frame.border = frame.addChild(new PIXI.Graphics()); - frame.handle = frame.addChild(new ResizeHandle([1, 1])); - return frame; - } - - /* -------------------------------------------- */ - - /** - * Prepare the text style used to instantiate a PIXI.Text or PreciseText instance for this Drawing document. - * @returns {PIXI.TextStyle} - * @protected - */ - _getTextStyle() { - const {fontSize, fontFamily, textColor, shape} = this.document; - const stroke = Math.max(Math.round(fontSize / 32), 2); - return PreciseText.getTextStyle({ - fontFamily: fontFamily, - fontSize: fontSize, - fill: textColor, - strokeThickness: stroke, - dropShadowBlur: Math.max(Math.round(fontSize / 16), 2), - align: "left", - wordWrap: true, - wordWrapWidth: shape.width, - padding: stroke * 4 - }); - } - - /* -------------------------------------------- */ - - /** - * Create a PreciseText element to be displayed as part of this drawing. - * @returns {PreciseText} - * @private - */ - #drawText() { - const textStyle = this._getTextStyle(); - return new PreciseText(this.document.text || undefined, textStyle); - } - - /* -------------------------------------------- */ - - /** @override */ - _refresh(options) { - - // Refresh the primary drawing container - this.shape.refresh(); - - // Refresh the shape bounds and the displayed frame - const {x, y, z, hidden, shape, rotation} = this.document; - const bounds = PIXI.Rectangle.fromRotation(0, 0, shape.width, shape.height, Math.toRadians(rotation)).normalize(); - this.hitArea = this.controlled ? bounds.clone().pad(50) : bounds; // Pad to include resize handle - this.buttonMode = true; - if ( this.id && this.controlled ) this.#refreshFrame(bounds); - else this.frame.visible = false; - - // Refresh the display of text - this.#refreshText(); - - // Set position and visibility - this.position.set(x, y); - this.zIndex = z; - this.visible = !hidden || game.user.isGM; - } - - /* -------------------------------------------- */ - - /** - * Refresh the boundary frame which outlines the Drawing shape - * @param {Rectangle} rect The rectangular bounds of the drawing - * @private - */ - #refreshFrame(rect) { - - // Determine the border color - const colors = CONFIG.Canvas.dispositionColors; - let bc = colors.INACTIVE; - if ( this.controlled ) { - bc = this.document.locked ? colors.HOSTILE : colors.CONTROLLED; - } - - // Draw the padded border - const pad = 6; - const t = CONFIG.Canvas.objectBorderThickness; - const h = Math.round(t/2); - const o = Math.round(h/2) + pad; - const border = rect.clone().pad(o); - this.frame.border.clear().lineStyle(t, 0x000000).drawShape(border).lineStyle(h, bc).drawShape(border); - - // Draw the handle - this.frame.handle.refresh(border); - this.frame.visible = true; - } - - /* -------------------------------------------- */ - - /** - * Refresh the appearance of text displayed above the drawing. - * @private - */ - #refreshText() { - if ( !this.text ) return; - const {rotation, textAlpha, shape, hidden} = this.document; - this.text.alpha = hidden ? Math.min(0.5, textAlpha) : (textAlpha ?? 1.0); - this.text.pivot.set(this.text.width / 2, this.text.height / 2); - this.text.position.set( - (this.text.width / 2) + ((shape.width - this.text.width) / 2), - (this.text.height / 2) + ((shape.height - this.text.height) / 2) - ); - this.text.angle = rotation; - } - - /* -------------------------------------------- */ - /* Interactivity */ - /* -------------------------------------------- */ - - /** - * Add a new polygon point to the drawing, ensuring it differs from the last one - * @param {Point} position The drawing point to add - * @param {object} [options] Options which configure how the point is added - * @param {boolean} [options.round=false] Should the point be rounded to integer coordinates? - * @param {boolean} [options.snap=false] Should the point be snapped to grid precision? - * @param {boolean} [options.temporary=false] Is this a temporary control point? - * @internal - */ - _addPoint(position, {round=false, snap=false, temporary=false}={}) { - if ( snap ) position = canvas.grid.getSnappedPosition(position.x, position.y, this.layer.gridPrecision); - else if ( round ) { - position.x = Math.roundFast(position.x); - position.y = Math.roundFast(position.y); - } - - // Avoid adding duplicate points - const last = this._fixedPoints.slice(-2); - const next = [position.x - this.document.x, position.y - this.document.y]; - if ( next.equals(last) ) return; - - // Append the new point and update the shape - const points = this._fixedPoints.concat(next); - this.document.shape.updateSource({points}); - if ( !temporary ) { - this._fixedPoints = points; - this._drawTime = Date.now(); - } - } - - /* -------------------------------------------- */ - - /** - * Remove the last fixed point from the polygon - * @private - */ - _removePoint() { - this._fixedPoints.splice(-2); - this.document.shape.updateSource({points: this._fixedPoints}); - } - - /* -------------------------------------------- */ - - /** @override */ - _onControl(options) { - super._onControl(options); - if ( game.activeTool === "text" ) { - this._onkeydown = this._onDrawingTextKeydown.bind(this); - if ( !options.isNew ) this._pendingText = this.document.text; - document.addEventListener("keydown", this._onkeydown); - } - } - - /* -------------------------------------------- */ - - /** @override */ - _onRelease(options) { - super._onRelease(options); - if ( this._onkeydown ) { - document.removeEventListener("keydown", this._onkeydown); - this._onkeydown = null; - } - if ( game.activeTool === "text" ) { - if ( !canvas.scene.drawings.has(this.id) ) return; - let text = this._pendingText ?? this.document.text; - if ( text === "" ) return this.document.delete(); - if ( this._pendingText ) { // Submit pending text - this.document.update({ - text: this._pendingText, - width: this.document.shape.width, - height: this.document.shape.height - }); - this._pendingText = ""; - } - } - } - - /* -------------------------------------------- */ - - /** @override */ - _onDelete(...args) { - super._onDelete(...args); - if ( this._onkeydown ) document.removeEventListener("keydown", this._onkeydown); - } - - /* -------------------------------------------- */ - - /** - * Handle text entry in an active text tool - * @param {KeyboardEvent} event - * @private - */ - _onDrawingTextKeydown(event) { - - // Ignore events when an input is focused, or when ALT or CTRL modifiers are applied - if ( event.altKey || event.ctrlKey || event.metaKey ) return; - if ( game.keyboard.hasFocus ) return; - - // Track refresh or conclusion conditions - let conclude = ["Escape", "Enter"].includes(event.key); - let refresh = false; - - // Submitting the change, update or delete - if ( event.key === "Enter" ) { - if ( this._pendingText ) { - return this.document.update({ - text: this._pendingText, - width: this.document.shape.width, - height: this.document.shape.height - }).then(() => this.release()); - } - else return this.document.delete(); - } - - // Cancelling the change - else if ( event.key === "Escape" ) { - this._pendingText = this.document.text; - refresh = true; - } - - // Deleting a character - else if ( event.key === "Backspace" ) { - this._pendingText = this._pendingText.slice(0, -1); - refresh = true; - } - - // Typing text (any single char) - else if ( /^.$/.test(event.key) ) { - this._pendingText += event.key; - refresh = true; - } - - // Stop propagation if the event was handled - if ( refresh || conclude ) { - event.preventDefault(); - event.stopPropagation(); - } - - // Refresh the display - if ( refresh ) { - this.text.text = this._pendingText; - this.document.shape.width = this.text.width + 100; - this.document.shape.height = this.text.height + 50; - this.refresh(); - } - - // Conclude the workflow - if ( conclude ) { - this.release(); - } - } - - /* -------------------------------------------- */ - /* Socket Listeners and Handlers */ - /* -------------------------------------------- */ - - /** @override */ - _onUpdate(changed, options, userId) { - // Update elevation? - if ( "z" in changed ) this.document.elevation = changed.z; - - // Fully re-draw when some drawing elements have changed - const textChanged = ("text" in changed) - || (this.document.text && ["fontFamily", "fontSize", "textColor", "width"].some(k => k in changed)); - if ( changed.shape?.type || ("texture" in changed) || textChanged ) { - this.draw().then(() => super._onUpdate(changed, options, userId)); - } - // Otherwise, simply refresh the existing drawing - else super._onUpdate(changed, options, userId); - } - - /* -------------------------------------------- */ - /* Permission Controls */ - /* -------------------------------------------- */ - - /** @override */ - _canControl(user, event) { - if ( this._creating ) { // Allow one-time control immediately following creation - delete this._creating; - return true; - } - if ( this.controlled ) return true; - if ( game.activeTool !== "select" ) return false; - return user.isGM || (user === this.document.author); - } - - /* -------------------------------------------- */ - - /** @override */ - _canConfigure(user, event) { - return this.controlled; - } - - /* -------------------------------------------- */ - /* Event Listeners and Handlers */ - /* -------------------------------------------- */ - - /** @override */ - activateListeners() { - super.activateListeners(); - this.frame.handle.off("mouseover").off("mouseout").off("mousedown") - .on("mouseover", this._onHandleHoverIn.bind(this)) - .on("mouseout", this._onHandleHoverOut.bind(this)) - .on("mousedown", this._onHandleMouseDown.bind(this)); - this.frame.handle.interactive = true; - } - - /* -------------------------------------------- */ - - /** - * Handle mouse movement which modifies the dimensions of the drawn shape - * @param {PIXI.InteractionEvent} event - * @private - */ - _onMouseDraw(event) { - const {destination, origin, originalEvent} = event.data; - const isShift = originalEvent.shiftKey; - const isAlt = originalEvent.altKey; - let position = destination; - - // Drag differently depending on shape type - switch ( this.type ) { - - // Polygon Shapes - case Drawing.SHAPE_TYPES.POLYGON: - const isFreehand = game.activeTool === "freehand"; - let temporary = true; - if ( isFreehand ) { - const now = Date.now(); - temporary = (now - this._drawTime) < this.constructor.FREEHAND_SAMPLE_RATE; - } - const snap = !(isShift || isFreehand); - this._addPoint(position, {snap, temporary}); - break; - - // Other Shapes - default: - const shape = this.shape; - const minSize = canvas.dimensions.size * 0.5; - let dx = position.x - origin.x; - let dy = position.y - origin.y; - if ( Math.abs(dx) < minSize ) dx = minSize * Math.sign(shape.width); - if ( Math.abs(dy) < minSize ) dy = minSize * Math.sign(shape.height); - if ( isAlt ) { - dx = Math.abs(dy) < Math.abs(dx) ? Math.abs(dy) * Math.sign(dx) : dx; - dy = Math.abs(dx) < Math.abs(dy) ? Math.abs(dx) * Math.sign(dy) : dy; - } - const r = new PIXI.Rectangle(origin.x, origin.y, dx, dy).normalize(); - this.document.updateSource({ - x: r.x, - y: r.y, - shape: { - width: r.width, - height: r.height - } - }); - break; - } - - // Refresh the display - this.refresh(); - } - - /* -------------------------------------------- */ - /* Interactivity */ - /* -------------------------------------------- */ - - /** @override */ - _onDragLeftStart(event) { - if ( this._dragHandle ) return this._onHandleDragStart(event); - if ( this._pendingText ) this.document.text = this._pendingText; - return super._onDragLeftStart(event); - } - - /* -------------------------------------------- */ - - /** @override */ - _onDragLeftMove(event) { - if ( this._dragHandle ) return this._onHandleDragMove(event); - return super._onDragLeftMove(event); - } - - /* -------------------------------------------- */ - - /** @override */ - async _onDragLeftDrop(event) { - if ( this._dragHandle ) return this._onHandleDragDrop(event); - if ( this._dragPassthrough ) return canvas._onDragLeftDrop(event); - - // Update each dragged Drawing, confirming pending text - const clones = event.data.clones || []; - const updates = clones.map(c => { - let dest = {x: c.document.x, y: c.document.y}; - if ( !event.data.originalEvent.shiftKey ) { - dest = canvas.grid.getSnappedPosition(dest.x, dest.y, this.layer.gridPrecision); - } - - // Define the update - const update = { - _id: c._original.id, - x: dest.x, - y: dest.y, - rotation: c.document.rotation, - text: c._original._pendingText ? c._original._pendingText : c.document.text - }; - - // Commit pending text - if ( c._original._pendingText ) { - update.text = c._original._pendingText; - } - c.visible = false; - c._original.visible = false; - return update; - }); - return canvas.scene.updateEmbeddedDocuments("Drawing", updates, {diff: false}); - } - - /* -------------------------------------------- */ - - /** @override */ - _onDragLeftCancel(event) { - if ( this._dragHandle ) return this._onHandleDragCancel(event); - return super._onDragLeftCancel(event); - } - - /* -------------------------------------------- */ - - /** @inheritDoc */ - _onDragStart() { - super._onDragStart(); - const o = this._original; - o.shape.alpha = o.alpha; - } - - /* -------------------------------------------- */ - - /** @inheritDoc */ - _onDragEnd() { - super._onDragEnd(); - if ( this.isPreview ) this._original.shape.alpha = 1.0; - } - - /* -------------------------------------------- */ - /* Resize Handling */ - /* -------------------------------------------- */ - - /** - * Handle mouse-over event on a control handle - * @param {PIXI.InteractionEvent} event The mouseover event - * @private - */ - _onHandleHoverIn(event) { - const handle = event.target; - handle.scale.set(1.5, 1.5); - event.data.handle = event.target; - } - - /* -------------------------------------------- */ - - /** - * Handle mouse-out event on a control handle - * @param {PIXI.InteractionEvent} event The mouseout event - * @private - */ - _onHandleHoverOut(event) { - event.data.handle.scale.set(1.0, 1.0); - if ( this.interactionState < MouseInteractionManager.INTERACTION_STATES.CLICKED ) { - this._dragHandle = false; - } - } - - /* -------------------------------------------- */ - - /** - * When we start a drag event - create a preview copy of the Tile for re-positioning - * @param {PIXI.InteractionEvent} event The mousedown event - * @private - */ - _onHandleMouseDown(event) { - if ( !this.document.locked ) { - this._dragHandle = true; - this._original = this.document.toObject(); - } - } - - /* -------------------------------------------- */ - - /** - * Handle the beginning of a drag event on a resize handle - * @param {PIXI.InteractionEvent} event The mouse interaction event - * @private - */ - _onHandleDragStart(event) { - event.data.origin = {x: this.bounds.right, y: this.bounds.bottom}; - } - - /* -------------------------------------------- */ - - /** - * Handle mousemove while dragging a tile scale handler - * @param {PIXI.InteractionEvent} event The mouse interaction event - * @private - */ - _onHandleDragMove(event) { - const {destination, origin, originalEvent} = event.data; - - // Pan the canvas if the drag event approaches the edge - canvas._onDragCanvasPan(originalEvent); - - // Update Drawing dimensions - const dx = destination.x - origin.x; - const dy = destination.y - origin.y; - const normalized = this._rescaleDimensions(this._original, dx, dy); - try { - this.document.updateSource(normalized); - this.refresh(); - } catch(err) {} - } - - /* -------------------------------------------- */ - - /** - * Handle mouseup after dragging a tile scale handler - * @param {PIXI.InteractionEvent} event The mouseup event - * @private - */ - _onHandleDragDrop(event) { - let {destination, origin, originalEvent} = event.data; - if ( !originalEvent.shiftKey ) { - destination = canvas.grid.getSnappedPosition(destination.x, destination.y, this.layer.gridPrecision); - } - const dx = destination.x - origin.x; - const dy = destination.y - origin.y; - const update = this._rescaleDimensions(this._original, dx, dy); - return this.document.update(update, {diff: false}); - } - - /* -------------------------------------------- */ - - /** - * Handle cancellation of a drag event for one of the resizing handles - * @param {PointerEvent} event The drag cancellation event - * @private - */ - _onHandleDragCancel(event) { - this.document.updateSource(this._original); - this._dragHandle = false; - delete this._original; - this.refresh(); - } - - /* -------------------------------------------- */ - - /** - * Apply a vectorized rescaling transformation for the drawing data - * @param {Object} original The original drawing data - * @param {number} dx The pixel distance dragged in the horizontal direction - * @param {number} dy The pixel distance dragged in the vertical direction - * @private - */ - _rescaleDimensions(original, dx, dy) { - let {points, width, height} = original.shape; - width += dx; - height += dy; - points = points || []; - - // Rescale polygon points - if ( this.isPolygon ) { - const scaleX = 1 + (dx / original.shape.width); - const scaleY = 1 + (dy / original.shape.height); - points = points.map((p, i) => p * (i % 2 ? scaleY : scaleX)); - } - - // Constrain drawing bounds by the contained text size - if ( this.document.text ) { - const textBounds = this.text.getLocalBounds(); - width = Math.max(textBounds.width + 16, width); - height = Math.max(textBounds.height + 8, height); - } - - // Normalize the shape - return this.constructor.normalizeShape({ - x: original.x, - y: original.y, - shape: {width: Math.roundFast(width), height: Math.roundFast(height), points} - }); - } - - /* -------------------------------------------- */ - - /** - * Adjust the location, dimensions, and points of the Drawing before committing the change - * @param {object} data The DrawingData pending update - * @returns {object} The adjusted data - * @private - */ - static normalizeShape(data) { - - // Adjust shapes with an explicit points array - const rawPoints = data.shape.points; - if ( rawPoints?.length ) { - - // Organize raw points and de-dupe any points which repeated in sequence - const xs = []; - const ys = []; - for ( let i=1; i