From 411f882954f638a6ea1615e6426884785a4afc21 Mon Sep 17 00:00:00 2001 From: Dorako Date: Wed, 1 Feb 2023 21:57:40 +0100 Subject: [PATCH] 1.11.4 --- .github/workflows/main.yml | 57 + .gitignore | 6 +- CHANGELOG.md | 4 + foundry.js | 81298 +++++++++++++++++++++++++++++++ modules/base-theme-hooks.js | 70 +- modules/consts.js | 2 +- modules/dark-theme-hooks.js | 11 +- modules/settings/migrations.js | 2 +- 8 files changed, 81397 insertions(+), 53 deletions(-) create mode 100644 .github/workflows/main.yml create mode 100644 foundry.js diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..41939fc --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,57 @@ +name: Release Creation + +on: + release: + types: [published] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + # get part of the tag after the `v` + - name: Extract tag version number + id: get_version + uses: battila7/get-version-action@v2 + + # Substitute the Manifest and Download URLs in the module.json + - name: Substitute Manifest and Download Links For Versioned Ones + id: sub_manifest_link_version + uses: microsoft/variable-substitution@v1 + with: + files: "module.json" + env: + version: ${{steps.get_version.outputs.version-without-v}} + url: https://github.com/${{github.repository}} + manifest: https://github.com/${{github.repository}}/releases/latest/download/module.json + download: https://github.com/${{github.repository}}/releases/download/${{github.event.release.tag_name}}/module.zip + + # Create a zip file with all files required by the module to add to the release + - run: zip -r ./module.zip module.json CHANGELOG.md README.md LICENSE styles/ templates/ languages/ modules/ img/ + + # Create a release for this specific version + - name: Update Release with Files + id: create_version_release + uses: ncipollo/release-action@v1 + with: + allowUpdates: true # Set this to false if you want to prevent updating existing releases + name: ${{ github.event.release.name }} + draft: ${{ github.event.release.unpublished }} + prerelease: ${{ github.event.release.prerelease }} + token: ${{ secrets.GITHUB_TOKEN }} + artifacts: "./module.json, ./module.zip" + tag: ${{ github.event.release.tag_name }} + body: ${{ github.event.release.body }} + + # Publish to FoundryVTT + - name: Publish to Foundry VTT Repo + if: "!github.event.release.prerelease" + id: publish_foundry_repo + run: npx @ghost-fvtt/foundry-publish + env: + FVTT_MANIFEST_PATH: "module.json" + FVTT_PACKAGE_ID: 2350 + FVTT_USERNAME: ${{ secrets.FOUNDRY_ADMIN_USERNAME }} + FVTT_PASSWORD: ${{ secrets.FOUNDRY_ADMIN_PASSWORD }} + FVTT_MANIFEST_URL: https://github.com/${{github.repository}}/releases/download/${{github.event.release.tag_name}}/module.json diff --git a/.gitignore b/.gitignore index ebc33a9..b4f2935 100644 --- a/.gitignore +++ b/.gitignore @@ -56,5 +56,9 @@ static/packs/* foundryconfig.json dist/ +docs/ -docs/ \ No newline at end of file +jsconfig.json +foundry.jsconfig +/client +/common \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6886de5..ed43254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 1.11.4 + +- (Fix) Fix Adventure Importers with fancy frames getting overwritten. The logic that was formerly used for only Journals is now applied to all windows. + # 1.11.3 - (Fix) Fixed illegible text in dark theme windows with sidebars diff --git a/foundry.js b/foundry.js new file mode 100644 index 0000000..aaf14df --- /dev/null +++ b/foundry.js @@ -0,0 +1,81298 @@ +/** @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