/** @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; /** * 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 {Record} * @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"); } } /** * 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 {Record} */ Object.defineProperty(this, "trees", {value: {}}); /** * A reverse-lookup of a document's UUID to its parent node in the word tree. * @type {Record} */ 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.WORLD_DOCUMENT_TYPES.filter(c => { const documentClass = getDocumentClass(c); return documentClass.metadata.indexed && documentClass.schema.has("name"); }); // 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. * @param {StringTreeEntryFilter} [options.filterEntries] A filter function to apply to each candidate entry. * @param {DOCUMENT_OWNERSHIP_LEVELS|string} [options.ownership] Only return entries that the user meets this * ownership level for. * @returns {Record} A number of entries that have the given prefix, grouped by document * type. */ lookup(prefix, {limit=10, documentTypes=[], ownership, filterEntries}={}) { const types = documentTypes.length ? documentTypes : Object.keys(this.trees); if ( ownership !== undefined ) { const originalFilterEntries = filterEntries ?? (() => true); filterEntries = entry => { return originalFilterEntries(entry) && DocumentIndex.#filterEntryForOwnership(entry, ownership); } } const results = {}; for ( const type of types ) { results[type] = []; const tree = this.trees[type]; if ( !tree ) continue; results[type].push(...tree.lookup(prefix, { limit, filterEntries })); } 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); if ( index ) 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[foundry.utils.StringTree.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 foundry.utils.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); } } /* -------------------------------------------- */ /* Helpers */ /* -------------------------------------------- */ /** * Check if the given entry meets the given ownership requirements. * @param {WordTreeEntry} entry The candidate entry. * @param {DOCUMENT_OWNERSHIP_LEVELS|string} ownership The ownership. * @returns {boolean} */ static #filterEntryForOwnership({ uuid, pack }, ownership) { if ( pack ) return game.packs.get(pack)?.testUserPermission(game.user, ownership); return fromUuidSync(uuid)?.testUserPermission(game.user, ownership); } } /** * Management class for Gamepad events */ class GamepadManager { constructor() { this._gamepadPoller = null; /** * The connected Gamepads * @type {Map} * @private */ this._connectedGamepads = new Map(); } /** * How often Gamepad polling should check for button presses * @type {number} */ static GAMEPAD_POLLER_INTERVAL_MS = 100; /* -------------------------------------------- */ /** * Begin listening to gamepad events. * @internal */ _activateListeners() { window.addEventListener("gamepadconnected", this._onGamepadConnect.bind(this)); window.addEventListener("gamepaddisconnected", this._onGamepadDisconnect.bind(this)); } /* -------------------------------------------- */ /** * 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 {Record} */ static get events() { return this.#events; } /** * @type {Record} * @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 = new Error(`${msg}. ${error.message}`, { cause: error }); if ( log ) console[log]?.(error); if ( notify ) ui.notifications[notify]?.(msg || error.message); 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 = await 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 renderTexture = PIXI.RenderTexture.create({ width: width, height: height, scaleMode: PIXI.SCALE_MODES.LINEAR, resolution: canvas.app.renderer.resolution }); canvas.app.renderer.render(object, { renderTexture, transform }); return renderTexture; } /* -------------------------------------------- */ /** * Extract a texture to a base64 PNG string * @param {PIXI.Texture} texture The texture object to extract * @param {object} options * @param {string} [options.format] Image format, e.g. "image/jpeg" or "image/webp". * @param {number} [options.quality] JPEG or WEBP compression from 0 to 1. Default is 0.92. * @returns {Promise} A base64 png string of the texture */ static async 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 this.canvasToBase64(extracted, type, quality); } /* -------------------------------------------- */ /** * Asynchronously convert a canvas element to base64. * @param {HTMLCanvasElement} canvas * @param {string} [type="image/png"] * @param {number} [quality] * @returns {Promise} The base64 string of the canvas. */ static async canvasToBase64(canvas, type, quality) { return new Promise((resolve, reject) => { canvas.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 * @param {boolean} [options.notify=true] Display a UI notification when the upload is processed. * @returns {Promise} A promise which resolves to the FilePicker upload response */ static async uploadBase64(base64, fileName, filePath, {storage="data", type, notify=true}={}) { 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, {}, { notify }); } /* -------------------------------------------- */ /** * Create a canvas element containing the pixel data. * @param {Uint8ClampedArray} pixels Buffer used to create the image data. * @param {number} width Buffered image width. * @param {number} height Buffered image height. * @param {object} options * @param {HTMLCanvasElement} [options.element] The element to use. * @param {number} [options.ew] Specified width for the element (default to buffer image width). * @param {number} [options.eh] Specified height for the element (default to buffer image height). * @returns {HTMLCanvasElement} */ static pixelsToCanvas(pixels, width, height, {element, ew, eh}={}) { // If an element is provided, use it. Otherwise, create a canvas element element ??= document.createElement("canvas"); // Assign specific element width and height, if provided. Otherwise, assign buffered image dimensions element.width = ew ?? width; element.height = eh ?? height; // Get the context and create a new image data with the buffer const context = element.getContext("2d"); const imageData = new ImageData(pixels, width, height); context.putImageData(imageData, 0, 0); return element; } } /** * An object structure of document types at the top level, with a count of different sub-types for that document type. * @typedef {Record>} ModuleSubTypeCounts */ /** * A class responsible for tracking issues in the current world. */ class ClientIssues { /** * Keep track of valid Documents in the world that are using module-provided sub-types. * @type {Map} */ #moduleTypeMap = new Map(); /** * Keep track of document validation failures. * @type {object} */ #documentValidationFailures = {}; /** * @typedef {object} UsabilityIssue * @property {string} message The pre-localized message to display in relation to the usability issue. * @property {string} severity The severity of the issue, either "error", "warning", or "info". * @property {object} [params] Parameters to supply to the localization. */ /** * Keep track of any usability issues related to browser or technology versions. * @type {Record} */ #usabilityIssues = {}; /** * The minimum supported resolution. * @type {{WIDTH: number, HEIGHT: number}} */ static #MIN_RESOLUTION = {WIDTH: 1024, HEIGHT: 700}; /** * @typedef {object} BrowserTest * @property {number} minimum The minimum supported version for this browser. * @property {RegExp} match A regular expression to match the browser against the user agent string. * @property {string} message A message to display if the user's browser version does not meet the minimum. */ /** * The minimum supported client versions. * @type {Record} */ static #BROWSER_TESTS = { Electron: { minimum: 29, match: /Electron\/(\d+)\./, message: "ERROR.ElectronVersion" }, Chromium: { minimum: 105, match: /Chrom(?:e|ium)\/(\d+)\./, message: "ERROR.BrowserVersion" }, Firefox: { minimum: 121, match: /Firefox\/(\d+)\./, message: "ERROR.BrowserVersion" }, Safari: { minimum: 15.4, match: /Version\/(\d+)\..*Safari\//, message: "ERROR.BrowserVersion" } }; /* -------------------------------------------- */ /** * Add a Document to the count of module-provided sub-types. * @param {string} documentName The Document name. * @param {string} subType The Document's sub-type. * @param {object} [options] * @param {boolean} [options.decrement=false] Decrement the counter rather than incrementing it. */ #countDocumentSubType(documentName, subType, {decrement=false}={}) { if ( !((typeof subType === "string") && subType.includes(".")) ) return; const [moduleId, ...rest] = subType.split("."); subType = rest.join("."); if ( !this.#moduleTypeMap.has(moduleId) ) this.#moduleTypeMap.set(moduleId, {}); const counts = this.#moduleTypeMap.get(moduleId); const types = counts[documentName] ??= {}; types[subType] ??= 0; if ( decrement ) types[subType] = Math.max(types[subType] - 1, 0); else types[subType]++; } /* -------------------------------------------- */ /** * Detect the user's browser and display a notification if it is below the minimum required version. */ #detectBrowserVersion() { for ( const [browser, {minimum, match, message}] of Object.entries(ClientIssues.#BROWSER_TESTS) ) { const [, version] = navigator.userAgent.match(match) ?? []; if ( !Number.isNumeric(version) ) continue; if ( Number(version) < minimum ) { const err = game.i18n.format(message, {browser, version, minimum}); ui.notifications?.error(err, {permanent: true, console: true}); this.#usabilityIssues.browserVersionIncompatible = { message, severity: "error", params: {browser, version, minimum} }; } break; } } /* -------------------------------------------- */ /** * Record a reference to a resolution notification ID so that we can remove it if the problem is remedied. * @type {number} */ #resolutionTooLowNotification; /** * Detect the user's resolution and display a notification if it is too small. */ #detectResolution() { const {WIDTH: reqWidth, HEIGHT: reqHeight} = ClientIssues.#MIN_RESOLUTION; const {innerWidth: width, innerHeight: height} = window; if ( (height < reqHeight) || (width < reqWidth) ) { // Display a permanent error notification if ( ui.notifications && !this.#resolutionTooLowNotification ) { this.#resolutionTooLowNotification = ui.notifications.error(game.i18n.format("ERROR.LowResolution", { width, reqWidth, height, reqHeight }), {permanent: true}); } // Record the usability issue this.#usabilityIssues.resolutionTooLow = { message: "ERROR.LowResolution", severity: "error", params: {width, reqWidth, height, reqHeight} }; } // Remove an error notification if present else { if ( this.#resolutionTooLowNotification ) { this.#resolutionTooLowNotification = ui.notifications.remove(this.#resolutionTooLowNotification); } delete this.#usabilityIssues.resolutionTooLow; } } /* -------------------------------------------- */ /** * Detect and display warnings for known performance issues which may occur due to the user's hardware or browser * configuration. * @internal */ _detectWebGLIssues() { const context = canvas.app.renderer.context; try { const rendererInfo = SupportDetails.getWebGLRendererInfo(context.gl); if ( /swiftshader/i.test(rendererInfo) ) { ui.notifications.warn("ERROR.NoHardwareAcceleration", {localize: true, permanent: true}); this.#usabilityIssues.hardwareAccel = {message: "ERROR.NoHardwareAcceleration", severity: "error"}; } } catch ( err ) { ui.notifications.warn("ERROR.RendererNotDetected", {localize: true, permanent: true}); this.#usabilityIssues.noRenderer = {message: "ERROR.RendererNotDetected", severity: "warning"}; } // Verify that WebGL2 is being used. if ( !canvas.supported.webGL2 ) { ui.notifications.error("ERROR.NoWebGL2", {localize: true, permanent: true}); this.#usabilityIssues.webgl2 = {message: "ERROR.NoWebGL2", severity: "error"}; } } /* -------------------------------------------- */ /** * Add an invalid Document to the module-provided sub-type counts. * @param {typeof Document} cls The Document class. * @param {object} source The Document's source data. * @param {object} [options] * @param {boolean} [options.decrement=false] Decrement the counter rather than incrementing it. * @internal */ _countDocumentSubType(cls, source, options={}) { if ( cls.hasTypeData ) this.#countDocumentSubType(cls.documentName, source.type, options); for ( const [embeddedName, field] of Object.entries(cls.hierarchy) ) { if ( !(field instanceof foundry.data.fields.EmbeddedCollectionField) ) continue; for ( const embedded of source[embeddedName] ) { this._countDocumentSubType(field.model, embedded, options); } } } /* -------------------------------------------- */ /** * Track a validation failure that occurred in a WorldCollection. * @param {WorldCollection} collection The parent collection. * @param {object} source The Document's source data. * @param {DataModelValidationError} error The validation error. * @internal */ _trackValidationFailure(collection, source, error) { if ( !(collection instanceof WorldCollection) ) return; if ( !(error instanceof foundry.data.validation.DataModelValidationError) ) return; const documentName = collection.documentName; this.#documentValidationFailures[documentName] ??= {}; this.#documentValidationFailures[documentName][source._id] = {name: source.name, error}; } /* -------------------------------------------- */ /** * Detect and record certain usability error messages which are likely to result in the user having a bad experience. * @internal */ _detectUsabilityIssues() { this.#detectResolution(); this.#detectBrowserVersion(); window.addEventListener("resize", foundry.utils.debounce(this.#detectResolution.bind(this), 250), {passive: true}); } /* -------------------------------------------- */ /** * Get the Document sub-type counts for a given module. * @param {Module|string} module The module or its ID. * @returns {ModuleSubTypeCounts} */ getSubTypeCountsFor(module) { return this.#moduleTypeMap.get(module.id ?? module); } /* -------------------------------------------- */ /** * Retrieve all sub-type counts in the world. * @returns {Iterator} */ getAllSubTypeCounts() { return this.#moduleTypeMap.entries(); } /* -------------------------------------------- */ /** * Retrieve the tracked validation failures. * @returns {object} */ get validationFailures() { return this.#documentValidationFailures; } /* -------------------------------------------- */ /** * Retrieve the tracked usability issues. * @returns {Record} */ get usabilityIssues() { return this.#usabilityIssues; } /* -------------------------------------------- */ /** * @typedef {object} PackageCompatibilityIssue * @property {string[]} error Error messages. * @property {string[]} warning Warning messages. */ /** * Retrieve package compatibility issues. * @returns {Record} */ get packageCompatibilityIssues() { return game.data.packageWarnings; } } /** * 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"], // On ALT, 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 * @returns {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. * @param {string} view The active game view * @internal */ _registerCoreKeybindings(view) { const {SHIFT, CONTROL, ALT} = KeyboardManager.MODIFIER_KEYS; // General Purpose - All Views game.keybindings.register("core", "dismiss", { name: "KEYBINDINGS.Dismiss", uneditable: [ {key: "Escape"} ], onDown: ClientKeybindings._onDismiss, precedence: CONST.KEYBINDING_PRECEDENCE.DEFERRED }); // Game View Only if ( view !== "game" ) return; game.keybindings.register("core", "cycleView", { name: "KEYBINDINGS.CycleView", editable: [ {key: "Tab"} ], onDown: ClientKeybindings._onCycleView, reservedModifiers: [SHIFT], repeat: true }); game.keybindings.register("core", "measuredRulerMovement", { name: "KEYBINDINGS.MoveAlongMeasuredRuler", editable: [ {key: "Space"} ], onDown: ClientKeybindings._onMeasuredRulerMovement, precedence: CONST.KEYBINDING_PRECEDENCE.PRIORITY, reservedModifiers: [SHIFT, 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", "sendToBack", { name: "KEYBINDINGS.SendToBack", editable: [ {key: "BracketLeft"} ], onDown: ClientKeybindings.#onSendToBack }); game.keybindings.register("core", "bringToFront", { name: "KEYBINDINGS.BringToFront", editable: [ {key: "BracketRight"} ], onDown: ClientKeybindings.#onBringToFront }); 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 Array.fromRange(9, 1).concat([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 Array.fromRange(5, 1) ) { 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 {KeyboardEventContext} context The context data of the event * @private */ static _onSelectAllObjects(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 async _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) { await 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 const closingApps = []; for ( const app of Object.values(ui.windows) ) { closingApps.push(app.close({closeKey: true}).then(() => !app.rendered)); } for ( const app of foundry.applications.instances.values() ) { if ( app.hasFrame ) closingApps.push(app.close({closeKey: true}).then(() => !app.rendered)); } const closedApp = (await Promise.all(closingApps)).some(c => c); // Confirm an application actually closed if ( closedApp ) return true; // Case 4 (GM) - release controlled objects (if not in a preview) if ( game.view !== "game" ) return; if (game.user.isGM && (canvas.activeLayer instanceof PlaceablesLayer) && 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 ) await 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(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 || hovered.document.isSecret ) return false; hovered.setTarget(!hovered.isTargeted, {releaseOthers: !context.isShift}); return true; } /* -------------------------------------------- */ /** * Handle action to send the currently controlled placeables to the back. * @param {KeyboardEventContext} context The context data of the event */ static #onSendToBack(context) { if ( !canvas.ready ) return false; return canvas.activeLayer?._sendToBackOrBringToFront(false) ?? false; } /* -------------------------------------------- */ /** * Handle action to bring the currently controlled placeables to the front. * @param {KeyboardEventContext} context The context data of the event */ static #onBringToFront(context) { if ( !canvas.ready ) return false; return canvas.activeLayer?._sendToBackOrBringToFront(true) ?? false; } /* -------------------------------------------- */ /** * Handle DELETE Keypress Events * @param {KeyboardEventContext} context The context data of the event * @private */ static _onDelete(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(context.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; // Get the directions of movement let directions = this.moveKeys; const grid = canvas.grid; const diagonals = (grid.type !== CONST.GRID_TYPES.SQUARE) || (grid.diagonals !== CONST.GRID_DIAGONALS.ILLEGAL); if ( !diagonals ) directions = new Set(Array.from(directions).slice(-1)); // Define movement offsets and get moved directions let dx = 0; let dy = 0; if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT) ) dx -= 1; if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT) ) dx += 1; if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.UP) ) dy -= 1; 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) { if ( !canvas.ready ) return; const ruler = canvas.controls.ruler; if ( ruler.state !== Ruler.STATES.MEASURING ) return; ruler._onMoveKeyDown(context); 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 * @param {number} number The numbered macro slot to execute * @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 * @param {number} page The numbered macro page to activate * @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.mousePosition; 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; 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(); } /* -------------------------------------------- */ /** * Begin listening to keyboard events. * @internal */ _activateListeners() { 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"; /* -------------------------------------------- */ /** * A 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 {Record} */ 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 an HTMLElement currently has focus. * If so we normally don't want to process keybinding actions. * @type {boolean} */ get hasFocus() { return document.querySelector(":focus") instanceof HTMLElement; } /* -------------------------------------------- */ /* 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)); } /* -------------------------------------------- */ /** * Report whether a core action key is currently actively depressed. * @param {string} action The core action to verify (ex: "target") * @returns {boolean} Is this core action key currently down (active)? */ isCoreActionKeyActive(action) { const binds = game.keybindings.get("core", action); return !!binds?.some(k => this.downKeys.has(k.key)); } /* -------------------------------------------- */ /** * 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; } /** * 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; /* -------------------------------------------- */ /** * Begin listening to mouse events. * @internal */ _activateListeners() { window.addEventListener("wheel", this._onWheel.bind(this), {passive: false}); } /* -------------------------------------------- */ /** * 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 foundry.utils.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); } } /** * @typedef {Object} PackageCompatibilityBadge * @property {string} type A type in "safe", "unsafe", "warning", "neutral" applied as a CSS class * @property {string} tooltip A tooltip string displayed when hovering over the badge * @property {string} [label] An optional text label displayed in the badge * @property {string} [icon] An optional icon displayed in the badge */ /** * 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 { /** * Is this package marked as a favorite? * This boolean is currently only populated as true in the /setup view of the software. * @type {boolean} */ favorite = false; /** * Associate package availability with certain badge for client-side display. * @returns {PackageCompatibilityBadge|null} */ getVersionBadge() { return this.constructor.getVersionBadge(this.availability, this); } /* -------------------------------------------- */ /** * Determine a version badge for the provided compatibility data. * @param {number} availability The availability level. * @param {Partial} data The compatibility data. * @param {object} [options] * @param {Collection} [options.modules] A specific collection of modules to test availability * against. Tests against the currently installed modules by * default. * @param {Collection} [options.systems] A specific collection of systems to test availability * against. Tests against the currently installed systems by * default. * @returns {PackageCompatibilityBadge|null} */ static getVersionBadge(availability, data, { modules, systems }={}) { modules ??= game.modules; systems ??= game.systems; const codes = CONST.PACKAGE_AVAILABILITY_CODES; const { compatibility, version, relationships } = data; switch ( availability ) { // Unsafe case codes.UNKNOWN: case codes.REQUIRES_CORE_DOWNGRADE: case codes.REQUIRES_CORE_UPGRADE_STABLE: case codes.REQUIRES_CORE_UPGRADE_UNSTABLE: const labels = { [codes.UNKNOWN]: "SETUP.CompatibilityUnknown", [codes.REQUIRES_CORE_DOWNGRADE]: "SETUP.RequireCoreDowngrade", [codes.REQUIRES_CORE_UPGRADE_STABLE]: "SETUP.RequireCoreUpgrade", [codes.REQUIRES_CORE_UPGRADE_UNSTABLE]: "SETUP.RequireCoreUnstable" }; return { type: "error", tooltip: game.i18n.localize(labels[availability]), label: version, icon: "fa fa-file-slash" }; case codes.MISSING_SYSTEM: return { type: "error", tooltip: game.i18n.format("SETUP.RequireDep", { dependencies: data.system }), label: version, icon: "fa fa-file-slash" }; case codes.MISSING_DEPENDENCY: case codes.REQUIRES_DEPENDENCY_UPDATE: return { type: "error", label: version, icon: "fa fa-file-slash", tooltip: this._formatBadDependenciesTooltip(availability, data, relationships.requires, { modules, systems }) }; // Warning case codes.UNVERIFIED_GENERATION: return { type: "warning", tooltip: game.i18n.format("SETUP.CompatibilityRiskWithVersion", { version: compatibility.verified }), label: version, icon: "fas fa-exclamation-triangle" }; case codes.UNVERIFIED_SYSTEM: return { type: "warning", label: version, icon: "fas fa-exclamation-triangle", tooltip: this._formatIncompatibleSystemsTooltip(data, relationships.systems, { systems }) }; // Neutral case codes.UNVERIFIED_BUILD: return { type: "neutral", tooltip: game.i18n.format("SETUP.CompatibilityRiskWithVersion", { version: compatibility.verified }), label: version, icon: "fas fa-code-branch" }; // Safe case codes.VERIFIED: return { type: "success", tooltip: game.i18n.localize("SETUP.Verified"), label: version, icon: "fas fa-code-branch" }; } return null; } /* -------------------------------------------- */ /** * List missing dependencies and format them for display. * @param {number} availability The availability value. * @param {Partial} data The compatibility data. * @param {Iterable} deps The dependencies to format. * @param {object} [options] * @param {Collection} [options.modules] A specific collection of modules to test availability * against. Tests against the currently installed modules by * default. * @param {Collection} [options.systems] A specific collection of systems to test availability * against. Tests against the currently installed systems by * default. * @returns {string} * @protected */ static _formatBadDependenciesTooltip(availability, data, deps, { modules, systems }={}) { modules ??= game.modules; systems ??= game.systems; const codes = CONST.PACKAGE_AVAILABILITY_CODES; const checked = new Set(); const bad = []; for ( const dep of deps ) { if ( (dep.type !== "module") || checked.has(dep.id) ) continue; if ( !modules.has(dep.id) ) bad.push(dep.id); else if ( availability === codes.REQUIRES_DEPENDENCY_UPDATE ) { const module = modules.get(dep.id); if ( module.availability !== codes.VERIFIED ) bad.push(dep.id); } checked.add(dep.id); } const label = availability === codes.MISSING_DEPENDENCY ? "SETUP.RequireDep" : "SETUP.IncompatibleDep"; const formatter = game.i18n.getListFormatter({ style: "short", type: "unit" }); return game.i18n.format(label, { dependencies: formatter.format(bad) }); } /* -------------------------------------------- */ /** * List any installed systems that are incompatible with this module's systems relationship, and format them for * display. * @param {Partial} data The compatibility data. * @param {Iterable} relationships The system relationships. * @param {object} [options] * @param {Collection} [options.systems] A specific collection of systems to test against. Tests * against the currently installed systems by default. * @returns {string} * @protected */ static _formatIncompatibleSystemsTooltip(data, relationships, { systems }={}) { systems ??= game.systems; const incompatible = []; for ( const { id, compatibility } of relationships ) { const system = systems.get(id); if ( !system ) continue; if ( !this.testDependencyCompatibility(compatibility, system) || system.unavailable ) incompatible.push(id); } const label = incompatible.length ? "SETUP.IncompatibleSystems" : "SETUP.NoSupportedSystem"; const formatter = game.i18n.getListFormatter({ style: "short", type: "unit" }); return game.i18n.format(label, { systems: formatter.format(incompatible) }); } /* ----------------------------------------- */ /** * 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() { this.constructor.uninstall(this.id); } /* -------------------------------------------- */ /** * Remove a package from the local game data when it has been uninstalled. * @param {string} id The package ID. */ static uninstall(id) { game.data[this.collection].findSplice(p => p.id === id); game[this.collection].delete(id); } /* -------------------------------------------- */ /** * 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 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) { constructor(data, options={}) { options.strictDataCleaning = data.strictDataCleaning; super(data, options); } /** @inheritDoc */ _configure(options) { super._configure(options); this.strictDataCleaning = !!options.strictDataCleaning; } /** * @deprecated since v12 * @ignore */ get template() { foundry.utils.logCompatibilityWarning("System#template is deprecated in favor of System#documentTypes", {since: 12, until: 14}); return game.model; } } /* ---------------------------------------- */ /** * @extends foundry.packages.BaseWorld * @mixes ClientPackageMixin * @category - Packages */ class World extends ClientPackageMixin(foundry.packages.BaseWorld) { /** @inheritDoc */ static getVersionBadge(availability, data, { modules, systems }={}) { modules ??= game.modules; systems ??= game.systems; const badge = super.getVersionBadge(availability, data, { modules, systems }); if ( !badge ) return badge; const codes = CONST.PACKAGE_AVAILABILITY_CODES; if ( availability === codes.VERIFIED ) { const system = systems.get(data.system); if ( system.availability !== codes.VERIFIED ) badge.type = "neutral"; } if ( !data.manifest ) badge.label = ""; return badge; } /* -------------------------------------------- */ /** * Provide data for a system badge displayed for the world which reflects the system ID and its availability * @param {System} [system] A specific system to use, otherwise use the installed system. * @returns {PackageCompatibilityBadge|null} */ getSystemBadge(system) { system ??= game.systems.get(this.system); if ( !system ) return { type: "error", tooltip: game.i18n.format("SETUP.RequireSystem", { system: this.system }), label: this.system, icon: "fa fa-file-slash" }; const badge = system.getVersionBadge(); if ( badge.type === "safe" ) { badge.type = "neutral"; badge.icon = null; } badge.tooltip = `

${system.title}

${badge.tooltip}

`; badge.label = system.id; return badge; } /* -------------------------------------------- */ /** @inheritdoc */ static _formatBadDependenciesTooltip(availability, data, deps) { const system = game.systems.get(data.system); if ( system ) deps ??= [...data.relationships.requires.values(), ...system.relationships.requires.values()]; return super._formatBadDependenciesTooltip(availability, data, deps); } } /* ---------------------------------------- */ /** * A mapping of allowed package types and the classes which implement them. * @type {{world: World, system: System, module: Module}} */ 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 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)] ]); } /* -------------------------------------------- */ /** * 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: new foundry.fields.NumberField({nullable: false, 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"; key = `${namespace}.${key}`; // Validate type if ( data.type ) { const allowedTypes = [foundry.data.fields.DataField, foundry.abstract.DataModel, Function]; if ( !allowedTypes.some(t => data.type instanceof t) ) { throw new Error(`Setting ${key} type must be a DataField, DataModel, or callable function`); } // Sync some setting data with the DataField if ( data.type instanceof foundry.data.fields.DataField ) { data.default ??= data.type.initial; data.type.name = key; data.type.label ??= data.label; data.type.hint ??= data.hint; } } // Setting values may not be undefined, only null, so the default should also adhere to this behavior data.default ??= null; // Store the setting configuration this.settings.set(key, data); // Reinitialize to cast the value of the Setting into its defined type if ( data.scope === "world" ) this.storage.get("world").getSetting(key)?.reset(); } /* -------------------------------------------- */ /** * 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?.prototype instanceof FormApplication) || (data.type?.prototype instanceof foundry.applications.api.ApplicationV2) )) { throw new Error("You must provide a menu type that is a FormApplication or ApplicationV2 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) { key = this.#assertKey(namespace, key); const config = this.settings.get(key); const storage = this.storage.get(config.scope); // Get the Setting instance let setting; switch ( config.scope ) { case "client": setting = new Setting({key, value: storage.getItem(key) ?? config.default}); break; case "world": setting = storage.getSetting(key); if ( !setting ) setting = new Setting({key, value: config.default}); break; } 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 * @returns {*} The assigned setting value * * @example Update the current value of a setting * ```js * game.settings.set("myModule", "myClientSetting", "b"); * ``` */ async set(namespace, key, value, options={}) { key = this.#assertKey(namespace, key); const setting = this.settings.get(key); if ( value === undefined ) value = setting.default; // Assign using DataField if ( setting.type instanceof foundry.data.fields.DataField ) { const err = setting.type.validate(value, {fallback: false}); if ( err instanceof foundry.data.validation.DataModelValidationFailure ) throw err.asError(); } // Assign using DataModel if ( foundry.utils.isSubclass(setting.type, foundry.abstract.DataModel) ) { value = setting.type.fromSource(value, {strict: true}); } // Save the setting change if ( setting.scope === "world" ) await this.#setWorld(key, value, options); else this.#setClient(key, value, setting.onChange); return value; } /* -------------------------------------------- */ /** * Assert that the namespace and setting name were provided and form a valid key. * @param {string} namespace The setting namespace * @param {string} settingName The setting name * @returns {string} The combined setting key */ #assertKey(namespace, settingName) { const key = `${namespace}.${settingName}`; if ( !namespace || !settingName ) throw new Error("You must specify both namespace and key portions of the" + `setting, you provided "${key}"`); if ( !this.settings.has(key) ) throw new Error(`"${key}" is not a registered game setting`); return key; } /* -------------------------------------------- */ /** * Create or update a Setting document in the World database. * @param {string} key The setting key * @param {*} value The desired setting value * @param {object} [options] Additional options which are passed to the document creation or update workflows * @returns {Promise} The created or updated Setting document */ async #setWorld(key, value, options) { if ( !game.ready ) throw new Error("You may not set a World-level Setting before the Game is ready."); const current = this.storage.get("world").getSetting(key); const json = JSON.stringify(value); if ( current ) return current.update({value: json}, options); else return Setting.create({key, value: json}, options); } /* -------------------------------------------- */ /** * Create or update a Setting document in the browser client storage. * @param {string} key The setting key * @param {*} value The desired setting value * @param {Function} onChange A registered setting onChange callback * @returns {Setting} A Setting document which represents the created setting */ #setClient(key, value, onChange) { const storage = this.storage.get("client"); const json = JSON.stringify(value); let setting; if ( key in storage ) { setting = new Setting({key, value: storage.getItem(key)}); const diff = setting.updateSource({value: json}); if ( foundry.utils.isEmpty(diff) ) return setting; } else setting = new Setting({key, value: json}); storage.setItem(key, json); if ( onChange instanceof Function ) onChange(value); return setting; } } /** * A standardized way socket messages are dispatched and their responses are handled */ class SocketInterface { /** * Send a socket request to all other clients and handle their responses. * @param {string} eventName The socket event name being handled * @param {DocumentSocketRequest|object} request 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 = SocketInterface.#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 */ 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 = Array.from(siblings); 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 + (sortBefore ? 0 : 1), 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" }; /** * The number of pixels buffer around a locked tooltip zone before they should be dismissed. * @type {number} */ static LOCKED_TOOLTIP_BUFFER_PX = 50; /** * Is the tooltip currently active? * @type {boolean} */ #active = false; /** * A reference to a window timeout function when an element is activated. */ #activationTimeout; /** * A reference to a window timeout function when an element is deactivated. */ #deactivationTimeout; /** * An element which is pending tooltip activation if hover is sustained * @type {HTMLElement|null} */ #pending; /** * Maintain state about active locked tooltips in order to perform appropriate automatic dismissal. * @type {{elements: Set, boundingBox: Rectangle}} */ #locked = { elements: new Set(), boundingBox: {} }; /* -------------------------------------------- */ /** * 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); document.body.addEventListener("pointerup", this._onLockTooltip.bind(this), true); document.body.addEventListener("pointermove", this.#testLockedTooltipProximity.bind(this), { capture: true, passive: 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.closest(".editor-content.ProseMirror") ) return; // Don't activate tooltips inside text editors. 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 or is in a matching link tooltip 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); return; } // Clear any existing deactivation workflow this.#clearDeactivation(); // Delay activation to determine user intent this.#pending = element; this.#activationTimeout = window.setTimeout(() => { this.#activationTimeout = null; if ( this.#pending ) this.activate(this.#pending); }, 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; const parent = event.target.parentElement.closest("[data-tooltip]"); if ( parent ) this.activate(parent); else this.#startDeactivation(); } /* -------------------------------------------- */ /** * Start the deactivation process. */ #startDeactivation() { if ( this.#deactivationTimeout ) return; // Clear any existing activation workflow this.clearPending(); // Delay deactivation to confirm whether some new element is now pending this.#deactivationTimeout = window.setTimeout(() => { this.#deactivationTimeout = null; if ( !this.#pending ) this.deactivate(); }, this.constructor.TOOLTIP_ACTIVATION_MS); } /* -------------------------------------------- */ /** * Clear any existing deactivation workflow. */ #clearDeactivation() { window.clearTimeout(this.#deactivationTimeout); 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, space-separated list of CSS classes to apply to the activated * tooltip. If this is not provided, the CSS classes are acquired from the * data-tooltip-class attribute of the element or one of its parents. * @param {boolean} [options.locked] An optional boolean to lock the tooltip after creation. Defaults to false. * @param {HTMLElement} [options.content] Explicit HTML content to inject into the tooltip rather than using tooltip * text. */ activate(element, {text, direction, cssClass, locked=false, content}={}) { if ( text && content ) throw new Error("Cannot provide both text and content options to TooltipManager#activate."); // Deactivate currently active element this.deactivate(); // Check if the element still exists in the DOM. if ( !document.body.contains(element) ) return; // Mark the new element as active this.#active = true; this.element = element; element.setAttribute("aria-describedby", "tooltip"); if ( content ) { this.tooltip.innerHTML = ""; // Clear existing content. this.tooltip.appendChild(content); } else this.tooltip.innerHTML = text || game.i18n.localize(element.dataset.tooltip); // Activate display of the tooltip this.tooltip.removeAttribute("class"); this.tooltip.classList.add("active"); cssClass ??= element.closest("[data-tooltip-class]")?.dataset.tooltipClass; if ( cssClass ) this.tooltip.classList.add(...cssClass.split(" ")); // Set tooltip position direction ??= element.closest("[data-tooltip-direction]")?.dataset.tooltipDirection; if ( !direction ) direction = this._determineDirection(); this._setAnchor(direction); if ( locked || element.dataset.hasOwnProperty("locked") ) this.lockTooltip(); } /* -------------------------------------------- */ /** * Deactivate the tooltip from a previously hovered HTML element. */ deactivate() { // Deactivate display of the tooltip this.#active = false; this.tooltip.classList.remove("active"); // Clear any existing (de)activation workflow this.clearPending(); this.#clearDeactivation(); // 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; } /* -------------------------------------------- */ /** * Lock the current tooltip. * @returns {HTMLElement} */ lockTooltip() { const clone = this.tooltip.cloneNode(false); // Steal the content from the original tooltip rather than cloning it, so that listeners are preserved. while ( this.tooltip.firstChild ) clone.appendChild(this.tooltip.firstChild); clone.removeAttribute("id"); clone.classList.add("locked-tooltip", "active"); document.body.appendChild(clone); this.deactivate(); clone.addEventListener("contextmenu", this._onLockedTooltipDismiss.bind(this)); this.#locked.elements.add(clone); // If the tooltip's contents were injected via setting innerHTML, then immediately requesting the bounding box will // return incorrect values as the browser has not had a chance to reflow yet. For that reason we defer computing the // bounding box until the next frame. requestAnimationFrame(() => this.#computeLockedBoundingBox()); return clone; } /* -------------------------------------------- */ /** * Handle a request to lock the current tooltip. * @param {MouseEvent} event The click event. * @protected */ _onLockTooltip(event) { if ( (event.button !== 1) || !this.#active || Tour.tourInProgress ) return; event.preventDefault(); this.lockTooltip(); } /* -------------------------------------------- */ /** * Handle dismissing a locked tooltip. * @param {MouseEvent} event The click event. * @protected */ _onLockedTooltipDismiss(event) { event.preventDefault(); const target = event.currentTarget; this.dismissLockedTooltip(target); } /* -------------------------------------------- */ /** * Dismiss a given locked tooltip. * @param {HTMLElement} element The locked tooltip to dismiss. */ dismissLockedTooltip(element) { this.#locked.elements.delete(element); element.remove(); this.#computeLockedBoundingBox(); } /* -------------------------------------------- */ /** * Compute the unified bounding box from the set of locked tooltip elements. */ #computeLockedBoundingBox() { let bb = null; for ( const element of this.#locked.elements.values() ) { const {x, y, width, height} = element.getBoundingClientRect(); const rect = new PIXI.Rectangle(x, y, width, height); if ( bb ) bb.enlarge(rect); else bb = rect; } this.#locked.boundingBox = bb; } /* -------------------------------------------- */ /** * Check whether the user is moving away from the locked tooltips and dismiss them if so. * @param {MouseEvent} event The mouse move event. */ #testLockedTooltipProximity(event) { if ( !this.#locked.elements.size ) return; const {clientX: x, clientY: y, movementX, movementY} = event; const buffer = this.#locked.boundingBox?.clone?.().pad(this.constructor.LOCKED_TOOLTIP_BUFFER_PX); // If the cursor is close enough to the bounding box, or we have no movement information, do nothing. if ( !buffer || buffer.contains(x, y) || !Number.isFinite(movementX) || !Number.isFinite(movementY) ) return; // Otherwise, check if the cursor is moving away from the tooltip, and dismiss it if so. if ( ((movementX > 0) && (x > buffer.right)) || ((movementX < 0) && (x < buffer.x)) || ((movementY > 0) && (y > buffer.bottom)) || ((movementY < 0) && (y < buffer.y)) ) this.dismissLockedTooltips(); } /* -------------------------------------------- */ /** * Dismiss the set of active locked tooltips. */ dismissLockedTooltips() { for ( const element of this.#locked.elements.values() ) { element.remove(); } this.#locked.elements = new Set(); } /* -------------------------------------------- */ /** * Create a locked tooltip at the given position. * @param {object} position A position object with coordinates for where the tooltip should be placed * @param {string} position.top Explicit top position for the tooltip * @param {string} position.right Explicit right position for the tooltip * @param {string} position.bottom Explicit bottom position for the tooltip * @param {string} position.left Explicit left position for the tooltip * @param {string} text Explicit tooltip text or HTML to display. * @param {object} [options={}] Additional options which can override tooltip behavior. * @param {array} [options.cssClass] An optional, space-separated list of CSS classes to apply to the activated * tooltip. * @returns {HTMLElement} */ createLockedTooltip(position, text, {cssClass}={}) { this.#clearDeactivation(); this.tooltip.innerHTML = text; this.tooltip.style.top = position.top || ""; this.tooltip.style.right = position.right || ""; this.tooltip.style.bottom = position.bottom || ""; this.tooltip.style.left = position.left || ""; const clone = this.lockTooltip(); if ( cssClass ) clone.classList.add(...cssClass.split(" ")); return clone; } /* -------------------------------------------- */ /** * 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. * @protected */ _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. * @protected */ _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 * @protected */ _setStyle(position={}) { const pad = this.constructor.TOOLTIP_MARGIN_PX; position = {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.clamp(position.left, pad, maxW - pad); if ( position.right ) position.right = Math.clamp(position.right, pad, maxW - pad); // Top or Bottom const maxH = window.innerHeight - this.tooltip.offsetHeight; if ( position.top ) position.top = Math.clamp(position.top, pad, maxH - pad); if ( position.bottom ) position.bottom = Math.clamp(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; } this.tooltip.classList.remove(...["center", "left", "right"].map(dir => `text-${dir}`)); this.tooltip.classList.add(`text-${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 if ( stepIndex > 0 ) { 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 {object} [options] Options to configure how a UUID is resolved. * @param {Document} [options.relative] A Document to resolve relative UUIDs against. * @param {boolean} [options.invalid=false] Allow retrieving an invalid Document. * @returns {Promise} Returns the Document if it could be found, otherwise null. */ async function fromUuid(uuid, options={}) { if ( !uuid ) return null; /** @deprecated since v11 */ if ( foundry.utils.getType(options) !== "Object" ) { foundry.utils.logCompatibilityWarning("Passing a relative document as the second parameter to fromUuid is " + "deprecated. Please pass it within an options object instead.", {since: 11, until: 13}); options = {relative: options}; } const {relative, invalid=false} = options; let {type, id, primaryId, collection, embedded, doc} = foundry.utils.parseUuid(uuid, {relative}); if ( collection instanceof CompendiumCollection ) { if ( type === "Folder" ) return collection.folders.get(id); doc = await collection.getDocument(primaryId ?? id); } else doc = doc ?? collection?.get(primaryId ?? id, {invalid}); if ( embedded.length ) doc = _resolveEmbedded(doc, embedded, {invalid}); 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 {object} [options] Options to configure how a UUID is resolved. * @param {Document} [options.relative] A Document to resolve relative UUIDs against. * @param {boolean} [options.invalid=false] Allow retrieving an invalid Document. * @param {boolean} [options.strict=true] Throw an error if the UUID cannot be resolved synchronously. * @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, and the strict option is true. */ function fromUuidSync(uuid, options={}) { if ( !uuid ) return null; /** @deprecated since v11 */ if ( foundry.utils.getType(options) !== "Object" ) { foundry.utils.logCompatibilityWarning("Passing a relative document as the second parameter to fromUuidSync is " + "deprecated. Please pass it within an options object instead.", {since: 11, until: 13}); options = {relative: options}; } const {relative, invalid=false, strict=true} = options; let {type, id, primaryId, collection, embedded, doc} = foundry.utils.parseUuid(uuid, {relative}); if ( (collection instanceof CompendiumCollection) && embedded.length ) { if ( !strict ) return null; throw new Error( `fromUuidSync was invoked on UUID '${uuid}' which references an Embedded Document and cannot be retrieved ` + "synchronously."); } const baseId = primaryId ?? id; if ( collection instanceof CompendiumCollection ) { if ( type === "Folder" ) return collection.folders.get(id); doc = doc ?? collection.get(baseId, {invalid}) ?? collection.index.get(baseId); if ( doc ) doc.pack = collection.collection; } else { doc = doc ?? collection?.get(baseId, {invalid}); if ( embedded.length ) doc = _resolveEmbedded(doc, embedded, {invalid}); } return doc || null; } /* -------------------------------------------- */ /** * 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. * @param {object} [options] Additional options to configure Embedded Document resolution. * @param {boolean} [options.invalid=false] Allow retrieving an invalid Embedded Document. * @returns {Document} The resolved Embedded Document. * @private */ function _resolveEmbedded(parent, parts, {invalid=false}={}) { let doc = parent; while ( doc && (parts.length > 1) ) { const [embeddedName, embeddedId] = parts.splice(0, 2); doc = doc.getEmbeddedDocument(embeddedName, embeddedId, {invalid}); } return doc; } /* -------------------------------------------- */ /** * 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 foundry.abstract.Document} 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) { if ( !object ) return null; 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(true); const resource = new PIXI.VideoResource(clone, {autoPlay: false}); resource.internal = true; await resource.load(); return new PIXI.Texture(new PIXI.BaseTexture(resource, { alphaMode: await PIXI.utils.detectVideoAlphaMode() })); } /* -------------------------------------------- */ /** * Check if a source has a video extension. * @param {string} src The source. * @returns {boolean} If the source has a video extension or not. */ 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; // 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.clamp(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", "pointerdown", "pointerup", "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 {Record} 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 */ /** * 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? * @param {string[]} [options.scripts] Should the worker operates in script modes? Optional scripts. */ class AsyncWorker extends Worker { constructor(name, {debug=false, loadPrimitives=false, scripts}={}) { super(AsyncWorker.WORKER_HARNESS_JS); this.name = name; this.addEventListener("message", this.#onMessage.bind(this)); this.addEventListener("error", this.#onError.bind(this)); this.#ready = this.#dispatchTask({ action: WorkerManager.WORKER_TASK_ACTIONS.INIT, workerName: name, debug, loadPrimitives, scripts }); } /** * 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 void, reject: (error: Error) => void}>} */ #tasks = new Map(); /** * An auto-incrementing task index. * @type {number} */ #taskIndex = 0; /** * A Promise which resolves once the Worker is ready to accept tasks * @type {Promise} */ get ready() { return this.#ready; } #ready; /* -------------------------------------------- */ /* 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<*>} [args] An array of parameters with which to call the requested function * @param {Array<*>} [transfer] An array of transferable objects which are transferred to the worker thread. * See https://developer.mozilla.org/en-US/docs/Glossary/Transferable_objects * @returns {Promise} A Promise which resolves with the returned result of the function once complete. */ async executeFunction(functionName, args=[], transfer=[]) { const action = WorkerManager.WORKER_TASK_ACTIONS.EXECUTE; return this.#dispatchTask({action, functionName, args}, transfer); } /* -------------------------------------------- */ /** * 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. * @param {Array<*>} transfer An array of transferable objects which are transferred to the worker thread. * @returns {Promise} A Promise which wraps the task transaction. */ async #dispatchTask(taskData={}, transfer=[]) { const taskId = taskData.taskId = this.#taskIndex++; return new Promise((resolve, reject) => { this.#tasks.set(taskId, {resolve, reject}); this.postMessage(taskData, transfer); }); } /* -------------------------------------------- */ /** * Handle messages emitted by the Worker thread. * @param {MessageEvent} event The dispatched message event */ #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 */ #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 extends Map { constructor() { if ( game.workers instanceof WorkerManager ) { throw new Error("The singleton WorkerManager instance has already been constructed as Game#workers"); } super(); } /** * Supported worker task actions * @enum {string} */ static WORKER_TASK_ACTIONS = Object.freeze({ 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.has(name)) { throw new Error(`A Worker already exists with the name "${name}"`); } const worker = new AsyncWorker(name, config); this.set(name, worker); await worker.ready; return worker; } /* -------------------------------------------- */ /** * Retire a current Worker, terminating it immediately. * @see Worker#terminate * @param {string} name The named worker to terminate */ retireWorker(name) { const worker = this.get(name); if ( !worker ) return; worker.terminate(); this.delete(name); } /* -------------------------------------------- */ /** * @deprecated since 11 * @ignore */ getWorker(name) { foundry.utils.logCompatibilityWarning("WorkerManager#getWorker is deprecated in favor of WorkerManager#get", {since: 11, until: 13}); const w = this.get(name); if ( !w ) throw new Error(`No worker with name ${name} currently exists!`); return w; } } /* -------------------------------------------- */ /** * A namespace containing the user interface applications which are defined throughout the Foundry VTT ecosystem. * @namespace applications */ let _appId = globalThis._appId = 0; let _maxZ = Number(getComputedStyle(document.body).getPropertyValue("--z-index-window") ?? 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 }); /** * 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|null} */ 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 application ID is a unique incrementing integer which is used to identify every application window * drawn by the VTT * @type {number} */ appId; /** * The sequence of rendering states that track the Application life-cycle. * @enum {number} */ static RENDER_STATES = Object.freeze({ ERROR: -3, CLOSING: -2, CLOSED: -1, NONE: 0, RENDERING: 1, RENDERED: 2 }); /* -------------------------------------------- */ /** * 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; } /* -------------------------------------------- */ /** * Whether the Application is currently closing. * @type {boolean} */ get closing() { return this._state === Application.RENDER_STATES.CLOSING; } /* -------------------------------------------- */ /** * 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 * @fires renderApplication * @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 }); options.focus ??= force; // Get the existing HTML element and application data used for rendering const element = this.element; this.appId = element.data("appid") ?? ++_appId; if ( this.popOut ) ui.windows[this.appId] = this; 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); } // 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 ( this.popOut && (options.focus === true) ) this.maximize().then(() => this.bringToTop()); // Dispatch Hooks for rendering the base and subclass applications this._callHooks("render", 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; } /* -------------------------------------------- */ /** * Call all hooks for all applications in the inheritance chain. * @param {string | (className: string) => string} hookName The hook being triggered, which formatted * with the Application class name * @param {...*} hookArgs The arguments passed to the hook calls * @protected * @internal */ _callHooks(hookName, ...hookArgs) { const formatHook = typeof hookName === "string" ? className => `${hookName}${className}` : hookName; for ( const cls of this.constructor._getInheritanceChain() ) { if ( !cls.name ) continue; Hooks.callAll(formatHook(cls.name), this, ...hookArgs); } } /* -------------------------------------------- */ /** * 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 this.position.zIndex = Math.min(++_maxZ, 99999); html[0].style.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. * @fires getApplicationHeaderButtons * @returns {ApplicationHeaderButton[]} * @protected */ _getHeaderButtons() { const buttons = [ { label: "Close", class: "close", icon: "fas fa-times", onclick: () => this.close() } ]; this._callHooks(className => `get${className}HeaderButtons`, 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 content = this.popOut ? html[0].parentElement : html[0]; this._tabs.forEach(t => t.bind(content)); this._dragDrop.forEach(d => d.bind(content)); this._searchFilters.forEach(f => f.bind(content)); } /* -------------------------------------------- */ /** * 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() { if ( ui.activeWindow === this ) return; 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 * @fires closeApplication * @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 this._callHooks("close", 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"); this._saveScrollPositions(window); // 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); this._restoreScrollPositions(window); 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.clamp(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.clamp(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.clamp(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.clamp(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) {} /* -------------------------------------------- */ /** * Wait for any images present in the Application to load. * @returns {Promise} A Promise that resolves when all images have loaded. * @protected */ _waitForImages() { return new Promise(resolve => { let loaded = 0; const images = Array.from(this.element.find("img")).filter(img => !img.complete); if ( !images.length ) resolve(); for ( const img of images ) { img.onload = img.onerror = () => { loaded++; img.onload = img.onerror = null; if ( loaded >= images.length ) resolve(); }; } }); } } /** * @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 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 {Record} */ this.editors = {}; } /** * An array of custom element tag names that should be listened to for changes. * @type {string[]} * @protected */ static _customElements = Object.values(foundry.applications.elements).reduce((arr, el) => { if ( el.tagName ) arr.push(el.tagName); return arr; }, []); /* -------------------------------------------- */ /** * 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; const changeElements = ["input", "select", "textarea"].concat(this.constructor._customElements); html.on("change", changeElements.join(","), this._onChangeInput.bind(this)); html.find(".editor-content[data-edit]").each((i, div) => this._activateEditor(div)); html.find("button.file-picker").click(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) { // Saving a element if ( event.currentTarget.matches("prose-mirror") ) return this._onSubmit(event); // Ignore inputs inside an editor environment 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; input.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; } } /* -------------------------------------------- */ /** * 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; // Legacy behavior to support TinyMCE. // We could remove this in the future if we drop official support for TinyMCE. 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 {object} [options] * @param {boolean} [options.remove] Remove the editor after saving its content * @param {boolean} [options.preventRender] Prevent normal re-rendering of the sheet after saving. * @returns {Promise} */ async saveEditor(name, {remove=true, preventRender}={}) { 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"), { preventRender }); // 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}); // Define the editor configuration const initial = foundry.utils.getProperty(this.object, name); const editor = this.editors[name] = { options, target: name, button: button, hasButton: hasButton, mce: null, instance: null, active: !hasButton, changed: false, initial }; // Activate the editor immediately, or upon button click const activate = () => { editor.initial = foundry.utils.getProperty(this.object, 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}) }) }; } /* -------------------------------------------- */ /* 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.length = 0; for ( const fp of this.element[0].querySelectorAll("file-picker") ) fp.picker?.close(); // 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 {Promise} Return a self-reference for convenient method chaining */ async submit(options={}) { if ( this._submitting ) return this; const submitEvent = new Event("submit"); await this._onSubmit(submitEvent, options); return this; } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get filepickers() { foundry.utils.logCompatibilityWarning("FormApplication#filepickers is deprecated and replaced by the " + "HTML element", {since: 12, until: 14, once: true}); return this.#filepickers; } #filepickers = []; /** * @deprecated since v12 * @ignore */ _activateFilePicker(event) { foundry.utils.logCompatibilityWarning("FormApplication#_activateFilePicker is deprecated without replacement", {since: 12, until: 14, once: true}); event.preventDefault(); const options = this._getFilePickerOptions(event); const fp = new FilePicker(options); this.#filepickers.push(fp); return fp.browse(); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ _getFilePickerOptions(event) { foundry.utils.logCompatibilityWarning("FormApplication#_getFilePickerOptions is deprecated without replacement", {since: 12, until: 14, once: true}); 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) }; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ _onSelectFile(selection, filePicker) {} } /* -------------------------------------------- */ /** * @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.isEditable ) html.find("img[data-edit]").on("click", this._onEditImage.bind(this)); if ( !this.document.isOwner ) return; this._secrets.forEach(secret => secret.bind(html[0])); } /* -------------------------------------------- */ /** @inheritdoc */ async activateEditor(name, options={}, initialContent="") { const editor = this.editors[name]; options.document = this.document; if ( editor?.options.engine === "prosemirror" ) { options.plugins = foundry.utils.mergeObject({ highlightDocumentMatches: ProseMirror.ProseMirrorHighlightMatchesPlugin.build(ProseMirror.defaultSchema) }, options.plugins); } return super.activateEditor(name, options, initialContent); } /* -------------------------------------------- */ /** @inheritDoc */ async _render(force, options={}) { // Verify user permission to view and edit if ( !this._canUserView(game.user) ) { if ( !force ) return; const err = game.i18n.format("SHEETS.DocumentSheetPrivate", { type: game.i18n.localize(this.object.constructor.metadata.label) }); ui.notifications.warn(err); return; } options.editable = options.editable ?? this.object.isOwner; // Parent class rendering workflow await super._render(force, options); // Register the active Application with the referenced Documents this.object.apps[this.appId] = this; } /* -------------------------------------------- */ /** @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.ariaLabel = game.i18n.localize("SHEETS.CopyUuid"); idLink.dataset.tooltip = `SHEETS.CopyUuid`; idLink.dataset.tooltipDirection = "UP"; idLink.innerHTML = ''; idLink.addEventListener("click", event => { event.preventDefault(); game.clipboard.copyPlainText(this.object.uuid); ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type: "uuid", id: this.object.uuid})); }); idLink.addEventListener("contextmenu", event => { event.preventDefault(); game.clipboard.copyPlainText(this.object.id); ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type: "id", id: this.object.id})); }); 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) { 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.constructor.name !== "Folder") && !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 && (this.document.getFlag("core", "sheetLock") !== true) ) { 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); } /* -------------------------------------------- */ /** * Handle changing a Document's image. * @param {MouseEvent} event The click event. * @returns {Promise} * @protected */ _onEditImage(event) { const attr = event.currentTarget.dataset.edit; const current = foundry.utils.getProperty(this.object, attr); const { img } = this.document.constructor.getDefaultArtwork?.(this.document.toObject()) ?? {}; const fp = new FilePicker({ current, type: "image", redirectToRoot: img ? [img] : [], callback: path => { event.currentTarget.src = path; if ( this.options.submitOnChange ) return this._onSubmit(event); }, top: this.position.top + 40, left: this.position.left + 10 }); return fp.browse(); } /* -------------------------------------------- */ /** @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 = {}; } /* -------------------------------------------- */ /** * Cached store of Intl.ListFormat instances. * @type {Record} */ #formatters = {}; /* -------------------------------------------- */ /** * 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 ( const t of types ) { if ( config.typeLabels[t] ) continue; const key = t === CONST.BASE_DOCUMENT_TYPE ? "TYPES.Base" :`TYPES.${documentName}.${t}`; config.typeLabels[t] = key; /** @deprecated since v11 */ const legacyKey = `${documentName.toUpperCase()}.Type${t.titleCase()}`; if ( !this.has(key) && this.has(legacyKey) ) { foundry.utils.logCompatibilityWarning( `You are using the '${legacyKey}' localization key which has been deprecated. ` + `Please define a '${key}' key instead.`, {since: 11, until: 13} ); config.typeLabels[t] = legacyKey; } } } } // Pre-localize data models Localization.#localizeDataModels(); Hooks.callAll("i18nInit"); } /* -------------------------------------------- */ /* Data Model Localization */ /* -------------------------------------------- */ /** * Perform one-time localization of the fields in a DataModel schema, translating their label and hint properties. * @param {typeof DataModel} model The DataModel class to localize * @param {object} options Options which configure how localization is performed * @param {string[]} [options.prefixes] An array of localization key prefixes to use. If not specified, prefixes * are learned from the DataModel.LOCALIZATION_PREFIXES static property. * @param {string} [options.prefixPath] A localization path prefix used to prefix all field names within this * model. This is generally not required. * * @example * JavaScript class definition and localization call. * ```js * class MyDataModel extends foundry.abstract.DataModel { * static defineSchema() { * return { * foo: new foundry.data.fields.StringField(), * bar: new foundry.data.fields.NumberField() * }; * } * static LOCALIZATION_PREFIXES = ["MYMODULE.MYDATAMODEL"]; * } * * Hooks.on("i18nInit", () => { * Localization.localizeDataModel(MyDataModel); * }); * ``` * * JSON localization file * ```json * { * "MYMODULE": { * "MYDATAMODEL": { * "FIELDS" : { * "foo": { * "label": "Foo", * "hint": "Instructions for foo" * }, * "bar": { * "label": "Bar", * "hint": "Instructions for bar" * } * } * } * } * } * ``` */ static localizeDataModel(model, {prefixes, prefixPath}={}) { prefixes ||= model.LOCALIZATION_PREFIXES; Localization.#localizeSchema(model.schema, prefixes, {prefixPath}); } /* -------------------------------------------- */ /** * Perform one-time localization of data model definitions which localizes their label and hint properties. */ static #localizeDataModels() { for ( const document of Object.values(foundry.documents) ) { const cls = document.implementation; Localization.localizeDataModel(cls); for ( const model of Object.values(CONFIG[cls.documentName].dataModels ?? {}) ) { Localization.localizeDataModel(model, {prefixPath: "system."}); } } } /* -------------------------------------------- */ /** * Localize the "label" and "hint" properties for all fields in a data schema. * @param {SchemaField} schema * @param {string[]} prefixes * @param {object} [options] * @param {string} [options.prefixPath] */ static #localizeSchema(schema, prefixes=[], {prefixPath=""}={}) { const getRules = prefixes => { const rules = {}; for ( const prefix of prefixes ) { if ( game.i18n.lang !== "en" ) { const fallback = foundry.utils.getProperty(game.i18n._fallback, `${prefix}.FIELDS`); Object.assign(rules, fallback); } Object.assign(rules, foundry.utils.getProperty(game.i18n.translations, `${prefix}.FIELDS`)); } return rules; }; const rules = getRules(prefixes); // Apply localization to fields of the model schema.apply(function() { // Inner models may have prefixes which take precedence if ( this instanceof foundry.data.fields.EmbeddedDataField ) { if ( this.model.LOCALIZATION_PREFIXES.length ) { foundry.utils.setProperty(rules, this.fieldPath, getRules(this.model.LOCALIZATION_PREFIXES)); } } // Localize model fields let k = this.fieldPath; if ( prefixPath ) k = k.replace(prefixPath, ""); const field = foundry.utils.getProperty(rules, k); if ( field?.label ) this.label = game.i18n.localize(field.label); if ( field?.hint ) this.hint = game.i18n.localize(field.hint); }); } /* -------------------------------------------- */ /** * 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 || ((p.type === "module") && !p.active) ) 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; } /* -------------------------------------------- */ /** * Retrieve list formatter configured to the world's language setting. * @see [Intl.ListFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat/ListFormat) * @param {object} [options] * @param {ListFormatStyle} [options.style=long] The list formatter style, either "long", "short", or "narrow". * @param {ListFormatType} [options.type=conjunction] The list formatter type, either "conjunction", "disjunction", * or "unit". * @returns {Intl.ListFormat} */ getListFormatter({style="long", type="conjunction"}={}) { const key = `${style}${type}`; this.#formatters[key] ??= new Intl.ListFormat(this.lang, {style, type}); return this.#formatters[key]; } /* -------------------------------------------- */ /** * Sort an array of objects by a given key in a localization-aware manner. * @param {object[]} objects The objects to sort, this array will be mutated. * @param {string} key The key to sort the objects by. This can be provided in dot-notation. * @returns {object[]} */ sortObjects(objects, key) { const collator = new Intl.Collator(this.lang); objects.sort((a, b) => { return collator.compare(foundry.utils.getProperty(a, key), foundry.utils.getProperty(b, key)); }); return objects; } } /* -------------------------------------------- */ /* HTML Template Loading */ /* -------------------------------------------- */ /** * 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 ( path in Handlebars.partials ) return Handlebars.partials[path]; const htmlString = await new Promise((resolve, reject) => { game.socket.emit("template", path, resp => { if ( resp.error ) return reject(new Error(resp.error)); return resolve(resp.html); }); }); const compiled = Handlebars.compile(htmlString); Handlebars.registerPartial(id ?? path, compiled); console.log(`Foundry VTT | Retrieved and compiled template ${path}`); return compiled; } /* -------------------------------------------- */ /** * Load and cache a set of templates by providing an Array of paths * @param {string[]|Record} 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)); } /* -------------------------------------------- */ /** * Construct an editor element for rich text editing with TinyMCE or ProseMirror. * @param {string} content The content to display and edit. * @param {object} [options] * @param {string} [options.target] The named target data element * @param {boolean} [options.button] Include a button used to activate the editor later? * @param {string} [options.class] A specific CSS class to add to the editor container * @param {boolean} [options.editable=true] Is the text editor area currently editable? * @param {string} [options.engine=tinymce] The editor engine to use, see {@link TextEditor.create}. * @param {boolean} [options.collaborate=false] Whether to turn on collaborative editing features for ProseMirror. * @returns {Handlebars.SafeString} * * @example * ```hbs * {{editor world.description target="description" button=false engine="prosemirror" collaborate=false}} * ``` */ static editor(content, options) { const { target, editable=true, button, engine="tinymce", collaborate=false, class: cssClass } = options.hash; const config = {name: target, value: content, button, collaborate, editable, engine}; const element = foundry.applications.fields.createEditorInput(config); if ( cssClass ) element.querySelector(".editor-content").classList.add(cssClass); return new Handlebars.SafeString(element.outerHTML); } /* -------------------------------------------- */ /** * A ternary expression that allows inserting A or B depending on the value of C. * @param {boolean} criteria The test criteria * @param {string} ifTrue The string to output if true * @param {string} ifFalse The string to output if false * @returns {string} The ternary result * * @example Ternary if-then template usage * ```hbs * {{ifThen true "It is true" "It is false"}} * ``` */ static ifThen(criteria, ifTrue, ifFalse) { return criteria ? ifTrue : ifFalse; } /* -------------------------------------------- */ /** * 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) { if ( value instanceof Handlebars.SafeString ) value = value.toString(); 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|string} 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}} * {{formatNumber null decimals=2 sign=false}} * {{formatNumber undefined decimals=0 sign=true}} * ``` */ static numberFormat(value, options) { const originalValue = value; const dec = options.hash.decimals ?? 0; const sign = options.hash.sign || false; if ( (typeof value === "string") || (value == null) ) value = parseFloat(value); if ( Number.isNaN(value) ) { console.warn("An invalid value was passed to numberFormat:", { originalValue, valueType: typeof originalValue, options }); } let strVal = sign && (value >= 0) ? `+${value.toFixed(dec)}` : value.toFixed(dec); return new Handlebars.SafeString(strVal); } /* --------------------------------------------- */ /** * Render a form input field of type number with value appropriately rounded to step size. * @param {number} value * @param {FormInputConfig & NumberInputConfig} options * @returns {Handlebars.SafeString} * * @example * ```hbs * {{numberInput value name="numberField" step=1 min=0 max=10}} * ``` */ static numberInput(value, options) { const {class: cssClass, ...config} = options.hash; config.value = value; const element = foundry.applications.fields.createNumberInput(config); if ( cssClass ) element.className = cssClass; return new Handlebars.SafeString(element.outerHTML); } /* -------------------------------------------- */ /** * 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); } /* -------------------------------------------- */ /** * @typedef {Object} SelectOptionsHelperOptions * @property {boolean} invert Invert the key/value order of a provided choices object * @property {string|string[]|Set} selected The currently selected value or values */ /** * 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 and multi-select input fields. * * @param {object|Array} choices A mapping of radio checkbox values to human-readable labels * @param {SelectInputConfig & SelectOptionsHelperOptions} options Options which configure how select options are * generated by the helper * @returns {Handlebars.SafeString} Generated HTML safe for rendering into a Handlebars template * * @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 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, blank, sort, nameAttr, valueAttr, labelAttr, inverted, groups} = options.hash; if ( (selected === undefined) || (selected === null) ) selected = []; else if ( !(selected instanceof Array) ) selected = [selected]; if ( nameAttr && !valueAttr ) { foundry.utils.logCompatibilityWarning(`The "nameAttr" property of the {{selectOptions}} handlebars helper is renamed to "valueAttr" for consistency with other methods.`, {since: 12, until: 14}); valueAttr = nameAttr; } // Prepare the choices as an array of objects const selectOptions = []; if ( choices instanceof Array ) { for ( const [i, choice] of choices.entries() ) { if ( typeof choice === "object" ) selectOptions.push(choice); else selectOptions.push({value: i, label: choice}); } } // Object of keys and values else { for ( const choice of Object.entries(choices) ) { const [k, v] = inverted ? choice.reverse() : choice; const value = valueAttr ? v[valueAttr] : k; if ( typeof v === "object" ) selectOptions.push({value, ...v}); else selectOptions.push({value, label: v}); } } // Delegate to new fields helper const select = foundry.applications.fields.createSelectInput({ options: selectOptions, value: selected, blank, groups, labelAttr, localize, sort, valueAttr }); return new Handlebars.SafeString(select.innerHTML); } /* -------------------------------------------- */ /** * Convert a DataField instance into an HTML input fragment. * @param {DataField} field The DataField instance to convert to an input * @param {object} options Helper options * @returns {Handlebars.SafeString} */ static formInput(field, options) { const input = field.toInput(options.hash); return new Handlebars.SafeString(input.outerHTML); } /* -------------------------------------------- */ /** * Convert a DataField instance into an HTML input fragment. * @param {DataField} field The DataField instance to convert to an input * @param {object} options Helper options * @returns {Handlebars.SafeString} */ static formGroup(field, options) { const {classes, label, hint, rootId, stacked, units, widget, ...inputConfig} = options.hash; const groupConfig = {label, hint, rootId, stacked, widget, localize: inputConfig.localize, units, classes: typeof classes === "string" ? classes.split(" ") : []}; const group = field.toFormGroup(groupConfig, inputConfig); return new Handlebars.SafeString(group.outerHTML); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static filePicker(options) { foundry.utils.logCompatibilityWarning("The {{filePicker}} Handlebars helper is deprecated and replaced by" + " use of the custom HTML element", {since: 12, until: 14, once: true}); const type = options.hash.type; const target = options.hash.target; if ( !target ) throw new Error("You must define the name of the target field."); if ( game.world && !game.user.can("FILES_BROWSE" ) ) return ""; const tooltip = game.i18n.localize("FILES.BrowseTooltip"); return new Handlebars.SafeString(` `); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static colorPicker(options) { foundry.utils.logCompatibilityWarning("The {{colorPicker}} Handlebars helper is deprecated and replaced by" + " use of the custom HTML element", {since: 12, until: 14, once: true}); let {name, default: defaultColor, value} = options.hash; name = name || "color"; value = value || defaultColor || ""; const htmlString = ``; return new Handlebars.SafeString(htmlString); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static select(selected, options) { foundry.utils.logCompatibilityWarning("The {{select}} handlebars helper is deprecated in favor of using the " + "{{selectOptions}} helper or the foundry.applications.fields.createSelectInput, " + "foundry.applications.fields.createMultiSelectElement, or " + "foundry.applications.fields.prepareSelectOptionGroups methods.", {since: 12, until: 14}); const escapedValue = RegExp.escape(Handlebars.escapeExpression(selected)); const rgx = new RegExp(` value=[\"']${escapedValue}[\"\']`); const html = options.fn(this); return html.replace(rgx, "$& selected"); } } // Register all handlebars helpers Handlebars.registerHelper({ checked: HandlebarsHelpers.checked, disabled: HandlebarsHelpers.disabled, colorPicker: HandlebarsHelpers.colorPicker, concat: HandlebarsHelpers.concat, editor: HandlebarsHelpers.editor, formInput: HandlebarsHelpers.formInput, formGroup: HandlebarsHelpers.formGroup, formField: HandlebarsHelpers.formGroup, // Alias filePicker: HandlebarsHelpers.filePicker, ifThen: HandlebarsHelpers.ifThen, 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 { /** * Initialize a singleton Game instance for a specific view using socket data retrieved from the server. * @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) { // Session Properties Object.defineProperties(this, { view: {value: view, enumerable: true}, sessionId: {value: sessionId, enumerable: true}, socket: {value: socket, enumerable: true}, userId: {value: data.userId || null, enumerable: true}, data: {value: data, enumerable: true}, release: {value: new foundry.config.ReleaseData(data.release), enumerable: true} }); // Set up package data this.setupPackages(data); // Helper Properties Object.defineProperties(this, { audio: {value: new foundry.audio.AudioHelper(), enumerable: true}, canvas: {value: new Canvas(), enumerable: true}, clipboard: {value: new ClipboardHelper(), enumerable: true}, collections: {value: new foundry.utils.Collection(), enumerable: true}, compendiumArt: {value: new foundry.helpers.CompendiumArt(), enumerable: true}, documentIndex: {value: new DocumentIndex(), enumerable: true}, i18n: {value: new Localization(data?.options?.language), enumerable: true}, issues: {value: new ClientIssues(), enumerable: true}, gamepad: {value: new GamepadManager(), enumerable: true}, keyboard: {value: new KeyboardManager(), enumerable: true}, mouse: {value: new MouseManager(), enumerable: true}, nue: {value: new NewUserExperience(), enumerable: true}, packs: {value: new CompendiumPacks(), enumerable: true}, settings: {value: new ClientSettings(data.settings || []), enumerable: true}, time: {value: new GameTime(socket), enumerable: true}, tooltip: {value: new TooltipManager(), configurable: true, enumerable: true}, tours: {value: new Tours(), enumerable: true}, video: {value: new VideoHelper(), enumerable: true}, workers: {value: new WorkerManager(), enumerable: true}, keybindings: {value: new ClientKeybindings(), enumerable: true} }); /** * The singleton game Canvas. * @type {Canvas} */ Object.defineProperty(globalThis, "canvas", {value: this.canvas, writable: true}); } /* -------------------------------------------- */ /* Session Attributes */ /* -------------------------------------------- */ /** * The named view which is currently active. * @type {"join"|"setup"|"players"|"license"|"game"|"stream"} */ view; /** * The object of world data passed from the server. * @type {object} */ data; /** * The client session id which is currently active. * @type {string} */ sessionId; /** * A reference to the open Socket.io connection. * @type {WebSocket|null} */ socket; /** * The id of the active World user, if any. * @type {string|null} */ userId; /* -------------------------------------------- */ /* Packages Attributes */ /* -------------------------------------------- */ /** * 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; /** * A mapping of CompendiumCollection instances, one per Compendium pack. * @type {CompendiumPacks} */ packs; /** * A registry of document sub-types and their respective data models. * @type {Record>} */ get model() { return this.#model; } #model; /* -------------------------------------------- */ /* Document Attributes */ /* -------------------------------------------- */ /** * A registry of document types supported by the active world. * @type {Record} */ get documentTypes() { return this.#documentTypes; } #documentTypes; /** * The singleton DocumentIndex instance. * @type {DocumentIndex} */ documentIndex; /** * The UUID redirects tree. * @type {foundry.utils.StringTree} */ compendiumUUIDRedirects; /** * A mapping of WorldCollection instances, one per primary Document type. * @type {Collection} */ collections; /** * The collection of Actor documents which exists in the World. * @type {Actors} */ actors; /** * The collection of Cards documents which exists in the World. * @type {CardStacks} */ cards; /** * The collection of Combat documents which exists in the World. * @type {CombatEncounters} */ combats; /** * The collection of Cards documents which exists in the World. * @type {Folders} */ folders; /** * The collection of Item documents which exists in the World. * @type {Items} */ items; /** * The collection of JournalEntry documents which exists in the World. * @type {Journal} */ journal; /** * The collection of Macro documents which exists in the World. * @type {Macros} */ macros; /** * The collection of ChatMessage documents which exists in the World. * @type {Messages} */ messages; /** * The collection of Playlist documents which exists in the World. * @type {Playlists} */ playlists; /** * The collection of Scene documents which exists in the World. * @type {Scenes} */ scenes; /** * The collection of RollTable documents which exists in the World. * @type {RollTables} */ tables; /** * The collection of User documents which exists in the World. * @type {Users} */ users; /* -------------------------------------------- */ /* State Attributes */ /* -------------------------------------------- */ /** * The Release data for this version of Foundry * @type {config.ReleaseData} */ release; /** * Returns the current version of the Release, usable for comparisons using isNewerVersion * @type {string} */ get version() { return this.release.version; } /** * Whether the Game is running in debug mode * @type {boolean} */ debug = false; /** * A flag for whether texture assets for the game canvas are currently loading * @type {boolean} */ loading = false; /** * The user role permissions setting. * @type {object} */ permissions; /** * A flag for whether the Game has successfully reached the "ready" hook * @type {boolean} */ ready = false; /** * An array of buffered events which are received by the socket before the game is ready to use that data. * Buffered events are replayed in the order they are received until the buffer is empty. * @type {Array>} */ static #socketEventBuffer = []; /* -------------------------------------------- */ /* Helper Classes */ /* -------------------------------------------- */ /** * The singleton compendium art manager. * @type {CompendiumArt} */ compendiumArt; /** * The singleton Audio Helper. * @type {AudioHelper} */ audio; /** * The singleton game Canvas. * @type {Canvas} */ canvas; /** * The singleton Clipboard Helper. * @type {ClipboardHelper} */ clipboard; /** * Localization support. * @type {Localization} */ i18n; /** * The singleton ClientIssues manager. * @type {ClientIssues} */ issues; /** * The singleton Gamepad Manager. * @type {GamepadManager} */ gamepad; /** * The singleton Keyboard Manager. * @type {KeyboardManager} */ keyboard; /** * Client keybindings which are used to configure application behavior * @type {ClientKeybindings} */ keybindings; /** * The singleton Mouse Manager. * @type {MouseManager} */ mouse; /** * The singleton New User Experience manager. * @type {NewUserExperience} */ nue; /** * Client settings which are used to configure application behavior. * @type {ClientSettings} */ settings; /** * A singleton GameTime instance which manages the progression of time within the game world. * @type {GameTime} */ time; /** * The singleton TooltipManager. * @type {TooltipManager} */ tooltip; /** * The singleton Tours collection. * @type {Tours} */ tours; /** * The singleton Video Helper. * @type {VideoHelper} */ video; /** * A singleton web Worker manager. * @type {WorkerManager} */ workers; /* -------------------------------------------- */ /** * 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) { // Connect to the websocket const socket = await 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)); }); // Buffer events until the game is ready socket.prependAny(Game.#bufferSocketEvents); // Disconnection and reconnection attempts let disconnectedTime = 0; socket.on("disconnect", () => { disconnectedTime = Date.now(); ui.notifications.error("You have lost connection to the server, attempting to re-establish."); }); // Reconnect attempt socket.io.on("reconnect_attempt", () => { const t = Date.now(); console.log(`${vtt} | Attempting to re-connect: ${((t - disconnectedTime) / 1000).toFixed(2)} seconds`); }); // Reconnect failed socket.io.on("reconnect_failed", () => { ui.notifications.error(`${vtt} | Server connection lost.`); window.location.href = foundry.utils.getRoute("no"); }); // Reconnect succeeded const reconnectTimeRequireRefresh = 5000; socket.io.on("reconnect", () => { ui.notifications.info(`${vtt} | Server connection re-established.`); if ( (Date.now() - disconnectedTime) >= reconnectTimeRequireRefresh ) { foundry.utils.debouncedReload(); } }); return socket; } /* -------------------------------------------- */ /** * Place a buffered socket event into the queue * @param {[string, ...any]} args Arguments of the socket event */ static #bufferSocketEvents(...args) { Game.#socketEventBuffer.push(Object.freeze(args)); } /* -------------------------------------------- */ /** * Apply the queue of buffered socket events to game data once the game is ready. */ static #applyBufferedSocketEvents() { while ( Game.#socketEventBuffer.length ) { const args = Game.#socketEventBuffer.shift(); console.log(`Applying buffered socket event: ${args[0]}`); game.socket.emitEvent(args); } } /* -------------------------------------------- */ /** * 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); this.#model = Object.freeze(data.model); this.#template = Object.freeze(data.template); this.#documentTypes = Object.freeze(Object.entries(this.model).reduce((obj, [d, types]) => { obj[d] = Object.keys(types); return obj; }, {})); } 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.issues._detectUsabilityIssues(); } /* -------------------------------------------- */ /** * Shut down the currently active Game. Requires GameMaster user permission. * @returns {Promise} */ async shutDown() { if ( !(game.user?.isGM || game.data.isAdmin) ) { throw new Error("Only a Gamemaster User or server Administrator may shut down the currently active world"); } // Display a warning if other players are connected const othersActive = game.users.filter(u => u.active && !u.isSelf).length; if ( othersActive ) { const warning = othersActive > 1 ? "GAME.ReturnSetupActiveUsers" : "GAME.ReturnSetupActiveUser"; const confirm = await Dialog.confirm({ title: game.i18n.localize("GAME.ReturnSetup"), content: `

${game.i18n.format(warning, {number: othersActive})}

` }); if ( !confirm ) return; } // Dispatch the request const setupUrl = foundry.utils.getRoute("setup"); const response = await foundry.utils.fetchWithTimeout(setupUrl, { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({shutdown: true}), redirect: "manual" }); // Redirect after allowing time for a pop-up notification setTimeout(() => window.location.href = response.url, 1000); } /* -------------------------------------------- */ /* Primary Game Initialization /* -------------------------------------------- */ /** * Fully set up the game state, initializing Documents, UI applications, and the Canvas * @returns {Promise} */ async setupGame() { // Store permission settings this.permissions = await this.settings.get("core", "permissions"); // Initialize configuration data this.initializeConfig(); // Initialize world data this.initializePacks(); // Initialize compendium packs this.initializeDocuments(); // Initialize world documents // Monkeypatch a search method on EmbeddedCollection foundry.abstract.EmbeddedCollection.prototype.search = DocumentCollection.prototype.search; // Call world setup hook Hooks.callAll("setup"); // Initialize audio playback // noinspection ES6MissingAwait this.playlists.initialize(); // Initialize AV conferencing // noinspection ES6MissingAwait this.initializeRTC(); // Initialize user interface this.initializeMouse(); this.initializeGamepads(); this.initializeKeyboard(); // Parse the UUID redirects configuration. this.#parseRedirects(); // Initialize dynamic token config foundry.canvas.tokens.TokenRingConfig.initialize(); // Call this here to set up a promise that dependent UI elements can await. this.canvas.initializing = this.initializeCanvas(); this.initializeUI(); DocumentSheetConfig.initializeSheets(); // 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); } // Index documents for search await this.documentIndex.index(); // Wait for canvas initialization and call all game ready hooks await this.canvas.initializing; this.ready = true; this.activateSocketListeners(); Hooks.callAll("ready"); // Initialize New User Experience this.nue.initialize(); } /* -------------------------------------------- */ /** * Initialize configuration state. */ initializeConfig() { // Configure token ring subject paths Object.assign(CONFIG.Token.ring.subjectPaths, this.system.flags?.tokenRingSubjectMappings); for ( const module of this.modules ) { if ( module.active ) Object.assign(CONFIG.Token.ring.subjectPaths, module.flags?.tokenRingSubjectMappings); } // Configure Actor art. this.compendiumArt._registerArt(); } /* -------------------------------------------- */ /** * Initialize game state data by creating WorldCollection instances for every primary Document type */ initializeDocuments() { const excluded = ["FogExploration", "Setting"]; const initOrder = ["User", "Folder", "Actor", "Item", "Scene", "Combat", "JournalEntry", "Macro", "Playlist", "RollTable", "Cards", "ChatMessage"]; if ( !new Set(initOrder).equals(new Set(CONST.WORLD_DOCUMENT_TYPES.filter(t => !excluded.includes(t)))) ) { throw new Error("Missing Document initialization type!"); } // Warn developers about collision with V10 DataModel changes const v10DocumentMigrationErrors = []; for ( const documentName of initOrder ) { 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() { for ( let metadata of this.data.packs ) { let pack = this.packs.get(metadata.id); // Update the compendium collection if ( !pack ) pack = new CompendiumCollection(metadata); this.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; } /* -------------------------------------------- */ /** * Initialize the WebRTC implementation */ initializeRTC() { this.webrtc = new AVMaster(); return this.webrtc.connect(); } /* -------------------------------------------- */ /** * Initialize core UI elements */ initializeUI() { // Global light/dark theme. matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => this.#updatePreferredColorScheme()); this.#updatePreferredColorScheme(); // Initialize all singleton applications for ( let [k, cls] of Object.entries(CONFIG.ui) ) { ui[k] = new cls(); } // Initialize pack applications for ( let pack of this.packs.values() ) { if ( Application.isPrototypeOf(pack.applicationClass) ) { const app = new pack.applicationClass({collection: pack}); pack.apps.push(app); } } // 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() { Object.defineProperty(globalThis, "keyboard", {value: this.keyboard, writable: false, enumerable: true}); this.keyboard._activateListeners(); try { game.keybindings._registerCoreKeybindings(this.view); game.keybindings.initialize(); } catch(e) { console.error(e); } } /* -------------------------------------------- */ /** * Initialize Mouse controls */ initializeMouse() { this.mouse._activateListeners(); } /* -------------------------------------------- */ /** * Initialize Gamepad controls */ initializeGamepads() { this.gamepad._activateListeners(); } /* -------------------------------------------- */ /** * 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: foundry.applications.apps.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(); if ( canvas.ready ) canvas.controls.drawCursors(); } }); // 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: {}, requiresReload: true }); // 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: {} }); // Combat Tracker Configuration game.settings.registerMenu("core", Combat.CONFIG_SETTING, { name: "SETTINGS.CombatConfigN", label: "SETTINGS.CombatConfigL", hint: "SETTINGS.CombatConfigH", icon: "fa-solid fa-swords", type: CombatTrackerConfig }); // No-Canvas Mode game.settings.register("core", "noCanvas", { name: "SETTINGS.NoCanvasN", hint: "SETTINGS.NoCanvasL", scope: "client", config: true, type: new foundry.data.fields.BooleanField({initial: false}), requiresReload: true }); // Language preference game.settings.register("core", "language", { name: "SETTINGS.LangN", hint: "SETTINGS.LangL", scope: "client", config: true, type: new foundry.data.fields.StringField({required: true, blank: false, initial: game.i18n.lang, choices: CONFIG.supportedLanguages}), requiresReload: true }); // Color Scheme game.settings.register("core", "colorScheme", { name: "SETTINGS.ColorSchemeN", hint: "SETTINGS.ColorSchemeH", scope: "client", config: true, type: new foundry.data.fields.StringField({required: true, blank: true, initial: "", choices: { "": "SETTINGS.ColorSchemeDefault", dark: "SETTINGS.ColorSchemeDark", light: "SETTINGS.ColorSchemeLight" }}), onChange: () => this.#updatePreferredColorScheme() }); // Token ring settings foundry.canvas.tokens.TokenRingConfig.registerSettings(); // Chat message roll mode game.settings.register("core", "rollMode", { name: "Default Roll Mode", scope: "client", config: false, type: new foundry.data.fields.StringField({required: true, blank: false, initial: CONST.DICE_ROLL_MODES.PUBLIC, choices: CONFIG.Dice.rollModes}), onChange: ChatLog._setRollMode }); // Dice Configuration game.settings.register("core", "diceConfiguration", { config: false, default: {}, type: Object, scope: "client" }); game.settings.registerMenu("core", "diceConfiguration", { name: "DICE.CONFIG.Title", label: "DICE.CONFIG.Label", hint: "DICE.CONFIG.Hint", icon: "fas fa-dice-d20", type: DiceConfig, restricted: false }); // Compendium art configuration. game.settings.register("core", this.compendiumArt.SETTING, { config: false, default: {}, type: Object, scope: "world" }); game.settings.registerMenu("core", this.compendiumArt.SETTING, { name: "COMPENDIUM.ART.SETTING.Title", label: "COMPENDIUM.ART.SETTING.Label", hint: "COMPENDIUM.ART.SETTING.Hint", icon: "fas fa-palette", type: foundry.applications.apps.CompendiumArtConfig, restricted: true }); // World time game.settings.register("core", "time", { name: "World Time", scope: "world", config: false, type: new foundry.data.fields.NumberField({required: true, nullable: false, initial: 0}), 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) }); game.settings.registerMenu("core", "sheetClasses", { name: "SETTINGS.DefaultSheetsN", label: "SETTINGS.DefaultSheetsL", hint: "SETTINGS.DefaultSheetsH", icon: "fa-solid fa-scroll", type: DefaultSheetsConfig, restricted: true }); // Are Chat Bubbles Enabled? game.settings.register("core", "chatBubbles", { name: "SETTINGS.CBubN", hint: "SETTINGS.CBubL", scope: "client", config: true, type: new foundry.data.fields.BooleanField({initial: true}) }); // Pan to Token Speaker game.settings.register("core", "chatBubblesPan", { name: "SETTINGS.CBubPN", hint: "SETTINGS.CBubPL", scope: "client", config: true, type: new foundry.data.fields.BooleanField({initial: true}) }); // Scrolling Status Text game.settings.register("core", "scrollingStatusText", { name: "SETTINGS.ScrollStatusN", hint: "SETTINGS.ScrollStatusL", scope: "world", config: true, type: new foundry.data.fields.BooleanField({initial: true}) }); // Disable Resolution Scaling game.settings.register("core", "pixelRatioResolutionScaling", { name: "SETTINGS.ResolutionScaleN", hint: "SETTINGS.ResolutionScaleL", scope: "client", config: true, type: new foundry.data.fields.BooleanField({initial: true}), requiresReload: true }); // Left-Click Deselection game.settings.register("core", "leftClickRelease", { name: "SETTINGS.LClickReleaseN", hint: "SETTINGS.LClickReleaseL", scope: "client", config: true, type: new foundry.data.fields.BooleanField({initial: false}) }); // Canvas Performance Mode game.settings.register("core", "performanceMode", { name: "SETTINGS.PerformanceModeN", hint: "SETTINGS.PerformanceModeL", scope: "client", config: true, type: new foundry.data.fields.NumberField({required: true, nullable: true, initial: null, 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" }}), requiresReload: true, 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: new foundry.data.fields.NumberField({required: true, min: 10, max: 60, step: 10, initial: 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: new foundry.data.fields.BooleanField({initial: 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: new foundry.data.fields.NumberField({required: true, min: 1, max: 10, step: 1, initial: 5}), onChange: () => game.scaleFonts() }); // Photosensitivity mode. game.settings.register("core", "photosensitiveMode", { name: "SETTINGS.PhotosensitiveModeN", hint: "SETTINGS.PhotosensitiveModeL", scope: "client", config: true, type: new foundry.data.fields.BooleanField({initial: false}), requiresReload: true }); // Live Token Drag Preview game.settings.register("core", "tokenDragPreview", { name: "SETTINGS.TokenDragPreviewN", hint: "SETTINGS.TokenDragPreviewL", scope: "world", config: true, type: new foundry.data.fields.BooleanField({initial: false}) }); // Animated Token Vision game.settings.register("core", "visionAnimation", { name: "SETTINGS.AnimVisionN", hint: "SETTINGS.AnimVisionL", config: true, type: new foundry.data.fields.BooleanField({initial: true}) }); // Light Source Flicker game.settings.register("core", "lightAnimation", { name: "SETTINGS.AnimLightN", hint: "SETTINGS.AnimLightL", config: true, type: new foundry.data.fields.BooleanField({initial: true}), onChange: () => canvas.effects?.activateAnimation() }); // Mipmap Antialiasing game.settings.register("core", "mipmap", { name: "SETTINGS.MipMapN", hint: "SETTINGS.MipMapL", config: true, type: new foundry.data.fields.BooleanField({initial: 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: new foundry.data.fields.BooleanField({initial: 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: new foundry.data.fields.NumberField({required: true, min: 30, max: 300, step: 10, initial: 60}) }); // Link recommendations. game.settings.register("core", "pmHighlightDocumentMatches", { name: "SETTINGS.EnableHighlightDocumentMatches", hint: "SETTINGS.EnableHighlightDocumentMatchesH", scope: "world", config: false, type: new foundry.data.fields.BooleanField({initial: true}) }); // Combat Theme game.settings.register("core", "combatTheme", { name: "SETTINGS.CombatThemeN", hint: "SETTINGS.CombatThemeL", scope: "client", config: false, type: new foundry.data.fields.StringField({required: true, blank: false, initial: "none", 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")}) }) }); // Show Toolclips game.settings.register("core", "showToolclips", { name: "SETTINGS.ShowToolclips", hint: "SETTINGS.ShowToolclipsH", scope: "client", config: true, type: new foundry.data.fields.BooleanField({initial: true}), requiresReload: true }); // Favorite paths game.settings.register("core", "favoritePaths", { scope: "client", config: false, type: Object, default: {"data-/": {source: "data", path: "/", label: "root"}} }); // Top level collection sorting game.settings.register("core", "collectionSortingModes", { scope: "client", config: false, type: Object, default: {} }); // Collection searching game.settings.register("core", "collectionSearchModes", { scope: "client", config: false, type: Object, default: {} }); // Hotbar lock game.settings.register("core", "hotbarLock", { scope: "client", config: false, type: new foundry.data.fields.BooleanField({initial: false}) }); // Adventure imports game.settings.register("core", "adventureImports", { scope: "world", config: false, type: Object, default: {} }); // Document-specific settings RollTables.registerSettings(); // Audio playback settings foundry.audio.AudioHelper.registerSettings(); // Register CanvasLayer settings NotesLayer.registerSettings(); // Square Grid Diagonals game.settings.register("core", "gridDiagonals", { name: "SETTINGS.GridDiagonalsN", hint: "SETTINGS.GridDiagonalsL", scope: "world", config: true, type: new foundry.data.fields.NumberField({ required: true, initial: game.system?.grid.diagonals ?? CONST.GRID_DIAGONALS.EQUIDISTANT, choices: { [CONST.GRID_DIAGONALS.EQUIDISTANT]: "SETTINGS.GridDiagonalsEquidistant", [CONST.GRID_DIAGONALS.EXACT]: "SETTINGS.GridDiagonalsExact", [CONST.GRID_DIAGONALS.APPROXIMATE]: "SETTINGS.GridDiagonalsApproximate", [CONST.GRID_DIAGONALS.RECTILINEAR]: "SETTINGS.GridDiagonalsRectilinear", [CONST.GRID_DIAGONALS.ALTERNATING_1]: "SETTINGS.GridDiagonalsAlternating1", [CONST.GRID_DIAGONALS.ALTERNATING_2]: "SETTINGS.GridDiagonalsAlternating2", [CONST.GRID_DIAGONALS.ILLEGAL]: "SETTINGS.GridDiagonalsIllegal" } }), requiresReload: true }); 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", "backupsOverview", await SetupTour.fromJSON("/tours/backups-overview.json")); game.tours.register("core", "compatOverview", await SetupTour.fromJSON("/tours/compatibility-preview-overview.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`; } /* -------------------------------------------- */ /** * Set the global CSS theme according to the user's preferred color scheme settings. */ #updatePreferredColorScheme() { // Light or Dark Theme let theme; const clientSetting = game.settings.get("core", "colorScheme"); if ( clientSetting ) theme = `theme-${clientSetting}`; else if ( matchMedia("(prefers-color-scheme: dark)").matches ) theme = "theme-dark"; else if ( matchMedia("(prefers-color-scheme: light)").matches ) theme = "theme-light"; document.body.classList.remove("theme-light", "theme-dark"); if ( theme ) document.body.classList.add(theme); // User Color for ( const user of game.users ) { document.documentElement.style.setProperty(`--user-color-${user.id}`, user.color.css); } document.documentElement.style.setProperty("--user-color", game.user.color.css); } /* -------------------------------------------- */ /** * Parse the configured UUID redirects and arrange them as a {@link foundry.utils.StringTree}. */ #parseRedirects() { this.compendiumUUIDRedirects = new foundry.utils.StringTree(); for ( const [prefix, replacement] of Object.entries(CONFIG.compendium.uuidRedirects) ) { if ( !prefix.startsWith("Compendium.") ) continue; this.compendiumUUIDRedirects.addLeaf(prefix.split("."), replacement.split(".")); } } /* -------------------------------------------- */ /* Socket Listeners and Handlers */ /* -------------------------------------------- */ /** * Activate Socket event listeners which are used to transact game state data with the server */ activateSocketListeners() { // Stop buffering events game.socket.offAny(Game.#bufferSocketEvents); // 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()); // Hot Reload this.socket.on("hotReload", this.#handleHotReload.bind(this)); // Database Operations CONFIG.DatabaseBackend.activateSocketListeners(this.socket); // Additional events foundry.audio.AudioHelper._activateSocketListeners(this.socket); Users._activateSocketListeners(this.socket); Scenes._activateSocketListeners(this.socket); Journal._activateSocketListeners(this.socket); FogExplorations._activateSocketListeners(this.socket); ChatBubbles._activateSocketListeners(this.socket); ProseMirrorEditor._activateSocketListeners(this.socket); CompendiumCollection._activateSocketListeners(this.socket); RegionDocument._activateSocketListeners(this.socket); foundry.data.regionBehaviors.TeleportTokenRegionBehaviorType._activateSocketListeners(this.socket); // Apply buffered events Game.#applyBufferedSocketEvents(); // Request updated activity data game.socket.emit("getUserActivity"); } /* -------------------------------------------- */ /** * @typedef {Object} HotReloadData * @property {string} packageType The type of package which was modified * @property {string} packageId The id of the package which was modified * @property {string} content The updated stringified file content * @property {string} path The relative file path which was modified * @property {string} extension The file extension which was modified, e.g. "js", "css", "html" */ /** * Handle a hot reload request from the server * @param {HotReloadData} data The hot reload data * @private */ #handleHotReload(data) { const proceed = Hooks.call("hotReload", data); if ( proceed === false ) return; switch ( data.extension ) { case "css": return this.#hotReloadCSS(data); case "html": case "hbs": return this.#hotReloadHTML(data); case "json": return this.#hotReloadJSON(data); } } /* -------------------------------------------- */ /** * Handle hot reloading of CSS files * @param {HotReloadData} data The hot reload data */ #hotReloadCSS(data) { const links = document.querySelectorAll("link"); const link = Array.from(links).find(l => { let href = l.getAttribute("href"); if ( href.includes("?") ) { const [path, _query] = href.split("?"); href = path; } return href === data.path; }); if ( !link ) return; const href = link.getAttribute("href"); link.setAttribute("href", `${href}?${Date.now()}`); } /* -------------------------------------------- */ /** * Handle hot reloading of HTML files, such as Handlebars templates * @param {HotReloadData} data The hot reload data */ #hotReloadHTML(data) { let template; try { template = Handlebars.compile(data.content); } catch(err) { return console.error(err); } Handlebars.registerPartial(data.path, template); for ( const appV1 of Object.values(ui.windows) ) appV1.render(); for ( const appV2 of foundry.applications.instances.values() ) appV2.render(); } /* -------------------------------------------- */ /** * Handle hot reloading of JSON files, such as language files * @param {HotReloadData} data The hot reload data */ #hotReloadJSON(data) { const currentLang = game.i18n.lang; if ( data.packageId === "core" ) { if ( !data.path.endsWith(`lang/${currentLang}.json`) ) return; } else { const pkg = data.packageType === "system" ? game.system : game.modules.get(data.packageId); const lang = pkg.languages.find(l=> (l.path === data.path) && (l.lang === currentLang)); if ( !lang ) return; } // Update the translations let translations = {}; try { translations = JSON.parse(data.content); } catch(err) { return console.error(err); } foundry.utils.mergeObject(game.i18n.translations, translations); for ( const appV1 of Object.values(ui.windows) ) appV1.render(); for ( const appV2 of foundry.applications.instances.values() ) appV2.render(); } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** * Activate Event Listeners which apply to every Game View */ activateListeners() { // Disable touch zoom document.addEventListener("touchmove", ev => { if ( (ev.scale !== undefined) && (ev.scale !== 1) ) ev.preventDefault(); }, {passive: false}); // 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 || r.readOnly ) 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.clamp(parseFloat(r.value) + step, parseFloat(r.min), parseFloat(r.max)); // Dispatch input and change events r.dispatchEvent(new Event("input", {bubbles: true})); r.dispatchEvent(new Event("change", {bubbles: true})); } /* -------------------------------------------- */ /** * 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) { for ( const appV1 of Object.values(ui.windows) ) { appV1.setPosition({top: appV1.position.top, left: appV1.position.left}); } for ( const appV2 of foundry.applications.instances.values() ) appV2.setPosition(); 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(); // ProseMirror menu listeners ProseMirror.ProseMirrorMenu.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); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ get template() { foundry.utils.logCompatibilityWarning("Game#template is deprecated and will be removed in Version 14. " + "Use cases for Game#template should be refactored to instead use System#documentTypes or Game#model", {since: 12, until: 14, once: true}); return this.#template; } #template; } /** * A specialized subclass of the ClientDocumentMixin which is used for document types that are intended to be * represented upon the game Canvas. * @category - Mixins * @param {typeof abstract.Document} Base The base document class mixed with client and canvas features * @returns {typeof CanvasDocument} The mixed CanvasDocument class definition */ function CanvasDocumentMixin(Base) { return class CanvasDocument extends ClientDocumentMixin(Base) { /* -------------------------------------------- */ /* Properties */ /* -------------------------------------------- */ /** * A lazily constructed PlaceableObject instance which can represent this Document on the game canvas. * @type {PlaceableObject|null} */ get object() { if ( this._object || this._destroyed ) return this._object; if ( !this.parent?.isView || !this.layer ) return null; return this._object = this.layer.createObject(this); } /** * @type {PlaceableObject|null} * @private */ _object = this._object ?? null; /** * Has this object been deliberately destroyed as part of the deletion workflow? * @type {boolean} * @private */ _destroyed = false; /* -------------------------------------------- */ /** * A reference to the CanvasLayer which contains Document objects of this type. * @type {PlaceablesLayer} */ get layer() { return canvas.getLayerByEmbeddedName(this.documentName); } /* -------------------------------------------- */ /** * An indicator for whether this document is currently rendered on the game canvas. * @type {boolean} */ get rendered() { return this._object && !this._object.destroyed; } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritdoc */ async _preCreate(data, options, user) { const allowed = await super._preCreate(data, options, user); if ( allowed === false ) return false; if ( !this.schema.has("sort") || ("sort" in data) ) return; let sort = 0; for ( const document of this.collection ) sort = Math.max(sort, document.sort + 1); this.updateSource({sort}); } /* -------------------------------------------- */ /** @inheritDoc */ _onCreate(data, options, userId) { super._onCreate(data, options, userId); const object = this.object; if ( !object ) return; this.layer.objects.addChild(object); object.draw().then(() => { object?._onCreate(data, options, userId); }); } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { super._onUpdate(changed, options, userId); this._object?._onUpdate(changed, options, userId); } /* -------------------------------------------- */ /** @inheritDoc */ _onDelete(options, userId) { super._onDelete(options, userId); this._object?._onDelete(options, userId); } }; } /** * 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. * @param {typeof abstract.Document} Base The base Document class to be mixed * @returns {typeof ClientDocument} The mixed client-side document class definition * @category - Mixins * @mixin */ function ClientDocumentMixin(Base) { /** * The ClientDocument extends the base Document class by adding client-specific behaviors to all Document types. * @extends {abstract.Document} */ return class ClientDocument 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 {Record} * @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.parentCollection]; 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 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() { return game.users.some(u => !u.isGM && this.testUserPermission(u, "OWNER")); } /* ---------------------------------------- */ /** * 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 {Application|ApplicationV2|null} * @memberof ClientDocumentMixin# */ get sheet() { if ( !this._sheet ) { const cls = this._getSheetClass(); // Application V1 Document Sheets if ( foundry.utils.isSubclass(cls, Application) ) { this._sheet = new cls(this, {editable: this.isOwner}); } // Application V2 Document Sheets else if ( foundry.utils.isSubclass(cls, foundry.applications.api.DocumentSheetV2) ) { this._sheet = new cls({document: this}); } // No valid sheet class else this._sheet = null; } return this._sheet; } /* -------------------------------------------- */ /** * 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") ?? null; if ( (override !== null) && (override in sheets) ) return sheets[override].cls; // Default sheet selection for the type const classes = Object.values(sheets); if ( !classes.length ) return BaseSheet; 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() { const isTypeData = this.system instanceof foundry.abstract.TypeDataModel; if ( isTypeData ) this.system.prepareBaseData(); this.prepareBaseData(); this.prepareEmbeddedDocuments(); if ( isTypeData ) this.system.prepareDerivedData(); 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() { for ( const collectionName of Object.keys(this.constructor.hierarchy || {}) ) { for ( let e of this.getEmbeddedCollection(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 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, foundry.utils.deepClone(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} relative The document to compare against. */ getRelativeUUID(relative) { // The Documents are in two different compendia. if ( this.compendium && (this.compendium !== relative.compendium) ) return this.uuid; // This Document is a sibling of the relative Document. if ( this.isEmbedded && (this.collection === relative.collection) ) return `.${this.id}`; // This Document may be a descendant of the relative Document, so walk up the hierarchy to check. const parts = [this.documentName, this.id]; let parent = this.parent; while ( parent ) { if ( parent === relative ) break; parts.unshift(parent.documentName, parent.id); parent = parent.parent; } // The relative Document was a parent or grandparent of this one. if ( parent === relative ) return `.${parts.join(".")}`; // The relative Document was unrelated to this one. 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); } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ async _preCreate(data, options, user) { const allowed = await super._preCreate(data, options, user); if ( allowed === false ) return false; // Forward to type data model if ( this.system instanceof foundry.abstract.TypeDataModel ) { return this.system._preCreate(data, options, user); } } /* -------------------------------------------- */ /** @inheritDoc */ _onCreate(data, options, userId) { super._onCreate(data, options, userId); // Render the sheet for this application if ( options.renderSheet && (userId === game.user.id) && this.sheet ) { const options = { renderContext: `create${this.documentName}`, renderData: data }; /** @deprecated since v12 */ Object.defineProperties(options, { action: { get() { foundry.utils.logCompatibilityWarning("The render options 'action' property is deprecated. " + "Please use 'renderContext' instead.", { since: 12, until: 14 }); return "create"; } }, data: { get() { foundry.utils.logCompatibilityWarning("The render options 'data' property is deprecated. " + "Please use 'renderData' instead.", { since: 12, until: 14 }); return data; } } }); this.sheet.render(true, options); } // Update Compendium and global indices if ( this.pack && !this.isEmbedded ) { if ( this instanceof Folder ) this.compendium.folders.set(this.id, this); else this.compendium.indexDocument(this); } if ( this.constructor.metadata.indexed ) game.documentIndex.addDocument(this); // Update support metadata game.issues._countDocumentSubType(this.constructor, this._source); // Forward to type data model if ( this.system instanceof foundry.abstract.TypeDataModel ) { this.system._onCreate(data, options, userId); } } /* -------------------------------------------- */ /** @inheritDoc */ async _preUpdate(changes, options, user) { const allowed = await super._preUpdate(changes, options, user); if ( allowed === false ) return false; // Forward to type data model if ( this.system instanceof foundry.abstract.TypeDataModel ) { return this.system._preUpdate(changes, options, user); } } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { super._onUpdate(changed, options, userId); // Clear cached sheet if a new sheet is chosen, or the Document's sub-type changes. const sheetChanged = ("type" in changed) || ("sheetClass" in (changed.flags?.core || {})); if ( !options.preview && sheetChanged ) this._onSheetChange(); // Otherwise re-render associated applications. else if ( options.render !== false ) { const options = { renderContext: `update${this.documentName}`, renderData: changed }; /** @deprecated since v12 */ Object.defineProperties(options, { action: { get() { foundry.utils.logCompatibilityWarning("The render options 'action' property is deprecated. " + "Please use 'renderContext' instead.", { since: 12, until: 14 }); return "update"; } }, data: { get() { foundry.utils.logCompatibilityWarning("The render options 'data' property is deprecated. " + "Please use 'renderData' instead.", { since: 12, until: 14 }); return changed; } } }); this.render(false, options); } // Update Compendium and global indices if ( this.pack && !this.isEmbedded ) { if ( this instanceof Folder ) this.compendium.folders.set(this.id, this); else this.compendium.indexDocument(this); } if ( "name" in changed ) game.documentIndex.replaceDocument(this); // Forward to type data model if ( this.system instanceof foundry.abstract.TypeDataModel ) { this.system._onUpdate(changed, options, userId); } } /* -------------------------------------------- */ /** @inheritDoc */ async _preDelete(options, user) { const allowed = await super._preDelete(options, user); if ( allowed === false ) return false; // Forward to type data model if ( this.system instanceof foundry.abstract.TypeDataModel ) { return this.system._preDelete(options, user); } } /* -------------------------------------------- */ /** @inheritDoc */ _onDelete(options, userId) { super._onDelete(options, userId); // Close open Applications for this Document const renderOptions = { submit: false, renderContext: `delete${this.documentName}`, renderData: this }; /** @deprecated since v12 */ Object.defineProperties(renderOptions, { action: { get() { foundry.utils.logCompatibilityWarning("The render options 'action' property is deprecated. " + "Please use 'renderContext' instead.", {since: 12, until: 14}); return "delete"; } }, data: { get() { foundry.utils.logCompatibilityWarning("The render options 'data' property is deprecated. " + "Please use 'renderData' instead.", {since: 12, until: 14}); return this; } } }); Object.values(this.apps).forEach(a => a.close(renderOptions)); // Update Compendium and global indices if ( this.pack && !this.isEmbedded ) { if ( this instanceof Folder ) this.compendium.folders.delete(this.id); else this.compendium.index.delete(this.id); } game.documentIndex.removeDocument(this); // Update support metadata game.issues._countDocumentSubType(this.constructor, this._source, {decrement: true}); // Forward to type data model if ( this.system instanceof foundry.abstract.TypeDataModel ) { this.system._onDelete(options, userId); } } /* -------------------------------------------- */ /* Descendant Document Events */ /* -------------------------------------------- */ /** * Orchestrate dispatching descendant document events to parent documents when embedded children are modified. * @param {string} event The event name, preCreate, onCreate, etc... * @param {string} collection The collection name being modified within this parent document * @param {Array<*>} args Arguments passed to each dispatched function * @param {ClientDocument} [_parent] The document with directly modified embedded documents. * Either this document or a descendant of this one. * @internal */ _dispatchDescendantDocumentEvents(event, collection, args, _parent) { _parent ||= this; // Dispatch the event to this Document const fn = this[`_${event}DescendantDocuments`]; if ( !(fn instanceof Function) ) throw new Error(`Invalid descendant document event "${event}"`); fn.call(this, _parent, collection, ...args); // Dispatch the legacy "EmbeddedDocuments" event to the immediate parent only if ( _parent === this ) { /** @deprecated since v11 */ const legacyFn = `_${event}EmbeddedDocuments`; const isOverridden = foundry.utils.getDefiningClass(this, legacyFn)?.name !== "ClientDocumentMixin"; if ( isOverridden && (this[legacyFn] instanceof Function) ) { const documentName = this.constructor.hierarchy[collection].model.documentName; const warning = `The ${this.documentName} class defines the _${event}EmbeddedDocuments method which is ` + `deprecated in favor of a new _${event}DescendantDocuments method.`; foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13}); this[legacyFn](documentName, ...args); } } // Bubble the event to the parent Document /** @type ClientDocument */ const parent = this.parent; if ( !parent ) return; parent._dispatchDescendantDocumentEvents(event, collection, args, _parent); } /* -------------------------------------------- */ /** * Actions taken after descendant documents have been created, but before changes are applied to the client data. * @param {Document} parent The direct parent of the created Documents, may be this Document or a child * @param {string} collection The collection within which documents are being created * @param {object[]} data The source data for new documents that are being created * @param {object} options Options which modified the creation operation * @param {string} userId The ID of the User who triggered the operation * @protected */ _preCreateDescendantDocuments(parent, collection, data, options, userId) {} /* -------------------------------------------- */ /** * Actions taken after descendant documents have been created and changes have been applied to client data. * @param {Document} parent The direct parent of the created Documents, may be this Document or a child * @param {string} collection The collection within which documents were created * @param {Document[]} documents The array of created Documents * @param {object[]} data The source data for new documents that were created * @param {object} options Options which modified the creation operation * @param {string} userId The ID of the User who triggered the operation * @protected */ _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) { if ( options.render === false ) return; this.render(false, {renderContext: `create${collection}`, renderData: data}); } /* -------------------------------------------- */ /** * Actions taken after descendant documents have been updated, but before changes are applied to the client data. * @param {Document} parent The direct parent of the updated Documents, may be this Document or a child * @param {string} collection The collection within which documents are being updated * @param {object[]} changes The array of differential Document updates to be applied * @param {object} options Options which modified the update operation * @param {string} userId The ID of the User who triggered the operation * @protected */ _preUpdateDescendantDocuments(parent, collection, changes, options, userId) {} /* -------------------------------------------- */ /** * Actions taken after descendant documents have been updated and changes have been applied to client data. * @param {Document} parent The direct parent of the updated Documents, may be this Document or a child * @param {string} collection The collection within which documents were updated * @param {Document[]} documents The array of updated Documents * @param {object[]} changes The array of differential Document updates which were applied * @param {object} options Options which modified the update operation * @param {string} userId The ID of the User who triggered the operation * @protected */ _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) { if ( options.render === false ) return; this.render(false, {renderContext: `update${collection}`, renderData: changes}); } /* -------------------------------------------- */ /** * Actions taken after descendant documents have been deleted, but before deletions are applied to the client data. * @param {Document} parent The direct parent of the deleted Documents, may be this Document or a child * @param {string} collection The collection within which documents were deleted * @param {string[]} ids The array of document IDs which were deleted * @param {object} options Options which modified the deletion operation * @param {string} userId The ID of the User who triggered the operation * @protected */ _preDeleteDescendantDocuments(parent, collection, ids, options, userId) {} /* -------------------------------------------- */ /** * Actions taken after descendant documents have been deleted and those deletions have been applied to client data. * @param {Document} parent The direct parent of the deleted Documents, may be this Document or a child * @param {string} collection The collection within which documents were deleted * @param {Document[]} documents The array of Documents which were deleted * @param {string[]} ids The array of document IDs which were deleted * @param {object} options Options which modified the deletion operation * @param {string} userId The ID of the User who triggered the operation * @protected */ _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) { if ( options.render === false ) return; this.render(false, {renderContext: `delete${collection}`, renderData: ids}); } /* -------------------------------------------- */ /** * Whenever the Document's sheet changes, close any existing applications for this Document, and re-render the new * sheet if one was already open. * @param {object} [options] * @param {boolean} [options.sheetOpen] Whether the sheet was originally open and needs to be re-opened. * @internal */ async _onSheetChange({ sheetOpen }={}) { sheetOpen ??= this.sheet.rendered; await Promise.all(Object.values(this.apps).map(app => app.close())); this._sheet = null; if ( sheetOpen ) this.sheet.render(true); // Re-draw the parent sheet in case of a dependency on the child sheet. this.parent?.sheet?.render(false); } /* -------------------------------------------- */ /** * Gets the default new name for a Document * @param {object} context The context for which to create the Document name. * @param {string} [context.type] The sub-type of the document * @param {Document|null} [context.parent] A parent document within which the created Document should belong * @param {string|null} [context.pack] A compendium pack within which the Document should be created * @returns {string} */ static defaultName({type, parent, pack}={}) { const documentName = this.metadata.name; let collection; if ( parent ) collection = parent.getEmbeddedCollection(documentName); else if ( pack ) collection = game.packs.get(pack); else collection = game.collections.get(documentName); const takenNames = new Set(); for ( const document of collection ) takenNames.add(document.name); let baseNameKey = this.metadata.label; if ( type && this.hasTypeData ) { const typeNameKey = CONFIG[documentName].typeLabels?.[type]; if ( typeNameKey && game.i18n.has(typeNameKey) ) baseNameKey = typeNameKey; } const baseName = game.i18n.localize(baseNameKey); let name = baseName; let index = 1; while ( takenNames.has(name) ) name = `${baseName} (${++index})`; return name; } /* -------------------------------------------- */ /* 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 * @param {Document|null} [context.parent] A parent document within which the created Document should belong * @param {string|null} [context.pack] A compendium pack within which the Document should be created * @param {string[]} [context.types] A restriction the selectable sub-types of the Dialog. * @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, types, ...options}={}) { const cls = this.implementation; // Identify allowed types let documentTypes = []; let defaultType = CONFIG[this.documentName]?.defaultType; let defaultTypeAllowed = false; let hasTypes = false; if ( this.TYPES.length > 1 ) { if ( types?.length === 0 ) throw new Error("The array of sub-types to restrict to must not be empty"); // Register supported types for ( const type of this.TYPES ) { if ( type === CONST.BASE_DOCUMENT_TYPE ) continue; if ( types && !types.includes(type) ) continue; let label = CONFIG[this.documentName]?.typeLabels?.[type]; label = label && game.i18n.has(label) ? game.i18n.localize(label) : type; documentTypes.push({value: type, label}); if ( type === defaultType ) defaultTypeAllowed = true; } if ( !documentTypes.length ) throw new Error("No document types were permitted to be created"); if ( !defaultTypeAllowed ) defaultType = documentTypes[0].value; // Sort alphabetically documentTypes.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang)); hasTypes = true; } // Identify destination collection let collection; if ( !parent ) { if ( pack ) collection = game.packs.get(pack); else collection = game.collections.get(this.documentName); } // Collect data const folders = collection?._formatFolderSelectOptions() ?? []; const label = game.i18n.localize(this.metadata.label); const title = game.i18n.format("DOCUMENT.Create", {type: label}); const type = data.type || defaultType; // Render the document creation form const html = await renderTemplate("templates/sidebar/document-create.html", { folders, name: data.name || "", defaultName: cls.defaultName({type, parent, pack}), folder: data.folder, hasFolders: folders.length >= 1, hasTypes, type, types: documentTypes }); // Render the confirmation dialog window return Dialog.prompt({ title, content: html, label: title, render: html => { if ( !hasTypes ) return; html[0].querySelector('[name="type"]').addEventListener("change", e => { const nameInput = html[0].querySelector('[name="name"]'); nameInput.placeholder = cls.defaultName({type: e.target.value, parent, pack}); }); }, 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 ( !data.name?.trim() ) data.name = cls.defaultName({type: data.type, parent, pack}); return cls.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 * @returns {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(), options: options }); } /* -------------------------------------------- */ /** * Export document data to a JSON file which can be saved by the client and later imported into a different session. * Only world Documents may be exported. * @param {object} [options] Additional options passed to the {@link ClientDocumentMixin#toCompendium} method * @memberof ClientDocumentMixin# */ exportToJSON(options) { if ( !CONST.WORLD_DOCUMENT_TYPES.includes(this.documentName) ) { throw new Error("Only world Documents may be exported"); } 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(), this.id].filterJoin("-"); saveDataToFile(JSON.stringify(data, null, 2), "text/json", `${filename}.json`); } /* -------------------------------------------- */ /** * 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; // 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._stats?.compendiumSource ) { document.updateSource({"_stats.compendiumSource": document.uuid}); } return document; } /* -------------------------------------------- */ /** * Create the Document from the given source with migration applied to it. * Only primary Documents may be imported. * * This function must be used to create a document from data that predates the current core version. * It must be given nonpartial data matching the schema it had in the core version it is coming from. * It applies legacy migrations to the source data before calling {@link Document.fromSource}. * If this function is not used to import old data, necessary migrations may not applied to the data * resulting in an incorrectly imported document. * * The core version is recorded in the `_stats` field, which all primary documents have. If the given source data * doesn't contain a `_stats` field, the data is assumed to be pre-V10, when the `_stats` field didn't exist yet. * The `_stats` field must not be stripped from the data before it is exported! * @param {object} source The document data that is imported. * @param {DocumentConstructionContext & DataValidationOptions} [context] * The model construction context passed to {@link Document.fromSource}. * @param {boolean} [context.strict=true] Strict validation is enabled by default. * @returns {Promise} */ static async fromImport(source, context) { if ( !CONST.PRIMARY_DOCUMENT_TYPES.includes(this.documentName) ) { throw new Error("Only primary Documents may be imported"); } const coreVersion = source._stats?.coreVersion; if ( coreVersion && foundry.utils.isNewerVersion(coreVersion, game.version) ) { throw new Error("Documents from a core version newer than the running version cannot be imported"); } if ( coreVersion !== game.version ) { const response = await new Promise(resolve => { game.socket.emit("migrateDocumentData", this.documentName, source, resolve); }); if ( response.error ) throw new Error(response.error); source = response.source; } return this.fromSource(source, {strict: true, ...context}); } /* -------------------------------------------- */ /** * Update this Document using a provided JSON string. * Only world Documents may be imported. * @this {ClientDocument} * @param {string} json Raw JSON data to import * @returns {Promise} The updated Document instance */ async importFromJSON(json) { if ( !CONST.WORLD_DOCUMENT_TYPES.includes(this.documentName) ) { throw new Error("Only world Documents may be imported"); } // Create a document from the JSON data const parsedJSON = JSON.parse(json); const doc = await this.constructor.fromImport(parsedJSON); // Treat JSON import using the same workflows that are used when importing from a compendium pack const data = this.collection.fromCompendium(doc); // 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.clearSource=true] Clear any prior source information * @param {boolean} [options.clearSort=true] Clear the currently assigned sort order * @param {boolean} [options.clearFolder=false] Clear the currently assigned folder * @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, clearFolder=false, clearFlags=false, clearSource=true, clearOwnership=true, clearState=true, keepId=false} = {}) { const data = this.toObject(); if ( !keepId ) delete data._id; if ( clearSort ) delete data.sort; if ( clearFolder ) delete data.folder; if ( clearFlags ) delete data.flags; if ( clearSource ) { delete data._stats?.compendiumSource; delete data._stats?.duplicateSource; } if ( clearOwnership ) delete data.ownership; if ( clearState ) delete data.active; return data; } /* -------------------------------------------- */ /* Enrichment */ /* -------------------------------------------- */ /** * Create a content link for this Document. * @param {Partial} [options] Additional options to configure how the link is constructed. * @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; if ( !classes.includes("content-link") ) classes.unshift("content-link"); attrs = foundry.utils.mergeObject({ draggable: "true" }, attrs); dataset = foundry.utils.mergeObject({ link: "", 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 ? game.i18n.format("DOCUMENT.TypePageFormat", {type: typeName, page: documentName}) : documentName; anchorIcon = icon ?? documentConfig.typeIcons?.[this.type] ?? documentConfig.sidebarIcon; } name ??= this.name; return TextEditor.createAnchor({ attrs, dataset, name, classes, icon: anchorIcon }); } /* -------------------------------------------- */ /** * Convert a Document to some HTML display for embedding purposes. * @param {DocumentHTMLEmbedConfig} config Configuration for embedding behavior. * @param {EnrichmentOptions} [options] The original enrichment options for cases where the Document embed * content also contains text that must be enriched. * @returns {Promise} A representation of the Document as HTML content, or null if such a * representation could not be generated. */ async toEmbed(config, options={}) { const content = await this._buildEmbedHTML(config, options); if ( !content ) return null; let embed; if ( config.inline ) embed = await this._createInlineEmbed(content, config, options); else embed = await this._createFigureEmbed(content, config, options); if ( embed ) { embed.classList.add("content-embed"); embed.dataset.uuid = this.uuid; embed.dataset.contentEmbed = ""; if ( config.classes ) embed.classList.add(...config.classes.split(" ")); } return embed; } /* -------------------------------------------- */ /** * A method that can be overridden by subclasses to customize embedded HTML generation. * @param {DocumentHTMLEmbedConfig} config Configuration for embedding behavior. * @param {EnrichmentOptions} [options] The original enrichment options for cases where the Document embed * content also contains text that must be enriched. * @returns {Promise} Either a single root element to append, or a collection of * elements that comprise the embedded content. * @protected */ async _buildEmbedHTML(config, options={}) { return this.system instanceof foundry.abstract.TypeDataModel ? this.system.toEmbed(config, options) : null; } /* -------------------------------------------- */ /** * A method that can be overridden by subclasses to customize inline embedded HTML generation. * @param {HTMLElement|HTMLCollection} content The embedded content. * @param {DocumentHTMLEmbedConfig} config Configuration for embedding behavior. * @param {EnrichmentOptions} [options] The original enrichment options for cases where the Document embed * content also contains text that must be enriched. * @returns {Promise} * @protected */ async _createInlineEmbed(content, config, options) { const section = document.createElement("section"); if ( content instanceof HTMLCollection ) section.append(...content); else section.append(content); return section; } /* -------------------------------------------- */ /** * A method that can be overridden by subclasses to customize the generation of the embed figure. * @param {HTMLElement|HTMLCollection} content The embedded content. * @param {DocumentHTMLEmbedConfig} config Configuration for embedding behavior. * @param {EnrichmentOptions} [options] The original enrichment options for cases where the Document embed * content also contains text that must be enriched. * @returns {Promise} * @protected */ async _createFigureEmbed(content, { cite, caption, captionPosition="bottom", label }, options) { const figure = document.createElement("figure"); if ( content instanceof HTMLCollection ) figure.append(...content); else figure.append(content); if ( cite || caption ) { const figcaption = document.createElement("figcaption"); if ( caption ) figcaption.innerHTML += `${label || this.name}`; if ( cite ) figcaption.innerHTML += `${this.toAnchor().outerHTML}`; figure.insertAdjacentElement(captionPosition === "bottom" ? "beforeend" : "afterbegin", figcaption); if ( captionPosition === "top" ) figure.append(figcaption.querySelector(":scope > cite")); } return figure; } /* -------------------------------------------- */ /* Deprecations */ /* -------------------------------------------- */ /** * The following are stubs to prevent errors where existing classes may be attempting to call them via super. */ /** * @deprecated since v11 * @ignore */ _preCreateEmbeddedDocuments() {} /** * @deprecated since v11 * @ignore */ _preUpdateEmbeddedDocuments() {} /** * @deprecated since v11 * @ignore */ _preDeleteEmbeddedDocuments() {} /** * @deprecated since v11 * @ignore */ _onCreateEmbeddedDocuments() {} /** * @deprecated since v11 * @ignore */ _onUpdateEmbeddedDocuments() {} /** * @deprecated since v11 * @ignore */ _onDeleteEmbeddedDocuments() {} }; } /** * A mixin which adds directory functionality to a DocumentCollection, such as folders, tree structures, and sorting. * @param {typeof Collection} BaseCollection The base collection class to extend * @returns {typeof DirectoryCollection} A Collection mixed with DirectoryCollection functionality * @category - Mixins * @mixin */ function DirectoryCollectionMixin(BaseCollection) { /** * An extension of the Collection class which adds behaviors specific to tree-based collections of entries and folders. * @extends {Collection} */ return class DirectoryCollection extends BaseCollection { /** * Reference the set of Folders which contain documents in this collection * @type {Collection} */ get folders() { throw new Error("You must implement the folders getter for this DirectoryCollection"); } /* -------------------------------------------- */ /** * The built tree structure of the DocumentCollection * @type {object} */ get tree() { if ( !this.#tree ) this.initializeTree(); return this.#tree; } /** * The built tree structure of the DocumentCollection. Lazy initialized. * @type {object} */ #tree; /* -------------------------------------------- */ /** * The current search mode for this collection * @type {string} */ get searchMode() { const searchModes = game.settings.get("core", "collectionSearchModes"); return searchModes[this.collection ?? this.name] || CONST.DIRECTORY_SEARCH_MODES.NAME; } /** * Toggle the search mode for this collection between "name" and "full" text search */ toggleSearchMode() { const name = this.collection ?? this.name; const searchModes = game.settings.get("core", "collectionSearchModes"); searchModes[name] = searchModes[name] === CONST.DIRECTORY_SEARCH_MODES.FULL ? CONST.DIRECTORY_SEARCH_MODES.NAME : CONST.DIRECTORY_SEARCH_MODES.FULL; game.settings.set("core", "collectionSearchModes", searchModes); } /* -------------------------------------------- */ /** * The current sort mode used to order the top level entries in this collection * @type {string} */ get sortingMode() { const sortingModes = game.settings.get("core", "collectionSortingModes"); return sortingModes[this.collection ?? this.name] || "a"; } /** * Toggle the sorting mode for this collection between "a" (Alphabetical) and "m" (Manual by sort property) */ toggleSortingMode() { const name = this.collection ?? this.name; const sortingModes = game.settings.get("core", "collectionSortingModes"); const updatedSortingMode = sortingModes[name] === "a" ? "m" : "a"; sortingModes[name] = updatedSortingMode; game.settings.set("core", "collectionSortingModes", sortingModes); this.initializeTree(); } /* -------------------------------------------- */ /** * The maximum depth of folder nesting which is allowed in this collection * @returns {number} */ get maxFolderDepth() { return CONST.FOLDER_MAX_DEPTH; } /* -------------------------------------------- */ /** * Return a reference to list of entries which are visible to the User in this tree * @returns {Array<*>} * @private */ _getVisibleTreeContents() { return this.contents; } /* -------------------------------------------- */ /** * Initialize the tree by categorizing folders and entries into a hierarchical tree structure. */ initializeTree() { const folders = this.folders.contents; const entries = this._getVisibleTreeContents(); this.#tree = this.#buildTree(folders, entries); } /* -------------------------------------------- */ /** * Given a list of Folders and a list of Entries, set up the Folder tree * @param {Folder[]} folders The Array of Folder objects to organize * @param {Object[]} entries The Array of Entries objects to organize * @returns {object} A tree structure containing the folders and entries */ #buildTree(folders, entries) { const handled = new Set(); const createNode = (root, folder, depth) => { return {root, folder, depth, visible: false, children: [], entries: []}; }; // Create the tree structure const tree = createNode(true, null, 0); const depths = [[tree]]; // Iterate by folder depth, populating content for ( let depth = 1; depth <= this.maxFolderDepth + 1; depth++ ) { const allowChildren = depth <= this.maxFolderDepth; depths[depth] = []; const nodes = depths[depth - 1]; if ( !nodes.length ) break; for ( const node of nodes ) { const folder = node.folder; if ( !node.root ) { // Ensure we don't encounter any infinite loop if ( handled.has(folder.id) ) continue; handled.add(folder.id); } // Classify content for this folder const classified = this.#classifyFolderContent(folder, folders, entries, {allowChildren}); node.entries = classified.entries; node.children = classified.folders.map(folder => createNode(false, folder, depth)); depths[depth].push(...node.children); // Update unassigned content folders = classified.unassignedFolders; entries = classified.unassignedEntries; } } // Populate left-over folders at the root level of the tree for ( const folder of folders ) { const node = createNode(false, folder, 1); const classified = this.#classifyFolderContent(folder, folders, entries, {allowChildren: false}); node.entries = classified.entries; entries = classified.unassignedEntries; depths[1].push(node); } // Populate left-over entries at the root level of the tree if ( entries.length ) { tree.entries.push(...entries); } // Sort the top level entries and folders const sort = this.sortingMode === "a" ? this.constructor._sortAlphabetical : this.constructor._sortStandard; tree.entries.sort(sort); tree.children.sort((a, b) => sort(a.folder, b.folder)); // Recursively filter visibility of the tree const filterChildren = node => { node.children = node.children.filter(child => { filterChildren(child); return child.visible; }); node.visible = node.root || game.user.isGM || ((node.children.length + node.entries.length) > 0); // Populate some attributes of the Folder document if ( node.folder ) { node.folder.displayed = node.visible; node.folder.depth = node.depth; node.folder.children = node.children; } }; filterChildren(tree); return tree; } /* -------------------------------------------- */ /** * Creates the list of Folder options in this Collection in hierarchical order * for populating the options of a select tag. * @returns {{id: string, name: string}[]} * @internal */ _formatFolderSelectOptions() { const options = []; const traverse = node => { if ( !node ) return; const folder = node.folder; if ( folder?.visible ) options.push({ id: folder.id, name: `${"─".repeat(folder.depth - 1)} ${folder.name}`.trim() }); node.children.forEach(traverse); }; traverse(this.tree); return options; } /* -------------------------------------------- */ /** * Populate a single folder with child folders and content * This method is called recursively when building the folder tree * @param {Folder|null} folder A parent folder being populated or null for the root node * @param {Folder[]} folders Remaining unassigned folders which may be children of this one * @param {Object[]} entries Remaining unassigned entries which may be children of this one * @param {object} [options={}] Options which configure population * @param {boolean} [options.allowChildren=true] Allow additional child folders */ #classifyFolderContent(folder, folders, entries, {allowChildren = true} = {}) { const sort = folder?.sorting === "a" ? this.constructor._sortAlphabetical : this.constructor._sortStandard; // Determine whether an entry belongs to a folder, via folder ID or folder reference function folderMatches(entry) { if ( entry.folder?._id ) return entry.folder._id === folder?._id; return (entry.folder === folder) || (entry.folder === folder?._id); } // Partition folders into children and unassigned folders const [unassignedFolders, subfolders] = folders.partition(f => allowChildren && folderMatches(f)); subfolders.sort(sort); // Partition entries into folder contents and unassigned entries const [unassignedEntries, contents] = entries.partition(e => folderMatches(e)); contents.sort(sort); // Return the classified content return {folders: subfolders, entries: contents, unassignedFolders, unassignedEntries}; } /* -------------------------------------------- */ /** * Sort two Entries by name, alphabetically. * @param {Object} a Some Entry * @param {Object} b Some other Entry * @returns {number} The sort order between entries a and b * @protected */ static _sortAlphabetical(a, b) { if ( a.name === undefined ) throw new Error(`Missing name property for ${a.constructor.name} ${a.id}`); if ( b.name === undefined ) throw new Error(`Missing name property for ${b.constructor.name} ${b.id}`); return a.name.localeCompare(b.name, game.i18n.lang); } /* -------------------------------------------- */ /** * Sort two Entries using their numeric sort fields. * @param {Object} a Some Entry * @param {Object} b Some other Entry * @returns {number} The sort order between Entries a and b * @protected */ static _sortStandard(a, b) { if ( a.sort === undefined ) throw new Error(`Missing sort property for ${a.constructor.name} ${a.id}`); if ( b.sort === undefined ) throw new Error(`Missing sort property for ${b.constructor.name} ${b.id}`); return a.sort - b.sort; } } } /** * 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; if ( game.issues ) game.issues._countDocumentSubType(this.documentClass, d); try { doc = this.documentClass.fromSource(d, {strict: true, dropInvalidEmbedded: true}); super.set(doc.id, doc); } catch(err) { this.invalidDocumentIds.add(d._id); if ( game.issues ) game.issues._trackValidationFailure(this, d, err); Hooks.onError(`${this.constructor.name}#_initialize`, err, { msg: `Failed to initialize ${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 {typeof foundry.abstract.Document} */ 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 */ /* -------------------------------------------- */ /** * Instantiate a Document for inclusion in the Collection. * @param {object} data The Document data. * @param {object} [context] Document creation context. * @returns {foundry.abstract.Document} */ createDocument(data, context={}) { return new this.documentClass(data, context); } /* -------------------------------------------- */ /** * 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. * @param {object} [options] Additional options to configure retrieval. * @param {boolean} [options.strict=true] Throw an Error if the requested ID is not in the set of invalid IDs for * this collection. * @returns {Document} An in-memory instance for the invalid Document * @throws If strict is true and the requested ID is not in the set of invalid IDs for this collection. */ getInvalid(id, {strict=true}={}) { if ( !this.invalidDocumentIds.has(id) ) { if ( strict ) throw new Error(`${this.constructor.documentName} id [${id}] is not in the set of invalid ids`); return; } const data = this._source.find(d => d._id === id); return this.documentClass.fromSource(foundry.utils.deepClone(data)); } /* -------------------------------------------- */ /** * Get an element from the DocumentCollection by its ID. * @param {string} id The ID of the Document to retrieve. * @param {object} [options] Additional options to configure retrieval. * @param {boolean} [options.strict=false] Throw an Error if the requested Document does not exist. * @param {boolean} [options.invalid=false] Allow retrieving an invalid Document. * @returns {foundry.abstract.Document} * @throws If strict is true and the Document cannot be found. */ get(id, {invalid=false, strict=false}={}) { let result = super.get(id); if ( !result && invalid ) result = this.getInvalid(id, { strict: false }); if ( !result && strict ) throw new Error(`${this.constructor.documentName} id [${id}] does not exist in the ` + `${this.constructor.name} collection.`); return result; } /* -------------------------------------------- */ /** @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`); } const replacement = this.has(document.id); super.set(document.id, document); if ( replacement ) this._source.findSplice(e => e._id === id, document.toObject()); else this._source.push(document.toObject()); } /* -------------------------------------------- */ /** @inheritdoc */ delete(id) { super.delete(id); const removed = this._source.findSplice(e => e._id === id); return !!removed; } /* -------------------------------------------- */ /** * Render any Applications associated with this DocumentCollection. */ render(force, options) { for (let a of this.apps) a.render(force, options); } /* -------------------------------------------- */ /** * The cache of search fields for each data model * @type {Map>} */ static #dataModelSearchFieldsCache = new Map(); /** * Get the searchable fields for a given document or index, based on its data model * @param {string} documentName The document type name * @param {string} [documentSubtype=""] The document subtype name * @param {boolean} [isEmbedded=false] Whether the document is an embedded object * @returns {Set} The dot-delimited property paths of searchable fields */ static getSearchableFields(documentName, documentSubtype="", isEmbedded=false) { const isSubtype = !!documentSubtype; const cacheName = isSubtype ? `${documentName}.${documentSubtype}` : documentName; // If this already exists in the cache, return it if ( DocumentCollection.#dataModelSearchFieldsCache.has(cacheName) ) { return DocumentCollection.#dataModelSearchFieldsCache.get(cacheName); } // Load the Document DataModel const docConfig = CONFIG[documentName]; if ( !docConfig ) throw new Error(`Could not find configuration for ${documentName}`); // Read the fields that can be searched from the Data Model const textSearchFields = new Set(isSubtype ? this.getSearchableFields(documentName) : []); const dataModel = isSubtype ? docConfig.dataModels?.[documentSubtype] : docConfig.documentClass; dataModel?.schema.apply(function() { if ( (this instanceof foundry.data.fields.StringField) && this.textSearch ) { // Non-TypeDataModel sub-types may produce an incorrect field path, in which case we prepend "system." textSearchFields.add(isSubtype && !dataModel.schema.name ? `system.${this.fieldPath}` : this.fieldPath); } }); // Cache the result DocumentCollection.#dataModelSearchFieldsCache.set(cacheName, textSearchFields); return textSearchFields; } /* -------------------------------------------- */ /** * Find all Documents which match a given search term using a full-text search against their indexed HTML fields and their name. * If filters are provided, results are filtered to only those that match the provided values. * @param {object} search An object configuring the search * @param {string} [search.query] A case-insensitive search string * @param {FieldFilter[]} [search.filters] An array of filters to apply * @param {string[]} [search.exclude] An array of document IDs to exclude from search results * @returns {string[]} */ search({query= "", filters=[], exclude=[]}) { query = SearchFilter.cleanQuery(query); const regex = new RegExp(RegExp.escape(query), "i"); const results = []; const hasFilters = !foundry.utils.isEmpty(filters); let domParser; for ( const doc of this.index ?? this.contents ) { if ( exclude.includes(doc._id) ) continue; let isMatch = !query; // Do a full-text search against any searchable fields based on metadata if ( query ) { const textSearchFields = DocumentCollection.getSearchableFields( doc.constructor.documentName ?? this.documentName, doc.type, !!doc.parentCollection); for ( const fieldName of textSearchFields ) { let value = foundry.utils.getProperty(doc, fieldName); // Search the text context of HTML instead of the HTML if ( value ) { let field; if ( fieldName.startsWith("system.") ) { if ( doc.system instanceof foundry.abstract.DataModel ) { field = doc.system.schema.getField(fieldName.slice(7)); } } else field = doc.schema.getField(fieldName); if ( field instanceof foundry.data.fields.HTMLField ) { // TODO: Ideally we would search the text content of the enriched HTML: can we make that happen somehow? domParser ??= new DOMParser(); value = domParser.parseFromString(value, "text/html").body.textContent; } } if ( value && regex.test(SearchFilter.cleanQuery(value)) ) { isMatch = true; break; // No need to evaluate other fields, we already know this is a match } } } // Apply filters if ( hasFilters ) { for ( const filter of filters ) { if ( !SearchFilter.evaluateFilter(doc, filter) ) { isMatch = false; break; // No need to evaluate other filters, we already know this is not a match } } } if ( isMatch ) results.push(doc); } return results; } /* -------------------------------------------- */ /* 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.updateDocuments * @returns {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); } /* -------------------------------------------- */ /** * Follow-up actions to take when a database operation modifies Documents in this DocumentCollection. * @param {DatabaseAction} action The database action performed * @param {ClientDocument[]} documents The array of modified Documents * @param {any[]} result The result of the database operation * @param {DatabaseOperation} operation Database operation details * @param {User} user The User who performed the operation * @internal */ _onModifyContents(action, documents, result, operation, user) { if ( operation.render ) { this.render(false, {renderContext: `${action}${this.documentName}`, renderData: result}); } } } /** * 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 DirectoryCollectionMixin(DocumentCollection) { /* -------------------------------------------- */ /* Collection Properties */ /* -------------------------------------------- */ /** * Reference the set of Folders which contain documents in this collection * @type {Collection} */ get folders() { return game.folders.reduce((collection, folder) => { if (folder.type === this.documentName) { collection.set(folder.id, folder); } return collection; }, new foundry.utils.Collection()); } /** * Return a reference to the SidebarDirectory application for this WorldCollection. * @type {DocumentDirectory} */ 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 */ /* -------------------------------------------- */ /** @override */ _getVisibleTreeContents(entry) { return this.contents.filter(c => c.visible); } /* -------------------------------------------- */ /** * 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); } /* -------------------------------------------- */ /** * @typedef {object} FromCompendiumOptions * @property {boolean} [options.clearFolder=false] Clear the currently assigned folder. * @property {boolean} [options.clearSort=true] Clear the current sort order. * @property {boolean} [options.clearOwnership=true] Clear Document ownership. * @property {boolean} [options.keepId=false] Retain the Document ID from the source Compendium. */ /** * Apply data transformations when importing a Document from a Compendium pack * @param {Document|object} document The source Document, or a plain data object * @param {FromCompendiumOptions} [options] Additional options which modify how the document is imported * @returns {object} The processed data ready for world Document creation */ fromCompendium(document, {clearFolder=false, clearSort=true, clearOwnership=true, keepId=false, ...rest}={}) { /** @deprecated since v12 */ if ( "addFlags" in rest ) { foundry.utils.logCompatibilityWarning("The addFlags option for WorldCompendium#fromCompendium has been removed. ", { since: 12, until: 14 }); } // Prepare the data structure let data = document; if (document instanceof foundry.abstract.Document) { data = document.toObject(); if ( document.pack ) foundry.utils.setProperty(data, "_stats.compendiumSource", document.uuid); } // Eliminate certain fields if ( !keepId ) delete data._id; if ( clearFolder ) delete data.folder; if ( clearSort ) 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 {Record} */ 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"; /* -------------------------------------------- */ /** * @param {Document|object} document * @param {FromCompendiumOptions} [options] * @param {boolean} [options.clearPrototypeToken=true] Clear prototype token data to allow default token settings to * be applied. * @returns {object} */ fromCompendium(document, options={}) { const data = super.fromCompendium(document, options); // Clear prototype token data. if ( (options.clearPrototypeToken !== false) && ("prototypeToken" in data) ) { const settings = game.settings.get("core", DefaultTokenConfig.SETTING) ?? {}; foundry.data.PrototypeToken.schema.apply(function(v) { if ( typeof v !== "object" ) foundry.utils.setProperty(data.prototypeToken, this.fieldPath, undefined); }, settings, { partial: true }); } // 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); } } } /** * @typedef {SocketRequest} ManageCompendiumRequest * @property {string} action The request action. * @property {PackageCompendiumData|string} data The compendium creation data, or the ID of the compendium to delete. * @property {object} [options] Additional options. */ /** * @typedef {SocketResponse} ManageCompendiumResponse * @property {ManageCompendiumRequest} request The original request. * @property {PackageCompendiumData|string} result The compendium creation data, or the collection name of the * deleted compendium. */ /** * 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 DirectoryCollectionMixin(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 subsidiary collection which contains the folders within the pack * @type {Collection} */ this.#folders = new CompendiumFolderCollection(this); /** * 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); for ( let i of metadata.index ) { i.uuid = this.getUuid(i._id); this.index.set(i._id, i); } delete metadata.index; for ( let f of metadata.folders.sort((a, b) => a.sort - b.sort) ) { this.#folders.set(f._id, new Folder.implementation(f, {pack: this.collection})); } delete metadata.folders; } /* -------------------------------------------- */ /** * 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; } /** * The banner image for this Compendium pack, or the default image for the pack type if no image is set. * @returns {string|null|void} */ get banner() { if ( this.metadata.banner === undefined ) return CONFIG[this.metadata.type]?.compendiumBanner; return this.metadata.banner; } /** * A reference to the Application class which provides an interface to interact with this compendium content. * @type {typeof Application} */ applicationClass = Compendium; /** * The set of Compendium Folders */ #folders; get folders() { return this.#folders; } /** @override */ get maxFolderDepth() { return super.maxFolderDepth - 1; } /* -------------------------------------------- */ /** * Get the Folder that this Compendium is displayed within * @returns {Folder|null} */ get folder() { return game.folders.get(this.config.folder) ?? null; } /* -------------------------------------------- */ /** * Assign this CompendiumCollection to be organized within a specific Folder. * @param {Folder|string|null} folder The desired Folder within the World or null to clear the folder * @returns {Promise} A promise which resolves once the transaction is complete */ async setFolder(folder) { const current = this.config.folder; // Clear folder if ( folder === null ) { if ( current === null ) return; return this.configure({folder: null}); } // Set folder if ( typeof folder === "string" ) folder = game.folders.get(folder); if ( !(folder instanceof Folder) ) throw new Error("You must pass a valid Folder or Folder ID."); if ( folder.type !== "Compendium" ) throw new Error(`Folder "${folder.id}" is not of the required Compendium type`); if ( folder.id === current ) return; await this.configure({folder: folder.id}); } /* -------------------------------------------- */ /** * Get the sort order for this Compendium * @returns {number} */ get sort() { return this.config.sort ?? 0; } /* -------------------------------------------- */ /** @override */ _getVisibleTreeContents() { return this.index.contents; } /** @override */ static _sortStandard(a, b) { return a.sort - b.sort; } /** * Access the compendium configuration data for this pack * @type {object} */ get config() { const setting = game.settings.get("core", "compendiumConfiguration"); const config = setting[this.collection] || {}; /** @deprecated since v11 */ if ( "private" in config ) { if ( config.private === true ) config.ownership = {PLAYER: "LIMITED", ASSISTANT: "OWNER"}; delete config.private; } return config; } /** @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"); } /** * The visibility configuration of this compendium pack. * @type {Record} */ get ownership() { return this.config.ownership ?? this.metadata.ownership ?? {...Module.schema.getField("packs.ownership").initial}; } /** * Is this Compendium pack visible to the current game User? * @type {boolean} */ get visible() { return this.getUserLevel() >= CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER; } /** * 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) { if ( document instanceof Folder ) { return this.#folders.set(id, document); } this._flush(); this.indexDocument(document); return super.set(id, document); } /* -------------------------------------------- */ /** @inheritdoc */ delete(id) { this.index.delete(id); return super.delete(id); } /* -------------------------------------------- */ /** @inheritDoc */ clear() { for ( const doc of this.values() ) { if ( !Object.values(doc.apps).some(app => app.rendered) ) super.delete(doc.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: {}, 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); const indexed = x ? foundry.utils.mergeObject(x, i) : i; indexed.uuid = this.getUuid(indexed._id); this.index.set(i._id, indexed); } // 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 * * @example Get Documents that match the given value only. * ```js * await pack.getDocuments({ type: "weapon" }); * ``` * * @example Get several Documents by their IDs. * ```js * await pack.getDocuments({ _id__in: arrayOfIds }); * ``` * * @example Get Documents by their sub-types. * ```js * await pack.getDocuments({ type__in: ["weapon", "armor"] }); * ``` */ 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; } /* -------------------------------------------- */ /** * Get the ownership level that a User has for this Compendium pack. * @param {documents.User} user The user being tested * @returns {number} The ownership level in CONST.DOCUMENT_OWNERSHIP_LEVELS */ getUserLevel(user=game.user) { const levels = CONST.DOCUMENT_OWNERSHIP_LEVELS; let level = levels.NONE; for ( const [role, l] of Object.entries(this.ownership) ) { if ( user.hasRole(role) ) level = Math.max(level, levels[l]); } return level; } /* -------------------------------------------- */ /** * Test whether a certain User has a requested permission level (or greater) over the Compendium pack * @param {documents.BaseUser} user The User being tested * @param {string|number} permission The permission level from DOCUMENT_OWNERSHIP_LEVELS to test * @param {object} options Additional options involved in the permission test * @param {boolean} [options.exact=false] Require the exact permission level requested? * @returns {boolean} Does the user have this permission level over the Compendium pack? */ testUserPermission(user, permission, {exact=false}={}) { const perms = CONST.DOCUMENT_OWNERSHIP_LEVELS; const level = user.isGM ? perms.OWNER : this.getUserLevel(user); const target = (typeof permission === "string") ? (perms[permission] ?? perms.OWNER) : permission; return exact ? level === target : level >= target; } /* -------------------------------------------- */ /** * 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 */ async importDocument(document, options={}) { if ( !(document instanceof this.documentClass) && !(document instanceof Folder) ) { 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 document.constructor.create(data, {pack: this.collection}); } /* -------------------------------------------- */ /** * Import a Folder into this Compendium Collection. * @param {Folder} folder The existing Folder you wish to import * @param {object} [options] Additional options which modify how the data is imported. * @param {boolean} [options.importParents=true] Import any parent folders which are not already present in the Compendium * @returns {Promise} */ async importFolder(folder, {importParents=true, ...options}={}) { if ( !(folder instanceof Folder) ) { const err = Error(`You may not import a ${folder.constructor.name} Document into the folders collection of the ${this.collection} Compendium.`); ui.notifications.error(err.message); throw err; } const toCreate = [folder]; if ( importParents ) toCreate.push(...folder.getParentFolders().filter(f => !this.folders.has(f.id))); await Folder.createDocuments(toCreate, {pack: this.collection, keepId: true}); } /* -------------------------------------------- */ /** * Import an array of Folders into this Compendium Collection. * @param {Folder[]} folders The existing Folders you wish to import * @param {object} [options] Additional options which modify how the data is imported. * @param {boolean} [options.importParents=true] Import any parent folders which are not already present in the Compendium * @returns {Promise} */ async importFolders(folders, {importParents=true, ...options}={}) { if ( folders.some(f => !(f instanceof Folder)) ) { const err = Error(`You can only import Folder documents into the folders collection of the ${this.collection} Compendium.`); ui.notifications.error(err.message); throw err; } const toCreate = new Set(folders); if ( importParents ) { for ( const f of folders ) { for ( const p of f.getParentFolders() ) { if ( !this.folders.has(p.id) ) toCreate.add(p); } } } await Folder.createDocuments(Array.from(toCreate), {pack: this.collection, keepId: true}); } /* -------------------------------------------- */ /** * 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 parentFolder; // Optionally, create a top level folder if ( CONST.FOLDER_DOCUMENT_TYPES.includes(this.documentName) ) { // Re-use an existing folder if ( folderId ) parentFolder = game.folders.get(folderId, {strict: true}); // Create a new Folder if ( !parentFolder ) { parentFolder = await Folder.create({ name: folderName || this.title, type: this.documentName, parent: null, color: this.folder?.color ?? null }); } } // Load all content const folders = this.folders; const documents = await this.getDocuments(); ui.notifications.info(game.i18n.format("COMPENDIUM.ImportAllStart", { number: documents.length, folderNumber: folders.size, type: game.i18n.localize(this.documentClass.metadata.label), folder: parentFolder.name })); // Create any missing Folders const folderCreateData = folders.map(f => { if ( game.folders.has(f.id) ) return null; const data = f.toObject(); // If this folder has no parent folder, assign it to the new folder if ( !data.folder ) data.folder = parentFolder.id; return data; }).filter(f => f); await Folder.createDocuments(folderCreateData, {keepId: true}); // Prepare import data const collection = game.collections.get(this.documentName); const createData = documents.map(doc => { const data = collection.fromCompendium(doc, options); // If this document has no folder, assign it to the new folder if ( !data.folder) data.folder = parentFolder.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 collection = CONFIG[this.documentName]?.collection?.instance; const html = await renderTemplate("templates/sidebar/apps/compendium-import.html", { folderName: this.title, keepId: options.keepId ?? false, folders: collection?._formatFolderSelectOptions() ?? [] }); // Present the Dialog options.jQuery = false; return Dialog.confirm({ title: `${game.i18n.localize("COMPENDIUM.ImportAll")}: ${this.title}`, content: html, render: html => { const form = html.querySelector("form"); form.elements.folder.addEventListener("change", event => { form.elements.folderName.disabled = !!event.currentTarget.value; }, { passive: true }); }, yes: html => { const form = html.querySelector("form"); return this.importAll({ folderId: form.elements.folder.value, 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; }, {}); } index.img = data.thumb ?? data.img; index._id = data._id; index.uuid = document.uuid; this.index.set(document.id, index); } /* -------------------------------------------- */ /** * Prompt the gamemaster with a dialog to configure ownership of this Compendium pack. * @returns {Promise>} The configured ownership for the pack */ async configureOwnershipDialog() { if ( !game.user.isGM ) throw new Error("You do not have permission to configure ownership for this Compendium pack"); const current = this.ownership; const levels = { "": game.i18n.localize("COMPENDIUM.OwnershipInheritBelow"), NONE: game.i18n.localize("OWNERSHIP.NONE"), LIMITED: game.i18n.localize("OWNERSHIP.LIMITED"), OBSERVER: game.i18n.localize("OWNERSHIP.OBSERVER"), OWNER: game.i18n.localize("OWNERSHIP.OWNER") }; const roles = { ASSISTANT: {label: "USER.RoleAssistant", value: current.ASSISTANT, levels: { ...levels }}, TRUSTED: {label: "USER.RoleTrusted", value: current.TRUSTED, levels: { ...levels }}, PLAYER: {label: "USER.RolePlayer", value: current.PLAYER, levels: { ...levels }} }; delete roles.PLAYER.levels[""]; await Dialog.wait({ title: `${game.i18n.localize("OWNERSHIP.Title")}: ${this.metadata.label}`, content: await renderTemplate("templates/sidebar/apps/compendium-ownership.hbs", {roles}), default: "ok", close: () => null, buttons: { reset: { label: game.i18n.localize("COMPENDIUM.OwnershipReset"), icon: '', callback: () => this.configure({ ownership: undefined }) }, ok: { label: game.i18n.localize("OWNERSHIP.Configure"), icon: '', callback: async html => { const fd = new FormDataExtended(html.querySelector("form.compendium-ownership-dialog")); let ownership = Object.entries(fd.object).reduce((obj, [r, l]) => { if ( l ) obj[r] = l; return obj; }, {}); ownership.GAMEMASTER = "OWNER"; await this.configure({ownership}); } } } }, { jQuery: false }); return this.ownership; } /* -------------------------------------------- */ /* Compendium Management */ /* -------------------------------------------- */ /** * Activate the Socket event listeners used to receive responses to compendium management events. * @param {Socket} socket The active game socket. * @internal */ static _activateSocketListeners(socket) { socket.on("manageCompendium", response => { const { request } = response; switch ( request.action ) { case "create": CompendiumCollection.#handleCreateCompendium(response); break; case "delete": CompendiumCollection.#handleDeleteCompendium(response); break; default: throw new Error(`Invalid Compendium modification action ${request.action} provided.`); } }); } /** * 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 }); return this.#handleCreateCompendium(response); } /* -------------------------------------------- */ /** * Generate a UUID for a given primary document ID within this Compendium pack * @param {string} id The document ID to generate a UUID for * @returns {string} The generated UUID, in the form of "Compendium..." */ getUuid(id) { return `Compendium.${this.collection}.${this.documentName}.${id}`; } /* ----------------------------------------- */ /** * Assign configuration metadata settings to the compendium pack * @param {object} configuration The object of compendium settings to define * @returns {Promise} A Promise which resolves once the setting is updated */ configure(configuration={}) { const settings = game.settings.get("core", "compendiumConfiguration"); const config = this.config; for ( const [k, v] of Object.entries(configuration) ) { if ( v === undefined ) delete config[k]; else config[k] = v; } settings[this.collection] = config; return game.settings.set("core", this.constructor.CONFIG_SETTING, settings); } /* ----------------------------------------- */ /** * 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.#assertUserCanManage(); this.apps.forEach(app => app.close()); const response = await SocketInterface.dispatch("manageCompendium", { action: "delete", data: this.metadata.name }); return CompendiumCollection.#handleDeleteCompendium(response); } /* ----------------------------------------- */ /** * Duplicate a compendium pack to the current World. * @param {string} label A new Compendium label * @returns {Promise} */ async duplicateCompendium({label}={}) { this.#assertUserCanManage({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 */ #assertUserCanManage({requireUnlocked=true}={}) { const config = this.config; 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.#assertUserCanManage(); 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, options: { broadcast: false } }); 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 */ _onModifyContents(action, documents, result, operation, user) { super._onModifyContents(action, documents, result, operation, user); Hooks.callAll("updateCompendium", this, documents, operation, user.id); } /* -------------------------------------------- */ /** * Handle a response from the server where a compendium was created. * @param {ManageCompendiumResponse} response The server response. * @returns {CompendiumCollection} */ static #handleCreateCompendium({ result }) { game.data.packs.push(result); const pack = new this(result); game.packs.set(pack.collection, pack); pack.apps.push(new Compendium({collection: pack})); ui.compendium.render(); return pack; } /* -------------------------------------------- */ /** * Handle a response from the server where a compendium was deleted. * @param {ManageCompendiumResponse} response The server response. * @returns {CompendiumCollection} */ static #handleDeleteCompendium({ result }) { const pack = game.packs.get(result); if ( !pack ) throw new Error(`Compendium pack '${result}' did not exist to be deleted.`); game.data.packs.findSplice(p => p.id === result); game.packs.delete(result); ui.compendium.render(); return pack; } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ get private() { foundry.utils.logCompatibilityWarning("CompendiumCollection#private is deprecated in favor of the new " + "CompendiumCollection#ownership, CompendiumCollection#getUserLevel, CompendiumCollection#visible properties"); return !this.visible; } /** * @deprecated since v11 * @ignore */ get isOpen() { foundry.utils.logCompatibilityWarning("CompendiumCollection#isOpen is deprecated and will be removed in V13"); return this.apps.some(app => app._state > Application.RENDER_STATES.NONE); } } /** * A Collection of Folder documents within a Compendium pack. */ class CompendiumFolderCollection extends DocumentCollection { constructor(pack, data=[]) { super(data); this.pack = pack; } /** * The CompendiumPack instance which contains this CompendiumFolderCollection * @type {CompendiumPack} */ pack; /* -------------------------------------------- */ /** @inheritdoc */ get documentName() { return "Folder"; } /* -------------------------------------------- */ /** @override */ render(force, options) { this.pack.render(force, options); } /* -------------------------------------------- */ /** @inheritdoc */ async updateAll(transformation, condition=null, options={}) { options.pack = this.collection; return super.updateAll(transformation, condition, options); } } class CompendiumPacks extends DirectoryCollectionMixin(Collection) { /** * Get a Collection of Folders which contain Compendium Packs * @returns {Collection} */ get folders() { return game.folders.reduce((collection, folder) => { if ( folder.type === "Compendium" ) { collection.set(folder.id, folder); } return collection; }, new foundry.utils.Collection()); } /* -------------------------------------------- */ /** @override */ _getVisibleTreeContents() { return this.contents.filter(pack => pack.visible); } /* -------------------------------------------- */ /** @override */ static _sortAlphabetical(a, b) { if ( a.metadata && b.metadata ) return a.metadata.label.localeCompare(b.metadata.label, game.i18n.lang); else return super._sortAlphabetical(a, b); } } /** * 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"; /** * Activate Socket event listeners to handle for fog resets * @param {Socket} socket The active web socket connection * @internal */ static _activateSocketListeners(socket) { socket.on("resetFog", ({sceneId}) => { if ( 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 { /** @override */ static documentName = "Folder"; /** * Track which Folders are currently expanded in the UI */ _expanded = {}; /* -------------------------------------------- */ /** @override */ _onModifyContents(action, documents, result, operation, user) { if ( operation.render ) { const folderTypes = new Set(documents.map(f => f.type)); for ( const type of folderTypes ) { if ( type === "Compendium" ) ui.sidebar.tabs.compendium.render(false); else { const collection = game.collections.get(type); collection.render(false, {renderContext: `${action}${this.documentName}`, renderData: result}); } } if ( folderTypes.has("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(); } } /* -------------------------------------------- */ /** @override */ render(force, options={}) { console.warn("The Folders collection is not directly rendered"); } } /** * 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 {Error} 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}; const { OBSERVER } = CONST.DOCUMENT_OWNERSHIP_LEVELS; if ( entry instanceof JournalEntryPage ) { options.mode = JournalSheet.VIEW_MODES.SINGLE; options.pageId = entry.id; // Set temporary observer permissions for this page. if ( entry.getUserLevel(game.user) < OBSERVER ) entry.ownership[game.userId] = OBSERVER; entry = entry.parent; } else if ( entry instanceof JournalEntry ) { if ( entry.getUserLevel(game.user) < OBSERVER ) entry.ownership[game.userId] = 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, style, speaker} = message; if ( speaker.scene === canvas.scene.id ) { const token = canvas.tokens.get(speaker.token); if ( token ) canvas.hud.bubbles.say(token, content, { cssClasses: style === CONST.CHAT_MESSAGE_STYLES.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: async () => { await this.documentClass.deleteDocuments([], {deleteAll: true}); const jumpToBottomElement = document.querySelector(".jump-to-bottom"); jumpToBottomElement.classList.add("hidden"); }, 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 { /** @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. * @returns {Promise} */ async initialize() { await game.audio.unlock; for ( let playlist of this ) { for ( let sound of playlist.sounds ) sound.sync(); } ui.playlists?.render(); } /* -------------------------------------------- */ /** * 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(foundry.audio.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(foundry.audio.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, { clearState=true, clearSort=true, ...options }={}) { const data = super.fromCompendium(document, { clearSort, ...options }); if ( clearState ) delete data.active; if ( clearSort ) { data.navigation = false; 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, type: new foundry.data.fields.BooleanField({initial: true}) }); } } /** * 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")); } /* -------------------------------------------- */ /** * Get one User who is an active Gamemaster (non-assistant if possible), or null if no active GM is available. * This can be useful for workflows which occur on all clients, but where only one user should take action. * @type {User|null} */ get activeGM() { const activeGMs = game.users.filter(u => u.active && u.isGM); activeGMs.sort((a, b) => (b.role - a.role) || a.id.compare(b.id)); // Alphanumeric sort IDs without using localeCompare return activeGMs[0] || null; } /* -------------------------------------------- */ /* 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 || user.isSelf ) return; // Update User active state const active = "active" in activityData ? activityData.active : true; if ( user.active !== active ) { user.active = active; game.users.render(); 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; } // 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); } } } /** * @typedef {EffectDurationData} ActiveEffectDuration * @property {string} type The duration type, either "seconds", "turns", or "none" * @property {number|null} duration The total effect duration, in seconds of world time or as a decimal * number with the format {rounds}.{turns} * @property {number|null} remaining The remaining effect duration, in seconds of world time or as a decimal * number with the format {rounds}.{turns} * @property {string} label A formatted string label that represents the remaining duration * @property {number} [_worldTime] An internal flag used determine when to recompute seconds-based duration * @property {number} [_combatTime] An internal flag used determine when to recompute turns-based duration */ /** * 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 foundry.documents.BaseActiveEffect * @mixes ClientDocumentMixin * * @see {@link Actor} The Actor document which contains ActiveEffect embedded documents * @see {@link Item} The Item document which contains ActiveEffect embedded documents * * @property {ActiveEffectDuration} duration Expanded effect duration data. */ class ActiveEffect extends ClientDocumentMixin(foundry.documents.BaseActiveEffect) { /** * Create an ActiveEffect instance from some status effect ID. * Delegates to {@link ActiveEffect._fromStatusEffect} to create the ActiveEffect instance * after creating the ActiveEffect data from the status effect data if `CONFIG.statusEffects`. * @param {string} statusId The status effect ID. * @param {DocumentConstructionContext} [options] Additional options to pass to the ActiveEffect constructor. * @returns {Promise} The created ActiveEffect instance. * * @throws {Error} An error if there is no status effect in `CONFIG.statusEffects` with the given status ID and if * the status has implicit statuses but doesn't have a static _id. */ static async fromStatusEffect(statusId, options={}) { const status = CONFIG.statusEffects.find(e => e.id === statusId); if ( !status ) throw new Error(`Invalid status ID "${statusId}" provided to ActiveEffect#fromStatusEffect`); /** @deprecated since v12 */ for ( const [oldKey, newKey] of Object.entries({label: "name", icon: "img"}) ) { if ( !(newKey in status) && (oldKey in status) ) { const msg = `StatusEffectConfig#${oldKey} has been deprecated in favor of StatusEffectConfig#${newKey}`; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); } } const {id, label, icon, hud, ...effectData} = foundry.utils.deepClone(status); effectData.name = game.i18n.localize(effectData.name ?? /** @deprecated since v12 */ label); effectData.img ??= /** @deprecated since v12 */ icon; effectData.statuses = Array.from(new Set([id, ...effectData.statuses ?? []])); if ( (effectData.statuses.length > 1) && !status._id ) { throw new Error("Status effects with implicit statuses must have a static _id"); } return ActiveEffect.implementation._fromStatusEffect(statusId, effectData, options); } /* -------------------------------------------- */ /** * Create an ActiveEffect instance from status effect data. * Called by {@link ActiveEffect.fromStatusEffect}. * @param {string} statusId The status effect ID. * @param {ActiveEffectData} effectData The status effect data. * @param {DocumentConstructionContext} [options] Additional options to pass to the ActiveEffect constructor. * @returns {Promise} The created ActiveEffect instance. * @protected */ static async _fromStatusEffect(statusId, effectData, options) { return new this(effectData, options); } /* -------------------------------------------- */ /* Properties */ /* -------------------------------------------- */ /** * Is there some system logic that makes this active effect ineligible for application? * @type {boolean} */ get isSuppressed() { return false; } /* --------------------------------------------- */ /** * Retrieve the Document that this ActiveEffect targets for modification. * @type {Document|null} */ get target() { if ( this.parent instanceof Actor ) return this.parent; if ( CONFIG.ActiveEffect.legacyTransferral ) return this.transfer ? null : this.parent; return this.transfer ? (this.parent.parent ?? null) : this.parent; } /* -------------------------------------------- */ /** * Whether the Active Effect currently applying its changes to the target. * @type {boolean} */ get active() { return !this.disabled && !this.isSuppressed; } /* -------------------------------------------- */ /** * Does this Active Effect currently modify an Actor? * @type {boolean} */ get modifiesActor() { if ( !this.active ) return false; if ( CONFIG.ActiveEffect.legacyTransferral ) return this.parent instanceof Actor; return this.target instanceof Actor; } /* --------------------------------------------- */ /** @inheritdoc */ prepareBaseData() { /** @deprecated since v11 */ const statusId = this.flags.core?.statusId; if ( (typeof statusId === "string") && (statusId !== "") ) this.statuses.add(statusId); } /* --------------------------------------------- */ /** @inheritdoc */ prepareDerivedData() { this.updateDuration(); } /* --------------------------------------------- */ /** * Update derived Active Effect duration data. * Configure the remaining and label properties to be getters which lazily recompute only when necessary. * @returns {ActiveEffectDuration} */ updateDuration() { const {remaining, label, ...durationData} = this._prepareDuration(); Object.assign(this.duration, durationData); const getOrUpdate = (attr, value) => this._requiresDurationUpdate() ? this.updateDuration()[attr] : value; Object.defineProperties(this.duration, { remaining: { get: getOrUpdate.bind(this, "remaining", remaining), configurable: true }, label: { get: getOrUpdate.bind(this, "label", label), configurable: true } }); return this.duration; } /* --------------------------------------------- */ /** * Determine whether the ActiveEffect requires a duration update. * True if the worldTime has changed for an effect whose duration is tracked in seconds. * True if the combat turn has changed for an effect tracked in turns where the effect target is a combatant. * @returns {boolean} * @protected */ _requiresDurationUpdate() { const {_worldTime, _combatTime, type} = this.duration; if ( type === "seconds" ) return game.time.worldTime !== _worldTime; if ( (type === "turns") && game.combat ) { const ct = this._getCombatTime(game.combat.round, game.combat.turn); return (ct !== _combatTime) && !!this.target?.inCombat; } return false; } /* --------------------------------------------- */ /** * Compute derived data related to active effect duration. * @returns {{ * type: string, * duration: number|null, * remaining: number|null, * label: string, * [_worldTime]: number, * [_combatTime]: number} * } * @internal */ _prepareDuration() { const d = this.duration; // Time-based duration if ( Number.isNumeric(d.seconds) ) { const wt = game.time.worldTime; const start = (d.startTime || wt); const elapsed = wt - start; const remaining = d.seconds - elapsed; return { type: "seconds", duration: d.seconds, remaining: remaining, label: `${remaining} ${game.i18n.localize("Seconds")}`, _worldTime: wt }; } // Turn-based duration else if ( d.rounds || d.turns ) { const cbt = game.combat; if ( !cbt ) return { type: "turns", _combatTime: undefined }; // Determine the current combat duration 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 { type: "turns", duration: duration, remaining: duration, label: this._getDurationLabel(d.rounds, d.turns), _combatTime: current }; // 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 remainingTurns = 0; if ( remaining > 0 ) { let nt = c.turn - d.startTurn; while ( nt < 0 ) nt += c.nTurns; remainingTurns = nt > 0 ? c.nTurns - nt : 0; } return { type: "turns", duration: duration, remaining: remaining, label: this._getDurationLabel(remainingRounds, remainingTurns), _combatTime: current }; } // No duration return { 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.statuses.size > 0); } /* -------------------------------------------- */ /** * The source name of the Active Effect. The source is retrieved synchronously. * Therefore "Unknown" (localized) is returned if the origin points to a document inside a compendium. * Returns "None" (localized) if it has no origin, and "Unknown" (localized) if the origin cannot be resolved. * @type {string} */ get sourceName() { if ( !this.origin ) return game.i18n.localize("None"); let name; try { name = fromUuidSync(this.origin)?.name; } catch(e) {} return name || game.i18n.localize("Unknown"); } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** * Apply EffectChangeData to a field within a DataModel. * @param {DataModel} model The model instance. * @param {EffectChangeData} change The change to apply. * @param {DataField} [field] The field. If not supplied, it will be retrieved from the supplied model. * @returns {*} The updated value. */ static applyField(model, change, field) { field ??= model.schema.getField(change.key); const current = foundry.utils.getProperty(model, change.key); const update = field.applyChange(current, model, change); foundry.utils.setProperty(model, change.key, update); return update; } /* -------------------------------------------- */ /** * 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 {Record} An object of property paths and their updated values. */ apply(actor, change) { let field; const changes = {}; if ( change.key.startsWith("system.") ) { if ( actor.system instanceof foundry.abstract.DataModel ) { field = actor.system.schema.getField(change.key.slice(7)); } } else field = actor.schema.getField(change.key); if ( field ) changes[change.key] = this.constructor.applyField(actor, change, field); else this._applyLegacy(actor, change, changes); return changes; } /* -------------------------------------------- */ /** * Apply this ActiveEffect to a provided Actor using a heuristic to infer the value types based on the current value * and/or the default value in the template.json. * @param {Actor} actor The Actor to whom this effect should be applied. * @param {EffectChangeData} change The change data being applied. * @param {Record} changes The aggregate update paths and their updated values. * @protected */ _applyLegacy(actor, change, changes) { // 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; 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); } /* -------------------------------------------- */ /** * 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); Hooks.call("applyActiveEffect", actor, change, current, delta, changes); const postHook = foundry.utils.getProperty(actor, change.key); if ( postHook !== preHook ) changes[change.key] = postHook; } /* -------------------------------------------- */ /** * Retrieve the initial duration configuration. * @returns {{duration: {startTime: number, [startRound]: number, [startTurn]: number}}} */ static getInitialDuration() { const data = {duration: {startTime: game.time.worldTime}}; if ( game.combat ) { data.duration.startRound = game.combat.round; data.duration.startTurn = game.combat.turn ?? 0; } return data; } /* -------------------------------------------- */ /* Flag Operations */ /* -------------------------------------------- */ /** @inheritdoc */ getFlag(scope, key) { if ( (scope === "core") && (key === "statusId") ) { foundry.utils.logCompatibilityWarning("You are setting flags.core.statusId on an Active Effect. This flag is" + " deprecated in favor of the statuses set.", {since: 11, until: 13}); } return super.getFlag(scope, key); } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ async _preCreate(data, options, user) { const allowed = await super._preCreate(data, options, user); if ( allowed === false ) return false; if ( foundry.utils.hasProperty(data, "flags.core.statusId") ) { foundry.utils.logCompatibilityWarning("You are setting flags.core.statusId on an Active Effect. This flag is" + " deprecated in favor of the statuses set.", {since: 11, until: 13}); } // Set initial duration data for Actor-owned effects if ( this.parent instanceof Actor ) { const updates = this.constructor.getInitialDuration(); for ( const k of Object.keys(updates.duration) ) { if ( Number.isNumeric(data.duration?.[k]) ) delete updates.duration[k]; // Prefer user-defined duration data } updates.transfer = false; this.updateSource(updates); } } /* -------------------------------------------- */ /** @inheritDoc */ _onCreate(data, options, userId) { super._onCreate(data, options, userId); if ( this.modifiesActor && (options.animate !== false) ) this._displayScrollingStatus(true); } /* -------------------------------------------- */ /** @inheritDoc */ async _preUpdate(changed, options, user) { if ( foundry.utils.hasProperty(changed, "flags.core.statusId") || foundry.utils.hasProperty(changed, "flags.core.-=statusId") ) { foundry.utils.logCompatibilityWarning("You are setting flags.core.statusId on an Active Effect. This flag is" + " deprecated in favor of the statuses set.", {since: 11, until: 13}); } if ( ("statuses" in changed) && (this._source.flags.core?.statusId !== undefined) ) { foundry.utils.setProperty(changed, "flags.core.-=statusId", null); } return super._preUpdate(changed, options, user); } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { super._onUpdate(changed, options, userId); if ( !(this.target instanceof Actor) ) return; const activeChanged = "disabled" in changed; if ( activeChanged && (options.animate !== false) ) this._displayScrollingStatus(this.active); } /* -------------------------------------------- */ /** @inheritDoc */ _onDelete(options, userId) { super._onDelete(options, userId); if ( this.modifiesActor && (options.animate !== false) ) this._displayScrollingStatus(false); } /* -------------------------------------------- */ /** * Display changes to active effects as scrolling Token status text. * @param {boolean} enabled Is the active effect currently enabled? * @protected */ _displayScrollingStatus(enabled) { if ( !(this.statuses.size || this.changes.length) ) return; const actor = this.target; const tokens = actor.getActiveTokens(true); const text = `${enabled ? "+" : "-"}(${this.name})`; for ( let t of tokens ) { if ( !t.visible || t.document.isSecret ) continue; canvas.interface.createScrollingText(t.center, text, { 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 }); } } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * Get the name of the source of the Active Effect * @type {string} * @deprecated since v11 * @ignore */ async _getSourceName() { const warning = "You are accessing ActiveEffect._getSourceName which is deprecated."; foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13}); if ( !this.origin ) return game.i18n.localize("None"); const source = await fromUuid(this.origin); return source?.name ?? game.i18n.localize("Unknown"); } } /** * The client-side ActorDelta embedded document which extends the common BaseActorDelta document model. * @extends foundry.documents.BaseActorDelta * @mixes ClientDocumentMixin * @see {@link TokenDocument} The TokenDocument document type which contains ActorDelta embedded documents. */ class ActorDelta extends ClientDocumentMixin(foundry.documents.BaseActorDelta) { /** @inheritdoc */ _configure(options={}) { super._configure(options); this._createSyntheticActor(); } /* -------------------------------------------- */ /** @inheritdoc */ _initialize({sceneReset=false, ...options}={}) { // Do not initialize the ActorDelta as part of a Scene reset. if ( sceneReset ) return; super._initialize(options); if ( !this.parent.isLinked && (this.syntheticActor?.id !== this.parent.actorId) ) { this._createSyntheticActor({ reinitializeCollections: true }); } } /* -------------------------------------------- */ /** * Pass-through the type from the synthetic Actor, if it exists. * @type {string} */ get type() { return this.syntheticActor?.type ?? this._type ?? this._source.type; } set type(type) { this._type = type; } _type; /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** * Apply this ActorDelta to the base Actor and return a synthetic Actor. * @param {object} [context] Context to supply to synthetic Actor instantiation. * @returns {Actor|null} */ apply(context={}) { return this.constructor.applyDelta(this, this.parent.baseActor, context); } /* -------------------------------------------- */ /** @override */ prepareEmbeddedDocuments() { // The synthetic actor prepares its items in the appropriate context of an actor. The actor delta does not need to // prepare its items, and would do so in the incorrect context. } /* -------------------------------------------- */ /** @inheritdoc */ updateSource(changes={}, options={}) { // If there is no baseActor, there is no synthetic actor either, so we do nothing. if ( !this.syntheticActor || !this.parent.baseActor ) return {}; // Perform an update on the synthetic Actor first to validate the changes. let actorChanges = foundry.utils.deepClone(changes); delete actorChanges._id; actorChanges.type ??= this.syntheticActor.type; actorChanges.name ??= this.syntheticActor.name; // In the non-recursive case we must apply the changes as actor delta changes first in order to get an appropriate // actor update, otherwise applying an actor delta update non-recursively to an actor will truncate most of its // data. if ( options.recursive === false ) { const tmpDelta = new ActorDelta.implementation(actorChanges, { parent: this.parent }); const updatedActor = this.constructor.applyDelta(tmpDelta, this.parent.baseActor); if ( updatedActor ) actorChanges = updatedActor.toObject(); } this.syntheticActor.updateSource(actorChanges, { ...options }); const diff = super.updateSource(changes, options); // If this was an embedded update, re-apply the delta to make sure embedded collections are merged correctly. const embeddedUpdate = Object.keys(this.constructor.hierarchy).some(k => k in changes); const deletionUpdate = Object.keys(foundry.utils.flattenObject(changes)).some(k => k.includes("-=")); if ( !this.parent.isLinked && (embeddedUpdate || deletionUpdate) ) this.updateSyntheticActor(); return diff; } /* -------------------------------------------- */ /** @inheritdoc */ reset() { super.reset(); // Propagate reset calls on the ActorDelta to the synthetic Actor. if ( !this.parent.isLinked ) this.syntheticActor?.reset(); } /* -------------------------------------------- */ /** * Generate a synthetic Actor instance when constructed, or when the represented Actor, or actorLink status changes. * @param {object} [options] * @param {boolean} [options.reinitializeCollections] Whether to fully re-initialize this ActorDelta's collections in * order to re-retrieve embedded Documents from the synthetic * Actor. * @internal */ _createSyntheticActor({ reinitializeCollections=false }={}) { Object.defineProperty(this, "syntheticActor", {value: this.apply({strict: false}), configurable: true}); if ( reinitializeCollections ) { for ( const collection of Object.values(this.collections) ) collection.initialize({ full: true }); } } /* -------------------------------------------- */ /** * Update the synthetic Actor instance with changes from the delta or the base Actor. */ updateSyntheticActor() { if ( this.parent.isLinked ) return; const updatedActor = this.apply(); if ( updatedActor ) this.syntheticActor.updateSource(updatedActor.toObject(), {diff: false, recursive: false}); } /* -------------------------------------------- */ /** * Restore this delta to empty, inheriting all its properties from the base actor. * @returns {Promise} The restored synthetic Actor. */ async restore() { if ( !this.parent.isLinked ) await Promise.all(Object.values(this.syntheticActor.apps).map(app => app.close())); await this.delete({diff: false, recursive: false, restoreDelta: true}); return this.parent.actor; } /* -------------------------------------------- */ /** * Ensure that the embedded collection delta is managing any entries that have had their descendants updated. * @param {Document} doc The parent whose immediate children have been modified. * @internal */ _handleDeltaCollectionUpdates(doc) { // Recurse up to an immediate child of the ActorDelta. if ( !doc ) return; if ( doc.parent !== this ) return this._handleDeltaCollectionUpdates(doc.parent); const collection = this.getEmbeddedCollection(doc.parentCollection); if ( !collection.manages(doc.id) ) collection.set(doc.id, doc); } /* -------------------------------------------- */ /* Database Operations */ /* -------------------------------------------- */ /** @inheritDoc */ async _preDelete(options, user) { if ( this.parent.isLinked ) return super._preDelete(options, user); // Emulate a synthetic actor update. const data = this.parent.baseActor.toObject(); let allowed = await this.syntheticActor._preUpdate(data, options, user) ?? true; allowed &&= (options.noHook || Hooks.call("preUpdateActor", this.syntheticActor, data, options, user.id)); if ( allowed === false ) { console.debug(`${vtt} | Actor update prevented during pre-update`); return false; } return super._preDelete(options, user); } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { super._onUpdate(changed, options, userId); if ( this.parent.isLinked ) return; this.syntheticActor._onUpdate(changed, options, userId); Hooks.callAll("updateActor", this.syntheticActor, changed, options, userId); } /* -------------------------------------------- */ /** @inheritDoc */ _onDelete(options, userId) { super._onDelete(options, userId); if ( !this.parent.baseActor ) return; // Create a new, ephemeral ActorDelta Document in the parent Token and emulate synthetic actor update. this.parent.updateSource({ delta: { _id: this.parent.id } }); this.parent.delta._onUpdate(this.parent.baseActor.toObject(), options, userId); } /* -------------------------------------------- */ /** @inheritDoc */ _dispatchDescendantDocumentEvents(event, collection, args, _parent) { super._dispatchDescendantDocumentEvents(event, collection, args, _parent); if ( !_parent ) { // Emulate descendant events on the synthetic actor. const fn = this.syntheticActor[`_${event}DescendantDocuments`]; fn?.call(this.syntheticActor, this.syntheticActor, collection, ...args); /** @deprecated since v11 */ const legacyFn = `_${event}EmbeddedDocuments`; const definingClass = foundry.utils.getDefiningClass(this.syntheticActor, legacyFn); const isOverridden = definingClass?.name !== "ClientDocumentMixin"; if ( isOverridden && (this.syntheticActor[legacyFn] instanceof Function) ) { const documentName = this.syntheticActor.constructor.hierarchy[collection].model.documentName; const warning = `The Actor class defines ${legacyFn} method which is deprecated in favor of a new ` + `_${event}DescendantDocuments method.`; foundry.utils.logCompatibilityWarning(warning, { since: 11, until: 13 }); this.syntheticActor[legacyFn](documentName, ...args); } } } } /** * The client-side Actor document which extends the common BaseActor model. * * ### Hook Events * {@link hookEvents.applyCompendiumArt} * * @extends foundry.documents.BaseActor * @mixes ClientDocumentMixin * @category - Documents * * @see {@link Actors} The world-level collection of Actor documents * @see {@link 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) { /** @inheritdoc */ _configure(options={}) { super._configure(options); /** * Maintain a list of Token Documents that represent this Actor, stored by Scene. * @type {IterableWeakMap>} * @private */ Object.defineProperty(this, "_dependentTokens", { value: new foundry.utils.IterableWeakMap() }); } /* -------------------------------------------- */ /** @inheritDoc */ _initializeSource(source, options={}) { source = super._initializeSource(source, options); // Apply configured Actor art. const pack = game.packs.get(options.pack); if ( !source._id || !pack || !game.compendiumArt.enabled ) return source; const uuid = pack.getUuid(source._id); const art = game.compendiumArt.get(uuid) ?? {}; if ( !art.actor && !art.token ) return source; if ( art.actor ) source.img = art.actor; if ( typeof token === "string" ) source.prototypeToken.texture.src = art.token; else if ( art.token ) foundry.utils.mergeObject(source.prototypeToken, art.token); Hooks.callAll("applyCompendiumArt", this.constructor, source, pack, art); return source; } /* -------------------------------------------- */ /** * An object that tracks which tracks the changes to the data model which were applied by active effects * @type {object} */ overrides = this.overrides ?? {}; /** * The statuses that are applied to this actor by active effects * @type {Set} */ statuses = this.statuses ?? new Set(); /** * 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 {Record} */ 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; } /* -------------------------------------------- */ /** * Retrieve the list of ActiveEffects that are currently applied to this Actor. * @type {ActiveEffect[]} */ get appliedEffects() { const effects = []; for ( const effect of this.allApplicableEffects() ) { if ( effect.active ) effects.push(effect); } return effects; } /* -------------------------------------------- */ /** * An array of ActiveEffect instances which are present on the Actor which have a limited duration. * @type {ActiveEffect[]} */ get temporaryEffects() { const effects = []; for ( const effect of this.allApplicableEffects() ) { if ( effect.active && effect.isTemporary ) effects.push(effect); } return effects; } /* -------------------------------------------- */ /** * 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; } /* -------------------------------------------- */ /** * Whether the Actor has at least one Combatant in the active Combat that represents it. * @returns {boolean} */ get inCombat() { return !!game.combat?.getCombatantsByActor(this).length; } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** * Apply any transformations to the Actor data which are caused by ActiveEffects. */ applyActiveEffects() { const overrides = {}; this.statuses.clear(); // Organize non-disabled effects by their application priority const changes = []; for ( const effect of this.allApplicableEffects() ) { if ( !effect.active ) continue; changes.push(...effect.changes.map(change => { const c = foundry.utils.deepClone(change); c.effect = effect; c.priority = c.priority ?? (c.mode * 10); return c; })); for ( const statusId of effect.statuses ) this.statuses.add(statusId); } 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 {Array} An array of Token instances in the current Scene which reference this Actor. */ getActiveTokens(linked=false, document=false) { if ( !canvas.ready ) return []; const tokens = []; for ( const t of this.getDependentTokens({ linked, scenes: canvas.scene }) ) { if ( t !== canvas.scene.tokens.get(t.id) ) continue; if ( document ) tokens.push(t); else if ( t.rendered ) tokens.push(t.object); } return tokens; } /* -------------------------------------------- */ /** * Get all ActiveEffects that may apply to this Actor. * If CONFIG.ActiveEffect.legacyTransferral is true, this is equivalent to actor.effects.contents. * If CONFIG.ActiveEffect.legacyTransferral is false, this will also return all the transferred ActiveEffects on any * of the Actor's owned Items. * @yields {ActiveEffect} * @returns {Generator} */ *allApplicableEffects() { for ( const effect of this.effects ) { yield effect; } if ( CONFIG.ActiveEffect.legacyTransferral ) return; for ( const item of this.items ) { for ( const effect of item.effects ) { if ( effect.transfer ) yield effect; } } } /* -------------------------------------------- */ /** * Return a data object which defines the data schema against which dice rolls can be evaluated. * By default, this is directly the Actor's system data, but systems may extend this to include additional properties. * If overriding or extending this method to add additional properties, care must be taken not to mutate the original * object. * @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 * @param {object} [options={}] The options passed to the TokenDocument constructor * @returns {Promise} The created TokenDocument instance */ async getTokenDocument(data={}, options={}) { 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; } if ( !tokenData.actorLink ) { if ( tokenData.appendNumber ) { // Count how many tokens are already linked to this actor const tokens = canvas.scene.tokens.filter(t => t.actorId === this.id); const n = tokens.length + 1; tokenData.name = `${tokenData.name} (${n})`; } if ( tokenData.prependAdjective ) { const adjectives = Object.values( foundry.utils.getProperty(game.i18n.translations, CONFIG.Token.adjectivesPrefix) || foundry.utils.getProperty(game.i18n._fallback, CONFIG.Token.adjectivesPrefix) || {}); const adjective = adjectives[Math.floor(Math.random() * adjectives.length)]; tokenData.name = `${adjective} ${tokenData.name}`; } } foundry.utils.mergeObject(tokenData, data); const cls = getDocumentClass("Token"); return new cls(tokenData, options); } /* -------------------------------------------- */ /** * 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 attr = foundry.utils.getProperty(this.system, attribute); const current = isBar ? attr.value : attr; const update = isDelta ? current + value : value; if ( update === current ) return this; // Determine the updates to make to the actor data let updates; if ( isBar ) updates = {[`system.${attribute}.value`]: Math.clamp(update, 0, attr.max)}; else updates = {[`system.${attribute}`]: update}; // Allow a hook to override these changes const allowed = Hooks.call("modifyTokenAttribute", {attribute, value, isDelta, isBar}, updates); return allowed !== false ? this.update(updates) : this; } /* -------------------------------------------- */ /** @inheritdoc */ prepareData() { // Identify which special statuses had been active this.statuses ??= new Set(); const specialStatuses = new Map(); for ( const statusId of Object.values(CONFIG.specialStatusEffects) ) { specialStatuses.set(statusId, this.statuses.has(statusId)); } super.prepareData(); // Apply special statuses that changed to active tokens let tokens; for ( const [statusId, wasActive] of specialStatuses ) { const isActive = this.statuses.has(statusId); if ( isActive === wasActive ) continue; tokens ??= this.getDependentTokens({scenes: canvas.scene}).filter(t => t.rendered).map(t => t.object); for ( const token of tokens ) token._onApplyStatusEffect(statusId, isActive); } } /* -------------------------------------------- */ /** @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 ( this.isToken && (c.token !== this.token) ) return arr; if ( !this.isToken && (c.actor !== this) ) return arr; if ( !rerollInitiative && (c.initiative !== null) ) return arr; arr.push(c.id); return arr; }, []); await combat.rollInitiative(combatants, initiativeOptions); return combat; } /* -------------------------------------------- */ /** * Toggle a configured status effect for the Actor. * @param {string} statusId A status effect ID defined in CONFIG.statusEffects * @param {object} [options={}] Additional options which modify how the effect is created * @param {boolean} [options.active] Force the effect to be active or inactive regardless of its current state * @param {boolean} [options.overlay=false] Display the toggled effect as an overlay * @returns {Promise} A promise which resolves to one of the following values: * - ActiveEffect if a new effect need to be created * - true if was already an existing effect * - false if an existing effect needed to be removed * - undefined if no changes need to be made */ async toggleStatusEffect(statusId, {active, overlay=false}={}) { const status = CONFIG.statusEffects.find(e => e.id === statusId); if ( !status ) throw new Error(`Invalid status ID "${statusId}" provided to Actor#toggleStatusEffect`); const existing = []; // Find the effect with the static _id of the status effect if ( status._id ) { const effect = this.effects.get(status._id); if ( effect ) existing.push(effect.id); } // If no static _id, find all single-status effects that have this status else { for ( const effect of this.effects ) { const statuses = effect.statuses; if ( (statuses.size === 1) && statuses.has(status.id) ) existing.push(effect.id); } } // Remove the existing effects unless the status effect is forced active if ( existing.length ) { if ( active ) return true; await this.deleteEmbeddedDocuments("ActiveEffect", existing); return false; } // Create a new effect unless the status effect is forced inactive if ( !active && (active !== undefined) ) return; const effect = await ActiveEffect.implementation.fromStatusEffect(statusId); if ( overlay ) effect.updateSource({"flags.core.overlay": true}); return ActiveEffect.implementation.create(effect, {parent: this, keepId: true}); } /* -------------------------------------------- */ /** * 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); }); }); } /* -------------------------------------------- */ /* Tokens */ /* -------------------------------------------- */ /** * Get this actor's dependent tokens. * If the actor is a synthetic token actor, only the exact Token which it represents will be returned. * @param {object} [options] * @param {Scene|Scene[]} [options.scenes] A single Scene, or list of Scenes to filter by. * @param {boolean} [options.linked] Limit the results to tokens that are linked to the actor. * @returns {TokenDocument[]} */ getDependentTokens({ scenes, linked=false }={}) { if ( this.isToken && !scenes ) return [this.token]; if ( scenes ) scenes = Array.isArray(scenes) ? scenes : [scenes]; else scenes = Array.from(this._dependentTokens.keys()); if ( this.isToken ) { const parent = this.token.parent; return scenes.includes(parent) ? [this.token] : []; } const allTokens = []; for ( const scene of scenes ) { if ( !scene ) continue; const tokens = this._dependentTokens.get(scene); for ( const token of (tokens ?? []) ) { if ( !linked || token.actorLink ) allTokens.push(token); } } return allTokens; } /* -------------------------------------------- */ /** * Register a token as a dependent of this actor. * @param {TokenDocument} token The token. * @internal */ _registerDependentToken(token) { if ( !token?.parent ) return; if ( !this._dependentTokens.has(token.parent) ) { this._dependentTokens.set(token.parent, new foundry.utils.IterableWeakSet()); } const tokens = this._dependentTokens.get(token.parent); tokens.add(token); } /* -------------------------------------------- */ /** * Remove a token from this actor's dependents. * @param {TokenDocument} token The token. * @internal */ _unregisterDependentToken(token) { if ( !token?.parent ) return; const tokens = this._dependentTokens.get(token.parent); tokens?.delete(token); } /* -------------------------------------------- */ /** * Prune a whole scene from this actor's dependent tokens. * @param {Scene} scene The scene. * @internal */ _unregisterDependentScene(scene) { this._dependentTokens.delete(scene); } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { // Update prototype token config references to point to the new PrototypeToken object. Object.values(this.apps).forEach(app => { if ( !(app instanceof TokenConfig) ) return; app.object = this.prototypeToken; app._previewChanges(changed.prototypeToken ?? {}); }); super._onUpdate(changed, options, userId); // Additional options only apply to base Actors if ( this.isToken ) return; this._updateDependentTokens(changed, options); // If the prototype token was changed, expire any cached token images if ( "prototypeToken" in changed ) this._tokenImages = null; // If ownership changed for the actor reset token control if ( ("permission" in changed) && tokens.length ) { canvas.tokens.releaseAll(); canvas.tokens.cycleTokens(true, true); } } /* -------------------------------------------- */ /** @inheritDoc */ _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) { // If this is a grandchild Active Effect creation, call reset to re-prepare and apply active effects, then call // super which will invoke sheet re-rendering. if ( !CONFIG.ActiveEffect.legacyTransferral && (parent instanceof Item) ) this.reset(); super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId); this._onEmbeddedDocumentChange(); } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) { // If this is a grandchild Active Effect update, call reset to re-prepare and apply active effects, then call // super which will invoke sheet re-rendering. if ( !CONFIG.ActiveEffect.legacyTransferral && (parent instanceof Item) ) this.reset(); super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId); this._onEmbeddedDocumentChange(); } /* -------------------------------------------- */ /** @inheritDoc */ _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) { // If this is a grandchild Active Effect deletion, call reset to re-prepare and apply active effects, then call // super which will invoke sheet re-rendering. if ( !CONFIG.ActiveEffect.legacyTransferral && (parent instanceof Item) ) this.reset(); super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId); this._onEmbeddedDocumentChange(); } /* -------------------------------------------- */ /** * Additional workflows to perform when any descendant document within this Actor changes. * @protected */ _onEmbeddedDocumentChange() { if ( !this.isToken ) this._updateDependentTokens(); } /* -------------------------------------------- */ /** * Update the active TokenDocument instances which represent this Actor. * @param {...any} args Arguments forwarded to Token#_onUpdateBaseActor * @protected */ _updateDependentTokens(...args) { for ( const token of this.getDependentTokens() ) { token._onUpdateBaseActor(...args); } } } /** * @typedef {Object} AdventureImportData * @property {Record} toCreate Arrays of document data to create, organized by document name * @property {Record} toUpdate Arrays of document data to update, organized by document name * @property {number} documentCount The total count of documents to import */ /** * @typedef {Object} AdventureImportResult * @property {Record} created Documents created as a result of the import, organized by document name * @property {Record} updated Documents updated as a result of the import, organized by document name */ /** * The client-side Adventure document which extends the common {@link foundry.documents.BaseAdventure} model. * @extends foundry.documents.BaseAdventure * @mixes ClientDocumentMixin * * ### Hook Events * {@link hookEvents.preImportAdventure} emitted by Adventure#import * {@link hookEvents.importAdventure} emitted by Adventure#import */ class Adventure extends ClientDocumentMixin(foundry.documents.BaseAdventure) { /** @inheritdoc */ static fromSource(source, options={}) { const pack = game.packs.get(options.pack); if ( pack && !pack.metadata.system ) { // Omit system-specific documents from this Adventure's data. source.actors = []; source.items = []; source.folders = source.folders.filter(f => !CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(f.type)); } return super.fromSource(source, options); } /* -------------------------------------------- */ /** * Perform a full import workflow of this Adventure. * Create new and update existing documents within the World. * @param {object} [options] Options which configure and customize the import process * @param {boolean} [options.dialog=true] Display a warning dialog if existing documents would be overwritten * @returns {Promise} The import result */ async import({dialog=true, ...importOptions}={}) { const importData = await this.prepareImport(importOptions); // Allow modules to preprocess adventure data or to intercept the import process const allowed = Hooks.call("preImportAdventure", this, importOptions, importData.toCreate, importData.toUpdate); if ( allowed === false ) { console.log(`"${this.name}" Adventure import was prevented by the "preImportAdventure" hook`); return {created: [], updated: []}; } // Warn the user if the import operation will overwrite existing World content if ( !foundry.utils.isEmpty(importData.toUpdate) && dialog ) { const confirm = await Dialog.confirm({ title: game.i18n.localize("ADVENTURE.ImportOverwriteTitle"), content: `

${game.i18n.localize("Warning")}:

${game.i18n.format("ADVENTURE.ImportOverwriteWarning", {name: this.name})}

` }); if ( !confirm ) return {created: [], updated: []}; } // Perform the import const {created, updated} = await this.importContent(importData); // Refresh the sidebar display ui.sidebar.render(); // Allow modules to perform additional post-import workflows Hooks.callAll("importAdventure", this, importOptions, created, updated); // Update the imported state of the adventure. const imports = game.settings.get("core", "adventureImports"); imports[this.uuid] = true; await game.settings.set("core", "adventureImports", imports); return {created, updated}; } /* -------------------------------------------- */ /** * Prepare Adventure data for import into the World. * @param {object} [options] Options passed in from the import dialog to configure the import * behavior. * @param {string[]} [options.importFields] A subset of adventure fields to import. * @returns {Promise} */ async prepareImport({ importFields=[] }={}) { importFields = new Set(importFields); const adventureData = this.toObject(); const toCreate = {}; const toUpdate = {}; let documentCount = 0; const importAll = !importFields.size || importFields.has("all"); const keep = new Set(); for ( const [field, cls] of Object.entries(Adventure.contentFields) ) { if ( !importAll && !importFields.has(field) ) continue; keep.add(cls.documentName); const collection = game.collections.get(cls.documentName); let [c, u] = adventureData[field].partition(d => collection.has(d._id)); if ( (field === "folders") && !importAll ) { c = c.filter(f => keep.has(f.type)); u = u.filter(f => keep.has(f.type)); } if ( c.length ) { toCreate[cls.documentName] = c; documentCount += c.length; } if ( u.length ) { toUpdate[cls.documentName] = u; documentCount += u.length; } } return {toCreate, toUpdate, documentCount}; } /* -------------------------------------------- */ /** * Execute an Adventure import workflow, creating and updating documents in the World. * @param {AdventureImportData} data Prepared adventure data to import * @returns {Promise} The import result */ async importContent({toCreate, toUpdate, documentCount}={}) { const created = {}; const updated = {}; // Display importer progress const importMessage = game.i18n.localize("ADVENTURE.ImportProgress"); let nImported = 0; SceneNavigation.displayProgressBar({label: importMessage, pct: 1}); // Create new documents for ( const [documentName, createData] of Object.entries(toCreate) ) { const cls = getDocumentClass(documentName); const docs = await cls.createDocuments(createData, {keepId: true, keepEmbeddedId: true, renderSheet: false}); created[documentName] = docs; nImported += docs.length; SceneNavigation.displayProgressBar({label: importMessage, pct: Math.floor(nImported * 100 / documentCount)}); } // Update existing documents for ( const [documentName, updateData] of Object.entries(toUpdate) ) { const cls = getDocumentClass(documentName); const docs = await cls.updateDocuments(updateData, {diff: false, recursive: false, noHook: true}); updated[documentName] = docs; nImported += docs.length; SceneNavigation.displayProgressBar({label: importMessage, pct: Math.floor(nImported * 100 / documentCount)}); } SceneNavigation.displayProgressBar({label: importMessage, pct: 100}); return {created, updated}; } } /** * The client-side AmbientLight document which extends the common BaseAmbientLight document model. * @extends foundry.documents.BaseAmbientLight * @mixes ClientDocumentMixin * * @see {@link Scene} The Scene document type which contains AmbientLight documents * @see {@link foundry.applications.sheets.AmbientLightConfig} The AmbientLight configuration application */ class AmbientLightDocument extends CanvasDocumentMixin(foundry.documents.BaseAmbientLight) { /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { const configs = Object.values(this.apps).filter(app => { return app instanceof foundry.applications.sheets.AmbientLightConfig; }); configs.forEach(app => { if ( app.preview ) options.animate = false; app._previewChanges(changed); }); super._onUpdate(changed, options, userId); configs.forEach(app => app._previewChanges()); } /* -------------------------------------------- */ /* 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 foundry.documents.BaseAmbientSound * @mixes ClientDocumentMixin * * @see {@link Scene} The Scene document type which contains AmbientSound documents * @see {@link foundry.applications.sheets.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 foundry.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.clamp(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._source.name) : this.back.name) || game.i18n.format("CARD.Unknown", {source: this.source?.name || game.i18n.localize("Unknown")}); } /* -------------------------------------------- */ /* 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.implementation.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 foundry.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); } /* -------------------------------------------- */ /** * 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 { 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}); const deletedFromOrigin = card.origin && !card.origin.cards.get(id); // 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) && !deletedFromOrigin ) { 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; createData.drawn = !copyCard && !deletedFromOrigin; 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); } 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 => [foundry.dice.MersenneTwister.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 || !card.origin ) continue; // Return drawn cards to their origin deck if ( card.origin.cards.get(card.id) ) { 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); } 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. * Sorting with "en" locale to ensure the same order regardless of which client sorts the deck. * @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 ?? -Infinity) - (b.value ?? -Infinity)) || 0; return (a.suit ?? "").compare(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 = { style: CONST.CHAT_MESSAGE_STYLES.OTHER, speaker: {user: game.user}, content: `
${source.name}

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

` }; ChatMessage.applyRollMode(messageData, game.settings.get("core", "rollMode")); return ChatMessage.implementation.create(messageData); } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ async _preCreate(data, options, user) { const allowed = await super._preCreate(data, options, user); if ( allowed === false ) return false; for ( const card of this.cards ) { card.updateSource({drawn: false}); } } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { if ( "type" in changed ) { this.sheet?.close(); this._sheet = undefined; } super._onUpdate(changed, 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 ) { ui.notifications.warn("CARDS.DealWarnNoTargets", {localize: true}); return this; } // 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 ) { ui.notifications.warn("CARDS.DrawWarnNoSources", {localize: true}); return []; } // 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 ) { ui.notifications.warn("CARDS.PassWarnNoTargets", {localize: true}); return this; } // 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 ) { ui.notifications.warn("CARDS.PassWarnNoTargets", {localize: true}); return []; } // 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 => { ui.notifications.error(err.message); return []; }); }, 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, types, ...options}={}) { if ( types ) { if ( types.length === 0 ) throw new Error("The array of sub-types to restrict to must not be empty"); for ( const type of types ) { if ( !this.TYPES.includes(type) ) throw new Error(`Invalid ${this.documentName} sub-type: "${type}"`); } } // Collect data const documentTypes = this.TYPES.filter(t => types?.includes(t) !== false); let collection; if ( !parent ) { if ( pack ) collection = game.packs.get(pack); else collection = game.collections.get(this.documentName); } const folders = collection?._formatFolderSelectOptions() ?? []; const label = game.i18n.localize(this.metadata.label); const title = game.i18n.format("DOCUMENT.Create", {type: label}); const type = data.type || documentTypes[0]; // Render the document creation form const html = await renderTemplate("templates/sidebar/cards-create.html", { folders, name: data.name || "", defaultName: this.implementation.defaultName({type, parent, pack}), folder: data.folder, hasFolders: folders.length >= 1, type, types: Object.fromEntries(documentTypes.map(type => { const label = CONFIG[this.documentName]?.typeLabels?.[type]; return [type, label && game.i18n.has(label) ? game.i18n.localize(label) : type]; }).sort((a, b) => a[1].localeCompare(b[1], game.i18n.lang))), hasTypes: true, presets: CONFIG.Cards.presets }); // Render the confirmation dialog window return Dialog.prompt({ title: title, content: html, label: title, render: html => { html[0].querySelector('[name="type"]').addEventListener("change", e => { html[0].querySelector('[name="name"]').placeholder = this.implementation.defaultName( {type: e.target.value, parent, pack}); }); }, 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.implementation.defaultName({type: data.type, parent, pack}); 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.implementation.create(data, {parent, pack, renderSheet: true}); }, rejectClose: false, options }); } } /** * The client-side ChatMessage document which extends the common BaseChatMessage model. * * @extends foundry.documents.BaseChatMessage * @mixes ClientDocumentMixin * * @see {@link Messages} The world-level collection of ChatMessage documents * * @property {Roll[]} rolls The prepared array of Roll instances */ 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.author?.name ?? game.i18n.localize("CHAT.UnknownUser"); } /* -------------------------------------------- */ /** * Is the current User the author of this message? * @type {boolean} */ get isAuthor() { return game.user === this.author; } /* -------------------------------------------- */ /** * 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; } /* -------------------------------------------- */ /** * Does this message contain dice rolls? * @type {boolean} */ get isRoll() { return this.rolls.length > 0; } /* -------------------------------------------- */ /** * 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.isRoll ) 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}) { 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.author?.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; } const lowerName = name.toLowerCase(); const users = game.users.filter(u => u.name.toLowerCase() === lowerName); if ( users.length ) return users; const actors = game.users.filter(a => a.character && (a.character.name.toLowerCase() === lowerName)); if ( actors.length ) return actors; // 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, {rollData: this.getRollData()}); const isWhisper = this.whisper.length; // Construct message data const messageData = { message: data, user: game.user, author: this.author, alias: this.alias, cssClass: [ this.style === CONST.CHAT_MESSAGE_STYLES.IC ? "ic" : null, this.style === CONST.CHAT_MESSAGE_STYLES.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.style === CONST.CHAT_MESSAGE_STYLES.OOC ) messageData.borderColor = this.author?.color.css; // 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"); 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 this._renderRollHTML(false); } // Otherwise, show "rolled privately" messages for Roll content else { const name = this.author?.name ?? game.i18n.localize("CHAT.UnknownUser"); data.flavor = game.i18n.format("CHAT.PrivateRollContent", {user: name}); data.content = await renderRolls(true); messageData.alias = name; } } /* -------------------------------------------- */ /** * Render HTML for the array of Roll objects included in this message. * @param {boolean} isPrivate Is the chat message private? * @returns {Promise} The rendered HTML string * @private */ async _renderRollHTML(isPrivate) { let html = ""; for ( const roll of this.rolls ) { html += await roll.render({isPrivate}); } return html; } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ async _preCreate(data, options, user) { const allowed = await super._preCreate(data, options, user); if ( allowed === false ) return false; 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, { 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.sounds.dice}); if ( options.rollMode || !(data.whisper?.length > 0) ) this.applyRollMode(options.rollMode || "roll"); } } /* -------------------------------------------- */ /** @inheritDoc */ _onCreate(data, options, userId) { super._onCreate(data, options, userId); ui.chat.postOne(this, {notify: true}); if ( options.chatBubble && canvas.ready ) { game.messages.sayBubble(this); } } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { if ( !this.visible ) ui.chat.deleteMessage(this.id); else ui.chat.updateMessage(this); super._onUpdate(changed, options, userId); } /* -------------------------------------------- */ /** @inheritDoc */ _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")}`; } } /** * @typedef {Object} CombatHistoryData * @property {number|null} round * @property {number|null} turn * @property {string|null} tokenId * @property {string|null} combatantId */ /** * The client-side Combat document which extends the common BaseCombat model. * * @extends foundry.documents.BaseCombat * @mixes ClientDocumentMixin * * @see {@link 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) { /** * Track the sorted turn order of this combat encounter * @type {Combatant[]} */ turns = this.turns || []; /** * Record the current round, turn, and tokenId to understand changes in the encounter state * @type {CombatHistoryData} */ current = this._getCurrentState(); /** * Track the previous round, turn, and tokenId to understand changes in the encounter state * @type {CombatHistoryData} */ previous = undefined; /** * 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.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|TokenDocument} token A Token ID or a TokenDocument instance * @returns {Combatant[]} An array of Combatants which represent the Token */ getCombatantsByToken(token) { const tokenId = token instanceof TokenDocument ? token.id : token; return this.combatants.filter(c => c.tokenId === tokenId); } /* -------------------------------------------- */ /** * Get a Combatant that represents the given Actor or Actor ID. * @param {string|Actor} actor An Actor ID or an Actor instance * @returns {Combatant[]} */ getCombatantsByActor(actor) { const isActor = actor instanceof Actor; if ( isActor && actor.isToken ) return this.getCombatantsByToken(actor.token); const actorId = isActor ? actor.id : actor; return this.combatants.filter(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 = {direction: 1, worldTime: {delta: advanceTime}}; 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); if ( round === 0 ) turn = null; 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 = {direction: -1, worldTime: {delta: advanceTime}}; 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 = {direction: 1, worldTime: {delta: CONFIG.time.turnTime}}; 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 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 = {direction: -1, worldTime: {delta: -1 * CONFIG.time.turnTime}}; 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); if ( (scene !== null) && this.combatants.some(c => c.sceneId && (c.sceneId !== scene)) ) { ui.notifications.error("COMBAT.CannotLinkToScene", {localize: true}); return this; } 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: this.started ? 0 : null, 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(); 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() { this.turns ||= []; // Determine the turn order and the current turn const turns = this.combatants.contents.sort(this._sortCombatants); if ( this.turn !== null) this.turn = Math.clamp(this.turn, 0, turns.length-1); // Update state tracking let c = turns[this.turn]; this.current = this._getCurrentState(c); // One-time initialization of the previous state if ( !this.previous ) this.previous = this.current; // Return the array of prepared turns return this.turns = turns; } /* -------------------------------------------- */ /** * Debounce changes to the composition of the Combat encounter to de-duplicate multiple concurrent Combatant changes. * If this is the currently viewed encounter, re-render the CombatTracker application. * @type {Function} */ debounceSetup = foundry.utils.debounce(() => { this.current.round = this.round; this.current.turn = this.turn; this.setupTurns(); if ( ui.combat.viewed === this ) ui.combat.render(); }, 50); /* -------------------------------------------- */ /** * Update active effect durations for all actors present in this Combat encounter. */ updateCombatantActors() { for ( const combatant of this.combatants ) combatant.actor?.render(false, {renderContext: "updateCombat"}); } /* -------------------------------------------- */ /** * 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} announcement The announcement that should be played: "startEncounter", "nextUp", or "yourTurn". * @protected */ _playCombatSound(announcement) { if ( !CONST.COMBAT_ANNOUNCEMENTS.includes(announcement) ) { throw new Error(`"${announcement}" is not a valid Combat announcement type`); } const theme = CONFIG.Combat.sounds[game.settings.get("core", "combatTheme")]; if ( !theme || theme === "none" ) return; const sounds = theme[announcement]; if ( !sounds ) return; const src = sounds[Math.floor(Math.random() * sounds.length)]; game.audio.play(src, {context: game.audio.interface}); } /* -------------------------------------------- */ /** * 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); } /* -------------------------------------------- */ /** * Refresh the Token HUD under certain circumstances. * @param {Combatant[]} documents A list of Combatant documents that were added or removed. * @protected */ _refreshTokenHUD(documents) { if ( documents.some(doc => doc.token?.object?.hasActiveHUD) ) canvas.tokens.hud.render(); } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ _onCreate(data, options, userId) { super._onCreate(data, options, userId); if ( !this.collection.viewed && this.collection.combats.includes(this) ) { ui.combat.initialize({combat: this, render: false}); } this._manageTurnEvents(); } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { super._onUpdate(changed, options, userId); const priorState = foundry.utils.deepClone(this.current); if ( !this.previous ) this.previous = priorState; // Just in case // Determine the new turn order if ( "combatants" in changed ) this.setupTurns(); // Update all combatants else this.current = this._getCurrentState(); // Update turn or round // Record the prior state and manage turn events const stateChanged = this.#recordPreviousState(priorState); if ( stateChanged && (options.turnEvents !== false) ) this._manageTurnEvents(); // Render applications for Actors involved in the Combat this.updateCombatantActors(); // Render the CombatTracker sidebar if ( (changed.active === true) && this.isActive ) ui.combat.initialize({combat: this}); else if ( "scene" in changed ) ui.combat.initialize(); // Trigger combat sound cues in the active encounter if ( this.active && this.started && priorState.round ) { const play = c => c && (game.user.isGM ? !c.hasPlayerOwner : c.isOwner); if ( play(this.combatant) ) this._playCombatSound("yourTurn"); else if ( play(this.nextCombatant) ) this._playCombatSound("nextUp"); } } /* -------------------------------------------- */ /** @inheritDoc */ _onDelete(options, userId) { super._onDelete(options, userId); if ( this.collection.viewed === this ) ui.combat.initialize(); if ( userId === game.userId ) this.collection.viewed?.activate(); } /* -------------------------------------------- */ /* Combatant Management Workflows */ /* -------------------------------------------- */ /** @inheritDoc */ _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) { super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId); this.#onModifyCombatants(parent, documents, options); } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) { super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId); this.#onModifyCombatants(parent, documents, options); } /* -------------------------------------------- */ /** @inheritDoc */ _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) { super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId); this.#onModifyCombatants(parent, documents, options); } /* -------------------------------------------- */ /** * Shared actions taken when Combatants are modified within this Combat document. * @param {Document} parent The direct parent of the created Documents, may be this Document or a child * @param {Document[]} documents The array of created Documents * @param {object} options Options which modified the operation */ #onModifyCombatants(parent, documents, options) { const {combatTurn, turnEvents, render} = options; if ( parent === this ) this._refreshTokenHUD(documents); const priorState = foundry.utils.deepClone(this.current); if ( typeof combatTurn === "number" ) this.updateSource({turn: combatTurn}); this.setupTurns(); const turnChange = this.#recordPreviousState(priorState); if ( turnChange && (turnEvents !== false) ) this._manageTurnEvents(); if ( (ui.combat.viewed === parent) && (render !== false) ) ui.combat.render(); } /* -------------------------------------------- */ /** * Get the current history state of the Combat encounter. * @param {Combatant} [combatant] The new active combatant * @returns {CombatHistoryData} * @protected */ _getCurrentState(combatant) { combatant ||= this.combatant; return { round: this.round, turn: this.turn ?? null, combatantId: combatant?.id || null, tokenId: combatant?.tokenId || null }; } /* -------------------------------------------- */ /** * Update the previous turn data. * Compare the state with the new current state. Only update the previous state if there is a difference. * @param {CombatHistoryData} priorState A cloned copy of the current history state before changes * @returns {boolean} Has the combat round or current combatant changed? */ #recordPreviousState(priorState) { const {round, combatantId} = this.current; const turnChange = (combatantId !== priorState.combatantId) || (round !== priorState.round); Object.assign(this.previous, priorState); return turnChange; } /* -------------------------------------------- */ /* Turn Events */ /* -------------------------------------------- */ /** * Manage the execution of Combat lifecycle events. * This method orchestrates the execution of four events in the following order, as applicable: * 1. End Turn * 2. End Round * 3. Begin Round * 4. Begin Turn * Each lifecycle event is an async method, and each is awaited before proceeding. * @returns {Promise} * @protected */ async _manageTurnEvents() { if ( !this.started ) return; // Gamemaster handling only if ( game.users.activeGM?.isSelf ) { const advanceRound = this.current.round > (this.previous.round ?? -1); const advanceTurn = advanceRound || (this.current.turn > (this.previous.turn ?? -1)); const changeCombatant = this.current.combatantId !== this.previous.combatantId; if ( !(advanceTurn || advanceRound || changeCombatant) ) return; // Conclude the prior Combatant turn const prior = this.combatants.get(this.previous.combatantId); if ( (advanceTurn || changeCombatant) && prior ) await this._onEndTurn(prior); // Conclude the prior round if ( advanceRound && this.previous.round ) await this._onEndRound(); // Begin the new round if ( advanceRound ) await this._onStartRound(); // Begin a new Combatant turn const next = this.combatant; if ( (advanceTurn || changeCombatant) && next ) await this._onStartTurn(this.combatant); } // Hooks handled by all clients Hooks.callAll("combatTurnChange", this, this.previous, this.current); } /* -------------------------------------------- */ /** * A workflow that occurs at the end of each Combat Turn. * This workflow occurs after the Combat document update, prior round information exists in this.previous. * This can be overridden to implement system-specific combat tracking behaviors. * This method only executes for one designated GM user. If no GM users are present this method will not be called. * @param {Combatant} combatant The Combatant whose turn just ended * @returns {Promise} * @protected */ async _onEndTurn(combatant) { if ( CONFIG.debug.combat ) { console.debug(`${vtt} | Combat End Turn: ${this.combatants.get(this.previous.combatantId).name}`); } // noinspection ES6MissingAwait this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_TURN_END, [combatant]); } /* -------------------------------------------- */ /** * A workflow that occurs at the end of each Combat Round. * This workflow occurs after the Combat document update, prior round information exists in this.previous. * This can be overridden to implement system-specific combat tracking behaviors. * This method only executes for one designated GM user. If no GM users are present this method will not be called. * @returns {Promise} * @protected */ async _onEndRound() { if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat End Round: ${this.previous.round}`); // noinspection ES6MissingAwait this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_ROUND_END, this.combatants); } /* -------------------------------------------- */ /** * A workflow that occurs at the start of each Combat Round. * This workflow occurs after the Combat document update, new round information exists in this.current. * This can be overridden to implement system-specific combat tracking behaviors. * This method only executes for one designated GM user. If no GM users are present this method will not be called. * @returns {Promise} * @protected */ async _onStartRound() { if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat Start Round: ${this.round}`); // noinspection ES6MissingAwait this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_ROUND_START, this.combatants); } /* -------------------------------------------- */ /** * A workflow that occurs at the start of each Combat Turn. * This workflow occurs after the Combat document update, new turn information exists in this.current. * This can be overridden to implement system-specific combat tracking behaviors. * This method only executes for one designated GM user. If no GM users are present this method will not be called. * @param {Combatant} combatant The Combatant whose turn just started * @returns {Promise} * @protected */ async _onStartTurn(combatant) { if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat Start Turn: ${this.combatant.name}`); // noinspection ES6MissingAwait this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_TURN_START, [combatant]); } /* -------------------------------------------- */ /** * Trigger Region events for Combat events. * @param {string} eventName The event name * @param {Iterable} combatants The combatants to trigger the event for * @returns {Promise} */ async #triggerRegionEvents(eventName, combatants) { const promises = []; for ( const combatant of combatants ) { const token = combatant.token; if ( !token ) continue; for ( const region of token.regions ) { promises.push(region._triggerEvent(eventName, {token, combatant})); } } await Promise.allSettled(promises); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ updateEffectDurations() { const msg = "Combat#updateEffectDurations is renamed to Combat#updateCombatantActors"; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return this.updateCombatantActors(); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getCombatantByActor(actor) { const combatants = this.getCombatantsByActor(actor); return combatants?.[0] || null; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getCombatantByToken(token) { const combatants = this.getCombatantsByToken(token); return combatants?.[0] || null; } } /** * The client-side Combatant document which extends the common BaseCombatant model. * * @extends foundry.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.hasPlayerOwner; } /* -------------------------------------------- */ /** * Eschew `ClientDocument`'s redirection to `Combat#permission` in favor of special ownership determination. * @override */ get permission() { if ( game.user.isGM ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER; return this.getUserLevel(game.user); } /* -------------------------------------------- */ /** @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 non-Gamemaster Users who have ownership of this Combatant. * @type {User[]} */ get players() { return game.users.filter(u => !u.isGM && this.testUserPermission(u, "OWNER")); } /* -------------------------------------------- */ /** * Has this combatant been marked as defeated? * @type {boolean} */ get isDefeated() { return this.defeated || !!this.actor?.statuses.has(CONFIG.specialStatusEffects.DEFEATED); } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** @inheritdoc */ testUserPermission(user, permission, {exact=false}={}) { if ( user.isGM ) return true; 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(); 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); } /* -------------------------------------------- */ /* Database Lifecycle Events */ /* -------------------------------------------- */ /** @override */ static async _preCreateOperation(documents, operation, _user) { const combatant = operation.parent?.combatant; if ( !combatant ) return; const combat = operation.parent.clone(); combat.updateSource({combatants: documents.map(d => d.toObject())}); combat.setupTurns(); operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0); } /* -------------------------------------------- */ /** @override */ static async _preUpdateOperation(_documents, operation, _user) { const combatant = operation.parent?.combatant; if ( !combatant ) return; const combat = operation.parent.clone(); combat.updateSource({combatants: operation.updates}); combat.setupTurns(); if ( operation.turnEvents !== false ) { operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0); } } /* -------------------------------------------- */ /** @override */ static async _preDeleteOperation(_documents, operation, _user) { const combatant = operation.parent?.combatant; if ( !combatant ) return; // Simulate new turns const combat = operation.parent.clone(); for ( const id of operation.ids ) combat.combatants.delete(id); combat.setupTurns(); // If the current combatant was deleted if ( operation.ids.includes(combatant?.id) ) { const {prevSurvivor, nextSurvivor} = operation.parent.turns.reduce((obj, t, i) => { let valid = !operation.ids.includes(t.id); if ( combat.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; }, {}); const survivor = nextSurvivor || prevSurvivor; if ( survivor ) operation.combatTurn = combat.turns.findIndex(t => t.id === survivor.id); } // Otherwise maintain the same combatant turn else operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0); } } /** * The client-side Drawing document which extends the common BaseDrawing model. * * @extends foundry.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) { /* -------------------------------------------- */ /* Model Properties */ /* -------------------------------------------- */ /** * Is the current User the author of this drawing? * @type {boolean} */ get isAuthor() { return game.user === this.author; } } /** * The client-side FogExploration document which extends the common BaseFogExploration model. * @extends foundry.documents.BaseFogExploration * @mixes ClientDocumentMixin */ class FogExploration extends ClientDocumentMixin(foundry.documents.BaseFogExploration) { /** * 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 load({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 query = {scene: sceneId, user: userId}; const response = await this.database.get(this, {query, ...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); } /* -------------------------------------------- */ /** @inheritDoc */ _onCreate(data, options, userId) { super._onCreate(data, options, userId); if ( (options.loadFog !== false) && (this.user === game.user) && (this.scene === canvas.scene) ) canvas.fog.load(); } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { super._onUpdate(changed, options, userId); if ( (options.loadFog !== false) && (this.user === game.user) && (this.scene === canvas.scene) ) canvas.fog.load(); } /* -------------------------------------------- */ /** @inheritDoc */ _onDelete(options, userId) { super._onDelete(options, userId); if ( (options.loadFog !== false) && (this.user === game.user) && (this.scene === canvas.scene) ) canvas.fog.load(); } /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ explore(source, force=false) { const msg = "explore is obsolete and always returns true. The fog exploration does not record position anymore."; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return true; } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** @inheritDoc */ static get(...args) { if ( typeof args[0] === "object" ) { foundry.utils.logCompatibilityWarning("You are calling FogExploration.get by passing an object. This means you" + " are probably trying to load Fog of War exploration data, an operation which has been renamed to" + " FogExploration.load", {since: 12, until: 14}); return this.load(...args); } return super.get(...args); } } /** * The client-side Folder document which extends the common BaseFolder model. * @extends foundry.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 */ /* -------------------------------------------- */ /** * The array of the Document instances which are contained within this Folder, * unless it's a Folder inside a Compendium pack, in which case it's the array * of objects inside the index of the pack that are contained in this Folder. * @type {(ClientDocument|object)[]} */ get contents() { if ( this.#contents ) return this.#contents; if ( this.pack ) return game.packs.get(this.pack).index.filter(d => d.folder === this.id ); return this.documentCollection?.filter(d => d.folder === this) ?? []; } set contents(value) { this.#contents = value; } #contents; /* -------------------------------------------- */ /** * The reference to the Document type which is contained within this Folder. * @type {Function} */ get documentClass() { return CONFIG[this.type].documentClass; } /* -------------------------------------------- */ /** * The reference to the WorldCollection instance which provides Documents to this Folder, * unless it's a Folder inside a Compendium pack, in which case it's the index of the pack. * A world Folder containing CompendiumCollections will have neither. * @type {WorldCollection|Collection|undefined} */ get documentCollection() { if ( this.pack ) return game.packs.get(this.pack).index; 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.uuid] || 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 */ /* -------------------------------------------- */ /** @inheritDoc */ async _preCreate(data, options, user) { // If the folder would be created past the maximum depth, throw an error if ( data.folder ) { const collection = data.pack ? game.packs.get(data.pack).folders : game.folders; const parent = collection.get(data.folder); if ( !parent ) return; const maxDepth = data.pack ? (CONST.FOLDER_MAX_DEPTH - 1) : CONST.FOLDER_MAX_DEPTH; if ( (parent.ancestors.length + 1) >= maxDepth ) throw new Error(game.i18n.format("FOLDER.ExceededMaxDepth", {depth: maxDepth})); } return super._preCreate(data, options, user); } /* -------------------------------------------- */ /** @override */ static async createDialog(data={}, options={}) { const folder = new Folder.implementation(foundry.utils.mergeObject({ name: Folder.implementation.defaultName({pack: options.pack}), sorting: "a" }, data), { pack: options.pack }); 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 * @param {boolean} [options.keepId=false] Retain the original _id attribute when updating an entity * @param {boolean} [options.keepFolders=false] Retain the existing Folder structure * @param {string} [options.folder] A target folder id to which the documents will be exported * @returns {Promise} The updated Compendium Collection instance */ async exportToCompendium(pack, options={}) { const updateByName = options.updateByName ?? false; const index = await pack.getIndex(); ui.notifications.info(game.i18n.format("FOLDER.Exporting", { type: game.i18n.localize(getDocumentClass(this.type).metadata.labelPlural), compendium: pack.collection })); options.folder ||= null; // Classify creations and updates const foldersToCreate = []; const foldersToUpdate = []; const documentsToCreate = []; const documentsToUpdate = []; // Ensure we do not overflow maximum allowed folder depth const originDepth = this.ancestors.length; const targetDepth = options.folder ? ((pack.folders.get(options.folder)?.ancestors.length ?? 0) + 1) : 0; /** * Recursively extract the contents and subfolders of a Folder into the Pack * @param {Folder} folder The Folder to extract * @param {number} [_depth] An internal recursive depth tracker * @private */ const _extractFolder = async (folder, _depth=0) => { const folderData = folder.toCompendium(pack, {...options, clearSort: false, keepId: true}); if ( options.keepFolders ) { // Ensure that the exported folder is within the maximum allowed folder depth const currentDepth = _depth + targetDepth - originDepth; const exceedsDepth = currentDepth > pack.maxFolderDepth; if ( exceedsDepth ) { throw new Error(`Folder "${folder.name}" exceeds maximum allowed folder depth of ${pack.maxFolderDepth}`); } // Re-parent child folders into the target folder or into the compendium root if ( folderData.folder === this.id ) folderData.folder = options.folder; // Classify folder data for creation or update if ( folder !== this ) { const existing = updateByName ? pack.folders.find(f => f.name === folder.name) : pack.folders.get(folder.id); if ( existing ) { folderData._id = existing._id; foldersToUpdate.push(folderData); } else foldersToCreate.push(folderData); } } // Iterate over Documents in the Folder, preparing each for export for ( let doc of folder.contents ) { const data = doc.toCompendium(pack, options); // Re-parent immediate child documents into the target folder. if ( data.folder === this.id ) data.folder = options.folder; // Otherwise retain their folder structure if keepFolders is true. else data.folder = options.keepFolders ? folderData._id : options.folder; // Generate thumbnails for Scenes if ( doc instanceof Scene ) { const { thumb } = await doc.createThumbnail({ img: data.background.src }); data.thumb = thumb; } // Classify document data for creation or update const existing = updateByName ? index.find(i => i.name === data.name) : index.find(i => i._id === data._id); if ( existing ) { data._id = existing._id; documentsToUpdate.push(data); } else documentsToCreate.push(data); console.log(`Prepared "${data.name}" for export to "${pack.collection}"`); } // Iterate over subfolders of the Folder, preparing each for export for ( let c of folder.children ) await _extractFolder(c.folder, _depth+1); }; // Prepare folders for export try { await _extractFolder(this, 0); } catch(err) { const msg = `Cannot export Folder "${this.name}" to Compendium pack "${pack.collection}":\n${err.message}`; return ui.notifications.error(msg, {console: true}); } // Create and update Folders if ( foldersToUpdate.length ) { await this.constructor.updateDocuments(foldersToUpdate, { pack: pack.collection, diff: false, recursive: false, render: false }); } if ( foldersToCreate.length ) { await this.constructor.createDocuments(foldersToCreate, { pack: pack.collection, keepId: true, render: false }); } // Create and update Documents const cls = pack.documentClass; if ( documentsToUpdate.length ) await cls.updateDocuments(documentsToUpdate, { pack: pack.collection, diff: false, recursive: false, render: false }); if ( documentsToCreate.length ) await cls.createDocuments(documentsToCreate, { pack: pack.collection, keepId: options.keepId, render: false }); // Re-render the pack ui.notifications.info(game.i18n.format("FOLDER.ExportDone", { type: game.i18n.localize(getDocumentClass(this.type).metadata.labelPlural), 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: game.i18n.localize(getDocumentClass(this.type).metadata.label)})); } // 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, keepFolders: options.keepFolders ?? true, hasFolders: options.pack?.folders?.length ?? false, folders: options.pack?.folders?.map(f => ({id: f.id, name: f.name})) || [], }); // Display it as a dialog prompt return FolderExport.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, keepFolders: form.keepFolders.checked, folder: form.folder.value }); }, 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; } /* -------------------------------------------- */ /** * Get the Folder documents which are parent folders of the current folder or any if its parents. * @returns {Folder[]} An array of Folder documents which are parent folders of this one */ getParentFolders() { let folders = []; let parent = this.folder; while ( parent ) { folders.push(parent); parent = parent.folder; } return folders; } } /** * The client-side Item document which extends the common BaseItem model. * @extends foundry.documents.BaseItem * @mixes ClientDocumentMixin * * @see {@link Items} The world-level collection of Item documents * @see {@link 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 legacy alias of Item#isEmbedded * @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 */ /* -------------------------------------------- */ /** * Return a data object which defines the data schema against which dice rolls can be evaluated. * By default, this is directly the Item's system data, but systems may extend this to include additional properties. * If overriding or extending this method to add additional properties, care must be taken not to mutate the original * object. * @returns {object} */ getRollData() { return this.system; } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritdoc */ async _preCreate(data, options, user) { if ( (this.parent instanceof Actor) && !CONFIG.ActiveEffect.legacyTransferral ) { for ( const effect of this.effects ) { if ( effect.transfer ) effect.updateSource(ActiveEffect.implementation.getInitialDuration()); } } return super._preCreate(data, options, user); } /* -------------------------------------------- */ /** @override */ static async _onCreateOperation(documents, operation, user) { if ( !(operation.parent instanceof Actor) || !CONFIG.ActiveEffect.legacyTransferral || !user.isSelf ) return; const cls = getDocumentClass("ActiveEffect"); // Create effect data const toCreate = []; for ( let item of documents ) { for ( let e of item.effects ) { if ( !e.transfer ) continue; const effectData = e.toJSON(); effectData.origin = item.uuid; toCreate.push(effectData); } } // Asynchronously create transferred Active Effects operation = {...operation}; delete operation.data; operation.renderSheet = false; // noinspection ES6MissingAwait cls.createDocuments(toCreate, operation); } /* -------------------------------------------- */ /** @inheritdoc */ static async _onDeleteOperation(documents, operation, user) { const actor = operation.parent; const cls = getDocumentClass("ActiveEffect"); if ( !(actor instanceof Actor) || !CONFIG.ActiveEffect.legacyTransferral || !user.isSelf ) return; // Identify effects that should be deleted const deletedUUIDs = new Set(documents.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); } // Asynchronously delete transferred Active Effects operation = {...operation}; delete operation.ids; delete operation.deleteAll; // noinspection ES6MissingAwait cls.deleteDocuments(toDelete, operation); } } /** * The client-side JournalEntryPage document which extends the common BaseJournalEntryPage document model. * @extends foundry.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. * @property {number} order The linear ordering of the heading in the table of contents. */ /** * The cached table of contents for this JournalEntryPage. * @type {Record} * @protected */ _toc; /* -------------------------------------------- */ /** * The table of contents for this JournalEntryPage. * @type {Record} */ 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 {Record} */ 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 {Record} * @protected */ static _flattenTOC(nodes) { let order = 0; const toc = {}; const addNode = node => { if ( toc[node.slug] ) { let i = 1; while ( toc[`${node.slug}$${i}`] ) i++; node.slug = `${node.slug}$${i}`; } node.order = order++; 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}); } /* -------------------------------------------- */ /** @inheritdoc */ _onUpdate(changed, options, userId) { super._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()); } } /* -------------------------------------------- */ /** @inheritDoc */ async _buildEmbedHTML(config, options={}) { const embed = await super._buildEmbedHTML(config, options); if ( !embed ) { if ( this.type === "text" ) return this._embedTextPage(config, options); else if ( this.type === "image" ) return this._embedImagePage(config, options); } return embed; } /* -------------------------------------------- */ /** @inheritDoc */ async _createFigureEmbed(content, config, options) { const figure = await super._createFigureEmbed(content, config, options); if ( (this.type === "image") && config.caption && !config.label && this.image.caption ) { const caption = figure.querySelector("figcaption > .embed-caption"); if ( caption ) caption.innerText = this.image.caption; } return figure; } /* -------------------------------------------- */ /** * Embed text page content. * @param {DocumentHTMLEmbedConfig & EnrichmentOptions} config Configuration for embedding behavior. This can include * enrichment options to override those passed as part of * the root enrichment process. * @param {EnrichmentOptions} [options] The original enrichment options to propagate to the embedded text page's * enrichment. * @returns {Promise} * @protected * * @example Embed the content of the Journal Entry Page as a figure. * ```@Embed[.yDbDF1ThSfeinh3Y classes="small right"]{Special caption}``` * becomes * ```html *
*

The contents of the page

*
* Special caption * * * Text Page * * *
*
* ``` * * @example Embed the content of the Journal Entry Page into the main content flow. * ```@Embed[.yDbDF1ThSfeinh3Y inline]``` * becomes * ```html *
*

The contents of the page

*
* ``` */ async _embedTextPage(config, options={}) { options = { ...options, relativeTo: this }; const { secrets=options.secrets, documents=options.documents, links=options.links, rolls=options.rolls, embeds=options.embeds } = config; foundry.utils.mergeObject(options, { secrets, documents, links, rolls, embeds }); const enrichedPage = await TextEditor.enrichHTML(this.text.content, options); const container = document.createElement("div"); container.innerHTML = enrichedPage; return container.children; } /* -------------------------------------------- */ /** * Embed image page content. * @param {DocumentHTMLEmbedConfig} config Configuration for embedding behavior. * @param {string} [config.alt] Alt text for the image, otherwise the caption will be used. * @param {EnrichmentOptions} [options] The original enrichment options for cases where the Document embed content * also contains text that must be enriched. * @returns {Promise} * @protected * * @example Create an embedded image from a sibling journal entry page. * ```@Embed[.QnH8yGIHy4pmFBHR classes="small right"]{Special caption}``` * becomes * ```html *
* Special caption *
* Special caption * * * Image Page * * *
*
* ``` */ async _embedImagePage({ alt, label }, options={}) { const img = document.createElement("img"); img.src = this.src; img.alt = alt || label || this.image.caption || this.name; return img; } } /** * The client-side JournalEntry document which extends the common BaseJournalEntry model. * @extends foundry.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"); } /* -------------------------------------------- */ /** @inheritdoc */ getUserLevel(user) { // Upgrade to OBSERVER ownership if the journal entry is in a LIMITED compendium, as LIMITED has no special meaning // for journal entries in this context. if ( this.pack && (this.compendium.getUserLevel(user) === CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED) ) { return CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER; } return super.getUserLevel(user); } /* -------------------------------------------- */ /** * 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 */ _onUpdate(changed, options, userId) { super._onUpdate(changed, options, userId); if ( !canvas.ready ) return; if ( ["name", "ownership"].some(k => k in changed) ) { canvas.notes.placeables.filter(n => n.document.entryId === this.id).forEach(n => n.draw()); } } /* -------------------------------------------- */ /** @inheritDoc */ _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 foundry.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 this Macro. * @type {boolean} */ get canExecute() { return this.canUserExecute(game.user); } /* -------------------------------------------- */ /** * Provide a thumbnail image path used to represent this document. * @type {string} */ get thumbnail() { return this.img; } /* -------------------------------------------- */ /* Model Methods */ /* -------------------------------------------- */ /** * Test whether the given User is capable of executing this Macro. * @param {User} user The User to test. * @returns {boolean} Can this User execute this Macro? */ canUserExecute(user) { if ( !this.testUserPermission(user, "LIMITED") ) return false; return this.type === "script" ? user.can("MACRO_SCRIPT") : true; } /* -------------------------------------------- */ /** * Execute the Macro command. * @param {object} [scope={}] Macro execution scope which is passed to script macros * @param {ChatSpeakerData} [scope.speaker] The speaker data * @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 * @param {Event|RegionEvent} [scope.event] An optional event passed to the executed macro * @returns {Promise|void} A promising containing a created {@link ChatMessage} (or `undefined`) if a chat * macro or the return value if a script macro. A void return is possible if the user * is not permitted to execute macros or a script macro execution fails. */ execute(scope={}) { if ( !this.canExecute ) { ui.notifications.warn(`You do not have permission to execute Macro "${this.name}".`); return; } switch ( this.type ) { case "chat": return this.#executeChat(scope.speaker); case "script": if ( foundry.utils.getType(scope) !== "Object" ) { throw new Error("Invalid scope parameter passed to Macro#execute which must be an object"); } return this.#executeScript(scope); } } /* -------------------------------------------- */ /** * Execute the command as a chat macro. * Chat macros simulate the process of the command being entered into the Chat Log input textarea. * @param {ChatSpeakerData} [speaker] The speaker data * @returns {Promise} A promising that resolves to either a created chat message or void in case an * error is thrown or the message's creation is prevented by some other means * (e.g., a hook). */ #executeChat(speaker) { return ui.chat.processMessage(this.command, {speaker}).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} [scope={}] Macro execution scope which is passed to script macros * @param {ChatSpeakerData} [scope.speaker] The speaker data * @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 * @returns {Promise|void} A promise containing the return value of the macro, if any, or nothing if the * macro execution throws an error. */ #executeScript({speaker, actor, token, ...scope}={}) { // Add variables to the evaluation scope speaker = speaker || ChatMessage.implementation.getSpeaker({actor, token}); const character = game.user.character; token = token || (canvas.ready ? canvas.tokens.get(speaker.token) : null) || null; actor = actor || token?.actor || game.actors.get(speaker.actor) || null; // Unpack argument names and values const argNames = Object.keys(scope); if ( argNames.some(k => Number.isNumeric(k)) ) { throw new Error("Illegal numeric Macro parameter passed to execution scope."); } const argValues = Object.values(scope); // Define an AsyncFunction that wraps the macro content // eslint-disable-next-line no-new-func const fn = new foundry.utils.AsyncFunction("speaker", "actor", "token", "character", "scope", ...argNames, `{${this.command}\n}`); // Attempt macro execution try { return fn.call(this, speaker, actor, token, character, scope, ...argValues); } catch(err) { ui.notifications.error("MACRO.Error", { localize: true }); } } /* -------------------------------------------- */ /** @inheritdoc */ _onClickDocumentLink(event) { return this.execute({event}); } } /** * The client-side MeasuredTemplate document which extends the common BaseMeasuredTemplate document model. * @extends foundry.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 */ /* -------------------------------------------- */ /** * Rotation is an alias for direction * @returns {number} */ get rotation() { return this.direction; } /* -------------------------------------------- */ /** * Is the current User the author of this template? * @type {boolean} */ get isAuthor() { return game.user === this.author; } } /** * The client-side Note document which extends the common BaseNote document model. * @extends foundry.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 foundry.documents.BasePlaylistSound * @mixes ClientDocumentMixin * * @see {@link Playlist} The Playlist document which contains PlaylistSound embedded documents * @see {@link PlaylistSoundConfig} The PlaylistSound configuration application * @see {@link foundry.audio.Sound} The Sound API which manages web audio playback */ class PlaylistSound extends ClientDocumentMixin(foundry.documents.BasePlaylistSound) { /** * The debounce tolerance for processing rapid volume changes into database updates in milliseconds * @type {number} */ static VOLUME_DEBOUNCE_MS = 100; /** * The Sound which manages playback for this playlist sound. * The Sound is created lazily when playback is required. * @type {Sound|null} */ sound; /** * A debounced function, accepting a single volume parameter to adjust the volume of this sound * @type {function(number): void} * @param {number} volume The desired volume level */ debounceVolume = foundry.utils.debounce(volume => { this.update({volume}, {diff: false, render: false}); }, PlaylistSound.VOLUME_DEBOUNCE_MS); /* -------------------------------------------- */ /** * Create a Sound used to play this PlaylistSound document * @returns {Sound|null} * @protected */ _createSound() { if ( game.audio.locked ) { throw new Error("You may not call PlaylistSound#_createSound until after game audio is unlocked."); } if ( !(this.id && this.path) ) return null; const sound = game.audio.create({src: this.path, context: this.context, singleton: false}); sound.addEventListener("play", this._onStart.bind(this)); sound.addEventListener("end", this._onEnd.bind(this)); sound.addEventListener("stop", this._onStop.bind(this)); return sound; } /* -------------------------------------------- */ /* Properties */ /* -------------------------------------------- */ /** * 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.clamp(this.fade ?? this.parent.fade ?? 0, 0, halfDuration); } /** * The audio context within which this sound is played. * This will be undefined if the audio context is not yet active. * @type {AudioContext|undefined} */ get context() { const channel = (this.channel || this.parent.channel) ?? "music"; return game.audio[channel]; } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** * Synchronize playback for this particular PlaylistSound instance. */ sync() { // Conclude playback if ( !this.playing ) { if ( this.sound?.playing ) { this.sound.stop({fade: this.pausedTime ? 0 : this.fadeDuration, volume: 0}); } return; } // Create a Sound if necessary this.sound ||= this._createSound(); const sound = this.sound; if ( !sound || sound.failed ) return; // Update an already playing sound if ( sound.playing ) { sound.loop = this.repeat; sound.fade(this.volume, {duration: 500}); return; } // Begin playback sound.load({autoplay: true, autoplayOptions: { loop: this.repeat, volume: this.volume, fade: this.fade, offset: this.pausedTime && !sound.playing ? this.pausedTime : undefined }}); } /* -------------------------------------------- */ /** * Load the audio for this sound for the current client. * @returns {Promise} */ async load() { this.sound ||= this._createSound(); await this.sound.load(); } /* -------------------------------------------- */ /** @inheritdoc */ toAnchor({classes=[], ...options}={}) { if ( this.playing ) classes.push("playing"); if ( !this.isOwner ) 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 */ /* -------------------------------------------- */ /** @inheritDoc */ _onCreate(data, options, userId) { super._onCreate(data, options, userId); if ( this.parent ) this.parent._playbackOrder = undefined; } /* -------------------------------------------- */ /** @inheritDoc */ _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(); } /* -------------------------------------------- */ /** @inheritDoc */ _onDelete(options, userId) { super._onDelete(options, userId); if ( this.parent ) this.parent._playbackOrder = undefined; this.playing = false; this.sync(); } /* -------------------------------------------- */ /** * Special handling that occurs when playback of a PlaylistSound is started. * @protected */ async _onStart() { if ( !this.playing ) return this.sound.stop(); const {volume, fadeDuration} = this; // Immediate fade-in if ( fadeDuration ) { // noinspection ES6MissingAwait this.sound.fade(volume, {duration: fadeDuration}); } // Schedule fade-out if ( !this.repeat && Number.isFinite(this.sound.duration) ) { const fadeOutTime = this.sound.duration - (fadeDuration / 1000); const fadeOut = () => this.sound.fade(0, {duration: fadeDuration}); // noinspection ES6MissingAwait this.sound.schedule(fadeOut, fadeOutTime); } // Playlist-level orchestration actions return this.parent._onSoundStart(this); } /* -------------------------------------------- */ /** * Special handling that occurs when a PlaylistSound reaches the natural conclusion of its playback. * @protected */ async _onEnd() { if ( !this.parent.isOwner ) return; return this.parent._onSoundEnd(this); } /* -------------------------------------------- */ /** * Special handling that occurs when a PlaylistSound is manually stopped before its natural conclusion. * @protected */ async _onStop() {} /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * The effective volume at which this playlist sound is played, incorporating the global playlist volume setting. * @type {number} */ get effectiveVolume() { foundry.utils.logCompatibilityWarning("PlaylistSound#effectiveVolume is deprecated in favor of using" + " PlaylistSound#volume directly", {since: 12, until: 14}); return this.volume; } } /** * The client-side Playlist document which extends the common BasePlaylist model. * @extends foundry.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 foundry.dice.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 this.isOwner || 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[data-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. * If sorting alphabetically, the sounds are sorted with a locale-independent comparator * to ensure the same order on all clients. * @private */ _sortSounds(a, b) { switch ( this.sorting ) { case CONST.PLAYLIST_SORT_MODES.ALPHABETICAL: return a.name.compare(b.name); case CONST.PLAYLIST_SORT_MODES.MANUAL: return a.sort - b.sort; } } /* -------------------------------------------- */ /** @inheritdoc */ toAnchor({classes=[], ...options}={}) { if ( this.playing ) classes.push("playing"); if ( !this.isOwner ) 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) && !game.audio.locked ) 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 */ _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) { super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId); if ( options.render !== false ) this.collection.render(); } /* -------------------------------------------- */ /** @inheritdoc */ _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) { super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId); if ( options.render !== false ) this.collection.render(); } /* -------------------------------------------- */ /** @inheritdoc */ _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) { super._onDeleteDescendantDocuments(parent, collection, 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 * @internal */ 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 * @internal */ 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); next?.load(); }, (sound.sound.duration - apl) * 1000); } } /* -------------------------------------------- */ /** * Update the playing status of this Playlist in content links. * @param {object} changed The data changes. */ #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 RegionBehavior document which extends the common BaseRegionBehavior model. * @extends foundry.documents.BaseRegionBehavior * @mixes ClientDocumentMixin */ class RegionBehavior extends ClientDocumentMixin(foundry.documents.BaseRegionBehavior) { /** * A convenience reference to the RegionDocument which contains this RegionBehavior. * @type {RegionDocument|null} */ get region() { return this.parent; } /* ---------------------------------------- */ /** * A convenience reference to the Scene which contains this RegionBehavior. * @type {Scene|null} */ get scene() { return this.region?.parent ?? null; } /* ---------------------------------------- */ /** * A RegionBehavior is active if and only if it was created, hasn't been deleted yet, and isn't disabled. * @type {boolean} */ get active() { return !this.disabled && (this.region?.behaviors.get(this.id) === this) && (this.scene?.regions.get(this.region.id) === this.region); } /* -------------------------------------------- */ /** * A RegionBehavior is viewed if and only if it is active and the Scene of its Region is viewed. * @type {boolean} */ get viewed() { return this.active && (this.scene?.isView === true); } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** @override */ prepareBaseData() { this.name ||= game.i18n.localize(CONFIG.RegionBehavior.typeLabels[this.type]); } /* -------------------------------------------- */ /** * Does this RegionBehavior handle the Region events with the given name? * @param {string} eventName The Region event name * @returns {boolean} */ hasEvent(eventName) { const system = this.system; return (system instanceof foundry.data.regionBehaviors.RegionBehaviorType) && ((eventName in system.constructor.events) || system.events.has(eventName)); } /* -------------------------------------------- */ /** * Handle the Region event. * @param {RegionEvent} event The Region event * @returns {Promise} * @internal */ async _handleRegionEvent(event) { const system = this.system; if ( !(system instanceof foundry.data.regionBehaviors.RegionBehaviorType) ) return; // Statically registered events for the behavior type if ( event.name in system.constructor.events ) { await system.constructor.events[event.name].call(system, event); } // Registered events specific to this behavior document if ( !system.events.has(event.name) ) return; await system._handleRegionEvent(event); } /* -------------------------------------------- */ /* Interaction Dialogs */ /* -------------------------------------------- */ /** @inheritDoc */ static async createDialog(data, options) { if ( !game.user.can("MACRO_SCRIPT") ) { options = {...options, types: (options?.types ?? this.TYPES).filter(t => t !== "executeScript")}; } return super.createDialog(data, options); } } /** * @typedef {object} RegionEvent * @property {string} name The name of the event * @property {object} data The data of the event * @property {RegionDocument} region The Region the event was triggered on * @property {User} user The User that triggered the event */ /** * @typedef {object} SocketRegionEvent * @property {string} regionUuid The UUID of the Region the event was triggered on * @property {string} userId The ID of the User that triggered the event * @property {string} eventName The name of the event * @property {object} eventData The data of the event * @property {string[]} eventDataUuids The keys of the event data that are Documents */ /** * The client-side Region document which extends the common BaseRegion model. * @extends foundry.documents.BaseRegion * @mixes CanvasDocumentMixin */ class RegionDocument extends CanvasDocumentMixin(foundry.documents.BaseRegion) { /** * Activate the Socket event listeners. * @param {Socket} socket The active game socket * @internal */ static _activateSocketListeners(socket) { socket.on("regionEvent", this.#onSocketEvent.bind(this)); } /* -------------------------------------------- */ /** * Handle the Region event received via the socket. * @param {SocketRegionEvent} socketEvent The socket Region event */ static async #onSocketEvent(socketEvent) { const {regionUuid, userId, eventName, eventData, eventDataUuids} = socketEvent; const region = await fromUuid(regionUuid); if ( !region ) return; for ( const key of eventDataUuids ) { const uuid = foundry.utils.getProperty(eventData, key); const document = await fromUuid(uuid); foundry.utils.setProperty(eventData, key, document); } const event = {name: eventName, data: eventData, region, user: game.users.get(userId)}; await region._handleEvent(event); } /* -------------------------------------------- */ /** * Update the tokens of the given regions. * @param {RegionDocument[]} regions The Regions documents, which must be all in the same Scene * @param {object} [options={}] Additional options * @param {boolean} [options.deleted=false] Are the Region documents deleted? * @param {boolean} [options.reset=true] Reset the Token document if animated? * If called during Region/Scene create/update/delete workflows, the Token documents are always reset and * so never in an animated state, which means the reset option may be false. It is important that the * containment test is not done in an animated state. * @internal */ static async _updateTokens(regions, {deleted=false, reset=true}={}) { if ( regions.length === 0 ) return; const updates = []; const scene = regions[0].parent; for ( const region of regions ) { if ( !deleted && !region.object ) continue; for ( const token of scene.tokens ) { if ( !deleted && !token.object ) continue; if ( !deleted && reset && (token.object.animationContexts.size !== 0) ) token.reset(); const inside = !deleted && token.object.testInsideRegion(region.object); if ( inside ) { if ( !token._regions.includes(region.id) ) { updates.push({_id: token.id, _regions: [...token._regions, region.id].sort()}); } } else { if ( token._regions.includes(region.id) ) { updates.push({_id: token.id, _regions: token._regions.filter(id => id !== region.id)}); } } } } await scene.updateEmbeddedDocuments("Token", updates); } /* -------------------------------------------- */ /** @override */ static async _onCreateOperation(documents, operation, user) { if ( user.isSelf ) { // noinspection ES6MissingAwait RegionDocument._updateTokens(documents, {reset: false}); } for ( const region of documents ) { const status = {active: true}; if ( region.parent.isView ) status.viewed = true; // noinspection ES6MissingAwait region._handleEvent({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region, user}); } } /* -------------------------------------------- */ /** @override */ static async _onUpdateOperation(documents, operation, user) { const changedRegions = []; for ( let i = 0; i < documents.length; i++ ) { const changed = operation.updates[i]; if ( ("shapes" in changed) || ("elevation" in changed) ) changedRegions.push(documents[i]); } if ( user.isSelf ) { // noinspection ES6MissingAwait RegionDocument._updateTokens(changedRegions, {reset: false}); } for ( const region of changedRegions ) { // noinspection ES6MissingAwait region._handleEvent({ name: CONST.REGION_EVENTS.REGION_BOUNDARY, data: {}, region, user }); } } /* -------------------------------------------- */ /** @override */ static async _onDeleteOperation(documents, operation, user) { if ( user.isSelf ) { // noinspection ES6MissingAwait RegionDocument._updateTokens(documents, {deleted: true}); } const regionEvents = []; for ( const region of documents ) { for ( const token of region.tokens ) { region.tokens.delete(token); regionEvents.push({ name: CONST.REGION_EVENTS.TOKEN_EXIT, data: {token}, region, user }); } region.tokens.clear(); } for ( const region of documents ) { const status = {active: false}; if ( region.parent.isView ) status.viewed = false; regionEvents.push({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region, user}); } for ( const event of regionEvents ) { // noinspection ES6MissingAwait event.region._handleEvent(event); } } /* -------------------------------------------- */ /** * The tokens inside this region. * @type {Set} */ tokens = new Set(); /* -------------------------------------------- */ /** * Trigger the Region event. * @param {string} eventName The event name * @param {object} eventData The event data * @returns {Promise} * @internal */ async _triggerEvent(eventName, eventData) { // Serialize Documents in the event data as UUIDs eventData = foundry.utils.deepClone(eventData); const eventDataUuids = []; const serializeDocuments = (object, key, path=key) => { const value = object[key]; if ( (value === null) || (typeof value !== "object") ) return; if ( !value.constructor || (value.constructor === Object) ) { for ( const key in value ) serializeDocuments(value, key, `${path}.${key}`); } else if ( Array.isArray(value) ) { for ( let i = 0; i < value.length; i++ ) serializeDocuments(value, i, `${path}.${i}`); } else if ( value instanceof foundry.abstract.Document ) { object[key] = value.uuid; eventDataUuids.push(path); } }; for ( const key in eventData ) serializeDocuments(eventData, key); // Emit socket event game.socket.emit("regionEvent", { regionUuid: this.uuid, userId: game.user.id, eventName, eventData, eventDataUuids }); } /* -------------------------------------------- */ /** * Handle the Region event. * @param {RegionEvent} event The Region event * @returns {Promise} * @internal */ async _handleEvent(event) { const results = await Promise.allSettled(this.behaviors.filter(b => !b.disabled) .map(b => b._handleRegionEvent(event))); for ( const result of results ) { if ( result.status === "rejected" ) console.error(result.reason); } } /* -------------------------------------------- */ /* Database Event Handlers */ /* -------------------------------------------- */ /** * When behaviors are created within the region, dispatch events for Tokens that are already inside the region. * @inheritDoc */ _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) { super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId); if ( collection !== "behaviors" ) return; // Trigger events const user = game.users.get(userId); for ( let i = 0; i < documents.length; i++ ) { const behavior = documents[i]; if ( behavior.disabled ) continue; // Trigger status event const status = {active: true}; if ( this.parent.isView ) status.viewed = true; behavior._handleRegionEvent({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region: this, user}); // Trigger enter events for ( const token of this.tokens ) { const deleted = !this.parent.tokens.has(token.id); if ( deleted ) continue; behavior._handleRegionEvent({ name: CONST.REGION_EVENTS.TOKEN_ENTER, data: {token}, region: this, user }); } } } /* -------------------------------------------- */ /** * When behaviors are updated within the region, dispatch events for Tokens that are already inside the region. * @inheritDoc */ _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) { super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId); if ( collection !== "behaviors" ) return; // Trigger status events const user = game.users.get(userId); for ( let i = 0; i < documents.length; i++ ) { const disabled = changes[i].disabled; if ( disabled === undefined ) continue; const behavior = documents[i]; // Trigger exit events if ( disabled ) { for ( const token of this.tokens ) { behavior._handleRegionEvent({ name: CONST.REGION_EVENTS.TOKEN_EXIT, data: {token}, region: this, user }); } } // Triger status event const status = {active: !disabled}; if ( this.parent.isView ) status.viewed = !disabled; behavior._handleRegionEvent({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region: this, user}); // Trigger enter events if ( !disabled ) { for ( const token of this.tokens ) { const deleted = !this.parent.tokens.has(token.id); if ( deleted ) continue; behavior._handleRegionEvent({ name: CONST.REGION_EVENTS.TOKEN_ENTER, data: {token}, region: this, user }); } } } } /* -------------------------------------------- */ /** * When behaviors are deleted within the region, dispatch events for Tokens that were previously inside the region. * @inheritDoc */ _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) { super._onDeleteDescendantDocuments(parent, collection, ids, options, userId); if ( collection !== "behaviors" ) return; // Trigger events const user = game.users.get(userId); for ( let i = 0; i < documents.length; i++ ) { const behavior = documents[i]; if ( behavior.disabled ) continue; // Trigger exit events for ( const token of this.tokens ) { const deleted = !this.parent.tokens.has(token.id); if ( deleted ) continue; behavior._handleRegionEvent({ name: CONST.REGION_EVENTS.TOKEN_EXIT, data: {token}, region: this, user }); } // Trigger status event const status = {active: false}; if ( this.parent.isView ) status.viewed = false; behavior._handleRegionEvent({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region: this, user}); } } } /** * The client-side Scene document which extends the common BaseScene model. * @extends foundry.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; /** * The grid instance. * @type {foundry.grid.BaseGrid} */ grid = this.grid; // Workaround for subclass property instantiation issue. /** * 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); }); } /* -------------------------------------------- */ /** @override */ reset() { this._initialize({sceneReset: true}); } /* -------------------------------------------- */ /** @inheritdoc */ toObject(source=true) { const object = super.toObject(source); if ( !source && this.grid.isHexagonal && this.flags.core?.legacyHex ) { object.grid.size = Math.round(this.grid.size * (2 * Math.SQRT1_3)); } return object; } /* -------------------------------------------- */ /** @inheritdoc */ prepareBaseData() { this.grid = Scene.#getGrid(this); 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); } /* -------------------------------------------- */ /** * Create the grid instance from the grid config of this scene if it doesn't exist yet. * @param {Scene} scene * @returns {foundry.grid.BaseGrid} */ static #getGrid(scene) { const grid = scene.grid; if ( grid instanceof foundry.grid.BaseGrid ) return grid; const T = CONST.GRID_TYPES; const type = grid.type; const config = { size: grid.size, distance: grid.distance, units: grid.units, style: grid.style, thickness: grid.thickness, color: grid.color, alpha: grid.alpha }; // Gridless grid if ( type === T.GRIDLESS ) return new foundry.grid.GridlessGrid(config); // Square grid if ( type === T.SQUARE ) { config.diagonals = game.settings.get("core", "gridDiagonals"); return new foundry.grid.SquareGrid(config); } // Hexagonal grid if ( type.between(T.HEXODDR, T.HEXEVENQ) ) { config.columns = (type === T.HEXODDQ) || (type === T.HEXEVENQ); config.even = (type === T.HEXEVENR) || (type === T.HEXEVENQ); if ( scene.flags.core?.legacyHex ) config.size *= (Math.SQRT3 / 2); return new foundry.grid.HexagonalGrid(config); } throw new Error("Invalid grid type"); } /* -------------------------------------------- */ /** * @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} distancePixels The factor to convert distance units to pixels. * @property {string} units The units of distance. * @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. * @property {number} rows The number of grid rows on the canvas. * @property {number} columns The number of grid columns 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 sceneWidth = this.width; const sceneHeight = this.height; // Compute the correct grid sizing let dimensions; if ( grid.isHexagonal && this.flags.core?.legacyHex ) { const legacySize = Math.round(grid.size * (2 * Math.SQRT1_3)); dimensions = foundry.grid.HexagonalGrid._calculatePreV10Dimensions(grid.columns, legacySize, sceneWidth, sceneHeight, this.padding); } else { dimensions = grid.calculateDimensions(sceneWidth, sceneHeight, this.padding); } const {width, height} = dimensions; const sceneX = dimensions.x - this.background.offsetX; const sceneY = dimensions.y - this.background.offsetY; // Define Scene dimensions return { width, height, size: grid.size, rect: {x: 0, y: 0, width, height}, sceneX, sceneY, sceneWidth, sceneHeight, sceneRect: {x: sceneX, y: sceneY, width: sceneWidth, height: sceneHeight}, distance: grid.distance, distancePixels: grid.size / grid.distance, ratio: sceneWidth / sceneHeight, maxR: Math.hypot(width, height), rows: dimensions.rows, columns: dimensions.columns }; } /* -------------------------------------------- */ /** @inheritdoc */ _onClickDocumentLink(event) { if ( this.journal ) return this.journal._onClickDocumentLink(event); return super._onClickDocumentLink(event); } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ async _preCreate(data, options, user) { const allowed = await super._preCreate(data, options, user); if ( allowed === false ) return false; // 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); } /* -------------------------------------------- */ /** @inheritDoc */ static async _preCreateOperation(documents, operation, user) { // Set a scene as active if none currently are. if ( !game.scenes.active ) { const candidate = documents.find((s, i) => !("active" in operation.data[i])); candidate?.updateSource({ active: true }); } } /* -------------------------------------------- */ /** @inheritDoc */ _onCreate(data, options, userId) { super._onCreate(data, options, userId); // Trigger Region Behavior status events const user = game.users.get(userId); for ( const region of this.regions ) { region._handleEvent({ name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: {active: true}, region, user }); } if ( data.active === true ) this._onActivate(true); } /* -------------------------------------------- */ /** @inheritDoc */ async _preUpdate(changed, options, user) { const allowed = await super._preUpdate(changed, options, user); if ( allowed === false ) return false; // Handle darkness level lock special case if ( changed.environment?.darknessLevel !== undefined ) { const darknessLocked = this.environment.darknessLock && (changed.environment.darknessLock !== false); if ( darknessLocked ) delete changed.environment.darknessLevel; } if ( "thumb" in changed ) { options.thumb ??= []; options.thumb.push(this.id); } // If the canvas size has changed, translate the placeable objects if ( options.autoReposition ) { try { changed = this._repositionObjects(changed); } catch (err) { delete changed.width; delete changed.height; delete changed.padding; delete changed.background; return ui.notifications.error(err.message); } } const audioChange = ("active" in changed) || (this.active && ["playlist", "playlistSound"].some(k => k in changed)); if ( audioChange ) return game.playlists._onChangeScene(this, changed); } /* -------------------------------------------- */ /** * Handle repositioning of placed objects when the Scene dimensions change * @private */ _repositionObjects(sceneUpdateData) { const translationScaleX = "width" in sceneUpdateData ? (sceneUpdateData.width / this.width) : 1; const translationScaleY = "height" in sceneUpdateData ? (sceneUpdateData.height / this.height) : 1; const averageTranslationScale = (translationScaleX + translationScaleY) / 2; // If the padding is larger than before, we need to add to it. If it's smaller, we need to subtract from it. const originalDimensions = this.getDimensions(); const updatedScene = this.clone(); updatedScene.updateSource(sceneUpdateData); const newDimensions = updatedScene.getDimensions(); const paddingOffsetX = "padding" in sceneUpdateData ? ((newDimensions.width - originalDimensions.width) / 2) : 0; const paddingOffsetY = "padding" in sceneUpdateData ? ((newDimensions.height - originalDimensions.height) / 2) : 0; // Adjust for the background offset const backgroundOffsetX = sceneUpdateData.background?.offsetX !== undefined ? (this.background.offsetX - sceneUpdateData.background.offsetX) : 0; const backgroundOffsetY = sceneUpdateData.background?.offsetY !== undefined ? (this.background.offsetY - sceneUpdateData.background.offsetY) : 0; // If not gridless and grid size is not already being updated, adjust the grid size, ensuring the minimum if ( (this.grid.type !== CONST.GRID_TYPES.GRIDLESS) && !foundry.utils.hasProperty(sceneUpdateData, "grid.size") ) { const gridSize = Math.round(this._source.grid.size * averageTranslationScale); if ( gridSize < CONST.GRID_MIN_SIZE ) throw new Error(game.i18n.localize("SCENES.GridSizeError")); foundry.utils.setProperty(sceneUpdateData, "grid.size", gridSize); } function adjustPoint(x, y, applyOffset = true) { return { x: Math.round(x * translationScaleX + (applyOffset ? paddingOffsetX + backgroundOffsetX: 0) ), y: Math.round(y * translationScaleY + (applyOffset ? paddingOffsetY + backgroundOffsetY: 0) ) } } // Placeables that have just a Position for ( let collection of ["tokens", "lights", "sounds", "templates"] ) { sceneUpdateData[collection] = this[collection].map(p => { const {x, y} = adjustPoint(p.x, p.y); return {_id: p.id, x, y}; }); } // Placeables that have a Position and a Size for ( let collection of ["tiles"] ) { sceneUpdateData[collection] = this[collection].map(p => { const {x, y} = adjustPoint(p.x, p.y); const width = Math.round(p.width * translationScaleX); const height = Math.round(p.height * translationScaleY); return {_id: p.id, x, y, width, height}; }); } // Notes have both a position and an icon size sceneUpdateData["notes"] = this.notes.map(p => { const {x, y} = adjustPoint(p.x, p.y); const iconSize = Math.max(32, Math.round(p.iconSize * averageTranslationScale)); return {_id: p.id, x, y, iconSize}; }); // Drawings possibly have relative shape points sceneUpdateData["drawings"] = this.drawings.map(p => { const {x, y} = adjustPoint(p.x, p.y); const width = Math.round(p.shape.width * translationScaleX); const height = Math.round(p.shape.height * translationScaleY); let points = []; if ( p.shape.points ) { for ( let i = 0; i < p.shape.points.length; i += 2 ) { const {x, y} = adjustPoint(p.shape.points[i], p.shape.points[i+1], false); points.push(x); points.push(y); } } return {_id: p.id, x, y, "shape.width": width, "shape.height": height, "shape.points": points}; }); // Walls are two points sceneUpdateData["walls"] = this.walls.map(w => { const c = w.c; const p1 = adjustPoint(c[0], c[1]); const p2 = adjustPoint(c[2], c[3]); return {_id: w.id, c: [p1.x, p1.y, p2.x, p2.y]}; }); return sceneUpdateData; } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { if ( !("thumb" in changed) && (options.thumb ?? []).includes(this.id) ) changed.thumb = this.thumb; super._onUpdate(changed, options, userId); const changedKeys = new Set(Object.keys(foundry.utils.flattenObject(changed)).filter(k => k !== "_id")); // If the Scene became active, go through the full activation procedure if ( ("active" in changed) ) this._onActivate(changed.active); // If the Thumbnail was updated, bust the image cache if ( ("thumb" in changed) && this.thumb ) { this.thumb = `${this.thumb.split("?")[0]}?${Date.now()}`; } // Update the Regions the Token is in if ( (game.user.id === userId) && ["grid.type", "grid.size"].some(k => changedKeys.has(k)) ) { // noinspection ES6MissingAwait RegionDocument._updateTokens(this.regions.contents, {reset: false}); } // If the scene is already active, maybe re-draw the canvas if ( canvas.scene === this ) { const redraw = [ "foreground", "fog.overlay", "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 => changedKeys.has(k)) || ("background" in changed) ) return canvas.draw(); // Update grid mesh if ( "grid" in changed ) canvas.interface.grid.initializeMesh(this.grid); // Modify vision conditions const perceptionAttrs = ["globalLight", "tokenVision", "fog.exploration"]; if ( perceptionAttrs.some(k => changedKeys.has(k)) ) canvas.perception.initialize(); if ( "tokenVision" in changed ) { for ( const token of canvas.tokens.placeables ) token.initializeVisionSource(); } // Progress darkness level if ( changedKeys.has("environment.darknessLevel") && options.animateDarkness ) { return canvas.effects.animateDarkness(changed.environment.darknessLevel, { duration: typeof options.animateDarkness === "number" ? options.animateDarkness : undefined }); } // Initialize the color manager with the new darkness level and/or scene background color if ( ("environment" in changed) || ["backgroundColor", "fog.colors.unexplored", "fog.colors.explored"].some(k => changedKeys.has(k)) ) { canvas.environment.initialize(); } // New initial view position if ( ["initial.x", "initial.y", "initial.scale", "width", "height"].some(k => changedKeys.has(k)) ) { this._viewPosition = {}; canvas.initializeCanvasPosition(); } /** * @type {SceneConfig} */ const sheet = this.sheet; if ( changedKeys.has("environment.darknessLock") ) { // Initialize controls with a darkness lock update if ( ui.controls.rendered ) ui.controls.initialize(); // Update live preview if the sheet is rendered (force all) if ( sheet?.rendered ) sheet._previewScene("force"); // TODO: Think about a better design } } } /* -------------------------------------------- */ /** @inheritDoc */ async _preDelete(options, user) { const allowed = await super._preDelete(options, user); if ( allowed === false ) return false; if ( this.active ) game.playlists._onChangeScene(this, {active: false}); } /* -------------------------------------------- */ /** @inheritDoc */ _onDelete(options, userId) { super._onDelete(options, userId); if ( canvas.scene?.id === this.id ) canvas.draw(null); for ( const token of this.tokens ) { token.baseActor?._unregisterDependentScene(this); } // Trigger Region Behavior status events const user = game.users.get(userId); for ( const region of this.regions ) { region._handleEvent({ name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: {active: false}, region, user }); } } /* -------------------------------------------- */ /** * 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 */ _preCreateDescendantDocuments(parent, collection, data, options, userId) { super._preCreateDescendantDocuments(parent, collection, data, options, userId); // Record layer history for child embedded documents if ( (userId === game.userId) && this.isView && (parent === this) && !options.isUndo ) { const layer = canvas.getCollectionLayer(collection); layer?.storeHistory("create", data.map(d => ({_id: d._id}))); } } /* -------------------------------------------- */ /** @inheritdoc */ _preUpdateDescendantDocuments(parent, collection, changes, options, userId) { super._preUpdateDescendantDocuments(parent, collection, changes, options, userId); // Record layer history for child embedded documents if ( (userId === game.userId) && this.isView && (parent === this) && !options.isUndo ) { const documentCollection = this.getEmbeddedCollection(collection); const originals = changes.reduce((data, change) => { const doc = documentCollection.get(change._id); if ( doc ) { const source = doc.toObject(); const original = foundry.utils.filterObject(source, change); // Special handling of flag changes if ( "flags" in change ) { original.flags ??= {}; for ( let flag in foundry.utils.flattenObject(change.flags) ) { // Record flags that are deleted if ( flag.includes(".-=") ) { flag = flag.replace(".-=", "."); foundry.utils.setProperty(original.flags, flag, foundry.utils.getProperty(source.flags, flag)); } // Record flags that are added else if ( !foundry.utils.hasProperty(original.flags, flag) ) { let parent; for ( ;; ) { const parentFlag = flag.split(".").slice(0, -1).join("."); parent = parentFlag ? foundry.utils.getProperty(original.flags, parentFlag) : original.flags; if ( parent !== undefined ) break; flag = parentFlag; } if ( foundry.utils.getType(parent) === "Object" ) parent[`-=${flag.split(".").at(-1)}`] = null; } } } data.push(original); } return data; }, []); const layer = canvas.getCollectionLayer(collection); layer?.storeHistory("update", originals); } } /* -------------------------------------------- */ /** @inheritdoc */ _preDeleteDescendantDocuments(parent, collection, ids, options, userId) { super._preDeleteDescendantDocuments(parent, collection, ids, options, userId); // Record layer history for child embedded documents if ( (userId === game.userId) && this.isView && (parent === this) && !options.isUndo ) { const documentCollection = this.getEmbeddedCollection(collection); const originals = ids.reduce((data, id) => { const doc = documentCollection.get(id); if ( doc ) data.push(doc.toObject()); return data; }, []); const layer = canvas.getCollectionLayer(collection); layer?.storeHistory("delete", originals); } } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) { super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId); if ( (parent === this) && documents.some(doc => doc.object?.hasActiveHUD) ) { canvas.getCollectionLayer(collection).hud.render(); } } /* -------------------------------------------- */ /* Importing and Exporting */ /* -------------------------------------------- */ /** @inheritdoc */ toCompendium(pack, options={}) { const data = super.toCompendium(pack, options); if ( options.clearState ) delete data.fog.reset; 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); 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; // Simulate the way a sprite 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 = tint; s.position.set(x + (width/2) - d.sceneRect.x, y + (height/2) - d.sceneRect.y); s.angle = rotation; s.elevation = tile.elevation; s.zIndex = tile.sort; return s; }; // Background container if ( backgroundTexture ) { const bg = new PIXI.Sprite(backgroundTexture); bg.width = d.sceneWidth; bg.height = d.sceneHeight; bg.elevation = PrimaryCanvasGroup.BACKGROUND_ELEVATION; bg.zIndex = -Infinity; 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.elevation = scene.foregroundElevation; fg.zIndex = -Infinity; baseContainer.addChild(fg); } // Tiles for ( let t of tiles ) { const sprite = await drawTile(t); if ( sprite ) baseContainer.addChild(sprite); } // Sort by elevation and sort baseContainer.children.sort((a, b) => (a.elevation - b.elevation) || (a.zIndex - b.zIndex)); // Render the container to a thumbnail const stage = new PIXI.Container(); stage.addChild(baseContainer); return ImageHelper.createThumbnail(stage, {width, height, format, quality}); } } /** * The client-side Setting document which extends the common BaseSetting model. * @extends foundry.documents.BaseSetting * @mixes ClientDocumentMixin * * @see {@link WorldSettings} The world-level collection of Setting documents */ class Setting extends ClientDocumentMixin(foundry.documents.BaseSetting) { /** * The types of settings which should be constructed as a function call rather than as a class constructor. */ static #PRIMITIVE_TYPES = Object.freeze([String, Number, Boolean, Array, Symbol, BigInt]); /** * The setting configuration for this setting document. * @type {SettingsConfig|undefined} */ get config() { return game.settings?.settings.get(this.key); } /* -------------------------------------------- */ /** @inheritDoc */ _initialize(options={}) { super._initialize(options); this.value = this._castType(); } /* -------------------------------------------- */ /** @inheritDoc */ _onCreate(data, options, userId) { super._onCreate(data, options, userId); const onChange = this.config?.onChange; if ( onChange instanceof Function ) onChange(this.value, options, userId); } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { super._onUpdate(changed, options, userId); const onChange = this.config?.onChange; if ( ("value" in changed) && (onChange instanceof Function) ) onChange(this.value, options, userId); } /* -------------------------------------------- */ /** * Cast the value of the Setting into its defined type. * @returns {*} The initialized type of the Setting document. * @protected */ _castType() { // Allow undefined and null directly if ( (this.value === null) || (this.value === undefined) ) return this.value; // Undefined type stays as a string const type = this.config?.type; if ( !(type instanceof Function) ) return this.value; // Primitive types if ( Setting.#PRIMITIVE_TYPES.includes(type) ) { if ( (type === String) && (typeof this.value !== "string") ) return JSON.stringify(this.value); if ( this.value instanceof type ) return this.value; return type(this.value); } // DataField types if ( type instanceof foundry.data.fields.DataField ) { return type.initialize(value); } // DataModel types if ( foundry.utils.isSubclass(type, foundry.abstract.DataModel) ) { return type.fromSource(this.value); } // Constructed types const isConstructed = type?.prototype?.constructor === type; return isConstructed ? new type(this.value) : type(this.value); } } /** * The client-side TableResult document which extends the common BaseTableResult document model. * @extends foundry.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; } /** @override */ prepareBaseData() { super.prepareBaseData(); if ( game._documentsReady ) { if ( this.type === "document" ) { this.img = game.collections.get(this.documentCollection)?.get(this.documentId)?.img ?? this.img; } else if ( this.type === "pack" ) { this.img = game.packs.get(this.documentCollection)?.index.get(this.documentId)?.img ?? this.img; } } } /** * 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 foundry.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, 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, rolls: [], sound: roll ? CONFIG.sounds.dice : null, flags: {"core.RollTable": this.id} }, messageData); if ( roll ) messageData.rolls.push(roll); // 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}), 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.implementation.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 = CONFIG.Dice.termTypes.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 ?? 1; 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}`); } // If there is no formula, automatically calculate an even distribution if ( !this.formula ) { await this.normalize(); } // 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 ( !available.length ) { ui.notifications.warn(game.i18n.localize("TABLE.NoAvailableResults")); return {roll, results}; } // Ensure that results are available within the minimum/maximum range const minRoll = (await roll.reroll({minimize: true})).total; const maxRoll = (await roll.reroll({maximize: 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(); 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 }; } /* -------------------------------------------- */ /** * Handle a roll from within embedded content. * @param {PointerEvent} event The originating event. * @protected */ async _rollFromEmbeddedHTML(event) { await this.draw(); const table = event.target.closest(".roll-table-embed"); if ( !table ) return; let i = 0; const rows = table.querySelectorAll(":scope > tbody > tr"); for ( const { drawn } of this.results ) { const row = rows[i++]; row?.classList.toggle("drawn", drawn); } } /* -------------------------------------------- */ /** * 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)); } /* -------------------------------------------- */ /** * @typedef {DocumentHTMLEmbedConfig} RollTableHTMLEmbedConfig * @property {boolean} [rollable=false] Adds a button allowing the table to be rolled directly from its embedded * context. */ /** * Create embedded roll table markup. * @param {RollTableHTMLEmbedConfig} config Configuration for embedding behavior. * @param {EnrichmentOptions} [options] The original enrichment options for cases where the Document embed content * also contains text that must be enriched. * @returns {Promise} * @protected * * @example Embed the content of a Roll Table as a figure. * ```@Embed[RollTable.kRfycm1iY3XCvP8c]``` * becomes * ```html *
* * * * * * * * * * * * * * * * * *
RollResult
1—10 * * * 1d6 * * Orcs attack! *
11—20No encounter
*
*
*

This is the Roll Table description.

*
* * * * Rollable Table * *
*
* ``` */ async _buildEmbedHTML(config, options={}) { options = { ...options, relativeTo: this }; const rollable = config.rollable || config.values.includes("rollable"); const results = this.results.toObject(); results.sort((a, b) => a.range[0] - b.range[0]); const table = document.createElement("table"); let rollHeader = game.i18n.localize("TABLE.Roll"); if ( rollable ) { rollHeader = ` ${rollHeader} `; } table.classList.add("roll-table-embed"); table.classList.toggle("roll-table-rollable", rollable); table.innerHTML = ` ${rollHeader} ${game.i18n.localize("TABLE.Result")} `; const tbody = table.querySelector("tbody"); for ( const { range, type, text, documentCollection, documentId, drawn } of results ) { const row = document.createElement("tr"); row.classList.toggle("drawn", drawn); const [lo, hi] = range; row.innerHTML += `${lo === hi ? lo : `${lo}—${hi}`}`; let result; let doc; switch ( type ) { case CONST.TABLE_RESULT_TYPES.TEXT: result = await TextEditor.enrichHTML(text, options); break; case CONST.TABLE_RESULT_TYPES.DOCUMENT: doc = CONFIG[documentCollection].collection.instance?.get(documentId); break; case CONST.TABLE_RESULT_TYPES.COMPENDIUM: const pack = game.packs.get(documentCollection); doc = await pack.getDocument(documentId); break; } if ( result === undefined ) { if ( doc ) result = doc.toAnchor().outerHTML; else result = TextEditor.createAnchor({ label: text, icon: "fas fa-unlink", classes: ["content-link", "broken"] }).outerHTML; } row.innerHTML += `${result}`; tbody.append(row); } return table; } /* -------------------------------------------- */ /** @inheritDoc */ async _createFigureEmbed(content, config, options) { const figure = await super._createFigureEmbed(content, config, options); if ( config.caption && !config.label ) { // Add the table description as the caption. options = { ...options, relativeTo: this }; const description = await TextEditor.enrichHTML(this.description, options); const figcaption = figure.querySelector(":scope > figcaption"); figcaption.querySelector(":scope > .embed-caption").remove(); const caption = document.createElement("div"); caption.classList.add("embed-caption"); caption.innerHTML = description; figcaption.insertAdjacentElement("afterbegin", caption); } return figure; } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritdoc */ _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) { super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId); if ( options.render !== false ) this.collection.render(); } /* -------------------------------------------- */ /** @inheritdoc */ _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) { super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId); if ( options.render !== false ) 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: folder.pack ? CONST.TABLE_RESULT_TYPES.COMPENDIUM : CONST.TABLE_RESULT_TYPES.DOCUMENT, documentCollection: folder.pack ? folder.pack : folder.type, documentId: 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 foundry.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) { /** @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.clamp(this.x.toNearest(0.1), minX, maxX); this.y = Math.clamp(this.y.toNearest(0.1), minY, maxY); } } /** * The client-side Token document which extends the common BaseToken document model. * @extends foundry.documents.BaseToken * @mixes CanvasDocumentMixin * * @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) { /* -------------------------------------------- */ /* Properties */ /* -------------------------------------------- */ /** * A singleton collection which holds a reference to the synthetic token actor by its base actor's ID. * @type {Collection} */ actors = (function() { const collection = new foundry.utils.Collection(); collection.documentClass = Actor.implementation; return collection; })(); /* -------------------------------------------- */ /** * A 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 ActorDelta. * @returns {Actor|null} */ get actor() { return (this.isLinked ? this.baseActor : this.delta?.syntheticActor) ?? null; } /* -------------------------------------------- */ /** * A reference to the base, World-level Actor this token represents. * @returns {Actor} */ get baseActor() { return game.actors.get(this.actorId); } /* -------------------------------------------- */ /** * 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; } /* -------------------------------------------- */ /** * Does this TokenDocument have the SECRET disposition and is the current user lacking the necessary permissions * that would reveal this secret? * @type {boolean} */ get isSecret() { return (this.disposition === CONST.TOKEN_DISPOSITIONS.SECRET) && !this.testUserPermission(game.user, "OBSERVER"); } /* -------------------------------------------- */ /** * 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?.combatants.find(c => c.tokenId === this.id) || null; } /* -------------------------------------------- */ /** * An indicator for whether this Token is currently involved in the active combat encounter. * @type {boolean} */ get inCombat() { return !!this.combatant; } /* -------------------------------------------- */ /** * The Regions this Token is currently in. * @type {Set} */ regions = game._documentsReady ? new Set() : null; /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** @inheritdoc */ _initialize(options = {}) { super._initialize(options); this.baseActor?._registerDependentToken(this); } /* -------------------------------------------- */ /** @override */ prepareBaseData() { // Initialize regions if ( this.regions === null ) { this.regions = new Set(); if ( !this.parent ) return; for ( const id of this._regions ) { const region = this.parent.regions.get(id); if ( !region ) continue; this.regions.add(region); region.tokens.add(this); } } this.name ||= this.actor?.name || "Unknown"; if ( this.hidden ) this.alpha = Math.min(this.alpha, game.user.isGM ? 0.5 : 0); this._prepareDetectionModes(); } /* -------------------------------------------- */ /** @inheritdoc */ prepareEmbeddedDocuments() { if ( game._documentsReady && !this.delta ) this.updateSource({ delta: { _id: this.id } }); } /* -------------------------------------------- */ /** @inheritDoc */ prepareDerivedData() { if ( this.ring.enabled && !this.ring.subject.texture ) { this.ring.subject.texture = this._inferRingSubjectTexture(); } } /* -------------------------------------------- */ /** * Infer the subject texture path to use for a token ring. * @returns {string} * @protected */ _inferRingSubjectTexture() { let tex = this.texture.src; for ( const [prefix, replacement] of Object.entries(CONFIG.Token.ring.subjectPaths) ) { if ( tex.startsWith(prefix) ) return tex.replace(prefix, replacement); } return tex; } /* -------------------------------------------- */ /** * 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 lightMode = this.detectionModes.find(m => m.id === "lightPerception"); if ( !lightMode ) this.detectionModes.push({id: "lightPerception", enabled: true, range: null}); const basicMode = this.detectionModes.find(m => m.id === "basicSight"); if ( !basicMode ) this.detectionModes.push({id: "basicSight", enabled: true, range: this.sight.range}); } /* -------------------------------------------- */ /** * 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 {object} [options] * @param {string} [options.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 attribute = alternative || this[barName]?.attribute; if ( !attribute || !this.actor ) return null; const system = this.actor.system; const isSystemDataModel = system instanceof foundry.abstract.DataModel; const templateModel = game.model.Actor[this.actor.type]; // Get the current attribute value const data = foundry.utils.getProperty(system, attribute); if ( (data === null) || (data === undefined) ) return null; // Single values if ( Number.isNumeric(data) ) { let editable = foundry.utils.hasProperty(templateModel, attribute); if ( isSystemDataModel ) { const field = system.schema.getField(attribute); if ( field ) editable = field instanceof foundry.data.fields.NumberField; } return {type: "value", attribute, value: Number(data), editable}; } // Attribute objects else if ( ("value" in data) && ("max" in data) ) { let editable = foundry.utils.hasProperty(templateModel, `${attribute}.value`); if ( isSystemDataModel ) { const field = system.schema.getField(`${attribute}.value`); if ( field ) editable = field instanceof foundry.data.fields.NumberField; } return {type: "bar", attribute, value: parseInt(data.value || 0), max: parseInt(data.max || 0), editable}; } // Otherwise null return null; } /* -------------------------------------------- */ /** * 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 Actor of the Token have this status effect? */ hasStatusEffect(statusId) { return this.actor?.statuses.has(statusId) ?? false; } /* -------------------------------------------- */ /* Combat Operations */ /* -------------------------------------------- */ /** * Add or remove this Token from a Combat encounter. * @param {object} [options={}] Additional options passed to TokenDocument.createCombatants or * TokenDocument.deleteCombatants * @param {boolean} [options.active] Require this token to be an active Combatant or to be removed. * Otherwise, the current combat state of the Token is toggled. * @returns {Promise} Is this Token now an active Combatant? */ async toggleCombatant({active, ...options}={}) { active ??= !this.inCombat; if ( active ) await this.constructor.createCombatants([this], options); else await this.constructor.deleteCombatants([this], options); return this.inCombat; } /* -------------------------------------------- */ /** * Create or remove Combatants for an array of provided Token objects. * @param {TokenDocument[]} tokens The tokens which should be added to the Combat * @param {object} [options={}] Options which modify the toggle operation * @param {Combat} [options.combat] A specific Combat instance which should be modified. If undefined, the * current active combat will be modified if one exists. Otherwise, a new * Combat encounter will be created if the requesting user is a Gamemaster. * @returns {Promise} An array of created Combatant documents */ static async createCombatants(tokens, {combat}={}) { // Identify the target Combat encounter 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: false}); } else throw new Error(game.i18n.localize("COMBAT.NoneActive")); } // Add tokens to the Combat encounter const createData = new Set(tokens).reduce((arr, token) => { if ( token.inCombat ) return arr; arr.push({tokenId: token.id, sceneId: token.parent.id, actorId: token.actorId, hidden: token.hidden}); return arr; }, []); return combat.createEmbeddedDocuments("Combatant", createData); } /* -------------------------------------------- */ /** * Remove Combatants for the array of provided Tokens. * @param {TokenDocument[]} tokens The tokens which should removed from the Combat * @param {object} [options={}] Options which modify the operation * @param {Combat} [options.combat] A specific Combat instance from which Combatants should be deleted * @returns {Promise} An array of deleted Combatant documents */ static async deleteCombatants(tokens, {combat}={}) { combat ??= game.combats.viewed; 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); } /* -------------------------------------------- */ /* 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 ) { const defaults = CONFIG.Canvas.visionModes[visionMode].vision.defaults; for ( const [key, value] of Object.entries(defaults)) { if ( value === undefined ) continue; update.sight[key] = value; } } return this.update(update); } /* -------------------------------------------- */ /** @inheritdoc */ getEmbeddedCollection(embeddedName) { if ( this.isLinked ) return super.getEmbeddedCollection(embeddedName); switch ( embeddedName ) { case "Actor": this.actors.set(this.actorId, this.actor); return this.actors; case "Item": return this.actor.items; case "ActiveEffect": return this.actor.effects; } return super.getEmbeddedCollection(embeddedName); } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ _onCreate(data, options, userId) { // Initialize the regions of this token for ( const id of this._regions ) { const region = this.parent.regions.get(id); if ( !region ) continue; this.regions.add(region); region.tokens.add(this); } super._onCreate(data, options, userId); } /* -------------------------------------------- */ /** @inheritDoc */ async _preUpdate(changed, options, user) { const allowed = await super._preUpdate(changed, options, user); if ( allowed === false ) return false; if ( "actorId" in changed ) options.previousActorId = this.actorId; if ( "actorData" in changed ) { foundry.utils.logCompatibilityWarning("This update operation includes an update to the Token's actorData " + "property, which is deprecated. Please perform updates via the synthetic Actor instead, accessible via the " + "'actor' getter.", {since: 11, until: 13}); } } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { const configs = Object.values(this.apps).filter(app => app instanceof TokenConfig); configs.forEach(app => { if ( app.preview ) options.animate = false; app._previewChanges(changed); }); // If the Actor association has changed, expire the cached Token actor if ( ("actorId" in changed) || ("actorLink" in changed) ) { const previousActor = game.actors.get(options.previousActorId); if ( previousActor ) { Object.values(previousActor.apps).forEach(app => app.close({submit: false})); previousActor._unregisterDependentToken(this); } this.delta._createSyntheticActor({ reinitializeCollections: true }); } // Handle region changes const priorRegionIds = options._priorRegions?.[this.id]; if ( priorRegionIds ) this.#onUpdateRegions(priorRegionIds); // Handle movement if ( game.user.id === userId ) { const origin = options._priorPosition?.[this.id]; if ( origin ) this.#triggerMoveRegionEvents(origin, options.teleport === true, options.forced === true); } // Post-update the Token itself super._onUpdate(changed, options, userId); configs.forEach(app => app._previewChanges()); } /* -------------------------------------------- */ /** * Handle changes to the regions this token is in. * @param {string[]} priorRegionIds The IDs of the prior regions */ #onUpdateRegions(priorRegionIds) { // Update the regions of this token this.regions.clear(); for ( const id of this._regions ) { const region = this.parent.regions.get(id); if ( !region ) continue; this.regions.add(region); } // Update tokens of regions const priorRegions = new Set(); for ( const id of priorRegionIds ) { const region = this.parent.regions.get(id); if ( region ) priorRegions.add(region); } for ( const region of priorRegions ) region.tokens.delete(this); for ( const region of this.regions ) region.tokens.add(this); } /* -------------------------------------------- */ /** * Trigger TOKEN_MOVE, TOKEN_MOVE_IN, and TOKEN_MOVE_OUT events. * @param {{x: number, y: number, elevation: number}} [origin] The origin of movement * @param {boolean} teleport Teleporation? * @param {boolean} forced Forced movement? */ #triggerMoveRegionEvents(origin, teleport, forced) { if ( !this.parent.isView || !this.object ) return; const E = CONST.REGION_EVENTS; const elevation = this.elevation; const destination = {x: this.x, y: this.y, elevation}; for ( const region of this.parent.regions ) { if ( !region.object ) continue; if ( !region.behaviors.some(b => !b.disabled && (b.hasEvent(E.TOKEN_MOVE) || b.hasEvent(E.TOKEN_MOVE_IN) || b.hasEvent(E.TOKEN_MOVE_OUT))) ) continue; const segments = this.object.segmentizeRegionMovement(region.object, [origin, destination], {teleport}); if ( segments.length === 0 ) continue; const T = Region.MOVEMENT_SEGMENT_TYPES; const first = segments[0].type; const last = segments.at(-1).type; const eventData = {token: this, origin, destination, teleport, forced, segments}; if ( (first === T.ENTER) && (last !== T.EXIT) ) region._triggerEvent(E.TOKEN_MOVE_IN, eventData); region._triggerEvent(E.TOKEN_MOVE, eventData); if ( (first !== T.ENTER) && (last === T.EXIT) ) region._triggerEvent(E.TOKEN_MOVE_OUT, eventData); } } /* -------------------------------------------- */ /** @inheritDoc */ _onDelete(options, userId) { if ( game.user.id === userId ) { // noinspection ES6MissingAwait game.combats._onDeleteToken(this.parent.id, this.id); } super._onDelete(options, userId); this.baseActor?._unregisterDependentToken(this); } /* -------------------------------------------- */ /** * Identify the Regions the Token currently is or is going to be in after the changes are applied. * @param {object} [changes] The changes. * @returns {string[]|void} The Region IDs the token is (sorted), if it could be determined. */ #identifyRegions(changes={}) { if ( !this.parent?.isView ) return; const regionIds = []; let token; for ( const region of this.parent.regions ) { if ( !region.object ) continue; token ??= this.clone(changes); const isInside = token.object.testInsideRegion(region.object); if ( isInside ) regionIds.push(region.id); } token?.object.destroy({children: true}); return regionIds.sort(); } /* -------------------------------------------- */ /** @inheritdoc */ static async _preCreateOperation(documents, operation, user) { const allowed = await super._preCreateOperation(documents, operation, user); if ( allowed === false ) return false; // Identify and set the regions the token is in for ( const document of documents ) document.updateSource({_regions: document.#identifyRegions() ?? []}); } /* -------------------------------------------- */ /** @inheritDoc */ static async _preUpdateOperation(documents, operation, user) { const allowed = await super._preUpdateOperation(documents, operation, user); if ( allowed === false ) return false; await TokenDocument.#preUpdateMovement(documents, operation, user); TokenDocument.#preUpdateRegions(documents, operation, user); } /* -------------------------------------------- */ /** * Handle Regions potentially stopping movement. * @param {TokenDocument[]} documents Document instances to be updated * @param {DatabaseUpdateOperation} operation Parameters of the database update operation * @param {User} user The User requesting the update operation */ static async #preUpdateMovement(documents, operation, user) { if ( !operation.parent.isView ) return; // Handle regions stopping movement const teleport = operation.teleport === true; for ( let i = 0; i < documents.length; i++ ) { const document = documents[i]; if ( !document.object ) continue; const changes = operation.updates[i]; // No action need unless position/elevation is changed if ( !(("x" in changes) || ("y" in changes) || ("elevation" in changes)) ) continue; // Prepare origin and destination const {x: originX, y: originY, elevation: originElevation} = document; const origin = {x: originX, y: originY, elevation: originElevation}; const destinationX = changes.x ?? originX; const destinationY = changes.y ?? originY; const destinationElevation = changes.elevation ?? originElevation; const destination = {x: destinationX, y: destinationY, elevation: destinationElevation}; // We look for the closest position to the origin where movement is broken let stopDestination; let stopDistance; // Iterate regions and test movement for ( const region of document.parent.regions ) { if ( !region.object ) continue; // Collect behaviors that can break movement const behaviors = region.behaviors.filter(b => !b.disabled && b.hasEvent(CONST.REGION_EVENTS.TOKEN_PRE_MOVE)); if ( behaviors.length === 0 ) continue; // Reset token so that it isn't in an animated state if ( document.object.animationContexts.size !== 0 ) document.reset(); // Break the movement into its segments const segments = document.object.segmentizeRegionMovement(region.object, [origin, destination], {teleport}); if ( segments.length === 0 ) continue; // Create the TOKEN_PRE_MOVE event const event = { name: CONST.REGION_EVENTS.TOKEN_PRE_MOVE, data: {token: document, origin, destination, teleport, segments}, region, user }; // Find the closest destination where movement is broken for ( const behavior of behaviors ) { // Dispatch event try { await behavior._handleRegionEvent(event); } catch(e) { console.error(e); } // Check if the destination of the event data was modified const destination = event.data.destination; if ( (destination.x === destinationX) && (destination.y === destinationY) && (destination.elevation === destinationElevation) ) continue; // Choose the closer destination const distance = Math.hypot( destination.x - origin.x, destination.y - origin.y, (destination.elevation - origin.elevation) * canvas.dimensions.distancePixels ); if ( !stopDestination || (distance < stopDistance) ) { stopDestination = {x: destination.x, y: destination.y, elevation: destination.elevation}; stopDistance = distance; } // Reset the destination event.data.destination = {x: destinationX, y: destinationY, elevation: destinationElevation}; } } // Update the destination to the stop position if the movement is broken if ( stopDestination ) { changes.x = stopDestination.x; changes.y = stopDestination.y; changes.elevation = stopDestination.elevation; } } } /* -------------------------------------------- */ /** * Identify and update the regions this Token is going to be in if necessary. * @param {TokenDocument[]} documents Document instances to be updated * @param {DatabaseUpdateOperation} operation Parameters of the database update operation */ static #preUpdateRegions(documents, operation) { if ( !operation.parent.isView ) return; // Update the regions the token is in for ( let i = 0; i < documents.length; i++ ) { const document = documents[i]; const changes = operation.updates[i]; if ( document._couldRegionsChange(changes) ) changes._regions = document.#identifyRegions(changes); } } /* -------------------------------------------- */ /** @override */ static async _onCreateOperation(documents, operation, user) { for ( const token of documents ) { for ( const region of token.regions ) { // noinspection ES6MissingAwait region._handleEvent({ name: CONST.REGION_EVENTS.TOKEN_ENTER, data: {token}, region, user }); } } } /* -------------------------------------------- */ /** @override */ static async _onUpdateOperation(documents, operation, user) { if ( !operation._priorRegions ) return; for ( const token of documents ) { const priorRegionIds = operation._priorRegions[token.id]; if ( !priorRegionIds ) continue; const priorRegions = new Set(); for ( const id of priorRegionIds ) { const region = token.parent.regions.get(id); if ( region ) priorRegions.add(region); } const addedRegions = token.regions.difference(priorRegions); const removedRegions = priorRegions.difference(token.regions); for ( const region of removedRegions ) { // noinspection ES6MissingAwait region._handleEvent({ name: CONST.REGION_EVENTS.TOKEN_EXIT, data: {token}, region, user }); } for ( const region of addedRegions ) { // noinspection ES6MissingAwait region._handleEvent({ name: CONST.REGION_EVENTS.TOKEN_ENTER, data: {token}, region, user }); } } } /* -------------------------------------------- */ /** @override */ static async _onDeleteOperation(documents, operation, user) { const regionEvents = []; for ( const token of documents ) { for ( const region of token.regions ) { region.tokens.delete(token); regionEvents.push({ name: CONST.REGION_EVENTS.TOKEN_EXIT, data: {token}, region, user }); } token.regions.clear(); } for ( const event of regionEvents ) { // noinspection ES6MissingAwait event.region._handleEvent(event); } } /* -------------------------------------------- */ /** * Is to Token document updated such that the Regions the Token is contained in may change? * Called as part of the preUpdate workflow. * @param {object} changes The changes. * @returns {boolean} Could this Token update change Region containment? * @protected */ _couldRegionsChange(changes) { const positionChange = ("x" in changes) || ("y" in changes); const elevationChange = "elevation" in changes; const sizeChange = ("width" in changes) || ("height" in changes); const shapeChange = this.parent.grid.isHexagonal && ("hexagonalShape" in changes); return positionChange || elevationChange || sizeChange || shapeChange; } /* -------------------------------------------- */ /* Actor Delta Operations */ /* -------------------------------------------- */ /** * Support the special case descendant document changes within an ActorDelta. * The descendant documents themselves are configured to have a synthetic Actor as their parent. * We need this to ensure that the ActorDelta receives these events which do not bubble up. * @inheritDoc */ _preCreateDescendantDocuments(parent, collection, data, options, userId) { if ( parent !== this.delta ) this.delta?._handleDeltaCollectionUpdates(parent); } /* -------------------------------------------- */ /** @inheritDoc */ _preUpdateDescendantDocuments(parent, collection, changes, options, userId) { if ( parent !== this.delta ) this.delta?._handleDeltaCollectionUpdates(parent); } /* -------------------------------------------- */ /** @inheritDoc */ _preDeleteDescendantDocuments(parent, collection, ids, options, userId) { if ( parent !== this.delta ) this.delta?._handleDeltaCollectionUpdates(parent); } /* -------------------------------------------- */ /** @inheritDoc */ _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) { super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId); this._onRelatedUpdate(data, options); } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) { super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId); this._onRelatedUpdate(changes, options); } /* -------------------------------------------- */ /** @inheritDoc */ _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) { super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId); this._onRelatedUpdate({}, options); } /* -------------------------------------------- */ /** * When the base Actor for a TokenDocument changes, we may need to update its Actor instance * @param {object} update * @param {object} options * @internal */ _onUpdateBaseActor(update={}, options={}) { // Update synthetic Actor data if ( !this.isLinked && this.delta ) { this.delta.updateSyntheticActor(); for ( const collection of Object.values(this.delta.collections) ) collection.initialize({ full: true }); this.actor.sheet.render(false, {renderContext: "updateActor"}); } this._onRelatedUpdate(update, options); } /* -------------------------------------------- */ /** * Whenever the token's actor delta changes, or the base actor changes, perform associated refreshes. * @param {object} [update] The update delta. * @param {Partial} [operation] The database operation that was performed * @protected */ _onRelatedUpdate(update={}, operation={}) { // Update tracked Combat resource const c = this.combatant; if ( c && foundry.utils.hasProperty(update.system || {}, game.combat.settings.resource) ) { c.updateResource(); } if ( this.inCombat ) ui.combat.render(); // Trigger redraws on the token if ( this.parent.isView ) { if ( this.object?.hasActiveHUD ) canvas.tokens.hud.render(); this.object?.renderFlags.set({refreshBars: true, redrawEffects: true}); const configs = Object.values(this.apps).filter(app => app instanceof TokenConfig); configs.forEach(app => { app.preview?.updateSource({delta: this.toObject().delta}, {diff: false, recursive: false}); app.preview?.object?.renderFlags.set({refreshBars: true, redrawEffects: true}); }); } } /* -------------------------------------------- */ /** * @typedef {object} TrackedAttributesDescription * @property {string[][]} bar A list of property path arrays to attributes with both a value and a max property. * @property {string[][]} value A list of property path arrays to attributes that have only a value property. */ /** * Get an Array of attribute choices which could be tracked for Actors in the Combat Tracker * @param {object|DataModel|typeof DataModel|SchemaField|string} [data] The object to explore for attributes, or an * Actor type. * @param {string[]} [_path] * @returns {TrackedAttributesDescription} */ static getTrackedAttributes(data, _path=[]) { // Case 1 - Infer attributes from schema structure. if ( (data instanceof foundry.abstract.DataModel) || foundry.utils.isSubclass(data, foundry.abstract.DataModel) ) { return this._getTrackedAttributesFromSchema(data.schema, _path); } if ( data instanceof foundry.data.fields.SchemaField ) return this._getTrackedAttributesFromSchema(data, _path); // Case 2 - Infer attributes from object structure. if ( ["Object", "Array"].includes(foundry.utils.getType(data)) ) { return this._getTrackedAttributesFromObject(data, _path); } // Case 3 - Retrieve explicitly configured attributes. if ( !data || (typeof data === "string") ) { const config = this._getConfiguredTrackedAttributes(data); if ( config ) return config; data = undefined; } // Track the path and record found attributes if ( data !== undefined ) return {bar: [], value: []}; // Case 4 - Infer attributes from system template. const bar = new Set(); const value = new Set(); for ( let [type, model] of Object.entries(game.model.Actor) ) { const dataModel = CONFIG.Actor.dataModels?.[type]; const inner = this.getTrackedAttributes(dataModel ?? model, _path); inner.bar.forEach(attr => bar.add(attr.join("."))); inner.value.forEach(attr => value.add(attr.join("."))); } return { bar: Array.from(bar).map(attr => attr.split(".")), value: Array.from(value).map(attr => attr.split(".")) }; } /* -------------------------------------------- */ /** * Retrieve an Array of attribute choices from a plain object. * @param {object} data The object to explore for attributes. * @param {string[]} _path * @returns {TrackedAttributesDescription} * @protected */ static _getTrackedAttributesFromObject(data, _path=[]) { 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; } /* -------------------------------------------- */ /** * Retrieve an Array of attribute choices from a SchemaField. * @param {SchemaField} schema The schema to explore for attributes. * @param {string[]} _path * @returns {TrackedAttributesDescription} * @protected */ static _getTrackedAttributesFromSchema(schema, _path=[]) { const attributes = {bar: [], value: []}; for ( const [name, field] of Object.entries(schema.fields) ) { const p = _path.concat([name]); if ( field instanceof foundry.data.fields.NumberField ) attributes.value.push(p); const isSchema = field instanceof foundry.data.fields.SchemaField; const isModel = field instanceof foundry.data.fields.EmbeddedDataField; if ( isSchema || isModel ) { const schema = isModel ? field.model.schema : field; const isBar = schema.has("value") && schema.has("max"); if ( isBar ) attributes.bar.push(p); else { const inner = this.getTrackedAttributes(schema, p); attributes.bar.push(...inner.bar); attributes.value.push(...inner.value); } } } return attributes; } /* -------------------------------------------- */ /** * Retrieve any configured attributes for a given Actor type. * @param {string} [type] The Actor type. * @returns {TrackedAttributesDescription|void} * @protected */ static _getConfiguredTrackedAttributes(type) { // If trackable attributes are not configured fallback to the system template if ( foundry.utils.isEmpty(CONFIG.Actor.trackableAttributes) ) return; // If the system defines trackableAttributes per type let config = foundry.utils.deepClone(CONFIG.Actor.trackableAttributes[type]); // Otherwise union all configured trackable attributes if ( foundry.utils.isEmpty(config) ) { const bar = new Set(); const value = new Set(); for ( const attrs of Object.values(CONFIG.Actor.trackableAttributes) ) { attrs.bar.forEach(bar.add, bar); attrs.value.forEach(value.add, value); } config = { bar: Array.from(bar), value: Array.from(value) }; } // Split dot-separate attribute paths into arrays Object.keys(config).forEach(k => config[k] = config[k].map(attr => attr.split("."))); return config; } /* -------------------------------------------- */ /** * 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(); const barGroup = game.i18n.localize("TOKEN.BarAttributes"); const valueGroup = game.i18n.localize("TOKEN.BarValues"); const bars = attributes.bar.map(v => { const a = v.join("."); return {group: barGroup, value: a, label: a}; }); bars.sort((a, b) => a.value.compare(b.value)); const values = attributes.value.map(v => { const a = v.join("."); return {group: valueGroup, value: a, label: a}; }); values.sort((a, b) => a.value.compare(b.value)); return bars.concat(values); } /* -------------------------------------------- */ /* Deprecations */ /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ getActor() { foundry.utils.logCompatibilityWarning("TokenDocument#getActor has been deprecated. Please use the " + "TokenDocument#actor getter to retrieve the Actor instance that the TokenDocument represents, or use " + "TokenDocument#delta#apply to generate a new synthetic Actor instance."); return this.delta?.apply() ?? this.baseActor ?? null; } /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ get actorData() { foundry.utils.logCompatibilityWarning("You are accessing TokenDocument#actorData which is deprecated. Source data " + "may be retrieved via TokenDocument#delta but all modifications/access should be done via the synthetic Actor " + "at TokenDocument#actor if possible.", {since: 11, until: 13}); return this.delta.toObject(); } set actorData(actorData) { foundry.utils.logCompatibilityWarning("You are accessing TokenDocument#actorData which is deprecated. Source data " + "may be retrieved via TokenDocument#delta but all modifications/access should be done via the synthetic Actor " + "at TokenDocument#actor if possible.", {since: 11, until: 13}); const id = this.delta.id; this.delta = new ActorDelta.implementation({...actorData, _id: id}, {parent: this}); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ async toggleActiveEffect(effectData, {overlay=false, active}={}) { foundry.utils.logCompatibilityWarning("TokenDocument#toggleActiveEffect is deprecated in favor of " + "Actor#toggleStatusEffect", {since: 12, until: 14}); if ( !this.actor || !effectData.id ) return false; return !!(await this.actor.toggleStatusEffect(effectData.id, {active, overlay})); } } /* -------------------------------------------- */ /* Proxy Prototype Token Methods */ /* -------------------------------------------- */ foundry.data.PrototypeToken.prototype.getBarAttribute = TokenDocument.prototype.getBarAttribute; /** * The client-side User document which extends the common BaseUser model. * Each User document contains UserData which defines its data schema. * * @extends foundry.documents.BaseUser * @mixes ClientDocumentMixin * * @see {@link Users} The world-level collection of User documents * @see {@link foundry.applications.sheets.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; this.border = this.color.multiply(2); } /* ---------------------------------------- */ /* 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; const current = hotbar[slot]; // Update the macro for the new slot const update = foundry.utils.deepClone(hotbar); if ( macro ) update[slot] = macro.id; else delete update[slot]; // Replace or remove the macro in the old slot if ( Number.isNumeric(fromSlot) && (fromSlot in hotbar) ) { if ( current ) update[fromSlot] = current; else 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 coordinates. * @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 {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. * Activity data uses a volatile event to prevent unnecessary buffering if the client temporarily loses connection. * @param {ActivityData} activityData An object of User activity data to submit to the server for broadcast. * @param {object} [options] * @param {boolean|undefined} [options.volatile] If undefined, volatile is inferred from the activity data. */ broadcastActivity(activityData={}, {volatile}={}) { volatile ??= !(("sceneId" in activityData) || (activityData.ruler === null) || ("targets" in activityData) || ("ping" in activityData) || ("av" in activityData)); if ( volatile ) game.socket.volatile.emit("userActivity", this.id, activityData); else 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(changed, options, userId) { super._onUpdate(changed, options, userId); // If the user role changed, we need to re-build the immutable User object if ( this._source.role !== this.role ) { const user = this.clone({}, {keepId: true}); game.users.set(user.id, user); return user._onUpdate(changed, options, userId); } // If your own password or role changed - you must re-authenticate const isSelf = changed._id === game.userId; if ( isSelf && ["password", "role"].some(k => k in changed) ) return game.logOut(); if ( !game.ready ) return; // User Color if ( "color" in changed ) { document.documentElement.style.setProperty(`--user-color-${this.id}`, this.color.css); if ( isSelf ) document.documentElement.style.setProperty("--user-color", this.color.css); } // Redraw Navigation if ( ["active", "character", "color", "role"].some(k => k in changed) ) { ui.nav?.render(); ui.players?.render(); } // Redraw Hotbar if ( isSelf && ("hotbar" in changed) ) ui.hotbar?.render(); // Reconnect to Audio/Video conferencing, or re-render camera views const webRTCReconnect = ["permissions", "role"].some(k => k in changed); if ( webRTCReconnect && (changed._id === game.userId) ) { game.webrtc?.client.updateLocalStream().then(() => game.webrtc.render()); } else if ( ["name", "avatar", "character"].some(k => k in changed) ) game.webrtc?.render(); // Update Canvas if ( canvas.ready ) { // Redraw Cursor if ( "color" in changed ) { canvas.controls.drawCursor(this); const ruler = canvas.controls.getRulerForUser(this.id); if ( ruler ) ruler.color = Color.from(changed.color); } if ( "active" in changed ) canvas.controls.updateCursor(this, null); // Modify impersonated character if ( isSelf && ("character" in changed) ) { 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 foundry.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) {} const BLEND_MODES = {}; /** * A custom blend mode equation which chooses the maximum color from each channel within the stack. * @type {number[]} */ BLEND_MODES.MAX_COLOR = [ WebGL2RenderingContext.ONE, WebGL2RenderingContext.ONE, WebGL2RenderingContext.ONE, WebGL2RenderingContext.ONE, WebGL2RenderingContext.MAX, WebGL2RenderingContext.MAX ]; /** * A custom blend mode equation which chooses the minimum color from each channel within the stack. * @type {number[]} */ BLEND_MODES.MIN_COLOR = [ WebGL2RenderingContext.ONE, WebGL2RenderingContext.ONE, WebGL2RenderingContext.ONE, WebGL2RenderingContext.ONE, WebGL2RenderingContext.MIN, WebGL2RenderingContext.MAX ]; /** * A custom blend mode equation which chooses the minimum color for color channels and min alpha from alpha channel. * @type {number[]} */ BLEND_MODES.MIN_ALL = [ WebGL2RenderingContext.ONE, WebGL2RenderingContext.ONE, WebGL2RenderingContext.ONE, WebGL2RenderingContext.ONE, WebGL2RenderingContext.MIN, WebGL2RenderingContext.MIN ]; /** * 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() { Object.defineProperty(this, "edges", {value: new foundry.canvas.edges.CanvasEdges()}); Object.defineProperty(this, "fog", {value: new CONFIG.Canvas.fogManager()}); Object.defineProperty(this, "perception", {value: new PerceptionManager()}); } /** * A 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; /** * Configure options passed to the texture loaded for the Scene. * This object can be configured during the canvasInit hook before textures have been loaded. * @type {{expireCache: boolean, additionalSources: string[]}} */ loadTexturesOptions; /** * Configure options used by the visibility framework for special effects * This object can be configured during the canvasInit hook before visibility is initialized. * @type {{persistentVision: boolean}} */ visibilityOptions; /** * Configure options passed to initialize blur for the Scene and override normal behavior. * This object can be configured during the canvasInit hook before blur is initialized. * @type {{enabled: boolean, blurClass: Class, strength: number, passes: number, kernels: number}} */ blurOptions; /** * Configure the Textures to apply to the Scene. * Textures registered here will be automatically loaded as part of the TextureLoader.loadSceneTextures workflow. * Textures which need to be loaded should be configured during the "canvasInit" hook. * @type {{[background]: string, [foreground]: string, [fogOverlay]: string}} */ sceneTextures = {}; /** * 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 {string} mipmap Whether to use mipmaps, "ON" or "OFF" * @property {boolean} msaa Whether to apply MSAA at the overall canvas level * @property {boolean} smaa Whether to apply SMAA 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 */ /** * Configured performance settings which affect the behavior of the Canvas and its renderer. * @type {CanvasPerformanceSettings} */ performance; /** * @typedef {Object} CanvasSupportedComponents * @property {boolean} webGL2 Is WebGL2 supported? * @property {boolean} readPixelsRED Is reading pixels in RED format supported? * @property {boolean} offscreenCanvas Is the OffscreenCanvas supported? */ /** * A list of supported webGL capabilities and limitations. * @type {CanvasSupportedComponents} */ supported; /** * Is the photosensitive mode enabled? * @type {boolean} */ photosensitiveMode; /** * The renderer screen dimensions. * @type {number[]} */ screenDimensions = [0, 0]; /** * A flag to indicate whether a new Scene is currently being drawn. * @type {boolean} */ loading = false; /** * A promise that resolves when the canvas is first initialized and ready. * @type {Promise|null} */ initializing = null; /* -------------------------------------------- */ /** * A throttled function that handles mouse moves. * @type {function()} */ #throttleOnMouseMove = foundry.utils.throttle(this.#onMouseMove.bind(this), 100); /** * An internal reference to a Promise in-progress to draw the canvas. * @type {Promise} */ #drawing = Promise.resolve(this); /* -------------------------------------------- */ /* 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 rendered canvas group which render the environment canvas group and the interface canvas group. * @see environment * @see interface * @type {RenderedCanvasGroup} */ rendered; /** * A singleton CanvasEdges instance. * @type {foundry.canvas.edges.CanvasEdges} */ edges; /** * The singleton FogManager instance. * @type {FogManager} */ fog; /** * A perception manager interface for batching lighting, sight, and sound updates. * @type {PerceptionManager} */ perception; /** * The environment canvas group which render the primary canvas group and the effects canvas group. * @see primary * @see effects * @type {EnvironmentCanvasGroup} */ environment; /** * 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, vision, fog of war and related animations. * @type {EffectsCanvasGroup} */ effects; /** * The visibility Canvas group which handles the fog of war overlay by consolidating multiple render textures, * and applying a filter with special effects and blur. * @type {CanvasVisibility} */ visibility; /** * 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 overlay Canvas group which is rendered above other groups and contains elements not bound to stage transform. * @type {OverlayCanvasGroup} */ overlay; /** * The singleton HeadsUpDisplay container which overlays HTML rendering on top of this Canvas. * @type {HeadsUpDisplay} */ hud; /** * Position of the mouse on stage. * @type {PIXI.Point} */ mousePosition = new PIXI.Point(); /** * 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 last automatic pan time to throttle * @type {number} * @private */ _panTime = 0; /* -------------------------------------------- */ /** * Force snapping to grid vertices? * @type {boolean} */ forceSnapVertices = false; /* -------------------------------------------- */ /* 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 SceneManager instance which adds behaviors to this Scene, or null if there is no manager. * @type {SceneManager|null} */ get manager() { return this.#manager; } #manager = null; /* -------------------------------------------- */ /** * @typedef {object} _CanvasDimensions * @property {PIXI.Rectangle} rect The canvas rectangle. * @property {PIXI.Rectangle} sceneRect The scene rectangle. */ /** * @typedef {SceneDimensions & _CanvasDimensions} CanvasDimensions */ /** * The current pixel dimensions of the displayed Scene, or null if the Canvas is blank. * @type {Readonly|null} */ get dimensions() { return this.#dimensions; } #dimensions = null; /* -------------------------------------------- */ /** * A reference to the grid of the currently displayed Scene document, or null if the Canvas is currently blank. * @type {foundry.grid.BaseGrid|null} */ get grid() { return this.scene?.grid ?? 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 colors bound to this scene and handled by the color manager. * @type {Color} */ get colors() { return this.environment.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 {Record} */ static get layers() { return CONFIG.Canvas.layers; } /* -------------------------------------------- */ /** * An Array of all CanvasLayer instances which are active on the Canvas board * @type {CanvasLayer[]} */ get layers() { const layers = []; for ( const [k, cfg] of Object.entries(CONFIG.Canvas.layers) ) { const l = this[cfg.group]?.[k] ?? this[k]; if ( l instanceof CanvasLayer ) layers.push(l); } return layers; } /* -------------------------------------------- */ /** * Return a reference to the active Canvas Layer * @type {CanvasLayer} */ get activeLayer() { for ( const layer of this.layers ) { 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.environment.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 game.issues._detectWebGLIssues(); // 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}); // Cache photosensitive mode Object.defineProperty(this, "photosensitiveMode", { value: game.settings.get("core", "photosensitiveMode"), writable: false }); // Create groups this.#createGroups("stage", this.stage); // Update state flags this.#scene = null; this.#manager = 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. */ 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} */ 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. */ 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() { BaseSamplerShader.registerPlugin({force: true}); OccludableSamplerShader.registerPlugin(); DepthSamplerShader.registerPlugin(); // Configure TokenRing CONFIG.Token.ring.ringClass.initialize(); } /* -------------------------------------------- */ /** * 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}); // Map all the custom blend modes this.#mapBlendModes(); // Attach specific behaviors to the PIXI runners this.#attachToRunners(); // Test the support of some GPU features const supported = this.#testSupport(app.renderer); Object.defineProperty(this, "supported", { value: Object.freeze(supported), writable: false, enumerable: true }); // Additional PIXI configuration : Adding the FramebufferSnapshot to the canvas const snapshot = new FramebufferSnapshot(); Object.defineProperty(this, "snapshot", {value: snapshot, writable: false}); } /* -------------------------------------------- */ /** * Attach specific behaviors to the PIXI runners. * - contextChange => Remap all the blend modes */ #attachToRunners() { const contextChange = { contextChange: () => { console.debug(`${vtt} | Recovering from context loss.`); this.#mapBlendModes(); this.hidden.invalidateMasks(); this.effects.illumination.invalidateDarknessLevelContainer(true); } }; this.app.renderer.runners.contextChange.add(contextChange); } /* -------------------------------------------- */ /** * Map custom blend modes and premultiplied blend modes. */ #mapBlendModes() { 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(); } /* -------------------------------------------- */ /** * 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); } /* -------------------------------------------- */ /** * Initialize the group containers of the game Canvas. * @param {string} parentName * @param {PIXI.DisplayObject} parent */ #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. * @param options Blur options. * @private */ _initializeBlur(options={}) { // Discard shared filters this.blurFilters.clear(); // Compute base values from grid size const gridSize = this.scene.grid.size; const blurStrength = gridSize / 25; const blurFactor = gridSize / 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: options.enabled ?? this.performance.mode > CONST.CANVAS_PERFORMANCE_MODES.MED, blurClass: options.blurClass ?? AlphaBlurFilter, blurPassClass: options.blurPassClass ?? AlphaBlurFilterPass, strength: options.strength ?? blurStrength, passes: options.passes ?? Math.clamp(level + Math.floor(blurFactor), 2, maxPass), kernels: options.kernels ?? Math.clamp((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"); // 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 ( mode === null ) { 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 mode = CONST.CANVAS_PERFORMANCE_MODES.HIGH; game.settings.storage.get("client").setItem("core.performanceMode", String(mode)); } // Construct performance settings object const settings = { mode: mode, mipmap: mip ? "ON" : "OFF", msaa: false, smaa: false, fps: Math.clamp(fps, 0, 60), tokenAnimation: true, lightAnimation: true, lightSoftEdges: false }; // Low settings if ( mode >= modes.LOW ) { settings.tokenAnimation = false; settings.lightAnimation = false; } // Medium settings if ( mode >= modes.MED ) { settings.lightSoftEdges = true; settings.smaa = true; } // Max settings if ( mode === modes.MAX ) { if ( settings.fps === 60 ) settings.fps = 0; } // Configure performance settings PIXI.BaseTexture.defaultOptions.mipmap = PIXI.MIPMAP_MODES[settings.mipmap]; // Use the resolution and multisample of the current render target for filters by default PIXI.Filter.defaultResolution = null; PIXI.Filter.defaultMultisample = null; 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) { this.#drawing = this.#drawing.finally(this.#draw.bind(this, scene)); await this.#drawing; return this; } /* -------------------------------------------- */ /** * Draw the game canvas. * This method is wrapped by a promise that enqueues multiple draw requests. * @param {Scene} [scene] A specific Scene document to render on the Canvas * @returns {Promise} */ 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.error(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; // Draw a blank canvas if ( this.#scene === null ) return this.#drawBlank(); // Configure Scene dimensions const {rect, sceneRect, ...sceneDimensions} = scene.getDimensions(); this.#dimensions = Object.assign(sceneDimensions, { rect: new PIXI.Rectangle(rect.x, rect.y, rect.width, rect.height), sceneRect: new PIXI.Rectangle(sceneRect.x, sceneRect.y, sceneRect.width, sceneRect.height) }); canvas.app.view.style.display = "block"; document.documentElement.style.setProperty("--gridSize", `${this.dimensions.size}px`); // Configure a SceneManager instance this.#manager = Canvas.getSceneManager(this.#scene); // Initialize the basis transcoder if ( CONFIG.Canvas.transcoders.basis ) await TextureLoader.initializeBasisTranscoder(); // Call Canvas initialization hooks this.loadTexturesOptions = {expireCache: true, additionalSources: []}; this.visibilityOptions = {persistentVision: false}; console.log(`${vtt} | Drawing game canvas for scene ${this.#scene.name}`); await this.#callManagerEvent("_onInit"); await this.#callManagerEvent("_registerHooks"); Hooks.callAll("canvasInit", this); // Configure attributes of the Stage this.stage.position.set(window.innerWidth / 2, window.innerHeight / 2); this.stage.hitArea = {contains: () => true}; this.stage.eventMode = "static"; this.stage.sortableChildren = true; // Initialize the camera view position (although the canvas is hidden) this.initializeCanvasPosition(); // Initialize blur parameters this._initializeBlur(this.blurOptions); // Load required textures try { await TextureLoader.loadSceneTextures(this.#scene, this.loadTexturesOptions); } catch(err) { Hooks.onError("Canvas#draw", err, { msg: `Texture loading failed: ${err.message}`, log: "error", notify: "error" }); this.loading = false; return; } // Configure the SMAA filter if ( this.performance.smaa ) this.stage.filters = [new foundry.canvas.SMAAFilter()]; // Configure TokenRing CONFIG.Token.ring.ringClass.createAssetsUVs(); // Activate ticker render workflows this.#activateTicker(); // Draw canvas groups await this.#callManagerEvent("_onDraw"); Hooks.callAll("canvasDraw", this); for ( const name of Object.keys(CONFIG.Canvas.groups) ) { const group = this[name]; try { await group.draw(); } catch(err) { Hooks.onError("Canvas#draw", err, { msg: `Failed drawing ${name} canvas group: ${err.message}`, log: "error", notify: "error" }); this.loading = false; return; } } // 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.interface.templates.mask = this.masks.canvas; // Compute the scene scissor mask const sr = canvas.dimensions.sceneRect; this.masks.scene.clear().beginFill(0xFFFFFF, 1.0).drawRect(sr.x, sr.y, sr.width, sr.height).endFill(); // Initialize starting conditions await this.#initialize(); this.#scene._view = true; this.stage.visible = true; await this.#callManagerEvent("_onReady"); Hooks.call("canvasReady", this); // Record that loading was complete and return this.loading = false; // Trigger Region status events await this.#handleRegionBehaviorStatusEvents(true); MouseInteractionManager.emulateMoveEvent(); } /* -------------------------------------------- */ /** * When re-drawing the canvas, first tear down or discontinue some existing processes * @returns {Promise} */ async tearDown() { this.stage.visible = false; this.stage.filters = null; this.sceneTextures = {}; this.blurOptions = undefined; // 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 ticker workflows this.#deactivateTicker(); this.deactivateFPSMeter(); // Deactivate every layer before teardown for ( let l of this.layers.reverse() ) { if ( l instanceof InteractionLayer ) l.deactivate(); } // Trigger Region status events await this.#handleRegionBehaviorStatusEvents(false); // Call tear-down hooks await this.#callManagerEvent("_deactivateHooks"); await this.#callManagerEvent("_onTearDown"); 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(); } // Clear edges this.edges.clear(); // Discard shared filters this.blurFilters.clear(); // Create a new event boundary for the stage this.app.renderer.events.rootBoundary = new PIXI.EventBoundary(this.stage); MouseInteractionManager.emulateMoveEvent(); } /* -------------------------------------------- */ /** * Handle Region BEHAVIOR_STATUS events that are triggered when the Scene is (un)viewed. * @param {boolean} viewed Is the scene viewed or not? */ async #handleRegionBehaviorStatusEvents(viewed) { const results = await Promise.allSettled(this.scene.regions.map(region => region._handleEvent({ name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: {viewed}, region, user: game.user }))); for ( const result of results ) { if ( result.status === "rejected" ) console.error(result.reason); } } /* -------------------------------------------- */ /** * Create a SceneManager instance used for this Scene, if any. * @param {Scene} scene * @returns {foundry.canvas.SceneManager|null} * @internal */ static getSceneManager(scene) { const managerCls = CONFIG.Canvas.managedScenes[scene.id]; return managerCls ? new managerCls(scene) : null; } /* -------------------------------------------- */ /** * A special workflow to perform when rendering a blank Canvas with no active Scene. */ #drawBlank() { console.log(`${vtt} | Skipping game canvas - no active scene.`); canvas.app.view.style.display = "none"; ui.controls.render(); this.loading = this.#ready = false; this.#manager = null; this.#dimensions = null; MouseInteractionManager.emulateMoveEvent(); } /* -------------------------------------------- */ /** * 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} */ 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); // Initialize canvas conditions this.#initializeCanvasLayer(); this.#initializeTokenControl(); this._onResize(); this.#reload = {}; // Initialize edges and perception this.edges.initialize(); this.perception.initialize(); // Broadcast user presence in the Scene and request user activity data game.user.viewedScene = this.#scene.id; game.user.broadcastActivity({sceneId: this.#scene.id, cursor: null, ruler: null, targets: []}); game.socket.emit("getUserActivity"); // Activate user interaction this.#addListeners(); // Call PCO sorting canvas.primary.sortChildren(); } /* -------------------------------------------- */ /** * 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.clamp(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 */ #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. */ #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 && !canvas.effects.visionSources.size ) { 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}); } /* -------------------------------------------- */ /** * Safely call a function of the SceneManager instance, catching and logging any errors. * @param {string} fnName The name of the manager function to invoke * @returns {Promise} */ async #callManagerEvent(fnName) { if ( !this.#manager ) return; const fn = this.#manager[fnName]; try { if ( !(fn instanceof Function) ) { console.error(`Invalid SceneManager function name "${fnName}"`); return; } await fn.call(this.#manager); } catch(err) { err.message = `${this.#manager.constructor.name}#${fnName} failed with error: ${err.message}`; console.error(err); } } /* -------------------------------------------- */ /** * 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, MeasuredTemplate: this.templates, Note: this.notes, Region: this.regions, Tile: this.tiles, Token: this.tokens, Wall: this.walls }[embeddedName] || null; } /* -------------------------------------------- */ /** * Get the InteractionLayer of the canvas which manages Documents of a certain collection within the Scene. * @param {string} collectionName The collection name * @returns {PlaceablesLayer} The canvas layer */ getCollectionLayer(collectionName) { return { drawings: this.drawings, lights: this.lighting, notes: this.notes, regions: this.regions, sounds: this.sounds, templates: this.templates, tiles: this.tiles, tokens: this.tokens, walls: this.walls }[collectionName]; } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** * Activate framerate tracking by adding an HTML element to the display and refreshing it every frame. */ activateFPSMeter() { this.deactivateFPSMeter(); if ( !this.#ready ) return; this.fps.element.style.display = "block"; this.app.ticker.add(this.#measureFPS, this, PIXI.UPDATE_PRIORITY.LOW); } /* -------------------------------------------- */ /** * Deactivate framerate tracking by canceling ticker updates and removing the HTML element. */ deactivateFPSMeter() { this.app.ticker.remove(this.#measureFPS, this); this.fps.element.style.display = "none"; } /* -------------------------------------------- */ /** * Measure average framerate per second over the past 30 frames */ #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 this.scene._viewPosition = constrained; // Call hooks Hooks.callAll("canvasPan", this, constrained); // Update controls this.controls._onCanvasPan(); // Align the HUD this.hud.align(); // Invalidate cached containers this.hidden.invalidateMasks(); this.effects.illumination.invalidateDarknessLevelContainer(); // Emulate mouse event to update the hover states MouseInteractionManager.emulateMoveEvent(); } /* -------------------------------------------- */ /** * 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 * @param {Function} [view.easing] An easing function passed to CanvasAnimation animate * @returns {Promise} A Promise which resolves once the animation has been completed */ async animatePan({x, y, scale, duration=250, speed, easing}={}) { // 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 position = {...this.scene._viewPosition}; const constrained = this._constrainView({x, y, scale}); // Trigger the animation function return CanvasAnimation.animate([ {parent: position, attribute: "x", to: constrained.x}, {parent: position, attribute: "y", to: constrained.y}, {parent: position, attribute: "scale", to: constrained.scale} ], { name: "canvas.animatePan", duration: duration, easing: easing ?? CanvasAnimation.easeInOutCosine, ontick: () => this.pan(position) }); } /* -------------------------------------------- */ /** * 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.highlightObjects = active; for ( let o of layer.placeables ) { o.renderFlags.set({refreshState: true}); } } if ( canvas.tokens.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.HIGHLIGHTED ) { canvas.perception.update({refreshOcclusion: true}); } /** @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) { // Don't allow pinging outside of the canvas bounds if ( !this.dimensions.rect.contains(origin.x, origin.y) ) return false; // Configure the ping to be dispatched const types = CONFIG.Canvas.pings.types; const isPull = 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 * @internal */ _constrainView({x, y, scale}) { if ( !Number.isNumeric(x) ) x = this.stage.pivot.x; if ( !Number.isNumeric(y) ) y = this.stage.pivot.y; if ( !Number.isNumeric(scale) ) scale = this.stage.scale.x; const d = canvas.dimensions; // Constrain the scale to the maximum zoom level const maxScale = CONFIG.Canvas.maxZoom; const minScale = 1 / Math.max(d.width / window.innerWidth, d.height / window.innerHeight, maxScale); scale = Math.clamp(scale, minScale, maxScale); // Constrain the pivot point using the new scale const padX = 0.4 * (window.innerWidth / scale); const padY = 0.4 * (window.innerHeight / scale); x = Math.clamp(x, -padX, d.width + padX); y = Math.clamp(y, -padY, d.height + padY); // 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 * @param {number} blurQuality The desired quality to use for this filter * @returns {PIXI.BlurFilter} */ createBlurFilter(blurStrength, blurQuality=CONFIG.Canvas.blurQuality) { const configuredStrength = blurStrength ?? this.blur.strength ?? CONFIG.Canvas.blurStrength; const f = new PIXI.BlurFilter(configuredStrength, blurQuality); f._configuredStrength = configuredStrength; this.addBlurFilter(f); return f; } /* -------------------------------------------- */ /** * Add a filter to the blur filter list. The filter must have the blur property * @param {PIXI.BlurFilter} filter The Filter instance to add * @returns {PIXI.BlurFilter} The added filter for method chaining */ addBlurFilter(filter) { if ( filter.blur === undefined ) return; filter.blur = (filter._configuredStrength ?? this.blur.strength ?? CONFIG.Canvas.blurStrength) * 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 ?? filter._configuredStrength ?? this.blur.strength ?? CONFIG.Canvas.blurStrength) * this.stage.scale.x; } } /* -------------------------------------------- */ /** * Convert canvas coordinates to the client's viewport. * @param {Point} origin The canvas coordinates. * @returns {Point} The corresponding coordinates relative to the client's viewport. */ clientCoordinatesFromCanvas(origin) { const point = {x: origin.x, y: origin.y}; return this.stage.worldTransform.apply(point, point); } /* -------------------------------------------- */ /** * Convert client viewport coordinates to canvas coordinates. * @param {Point} origin The client coordinates. * @returns {Point} The corresponding canvas coordinates. */ canvasCoordinatesFromClient(origin) { const point = {x: origin.x, y: origin.y}; return this.stage.worldTransform.applyInverse(point, point); } /* -------------------------------------------- */ /** * Determine whether given canvas coordinates are off-screen. * @param {Point} position The canvas coordinates. * @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); } } /* -------------------------------------------- */ /** * Get a texture with the required configuration and clear color. * @param {object} options * @param {number[]} [options.clearColor] The clear color to use for this texture. Transparent by default. * @param {object} [options.textureConfiguration] The render texture configuration. * @returns {PIXI.RenderTexture} */ static getRenderTexture({clearColor, textureConfiguration}={}) { const texture = PIXI.RenderTexture.create(textureConfiguration); if ( clearColor ) texture.baseTexture.clearColor = clearColor; return texture; } /* -------------------------------------------- */ /* Event Handlers /* -------------------------------------------- */ /** * Attach event listeners to the game canvas to handle click and interaction events */ #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: this._onDragRightStart.bind(this), dragRightMove: this._onDragRightMove.bind(this), dragRightDrop: this._onDragRightDrop.bind(this), dragRightCancel: this._onDragRightCancel.bind(this), longPress: this.#onLongPress.bind(this) }; // Create and activate the interaction manager const permissions = { clickRight2: false, dragLeftStart: this.#canDragLeftStart.bind(this) }; 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(); this.dt = 0; // Add a listener for cursor movement this.stage.on("pointermove", event => { event.getLocalPosition(this.stage, this.mousePosition); this.#throttleOnMouseMove(); }); } /* -------------------------------------------- */ /** * Handle mouse movement on the game canvas. */ #onMouseMove() { this.controls._onMouseMove(); this.sounds._onMouseMove(); this.primary._onMouseMove(); } /* -------------------------------------------- */ /** * Handle left mouse-click events occurring on the Canvas. * @see {MouseInteractionManager##handleClickLeft} * @param {PIXI.FederatedEvent} event */ #onClickLeft(event) { const layer = this.activeLayer; if ( layer instanceof InteractionLayer ) return layer._onClickLeft(event); } /* -------------------------------------------- */ /** * Handle double left-click events occurring on the Canvas. * @see {MouseInteractionManager##handleClickLeft2} * @param {PIXI.FederatedEvent} event */ #onClickLeft2(event) { const layer = this.activeLayer; if ( layer instanceof InteractionLayer ) return layer._onClickLeft2(event); } /* -------------------------------------------- */ /** * Handle long press events occurring on the Canvas. * @see {MouseInteractionManager##handleLongPress} * @param {PIXI.FederatedEvent} event The triggering canvas interaction event. * @param {PIXI.Point} origin The local canvas coordinates of the mousepress. */ #onLongPress(event, origin) { canvas.controls._onLongPress(event, origin); } /* -------------------------------------------- */ /** * Does the User have permission to left-click drag on the Canvas? * @param {User} user The User performing the action. * @param {PIXI.FederatedEvent} event The event object. * @returns {boolean} */ #canDragLeftStart(user, event) { const layer = this.activeLayer; if ( (layer instanceof TokenLayer) && CONFIG.Canvas.rulerClass.canMeasure ) return !this.controls.ruler.active; if ( ["select", "target"].includes(game.activeTool) ) return true; if ( layer instanceof InteractionLayer ) return layer._canDragLeftStart(user, event); return false; } /* -------------------------------------------- */ /** * Handle the beginning of a left-mouse drag workflow on the Canvas stage or its active Layer. * @see {MouseInteractionManager##handleDragStart} * @param {PIXI.FederatedEvent} event */ #onDragLeftStart(event) { const layer = this.activeLayer; // Begin ruler measurement if ( (layer instanceof TokenLayer) && CONFIG.Canvas.rulerClass.canMeasure ) { event.interactionData.ruler = true; 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.interactionData.coords; canvas.controls.select.active = true; return; } // Dispatch the event to the active layer if ( layer instanceof InteractionLayer ) return layer._onDragLeftStart(event); } /* -------------------------------------------- */ /** * Handle mouse movement events occurring on the Canvas. * @see {MouseInteractionManager##handleDragMove} * @param {PIXI.FederatedEvent} event */ #onDragLeftMove(event) { const layer = this.activeLayer; // Pan the canvas if the drag event approaches the edge this._onDragCanvasPan(event); // Continue a Ruler measurement if ( event.interactionData.ruler ) return this.controls.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 ) return layer._onDragLeftMove(event); } /* -------------------------------------------- */ /** * Handle the conclusion of a left-mouse drag workflow when the mouse button is released. * @see {MouseInteractionManager##handleDragDrop} * @param {PIXI.FederatedEvent} event * @internal */ #onDragLeftDrop(event) { // Extract event data const coords = event.interactionData.coords; const tool = game.activeTool; const layer = canvas.activeLayer; // Conclude a measurement event if we aren't holding the CTRL key if ( event.interactionData.ruler ) return canvas.controls.ruler._onMouseUp(event); // Conclude a select event const isSelect = ["select", "target"].includes(tool); const targetKeyDown = game.keyboard.isCoreActionKeyActive("target"); if ( isSelect && canvas.controls.select.active && (layer instanceof PlaceablesLayer) ) { canvas.controls.select.clear(); canvas.controls.select.active = false; const releaseOthers = !event.shiftKey; if ( !coords ) return; if ( tool === "select" && !targetKeyDown ) return layer.selectObjects(coords, {releaseOthers}); else if ( tool === "target" || targetKeyDown ) return layer.targetObjects(coords, {releaseOthers}); } // Dispatch the event to the active layer if ( layer instanceof InteractionLayer ) return 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 unless the token was moved by the ruler if ( event.interactionData.ruler ) { const ruler = canvas.controls.ruler; return !ruler.active || (ruler.state === Ruler.STATES.MOVING); } // Clear selection const isSelect = ["select", "target"].includes(tool); if ( isSelect ) { canvas.controls.select.clear(); return; } // Dispatch the event to the active layer if ( layer instanceof InteractionLayer ) return layer._onDragLeftCancel(event); } /* -------------------------------------------- */ /** * Handle right mouse-click events occurring on the Canvas. * @see {MouseInteractionManager##handleClickRight} * @param {PIXI.FederatedEvent} event */ #onClickRight(event) { const ruler = canvas.controls.ruler; if ( ruler.state === Ruler.STATES.MEASURING ) return ruler._onClickRight(event); // Dispatch to the active layer const layer = this.activeLayer; if ( layer instanceof InteractionLayer ) return layer._onClickRight(event); } /* -------------------------------------------- */ /** * Handle double right-click events occurring on the Canvas. * @see {MouseInteractionManager##handleClickRight} * @param {PIXI.FederatedEvent} event */ #onClickRight2(event) { const layer = this.activeLayer; if ( layer instanceof InteractionLayer ) return layer._onClickRight2(event); } /* -------------------------------------------- */ /** * Handle right-mouse start drag events occurring on the Canvas. * @see {MouseInteractionManager##handleDragStart} * @param {PIXI.FederatedEvent} event * @internal */ _onDragRightStart(event) {} /* -------------------------------------------- */ /** * Handle right-mouse drag events occurring on the Canvas. * @see {MouseInteractionManager##handleDragMove} * @param {PIXI.FederatedEvent} event * @internal */ _onDragRightMove(event) { // Extract event data const {origin, destination} = event.interactionData; const dx = destination.x - origin.x; const dy = destination.y - origin.y; // Pan the canvas this.pan({ x: canvas.stage.pivot.x - (dx * CONFIG.Canvas.dragSpeedModifier), y: canvas.stage.pivot.y - (dy * CONFIG.Canvas.dragSpeedModifier) }); // 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.FederatedEvent} event * @internal */ _onDragRightDrop(event) {} /* -------------------------------------------- */ /** * Handle the cancellation of a right-mouse drag workflow the Canvas stage. * @see {MouseInteractionManager##handleDragCancel} * @param {PIXI.FederatedEvent} event * @internal */ _onDragRightCancel(event) {} /* -------------------------------------------- */ /** * Determine selection coordinate rectangle during a mouse-drag workflow * @param {PIXI.FederatedEvent} event */ #onDragSelect(event) { // Extract event data const {origin, destination} = event.interactionData; // 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.interactionData.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} = this.canvasCoordinatesFromClient({x: event.clientX, y: event.clientY}); data.x = x; data.y = 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); } } /* -------------------------------------------- */ /* Pre-Rendering Workflow */ /* -------------------------------------------- */ /** * Track objects which have pending render flags. * @enum {Set} */ pendingRenderFlags; /** * Cached references to bound ticker functions which can be removed later. * @type {Record} */ #tickerFunctions = {}; /* -------------------------------------------- */ /** * Activate ticker functions which should be called as part of the render loop. * This occurs as part of setup for a newly viewed Scene. */ #activateTicker() { const p = PIXI.UPDATE_PRIORITY; // Define custom ticker priorities Object.assign(p, { OBJECTS: p.HIGH - 2, PRIMARY: p.NORMAL + 3, PERCEPTION: p.NORMAL + 2 }); // Create pending queues Object.defineProperty(this, "pendingRenderFlags", { value: { OBJECTS: new Set(), PERCEPTION: new Set() }, configurable: true, writable: false }); // Apply PlaceableObject RenderFlags this.#tickerFunctions.OBJECTS = this.#applyRenderFlags.bind(this, this.pendingRenderFlags.OBJECTS); this.app.ticker.add(this.#tickerFunctions.OBJECTS, undefined, p.OBJECTS); // Update the primary group this.#tickerFunctions.PRIMARY = this.primary.update.bind(this.primary); this.app.ticker.add(this.#tickerFunctions.PRIMARY, undefined, p.PRIMARY); // Update Perception this.#tickerFunctions.PERCEPTION = this.#applyRenderFlags.bind(this, this.pendingRenderFlags.PERCEPTION); this.app.ticker.add(this.#tickerFunctions.PERCEPTION, undefined, p.PERCEPTION); } /* -------------------------------------------- */ /** * Deactivate ticker functions which were previously registered. * This occurs during tear-down of a previously viewed Scene. */ #deactivateTicker() { for ( const queue of Object.values(this.pendingRenderFlags) ) queue.clear(); for ( const [k, fn] of Object.entries(this.#tickerFunctions) ) { canvas.app.ticker.remove(fn); delete this.#tickerFunctions[k]; } } /* -------------------------------------------- */ /** * Apply pending render flags which should be handled at a certain ticker priority. * @param {Set} queue The queue of objects to handle */ #applyRenderFlags(queue) { if ( !queue.size ) return; const objects = Array.from(queue); queue.clear(); for ( const object of objects ) object.applyRenderFlags(); } /* -------------------------------------------- */ /** * Test support for some GPU capabilities and update the supported property. * @param {PIXI.Renderer} renderer */ #testSupport(renderer) { const supported = {}; const gl = renderer?.gl; if ( !(gl instanceof WebGL2RenderingContext) ) { supported.webGL2 = false; return supported; } supported.webGL2 = true; let renderTexture; // Test support for reading pixels in RED/UNSIGNED_BYTE format renderTexture = PIXI.RenderTexture.create({ width: 1, height: 1, format: PIXI.FORMATS.RED, type: PIXI.TYPES.UNSIGNED_BYTE, resolution: 1, multisample: PIXI.MSAA_QUALITY.NONE }); renderer.renderTexture.bind(renderTexture); const format = gl.getParameter(gl.IMPLEMENTATION_COLOR_READ_FORMAT); const type = gl.getParameter(gl.IMPLEMENTATION_COLOR_READ_TYPE); supported.readPixelsRED = (format === gl.RED) && (type === gl.UNSIGNED_BYTE); renderer.renderTexture.bind(); renderTexture?.destroy(true); // Test support for OffscreenCanvas try { supported.offscreenCanvas = (typeof OffscreenCanvas !== "undefined") && (!!new OffscreenCanvas(10, 10).getContext("2d")); } catch(e) { supported.offscreenCanvas = false; } return supported; } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ addPendingOperation(name, fn, scope, args) { const msg = "Canvas#addPendingOperation is deprecated without replacement in v11. The callback that you have " + "passed as a pending operation has been executed immediately. We recommend switching your code to use a " + "debounce operation or RenderFlags to de-duplicate overlapping requests."; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); fn.call(scope, ...args); } /** * @deprecated since v11 * @ignore */ triggerPendingOperations() { const msg = "Canvas#triggerPendingOperations is deprecated without replacement in v11 and performs no action."; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); } /** * @deprecated since v11 * @ignore */ get pendingOperations() { const msg = "Canvas#pendingOperations is deprecated without replacement in v11."; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return []; } /** * @deprecated since v12 * @ignore */ get colorManager() { const msg = "Canvas#colorManager is deprecated and replaced by Canvas#environment"; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return this.environment; } } /** * 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 RenderFlagsMixin(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; /** * A control icon for interacting with the object * @type {ControlIcon|null} */ 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; } /* -------------------------------------------- */ /* Properties */ /* -------------------------------------------- */ /** * Identify the official Document name for this PlaceableObject class * @type {string} */ static embeddedName; /** * The flags declared here are required for all PlaceableObject subclasses to also support. * @override */ static RENDER_FLAGS = { redraw: {propagate: ["refresh"]}, refresh: {propagate: ["refreshState"], alias: true}, refreshState: {} }; /** * The object that this object is a preview of if this object is a preview. * @type {PlaceableObject|undefined} */ get _original() { return this.#original; } /** * The object that this object is a preview of if this object is a preview. * @type {PlaceableObject|undefined} */ #original; /* -------------------------------------------- */ /** * The bounds that the placeable was added to the quadtree with. * @type {PIXI.Rectangle} */ #lastQuadtreeBounds; /** * An internal reference to a Promise in-progress to draw the Placeable Object. * @type {Promise} */ #drawing = Promise.resolve(this); /** * Has this Placeable Object been drawn and is there no drawing in progress? * @type {boolean} */ #drawn = false; /* -------------------------------------------- */ /** * A convenient reference for whether the current User has full control over the document. * @type {boolean} */ get isOwner() { return this.document.isOwner; } /* -------------------------------------------- */ /** * The mouse interaction state of this placeable. * @type {MouseInteractionManager.INTERACTION_STATES|undefined} */ 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 * @type {PIXI.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 || !this.document.id; } /* -------------------------------------------- */ /** * Does there exist a temporary preview of this placeable object? * @type {boolean} */ get hasPreview() { return !!this._preview; } /* -------------------------------------------- */ /** * Provide a reference to the CanvasLayer which contains this PlaceableObject. * @type {PlaceablesLayer} */ get layer() { return this.document.layer; } /* -------------------------------------------- */ /** * 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; /* -------------------------------------------- */ /** * Is the HUD display active for this Placeable? * @returns {boolean} */ get hasActiveHUD() { return this.layer.hud?.object === this; } /* -------------------------------------------- */ /** * Get the snapped position for a given position or the current position. * @param {Point} [position] The position to be used instead of the current position * @returns {Point} The snapped position */ getSnappedPosition(position) { return this.layer.getSnappedPoint(position ?? this.document); } /* -------------------------------------------- */ /* Rendering */ /* -------------------------------------------- */ /** @override */ applyRenderFlags() { if ( !this.renderFlags.size || this._destroyed ) return; const flags = this.renderFlags.clear(); // Full re-draw if ( flags.redraw ) { this.draw(); return; } // Don't refresh until the object is drawn if ( !this.#drawn ) return; // Incremental refresh this._applyRenderFlags(flags); Hooks.callAll(`refresh${this.document.documentName}`, this, flags); } /* -------------------------------------------- */ /** * Apply render flags before a render occurs. * @param {Record} flags The render flags which must be applied * @protected */ _applyRenderFlags(flags) {} /* -------------------------------------------- */ /** * Clear the display of the existing object. * @returns {PlaceableObject} The cleared object */ clear() { this.removeChildren().forEach(c => c.destroy({children: true})); return this; } /* -------------------------------------------- */ /** @inheritdoc */ destroy(options) { this.mouseInteractionManager?.cancel(); MouseInteractionManager.emulateMoveEvent(); if ( this._original ) this._original._preview = undefined; this.document._object = null; this.document._destroyed = true; if ( this.controlIcon ) this.controlIcon.destroy(); this.renderFlags.clear(); 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 * @param {object} [options] Options which may modify the draw and refresh workflow * @returns {Promise} The drawn object */ async draw(options={}) { return this.#drawing = this.#drawing.finally(async () => { this.#drawn = false; const wasVisible = this.visible; const wasRenderable = this.renderable; this.visible = false; this.renderable = false; this.clear(); this.mouseInteractionManager?.cancel(); MouseInteractionManager.emulateMoveEvent(); await this._draw(options); Hooks.callAll(`draw${this.document.documentName}`, this); this.renderFlags.set({refresh: true}); // Refresh all flags if ( this.id ) this.activateListeners(); this.visible = wasVisible; this.renderable = wasRenderable; this.#drawn = true; MouseInteractionManager.emulateMoveEvent(); }); } /** * The inner _draw method which must be defined by each PlaceableObject subclass. * @param {object} options Options which may modify the draw workflow * @abstract * @protected */ async _draw(options) { throw new Error(`The ${this.constructor.name} subclass of PlaceableObject must define the _draw method`); } /* -------------------------------------------- */ /** * Execute a partial draw. * @param {() => Promise} fn The draw function * @returns {Promise} The drawn object * @internal */ async _partialDraw(fn) { return this.#drawing = this.#drawing.finally(async () => { if ( !this.#drawn ) return; await fn(); }); } /* -------------------------------------------- */ /** * Refresh all incremental render flags for the PlaceableObject. * This method is no longer used by the core software but provided for backwards compatibility. * @param {object} [options] Options which may modify the refresh workflow * @returns {PlaceableObject} The refreshed object */ refresh(options={}) { this.renderFlags.set({refresh: true}); return this; } /* -------------------------------------------- */ /** * Update the quadtree. * @internal */ _updateQuadtree() { const layer = this.layer; if ( !layer.quadtree || this.isPreview ) return; if ( this.destroyed || this.parent !== layer.objects ) { this.#lastQuadtreeBounds = undefined; layer.quadtree.remove(this); return; } const bounds = this.bounds; if ( !this.#lastQuadtreeBounds || bounds.x !== this.#lastQuadtreeBounds.x || bounds.y !== this.#lastQuadtreeBounds.y || bounds.width !== this.#lastQuadtreeBounds.width || bounds.height !== this.#lastQuadtreeBounds.height ) { this.#lastQuadtreeBounds = bounds; layer.quadtree.update({r: bounds, t: this}); } } /* -------------------------------------------- */ /** * Is this PlaceableObject within the selection rectangle? * @param {PIXI.Rectangle} rectangle The selection rectangle * @protected * @internal */ _overlapsSelection(rectangle) { const {x, y} = this.center; return rectangle.contains(x, y); } /* -------------------------------------------- */ /** * Get the target opacity that should be used for a Placeable Object depending on its preview state. * @returns {number} * @protected */ _getTargetAlpha() { const isDragging = this._original?.mouseInteractionManager?.isDragging ?? this.mouseInteractionManager?.isDragging; return isDragging ? (this.isPreview ? 0.8 : (this.hasPreview ? 0.4 : 1)) : 1; } /* -------------------------------------------- */ /** * 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) {} /* -------------------------------------------- */ /** * 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) { this._updateQuadtree(); if ( this.parent && (("elevation" in changed) || ("sort" in changed)) ) this.parent.sortDirty = true; } /* -------------------------------------------- */ /** * 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; 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); Hooks.callAll(`control${this.constructor.embeddedName}`, this, this.#controlled); 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.renderFlags.set({refreshState: true}); } /* -------------------------------------------- */ /** * 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); 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 = false; if ( this === layer.hover ) layer.hover = null; if ( this.hasActiveHUD ) layer.hud.clear(); this.renderFlags.set({refreshState: true}); } /* -------------------------------------------- */ /** * 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.eventMode = "none"; clone.#controlled = this.#controlled; this._preview = clone; return clone; } /* -------------------------------------------- */ /** * 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.schema.has("rotation") ) return this; if ( game.paused && !game.user.isGM ) { ui.notifications.warn("GAME.PausedWarning", {localize: true}); return this; } const rotation = this._updateRotation({angle, snap}); 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 * @internal */ _updateRotation({angle, delta=0, snap=0}={}) { let degrees = Number.isNumeric(angle) ? angle : this.document.rotation + delta; if ( snap > 0 ) degrees = degrees.toNearest(snap); return Math.normalizeDegrees(degrees); } /* -------------------------------------------- */ /** * Obtain a shifted position for the Placeable Object * @param {-1|0|1} dx The number of grid units to shift along the X-axis * @param {-1|0|1} dy The number of grid units to shift along the Y-axis * @returns {Point} The shifted target coordinates * @internal */ _getShiftedPosition(dx, dy) { const {x, y} = this.document; const snapped = this.getSnappedPosition(); const D = CONST.MOVEMENT_DIRECTIONS; let direction = 0; if ( dx < 0 ) { if ( x <= snapped.x + 0.5 ) direction |= D.LEFT; } else if ( dx > 0 ) { if ( x >= snapped.x - 0.5 ) direction |= D.RIGHT; } if ( dy < 0 ) { if ( y <= snapped.y + 0.5 ) direction |= D.UP; } else if ( dy > 0 ) { if ( y >= snapped.y - 0.5 ) direction |= D.DOWN; } const grid = this.scene.grid; let biasX = 0; let biasY = 0; if ( grid.isHexagonal ) { if ( grid.columns ) biasY = 1; else biasX = 1; } snapped.x += biasX; snapped.y += biasY; const shifted = grid.getShiftedPoint(snapped, direction); shifted.x -= biasX; shifted.y -= biasY; return shifted; } /* -------------------------------------------- */ /* 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, clickLeft: this._canControl, clickLeft2: this._canView, clickRight: this._canHUD, clickRight2: this._canConfigure, dragStart: this._canDrag, dragLeftStart: this._canDragLeftStart }; // 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, unclickLeft: this._onUnclickLeft, unclickRight: this._onUnclickRight, dragLeftStart: this._onDragLeftStart, dragLeftMove: this._onDragLeftMove, dragLeftDrop: this._onDragLeftDrop, dragLeftCancel: this._onDragLeftCancel, dragRightStart: this._onDragRightStart, dragRightMove: this._onDragRightMove, dragRightDrop: this._onDragRightDrop, dragRightCancel: this._onDragRightCancel, 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); } /* -------------------------------------------- */ /** * 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.isOwner; } /* -------------------------------------------- */ /** * 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 || this.isPreview ) 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 left-click drag this Placeable Object? * @param {User} user The User performing the action. * @param {object} event The event object. * @returns {boolean} The returned status. * @protected */ _canDragLeftStart(user, event) { if ( game.paused && !game.user.isGM ) { ui.notifications.warn("GAME.PausedWarning", {localize: true}); return false; } if ( this.document.schema.has("locked") && this.document.locked ) { ui.notifications.warn(game.i18n.format("CONTROLS.ObjectIsLocked", { type: game.i18n.localize(this.document.constructor.metadata.label)})); return false; } return true; } /* -------------------------------------------- */ /** * 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); } /* -------------------------------------------- */ /** * Actions that should be taken for this Placeable Object when a mouseover event occurs. * Hover events on PlaceableObject instances allow event propagation by default. * @see MouseInteractionManager##handlePointerOver * @param {PIXI.FederatedEvent} event The triggering canvas interaction event * @param {object} options Options which customize event handling * @param {boolean} [options.hoverOutOthers=false] Trigger hover-out behavior on sibling objects * @protected */ _onHoverIn(event, {hoverOutOthers=false}={}) { if ( this.hover ) return; if ( event.buttons & 0x03 ) return; // Returning if hovering is happening with pressed left or right button // Handle the event const layer = this.layer; layer.hover = this; if ( hoverOutOthers ) { for ( const o of layer.placeables ) { if ( o !== this ) o._onHoverOut(event); } } this.hover = true; // Set render flags this.renderFlags.set({refreshState: true}); 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##handlePointerOut * @param {PIXI.FederatedEvent} event The triggering canvas interaction event * @protected */ _onHoverOut(event) { if ( !this.hover ) return; // Handle the event const layer = this.layer; layer.hover = null; this.hover = false; // Set render flags this.renderFlags.set({refreshState: true}); Hooks.callAll(`hover${this.constructor.embeddedName}`, this, this.hover); } /* -------------------------------------------- */ /** * Should the placeable propagate left click downstream? * @param {PIXI.FederatedEvent} event * @returns {boolean} * @protected */ _propagateLeftClick(event) { return false; } /* -------------------------------------------- */ /** * Callback actions which occur on a single left-click event to assume control of the object * @see MouseInteractionManager##handleClickLeft * @param {PIXI.FederatedEvent} event The triggering canvas interaction event * @protected */ _onClickLeft(event) { this.layer.hud?.clear(); // Add or remove the Placeable Object from the currently controlled set if ( !this.#controlled ) this.control({releaseOthers: !event.shiftKey}); else if ( event.shiftKey ) event.interactionData.release = true; // Release on unclick // Propagate left click to the underlying canvas? if ( !this._propagateLeftClick(event) ) event.stopPropagation(); } /* -------------------------------------------- */ /** * Callback actions which occur on a single left-unclick event to assume control of the object * @param {PIXI.FederatedEvent} event The triggering canvas interaction event * @protected */ _onUnclickLeft(event) { // Remove Placeable Object from the currently controlled set if ( event.interactionData.release === true ) this.release(); // Propagate left click to the underlying canvas? if ( !this._propagateLeftClick(event) ) event.stopPropagation(); } /* -------------------------------------------- */ /** * Callback actions which occur on a double left-click event to activate * @see MouseInteractionManager##handleClickLeft2 * @param {PIXI.FederatedEvent} event The triggering canvas interaction event * @protected */ _onClickLeft2(event) { const sheet = this.sheet; if ( sheet ) sheet.render(true); if ( !this._propagateLeftClick(event) ) event.stopPropagation(); } /* -------------------------------------------- */ /** * Should the placeable propagate right click downstream? * @param {PIXI.FederatedEvent} event * @returns {boolean} * @protected */ _propagateRightClick(event) { return false; } /* -------------------------------------------- */ /** * Callback actions which occur on a single right-click event to configure properties of the object * @see MouseInteractionManager##handleClickRight * @param {PIXI.FederatedEvent} event The triggering canvas interaction event * @protected */ _onClickRight(event) { if ( this.layer.hud ) { const releaseOthers = !this.#controlled && !event.shiftKey; this.control({releaseOthers}); if ( this.hasActiveHUD ) this.layer.hud.clear(); else this.layer.hud.bind(this); } // Propagate the right-click to the underlying canvas? if ( !this._propagateRightClick(event) ) event.stopPropagation(); } /* -------------------------------------------- */ /** * Callback actions which occur on a single right-unclick event * @param {PIXI.FederatedEvent} event The triggering canvas interaction event * @protected */ _onUnclickRight(event) { // Propagate right-click to the underlying canvas? if ( !this._propagateRightClick(event) ) event.stopPropagation(); } /* -------------------------------------------- */ /** * Callback actions which occur on a double right-click event to configure properties of the object * @see MouseInteractionManager##handleClickRight2 * @param {PIXI.FederatedEvent} event The triggering canvas interaction event * @protected */ _onClickRight2(event) { const sheet = this.sheet; if ( sheet ) sheet.render(true); if ( !this._propagateRightClick(event) ) event.stopPropagation(); } /* -------------------------------------------- */ /** * Callback actions which occur when a mouse-drag action is first begun. * @see MouseInteractionManager##handleDragStart * @param {PIXI.FederatedEvent} event The triggering canvas interaction event * @protected */ _onDragLeftStart(event) { const objects = this.layer.options.controllableObjects ? this.layer.controlled : [this]; const clones = []; for ( const o of objects ) { if ( !o._canDrag(game.user, event) ) continue; // FIXME: Find a better solution such that any object for which _canDragLeftStart // would return false is included in the drag operation. The locked state might not // be the only condition that prevents dragging that is checked in _canDragLeftStart. if ( o.document.locked ) continue; // Clone the object const c = o.clone(); clones.push(c); // Draw the clone c._onDragStart(); c.visible = false; this.layer.preview.addChild(c); c.draw().then(c => c.visible = true); } event.interactionData.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; o.document.locked = true; o.renderFlags.set({refreshState: 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() { const o = this._original; if ( o ) { o.document.locked = o.document._source.locked; o.renderFlags.set({refreshState: true}); } } /* -------------------------------------------- */ /** * Callback actions which occur on a mouse-move operation. * @see MouseInteractionManager##handleDragMove * @param {PIXI.FederatedEvent} event The triggering canvas interaction event * @protected */ _onDragLeftMove(event) { canvas._onDragCanvasPan(event); const {clones, destination, origin} = event.interactionData; // Calculate the (snapped) position of the dragged object let position = { x: this.document.x + (destination.x - origin.x), y: this.document.y + (destination.y - origin.y) }; if ( !event.shiftKey ) position = this.getSnappedPosition(position); // Move all other objects in the selection relative to the the dragged object. // We want to avoid that the dragged object doesn't move when the cursor is moved, // because it snaps to the same position, but other objects in the selection do. const dx = position.x - this.document.x; const dy = position.y - this.document.y; for ( const c of clones || [] ) { const o = c._original; let position = {x: o.document.x + dx, y: o.document.y + dy}; if ( !event.shiftKey ) position = this.getSnappedPosition(position); c.document.x = position.x; c.document.y = position.y; c.renderFlags.set({refreshPosition: true}); } } /* -------------------------------------------- */ /** * Callback actions which occur on a mouse-move operation. * @see MouseInteractionManager##handleDragDrop * @param {PIXI.FederatedEvent} event The triggering canvas interaction event * @protected */ _onDragLeftDrop(event) { // Ensure that we landed in bounds const {clones, destination} = event.interactionData; if ( !clones || !canvas.dimensions.rect.contains(destination.x, destination.y) ) return false; event.interactionData.clearPreviewContainer = false; // Perform database updates using dropped data const updates = this._prepareDragLeftDropUpdates(event); // noinspection ES6MissingAwait if ( updates ) this.#commitDragLeftDropUpdates(updates); } /* -------------------------------------------- */ /** * Perform the database updates that should occur as the result of a drag-left-drop operation. * @param {PIXI.FederatedEvent} event The triggering canvas interaction event * @returns {object[]|null} An array of database updates to perform for documents in this collection */ _prepareDragLeftDropUpdates(event) { const updates = []; for ( const clone of event.interactionData.clones ) { let dest = {x: clone.document.x, y: clone.document.y}; if ( !event.shiftKey ) dest = this.getSnappedPosition(dest); updates.push({_id: clone._original.id, x: dest.x, y: dest.y, rotation: clone.document.rotation}); } return updates; } /* -------------------------------------------- */ /** * Perform database updates using the result of a drag-left-drop operation. * @param {object[]} updates The database updates for documents in this collection * @returns {Promise} */ async #commitDragLeftDropUpdates(updates) { for ( const u of updates ) { const d = this.document.collection.get(u._id); if ( d ) d.locked = d._source.locked; // Unlock original documents } await canvas.scene.updateEmbeddedDocuments(this.document.documentName, updates); this.layer.clearPreviewContainer(); } /* -------------------------------------------- */ /** * Callback actions which occur on a mouse-move operation. * @see MouseInteractionManager##handleDragCancel * @param {PIXI.FederatedEvent} event The triggering mouse click event * @protected */ _onDragLeftCancel(event) { if ( event.interactionData.clearPreviewContainer !== false ) { this.layer.clearPreviewContainer(); } } /* -------------------------------------------- */ /** * Callback actions which occur on a right mouse-drag operation. * @see MouseInteractionManager##handleDragStart * @param {PIXI.FederatedEvent} event The triggering mouse click event * @protected */ _onDragRightStart(event) { return canvas._onDragRightStart(event); } /* -------------------------------------------- */ /** * Callback actions which occur on a right mouse-drag operation. * @see MouseInteractionManager##handleDragMove * @param {PIXI.FederatedEvent} event The triggering canvas interaction event * @protected */ _onDragRightMove(event) { return canvas._onDragRightMove(event); } /* -------------------------------------------- */ /** * Callback actions which occur on a right mouse-drag operation. * @see MouseInteractionManager##handleDragDrop * @param {PIXI.FederatedEvent} event The triggering canvas interaction event * @returns {Promise<*>} * @protected */ _onDragRightDrop(event) { return canvas._onDragRightDrop(event); } /* -------------------------------------------- */ /** * Callback actions which occur on a right mouse-drag operation. * @see MouseInteractionManager##handleDragCancel * @param {PIXI.FederatedEvent} event The triggering mouse click event * @protected */ _onDragRightCancel(event) { return canvas._onDragRightCancel(event); } /* -------------------------------------------- */ /** * Callback action which occurs on a long press. * @see MouseInteractionManager##handleLongPress * @param {PIXI.FederatedEvent} 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; /** * Record the timestamps when each asset path is retrieved from cache. * @type {Map} */ static #cacheTime = new Map(); /** * A mapping of cached texture data * @type {WeakMap>} */ static #textureDataMap = new WeakMap(); /** * Create a fixed retry string to use for CORS retries. * @type {string} */ static #retryString = Date.now().toString(); /** * To know if the basis transcoder has been initialized * @type {boolean} */ static #basisTranscoderInitialized = false; /* -------------------------------------------- */ /** * Initialize the basis transcoder for PIXI.Assets * @returns {Promise<*>} */ static async initializeBasisTranscoder() { if ( this.#basisTranscoderInitialized ) return; this.#basisTranscoderInitialized = true; return await PIXI.TranscoderWorker.loadTranscoder( "scripts/basis_transcoder.js", "scripts/basis_transcoder.wasm" ); } /* -------------------------------------------- */ /** * Check if a source has a text file extension. * @param {string} src The source. * @returns {boolean} If the source has a text extension or not. */ static hasTextExtension(src) { let rgx = new RegExp(`(\\.${Object.keys(CONST.TEXT_FILE_EXTENSIONS).join("|\\.")})(\\?.*)?`, "i"); return rgx.test(src); } /* -------------------------------------------- */ /** * @typedef {Object} TextureAlphaData * @property {number} width The width of the (downscaled) texture. * @property {number} height The height of the (downscaled) texture. * @property {number} minX The minimum x-coordinate with alpha > 0. * @property {number} minY The minimum y-coordinate with alpha > 0. * @property {number} maxX The maximum x-coordinate with alpha > 0 plus 1. * @property {number} maxY The maximum y-coordinate with alpha > 0 plus 1. * @property {Uint8Array} data The array containing the texture alpha values (0-255) * with the dimensions (maxX-minX)×(maxY-minY). */ /** * Use the texture to create a cached mapping of pixel alpha and cache it. * Cache the bounding box of non-transparent pixels for the un-rotated shape. * @param {PIXI.Texture} texture The provided texture. * @param {number} [resolution=1] Resolution of the texture data output. * @returns {TextureAlphaData|undefined} The texture data if the texture is valid, else undefined. */ static getTextureAlphaData(texture, resolution=1) { // If texture is not present if ( !texture?.valid ) return; // Get the base tex and the stringified frame + width/height const width = Math.ceil(Math.round(texture.width * texture.resolution) * resolution); const height = Math.ceil(Math.round(texture.height * texture.resolution) * resolution); const baseTex = texture.baseTexture; const frame = texture.frame; const sframe = `${frame.x},${frame.y},${frame.width},${frame.height},${width},${height}`; // Get frameDataMap and textureData if they exist let textureData; let frameDataMap = this.#textureDataMap.get(baseTex); if ( frameDataMap ) textureData = frameDataMap.get(sframe); // If texture data exists for the baseTex/frame couple, we return it if ( textureData ) return textureData; else textureData = {}; // Create a temporary Sprite using the provided texture const sprite = new PIXI.Sprite(texture); sprite.width = textureData.width = width; sprite.height = textureData.height = height; sprite.anchor.set(0, 0); // Create or update the alphaMap render texture const tex = PIXI.RenderTexture.create({width: width, height: height}); canvas.app.renderer.render(sprite, {renderTexture: tex}); sprite.destroy(false); const pixels = canvas.app.renderer.extract.pixels(tex); tex.destroy(true); // Trim pixels with zero alpha let minX = width; let minY = height; let maxX = 0; let maxY = 0; for ( let i = 3, y = 0; y < height; y++ ) { for ( let x = 0; x < width; x++, i += 4 ) { const alpha = pixels[i]; if ( alpha === 0 ) continue; if ( x < minX ) minX = x; if ( x >= maxX ) maxX = x + 1; if ( y < minY ) minY = y; if ( y >= maxY ) maxY = y + 1; } } // Special case when the whole texture is alpha 0 if ( minX > maxX ) minX = minY = maxX = maxY = 0; // Set the bounds of the trimmed region textureData.minX = minX; textureData.minY = minY; textureData.maxX = maxX; textureData.maxY = maxY; // Create new buffer for storing the alpha channel only const data = textureData.data = new Uint8Array((maxX - minX) * (maxY - minY)); for ( let i = 0, y = minY; y < maxY; y++ ) { for ( let x = minX; x < maxX; x++, i++ ) { data[i] = pixels[(((width * y) + x) * 4) + 3]; } } // Saving the texture data if ( !frameDataMap ) { frameDataMap = new Map(); this.#textureDataMap.set(baseTex, frameDataMap); } frameDataMap.set(sframe, textureData); return textureData; } /* -------------------------------------------- */ /** * 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 * @param {boolean} [options.additionalSources=[]] Additional sources to load during canvas initialize * @param {number} [options.maxConcurrent] The maximum number of textures that can be loaded concurrently * @returns {Promise} */ static loadSceneTextures(scene, {expireCache=true, additionalSources=[], maxConcurrent}={}) { let toLoad = []; // Scene background and foreground textures if ( scene.background.src ) toLoad.push(scene.background.src); if ( scene.foreground ) toLoad.push(scene.foreground); if ( scene.fog.overlay ) toLoad.push(scene.fog.overlay); // Tiles toLoad = toLoad.concat(scene.tiles.reduce((arr, t) => { if ( t.texture.src ) arr.push(t.texture.src); return arr; }, [])); // Tokens toLoad.push(CONFIG.Token.ring.spritesheet); toLoad = toLoad.concat(scene.tokens.reduce((arr, t) => { if ( t.texture.src ) arr.push(t.texture.src); if ( t.ring.enabled ) arr.push(t.ring.subject.texture); return arr; }, [])); // Control Icons toLoad = toLoad.concat(Object.values(CONFIG.controlIcons)); // Status Effect textures toLoad = toLoad.concat(CONFIG.statusEffects.map(e => e.img ?? /** @deprecated since v12 */ e.icon)); // Configured scene textures toLoad.push(...Object.values(canvas.sceneTextures)); // Additional requested sources toLoad.push(...additionalSources); // 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, maxConcurrent }); } /* -------------------------------------------- */ /** * 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? * @param {number} [options.maxConcurrent] The maximum number of textures that can be loaded concurrently. * @param {boolean} [options.displayProgress] Display loading progress bar * @returns {Promise} A Promise which resolves once all textures are loaded */ async load(sources, {message, expireCache=false, maxConcurrent, displayProgress=true}={}) { sources = new Set(sources); const progress = {message: message, loaded: 0, failed: 0, total: sources.size, pct: 0}; console.groupCollapsed(`${vtt} | Loading ${sources.size} Assets`); const loadTexture = async src => { try { await this.loadTexture(src); if ( displayProgress ) TextureLoader.#onProgress(src, progress); } catch(err) { TextureLoader.#onError(src, progress, err); } }; const promises = []; if ( maxConcurrent ) { const semaphore = new foundry.utils.Semaphore(maxConcurrent); for ( const src of sources ) promises.push(semaphore.add(loadTexture, src)); } else { for ( const src of sources ) promises.push(loadTexture(src)); } await Promise.allSettled(promises); console.groupEnd(); if ( expireCache ) await this.expireCache(); } /* -------------------------------------------- */ /** * Load a single texture or spritesheet 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) { const loadAsset = async (src, bustCache=false) => { if ( bustCache ) src = TextureLoader.getCacheBustURL(src); if ( !src ) return null; try { return await PIXI.Assets.load(src); } catch ( err ) { if ( bustCache ) throw err; return await loadAsset(src, true); } }; let asset = await loadAsset(src); if ( !asset?.baseTexture?.valid ) return null; if ( asset instanceof PIXI.Texture ) asset = asset.baseTexture; this.setCache(src, asset); return asset; } /* --------------------------------------------- */ /** * 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(`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(`Loading failed for ${src} (${progress.pct}%): ${error.message}`); } /* -------------------------------------------- */ /* Cache Controls */ /* -------------------------------------------- */ /** * Add an image or a sprite sheet url to the assets cache. * @param {string} src The source URL. * @param {PIXI.BaseTexture|PIXI.Spritesheet} asset The asset */ setCache(src, asset) { TextureLoader.#cacheTime.set(asset, {src, time: Date.now()}); } /* -------------------------------------------- */ /** * Retrieve a texture or a sprite sheet from the assets cache * @param {string} src The source URL * @returns {PIXI.BaseTexture|PIXI.Spritesheet|null} The cached texture, a sprite sheet or undefined */ getCache(src) { if ( !src ) return null; if ( !PIXI.Assets.cache.has(src) ) src = TextureLoader.getCacheBustURL(src) || src; let asset = PIXI.Assets.get(src); if ( !asset?.baseTexture?.valid ) return null; if ( asset instanceof PIXI.Texture ) asset = asset.baseTexture; this.setCache(src, asset); return asset; } /* -------------------------------------------- */ /** * Expire and unload assets from the cache which have not been used for more than CACHE_TTL milliseconds. */ async expireCache() { const promises = []; const t = Date.now(); for ( const [asset, {src, time}] of TextureLoader.#cacheTime.entries() ) { const baseTexture = asset instanceof PIXI.Spritesheet ? asset.baseTexture : asset; if ( !baseTexture || baseTexture.destroyed ) { TextureLoader.#cacheTime.delete(asset); continue; } if ( (t - time) <= TextureLoader.CACHE_TTL ) continue; console.log(`${vtt} | Expiring cached texture: ${src}`); promises.push(PIXI.Assets.unload(src)); TextureLoader.#cacheTime.delete(asset); } await Promise.allSettled(promises); } /* -------------------------------------------- */ /** * 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", TextureLoader.#retryString); return url.href; } /* -------------------------------------------- */ /* Deprecations */ /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ async loadImageTexture(src) { const warning = "TextureLoader#loadImageTexture is deprecated. Use TextureLoader#loadTexture instead."; foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13}); return this.loadTexture(src); } /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ async loadVideoTexture(src) { const warning = "TextureLoader#loadVideoTexture is deprecated. Use TextureLoader#loadTexture instead."; foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13}); return this.loadTexture(src); } /** * @deprecated since v12 * @ignore */ static get textureBufferDataMap() { const warning = "TextureLoader.textureBufferDataMap is deprecated without replacement. Use " + "TextureLoader.getTextureAlphaData to create a texture data map and cache it automatically, or create your own" + " caching system."; foundry.utils.logCompatibilityWarning(warning, {since: 12, until: 14}); return this.#textureBufferDataMap; } /** * @deprecated since v12 * @ignore */ static #textureBufferDataMap = new Map(); } /** * 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 or sprite sheet from the cache. * @param {string} src The texture path to load. * @returns {PIXI.Texture|PIXI.Spritesheet|null} A texture, a sprite sheet or null if not found in cache. */ function getTexture(src) { const asset = TextureLoader.loader.getCache(src); const baseTexture = asset instanceof PIXI.Spritesheet ? asset.baseTexture : asset; if ( !baseTexture?.valid ) return null; return (asset instanceof PIXI.Spritesheet ? asset : new PIXI.Texture(asset)); } /* -------------------------------------------- */ /** * Load a single asset and return a Promise which resolves once the asset is ready to use * @param {string} src The requested asset source * @param {object} [options] Additional options which modify asset loading * @param {string} [options.fallback] A fallback texture URL to use if the requested source is unavailable * @returns {PIXI.Texture|PIXI.Spritesheet|null} The loaded Texture or sprite sheet, * or null if loading failed with no fallback */ async function loadTexture(src, {fallback}={}) { let asset; let error; try { asset = await TextureLoader.loader.loadTexture(src); const baseTexture = asset instanceof PIXI.Spritesheet ? asset.baseTexture : asset; if ( !baseTexture?.valid ) error = new Error(`Invalid Asset ${src}`); } catch(err) { err.message = `The requested asset ${src} could not be loaded: ${err.message}`; error = err; } if ( error ) { console.error(error); if ( TextureLoader.hasTextExtension(src) ) return null; // No fallback for spritesheets return fallback ? loadTexture(fallback) : null; } if ( asset instanceof PIXI.Spritesheet ) return asset; return new PIXI.Texture(asset); } /** * 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 CanvasGroupMixin} A ContainerClass subclass mixed with CanvasGroupMixin features. */ const CanvasGroupMixin = ContainerClass => { return class CanvasGroup extends ContainerClass { constructor(...args) { super(...args); this.sortableChildren = true; this.layers = this._createLayers(); } /** * The name of this canvas group. * @type {string} * @abstract */ static groupName; /** * If this canvas group should teardown non-layers children. * @type {boolean} */ static tearDownChildren = true; /** * The canonical name of the canvas group is the name of the constructor that is the immediate child of the * defined base class. * @type {string} */ get name() { let cls = Object.getPrototypeOf(this.constructor); let name = this.constructor.name; while ( cls ) { if ( cls !== CanvasGroup ) { name = cls.name; cls = Object.getPrototypeOf(cls); } else break; } return name; } /** * The name used by hooks to construct their hook string. * Note: You should override this getter if hookName should not return the class constructor name. * @type {string} */ get hookName() { return this.name; } /** * A mapping of CanvasLayer classes which belong to this group. * @type {Record} */ layers; /* -------------------------------------------- */ /** * Create CanvasLayer instances which belong to the canvas group. * @protected */ _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}); if ( !(name in canvas) ) Object.defineProperty(canvas, name, {value: layer, writable: false}); } return layers; } /* -------------------------------------------- */ /* Rendering */ /* -------------------------------------------- */ /** * An internal reference to a Promise in-progress to draw the canvas group. * @type {Promise} */ #drawing = Promise.resolve(this); /* -------------------------------------------- */ /** * Is the group drawn? * @type {boolean} */ #drawn = false; /* -------------------------------------------- */ /** * Draw the canvas group and all its components. * @param {object} [options={}] * @returns {Promise} A Promise which resolves once the group is fully drawn */ async draw(options={}) { return this.#drawing = this.#drawing.finally(async () => { console.log(`${vtt} | Drawing the ${this.hookName} canvas group`); await this.tearDown(); await this._draw(options); Hooks.callAll(`draw${this.hookName}`, this); this.#drawn = true; MouseInteractionManager.emulateMoveEvent(); }); } /** * Draw the canvas group and all its component layers. * @param {object} options * @protected */ async _draw(options) { // 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={}) { if ( !this.#drawn ) return this; this.#drawn = false; await this._tearDown(options); Hooks.callAll(`tearDown${this.hookName}`, this); MouseInteractionManager.emulateMoveEvent(); return this; } /** * Remove and destroy all layers from the base canvas. * @param {object} options * @protected */ 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 ( !this.constructor.tearDownChildren ) return; // Yes? Then proceed with children cleaning for ( const child of this.removeChildren() ) { if ( child instanceof CachedContainer ) child.clear(); else child.destroy({children: true}); } } }; }; /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ Object.defineProperty(globalThis, "BaseCanvasMixin", { get() { const msg = "BaseCanvasMixin is deprecated in favor of CanvasGroupMixin"; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return CanvasGroupMixin; } }); /** * 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); } /** * The texture configuration to use for this cached container * @type {{multisample: PIXI.MSAA_QUALITY, scaleMode: PIXI.SCALE_MODES, format: PIXI.FORMATS}} * @abstract */ static textureConfiguration = {}; /** * A bound resize function which fires on the renderer resize event. * @type {function(PIXI.Renderer)} * @private */ #onResize; /** * A map of render textures, linked to their render function and an optional RGBA clear color. * @type {Map} * @protected */ _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; /** * If true, the Container is rendered every frame. * If false, the Container is rendered only if {@link CachedContainer#renderDirty} is true. * @type {boolean} */ autoRender = true; /** * Does the Container need to be rendered? * Set to false after the Container is rendered. * @type {boolean} */ renderDirty = true; /* ---------------------------------------- */ /** * 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; const conf = this.constructor.textureConfiguration; const pm = canvas.performance.mode; // Disabling linear filtering by default for low/medium performance mode const defaultScaleMode = (pm > CONST.CANVAS_PERFORMANCE_MODES.MED) ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST; // Creating the render texture const renderTexture = PIXI.RenderTexture.create({ width: renderer.screen.width, height: renderer.screen.height, resolution: renderer.resolution, multisample: conf.multisample ?? renderer.multisample, scaleMode: conf.scaleMode ?? defaultScaleMode, format: conf.format ?? PIXI.FORMATS.RGBA }); 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 this.renderDirty = true; // 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); this.renderDirty = 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 if ( this.autoRender || this.renderDirty ) { this.renderDirty = false; 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 this.renderDirty = true; } /* ---------------------------------------- */ /** * 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); } } /** * Augment any PIXI.DisplayObject to assume bounds that are always aligned with the full visible screen. * The bounds of this container do not depend on its children but always fill the entire canvas. * @param {typeof PIXI.DisplayObject} Base Any PIXI DisplayObject subclass * @returns {typeof FullCanvasObject} The decorated subclass with full canvas bounds */ function FullCanvasObjectMixin(Base) { return class FullCanvasObject extends Base { /** @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; } }; } /** * @deprecated since v11 * @ignore */ class FullCanvasContainer extends FullCanvasObjectMixin(PIXI.Container) { constructor(...args) { super(...args); const msg = "You are using the FullCanvasContainer class which has been deprecated in favor of a more flexible " + "FullCanvasObjectMixin which can augment any PIXI.DisplayObject subclass."; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); } } /** * 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; /** * The transform world ID of the bounds. * @type {number} */ _worldID = -1; /** * The geometry update ID of the bounds. * @type {number} */ _updateID = -1; /* -------------------------------------------- */ /* PointSourceMesh Properties */ /* -------------------------------------------- */ /** @override */ get geometry() { return super.geometry; } /** @override */ set geometry(value) { if ( this._geometry !== value ) this._updateID = -1; super.geometry = value; } /* -------------------------------------------- */ /* PointSourceMesh Methods */ /* -------------------------------------------- */ /** @override */ addChild() { throw new Error("You can't add children to a PointSourceMesh."); } /* ---------------------------------------- */ /** @override */ addChildAt() { throw new Error("You can't add children to a PointSourceMesh."); } /* ---------------------------------------- */ /** @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); } /* ---------------------------------------- */ /** @override */ calculateBounds() { const {transform, geometry} = this; // Checking bounds id to update only when it is necessary if ( this._worldID !== transform._worldID || this._updateID !== geometry.buffers[0]._updateID ) { this._worldID = transform._worldID; this._updateID = geometry.buffers[0]._updateID; const {x, y, width, height} = this.geometry.bounds; this._bounds.clear(); this._bounds.addFrame(transform, x, y, x + width, y + height); } this._bounds.updateID = this._boundsID; } /* ---------------------------------------- */ /** @override */ _calculateBounds() { this.calculateBounds(); } /* ---------------------------------------- */ /** * The local bounds need to be drawn from the underlying geometry. * @override */ getLocalBounds(rect) { rect ??= this._localBoundsRect ??= new PIXI.Rectangle(); return this.geometry.bounds.copyTo(rect); } } /** * A basic rectangular mesh with a shader only. Does not natively handle textures (but a bound shader can). * Bounds calculations are simplified and the geometry does not need to handle texture coords. * @param {AbstractBaseShader} shaderClass The shader class to use. */ class QuadMesh extends PIXI.Container { constructor(shaderClass) { super(); // Assign shader, state and properties if ( !AbstractBaseShader.isPrototypeOf(shaderClass) ) { throw new Error("QuadMesh shader class must inherit from AbstractBaseShader."); } this.#shader = shaderClass.create(); } /** * Geometry bound to this QuadMesh. * @type {PIXI.Geometry} */ #geometry = new PIXI.Geometry() .addAttribute("aVertexPosition", [0, 0, 1, 0, 1, 1, 0, 1], 2) .addIndex([0, 1, 2, 0, 2, 3]); /* ---------------------------------------- */ /** * The shader bound to this mesh. * @type {AbstractBaseShader} */ get shader() { return this.#shader; } /** * @type {AbstractBaseShader} */ #shader; /* ---------------------------------------- */ /** * Assigned blend mode to this mesh. * @type {PIXI.BLEND_MODES} */ get blendMode() { return this.#state.blendMode; } set blendMode(value) { this.#state.blendMode = value; } /** * State bound to this QuadMesh. * @type {PIXI.State} */ #state = PIXI.State.for2d(); /* ---------------------------------------- */ /** * Initialize shader based on the shader class type. * @param {class} shaderClass Shader class used. Must inherit from AbstractBaseShader. */ setShaderClass(shaderClass) { // Escape conditions if ( !AbstractBaseShader.isPrototypeOf(shaderClass) ) { throw new Error("QuadMesh shader class must inherit from AbstractBaseShader."); } if ( this.#shader.constructor === shaderClass ) return; // Create shader program this.#shader = shaderClass.create(); } /* ---------------------------------------- */ /** @override */ _render(renderer) { this.#shader._preRender(this, renderer); this.#shader.uniforms.translationMatrix = this.transform.worldTransform.toArray(true); // Flush batch renderer renderer.batch.flush(); // Set state renderer.state.set(this.#state); // Bind shader and geometry renderer.shader.bind(this.#shader); renderer.geometry.bind(this.#geometry, this.#shader); // Draw the geometry renderer.geometry.draw(PIXI.DRAW_MODES.TRIANGLES); } /* ---------------------------------------- */ /** @override */ _calculateBounds() { this._bounds.addFrame(this.transform, 0, 0, 1, 1); } /* ---------------------------------------- */ /** * Tests if a point is inside this QuadMesh. * @param {PIXI.IPointData} point * @returns {boolean} */ containsPoint(point) { return this.getBounds().contains(point.x, point.y); } /* ---------------------------------------- */ /** @override */ destroy(options) { super.destroy(options); this.#geometry.dispose(); this.#geometry = null; this.#shader = null; this.#state = null; } } /** * @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 {PIXI.Rectangle} */ this.bounds = new PIXI.Rectangle(bounds.x, bounds.y, bounds.width, bounds.height); /** * 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 {PIXI.Texture} [texture=PIXI.Texture.EMPTY] Texture bound to this sprite mesh. * @param {typeof BaseSamplerShader} [shaderClass=BaseSamplerShader] Shader class used by this sprite mesh. */ class SpriteMesh extends PIXI.Container { constructor(texture, shaderClass=BaseSamplerShader) { super(); // Create shader program if ( !foundry.utils.isSubclass(shaderClass, BaseSamplerShader) ) { throw new Error("SpriteMesh shader class must be a subclass of BaseSamplerShader."); } this._shader = shaderClass.create(); // Initialize other data to emulate sprite this.vertexData = this.#geometry.buffers[0].data; this.uvs = this.#geometry.buffers[1].data; this.indices = this.#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; this.isSprite = true; // 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; } /** * A temporary reusable rect. * @type {PIXI.Rectangle} */ static #TEMP_RECT = new PIXI.Rectangle(); /** * A temporary reusable point. * @type {PIXI.Point} */ static #TEMP_POINT = new PIXI.Point(); /** * Geometry bound to this SpriteMesh. * @type {PIXI.Geometry} */ #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]); /** * Snapshot of some parameters of this display object to render in batched mode. * @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 }; /** * The indices of the geometry. * @type {Uint16Array} */ indices; /** * The width of the sprite (this is initially set by the texture). * @type {number} * @protected */ _width = 0; /** * The height of the sprite (this is initially set by the texture) * @type {number} * @protected */ _height = 0; /** * The texture that the sprite is using. * @type {PIXI.Texture} * @protected */ _texture; /** * The texture ID. * @type {number} * @protected */ _textureID = -1; /** * Cached tint value so we can tell when the tint is changed. * @type {[red: number, green: number, blue: number, alpha: number]} * @protected * @internal */ _cachedTint = [1, 1, 1, 1]; /** * The texture trimmed ID. * @type {number} * @protected */ _textureTrimmedID = -1; /** * This is used to store the uvs data of the sprite, assigned at the same time * as the vertexData in calculateVertices(). * @type {Float32Array} * @protected */ uvs; /** * The anchor point defines the normalized coordinates * in the texture that map to the position of this * sprite. * * By default, this is `(0,0)` (or `texture.defaultAnchor` * if you have modified that), which means the position * `(x,y)` of this `Sprite` will be the top-left corner. * * Note: Updating `texture.defaultAnchor` after * constructing a `Sprite` does _not_ update its anchor. * * {@link https://docs.cocos2d-x.org/cocos2d-x/en/sprites/manipulation.html} * @type {PIXI.ObservablePoint} * @protected */ _anchor; /** * This is used to store the vertex data of the sprite (basically a quad). * @type {Float32Array} * @protected */ vertexData; /** * This is used to calculate the bounds of the object IF it is a trimmed sprite. * @type {Float32Array|null} * @protected */ vertexTrimmedData = null; /** * The transform ID. * @type {number} * @private */ _transformID = -1; /** * The transform ID. * @type {number} * @private */ _transformTrimmedID = -1; /** * The tint applied to the sprite. This is a hex value. A value of 0xFFFFFF will remove any tint effect. * @type {PIXI.Color} * @protected */ _tintColor = new PIXI.Color(0xFFFFFF); /** * The tint applied to the sprite. This is a RGB value. A value of 0xFFFFFF will remove any tint effect. * @type {number} * @protected */ _tintRGB = 0xFFFFFF; /** * An instance of a texture uvs used for padded SpriteMesh. * Instanced only when padding becomes non-zero. * @type {PIXI.TextureUvs|null} * @protected */ _textureUvs = null; /** * Used to track a tint or alpha change to execute a recomputation of _cachedTint. * @type {boolean} * @protected */ _tintAlphaDirty = true; /** * The PIXI.State of this SpriteMesh. * @type {PIXI.State} */ #state = PIXI.State.for2d(); /* ---------------------------------------- */ /** * The shader bound to this mesh. * @type {BaseSamplerShader} */ get shader() { return this._shader; } /** * The shader bound to this mesh. * @type {BaseSamplerShader} * @protected */ _shader; /* ---------------------------------------- */ /** * The x padding in pixels (must be a non-negative value.) * @type {number} */ get paddingX() { return this._paddingX; } set paddingX(value) { if ( value < 0 ) throw new Error("The padding must be a non-negative value."); if ( this._paddingX === value ) return; this._paddingX = value; this._textureID = -1; this._textureTrimmedID = -1; this._textureUvs ??= new PIXI.TextureUvs(); } /** * They y padding in pixels (must be a non-negative value.) * @type {number} */ get paddingY() { return this._paddingY; } set paddingY(value) { if ( value < 0 ) throw new Error("The padding must be a non-negative value."); if ( this._paddingY === value ) return; this._paddingY = value; this._textureID = -1; this._textureTrimmedID = -1; this._textureUvs ??= new PIXI.TextureUvs(); } /** * The maximum x/y padding in pixels (must be a non-negative value.) * @type {number} */ get padding() { return Math.max(this._paddingX, this._paddingY); } set padding(value) { if ( value < 0 ) throw new Error("The padding must be a non-negative value."); this.paddingX = this.paddingY = value; } /** * @type {number} * @protected */ _paddingX = 0; /** * @type {number} * @protected */ _paddingY = 0; /* ---------------------------------------- */ /** * The blend mode applied to the SpriteMesh. * @type {PIXI.BLEND_MODES} * @defaultValue PIXI.BLEND_MODES.NORMAL */ set blendMode(value) { this.#state.blendMode = value; } get blendMode() { return this.#state.blendMode; } /* ---------------------------------------- */ /** * If true PixiJS will Math.round() x/y values when rendering, stopping pixel interpolation. * Advantages can include sharper image quality (like text) and faster rendering on canvas. * The main disadvantage is movement of objects may appear less smooth. * To set the global default, change PIXI.settings.ROUND_PIXELS * @defaultValue PIXI.settings.ROUND_PIXELS */ set roundPixels(value) { if ( this.#roundPixels !== value ) this._transformID = -1; this.#roundPixels = value; } get roundPixels() { return this.#roundPixels; } #roundPixels = PIXI.settings.ROUND_PIXELS; /* ---------------------------------------- */ /** * 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} */ 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; } /* ---------------------------------------- */ /** @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; } /* ---------------------------------------- */ /** * The texture that the sprite is using. * @type {PIXI.Texture} */ 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); } } /* ---------------------------------------- */ /** * The anchor sets the origin point of the sprite. The default value is taken from the {@link PIXI.Texture|Texture} * and passed to the constructor. * * The default is `(0,0)`, this means the sprite's origin is the top left. * * Setting the anchor to `(0.5,0.5)` means the sprite's origin is centered. * * Setting the anchor to `(1,1)` would mean the sprite's origin point will be the bottom right corner. * * If you pass only single parameter, it will set both x and y to the same value as shown in the example below. * @type {PIXI.ObservablePoint} */ get anchor() { return this._anchor; } set anchor(anchor) { this._anchor.copyFrom(anchor); } /* ---------------------------------------- */ /** * The tint applied to the sprite. This is a hex value. * * A value of 0xFFFFFF will remove any tint effect. * @type {number} * @defaultValue 0xFFFFFF */ get tint() { return this._tintColor.value; } set tint(tint) { this._tintColor.setValue(tint); const tintRGB = this._tintColor.toLittleEndianNumber(); if ( tintRGB === this._tintRGB ) return; this._tintRGB = tintRGB; this._tintAlphaDirty = true; } /* ---------------------------------------- */ /** * 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"; } /* ---------------------------------------- */ /** * When the texture is updated, this event will fire to update the scale and frame. * @protected */ _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; this.updateUvs(); } /* ---------------------------------------- */ /** * Called when the anchor position updates. * @protected */ _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 ) { let textureUvs; if ( (this._paddingX !== 0) || (this._paddingY !== 0) ) { const texture = this._texture; const frame = SpriteMesh.#TEMP_RECT.copyFrom(texture.frame).pad(this._paddingX, this._paddingY); textureUvs = this._textureUvs; textureUvs.set(frame, texture.baseTexture, texture.rotate); } else { textureUvs = this._texture._uvs; } this.uvs.set(textureUvs.uvsFloat32); this.#geometry.buffers[1].update(); } } /* ---------------------------------------- */ /** * Initialize shader based on the shader class type. * @param {typeof BaseSamplerShader} shaderClass The shader class */ setShaderClass(shaderClass) { if ( !foundry.utils.isSubclass(shaderClass, BaseSamplerShader) ) { throw new Error("SpriteMesh shader class must inherit from BaseSamplerShader."); } if ( this._shader.constructor === shaderClass ) return; this._shader = shaderClass.create(); } /* ---------------------------------------- */ /** @override */ updateTransform() { super.updateTransform(); // 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; /* ---------------------------------------- */ /** * Calculates worldTransform * vertices, store it in vertexData. */ 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; const padX = this._paddingX; const padY = this._paddingY; 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) - padX; w0 = w1 + trim.width + (2 * padX); h1 = trim.y - (this._anchor._y * orig.height) - padY; h0 = h1 + trim.height + (2 * padY); } else { w1 = (-this._anchor._x * orig.width) - padX; w0 = w1 + orig.width + (2 * padX); h1 = (-this._anchor._y * orig.height) - padY; h0 = h1 + orig.height + (2 * padY); } const vertexData = this.vertexData; vertexData[0] = (a * w1) + (c * h1) + tx; vertexData[1] = (d * h1) + (b * w1) + ty; vertexData[2] = (a * w0) + (c * h1) + tx; vertexData[3] = (d * h1) + (b * w0) + ty; vertexData[4] = (a * w0) + (c * h0) + tx; vertexData[5] = (d * h0) + (b * w0) + ty; vertexData[6] = (a * w1) + (c * h0) + tx; vertexData[7] = (d * h0) + (b * w1) + ty; if ( this.roundPixels ) { const r = PIXI.settings.RESOLUTION; for ( let i = 0; i < vertexData.length; ++i ) vertexData[i] = Math.round(vertexData[i] * r) / r; } this.#geometry.buffers[0].update(); } /* ---------------------------------------- */ /** * Calculates worldTransform * vertices for a non texture with a trim. store it in vertexTrimmedData. * * This is used to ensure that the true width and height of a trimmed texture is respected. */ calculateTrimmedVertices() { if ( !this.vertexTrimmedData ) this.vertexTrimmedData = new Float32Array(8); else if ( (this._transformTrimmedID === this.transform._worldID) && (this._textureTrimmedID === this._texture._updateID) ) return; this._transformTrimmedID = this.transform._worldID; this._textureTrimmedID = this._texture._updateID; const texture = this._texture; const vertexData = this.vertexTrimmedData; const orig = texture.orig; const anchor = this._anchor; const padX = this._paddingX; const padY = this._paddingY; // Compute the new untrimmed bounds const wt = this.transform.worldTransform; const a = wt.a; const b = wt.b; const c = wt.c; const d = wt.d; const tx = wt.tx; const ty = wt.ty; const w1 = (-anchor._x * orig.width) - padX; const w0 = w1 + orig.width + (2 * padX); const h1 = (-anchor._y * orig.height) - padY; const h0 = h1 + orig.height + (2 * padY); vertexData[0] = (a * w1) + (c * h1) + tx; vertexData[1] = (d * h1) + (b * w1) + ty; vertexData[2] = (a * w0) + (c * h1) + tx; vertexData[3] = (d * h1) + (b * w0) + ty; vertexData[4] = (a * w0) + (c * h0) + tx; vertexData[5] = (d * h0) + (b * w0) + ty; vertexData[6] = (a * w1) + (c * h0) + tx; vertexData[7] = (d * h0) + (b * w1) + ty; if ( this.roundPixels ) { const r = PIXI.settings.RESOLUTION; for ( let i = 0; i < vertexData.length; ++i ) vertexData[i] = Math.round(vertexData[i] * r) / r; } } /* ---------------------------------------- */ /** @override */ _render(renderer) { const pluginName = this.pluginName; if ( pluginName ) this.#renderBatched(renderer, pluginName); else this.#renderDirect(renderer, this._shader); } /* ---------------------------------------- */ /** * Render with batching. * @param {PIXI.Renderer} renderer The renderer * @param {string} pluginName The batch renderer */ #renderBatched(renderer, pluginName) { this.calculateVertices(); this._updateBatchData(); const batchRenderer = renderer.plugins[pluginName]; renderer.batch.setObjectRenderer(batchRenderer); batchRenderer.render(this._batchData); } /* ---------------------------------------- */ /** * Render without batching. * @param {PIXI.Renderer} renderer The renderer * @param {BaseSamplerShader} shader The shader */ #renderDirect(renderer, shader) { this.calculateVertices(); if ( this._tintAlphaDirty ) { PIXI.Color.shared.setValue(this._tintColor) .premultiply(this.worldAlpha, this.alphaMode > 0) .toArray(this._cachedTint); this._tintAlphaDirty = false; } shader._preRender(this, renderer); renderer.batch.flush(); renderer.shader.bind(shader); renderer.state.set(this.#state); renderer.geometry.bind(this.#geometry, shader); renderer.geometry.draw(PIXI.DRAW_MODES.TRIANGLES, 6, 0); } /* ---------------------------------------- */ /** * Update the batch data object. * @protected */ _updateBatchData() { this._batchData._texture = this._texture; this._batchData.worldAlpha = this.worldAlpha; this._batchData._tintRGB = this._tintRGB; this._batchData.blendMode = this.#state.blendMode; } /* ---------------------------------------- */ /** @override */ _calculateBounds() { const trim = this._texture.trim; const orig = this._texture.orig; // First lets check to see if the current texture has a trim. if ( !trim || ((trim.width === orig.width) && (trim.height === orig.height)) ) { this.calculateVertices(); this._bounds.addQuad(this.vertexData); } else { this.calculateTrimmedVertices(); this._bounds.addQuad(this.vertexTrimmedData); } } /* ---------------------------------------- */ /** @override */ getLocalBounds(rect) { // Fast local bounds computation if the sprite has no children! if ( this.children.length === 0 ) { if ( !this._localBounds ) this._localBounds = new PIXI.Bounds(); const padX = this._paddingX; const padY = this._paddingY; const orig = this._texture.orig; this._localBounds.minX = (orig.width * -this._anchor._x) - padX; this._localBounds.minY = (orig.height * -this._anchor._y) - padY; this._localBounds.maxX = (orig.width * (1 - this._anchor._x)) + padX; this._localBounds.maxY = (orig.height * (1 - this._anchor._y)) + padY; if ( !rect ) { if ( !this._localBoundsRect ) this._localBoundsRect = new PIXI.Rectangle(); rect = this._localBoundsRect; } return this._localBounds.getRectangle(rect); } return super.getLocalBounds(rect); } /* ---------------------------------------- */ /** @override */ containsPoint(point) { const tempPoint = SpriteMesh.#TEMP_POINT; this.worldTransform.applyInverse(point, tempPoint); const width = this._texture.orig.width; const height = this._texture.orig.height; const x1 = -width * this.anchor.x; let y1 = 0; if ( (tempPoint.x >= x1) && (tempPoint.x < (x1 + width)) ) { y1 = -height * this.anchor.y; if ( (tempPoint.y >= y1) && (tempPoint.y < (y1 + height)) ) return true; } return false; } /* ---------------------------------------- */ /** @override */ destroy(options) { super.destroy(options); this.#geometry.dispose(); this.#geometry = null; this._shader = null; this.#state = null; this.uvs = null; this.indices = null; this.vertexData = null; this._texture.off("update", this._onTextureUpdate, this); this._anchor = null; const destroyTexture = (typeof options === "boolean" ? options : options?.texture); if ( destroyTexture ) { const destroyBaseTexture = (typeof options === "boolean" ? options : options?.baseTexture); this._texture.destroy(!!destroyBaseTexture); } this._texture = null; } /* ---------------------------------------- */ /** * Create a SpriteMesh from another source. * You can specify texture options and a specific shader class derived from BaseSamplerShader. * @param {string|PIXI.Texture|HTMLCanvasElement|HTMLVideoElement} source Source to create texture from. * @param {object} [textureOptions] See {@link PIXI.BaseTexture}'s constructor for options. * @param {BaseSamplerShader} [shaderClass] The shader class to use. BaseSamplerShader by default. * @returns {SpriteMesh} */ static from(source, textureOptions, shaderClass) { const texture = source instanceof PIXI.Texture ? source : PIXI.Texture.from(source, textureOptions); return new SpriteMesh(texture, shaderClass); } } /** * UnboundContainers behave like PIXI.Containers except that they are not bound to their parent's transforms. * However, they normally propagate their own transformations to their children. */ class UnboundContainer extends PIXI.Container { constructor(...args) { super(...args); // Replacing PIXI.Transform with an UnboundTransform this.transform = new UnboundTransform(); } } /* -------------------------------------------- */ /** * A custom Transform class which is not bound to the parent worldTransform. * localTransform are working as usual. */ class UnboundTransform extends PIXI.Transform { /** @override */ static IDENTITY = new UnboundTransform(); /* -------------------------------------------- */ /** @override */ updateTransform(parentTransform) { const lt = this.localTransform; if ( this._localID !== this._currentLocalID ) { // Get the matrix values of the displayobject based on its transform properties.. lt.a = this._cx * this.scale.x; lt.b = this._sx * this.scale.x; lt.c = this._cy * this.scale.y; lt.d = this._sy * this.scale.y; lt.tx = this.position.x - ((this.pivot.x * lt.a) + (this.pivot.y * lt.c)); lt.ty = this.position.y - ((this.pivot.x * lt.b) + (this.pivot.y * lt.d)); this._currentLocalID = this._localID; // Force an update this._parentID = -1; } if ( this._parentID !== parentTransform._worldID ) { // We don't use the values from the parent transform. We're just updating IDs. this._parentID = parentTransform._worldID; this._worldID++; } } } /** * @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 * @property {boolean} [color] Is this a color animation that applies to RGB channels */ /** * @typedef {Object} CanvasAnimationOptions * @property {PIXI.DisplayObject} [context] A DisplayObject which defines context to the PIXI.Ticker function * @property {string|symbol} [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 * @property {Promise} [wait] The animation isn't started until this promise resolves */ /** * @typedef {Object} _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 {number} state The current state of the animation (see {@link CanvasAnimation.STATES}) * @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 */ /** * @typedef {_CanvasAnimationData & CanvasAnimationOptions} CanvasAnimationData */ /** * A helper class providing utility methods for PIXI Canvas animation */ class CanvasAnimation { /** * The possible states of an animation. * @enum {number} */ static get STATES() { return this.#STATES; } static #STATES = Object.freeze({ /** * An error occurred during waiting or running the animation. */ FAILED: -2, /** * The animation was terminated before it could complete. */ TERMINATED: -1, /** * Waiting for the wait promise before the animation is started. */ WAITING: 0, /** * The animation has been started and is running. */ RUNNING: 1, /** * The animation was completed without errors and without being terminated. */ COMPLETED: 2 }); /* -------------------------------------------- */ /** * The ticker used for animations. * @type {PIXI.Ticker} */ 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 {Record} */ 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, wait}={}) { priority ??= PIXI.UPDATE_PRIORITY.LOW + 1; 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; // Special handling for color transitions if ( a.to instanceof Color ) { a.color = true; a.from = Color.from(a.from); } return a; }); if ( attributes.length && attributes.every(a => a.delta === 0) ) return; const animation = {attributes, context, duration, easing, name, ontick, time: 0, wait, state: CanvasAnimation.STATES.WAITING}; animation.fn = dt => CanvasAnimation.#animateFrame(dt, animation); // Create a promise which manages the animation lifecycle const promise = new Promise(async (resolve, reject) => { animation.resolve = completed => { if ( (animation.state === CanvasAnimation.STATES.WAITING) || (animation.state === CanvasAnimation.STATES.RUNNING) ) { animation.state = completed ? CanvasAnimation.STATES.COMPLETED : CanvasAnimation.STATES.TERMINATED; resolve(completed); } }; animation.reject = error => { if ( (animation.state === CanvasAnimation.STATES.WAITING) || (animation.state === CanvasAnimation.STATES.RUNNING) ) { animation.state = CanvasAnimation.STATES.FAILED; reject(error); } }; try { if ( wait instanceof Promise ) await wait; if ( animation.state === CanvasAnimation.STATES.WAITING ) { animation.state = CanvasAnimation.STATES.RUNNING; this.ticker.add(animation.fn, context, priority); } } catch(err) { animation.reject(err); } }) // Log any errors .catch(err => console.error(err)) // Remove the animation once completed .finally(() => { this.ticker.remove(animation.fn, context); if ( name && (this.animations[name] === animation) ) 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 */ 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 complete = animation.time >= duration; const pt = complete ? 1 : animation.time / duration; // Proportion of total duration const pa = animation.easing ? animation.easing(pt) : pt; // Update each attribute try { for ( let a of attributes ) CanvasAnimation.#updateAttribute(a, pa); 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); } /* -------------------------------------------- */ /** * Update a single attribute according to its animation completion percentage * @param {CanvasAnimationAttribute} attribute The attribute being animated * @param {number} percentage The animation completion percentage */ static #updateAttribute(attribute, percentage) { attribute.done = attribute.delta * percentage; // Complete animation if ( percentage === 1 ) { attribute.parent[attribute.attribute] = attribute.to; return; } // Color animation if ( attribute.color ) { attribute.parent[attribute.attribute] = attribute.from.mix(attribute.to, percentage); return; } // Numeric attribute attribute.parent[attribute.attribute] = attribute.from + attribute.done; } } /** * 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, elevation=0}={}, ...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.eventMode = "static"; this.interactiveChildren = false; this.hitArea = new PIXI.Rectangle(...this.rect); this.cursor = "pointer"; // Background this.bg = this.addChild(new PIXI.Graphics()); this.bg.clear().beginFill(0x000000, 0.4).lineStyle(2, 0x000000, 1.0).drawRoundedRect(...this.rect, 5).endFill(); // Icon this.icon = this.addChild(new PIXI.Sprite()); // Border this.border = this.addChild(new PIXI.Graphics()); this.border.visible = false; // Elevation this.tooltip = this.addChild(new PreciseText()); this.tooltip.visible = false; // Set the initial elevation this.elevation = elevation; // Draw asynchronously this.draw(); } /* -------------------------------------------- */ /** * The elevation of the ControlIcon, which is displayed in its tooltip text. * @type {number} */ get elevation() { return this.#elevation; } set elevation(value) { if ( (typeof value !== "number") || !Number.isFinite(value) ) { throw new Error("ControlIcon#elevation must be a finite numeric value."); } if ( value === this.#elevation ) return; this.#elevation = value; this.tooltip.text = `${value > 0 ? "+" : ""}${value} ${canvas.grid.units}`.trim(); this.tooltip.visible = value !== 0; } #elevation = 0; /* -------------------------------------------- */ /** * Initial drawing of the ControlIcon * @returns {Promise} */ async draw() { if ( this.destroyed ) return this; this.texture = this.texture ?? await loadTexture(this.iconSrc); this.icon.texture = this.texture; this.icon.width = this.icon.height = this.size; this.tooltip.style = CONFIG.canvasTextStyle; this.tooltip.anchor.set(0.5, 1); this.tooltip.position.set(this.size / 2, -12); return this.refresh(); } /* -------------------------------------------- */ /** * Incremental refresh for ControlIcon appearance. */ refresh({visible, iconColor, borderColor, borderVisible}={}) { if ( iconColor !== undefined ) this.tintColor = iconColor; this.icon.tint = this.tintColor ?? 0xFFFFFF; if ( borderColor !== undefined ) this.borderColor = borderColor; this.border.clear().lineStyle(2, this.borderColor, 1.0).drawRoundedRect(...this.rect, 5).endFill(); if ( borderVisible !== undefined ) this.border.visible = borderVisible; if ( visible !== undefined && (this.visible !== visible) ) { this.visible = visible; MouseInteractionManager.emulateMoveEvent(); } return this; } } /** * Handle mouse interaction events for a Canvas object. * There are three phases of events: hover, click, and drag * * Hover Events: * _handlePointerOver * action: hoverIn * _handlePointerOut * action: hoverOut * * Left Click and Double-Click * _handlePointerDown * action: clickLeft * action: clickLeft2 * action: unclickLeft * * Right Click and Double-Click * _handleRightDown * action: clickRight * action: clickRight2 * action: unclickRight * * Drag and Drop * _handlePointerMove * action: dragLeftStart * action: dragRightStart * action: dragLeftMove * action: dragRightMove * _handlePointerUp * 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 interaction data object to populate with custom data. * @type {Record} */ this.interactionData = {}; /** * The drag handling time * @type {number} */ this.dragTime = 0; /** * 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|null} */ this.controlIcon = this.options.target ? this.object[this.options.target] : null; /** * The view id pertaining to the PIXI Application. * If not provided, default to canvas.app.view.id * @type {string} */ this.viewId = (this.options.application ?? canvas.app).view.id; } /** * The client position of the last left/right-click. * @type {PIXI.Point} */ lastClick = new PIXI.Point(); /** * Bound handlers which can be added and removed * @type {Record} */ #handlers = {}; /** * 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: GRABBED - the object is grabbed * 4: DRAG - the object is being dragged * 5: DROP - the object is being dropped * @enum {number} */ static INTERACTION_STATES = { NONE: 0, HOVER: 1, CLICKED: 2, GRABBED: 3, DRAG: 4, DROP: 5 }; /** * Enumerate the states of handle outcome. * -2: SKIPPED - the handler has been skipped by previous logic * -1: DISALLOWED - the handler has dissallowed further process * 1: REFUSED - the handler callback has been processed and is refusing further process * 2: ACCEPTED - the handler callback has been processed and is accepting further process * @enum {number} */ static #HANDLER_OUTCOME = { SKIPPED: -2, DISALLOWED: -1, REFUSED: 1, ACCEPTED: 2 }; /** * The maximum number of milliseconds between two clicks to be considered a double-click. * @type {number} */ static DOUBLE_CLICK_TIME_MS = 250; /** * The maximum number of pixels between two clicks to be considered a double-click. * @type {number} */ static DOUBLE_CLICK_DISTANCE_PX = 5; /** * 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; /* -------------------------------------------- */ /** * Emulate a pointermove event. Needs to be called when an object with the static event mode * or any of its parents is transformed or its visibility is changed. */ static emulateMoveEvent() { MouseInteractionManager.#emulateMoveEvent(); } static #emulateMoveEvent = foundry.utils.throttle(() => { const events = canvas.app.renderer.events; const rootPointerEvent = events.rootPointerEvent; if ( !events.supportsPointerEvents ) return; if ( events.supportsTouchEvents && (rootPointerEvent.pointerType === "touch") ) return; events.domElement.dispatchEvent(new PointerEvent("pointermove", { pointerId: rootPointerEvent.pointerId, pointerType: rootPointerEvent.pointerType, isPrimary: rootPointerEvent.isPrimary, clientX: rootPointerEvent.clientX, clientY: rootPointerEvent.clientY, pageX: rootPointerEvent.pageX, pageY: rootPointerEvent.pageY, altKey: rootPointerEvent.altKey, ctrlKey: rootPointerEvent.ctrlKey, metaKey: rootPointerEvent.metaKey, shiftKey: rootPointerEvent.shiftKey })); }, 10); /* -------------------------------------------- */ /** * Get the target. * @type {PIXI.DisplayObject} */ get target() { return this.options.target ? this.object[this.options.target] : this.object; } /** * Is this mouse manager in a dragging state? * @type {boolean} */ get isDragging() { return this.state >= this.states.DRAG; } /* -------------------------------------------- */ /** * 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 = { pointerover: this.#handlePointerOver.bind(this), pointerout: this.#handlePointerOut.bind(this), pointerdown: this.#handlePointerDown.bind(this), pointermove: this.#handlePointerMove.bind(this), pointerup: this.#handlePointerUp.bind(this), contextmenu: this.#handleDragCancel.bind(this) }; // Activate hover events to start the workflow this.#activateHoverEvents(); // Set the target as interactive this.target.eventMode = "static"; 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|PIXI.FederatedEvent} 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|PIXI.FederatedEvent} event The event being handled * @param {...*} args Additional callback arguments. * @returns {boolean} A boolean which may indicate that the event was handled by the callback. * Events which do not specify a callback are assumed to have been handled as no-op. */ callback(action, event, ...args) { const fn = this.callbacks[action]; if ( fn instanceof Function ) { this.#assignInteractionData(event); return fn.call(this.object, event, ...args) ?? true; } return true; } /* -------------------------------------------- */ /** * A reference to the possible interaction states which can be observed * @returns {Record} */ get states() { return this.constructor.INTERACTION_STATES; } /* -------------------------------------------- */ /** * A reference to the possible interaction states which can be observed * @returns {Record} */ get handlerOutcomes() { return MouseInteractionManager.#HANDLER_OUTCOME; } /* -------------------------------------------- */ /* Listener Activation and Deactivation */ /* -------------------------------------------- */ /** * Activate a set of listeners which handle hover events on the target object */ #activateHoverEvents() { // Disable and re-register mouseover and mouseout handlers this.target.off("pointerover", this.#handlers.pointerover).on("pointerover", this.#handlers.pointerover); this.target.off("pointerout", this.#handlers.pointerout).on("pointerout", this.#handlers.pointerout); } /* -------------------------------------------- */ /** * Activate a new set of listeners for click events on the target object. */ #activateClickEvents() { this.#deactivateClickEvents(); this.target.on("pointerdown", this.#handlers.pointerdown); this.target.on("pointerup", this.#handlers.pointerup); this.target.on("pointerupoutside", this.#handlers.pointerup); } /* -------------------------------------------- */ /** * Deactivate event listeners for click events on the target object. */ #deactivateClickEvents() { this.target.off("pointerdown", this.#handlers.pointerdown); this.target.off("pointerup", this.#handlers.pointerup); this.target.off("pointerupoutside", this.#handlers.pointerup); } /* -------------------------------------------- */ /** * Activate events required for handling a drag-and-drop workflow */ #activateDragEvents() { this.#deactivateDragEvents(); this.layer.on("pointermove", this.#handlers.pointermove); if ( !this._dragRight ) { canvas.app.view.addEventListener("contextmenu", this.#handlers.contextmenu, {capture: true}); } } /* -------------------------------------------- */ /** * Deactivate events required for handling drag-and-drop workflow. * @param {boolean} [silent] Set to true to activate the silent mode. */ #deactivateDragEvents(silent) { this.layer.off("pointermove", this.#handlers.pointermove); 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. * @param {PIXI.FederatedEvent} event */ #handlePointerOver(event) { const action = "hoverIn"; if ( (this.state !== this.states.NONE) || (event.nativeEvent && (event.nativeEvent.target.id !== this.viewId)) ) { return this.#debug(action, event, this.handlerOutcomes.SKIPPED); } if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED); // Invoke the callback function this.state = this.states.HOVER; if ( this.callback(action, event) === false ) { this.state = this.states.NONE; return this.#debug(action, event, this.handlerOutcomes.REFUSED); } // Activate click events this.#activateClickEvents(); return this.#debug(action, event); } /* -------------------------------------------- */ /** * Handle mouse-out events which terminate hover workflows and do not stop propagation. * @param {PIXI.FederatedEvent} event */ #handlePointerOut(event) { if ( event.pointerType === "touch" ) return; // Ignore Touch events const action = "hoverOut"; if ( !this.state.between(this.states.HOVER, this.states.CLICKED) || (event.nativeEvent && (event.nativeEvent.target.id !== this.viewId) ) ) { return this.#debug(action, event, this.handlerOutcomes.SKIPPED); } if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED); // Was the mouse-out event handled by the callback? const priorState = this.state; this.state = this.states.NONE; if ( this.callback(action, event) === false ) { this.state = priorState; return this.#debug(action, event, this.handlerOutcomes.REFUSED); } // Deactivate click events this.#deactivateClickEvents(); return this.#debug(action, event); } /* -------------------------------------------- */ /** * Handle mouse-down events which activate downstream listeners. * @param {PIXI.FederatedEvent} event */ #handlePointerDown(event) { if ( event.button === 0 ) return this.#handleLeftDown(event); if ( event.button === 2 ) return this.#handleRightDown(event); } /* -------------------------------------------- */ /* Left Click and Double Click */ /* -------------------------------------------- */ /** * Handle left-click mouse-down events. * Stop further propagation only if the event is allowed by either single or double-click. * @param {PIXI.FederatedEvent} event */ #handleLeftDown(event) { if ( !this.state.between(this.states.HOVER, this.states.DRAG) ) return; // Determine double vs single click const isDouble = ((event.timeStamp - this.lcTime) <= MouseInteractionManager.DOUBLE_CLICK_TIME_MS) && (Math.hypot(event.clientX - this.lastClick.x, event.clientY - this.lastClick.y) <= MouseInteractionManager.DOUBLE_CLICK_DISTANCE_PX); this.lcTime = isDouble ? 0 : event.timeStamp; this.lastClick.set(event.clientX, event.clientY); // Set the origin point from layer local position this.interactionData.origin = event.getLocalPosition(this.layer); // Activate a timeout to detect long presses if ( !isDouble ) { clearTimeout(this.constructor.longPressTimeout); this.constructor.longPressTimeout = setTimeout(() => { this.#handleLongPress(event, this.interactionData.origin); }, MouseInteractionManager.LONG_PRESS_DURATION_MS); } // Dispatch to double and single-click handlers if ( isDouble && this.can("clickLeft2", event) ) return this.#handleClickLeft2(event); else return this.#handleClickLeft(event); } /* -------------------------------------------- */ /** * Handle mouse-down which trigger a single left-click workflow. * @param {PIXI.FederatedEvent} event */ #handleClickLeft(event) { const action = "clickLeft"; if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED); this._dragRight = false; // Was the left-click event handled by the callback? const priorState = this.state; if ( this.state === this.states.HOVER ) this.state = this.states.CLICKED; canvas.currentMouseManager = this; if ( this.callback(action, event) === false ) { this.state = priorState; canvas.currentMouseManager = null; return this.#debug(action, event, this.handlerOutcomes.REFUSED); } // Activate drag event handlers if ( (this.state === this.states.CLICKED) && this.can("dragStart", event) ) { this.state = this.states.GRABBED; this.#activateDragEvents(); } return this.#debug(action, event); } /* -------------------------------------------- */ /** * Handle mouse-down which trigger a single left-click workflow. * @param {PIXI.FederatedEvent} event */ #handleClickLeft2(event) { const action = "clickLeft2"; if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED); return this.#debug(action, event); } /* -------------------------------------------- */ /** * Handle a long mouse depression to trigger a long-press workflow. * @param {PIXI.FederatedEvent} event The mousedown event. * @param {PIXI.Point} origin The original canvas coordinates of the mouse click */ #handleLongPress(event, origin) { const action = "longPress"; if ( this.callback(action, event, origin) === false ) { return this.#debug(action, event, this.handlerOutcomes.REFUSED); } return this.#debug(action, event); } /* -------------------------------------------- */ /* Right Click and Double Click */ /* -------------------------------------------- */ /** * Handle right-click mouse-down events. * Stop further propagation only if the event is allowed by either single or double-click. * @param {PIXI.FederatedEvent} event */ #handleRightDown(event) { if ( !this.state.between(this.states.HOVER, this.states.DRAG) ) return; // Determine double vs single click const isDouble = ((event.timeStamp - this.rcTime) <= MouseInteractionManager.DOUBLE_CLICK_TIME_MS) && (Math.hypot(event.clientX - this.lastClick.x, event.clientY - this.lastClick.y) <= MouseInteractionManager.DOUBLE_CLICK_DISTANCE_PX); this.rcTime = isDouble ? 0 : event.timeStamp; this.lastClick.set(event.clientX, event.clientY); // Update event data this.interactionData.origin = event.getLocalPosition(this.layer); // Dispatch to double and single-click handlers if ( isDouble && this.can("clickRight2", event) ) return this.#handleClickRight2(event); else return this.#handleClickRight(event); } /* -------------------------------------------- */ /** * Handle single right-click actions. * @param {PIXI.FederatedEvent} event */ #handleClickRight(event) { const action = "clickRight"; if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED); this._dragRight = true; // Was the right-click event handled by the callback? const priorState = this.state; if ( this.state === this.states.HOVER ) this.state = this.states.CLICKED; canvas.currentMouseManager = this; if ( this.callback(action, event) === false ) { this.state = priorState; canvas.currentMouseManager = null; return this.#debug(action, event, this.handlerOutcomes.REFUSED); } // Activate drag event handlers if ( (this.state === this.states.CLICKED) && this.can("dragRight", event) ) { this.state = this.states.GRABBED; this.#activateDragEvents(); } return this.#debug(action, event); } /* -------------------------------------------- */ /** * Handle double right-click actions. * @param {PIXI.FederatedEvent} event */ #handleClickRight2(event) { const action = "clickRight2"; if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED); return this.#debug(action, event); } /* -------------------------------------------- */ /* Drag and Drop */ /* -------------------------------------------- */ /** * Handle mouse movement during a drag workflow * @param {PIXI.FederatedEvent} event */ #handlePointerMove(event) { if ( !this.state.between(this.states.GRABBED, this.states.DRAG) ) return; // Limit dragging to 60 updates per second const now = Date.now(); if ( (now - this.dragTime) < canvas.app.ticker.elapsedMS ) return; this.dragTime = now; // Update interaction data const data = this.interactionData; data.destination = event.getLocalPosition(this.layer, data.destination); // Handling rare case when origin is not defined // FIXME: The root cause should be identified and this code removed if ( data.origin === undefined ) data.origin = new PIXI.Point().copyFrom(data.destination); // Begin a new drag event if ( this.state !== this.states.DRAG ) { const dx = data.destination.x - data.origin.x; const dy = data.destination.y - data.origin.y; const dz = Math.hypot(dx, dy); const r = this.options.dragResistance || (canvas.dimensions.size / 4); if ( dz >= r ) this.#handleDragStart(event); } // Continue a drag event if ( this.state === this.states.DRAG ) this.#handleDragMove(event); } /* -------------------------------------------- */ /** * Handle the beginning of a new drag start workflow, moving all controlled objects on the layer * @param {PIXI.FederatedEvent} event */ #handleDragStart(event) { clearTimeout(this.constructor.longPressTimeout); const action = this._dragRight ? "dragRightStart" : "dragLeftStart"; if ( !this.can(action, event) ) { this.#debug(action, event, this.handlerOutcomes.DISALLOWED); this.cancel(event); return; } this.state = this.states.DRAG; if ( this.callback(action, event) === false ) { this.state = this.states.GRABBED; return this.#debug(action, event, this.handlerOutcomes.REFUSED); } return this.#debug(action, event, this.handlerOutcomes.ACCEPTED); } /* -------------------------------------------- */ /** * Handle the continuation of a drag workflow, moving all controlled objects on the layer * @param {PIXI.FederatedEvent} event */ #handleDragMove(event) { clearTimeout(this.constructor.longPressTimeout); const action = this._dragRight ? "dragRightMove" : "dragLeftMove"; if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED); const handled = this.callback(action, event); return this.#debug(action, event, handled ? this.handlerOutcomes.ACCEPTED : this.handlerOutcomes.REFUSED); } /* -------------------------------------------- */ /** * Handle mouse up events which may optionally conclude a drag workflow * @param {PIXI.FederatedEvent} event */ #handlePointerUp(event) { clearTimeout(this.constructor.longPressTimeout); // If this is a touch hover event, treat it as a drag if ( (this.state === this.states.HOVER) && (event.pointerType === "touch") ) { this.state = this.states.DRAG; } // Save prior state const priorState = this.state; // Update event data this.interactionData.destination = event.getLocalPosition(this.layer, this.interactionData.destination); if ( this.state >= this.states.DRAG ) { event.stopPropagation(); if ( event.type.startsWith("right") && !this._dragRight ) return; if ( this.state === this.states.DRAG ) this.#handleDragDrop(event); } // Continue a multi-click drag workflow if ( event.defaultPrevented ) { this.state = priorState; return this.#debug("mouseUp", event, this.handlerOutcomes.SKIPPED); } // Handle the unclick event this.#handleUnclick(event); // Cancel the drag workflow this.#handleDragCancel(event); } /* -------------------------------------------- */ /** * Handle the conclusion of a drag workflow, placing all dragged objects back on the layer * @param {PIXI.FederatedEvent} event */ #handleDragDrop(event) { const action = this._dragRight ? "dragRightDrop" : "dragLeftDrop"; if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED); // Was the drag-drop event handled by the callback? this.state = this.states.DROP; if ( this.callback(action, event) === false ) { this.state = this.states.DRAG; return this.#debug(action, event, this.handlerOutcomes.REFUSED); } // Update the workflow state return this.#debug(action, event); } /* -------------------------------------------- */ /** * Handle the cancellation of a drag workflow, resetting back to the original state * @param {PIXI.FederatedEvent} event */ #handleDragCancel(event) { this.cancel(event); } /* -------------------------------------------- */ /** * Handle the unclick event * @param {PIXI.FederatedEvent} event */ #handleUnclick(event) { const action = event.button === 0 ? "unclickLeft" : "unclickRight"; if ( !this.state.between(this.states.CLICKED, this.states.GRABBED) ) { return this.#debug(action, event, this.handlerOutcomes.SKIPPED); } if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED); return this.#debug(action, event); } /* -------------------------------------------- */ /** * A public method to handle directly an event into this manager, according to its type. * Note: drag events are not handled. * @param {PIXI.FederatedEvent} event * @returns {boolean} Has the event been processed? */ handleEvent(event) { switch ( event.type ) { case "pointerover": this.#handlePointerOver(event); break; case "pointerout": this.#handlePointerOut(event); break; case "pointerup": this.#handlePointerUp(event); break; case "pointerdown": this.#handlePointerDown(event); break; default: return false; } return true; } /* -------------------------------------------- */ /** * A public method to cancel a current interaction workflow from this manager. * @param {PIXI.FederatedEvent} [event] The event that initiates the cancellation */ cancel(event) { const eventSystem = canvas.app.renderer.events; const rootBoundary = eventSystem.rootBoundary; const createEvent = !event; if ( createEvent ) { event = rootBoundary.createPointerEvent(eventSystem.pointer, "pointermove", this.target); event.defaultPrevented = false; event.path = null; } try { const action = this._dragRight ? "dragRightCancel" : "dragLeftCancel"; const endState = this.state; if ( endState <= this.states.HOVER ) return this.#debug(action, event, this.handlerOutcomes.SKIPPED); // Dispatch a cancellation callback if ( endState >= this.states.DRAG ) { if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED); } // Continue a multi-click drag workflow if the default event was prevented in the callback if ( event.defaultPrevented ) { this.state = this.states.DRAG; return this.#debug(action, event, this.handlerOutcomes.SKIPPED); } // Reset the interaction data and state and deactivate drag events this.interactionData = {}; this.state = this.states.HOVER; canvas.currentMouseManager = null; clearTimeout(this.constructor.longPressTimeout); this.#deactivateDragEvents(); this.#debug(action, event); // Check hover state and hover out if necessary if ( !rootBoundary.trackingData(event.pointerId).overTargets?.includes(this.target) ) { this.#handlePointerOut(event); } } finally { if ( createEvent ) rootBoundary.freeEvent(event); } } /* -------------------------------------------- */ /** * Display a debug message in the console (if mouse interaction debug is activated). * @param {string} action Which action to display? * @param {Event|PIXI.FederatedEvent} event Which event to display? * @param {number} [outcome=this.handlerOutcomes.ACCEPTED] The handler outcome. */ #debug(action, event, outcome=this.handlerOutcomes.ACCEPTED) { if ( CONFIG.debug.mouseInteraction ) { const name = this.object.constructor.name; const targetName = event.target?.constructor.name; const {eventPhase, type, button} = event; const state = Object.keys(this.states)[this.state.toString()]; let msg = `${name} | ${action} | state:${state} | target:${targetName} | phase:${eventPhase} | type:${type} | ` + `btn:${button} | skipped:${outcome <= -2} | allowed:${outcome > -1} | handled:${outcome > 1}`; console.debug(msg); } } /* -------------------------------------------- */ /** * Reset the mouse manager. * @param {object} [options] * @param {boolean} [options.interactionData=true] Reset the interaction data? * @param {boolean} [options.state=true] Reset the state? */ reset({interactionData=true, state=true}={}) { if ( CONFIG.debug.mouseInteraction ) { console.debug(`${this.object.constructor.name} | Reset | interactionData:${interactionData} | state:${state}`); } if ( interactionData ) this.interactionData = {}; if ( state ) this.state = MouseInteractionManager.INTERACTION_STATES.NONE; } /* -------------------------------------------- */ /** * Assign the interaction data to the event. * @param {PIXI.FederatedEvent} event */ #assignInteractionData(event) { this.interactionData.object = this.object; event.interactionData = this.interactionData; // Add deprecated event data references for ( const k of Object.keys(this.interactionData) ) { if ( event.hasOwnProperty(k) ) continue; /** * @deprecated since v11 * @ignore */ Object.defineProperty(event, k, { get() { const msg = `event.data.${k} is deprecated in favor of event.interactionData.${k}.`; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return this.interactionData[k]; }, set(value) { const msg = `event.data.${k} is deprecated in favor of event.interactionData.${k}.`; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); this.interactionData[k] = value; } }); } } } /** * @typedef {object} PingOptions * @property {number} [duration=900] The duration of the animation in milliseconds. * @property {number} [size=128] The size of the ping graphic. * @property {string} [color=#ff6400] The color of the ping graphic. * @property {string} [name] The name for the ping animation to pass to {@link CanvasAnimation.animate}. */ /** * A class to manage a user ping on the canvas. * @param {Point} origin The canvas coordinates of the origin of the ping. * @param {PingOptions} [options] Additional options to configure the ping animation. */ class Ping extends PIXI.Container { constructor(origin, options={}) { super(); this.x = origin.x; this.y = origin.y; this.options = foundry.utils.mergeObject({duration: 900, size: 128, color: "#ff6400"}, options); this._color = Color.from(this.options.color); } /* -------------------------------------------- */ /** @inheritdoc */ destroy(options={}) { options.children = true; super.destroy(options); } /* -------------------------------------------- */ /** * Start the ping animation. * @returns {Promise} Returns true if the animation ran to completion, false otherwise. */ async animate() { const completed = await CanvasAnimation.animate([], { context: this, name: this.options.name, duration: this.options.duration, ontick: this._animateFrame.bind(this) }); this.destroy(); return completed; } /* -------------------------------------------- */ /** * On each tick, advance the animation. * @param {number} dt The number of ms that elapsed since the previous frame. * @param {CanvasAnimationData} animation The animation state. * @protected */ _animateFrame(dt, animation) { throw new Error("Subclasses of Ping must implement the _animateFrame method."); } } /** * @typedef {Object} RenderFlag * @property {string[]} propagate Activating this flag also sets these flags to true * @property {string[]} reset Activating this flag resets these flags to false * @property {object} [deprecated] Is this flag deprecated? The deprecation options are passed to * logCompatibilityWarning. The deprectation message is auto-generated * unless message is passed with the options. * By default the message is logged only once. */ /** * A data structure for tracking a set of boolean status flags. * This is a restricted set which can only accept flag values which are pre-defined. * @param {Record} flags An object which defines the flags which are supported for tracking * @param {object} [config] Optional configuration * @param {RenderFlagObject} [config.object] The object which owns this RenderFlags instance * @param {number} [config.priority] The ticker priority at which these render flags are handled */ class RenderFlags extends Set { constructor(flags={}, {object, priority=PIXI.UPDATE_PRIORITY.OBJECTS}={}) { super([]); for ( const cfg of Object.values(flags) ) { cfg.propagate ||= []; cfg.reset ||= []; } Object.defineProperties(this, { /** * The flags tracked by this data structure. * @type {Record} */ flags: {value: Object.freeze(flags), enumerable: false, writable: false}, /** * The RenderFlagObject instance which owns this set of RenderFlags * @type {RenderFlagObject} */ object: {value: object, enumerable: false, writable: false}, /** * The update priority when these render flags are applied. * Valid options are OBJECTS or PERCEPTION. * @type {string} */ priority: {value: priority, enumerable: false, writable: false} }); } /* -------------------------------------------- */ /** * @inheritDoc * @returns {Record} The flags which were previously set that have been cleared. */ clear() { // Record which flags were previously active const flags = {}; for ( const flag of this ) { flags[flag] = true; } // Empty the set super.clear(); // Remove the object from the pending queue if ( this.object ) canvas.pendingRenderFlags[this.priority].delete(this.object); return flags; } /* -------------------------------------------- */ /** * Allow for handling one single flag at a time. * This function returns whether the flag needs to be handled and removes it from the pending set. * @param {string} flag * @returns {boolean} */ handle(flag) { const active = this.has(flag); this.delete(flag); return active; } /* -------------------------------------------- */ /** * Activate certain flags, also toggling propagation and reset behaviors * @param {Record} changes */ set(changes) { const seen = new Set(); for ( const [flag, value] of Object.entries(changes) ) { this.#set(flag, value, seen); } if ( this.object ) canvas.pendingRenderFlags[this.priority].add(this.object); } /* -------------------------------------------- */ /** * Recursively set a flag. * This method applies propagation or reset behaviors when flags are assigned. * @param {string} flag * @param {boolean} value * @param {Set} seen */ #set(flag, value, seen) { if ( seen.has(flag) || !value ) return; seen.add(flag); const cfg = this.flags[flag]; if ( !cfg ) throw new Error(`"${flag}" is not defined as a supported RenderFlag option.`); if ( cfg.deprecated ) this.#logDreprecationWarning(flag); if ( !cfg.alias ) this.add(flag); for ( const r of cfg.reset ) this.delete(r); for ( const p of cfg.propagate ) this.#set(p, true, seen); } /* -------------------------------------------- */ /** * Log the deprecation warning of the flag. * @param {string} flag */ #logDreprecationWarning(flag) { const cfg = this.flags[flag]; if ( !cfg.deprecated ) throw new Error(`The RenderFlag "${flag}" is not deprecated`); let {message, ...options} = cfg.deprecated; if ( !message ) { message = `The RenderFlag "${flag}"`; if ( this.object ) message += ` of ${this.object.constructor.name}`; message += " is deprecated"; if ( cfg.propagate.length === 0 ) message += " without replacement."; else if ( cfg.propagate.length === 1 ) message += ` in favor of ${cfg.propagate[0]}.`; else message += `. Use ${cfg.propagate.slice(0, -1).join(", ")} and/or ${cfg.propagate.at(-1)} instead.`; } options.once ??= true; foundry.utils.logCompatibilityWarning(message, options); } } /* -------------------------------------------- */ /** * Add RenderFlags functionality to some other object. * This mixin standardizes the interface for such functionality. * @param {typeof PIXI.DisplayObject|typeof Object} Base The base class being mixed. Normally a PIXI.DisplayObject * @returns {typeof RenderFlagObject} The mixed class definition */ function RenderFlagsMixin(Base) { return class RenderFlagObject extends Base { constructor(...args) { super(...args); this.renderFlags = new RenderFlags(this.constructor.RENDER_FLAGS, { object: this, priority: this.constructor.RENDER_FLAG_PRIORITY }); } /** * Configure the render flags used for this class. * @type {Record} */ static RENDER_FLAGS = {}; /** * The ticker priority when RenderFlags of this class are handled. * Valid values are OBJECTS or PERCEPTION. * @type {string} */ static RENDER_FLAG_PRIORITY = "OBJECTS"; /** * Status flags which are applied at render-time to update the PlaceableObject. * If an object defines RenderFlags, it should at least include flags for "redraw" and "refresh". * @type {RenderFlags} */ renderFlags; /** * Apply any current render flags, clearing the renderFlags set. * Subclasses should override this method to define behavior. */ applyRenderFlags() { this.renderFlags.clear(); } }; } /* -------------------------------------------- */ class ResizeHandle extends PIXI.Graphics { constructor(offset, handlers={}) { super(); this.offset = offset; this.handlers = handlers; this.lineStyle(4, 0x000000, 1.0).beginFill(0xFF9829, 1.0).drawCircle(0, 0, 10).endFill(); this.cursor = "pointer"; } /** * Track whether the handle is being actively used for a drag workflow * @type {boolean} */ active = false; /* -------------------------------------------- */ refresh(bounds) { this.position.set(bounds.x + (bounds.width * this.offset[0]), bounds.y + (bounds.height * this.offset[1])); this.hitArea = new PIXI.Rectangle(-16, -16, 32, 32); // Make the handle easier to grab } /* -------------------------------------------- */ updateDimensions(current, origin, destination, {aspectRatio=null}={}) { // Identify the change in dimensions const dx = destination.x - origin.x; const dy = destination.y - origin.y; // Determine the new width and the new height let width = Math.max(origin.width + dx, 24); let height = Math.max(origin.height + dy, 24); // Constrain the aspect ratio if ( aspectRatio ) { if ( width >= height ) width = height * aspectRatio; else height = width / aspectRatio; } // Adjust the final points return { x: current.x, y: current.y, width: width * Math.sign(current.width), height: height * Math.sign(current.height) }; } /* -------------------------------------------- */ /* Interactivity */ /* -------------------------------------------- */ activateListeners() { this.off("pointerover").off("pointerout").off("pointerdown") .on("pointerover", this._onHoverIn.bind(this)) .on("pointerout", this._onHoverOut.bind(this)) .on("pointerdown", this._onMouseDown.bind(this)); this.eventMode = "static"; } /* -------------------------------------------- */ /** * Handle mouse-over event on a control handle * @param {PIXI.FederatedEvent} event The mouseover event * @protected */ _onHoverIn(event) { const handle = event.target; handle.scale.set(1.5, 1.5); } /* -------------------------------------------- */ /** * Handle mouse-out event on a control handle * @param {PIXI.FederatedEvent} event The mouseout event * @protected */ _onHoverOut(event) { const handle = event.target; handle.scale.set(1.0, 1.0); } /* -------------------------------------------- */ /** * When we start a drag event - create a preview copy of the Tile for re-positioning * @param {PIXI.FederatedEvent} event The mousedown event * @protected */ _onMouseDown(event) { if ( this.handlers.canDrag && !this.handlers.canDrag() ) return; this.active = true; } } /** * A subclass of Set which manages the Token ids which the User has targeted. * @extends {Set} * @see User#targets */ class UserTargets extends Set { constructor(user) { super(); if ( user.targets ) throw new Error(`User ${user.id} already has a targets set defined`); this.user = user; } /** * Return the Token IDs which are user targets * @type {string[]} */ get ids() { return Array.from(this).map(t => t.id); } /** @override */ add(token) { if ( this.has(token) ) return this; super.add(token); this.#hook(token, true); return this; } /** @override */ clear() { const tokens = Array.from(this); super.clear(); tokens.forEach(t => this.#hook(t, false)); } /** @override */ delete(token) { if ( !this.has(token) ) return false; super.delete(token); this.#hook(token, false); return true; } /** * Dispatch the targetToken hook whenever the user's target set changes. * @param {Token} token The targeted Token * @param {boolean} targeted Whether the Token has been targeted or untargeted */ #hook(token, targeted) { Hooks.callAll("targetToken", this.user, token, targeted); } } /** * A special class of Polygon which implements a limited angle of emission for a Point Source. * The shape is defined by a point origin, radius, angle, and rotation. * The shape is further customized by a configurable density which informs the approximation. * An optional secondary externalRadius can be provided which adds supplementary visibility outside the primary angle. */ class LimitedAnglePolygon extends PIXI.Polygon { constructor(origin, {radius, angle=360, rotation=0, density, externalRadius=0} = {}) { super([]); /** * The origin point of the Polygon * @type {Point} */ this.origin = origin; /** * The radius of the emitted cone. * @type {number} */ this.radius = radius; /** * The angle of the Polygon in degrees. * @type {number} */ this.angle = angle; /** * The direction of rotation at the center of the emitted angle in degrees. * @type {number} */ this.rotation = rotation; /** * The density of rays which approximate the cone, defined as rays per PI. * @type {number} */ this.density = density ?? PIXI.Circle.approximateVertexDensity(this.radius); /** * An optional "external radius" which is included in the polygon for the supplementary area outside the cone. * @type {number} */ this.externalRadius = externalRadius; /** * The angle of the left (counter-clockwise) edge of the emitted cone in radians. * @type {number} */ this.aMin = Math.normalizeRadians(Math.toRadians(this.rotation + 90 - (this.angle / 2))); /** * The angle of the right (clockwise) edge of the emitted cone in radians. * @type {number} */ this.aMax = this.aMin + Math.toRadians(this.angle); // Generate polygon points this.#generatePoints(); } /** * The bounding box of the circle defined by the externalRadius, if any * @type {PIXI.Rectangle} */ externalBounds; /* -------------------------------------------- */ /** * Generate the points of the LimitedAnglePolygon using the provided configuration parameters. */ #generatePoints() { const {x, y} = this.origin; // Construct polygon points for the primary angle const primaryAngle = this.aMax - this.aMin; const nPrimary = Math.ceil((primaryAngle * this.density) / (2 * Math.PI)); const dPrimary = primaryAngle / nPrimary; for ( let i=0; i<=nPrimary; i++ ) { const pad = Ray.fromAngle(x, y, this.aMin + (i * dPrimary), this.radius); this.points.push(pad.B.x, pad.B.y); } // Add secondary angle if ( this.externalRadius ) { const secondaryAngle = (2 * Math.PI) - primaryAngle; const nSecondary = Math.ceil((secondaryAngle * this.density) / (2 * Math.PI)); const dSecondary = secondaryAngle / nSecondary; for ( let i=0; i<=nSecondary; i++ ) { const pad = Ray.fromAngle(x, y, this.aMax + (i * dSecondary), this.externalRadius); this.points.push(pad.B.x, pad.B.y); } this.externalBounds = (new PIXI.Circle(x, y, this.externalRadius)).getBounds(); } // No secondary angle else { this.points.unshift(x, y); this.points.push(x, y); } } /* -------------------------------------------- */ /** * Restrict the edges which should be included in a PointSourcePolygon based on this specialized shape. * We use two tests to jointly keep or reject edges. * 1. If this shape uses an externalRadius, keep edges which collide with the bounding box of that circle. * 2. Keep edges which are contained within or collide with one of the primary angle boundary rays. * @param {Point} a The first edge vertex * @param {Point} b The second edge vertex * @returns {boolean} Should the edge be included in the PointSourcePolygon computation? * @internal */ _includeEdge(a, b) { // 1. If this shape uses an externalRadius, keep edges which collide with the bounding box of that circle. if ( this.externalBounds?.lineSegmentIntersects(a, b, {inside: true}) ) return true; // 2. Keep edges which are contained within or collide with one of the primary angle boundary rays. const roundPoint = p => ({x: Math.round(p.x), y: Math.round(p.y)}); const rMin = Ray.fromAngle(this.origin.x, this.origin.y, this.aMin, this.radius); roundPoint(rMin.B); const rMax = Ray.fromAngle(this.origin.x, this.origin.y, this.aMax, this.radius); roundPoint(rMax.B); // If either vertex is inside, keep the edge if ( LimitedAnglePolygon.pointBetweenRays(a, rMin, rMax, this.angle) ) return true; if ( LimitedAnglePolygon.pointBetweenRays(b, rMin, rMax, this.angle) ) return true; // If both vertices are outside, test whether the edge collides with one (either) of the limiting rays if ( foundry.utils.lineSegmentIntersects(rMin.A, rMin.B, a, b) ) return true; if ( foundry.utils.lineSegmentIntersects(rMax.A, rMax.B, a, b) ) return true; // Otherwise, the edge can be discarded return false; } /* -------------------------------------------- */ /** * Test whether a vertex lies between two boundary rays. * If the angle is greater than 180, test for points between rMax and rMin (inverse). * Otherwise, keep vertices that are between the rays directly. * @param {Point} point The candidate point * @param {PolygonRay} rMin The counter-clockwise bounding ray * @param {PolygonRay} rMax The clockwise bounding ray * @param {number} angle The angle being tested, in degrees * @returns {boolean} Is the vertex between the two rays? */ static pointBetweenRays(point, rMin, rMax, angle) { const ccw = foundry.utils.orient2dFast; if ( angle > 180 ) { const outside = (ccw(rMax.A, rMax.B, point) <= 0) && (ccw(rMin.A, rMin.B, point) >= 0); return !outside; } return (ccw(rMin.A, rMin.B, point) <= 0) && (ccw(rMax.A, rMax.B, point) >= 0); } } // noinspection TypeScriptUMDGlobal /** * A helper class used to construct triangulated polygon meshes * Allow to add padding and a specific depth value. * @param {number[]|PIXI.Polygon} poly Closed polygon to be processed and converted to a mesh * (array of points or PIXI Polygon) * @param {object|{}} options Various options : normalizing, offsetting, add depth, ... */ class PolygonMesher { constructor(poly, options = {}) { this.options = {...this.constructor._defaultOptions, ...options}; const {normalize, x, y, radius, scale, offset} = this.options; // Creating the scaled values this.#scaled.sradius = radius * scale; this.#scaled.sx = x * scale; this.#scaled.sy = y * scale; this.#scaled.soffset = offset * scale; // Computing required number of pass (minimum 1) this.#nbPass = Math.ceil(Math.abs(offset) / 3); // Get points from poly param const points = poly instanceof PIXI.Polygon ? poly.points : poly; if ( !Array.isArray(points) ) { throw new Error("You must provide a PIXI.Polygon or an array of vertices to the PolygonMesher constructor"); } // Correcting normalize option if necessary. We can't normalize with a radius of 0. if ( normalize && (radius === 0) ) this.options.normalize = false; // Creating the mesh vertices this.#computePolygonMesh(points); } /** * Default options values * @type {Record} */ static _defaultOptions = { offset: 0, // The position value in pixels normalize: false, // Should the vertices be normalized? x: 0, // The x origin y: 0, // The y origin radius: 0, // The radius depthOuter: 0, // The depth value on the outer polygon depthInner: 1, // The depth value on the inner(s) polygon(s) scale: 10e8, // Constant multiplier to avoid floating point imprecision with ClipperLib miterLimit: 7, // Distance of the miter limit, when sharp angles are cut during offsetting. interleaved: false // Should the vertex data be interleaved into one VBO? }; /* -------------------------------------------- */ /** * Polygon mesh vertices * @type {number[]} */ vertices = []; /** * Polygon mesh indices * @type {number[]} */ indices = []; /** * Contains options to apply during the meshing process * @type {Record} */ options = {}; /** * Contains some options values scaled by the constant factor * @type {Record} * @private */ #scaled = {}; /** * Polygon mesh geometry * @type {PIXI.Geometry} * @private */ #geometry = null; /** * Contain the polygon tree node object, containing the main forms and its holes and sub-polygons * @type {{poly: number[], nPoly: number[], children: object[]}} * @private */ #polygonNodeTree = null; /** * Contains the the number of offset passes required to compute the polygon * @type {number} * @private */ #nbPass; /* -------------------------------------------- */ /* Polygon Mesher static helper methods */ /* -------------------------------------------- */ /** * Convert a flat points array into a 2 dimensional ClipperLib path * @param {number[]|PIXI.Polygon} poly PIXI.Polygon or points flat array. * @param {number} [dimension=2] Dimension. * @returns {number[]|undefined} The clipper lib path. */ static getClipperPathFromPoints(poly, dimension = 2) { poly = poly instanceof PIXI.Polygon ? poly.points : poly; // If points is not an array or if its dimension is 1, 0 or negative, it can't be translated to a path. if ( !Array.isArray(poly) || dimension < 2 ) { throw new Error("You must provide valid coordinates to create a path."); } const path = new ClipperLib.Path(); if ( poly.length <= 1 ) return path; // Returning an empty path if we have zero or one point. for ( let i = 0; i < poly.length; i += dimension ) { path.push(new ClipperLib.IntPoint(poly[i], poly[i + 1])); } return path; } /* -------------------------------------------- */ /* Polygon Mesher Methods */ /* -------------------------------------------- */ /** * Create the polygon mesh * @param {number[]} points * @private */ #computePolygonMesh(points) { if ( !points || points.length < 6 ) return; this.#updateVertices(points); this.#updatePolygonNodeTree(); } /* -------------------------------------------- */ /** * Update vertices and add depth * @param {number[]} vertices * @private */ #updateVertices(vertices) { const {offset, depthOuter, scale} = this.options; const z = (offset === 0 ? 1.0 : depthOuter); for ( let i = 0; i < vertices.length; i += 2 ) { const x = Math.round(vertices[i] * scale); const y = Math.round(vertices[i + 1] * scale); this.vertices.push(x, y, z); } } /* -------------------------------------------- */ /** * Create the polygon by generating the edges and the interior of the polygon if an offset != 0, * and just activate a fast triangulation if offset = 0 * @private */ #updatePolygonNodeTree() { // Initializing the polygon node tree this.#polygonNodeTree = {poly: this.vertices, nPoly: this.#normalize(this.vertices), children: []}; // Computing offset only if necessary if ( this.options.offset === 0 ) return this.#polygonNodeTree.fastTriangulation = true; // Creating the offsetter ClipperLib object, and adding our polygon path to it. const offsetter = new ClipperLib.ClipperOffset(this.options.miterLimit); // Launching the offset computation return this.#createOffsetPolygon(offsetter, this.#polygonNodeTree); } /* -------------------------------------------- */ /** * Recursively create offset polygons in successive passes * @param {ClipperLib.ClipperOffset} offsetter ClipperLib offsetter * @param {object} node A polygon node object to offset * @param {number} [pass=0] The pass number (initialized with 0 for the first call) */ #createOffsetPolygon(offsetter, node, pass = 0) { // Time to stop recursion on this node branch? if ( pass >= this.#nbPass ) return; const path = PolygonMesher.getClipperPathFromPoints(node.poly, 3); // Converting polygon points to ClipperLib path const passOffset = Math.round(this.#scaled.soffset / this.#nbPass); // Mapping the offset for this path const depth = Math.mix(this.options.depthOuter, this.options.depthInner, (pass + 1) / this.#nbPass); // Computing depth according to the actual pass and maximum number of pass (linear interpolation) // Executing the offset const paths = new ClipperLib.Paths(); offsetter.AddPath(path, ClipperLib.JoinType.jtMiter, ClipperLib.EndType.etClosedPolygon); offsetter.Execute(paths, passOffset); offsetter.Clear(); // Verifying if we have pathes. If it's not the case, the area is too small to generate pathes with this offset. // It's time to stop recursion on this node branch. if ( !paths.length ) return; // Incrementing the number of pass to know when recursive offset should stop pass++; // Creating offsets for children for ( const path of paths ) { const flat = this.#flattenVertices(path, depth); const child = { poly: flat, nPoly: this.#normalize(flat), children: []}; node.children.push(child); this.#createOffsetPolygon(offsetter, child, pass); } } /* -------------------------------------------- */ /** * Flatten a ClipperLib path to array of numbers * @param {ClipperLib.IntPoint[]} path path to convert * @param {number} depth depth to add to the flattened vertices * @returns {number[]} flattened array of points * @private */ #flattenVertices(path, depth) { const flattened = []; for ( const point of path ) { flattened.push(point.X, point.Y, depth); } return flattened; } /* -------------------------------------------- */ /** * Normalize polygon coordinates and put result into nPoly property. * @param {number[]} poly the poly to normalize * @returns {number[]} the normalized poly array * @private */ #normalize(poly) { if ( !this.options.normalize ) return []; // Compute the normalized vertex const {sx, sy, sradius} = this.#scaled; const nPoly = []; for ( let i = 0; i < poly.length; i+=3 ) { const x = (poly[i] - sx) / sradius; const y = (poly[i+1] - sy) / sradius; nPoly.push(x, y, poly[i+2]); } return nPoly; } /* -------------------------------------------- */ /** * Execute the triangulation to create indices * @param {PIXI.Geometry} geometry A geometry to update * @returns {PIXI.Geometry} The resulting geometry */ triangulate(geometry) { this.#geometry = geometry; // Can we draw at least one triangle (counting z now)? If not, update or create an empty geometry if ( this.vertices.length < 9 ) return this.#emptyGeometry(); // Triangulate the mesh and create indices if ( this.#polygonNodeTree.fastTriangulation ) this.#triangulateFast(); else this.#triangulateTree(); // Update the geometry return this.#updateGeometry(); } /* -------------------------------------------- */ /** * Fast triangulation of the polygon node tree * @private */ #triangulateFast() { this.indices = PIXI.utils.earcut(this.vertices, null, 3); if ( this.options.normalize ) { this.vertices = this.#polygonNodeTree.nPoly; } } /* -------------------------------------------- */ /** * Recursive triangulation of the polygon node tree * @private */ #triangulateTree() { this.vertices = []; this.indices = this.#triangulateNode(this.#polygonNodeTree); } /* -------------------------------------------- */ /** * Triangulate a node and its children recursively to compose a mesh with multiple levels of depth * @param {object} node The polygon node tree to triangulate * @param {number[]} [indices=[]] An optional array to receive indices (used for recursivity) * @returns {number[]} An array of indices, result of the triangulation */ #triangulateNode(node, indices = []) { const {normalize} = this.options; const vert = []; const polyLength = node.poly.length / 3; const hasChildren = !!node.children.length; vert.push(...node.poly); // If the node is the outer hull (beginning polygon), it has a position of 0 into the vertices array. if ( !node.position ) { node.position = 0; this.vertices.push(...(normalize ? node.nPoly : node.poly)); } // If the polygon has no children, it is an interior polygon triangulated in the fast way. Returning here. if ( !hasChildren ) { indices.push(...(PIXI.utils.earcut(vert, null, 3).map(v => v + node.position))); return indices; } let holePosition = polyLength; let holes = []; let holeGroupPosition = 0; for ( const nodeChild of node.children ) { holes.push(holePosition); nodeChild.position = (this.vertices.length / 3); if ( !holeGroupPosition ) holeGroupPosition = nodeChild.position; // The position of the holes as a contiguous group. holePosition += (nodeChild.poly.length / 3); vert.push(...nodeChild.poly); this.vertices.push(...(normalize ? nodeChild.nPoly : nodeChild.poly)); } // We need to shift the result of the indices, to match indices as it is saved in the vertices. // We are using earcutEdges to enforce links between the outer and inner(s) polygons. const holeGroupShift = holeGroupPosition - polyLength; indices.push(...(earcut.earcutEdges(vert, holes).map(v => { if ( v < polyLength ) return v + node.position; else return v + holeGroupShift; }))); // Triangulating children for ( const nodeChild of node.children ) { this.#triangulateNode(nodeChild, indices); } return indices; } /* -------------------------------------------- */ /** * Updating or creating the PIXI.Geometry that will be used by the mesh * @private */ #updateGeometry() { const {interleaved, normalize, scale} = this.options; // Unscale non normalized vertices if ( !normalize ) { for ( let i = 0; i < this.vertices.length; i+=3 ) { this.vertices[i] /= scale; this.vertices[i+1] /= scale; } } // If VBO shouldn't be interleaved, we create a separate array for vertices and depth let vertices; let depth; if ( !interleaved ) { vertices = []; depth = []; for ( let i = 0; i < this.vertices.length; i+=3 ) { vertices.push(this.vertices[i], this.vertices[i+1]); depth.push(this.vertices[i+2]); } } else vertices = this.vertices; if ( this.#geometry ) { const vertBuffer = this.#geometry.getBuffer("aVertexPosition"); vertBuffer.update(new Float32Array(vertices)); const indicesBuffer = this.#geometry.getIndex(); indicesBuffer.update(new Uint16Array(this.indices)); if ( !interleaved ) { const depthBuffer = this.#geometry.getBuffer("aDepthValue"); depthBuffer.update(new Float32Array(depth)); } } else this.#geometry = this.#createGeometry(vertices, depth); return this.#geometry; } /* -------------------------------------------- */ /** * Empty the geometry, or if geometry is null, create an empty geometry. * @private */ #emptyGeometry() { const {interleaved} = this.options; // Empty the current geometry if it exists if ( this.#geometry ) { const vertBuffer = this.#geometry.getBuffer("aVertexPosition"); vertBuffer.update(new Float32Array([0, 0])); const indicesBuffer = this.#geometry.getIndex(); indicesBuffer.update(new Uint16Array([0, 0])); if ( !interleaved ) { const depthBuffer = this.#geometry.getBuffer("aDepthValue"); depthBuffer.update(new Float32Array([0])); } } // Create an empty geometry otherwise else if ( interleaved ) { // Interleaved version return new PIXI.Geometry().addAttribute("aVertexPosition", [0, 0, 0], 3).addIndex([0, 0]); } else { this.#geometry = new PIXI.Geometry().addAttribute("aVertexPosition", [0, 0], 2) .addAttribute("aTextureCoord", [0, 0, 0, 1, 1, 1, 1, 0], 2) .addAttribute("aDepthValue", [0], 1) .addIndex([0, 0]); } return this.#geometry; } /* -------------------------------------------- */ /** * Create a new Geometry from provided buffers * @param {number[]} vertices provided vertices array (interleaved or not) * @param {number[]} [depth=undefined] provided depth array * @param {number[]} [indices=this.indices] provided indices array * @returns {PIXI.Geometry} the new PIXI.Geometry constructed from the provided buffers */ #createGeometry(vertices, depth=undefined, indices=this.indices) { if ( this.options.interleaved ) { return new PIXI.Geometry().addAttribute("aVertexPosition", vertices, 3).addIndex(indices); } if ( !depth ) throw new Error("You must provide a separate depth buffer when the data is not interleaved."); return new PIXI.Geometry() .addAttribute("aVertexPosition", vertices, 2) .addAttribute("aTextureCoord", [0, 0, 1, 0, 1, 1, 0, 1], 2) .addAttribute("aDepthValue", depth, 1) .addIndex(indices); } } /** * An extension of the default PIXI.Text object which forces double resolution. * At default resolution Text often looks blurry or fuzzy. */ class PreciseText extends PIXI.Text { constructor(...args) { super(...args); this._autoResolution = false; this._resolution = 2; } /** * Prepare a TextStyle object which merges the canvas defaults with user-provided options * @param {object} [options={}] Additional options merged with the default TextStyle * @param {number} [options.anchor] A text anchor point from CONST.TEXT_ANCHOR_POINTS * @returns {PIXI.TextStyle} The prepared TextStyle */ static getTextStyle({anchor, ...options}={}) { const style = CONFIG.canvasTextStyle.clone(); for ( let [k, v] of Object.entries(options) ) { if ( v !== undefined ) style[k] = v; } // Positioning if ( !("align" in options) ) { if ( anchor === CONST.TEXT_ANCHOR_POINTS.LEFT ) style.align = "right"; else if ( anchor === CONST.TEXT_ANCHOR_POINTS.RIGHT ) style.align = "left"; } // Adaptive Stroke if ( !("stroke" in options) ) { const fill = Color.from(style.fill); style.stroke = fill.hsv[2] > 0.6 ? 0x000000 : 0xFFFFFF; } return style; } } /** * @typedef {Object} RayIntersection * @property {number} x The x-coordinate of intersection * @property {number} y The y-coordinate of intersection * @property {number} t0 The proximity to the Ray origin, as a ratio of distance * @property {number} t1 The proximity to the Ray destination, as a ratio of distance */ /** * A ray for the purposes of computing sight and collision * Given points A[x,y] and B[x,y] * * Slope-Intercept form: * y = a + bx * y = A.y + ((B.y - A.Y) / (B.x - A.x))x * * Parametric form: * R(t) = (1-t)A + tB * * @param {Point} A The origin of the Ray * @param {Point} B The destination of the Ray */ class Ray { constructor(A, B) { /** * The origin point, {x, y} * @type {Point} */ this.A = A; /** * The destination point, {x, y} * @type {Point} */ this.B = B; /** * The origin y-coordinate * @type {number} */ this.y0 = A.y; /** * The origin x-coordinate * @type {number} */ this.x0 = A.x; /** * The horizontal distance of the ray, x1 - x0 * @type {number} */ this.dx = B.x - A.x; /** * The vertical distance of the ray, y1 - y0 * @type {number} */ this.dy = B.y - A.y; /** * The slope of the ray, dy over dx * @type {number} */ this.slope = this.dy / this.dx; } /* -------------------------------------------- */ /* Attributes */ /* -------------------------------------------- */ /** * The cached angle, computed lazily in Ray#angle * @type {number} * @private */ _angle = undefined; /** * The cached distance, computed lazily in Ray#distance * @type {number} * @private */ _distance = undefined; /* -------------------------------------------- */ /** * The normalized angle of the ray in radians on the range (-PI, PI). * The angle is computed lazily (only if required) and cached. * @type {number} */ get angle() { if ( this._angle === undefined ) this._angle = Math.atan2(this.dy, this.dx); return this._angle; } set angle(value) { this._angle = Number(value); } /* -------------------------------------------- */ /** * A normalized bounding rectangle that encompasses the Ray * @type {PIXI.Rectangle} */ get bounds() { return new PIXI.Rectangle(this.A.x, this.A.y, this.dx, this.dy).normalize(); } /* -------------------------------------------- */ /** * The distance (length) of the Ray in pixels. * The distance is computed lazily (only if required) and cached. * @type {number} */ get distance() { if ( this._distance === undefined ) this._distance = Math.hypot(this.dx, this.dy); return this._distance; } set distance(value) { this._distance = Number(value); } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** * A factory method to construct a Ray from an origin point, an angle, and a distance * @param {number} x The origin x-coordinate * @param {number} y The origin y-coordinate * @param {number} radians The ray angle in radians * @param {number} distance The distance of the ray in pixels * @returns {Ray} The constructed Ray instance */ static fromAngle(x, y, radians, distance) { const dx = Math.cos(radians); const dy = Math.sin(radians); const ray = this.fromArrays([x, y], [x + (dx * distance), y + (dy * distance)]); ray._angle = Math.normalizeRadians(radians); // Store the angle, cheaper to compute here ray._distance = distance; // Store the distance, cheaper to compute here return ray; } /* -------------------------------------------- */ /** * A factory method to construct a Ray from points in array format. * @param {number[]} A The origin point [x,y] * @param {number[]} B The destination point [x,y] * @returns {Ray} The constructed Ray instance */ static fromArrays(A, B) { return new this({x: A[0], y: A[1]}, {x: B[0], y: B[1]}); } /* -------------------------------------------- */ /** * Project the Array by some proportion of it's initial distance. * Return the coordinates of that point along the path. * @param {number} t The distance along the Ray * @returns {Object} The coordinates of the projected point */ project(t) { return { x: this.A.x + (t * this.dx), y: this.A.y + (t * this.dy) }; } /* -------------------------------------------- */ /** * Create a Ray by projecting a certain distance towards a known point. * @param {Point} origin The origin of the Ray * @param {Point} point The point towards which to project * @param {number} distance The distance of projection * @returns {Ray} */ static towardsPoint(origin, point, distance) { const dx = point.x - origin.x; const dy = point.y - origin.y; const t = distance / Math.hypot(dx, dy); return new this(origin, { x: origin.x + (t * dx), y: origin.y + (t * dy) }); } /* -------------------------------------------- */ /** * Create a Ray by projecting a certain squared-distance towards a known point. * @param {Point} origin The origin of the Ray * @param {Point} point The point towards which to project * @param {number} distance2 The squared distance of projection * @returns {Ray} */ static towardsPointSquared(origin, point, distance2) { const dx = point.x - origin.x; const dy = point.y - origin.y; const t = Math.sqrt(distance2 / (Math.pow(dx, 2) + Math.pow(dy, 2))); return new this(origin, { x: origin.x + (t * dx), y: origin.y + (t * dy) }); } /* -------------------------------------------- */ /** * Reverse the direction of the Ray, returning a second Ray * @returns {Ray} */ reverse() { const r = new Ray(this.B, this.A); r._distance = this._distance; r._angle = Math.PI - this._angle; return r; } /* -------------------------------------------- */ /** * Create a new ray which uses the same origin point, but a slightly offset angle and distance * @param {number} offset An offset in radians which modifies the angle of the original Ray * @param {number} [distance] A distance the new ray should project, otherwise uses the same distance. * @return {Ray} A new Ray with an offset angle */ shiftAngle(offset, distance) { return this.constructor.fromAngle(this.x0, this.y0, this.angle + offset, distance || this.distance); } /* -------------------------------------------- */ /** * Find the point I[x,y] and distance t* on ray R(t) which intersects another ray * @see foundry.utils.lineLineIntersection */ intersectSegment(coords) { return foundry.utils.lineSegmentIntersection(this.A, this.B, {x: coords[0], y: coords[1]}, {x: coords[2], y: coords[3]}); } } /** * @typedef {"light"|"sight"|"sound"|"move"|"universal"} PointSourcePolygonType */ /** * @typedef {Object} PointSourcePolygonConfig * @property {PointSourcePolygonType} type The type of polygon being computed * @property {number} [angle=360] The angle of emission, if limited * @property {number} [density] The desired density of padding rays, a number per PI * @property {number} [radius] A limited radius of the resulting polygon * @property {number} [rotation] The direction of facing, required if the angle is limited * @property {number} [wallDirectionMode] Customize how wall direction of one-way walls is applied * @property {boolean} [useThreshold=false] Compute the polygon with threshold wall constraints applied * @property {boolean} [includeDarkness=false] Include edges coming from darkness sources * @property {number} [priority] Priority when it comes to ignore edges from darkness sources * @property {boolean} [debug] Display debugging visualization and logging for the polygon * @property {PointSource} [source] The object (if any) that spawned this polygon. * @property {Array} [boundaryShapes] Limiting polygon boundary shapes * @property {Readonly} [useInnerBounds] Does this polygon use the Scene inner or outer bounding rectangle * @property {Readonly} [hasLimitedRadius] Does this polygon have a limited radius? * @property {Readonly} [hasLimitedAngle] Does this polygon have a limited angle? * @property {Readonly} [boundingBox] The computed bounding box for the polygon */ /** * An extension of the default PIXI.Polygon which is used to represent the line of sight for a point source. * @extends {PIXI.Polygon} */ class PointSourcePolygon extends PIXI.Polygon { /** * Customize how wall direction of one-way walls is applied * @enum {number} */ static WALL_DIRECTION_MODES = Object.freeze({ NORMAL: 0, REVERSED: 1, BOTH: 2 }); /** * The rectangular bounds of this polygon * @type {PIXI.Rectangle} */ bounds = new PIXI.Rectangle(0, 0, 0, 0); /** * The origin point of the source polygon. * @type {Point} */ origin; /** * The configuration of this polygon. * @type {PointSourcePolygonConfig} */ config = {}; /* -------------------------------------------- */ /** * An indicator for whether this polygon is constrained by some boundary shape? * @type {boolean} */ get isConstrained() { return this.config.boundaryShapes.length > 0; } /* -------------------------------------------- */ /** * Benchmark the performance of polygon computation for this source * @param {number} iterations The number of test iterations to perform * @param {Point} origin The origin point to benchmark * @param {PointSourcePolygonConfig} config The polygon configuration to benchmark */ static benchmark(iterations, origin, config) { const f = () => this.create(foundry.utils.deepClone(origin), foundry.utils.deepClone(config)); Object.defineProperty(f, "name", {value: `${this.name}.construct`, configurable: true}); return foundry.utils.benchmark(f, iterations); } /* -------------------------------------------- */ /** * Compute the polygon given a point origin and radius * @param {Point} origin The origin source point * @param {PointSourcePolygonConfig} [config={}] Configuration options which customize the polygon computation * @returns {PointSourcePolygon} The computed polygon instance */ static create(origin, config={}) { const poly = new this(); poly.initialize(origin, config); poly.compute(); return this.applyThresholdAttenuation(poly); } /* -------------------------------------------- */ /** * Create a clone of this polygon. * This overrides the default PIXI.Polygon#clone behavior. * @override * @returns {PointSourcePolygon} A cloned instance */ clone() { const poly = new this.constructor([...this.points]); poly.config = foundry.utils.deepClone(this.config); poly.origin = {...this.origin}; poly.bounds = this.bounds.clone(); return poly; } /* -------------------------------------------- */ /* Polygon Computation */ /* -------------------------------------------- */ /** * Compute the polygon using the origin and configuration options. * @returns {PointSourcePolygon} The computed polygon */ compute() { let t0 = performance.now(); const {x, y} = this.origin; const {width, height} = canvas.dimensions; const {angle, debug, radius} = this.config; if ( !(x >= 0 && x <= width && y >= 0 && y <= height) ) { console.warn("The polygon cannot be computed because its origin is out of the scene bounds."); this.points.length = 0; this.bounds = new PIXI.Rectangle(0, 0, 0, 0); return this; } // Skip zero-angle or zero-radius polygons if ( (radius === 0) || (angle === 0) ) { this.points.length = 0; this.bounds = new PIXI.Rectangle(0, 0, 0, 0); return this; } // Clear the polygon bounds this.bounds = undefined; // Delegate computation to the implementation this._compute(); // Cache the new polygon bounds this.bounds = this.getBounds(); // Debugging and performance metrics if ( debug ) { let t1 = performance.now(); console.log(`Created ${this.constructor.name} in ${Math.round(t1 - t0)}ms`); this.visualize(); } return this; } /** * Perform the implementation-specific computation * @protected */ _compute() { throw new Error("Each subclass of PointSourcePolygon must define its own _compute method"); } /* -------------------------------------------- */ /** * Customize the provided configuration object for this polygon type. * @param {Point} origin The provided polygon origin * @param {PointSourcePolygonConfig} config The provided configuration object */ initialize(origin, config) { // Polygon origin const o = this.origin = {x: Math.round(origin.x), y: Math.round(origin.y)}; // Configure radius const cfg = this.config = config; const maxR = canvas.dimensions.maxR; cfg.radius = Math.min(cfg.radius ?? maxR, maxR); cfg.hasLimitedRadius = (cfg.radius > 0) && (cfg.radius < maxR); cfg.density = cfg.density ?? PIXI.Circle.approximateVertexDensity(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 const sceneRect = canvas.dimensions.sceneRect; cfg.useInnerBounds ??= (cfg.type === "sight") && (o.x >= sceneRect.left && o.x <= sceneRect.right && o.y >= sceneRect.top && o.y <= sceneRect.bottom); // Customize wall direction cfg.wallDirectionMode ??= PointSourcePolygon.WALL_DIRECTION_MODES.NORMAL; // Configure threshold cfg.useThreshold ??= false; // Configure darkness inclusion cfg.includeDarkness ??= false; // Boundary Shapes cfg.boundaryShapes ||= []; if ( cfg.hasLimitedAngle ) this.#configureLimitedAngle(); else if ( cfg.hasLimitedRadius ) this.#configureLimitedRadius(); if ( CONFIG.debug.polygons ) cfg.debug = true; } /* -------------------------------------------- */ /** * 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)); } /* -------------------------------------------- */ /** * Apply a constraining boundary shape to an existing PointSourcePolygon. * Return a new instance of the polygon with the constraint applied. * The new instance is only a "shallow clone", as it shares references to component properties with the original. * @param {PIXI.Circle|PIXI.Rectangle|PIXI.Polygon} constraint The constraining boundary shape * @param {object} [intersectionOptions] Options passed to the shape intersection method * @returns {PointSourcePolygon} A new constrained polygon */ applyConstraint(constraint, intersectionOptions={}) { // Enhance polygon configuration data using knowledge of the constraint const poly = this.clone(); poly.config.boundaryShapes.push(constraint); if ( (constraint instanceof PIXI.Circle) && (constraint.x === this.origin.x) && (constraint.y === this.origin.y) ) { if ( poly.config.radius <= constraint.radius ) return poly; poly.config.radius = constraint.radius; poly.config.density = intersectionOptions.density ??= PIXI.Circle.approximateVertexDensity(constraint.radius); if ( constraint.radius === 0 ) { poly.points.length = 0; poly.bounds.x = poly.bounds.y = poly.bounds.width = poly.bounds.height = 0; return poly; } } if ( !poly.points.length ) return poly; // Apply the constraint and return the constrained polygon const c = constraint.intersectPolygon(poly, intersectionOptions); poly.points = c.points; poly.bounds = poly.getBounds(); return poly; } /* -------------------------------------------- */ /** @inheritDoc */ contains(x, y) { return this.bounds.contains(x, y) && super.contains(x, y); } /* -------------------------------------------- */ /* Polygon Boundary Constraints */ /* -------------------------------------------- */ /** * Constrain polygon points by applying boundary shapes. * @protected */ _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 */ /* -------------------------------------------- */ /** * Test whether a Ray between the origin and destination points would collide with a boundary of this Polygon. * A valid wall restriction type is compulsory and must be passed into the config options. * @param {Point} origin An origin point * @param {Point} destination A destination point * @param {PointSourcePolygonConfig} config The configuration that defines a certain Polygon type * @param {"any"|"all"|"closest"} [config.mode] The collision mode to test: "any", "all", or "closest" * @returns {boolean|PolygonVertex|PolygonVertex[]|null} The collision result depends on the mode of the test: * * any: returns a boolean for whether any collision occurred * * all: returns a sorted array of PolygonVertex instances * * closest: returns a PolygonVertex instance or null */ static testCollision(origin, destination, {mode="all", ...config}={}) { if ( !CONST.WALL_RESTRICTION_TYPES.includes(config.type) ) { throw new Error("A valid wall restriction type is required for testCollision."); } const poly = new this(); const ray = new Ray(origin, destination); config.boundaryShapes ||= []; config.boundaryShapes.push(ray.bounds); poly.initialize(origin, config); return poly._testCollision(ray, mode); } /* -------------------------------------------- */ /** * Determine the set of collisions which occurs for a Ray. * @param {Ray} ray The Ray to test * @param {string} mode The collision mode being tested * @returns {boolean|PolygonVertex|PolygonVertex[]|null} The collision test result * @protected * @abstract */ _testCollision(ray, mode) { throw new Error(`The ${this.constructor.name} class must implement the _testCollision method`); } /* -------------------------------------------- */ /* Visualization and Debugging */ /* -------------------------------------------- */ /** * Visualize the polygon, displaying its computed area and applied boundary shapes. * @returns {PIXI.Graphics|undefined} The rendered debugging shape */ visualize() { if ( !this.points.length ) return; let dg = canvas.controls.debug; dg.clear(); for ( const constraint of this.config.boundaryShapes ) { dg.lineStyle(2, 0xFFFFFF, 1.0).beginFill(0xAAFF00).drawShape(constraint).endFill(); } dg.lineStyle(2, 0xFFFFFF, 1.0).beginFill(0xFFAA99, 0.25).drawShape(this).endFill(); return dg; } /* -------------------------------------------- */ /** * Determine if the shape is a complete circle. * The config object must have an angle and a radius properties. */ isCompleteCircle() { const { radius, angle, density } = this.config; if ( radius === 0 ) return true; if ( angle < 360 || (this.points.length !== (density * 2)) ) return false; const shapeArea = Math.abs(this.signedArea()); const circleArea = (0.5 * density * Math.sin(2 * Math.PI / density)) * (radius ** 2); return circleArea.almostEqual(shapeArea, 1e-5); } /* -------------------------------------------- */ /* Threshold Polygons */ /* -------------------------------------------- */ /** * Augment a PointSourcePolygon by adding additional coverage for shapes permitted by threshold walls. * @param {PointSourcePolygon} polygon The computed polygon * @returns {PointSourcePolygon} The augmented polygon */ static applyThresholdAttenuation(polygon) { const config = polygon.config; if ( !config.useThreshold ) return polygon; // Identify threshold walls and confirm whether threshold augmentation is required const {nAttenuated, edges} = PointSourcePolygon.#getThresholdEdges(polygon.origin, config); if ( !nAttenuated ) return polygon; // Create attenuation shapes for all threshold walls const attenuationShapes = PointSourcePolygon.#createThresholdShapes(polygon, edges); if ( !attenuationShapes.length ) return polygon; // Compute a second polygon which does not enforce threshold walls const noThresholdPolygon = new this(); noThresholdPolygon.initialize(polygon.origin, {...config, useThreshold: false}); noThresholdPolygon.compute(); // Combine the unrestricted polygon with the attenuation shapes const combined = PointSourcePolygon.#combineThresholdShapes(noThresholdPolygon, attenuationShapes); polygon.points = combined.points; polygon.bounds = polygon.getBounds(); return polygon; } /* -------------------------------------------- */ /** * Identify edges in the Scene which include an active threshold. * @param {Point} origin * @param {object} config * @returns {{edges: Edge[], nAttenuated: number}} */ static #getThresholdEdges(origin, config) { let nAttenuated = 0; const edges = []; for ( const edge of canvas.edges.values() ) { if ( edge.applyThreshold(config.type, origin, config.externalRadius) ) { edges.push(edge); nAttenuated += edge.threshold.attenuation; } } return {edges, nAttenuated}; } /* -------------------------------------------- */ /** * @typedef {ClipperPoint[]} ClipperPoints */ /** * For each threshold wall that this source passes through construct a shape representing the attenuated source. * The attenuated shape is a circle with a radius modified by origin proximity to the threshold wall. * Intersect the attenuated shape against the LOS with threshold walls considered. * The result is the LOS for the attenuated light source. * @param {PointSourcePolygon} thresholdPolygon The computed polygon with thresholds applied * @param {Edge[]} edges The identified array of threshold walls * @returns {ClipperPoints[]} The resulting array of intersected threshold shapes */ static #createThresholdShapes(thresholdPolygon, edges) { const cps = thresholdPolygon.toClipperPoints(); const origin = thresholdPolygon.origin; const {radius, externalRadius, type} = thresholdPolygon.config; const shapes = []; // Iterate over threshold walls for ( const edge of edges ) { let thresholdShape; // Create attenuated shape if ( edge.threshold.attenuation ) { const r = PointSourcePolygon.#calculateThresholdAttenuation(edge, origin, radius, externalRadius, type); if ( !r.outside ) continue; thresholdShape = new PIXI.Circle(origin.x, origin.y, r.inside + r.outside); } // No attenuation, use the full circle else thresholdShape = new PIXI.Circle(origin.x, origin.y, radius); // Intersect each shape against the LOS const ix = thresholdShape.intersectClipper(cps, {convertSolution: false}); if ( ix.length && ix[0].length > 2 ) shapes.push(ix[0]); } return shapes; } /* -------------------------------------------- */ /** * Calculate the attenuation of the source as it passes through the threshold wall. * The distance of perception through the threshold wall depends on proximity of the source from the wall. * @param {Edge} edge The Edge for which this threshold applies * @param {Point} origin Origin point on the canvas for this source * @param {number} radius Radius to use for this source, before considering attenuation * @param {number} externalRadius The external radius of the source * @param {string} type Sense type for the source * @returns {{inside: number, outside: number}} The inside and outside portions of the radius */ static #calculateThresholdAttenuation(edge, origin, radius, externalRadius, type) { const d = edge.threshold?.[type]; if ( !d ) return { inside: radius, outside: radius }; const proximity = edge[type] === CONST.WALL_SENSE_TYPES.PROXIMITY; // Find the closest point on the threshold wall to the source. // Calculate the proportion of the source radius that is "inside" and "outside" the threshold wall. const pt = foundry.utils.closestPointToSegment(origin, edge.a, edge.b); const inside = Math.hypot(pt.x - origin.x, pt.y - origin.y); const outside = radius - inside; if ( (outside < 0) || outside.almostEqual(0) ) return { inside, outside: 0 }; // Attenuate the radius outside the threshold wall based on source proximity to the wall. const sourceDistance = proximity ? Math.max(inside - externalRadius, 0) : (inside + externalRadius); const percentDistance = sourceDistance / d; const pInv = proximity ? 1 - percentDistance : Math.min(1, percentDistance - 1); const a = (pInv / (2 * (1 - pInv))) * CONFIG.Wall.thresholdAttenuationMultiplier; return { inside, outside: Math.min(a * d, outside) }; } /* -------------------------------------------- */ /** * Union the attenuated shape-LOS intersections with the closed LOS. * The portion of the light sources "inside" the threshold walls are not modified from their default radius or shape. * Clipper can union everything at once. Use a positive fill to avoid checkerboard; fill any overlap. * @param {PointSourcePolygon} los The LOS polygon with threshold walls inactive * @param {ClipperPoints[]} shapes Attenuation shapes for threshold walls * @returns {PIXI.Polygon} The combined LOS polygon with threshold shapes */ static #combineThresholdShapes(los, shapes) { const c = new ClipperLib.Clipper(); const combined = []; const cPaths = [los.toClipperPoints(), ...shapes]; c.AddPaths(cPaths, ClipperLib.PolyType.ptSubject, true); const p = ClipperLib.PolyFillType.pftPositive; c.Execute(ClipperLib.ClipType.ctUnion, combined, p, p); return PIXI.Polygon.fromClipperPoints(combined.length ? combined[0] : []); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** @ignore */ get rays() { foundry.utils.logCompatibilityWarning("You are referencing PointSourcePolygon#rays which is no longer a required " + "property of that interface. If your subclass uses the rays property it should be explicitly defined by the " + "subclass which requires it.", {since: 11, until: 13}); return this.#rays; } set rays(rays) { this.#rays = rays; } /** @deprecated since v11 */ #rays = []; } /** * A type of ping that points to a specific location. * @param {Point} origin The canvas coordinates of the origin of the ping. * @param {PingOptions} [options] Additional options to configure the ping animation. * @extends Ping */ class ChevronPing extends Ping { constructor(origin, options={}) { super(origin, options); this._r = (this.options.size / 2) * .75; // The inner ring is 3/4s the size of the outer. this._rInner = this._r * .75; // The animation is split into three stages. First, the chevron fades in and moves downwards, then the rings fade // in, then everything fades out as the chevron moves back up. // Store the 1/4 time slice. this._t14 = this.options.duration * .25; // Store the 1/2 time slice. this._t12 = this.options.duration * .5; // Store the 3/4s time slice. this._t34 = this._t14 * 3; } /** * The path to the chevron texture. * @type {string} * @private */ static _CHEVRON_PATH = "icons/pings/chevron.webp"; /* -------------------------------------------- */ /** @inheritdoc */ async animate() { this.removeChildren(); this.addChild(...this._createRings()); this._chevron = await this._loadChevron(); this.addChild(this._chevron); return super.animate(); } /* -------------------------------------------- */ /** @inheritdoc */ _animateFrame(dt, animation) { const { time } = animation; if ( time < this._t14 ) { // Normalise t between 0 and 1. const t = time / this._t14; // Apply easing function. const dy = CanvasAnimation.easeOutCircle(t); this._chevron.y = this._y + (this._h2 * dy); this._chevron.alpha = time / this._t14; } else if ( time < this._t34 ) { const t = time - this._t14; const a = t / this._t12; this._drawRings(a); } else { const t = (time - this._t34) / this._t14; const a = 1 - t; const dy = CanvasAnimation.easeInCircle(t); this._chevron.y = this._y + ((1 - dy) * this._h2); this._chevron.alpha = a; this._drawRings(a); } } /* -------------------------------------------- */ /** * Draw the outer and inner rings. * @param {number} a The alpha. * @private */ _drawRings(a) { this._outer.clear(); this._inner.clear(); this._outer.lineStyle(6, this._color, a).drawCircle(0, 0, this._r); this._inner.lineStyle(3, this._color, a).arc(0, 0, this._rInner, 0, Math.PI * 1.5); } /* -------------------------------------------- */ /** * Load the chevron texture. * @returns {Promise} * @private */ async _loadChevron() { const texture = await TextureLoader.loader.loadTexture(ChevronPing._CHEVRON_PATH); const chevron = PIXI.Sprite.from(texture); chevron.tint = this._color; const w = this.options.size; const h = (texture.height / texture.width) * w; chevron.width = w; chevron.height = h; // The chevron begins the animation slightly above the pinged point. this._h2 = h / 2; chevron.x = -(w / 2); chevron.y = this._y = -h - this._h2; return chevron; } /* -------------------------------------------- */ /** * Draw the two rings that are used as part of the ping animation. * @returns {PIXI.Graphics[]} * @private */ _createRings() { this._outer = new PIXI.Graphics(); this._inner = new PIXI.Graphics(); return [this._outer, this._inner]; } } /** * @typedef {PingOptions} PulsePingOptions * @property {number} [rings=3] The number of rings used in the animation. * @property {string} [color2=#ffffff] The alternate color that the rings begin at. Use white for a 'flashing' effect. */ /** * A type of ping that produces a pulsing animation. * @param {Point} origin The canvas coordinates of the origin of the ping. * @param {PulsePingOptions} [options] Additional options to configure the ping animation. * @extends Ping */ class PulsePing extends Ping { constructor(origin, {rings=3, color2="#ffffff", ...options}={}) { super(origin, {rings, color2, ...options}); this._color2 = game.settings.get("core", "photosensitiveMode") ? this._color : Color.from(color2); // The radius is half the diameter. this._r = this.options.size / 2; // This is the radius that the rings initially begin at. It's set to 1/5th of the maximum radius. this._r0 = this._r / 5; this._computeTimeSlices(); } /* -------------------------------------------- */ /** * Initialize some time slice variables that will be used to control the animation. * * The animation for each ring can be separated into two consecutive stages. * Stage 1: Fade in a white ring with radius r0. * Stage 2: Expand radius outward. While the radius is expanding outward, we have two additional, consecutive * animations: * Stage 2.1: Transition color from white to the configured color. * Stage 2.2: Fade out. * 1/5th of the animation time is allocated to Stage 1. 4/5ths are allocated to Stage 2. Of those 4/5ths, 2/5ths * are allocated to Stage 2.1, and 2/5ths are allocated to Stage 2.2. * @private */ _computeTimeSlices() { // We divide up the total duration of the animation into rings + 1 time slices. Ring animations are staggered by 1 // slice, and last for a total of 2 slices each. This uses up the full duration and creates the ripple effect. this._timeSlice = this.options.duration / (this.options.rings + 1); this._timeSlice2 = this._timeSlice * 2; // Store the 1/5th time slice for Stage 1. this._timeSlice15 = this._timeSlice2 / 5; // Store the 2/5ths time slice for the subdivisions of Stage 2. this._timeSlice25 = this._timeSlice15 * 2; // Store the 4/5ths time slice for Stage 2. this._timeSlice45 = this._timeSlice25 * 2; } /* -------------------------------------------- */ /** @inheritdoc */ async animate() { // Draw rings. this.removeChildren(); for ( let i = 0; i < this.options.rings; i++ ) { this.addChild(new PIXI.Graphics()); } // Add a blur filter to soften the sharp edges of the shape. const f = new PIXI.BlurFilter(2); f.padding = this.options.size; this.filters = [f]; return super.animate(); } /* -------------------------------------------- */ /** @inheritdoc */ _animateFrame(dt, animation) { const { time } = animation; for ( let i = 0; i < this.options.rings; i++ ) { const ring = this.children[i]; // Offset each ring by 1 time slice. const tMin = this._timeSlice * i; // Each ring gets 2 time slices to complete its full animation. const tMax = tMin + this._timeSlice2; // If it's not time for this ring to animate, do nothing. if ( (time < tMin) || (time >= tMax) ) continue; // Normalise our t. let t = time - tMin; ring.clear(); if ( t < this._timeSlice15 ) { // Stage 1. Fade in a white ring of radius r0. const a = t / this._timeSlice15; this._drawShape(ring, this._color2, a, this._r0); } else { // Stage 2. Expand radius, transition color, and fade out. Re-normalize t for Stage 2. t -= this._timeSlice15; const dr = this._r / this._timeSlice45; const r = this._r0 + (t * dr); const c0 = this._color; const c1 = this._color2; const c = t <= this._timeSlice25 ? this._colorTransition(c0, c1, this._timeSlice25, t) : c0; const ta = Math.max(0, t - this._timeSlice25); const a = 1 - (ta / this._timeSlice25); this._drawShape(ring, c, a, r); } } } /* -------------------------------------------- */ /** * Transition linearly from one color to another. * @param {Color} from The color to transition from. * @param {Color} to The color to transition to. * @param {number} duration The length of the transition in milliseconds. * @param {number} t The current time along the duration. * @returns {number} The incremental color between from and to. * @private */ _colorTransition(from, to, duration, t) { const d = t / duration; const rgbFrom = from.rgb; const rgbTo = to.rgb; return Color.fromRGB(rgbFrom.map((c, i) => { const diff = rgbTo[i] - c; return c + (d * diff); })); } /* -------------------------------------------- */ /** * Draw the shape for this ping. * @param {PIXI.Graphics} g The graphics object to draw to. * @param {number} color The color of the shape. * @param {number} alpha The alpha of the shape. * @param {number} size The size of the shape to draw. * @protected */ _drawShape(g, color, alpha, size) { g.lineStyle({color, alpha, width: 6, cap: PIXI.LINE_CAP.ROUND, join: PIXI.LINE_JOIN.BEVEL}); g.drawCircle(0, 0, size); } } /** * A type of ping that produces an arrow pointing in a given direction. * @property {PIXI.Point} origin The canvas coordinates of the origin of the ping. This becomes the arrow's * tip. * @property {PulsePingOptions} [options] Additional options to configure the ping animation. * @property {number} [options.rotation=0] The angle of the arrow in radians. * @extends PulsePing */ class ArrowPing extends PulsePing { constructor(origin, {rotation=0, ...options}={}) { super(origin, options); this.rotation = Math.normalizeRadians(rotation + (Math.PI * 1.5)); } /* -------------------------------------------- */ /** @inheritdoc */ _drawShape(g, color, alpha, size) { g.lineStyle({color, alpha, width: 6, cap: PIXI.LINE_CAP.ROUND, join: PIXI.LINE_JOIN.BEVEL}); const half = size / 2; const x = -half; const y = -size; g.moveTo(x, y) .lineTo(0, 0) .lineTo(half, y) .lineTo(0, -half) .lineTo(x, y); } } /** * A type of ping that produces a pulse warning sign animation. * @param {PIXI.Point} origin The canvas coordinates of the origin of the ping. * @param {PulsePingOptions} [options] Additional options to configure the ping animation. * @extends PulsePing */ class AlertPing extends PulsePing { constructor(origin, {color="#ff0000", ...options}={}) { super(origin, {color, ...options}); this._r = this.options.size; } /* -------------------------------------------- */ /** @inheritdoc */ _drawShape(g, color, alpha, size) { // Draw a chamfered triangle. g.lineStyle({color, alpha, width: 6, cap: PIXI.LINE_CAP.ROUND, join: PIXI.LINE_JOIN.BEVEL}); const half = size / 2; const chamfer = size / 10; const chamfer2 = chamfer / 2; const x = -half; const y = -(size / 3); g.moveTo(x+chamfer, y) .lineTo(x+size-chamfer, y) .lineTo(x+size, y+chamfer) .lineTo(x+half+chamfer2, y+size-chamfer) .lineTo(x+half-chamfer2, y+size-chamfer) .lineTo(x, y+chamfer) .lineTo(x+chamfer, y); } } /** * An abstract pattern for primary layers of the game canvas to implement. * @category - Canvas * @abstract * @interface */ class CanvasLayer extends PIXI.Container { /** * Options for this layer instance. * @type {{name: string}} */ options = this.constructor.layerOptions; // Default interactivity interactiveChildren = false; /* -------------------------------------------- */ /* Layer Attributes */ /* -------------------------------------------- */ /** * Customize behaviors of this CanvasLayer by modifying some behaviors at a class level. * @type {{name: string}} */ static get layerOptions() { return { name: "", baseClass: CanvasLayer }; } /* -------------------------------------------- */ /** * Return a reference to the active instance of this canvas layer * @type {CanvasLayer} */ static get instance() { return canvas[this.layerOptions.name]; } /* -------------------------------------------- */ /** * The canonical name of the CanvasLayer is the name of the constructor that is the immediate child of the * defined baseClass for the layer type. * @type {string} * * @example * canvas.lighting.name -> "LightingLayer" */ get name() { const baseCls = this.constructor.layerOptions.baseClass; let cls = Object.getPrototypeOf(this.constructor); let name = this.constructor.name; while ( cls ) { if ( cls !== baseCls ) { name = cls.name; cls = Object.getPrototypeOf(cls); } else break; } return name; } /* -------------------------------------------- */ /** * The name used by hooks to construct their hook string. * Note: You should override this getter if hookName should not return the class constructor name. * @type {string} */ get hookName() { return this.name; } /* -------------------------------------------- */ /** * An internal reference to a Promise in-progress to draw the CanvasLayer. * @type {Promise} */ #drawing = Promise.resolve(this); /* -------------------------------------------- */ /** * Is the layer drawn? * @type {boolean} */ #drawn = false; /* -------------------------------------------- */ /* Rendering /* -------------------------------------------- */ /** * Draw the canvas layer, rendering its internal components and returning a Promise. * The Promise resolves to the drawn layer once its contents are successfully rendered. * @param {object} [options] Options which configure how the layer is drawn * @returns {Promise} */ async draw(options={}) { return this.#drawing = this.#drawing.finally(async () => { console.log(`${vtt} | Drawing the ${this.constructor.name} canvas layer`); await this.tearDown(); await this._draw(options); Hooks.callAll(`draw${this.hookName}`, this); this.#drawn = true; }); } /** * The inner _draw method which must be defined by each CanvasLayer subclass. * @param {object} options Options which configure how the layer is drawn * @abstract * @protected */ async _draw(options) { throw new Error(`The ${this.constructor.name} subclass of CanvasLayer must define the _draw method`); } /* -------------------------------------------- */ /** * Deconstruct data used in the current layer in preparation to re-draw the canvas * @param {object} [options] Options which configure how the layer is deconstructed * @returns {Promise} */ async tearDown(options={}) { if ( !this.#drawn ) return this; MouseInteractionManager.emulateMoveEvent(); this.#drawn = false; this.renderable = false; await this._tearDown(options); Hooks.callAll(`tearDown${this.hookName}`, this); this.renderable = true; MouseInteractionManager.emulateMoveEvent(); return this; } /** * The inner _tearDown method which may be customized by each CanvasLayer subclass. * @param {object} options Options which configure how the layer is deconstructed * @protected */ async _tearDown(options) { this.removeChildren().forEach(c => c.destroy({children: true})); } } /** * A subclass of CanvasLayer which provides support for user interaction with its contained objects. * @category - Canvas */ class InteractionLayer extends CanvasLayer { /** * Is this layer currently active * @type {boolean} */ get active() { return this.#active; } /** @ignore */ #active = false; /** @override */ eventMode = "passive"; /** * Customize behaviors of this CanvasLayer by modifying some behaviors at a class level. * @type {{name: string, zIndex: number}} */ static get layerOptions() { return Object.assign(super.layerOptions, { baseClass: InteractionLayer, zIndex: 0 }); } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** * Activate the InteractionLayer, deactivating other layers and marking this layer's children as interactive. * @param {object} [options] Options which configure layer activation * @param {string} [options.tool] A specific tool in the control palette to set as active * @returns {InteractionLayer} The layer instance, now activated */ activate({tool}={}) { // Set this layer as active const wasActive = this.#active; this.#active = true; // Deactivate other layers for ( const name of Object.keys(Canvas.layers) ) { const layer = canvas[name]; if ( (layer !== this) && (layer instanceof InteractionLayer) ) layer.deactivate(); } // Re-render Scene controls ui.controls?.initialize({layer: this.constructor.layerOptions.name, tool}); if ( wasActive ) return this; // Reset the interaction manager canvas.mouseInteractionManager?.reset({state: false}); // Assign interactivity for the active layer this.zIndex = this.getZIndex(); this.eventMode = "static"; this.interactiveChildren = true; // Call layer-specific activation procedures this._activate(); Hooks.callAll(`activate${this.hookName}`, this); Hooks.callAll("activateCanvasLayer", this); return this; } /** * The inner _activate method which may be defined by each InteractionLayer subclass. * @protected */ _activate() {} /* -------------------------------------------- */ /** * Deactivate the InteractionLayer, removing interactivity from its children. * @returns {InteractionLayer} The layer instance, now inactive */ deactivate() { if ( !this.#active ) return this; canvas.highlightObjects(false); this.#active = false; this.eventMode = "passive"; this.interactiveChildren = false; this.zIndex = this.getZIndex(); this._deactivate(); Hooks.callAll(`deactivate${this.hookName}`, this); return this; } /** * The inner _deactivate method which may be defined by each InteractionLayer subclass. * @protected */ _deactivate() {} /* -------------------------------------------- */ /** @override */ async _draw(options) { this.hitArea = canvas.dimensions.rect; this.zIndex = this.getZIndex(); } /* -------------------------------------------- */ /** * Get the zIndex that should be used for ordering this layer vertically relative to others in the same Container. * @returns {number} */ getZIndex() { return this.options.zIndex; } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** * Handle left mouse-click events which originate from the Canvas stage. * @see {@link Canvas._onClickLeft} * @param {PIXI.FederatedEvent} event The PIXI InteractionEvent which wraps a PointerEvent * @protected */ _onClickLeft(event) {} /* -------------------------------------------- */ /** * Handle double left-click events which originate from the Canvas stage. * @see {@link Canvas.#onClickLeft2} * @param {PIXI.FederatedEvent} event The PIXI InteractionEvent which wraps a PointerEvent * @protected */ _onClickLeft2(event) {} /* -------------------------------------------- */ /** * Does the User have permission to left-click drag on the Canvas? * @param {User} user The User performing the action. * @param {PIXI.FederatedEvent} event The event object. * @returns {boolean} * @protected */ _canDragLeftStart(user, event) { return true; } /* -------------------------------------------- */ /** * Start a left-click drag workflow originating from the Canvas stage. * @see {@link Canvas.#onDragLeftStart} * @param {PIXI.FederatedEvent} event The PIXI InteractionEvent which wraps a PointerEvent * @protected */ _onDragLeftStart(event) {} /* -------------------------------------------- */ /** * Continue a left-click drag workflow originating from the Canvas stage. * @see {@link Canvas.#onDragLeftMove} * @param {PIXI.FederatedEvent} event The PIXI InteractionEvent which wraps a PointerEvent * @protected */ _onDragLeftMove(event) {} /* -------------------------------------------- */ /** * Conclude a left-click drag workflow originating from the Canvas stage. * @see {@link Canvas.#onDragLeftDrop} * @param {PIXI.FederatedEvent} event The PIXI InteractionEvent which wraps a PointerEvent * @protected */ _onDragLeftDrop(event) {} /* -------------------------------------------- */ /** * Cancel a left-click drag workflow originating from the Canvas stage. * @see {@link Canvas.#onDragLeftDrop} * @param {PointerEvent} event A right-click pointer event on the document. * @protected */ _onDragLeftCancel(event) {} /* -------------------------------------------- */ /** * Handle right mouse-click events which originate from the Canvas stage. * @see {@link Canvas._onClickRight} * @param {PIXI.FederatedEvent} event The PIXI InteractionEvent which wraps a PointerEvent * @protected */ _onClickRight(event) {} /* -------------------------------------------- */ /** * Handle mouse-wheel events which occur for this active layer. * @see {@link MouseManager._onWheel} * @param {WheelEvent} event The WheelEvent initiated on the document * @protected */ _onMouseWheel(event) {} /* -------------------------------------------- */ /** * Handle a DELETE keypress while this layer is active. * @see {@link ClientKeybindings._onDelete} * @param {KeyboardEvent} event The delete key press event * @protected */ async _onDeleteKey(event) {} } /* -------------------------------------------- */ /** * @typedef {Object} CanvasHistory * @property {string} type The type of operation stored as history (create, update, delete) * @property {Object[]} data The data corresponding to the action which may later be un-done */ /** * @typedef {Object} PlaceablesLayerOptions * @property {boolean} controllableObjects Can placeable objects in this layer be controlled? * @property {boolean} rotatableObjects Can placeable objects in this layer be rotated? * @property {boolean} confirmDeleteKey Confirm placeable object deletion with a dialog? * @property {PlaceableObject} objectClass The class used to represent an object on this layer. * @property {boolean} quadtree Does this layer use a quadtree to track object positions? */ /** * A subclass of Canvas Layer which is specifically designed to contain multiple PlaceableObject instances, * each corresponding to an embedded Document. * @category - Canvas */ class PlaceablesLayer extends InteractionLayer { /** * Sort order for placeables belonging to this layer. * @type {number} */ static SORT_ORDER = 0; /** * Placeable Layer Objects * @type {PIXI.Container|null} */ objects = null; /** * Preview Object Placement */ preview = null; /** * Keep track of history so that CTRL+Z can undo changes * @type {CanvasHistory[]} */ history = []; /** * Keep track of an object copied with CTRL+C which can be pasted later * @type {PlaceableObject[]} */ _copy = []; /** * A Quadtree which partitions and organizes Walls into quadrants for efficient target identification. * @type {Quadtree|null} */ quadtree = this.options.quadtree ? new CanvasQuadtree() : null; /* -------------------------------------------- */ /* Attributes */ /* -------------------------------------------- */ /** * Configuration options for the PlaceablesLayer. * @type {PlaceablesLayerOptions} */ static get layerOptions() { return foundry.utils.mergeObject(super.layerOptions, { baseClass: PlaceablesLayer, controllableObjects: false, rotatableObjects: false, confirmDeleteKey: false, objectClass: CONFIG[this.documentName]?.objectClass, quadtree: true }); } /* -------------------------------------------- */ /** * A reference to the named Document type which is contained within this Canvas Layer. * @type {string} */ static documentName; /** * Creation states affected to placeables during their construction. * @enum {number} */ static CREATION_STATES = { NONE: 0, POTENTIAL: 1, CONFIRMED: 2, COMPLETED: 3 }; /* -------------------------------------------- */ /** * Obtain a reference to the Collection of embedded Document instances within the currently viewed Scene * @type {Collection|null} */ get documentCollection() { return canvas.scene?.getEmbeddedCollection(this.constructor.documentName) || null; } /* -------------------------------------------- */ /** * Obtain a reference to the PlaceableObject class definition which represents the Document type in this layer. * @type {Function} */ static get placeableClass() { return CONFIG[this.documentName].objectClass; } /* -------------------------------------------- */ /** * If objects on this PlaceablesLayer have a HUD UI, provide a reference to its instance * @type {BasePlaceableHUD|null} */ get hud() { return null; } /* -------------------------------------------- */ /** * A convenience method for accessing the placeable object instances contained in this layer * @type {PlaceableObject[]} */ get placeables() { if ( !this.objects ) return []; return this.objects.children; } /* -------------------------------------------- */ /** * An Array of placeable objects in this layer which have the _controlled attribute * @returns {PlaceableObject[]} */ get controlled() { return Array.from(this.#controlledObjects.values()); } /* -------------------------------------------- */ /** * Iterates over placeable objects that are eligible for control/select. * @yields A placeable object * @returns {Generator} */ *controllableObjects() { if ( !this.options.controllableObjects ) return; for ( const placeable of this.placeables ) { if ( placeable.visible ) yield placeable; } } /* -------------------------------------------- */ /** * Track the set of PlaceableObjects on this layer which are currently controlled. * @type {Map} */ get controlledObjects() { return this.#controlledObjects; } /** @private */ #controlledObjects = new Map(); /* -------------------------------------------- */ /** * Track the PlaceableObject on this layer which is currently hovered upon. * @type {PlaceableObject|null} */ get hover() { return this.#hover; } set hover(object) { if ( object instanceof this.constructor.placeableClass ) this.#hover = object; else this.#hover = null; } #hover = null; /* -------------------------------------------- */ /** * Track whether "highlight all objects" is currently active * @type {boolean} */ highlightObjects = false; /* -------------------------------------------- */ /** * Get the maximum sort value of all placeables. * @returns {number} The maximum sort value (-Infinity if there are no objects) */ getMaxSort() { let sort = -Infinity; const collection = this.documentCollection; if ( !collection?.documentClass.schema.has("sort") ) return sort; for ( const document of collection ) sort = Math.max(sort, document.sort); return sort; } /* -------------------------------------------- */ /** * Send the controlled objects of this layer to the back or bring them to the front. * @param {boolean} front Bring to front instead of send to back? * @returns {boolean} Returns true if the layer has sortable object, and false otherwise * @internal */ _sendToBackOrBringToFront(front) { const collection = this.documentCollection; const documentClass = collection?.documentClass; if ( !documentClass?.schema.has("sort") ) return false; if ( !this.controlled.length ) return true; // Determine to-be-updated objects and the minimum/maximum sort value of the other objects const toUpdate = []; let target = front ? -Infinity : Infinity; for ( const document of collection ) { if ( document.object?.controlled && !document.locked ) toUpdate.push(document); else target = (front ? Math.max : Math.min)(target, document.sort); } if ( !Number.isFinite(target) ) return true; target += (front ? 1 : -toUpdate.length); // Sort the to-be-updated objects by sort in ascending order toUpdate.sort((a, b) => a.sort - b.sort); // Update the to-be-updated objects const updates = toUpdate.map((document, i) => ({_id: document.id, sort: target + i})); canvas.scene.updateEmbeddedDocuments(documentClass.documentName, updates); return true; } /* -------------------------------------------- */ /** * Snaps the given point to grid. The layer defines the snapping behavior. * @param {Point} point The point that is to be snapped * @returns {Point} The snapped point */ getSnappedPoint(point) { const M = CONST.GRID_SNAPPING_MODES; const grid = canvas.grid; return grid.getSnappedPoint(point, { mode: grid.isHexagonal && !this.options.controllableObjects ? M.CENTER | M.VERTEX : M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT, resolution: 1 }); } /* -------------------------------------------- */ /* Rendering /* -------------------------------------------- */ /** * Obtain an iterable of objects which should be added to this PlaceablesLayer * @returns {Document[]} */ getDocuments() { return this.documentCollection || []; } /* -------------------------------------------- */ /** @inheritDoc */ async _draw(options) { await super._draw(options); // Create objects container which can be sorted this.objects = this.addChild(new PIXI.Container()); this.objects.sortableChildren = true; this.objects.visible = false; const cls = getDocumentClass(this.constructor.documentName); if ( (cls.schema.get("elevation") instanceof foundry.data.fields.NumberField) && (cls.schema.get("sort") instanceof foundry.data.fields.NumberField) ) { this.objects.sortChildren = PlaceablesLayer.#sortObjectsByElevationAndSort; } this.objects.on("childAdded", obj => { if ( !(obj instanceof this.constructor.placeableClass) ) { console.error(`An object of type ${obj.constructor.name} was added to ${this.constructor.name}#objects. ` + `The object must be an instance of ${this.constructor.placeableClass.name}.`); } if ( obj instanceof PlaceableObject ) obj._updateQuadtree(); }); this.objects.on("childRemoved", obj => { if ( obj instanceof PlaceableObject ) obj._updateQuadtree(); }); // Create preview container which is always above objects this.preview = this.addChild(new PIXI.Container()); // Create and draw objects const documents = this.getDocuments(); const promises = documents.map(doc => { const obj = doc._object = this.createObject(doc); this.objects.addChild(obj); return obj.draw(); }); // Wait for all objects to draw await Promise.all(promises); this.objects.visible = this.active; } /* -------------------------------------------- */ /** * Draw a single placeable object * @param {ClientDocument} document The Document instance used to create the placeable object * @returns {PlaceableObject} */ createObject(document) { return new this.constructor.placeableClass(document); } /* -------------------------------------------- */ /** @inheritDoc */ async _tearDown(options) { this.history = []; if ( this.options.controllableObjects ) { this.controlledObjects.clear(); } if ( this.hud ) this.hud.clear(); if ( this.quadtree ) this.quadtree.clear(); this.objects = null; return super._tearDown(options); } /** * The method to sort the objects elevation and sort before sorting by the z-index. * @type {Function} */ static #sortObjectsByElevationAndSort = function() { for ( let i = 0; i < this.children.length; i++ ) { this.children[i]._lastSortedIndex = i; } this.children.sort((a, b) => (a.document.elevation - b.document.elevation) || (a.document.sort - b.document.sort) || (a.zIndex - b.zIndex) || (a._lastSortedIndex - b._lastSortedIndex) ); this.sortDirty = false; }; /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** @override */ _activate() { this.objects.visible = true; this.placeables.forEach(l => l.renderFlags.set({refreshState: true})); } /* -------------------------------------------- */ /** @override */ _deactivate() { this.objects.visible = false; this.releaseAll(); this.placeables.forEach(l => l.renderFlags.set({refreshState: true})); this.clearPreviewContainer(); } /* -------------------------------------------- */ /** * Clear the contents of the preview container, restoring visibility of original (non-preview) objects. */ clearPreviewContainer() { if ( !this.preview ) return; this.preview.removeChildren().forEach(c => { c._onDragEnd(); c.destroy({children: true}); }); } /* -------------------------------------------- */ /** * Get a PlaceableObject contained in this layer by its ID. * Returns undefined if the object doesn't exist or if the canvas is not rendering a Scene. * @param {string} objectId The ID of the contained object to retrieve * @returns {PlaceableObject} The object instance, or undefined */ get(objectId) { return this.documentCollection?.get(objectId)?.object || undefined; } /* -------------------------------------------- */ /** * Acquire control over all PlaceableObject instances which are visible and controllable within the layer. * @param {object} options Options passed to the control method of each object * @returns {PlaceableObject[]} An array of objects that were controlled */ controlAll(options={}) { if ( !this.options.controllableObjects ) return []; options.releaseOthers = false; for ( const placeable of this.controllableObjects() ) { placeable.control(options); } return this.controlled; } /* -------------------------------------------- */ /** * Release all controlled PlaceableObject instance from this layer. * @param {object} options Options passed to the release method of each object * @returns {number} The number of PlaceableObject instances which were released */ releaseAll(options={}) { let released = 0; for ( let o of this.placeables ) { if ( !o.controlled ) continue; o.release(options); released++; } return released; } /* -------------------------------------------- */ /** * Simultaneously rotate multiple PlaceableObjects using a provided angle or incremental. * This executes a single database operation using Scene#updateEmbeddedDocuments. * @param {object} options Options which configure how multiple objects are rotated * @param {number} [options.angle] A target angle of rotation (in degrees) where zero faces "south" * @param {number} [options.delta] An incremental angle of rotation (in degrees) * @param {number} [options.snap] Snap the resulting angle to a multiple of some increment (in degrees) * @param {Array} [options.ids] An Array of object IDs to target for rotation * @param {boolean} [options.includeLocked=false] Rotate objects whose documents are locked? * @returns {Promise} An array of objects which were rotated * @throws An error if an explicitly provided id is not valid */ async rotateMany({angle, delta, snap, ids, includeLocked=false}={}) { if ( (angle ?? delta ?? null) === null ) { throw new Error("Either a target angle or relative delta must be provided."); } // Rotation is not permitted if ( !this.options.rotatableObjects ) return []; if ( game.paused && !game.user.isGM ) { ui.notifications.warn("GAME.PausedWarning", {localize: true}); return []; } // Identify the objects requested for rotation const objects = this._getMovableObjects(ids, includeLocked); if ( !objects.length ) return objects; // Conceal any active HUD this.hud?.clear(); // Commit updates to the Scene const updateData = objects.map(o => ({ _id: o.id, rotation: o._updateRotation({angle, delta, snap}) })); await canvas.scene.updateEmbeddedDocuments(this.constructor.documentName, updateData); return objects; } /* -------------------------------------------- */ /** * Simultaneously move multiple PlaceableObjects via keyboard movement offsets. * This executes a single database operation using Scene#updateEmbeddedDocuments. * @param {object} options Options which configure how multiple objects are moved * @param {-1|0|1} [options.dx=0] Horizontal movement direction * @param {-1|0|1} [options.dy=0] Vertical movement direction * @param {boolean} [options.rotate=false] Rotate the placeable to direction instead of moving * @param {string[]} [options.ids] An Array of object IDs to target for movement. * The default is the IDs of controlled objects. * @param {boolean} [options.includeLocked=false] Move objects whose documents are locked? * @returns {Promise} An array of objects which were moved during the operation * @throws An error if an explicitly provided id is not valid */ async moveMany({dx=0, dy=0, rotate=false, ids, includeLocked=false}={}) { if ( ![-1, 0, 1].includes(dx) ) throw new Error("Invalid argument: dx must be -1, 0, or 1"); if ( ![-1, 0, 1].includes(dy) ) throw new Error("Invalid argument: dy must be -1, 0, or 1"); if ( !dx && !dy ) return []; if ( game.paused && !game.user.isGM ) { ui.notifications.warn("GAME.PausedWarning", {localize: true}); return []; } // Identify the objects requested for movement const objects = this._getMovableObjects(ids, includeLocked); if ( !objects.length ) return objects; // Define rotation angles const rotationAngles = { square: [45, 135, 225, 315], hexR: [30, 150, 210, 330], hexQ: [60, 120, 240, 300] }; // Determine the rotation angle let offsets = [dx, dy]; let angle = 0; if ( rotate ) { let angles = rotationAngles.square; const gridType = canvas.grid.type; if ( gridType >= CONST.GRID_TYPES.HEXODDQ ) angles = rotationAngles.hexQ; else if ( gridType >= CONST.GRID_TYPES.HEXODDR ) angles = rotationAngles.hexR; if (offsets.equals([0, 1])) angle = 0; else if (offsets.equals([-1, 1])) angle = angles[0]; else if (offsets.equals([-1, 0])) angle = 90; else if (offsets.equals([-1, -1])) angle = angles[1]; else if (offsets.equals([0, -1])) angle = 180; else if (offsets.equals([1, -1])) angle = angles[2]; else if (offsets.equals([1, 0])) angle = 270; else if (offsets.equals([1, 1])) angle = angles[3]; } // Conceal any active HUD this.hud?.clear(); // Commit updates to the Scene const updateData = objects.map(obj => { let update = {_id: obj.id}; if ( rotate ) update.rotation = angle; else foundry.utils.mergeObject(update, obj._getShiftedPosition(...offsets)); return update; }); await canvas.scene.updateEmbeddedDocuments(this.constructor.documentName, updateData); return objects; } /* -------------------------------------------- */ /** * An internal helper method to identify the array of PlaceableObjects which can be moved or rotated. * @param {string[]} ids An explicit array of IDs requested. * @param {boolean} includeLocked Include locked objects which would otherwise be ignored? * @returns {PlaceableObject[]} An array of objects which can be moved or rotated * @throws An error if any explicitly requested ID is not valid * @internal */ _getMovableObjects(ids, includeLocked) { if ( ids instanceof Array ) return ids.reduce((arr, id) => { const object = this.get(id); if ( !object ) throw new Error(`"${id} is not a valid ${this.constructor.documentName} in the current Scene`); if ( includeLocked || !object.document.locked ) arr.push(object); return arr; }, []); return this.controlled.filter(object => includeLocked || !object.document.locked); } /* -------------------------------------------- */ /** * Undo a change to the objects in this layer * This method is typically activated using CTRL+Z while the layer is active * @returns {Promise} An array of documents which were modified by the undo operation */ async undoHistory() { if ( game.paused && !game.user.isGM ) { ui.notifications.warn("GAME.PausedWarning", {localize: true}); return []; } const type = this.constructor.documentName; if ( !this.history.length ) { ui.notifications.info(game.i18n.format("CONTROLS.EmptyUndoHistory", { type: game.i18n.localize(getDocumentClass(type).metadata.label)})); return []; } let event = this.history.pop(); // Undo creation with deletion if ( event.type === "create" ) { const ids = event.data.map(d => d._id); const deleted = await canvas.scene.deleteEmbeddedDocuments(type, ids, {isUndo: true}); if ( deleted.length !== 1 ) ui.notifications.info(game.i18n.format("CONTROLS.UndoCreateObjects", {count: deleted.length, type: game.i18n.localize(getDocumentClass(type).metadata.label)})); return deleted; } // Undo updates with update else if ( event.type === "update" ) { return canvas.scene.updateEmbeddedDocuments(type, event.data, {isUndo: true}); } // Undo deletion with creation else if ( event.type === "delete" ) { const created = await canvas.scene.createEmbeddedDocuments(type, event.data, {isUndo: true, keepId: true}); if ( created.length !== 1 ) ui.notifications.info(game.i18n.format("CONTROLS.UndoDeleteObjects", {count: created.length, type: game.i18n.localize(getDocumentClass(type).metadata.label)})); return created; } } /* -------------------------------------------- */ /** * A helper method to prompt for deletion of all PlaceableObject instances within the Scene * Renders a confirmation dialogue to confirm with the requester that all objects will be deleted * @returns {Promise} An array of Document objects which were deleted by the operation */ async deleteAll() { const type = this.constructor.documentName; if ( !game.user.isGM ) { throw new Error(`You do not have permission to delete ${type} objects from the Scene.`); } const typeLabel = game.i18n.localize(getDocumentClass(type).metadata.label); return Dialog.confirm({ title: game.i18n.localize("CONTROLS.ClearAll"), content: `

${game.i18n.format("CONTROLS.ClearAllHint", {type: typeLabel})}

`, yes: async () => { const deleted = await canvas.scene.deleteEmbeddedDocuments(type, [], {deleteAll: true}); ui.notifications.info(game.i18n.format("CONTROLS.DeletedObjects", {count: deleted.length, type: typeLabel})); } }); } /* -------------------------------------------- */ /** * Record a new CRUD event in the history log so that it can be undone later * @param {string} type The event type (create, update, delete) * @param {Object[]} data The object data */ storeHistory(type, data) { if ( data.every(d => !("_id" in d)) ) throw new Error("The data entries must contain the _id key"); if ( type === "update" ) data = data.filter(d => Object.keys(d).length > 1); // Filter entries without changes if ( data.length === 0 ) return; // Don't store empty history data if ( this.history.length >= 10 ) this.history.shift(); this.history.push({type, data}); } /* -------------------------------------------- */ /** * Copy currently controlled PlaceableObjects to a temporary Array, ready to paste back into the scene later * @returns {PlaceableObject[]} The Array of copied PlaceableObject instances */ copyObjects() { if ( this.options.controllableObjects ) this._copy = [...this.controlled]; else if ( this.hover ) this._copy = [this.hover]; else this._copy = []; const typeLabel = game.i18n.localize(getDocumentClass(this.constructor.documentName).metadata.label); ui.notifications.info(game.i18n.format("CONTROLS.CopiedObjects", { count: this._copy.length, type: typeLabel })); return this._copy; } /* -------------------------------------------- */ /** * Paste currently copied PlaceableObjects back to the layer by creating new copies * @param {Point} position The destination position for the copied data. * @param {object} [options] Options which modify the paste operation * @param {boolean} [options.hidden=false] Paste data in a hidden state, if applicable. Default is false. * @param {boolean} [options.snap=true] Snap the resulting objects to the grid. Default is true. * @returns {Promise} An Array of created Document instances */ async pasteObjects(position, {hidden=false, snap=true}={}) { if ( !this._copy.length ) return []; // Get the center of all copies const center = {x: 0, y: 0}; for ( const copy of this._copy ) { const c = copy.center; center.x += c.x; center.y += c.y; } center.x /= this._copy.length; center.y /= this._copy.length; // Offset of the destination position relative to the center const offset = {x: position.x - center.x, y: position.y - center.y}; // Iterate over objects const toCreate = []; for ( const copy of this._copy ) { toCreate.push(this._pasteObject(copy, offset, {hidden, snap})); } /** * A hook event that fires when any PlaceableObject is pasted onto the * Scene. Substitute the PlaceableObject name in the hook event to target a * specific PlaceableObject type, for example "pasteToken". * @function pastePlaceableObject * @memberof hookEvents * @param {PlaceableObject[]} copied The PlaceableObjects that were copied * @param {object[]} createData The new objects that will be added to the Scene */ Hooks.call(`paste${this.constructor.documentName}`, this._copy, toCreate); // Create all objects const created = await canvas.scene.createEmbeddedDocuments(this.constructor.documentName, toCreate); ui.notifications.info(game.i18n.format("CONTROLS.PastedObjects", {count: created.length, type: game.i18n.localize(getDocumentClass(this.constructor.documentName).metadata.label)})); return created; } /* -------------------------------------------- */ /** * Get the data of the copied object pasted at the position given by the offset. * Called by {@link PlaceablesLayer#pasteObjects} for each copied object. * @param {PlaceableObject} copy The copied object that is pasted * @param {Point} offset The offset relative from the current position to the destination * @param {object} [options] Options of {@link PlaceablesLayer#pasteObjects} * @param {boolean} [options.hidden=false] Paste in a hidden state, if applicable. Default is false. * @param {boolean} [options.snap=true] Snap to the grid. Default is true. * @returns {object} The update data * @protected */ _pasteObject(copy, offset, {hidden=false, snap=true}={}) { const {x, y} = copy.document; let position = {x: x + offset.x, y: y + offset.y}; if ( snap ) position = this.getSnappedPoint(position); const d = canvas.dimensions; position.x = Math.clamp(position.x, 0, d.width - 1); position.y = Math.clamp(position.y, 0, d.height - 1); const data = copy.document.toObject(); delete data._id; data.x = position.x; data.y = position.y; data.hidden ||= hidden; return data; } /* -------------------------------------------- */ /** * Select all PlaceableObject instances which fall within a coordinate rectangle. * @param {object} [options={}] * @param {number} [options.x] The top-left x-coordinate of the selection rectangle. * @param {number} [options.y] The top-left y-coordinate of the selection rectangle. * @param {number} [options.width] The width of the selection rectangle. * @param {number} [options.height] The height of the selection rectangle. * @param {object} [options.releaseOptions={}] Optional arguments provided to any called release() method. * @param {object} [options.controlOptions={}] Optional arguments provided to any called control() method. * @param {object} [aoptions] Additional options to configure selection behaviour. * @param {boolean} [aoptions.releaseOthers=true] Whether to release other selected objects. * @returns {boolean} A boolean for whether the controlled set was changed in the operation. */ selectObjects({x, y, width, height, releaseOptions={}, controlOptions={}}={}, {releaseOthers=true}={}) { if ( !this.options.controllableObjects ) return false; const oldSet = new Set(this.controlled); // Identify selected objects const newSet = new Set(); const rectangle = new PIXI.Rectangle(x, y, width, height); for ( const placeable of this.controllableObjects() ) { if ( placeable._overlapsSelection(rectangle) ) newSet.add(placeable); } // Release objects that are no longer controlled const toRelease = oldSet.difference(newSet); if ( releaseOthers ) toRelease.forEach(placeable => placeable.release(releaseOptions)); // Control objects that were not controlled before if ( foundry.utils.isEmpty(controlOptions) ) controlOptions.releaseOthers = false; const toControl = newSet.difference(oldSet); toControl.forEach(placeable => placeable.control(controlOptions)); // Return a boolean for whether the control set was changed return (releaseOthers && (toRelease.size > 0)) || (toControl.size > 0); } /* -------------------------------------------- */ /** * Update all objects in this layer with a provided transformation. * Conditionally filter to only apply to objects 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 * @returns {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 = this.placeables.reduce((arr, obj) => { if ( hasCondition && !condition(obj) ) return arr; const update = hasTransformer ? transformation(obj) : foundry.utils.deepClone(transformation); update._id = obj.id; arr.push(update); return arr; },[]); return canvas.scene.updateEmbeddedDocuments(this.constructor.documentName, updates, options); } /* -------------------------------------------- */ /** * Get the world-transformed drop position. * @param {DragEvent} event * @param {object} [options] * @param {boolean} [options.center=true] Return the coordinates of the center of the nearest grid element. * @returns {number[]|boolean} Returns the transformed x, y coordinates, or false if the drag event was outside * the canvas. * @protected */ _canvasCoordinatesFromDrop(event, {center=true}={}) { let coords = canvas.canvasCoordinatesFromClient({x: event.clientX, y: event.clientY}); if ( center ) coords = canvas.grid.getCenterPoint(coords); if ( canvas.dimensions.rect.contains(coords.x, coords.y) ) return [coords.x, coords.y]; return false; } /* -------------------------------------------- */ /** * Create a preview of this layer's object type from a world document and show its sheet to be finalized. * @param {object} createData The data to create the object with. * @param {object} [options] Options which configure preview creation * @param {boolean} [options.renderSheet] Render the preview object config sheet? * @param {number} [options.top] The offset-top position where the sheet should be rendered * @param {number} [options.left] The offset-left position where the sheet should be rendered * @returns {PlaceableObject} The created preview object * @internal */ async _createPreview(createData, {renderSheet=true, top=0, left=0}={}) { const documentName = this.constructor.documentName; const cls = getDocumentClass(documentName); const document = new cls(createData, {parent: canvas.scene}); if ( !document.canUserModify(game.user, "create") ) { return ui.notifications.warn(game.i18n.format("PERMISSION.WarningNoCreate", {document: documentName})); } const object = new CONFIG[documentName].objectClass(document); this.activate(); this.preview.addChild(object); await object.draw(); if ( renderSheet ) object.sheet.render(true, {top, left}); return object; } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** @override */ _onClickLeft(event) { if ( !event.target.hasActiveHUD ) this.hud?.clear(); if ( this.options.controllableObjects && game.settings.get("core", "leftClickRelease") && !this.hover ) { this.releaseAll(); } } /* -------------------------------------------- */ /** @override */ _canDragLeftStart(user, event) { if ( game.paused && !game.user.isGM ) { ui.notifications.warn("GAME.PausedWarning", {localize: true}); return false; } return true; } /* -------------------------------------------- */ /** @override */ _onDragLeftStart(event) { this.clearPreviewContainer(); } /* -------------------------------------------- */ /** @override */ _onDragLeftMove(event) { const preview = event.interactionData.preview; if ( !preview || preview._destroyed ) return; if ( preview.parent === null ) { // In theory this should never happen, but rarely does this.preview.addChild(preview); } } /* -------------------------------------------- */ /** @override */ _onDragLeftDrop(event) { const preview = event.interactionData.preview; if ( !preview || preview._destroyed ) return; event.interactionData.clearPreviewContainer = false; const cls = getDocumentClass(this.constructor.documentName); cls.create(preview.document.toObject(false), {parent: canvas.scene}) .finally(() => this.clearPreviewContainer()); } /* -------------------------------------------- */ /** @override */ _onDragLeftCancel(event) { if ( event.interactionData?.clearPreviewContainer !== false ) { this.clearPreviewContainer(); } } /* -------------------------------------------- */ /** @override */ _onClickRight(event) { if ( !event.target.hasActiveHUD ) this.hud?.clear(); } /* -------------------------------------------- */ /** @override */ _onMouseWheel(event) { // Prevent wheel rotation during dragging if ( this.preview.children.length ) return; // Determine the incremental angle of rotation from event data const snap = event.shiftKey ? (canvas.grid.isHexagonal ? 30 : 45) : 15; const delta = snap * Math.sign(event.delta); return this.rotateMany({delta, snap}); } /* -------------------------------------------- */ /** @override */ async _onDeleteKey(event) { if ( game.paused && !game.user.isGM ) { ui.notifications.warn("GAME.PausedWarning", {localize: true}); return; } // Identify objects which are candidates for deletion const objects = this.options.controllableObjects ? this.controlled : (this.hover ? [this.hover] : []); if ( !objects.length ) return; // Restrict to objects which can be deleted const ids = objects.reduce((ids, o) => { const isDragged = (o.interactionState === MouseInteractionManager.INTERACTION_STATES.DRAG); if ( isDragged || o.document.locked || !o.document.canUserModify(game.user, "delete") ) return ids; if ( this.hover === o ) this.hover = null; ids.push(o.id); return ids; }, []); if ( ids.length ) { const typeLabel = game.i18n.localize(getDocumentClass(this.constructor.documentName).metadata.label); if ( this.options.confirmDeleteKey ) { const confirmed = await foundry.applications.api.DialogV2.confirm({ window: { title: game.i18n.format("DOCUMENT.Delete", {type: typeLabel}) }, content: `

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

`, rejectClose: false }); if ( !confirmed ) return; } const deleted = await canvas.scene.deleteEmbeddedDocuments(this.constructor.documentName, ids); if ( deleted.length !== 1) ui.notifications.info(game.i18n.format("CONTROLS.DeletedObjects", { count: deleted.length, type: typeLabel})); } } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get gridPrecision() { const msg = "PlaceablesLayer#gridPrecision is deprecated. Use PlaceablesLayer#getSnappedPoint " + "instead of GridLayer#getSnappedPosition and PlaceablesLayer#gridPrecision."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); const grid = canvas.grid; if ( grid.type === CONST.GRID_TYPES.GRIDLESS ) return 0; // No snapping for gridless if ( grid.type === CONST.GRID_TYPES.SQUARE ) return 2; // Corners and centers return this.options.controllableObjects ? 2 : 5; // Corners or vertices } /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ get _highlight() { const msg = "PlaceablesLayer#_highlight is deprecated. Use PlaceablesLayer#highlightObjects instead."; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return this.highlightObjects; } /** * @deprecated since v11 * @ignore */ set _highlight(state) { const msg = "PlaceablesLayer#_highlight is deprecated. Use PlaceablesLayer#highlightObjects instead."; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); this.highlightObjects = !!state; } } /** * 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 extends FullCanvasObjectMixin(PIXI.Container) { constructor(options={}) { super(); /** * The array of emitters which are active for this particle effect * @type {PIXI.particles.Emitter[]} */ this.emitters = this.getParticleEmitters(options); } /* -------------------------------------------- */ /** * 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, config); } /* -------------------------------------------- */ /** * Get the particle emitters which should be active for this particle effect. * This base class creates a single emitter using the explicitly provided configuration. * Subclasses can override this method for more advanced configurations. * @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={}) { if ( foundry.utils.isEmpty(options) ) { throw new Error("The base ParticleEffect class may only be used with an explicitly provided configuration"); } return [this.createEmitter(/** @type {PIXI.particles.EmitterConfigV3} */ options)]; } /* -------------------------------------------- */ /** @override */ destroy(...args) { for ( const e of this.emitters ) e.destroy(); this.emitters = []; super.destroy(...args); } /* -------------------------------------------- */ /** * 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; } } } /** * 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 single Mouse Cursor * @type {PIXI.Container} */ class Cursor extends PIXI.Container { constructor(user) { super(); this.target = {x: 0, y: 0}; this.draw(user); } /** * To know if this cursor is animated * @type {boolean} */ #animating; /* -------------------------------------------- */ /** * Update visibility and animations * @param {User} user The user */ refreshVisibility(user) { const v = this.visible = !user.isSelf && user.hasPermission("SHOW_CURSOR"); if ( v && !this.#animating ) { canvas.app.ticker.add(this._animate, this); this.#animating = true; // Set flag to true when animation is added } else if ( !v && this.#animating ) { canvas.app.ticker.remove(this._animate, this); this.#animating = false; // Set flag to false when animation is removed } } /* -------------------------------------------- */ /** * Draw the user's cursor as a small dot with their user name attached as text */ draw(user) { // Cursor dot const d = this.addChild(new PIXI.Graphics()); d.beginFill(user.color, 0.35).lineStyle(1, 0x000000, 0.5).drawCircle(0, 0, 6); // Player name const style = CONFIG.canvasTextStyle.clone(); style.fontSize = 14; let n = this.addChild(new PreciseText(user.name, style)); n.x -= n.width / 2; n.y += 10; // Refresh this.refreshVisibility(user); } /* -------------------------------------------- */ /** * Move an existing cursor to a new position smoothly along the animation loop */ _animate() { const dy = this.target.y - this.y; const dx = this.target.x - this.x; if ( Math.abs( dx ) + Math.abs( dy ) < 10 ) return; this.x += dx / 10; this.y += dy / 10; } /* -------------------------------------------- */ /** @inheritdoc */ destroy(options) { if ( this.#animating ) { canvas.app.ticker.remove(this._animate, this); this.#animating = false; } super.destroy(options); } } /** * An icon representing a Door Control * @extends {PIXI.Container} */ class DoorControl extends PIXI.Container { constructor(wall) { super(); this.wall = wall; this.visible = false; // Door controls are not visible by default } /* -------------------------------------------- */ /** * The center of the wall which contains the door. * @type {PIXI.Point} */ get center() { return this.wall.center; } /* -------------------------------------------- */ /** * Draw the DoorControl icon, displaying its icon texture and border * @returns {Promise} */ async draw() { // Background this.bg = this.bg || this.addChild(new PIXI.Graphics()); this.bg.clear().beginFill(0x000000, 1.0).drawRoundedRect(-2, -2, 44, 44, 5).endFill(); this.bg.alpha = 0; // Control Icon this.icon = this.icon || this.addChild(new PIXI.Sprite()); this.icon.width = this.icon.height = 40; this.icon.alpha = 0.6; this.icon.texture = this._getTexture(); // Border this.border = this.border || this.addChild(new PIXI.Graphics()); this.border.clear().lineStyle(1, 0xFF5500, 0.8).drawRoundedRect(-2, -2, 44, 44, 5).endFill(); this.border.visible = false; // Add control interactivity this.eventMode = "static"; this.interactiveChildren = false; this.hitArea = new PIXI.Rectangle(-2, -2, 44, 44); this.cursor = "pointer"; // Set position this.reposition(); this.alpha = 1.0; // Activate listeners this.removeAllListeners(); this.on("pointerover", this._onMouseOver).on("pointerout", this._onMouseOut) .on("pointerdown", this._onMouseDown).on("rightdown", this._onRightDown); return this; } /* -------------------------------------------- */ /** * Get the icon texture to use for the Door Control icon based on the door state * @returns {PIXI.Texture} */ _getTexture() { // Determine displayed door state const ds = CONST.WALL_DOOR_STATES; let s = this.wall.document.ds; if ( !game.user.isGM && (s === ds.LOCKED) ) s = ds.CLOSED; // Determine texture path const icons = CONFIG.controlIcons; let path = { [ds.LOCKED]: icons.doorLocked, [ds.CLOSED]: icons.doorClosed, [ds.OPEN]: icons.doorOpen }[s] || icons.doorClosed; if ( (s === ds.CLOSED) && (this.wall.document.door === CONST.WALL_DOOR_TYPES.SECRET) ) path = icons.doorSecret; // Obtain the icon texture return getTexture(path); } /* -------------------------------------------- */ reposition() { let pos = this.wall.midpoint.map(p => p - 20); this.position.set(...pos); } /* -------------------------------------------- */ /** * Determine whether the DoorControl is visible to the calling user's perspective. * The control is always visible if the user is a GM and no Tokens are controlled. * @see {CanvasVisibility#testVisibility} * @type {boolean} */ get isVisible() { if ( !canvas.visibility.tokenVision ) return true; // Hide secret doors from players const w = this.wall; if ( (w.document.door === CONST.WALL_DOOR_TYPES.SECRET) && !game.user.isGM ) return false; // Test two points which are perpendicular to the door midpoint const ray = this.wall.toRay(); const [x, y] = w.midpoint; const [dx, dy] = [-ray.dy, ray.dx]; const t = 3 / (Math.abs(dx) + Math.abs(dy)); // Approximate with Manhattan distance for speed const points = [ {x: x + (t * dx), y: y + (t * dy)}, {x: x - (t * dx), y: y - (t * dy)} ]; // Test each point for visibility return points.some(p => { return canvas.visibility.testVisibility(p, {object: this, tolerance: 0}); }); } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** * Handle mouse over events on a door control icon. * @param {PIXI.FederatedEvent} event The originating interaction event * @protected */ _onMouseOver(event) { event.stopPropagation(); const canControl = game.user.can("WALL_DOORS"); const blockPaused = game.paused && !game.user.isGM; if ( !canControl || blockPaused ) return false; this.border.visible = true; this.icon.alpha = 1.0; this.bg.alpha = 0.25; canvas.walls.hover = this.wall; } /* -------------------------------------------- */ /** * Handle mouse out events on a door control icon. * @param {PIXI.FederatedEvent} event The originating interaction event * @protected */ _onMouseOut(event) { event.stopPropagation(); if ( game.paused && !game.user.isGM ) return false; this.border.visible = false; this.icon.alpha = 0.6; this.bg.alpha = 0; canvas.walls.hover = null; } /* -------------------------------------------- */ /** * Handle left mouse down events on a door control icon. * This should only toggle between the OPEN and CLOSED states. * @param {PIXI.FederatedEvent} event The originating interaction event * @protected */ _onMouseDown(event) { if ( event.button !== 0 ) return; // Only support standard left-click event.stopPropagation(); const { ds } = this.wall.document; const states = CONST.WALL_DOOR_STATES; // Determine whether the player can control the door at this time if ( !game.user.can("WALL_DOORS") ) return false; if ( game.paused && !game.user.isGM ) { ui.notifications.warn("GAME.PausedWarning", {localize: true}); return false; } const sound = !(game.user.isGM && game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.ALT)); // Play an audio cue for testing locked doors, only for the current client if ( ds === states.LOCKED ) { if ( sound ) this.wall._playDoorSound("test"); return false; } // Toggle between OPEN and CLOSED states return this.wall.document.update({ds: ds === states.CLOSED ? states.OPEN : states.CLOSED}, {sound}); } /* -------------------------------------------- */ /** * Handle right mouse down events on a door control icon. * This should toggle whether the door is LOCKED or CLOSED. * @param {PIXI.FederatedEvent} event The originating interaction event * @protected */ _onRightDown(event) { event.stopPropagation(); if ( !game.user.isGM ) return; let state = this.wall.document.ds; const states = CONST.WALL_DOOR_STATES; if ( state === states.OPEN ) return; state = state === states.LOCKED ? states.CLOSED : states.LOCKED; const sound = !(game.user.isGM && game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.ALT)); return this.wall.document.update({ds: state}, {sound}); } } /** * A CanvasLayer for displaying UI controls which are overlayed on top of other layers. * * We track three types of events: * 1) Cursor movement * 2) Ruler measurement * 3) Map pings */ class ControlsLayer extends InteractionLayer { constructor() { super(); // Always interactive even if disabled for doors controls this.interactiveChildren = true; /** * A container of DoorControl instances * @type {PIXI.Container} */ this.doors = this.addChild(new PIXI.Container()); /** * A container of cursor interaction elements. * Contains cursors, rulers, interaction rectangles, and pings * @type {PIXI.Container} */ this.cursors = this.addChild(new PIXI.Container()); this.cursors.eventMode = "none"; this.cursors.mask = canvas.masks.canvas; /** * Ruler tools, one per connected user * @type {PIXI.Container} */ this.rulers = this.addChild(new PIXI.Container()); this.rulers.eventMode = "none"; /** * A graphics instance used for drawing debugging visualization * @type {PIXI.Graphics} */ this.debug = this.addChild(new PIXI.Graphics()); this.debug.eventMode = "none"; } /** * The Canvas selection rectangle * @type {PIXI.Graphics} */ select; /** * A mapping of user IDs to Cursor instances for quick access * @type {Record} */ _cursors = {}; /** * A mapping of user IDs to Ruler instances for quick access * @type {Record} * @private */ _rulers = {}; /** * The positions of any offscreen pings we are tracking. * @type {Record} * @private */ _offscreenPings = {}; /* -------------------------------------------- */ /** @override */ static get layerOptions() { return foundry.utils.mergeObject(super.layerOptions, { name: "controls", zIndex: 1000 }); } /* -------------------------------------------- */ /* Properties and Public Methods */ /* -------------------------------------------- */ /** * A convenience accessor to the Ruler for the active game user * @type {Ruler} */ get ruler() { return this.getRulerForUser(game.user.id); } /* -------------------------------------------- */ /** * Get the Ruler display for a specific User ID * @param {string} userId * @returns {Ruler|null} */ getRulerForUser(userId) { return this._rulers[userId] || null; } /* -------------------------------------------- */ /* Rendering */ /* -------------------------------------------- */ /** @inheritDoc */ async _draw(options) { await super._draw(options); // Create additional elements this.drawCursors(); this.drawRulers(); this.drawDoors(); this.select = this.cursors.addChild(new PIXI.Graphics()); // Adjust scale const d = canvas.dimensions; this.hitArea = d.rect; } /* -------------------------------------------- */ /** @override */ async _tearDown(options) { this._cursors = {}; this._rulers = {}; this.doors.removeChildren(); this.cursors.removeChildren(); this.rulers.removeChildren(); this.debug.clear(); this.debug.debugText?.removeChildren().forEach(c => c.destroy({children: true})); } /* -------------------------------------------- */ /** * Draw the cursors container */ drawCursors() { for ( let u of game.users.filter(u => u.active && !u.isSelf ) ) { this.drawCursor(u); } } /* -------------------------------------------- */ /** * Create and add Ruler graphics instances for every game User. */ drawRulers() { const cls = CONFIG.Canvas.rulerClass; for (let u of game.users) { let ruler = this.getRulerForUser(u.id); if ( !ruler ) ruler = this._rulers[u.id] = new cls(u); this.rulers.addChild(ruler); } } /* -------------------------------------------- */ /** * Draw door control icons to the doors container. */ drawDoors() { for ( const wall of canvas.walls.placeables ) { if ( wall.isDoor ) wall.createDoorControl(); } } /* -------------------------------------------- */ /** * Draw the select rectangle given an event originated within the base canvas layer * @param {Object} coords The rectangle coordinates of the form {x, y, width, height} */ drawSelect({x, y, width, height}) { const s = this.select.clear(); s.lineStyle(3, 0xFF9829, 0.9).drawRect(x, y, width, height); } /* -------------------------------------------- */ /** @override */ _deactivate() { this.interactiveChildren = true; } /* -------------------------------------------- */ /* Event Listeners and Handlers /* -------------------------------------------- */ /** * Handle mousemove events on the game canvas to broadcast activity of the user's cursor position */ _onMouseMove() { if ( !game.user.hasPermission("SHOW_CURSOR") ) return; game.user.broadcastActivity({cursor: canvas.mousePosition}); } /* -------------------------------------------- */ /** * Handle pinging the canvas. * @param {PIXI.FederatedEvent} event The triggering canvas interaction event. * @param {PIXI.Point} origin The local canvas coordinates of the mousepress. * @protected */ _onLongPress(event, origin) { const isCtrl = game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL); const isTokenLayer = canvas.activeLayer instanceof TokenLayer; if ( !game.user.hasPermission("PING_CANVAS") || isCtrl || !isTokenLayer ) return; canvas.currentMouseManager.cancel(event); // Cancel drag workflow return canvas.ping(origin); } /* -------------------------------------------- */ /** * Handle the canvas panning to a new view. * @protected */ _onCanvasPan() { for ( const [name, position] of Object.entries(this._offscreenPings) ) { const { ray, intersection } = this._findViewportIntersection(position); if ( intersection ) { const { x, y } = canvas.canvasCoordinatesFromClient(intersection); const ping = CanvasAnimation.getAnimation(name).context; ping.x = x; ping.y = y; ping.rotation = Math.normalizeRadians(ray.angle + (Math.PI * 1.5)); } else CanvasAnimation.terminateAnimation(name); } } /* -------------------------------------------- */ /* Methods /* -------------------------------------------- */ /** * Create and draw the Cursor object for a given User * @param {User} user The User document for whom to draw the cursor Container */ drawCursor(user) { if ( user.id in this._cursors ) { this._cursors[user.id].destroy({children: true}); delete this._cursors[user.id]; } return this._cursors[user.id] = this.cursors.addChild(new Cursor(user)); } /* -------------------------------------------- */ /** * Update the cursor when the user moves to a new position * @param {User} user The User for whom to update the cursor * @param {Point} position The new cursor position */ updateCursor(user, position) { if ( !this.cursors ) return; const cursor = this._cursors[user.id] || this.drawCursor(user); // Ignore cursors on other Scenes if ( ( position === null ) || (user.viewedScene !== canvas.scene.id) ) { if ( cursor ) cursor.visible = false; return; } // Show the cursor in its currently tracked position cursor.refreshVisibility(user); cursor.target = {x: position.x || 0, y: position.y || 0}; } /* -------------------------------------------- */ /** * Update display of an active Ruler object for a user given provided data * @param {User} user The User for whom to update the ruler * @param {RulerMeasurementData|null} rulerData Data which describes the new ruler measurement to display */ updateRuler(user, rulerData) { // Ignore rulers for users who are not permitted to share if ( (user === game.user) || !user.hasPermission("SHOW_RULER") ) return; // Update the Ruler display for the user const ruler = this.getRulerForUser(user.id); ruler?.update(rulerData); } /* -------------------------------------------- */ /** * Handle a broadcast ping. * @see {@link Ping#drawPing} * @param {User} user The user who pinged. * @param {Point} position The position on the canvas that was pinged. * @param {PingData} [data] The broadcast ping data. * @returns {Promise} A promise which resolves once the Ping has been drawn and animated */ async handlePing(user, position, {scene, style="pulse", pull=false, zoom=1, ...pingOptions}={}) { if ( !canvas.ready || (canvas.scene?.id !== scene) || !position ) return; if ( pull && (user.isGM || user.isSelf) ) { await canvas.animatePan({ x: position.x, y: position.y, scale: Math.min(CONFIG.Canvas.maxZoom, zoom), duration: CONFIG.Canvas.pings.pullSpeed }); } else if ( canvas.isOffscreen(position) ) this.drawOffscreenPing(position, { style: "arrow", user }); if ( game.settings.get("core", "photosensitiveMode") ) style = CONFIG.Canvas.pings.types.PULL; return this.drawPing(position, { style, user, ...pingOptions }); } /* -------------------------------------------- */ /** * Draw a ping at the edge of the viewport, pointing to the location of an off-screen ping. * @see {@link Ping#drawPing} * @param {Point} position The coordinates of the off-screen ping. * @param {PingOptions} [options] Additional options to configure how the ping is drawn. * @param {string} [options.style=arrow] The style of ping to draw, from CONFIG.Canvas.pings. * @param {User} [options.user] The user who pinged. * @returns {Promise} A promise which resolves once the Ping has been drawn and animated */ async drawOffscreenPing(position, {style="arrow", user, ...pingOptions}={}) { const { ray, intersection } = this._findViewportIntersection(position); if ( !intersection ) return; const name = `Ping.${foundry.utils.randomID()}`; this._offscreenPings[name] = position; position = canvas.canvasCoordinatesFromClient(intersection); if ( game.settings.get("core", "photosensitiveMode") ) pingOptions.rings = 1; const animation = this.drawPing(position, { style, user, name, rotation: ray.angle, ...pingOptions }); animation.finally(() => delete this._offscreenPings[name]); return animation; } /* -------------------------------------------- */ /** * Draw a ping on the canvas. * @see {@link Ping#animate} * @param {Point} position The position on the canvas that was pinged. * @param {PingOptions} [options] Additional options to configure how the ping is drawn. * @param {string} [options.style=pulse] The style of ping to draw, from CONFIG.Canvas.pings. * @param {User} [options.user] The user who pinged. * @returns {Promise} A promise which resolves once the Ping has been drawn and animated */ async drawPing(position, {style="pulse", user, ...pingOptions}={}) { const cfg = CONFIG.Canvas.pings.styles[style] ?? CONFIG.Canvas.pings.styles.pulse; const options = { duration: cfg.duration, color: cfg.color ?? user?.color, size: canvas.dimensions.size * (cfg.size || 1) }; const ping = new cfg.class(position, foundry.utils.mergeObject(options, pingOptions)); this.cursors.addChild(ping); return ping.animate(); } /* -------------------------------------------- */ /** * Given off-screen coordinates, determine the closest point at the edge of the viewport to these coordinates. * @param {Point} position The off-screen coordinates. * @returns {{ray: Ray, intersection: LineIntersection|null}} The closest point at the edge of the viewport to these * coordinates and a ray cast from the centre of the * screen towards it. * @private */ _findViewportIntersection(position) { let { clientWidth: w, clientHeight: h } = document.documentElement; // Accommodate the sidebar. if ( !ui.sidebar._collapsed ) w -= ui.sidebar.options.width + 10; const [cx, cy] = [w / 2, h / 2]; const ray = new Ray({x: cx, y: cy}, canvas.clientCoordinatesFromCanvas(position)); const bounds = [[0, 0, w, 0], [w, 0, w, h], [w, h, 0, h], [0, h, 0, 0]]; const intersections = bounds.map(ray.intersectSegment.bind(ray)); const intersection = intersections.find(i => i !== null); return { ray, intersection }; } } /** * @typedef {Object} RulerMeasurementSegment * @property {Ray} ray The Ray which represents the point-to-point line segment * @property {PreciseText} label The text object used to display a label for this segment * @property {number} distance The measured distance of the segment * @property {number} cost The measured cost of the segment * @property {number} cumulativeDistance The cumulative measured distance of this segment and the segments before it * @property {number} cumulativeCost The cumulative measured cost of this segment and the segments before it * @property {boolean} history Is this segment part of the measurement history? * @property {boolean} first Is this segment the first one after the measurement history? * @property {boolean} last Is this segment the last one? * @property {object} animation Animation options passed to {@link TokenDocument#update} */ /** * @typedef {object} RulerMeasurementHistoryWaypoint * @property {number} x The x-coordinate of the waypoint * @property {number} y The y-coordinate of the waypoint * @property {boolean} teleport Teleported to from the previous waypoint this waypoint? * @property {number} cost The cost of having moved from the previous waypoint to this waypoint */ /** * @typedef {RulerMeasurementHistoryWaypoint[]} RulerMeasurementHistory */ /** * The Ruler - used to measure distances and trigger movements */ class Ruler extends PIXI.Container { /** * The Ruler constructor. * @param {User} [user=game.user] The User for whom to construct the Ruler instance * @param {object} [options] Additional options * @param {ColorSource} [options.color] The color of the ruler (defaults to the color of the User) */ constructor(user=game.user, {color}={}) { super(); /** * Record the User which this Ruler references * @type {User} */ this.user = user; /** * The ruler name - used to differentiate between players * @type {string} */ this.name = `Ruler.${user.id}`; /** * The ruler color - by default the color of the active user * @type {Color} */ this.color = Color.from(color ?? this.user.color); /** * The Ruler element is a Graphics instance which draws the line and points of the measured path * @type {PIXI.Graphics} */ this.ruler = this.addChild(new PIXI.Graphics()); /** * The Labels element is a Container of Text elements which label the measured path * @type {PIXI.Container} */ this.labels = this.addChild(new PIXI.Container()); } /* -------------------------------------------- */ /** * The possible Ruler measurement states. * @enum {number} */ static get STATES() { return Ruler.#STATES; } static #STATES = Object.freeze({ INACTIVE: 0, STARTING: 1, MEASURING: 2, MOVING: 3 }); /* -------------------------------------------- */ /** * Is the ruler ready for measure? * @type {boolean} */ static get canMeasure() { return (game.activeTool === "ruler") || game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL); } /* -------------------------------------------- */ /** * The current destination point at the end of the measurement * @type {Point|null} */ destination = null; /* -------------------------------------------- */ /** * The origin point of the measurement, which is the first waypoint. * @type {Point|null} */ get origin() { return this.waypoints.at(0) ?? null; } /* -------------------------------------------- */ /** * This Array tracks individual waypoints along the ruler's measured path. * The first waypoint is always the origin of the route. * @type {Point[]} */ waypoints = []; /* -------------------------------------------- */ /** * The array of most recently computed ruler measurement segments * @type {RulerMeasurementSegment[]} */ segments = []; /* -------------------------------------------- */ /** * The measurement history. * @type {RulerMeasurementHistory} */ get history() { return this.#history; } #history = []; /* -------------------------------------------- */ /** * The computed total distance of the Ruler. * @type {number} */ totalDistance = 0; /* -------------------------------------------- */ /** * The computed total cost of the Ruler. * @type {number} */ totalCost = 0; /* -------------------------------------------- */ /** * The current state of the Ruler (one of {@link Ruler.STATES}). * @type {number} */ get state() { return this._state; } /** * The current state of the Ruler (one of {@link Ruler.STATES}). * @type {number} * @protected */ _state = Ruler.STATES.INACTIVE; /* -------------------------------------------- */ /** * Is the Ruler being actively used to measure distance? * @type {boolean} */ get active() { return this.state !== Ruler.STATES.INACTIVE; } /* -------------------------------------------- */ /** * Get a GridHighlight layer for this Ruler * @type {GridHighlight} */ get highlightLayer() { return canvas.interface.grid.highlightLayers[this.name] || canvas.interface.grid.addHighlightLayer(this.name); } /* -------------------------------------------- */ /** * The Token that is moved by the Ruler. * @type {Token|null} */ get token() { return this.#token; } #token = null; /* -------------------------------------------- */ /* Ruler Methods */ /* -------------------------------------------- */ /** * Clear display of the current Ruler */ clear() { this._state = Ruler.STATES.INACTIVE; this.#token = null; this.destination = null; this.waypoints = []; this.segments = []; this.#history = []; this.totalDistance = 0; this.totalCost = 0; this.ruler.clear(); this.labels.removeChildren().forEach(c => c.destroy()); canvas.interface.grid.clearHighlightLayer(this.name); } /* -------------------------------------------- */ /** * Measure the distance between two points and render the ruler UI to illustrate it * @param {Point} destination The destination point to which to measure * @param {object} [options] Additional options * @param {boolean} [options.snap=true] Snap the destination? * @param {boolean} [options.force=false] If not forced and the destination matches the current destination * of this ruler, no measuring is done and nothing is returned * @returns {RulerMeasurementSegment[]|void} The array of measured segments if measured */ measure(destination, {snap=true, force=false}={}) { if ( this.state !== Ruler.STATES.MEASURING ) return; // Compute the measurement destination, segments, and distance const d = this._getMeasurementDestination(destination, {snap}); if ( this.destination && (d.x === this.destination.x) && (d.y === this.destination.y) && !force ) return; this.destination = d; this.segments = this._getMeasurementSegments(); this._computeDistance(); this._broadcastMeasurement(); // Draw the ruler graphic this.ruler.clear(); this._drawMeasuredPath(); // Draw grid highlight this.highlightLayer.clear(); for ( const segment of this.segments ) this._highlightMeasurementSegment(segment); return this.segments; } /* -------------------------------------------- */ /** * Get the measurement origin. * @param {Point} point The waypoint * @param {object} [options] Additional options * @param {boolean} [options.snap=true] Snap the waypoint? * @protected */ _getMeasurementOrigin(point, {snap=true}={}) { if ( this.token && snap ) { if ( canvas.grid.isGridless ) return this.token.getCenterPoint(); const snapped = this.token.getSnappedPosition(); const dx = this.token.document.x - Math.round(snapped.x); const dy = this.token.document.y - Math.round(snapped.y); const center = canvas.grid.getCenterPoint({x: point.x - dx, y: point.y - dy}); return {x: center.x + dx, y: center.y + dy}; } return snap ? canvas.grid.getCenterPoint(point) : {x: point.x, y: point.y}; } /* -------------------------------------------- */ /** * Get the destination point. By default the point is snapped to grid space centers. * @param {Point} point The point coordinates * @param {object} [options] Additional options * @param {boolean} [options.snap=true] Snap the point? * @returns {Point} The snapped destination point * @protected */ _getMeasurementDestination(point, {snap=true}={}) { return snap ? canvas.grid.getCenterPoint(point) : {x: point.x, y: point.y}; } /* -------------------------------------------- */ /** * Translate the waypoints and destination point of the Ruler into an array of Ray segments. * @returns {RulerMeasurementSegment[]} The segments of the measured path * @protected */ _getMeasurementSegments() { const segments = []; const path = this.history.concat(this.waypoints.concat([this.destination])); for ( let i = 1; i < path.length; i++ ) { const label = this.labels.children.at(i - 1) ?? this.labels.addChild(new PreciseText("", CONFIG.canvasTextStyle)); const ray = new Ray(path[i - 1], path[i]); segments.push({ ray, teleport: (i < this.history.length) ? path[i].teleport : (i === this.history.length) && (ray.distance > 0), label, distance: 0, cost: 0, cumulativeDistance: 0, cumulativeCost: 0, history: i <= this.history.length, first: i === this.history.length + 1, last: i === path.length - 1, animation: {} }); } if ( this.labels.children.length > segments.length ) { this.labels.removeChildren(segments.length).forEach(c => c.destroy()); } return segments; } /* -------------------------------------------- */ /** * Handle the start of a Ruler measurement workflow * @param {Point} origin The origin * @param {object} [options] Additional options * @param {boolean} [options.snap=true] Snap the origin? * @param {Token|null} [options.token] The token that is moved (defaults to {@link Ruler#_getMovementToken}) * @protected */ _startMeasurement(origin, {snap=true, token}={}) { if ( this.state !== Ruler.STATES.INACTIVE ) return; this.clear(); this._state = Ruler.STATES.STARTING; this.#token = token !== undefined ? token : this._getMovementToken(origin); this.#history = this._getMeasurementHistory() ?? []; this._addWaypoint(origin, {snap}); canvas.hud.token.clear(); } /* -------------------------------------------- */ /** * Handle the conclusion of a Ruler measurement workflow * @protected */ _endMeasurement() { if ( this.state !== Ruler.STATES.MEASURING ) return; this.clear(); this._broadcastMeasurement(); } /* -------------------------------------------- */ /** * Handle the addition of a new waypoint in the Ruler measurement path * @param {Point} point The waypoint * @param {object} [options] Additional options * @param {boolean} [options.snap=true] Snap the waypoint? * @protected */ _addWaypoint(point, {snap=true}={}) { if ( (this.state !== Ruler.STATES.STARTING) && (this.state !== Ruler.STATES.MEASURING ) ) return; const waypoint = this.state === Ruler.STATES.STARTING ? this._getMeasurementOrigin(point, {snap}) : this._getMeasurementDestination(point, {snap}); this.waypoints.push(waypoint); this._state = Ruler.STATES.MEASURING; this.measure(this.destination ?? point, {snap, force: true}); } /* -------------------------------------------- */ /** * Handle the removal of a waypoint in the Ruler measurement path * @protected */ _removeWaypoint() { if ( (this.state !== Ruler.STATES.STARTING) && (this.state !== Ruler.STATES.MEASURING ) ) return; if ( (this.state === Ruler.STATES.MEASURING) && (this.waypoints.length > 1) ) { this.waypoints.pop(); this.measure(this.destination, {snap: false, force: true}); } else this._endMeasurement(); } /* -------------------------------------------- */ /** * Get the cost function to be used for Ruler measurements. * @returns {GridMeasurePathCostFunction|void} * @protected */ _getCostFunction() {} /* -------------------------------------------- */ /** * Compute the distance of each segment and the total distance of the measured path. * @protected */ _computeDistance() { let path = []; if ( this.segments.length ) path.push(this.segments[0].ray.A); for ( const segment of this.segments ) { const {x, y} = segment.ray.B; path.push({x, y, teleport: segment.teleport}); } const measurements = canvas.grid.measurePath(path, {cost: this._getCostFunction()}).segments; this.totalDistance = 0; this.totalCost = 0; for ( let i = 0; i < this.segments.length; i++ ) { const segment = this.segments[i]; const distance = measurements[i].distance; const cost = segment.history ? this.history.at(i + 1)?.cost ?? 0 : measurements[i].cost; this.totalDistance += distance; this.totalCost += cost; segment.distance = distance; segment.cost = cost; segment.cumulativeDistance = this.totalDistance; segment.cumulativeCost = this.totalCost; } } /* -------------------------------------------- */ /** * Get the text label for a segment of the measured path * @param {RulerMeasurementSegment} segment * @returns {string} * @protected */ _getSegmentLabel(segment) { if ( segment.teleport ) return ""; const units = canvas.grid.units; let label = `${Math.round(segment.distance * 100) / 100}`; if ( units ) label += ` ${units}`; if ( segment.last ) { label += ` [${Math.round(this.totalDistance * 100) / 100}`; if ( units ) label += ` ${units}`; label += "]"; } return label; } /* -------------------------------------------- */ /** * Draw each segment of the measured path. * @protected */ _drawMeasuredPath() { const paths = []; let path = null; for ( const segment of this.segments ) { const ray = segment.ray; if ( ray.distance !== 0 ) { if ( segment.teleport ) path = null; else { if ( !path || (path.history !== segment.history) ) { path = {points: [ray.A], history: segment.history}; paths.push(path); } path.points.push(ray.B); } } // Draw Label const label = segment.label; if ( label ) { const text = this._getSegmentLabel(segment, /** @deprecated since v12 */ this.totalDistance); label.text = text; label.alpha = segment.last ? 1.0 : 0.5; label.visible = !!text && (ray.distance !== 0); label.anchor.set(0.5, 0.5); let {sizeX, sizeY} = canvas.grid; if ( canvas.grid.isGridless ) sizeX = sizeY = 6; // The radius of the waypoints const pad = 8; const offsetX = (label.width + (2 * pad) + sizeX) / Math.abs(2 * ray.dx); const offsetY = (label.height + (2 * pad) + sizeY) / Math.abs(2 * ray.dy); label.position = ray.project(1 + Math.min(offsetX, offsetY)); } } const points = paths.map(p => p.points).flat(); // Draw segments if ( points.length === 1 ) { this.ruler.beginFill(0x000000, 0.5, true).drawCircle(points[0].x, points[0].y, 3).endFill(); this.ruler.beginFill(this.color, 0.25, true).drawCircle(points[0].x, points[0].y, 2).endFill(); } else { const dashShader = new PIXI.smooth.DashLineShader(); for ( const {points, history} of paths ) { this.ruler.lineStyle({width: 6, color: 0x000000, alpha: 0.5, shader: history ? dashShader : null, join: PIXI.LINE_JOIN.ROUND, cap: PIXI.LINE_CAP.ROUND}); this.ruler.drawPath(points); this.ruler.lineStyle({width: 4, color: this.color, alpha: 0.25, shader: history ? dashShader : null, join: PIXI.LINE_JOIN.ROUND, cap: PIXI.LINE_CAP.ROUND}); this.ruler.drawPath(points); } } // Draw waypoints this.ruler.beginFill(this.color, 0.25, true).lineStyle(2, 0x000000, 0.5); for ( const {x, y} of points ) this.ruler.drawCircle(x, y, 6); this.ruler.endFill(); } /* -------------------------------------------- */ /** * Highlight the measurement required to complete the move in the minimum number of discrete spaces * @param {RulerMeasurementSegment} segment * @protected */ _highlightMeasurementSegment(segment) { if ( segment.teleport ) return; for ( const offset of canvas.grid.getDirectPath([segment.ray.A, segment.ray.B]) ) { const {x: x1, y: y1} = canvas.grid.getTopLeftPoint(offset); canvas.interface.grid.highlightPosition(this.name, {x: x1, y: y1, color: this.color}); } } /* -------------------------------------------- */ /* Token Movement Execution */ /* -------------------------------------------- */ /** * Determine whether a SPACE keypress event entails a legal token movement along a measured ruler * @returns {Promise} An indicator for whether a token was successfully moved or not. If True the * event should be prevented from propagating further, if False it should move on * to other handlers. */ async moveToken() { if ( this.state !== Ruler.STATES.MEASURING ) return false; if ( game.paused && !game.user.isGM ) { ui.notifications.warn("GAME.PausedWarning", {localize: true}); return false; } // Get the Token which should move const token = this.token; if ( !token ) return false; // Verify whether the movement is allowed let error; try { if ( !this._canMove(token) ) error = "RULER.MovementNotAllowed"; } catch(err) { error = err.message; } if ( error ) { ui.notifications.error(error, {localize: true}); return false; } // Animate the movement path defined by each ray segments this._state = Ruler.STATES.MOVING; await this._preMove(token); await this._animateMovement(token); await this._postMove(token); // Clear the Ruler this._state = Ruler.STATES.MEASURING; this._endMeasurement(); return true; } /* -------------------------------------------- */ /** * Acquire a Token, if any, which is eligible to perform a movement based on the starting point of the Ruler * @param {Point} origin The origin of the Ruler * @returns {Token|null} The Token that is to be moved, if any * @protected */ _getMovementToken(origin) { let tokens = canvas.tokens.controlled; if ( !tokens.length && game.user.character ) tokens = game.user.character.getActiveTokens(); for ( const token of tokens ) { if ( !token.visible || !token.shape ) continue; const {x, y} = token.document; for ( let dx = -1; dx <= 1; dx++ ) { for ( let dy = -1; dy <= 1; dy++ ) { if ( token.shape.contains(origin.x - x + dx, origin.y - y + dy) ) return token; } } } return null; } /* -------------------------------------------- */ /** * Get the current measurement history. * @returns {RulerMeasurementHistory|void} The current measurement history, if any * @protected */ _getMeasurementHistory() {} /* -------------------------------------------- */ /** * Create the next measurement history from the current history and current Ruler state. * @returns {RulerMeasurementHistory} The next measurement history * @protected */ _createMeasurementHistory() { if ( !this.segments.length ) return []; const origin = this.segments[0].ray.A; return this.segments.reduce((history, s) => { if ( s.ray.distance === 0 ) return history; history.push({x: s.ray.B.x, y: s.ray.B.y, teleport: s.teleport, cost: s.cost}); return history; }, [{x: origin.x, y: origin.y, teleport: false, cost: 0}]); } /* -------------------------------------------- */ /** * Test whether a Token is allowed to execute a measured movement path. * @param {Token} token The Token being tested * @returns {boolean} Whether the movement is allowed * @throws A specific Error message used instead of returning false * @protected */ _canMove(token) { const canUpdate = token.document.canUserModify(game.user, "update"); if ( !canUpdate ) throw new Error("RULER.MovementNoPermission"); if ( token.document.locked ) throw new Error("RULER.MovementLocked"); const hasCollision = this.segments.some(s => { return token.checkCollision(s.ray.B, {origin: s.ray.A, type: "move", mode: "any"}); }); if ( hasCollision ) throw new Error("RULER.MovementCollision"); return true; } /* -------------------------------------------- */ /** * Animate piecewise Token movement along the measured segment path. * @param {Token} token The Token being animated * @returns {Promise} A Promise which resolves once all animation is completed * @protected */ async _animateMovement(token) { const wasPaused = game.paused; // Determine offset of the initial origin relative to the snapped Token's top-left. // This is important to position the token relative to the ruler origin for non-1x1 tokens. const origin = this.segments[this.history.length].ray.A; const dx = token.document.x - origin.x; const dy = token.document.y - origin.y; // Iterate over each measured segment let priorDest = undefined; for ( const segment of this.segments ) { if ( segment.history || (segment.ray.distance === 0) ) continue; const r = segment.ray; const {x, y} = token.document._source; // Break the movement if the game is paused if ( !wasPaused && game.paused ) break; // Break the movement if Token is no longer located at the prior destination (some other change override this) if ( priorDest && ((x !== priorDest.x) || (y !== priorDest.y)) ) break; // Commit the movement and update the final resolved destination coordinates const adjustedDestination = {x: Math.round(r.B.x + dx), y: Math.round(r.B.y + dy)}; await this._animateSegment(token, segment, adjustedDestination); priorDest = adjustedDestination; } } /* -------------------------------------------- */ /** * Update Token position and configure its animation properties for the next leg of its animation. * @param {Token} token The Token being updated * @param {RulerMeasurementSegment} segment The measured segment being moved * @param {Point} destination The adjusted destination coordinate * @param {object} [updateOptions] Additional options to configure the `TokenDocument` update * @returns {Promise} A Promise that resolves once the animation for this segment is done * @protected */ async _animateSegment(token, segment, destination, updateOptions={}) { let name; if ( segment.animation?.name === undefined ) name = token.animationName; else name ||= Symbol(token.animationName); const {x, y} = token.document._source; await token.animate({x, y}, {name, duration: 0}); foundry.utils.mergeObject( updateOptions, {teleport: segment.teleport, animation: {...segment.animation, name}}, {overwrite: false} ); await token.document.update(destination, updateOptions); await CanvasAnimation.getAnimation(name)?.promise; } /* -------------------------------------------- */ /** * An method which can be extended by a subclass of Ruler to define custom behaviors before a confirmed movement. * @param {Token} token The Token that will be moving * @returns {Promise} * @protected */ async _preMove(token) {} /* -------------------------------------------- */ /** * An event which can be extended by a subclass of Ruler to define custom behaviors before a confirmed movement. * @param {Token} token The Token that finished moving * @returns {Promise} * @protected */ async _postMove(token) {} /* -------------------------------------------- */ /* Saving and Loading /* -------------------------------------------- */ /** * A throttled function that broadcasts the measurement data. * @type {function()} */ #throttleBroadcastMeasurement = foundry.utils.throttle(this.#broadcastMeasurement.bind(this), 100); /* -------------------------------------------- */ /** * Broadcast Ruler measurement. */ #broadcastMeasurement() { game.user.broadcastActivity({ruler: this.active ? this._getMeasurementData() : null}); } /* -------------------------------------------- */ /** * Broadcast Ruler measurement if its User is the connected client. * The broadcast is throttled to 100ms. * @protected */ _broadcastMeasurement() { if ( !this.user.isSelf || !game.user.hasPermission("SHOW_RULER") ) return; this.#throttleBroadcastMeasurement(); } /* -------------------------------------------- */ /** * @typedef {object} RulerMeasurementData * @property {number} state The state ({@link Ruler#state}) * @property {string|null} token The token ID ({@link Ruler#token}) * @property {RulerMeasurementHistory} history The measurement history ({@link Ruler#history}) * @property {Point[]} waypoints The waypoints ({@link Ruler#waypoints}) * @property {Point|null} destination The destination ({@link Ruler#destination}) */ /** * Package Ruler data to an object which can be serialized to a string. * @returns {RulerMeasurementData} * @protected */ _getMeasurementData() { return foundry.utils.deepClone({ state: this.state, token: this.token?.id ?? null, history: this.history, waypoints: this.waypoints, destination: this.destination }); } /* -------------------------------------------- */ /** * Update a Ruler instance using data provided through the cursor activity socket * @param {RulerMeasurementData|null} data Ruler data with which to update the display */ update(data) { if ( !data || (data.state === Ruler.STATES.INACTIVE) ) return this.clear(); this._state = data.state; this.#token = canvas.tokens.get(data.token) ?? null; this.#history = data.history; this.waypoints = data.waypoints; this.measure(data.destination, {snap: false, force: true}); } /* -------------------------------------------- */ /* Event Listeners and Handlers /* -------------------------------------------- */ /** * Handle the beginning of a new Ruler measurement workflow * @see {Canvas.#onDragLeftStart} * @param {PIXI.FederatedEvent} event The drag start event * @protected * @internal */ _onDragStart(event) { this._startMeasurement(event.interactionData.origin, {snap: !event.shiftKey}); if ( this.token && (this.state === Ruler.STATES.MEASURING) ) this.token.document.locked = true; } /* -------------------------------------------- */ /** * Handle left-click events on the Canvas during Ruler measurement. * @see {Canvas._onClickLeft} * @param {PIXI.FederatedEvent} event The pointer-down event * @protected * @internal */ _onClickLeft(event) { const isCtrl = event.ctrlKey || event.metaKey; if ( !isCtrl ) return; this._addWaypoint(event.interactionData.origin, {snap: !event.shiftKey}); } /* -------------------------------------------- */ /** * Handle right-click events on the Canvas during Ruler measurement. * @see {Canvas._onClickRight} * @param {PIXI.FederatedEvent} event The pointer-down event * @protected * @internal */ _onClickRight(event) { const token = this.token; const isCtrl = event.ctrlKey || event.metaKey; if ( isCtrl ) this._removeWaypoint(); else this._endMeasurement(); if ( this.active ) canvas.mouseInteractionManager._dragRight = false; else { if ( token ) token.document.locked = token.document._source.locked; canvas.mouseInteractionManager.cancel(event); } } /* -------------------------------------------- */ /** * Continue a Ruler measurement workflow for left-mouse movements on the Canvas. * @see {Canvas.#onDragLeftMove} * @param {PIXI.FederatedEvent} event The mouse move event * @protected * @internal */ _onMouseMove(event) { const destination = event.interactionData.destination; if ( !canvas.dimensions.rect.contains(destination.x, destination.y) ) return; this.measure(destination, {snap: !event.shiftKey}); } /* -------------------------------------------- */ /** * Conclude a Ruler measurement workflow by releasing the left-mouse button. * @see {Canvas.#onDragLeftDrop} * @param {PIXI.FederatedEvent} event The pointer-up event * @protected * @internal */ _onMouseUp(event) { if ( !this.active ) return; const isCtrl = event.ctrlKey || event.metaKey; if ( isCtrl || (this.waypoints.length > 1) ) event.preventDefault(); else { if ( this.token ) this.token.document.locked = this.token.document._source.locked; this._endMeasurement(); canvas.mouseInteractionManager.cancel(event); } } /* -------------------------------------------- */ /** * Move the Token along the measured path when the move key is pressed. * @param {KeyboardEventContext} context * @protected * @internal */ _onMoveKeyDown(context) { if ( this.token ) this.token.document.locked = this.token.document._source.locked; // noinspection ES6MissingAwait this.moveToken(); if ( this.state !== Ruler.STATES.MEASURING ) canvas.mouseInteractionManager.cancel(); } } /** * A layer of background alteration effects which change the appearance of the primary group render texture. * @category - Canvas */ class CanvasBackgroundAlterationEffects extends CanvasLayer { constructor() { super(); /** * A collection of effects which provide background vision alterations. * @type {PIXI.Container} */ this.vision = this.addChild(new PIXI.Container()); this.vision.sortableChildren = true; /** * A collection of effects which provide background preferred vision alterations. * @type {PIXI.Container} */ this.visionPreferred = this.addChild(new PIXI.Container()); this.visionPreferred.sortableChildren = true; /** * A collection of effects which provide other background alterations. * @type {PIXI.Container} */ this.lighting = this.addChild(new PIXI.Container()); this.lighting.sortableChildren = true; } /* -------------------------------------------- */ /** @override */ async _draw(options) { // Add the background vision filter const vf = this.vision.filter = new VoidFilter(); vf.blendMode = PIXI.BLEND_MODES.NORMAL; vf.enabled = false; this.vision.filters = [vf]; this.vision.filterArea = canvas.app.renderer.screen; // Add the background preferred vision filter const vpf = this.visionPreferred.filter = new VoidFilter(); vpf.blendMode = PIXI.BLEND_MODES.NORMAL; vpf.enabled = false; this.visionPreferred.filters = [vpf]; this.visionPreferred.filterArea = canvas.app.renderer.screen; // Add the background lighting filter const maskingFilter = CONFIG.Canvas.visualEffectsMaskingFilter; const lf = this.lighting.filter = maskingFilter.create({ visionTexture: canvas.masks.vision.renderTexture, darknessLevelTexture: canvas.effects.illumination.renderTexture, mode: maskingFilter.FILTER_MODES.BACKGROUND }); lf.blendMode = PIXI.BLEND_MODES.NORMAL; this.lighting.filters = [lf]; this.lighting.filterArea = canvas.app.renderer.screen; canvas.effects.visualEffectsMaskingFilters.add(lf); } /* -------------------------------------------- */ /** @override */ async _tearDown(options) { canvas.effects.visualEffectsMaskingFilters.delete(this.lighting?.filter); this.clear(); } /* -------------------------------------------- */ /** * Clear background alteration effects vision and lighting containers */ clear() { this.vision.removeChildren(); this.visionPreferred.removeChildren(); this.lighting.removeChildren(); } } /** * A CanvasLayer for displaying coloration visual effects * @category - Canvas */ class CanvasColorationEffects extends CanvasLayer { constructor() { super(); this.sortableChildren = true; this.#background = this.addChild(new PIXI.LegacyGraphics()); this.#background.zIndex = -Infinity; } /** * Temporary solution for the "white scene" bug (foundryvtt/foundryvtt#9957). * @type {PIXI.LegacyGraphics} */ #background; /** * The filter used to mask visual effects on this layer * @type {VisualEffectsMaskingFilter} */ filter; /* -------------------------------------------- */ /** * Clear coloration effects container */ clear() { this.removeChildren(); this.addChild(this.#background); } /* -------------------------------------------- */ /** @override */ async _draw(options) { const maskingFilter = CONFIG.Canvas.visualEffectsMaskingFilter; this.filter = maskingFilter.create({ visionTexture: canvas.masks.vision.renderTexture, darknessLevelTexture: canvas.effects.illumination.renderTexture, mode: maskingFilter.FILTER_MODES.COLORATION }); this.filter.blendMode = PIXI.BLEND_MODES.ADD; this.filterArea = canvas.app.renderer.screen; this.filters = [this.filter]; canvas.effects.visualEffectsMaskingFilters.add(this.filter); this.#background.clear().beginFill().drawShape(canvas.dimensions.rect).endFill(); } /* -------------------------------------------- */ /** @override */ async _tearDown(options) { canvas.effects.visualEffectsMaskingFilters.delete(this.filter); this.#background.clear(); } } /** * A layer of background alteration effects which change the appearance of the primary group render texture. * @category - Canvas */ class CanvasDarknessEffects extends CanvasLayer { constructor() { super(); this.sortableChildren = true; } /* -------------------------------------------- */ /** * Clear coloration effects container */ clear() { this.removeChildren(); } /* -------------------------------------------- */ /** @override */ async _draw(options) { this.filter = VoidFilter.create(); this.filter.blendMode = PIXI.BLEND_MODES.NORMAL; this.filterArea = canvas.app.renderer.screen; this.filters = [this.filter]; } } /** * A CanvasLayer for displaying illumination visual effects * @category - Canvas */ class CanvasIlluminationEffects extends CanvasLayer { constructor() { super(); this.#initialize(); } /** * The filter used to mask visual effects on this layer * @type {VisualEffectsMaskingFilter} */ filter; /** * The container holding the lights. * @type {PIXI.Container} */ lights = new PIXI.Container(); /** * A minimalist texture that holds the background color. * @type {PIXI.Texture} */ backgroundColorTexture; /** * The background color rgb array. * @type {number[]} */ #backgroundColorRGB; /** * The base line mesh. * @type {SpriteMesh} */ baselineMesh = new SpriteMesh(); /** * The cached container holding the illumination meshes. * @type {CachedContainer} */ darknessLevelMeshes = new DarknessLevelContainer(); /* -------------------------------------------- */ /** * To know if dynamic darkness level is active on this scene. * @returns {boolean} */ get hasDynamicDarknessLevel() { return this.darknessLevelMeshes.children.length > 0; } /** * The illumination render texture. * @returns {PIXI.RenderTexture} */ get renderTexture() { return this.darknessLevelMeshes.renderTexture; } /* -------------------------------------------- */ /** * Initialize the layer. */ #initialize() { // Configure background color texture this.backgroundColorTexture = this._createBackgroundColorTexture(); // Configure the base line mesh this.baselineMesh.setShaderClass(BaselineIlluminationSamplerShader); this.baselineMesh.texture = this.darknessLevelMeshes.renderTexture; // Add children canvas.masks.addChild(this.darknessLevelMeshes); // Region meshes cached container this.addChild(this.lights); // Light and vision illumination // Add baseline rendering for light const originalRender = this.lights.render; const baseMesh = this.baselineMesh; this.lights.render = renderer => { baseMesh.render(renderer); originalRender.call(this.lights, renderer); }; // Configure this.lights.sortableChildren = true; } /* -------------------------------------------- */ /** * Set or retrieve the illumination background color. * @param {number} color */ set backgroundColor(color) { const cb = this.#backgroundColorRGB = Color.from(color).rgb; if ( this.filter ) this.filter.uniforms.replacementColor = cb; this.backgroundColorTexture.baseTexture.resource.data.set(cb); this.backgroundColorTexture.baseTexture.resource.update(); } /* -------------------------------------------- */ /** * Clear illumination effects container */ clear() { this.lights.removeChildren(); } /* -------------------------------------------- */ /** * Invalidate the cached container state to trigger a render pass. * @param {boolean} [force=false] Force cached container invalidation? */ invalidateDarknessLevelContainer(force=false) { // If global light is enabled, the darkness level texture is affecting the vision mask if ( canvas.environment.globalLightSource.active ) canvas.masks.vision.renderDirty = true; if ( !(this.hasDynamicDarknessLevel || force) ) return; this.darknessLevelMeshes.renderDirty = true; // Sort by adjusted darkness level in descending order such that the final darkness level // at a point is the minimum of the adjusted darkness levels const compare = (a, b) => b.shader.darknessLevel - a.shader.darknessLevel; this.darknessLevelMeshes.children.sort(compare); canvas.visibility.vision.light.global.meshes.children.sort(compare); } /* -------------------------------------------- */ /** * Create the background color texture used by illumination point source meshes. * 1x1 single pixel texture. * @returns {PIXI.Texture} The background color texture. * @protected */ _createBackgroundColorTexture() { return PIXI.Texture.fromBuffer(new Float32Array(3), 1, 1, { type: PIXI.TYPES.FLOAT, format: PIXI.FORMATS.RGB, wrapMode: PIXI.WRAP_MODES.CLAMP, scaleMode: PIXI.SCALE_MODES.NEAREST, mipmap: PIXI.MIPMAP_MODES.OFF }); } /* -------------------------------------------- */ /** @override */ render(renderer) { // Prior blend mode is reinitialized. The first render into PointSourceMesh will use the background color texture. PointSourceMesh._priorBlendMode = undefined; PointSourceMesh._currentTexture = this.backgroundColorTexture; super.render(renderer); } /* -------------------------------------------- */ /** @override */ async _draw(options) { const maskingFilter = CONFIG.Canvas.visualEffectsMaskingFilter; this.darknessLevel = canvas.darknessLevel; this.filter = maskingFilter.create({ visionTexture: canvas.masks.vision.renderTexture, darknessLevelTexture: canvas.effects.illumination.renderTexture, mode: maskingFilter.FILTER_MODES.ILLUMINATION }); this.filter.blendMode = PIXI.BLEND_MODES.MULTIPLY; this.filterArea = canvas.app.renderer.screen; this.filters = [this.filter]; canvas.effects.visualEffectsMaskingFilters.add(this.filter); } /* -------------------------------------------- */ /** @override */ async _tearDown(options) { canvas.effects.visualEffectsMaskingFilters.delete(this.filter); this.clear(); } /* -------------------------------------------- */ /* Deprecations */ /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ updateGlobalLight() { const msg = "CanvasIlluminationEffects#updateGlobalLight has been deprecated."; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return false; } /** * @deprecated since v12 * @ignore */ background() { const msg = "CanvasIlluminationEffects#background is now obsolete."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return null; } /** * @deprecated since v12 * @ignore */ get globalLight() { const msg = "CanvasIlluminationEffects#globalLight has been deprecated without replacement. Check the" + "canvas.environment.globalLightSource.active instead."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return canvas.environment.globalLightSource.active; } } /** * Cached container used for dynamic darkness level. Display objects (of any type) added to this cached container will * contribute to computing the darkness level of the masked area. Only the red channel is utilized, which corresponds * to the desired darkness level. Other channels are ignored. */ class DarknessLevelContainer extends CachedContainer { constructor(...args) { super(...args); this.autoRender = false; this.on("childAdded", this.#onChildChange); this.on("childRemoved", this.#onChildChange); } /** @override */ static textureConfiguration = { scaleMode: PIXI.SCALE_MODES.NEAREST, format: PIXI.FORMATS.RED, multisample: PIXI.MSAA_QUALITY.NONE, mipmap: PIXI.MIPMAP_MODES.OFF }; /** * Called when a display object is added or removed from this container. */ #onChildChange() { this.autoRender = this.children.length > 0; this.renderDirty = true; canvas.perception.update({refreshVisionSources: true, refreshLightSources: true}); } } // noinspection JSPrimitiveTypeWrapperUsage /** * The visibility Layer which implements dynamic vision, lighting, and fog of war * This layer uses an event-driven workflow to perform the minimal required calculation in response to changes. * @see {@link PointSource} * * ### Hook Events * - {@link hookEvents.visibilityRefresh} * * @category - Canvas */ class CanvasVisibility extends CanvasLayer { /** * The currently revealed vision. * @type {CanvasVisionContainer} */ vision; /** * The exploration container which tracks exploration progress. * @type {PIXI.Container} */ explored; /** * The optional visibility overlay sprite that should be drawn instead of the unexplored color in the fog of war. * @type {PIXI.Sprite} */ visibilityOverlay; /** * The graphics used to render cached light sources. * @type {PIXI.LegacyGraphics} */ #cachedLights = new PIXI.LegacyGraphics(); /** * Matrix used for visibility rendering transformation. * @type {PIXI.Matrix} */ #renderTransform = new PIXI.Matrix(); /** * Dimensions of the visibility overlay texture and base texture used for tiling texture into the visibility filter. * @type {number[]} */ #visibilityOverlayDimensions; /** * The active vision source data object * @type {{source: VisionSource|null, activeLightingOptions: object}} */ visionModeData = { source: undefined, activeLightingOptions: {} }; /** * Define whether each lighting layer is enabled, required, or disabled by this vision mode. * The value for each lighting channel is a number in LIGHTING_VISIBILITY * @type {{illumination: number, background: number, coloration: number, * darkness: number, any: boolean}} */ lightingVisibility = { background: VisionMode.LIGHTING_VISIBILITY.ENABLED, illumination: VisionMode.LIGHTING_VISIBILITY.ENABLED, coloration: VisionMode.LIGHTING_VISIBILITY.ENABLED, darkness: VisionMode.LIGHTING_VISIBILITY.ENABLED, any: true }; /** * The map with the active cached light source IDs as keys and their update IDs as values. * @type {Map} */ #cachedLightSourceStates = new Map(); /** * The maximum allowable visibility texture size. * @type {number} */ static #MAXIMUM_VISIBILITY_TEXTURE_SIZE = 4096; /* -------------------------------------------- */ /* Canvas Visibility Properties */ /* -------------------------------------------- */ /** * A status flag for whether the layer initialization workflow has succeeded. * @type {boolean} */ get initialized() { return this.#initialized; } #initialized = false; /* -------------------------------------------- */ /** * Indicates whether containment filtering is required when rendering vision into a texture. * @type {boolean} * @internal */ get needsContainment() { return this.#needsContainment; } #needsContainment = false; /* -------------------------------------------- */ /** * Does the currently viewed Scene support Token field of vision? * @type {boolean} */ get tokenVision() { return canvas.scene.tokenVision; } /* -------------------------------------------- */ /** * The configured options used for the saved fog-of-war texture. * @type {FogTextureConfiguration} */ get textureConfiguration() { return this.#textureConfiguration; } /** @private */ #textureConfiguration; /* -------------------------------------------- */ /** * Optional overrides for exploration sprite dimensions. * @type {FogTextureConfiguration} */ set explorationRect(rect) { this.#explorationRect = rect; } /** @private */ #explorationRect; /* -------------------------------------------- */ /* Layer Initialization */ /* -------------------------------------------- */ /** * Initialize all Token vision sources which are present on this layer */ initializeSources() { canvas.effects.toggleMaskingFilters(false); // Deactivate vision masking before destroying textures for ( const source of canvas.effects.visionSources ) source.initialize(); Hooks.callAll("initializeVisionSources", canvas.effects.visionSources); } /* -------------------------------------------- */ /** * Initialize the vision mode. */ initializeVisionMode() { this.visionModeData.source = this.#getSingleVisionSource(); this.#configureLightingVisibility(); this.#updateLightingPostProcessing(); this.#updateTintPostProcessing(); Hooks.callAll("initializeVisionMode", this); } /* -------------------------------------------- */ /** * Identify whether there is one singular vision source active (excluding previews). * @returns {VisionSource|null} A singular source, or null */ #getSingleVisionSource() { return canvas.effects.visionSources.filter(s => s.active).sort((a, b) => (a.isPreview - b.isPreview) || (a.isBlinded - b.isBlinded) || (b.visionMode.perceivesLight - a.visionMode.perceivesLight) ).at(0) ?? null; } /* -------------------------------------------- */ /** * Configure the visibility of individual lighting channels based on the currently active vision source(s). */ #configureLightingVisibility() { const vs = this.visionModeData.source; const vm = vs?.visionMode; const lv = this.lightingVisibility; const lvs = VisionMode.LIGHTING_VISIBILITY; Object.assign(lv, { background: CanvasVisibility.#requireBackgroundShader(vm), illumination: vm?.lighting.illumination.visibility ?? lvs.ENABLED, coloration: vm?.lighting.coloration.visibility ?? lvs.ENABLED, darkness: vm?.lighting.darkness.visibility ?? lvs.ENABLED }); lv.any = (lv.background + lv.illumination + lv.coloration + lv.darkness) > VisionMode.LIGHTING_VISIBILITY.DISABLED; } /* -------------------------------------------- */ /** * Update the lighting according to vision mode options. */ #updateLightingPostProcessing() { // Check whether lighting configuration has changed const lightingOptions = this.visionModeData.source?.visionMode.lighting || {}; const diffOpt = foundry.utils.diffObject(this.visionModeData.activeLightingOptions, lightingOptions); this.visionModeData.activeLightingOptions = lightingOptions; if ( foundry.utils.isEmpty(lightingOptions) ) canvas.effects.resetPostProcessingFilters(); if ( foundry.utils.isEmpty(diffOpt) ) return; // Update post-processing filters and refresh lighting const modes = CONFIG.Canvas.visualEffectsMaskingFilter.FILTER_MODES; canvas.effects.resetPostProcessingFilters(); for ( const layer of ["background", "illumination", "coloration"] ) { if ( layer in lightingOptions ) { const options = lightingOptions[layer]; const filterMode = modes[layer.toUpperCase()]; canvas.effects.activatePostProcessingFilters(filterMode, options.postProcessingModes, options.uniforms); } } } /* -------------------------------------------- */ /** * Refresh the tint of the post processing filters. */ #updateTintPostProcessing() { // Update tint const activeOptions = this.visionModeData.activeLightingOptions; const singleSource = this.visionModeData.source; const color = singleSource?.visionModeOverrides.colorRGB; for ( const f of canvas.effects.visualEffectsMaskingFilters ) { const defaultTint = f.constructor.defaultUniforms.tint; const tintedLayer = activeOptions[f.uniforms.mode]?.uniforms?.tint; f.uniforms.tint = tintedLayer ? (color ?? (tintedLayer ?? defaultTint)) : defaultTint; } } /* -------------------------------------------- */ /** * Give the visibility requirement of the lighting background shader. * @param {VisionMode} visionMode The single Vision Mode active at the moment (if any). * @returns {VisionMode.LIGHTING_VISIBILITY} */ static #requireBackgroundShader(visionMode) { // Do we need to force lighting background shader? Force when : // - Multiple vision modes are active with a mix of preferred and non preferred visions // - Or when some have background shader required const lvs = VisionMode.LIGHTING_VISIBILITY; let preferred = false; let nonPreferred = false; for ( const vs of canvas.effects.visionSources ) { if ( !vs.active ) continue; const vm = vs.visionMode; if ( vm.lighting.background.visibility === lvs.REQUIRED ) return lvs.REQUIRED; if ( vm.vision.preferred ) preferred = true; else nonPreferred = true; } if ( preferred && nonPreferred ) return lvs.REQUIRED; return visionMode?.lighting.background.visibility ?? lvs.ENABLED; } /* -------------------------------------------- */ /* Layer Rendering */ /* -------------------------------------------- */ /** @override */ async _draw(options) { this.#configureVisibilityTexture(); // Initialize fog await canvas.fog.initialize(); // Create the vision container and attach it to the CanvasVisionMask cached container this.vision = this.#createVision(); canvas.masks.vision.attachVision(this.vision); this.#cacheLights(true); // Exploration container this.explored = this.addChild(this.#createExploration()); // Loading the fog overlay await this.#drawVisibilityOverlay(); // Apply the visibility filter with a normal blend this.filter = CONFIG.Canvas.visibilityFilter.create({ unexploredColor: canvas.colors.fogUnexplored.rgb, exploredColor: canvas.colors.fogExplored.rgb, backgroundColor: canvas.colors.background.rgb, visionTexture: canvas.masks.vision.renderTexture, primaryTexture: canvas.primary.renderTexture, overlayTexture: this.visibilityOverlay?.texture ?? null, dimensions: this.#visibilityOverlayDimensions, hasOverlayTexture: !!this.visibilityOverlay?.texture.valid }, canvas.visibilityOptions); this.filter.blendMode = PIXI.BLEND_MODES.NORMAL; this.filters = [this.filter]; this.filterArea = canvas.app.screen; // Add the visibility filter to the canvas blur filter list canvas.addBlurFilter(this.filter); this.visible = false; this.#initialized = true; } /* -------------------------------------------- */ /** * Create the exploration container with its exploration sprite. * @returns {PIXI.Container} The newly created exploration container. */ #createExploration() { const dims = canvas.dimensions; const explored = new PIXI.Container(); const explorationSprite = explored.addChild(canvas.fog.sprite); const exr = this.#explorationRect; // Check if custom exploration dimensions are required if ( exr ) { explorationSprite.position.set(exr.x, exr.y); explorationSprite.width = exr.width; explorationSprite.height = exr.height; } // Otherwise, use the standard behavior else { explorationSprite.position.set(dims.sceneX, dims.sceneY); explorationSprite.width = this.#textureConfiguration.width; explorationSprite.height = this.#textureConfiguration.height; } return explored; } /* -------------------------------------------- */ /** * Create the vision container and all its children. * @returns {PIXI.Container} The created vision container. */ #createVision() { const dims = canvas.dimensions; const vision = new PIXI.Container(); // Adding a void filter necessary when commiting fog on a texture for dynamic illumination vision.containmentFilter = VoidFilter.create(); vision.containmentFilter.blendMode = PIXI.BLEND_MODES.MAX_COLOR; vision.containmentFilter.enabled = false; // Disabled by default, used only when writing on textures vision.filters = [vision.containmentFilter]; // Areas visible because of light sources and light perception vision.light = vision.addChild(new PIXI.Container()); // The global light container, which hold darkness level meshes for dynamic illumination vision.light.global = vision.light.addChild(new PIXI.Container()); vision.light.global.source = vision.light.global.addChild(new PIXI.LegacyGraphics()); vision.light.global.meshes = vision.light.global.addChild(new PIXI.Container()); vision.light.global.source.blendMode = PIXI.BLEND_MODES.MAX_COLOR; // The light sources vision.light.sources = vision.light.addChild(new PIXI.LegacyGraphics()); vision.light.sources.blendMode = PIXI.BLEND_MODES.MAX_COLOR; // Preview container, which is not cached vision.light.preview = vision.light.addChild(new PIXI.LegacyGraphics()); vision.light.preview.blendMode = PIXI.BLEND_MODES.MAX_COLOR; // The cached light to avoid too many geometry drawings vision.light.cached = vision.light.addChild(new SpriteMesh(Canvas.getRenderTexture({ textureConfiguration: this.textureConfiguration }))); vision.light.cached.position.set(dims.sceneX, dims.sceneY); vision.light.cached.blendMode = PIXI.BLEND_MODES.MAX_COLOR; // The masked area vision.light.mask = vision.light.addChild(new PIXI.LegacyGraphics()); vision.light.mask.preview = vision.light.mask.addChild(new PIXI.LegacyGraphics()); // Areas visible because of FOV of vision sources vision.sight = vision.addChild(new PIXI.LegacyGraphics()); vision.sight.blendMode = PIXI.BLEND_MODES.MAX_COLOR; vision.sight.preview = vision.sight.addChild(new PIXI.LegacyGraphics()); vision.sight.preview.blendMode = PIXI.BLEND_MODES.MAX_COLOR; // Eraser for darkness sources vision.darkness = vision.addChild(new PIXI.LegacyGraphics()); vision.darkness.blendMode = PIXI.BLEND_MODES.ERASE; /** @deprecated since v12 */ Object.defineProperty(vision, "base", { get() { const msg = "CanvasVisibility#vision#base is deprecated in favor of CanvasVisibility#vision#light#preview."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.fov.preview; } }); /** @deprecated since v12 */ Object.defineProperty(vision, "fov", { get() { const msg = "CanvasVisibility#vision#fov is deprecated in favor of CanvasVisibility#vision#light."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.light; } }); /** @deprecated since v12 */ Object.defineProperty(vision, "los", { get() { const msg = "CanvasVisibility#vision#los is deprecated in favor of CanvasVisibility#vision#light#mask."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.light.mask; } }); /** @deprecated since v12 */ Object.defineProperty(vision.light, "lights", { get: () => { const msg = "CanvasVisibility#vision#fov#lights is deprecated without replacement."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.#cachedLights; } }); /** @deprecated since v12 */ Object.defineProperty(vision.light, "lightsSprite", { get() { const msg = "CanvasVisibility#vision#fov#lightsSprite is deprecated in favor of CanvasVisibility#vision#light#cached."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.cached; } }); /** @deprecated since v12 */ Object.defineProperty(vision.light, "tokens", { get() { const msg = "CanvasVisibility#vision#tokens is deprecated in favor of CanvasVisibility#vision#light."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this; } }); return vision; } /* -------------------------------------------- */ /** @inheritDoc */ async _tearDown(options) { canvas.masks.vision.detachVision(); this.#cachedLightSourceStates.clear(); await canvas.fog.clear(); // Performs deep cleaning of the detached vision container this.vision.destroy({children: true, texture: true, baseTexture: true}); this.vision = undefined; canvas.effects.visionSources.clear(); this.#initialized = false; return super._tearDown(options); } /* -------------------------------------------- */ /** * Update the display of the sight layer. * Organize sources into rendering queues and draw lighting containers for each source */ refresh() { if ( !this.initialized ) return; // Refresh visibility if ( this.tokenVision ) { this.refreshVisibility(); this.visible = canvas.effects.visionSources.some(s => s.active) || !game.user.isGM; } else this.visible = false; // Update visibility of objects this.restrictVisibility(); } /* -------------------------------------------- */ /** * Update vision (and fog if necessary) */ refreshVisibility() { canvas.masks.vision.renderDirty = true; if ( !this.vision ) return; const vision = this.vision; // Begin fills const fillColor = 0xFF0000; this.#cachedLights.beginFill(fillColor); vision.light.sources.clear().beginFill(fillColor); vision.light.preview.clear().beginFill(fillColor); vision.light.global.source.clear().beginFill(fillColor); vision.light.mask.clear().beginFill(); vision.light.mask.preview.clear().beginFill(); vision.sight.clear().beginFill(fillColor); vision.sight.preview.clear().beginFill(fillColor); vision.darkness.clear().beginFill(fillColor); // Checking if the lights cache needs a full redraw const redrawCache = this.#checkCachedLightSources(); if ( redrawCache ) this.#cachedLightSourceStates.clear(); // A flag to know if the lights cache render texture need to be refreshed let refreshCache = redrawCache; // A flag to know if fog need to be refreshed. let commitFog = false; // Iterating over each active light source for ( const [sourceId, lightSource] of canvas.effects.lightSources.entries() ) { // Ignoring inactive sources or global light (which is rendered using the global light mesh) if ( !lightSource.hasActiveLayer || (lightSource instanceof foundry.canvas.sources.GlobalLightSource) ) continue; // Is the light source providing vision? if ( lightSource.data.vision ) { if ( lightSource.isPreview ) vision.light.mask.preview.drawShape(lightSource.shape); else { vision.light.mask.drawShape(lightSource.shape); commitFog = true; } } // Update the cached state. Skip if already cached. const isCached = this.#shouldCacheLight(lightSource); if ( isCached ) { if ( this.#cachedLightSourceStates.has(sourceId) ) continue; this.#cachedLightSourceStates.set(sourceId, lightSource.updateId); refreshCache = true; } // Draw the light source if ( isCached ) this.#cachedLights.drawShape(lightSource.shape); else if ( lightSource.isPreview ) vision.light.preview.drawShape(lightSource.shape); else vision.light.sources.drawShape(lightSource.shape); } // Refresh the light source cache if necessary. // Note: With a full redraw, we need to refresh the texture cache, even if no elements are present if ( refreshCache ) this.#cacheLights(redrawCache); // Refresh global/dynamic illumination with global source and illumination meshes this.#refreshDynamicIllumination(); // Iterating over each active vision source for ( const visionSource of canvas.effects.visionSources ) { if ( !visionSource.hasActiveLayer ) continue; const blinded = visionSource.isBlinded; // Draw vision FOV if ( (visionSource.radius > 0) && !blinded && !visionSource.isPreview ) { vision.sight.drawShape(visionSource.shape); commitFog = true; } else vision.sight.preview.drawShape(visionSource.shape); // Draw light perception if ( (visionSource.lightRadius > 0) && !blinded && !visionSource.isPreview ) { vision.light.mask.drawShape(visionSource.light); commitFog = true; } else vision.light.mask.preview.drawShape(visionSource.light); } // Call visibility refresh hook Hooks.callAll("visibilityRefresh", this); // End fills vision.light.sources.endFill(); vision.light.preview.endFill(); vision.light.global.source.endFill(); vision.light.mask.endFill(); vision.light.mask.preview.endFill(); vision.sight.endFill(); vision.sight.preview.endFill(); vision.darkness.endFill(); // Update fog of war texture (if fow is activated) if ( commitFog ) canvas.fog.commit(); } /* -------------------------------------------- */ /** * Reset the exploration container with the fog sprite */ resetExploration() { if ( !this.explored ) return; this.explored.destroy(); this.explored = this.addChild(this.#createExploration()); } /* -------------------------------------------- */ /** * Refresh the dynamic illumination with darkness level meshes and global light. * Tell if a fence filter is needed when vision is rendered into a texture. */ #refreshDynamicIllumination() { // Reset filter containment this.#needsContainment = false; // Setting global light source container visibility const globalLightSource = canvas.environment.globalLightSource; const v = this.vision.light.global.visible = globalLightSource.active; if ( !v ) return; const {min, max} = globalLightSource.data.darkness; // Draw the global source if necessary const darknessLevel = canvas.environment.darknessLevel; if ( (darknessLevel >= min) && (darknessLevel <= max) ) { this.vision.light.global.source.drawShape(globalLightSource.shape); } // Then draw dynamic illumination meshes const illuminationMeshes = this.vision.light.global.meshes.children; for ( const mesh of illuminationMeshes ) { const darknessLevel = mesh.shader.darknessLevel; if ( (darknessLevel < min) || (darknessLevel > max)) { mesh.blendMode = PIXI.BLEND_MODES.ERASE; this.#needsContainment = true; } else mesh.blendMode = PIXI.BLEND_MODES.MAX_COLOR; } } /* -------------------------------------------- */ /** * Returns true if the light source should be cached. * @param {LightSource} lightSource The light source * @returns {boolean} */ #shouldCacheLight(lightSource) { return !(lightSource.object instanceof Token) && !lightSource.isPreview; } /* -------------------------------------------- */ /** * Check if the cached light sources need to be fully redrawn. * @returns {boolean} True if a full redraw is necessary. */ #checkCachedLightSources() { for ( const [sourceId, updateId] of this.#cachedLightSourceStates ) { const lightSource = canvas.effects.lightSources.get(sourceId); if ( !lightSource || !lightSource.active || !this.#shouldCacheLight(lightSource) || (updateId !== lightSource.updateId) ) return true; } return false; } /* -------------------------------------------- */ /** * Render `this.#cachedLights` into `this.vision.light.cached.texture`. * Note: A full cache redraw needs the texture to be cleared. * @param {boolean} clearTexture If the texture need to be cleared before rendering. */ #cacheLights(clearTexture) { const dims = canvas.dimensions; this.#renderTransform.tx = -dims.sceneX; this.#renderTransform.ty = -dims.sceneY; this.#cachedLights.blendMode = PIXI.BLEND_MODES.MAX_COLOR; canvas.app.renderer.render(this.#cachedLights, { renderTexture: this.vision.light.cached.texture, clear: clearTexture, transform: this.#renderTransform }); this.#cachedLights.clear(); } /* -------------------------------------------- */ /* Visibility Testing */ /* -------------------------------------------- */ /** * Restrict the visibility of certain canvas assets (like Tokens or DoorControls) based on the visibility polygon * These assets should only be displayed if they are visible given the current player's field of view */ restrictVisibility() { // Activate or deactivate visual effects vision masking canvas.effects.toggleMaskingFilters(this.visible); // Tokens & Notes const flags = {refreshVisibility: true}; for ( const token of canvas.tokens.placeables ) token.renderFlags.set(flags); for ( const note of canvas.notes.placeables ) note.renderFlags.set(flags); // Door Icons for ( const door of canvas.controls.doors.children ) door.visible = door.isVisible; Hooks.callAll("sightRefresh", this); } /* -------------------------------------------- */ /** * @typedef {Object} CanvasVisibilityTestConfig * @property {object|null} object The target object * @property {CanvasVisibilityTest[]} tests An array of visibility tests */ /** * @typedef {Object} CanvasVisibilityTest * @property {Point} point * @property {number} elevation * @property {Map} los */ /** * Test whether a target point on the Canvas is visible based on the current vision and LOS polygons. * @param {Point} point The point in space to test, an object with coordinates x and y. * @param {object} [options] Additional options which modify visibility testing. * @param {number} [options.tolerance=2] A numeric radial offset which allows for a non-exact match. * For example, if tolerance is 2 then the test will pass if the point * is within 2px of a vision polygon. * @param {object|null} [options.object] An optional reference to the object whose visibility is being tested * @returns {boolean} Whether the point is currently visible. */ testVisibility(point, options={}) { // If no vision sources are present, the visibility is dependant of the type of user if ( !canvas.effects.visionSources.some(s => s.active) ) return game.user.isGM; // Prepare an array of test points depending on the requested tolerance const object = options.object ?? null; const config = this._createVisibilityTestConfig(point, options); // First test basic detection for light sources which specifically provide vision for ( const lightSource of canvas.effects.lightSources ) { if ( !lightSource.data.vision || !lightSource.active ) continue; const result = lightSource.testVisibility(config); if ( result === true ) return true; } // Get scene rect to test that some points are not detected into the padding const sr = canvas.dimensions.sceneRect; const inBuffer = !sr.contains(point.x, point.y); // Skip sources that are not both inside the scene or both inside the buffer const activeVisionSources = canvas.effects.visionSources.filter(s => s.active && (inBuffer !== sr.contains(s.x, s.y))); const modes = CONFIG.Canvas.detectionModes; // Second test Basic Sight and Light Perception tests for vision sources for ( const visionSource of activeVisionSources ) { if ( visionSource.isBlinded ) continue; const token = visionSource.object.document; const basicMode = token.detectionModes.find(m => m.id === "basicSight"); if ( basicMode ) { const result = modes.basicSight.testVisibility(visionSource, basicMode, config); if ( result === true ) return true; } const lightMode = token.detectionModes.find(m => m.id === "lightPerception"); if ( lightMode ) { const result = modes.lightPerception.testVisibility(visionSource, lightMode, config); if ( result === true ) return true; } } // Special detection modes can only detect tokens if ( !(object instanceof Token) ) return false; // Lastly test special detection modes for vision sources for ( const visionSource of activeVisionSources ) { const token = visionSource.object.document; for ( const mode of token.detectionModes ) { if ( (mode.id === "basicSight") || (mode.id === "lightPerception") ) continue; const dm = modes[mode.id]; const result = dm?.testVisibility(visionSource, mode, config); if ( result === true ) { object.detectionFilter = dm.constructor.getDetectionFilter(); return true; } } } return false; } /* -------------------------------------------- */ /** * Create the visibility test config. * @param {Point} point The point in space to test, an object with coordinates x and y. * @param {object} [options] Additional options which modify visibility testing. * @param {number} [options.tolerance=2] A numeric radial offset which allows for a non-exact match. * For example, if tolerance is 2 then the test will pass if the point * is within 2px of a vision polygon. * @param {object|null} [options.object] An optional reference to the object whose visibility is being tested * @returns {CanvasVisibilityTestConfig} * @internal */ _createVisibilityTestConfig(point, {tolerance=2, object=null}={}) { const t = tolerance; const offsets = t > 0 ? [[0, 0], [-t, -t], [-t, t], [t, t], [t, -t], [-t, 0], [t, 0], [0, -t], [0, t]] : [[0, 0]]; const elevation = object instanceof Token ? object.document.elevation : 0; return { object, tests: offsets.map(o => ({ point: {x: point.x + o[0], y: point.y + o[1]}, elevation, los: new Map() })) }; } /* -------------------------------------------- */ /* Visibility Overlay and Texture management */ /* -------------------------------------------- */ /** * Load the scene fog overlay if provided and attach the fog overlay sprite to this layer. */ async #drawVisibilityOverlay() { this.visibilityOverlay = undefined; this.#visibilityOverlayDimensions = []; const overlaySrc = canvas.sceneTextures.fogOverlay ?? canvas.scene.fog.overlay; const overlayTexture = overlaySrc instanceof PIXI.Texture ? overlaySrc : getTexture(overlaySrc); if ( !overlayTexture ) return; // Creating the sprite and updating its base texture with repeating wrap mode const fo = this.visibilityOverlay = new PIXI.Sprite(overlayTexture); // Set dimensions and position according to overlay <-> scene foreground dimensions const bkg = canvas.primary.background; const baseTex = overlayTexture.baseTexture; if ( bkg && ((fo.width !== bkg.width) || (fo.height !== bkg.height)) ) { // Set to the size of the scene dimensions fo.width = canvas.scene.dimensions.width; fo.height = canvas.scene.dimensions.height; fo.position.set(0, 0); // Activate repeat wrap mode for this base texture (to allow tiling) baseTex.wrapMode = PIXI.WRAP_MODES.REPEAT; } else { // Set the same position and size as the scene primary background fo.width = bkg.width; fo.height = bkg.height; fo.position.set(bkg.x, bkg.y); } // The overlay is added to this canvas container to update its transforms only fo.renderable = false; this.addChild(this.visibilityOverlay); // Manage video playback const video = game.video.getVideoSource(overlayTexture); if ( video ) { const playOptions = {volume: 0}; game.video.play(video, playOptions); } // Passing overlay and base texture width and height for shader tiling calculations this.#visibilityOverlayDimensions = [fo.width, fo.height, baseTex.width, baseTex.height]; } /* -------------------------------------------- */ /** * @typedef {object} VisibilityTextureConfiguration * @property {number} resolution * @property {number} width * @property {number} height * @property {number} mipmap * @property {number} scaleMode * @property {number} multisample */ /** * Configure the fog texture will all required options. * 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 {VisibilityTextureConfiguration} * @private */ #configureVisibilityTexture() { const dims = canvas.dimensions; let width = dims.sceneWidth; let height = dims.sceneHeight; const maxSize = CanvasVisibility.#MAXIMUM_VISIBILITY_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 options return this.#textureConfiguration = { resolution, width, height, mipmap: PIXI.MIPMAP_MODES.OFF, multisample: PIXI.MSAA_QUALITY.NONE, scaleMode: PIXI.SCALE_MODES.LINEAR, alphaMode: PIXI.ALPHA_MODES.NPM, format: PIXI.FORMATS.RED }; } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ get fogOverlay() { const msg = "fogOverlay is deprecated in favor of visibilityOverlay"; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return this.visibilityOverlay; } } /** * A CanvasLayer for displaying visual effects like weather, transitions, flashes, or more. */ class WeatherEffects extends FullCanvasObjectMixin(CanvasLayer) { constructor() { super(); this.#initializeFilters(); this.mask = canvas.masks.scene; this.sortableChildren = true; this.eventMode = "none"; } /** * The container in which effects are added. * @type {PIXI.Container} */ weatherEffects; /* -------------------------------------------- */ /** * The container in which suppression meshed are added. * @type {PIXI.Container} */ suppression; /* -------------------------------------------- */ /** * Initialize the inverse occlusion and the void filters. */ #initializeFilters() { this.#suppressionFilter = VoidFilter.create(); this.occlusionFilter = WeatherOcclusionMaskFilter.create({ occlusionTexture: canvas.masks.depth.renderTexture }); this.#suppressionFilter.enabled = this.occlusionFilter.enabled = false; // FIXME: this does not produce correct results for weather effects that are configured // with the occlusion filter disabled and use a different blend mode than SCREEN this.#suppressionFilter.blendMode = PIXI.BLEND_MODES.SCREEN; this.occlusionFilter.elevation = this.#elevation; this.filterArea = canvas.app.renderer.screen; this.filters = [this.occlusionFilter, this.#suppressionFilter]; } /* -------------------------------------------- */ /** @inheritdoc */ static get layerOptions() { return foundry.utils.mergeObject(super.layerOptions, {name: "effects"}); } /* -------------------------------------------- */ /** * Array of weather effects linked to this weather container. * @type {Map} */ effects = new Map(); /** * @typedef {Object} WeatherTerrainMaskConfiguration * @property {boolean} enabled Enable or disable this mask. * @property {number[]} channelWeights An RGBA array of channel weights applied to the mask texture. * @property {boolean} [reverse=false] If the mask should be reversed. * @property {PIXI.Texture|PIXI.RenderTexture} texture A texture which defines the mask region. */ /** * A default configuration of the terrain mask that is automatically applied to any shader-based weather effects. * This configuration is automatically passed to WeatherShaderEffect#configureTerrainMask upon construction. * @type {WeatherTerrainMaskConfiguration} */ terrainMaskConfig; /** * @typedef {Object} WeatherOcclusionMaskConfiguration * @property {boolean} enabled Enable or disable this mask. * @property {number[]} channelWeights An RGBA array of channel weights applied to the mask texture. * @property {boolean} [reverse=false] If the mask should be reversed. * @property {PIXI.Texture|PIXI.RenderTexture} texture A texture which defines the mask region. */ /** * A default configuration of the terrain mask that is automatically applied to any shader-based weather effects. * This configuration is automatically passed to WeatherShaderEffect#configureTerrainMask upon construction. * @type {WeatherOcclusionMaskConfiguration} */ occlusionMaskConfig; /** * The inverse occlusion mask filter bound to this container. * @type {WeatherOcclusionMaskFilter} */ occlusionFilter; /** * The filter that is needed for suppression if the occlusion filter isn't enabled. * @type {VoidFilter} */ #suppressionFilter; /* -------------------------------------------- */ /** * The elevation of this object. * @type {number} * @default Infinity */ get elevation() { return this.#elevation; } set elevation(value) { if ( (typeof value !== "number") || Number.isNaN(value) ) { throw new Error("WeatherEffects#elevation must be a numeric value."); } if ( value === this.#elevation ) return; this.#elevation = value; if ( this.parent ) this.parent.sortDirty = true; } #elevation = Infinity; /* -------------------------------------------- */ /** * A key which resolves ties amongst objects at the same elevation of different layers. * @type {number} * @default PrimaryCanvasGroup.SORT_LAYERS.WEATHER */ get sortLayer() { return this.#sortLayer; } set sortLayer(value) { if ( (typeof value !== "number") || Number.isNaN(value) ) { throw new Error("WeatherEffects#sortLayer must be a numeric value."); } if ( value === this.#sortLayer ) return; this.#sortLayer = value; if ( this.parent ) this.parent.sortDirty = true; } #sortLayer = PrimaryCanvasGroup.SORT_LAYERS.WEATHER; /* -------------------------------------------- */ /** * A key which resolves ties amongst objects at the same elevation within the same layer. * @type {number} * @default 0 */ get sort() { return this.#sort; } set sort(value) { if ( (typeof value !== "number") || Number.isNaN(value) ) { throw new Error("WeatherEffects#sort must be a numeric value."); } if ( value === this.#sort ) return; this.#sort = value; if ( this.parent ) this.parent.sortDirty = true; } #sort = 0; /* -------------------------------------------- */ /** * A key which resolves ties amongst objects at the same elevation within the same layer and same sort. * @type {number} * @default 0 */ get zIndex() { return this._zIndex; } set zIndex(value) { if ( (typeof value !== "number") || Number.isNaN(value) ) { throw new Error("WeatherEffects#zIndex must be a numeric value."); } if ( value === this._zIndex ) return; this._zIndex = value; if ( this.parent ) this.parent.sortDirty = true; } /* -------------------------------------------- */ /* Weather Effect Rendering */ /* -------------------------------------------- */ /** @override */ async _draw(options) { const effect = CONFIG.weatherEffects[canvas.scene.weather]; this.weatherEffects = this.addChild(new PIXI.Container()); this.suppression = this.addChild(new PIXI.Container()); for ( const event of ["childAdded", "childRemoved"] ) { this.suppression.on(event, () => { this.#suppressionFilter.enabled = !this.occlusionFilter.enabled && !!this.suppression.children.length; }); } this.initializeEffects(effect); } /* -------------------------------------------- */ /** @inheritDoc */ async _tearDown(options) { this.clearEffects(); return super._tearDown(options); } /* -------------------------------------------- */ /* Weather Effect Management */ /* -------------------------------------------- */ /** * Initialize the weather container from a weather config object. * @param {object} [weatherEffectsConfig] Weather config object (or null/undefined to clear the container). */ initializeEffects(weatherEffectsConfig) { this.#destroyEffects(); Hooks.callAll("initializeWeatherEffects", this, weatherEffectsConfig); this.#constructEffects(weatherEffectsConfig); } /* -------------------------------------------- */ /** * Clear the weather container. */ clearEffects() { this.initializeEffects(null); } /* -------------------------------------------- */ /** * Destroy all effects associated with this weather container. */ #destroyEffects() { if ( this.effects.size === 0 ) return; for ( const effect of this.effects.values() ) effect.destroy(); this.effects.clear(); } /* -------------------------------------------- */ /** * Construct effects according to the weather effects config object. * @param {object} [weatherEffectsConfig] Weather config object (or null/undefined to clear the container). */ #constructEffects(weatherEffectsConfig) { if ( !weatherEffectsConfig ) { this.#suppressionFilter.enabled = this.occlusionFilter.enabled = false; return; } const effects = weatherEffectsConfig.effects; let zIndex = 0; // Enable a layer-wide occlusion filter unless it is explicitly disabled by the effect configuration const useOcclusionFilter = weatherEffectsConfig.filter?.enabled !== false; if ( useOcclusionFilter ) { WeatherEffects.configureOcclusionMask(this.occlusionFilter, this.occlusionMaskConfig || {enabled: true}); if ( this.terrainMaskConfig ) WeatherEffects.configureTerrainMask(this.occlusionFilter, this.terrainMaskConfig); this.occlusionFilter.blendMode = weatherEffectsConfig.filter?.blendMode ?? PIXI.BLEND_MODES.NORMAL; this.occlusionFilter.enabled = true; this.#suppressionFilter.enabled = false; } else { this.#suppressionFilter.enabled = !!this.suppression.children.length; } // Create each effect for ( const effect of effects ) { const requiredPerformanceLevel = Number.isNumeric(effect.performanceLevel) ? effect.performanceLevel : 0; if ( canvas.performance.mode < requiredPerformanceLevel ) { console.debug(`Skipping weather effect ${effect.id}. The client performance level ${canvas.performance.mode}` + ` is less than the required performance mode ${requiredPerformanceLevel} for the effect`); continue; } // Construct the effect container let ec; try { ec = new effect.effectClass(effect.config, effect.shaderClass); } catch(err) { err.message = `Failed to construct weather effect: ${err.message}`; console.error(err); continue; } // Configure effect container ec.zIndex = effect.zIndex ?? zIndex++; ec.blendMode = effect.blendMode ?? PIXI.BLEND_MODES.NORMAL; // Apply effect-level occlusion and terrain masking only if we are not using a layer-wide filter if ( effect.shaderClass && !useOcclusionFilter ) { WeatherEffects.configureOcclusionMask(ec.shader, this.occlusionMaskConfig || {enabled: true}); if ( this.terrainMaskConfig ) WeatherEffects.configureTerrainMask(ec.shader, this.terrainMaskConfig); } // Add to the layer, register the effect, and begin play this.weatherEffects.addChild(ec); this.effects.set(effect.id, ec); ec.play(); } } /* -------------------------------------------- */ /** * Set the occlusion uniforms for this weather shader. * @param {PIXI.Shader} context The shader context * @param {WeatherOcclusionMaskConfiguration} config Occlusion masking options * @protected */ static configureOcclusionMask(context, {enabled=false, channelWeights=[0, 0, 1, 0], reverse=false, texture}={}) { if ( !(context instanceof PIXI.Shader) ) return; const uniforms = context.uniforms; if ( texture !== undefined ) uniforms.occlusionTexture = texture; else uniforms.occlusionTexture ??= canvas.masks.depth.renderTexture; uniforms.useOcclusion = enabled; uniforms.occlusionWeights = channelWeights; uniforms.reverseOcclusion = reverse; if ( enabled && !uniforms.occlusionTexture ) { console.warn(`The occlusion configuration for the weather shader ${context.constructor.name} is enabled but` + " does not have a valid texture"); uniforms.useOcclusion = false; } } /* -------------------------------------------- */ /** * Set the terrain uniforms for this weather shader. * @param {PIXI.Shader} context The shader context * @param {WeatherTerrainMaskConfiguration} config Terrain masking options * @protected */ static configureTerrainMask(context, {enabled=false, channelWeights=[1, 0, 0, 0], reverse=false, texture}={}) { if ( !(context instanceof PIXI.Shader) ) return; const uniforms = context.uniforms; if ( texture !== undefined ) { uniforms.terrainTexture = texture; const terrainMatrix = new PIXI.TextureMatrix(texture); terrainMatrix.update(); uniforms.terrainUvMatrix.copyFrom(terrainMatrix.mapCoord); } uniforms.useTerrain = enabled; uniforms.terrainWeights = channelWeights; uniforms.reverseTerrain = reverse; if ( enabled && !uniforms.terrainTexture ) { console.warn(`The terrain configuration for the weather shader ${context.constructor.name} is enabled but` + " does not have a valid texture"); uniforms.useTerrain = false; } } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ get weather() { const msg = "The WeatherContainer at canvas.weather.weather is deprecated and combined with the layer itself."; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return this; } } /** * A special Graphics class which handles Grid layer highlighting * @extends {PIXI.Graphics} */ class GridHighlight extends PIXI.Graphics { constructor(name, ...args) { super(...args); /** * Track the Grid Highlight name * @type {string} */ this.name = name; /** * Track distinct positions which have already been highlighted * @type {Set} */ this.positions = new Set(); } /* -------------------------------------------- */ /** * Record a position that is highlighted and return whether or not it should be rendered * @param {number} x The x-coordinate to highlight * @param {number} y The y-coordinate to highlight * @return {boolean} Whether or not to draw the highlight for this location */ highlight(x, y) { let key = `${x},${y}`; if ( this.positions.has(key) ) return false; this.positions.add(key); return true; } /* -------------------------------------------- */ /** @inheritdoc */ clear() { this.positions = new Set(); return super.clear(); } /* -------------------------------------------- */ /** @inheritdoc */ destroy(...args) { delete canvas.interface.grid.highlightLayers[this.name]; return super.destroy(...args); } } /** * A CanvasLayer responsible for drawing a square grid */ class GridLayer extends CanvasLayer { /** * The grid mesh. * @type {GridMesh} */ mesh; /** * The Grid Highlight container * @type {PIXI.Container} */ highlight; /** * Map named highlight layers * @type {Record} */ highlightLayers = {}; /* -------------------------------------------- */ /** @inheritdoc */ static get layerOptions() { return foundry.utils.mergeObject(super.layerOptions, {name: "grid"}); } /* -------------------------------------------- */ /** @override */ async _draw(options) { // Draw the highlight layer this.highlightLayers = {}; this.highlight = this.addChild(new PIXI.Container()); this.highlight.sortableChildren = true; // Draw the grid this.mesh = this.addChild(await this._drawMesh()); // Initialize the mesh appeareance this.initializeMesh(canvas.grid); } /* -------------------------------------------- */ /** * Creates the grid mesh. * @returns {Promise} * @protected */ async _drawMesh() { return new GridMesh().initialize({ type: canvas.grid.type, width: canvas.dimensions.width, height: canvas.dimensions.height, size: canvas.dimensions.size }); } /* -------------------------------------------- */ /** * Initialize the grid mesh appearance and configure the grid shader. * @param {object} options * @param {string} [options.style] The grid style * @param {number} [options.thickness] The grid thickness * @param {string} [options.color] The grid color * @param {number} [options.alpha] The grid alpha */ initializeMesh({style, thickness, color, alpha}) { const {shaderClass, shaderOptions} = CONFIG.Canvas.gridStyles[style] ?? {}; this.mesh.initialize({thickness, color, alpha}); this.mesh.setShaderClass(shaderClass ?? GridShader); this.mesh.shader.configure(shaderOptions ?? {}); } /* -------------------------------------------- */ /* Grid Highlighting Methods /* -------------------------------------------- */ /** * Define a new Highlight graphic * @param {string} name The name for the referenced highlight layer */ addHighlightLayer(name) { const layer = this.highlightLayers[name]; if ( !layer || layer._destroyed ) { this.highlightLayers[name] = this.highlight.addChild(new GridHighlight(name)); } return this.highlightLayers[name]; } /* -------------------------------------------- */ /** * Clear a specific Highlight graphic * @param {string} name The name for the referenced highlight layer */ clearHighlightLayer(name) { const layer = this.highlightLayers[name]; if ( layer ) layer.clear(); } /* -------------------------------------------- */ /** * Destroy a specific Highlight graphic * @param {string} name The name for the referenced highlight layer */ destroyHighlightLayer(name) { const layer = this.highlightLayers[name]; if ( layer ) { this.highlight.removeChild(layer); layer.destroy(); } } /* -------------------------------------------- */ /** * Obtain the highlight layer graphic by name * @param {string} name The name for the referenced highlight layer */ getHighlightLayer(name) { return this.highlightLayers[name]; } /* -------------------------------------------- */ /** * Add highlighting for a specific grid position to a named highlight graphic * @param {string} name The name for the referenced highlight layer * @param {object} [options] Options for the grid position that should be highlighted * @param {number} [options.x] The x-coordinate of the highlighted position * @param {number} [options.y] The y-coordinate of the highlighted position * @param {PIXI.ColorSource} [options.color=0x33BBFF] The fill color of the highlight * @param {PIXI.ColorSource|null} [options.border=null] The border color of the highlight * @param {number} [options.alpha=0.25] The opacity of the highlight * @param {PIXI.Polygon} [options.shape=null] A predefined shape to highlight */ highlightPosition(name, {x, y, color=0x33BBFF, border=null, alpha=0.25, shape=null}) { const layer = this.highlightLayers[name]; if ( !layer ) return; const grid = canvas.grid; if ( grid.type !== CONST.GRID_TYPES.GRIDLESS ) { const cx = x + (grid.sizeX / 2); const cy = y + (grid.sizeY / 2); const points = grid.getShape(); for ( const point of points ) { point.x += cx; point.y += cy; } shape = new PIXI.Polygon(points); } else if ( !shape ) return; if ( !layer.highlight(x, y) ) return; layer.beginFill(color, alpha); if ( border !== null ) layer.lineStyle(2, border, Math.min(alpha * 1.5, 1.0)); layer.drawShape(shape).endFill(); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get type() { const msg = "GridLayer#type is deprecated. Use canvas.grid.type instead."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return canvas.grid.type; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get size() { const msg = "GridLayer#size is deprecated. Use canvas.grid.size instead."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return canvas.grid.size; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get grid() { const msg = "GridLayer#grid is deprecated. Use canvas.grid instead."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return canvas.grid; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ isNeighbor(r0, c0, r1, c1) { const msg = "GridLayer#isNeighbor is deprecated. Use canvas.grid.testAdjacency instead."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return canvas.grid.testAdjacency({i: r0, j: c0}, {i: r1, j: c1}); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get w() { const msg = "GridLayer#w is deprecated in favor of canvas.grid.sizeX."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return canvas.grid.sizeX; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get h() { const msg = "GridLayer#h is deprecated in favor of canvas.grid.sizeY."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return canvas.grid.sizeY; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get isHex() { const msg = "GridLayer#isHex is deprecated. Use canvas.grid.isHexagonal instead."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return canvas.grid.isHexagonal; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getTopLeft(x, y) { const msg = "GridLayer#getTopLeft is deprecated. Use canvas.grid.getTopLeftPoint instead."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return canvas.grid.getTopLeft(x, y); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getCenter(x, y) { const msg = "GridLayer#getCenter is deprecated. Use canvas.grid.getCenterPoint instead."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return canvas.grid.getCenter(x, y); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getSnappedPosition(x, y, interval=1, options={}) { const msg = "GridLayer#getSnappedPosition is deprecated. Use canvas.grid.getSnappedPoint instead."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); if ( interval === 0 ) return {x: Math.round(x), y: Math.round(y)}; return canvas.grid.getSnappedPosition(x, y, interval, options); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ measureDistance(origin, target, options={}) { const msg = "GridLayer#measureDistance is deprecated. " + "Use canvas.grid.measurePath instead for non-Euclidean measurements."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); const ray = new Ray(origin, target); const segments = [{ray}]; return canvas.grid.measureDistances(segments, options)[0]; } } /** * The grid mesh data. * @typedef {object} GridMeshData * @property {number} type The type of the grid (see {@link CONST.GRID_TYPES}) * @property {number} width The width of the grid in pixels * @property {number} height The height of the grid in pixels * @property {number} size The size of a grid space in pixels * @property {number} thickness The thickness of the grid lines in pixels * @property {number} color The color of the grid * @property {number} alpha The alpha of the grid */ /** * The grid mesh, which uses the {@link GridShader} to render the grid. */ class GridMesh extends QuadMesh { /** * The grid mesh constructor. * @param {typeof GridShader} [shaderClass=GridShader] The shader class */ constructor(shaderClass=GridShader) { super(shaderClass); this.width = 0; this.height = 0; this.alpha = 0; this.renderable = false; } /* -------------------------------------------- */ /** * The data of this mesh. * @type {GridMeshData} */ data = { type: CONST.GRID_TYPES.GRIDLESS, width: 0, height: 0, size: 0, thickness: 1, color: 0, alpha: 1 }; /* -------------------------------------------- */ /** * Initialize and update the mesh given the (partial) data. * @param {Partial} data The (partial) data. * @returns {this} */ initialize(data) { // Update the data this._initialize(data); // Update the width, height, and alpha const d = this.data; this.width = d.width; this.height = d.height; this.alpha = d.alpha; // Don't render if gridless or the thickness isn't positive positive this.renderable = (d.type !== CONST.GRID_TYPES.GRIDLESS) && (d.thickness > 0); return this; } /* -------------------------------------------- */ /** * Initialize the data of this mesh given the (partial) data. * @param {Partial} data The (partial) data. * @protected */ _initialize(data) { const d = this.data; if ( data.type !== undefined ) d.type = data.type; if ( data.width !== undefined ) d.width = data.width; if ( data.height !== undefined ) d.height = data.height; if ( data.size !== undefined ) d.size = data.size; if ( data.thickness !== undefined ) d.thickness = data.thickness; if ( data.color !== undefined ) { const color = Color.from(data.color); d.color = color.valid ? color.valueOf() : 0; } if ( data.alpha !== undefined ) d.alpha = data.alpha; } } /** * The depth mask which contains a mapping of elevation. Needed to know if we must render objects according to depth. * Red channel: Lighting occlusion (top). * Green channel: Lighting occlusion (bottom). * Blue channel: Weather occlusion. * @category - Canvas */ class CanvasDepthMask extends CachedContainer { constructor(...args) { super(...args); this.#createDepth(); } /** * Container in which roofs are rendered with depth data. * @type {PIXI.Container} */ roofs; /** @override */ static textureConfiguration = { scaleMode: PIXI.SCALE_MODES.NEAREST, format: PIXI.FORMATS.RGB, multisample: PIXI.MSAA_QUALITY.NONE }; /** @override */ clearColor = [0, 0, 0, 0]; /** * Update the elevation-to-depth mapping? * @type {boolean} * @internal */ _elevationDirty = false; /** * The elevations of the elevation-to-depth mapping. * Supported are up to 255 unique elevations. * @type {Float64Array} */ #elevations = new Float64Array([-Infinity]); /* -------------------------------------------- */ /** * Map an elevation to a value in the range [0, 1] with 8-bit precision. * The depth-rendered object are rendered with these values into the render texture. * @param {number} elevation The elevation in distance units * @returns {number} The value for this elevation in the range [0, 1] with 8-bit precision */ mapElevation(elevation) { const E = this.#elevations; if ( elevation < E[0] ) return 0; let i = 0; let j = E.length - 1; while ( i < j ) { const k = (i + j + 1) >> 1; const e = E[k]; if ( e <= elevation ) i = k; else j = k - 1; } return (i + 1) / 255; } /* -------------------------------------------- */ /** * Update the elevation-to-depth mapping. * Needs to be called after the children have been sorted * and the canvas transform phase. * @internal */ _update() { if ( !this._elevationDirty ) return; this._elevationDirty = false; const elevations = []; const children = canvas.primary.children; for ( let i = 0, n = children.length; i < n; i++ ) { const child = children[i]; if ( !child.shouldRenderDepth ) continue; const elevation = child.elevation; if ( elevation === elevations.at(-1) ) continue; elevations.push(elevation); } if ( !elevations.length ) elevations.push(-Infinity); else elevations.length = Math.min(elevations.length, 255); this.#elevations = new Float64Array(elevations); } /* -------------------------------------------- */ /** * Initialize the depth mask with the roofs container and token graphics. */ #createDepth() { this.roofs = this.addChild(this.#createRoofsContainer()); } /* -------------------------------------------- */ /** * Create the roofs container. * @returns {PIXI.Container} */ #createRoofsContainer() { const c = new PIXI.Container(); const render = renderer => { // Render the depth of each primary canvas object for ( const pco of canvas.primary.children ) { pco.renderDepthData?.(renderer); } }; c.render = render.bind(c); return c; } /* -------------------------------------------- */ /** * Clear the depth mask. */ clear() { Canvas.clearContainer(this.roofs, false); } } /** * The occlusion mask which contains radial occlusion and vision occlusion from tokens. * Red channel: Fade occlusion. * Green channel: Radial occlusion. * Blue channel: Vision occlusion. * @category - Canvas */ class CanvasOcclusionMask extends CachedContainer { constructor(...args) { super(...args); this.#createOcclusion(); } /** @override */ static textureConfiguration = { scaleMode: PIXI.SCALE_MODES.NEAREST, format: PIXI.FORMATS.RGB, multisample: PIXI.MSAA_QUALITY.NONE }; /** * Graphics in which token radial and vision occlusion shapes are drawn. * @type {PIXI.LegacyGraphics} */ tokens; /** * The occludable tokens. * @type {Token[]} */ #tokens; /** @override */ clearColor = [0, 1, 1, 1]; /** @override */ autoRender = false; /* -------------------------------------------- */ /** * Is vision occlusion active? * @type {boolean} */ get vision() { return this.#vision; } /** * @type {boolean} */ #vision = false; /** * The elevations of the elevation-to-depth mapping. * Supported are up to 255 unique elevations. * @type {Float64Array} */ #elevations = new Float64Array([-Infinity]); /* -------------------------------------------- */ /** * Initialize the depth mask with the roofs container and token graphics. */ #createOcclusion() { this.alphaMode = PIXI.ALPHA_MODES.NO_PREMULTIPLIED_ALPHA; this.tokens = this.addChild(new PIXI.LegacyGraphics()); this.tokens.blendMode = PIXI.BLEND_MODES.MIN_ALL; } /* -------------------------------------------- */ /** * Clear the occlusion mask. */ clear() { this.tokens.clear(); } /* -------------------------------------------- */ /* Occlusion Management */ /* -------------------------------------------- */ /** * Map an elevation to a value in the range [0, 1] with 8-bit precision. * The radial and vision shapes are drawn with these values into the render texture. * @param {number} elevation The elevation in distance units * @returns {number} The value for this elevation in the range [0, 1] with 8-bit precision */ mapElevation(elevation) { const E = this.#elevations; let i = 0; let j = E.length - 1; if ( elevation > E[j] ) return 1; while ( i < j ) { const k = (i + j) >> 1; const e = E[k]; if ( e >= elevation ) j = k; else i = k + 1; } return i / 255; } /* -------------------------------------------- */ /** * Update the set of occludable Tokens, redraw the occlusion mask, and update the occluded state * of all occludable objects. */ updateOcclusion() { this.#tokens = canvas.tokens._getOccludableTokens(); this._updateOcclusionMask(); this._updateOcclusionStates(); } /* -------------------------------------------- */ /** * Draw occlusion shapes to the occlusion mask. * Fade occlusion draws to the red channel with varying intensity from [0, 1] based on elevation. * Radial occlusion draws to the green channel with varying intensity from [0, 1] based on elevation. * Vision occlusion draws to the blue channel with varying intensity from [0, 1] based on elevation. * @internal */ _updateOcclusionMask() { this.#vision = false; this.tokens.clear(); const elevations = []; for ( const token of this.#tokens.sort((a, b) => a.document.elevation - b.document.elevation) ) { const elevation = token.document.elevation; if ( elevation !== elevations.at(-1) ) elevations.push(elevation); const occlusionElevation = Math.min(elevations.length - 1, 255); // Draw vision occlusion if ( token.vision?.active ) { this.#vision = true; this.tokens.beginFill(0xFFFF00 | occlusionElevation).drawShape(token.vision.los).endFill(); } // Draw radial occlusion (and radial into the vision channel if this token doesn't have vision) const origin = token.center; const occlusionRadius = Math.max(token.externalRadius, token.getLightRadius(token.document.occludable.radius)); this.tokens.beginFill(0xFF0000 | (occlusionElevation << 8) | (token.vision?.active ? 0xFF : occlusionElevation)) .drawCircle(origin.x, origin.y, occlusionRadius).endFill(); } if ( !elevations.length ) elevations.push(-Infinity); else elevations.length = Math.min(elevations.length, 255); this.#elevations = new Float64Array(elevations); this.renderDirty = true; } /* -------------------------------------------- */ /** * Update the current occlusion status of all Tile objects. * @internal */ _updateOcclusionStates() { const occluded = this._identifyOccludedObjects(this.#tokens); for ( const pco of canvas.primary.children ) { const isOccludable = pco.isOccludable; if ( (isOccludable === undefined) || (!isOccludable && !pco.occluded) ) continue; pco.debounceSetOcclusion(occluded.has(pco)); } } /* -------------------------------------------- */ /** * Determine the set of objects which should be currently occluded by a Token. * @param {Token[]} tokens The set of currently controlled Token objects * @returns {Set} The PCO objects which should be currently occluded * @protected */ _identifyOccludedObjects(tokens) { const occluded = new Set(); for ( const token of tokens ) { // Get the occludable primary canvas objects (PCO) according to the token bounds const matchingPCO = canvas.primary.quadtree.getObjects(token.bounds); for ( const pco of matchingPCO ) { // Don't bother re-testing a PCO or an object which is not occludable if ( !pco.isOccludable || occluded.has(pco) ) continue; if ( pco.testOcclusion(token, {corners: pco.restrictsLight && pco.restrictsWeather}) ) occluded.add(pco); } } return occluded; } /* -------------------------------------------- */ /* Deprecation and compatibility */ /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ _identifyOccludedTiles() { const msg = "CanvasOcclusionMask#_identifyOccludedTiles has been deprecated in " + "favor of CanvasOcclusionMask#_identifyOccludedObjects."; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return this._identifyOccludedObjects(); } } /** * @typedef {object} _CanvasVisionContainerSight * @property {PIXI.LegacyGraphics} preview FOV that should not be committed to fog exploration. */ /** * The sight part of {@link CanvasVisionContainer}. * The blend mode is MAX_COLOR. * @typedef {PIXI.LegacyGraphics & _CanvasVisionContainerSight} CanvasVisionContainerSight */ /** * @typedef {object} _CanvasVisionContainerLight * @property {PIXI.LegacyGraphics} preview FOV that should not be committed to fog exploration. * @property {SpriteMesh} cached The sprite with the texture of FOV of cached light sources. * @property {PIXI.LegacyGraphics & {preview: PIXI.LegacyGraphics}} mask * The light perception polygons of vision sources and the FOV of vision sources that provide vision. */ /** * The light part of {@link CanvasVisionContainer}. * The blend mode is MAX_COLOR. * @typedef {PIXI.LegacyGraphics & _CanvasVisionContainerLight} CanvasVisionContainerLight */ /** * @typedef {object} _CanvasVisionContainerDarkness * @property {PIXI.LegacyGraphics} darkness Darkness source erasing fog of war. */ /** * The sight part of {@link CanvasVisionContainer}. * The blend mode is ERASE. * @typedef {PIXI.LegacyGraphics & _CanvasVisionContainerDarkness} CanvasVisionContainerDarkness */ /** * The sight part of {@link CanvasVisionContainer}. * The blend mode is MAX_COLOR. * @typedef {PIXI.LegacyGraphics & _CanvasVisionContainerSight} CanvasVisionContainerSight */ /** * @typedef {object} _CanvasVisionContainer * @property {CanvasVisionContainerLight} light Areas visible because of light sources and light perception. * @property {CanvasVisionContainerSight} sight Areas visible because of FOV of vision sources. * @property {CanvasVisionContainerDarkness} darkness Areas erased by darkness sources. */ /** * The currently visible areas. * @typedef {PIXI.Container & _CanvasVisionContainer} CanvasVisionContainer */ /** * The vision mask which contains the current line-of-sight texture. * @category - Canvas */ class CanvasVisionMask extends CachedContainer { /** @override */ static textureConfiguration = { scaleMode: PIXI.SCALE_MODES.NEAREST, format: PIXI.FORMATS.RED, multisample: PIXI.MSAA_QUALITY.NONE }; /** @override */ clearColor = [0, 0, 0, 0]; /** @override */ autoRender = false; /** * The current vision Container. * @type {CanvasVisionContainer} */ vision; /** * The BlurFilter which applies to the vision mask texture. * This filter applies a NORMAL blend mode to the container. * @type {AlphaBlurFilter} */ blurFilter; /* -------------------------------------------- */ /** * Create the BlurFilter for the VisionMask container. * @returns {AlphaBlurFilter} */ #createBlurFilter() { // Initialize filters properties this.filters ??= []; this.filterArea = null; // Check if the canvas blur is disabled and return without doing anything if necessary const b = canvas.blur; this.filters.findSplice(f => f === this.blurFilter); if ( !b.enabled ) return; // Create the new filter const f = this.blurFilter = new b.blurClass(b.strength, b.passes, PIXI.Filter.defaultResolution, b.kernels); f.blendMode = PIXI.BLEND_MODES.NORMAL; this.filterArea = canvas.app.renderer.screen; this.filters.push(f); return canvas.addBlurFilter(this.blurFilter); } /* -------------------------------------------- */ async draw() { this.#createBlurFilter(); } /* -------------------------------------------- */ /** * Initialize the vision mask with the los and the fov graphics objects. * @param {PIXI.Container} vision The vision container to attach * @returns {CanvasVisionContainer} */ attachVision(vision) { return this.vision = this.addChild(vision); } /* -------------------------------------------- */ /** * Detach the vision mask from the cached container. * @returns {CanvasVisionContainer} The detached vision container. */ detachVision() { const vision = this.vision; this.removeChild(vision); this.vision = undefined; return vision; } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ get filter() { foundry.utils.logCompatibilityWarning("CanvasVisionMask#filter has been renamed to blurFilter.", {since: 11, until: 13}); return this.blurFilter; } /** * @deprecated since v11 * @ignore */ set filter(f) { foundry.utils.logCompatibilityWarning("CanvasVisionMask#filter has been renamed to blurFilter.", {since: 11, until: 13}); this.blurFilter = f; } } /** * The DrawingsLayer subclass of PlaceablesLayer. * This layer implements a container for drawings. * @category - Canvas */ class DrawingsLayer extends PlaceablesLayer { /** @inheritdoc */ static get layerOptions() { return foundry.utils.mergeObject(super.layerOptions, { name: "drawings", controllableObjects: true, rotatableObjects: true, zIndex: 500 }); } /** @inheritdoc */ static documentName = "Drawing"; /** * The named game setting which persists default drawing configuration for the User * @type {string} */ static DEFAULT_CONFIG_SETTING = "defaultDrawingConfig"; /** * The collection of drawing objects which are rendered in the interface. * @type {Collection} */ graphics = new foundry.utils.Collection(); /* -------------------------------------------- */ /* Properties */ /* -------------------------------------------- */ /** @inheritdoc */ get hud() { return canvas.hud.drawing; } /* -------------------------------------------- */ /** @inheritdoc */ get hookName() { return DrawingsLayer.name; } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** @override */ getSnappedPoint(point) { const M = CONST.GRID_SNAPPING_MODES; const size = canvas.dimensions.size; return canvas.grid.getSnappedPoint(point, canvas.forceSnapVertices ? {mode: M.VERTEX} : { mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT, resolution: size >= 128 ? 8 : (size >= 64 ? 4 : 2) }); } /* -------------------------------------------- */ /** * Render a configuration sheet to configure the default Drawing settings */ configureDefault() { const defaults = game.settings.get("core", DrawingsLayer.DEFAULT_CONFIG_SETTING); const d = DrawingDocument.fromSource(defaults); new DrawingConfig(d, {configureDefault: true}).render(true); } /* -------------------------------------------- */ /** @inheritDoc */ _deactivate() { super._deactivate(); this.objects.visible = true; } /* -------------------------------------------- */ /** @inheritdoc */ async _draw(options) { await super._draw(options); this.objects.visible = true; } /* -------------------------------------------- */ /** * Get initial data for a new drawing. * Start with some global defaults, apply user default config, then apply mandatory overrides per tool. * @param {Point} origin The initial coordinate * @returns {object} The new drawing data */ _getNewDrawingData(origin) { const tool = game.activeTool; // Get saved user defaults const defaults = game.settings.get("core", this.constructor.DEFAULT_CONFIG_SETTING) || {}; const userColor = game.user.color.css; const data = foundry.utils.mergeObject(defaults, { fillColor: userColor, strokeColor: userColor, fontFamily: CONFIG.defaultFontFamily }, {overwrite: false, inplace: false}); // Mandatory additions delete data._id; data.x = origin.x; data.y = origin.y; data.sort = Math.max(this.getMaxSort() + 1, 0); data.author = game.user.id; data.shape = {}; // Information toggle const interfaceToggle = ui.controls.controls.find(c => c.layer === "drawings").tools.find(t => t.name === "role"); data.interface = interfaceToggle.active; // Tool-based settings switch ( tool ) { case "rect": data.shape.type = Drawing.SHAPE_TYPES.RECTANGLE; data.shape.width = 1; data.shape.height = 1; break; case "ellipse": data.shape.type = Drawing.SHAPE_TYPES.ELLIPSE; data.shape.width = 1; data.shape.height = 1; break; case "polygon": data.shape.type = Drawing.SHAPE_TYPES.POLYGON; data.shape.points = [0, 0]; data.bezierFactor = 0; break; case "freehand": data.shape.type = Drawing.SHAPE_TYPES.POLYGON; data.shape.points = [0, 0]; data.bezierFactor = data.bezierFactor ?? 0.5; break; case "text": data.shape.type = Drawing.SHAPE_TYPES.RECTANGLE; data.shape.width = 1; data.shape.height = 1; data.fillColor = "#ffffff"; data.fillAlpha = 0.10; data.strokeColor = "#ffffff"; data.text ||= ""; break; } // Return the cleaned data return DrawingDocument.cleanData(data); } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** @inheritdoc */ _onClickLeft(event) { const {preview, drawingsState, destination} = event.interactionData; // Continue polygon point placement if ( (drawingsState >= 1) && preview.isPolygon ) { preview._addPoint(destination, {snap: !event.shiftKey, round: true}); preview._chain = true; // Note that we are now in chain mode return preview.refresh(); } // Standard left-click handling super._onClickLeft(event); } /* -------------------------------------------- */ /** @inheritdoc */ _onClickLeft2(event) { const {drawingsState, preview} = event.interactionData; // Conclude polygon placement with double-click if ( (drawingsState >= 1) && preview.isPolygon ) { event.interactionData.drawingsState = 2; return; } // Standard double-click handling super._onClickLeft2(event); } /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftStart(event) { super._onDragLeftStart(event); const interaction = event.interactionData; // Snap the origin to the grid const isFreehand = game.activeTool === "freehand"; if ( !event.shiftKey && !isFreehand ) { interaction.origin = this.getSnappedPoint(interaction.origin); } // Create the preview object const cls = getDocumentClass("Drawing"); let document; try { document = new cls(this._getNewDrawingData(interaction.origin), {parent: canvas.scene}); } catch(e) { if ( e instanceof foundry.data.validation.DataModelValidationError ) { ui.notifications.error("DRAWING.JointValidationErrorUI", {localize: true}); } throw e; } const drawing = new this.constructor.placeableClass(document); interaction.preview = this.preview.addChild(drawing); interaction.drawingsState = 1; drawing.draw(); } /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftMove(event) { const {preview, drawingsState} = event.interactionData; if ( !preview || preview._destroyed ) return; if ( preview.parent === null ) { // In theory this should never happen, but rarely does this.preview.addChild(preview); } if ( drawingsState >= 1 ) { preview._onMouseDraw(event); const isFreehand = game.activeTool === "freehand"; if ( !preview.isPolygon || isFreehand ) event.interactionData.drawingsState = 2; } } /* -------------------------------------------- */ /** * Handling of mouse-up events which conclude a new object creation after dragging * @param {PIXI.FederatedEvent} event The drag drop event * @private */ _onDragLeftDrop(event) { const interaction = event.interactionData; // Snap the destination to the grid const isFreehand = game.activeTool === "freehand"; if ( !event.shiftKey && !isFreehand ) { interaction.destination = this.getSnappedPoint(interaction.destination); } const {drawingsState, destination, origin, preview} = interaction; // Successful drawing completion if ( drawingsState === 2 ) { const distance = Math.hypot(Math.max(destination.x, origin.x) - preview.x, Math.max(destination.y, origin.x) - preview.y); const minDistance = distance >= (canvas.dimensions.size / 8); const completePolygon = preview.isPolygon && (preview.document.shape.points.length > 4); // Create a completed drawing if ( minDistance || completePolygon ) { event.interactionData.clearPreviewContainer = false; event.interactionData.drawingsState = 0; const data = preview.document.toObject(false); // Create the object preview._chain = false; const cls = getDocumentClass("Drawing"); const createData = this.constructor.placeableClass.normalizeShape(data); cls.create(createData, {parent: canvas.scene}).then(d => { const o = d.object; o._creating = true; if ( game.activeTool !== "freehand" ) o.control({isNew: true}); }).finally(() => this.clearPreviewContainer()); } } // In-progress polygon if ( (drawingsState === 1) && preview.isPolygon ) { event.preventDefault(); if ( preview._chain ) return; return this._onClickLeft(event); } } /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftCancel(event) { const preview = this.preview.children?.[0] || null; if ( preview?._chain ) { preview._removePoint(); preview.refresh(); if ( preview.document.shape.points.length ) return event.preventDefault(); } event.interactionData.drawingsState = 0; super._onDragLeftCancel(event); } /* -------------------------------------------- */ /** @inheritdoc */ _onClickRight(event) { const preview = this.preview.children?.[0] || null; if ( preview ) return canvas.mouseInteractionManager._dragRight = false; super._onClickRight(event); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get gridPrecision() { // eslint-disable-next-line no-unused-expressions super.gridPrecision; if ( canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ) return 0; return canvas.dimensions.size >= 128 ? 16 : 8; } } /** * The Lighting Layer which ambient light sources as part of the CanvasEffectsGroup. * @category - Canvas */ class LightingLayer extends PlaceablesLayer { /** @inheritdoc */ static documentName = "AmbientLight"; /** @inheritdoc */ static get layerOptions() { return foundry.utils.mergeObject(super.layerOptions, { name: "lighting", rotatableObjects: true, zIndex: 900 }); } /** * Darkness change event handler function. * @type {_onDarknessChange} */ #onDarknessChange; /* -------------------------------------------- */ /** @inheritdoc */ get hookName() { return LightingLayer.name; } /* -------------------------------------------- */ /* Rendering */ /* -------------------------------------------- */ /** @inheritDoc */ async _draw(options) { await super._draw(options); this.#onDarknessChange = this._onDarknessChange.bind(this); canvas.environment.addEventListener("darknessChange", this.#onDarknessChange); } /* -------------------------------------------- */ /** @inheritDoc */ async _tearDown(options) { canvas.environment.removeEventListener("darknessChange", this.#onDarknessChange); this.#onDarknessChange = undefined; return super._tearDown(options); } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** * Refresh the fields of all the ambient lights on this scene. */ refreshFields() { if ( !this.active ) return; for ( const ambientLight of this.placeables ) { ambientLight.renderFlags.set({refreshField: true}); } } /* -------------------------------------------- */ /** @override */ _activate() { super._activate(); for ( const p of this.placeables ) p.renderFlags.set({refreshField: true}); } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ _canDragLeftStart(user, event) { // Prevent creating a new light if currently previewing one. if ( this.preview.children.length ) { ui.notifications.warn("CONTROLS.ObjectConfigured", { localize: true }); return false; } return super._canDragLeftStart(user, event); } /* -------------------------------------------- */ /** @override */ _onDragLeftStart(event) { super._onDragLeftStart(event); const interaction = event.interactionData; // Snap the origin to the grid if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin); // Create a pending AmbientLightDocument const cls = getDocumentClass("AmbientLight"); const doc = new cls(interaction.origin, {parent: canvas.scene}); // Create the preview AmbientLight object const preview = new this.constructor.placeableClass(doc); // Updating interaction data interaction.preview = this.preview.addChild(preview); interaction.lightsState = 1; // Prepare to draw the preview preview.draw(); } /* -------------------------------------------- */ /** @override */ _onDragLeftMove(event) { const {destination, lightsState, preview, origin} = event.interactionData; if ( lightsState === 0 ) return; // Update the light radius const radius = Math.hypot(destination.x - origin.x, destination.y - origin.y); // Update the preview object data preview.document.config.dim = radius * (canvas.dimensions.distance / canvas.dimensions.size); preview.document.config.bright = preview.document.config.dim / 2; // Refresh the layer display preview.initializeLightSource(); preview.renderFlags.set({refreshState: true}); // Confirm the creation state event.interactionData.lightsState = 2; } /* -------------------------------------------- */ /** @override */ _onDragLeftCancel(event) { super._onDragLeftCancel(event); canvas.effects.refreshLighting(); event.interactionData.lightsState = 0; } /* -------------------------------------------- */ /** @override */ _onMouseWheel(event) { // Identify the hovered light source const light = this.hover; if ( !light || light.isPreview || (light.document.config.angle === 360) ) return; // Determine the incremental angle of rotation from event data const snap = event.shiftKey ? 15 : 3; const delta = snap * Math.sign(event.delta); return light.rotate(light.document.rotation + delta, snap); } /* -------------------------------------------- */ /** * Actions to take when the darkness level of the Scene is changed * @param {PIXI.FederatedEvent} event * @internal */ _onDarknessChange(event) { const {darknessLevel, priorDarknessLevel} = event.environmentData; for ( const light of this.placeables ) { const {min, max} = light.document.config.darkness; if ( darknessLevel.between(min, max) === priorDarknessLevel.between(min, max) ) continue; light.initializeLightSource(); if ( this.active ) light.renderFlags.set({refreshState: true}); } } } /** * The Notes Layer which contains Note canvas objects. * @category - Canvas */ class NotesLayer extends PlaceablesLayer { /** @inheritdoc */ static get layerOptions() { return foundry.utils.mergeObject(super.layerOptions, { name: "notes", zIndex: 800 }); } /** @inheritdoc */ static documentName = "Note"; /** * The named core setting which tracks the toggled visibility state of map notes * @type {string} */ static TOGGLE_SETTING = "notesDisplayToggle"; /* -------------------------------------------- */ /** @inheritdoc */ get hookName() { return NotesLayer.name; } /* -------------------------------------------- */ /** @override */ interactiveChildren = game.settings.get("core", this.constructor.TOGGLE_SETTING); /* -------------------------------------------- */ /* Methods /* -------------------------------------------- */ /** @override */ _deactivate() { super._deactivate(); const isToggled = game.settings.get("core", this.constructor.TOGGLE_SETTING); this.objects.visible = this.interactiveChildren = isToggled; } /* -------------------------------------------- */ /** @inheritDoc */ async _draw(options) { await super._draw(options); const isToggled = game.settings.get("core", this.constructor.TOGGLE_SETTING); this.objects.visible ||= isToggled; } /* -------------------------------------------- */ /** * Register game settings used by the NotesLayer */ static registerSettings() { game.settings.register("core", this.TOGGLE_SETTING, { name: "Map Note Toggle", scope: "client", config: false, type: new foundry.data.fields.BooleanField({initial: false}), onChange: value => { if ( !canvas.ready ) return; const layer = canvas.notes; layer.objects.visible = layer.interactiveChildren = layer.active || value; } }); } /* -------------------------------------------- */ /** * Visually indicate in the Scene Controls that there are visible map notes present in the Scene. */ hintMapNotes() { const hasVisibleNotes = this.placeables.some(n => n.visible); const i = document.querySelector(".scene-control[data-control='notes'] i"); i.classList.toggle("fa-solid", !hasVisibleNotes); i.classList.toggle("fa-duotone", hasVisibleNotes); i.classList.toggle("has-notes", hasVisibleNotes); } /* -------------------------------------------- */ /** * Pan to a given note on the layer. * @param {Note} note The note to pan to. * @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(note, {scale=1.5, duration=250}={}) { if ( !note ) return Promise.resolve(); if ( note.visible && !this.active ) this.activate(); return canvas.animatePan({x: note.x, y: note.y, scale, duration}).then(() => { if ( this.hover ) this.hover._onHoverOut(new Event("pointerout")); note._onHoverIn(new Event("pointerover"), {hoverOutOthers: true}); }); } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritdoc */ async _onClickLeft(event) { if ( game.activeTool !== "journal" ) return super._onClickLeft(event); // Capture the click coordinates const origin = event.getLocalPosition(canvas.stage); const {x, y} = canvas.grid.getCenterPoint(origin); // Render the note creation dialog const folders = game.journal.folders.filter(f => f.displayed); const title = game.i18n.localize("NOTE.Create"); const html = await renderTemplate("templates/sidebar/document-create.html", { folders, name: game.i18n.localize("NOTE.Unknown"), hasFolders: folders.length >= 1, hasTypes: false, content: `
` }); 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 origin; if ( (data.x === undefined) || (data.y === undefined) ) { const coords = this._canvasCoordinatesFromDrop(event, {center: false}); if ( !coords ) return false; origin = {x: coords[0], y: coords[1]}; } else { origin = {x: data.x, y: data.y}; } if ( !event.shiftKey ) origin = this.getSnappedPoint(origin); if ( !canvas.dimensions.rect.contains(origin.x, origin.y) ) return false; const noteData = {x: origin.x, y: origin.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}); } } /** * The Regions Container. * @category - Canvas */ class RegionLayer extends PlaceablesLayer { /** @inheritDoc */ static get layerOptions() { return foundry.utils.mergeObject(super.layerOptions, { name: "regions", controllableObjects: true, confirmDeleteKey: true, quadtree: false, zIndex: 100, zIndexActive: 600 }); } /* -------------------------------------------- */ /** @inheritDoc */ static documentName = "Region"; /* -------------------------------------------- */ /** * The method to sort the Regions. * @type {Function} */ static #sortRegions = function() { for ( let i = 0; i < this.children.length; i++ ) { this.children[i]._lastSortedIndex = i; } this.children.sort((a, b) => (a.zIndex - b.zIndex) || (a.top - b.top) || (a.bottom - b.bottom) || (a._lastSortedIndex - b._lastSortedIndex)); this.sortDirty = false; }; /* -------------------------------------------- */ /** @inheritDoc */ get hookName() { return RegionLayer.name; } /* -------------------------------------------- */ /** * The RegionLegend application of this RegionLayer. * @type {foundry.applications.ui.RegionLegend} */ get legend() { return this.#legend ??= new foundry.applications.ui.RegionLegend(); } #legend; /* -------------------------------------------- */ /** * The graphics used to draw the highlighted shape. * @type {PIXI.Graphics} */ #highlight; /* -------------------------------------------- */ /** * The graphics used to draw the preview of the shape that is drawn. * @type {PIXI.Graphics} */ #preview; /* -------------------------------------------- */ /** * Draw shapes as holes? * @type {boolean} * @internal */ _holeMode = false; /* -------------------------------------------- */ /* Methods /* -------------------------------------------- */ /** @inheritDoc */ _activate() { super._activate(); // noinspection ES6MissingAwait this.legend.render({force: true}); } /* -------------------------------------------- */ /** @inheritDoc */ _deactivate() { super._deactivate(); this.objects.visible = true; // noinspection ES6MissingAwait this.legend.close({animate: false}); } /* -------------------------------------------- */ /** @inheritDoc */ storeHistory(type, data) { super.storeHistory(type, type === "update" ? data.map(d => { if ( "behaviors" in d ) { d = foundry.utils.deepClone(d); delete d.behaviors; } return d; }) : data); } /* -------------------------------------------- */ /** @override */ copyObjects() { return []; // Prevent copy & paste } /* -------------------------------------------- */ /** @override */ getSnappedPoint(point) { const M = CONST.GRID_SNAPPING_MODES; const size = canvas.dimensions.size; return canvas.grid.getSnappedPoint(point, canvas.forceSnapVertices ? {mode: M.VERTEX} : { mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT, resolution: size >= 128 ? 8 : (size >= 64 ? 4 : 2) }); } /* -------------------------------------------- */ /** @override */ getZIndex() { return this.active ? this.options.zIndexActive : this.options.zIndex; } /* -------------------------------------------- */ /** @inheritDoc */ async _draw(options) { await super._draw(options); this.objects.sortChildren = RegionLayer.#sortRegions; this.objects.visible = true; this.#highlight = this.addChild(new PIXI.Graphics()); this.#highlight.eventMode = "none"; this.#highlight.visible = false; this.#preview = this.addChild(new PIXI.Graphics()); this.#preview.eventMode = "none"; this.#preview.visible = false; this.filters = [VisionMaskFilter.create()]; this.filterArea = canvas.app.screen; } /* -------------------------------------------- */ /** * Highlight the shape or clear the highlight. * @param {foundry.data.BaseShapeData|null} data The shape to highlight, or null to clear the highlight * @internal */ _highlightShape(data) { this.#highlight.clear(); this.#highlight.visible = false; if ( !data ) return; this.#highlight.visible = true; this.#highlight.lineStyle({ width: CONFIG.Canvas.objectBorderThickness, color: 0x000000, join: PIXI.LINE_JOIN.ROUND, shader: new PIXI.smooth.DashLineShader() }); const shape = foundry.canvas.regions.RegionShape.create(data); shape._drawShape(this.#highlight); } /* -------------------------------------------- */ /** * Refresh the preview shape. * @param {PIXI.FederatedEvent} event */ #refreshPreview(event) { this.#preview.clear(); this.#preview.lineStyle({ width: CONFIG.Canvas.objectBorderThickness, color: 0x000000, join: PIXI.LINE_JOIN.ROUND, cap: PIXI.LINE_CAP.ROUND, alignment: 0.75 }); this.#preview.beginFill(event.interactionData.drawingColor, 0.5); this.#drawPreviewShape(event); this.#preview.endFill(); this.#preview.lineStyle({ width: CONFIG.Canvas.objectBorderThickness / 2, color: CONFIG.Canvas.dispositionColors.CONTROLLED, join: PIXI.LINE_JOIN.ROUND, cap: PIXI.LINE_CAP.ROUND, alignment: 1 }); this.#drawPreviewShape(event); } /* -------------------------------------------- */ /** * Draw the preview shape. * @param {PIXI.FederatedEvent} event */ #drawPreviewShape(event) { const data = this.#createShapeData(event); if ( !data ) return; switch ( data.type ) { case "rectangle": this.#preview.drawRect(data.x, data.y, data.width, data.height); break; case "circle": this.#preview.drawCircle(data.x, data.y, data.radius); break; case "ellipse": this.#preview.drawEllipse(data.x, data.y, data.radiusX, data.radiusY); break; case "polygon": const polygon = new PIXI.Polygon(data.points); if ( !polygon.isPositive ) polygon.reverseOrientation(); this.#preview.drawPath(polygon.points); break; } } /* -------------------------------------------- */ /** * Create the shape data. * @param {PIXI.FederatedEvent} event * @returns {object|void} */ #createShapeData(event) { let data; switch ( event.interactionData.drawingTool ) { case "rectangle": data = this.#createRectangleData(event); break; case "ellipse": data = this.#createCircleOrEllipseData(event); break; case "polygon": data = this.#createPolygonData(event); break; } if ( data ) { data.elevation = { bottom: Number.isFinite(this.legend.elevation.bottom) ? this.legend.elevation.bottom : null, top: Number.isFinite(this.legend.elevation.top) ? this.legend.elevation.top : null }; if ( this._holeMode ) data.hole = true; return data; } } /* -------------------------------------------- */ /** * Create the rectangle shape data. * @param {PIXI.FederatedEvent} event * @returns {object|void} */ #createRectangleData(event) { const {origin, destination} = event.interactionData; let dx = Math.abs(destination.x - origin.x); let dy = Math.abs(destination.y - origin.y); if ( event.altKey ) dx = dy = Math.min(dx, dy); let x = origin.x; let y = origin.y; if ( event.ctrlKey || event.metaKey ) { x -= dx; y -= dy; dx *= 2; dy *= 2; } else { if ( origin.x > destination.x ) x -= dx; if ( origin.y > destination.y ) y -= dy; } if ( (dx === 0) || (dy === 0) ) return; return {type: "rectangle", x, y, width: dx, height: dy, rotation: 0}; } /* -------------------------------------------- */ /** * Create the circle or ellipse shape data. * @param {PIXI.FederatedEvent} event * @returns {object|void} */ #createCircleOrEllipseData(event) { const {origin, destination} = event.interactionData; let dx = Math.abs(destination.x - origin.x); let dy = Math.abs(destination.y - origin.y); if ( event.altKey ) dx = dy = Math.min(dx, dy); let x = origin.x; let y = origin.y; if ( !(event.ctrlKey || event.metaKey) ) { if ( origin.x > destination.x ) x -= dx; if ( origin.y > destination.y ) y -= dy; dx /= 2; dy /= 2; x += dx; y += dy; } if ( (dx === 0) || (dy === 0) ) return; return event.altKey ? {type: "circle", x, y, radius: dx} : {type: "ellipse", x, y, radiusX: dx, radiusY: dy, rotation: 0}; } /* -------------------------------------------- */ /** * Create the polygon shape data. * @param {PIXI.FederatedEvent} event * @returns {object|void} */ #createPolygonData(event) { let {destination, points, complete} = event.interactionData; if ( !complete ) points = [...points, destination.x, destination.y]; else if ( points.length < 6 ) return; return {type: "polygon", points}; } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ _onClickLeft(event) { const interaction = event.interactionData; // Continue polygon point placement if ( interaction.drawingTool === "polygon" ) { const {destination, points} = interaction; const point = !event.shiftKey ? this.getSnappedPoint(destination) : destination; // Clicking on the first point closes the shape if ( (point.x === points.at(0)) && (point.y === points.at(1)) ) { interaction.complete = true; } // Don't add the point if it is equal to the last one else if ( (point.x !== points.at(-2)) || (point.y !== points.at(-1)) ) { interaction.points.push(point.x, point.y); this.#refreshPreview(event); } return; } // If one of the drawing tools is selected, prevent left-click-to-release if ( ["rectangle", "ellipse", "polygon"].includes(game.activeTool) ) return; // Standard left-click handling super._onClickLeft(event); } /* -------------------------------------------- */ /** @inheritDoc */ _onClickLeft2(event) { const interaction = event.interactionData; // Conclude polygon drawing with a double-click if ( interaction.drawingTool === "polygon" ) { interaction.complete = true; return; } // Standard double-click handling super._onClickLeft2(event); } /* -------------------------------------------- */ /** @inheritDoc */ _canDragLeftStart(user, event) { if ( !super._canDragLeftStart(user, event) ) return false; if ( !["rectangle", "ellipse", "polygon"].includes(game.activeTool) ) return false; if ( this.controlled.length > 1 ) { ui.notifications.error("REGION.NOTIFICATIONS.DrawingMultipleRegionsControlled", {localize: true}); return false; } if ( this.controlled.at(0)?.document.locked ) { ui.notifications.warn(game.i18n.format("CONTROLS.ObjectIsLocked", { type: game.i18n.localize(RegionDocument.metadata.label)})); return false; } return true; } /* -------------------------------------------- */ /** @override */ _onDragLeftStart(event) { const interaction = event.interactionData; if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin); // Set drawing tool interaction.drawingTool = game.activeTool; interaction.drawingRegion = this.controlled.at(0); interaction.drawingColor = interaction.drawingRegion?.document.color ?? Color.from(RegionDocument.schema.fields.color.getInitialValue({})); // Initialize the polygon points with the origin if ( interaction.drawingTool === "polygon" ) { const point = interaction.origin; interaction.points = [point.x, point.y]; } this.#refreshPreview(event); this.#preview.visible = true; } /* -------------------------------------------- */ /** @override */ _onDragLeftMove(event) { const interaction = event.interactionData; if ( !interaction.drawingTool ) return; if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination); this.#refreshPreview(event); } /* -------------------------------------------- */ /** @override */ _onDragLeftDrop(event) { const interaction = event.interactionData; if ( !interaction.drawingTool ) return; if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination); // In-progress polygon drawing if ( (interaction.drawingTool === "polygon") && (interaction.complete !== true) ) { event.preventDefault(); return; } // Clear preview and refresh Regions this.#preview.clear(); this.#preview.visible = false; // Create the shape from the preview const shape = this.#createShapeData(event); if ( !shape ) return; // Add the shape to controlled Region or create a new Region if none is controlled const region = interaction.drawingRegion; if ( region ) { if ( !region.document.locked ) region.document.update({shapes: [...region.document.shapes, shape]}); } else RegionDocument.implementation.create({ name: RegionDocument.implementation.defaultName({parent: canvas.scene}), color: interaction.drawingColor, shapes: [shape] }, {parent: canvas.scene, renderSheet: true}).then(r => r.object.control({releaseOthers: true})); } /* -------------------------------------------- */ /** @override */ _onDragLeftCancel(event) { const interaction = event.interactionData; if ( !interaction.drawingTool ) return; // Remove point from in-progress polygon drawing if ( (interaction.drawingTool === "polygon") && (interaction.complete !== true) ) { interaction.points.splice(-2, 2); if ( interaction.points.length ) { event.preventDefault(); this.#refreshPreview(event); return; } } // Clear preview and refresh Regions this.#preview.clear(); this.#preview.visible = false; } /* -------------------------------------------- */ /** @inheritDoc */ _onClickRight(event) { const interaction = event.interactionData; if ( interaction.drawingTool ) return canvas.mouseInteractionManager._dragRight = false; super._onClickRight(event); } } /** * @typedef {Object} AmbientSoundPlaybackConfig * @property {Sound} sound The Sound node which should be controlled for playback * @property {foundry.canvas.sources.PointSoundSource} source The SoundSource which defines the area of effect * for the sound * @property {AmbientSound} object An AmbientSound object responsible for the sound, or undefined * @property {Point} listener The coordinates of the closest listener or undefined if there is none * @property {number} distance The minimum distance between a listener and the AmbientSound origin * @property {boolean} muffled Is the closest listener muffled * @property {boolean} walls Is playback constrained or muffled by walls? * @property {number} volume The final volume at which the Sound should be played */ /** * 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(); /** * Darkness change event handler function. * @type {_onDarknessChange} */ #onDarknessChange; /* -------------------------------------------- */ /** @inheritdoc */ static get layerOptions() { return foundry.utils.mergeObject(super.layerOptions, { name: "sounds", zIndex: 900 }); } /** @inheritdoc */ static documentName = "AmbientSound"; /* -------------------------------------------- */ /** @inheritdoc */ get hookName() { return SoundsLayer.name; } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** @inheritDoc */ async _draw(options) { await super._draw(options); this.#onDarknessChange = this._onDarknessChange.bind(this); canvas.environment.addEventListener("darknessChange", this.#onDarknessChange); } /* -------------------------------------------- */ /** @inheritDoc */ async _tearDown(options) { this.stopAll(); canvas.environment.removeEventListener("darknessChange", this.#onDarknessChange); this.#onDarknessChange = undefined; return super._tearDown(options); } /* -------------------------------------------- */ /** @override */ _activate() { super._activate(); for ( const p of this.placeables ) p.renderFlags.set({refreshField: true}); } /* -------------------------------------------- */ /** * Initialize all AmbientSound sources which are present on this layer */ initializeSources() { for ( let sound of this.placeables ) { sound.initializeSoundSource(); } for ( let sound of this.preview.children ) { sound.initializeSoundSource(); } } /* -------------------------------------------- */ /** * 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; for ( const sound of this.placeables ) sound.source.refresh(); if ( game.audio.locked ) { return game.audio.pending.push(() => this.refresh(options)); } const listeners = this.getListenerPositions(); 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)); } /* -------------------------------------------- */ /** * Get an array of listener positions for Tokens which are able to hear environmental sound. * @returns {Point[]} */ getListenerPositions() { const listeners = canvas.tokens.controlled.map(token => token.center); if ( !listeners.length && !game.user.isGM ) { for ( const token of canvas.tokens.placeables ) { if ( token.actor?.isOwner && token.isVisible ) listeners.push(token.center); } } return listeners; } /* -------------------------------------------- */ /** * 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 * @protected */ _syncPositions(listeners, options) { if ( !this.placeables.length || game.audio.locked ) return; /** @type {Record>} */ const paths = {}; for ( const /** @type {AmbientSound} */ object of this.placeables ) { const {path, easing, volume, walls} = object.document; if ( !path ) continue; const {sound, source} = object; // Track a singleton record per unique audio path paths[path] ||= {sound, source, object, volume: 0}; const config = paths[path]; if ( !config.sound && sound ) Object.assign(config, {sound, source, object}); // First defined Sound // Identify the closest listener to each sound source if ( !object.isAudible || !source.active ) continue; for ( let l of listeners ) { const v = volume * source.getVolumeMultiplier(l, {easing}); if ( v > config.volume ) { Object.assign(config, {source, object, listener: l, volume: v, walls}); config.sound ??= sound; // We might already have defined Sound } } } // Compute the effective volume for each sound path for ( const config of Object.values(paths) ) { this._configurePlayback(config); config.object.sync(config.volume > 0, config.volume, {...options, muffled: config.muffled}); } } /* -------------------------------------------- */ /** * Configure playback by assigning the muffled state and final playback volume for the sound. * This method should mutate the config object by assigning the volume and muffled properties. * @param {AmbientSoundPlaybackConfig} config * @protected */ _configurePlayback(config) { const {source, walls} = config; // Inaudible sources if ( !config.listener ) { config.volume = 0; return; } // Muffled by walls if ( !walls ) { if ( config.listener.equals(source) ) return false; // GM users listening to the source const polygonCls = CONFIG.Canvas.polygonBackends.sound; const x = polygonCls.testCollision(config.listener, source, {mode: "first", type: "sound"}); config.muffled = x && (x._distance < 1); // Collided before reaching the source } else config.muffled = false; } /* -------------------------------------------- */ /** * Actions to take when the darkness level of the Scene is changed * @param {PIXI.FederatedEvent} event * @internal */ _onDarknessChange(event) { const {darknessLevel, priorDarknessLevel} = event.environmentData; for ( const sound of this.placeables ) { const {min, max} = sound.document.darkness; if ( darknessLevel.between(min, max) === priorDarknessLevel.between(min, max) ) continue; sound.initializeSoundSource(); if ( this.active ) sound.renderFlags.set({refreshState: true}); } } /* -------------------------------------------- */ /** * Play a one-shot Sound originating from a predefined point on the canvas. * The sound plays locally for the current client only. * To play a sound for all connected clients use SoundsLayer#emitAtPosition. * * @param {string} src The sound source path to play * @param {Point} origin The canvas coordinates from which the sound originates * @param {number} radius The radius of effect in distance units * @param {object} options Additional options which configure playback * @param {number} [options.volume=1.0] The maximum volume at which the effect should be played * @param {boolean} [options.easing=true] Should volume be attenuated by distance? * @param {boolean} [options.walls=true] Should the sound be constrained by walls? * @param {boolean} [options.gmAlways=true] Should the sound always be played for GM users regardless * of actively controlled tokens? * @param {AmbientSoundEffect} [options.baseEffect] A base sound effect to apply to playback * @param {AmbientSoundEffect} [options.muffledEffect] A muffled sound effect to apply to playback, a sound may * only be muffled if it is not constrained by walls * @param {Partial} [options.sourceData] Additional data passed to the SoundSource constructor * @param {SoundPlaybackOptions} [options.playbackOptions] Additional options passed to Sound#play * @returns {Promise} A Promise which resolves to the played Sound, or null * * @example Play the sound of a trap springing * ```js * const src = "modules/my-module/sounds/spring-trap.ogg"; * const origin = {x: 5200, y: 3700}; // The origin point for the sound * const radius = 30; // Audible in a 30-foot radius * await canvas.sounds.playAtPosition(src, origin, radius); * ``` * * @example A Token casts a spell * ```js * const src = "modules/my-module/sounds/spells-sprite.ogg"; * const origin = token.center; // The origin point for the sound * const radius = 60; // Audible in a 60-foot radius * await canvas.sounds.playAtPosition(src, origin, radius, { * walls: false, // Not constrained by walls with a lowpass muffled effect * muffledEffect: {type: "lowpass", intensity: 6}, * sourceData: { * angle: 120, // Sound emitted at a limited angle * rotation: 270 // Configure the direction of sound emission * } * playbackOptions: { * loopStart: 12, // Audio sprite timing * loopEnd: 16, * fade: 300, // Fade-in 300ms * onended: () => console.log("Do something after the spell sound has played") * } * }); * ``` */ async playAtPosition(src, origin, radius, {volume=1, easing=true, walls=true, gmAlways=true, baseEffect, muffledEffect, sourceData, playbackOptions}={}) { // Construct a Sound and corresponding SoundSource const sound = new foundry.audio.Sound(src, {context: game.audio.environment}); const source = new CONFIG.Canvas.soundSourceClass({object: null}); source.initialize({ x: origin.x, y: origin.y, radius: canvas.dimensions.distancePixels * radius, walls, ...sourceData }); /** @type {Partial} */ const config = {sound, source, listener: undefined, volume: 0, walls}; // Identify the closest listener position const listeners = (gmAlways && game.user.isGM) ? [origin] : this.getListenerPositions(); for ( const l of listeners ) { const v = volume * source.getVolumeMultiplier(l, {easing}); if ( v > config.volume ) Object.assign(config, {listener: l, volume: v}); } // Configure playback volume and muffled state this._configurePlayback(config); if ( !config.volume ) return null; // Load the Sound and apply special effects await sound.load(); const sfx = CONFIG.soundEffects; let effect; if ( config.muffled && (muffledEffect?.type in sfx) ) { const muffledCfg = sfx[muffledEffect.type]; effect = new muffledCfg.effectClass(sound.context, muffledEffect); } if ( !effect && (baseEffect?.type in sfx) ) { const baseCfg = sfx[baseEffect.type]; effect = new baseCfg.effectClass(sound.context, baseEffect); } if ( effect ) sound.effects.push(effect); // Initiate sound playback await sound.play({...playbackOptions, loop: false, volume: config.volume}); return sound; } /* -------------------------------------------- */ /** * Emit playback to other connected clients to occur at a specified position. * @param {...*} args Arguments passed to SoundsLayer#playAtPosition * @returns {Promise} A Promise which resolves once playback for the initiating client has completed */ async emitAtPosition(...args) { game.socket.emit("playAudioPosition", args); return this.playAtPosition(...args); } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** * Handle mouse cursor movements which may cause ambient audio previews to occur */ _onMouseMove() { if ( !this.livePreview ) return; if ( canvas.tokens.active && canvas.tokens.controlled.length ) return; this.previewSound(canvas.mousePosition); } /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftStart(event) { super._onDragLeftStart(event); const interaction = event.interactionData; // Snap the origin to the grid if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin); // Create a pending AmbientSoundDocument const cls = getDocumentClass("AmbientSound"); const doc = new cls({type: "l", ...interaction.origin}, {parent: canvas.scene}); // Create the preview AmbientSound object const sound = new this.constructor.placeableClass(doc); interaction.preview = this.preview.addChild(sound); interaction.soundState = 1; this.preview._creating = false; sound.draw(); } /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftMove(event) { const {destination, soundState, preview, origin} = event.interactionData; if ( soundState === 0 ) return; const radius = Math.hypot(destination.x - origin.x, destination.y - origin.y); preview.document.updateSource({radius: radius / canvas.dimensions.distancePixels}); preview.initializeSoundSource(); preview.renderFlags.set({refreshState: true}); event.interactionData.soundState = 2; } /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftDrop(event) { // Snap the destination to the grid const interaction = event.interactionData; if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination); const {soundState, destination, origin, preview} = interaction; if ( soundState !== 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; preview.document.updateSource({radius: radius / canvas.dimensions.distancePixels}); preview.initializeSoundSource(); preview.renderFlags.set({refreshState: true}); 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; let origin; if ( (data.x === undefined) || (data.y === undefined) ) { const coords = this._canvasCoordinatesFromDrop(event, {center: false}); if ( !coords ) return false; origin = {x: coords[0], y: coords[1]}; } else { origin = {x: data.x, y: data.y}; } if ( !event.shiftKey ) origin = this.getSnappedPoint(origin); if ( !canvas.dimensions.rect.contains(origin.x, origin.y) ) return false; const soundData = { path: playlistSound.path, volume: playlistSound.volume, x: origin.x, y: origin.y, 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", rotatableObjects: true, zIndex: 400 }); } /** @inheritdoc */ static documentName = "MeasuredTemplate"; /* -------------------------------------------- */ /** @inheritdoc */ get hookName() { return TemplateLayer.name; } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** @inheritDoc */ _deactivate() { super._deactivate(); this.objects.visible = true; } /* -------------------------------------------- */ /** @inheritDoc */ async _draw(options) { await super._draw(options); this.objects.visible = true; } /* -------------------------------------------- */ /** * Register game settings used by the TemplatesLayer */ static registerSettings() { game.settings.register("core", "gridTemplates", { name: "TEMPLATE.GridTemplatesSetting", hint: "TEMPLATE.GridTemplatesSettingHint", scope: "world", config: true, type: new foundry.data.fields.BooleanField({initial: false}), onChange: () => { if ( canvas.ready ) canvas.templates.draw(); } }); game.settings.register("core", "coneTemplateType", { name: "TEMPLATE.ConeTypeSetting", hint: "TEMPLATE.ConeTypeSettingHint", scope: "world", config: true, type: new foundry.data.fields.StringField({required: true, blank: false, initial: "round", choices: { round: "TEMPLATE.ConeTypeRound", flat: "TEMPLATE.ConeTypeFlat" }}), onChange: () => { if ( canvas.ready ) canvas.templates.draw(); } }); } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftStart(event) { super._onDragLeftStart(event); const interaction = event.interactionData; // Snap the origin to the grid if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin); // Create a pending MeasuredTemplateDocument const tool = game.activeTool; const previewData = { user: game.user.id, t: tool, x: interaction.origin.x, y: interaction.origin.y, sort: Math.max(this.getMaxSort() + 1, 0), distance: 1, direction: 0, fillColor: game.user.color || "#FF0000", hidden: event.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); interaction.preview = this.preview.addChild(template); template.draw(); } /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftMove(event) { const interaction = event.interactionData; // Snap the destination to the grid if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination); // Compute the ray const {origin, destination, preview} = interaction; const ray = new Ray(origin, destination); let distance; // Grid type if ( game.settings.get("core", "gridTemplates") ) { distance = canvas.grid.measurePath([origin, destination]).distance; } // Euclidean type else { const ratio = (canvas.dimensions.size / canvas.dimensions.distance); distance = ray.distance / ratio; } // Update the preview object preview.document.direction = Math.normalizeDegrees(Math.toDegrees(ray.angle)); preview.document.distance = distance; preview.renderFlags.set({refreshShape: true}); } /* -------------------------------------------- */ /** @inheritdoc */ _onMouseWheel(event) { // Determine whether we have a hovered template? const template = this.hover; if ( !template || template.isPreview ) return; // Determine the incremental angle of rotation from event data const snap = event.shiftKey ? 15 : 5; const 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: 300, controllableObjects: true, rotatableObjects: true }); } /* -------------------------------------------- */ /** @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 || []; } /* -------------------------------------------- */ /** @override */ *controllableObjects() { const foreground = ui.controls.control.foreground ?? false; for ( const placeable of super.controllableObjects() ) { const overhead = placeable.document.elevation >= placeable.document.parent.foregroundElevation; if ( overhead === foreground ) yield placeable; } } /* -------------------------------------------- */ /* Layer Methods */ /* -------------------------------------------- */ /** @inheritDoc */ getSnappedPoint(point) { if ( canvas.forceSnapVertices ) return canvas.grid.getSnappedPoint(point, {mode: CONST.GRID_SNAPPING_MODES.VERTEX}); return super.getSnappedPoint(point); } /* -------------------------------------------- */ /** @inheritDoc */ async _tearDown(options) { for ( const tile of this.tiles ) { if ( tile.isVideo ) { game.video.stop(tile.sourceElement); } } return super._tearDown(options); } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftStart(event) { super._onDragLeftStart(event); const interaction = event.interactionData; // Snap the origin to the grid if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin); // Create the preview const tile = this.constructor.placeableClass.createPreview(interaction.origin); interaction.preview = this.preview.addChild(tile); this.preview._creating = false; } /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftMove(event) { const interaction = event.interactionData; // Snap the destination to the grid if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination); const {destination, tilesState, preview, origin} = interaction; if ( tilesState === 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 = (event.altKey ? dist * Math.sign(dx) : dx); preview.document.height = (event.altKey ? dist * Math.sign(dy) : dy); preview.renderFlags.set({refreshSize: true}); // Confirm the creation state interaction.tilesState = 2; } /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftDrop(event) { // Snap the destination to the grid const interaction = event.interactionData; if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination); const { tilesState, preview } = interaction; if ( tilesState !== 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; // Determine the elevation const foreground = ui.controls.controls.find(c => c.layer === "tiles").foreground; data.elevation = foreground ? canvas.scene.foregroundElevation : 0; data.sort = Math.max(this.getMaxSort() + 1, 0); foundry.utils.setProperty(data, "occlusion.mode", foreground ? CONST.OCCLUSION_MODES.FADE : CONST.OCCLUSION_MODES.NONE); // 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} = this.getSnappedPoint(data); 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; } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get roofs() { const msg = "TilesLayer#roofs has been deprecated without replacement."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return this.placeables.filter(t => t.isRoof); } /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ get textureDataMap() { const msg = "TilesLayer#textureDataMap has moved to TextureLoader.textureBufferDataMap"; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return TextureLoader.textureBufferDataMap; } /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ get depthMask() { const msg = "TilesLayer#depthMask is deprecated without replacement. Use canvas.masks.depth instead"; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return canvas.masks.depth; } } /** * 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", controllableObjects: true, rotatableObjects: true, zIndex: 200 }); } /** @inheritdoc */ static documentName = "Token"; /* -------------------------------------------- */ /** * The set of tokens that trigger occlusion (a union of {@link CONST.TOKEN_OCCLUSION_MODES}). * @type {number} */ set occlusionMode(value) { this.#occlusionMode = value; canvas.perception.update({refreshOcclusion: true}); } get occlusionMode() { return this.#occlusionMode; } #occlusionMode; /* -------------------------------------------- */ /** @inheritdoc */ get hookName() { return TokenLayer.name; } /* -------------------------------------------- */ /* 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 /* -------------------------------------------- */ /** @override */ getSnappedPoint(point) { const M = CONST.GRID_SNAPPING_MODES; return canvas.grid.getSnappedPoint(point, {mode: M.TOP_LEFT_CORNER, resolution: 1}); } /* -------------------------------------------- */ /** @inheritDoc */ async _draw(options) { await super._draw(options); this.objects.visible = true; // Reset the Tokens layer occlusion mode for the Scene const M = CONST.TOKEN_OCCLUSION_MODES; this.#occlusionMode = game.user.isGM ? M.CONTROLLED | M.HOVERED | M.HIGHLIGHTED : M.OWNED; 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(); this.objects.visible = true; if ( canvas.controls ) canvas.controls.doors.visible = false; } /* -------------------------------------------- */ /** @override */ _pasteObject(copy, offset, {hidden=false, snap=true}={}) { const {x, y} = copy.document; let position = {x: x + offset.x, y: y + offset.y}; if ( snap ) position = copy.getSnappedPosition(position); const d = canvas.dimensions; position.x = Math.clamp(position.x, 0, d.width - 1); position.y = Math.clamp(position.y, 0, d.height - 1); const data = copy.document.toObject(); delete data._id; data.x = position.x; data.y = position.y; data.hidden ||= hidden; return data; } /* -------------------------------------------- */ /** @inheritDoc */ _getMovableObjects(ids, includeLocked) { const ruler = canvas.controls.ruler; if ( ruler.state === Ruler.STATES.MEASURING ) return []; const tokens = super._getMovableObjects(ids, includeLocked); if ( ruler.token ) tokens.findSplice(token => token === ruler.token); return tokens; } /* -------------------------------------------- */ /** * 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 = new Set(); const rectangle = new PIXI.Rectangle(x, y, width, height); for ( const token of this.placeables ) { if ( !token.visible || token.document.isSecret ) continue; if ( token._overlapsSelection(rectangle) ) targets.add(token); } // Maybe release other targets if ( releaseOthers ) { for ( const token of user.targets ) { if ( targets.has(token) ) continue; token.setTarget(false, {releaseOthers: false, groupSelection: true}); } } // Acquire targets for tokens which are not yet targeted for ( const token of targets ) { if ( user.targets.has(token) ) continue; token.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; } /* -------------------------------------------- */ /** * 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.forEach(t => t.stopAnimation()); 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 tile occlusion. * By default, only tokens which are currently controlled or owned by a player are included as subjects. * @returns {Token[]} * @protected * @internal */ _getOccludableTokens() { const M = CONST.TOKEN_OCCLUSION_MODES; const mode = this.occlusionMode; if ( (mode & M.VISIBLE) || ((mode & M.HIGHLIGHTED) && this.highlightObjects) ) { return this.placeables.filter(t => t.visible); } const tokens = new Set(); if ( (mode & M.HOVERED) && this.hover ) tokens.add(this.hover); if ( mode & M.CONTROLLED ) this.controlled.forEach(t => tokens.add(t)); if ( mode & M.OWNED ) this.ownedTokens.filter(t => !t.document.hidden).forEach(t => tokens.add(t)); return Array.from(tokens); } /* -------------------------------------------- */ /** @inheritdoc */ storeHistory(type, data) { super.storeHistory(type, type === "update" ? data.map(d => { // Clean actorData and delta updates from the history so changes to those fields are not undone. d = foundry.utils.deepClone(d); delete d.actorData; delete d.delta; delete d._regions; return d; }) : data); } /* -------------------------------------------- */ /* 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({ hidden: game.user.isGM && event.altKey, sort: Math.max(this.getMaxSort() + 1, 0) }, {parent: canvas.scene}); // Set the position of the Token such that its center point is the drop position before snapping const t = this.createObject(td); let position = t.getCenterPoint({x: 0, y: 0}); position.x = data.x - position.x; position.y = data.y - position.y; if ( !event.shiftKey ) position = t.getSnappedPosition(position); t.destroy({children: true}); td.updateSource(position); // 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); } // If we don't explicitly return from handling the tool, use the default behavior super._onClickLeft(event); } /* -------------------------------------------- */ /** @override */ _onMouseWheel(event) { // Prevent wheel rotation during dragging if ( this.preview.children.length ) return; // Determine the incremental angle of rotation from event data const snap = canvas.grid.isHexagonal ? (event.shiftKey ? 60 : 30) : (event.shiftKey ? 45 : 15); const delta = snap * Math.sign(event.delta); return this.rotateMany({delta, snap}); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get gridPrecision() { // eslint-disable-next-line no-unused-expressions super.gridPrecision; return 1; // Snap tokens to top-left } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ async toggleCombat(state=true, combat=null, {token=null}={}) { foundry.utils.logCompatibilityWarning("TokenLayer#toggleCombat is deprecated in favor of" + " TokenDocument.implementation.createCombatants and TokenDocument.implementation.deleteCombatants", {since: 12, until: 14}); const tokens = this.controlled.map(t => t.document); if ( token && !token.controlled && (token.inCombat !== state) ) tokens.push(token.document); if ( state ) return TokenDocument.implementation.createCombatants(tokens, {combat}); else return TokenDocument.implementation.deleteCombatants(tokens, {combat}); } } /** * The Walls canvas layer which provides a container for Wall objects within the rendered Scene. * @category - Canvas */ class WallsLayer extends PlaceablesLayer { /** * 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 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, zIndex: 700 }); } /** @inheritdoc */ static documentName = "Wall"; /* -------------------------------------------- */ /** @inheritdoc */ get hookName() { return WallsLayer.name; } /* -------------------------------------------- */ /** * The grid used for snapping. * It's the same as canvas.grid except in the gridless case where this is the square version of the gridless grid. * @type {BaseGrid} */ #grid = canvas.grid; /* -------------------------------------------- */ /** * 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); } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** @override */ getSnappedPoint(point) { const M = CONST.GRID_SNAPPING_MODES; const size = canvas.dimensions.size; return this.#grid.getSnappedPoint(point, canvas.forceSnapVertices ? {mode: M.VERTEX} : { mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT, resolution: size >= 128 ? 8 : (size >= 64 ? 4 : 2) }); } /* -------------------------------------------- */ /** @inheritDoc */ async _draw(options) { this.#grid = canvas.grid.isGridless ? new foundry.grid.SquareGrid({size: canvas.grid.size}) : canvas.grid; await super._draw(options); this.chain = this.addChildAt(new PIXI.Graphics(), 0); this.last = {point: null}; } /* -------------------------------------------- */ /** @inheritdoc */ _deactivate() { super._deactivate(); this.chain?.clear(); } /* -------------------------------------------- */ /** * 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; } /* -------------------------------------------- */ /** @inheritdoc */ releaseAll(options) { if ( this.chain ) this.chain.clear(); return super.releaseAll(options); } /* -------------------------------------------- */ /** @override */ _pasteObject(copy, offset, options) { const c = copy.document.c; const dx = Math.round(offset.x); const dy = Math.round(offset.y); const a = {x: c[0] + dx, y: c[1] + dy}; const b = {x: c[2] + dx, y: c[3] + dy}; const data = copy.document.toObject(); delete data._id; data.c = [a.x, a.y, b.x, b.y]; return data; } /* -------------------------------------------- */ /** * 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.interactionData.panTime || 0) <= 100 ) return; event.interactionData.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 wall endpoint coordinates for a given point. * @param {Point} point The candidate wall endpoint. * @param {object} [options] * @param {boolean} [options.snap=true] Snap to the grid? * @returns {[x: number, y: number]} The wall endpoint coordinates. * @internal */ _getWallEndpointCoordinates(point, {snap=true}={}) { if ( snap ) point = this.getSnappedPoint(point); return [point.x, point.y].map(Math.round); } /* -------------------------------------------- */ /** * 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; case "window": const d = canvas.dimensions.distance; wallData.sight = wallData.light = CONST.WALL_SENSE_TYPES.PROXIMITY; wallData.threshold = {light: 2 * d, sight: 2 * d, attenuation: true}; break; } return wallData; } /* -------------------------------------------- */ /** * Identify the interior enclosed by the given walls. * @param {Wall[]} walls The walls that enclose the interior. * @returns {PIXI.Polygon[]} The polygons of the interior. * @license MIT */ identifyInteriorArea(walls) { // Build the graph from the walls const vertices = new Map(); const addEdge = (a, b) => { let v = vertices.get(a.key); if ( !v ) vertices.set(a.key, v = {X: a.x, Y: a.y, key: a.key, neighbors: new Set(), visited: false}); let w = vertices.get(b.key); if ( !w ) vertices.set(b.key, w = {X: b.x, Y: b.y, key: b.key, neighbors: new Set(), visited: false}); if ( v !== w ) { v.neighbors.add(w); w.neighbors.add(v); } }; for ( const wall of walls ) { const edge = wall.edge; const a = new foundry.canvas.edges.PolygonVertex(edge.a.x, edge.a.y); const b = new foundry.canvas.edges.PolygonVertex(edge.b.x, edge.b.y); if ( a.key === b.key ) continue; if ( edge.intersections.length === 0 ) addEdge(a, b); else { const p = edge.intersections.map(i => foundry.canvas.edges.PolygonVertex.fromPoint(i.intersection)); p.push(a, b); p.sort((v, w) => (v.x - w.x) || (v.y - w.y)); for ( let k = 1; k < p.length; k++ ) { const a = p[k - 1]; const b = p[k]; if ( a.key === b.key ) continue; addEdge(a, b); } } } // Find the boundary paths of the interior that enclosed by the walls const paths = []; while ( vertices.size !== 0 ) { let start; for ( const vertex of vertices.values() ) { vertex.visited = false; if ( !start || (start.X > vertex.X) || ((start.X === vertex.X) && (start.Y > vertex.Y)) ) start = vertex; } if ( start.neighbors.size >= 2 ) { const path = []; let current = start; let previous = {X: current.X - 1, Y: current.Y - 1}; for ( ;; ) { current.visited = true; const x0 = previous.X; const y0 = previous.Y; const x1 = current.X; const y1 = current.Y; let next; for ( const vertex of current.neighbors ) { if ( vertex === previous ) continue; if ( (vertex !== start) && vertex.visited ) continue; if ( !next ) { next = vertex; continue; } const x2 = next.X; const y2 = next.Y; const a1 = ((y0 - y1) * (x2 - x1)) + ((x1 - x0) * (y2 - y1)); const x3 = vertex.X; const y3 = vertex.Y; const a2 = ((y0 - y1) * (x3 - x1)) + ((x1 - x0) * (y3 - y1)); if ( a1 < 0 ) { if ( a2 >= 0 ) continue; } else if ( a1 > 0 ) { if ( a2 < 0 ) { next = vertex; continue; } if ( a2 === 0 ) { const b2 = ((x3 - x1) * (x0 - x1)) + ((y3 - y1) * (y0 - y1)) > 0; if ( !b2 ) next = vertex; continue; } } else { if ( a2 < 0 ) { next = vertex; continue; } const b1 = ((x2 - x1) * (x0 - x1)) + ((y2 - y1) * (y0 - y1)) > 0; if ( a2 > 0) { if ( b1 ) next = vertex; continue; } const b2 = ((x3 - x1) * (x0 - x1)) + ((y3 - y1) * (y0 - y1)) > 0; if ( b1 && !b2 ) next = vertex; continue; } const c = ((y1 - y2) * (x3 - x1)) + ((x2 - x1) * (y3 - y1)); if ( c > 0 ) continue; if ( c < 0 ) { next = vertex; continue; } const d1 = ((x2 - x1) * (x2 - x1)) + ((y2 - y1) * (y2 - y1)); const d2 = ((x3 - x1) * (x3 - x1)) + ((y3 - y1) * (y3 - y1)); if ( d2 < d1 ) next = vertex; } if (next) { path.push(current); previous = current; current = next; if ( current === start ) break; } else { current = path.pop(); if ( !current ) { previous = undefined; break; } previous = path.length ? path[path.length - 1] : {X: current.X - 1, Y: current.Y - 1}; } } if ( path.length !== 0 ) { paths.push(path); previous = path[path.length - 1]; for ( const vertex of path ) { previous.neighbors.delete(vertex); if ( previous.neighbors.size === 0 ) vertices.delete(previous.key); vertex.neighbors.delete(previous); previous = vertex; } if ( previous.neighbors.size === 0 ) vertices.delete(previous.key); } } for ( const vertex of start.neighbors ) { vertex.neighbors.delete(start); if ( vertex.neighbors.size === 0 ) vertices.delete(vertex.key); } vertices.delete(start.key); } // Unionize the paths const clipper = new ClipperLib.Clipper(); clipper.AddPaths(paths, ClipperLib.PolyType.ptSubject, true); clipper.Execute(ClipperLib.ClipType.ctUnion, paths, ClipperLib.PolyFillType.pftPositive, ClipperLib.PolyFillType.pftEvenOdd); // Convert the paths to polygons return paths.map(path => { const points = []; for ( const point of path ) points.push(point.X, point.Y); return new PIXI.Polygon(points); }); } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftStart(event) { this.clearPreviewContainer(); const interaction = event.interactionData; const origin = interaction.origin; interaction.wallsState = WallsLayer.CREATION_STATES.NONE; interaction.clearPreviewContainer = true; // Create a pending WallDocument const data = this._getWallDataFromActiveTool(game.activeTool); const snap = !event.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); interaction.wallsState = WallsLayer.CREATION_STATES.POTENTIAL; interaction.preview = wall; return wall.draw(); } /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftMove(event) { const interaction = event.interactionData; const {preview, destination} = interaction; const states = WallsLayer.CREATION_STATES; if ( !preview || preview._destroyed || [states.NONE, states.COMPLETED].includes(interaction.wallsState) ) return; if ( preview.parent === null ) this.preview.addChild(preview); // Should happen the first time it is moved const snap = !event.shiftKey; preview.document.updateSource({ c: preview.document.c.slice(0, 2).concat(this._getWallEndpointCoordinates(destination, {snap})) }); preview.refresh(); interaction.wallsState = WallsLayer.CREATION_STATES.CONFIRMED; } /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftDrop(event) { const interaction = event.interactionData; const {wallsState, destination, preview} = interaction; const states = WallsLayer.CREATION_STATES; // Check preview and state if ( !preview || preview._destroyed || (interaction.wallsState === states.NONE) ) { return; } // Prevent default to allow chaining to continue if ( game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL) ) { event.preventDefault(); this._chain = true; if ( wallsState < WallsLayer.CREATION_STATES.CONFIRMED ) return; } else this._chain = false; // Successful wall completion if ( wallsState === WallsLayer.CREATION_STATES.CONFIRMED ) { interaction.wallsState = WallsLayer.CREATION_STATES.COMPLETED; // Get final endpoint location const snap = !event.shiftKey; let dest = this._getWallEndpointCoordinates(destination, {snap}); const coords = preview.document.c.slice(0, 2).concat(dest); preview.document.updateSource({c: coords}); const clearPreviewAndChain = () => { this.clearPreviewContainer(); // Maybe chain if ( this._chain ) { interaction.origin = {x: dest[0], y: dest[1]}; this._onDragLeftStart(event); } }; // Ignore walls which are collapsed if ( (coords[0] === coords[2]) && (coords[1] === coords[3]) ) { clearPreviewAndChain(); return; } interaction.clearPreviewContainer = false; // Create the Wall this.last = {point: dest}; const cls = getDocumentClass(this.constructor.documentName); cls.create(preview.document.toObject(), {parent: canvas.scene}).finally(clearPreviewAndChain); } } /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftCancel(event) { this._chain = false; this.last = {point: null}; super._onDragLeftCancel(event); } /* -------------------------------------------- */ /** @inheritdoc */ _onClickRight(event) { if ( event.interactionData.wallsState > WallsLayer.CREATION_STATES.NONE ) return this._onDragLeftCancel(event); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ checkCollision(ray, options={}) { const msg = "WallsLayer#checkCollision is obsolete." + "Prefer calls to testCollision from CONFIG.Canvas.polygonBackends[type]"; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return CONFIG.Canvas.losBackend.testCollision(ray.A, ray.B, options); } /** * @deprecated since v11 * @ignore */ highlightControlledSegments() { foundry.utils.logCompatibilityWarning("The WallsLayer#highlightControlledSegments function is deprecated in favor" + "of calling wall.renderFlags.set(\"refreshHighlight\") on individual Wall objects", {since: 11, until: 13}); for ( const w of this.placeables ) w.renderFlags.set({refreshHighlight: true}); } /** * @deprecated since v12 * @ignore */ initialize() { foundry.utils.logCompatibilityWarning("WallsLayer#initialize is deprecated in favor of Canvas#edges#initialize", {since: 12, until: 14}); return canvas.edges.initialize(); } /** * @deprecated since v12 * @ignore */ identifyInteriorWalls() { foundry.utils.logCompatibilityWarning("WallsLayer#identifyInteriorWalls has been deprecated. " + "It has no effect anymore and there's no replacement.", {since: 12, until: 14}); } /** * @deprecated since v12 * @ignore */ identifyWallIntersections() { foundry.utils.logCompatibilityWarning("WallsLayer#identifyWallIntersections is deprecated in favor of" + " foundry.canvas.edges.Edge.identifyEdgeIntersections and has no effect.", {since: 12, until: 14}); } } /** * A batch shader generator that could handle extra uniforms during initialization. * @param {string} vertexSrc The vertex shader source * @param {string} fragTemplate The fragment shader source template * @param {object | (maxTextures: number) => object} [uniforms] Additional uniforms */ class BatchShaderGenerator extends PIXI.BatchShaderGenerator { constructor(vertexSrc, fragTemplate, uniforms={}) { super(vertexSrc, fragTemplate); this.#uniforms = uniforms; } /** * Extra uniforms used to create the batch shader. * @type {object | (maxTextures: number) => object} */ #uniforms; /* -------------------------------------------- */ /** @override */ generateShader(maxTextures) { if ( !this.programCache[maxTextures] ) { const sampleValues = Int32Array.from({length: maxTextures}, (n, i) => i); this.defaultGroupCache[maxTextures] = PIXI.UniformGroup.from({uSamplers: sampleValues}, true); let fragmentSrc = this.fragTemplate; fragmentSrc = fragmentSrc.replace(/%count%/gi, `${maxTextures}`); fragmentSrc = fragmentSrc.replace(/%forloop%/gi, this.generateSampleSrc(maxTextures)); this.programCache[maxTextures] = new PIXI.Program(this.vertexSrc, fragmentSrc); } let uniforms = this.#uniforms; if ( typeof uniforms === "function" ) uniforms = uniforms.call(this, maxTextures); else uniforms = foundry.utils.deepClone(uniforms); return new PIXI.Shader(this.programCache[maxTextures], { ...uniforms, tint: new Float32Array([1, 1, 1, 1]), translationMatrix: new PIXI.Matrix(), default: this.defaultGroupCache[maxTextures] }); } } /** * A batch renderer with a customizable data transfer function to packed geometries. * @extends PIXI.AbstractBatchRenderer */ class BatchRenderer extends PIXI.BatchRenderer { /** * The batch shader generator class. * @type {typeof BatchShaderGenerator} */ static shaderGeneratorClass = BatchShaderGenerator; /* -------------------------------------------- */ /** * The default uniform values for the batch shader. * @type {object | (maxTextures: number) => object} */ static defaultUniforms = {}; /* -------------------------------------------- */ /** * The PackInterleavedGeometry function provided by the sampler. * @type {Function|undefined} * @protected */ _packInterleavedGeometry; /* -------------------------------------------- */ /** * The update function provided by the sampler and that is called just before a flush. * @type {(batchRenderer: BatchRenderer) => void | undefined} * @protected */ _preRenderBatch; /* -------------------------------------------- */ /** * Get the uniforms bound to this abstract batch renderer. * @returns {object|undefined} */ get uniforms() { return this._shader?.uniforms; } /* -------------------------------------------- */ /** * The number of reserved texture units that the shader generator should not use (maximum 4). * @param {number} val * @protected */ set reservedTextureUnits(val) { // Some checks before... if ( typeof val !== "number" ) { throw new Error("BatchRenderer#reservedTextureUnits must be a number!"); } if ( (val < 0) || (val > 4) ) { throw new Error("BatchRenderer#reservedTextureUnits must be positive and can't exceed 4."); } this.#reservedTextureUnits = val; } /** * Number of reserved texture units reserved by the batch shader that cannot be used by the batch renderer. * @returns {number} */ get reservedTextureUnits() { return this.#reservedTextureUnits; } #reservedTextureUnits = 0; /* -------------------------------------------- */ /** @override */ setShaderGenerator({ vertex=this.constructor.defaultVertexSrc, fragment=this.constructor.defaultFragmentTemplate, uniforms=this.constructor.defaultUniforms }={}) { this.shaderGenerator = new this.constructor.shaderGeneratorClass(vertex, fragment, uniforms); } /* -------------------------------------------- */ /** * This override allows to allocate a given number of texture units reserved for a custom batched shader. * These reserved texture units won't be used to batch textures for PIXI.Sprite or SpriteMesh. * @override */ contextChange() { const gl = this.renderer.gl; // First handle legacy environment if ( PIXI.settings.PREFER_ENV === PIXI.ENV.WEBGL_LEGACY ) this.maxTextures = 1; else { // Step 1: first check max texture units the GPU can handle const gpuMaxTex = Math.min(gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS), 65536); // Step 2: Remove the number of reserved texture units that could be used by a custom batch shader const batchMaxTex = gpuMaxTex - this.#reservedTextureUnits; // Step 3: Checking if remainder of texture units is at least 1. Should never happens on GPU < than 20 years old! if ( batchMaxTex < 1 ) { const msg = "Impossible to allocate the required number of texture units in contextChange#BatchRenderer. " + "Your GPU should handle at least 8 texture units. Currently, it is supporting: " + `${gpuMaxTex} texture units.`; throw new Error(msg); } // Step 4: Check with the maximum number of textures of the setting (webGL specifications) this.maxTextures = Math.min(batchMaxTex, PIXI.settings.SPRITE_MAX_TEXTURES); // Step 5: Check the maximum number of if statements the shader can have too.. this.maxTextures = PIXI.checkMaxIfStatementsInShader(this.maxTextures, gl); } // Generate the batched shader this._shader = this.shaderGenerator?.generateShader(this.maxTextures) ?? null; // Initialize packed geometries for ( let i = 0; i < this._packedGeometryPoolSize; i++ ) { this._packedGeometries[i] = new (this.geometryClass)(); } this.initFlushBuffers(); } /* -------------------------------------------- */ /** @inheritdoc */ onPrerender() { if ( !this.shaderGenerator ) this.setShaderGenerator(); this._shader ??= this.shaderGenerator.generateShader(this.maxTextures); super.onPrerender(); } /* -------------------------------------------- */ /** @override */ start() { this._preRenderBatch?.(this); super.start(); } /* -------------------------------------------- */ /** @override */ packInterleavedGeometry(element, attributeBuffer, indexBuffer, aIndex, iIndex) { // If we have a specific function to pack data into geometry, we call it if ( this._packInterleavedGeometry ) { this._packInterleavedGeometry(element, attributeBuffer, indexBuffer, aIndex, iIndex); return; } // Otherwise, we call the parent method, with the classic packing super.packInterleavedGeometry(element, attributeBuffer, indexBuffer, aIndex, iIndex); } /* -------------------------------------------- */ /** * Verify if a PIXI plugin exists. Check by name. * @param {string} name The name of the pixi plugin to check. * @returns {boolean} True if the plugin exists, false otherwise. */ static hasPlugin(name) { return Object.keys(PIXI.Renderer.__plugins).some(k => k === name); } } /** * A mixin which decorates a PIXI.Filter or PIXI.Shader with common properties. * @category - Mixins * @param {typeof PIXI.Shader} ShaderClass The parent ShaderClass class being mixed. * @returns {typeof BaseShaderMixin} A Shader/Filter subclass mixed with BaseShaderMixin features. * @mixin */ const BaseShaderMixin = ShaderClass => { class BaseShaderMixin extends ShaderClass { /** * Useful constant values computed at compile time * @type {string} */ static CONSTANTS = ` const float PI = 3.141592653589793; const float TWOPI = 6.283185307179586; const float INVPI = 0.3183098861837907; const float INVTWOPI = 0.15915494309189535; const float SQRT2 = 1.4142135623730951; const float SQRT1_2 = 0.7071067811865476; const float SQRT3 = 1.7320508075688772; const float SQRT1_3 = 0.5773502691896257; const vec3 BT709 = vec3(0.2126, 0.7152, 0.0722); `; /* -------------------------------------------- */ /** * Fast approximate perceived brightness computation * Using Digital ITU BT.709 : Exact luminance factors * @type {string} */ static PERCEIVED_BRIGHTNESS = ` float perceivedBrightness(in vec3 color) { return sqrt(dot(BT709, color * color)); } float perceivedBrightness(in vec4 color) { return perceivedBrightness(color.rgb); } float reversePerceivedBrightness(in vec3 color) { return 1.0 - perceivedBrightness(color); } float reversePerceivedBrightness(in vec4 color) { return 1.0 - perceivedBrightness(color.rgb); } `; /* -------------------------------------------- */ /** * Convertion functions for sRGB and Linear RGB. * @type {string} */ static COLOR_SPACES = ` float luminance(in vec3 c) { return dot(BT709, c); } vec3 linear2grey(in vec3 c) { return vec3(luminance(c)); } vec3 linear2srgb(in vec3 c) { vec3 a = 12.92 * c; vec3 b = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055; vec3 s = step(vec3(0.0031308), c); return mix(a, b, s); } vec3 srgb2linear(in vec3 c) { vec3 a = c / 12.92; vec3 b = pow((c + 0.055) / 1.055, vec3(2.4)); vec3 s = step(vec3(0.04045), c); return mix(a, b, s); } vec3 srgb2linearFast(in vec3 c) { return c * c; } vec3 linear2srgbFast(in vec3 c) { return sqrt(c); } vec3 colorClamp(in vec3 c) { return clamp(c, vec3(0.0), vec3(1.0)); } vec4 colorClamp(in vec4 c) { return clamp(c, vec4(0.0), vec4(1.0)); } vec3 tintColorLinear(in vec3 color, in vec3 tint, in float intensity) { float t = luminance(tint); float c = luminance(color); return mix(color, mix( mix(tint, vec3(1.0), (c - t) / (1.0 - t)), tint * (c / t), step(c, t) ), intensity); } vec3 tintColor(in vec3 color, in vec3 tint, in float intensity) { return linear2srgbFast(tintColorLinear(srgb2linearFast(color), srgb2linearFast(tint), intensity)); } `; /* -------------------------------------------- */ /** * Fractional Brownian Motion for a given number of octaves * @param {number} [octaves=4] * @param {number} [amp=1.0] * @returns {string} */ static FBM(octaves = 4, amp = 1.0) { return `float fbm(in vec2 uv) { float total = 0.0, amp = ${amp.toFixed(1)}; for (int i = 0; i < ${octaves}; i++) { total += noise(uv) * amp; uv += uv; amp *= 0.5; } return total; }`; } /* -------------------------------------------- */ /** * High Quality Fractional Brownian Motion * @param {number} [octaves=3] * @returns {string} */ static FBMHQ(octaves = 3) { return `float fbm(in vec2 uv, in float smoothness) { float s = exp2(-smoothness); float f = 1.0; float a = 1.0; float t = 0.0; for( int i = 0; i < ${octaves}; i++ ) { t += a * noise(f * uv); f *= 2.0; a *= s; } return t; }`; } /* -------------------------------------------- */ /** * Angular constraint working with coordinates on the range [-1, 1] * => coord: Coordinates * => angle: Angle in radians * => smoothness: Smoothness of the pie * => l: Length of the pie. * @type {string} */ static PIE = ` float pie(in vec2 coord, in float angle, in float smoothness, in float l) { coord.x = abs(coord.x); vec2 va = vec2(sin(angle), cos(angle)); float lg = length(coord) - l; float clg = length(coord - va * clamp(dot(coord, va) , 0.0, l)); return smoothstep(0.0, smoothness, max(lg, clg * sign(va.y * coord.x - va.x * coord.y))); }`; /* -------------------------------------------- */ /** * A conventional pseudo-random number generator with the "golden" numbers, based on uv position * @type {string} */ static PRNG_LEGACY = ` float random(in vec2 uv) { return fract(cos(dot(uv, vec2(12.9898, 4.1414))) * 43758.5453); }`; /* -------------------------------------------- */ /** * A pseudo-random number generator based on uv position which does not use cos/sin * This PRNG replaces the old PRNG_LEGACY to workaround some driver bugs * @type {string} */ static PRNG = ` float random(in vec2 uv) { uv = mod(uv, 1000.0); return fract( dot(uv, vec2(5.23, 2.89) * fract((2.41 * uv.x + 2.27 * uv.y) * 251.19)) * 551.83); }`; /* -------------------------------------------- */ /** * A Vec2 pseudo-random generator, based on uv position * @type {string} */ static PRNG2D = ` vec2 random(in vec2 uv) { vec2 uvf = fract(uv * vec2(0.1031, 0.1030)); uvf += dot(uvf, uvf.yx + 19.19); return fract((uvf.x + uvf.y) * uvf); }`; /* -------------------------------------------- */ /** * A Vec3 pseudo-random generator, based on uv position * @type {string} */ static PRNG3D = ` vec3 random(in vec3 uv) { return vec3(fract(cos(dot(uv, vec3(12.9898, 234.1418, 152.01))) * 43758.5453), fract(sin(dot(uv, vec3(80.9898, 545.8937, 151515.12))) * 23411.1789), fract(cos(dot(uv, vec3(01.9898, 1568.5439, 154.78))) * 31256.8817)); }`; /* -------------------------------------------- */ /** * A conventional noise generator * @type {string} */ static NOISE = ` float noise(in vec2 uv) { const vec2 d = vec2(0.0, 1.0); vec2 b = floor(uv); vec2 f = smoothstep(vec2(0.), vec2(1.0), fract(uv)); return mix( mix(random(b), random(b + d.yx), f.x), mix(random(b + d.xy), random(b + d.yy), f.x), f.y ); }`; /* -------------------------------------------- */ /** * Convert a Hue-Saturation-Brightness color to RGB - useful to convert polar coordinates to RGB * @type {string} */ static HSB2RGB = ` vec3 hsb2rgb(in vec3 c) { vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0 ); rgb = rgb*rgb*(3.0-2.0*rgb); return c.z * mix(vec3(1.0), rgb, c.y); }`; /* -------------------------------------------- */ /** * Declare a wave function in a shader -> wcos (default), wsin or wtan. * Wave on the [v1,v2] range with amplitude -> a and speed -> speed. * @param {string} [func="cos"] the math function to use * @returns {string} */ static WAVE(func="cos") { return ` float w${func}(in float v1, in float v2, in float a, in float speed) { float w = ${func}( speed + a ) + 1.0; return (v1 - v2) * (w * 0.5) + v2; }`; } /* -------------------------------------------- */ /** * Rotation function. * @type {string} */ static ROTATION = ` mat2 rot(in float a) { float s = sin(a); float c = cos(a); return mat2(c, -s, s, c); } `; /* -------------------------------------------- */ /** * Voronoi noise function. Needs PRNG2D and CONSTANTS. * @see PRNG2D * @see CONSTANTS * @type {string} */ static VORONOI = ` vec3 voronoi(in vec2 uv, in float t, in float zd) { vec3 vor = vec3(0.0, 0.0, zd); vec2 uvi = floor(uv); vec2 uvf = fract(uv); for ( float j = -1.0; j <= 1.0; j++ ) { for ( float i = -1.0; i <= 1.0; i++ ) { vec2 uvn = vec2(i, j); vec2 uvr = 0.5 * sin(TWOPI * random(uvi + uvn) + t) + 0.5; uvr = 0.5 * sin(TWOPI * uvr + t) + 0.5; vec2 uvd = uvn + uvr - uvf; float dist = length(uvd); if ( dist < vor.z ) { vor.xy = uvr; vor.z = dist; } } } return vor; } vec3 voronoi(in vec2 vuv, in float zd) { return voronoi(vuv, 0.0, zd); } vec3 voronoi(in vec3 vuv, in float zd) { return voronoi(vuv.xy, vuv.z, zd); } `; /* -------------------------------------------- */ /** * Enables GLSL 1.0 backwards compatibility in GLSL 3.00 ES vertex shaders. * @type {string} */ static GLSL1_COMPATIBILITY_VERTEX = ` #define attribute in #define varying out `; /* -------------------------------------------- */ /** * Enables GLSL 1.0 backwards compatibility in GLSL 3.00 ES fragment shaders. * @type {string} */ static GLSL1_COMPATIBILITY_FRAGMENT = ` #define varying in #define texture2D texture #define textureCube texture #define texture2DProj textureProj #define texture2DLodEXT textureLod #define texture2DProjLodEXT textureProjLod #define textureCubeLodEXT textureLod #define texture2DGradEXT textureGrad #define texture2DProjGradEXT textureProjGrad #define textureCubeGradEXT textureGrad #define gl_FragDepthEXT gl_FragDepth `; } return BaseShaderMixin; }; /** * This class defines an interface which all shaders utilize. * @extends {PIXI.Shader} * @property {PIXI.Program} program The program to use with this shader. * @property {object} uniforms The current uniforms of the Shader. * @mixes BaseShaderMixin * @abstract */ class AbstractBaseShader extends BaseShaderMixin(PIXI.Shader) { constructor(program, uniforms) { super(program, foundry.utils.deepClone(uniforms)); /** * The initial values of the shader uniforms. * @type {object} */ this.initialUniforms = uniforms; } /* -------------------------------------------- */ /** * The raw vertex shader used by this class. * A subclass of AbstractBaseShader must implement the vertexShader static field. * @type {string} */ static vertexShader = ""; /** * The raw fragment shader used by this class. * A subclass of AbstractBaseShader must implement the fragmentShader static field. * @type {string} */ static fragmentShader = ""; /** * The default uniform values for the shader. * A subclass of AbstractBaseShader must implement the defaultUniforms static field. * @type {object} */ static defaultUniforms = {}; /* -------------------------------------------- */ /** * A factory method for creating the shader using its defined default values * @param {object} initialUniforms * @returns {AbstractBaseShader} */ static create(initialUniforms) { const program = PIXI.Program.from(this.vertexShader, this.fragmentShader); const uniforms = foundry.utils.mergeObject(this.defaultUniforms, initialUniforms, {inplace: false, insertKeys: false}); const shader = new this(program, uniforms); shader._configure(); return shader; } /* -------------------------------------------- */ /** * Reset the shader uniforms back to their initial values. */ reset() { for (let [k, v] of Object.entries(this.initialUniforms)) { this.uniforms[k] = foundry.utils.deepClone(v); } } /* ---------------------------------------- */ /** * A one time initialization performed on creation. * @protected */ _configure() {} /* ---------------------------------------- */ /** * Perform operations which are required before binding the Shader to the Renderer. * @param {PIXI.DisplayObject} mesh The mesh display object linked to this shader. * @param {PIXI.Renderer} renderer The renderer * @protected * @internal */ _preRender(mesh, renderer) {} /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get _defaults() { const msg = "AbstractBaseShader#_defaults is deprecated in favor of AbstractBaseShader#initialUniforms."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return this.initialUniforms; } } /** * A mixin wich decorates a shader or filter and construct a fragment shader according to a choosen channel. * @category - Mixins * @param {typeof PIXI.Shader|PIXI.Filter} ShaderClass The parent ShaderClass class being mixed. * @returns {typeof AdaptiveFragmentChannelMixin} A Shader/Filter subclass mixed with AdaptiveFragmentChannelMixin. * @mixin */ const AdaptiveFragmentChannelMixin = ShaderClass => { class AdaptiveFragmentChannelMixin extends ShaderClass { /** * The fragment shader which renders this filter. * A subclass of AdaptiveFragmentChannelMixin must implement the fragmentShader static field. * @type {Function} */ static adaptiveFragmentShader = null; /** * A factory method for creating the filter using its defined default values * @param {object} [options] Options which affect filter construction * @param {object} [options.uniforms] Initial uniforms provided to the filter/shader * @param {string} [options.channel="r"] The color channel to target for masking * @returns {PIXI.Shader|PIXI.Filter} */ static create({channel="r", ...uniforms}={}) { this.fragmentShader = this.adaptiveFragmentShader(channel); return super.create(uniforms); } } return AdaptiveFragmentChannelMixin; }; /** * An abstract filter which provides a framework for reusable definition * @extends {PIXI.Filter} * @mixes BaseShaderMixin * @abstract */ class AbstractBaseFilter extends BaseShaderMixin(PIXI.Filter) { /** * The default uniforms used by the filter * @type {object} */ static defaultUniforms = {}; /** * The fragment shader which renders this filter. * @type {string} */ static fragmentShader = undefined; /** * The vertex shader which renders this filter. * @type {string} */ static vertexShader = undefined; /** * A factory method for creating the filter using its defined default values. * @param {object} [initialUniforms] Initial uniform values which override filter defaults * @returns {AbstractBaseFilter} The constructed AbstractFilter instance. */ static create(initialUniforms={}) { return new this(this.vertexShader, this.fragmentShader, {...this.defaultUniforms, ...initialUniforms}); } } /** * The base sampler shader exposes a simple sprite shader and all the framework to handle: * - Batched shaders and plugin subscription * - Configure method (for special processing done once or punctually) * - Update method (pre-binding, normally done each frame) * All other sampler shaders (batched or not) should extend BaseSamplerShader */ class BaseSamplerShader extends AbstractBaseShader { /** * The named batch sampler plugin that is used by this shader, or null if no batching is used. * @type {string|null} */ static classPluginName = "batch"; /** * Is this shader pausable or not? * @type {boolean} */ static pausable = true; /** * The plugin name associated for this instance, if any. * Returns "batch" if the shader is disabled. * @type {string|null} */ get pluginName() { return this.#pluginName; } #pluginName = this.constructor.classPluginName; /** * Activate or deactivate this sampler. If set to false, the batch rendering is redirected to "batch". * Otherwise, the batch rendering is directed toward the instance pluginName (might be null) * @type {boolean} */ get enabled() { return this.#enabled; } set enabled(enabled) { this.#pluginName = enabled ? this.constructor.classPluginName : "batch"; this.#enabled = enabled; } #enabled = true; /** * Pause or Unpause this sampler. If set to true, the shader is disabled. Otherwise, it is enabled. * Contrary to enabled, a shader might decide to refuse a pause, to continue to render animations per example. * @see {enabled} * @type {boolean} */ get paused() { return !this.#enabled; } set paused(paused) { if ( !this.constructor.pausable ) return; this.enabled = !paused; } /** * Contrast adjustment * @type {string} */ static CONTRAST = ` // Computing contrasted color if ( contrast != 0.0 ) { changedColor = (changedColor - 0.5) * (contrast + 1.0) + 0.5; }`; /** * Saturation adjustment * @type {string} */ static SATURATION = ` // Computing saturated color if ( saturation != 0.0 ) { vec3 grey = vec3(perceivedBrightness(changedColor)); changedColor = mix(grey, changedColor, 1.0 + saturation); }`; /** * Exposure adjustment. * @type {string} */ static EXPOSURE = ` if ( exposure != 0.0 ) { changedColor *= (1.0 + exposure); }`; /** * The adjustments made into fragment shaders. * @type {string} */ static get ADJUSTMENTS() { return `vec3 changedColor = baseColor.rgb; ${this.CONTRAST} ${this.SATURATION} ${this.EXPOSURE} baseColor.rgb = changedColor;`; } /** @override */ static vertexShader = ` precision ${PIXI.settings.PRECISION_VERTEX} float; attribute vec2 aVertexPosition; attribute vec2 aTextureCoord; uniform mat3 projectionMatrix; varying vec2 vUvs; void main() { gl_Position = vec4((projectionMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0); vUvs = aTextureCoord; } `; /** @override */ static fragmentShader = ` precision ${PIXI.settings.PRECISION_FRAGMENT} float; uniform sampler2D sampler; uniform vec4 tintAlpha; varying vec2 vUvs; void main() { gl_FragColor = texture2D(sampler, vUvs) * tintAlpha; } `; /** * The batch vertex shader source. * @type {string} */ static batchVertexShader = ` #version 300 es precision ${PIXI.settings.PRECISION_VERTEX} float; in vec2 aVertexPosition; in vec2 aTextureCoord; in vec4 aColor; in float aTextureId; uniform mat3 projectionMatrix; uniform mat3 translationMatrix; uniform vec4 tint; out vec2 vTextureCoord; flat out vec4 vColor; flat out float vTextureId; void main(void){ gl_Position = vec4((projectionMatrix * translationMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0); vTextureCoord = aTextureCoord; vTextureId = aTextureId; vColor = aColor * tint; } `; /** * The batch fragment shader source. * @type {string} */ static batchFragmentShader = ` #version 300 es precision ${PIXI.settings.PRECISION_FRAGMENT} float; in vec2 vTextureCoord; flat in vec4 vColor; flat in float vTextureId; uniform sampler2D uSamplers[%count%]; out vec4 fragColor; #define texture2D texture void main(void){ vec4 color; %forloop% fragColor = color * vColor; } `; /** @inheritdoc */ static defaultUniforms = { sampler: 0, tintAlpha: [1, 1, 1, 1] }; /** * Batch geometry associated with this sampler. * @type {typeof PIXI.BatchGeometry|{id: string, size: number, normalized: boolean, type: PIXI.TYPES}[]} */ static batchGeometry = PIXI.BatchGeometry; /** * The size of a vertice with all its packed attributes. * @type {number} */ static batchVertexSize = 6; /** * Pack interleaved geometry custom function. * @type {Function|undefined} * @protected */ static _packInterleavedGeometry; /** * A prerender function happening just before the batch renderer is flushed. * @type {(batchRenderer: BatchRenderer) => void | undefined} * @protected */ static _preRenderBatch; /** * A function that returns default uniforms associated with the batched version of this sampler. * @type {object} */ static batchDefaultUniforms = {}; /** * The number of reserved texture units for this shader that cannot be used by the batch renderer. * @type {number} */ static reservedTextureUnits = 0; /** * Initialize the batch geometry with custom properties. */ static initializeBatchGeometry() {} /** * The batch renderer to use. * @type {typeof BatchRenderer} */ static batchRendererClass = BatchRenderer; /** * The batch generator to use. * @type {typeof BatchShaderGenerator} */ static batchShaderGeneratorClass = BatchShaderGenerator; /* ---------------------------------------- */ /** * Create a batch plugin for this sampler class. * @returns {typeof BatchPlugin} The batch plugin class linked to this sampler class. */ static createPlugin() { const shaderClass = this; const geometryClass = Array.isArray(shaderClass.batchGeometry) ? class BatchGeometry extends PIXI.Geometry { constructor(_static=false) { super(); this._buffer = new PIXI.Buffer(null, _static, false); this._indexBuffer = new PIXI.Buffer(null, _static, true); for ( const {id, size, normalized, type} of shaderClass.batchGeometry ) { this.addAttribute(id, this._buffer, size, normalized, type); } this.addIndex(this._indexBuffer); } } : shaderClass.batchGeometry; return class BatchPlugin extends shaderClass.batchRendererClass { /** @override */ static get shaderGeneratorClass() { return shaderClass.batchShaderGeneratorClass; } /* ---------------------------------------- */ /** @override */ static get defaultVertexSrc() { return shaderClass.batchVertexShader; } /* ---------------------------------------- */ /** @override */ static get defaultFragmentTemplate() { return shaderClass.batchFragmentShader; } /* ---------------------------------------- */ /** @override */ static get defaultUniforms() { return shaderClass.batchDefaultUniforms; } /* ---------------------------------------- */ /** * The batch plugin constructor. * @param {PIXI.Renderer} renderer The renderer */ constructor(renderer) { super(renderer); this.geometryClass = geometryClass; this.vertexSize = shaderClass.batchVertexSize; this.reservedTextureUnits = shaderClass.reservedTextureUnits; this._packInterleavedGeometry = shaderClass._packInterleavedGeometry; this._preRenderBatch = shaderClass._preRenderBatch; } /* ---------------------------------------- */ /** @inheritdoc */ setShaderGenerator(options) { if ( !canvas.performance ) return; super.setShaderGenerator(options); } /* ---------------------------------------- */ /** @inheritdoc */ contextChange() { this.shaderGenerator = null; super.contextChange(); } }; } /* ---------------------------------------- */ /** * Register the plugin for this sampler. * @param {object} [options] The options * @param {object} [options.force=false] Override the plugin of the same name that is already registered? */ static registerPlugin({force=false}={}) { const pluginName = this.classPluginName; // Checking the pluginName if ( !(pluginName && (typeof pluginName === "string") && (pluginName.length > 0)) ) { const msg = `Impossible to create a PIXI plugin for ${this.name}. ` + `The plugin name is invalid: [pluginName=${pluginName}]. ` + "The plugin name must be a string with at least 1 character."; throw new Error(msg); } // Checking for existing plugins if ( !force && BatchRenderer.hasPlugin(pluginName) ) { const msg = `Impossible to create a PIXI plugin for ${this.name}. ` + `The plugin name is already associated to a plugin in PIXI.Renderer: [pluginName=${pluginName}].`; throw new Error(msg); } // Initialize custom properties for the batch geometry this.initializeBatchGeometry(); // Create our custom batch renderer for this geometry const plugin = this.createPlugin(); // Register this plugin with its batch renderer PIXI.extensions.add({ name: pluginName, type: PIXI.ExtensionType.RendererPlugin, ref: plugin }); } /* ---------------------------------------- */ /** @override */ _preRender(mesh, renderer) { const uniforms = this.uniforms; uniforms.sampler = mesh.texture; uniforms.tintAlpha = mesh._cachedTint; } } /* eslint-disable no-tabs */ /** * @typedef {Object} ShaderTechnique * @property {number} id The numeric identifier of the technique * @property {string} label The localization string that labels the technique * @property {string|undefined} coloration The coloration shader fragment when the technique is used * @property {string|undefined} illumination The illumination shader fragment when the technique is used * @property {string|undefined} background The background shader fragment when the technique is used */ /** * This class defines an interface which all adaptive lighting shaders extend. */ class AdaptiveLightingShader extends AbstractBaseShader { /** * Has this lighting shader a forced default color? * @type {boolean} */ static forceDefaultColor = false; /* -------------------------------------------- */ /** Called before rendering. */ update() { this.uniforms.depthElevation = canvas.masks.depth.mapElevation(this.uniforms.elevation ?? 0); } /* -------------------------------------------- */ /** * Common attributes for vertex shaders. * @type {string} */ static VERTEX_ATTRIBUTES = ` attribute vec2 aVertexPosition; attribute float aDepthValue; `; /** * Common uniforms for vertex shaders. * @type {string} */ static VERTEX_UNIFORMS = ` uniform mat3 translationMatrix; uniform mat3 projectionMatrix; uniform float rotation; uniform float angle; uniform float radius; uniform float depthElevation; uniform vec2 screenDimensions; uniform vec2 resolution; uniform vec3 origin; uniform vec3 dimensions; `; /** * Common varyings shared by vertex and fragment shaders. * @type {string} */ static VERTEX_FRAGMENT_VARYINGS = ` varying vec2 vUvs; varying vec2 vSamplerUvs; varying float vDepth; `; /** * Common functions used by the vertex shaders. * @type {string} * @abstract */ static VERTEX_FUNCTIONS = ""; /** * Common uniforms shared by fragment shaders. * @type {string} */ static FRAGMENT_UNIFORMS = ` uniform int technique; uniform bool useSampler; uniform bool hasColor; uniform bool computeIllumination; uniform bool linkedToDarknessLevel; uniform bool enableVisionMasking; uniform bool globalLight; uniform float attenuation; uniform float borderDistance; uniform float contrast; uniform float shadows; uniform float exposure; uniform float saturation; uniform float intensity; uniform float brightness; uniform float luminosity; uniform float pulse; uniform float brightnessPulse; uniform float backgroundAlpha; uniform float illuminationAlpha; uniform float colorationAlpha; uniform float ratio; uniform float time; uniform float darknessLevel; uniform float darknessPenalty; uniform vec2 globalLightThresholds; uniform vec3 color; uniform vec3 colorBackground; uniform vec3 colorVision; uniform vec3 colorTint; uniform vec3 colorEffect; uniform vec3 colorDim; uniform vec3 colorBright; uniform vec3 ambientDaylight; uniform vec3 ambientDarkness; uniform vec3 ambientBrightest; uniform int dimLevelCorrection; uniform int brightLevelCorrection; uniform vec4 weights; uniform sampler2D primaryTexture; uniform sampler2D framebufferTexture; uniform sampler2D depthTexture; uniform sampler2D darknessLevelTexture; uniform sampler2D visionTexture; // Shared uniforms with vertex shader uniform ${PIXI.settings.PRECISION_VERTEX} float rotation; uniform ${PIXI.settings.PRECISION_VERTEX} float angle; uniform ${PIXI.settings.PRECISION_VERTEX} float radius; uniform ${PIXI.settings.PRECISION_VERTEX} float depthElevation; uniform ${PIXI.settings.PRECISION_VERTEX} vec2 resolution; uniform ${PIXI.settings.PRECISION_VERTEX} vec2 screenDimensions; uniform ${PIXI.settings.PRECISION_VERTEX} vec3 origin; uniform ${PIXI.settings.PRECISION_VERTEX} vec3 dimensions; uniform ${PIXI.settings.PRECISION_VERTEX} mat3 translationMatrix; uniform ${PIXI.settings.PRECISION_VERTEX} mat3 projectionMatrix; `; /** * Common functions used by the fragment shaders. * @type {string} * @abstract */ static FRAGMENT_FUNCTIONS = ` #define DARKNESS -2 #define HALFDARK -1 #define UNLIT 0 #define DIM 1 #define BRIGHT 2 #define BRIGHTEST 3 vec3 computedDimColor; vec3 computedBrightColor; vec3 computedBackgroundColor; float computedDarknessLevel; vec3 getCorrectedColor(int level) { if ( (level == HALFDARK) || (level == DIM) ) { return computedDimColor; } else if ( (level == BRIGHT) || (level == DARKNESS) ) { return computedBrightColor; } else if ( level == BRIGHTEST ) { return ambientBrightest; } else if ( level == UNLIT ) { return computedBackgroundColor; } return computedDimColor; } `; /** @inheritdoc */ static CONSTANTS = ` ${super.CONSTANTS} const float INVTHREE = 1.0 / 3.0; const vec2 PIVOT = vec2(0.5); const vec4 ALLONES = vec4(1.0); `; /** @inheritdoc */ static vertexShader = ` ${this.VERTEX_ATTRIBUTES} ${this.VERTEX_UNIFORMS} ${this.VERTEX_FRAGMENT_VARYINGS} ${this.VERTEX_FUNCTIONS} void main() { vec3 tPos = translationMatrix * vec3(aVertexPosition, 1.0); vUvs = aVertexPosition * 0.5 + 0.5; vDepth = aDepthValue; vSamplerUvs = tPos.xy / screenDimensions; gl_Position = vec4((projectionMatrix * tPos).xy, 0.0, 1.0); }`; /* -------------------------------------------- */ /* GLSL Helper Functions */ /* -------------------------------------------- */ /** * Construct adaptive shader according to shader type * @param {string} shaderType shader type to construct : coloration, illumination, background, etc. * @returns {string} the constructed shader adaptive block */ static getShaderTechniques(shaderType) { let shader = ""; let index = 0; for ( let technique of Object.values(this.SHADER_TECHNIQUES) ) { if ( technique[shaderType] ) { let cond = `if ( technique == ${technique.id} )`; if ( index > 0 ) cond = `else ${cond}`; shader += `${cond} {${technique[shaderType]}\n}\n`; index++; } } return shader; } /* -------------------------------------------- */ /** * The coloration technique coloration shader fragment * @type {string} */ static get COLORATION_TECHNIQUES() { return this.getShaderTechniques("coloration"); } /* -------------------------------------------- */ /** * The coloration technique illumination shader fragment * @type {string} */ static get ILLUMINATION_TECHNIQUES() { return this.getShaderTechniques("illumination"); } /* -------------------------------------------- */ /** * The coloration technique background shader fragment * @type {string} */ static get BACKGROUND_TECHNIQUES() { return this.getShaderTechniques("background"); } /* -------------------------------------------- */ /** * The adjustments made into fragment shaders * @type {string} */ static get ADJUSTMENTS() { return `vec3 changedColor = finalColor;\n ${this.CONTRAST} ${this.SATURATION} ${this.EXPOSURE} ${this.SHADOW} if ( useSampler ) finalColor = changedColor;`; } /* -------------------------------------------- */ /** * Contrast adjustment * @type {string} */ static CONTRAST = ` // Computing contrasted color if ( contrast != 0.0 ) { changedColor = (changedColor - 0.5) * (contrast + 1.0) + 0.5; }`; /* -------------------------------------------- */ /** * Saturation adjustment * @type {string} */ static SATURATION = ` // Computing saturated color if ( saturation != 0.0 ) { vec3 grey = vec3(perceivedBrightness(changedColor)); changedColor = mix(grey, changedColor, 1.0 + saturation); }`; /* -------------------------------------------- */ /** * Exposure adjustment * @type {string} */ static EXPOSURE = ` // Computing exposed color for background if ( exposure > 0.0 ) { float halfExposure = exposure * 0.5; float attenuationStrength = attenuation * 0.25; float lowerEdge = 0.98 - attenuationStrength; float upperEdge = 1.02 + attenuationStrength; float finalExposure = halfExposure * (1.0 - smoothstep(ratio * lowerEdge, clamp(ratio * upperEdge, 0.0001, 1.0), dist)) + halfExposure; changedColor *= (1.0 + finalExposure); } `; /* -------------------------------------------- */ /** * Switch between an inner and outer color, by comparing distance from center to ratio * Apply a strong gradient between the two areas if attenuation uniform is set to true * @type {string} */ static SWITCH_COLOR = ` vec3 switchColor( in vec3 innerColor, in vec3 outerColor, in float dist ) { float attenuationStrength = attenuation * 0.7; float lowerEdge = 0.99 - attenuationStrength; float upperEdge = 1.01 + attenuationStrength; return mix(innerColor, outerColor, smoothstep(ratio * lowerEdge, clamp(ratio * upperEdge, 0.0001, 1.0), dist)); }`; /* -------------------------------------------- */ /** * Shadow adjustment * @type {string} */ static SHADOW = ` // Computing shadows if ( shadows != 0.0 ) { float shadowing = mix(1.0, smoothstep(0.50, 0.80, perceivedBrightness(changedColor)), shadows); // Applying shadow factor changedColor *= shadowing; }`; /* -------------------------------------------- */ /** * Transition between bright and dim colors, if requested * @type {string} */ static TRANSITION = ` finalColor = switchColor(computedBrightColor, computedDimColor, dist);`; /** * Incorporate falloff if a attenuation uniform is requested * @type {string} */ static FALLOFF = ` if ( attenuation != 0.0 ) depth *= smoothstep(1.0, 1.0 - attenuation, dist); `; /** * Compute illumination uniforms * @type {string} */ static COMPUTE_ILLUMINATION = ` float weightDark = weights.x; float weightHalfdark = weights.y; float weightDim = weights.z; float weightBright = weights.w; if ( computeIllumination ) { computedDarknessLevel = texture2D(darknessLevelTexture, vSamplerUvs).r; computedBackgroundColor = mix(ambientDaylight, ambientDarkness, computedDarknessLevel); computedBrightColor = mix(computedBackgroundColor, ambientBrightest, weightBright); computedDimColor = mix(computedBackgroundColor, computedBrightColor, weightDim); // Apply lighting levels vec3 correctedComputedBrightColor = getCorrectedColor(brightLevelCorrection); vec3 correctedComputedDimColor = getCorrectedColor(dimLevelCorrection); computedBrightColor = correctedComputedBrightColor; computedDimColor = correctedComputedDimColor; } else { computedBackgroundColor = colorBackground; computedDimColor = colorDim; computedBrightColor = colorBright; computedDarknessLevel = darknessLevel; } computedDimColor = max(computedDimColor, computedBackgroundColor); computedBrightColor = max(computedBrightColor, computedBackgroundColor); if ( globalLight && ((computedDarknessLevel < globalLightThresholds[0]) || (computedDarknessLevel > globalLightThresholds[1])) ) discard; `; /** * Initialize fragment with common properties * @type {string} */ static FRAGMENT_BEGIN = ` ${this.COMPUTE_ILLUMINATION} float dist = distance(vUvs, vec2(0.5)) * 2.0; vec4 depthColor = texture2D(depthTexture, vSamplerUvs); float depth = smoothstep(0.0, 1.0, vDepth) * step(depthColor.g, depthElevation) * step(depthElevation, (254.5 / 255.0) - depthColor.r); vec4 baseColor = useSampler ? texture2D(primaryTexture, vSamplerUvs) : vec4(1.0); vec3 finalColor = baseColor.rgb; `; /** * Shader final * @type {string} */ static FRAGMENT_END = ` gl_FragColor = vec4(finalColor, 1.0) * depth; `; /* -------------------------------------------- */ /* Shader Techniques for lighting */ /* -------------------------------------------- */ /** * A mapping of available shader techniques * @type {Record} */ static SHADER_TECHNIQUES = { LEGACY: { id: 0, label: "LIGHT.LegacyColoration" }, LUMINANCE: { id: 1, label: "LIGHT.AdaptiveLuminance", coloration: ` float reflection = perceivedBrightness(baseColor); finalColor *= reflection;` }, INTERNAL_HALO: { id: 2, label: "LIGHT.InternalHalo", coloration: ` float reflection = perceivedBrightness(baseColor); finalColor = switchColor(finalColor, finalColor * reflection, dist);` }, EXTERNAL_HALO: { id: 3, label: "LIGHT.ExternalHalo", coloration: ` float reflection = perceivedBrightness(baseColor); finalColor = switchColor(finalColor * reflection, finalColor, dist);` }, COLOR_BURN: { id: 4, label: "LIGHT.ColorBurn", coloration: ` float reflection = perceivedBrightness(baseColor); finalColor = (finalColor * (1.0 - sqrt(reflection))) / clamp(baseColor.rgb * 2.0, 0.001, 0.25);` }, INTERNAL_BURN: { id: 5, label: "LIGHT.InternalBurn", coloration: ` float reflection = perceivedBrightness(baseColor); finalColor = switchColor((finalColor * (1.0 - sqrt(reflection))) / clamp(baseColor.rgb * 2.0, 0.001, 0.25), finalColor * reflection, dist);` }, EXTERNAL_BURN: { id: 6, label: "LIGHT.ExternalBurn", coloration: ` float reflection = perceivedBrightness(baseColor); finalColor = switchColor(finalColor * reflection, (finalColor * (1.0 - sqrt(reflection))) / clamp(baseColor.rgb * 2.0, 0.001, 0.25), dist);` }, LOW_ABSORPTION: { id: 7, label: "LIGHT.LowAbsorption", coloration: ` float reflection = perceivedBrightness(baseColor); reflection *= smoothstep(0.35, 0.75, reflection); finalColor *= reflection;` }, HIGH_ABSORPTION: { id: 8, label: "LIGHT.HighAbsorption", coloration: ` float reflection = perceivedBrightness(baseColor); reflection *= smoothstep(0.55, 0.85, reflection); finalColor *= reflection;` }, INVERT_ABSORPTION: { id: 9, label: "LIGHT.InvertAbsorption", coloration: ` float r = reversePerceivedBrightness(baseColor); finalColor *= (r * r * r * r * r);` }, NATURAL_LIGHT: { id: 10, label: "LIGHT.NaturalLight", coloration: ` float reflection = perceivedBrightness(baseColor); finalColor *= reflection;`, background: ` float ambientColorIntensity = perceivedBrightness(computedBackgroundColor); vec3 mutedColor = mix(finalColor, finalColor * mix(color, computedBackgroundColor, ambientColorIntensity), backgroundAlpha); finalColor = mix( finalColor, mutedColor, computedDarknessLevel);` } }; /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getDarknessPenalty(darknessLevel, luminosity) { const msg = "AdaptiveLightingShader#getDarknessPenalty is deprecated without replacement. " + "The darkness penalty is no longer applied on light and vision sources."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return 0; } } /** * This class defines an interface which all adaptive vision shaders extend. */ class AdaptiveVisionShader extends AdaptiveLightingShader { /** @inheritDoc */ static FRAGMENT_FUNCTIONS = ` ${super.FRAGMENT_FUNCTIONS} vec3 computedVisionColor; `; /* -------------------------------------------- */ /** @override */ static EXPOSURE = ` // Computing exposed color for background if ( exposure != 0.0 ) { changedColor *= (1.0 + exposure); }`; /* -------------------------------------------- */ /** @inheritDoc */ static COMPUTE_ILLUMINATION = ` ${super.COMPUTE_ILLUMINATION} if ( computeIllumination ) computedVisionColor = mix(computedDimColor, computedBrightColor, brightness); else computedVisionColor = colorVision; `; /* -------------------------------------------- */ // FIXME: need to redeclare fragment begin here to take into account COMPUTE_ILLUMINATION // Do not work without this redeclaration. /** @override */ static FRAGMENT_BEGIN = ` ${this.COMPUTE_ILLUMINATION} float dist = distance(vUvs, vec2(0.5)) * 2.0; vec4 depthColor = texture2D(depthTexture, vSamplerUvs); float depth = smoothstep(0.0, 1.0, vDepth) * step(depthColor.g, depthElevation) * step(depthElevation, (254.5 / 255.0) - depthColor.r); vec4 baseColor = useSampler ? texture2D(primaryTexture, vSamplerUvs) : vec4(1.0); vec3 finalColor = baseColor.rgb; `; /* -------------------------------------------- */ /** @override */ static SHADOW = ""; /* -------------------------------------------- */ /* Shader Techniques for vision */ /* -------------------------------------------- */ /** * A mapping of available shader techniques * @type {Record} */ static SHADER_TECHNIQUES = { LEGACY: { id: 0, label: "LIGHT.AdaptiveLuminance", coloration: ` float reflection = perceivedBrightness(baseColor); finalColor *= reflection;` } }; } /** * The default coloration shader used by standard rendering and animations. * A fragment shader which creates a solid light source. */ class AdaptiveBackgroundShader extends AdaptiveLightingShader { /** * Memory allocations for the Adaptive Background Shader * @type {string} */ static SHADER_HEADER = ` ${this.FRAGMENT_UNIFORMS} ${this.VERTEX_FRAGMENT_VARYINGS} ${this.FRAGMENT_FUNCTIONS} ${this.CONSTANTS} ${this.SWITCH_COLOR} `; /** @inheritdoc */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} ${this.ADJUSTMENTS} ${this.BACKGROUND_TECHNIQUES} ${this.FALLOFF} ${this.FRAGMENT_END} }`; /** @override */ static defaultUniforms = { technique: 1, contrast: 0, shadows: 0, saturation: 0, intensity: 5, attenuation: 0.5, exposure: 0, ratio: 0.5, color: [1, 1, 1], colorBackground: [1, 1, 1], screenDimensions: [1, 1], time: 0, useSampler: true, primaryTexture: null, depthTexture: null, darknessLevelTexture: null, depthElevation: 1, ambientBrightest: [1, 1, 1], ambientDarkness: [0, 0, 0], ambientDaylight: [1, 1, 1], weights: [0, 0, 0, 0], dimLevelCorrection: 1, brightLevelCorrection: 2, computeIllumination: false, globalLight: false, globalLightThresholds: [0, 0] }; static { const initial = foundry.data.LightData.cleanData(); this.defaultUniforms.technique = initial.coloration; this.defaultUniforms.contrast = initial.contrast; this.defaultUniforms.shadows = initial.shadows; this.defaultUniforms.saturation = initial.saturation; this.defaultUniforms.intensity = initial.animation.intensity; this.defaultUniforms.attenuation = initial.attenuation; } /** * Flag whether the background shader is currently required. * Check vision modes requirements first, then * if key uniforms are at their default values, we don't need to render the background container. * @type {boolean} */ get isRequired() { const vs = canvas.visibility.lightingVisibility; // Checking if a vision mode is forcing the rendering if ( vs.background === VisionMode.LIGHTING_VISIBILITY.REQUIRED ) return true; // Checking if disabled if ( vs.background === VisionMode.LIGHTING_VISIBILITY.DISABLED ) return false; // Then checking keys const keys = ["contrast", "saturation", "shadows", "exposure", "technique"]; return keys.some(k => this.uniforms[k] !== this.constructor.defaultUniforms[k]); } } /** * The default coloration shader used by standard rendering and animations. * A fragment shader which creates a light source. */ class AdaptiveColorationShader extends AdaptiveLightingShader { /** @override */ static FRAGMENT_END = ` gl_FragColor = vec4(finalColor * depth, 1.0); `; /** * The adjustments made into fragment shaders * @type {string} */ static get ADJUSTMENTS() { return ` vec3 changedColor = finalColor;\n ${this.SATURATION} ${this.SHADOW} finalColor = changedColor;\n`; } /** @override */ static SHADOW = ` // Computing shadows if ( shadows != 0.0 ) { float shadowing = mix(1.0, smoothstep(0.25, 0.35, perceivedBrightness(baseColor.rgb)), shadows); // Applying shadow factor changedColor *= shadowing; } `; /** * Memory allocations for the Adaptive Coloration Shader * @type {string} */ static SHADER_HEADER = ` ${this.FRAGMENT_UNIFORMS} ${this.VERTEX_FRAGMENT_VARYINGS} ${this.FRAGMENT_FUNCTIONS} ${this.CONSTANTS} ${this.SWITCH_COLOR} `; /** @inheritdoc */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} finalColor = color * colorationAlpha; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; /** @inheritdoc */ static defaultUniforms = { technique: 1, shadows: 0, contrast: 0, saturation: 0, colorationAlpha: 1, intensity: 5, attenuation: 0.5, ratio: 0.5, color: [1, 1, 1], time: 0, hasColor: false, screenDimensions: [1, 1], useSampler: false, primaryTexture: null, depthTexture: null, darknessLevelTexture: null, depthElevation: 1, ambientBrightest: [1, 1, 1], ambientDarkness: [0, 0, 0], ambientDaylight: [1, 1, 1], weights: [0, 0, 0, 0], dimLevelCorrection: 1, brightLevelCorrection: 2, computeIllumination: false, globalLight: false, globalLightThresholds: [0, 0] }; static { const initial = foundry.data.LightData.cleanData(); this.defaultUniforms.technique = initial.coloration; this.defaultUniforms.contrast = initial.contrast; this.defaultUniforms.shadows = initial.shadows; this.defaultUniforms.saturation = initial.saturation; this.defaultUniforms.intensity = initial.animation.intensity; this.defaultUniforms.attenuation = initial.attenuation; } /** * Flag whether the coloration shader is currently required. * @type {boolean} */ get isRequired() { const vs = canvas.visibility.lightingVisibility; // Checking if a vision mode is forcing the rendering if ( vs.coloration === VisionMode.LIGHTING_VISIBILITY.REQUIRED ) return true; // Checking if disabled if ( vs.coloration === VisionMode.LIGHTING_VISIBILITY.DISABLED ) return false; // Otherwise, we need the coloration if it has color return this.constructor.forceDefaultColor || this.uniforms.hasColor; } } /** * The default coloration shader used by standard rendering and animations. * A fragment shader which creates a solid light source. */ class AdaptiveDarknessShader extends AdaptiveLightingShader { /** @override */ update() { super.update(); this.uniforms.darknessLevel = canvas.environment.darknessLevel; } /* -------------------------------------------- */ /** * Flag whether the darkness shader is currently required. * Check vision modes requirements first, then * if key uniforms are at their default values, we don't need to render the background container. * @type {boolean} */ get isRequired() { const vs = canvas.visibility.lightingVisibility; // Checking if darkness layer is disabled if ( vs.darkness === VisionMode.LIGHTING_VISIBILITY.DISABLED ) return false; // Otherwise, returns true in every circumstances return true; } /* -------------------------------------------- */ /* GLSL Statics */ /* -------------------------------------------- */ /** @override */ static defaultUniforms = { intensity: 5, color: Color.from("#8651d5").rgb, screenDimensions: [1, 1], time: 0, primaryTexture: null, depthTexture: null, visionTexture: null, darknessLevelTexture: null, depthElevation: 1, ambientBrightest: [1, 1, 1], ambientDarkness: [0, 0, 0], ambientDaylight: [1, 1, 1], weights: [0, 0, 0, 0], dimLevelCorrection: 1, brightLevelCorrection: 2, borderDistance: 0, darknessLevel: 0, computeIllumination: false, globalLight: false, globalLightThresholds: [0, 0], enableVisionMasking: false }; static { const initial = foundry.data.LightData.cleanData(); this.defaultUniforms.intensity = initial.animation.intensity; } /* -------------------------------------------- */ /** * Shader final * @type {string} */ static FRAGMENT_END = ` gl_FragColor = vec4(finalColor, 1.0) * depth; `; /* -------------------------------------------- */ /** * Initialize fragment with common properties * @type {string} */ static FRAGMENT_BEGIN = ` ${this.COMPUTE_ILLUMINATION} float dist = distance(vUvs, vec2(0.5)) * 2.0; vec4 depthColor = texture2D(depthTexture, vSamplerUvs); float depth = smoothstep(0.0, 1.0, vDepth) * step(depthColor.g, depthElevation) * step(depthElevation, (254.5 / 255.0) - depthColor.r) * (enableVisionMasking ? 1.0 - step(texture2D(visionTexture, vSamplerUvs).r, 0.0) : 1.0) * (1.0 - smoothstep(borderDistance, 1.0, dist)); vec4 baseColor = texture2D(primaryTexture, vSamplerUvs); vec3 finalColor = baseColor.rgb; `; /* -------------------------------------------- */ /** * Memory allocations for the Adaptive Background Shader * @type {string} */ static SHADER_HEADER = ` ${this.FRAGMENT_UNIFORMS} ${this.VERTEX_FRAGMENT_VARYINGS} ${this.FRAGMENT_FUNCTIONS} ${this.CONSTANTS} `; /* -------------------------------------------- */ /** @inheritdoc */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} finalColor *= (mix(color, color * 0.33, darknessLevel) * colorationAlpha); ${this.FRAGMENT_END} }`; } /** * The default coloration shader used by standard rendering and animations. * A fragment shader which creates a solid light source. */ class AdaptiveIlluminationShader extends AdaptiveLightingShader { /** @inheritdoc */ static FRAGMENT_BEGIN = ` ${super.FRAGMENT_BEGIN} vec3 framebufferColor = min(texture2D(framebufferTexture, vSamplerUvs).rgb, computedBackgroundColor); `; /** @override */ static FRAGMENT_END = ` gl_FragColor = vec4(mix(framebufferColor, finalColor, depth), 1.0); `; /** * The adjustments made into fragment shaders * @type {string} */ static get ADJUSTMENTS() { return ` vec3 changedColor = finalColor;\n ${this.SATURATION} ${this.EXPOSURE} ${this.SHADOW} finalColor = changedColor;\n`; } static EXPOSURE = ` // Computing exposure with illumination if ( exposure > 0.0 ) { // Diminishing exposure for illumination by a factor 2 (to reduce the "inflating radius" visual problem) float quartExposure = exposure * 0.25; float attenuationStrength = attenuation * 0.25; float lowerEdge = 0.98 - attenuationStrength; float upperEdge = 1.02 + attenuationStrength; float finalExposure = quartExposure * (1.0 - smoothstep(ratio * lowerEdge, clamp(ratio * upperEdge, 0.0001, 1.0), dist)) + quartExposure; changedColor *= (1.0 + finalExposure); } else if ( exposure != 0.0 ) changedColor *= (1.0 + exposure); `; /** * Memory allocations for the Adaptive Illumination Shader * @type {string} */ static SHADER_HEADER = ` ${this.FRAGMENT_UNIFORMS} ${this.VERTEX_FRAGMENT_VARYINGS} ${this.FRAGMENT_FUNCTIONS} ${this.CONSTANTS} ${this.SWITCH_COLOR} `; /** @inheritdoc */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} ${this.TRANSITION} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; /** @inheritdoc */ static defaultUniforms = { technique: 1, shadows: 0, saturation: 0, intensity: 5, attenuation: 0.5, contrast: 0, exposure: 0, ratio: 0.5, darknessLevel: 0, color: [1, 1, 1], colorBackground: [1, 1, 1], colorDim: [1, 1, 1], colorBright: [1, 1, 1], screenDimensions: [1, 1], time: 0, useSampler: false, primaryTexture: null, framebufferTexture: null, depthTexture: null, darknessLevelTexture: null, depthElevation: 1, ambientBrightest: [1, 1, 1], ambientDarkness: [0, 0, 0], ambientDaylight: [1, 1, 1], weights: [0, 0, 0, 0], dimLevelCorrection: 1, brightLevelCorrection: 2, computeIllumination: false, globalLight: false, globalLightThresholds: [0, 0] }; static { const initial = foundry.data.LightData.cleanData(); this.defaultUniforms.technique = initial.coloration; this.defaultUniforms.contrast = initial.contrast; this.defaultUniforms.shadows = initial.shadows; this.defaultUniforms.saturation = initial.saturation; this.defaultUniforms.intensity = initial.animation.intensity; this.defaultUniforms.attenuation = initial.attenuation; } /** * Flag whether the illumination shader is currently required. * @type {boolean} */ get isRequired() { const vs = canvas.visibility.lightingVisibility; // Checking if disabled if ( vs.illumination === VisionMode.LIGHTING_VISIBILITY.DISABLED ) return false; // For the moment, we return everytimes true if we are here return true; } } /** * The shader used by {@link RegionMesh}. */ class RegionShader extends AbstractBaseShader { /** @override */ static vertexShader = ` precision ${PIXI.settings.PRECISION_VERTEX} float; attribute vec2 aVertexPosition; uniform mat3 translationMatrix; uniform mat3 projectionMatrix; uniform vec2 canvasDimensions; uniform vec4 sceneDimensions; uniform vec2 screenDimensions; varying vec2 vCanvasCoord; // normalized canvas coordinates varying vec2 vSceneCoord; // normalized scene coordinates varying vec2 vScreenCoord; // normalized screen coordinates void main() { vec2 pixelCoord = aVertexPosition; vCanvasCoord = pixelCoord / canvasDimensions; vSceneCoord = (pixelCoord - sceneDimensions.xy) / sceneDimensions.zw; vec3 tPos = translationMatrix * vec3(aVertexPosition, 1.0); vScreenCoord = tPos.xy / screenDimensions; gl_Position = vec4((projectionMatrix * tPos).xy, 0.0, 1.0); } `; /** @override */ static fragmentShader = ` precision ${PIXI.settings.PRECISION_FRAGMENT} float; uniform vec4 tintAlpha; void main() { gl_FragColor = tintAlpha; } `; /* ---------------------------------------- */ /** @override */ static defaultUniforms = { canvasDimensions: [1, 1], sceneDimensions: [0, 0, 1, 1], screenDimensions: [1, 1], tintAlpha: [1, 1, 1, 1] }; /* ---------------------------------------- */ /** @override */ _preRender(mesh, renderer) { const uniforms = this.uniforms; uniforms.tintAlpha = mesh._cachedTint; const dimensions = canvas.dimensions; uniforms.canvasDimensions[0] = dimensions.width; uniforms.canvasDimensions[1] = dimensions.height; uniforms.sceneDimensions = dimensions.sceneRect; uniforms.screenDimensions = canvas.screenDimensions; } } /** * Abstract shader used for Adjust Darkness Level region behavior. * @abstract * @internal * @ignore */ class AbstractDarknessLevelRegionShader extends RegionShader { /** @inheritDoc */ static defaultUniforms = { ...super.defaultUniforms, bottom: 0, top: 0, depthTexture: null }; /* ---------------------------------------- */ /** * The darkness level adjustment mode. * @type {number} */ mode = foundry.data.regionBehaviors.AdjustDarknessLevelRegionBehaviorType.MODES.OVERRIDE; /* ---------------------------------------- */ /** * The darkness level modifier. * @type {number} */ modifier = 0; /* ---------------------------------------- */ /** * Current darkness level of this mesh. * @type {number} */ get darknessLevel() { const M = foundry.data.regionBehaviors.AdjustDarknessLevelRegionBehaviorType.MODES; switch ( this.mode ) { case M.OVERRIDE: return this.modifier; case M.BRIGHTEN: return canvas.environment.darknessLevel * (1 - this.modifier); case M.DARKEN: return 1 - ((1 - canvas.environment.darknessLevel) * (1 - this.modifier)); default: throw new Error("Invalid mode"); } } /* ---------------------------------------- */ /** @inheritDoc */ _preRender(mesh, renderer) { super._preRender(mesh, renderer); const u = this.uniforms; u.bottom = canvas.masks.depth.mapElevation(mesh.region.bottom); u.top = canvas.masks.depth.mapElevation(mesh.region.top); if ( !u.depthTexture ) u.depthTexture = canvas.masks.depth.renderTexture; } } /* ---------------------------------------- */ /** * Render the RegionMesh with darkness level adjustments. * @internal * @ignore */ class AdjustDarknessLevelRegionShader extends AbstractDarknessLevelRegionShader { /** @override */ static fragmentShader = ` precision ${PIXI.settings.PRECISION_FRAGMENT} float; uniform sampler2D depthTexture; uniform float darknessLevel; uniform float top; uniform float bottom; uniform vec4 tintAlpha; varying vec2 vScreenCoord; void main() { vec2 depthColor = texture2D(depthTexture, vScreenCoord).rg; float depth = step(depthColor.g, top) * step(bottom, (254.5 / 255.0) - depthColor.r); gl_FragColor = vec4(darknessLevel, 0.0, 0.0, 1.0) * tintAlpha * depth; } `; /* ---------------------------------------- */ /** @inheritDoc */ static defaultUniforms = { ...super.defaultUniforms, darknessLevel: 0 }; /* ---------------------------------------- */ /** @inheritDoc */ _preRender(mesh, renderer) { super._preRender(mesh, renderer); this.uniforms.darknessLevel = this.darknessLevel; } } /* ---------------------------------------- */ /** * Render the RegionMesh with darkness level adjustments. * @internal * @ignore */ class IlluminationDarknessLevelRegionShader extends AbstractDarknessLevelRegionShader { /** @override */ static fragmentShader = ` precision ${PIXI.settings.PRECISION_FRAGMENT} float; uniform sampler2D depthTexture; uniform float top; uniform float bottom; uniform vec4 tintAlpha; varying vec2 vScreenCoord; void main() { vec2 depthColor = texture2D(depthTexture, vScreenCoord).rg; float depth = step(depthColor.g, top) * step(bottom, (254.5 / 255.0) - depthColor.r); gl_FragColor = vec4(1.0) * tintAlpha * depth; } `; } /** * Shader for the Region highlight. * @internal * @ignore */ class HighlightRegionShader extends RegionShader { /** @override */ static vertexShader = `\ precision ${PIXI.settings.PRECISION_VERTEX} float; ${this.CONSTANTS} attribute vec2 aVertexPosition; uniform mat3 translationMatrix; uniform mat3 projectionMatrix; uniform vec2 canvasDimensions; uniform vec4 sceneDimensions; uniform vec2 screenDimensions; uniform mediump float hatchThickness; varying vec2 vCanvasCoord; // normalized canvas coordinates varying vec2 vSceneCoord; // normalized scene coordinates varying vec2 vScreenCoord; // normalized screen coordinates varying float vHatchOffset; void main() { vec2 pixelCoord = aVertexPosition; vCanvasCoord = pixelCoord / canvasDimensions; vSceneCoord = (pixelCoord - sceneDimensions.xy) / sceneDimensions.zw; vec3 tPos = translationMatrix * vec3(aVertexPosition, 1.0); vScreenCoord = tPos.xy / screenDimensions; gl_Position = vec4((projectionMatrix * tPos).xy, 0.0, 1.0); vHatchOffset = (pixelCoord.x + pixelCoord.y) / (SQRT2 * 2.0 * hatchThickness); } `; /* ---------------------------------------- */ /** @override */ static fragmentShader = `\ precision ${PIXI.settings.PRECISION_FRAGMENT} float; varying float vHatchOffset; uniform vec4 tintAlpha; uniform float resolution; uniform bool hatchEnabled; uniform mediump float hatchThickness; void main() { gl_FragColor = tintAlpha; if ( !hatchEnabled ) return; float x = abs(vHatchOffset - floor(vHatchOffset + 0.5)) * 2.0; float s = hatchThickness * resolution; float y0 = clamp((x + 0.5) * s + 0.5, 0.0, 1.0); float y1 = clamp((x - 0.5) * s + 0.5, 0.0, 1.0); gl_FragColor *= mix(0.3333, 1.0, y0 - y1); } `; /* ---------------------------------------- */ /** @inheritDoc */ static defaultUniforms = { ...super.defaultUniforms, resolution: 1, hatchEnabled: false, hatchThickness: 1 }; /** @inheritDoc */ _preRender(mesh, renderer) { super._preRender(mesh, renderer); const uniforms = this.uniforms; uniforms.resolution = (renderer.renderTexture.current ?? renderer).resolution * mesh.worldTransform.a; const projection = renderer.projection.transform; if ( projection ) { const {a, b} = projection; uniforms.resolution *= Math.sqrt((a * a) + (b * b)); } } } /** * The default background shader used for vision sources */ class BackgroundVisionShader extends AdaptiveVisionShader { /** @inheritdoc */ static FRAGMENT_END = ` finalColor *= colorTint; if ( linkedToDarknessLevel ) finalColor = mix(baseColor.rgb, finalColor, computedDarknessLevel); ${super.FRAGMENT_END} `; /** * Adjust the intensity according to the difference between the pixel darkness level and the scene darkness level. * Used to see the difference of intensity when computing the background shader which is completeley overlapping * The surface texture. * @type {string} */ static ADJUST_INTENSITY = ` float darknessLevelDifference = clamp(computedDarknessLevel - darknessLevel, 0.0, 1.0); finalColor = mix(finalColor, finalColor * 0.5, darknessLevelDifference); `; /** @inheritdoc */ static ADJUSTMENTS = ` ${this.ADJUST_INTENSITY} ${super.ADJUSTMENTS} `; /** * Memory allocations for the Adaptive Background Shader * @type {string} */ static SHADER_HEADER = ` ${this.FRAGMENT_UNIFORMS} ${this.VERTEX_FRAGMENT_VARYINGS} ${this.FRAGMENT_FUNCTIONS} ${this.CONSTANTS}`; /** @inheritdoc */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} ${this.ADJUSTMENTS} ${this.BACKGROUND_TECHNIQUES} ${this.FALLOFF} ${this.FRAGMENT_END} }`; /** @inheritdoc */ static defaultUniforms = { technique: 0, saturation: 0, contrast: 0, attenuation: 0.10, exposure: 0, darknessLevel: 0, colorVision: [1, 1, 1], colorTint: [1, 1, 1], colorBackground: [1, 1, 1], screenDimensions: [1, 1], time: 0, useSampler: true, linkedToDarknessLevel: true, primaryTexture: null, depthTexture: null, darknessLevelTexture: null, depthElevation: 1, ambientBrightest: [1, 1, 1], ambientDarkness: [0, 0, 0], ambientDaylight: [1, 1, 1], weights: [0, 0, 0, 0], dimLevelCorrection: 1, brightLevelCorrection: 2, globalLight: false, globalLightThresholds: [0, 0] }; /** * Flag whether the background shader is currently required. * If key uniforms are at their default values, we don't need to render the background container. * @type {boolean} */ get isRequired() { const keys = ["contrast", "saturation", "colorTint", "colorVision"]; return keys.some(k => this.uniforms[k] !== this.constructor.defaultUniforms[k]); } } /** * The default coloration shader used for vision sources. */ class ColorationVisionShader extends AdaptiveVisionShader { /** @override */ static EXPOSURE = ""; /** @override */ static CONTRAST = ""; /** * Memory allocations for the Adaptive Coloration Shader * @type {string} */ static SHADER_HEADER = ` ${this.FRAGMENT_UNIFORMS} ${this.VERTEX_FRAGMENT_VARYINGS} ${this.FRAGMENT_FUNCTIONS} ${this.CONSTANTS} `; /** @inheritdoc */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} finalColor = colorEffect; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; /** @inheritdoc */ static defaultUniforms = { technique: 0, saturation: 0, attenuation: 0, colorEffect: [0, 0, 0], colorBackground: [0, 0, 0], colorTint: [1, 1, 1], time: 0, screenDimensions: [1, 1], useSampler: true, primaryTexture: null, linkedToDarknessLevel: true, depthTexture: null, depthElevation: 1, ambientBrightest: [1, 1, 1], ambientDarkness: [0, 0, 0], ambientDaylight: [1, 1, 1], weights: [0, 0, 0, 0], dimLevelCorrection: 1, brightLevelCorrection: 2, globalLight: false, globalLightThresholds: [0, 0] }; /** * Flag whether the coloration shader is currently required. * If key uniforms are at their default values, we don't need to render the coloration container. * @type {boolean} */ get isRequired() { const keys = ["saturation", "colorEffect"]; return keys.some(k => this.uniforms[k] !== this.constructor.defaultUniforms[k]); } } /** * The default illumination shader used for vision sources */ class IlluminationVisionShader extends AdaptiveVisionShader { /** @inheritdoc */ static FRAGMENT_BEGIN = ` ${super.FRAGMENT_BEGIN} vec3 framebufferColor = min(texture2D(framebufferTexture, vSamplerUvs).rgb, computedBackgroundColor); `; /** @override */ static FRAGMENT_END = ` gl_FragColor = vec4(mix(framebufferColor, finalColor, depth), 1.0); `; /** * Transition between bright and dim colors, if requested * @type {string} */ static VISION_COLOR = ` finalColor = computedVisionColor; `; /** * The adjustments made into fragment shaders * @type {string} */ static get ADJUSTMENTS() { return ` vec3 changedColor = finalColor;\n ${this.SATURATION} finalColor = changedColor;\n`; } /** * Memory allocations for the Adaptive Illumination Shader * @type {string} */ static SHADER_HEADER = ` ${this.FRAGMENT_UNIFORMS} ${this.VERTEX_FRAGMENT_VARYINGS} ${this.FRAGMENT_FUNCTIONS} ${this.CONSTANTS} `; /** @inheritdoc */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} ${this.VISION_COLOR} ${this.ILLUMINATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; /** @inheritdoc */ static defaultUniforms = { technique: foundry.data.LightData.cleanData().initial, attenuation: 0, exposure: 0, saturation: 0, darknessLevel: 0, colorVision: [1, 1, 1], colorTint: [1, 1, 1], colorBackground: [1, 1, 1], screenDimensions: [1, 1], time: 0, useSampler: false, linkedToDarknessLevel: true, primaryTexture: null, framebufferTexture: null, depthTexture: null, darknessLevelTexture: null, depthElevation: 1, ambientBrightest: [1, 1, 1], ambientDarkness: [0, 0, 0], ambientDaylight: [1, 1, 1], weights: [0, 0, 0, 0], dimLevelCorrection: 1, brightLevelCorrection: 2, globalLight: false, globalLightThresholds: [0, 0] }; } /** * The batch data that is needed by {@link DepthSamplerShader} to render an element with batching. * @typedef {object} DepthBatchData * @property {PIXI.Texture} _texture The texture * @property {Float32Array} vertexData The vertices * @property {Uint16Array|Uint32Array|number[]} indices The indices * @property {Float32Array} uvs The texture UVs * @property {number} elevation The elevation * @property {number} textureAlphaThreshold The texture alpha threshold * @property {number} fadeOcclusion The amount of FADE occlusion * @property {number} radialOcclusion The amount of RADIAL occlusion * @property {number} visionOcclusion The amount of VISION occlusion */ /** * The depth sampler shader. */ class DepthSamplerShader extends BaseSamplerShader { /* -------------------------------------------- */ /* Batched version Rendering */ /* -------------------------------------------- */ /** @override */ static classPluginName = "batchDepth"; /* ---------------------------------------- */ /** @override */ static batchGeometry = [ {id: "aVertexPosition", size: 2, normalized: false, type: PIXI.TYPES.FLOAT}, {id: "aTextureCoord", size: 2, normalized: false, type: PIXI.TYPES.FLOAT}, {id: "aTextureId", size: 1, normalized: false, type: PIXI.TYPES.UNSIGNED_BYTE}, {id: "aTextureAlphaThreshold", size: 1, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE}, {id: "aDepthElevation", size: 1, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE}, {id: "aRestrictionState", size: 1, normalized: false, type: PIXI.TYPES.UNSIGNED_BYTE}, {id: "aOcclusionData", size: 4, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE} ]; /* ---------------------------------------- */ /** @override */ static batchVertexSize = 6; /* -------------------------------------------- */ /** @override */ static reservedTextureUnits = 1; // We need a texture unit for the occlusion texture /* -------------------------------------------- */ /** @override */ static defaultUniforms = { screenDimensions: [1, 1], sampler: null, occlusionTexture: null, textureAlphaThreshold: 0, depthElevation: 0, occlusionElevation: 0, fadeOcclusion: 0, radialOcclusion: 0, visionOcclusion: 0, restrictsLight: false, restrictsWeather: false }; /* -------------------------------------------- */ /** @override */ static batchDefaultUniforms(maxTex) { return { screenDimensions: [1, 1], occlusionTexture: maxTex }; } /* -------------------------------------------- */ /** @override */ static _preRenderBatch(batchRenderer) { const uniforms = batchRenderer._shader.uniforms; uniforms.screenDimensions = canvas.screenDimensions; batchRenderer.renderer.texture.bind(canvas.masks.occlusion.renderTexture, uniforms.occlusionTexture); } /* ---------------------------------------- */ /** @override */ static _packInterleavedGeometry(element, attributeBuffer, indexBuffer, aIndex, iIndex) { const {float32View, uint8View} = attributeBuffer; // Write indices into buffer const packedVertices = aIndex / this.vertexSize; const indices = element.indices; for ( let i = 0; i < indices.length; i++ ) { indexBuffer[iIndex++] = packedVertices + indices[i]; } // Prepare attributes const vertexData = element.vertexData; const uvs = element.uvs; const textureId = element._texture.baseTexture._batchLocation; const restrictionState = element.restrictionState; const textureAlphaThreshold = (element.textureAlphaThreshold * 255) | 0; const depthElevation = (canvas.masks.depth.mapElevation(element.elevation) * 255) | 0; const occlusionElevation = (canvas.masks.occlusion.mapElevation(element.elevation) * 255) | 0; const fadeOcclusion = (element.fadeOcclusion * 255) | 0; const radialOcclusion = (element.radialOcclusion * 255) | 0; const visionOcclusion = (element.visionOcclusion * 255) | 0; // Write attributes into buffer const vertexSize = this.vertexSize; for ( let i = 0, j = 0; i < vertexData.length; i += 2, j += vertexSize ) { let k = aIndex + j; float32View[k++] = vertexData[i]; float32View[k++] = vertexData[i + 1]; float32View[k++] = uvs[i]; float32View[k++] = uvs[i + 1]; k <<= 2; uint8View[k++] = textureId; uint8View[k++] = textureAlphaThreshold; uint8View[k++] = depthElevation; uint8View[k++] = restrictionState; uint8View[k++] = occlusionElevation; uint8View[k++] = fadeOcclusion; uint8View[k++] = radialOcclusion; uint8View[k++] = visionOcclusion; } } /* ---------------------------------------- */ /** @override */ static get batchVertexShader() { return ` #version 300 es ${this.GLSL1_COMPATIBILITY_VERTEX} precision ${PIXI.settings.PRECISION_VERTEX} float; in vec2 aVertexPosition; in vec2 aTextureCoord; uniform vec2 screenDimensions; ${this._batchVertexShader} in float aTextureId; in float aTextureAlphaThreshold; in float aDepthElevation; in vec4 aOcclusionData; in float aRestrictionState; uniform mat3 projectionMatrix; uniform mat3 translationMatrix; out vec2 vTextureCoord; out vec2 vOcclusionCoord; flat out float vTextureId; flat out float vTextureAlphaThreshold; flat out float vDepthElevation; flat out float vOcclusionElevation; flat out float vFadeOcclusion; flat out float vRadialOcclusion; flat out float vVisionOcclusion; flat out uint vRestrictionState; void main() { vec2 vertexPosition; vec2 textureCoord; _main(vertexPosition, textureCoord); vec3 tPos = translationMatrix * vec3(vertexPosition, 1.0); gl_Position = vec4((projectionMatrix * tPos).xy, 0.0, 1.0); vTextureCoord = textureCoord; vOcclusionCoord = tPos.xy / screenDimensions; vTextureId = aTextureId; vTextureAlphaThreshold = aTextureAlphaThreshold; vDepthElevation = aDepthElevation; vOcclusionElevation = aOcclusionData.x; vFadeOcclusion = aOcclusionData.y; vRadialOcclusion = aOcclusionData.z; vVisionOcclusion = aOcclusionData.w; vRestrictionState = uint(aRestrictionState); } `; } /* -------------------------------------------- */ /** * The batch vertex shader source. Subclasses can override it. * @type {string} * @protected */ static _batchVertexShader = ` void _main(out vec2 vertexPosition, out vec2 textureCoord) { vertexPosition = aVertexPosition; textureCoord = aTextureCoord; } `; /* ---------------------------------------- */ /** @override */ static get batchFragmentShader() { return ` #version 300 es ${this.GLSL1_COMPATIBILITY_FRAGMENT} precision ${PIXI.settings.PRECISION_FRAGMENT} float; in vec2 vTextureCoord; flat in float vTextureId; uniform sampler2D uSamplers[%count%]; ${DepthSamplerShader.#OPTIONS_CONSTANTS} ${this._batchFragmentShader} in vec2 vOcclusionCoord; flat in float vTextureAlphaThreshold; flat in float vDepthElevation; flat in float vOcclusionElevation; flat in float vFadeOcclusion; flat in float vRadialOcclusion; flat in float vVisionOcclusion; flat in uint vRestrictionState; uniform sampler2D occlusionTexture; out vec3 fragColor; void main() { float textureAlpha = _main(); float textureAlphaThreshold = vTextureAlphaThreshold; float depthElevation = vDepthElevation; float occlusionElevation = vOcclusionElevation; float fadeOcclusion = vFadeOcclusion; float radialOcclusion = vRadialOcclusion; float visionOcclusion = vVisionOcclusion; bool restrictsLight = ((vRestrictionState & RESTRICTS_LIGHT) == RESTRICTS_LIGHT); bool restrictsWeather = ((vRestrictionState & RESTRICTS_WEATHER) == RESTRICTS_WEATHER); ${DepthSamplerShader.#FRAGMENT_MAIN} } `; } /* -------------------------------------------- */ /** * The batch fragment shader source. Subclasses can override it. * @type {string} * @protected */ static _batchFragmentShader = ` float _main() { vec4 color; %forloop% return color.a; } `; /* -------------------------------------------- */ /* Non-Batched version Rendering */ /* -------------------------------------------- */ /** @override */ static get vertexShader() { return ` #version 300 es ${this.GLSL1_COMPATIBILITY_VERTEX} precision ${PIXI.settings.PRECISION_VERTEX} float; in vec2 aVertexPosition; in vec2 aTextureCoord; uniform vec2 screenDimensions; ${this._vertexShader} uniform mat3 projectionMatrix; out vec2 vUvs; out vec2 vOcclusionCoord; void main() { vec2 vertexPosition; vec2 textureCoord; _main(vertexPosition, textureCoord); gl_Position = vec4((projectionMatrix * vec3(vertexPosition, 1.0)).xy, 0.0, 1.0); vUvs = textureCoord; vOcclusionCoord = vertexPosition / screenDimensions; } `; } /* -------------------------------------------- */ /** * The vertex shader source. Subclasses can override it. * @type {string} * @protected */ static _vertexShader = ` void _main(out vec2 vertexPosition, out vec2 textureCoord) { vertexPosition = aVertexPosition; textureCoord = aTextureCoord; } `; /* -------------------------------------------- */ /** @override */ static get fragmentShader() { return ` #version 300 es ${this.GLSL1_COMPATIBILITY_FRAGMENT} precision ${PIXI.settings.PRECISION_FRAGMENT} float; in vec2 vUvs; uniform sampler2D sampler; ${DepthSamplerShader.#OPTIONS_CONSTANTS} ${this._fragmentShader} in vec2 vOcclusionCoord; uniform sampler2D occlusionTexture; uniform float textureAlphaThreshold; uniform float depthElevation; uniform float occlusionElevation; uniform float fadeOcclusion; uniform float radialOcclusion; uniform float visionOcclusion; uniform bool restrictsLight; uniform bool restrictsWeather; out vec3 fragColor; void main() { float textureAlpha = _main(); ${DepthSamplerShader.#FRAGMENT_MAIN} } `; } /* -------------------------------------------- */ /** * The fragment shader source. Subclasses can override it. * @type {string} * @protected */ static _fragmentShader = ` float _main() { return texture(sampler, vUvs).a; } `; /* -------------------------------------------- */ /** @inheritdoc */ _preRender(mesh, renderer) { super._preRender(mesh, renderer); const uniforms = this.uniforms; uniforms.screenDimensions = canvas.screenDimensions; uniforms.textureAlphaThreshold = mesh.textureAlphaThreshold; const occlusionMask = canvas.masks.occlusion; uniforms.occlusionTexture = occlusionMask.renderTexture; uniforms.occlusionElevation = occlusionMask.mapElevation(mesh.elevation); uniforms.depthElevation = canvas.masks.depth.mapElevation(mesh.elevation); const occlusionState = mesh._occlusionState; uniforms.fadeOcclusion = occlusionState.fade; uniforms.radialOcclusion = occlusionState.radial; uniforms.visionOcclusion = occlusionState.vision; uniforms.restrictsLight = mesh.restrictsLight; uniforms.restrictsWeather = mesh.restrictsWeather; } /* -------------------------------------------- */ /** * The restriction options bit mask constants. * @type {string} */ static #OPTIONS_CONSTANTS = foundry.utils.BitMask.generateShaderBitMaskConstants([ "RESTRICTS_LIGHT", "RESTRICTS_WEATHER" ]); /* -------------------------------------------- */ /** * The fragment source. * @type {string} */ static #FRAGMENT_MAIN = ` float inverseDepthElevation = 1.0 - depthElevation; fragColor = vec3(inverseDepthElevation, depthElevation, inverseDepthElevation); fragColor *= step(textureAlphaThreshold, textureAlpha); vec3 weight = 1.0 - step(occlusionElevation, texture(occlusionTexture, vOcclusionCoord).rgb); float occlusion = step(0.5, max(max(weight.r * fadeOcclusion, weight.g * radialOcclusion), weight.b * visionOcclusion)); fragColor.r *= occlusion; fragColor.g *= 1.0 - occlusion; fragColor.b *= occlusion; if ( !restrictsLight ) { fragColor.r = 0.0; fragColor.g = 0.0; } if ( !restrictsWeather ) { fragColor.b = 0.0; } `; } /** * The batch data that is needed by {@link OccludableSamplerShader} to render an element with batching. * @typedef {object} OccludableBatchData * @property {PIXI.Texture} _texture The texture * @property {Float32Array} vertexData The vertices * @property {Uint16Array|Uint32Array|number[]} indices The indices * @property {Float32Array} uvs The texture UVs * @property {number} worldAlpha The world alpha * @property {number} _tintRGB The tint * @property {number} blendMode The blend mode * @property {number} elevation The elevation * @property {number} unoccludedAlpha The unoccluded alpha * @property {number} occludedAlpha The unoccluded alpha * @property {number} fadeOcclusion The amount of FADE occlusion * @property {number} radialOcclusion The amount of RADIAL occlusion * @property {number} visionOcclusion The amount of VISION occlusion */ /** * The occlusion sampler shader. */ class OccludableSamplerShader extends BaseSamplerShader { /** * The fragment shader code that applies occlusion. * @type {string} */ static #OCCLUSION = ` vec3 occluded = 1.0 - step(occlusionElevation, texture(occlusionTexture, vScreenCoord).rgb); float occlusion = max(occluded.r * fadeOcclusion, max(occluded.g * radialOcclusion, occluded.b * visionOcclusion)); fragColor *= mix(unoccludedAlpha, occludedAlpha, occlusion); `; /* -------------------------------------------- */ /* Batched version Rendering */ /* -------------------------------------------- */ /** @override */ static classPluginName = "batchOcclusion"; /* ---------------------------------------- */ /** @override */ static batchGeometry = [ {id: "aVertexPosition", size: 2, normalized: false, type: PIXI.TYPES.FLOAT}, {id: "aTextureCoord", size: 2, normalized: false, type: PIXI.TYPES.FLOAT}, {id: "aColor", size: 4, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE}, {id: "aTextureId", size: 1, normalized: false, type: PIXI.TYPES.UNSIGNED_SHORT}, {id: "aOcclusionAlphas", size: 2, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE}, {id: "aOcclusionData", size: 4, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE} ]; /* -------------------------------------------- */ /** @override */ static batchVertexSize = 7; /* -------------------------------------------- */ /** @override */ static reservedTextureUnits = 1; // We need a texture unit for the occlusion texture /* -------------------------------------------- */ /** @override */ static defaultUniforms = { screenDimensions: [1, 1], sampler: null, tintAlpha: [1, 1, 1, 1], occlusionTexture: null, unoccludedAlpha: 1, occludedAlpha: 0, occlusionElevation: 0, fadeOcclusion: 0, radialOcclusion: 0, visionOcclusion: 0 }; /* -------------------------------------------- */ /** @override */ static batchDefaultUniforms(maxTex) { return { screenDimensions: [1, 1], occlusionTexture: maxTex }; } /* -------------------------------------------- */ /** @override */ static _preRenderBatch(batchRenderer) { const occlusionMask = canvas.masks.occlusion; const uniforms = batchRenderer._shader.uniforms; uniforms.screenDimensions = canvas.screenDimensions; batchRenderer.renderer.texture.bind(occlusionMask.renderTexture, uniforms.occlusionTexture); } /* ---------------------------------------- */ /** @override */ static _packInterleavedGeometry(element, attributeBuffer, indexBuffer, aIndex, iIndex) { const {float32View, uint8View, uint16View, uint32View} = attributeBuffer; // Write indices into buffer const packedVertices = aIndex / this.vertexSize; const indices = element.indices; for ( let i = 0; i < indices.length; i++ ) { indexBuffer[iIndex++] = packedVertices + indices[i]; } // Prepare attributes const vertexData = element.vertexData; const uvs = element.uvs; const baseTexture = element._texture.baseTexture; const alpha = Math.min(element.worldAlpha, 1.0); const argb = PIXI.Color.shared.setValue(element._tintRGB).toPremultiplied(alpha, baseTexture.alphaMode > 0); const textureId = baseTexture._batchLocation; const unoccludedAlpha = (element.unoccludedAlpha * 255) | 0; const occludedAlpha = (element.occludedAlpha * 255) | 0; const occlusionElevation = (canvas.masks.occlusion.mapElevation(element.elevation) * 255) | 0; const fadeOcclusion = (element.fadeOcclusion * 255) | 0; const radialOcclusion = (element.radialOcclusion * 255) | 0; const visionOcclusion = (element.visionOcclusion * 255) | 0; // Write attributes into buffer const vertexSize = this.vertexSize; for ( let i = 0, j = 0; i < vertexData.length; i += 2, j += vertexSize ) { let k = aIndex + j; float32View[k++] = vertexData[i]; float32View[k++] = vertexData[i + 1]; float32View[k++] = uvs[i]; float32View[k++] = uvs[i + 1]; uint32View[k++] = argb; k <<= 1; uint16View[k++] = textureId; k <<= 1; uint8View[k++] = unoccludedAlpha; uint8View[k++] = occludedAlpha; uint8View[k++] = occlusionElevation; uint8View[k++] = fadeOcclusion; uint8View[k++] = radialOcclusion; uint8View[k++] = visionOcclusion; } } /* -------------------------------------------- */ /** @override */ static get batchVertexShader() { return ` #version 300 es ${this.GLSL1_COMPATIBILITY_VERTEX} precision ${PIXI.settings.PRECISION_VERTEX} float; in vec2 aVertexPosition; in vec2 aTextureCoord; in vec4 aColor; uniform mat3 translationMatrix; uniform vec4 tint; uniform vec2 screenDimensions; ${this._batchVertexShader} in float aTextureId; in vec2 aOcclusionAlphas; in vec4 aOcclusionData; uniform mat3 projectionMatrix; out vec2 vTextureCoord; out vec2 vScreenCoord; flat out vec4 vColor; flat out float vTextureId; flat out float vUnoccludedAlpha; flat out float vOccludedAlpha; flat out float vOcclusionElevation; flat out float vFadeOcclusion; flat out float vRadialOcclusion; flat out float vVisionOcclusion; void main() { vec2 vertexPosition; vec2 textureCoord; vec4 color; _main(vertexPosition, textureCoord, color); gl_Position = vec4((projectionMatrix * vec3(vertexPosition, 1.0)).xy, 0.0, 1.0); vTextureCoord = textureCoord; vScreenCoord = vertexPosition / screenDimensions; vColor = color; vTextureId = aTextureId; vUnoccludedAlpha = aOcclusionAlphas.x; vOccludedAlpha = aOcclusionAlphas.y; vOcclusionElevation = aOcclusionData.x; vFadeOcclusion = aOcclusionData.y; vRadialOcclusion = aOcclusionData.z; vVisionOcclusion = aOcclusionData.w; } `; } /* -------------------------------------------- */ /** * The batch vertex shader source. Subclasses can override it. * @type {string} * @protected */ static _batchVertexShader = ` void _main(out vec2 vertexPosition, out vec2 textureCoord, out vec4 color) { vertexPosition = (translationMatrix * vec3(aVertexPosition, 1.0)).xy; textureCoord = aTextureCoord; color = aColor * tint; } `; /* ---------------------------------------- */ /** @override */ static get batchFragmentShader() { return ` #version 300 es ${this.GLSL1_COMPATIBILITY_FRAGMENT} precision ${PIXI.settings.PRECISION_FRAGMENT} float; in vec2 vTextureCoord; in vec2 vScreenCoord; flat in vec4 vColor; flat in float vTextureId; uniform sampler2D uSamplers[%count%]; ${this._batchFragmentShader} flat in float vUnoccludedAlpha; flat in float vOccludedAlpha; flat in float vOcclusionElevation; flat in float vFadeOcclusion; flat in float vRadialOcclusion; flat in float vVisionOcclusion; uniform sampler2D occlusionTexture; out vec4 fragColor; void main() { fragColor = _main(); float unoccludedAlpha = vUnoccludedAlpha; float occludedAlpha = vOccludedAlpha; float occlusionElevation = vOcclusionElevation; float fadeOcclusion = vFadeOcclusion; float radialOcclusion = vRadialOcclusion; float visionOcclusion = vVisionOcclusion; ${OccludableSamplerShader.#OCCLUSION} } `; } /* -------------------------------------------- */ /** * The batch fragment shader source. Subclasses can override it. * @type {string} * @protected */ static _batchFragmentShader = ` vec4 _main() { vec4 color; %forloop% return color * vColor; } `; /* -------------------------------------------- */ /* Non-Batched version Rendering */ /* -------------------------------------------- */ /** @override */ static get vertexShader() { return ` #version 300 es ${this.GLSL1_COMPATIBILITY_VERTEX} precision ${PIXI.settings.PRECISION_VERTEX} float; in vec2 aVertexPosition; in vec2 aTextureCoord; uniform vec2 screenDimensions; ${this._vertexShader} uniform mat3 projectionMatrix; out vec2 vUvs; out vec2 vScreenCoord; void main() { vec2 vertexPosition; vec2 textureCoord; _main(vertexPosition, textureCoord); gl_Position = vec4((projectionMatrix * vec3(vertexPosition, 1.0)).xy, 0.0, 1.0); vUvs = textureCoord; vScreenCoord = vertexPosition / screenDimensions; } `; } /* -------------------------------------------- */ /** * The vertex shader source. Subclasses can override it. * @type {string} * @protected */ static _vertexShader = ` void _main(out vec2 vertexPosition, out vec2 textureCoord) { vertexPosition = aVertexPosition; textureCoord = aTextureCoord; } `; /* -------------------------------------------- */ /** @override */ static get fragmentShader() { return ` #version 300 es ${this.GLSL1_COMPATIBILITY_FRAGMENT} precision ${PIXI.settings.PRECISION_FRAGMENT} float; in vec2 vUvs; in vec2 vScreenCoord; uniform sampler2D sampler; uniform vec4 tintAlpha; ${this._fragmentShader} uniform sampler2D occlusionTexture; uniform float unoccludedAlpha; uniform float occludedAlpha; uniform float occlusionElevation; uniform float fadeOcclusion; uniform float radialOcclusion; uniform float visionOcclusion; out vec4 fragColor; void main() { fragColor = _main(); ${OccludableSamplerShader.#OCCLUSION} } `; } /* -------------------------------------------- */ /** * The fragment shader source. Subclasses can override it. * @type {string} * @protected */ static _fragmentShader = ` vec4 _main() { return texture(sampler, vUvs) * tintAlpha; } `; /* -------------------------------------------- */ /** @inheritdoc */ _preRender(mesh, renderer) { super._preRender(mesh, renderer); const uniforms = this.uniforms; uniforms.screenDimensions = canvas.screenDimensions; const occlusionMask = canvas.masks.occlusion; uniforms.occlusionTexture = occlusionMask.renderTexture; uniforms.occlusionElevation = occlusionMask.mapElevation(mesh.elevation); uniforms.unoccludedAlpha = mesh.unoccludedAlpha; uniforms.occludedAlpha = mesh.occludedAlpha; const occlusionState = mesh._occlusionState; uniforms.fadeOcclusion = occlusionState.fade; uniforms.radialOcclusion = occlusionState.radial; uniforms.visionOcclusion = occlusionState.vision; } } /** * The base shader class of {@link PrimarySpriteMesh}. */ class PrimaryBaseSamplerShader extends OccludableSamplerShader { /** * The depth shader class associated with this shader. * @type {typeof DepthSamplerShader} */ static depthShaderClass = DepthSamplerShader; /* -------------------------------------------- */ /** * The depth shader associated with this shader. * The depth shader is lazily constructed. * @type {DepthSamplerShader} */ get depthShader() { return this.#depthShader ??= this.#createDepthShader(); } #depthShader; /* -------------------------------------------- */ /** * Create the depth shader and configure it. * @returns {DepthSamplerShader} */ #createDepthShader() { const depthShader = this.constructor.depthShaderClass.create(); this._configureDepthShader(depthShader); return depthShader; } /* -------------------------------------------- */ /** * One-time configuration that is called when the depth shader is created. * @param {DepthSamplerShader} depthShader The depth shader * @protected */ _configureDepthShader(depthShader) {} } /** * Compute baseline illumination according to darkness level encoded texture. */ class BaselineIlluminationSamplerShader extends BaseSamplerShader { /** @override */ static classPluginName = null; /** @inheritdoc */ static fragmentShader = ` precision ${PIXI.settings.PRECISION_FRAGMENT} float; uniform sampler2D sampler; uniform vec4 tintAlpha; uniform vec3 ambientDarkness; uniform vec3 ambientDaylight; varying vec2 vUvs; void main() { float illuminationRed = texture2D(sampler, vUvs).r; vec3 finalColor = mix(ambientDaylight, ambientDarkness, illuminationRed); gl_FragColor = vec4(finalColor, 1.0) * tintAlpha; }`; /** @inheritdoc */ static defaultUniforms = { tintAlpha: [1, 1, 1, 1], ambientDarkness: [0, 0, 0], ambientDaylight: [1, 1, 1], sampler: null }; /* -------------------------------------------- */ /** @inheritDoc */ _preRender(mesh, renderer) { super._preRender(mesh, renderer); const c = canvas.colors; const u = this.uniforms; c.ambientDarkness.applyRGB(u.ambientDarkness); c.ambientDaylight.applyRGB(u.ambientDaylight); } } /** * A color adjustment shader. */ class ColorAdjustmentsSamplerShader extends BaseSamplerShader { /** @override */ static classPluginName = null; /* -------------------------------------------- */ /** @override */ static vertexShader = ` precision ${PIXI.settings.PRECISION_VERTEX} float; attribute vec2 aVertexPosition; attribute vec2 aTextureCoord; uniform mat3 projectionMatrix; uniform vec2 screenDimensions; varying vec2 vUvs; varying vec2 vScreenCoord; void main() { gl_Position = vec4((projectionMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0); vUvs = aTextureCoord; vScreenCoord = aVertexPosition / screenDimensions; }`; /* -------------------------------------------- */ /** @override */ static fragmentShader = ` precision ${PIXI.settings.PRECISION_FRAGMENT} float; uniform sampler2D sampler; uniform vec4 tintAlpha; uniform vec3 tint; uniform float exposure; uniform float contrast; uniform float saturation; uniform float brightness; uniform sampler2D darknessLevelTexture; uniform bool linkedToDarknessLevel; varying vec2 vUvs; varying vec2 vScreenCoord; ${this.CONSTANTS} ${this.PERCEIVED_BRIGHTNESS} void main() { vec4 baseColor = texture2D(sampler, vUvs); if ( baseColor.a > 0.0 ) { // Unmultiply rgb with alpha channel baseColor.rgb /= baseColor.a; // Copy original color before update vec3 originalColor = baseColor.rgb; ${this.ADJUSTMENTS} if ( linkedToDarknessLevel ) { float darknessLevel = texture2D(darknessLevelTexture, vScreenCoord).r; baseColor.rgb = mix(originalColor, baseColor.rgb, darknessLevel); } // Multiply rgb with tint and alpha channel baseColor.rgb *= (tint * baseColor.a); } // Output with tint and alpha gl_FragColor = baseColor * tintAlpha; }`; /* -------------------------------------------- */ /** @inheritdoc */ static defaultUniforms = { tintAlpha: [1, 1, 1, 1], tint: [1, 1, 1], contrast: 0, saturation: 0, exposure: 0, sampler: null, linkedToDarknessLevel: false, darknessLevelTexture: null, screenDimensions: [1, 1] }; /* -------------------------------------------- */ get linkedToDarknessLevel() { return this.uniforms.linkedToDarknessLevel; } set linkedToDarknessLevel(link) { this.uniforms.linkedToDarknessLevel = link; } /* -------------------------------------------- */ get contrast() { return this.uniforms.contrast; } set contrast(contrast) { this.uniforms.contrast = contrast; } /* -------------------------------------------- */ get exposure() { return this.uniforms.exposure; } set exposure(exposure) { this.uniforms.exposure = exposure; } /* -------------------------------------------- */ get saturation() { return this.uniforms.saturation; } set saturation(saturation) { this.uniforms.saturation = saturation; } } /* -------------------------------------------- */ /** * A light amplification shader. */ class AmplificationSamplerShader extends ColorAdjustmentsSamplerShader { /** @override */ static classPluginName = null; /* -------------------------------------------- */ /** @override */ static vertexShader = ` precision ${PIXI.settings.PRECISION_VERTEX} float; attribute vec2 aVertexPosition; attribute vec2 aTextureCoord; uniform mat3 projectionMatrix; uniform vec2 screenDimensions; varying vec2 vUvs; varying vec2 vScreenCoord; void main() { gl_Position = vec4((projectionMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0); vUvs = aTextureCoord; vScreenCoord = aVertexPosition / screenDimensions; } `; /* -------------------------------------------- */ /** @override */ static fragmentShader = ` precision ${PIXI.settings.PRECISION_FRAGMENT} float; uniform sampler2D sampler; uniform vec4 tintAlpha; uniform vec3 tint; uniform float exposure; uniform float contrast; uniform float saturation; uniform float brightness; uniform sampler2D darknessLevelTexture; uniform bool linkedToDarknessLevel; uniform bool enable; varying vec2 vUvs; varying vec2 vScreenCoord; ${this.CONSTANTS} ${this.PERCEIVED_BRIGHTNESS} void main() { vec4 baseColor = texture2D(sampler, vUvs); if ( enable && baseColor.a > 0.0 ) { // Unmultiply rgb with alpha channel baseColor.rgb /= baseColor.a; float lum = perceivedBrightness(baseColor.rgb); vec3 vision = vec3(smoothstep(0.0, 1.0, lum * 1.5)) * tint; float darknessLevel = texture2D(darknessLevelTexture, vScreenCoord).r; baseColor.rgb = vision + (vision * (lum + brightness) * 0.1) + (baseColor.rgb * (1.0 - darknessLevel) * 0.125); ${this.ADJUSTMENTS} // Multiply rgb with alpha channel baseColor.rgb *= baseColor.a; } // Output with tint and alpha gl_FragColor = baseColor * tintAlpha; }`; /* -------------------------------------------- */ /** @inheritdoc */ static defaultUniforms = { tintAlpha: [1, 1, 1, 1], tint: [0.38, 0.8, 0.38], brightness: 0, darknessLevelTexture: null, screenDimensions: [1, 1], enable: true }; /* -------------------------------------------- */ /** * Brightness controls the luminosity. * @type {number} */ get brightness() { return this.uniforms.brightness; } set brightness(brightness) { this.uniforms.brightness = brightness; } /* -------------------------------------------- */ /** * Tint color applied to Light Amplification. * @type {number[]} Light Amplification tint (default: [0.48, 1.0, 0.48]). */ get colorTint() { return this.uniforms.colorTint; } set colorTint(color) { this.uniforms.colorTint = color; } } /** * A simple shader which purpose is to make the original texture red channel the alpha channel, * and still keeping channel informations. Used in cunjunction with the AlphaBlurFilterPass and Fog of War. */ class FogSamplerShader extends BaseSamplerShader { /** @override */ static classPluginName = null; /** @override */ static fragmentShader = ` precision ${PIXI.settings.PRECISION_FRAGMENT} float; uniform sampler2D sampler; uniform vec4 tintAlpha; varying vec2 vUvs; void main() { vec4 color = texture2D(sampler, vUvs); gl_FragColor = vec4(1.0, color.gb, 1.0) * step(0.15, color.r) * tintAlpha; }`; } /** * The shader definition which powers the TokenRing. */ class TokenRingSamplerShader extends PrimaryBaseSamplerShader { /** @override */ static classPluginName = "tokenRingBatch"; /* -------------------------------------------- */ /** @override */ static pausable = false; /* -------------------------------------------- */ /** @inheritdoc */ static batchGeometry = [ ...(super.batchGeometry ?? []), {id: "aRingTextureCoord", size: 2, normalized: false, type: PIXI.TYPES.FLOAT}, {id: "aBackgroundTextureCoord", size: 2, normalized: false, type: PIXI.TYPES.FLOAT}, {id: "aRingColor", size: 4, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE}, {id: "aBackgroundColor", size: 4, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE}, {id: "aStates", size: 1, normalized: false, type: PIXI.TYPES.FLOAT}, {id: "aScaleCorrection", size: 2, normalized: false, type: PIXI.TYPES.FLOAT}, {id: "aRingColorBand", size: 2, normalized: false, type: PIXI.TYPES.FLOAT}, {id: "aTextureScaleCorrection", size: 1, normalized: false, type: PIXI.TYPES.FLOAT} ]; /* -------------------------------------------- */ /** @inheritdoc */ static batchVertexSize = super.batchVertexSize + 12; /* -------------------------------------------- */ /** @inheritdoc */ static reservedTextureUnits = super.reservedTextureUnits + 1; /* -------------------------------------------- */ /** * A null UVs array used for nulled texture position. * @type {Float32Array} */ static nullUvs = new Float32Array([0, 0, 0, 0, 0, 0, 0, 0]); /* -------------------------------------------- */ /** @inheritdoc */ static batchDefaultUniforms(maxTex) { return { ...super.batchDefaultUniforms(maxTex), tokenRingTexture: maxTex + super.reservedTextureUnits, time: 0 }; } /* -------------------------------------------- */ /** @override */ static _preRenderBatch(batchRenderer) { super._preRenderBatch(batchRenderer); batchRenderer.renderer.texture.bind(CONFIG.Token.ring.ringClass.baseTexture, batchRenderer.uniforms.tokenRingTexture); batchRenderer.uniforms.time = canvas.app.ticker.lastTime / 1000; batchRenderer.uniforms.debugColorBands = CONFIG.Token.ring.debugColorBands; } /* ---------------------------------------- */ /** @inheritdoc */ static _packInterleavedGeometry(element, attributeBuffer, indexBuffer, aIndex, iIndex) { super._packInterleavedGeometry(element, attributeBuffer, indexBuffer, aIndex, iIndex); const {float32View, uint32View} = attributeBuffer; // Prepare token ring attributes const vertexData = element.vertexData; const trConfig = CONFIG.Token.ringClass; const object = element.object.object || {}; const ringColor = PIXI.Color.shared.setValue(object.ring?.ringColorLittleEndian ?? 0xFFFFFF).toNumber(); const bkgColor = PIXI.Color.shared.setValue(object.ring?.bkgColorLittleEndian ?? 0xFFFFFF).toNumber(); const ringUvsFloat = object.ring?.ringUVs ?? trConfig.tokenRingSamplerShader.nullUvs; const bkgUvsFloat = object.ring?.bkgUVs ?? trConfig.tokenRingSamplerShader.nullUvs; const states = (object.ring?.effects ?? 0) + 0.5; const scaleCorrectionX = (object.ring?.scaleCorrection ?? 1) * (object.ring?.scaleAdjustmentX ?? 1); const scaleCorrectionY = (object.ring?.scaleCorrection ?? 1) * (object.ring?.scaleAdjustmentY ?? 1); const colorBandRadiusStart = object.ring?.colorBand.startRadius ?? 0; const colorBandRadiusEnd = object.ring?.colorBand.endRadius ?? 0; const textureScaleAdjustment = object.ring?.textureScaleAdjustment ?? 1; // Write attributes into buffer const vertexSize = this.vertexSize; const attributeOffset = PrimaryBaseSamplerShader.batchVertexSize; for ( let i = 0, j = attributeOffset; i < vertexData.length; i += 2, j += vertexSize ) { let k = aIndex + j; float32View[k++] = ringUvsFloat[i]; float32View[k++] = ringUvsFloat[i + 1]; float32View[k++] = bkgUvsFloat[i]; float32View[k++] = bkgUvsFloat[i + 1]; uint32View[k++] = ringColor; uint32View[k++] = bkgColor; float32View[k++] = states; float32View[k++] = scaleCorrectionX; float32View[k++] = scaleCorrectionY; float32View[k++] = colorBandRadiusStart; float32View[k++] = colorBandRadiusEnd; float32View[k++] = textureScaleAdjustment; } } /* ---------------------------------------- */ /* GLSL Shader Code */ /* ---------------------------------------- */ /** * The fragment shader header. * @type {string} */ static #FRAG_HEADER = ` const uint STATE_RING_PULSE = 0x02U; const uint STATE_RING_GRADIENT = 0x04U; const uint STATE_BKG_WAVE = 0x08U; const uint STATE_INVISIBLE = 0x10U; /* -------------------------------------------- */ bool hasState(in uint state) { return (vStates & state) == state; } /* -------------------------------------------- */ vec2 rotation(in vec2 uv, in float a) { uv -= 0.5; float s = sin(a); float c = cos(a); return uv * mat2(c, -s, s, c) + 0.5; } /* -------------------------------------------- */ float normalizedCos(in float val) { return (cos(val) + 1.0) * 0.5; } /* -------------------------------------------- */ float wave(in float dist) { float sinWave = 0.5 * (sin(-time * 4.0 + dist * 100.0) + 1.0); return mix(1.0, 0.55 * sinWave + 0.8, clamp(1.0 - dist, 0.0, 1.0)); } /* -------------------------------------------- */ vec4 colorizeTokenRing(in vec4 tokenRing, in float dist) { if ( tokenRing.a > 0.0 ) tokenRing.rgb /= tokenRing.a; vec3 rcol = hasState(STATE_RING_PULSE) ? mix(tokenRing.rrr, tokenRing.rrr * 0.35, (cos(time * 2.0) + 1.0) * 0.5) : tokenRing.rrr; vec3 ccol = vRingColor * rcol; vec3 gcol = hasState(STATE_RING_GRADIENT) ? mix(ccol, vBackgroundColor * tokenRing.r, smoothstep(0.0, 1.0, dot(rotation(vTextureCoord, time), vec2(0.5)))) : ccol; vec3 col = mix(tokenRing.rgb, gcol, step(vRingColorBand.x, dist) - step(vRingColorBand.y, dist)); return vec4(col, 1.0) * tokenRing.a; } /* -------------------------------------------- */ vec4 colorizeTokenBackground(in vec4 tokenBackground, in float dist) { if (tokenBackground.a > 0.0) tokenBackground.rgb /= tokenBackground.a; float wave = hasState(STATE_BKG_WAVE) ? (0.5 + wave(dist) * 1.5) : 1.0; vec3 bgColor = tokenBackground.rgb; vec3 tintColor = vBackgroundColor.rgb; vec3 resultColor; // Overlay blend mode if ( tintColor == vec3(1.0, 1.0, 1.0) ) { // If tint color is pure white, keep the original background color resultColor = bgColor; } else { // Overlay blend mode for ( int i = 0; i < 3; i++ ) { if ( bgColor[i] < 0.5 ) resultColor[i] = 2.0 * bgColor[i] * tintColor[i]; else resultColor[i] = 1.0 - 2.0 * (1.0 - bgColor[i]) * (1.0 - tintColor[i]); } } return vec4(resultColor, 1.0) * tokenBackground.a * wave; } /* -------------------------------------------- */ vec4 processTokenColor(in vec4 finalColor) { if ( !hasState(STATE_INVISIBLE) ) return finalColor; // Computing halo float lum = perceivedBrightness(finalColor.rgb); vec3 haloColor = vec3(lum) * vec3(0.5, 1.0, 1.0); // Construct final image return vec4(haloColor, 1.0) * finalColor.a * (0.55 + normalizedCos(time * 2.0) * 0.25); } /* -------------------------------------------- */ vec4 blend(vec4 src, vec4 dst) { return src + (dst * (1.0 - src.a)); } /* -------------------------------------------- */ float getTokenTextureClip() { return step(3.5, step(0.0, vTextureCoord.x) + step(0.0, vTextureCoord.y) + step(vTextureCoord.x, 1.0) + step(vTextureCoord.y, 1.0)); } `; /* ---------------------------------------- */ /** * Fragment shader body. * @type {string} */ static #FRAG_MAIN = ` vec4 color; vec4 result; %forloop% if ( vStates == 0U ) result = color * vColor; else { // Compute distances vec2 scaledDistVec = (vOrigTextureCoord - 0.5) * 2.0 * vScaleCorrection; // Euclidean distance computation float dist = length(scaledDistVec); // Rectangular distance computation vec2 absScaledDistVec = abs(scaledDistVec); float rectangularDist = max(absScaledDistVec.x, absScaledDistVec.y); // Clip token texture color (necessary when a mesh is padded on x and/or y axis) color *= getTokenTextureClip(); // Blend token texture, token ring and token background result = blend( processTokenColor(color * (vColor / vColor.a)), blend( colorizeTokenRing(texture(tokenRingTexture, vRingTextureCoord), dist), colorizeTokenBackground(texture(tokenRingTexture, vBackgroundTextureCoord), dist) ) * step(rectangularDist, 1.0) ) * vColor.a; } `; /* ---------------------------------------- */ /** * Fragment shader body for debug code. * @type {string} */ static #FRAG_MAIN_DEBUG = ` if ( debugColorBands ) { vec2 scaledDistVec = (vTextureCoord - 0.5) * 2.0 * vScaleCorrection; float dist = length(scaledDistVec); result.rgb += vec3(0.0, 0.5, 0.0) * (step(vRingColorBand.x, dist) - step(vRingColorBand.y, dist)); } `; /* ---------------------------------------- */ /** @override */ static _batchVertexShader = ` in vec2 aRingTextureCoord; in vec2 aBackgroundTextureCoord; in vec2 aScaleCorrection; in vec2 aRingColorBand; in vec4 aRingColor; in vec4 aBackgroundColor; in float aTextureScaleCorrection; in float aStates; out vec2 vRingTextureCoord; out vec2 vBackgroundTextureCoord; out vec2 vOrigTextureCoord; flat out vec2 vRingColorBand; flat out vec3 vRingColor; flat out vec3 vBackgroundColor; flat out vec2 vScaleCorrection; flat out uint vStates; void _main(out vec2 vertexPosition, out vec2 textureCoord, out vec4 color) { vRingTextureCoord = aRingTextureCoord; vBackgroundTextureCoord = aBackgroundTextureCoord; vRingColor = aRingColor.rgb; vBackgroundColor = aBackgroundColor.rgb; vStates = uint(aStates); vScaleCorrection = aScaleCorrection; vRingColorBand = aRingColorBand; vOrigTextureCoord = aTextureCoord; vertexPosition = (translationMatrix * vec3(aVertexPosition, 1.0)).xy; textureCoord = (aTextureCoord - 0.5) * aTextureScaleCorrection + 0.5; color = aColor * tint; } `; /* -------------------------------------------- */ /** @override */ static _batchFragmentShader = ` in vec2 vRingTextureCoord; in vec2 vBackgroundTextureCoord; in vec2 vOrigTextureCoord; flat in vec3 vRingColor; flat in vec3 vBackgroundColor; flat in vec2 vScaleCorrection; flat in vec2 vRingColorBand; flat in uint vStates; uniform sampler2D tokenRingTexture; uniform float time; uniform bool debugColorBands; ${this.CONSTANTS} ${this.PERCEIVED_BRIGHTNESS} ${TokenRingSamplerShader.#FRAG_HEADER} vec4 _main() { ${TokenRingSamplerShader.#FRAG_MAIN} ${TokenRingSamplerShader.#FRAG_MAIN_DEBUG} return result; } `; } /** * Bewitching Wave animation illumination shader */ class BewitchingWaveIlluminationShader extends AdaptiveIlluminationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PRNG} ${this.NOISE} ${this.FBM(4, 1.0)} ${this.PERCEIVED_BRIGHTNESS} // Transform UV vec2 transform(in vec2 uv, in float dist) { float t = time * 0.25; mat2 rotmat = mat2(cos(t), -sin(t), sin(t), cos(t)); mat2 scalemat = mat2(2.5, 0.0, 0.0, 2.5); uv -= vec2(0.5); uv *= rotmat * scalemat; uv += vec2(0.5); return uv; } float bwave(in float dist) { vec2 uv = transform(vUvs, dist); float motion = fbm(uv + time * 0.25); float distortion = mix(1.0, motion, clamp(1.0 - dist, 0.0, 1.0)); float sinWave = 0.5 * (sin(-time * 6.0 + dist * 10.0 * intensity * distortion) + 1.0); return 0.3 * sinWave + 0.8; } void main() { ${this.FRAGMENT_BEGIN} ${this.TRANSITION} finalColor *= bwave(dist); ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /* -------------------------------------------- */ /** * Bewitching Wave animation coloration shader */ class BewitchingWaveColorationShader extends AdaptiveColorationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PRNG} ${this.NOISE} ${this.FBM(4, 1.0)} ${this.PERCEIVED_BRIGHTNESS} // Transform UV vec2 transform(in vec2 uv, in float dist) { float t = time * 0.25; mat2 rotmat = mat2(cos(t), -sin(t), sin(t), cos(t)); mat2 scalemat = mat2(2.5, 0.0, 0.0, 2.5); uv -= vec2(0.5); uv *= rotmat * scalemat; uv += vec2(0.5); return uv; } float bwave(in float dist) { vec2 uv = transform(vUvs, dist); float motion = fbm(uv + time * 0.25); float distortion = mix(1.0, motion, clamp(1.0 - dist, 0.0, 1.0)); float sinWave = 0.5 * (sin(-time * 6.0 + dist * 10.0 * intensity * distortion) + 1.0); return 0.55 * sinWave + 0.8; } void main() { ${this.FRAGMENT_BEGIN} finalColor = color * bwave(dist) * colorationAlpha; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /** * Black Hole animation illumination shader */ class BlackHoleDarknessShader extends AdaptiveDarknessShader { /* -------------------------------------------- */ /* GLSL Statics */ /* -------------------------------------------- */ /** @inheritdoc */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PRNG} ${this.NOISE} ${this.FBMHQ()} ${this.PERCEIVED_BRIGHTNESS} // create an emanation composed of n beams, n = intensity vec3 beamsEmanation(in vec2 uv, in float dist, in vec3 pCol) { float angle = atan(uv.x, uv.y) * INVTWOPI; // Create the beams float dad = mix(0.33, 5.0, dist); float beams = fract(angle + sin(dist * 30.0 * (intensity * 0.2) - time + fbm(uv * 10.0 + time * 0.25, 1.0) * dad)); // Compose the final beams and reverse beams, to get a nice gradient on EACH side of the beams. beams = max(beams, 1.0 - beams); // Creating the effect return smoothstep(0.0, 1.1 + (intensity * 0.1), beams * pCol); } void main() { ${this.FRAGMENT_BEGIN} vec2 uvs = (2.0 * vUvs) - 1.0; finalColor *= (mix(color, color * 0.66, darknessLevel) * colorationAlpha); float rd = pow(1.0 - dist, 3.0); finalColor = beamsEmanation(uvs, rd, finalColor); ${this.FRAGMENT_END} }`; } /** * Chroma animation coloration shader */ class ChromaColorationShader extends AdaptiveColorationShader { /** @override */ static forceDefaultColor = true; /** @override */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.HSB2RGB} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} finalColor = mix( color, hsb2rgb(vec3(time * 0.25, 1.0, 1.0)), intensity * 0.1 ) * colorationAlpha; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /** * Emanation animation coloration shader */ class EmanationColorationShader extends AdaptiveColorationShader { /** @override */ static forceDefaultColor = true; /** @override */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} // Create an emanation composed of n beams, n = intensity vec3 beamsEmanation(in vec2 uv, in float dist) { float angle = atan(uv.x, uv.y) * INVTWOPI; // create the beams float beams = fract( angle * intensity + sin(dist * 10.0 - time)); // compose the final beams with max, to get a nice gradient on EACH side of the beams. beams = max(beams, 1.0 - beams); // creating the effect : applying color and color correction. saturate the entire output color. return smoothstep( 0.0, 1.0, beams * color); } void main() { ${this.FRAGMENT_BEGIN} vec2 uvs = (2.0 * vUvs) - 1.0; // apply beams emanation, fade and alpha finalColor = beamsEmanation(uvs, dist) * colorationAlpha; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /** * Energy field animation coloration shader */ class EnergyFieldColorationShader extends AdaptiveColorationShader { /** @override */ static forceDefaultColor = true; /** @override */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PRNG3D} ${this.PERCEIVED_BRIGHTNESS} // classic 3d voronoi (with some bug fixes) vec3 voronoi3d(const in vec3 x) { vec3 p = floor(x); vec3 f = fract(x); float id = 0.0; vec2 res = vec2(100.0); for (int k = -1; k <= 1; k++) { for (int j = -1; j <= 1; j++) { for (int i = -1; i <= 1; i++) { vec3 b = vec3(float(i), float(j), float(k)); vec3 r = vec3(b) - f + random(p + b); float d = dot(r, r); float cond = max(sign(res.x - d), 0.0); float nCond = 1.0 - cond; float cond2 = nCond * max(sign(res.y - d), 0.0); float nCond2 = 1.0 - cond2; id = (dot(p + b, vec3(1.0, 67.0, 142.0)) * cond) + (id * nCond); res = vec2(d, res.x) * cond + res * nCond; res.y = cond2 * d + nCond2 * res.y; } } } // replaced abs(id) by pow( abs(id + 10.0), 0.01) // needed to remove artifacts in some specific configuration return vec3( sqrt(res), pow( abs(id + 10.0), 0.01) ); } void main() { ${this.FRAGMENT_BEGIN} vec2 uv = vUvs; // Hemispherize and scaling the uv float f = (1.0 - sqrt(1.0 - dist)) / dist; uv -= vec2(0.5); uv *= f * 4.0 * intensity; uv += vec2(0.5); // time and uv motion variables float t = time * 0.4; float uvx = cos(uv.x - t); float uvy = cos(uv.y + t); float uvxt = cos(uv.x + sin(t)); float uvyt = sin(uv.y + cos(t)); // creating the voronoi 3D sphere, applying motion vec3 c = voronoi3d(vec3(uv.x - uvx + uvyt, mix(uv.x, uv.y, 0.5) + uvxt - uvyt + uvx, uv.y + uvxt - uvx)); // applying color and contrast, to create sharp black areas. finalColor = c.x * c.x * c.x * color * colorationAlpha; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /** * Fairy light animation coloration shader */ class FairyLightColorationShader extends AdaptiveColorationShader { /** @override */ static forceDefaultColor = true; /** @override */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.HSB2RGB} ${this.PRNG} ${this.NOISE} ${this.FBM(3, 1.0)} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} // Creating distortion with vUvs and fbm float distortion1 = fbm(vec2( fbm(vUvs * 3.0 + time * 0.50), fbm((-vUvs + vec2(1.)) * 5.0 + time * INVTHREE))); float distortion2 = fbm(vec2( fbm(-vUvs * 3.0 + time * 0.50), fbm((-vUvs + vec2(1.)) * 5.0 - time * INVTHREE))); vec2 uv = vUvs; // time related var float t = time * 0.5; float tcos = 0.5 * (0.5 * (cos(t)+1.0)) + 0.25; float tsin = 0.5 * (0.5 * (sin(t)+1.0)) + 0.25; // Creating distortions with cos and sin : create fluidity uv -= PIVOT; uv *= tcos * distortion1; uv *= tsin * distortion2; uv *= fbm(vec2(time + distortion1, time + distortion2)); uv += PIVOT; // Creating the rainbow float intens = intensity * 0.1; vec2 nuv = vUvs * 2.0 - 1.0; vec2 puv = vec2(atan(nuv.x, nuv.y) * INVTWOPI + 0.5, length(nuv)); vec3 rainbow = hsb2rgb(vec3(puv.x + puv.y - time * 0.2, 1.0, 1.0)); vec3 mixedColor = mix(color, rainbow, smoothstep(0.0, 1.5 - intens, dist)); finalColor = distortion1 * distortion1 * distortion2 * distortion2 * mixedColor * colorationAlpha * (1.0 - dist * dist * dist) * mix( uv.x + distortion1 * 4.5 * (intensity * 0.4), uv.y + distortion2 * 4.5 * (intensity * 0.4), tcos); ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /* -------------------------------------------- */ /** * Fairy light animation illumination shader */ class FairyLightIlluminationShader extends AdaptiveIlluminationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} ${this.PRNG} ${this.NOISE} ${this.FBM(3, 1.0)} void main() { ${this.FRAGMENT_BEGIN} // Creating distortion with vUvs and fbm float distortion1 = fbm(vec2( fbm(vUvs * 3.0 - time * 0.50), fbm((-vUvs + vec2(1.)) * 5.0 + time * INVTHREE))); float distortion2 = fbm(vec2( fbm(-vUvs * 3.0 - time * 0.50), fbm((-vUvs + vec2(1.)) * 5.0 - time * INVTHREE))); // linear interpolation motion float motionWave = 0.5 * (0.5 * (cos(time * 0.5) + 1.0)) + 0.25; ${this.TRANSITION} finalColor *= mix(distortion1, distortion2, motionWave); ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /** * Alternative torch illumination shader */ class FlameIlluminationShader extends AdaptiveIlluminationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} ${this.TRANSITION} finalColor *= brightnessPulse; ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; /** @inheritdoc */ static defaultUniforms = ({...super.defaultUniforms, brightnessPulse: 1}); } /* -------------------------------------------- */ /** * Alternative torch coloration shader */ class FlameColorationShader extends AdaptiveColorationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PRNG} ${this.NOISE} ${this.FBMHQ(3)} ${this.PERCEIVED_BRIGHTNESS} vec2 scale(in vec2 uv, in float scale) { mat2 scalemat = mat2(scale, 0.0, 0.0, scale); uv -= PIVOT; uv *= scalemat; uv += PIVOT; return uv; } void main() { ${this.FRAGMENT_BEGIN} vec2 uv = scale(vUvs, 10.0 * ratio); float intens = pow(0.1 * intensity, 2.0); float fratioInner = ratio * (intens * 0.5) - (0.005 * fbm( vec2( uv.x + time * 8.01, uv.y + time * 10.72), 1.0)); float fratioOuter = ratio - (0.007 * fbm( vec2( uv.x + time * 7.04, uv.y + time * 9.51), 2.0)); float fdist = max(dist - fratioInner * intens, 0.0); float flameDist = smoothstep(clamp(0.97 - fratioInner, 0.0, 1.0), clamp(1.03 - fratioInner, 0.0, 1.0), 1.0 - fdist); float flameDistInner = smoothstep(clamp(0.95 - fratioOuter, 0.0, 1.0), clamp(1.05 - fratioOuter, 0.0, 1.0), 1.0 - fdist); vec3 flameColor = color * 8.0; vec3 flameFlickerColor = color * 1.2; finalColor = mix(mix(color, flameFlickerColor, flameDistInner), flameColor, flameDist) * brightnessPulse * colorationAlpha; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} } `; /** @inheritdoc */ static defaultUniforms = ({ ...super.defaultUniforms, brightnessPulse: 1}); } /** * Fog animation coloration shader */ class FogColorationShader extends AdaptiveColorationShader { /** @override */ static forceDefaultColor = true; /** @override */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PRNG} ${this.NOISE} ${this.FBM(4, 1.0)} ${this.PERCEIVED_BRIGHTNESS} vec3 fog() { // constructing the palette vec3 c1 = color * 0.60; vec3 c2 = color * 0.95; vec3 c3 = color * 0.50; vec3 c4 = color * 0.75; vec3 c5 = vec3(0.3); vec3 c6 = color; // creating the deformation vec2 uv = vUvs; vec2 p = uv.xy * 8.0; // time motion fbm and palette mixing float q = fbm(p - time * 0.1); vec2 r = vec2(fbm(p + q - time * 0.5 - p.x - p.y), fbm(p + q - time * 0.3)); vec3 c = clamp(mix(c1, c2, fbm(p + r)) + mix(c3, c4, r.x) - mix(c5, c6, r.y), vec3(0.0), vec3(1.0)); // returning the color return c; } void main() { ${this.FRAGMENT_BEGIN} float intens = intensity * 0.2; // applying fog finalColor = fog() * intens * colorationAlpha; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /** * A futuristic Force Grid animation. */ class ForceGridColorationShader extends AdaptiveColorationShader { /** @override */ static forceDefaultColor = true; /** @override */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} const float MAX_INTENSITY = 1.2; const float MIN_INTENSITY = 0.8; vec2 hspherize(in vec2 uv, in float dist) { float f = (1.0 - sqrt(1.0 - dist)) / dist; uv -= vec2(0.50); uv *= f * 5.0; uv += vec2(0.5); return uv; } float wave(in float dist) { float sinWave = 0.5 * (sin(time * 6.0 + pow(1.0 - dist, 0.10) * 35.0 * intensity) + 1.0); return ((MAX_INTENSITY - MIN_INTENSITY) * sinWave) + MIN_INTENSITY; } float fpert(in float d, in float p) { return max(0.3 - mod(p + time + d * 0.3, 3.5), 0.0) * intensity * 2.0; } float pert(in vec2 uv, in float dist, in float d, in float w) { uv -= vec2(0.5); float f = fpert(d, min( uv.y, uv.x)) + fpert(d, min(-uv.y, uv.x)) + fpert(d, min(-uv.y, -uv.x)) + fpert(d, min( uv.y, -uv.x)); f *= f; return max(f, 3.0 - f) * w; } vec3 forcegrid(vec2 suv, in float dist) { vec2 uv = suv - vec2(0.2075, 0.2075); vec2 cid2 = floor(uv); float cid = (cid2.y + cid2.x); uv = fract(uv); float r = 0.3; float d = 1.0; float e; float c; for( int i = 0; i < 5; i++ ) { e = uv.x - r; c = clamp(1.0 - abs(e * 0.75), 0.0, 1.0); d += pow(c, 200.0) * (1.0 - dist); if ( e > 0.0 ) { uv.x = (uv.x - r) / (2.0 - r); } uv = uv.yx; } float w = wave(dist); vec3 col = vec3(max(d - 1.0, 0.0)) * 1.8; col *= pert(suv, dist * intensity * 4.0, d, w); col += color * 0.30 * w; return col * color; } void main() { ${this.FRAGMENT_BEGIN} vec2 uvs = vUvs; uvs -= PIVOT; uvs *= intensity * 0.2; uvs += PIVOT; vec2 suvs = hspherize(uvs, dist); finalColor = forcegrid(suvs, dist) * colorationAlpha; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} } `; } /** * Ghost light animation illumination shader */ class GhostLightIlluminationShader extends AdaptiveIlluminationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} ${this.PRNG} ${this.NOISE} ${this.FBM(3, 1.0)} void main() { ${this.FRAGMENT_BEGIN} // Creating distortion with vUvs and fbm float distortion1 = fbm(vec2( fbm(vUvs * 5.0 - time * 0.50), fbm((-vUvs - vec2(0.01)) * 5.0 + time * INVTHREE))); float distortion2 = fbm(vec2( fbm(-vUvs * 5.0 - time * 0.50), fbm((-vUvs + vec2(0.01)) * 5.0 + time * INVTHREE))); vec2 uv = vUvs; // time related var float t = time * 0.5; float tcos = 0.5 * (0.5 * (cos(t)+1.0)) + 0.25; ${this.TRANSITION} finalColor *= mix( distortion1 * 1.5 * (intensity * 0.2), distortion2 * 1.5 * (intensity * 0.2), tcos); ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /* -------------------------------------------- */ /** * Ghost light animation coloration shader */ class GhostLightColorationShader extends AdaptiveColorationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PRNG} ${this.NOISE} ${this.FBM(3, 1.0)} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} // Creating distortion with vUvs and fbm float distortion1 = fbm(vec2( fbm(vUvs * 3.0 + time * 0.50), fbm((-vUvs + vec2(1.)) * 5.0 + time * INVTHREE))); float distortion2 = fbm(vec2( fbm(-vUvs * 3.0 + time * 0.50), fbm((-vUvs + vec2(1.)) * 5.0 - time * INVTHREE))); vec2 uv = vUvs; // time related var float t = time * 0.5; float tcos = 0.5 * (0.5 * (cos(t)+1.0)) + 0.25; float tsin = 0.5 * (0.5 * (sin(t)+1.0)) + 0.25; // Creating distortions with cos and sin : create fluidity uv -= PIVOT; uv *= tcos * distortion1; uv *= tsin * distortion2; uv *= fbm(vec2(time + distortion1, time + distortion2)); uv += PIVOT; finalColor = distortion1 * distortion1 * distortion2 * distortion2 * color * pow(1.0 - dist, dist) * colorationAlpha * mix( uv.x + distortion1 * 4.5 * (intensity * 0.2), uv.y + distortion2 * 4.5 * (intensity * 0.2), tcos); ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /** * Hexagonal dome animation coloration shader */ class HexaDomeColorationShader extends AdaptiveColorationShader { /** @override */ static forceDefaultColor = true; /** @override */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} // rotate and scale uv vec2 transform(in vec2 uv, in float dist) { float hspherize = (1.0 - sqrt(1.0 - dist)) / dist; float t = -time * 0.20; float scale = 10.0 / (11.0 - intensity); float cost = cos(t); float sint = sin(t); mat2 rotmat = mat2(cost, -sint, sint, cost); mat2 scalemat = mat2(scale, 0.0, 0.0, scale); uv -= PIVOT; uv *= rotmat * scalemat * hspherize; uv += PIVOT; return uv; } // Adapted classic hexa algorithm float hexDist(in vec2 uv) { vec2 p = abs(uv); float c = dot(p, normalize(vec2(1.0, 1.73))); c = max(c, p.x); return c; } vec4 hexUvs(in vec2 uv) { const vec2 r = vec2(1.0, 1.73); const vec2 h = r*0.5; vec2 a = mod(uv, r) - h; vec2 b = mod(uv - h, r) - h; vec2 gv = dot(a, a) < dot(b,b) ? a : b; float x = atan(gv.x, gv.y); float y = 0.55 - hexDist(gv); vec2 id = uv - gv; return vec4(x, y, id.x, id.y); } vec3 hexa(in vec2 uv) { float t = time; vec2 uv1 = uv + vec2(0.0, sin(uv.y) * 0.25); vec2 uv2 = 0.5 * uv1 + 0.5 * uv + vec2(0.55, 0); float a = 0.2; float c = 0.5; float s = -1.0; uv2 *= mat2(c, -s, s, c); vec3 col = color; float hexy = hexUvs(uv2 * 10.0).y; float hexa = smoothstep( 3.0 * (cos(t)) + 4.5, 12.0, hexy * 20.0) * 3.0; col *= mix(hexa, 1.0 - hexa, min(hexy, 1.0 - hexy)); col += color * fract(smoothstep(1.0, 2.0, hexy * 20.0)) * 0.65; return col; } void main() { ${this.FRAGMENT_BEGIN} // Rotate, magnify and hemispherize the uvs vec2 uv = transform(vUvs, dist); // Hexaify the uv (hemisphere) and apply fade and alpha finalColor = hexa(uv) * pow(1.0 - dist, 0.18) * colorationAlpha; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /** * Light dome animation coloration shader */ class LightDomeColorationShader extends AdaptiveColorationShader { /** @override */ static forceDefaultColor = true; /** @override */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PRNG} ${this.NOISE} ${this.FBM(2)} ${this.PERCEIVED_BRIGHTNESS} // Rotate and scale uv vec2 transform(in vec2 uv, in float dist) { float hspherize = (1.0 - sqrt(1.0 - dist)) / dist; float t = time * 0.02; mat2 rotmat = mat2(cos(t), -sin(t), sin(t), cos(t)); mat2 scalemat = mat2(8.0 * intensity, 0.0, 0.0, 8.0 * intensity); uv -= PIVOT; uv *= rotmat * scalemat * hspherize; uv += PIVOT; return uv; } vec3 ripples(in vec2 uv) { // creating the palette vec3 c1 = color * 0.550; vec3 c2 = color * 0.020; vec3 c3 = color * 0.3; vec3 c4 = color; vec3 c5 = color * 0.025; vec3 c6 = color * 0.200; vec2 p = uv + vec2(5.0); float q = 2.0 * fbm(p + time * 0.2); vec2 r = vec2(fbm(p + q + ( time ) - p.x - p.y), fbm(p * 2.0 + ( time ))); return clamp( mix( c1, c2, abs(fbm(p + r)) ) + mix( c3, c4, abs(r.x * r.x * r.x) ) - mix( c5, c6, abs(r.y * r.y)), vec3(0.0), vec3(1.0)); } void main() { ${this.FRAGMENT_BEGIN} // to hemispherize, rotate and magnify vec2 uv = transform(vUvs, dist); finalColor = ripples(uv) * pow(1.0 - dist, 0.25) * colorationAlpha; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /** * Creates a gloomy ring of pure darkness. */ class MagicalGloomDarknessShader extends AdaptiveDarknessShader { /* -------------------------------------------- */ /* GLSL Statics */ /* -------------------------------------------- */ /** @inheritdoc */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} ${this.PRNG} ${this.NOISE} ${this.FBMHQ()} vec3 colorScale(in float t) { return vec3(1.0 + 0.8 * t) * t; } vec2 radialProjection(in vec2 uv, in float s, in float i) { uv = vec2(0.5) - uv; float px = 1.0 - fract(atan(uv.y, uv.x) / TWOPI + 0.25) + s; float py = (length(uv) * (1.0 + i * 2.0) - i) * 2.0; return vec2(px, py); } float interference(in vec2 n) { float noise1 = noise(n); float noise2 = noise(n * 2.1) * 0.6; float noise3 = noise(n * 5.4) * 0.42; return noise1 + noise2 + noise3; } float illuminate(in vec2 uv) { float t = time; // Adjust x-coordinate based on time and y-value float xOffset = uv.y < 0.5 ? 23.0 + t * 0.035 : -11.0 + t * 0.03; uv.x += xOffset; // Shift y-coordinate to range [0, 0.5] uv.y = abs(uv.y - 0.5); // Scale x-coordinate uv.x *= (10.0 + 80.0 * intensity * 0.2); // Compute interferences float q = interference(uv - t * 0.013) * 0.5; vec2 r = vec2(interference(uv + q * 0.5 + t - uv.x - uv.y), interference(uv + q - t)); // Compute final shade value float sh = (r.y + r.y) * max(0.0, uv.y) + 0.1; return sh * sh * sh; } vec3 voidHalf(in float intensity) { float minThreshold = 0.35; // Alter gradient intensity = pow(intensity, 0.75); // Compute the gradient vec3 color = colorScale(intensity); // Normalize the color by the sum of m2 and the color values color /= (1.0 + max(vec3(0), color)); return color; } vec3 voidRing(in vec2 uvs) { vec2 uv = (uvs - 0.5) / (borderDistance * 1.06) + 0.5; float r = 3.6; float ff = 1.0 - uv.y; vec2 uv2 = uv; uv2.y = 1.0 - uv2.y; // Calculate color for upper half vec3 colorUpper = voidHalf(illuminate(radialProjection(uv, 1.0, r))) * ff; // Calculate color for lower half vec3 colorLower = voidHalf(illuminate(radialProjection(uv2, 1.9, r))) * (1.0 - ff); // Return upper and lower half combined return colorUpper + colorLower; } void main() { ${this.FRAGMENT_BEGIN} float lumBase = perceivedBrightness(finalColor); lumBase = mix(lumBase, lumBase * 0.33, darknessLevel); vec3 voidRingColor = voidRing(vUvs); float lum = pow(perceivedBrightness(voidRingColor), 4.0); vec3 voidRingFinal = vec3(perceivedBrightness(voidRingColor)) * color; finalColor = voidRingFinal * lumBase * colorationAlpha; ${this.FRAGMENT_END} }`; } /** * Pulse animation illumination shader */ class PulseIlluminationShader extends AdaptiveIlluminationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} float fading = pow(abs(1.0 - dist * dist), 1.01 - ratio); ${this.TRANSITION} finalColor *= fading; ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /* -------------------------------------------- */ /** * Pulse animation coloration shader */ class PulseColorationShader extends AdaptiveColorationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} float pfade(in float dist, in float pulse) { return 1.0 - smoothstep(pulse * 0.5, 1.0, dist); } void main() { ${this.FRAGMENT_BEGIN} finalColor = color * pfade(dist, pulse) * colorationAlpha; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; /** @inheritdoc */ static defaultUniforms = ({...super.defaultUniforms, pulse: 0}); } /** * Radial rainbow animation coloration shader */ class RadialRainbowColorationShader extends AdaptiveColorationShader { /** @override */ static forceDefaultColor = true; /** @override */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.HSB2RGB} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} float intens = intensity * 0.1; vec2 nuv = vUvs * 2.0 - 1.0; vec2 puv = vec2(atan(nuv.x, nuv.y) * INVTWOPI + 0.5, length(nuv)); vec3 rainbow = hsb2rgb(vec3(puv.y - time * 0.2, 1.0, 1.0)); finalColor = mix(color, rainbow, smoothstep(0.0, 1.5 - intens, dist)) * (1.0 - dist * dist * dist); ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /** * Revolving animation coloration shader */ class RevolvingColorationShader extends AdaptiveColorationShader { /** @override */ static forceDefaultColor = true; /** @override */ static fragmentShader = ` ${this.SHADER_HEADER} uniform float gradientFade; uniform float beamLength; ${this.PERCEIVED_BRIGHTNESS} ${this.PIE} ${this.ROTATION} void main() { ${this.FRAGMENT_BEGIN} vec2 ncoord = vUvs * 2.0 - 1.0; float angularIntensity = mix(PI, PI * 0.5, intensity * 0.1); ncoord *= rot(angle + time); float angularCorrection = pie(ncoord, angularIntensity, gradientFade, beamLength); finalColor = color * colorationAlpha * angularCorrection; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} } `; /** @inheritdoc */ static defaultUniforms = { ...super.defaultUniforms, angle: 0, gradientFade: 0.15, beamLength: 1 }; } /** * Roling mass illumination shader - intended primarily for darkness */ class RoilingDarknessShader extends AdaptiveDarknessShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} ${this.PRNG} ${this.NOISE} ${this.FBM(3)} void main() { ${this.FRAGMENT_BEGIN} // Creating distortion with vUvs and fbm float distortion1 = fbm( vec2( fbm( vUvs * 2.5 + time * 0.5), fbm( (-vUvs - vec2(0.01)) * 5.0 + time * INVTHREE))); float distortion2 = fbm( vec2( fbm( -vUvs * 5.0 + time * 0.5), fbm( (vUvs + vec2(0.01)) * 2.5 + time * INVTHREE))); // Timed values float t = -time * 0.5; float cost = cos(t); float sint = sin(t); // Rotation matrix mat2 rotmat = mat2(cost, -sint, sint, cost); vec2 uv = vUvs; // Applying rotation before distorting uv -= vec2(0.5); uv *= rotmat; uv += vec2(0.5); // Amplify distortions vec2 dstpivot = vec2( sin(min(distortion1 * 0.1, distortion2 * 0.1)), cos(min(distortion1 * 0.1, distortion2 * 0.1)) ) * INVTHREE - vec2( cos(max(distortion1 * 0.1, distortion2 * 0.1)), sin(max(distortion1 * 0.1, distortion2 * 0.1)) ) * INVTHREE ; vec2 apivot = PIVOT - dstpivot; uv -= apivot; uv *= 1.13 + 1.33 * (cos(sqrt(max(distortion1, distortion2)) + 1.0) * 0.5); uv += apivot; // distorted distance float ddist = clamp(distance(uv, PIVOT) * 2.0, 0.0, 1.0); // R'lyeh Ftagnh ! float smooth = smoothstep(borderDistance, borderDistance * 1.2, ddist); float inSmooth = min(smooth, 1.0 - smooth) * 2.0; // Creating the spooky membrane around the bright area vec3 membraneColor = vec3(1.0 - inSmooth); finalColor *= (mix(color, color * 0.33, darknessLevel) * colorationAlpha); finalColor = mix(finalColor, vec3(0.0), 1.0 - smoothstep(0.25, 0.30 + (intensity * 0.2), ddist)); finalColor *= membraneColor; ${this.FRAGMENT_END} }`; } /** * Siren light animation coloration shader */ class SirenColorationShader extends AdaptiveColorationShader { static fragmentShader = ` ${this.SHADER_HEADER} uniform float gradientFade; uniform float beamLength; ${this.PERCEIVED_BRIGHTNESS} ${this.PIE} ${this.ROTATION} void main() { ${this.FRAGMENT_BEGIN} vec2 ncoord = vUvs * 2.0 - 1.0; float angularIntensity = mix(PI, 0.0, intensity * 0.1); ncoord *= rot(time * 50.0 + angle); float angularCorrection = pie(ncoord, angularIntensity, clamp(gradientFade * dist, 0.05, 1.0), beamLength); finalColor = color * brightnessPulse * colorationAlpha * angularCorrection; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} } `; /** @inheritdoc */ static defaultUniforms = ({ ...super.defaultUniforms, ratio: 0, brightnessPulse: 1, angle: 0, gradientFade: 0.15, beamLength: 1 }); } /* -------------------------------------------- */ /** * Siren light animation illumination shader */ class SirenIlluminationShader extends AdaptiveIlluminationShader { static fragmentShader = ` ${this.SHADER_HEADER} uniform float gradientFade; uniform float beamLength; ${this.PERCEIVED_BRIGHTNESS} ${this.PIE} ${this.ROTATION} void main() { ${this.FRAGMENT_BEGIN} ${this.TRANSITION} vec2 ncoord = vUvs * 2.0 - 1.0; float angularIntensity = mix(PI, 0.0, intensity * 0.1); ncoord *= rot(time * 50.0 + angle); float angularCorrection = mix(1.0, pie(ncoord, angularIntensity, clamp(gradientFade * dist, 0.05, 1.0), beamLength), 0.5); finalColor *= angularCorrection; ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; /** @inheritdoc */ static defaultUniforms = ({ ...super.defaultUniforms, angle: 0, gradientFade: 0.45, beamLength: 1 }); } /** * A patch of smoke */ class SmokePatchColorationShader extends AdaptiveColorationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} ${this.PRNG} ${this.NOISE} ${this.FBMHQ(3)} vec2 transform(in vec2 uv, in float dist) { float t = time * 0.1; float cost = cos(t); float sint = sin(t); mat2 rotmat = mat2(cost, -sint, sint, cost); mat2 scalemat = mat2(10.0, uv.x, uv.y, 10.0); uv -= PIVOT; uv *= (rotmat * scalemat); uv += PIVOT; return uv; } float smokefading(in float dist) { float t = time * 0.4; vec2 uv = transform(vUvs, dist); return pow(1.0 - dist, mix(fbm(uv, 1.0 + intensity * 0.4), max(fbm(uv + t, 1.0), fbm(uv - t, 1.0)), pow(dist, intensity * 0.5))); } void main() { ${this.FRAGMENT_BEGIN} finalColor = color * smokefading(dist) * colorationAlpha; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} } `; } /* -------------------------------------------- */ /** * A patch of smoke */ class SmokePatchIlluminationShader extends AdaptiveIlluminationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} ${this.PRNG} ${this.NOISE} ${this.FBMHQ(3)} vec2 transform(in vec2 uv, in float dist) { float t = time * 0.1; float cost = cos(t); float sint = sin(t); mat2 rotmat = mat2(cost, -sint, sint, cost); mat2 scalemat = mat2(10.0, uv.x, uv.y, 10.0); uv -= PIVOT; uv *= (rotmat * scalemat); uv += PIVOT; return uv; } float smokefading(in float dist) { float t = time * 0.4; vec2 uv = transform(vUvs, dist); return pow(1.0 - dist, mix(fbm(uv, 1.0 + intensity * 0.4), max(fbm(uv + t, 1.0), fbm(uv - t, 1.0)), pow(dist, intensity * 0.5))); } void main() { ${this.FRAGMENT_BEGIN} ${this.TRANSITION} finalColor *= smokefading(dist); ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} } `; } /** * A disco like star light. */ class StarLightColorationShader extends AdaptiveColorationShader { /** @override */ static forceDefaultColor = true; /** @override */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} ${this.PRNG} ${this.NOISE} ${this.FBM(2, 1.0)} vec2 transform(in vec2 uv, in float dist) { float t = time * 0.20; float cost = cos(t); float sint = sin(t); mat2 rotmat = mat2(cost, -sint, sint, cost); uv *= rotmat; return uv; } float makerays(in vec2 uv, in float t) { vec2 uvn = normalize(uv * (uv + t)) * (5.0 + intensity); return max(clamp(0.5 * tan(fbm(uvn - t)), 0.0, 2.25), clamp(3.0 - tan(fbm(uvn + t * 2.0)), 0.0, 2.25)); } float starlight(in float dist) { vec2 uv = (vUvs - 0.5); uv = transform(uv, dist); float rays = makerays(uv, time * 0.5); return pow(1.0 - dist, rays) * pow(1.0 - dist, 0.25); } void main() { ${this.FRAGMENT_BEGIN} finalColor = clamp(color * starlight(dist) * colorationAlpha, 0.0, 1.0); ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} } `; } /** * Sunburst animation illumination shader */ class SunburstIlluminationShader extends AdaptiveIlluminationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} // Smooth back and forth between a and b float cosTime(in float a, in float b) { return (a - b) * ((cos(time) + 1.0) * 0.5) + b; } // Create the sunburst effect vec3 sunBurst(in vec3 color, in vec2 uv, in float dist) { // Pulse calibration float intensityMod = 1.0 + (intensity * 0.05); float lpulse = cosTime(1.3 * intensityMod, 0.85 * intensityMod); // Compute angle float angle = atan(uv.x, uv.y) * INVTWOPI; // Creating the beams and the inner light float beam = fract(angle * 16.0 + time); float light = lpulse * pow(abs(1.0 - dist), 0.65); // Max agregation of the central light and the two gradient edges float sunburst = max(light, max(beam, 1.0 - beam)); // Creating the effect : applying color and color correction. ultra saturate the entire output color. return color * pow(sunburst, 3.0); } void main() { ${this.FRAGMENT_BEGIN} vec2 uv = (2.0 * vUvs) - 1.0; finalColor = switchColor(computedBrightColor, computedDimColor, dist); ${this.ADJUSTMENTS} finalColor = sunBurst(finalColor, uv, dist); ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /** * Sunburst animation coloration shader */ class SunburstColorationShader extends AdaptiveColorationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} // Smooth back and forth between a and b float cosTime(in float a, in float b) { return (a - b) * ((cos(time) + 1.0) * 0.5) + b; } // Create a sun burst effect vec3 sunBurst(in vec2 uv, in float dist) { // pulse calibration float intensityMod = 1.0 + (intensity * 0.05); float lpulse = cosTime(1.1 * intensityMod, 0.85 * intensityMod); // compute angle float angle = atan(uv.x, uv.y) * INVTWOPI; // creating the beams and the inner light float beam = fract(angle * 16.0 + time); float light = lpulse * pow(abs(1.0 - dist), 0.65); // agregation of the central light and the two gradient edges to create the sunburst float sunburst = max(light, max(beam, 1.0 - beam)); // creating the effect : applying color and color correction. saturate the entire output color. return color * pow(sunburst, 3.0); } void main() { ${this.FRAGMENT_BEGIN} vec2 uvs = (2.0 * vUvs) - 1.0; finalColor = sunBurst(uvs, dist) * colorationAlpha; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /** * Swirling rainbow animation coloration shader */ class SwirlingRainbowColorationShader extends AdaptiveColorationShader { /** @override */ static forceDefaultColor = true; /** @override */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.HSB2RGB} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} float intens = intensity * 0.1; vec2 nuv = vUvs * 2.0 - 1.0; vec2 puv = vec2(atan(nuv.x, nuv.y) * INVTWOPI + 0.5, length(nuv)); vec3 rainbow = hsb2rgb(vec3(puv.x + puv.y - time * 0.2, 1.0, 1.0)); finalColor = mix(color, rainbow, smoothstep(0.0, 1.5 - intens, dist)) * (1.0 - dist * dist * dist); ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /** * Allow coloring of illumination */ class TorchIlluminationShader extends AdaptiveIlluminationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} ${this.TRANSITION} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /* -------------------------------------------- */ /** * Torch animation coloration shader */ class TorchColorationShader extends AdaptiveColorationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} finalColor = color * brightnessPulse * colorationAlpha; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} } `; /** @inheritdoc */ static defaultUniforms = ({...super.defaultUniforms, ratio: 0, brightnessPulse: 1}); } /** * Vortex animation coloration shader */ class VortexColorationShader extends AdaptiveColorationShader { /** @override */ static forceDefaultColor = true; /** @override */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PRNG} ${this.NOISE} ${this.FBM(4, 1.0)} ${this.PERCEIVED_BRIGHTNESS} vec2 vortex(in vec2 uv, in float dist, in float radius, in mat2 rotmat) { float intens = intensity * 0.2; vec2 uvs = uv - PIVOT; uv *= rotmat; if ( dist < radius ) { float sigma = (radius - dist) / radius; float theta = sigma * sigma * TWOPI * intens; float st = sin(theta); float ct = cos(theta); uvs = vec2(dot(uvs, vec2(ct, -st)), dot(uvs, vec2(st, ct))); } uvs += PIVOT; return uvs; } vec3 spice(in vec2 iuv, in mat2 rotmat) { // constructing the palette vec3 c1 = color * 0.55; vec3 c2 = color * 0.95; vec3 c3 = color * 0.45; vec3 c4 = color * 0.75; vec3 c5 = vec3(0.20); vec3 c6 = color * 1.2; // creating the deformation vec2 uv = iuv; uv -= PIVOT; uv *= rotmat; vec2 p = uv.xy * 6.0; uv += PIVOT; // time motion fbm and palette mixing float q = fbm(p + time); vec2 r = vec2(fbm(p + q + time * 0.9 - p.x - p.y), fbm(p + q + time * 0.6)); vec3 c = mix(c1, c2, fbm(p + r)) + mix(c3, c4, r.x) - mix(c5, c6, r.y); // returning the color return c; } void main() { ${this.FRAGMENT_BEGIN} // Timed values float t = time * 0.5; float cost = cos(t); float sint = sin(t); // Rotation matrix mat2 vortexRotMat = mat2(cost, -sint, sint, cost); mat2 spiceRotMat = mat2(cost * 2.0, -sint * 2.0, sint * 2.0, cost * 2.0); // Creating vortex vec2 vuv = vortex(vUvs, dist, 1.0, vortexRotMat); // Applying spice finalColor = spice(vuv, spiceRotMat) * colorationAlpha; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /* -------------------------------------------- */ /** * Vortex animation coloration shader */ class VortexIlluminationShader extends AdaptiveIlluminationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PRNG} ${this.NOISE} ${this.FBM(4, 1.0)} ${this.PERCEIVED_BRIGHTNESS} vec2 vortex(in vec2 uv, in float dist, in float radius, in float angle, in mat2 rotmat) { vec2 uvs = uv - PIVOT; uv *= rotmat; if ( dist < radius ) { float sigma = (radius - dist) / radius; float theta = sigma * sigma * angle; float st = sin(theta); float ct = cos(theta); uvs = vec2(dot(uvs, vec2(ct, -st)), dot(uvs, vec2(st, ct))); } uvs += PIVOT; return uvs; } vec3 spice(in vec2 iuv, in mat2 rotmat) { // constructing the palette vec3 c1 = vec3(0.20); vec3 c2 = vec3(0.80); vec3 c3 = vec3(0.15); vec3 c4 = vec3(0.85); vec3 c5 = c3; vec3 c6 = vec3(0.9); // creating the deformation vec2 uv = iuv; uv -= PIVOT; uv *= rotmat; vec2 p = uv.xy * 6.0; uv += PIVOT; // time motion fbm and palette mixing float q = fbm(p + time); vec2 r = vec2(fbm(p + q + time * 0.9 - p.x - p.y), fbm(p + q + time * 0.6)); // Mix the final color return mix(c1, c2, fbm(p + r)) + mix(c3, c4, r.x) - mix(c5, c6, r.y); } vec3 convertToDarknessColors(in vec3 col, in float dist) { float intens = intensity * 0.20; float lum = (col.r * 2.0 + col.g * 3.0 + col.b) * 0.5 * INVTHREE; float colorMod = smoothstep(ratio * 0.99, ratio * 1.01, dist); return mix(computedDimColor, computedBrightColor * colorMod, 1.0 - smoothstep( 0.80, 1.00, lum)) * smoothstep( 0.25 * intens, 0.85 * intens, lum); } void main() { ${this.FRAGMENT_BEGIN} ${this.TRANSITION} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /** * Wave animation illumination shader */ class WaveIlluminationShader extends AdaptiveIlluminationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} float wave(in float dist) { float sinWave = 0.5 * (sin(-time * 6.0 + dist * 10.0 * intensity) + 1.0); return 0.3 * sinWave + 0.8; } void main() { ${this.FRAGMENT_BEGIN} ${this.TRANSITION} finalColor *= wave(dist); ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /* -------------------------------------------- */ /** * Wave animation coloration shader */ class WaveColorationShader extends AdaptiveColorationShader { static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} float wave(in float dist) { float sinWave = 0.5 * (sin(-time * 6.0 + dist * 10.0 * intensity) + 1.0); return 0.55 * sinWave + 0.8; } void main() { ${this.FRAGMENT_BEGIN} finalColor = color * wave(dist) * colorationAlpha; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; } /** * Shader specialized in light amplification */ class AmplificationBackgroundVisionShader extends BackgroundVisionShader { /** @inheritdoc */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} float lum = perceivedBrightness(baseColor.rgb); vec3 vision = vec3(smoothstep(0.0, 1.0, lum * 1.5)) * colorTint; finalColor = vision + (vision * (lum + brightness) * 0.1) + (baseColor.rgb * (1.0 - computedDarknessLevel) * 0.125); ${this.ADJUSTMENTS} ${this.BACKGROUND_TECHNIQUES} ${this.FALLOFF} ${this.FRAGMENT_END} }`; /** @inheritdoc */ static defaultUniforms = ({...super.defaultUniforms, colorTint: [0.38, 0.8, 0.38], brightness: 0.5}); /** @inheritdoc */ get isRequired() { return true; } } /** * Shader specialized in wave like senses (tremorsenses) */ class WaveBackgroundVisionShader extends BackgroundVisionShader { /** @inheritdoc */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.WAVE()} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} // Normalize vUvs and compute base time vec2 uvs = (2.0 * vUvs) - 1.0; float t = time * -8.0; // Rotate uvs float sinX = sin(t * 0.02); float cosX = cos(t * 0.02); mat2 rotationMatrix = mat2( cosX, -sinX, sinX, cosX); vec2 ruv = ((vUvs - 0.5) * rotationMatrix) + 0.5; // Produce 4 arms smoothed to the edges float angle = atan(ruv.x * 2.0 - 1.0, ruv.y * 2.0 - 1.0) * INVTWOPI; float beam = fract(angle * 4.0); beam = smoothstep(0.3, 1.0, max(beam, 1.0 - beam)); // Construct final color vec3 grey = vec3(perceivedBrightness(baseColor.rgb)); finalColor = mix(baseColor.rgb, grey * 0.5, sqrt(beam)) * mix(vec3(1.0), colorTint, 0.3); ${this.ADJUSTMENTS} ${this.BACKGROUND_TECHNIQUES} ${this.FALLOFF} ${this.FRAGMENT_END} }`; /** @inheritdoc */ static defaultUniforms = ({...super.defaultUniforms, colorTint: [0.8, 0.1, 0.8]}); /** @inheritdoc */ get isRequired() { return true; } } /* -------------------------------------------- */ /** * The wave vision shader, used to create waves emanations (ex: tremorsense) */ class WaveColorationVisionShader extends ColorationVisionShader { /** @inheritdoc */ static fragmentShader = ` ${this.SHADER_HEADER} ${this.WAVE()} ${this.PERCEIVED_BRIGHTNESS} void main() { ${this.FRAGMENT_BEGIN} // Normalize vUvs and compute base time vec2 uvs = (2.0 * vUvs) - 1.0; float t = time * -8.0; // Rotate uvs float sinX = sin(t * 0.02); float cosX = cos(t * 0.02); mat2 rotationMatrix = mat2( cosX, -sinX, sinX, cosX); vec2 ruv = ((vUvs - 0.5) * rotationMatrix) + 0.5; // Prepare distance from 4 corners float dst[4]; dst[0] = distance(vec2(0.0), ruv); dst[1] = distance(vec2(1.0), ruv); dst[2] = distance(vec2(1.0,0.0), ruv); dst[3] = distance(vec2(0.0,1.0), ruv); // Produce 4 arms smoothed to the edges float angle = atan(ruv.x * 2.0 - 1.0, ruv.y * 2.0 - 1.0) * INVTWOPI; float beam = fract(angle * 4.0); beam = smoothstep(0.3, 1.0, max(beam, 1.0 - beam)); // Computing the 4 corner waves float multiWaves = 0.0; for ( int i = 0; i <= 3 ; i++) { multiWaves += smoothstep(0.6, 1.0, max(multiWaves, wcos(-10.0, 1.30 - dst[i], dst[i] * 120.0, t))); } // Computing the central wave multiWaves += smoothstep(0.6, 1.0, max(multiWaves, wcos(-10.0, 1.35 - dist, dist * 120.0, -t))); // Construct final color finalColor = vec3(mix(multiWaves, 0.0, sqrt(beam))) * colorEffect; ${this.COLORATION_TECHNIQUES} ${this.ADJUSTMENTS} ${this.FALLOFF} ${this.FRAGMENT_END} }`; /** @inheritdoc */ static defaultUniforms = ({...super.defaultUniforms, colorEffect: [0.8, 0.1, 0.8]}); /** @inheritdoc */ get isRequired() { return true; } } /** * Determine the center of the circle. * Trivial, but used to match center method for other shapes. * @type {PIXI.Point} */ Object.defineProperty(PIXI.Circle.prototype, "center", { get: function() { return new PIXI.Point(this.x, this.y); }}); /* -------------------------------------------- */ /** * Determine if a point is on or nearly on this circle. * @param {Point} point Point to test * @param {number} epsilon Tolerated margin of error * @returns {boolean} Is the point on the circle within the allowed tolerance? */ PIXI.Circle.prototype.pointIsOn = function(point, epsilon = 1e-08) { const dist2 = Math.pow(point.x - this.x, 2) + Math.pow(point.y - this.y, 2); const r2 = Math.pow(this.radius, 2); return dist2.almostEqual(r2, epsilon); }; /* -------------------------------------------- */ /** * Get all intersection points on this circle for a segment A|B * Intersections are sorted from A to B. * @param {Point} a The first endpoint on segment A|B * @param {Point} b The second endpoint on segment A|B * @returns {Point[]} Points where the segment A|B intersects the circle */ PIXI.Circle.prototype.segmentIntersections = function(a, b) { const ixs = foundry.utils.lineCircleIntersection(a, b, this, this.radius); return ixs.intersections; }; /* -------------------------------------------- */ /** * Calculate an x,y point on this circle's circumference given an angle * 0: due east * π / 2: due south * π or -π: due west * -π/2: due north * @param {number} angle Angle of the point, in radians * @returns {Point} The point on the circle at the given angle */ PIXI.Circle.prototype.pointAtAngle = function(angle) { return { x: this.x + (this.radius * Math.cos(angle)), y: this.y + (this.radius * Math.sin(angle)) }; }; /* -------------------------------------------- */ /** * Get all the points for a polygon approximation of this circle between two points. * The two points can be anywhere in 2d space. The intersection of this circle with the line from this circle center * to the point will be used as the start or end point, respectively. * This is used to draw the portion of the circle (the arc) between two intersection points on this circle. * @param {Point} a Point in 2d space representing the start point * @param {Point} b Point in 2d space representing the end point * @param {object} [options] Options passed on to the pointsForArc method * @returns { Point[]} An array of points arranged clockwise from start to end */ PIXI.Circle.prototype.pointsBetween = function(a, b, options) { const fromAngle = Math.atan2(a.y - this.y, a.x - this.x); const toAngle = Math.atan2(b.y - this.y, b.x - this.x); return this.pointsForArc(fromAngle, toAngle, { includeEndpoints: false, ...options }); }; /* -------------------------------------------- */ /** * Get the points that would approximate a circular arc along this circle, given a starting and ending angle. * Points returned are clockwise. If from and to are the same, a full circle will be returned. * @param {number} fromAngle Starting angle, in radians. π is due north, π/2 is due east * @param {number} toAngle Ending angle, in radians * @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 * @param {boolean} [options.includeEndpoints] Whether to include points at the circle where the arc starts and ends * @returns {Point[]} An array of points along the requested arc */ PIXI.Circle.prototype.pointsForArc = function(fromAngle, toAngle, {density, includeEndpoints=true} = {}) { const pi2 = 2 * Math.PI; density ??= this.constructor.approximateVertexDensity(this.radius); const points = []; const delta = pi2 / density; if ( includeEndpoints ) points.push(this.pointAtAngle(fromAngle)); // Determine number of points to add let dAngle = toAngle - fromAngle; while ( dAngle <= 0 ) dAngle += pi2; // Angles may not be normalized, so normalize total. const nPoints = Math.round(dAngle / delta); // Construct padding rays (clockwise) for ( let i = 1; i < nPoints; i++ ) points.push(this.pointAtAngle(fromAngle + (i * delta))); if ( includeEndpoints ) points.push(this.pointAtAngle(toAngle)); return points; }; /* -------------------------------------------- */ /** * Approximate this PIXI.Circle as a PIXI.Polygon * @param {object} [options] Options forwarded on to the pointsForArc method * @returns {PIXI.Polygon} The Circle expressed as a PIXI.Polygon */ PIXI.Circle.prototype.toPolygon = function(options) { const points = this.pointsForArc(0, 0, options); points.pop(); // Drop the repeated endpoint return new PIXI.Polygon(points); }; /* -------------------------------------------- */ /** * The recommended vertex density for the regular polygon approximation of a circle of a given radius. * Small radius circles have fewer vertices. The returned value will be rounded up to the nearest integer. * See the formula described at: * https://math.stackexchange.com/questions/4132060/compute-number-of-regular-polgy-sides-to-approximate-circle-to-defined-precision * @param {number} radius Circle radius * @param {number} [epsilon] The maximum tolerable distance between an approximated line segment and the true radius. * A larger epsilon results in fewer points for a given radius. * @returns {number} The number of points for the approximated polygon */ PIXI.Circle.approximateVertexDensity = function(radius, epsilon=1) { return Math.ceil(Math.PI / Math.sqrt(2 * (epsilon / radius))); }; /* -------------------------------------------- */ /** * Intersect this PIXI.Circle with a PIXI.Polygon. * @param {PIXI.Polygon} polygon A PIXI.Polygon * @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 * @param {number} [options.clipType] The clipper clip type * @param {string} [options.weilerAtherton=true] Use the Weiler-Atherton algorithm. Otherwise, use Clipper. * @returns {PIXI.Polygon} The intersected polygon */ PIXI.Circle.prototype.intersectPolygon = function(polygon, {density, clipType, weilerAtherton=true, ...options}={}) { if ( !this.radius ) return new PIXI.Polygon([]); clipType ??= ClipperLib.ClipType.ctIntersection; // Use Weiler-Atherton for efficient intersection or union if ( weilerAtherton && polygon.isPositive ) { const res = WeilerAthertonClipper.combine(polygon, this, {clipType, density, ...options}); if ( !res.length ) return new PIXI.Polygon([]); return res[0]; } // Otherwise, use Clipper polygon intersection const approx = this.toPolygon({density}); return polygon.intersectPolygon(approx, options); }; /* -------------------------------------------- */ /** * Intersect this PIXI.Circle with an array of ClipperPoints. * 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 {ClipperPoint[]} clipperPoints Array of ClipperPoints generated by PIXI.Polygon.toClipperPoints() * @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.Circle.prototype.intersectClipper = function(clipperPoints, {density, ...options}={}) { if ( !this.radius ) return []; const approx = this.toPolygon({density}); return approx.intersectClipper(clipperPoints, options); }; /** * Draws a path. * @param {number[]|PIXI.IPointData[]|PIXI.Polygon|...number|...PIXI.IPointData} path The polygon or points. * @returns {this} This Graphics instance. */ PIXI.Graphics.prototype.drawPath = function(...path) { let closeStroke = false; let polygon = path[0]; let points; if ( polygon.points ) { closeStroke = polygon.closeStroke; points = polygon.points; } else if ( Array.isArray(path[0]) ) { points = path[0]; } else { points = path; } polygon = new PIXI.Polygon(points); polygon.closeStroke = closeStroke; return this.drawShape(polygon); }; PIXI.LegacyGraphics.prototype.drawPath = PIXI.Graphics.prototype.drawPath; PIXI.smooth.SmoothGraphics.prototype.drawPath = PIXI.Graphics.prototype.drawPath; /* -------------------------------------------- */ /** * Draws a smoothed polygon. * @param {number[]|PIXI.IPointData[]|PIXI.Polygon|...number|...PIXI.IPointData} path The polygon or points. * @param {number} [smoothing=0] The smoothness in the range [0, 1]. 0: no smoothing; 1: maximum smoothing. * @returns {this} This Graphics instance. */ PIXI.Graphics.prototype.drawSmoothedPolygon = function(...path) { let closeStroke = true; let polygon = path[0]; let points; let factor; if ( polygon.points ) { closeStroke = polygon.closeStroke; points = polygon.points; factor = path[1]; } else if ( Array.isArray(path[0]) ) { points = path[0]; factor = path[1]; } else if ( typeof path[0] === "number" ) { points = path; factor = path.length % 2 ? path.at(-1) : 0; } else { const n = path.length - (typeof path.at(-1) !== "object" ? 1 : 0); points = []; for ( let i = 0; i < n; i++ ) points.push(path[i].x, path[i].y); factor = path.at(n); } factor ??= 0; if ( (points.length < 6) || (factor <= 0) ) { polygon = new PIXI.Polygon(points.slice(0, points.length - (points.length % 2))); polygon.closeStroke = closeStroke; return this.drawShape(polygon); } const dedupedPoints = [points[0], points[1]]; for ( let i = 2; i < points.length - 1; i += 2 ) { const x = points[i]; const y = points[i + 1]; if ( (x === points[i - 2]) && (y === points[i - 1]) ) continue; dedupedPoints.push(x, y); } points = dedupedPoints; if ( closeStroke && (points[0] === points.at(-2)) && (points[1] === points.at(-1)) ) points.length -= 2; if ( points.length < 6 ) { polygon = new PIXI.Polygon(points); polygon.closeStroke = closeStroke; return this.drawShape(polygon); } const getBezierControlPoints = (fromX, fromY, toX, toY, nextX, nextY) => { const vectorX = nextX - fromX; const vectorY = nextY - fromY; const preDistance = Math.hypot(toX - fromX, toY - fromY); const postDistance = Math.hypot(nextX - toX, nextY - toY); const totalDistance = preDistance + postDistance; const cp0d = 0.5 * factor * (preDistance / totalDistance); const cp1d = 0.5 * factor * (postDistance / totalDistance); return [ toX - (vectorX * cp0d), toY - (vectorY * cp0d), toX + (vectorX * cp1d), toY + (vectorY * cp1d) ]; }; let [fromX, fromY, toX, toY] = points; let [cpX, cpY, cpXNext, cpYNext] = getBezierControlPoints(points.at(-2), points.at(-1), fromX, fromY, toX, toY); this.moveTo(fromX, fromY); for ( let i = 2, n = points.length + (closeStroke ? 2 : 0); i < n; i += 2 ) { const nextX = points[(i + 2) % points.length]; const nextY = points[(i + 3) % points.length]; cpX = cpXNext; cpY = cpYNext; let cpX2; let cpY2; [cpX2, cpY2, cpXNext, cpYNext] = getBezierControlPoints(fromX, fromY, toX, toY, nextX, nextY); if ( !closeStroke && (i === 2) ) this.quadraticCurveTo(cpX2, cpY2, toX, toY); else if ( !closeStroke && (i === points.length - 2) ) this.quadraticCurveTo(cpX, cpY, toX, toY); else this.bezierCurveTo(cpX, cpY, cpX2, cpY2, toX, toY); fromX = toX; fromY = toY; toX = nextX; toY = nextY; } if ( closeStroke ) this.closePath(); this.finishPoly(); return this; }; PIXI.LegacyGraphics.prototype.drawSmoothedPolygon = PIXI.Graphics.prototype.drawSmoothedPolygon; PIXI.smooth.SmoothGraphics.prototype.drawSmoothedPolygon = PIXI.Graphics.prototype.drawSmoothedPolygon; /* -------------------------------------------- */ /** * Draws a smoothed path. * @param {number[]|PIXI.IPointData[]|PIXI.Polygon|...number|...PIXI.IPointData} path The polygon or points. * @param {number} [smoothing=0] The smoothness in the range [0, 1]. 0: no smoothing; 1: maximum smoothing. * @returns {this} This Graphics instance. */ PIXI.Graphics.prototype.drawSmoothedPath = function(...path) { let closeStroke = false; let polygon = path[0]; let points; let factor; if ( polygon.points ) { closeStroke = polygon.closeStroke; points = polygon.points; factor = path[1]; } else if ( Array.isArray(path[0]) ) { points = path[0]; factor = path[1]; } else if ( typeof path[0] === "number" ) { points = path; factor = path.length % 2 ? path.at(-1) : 0; } else { const n = path.length - (typeof path.at(-1) !== "object" ? 1 : 0); points = []; for ( let i = 0; i < n; i++ ) points.push(path[i].x, path[i].y); factor = path.at(n); } polygon = new PIXI.Polygon(points); polygon.closeStroke = closeStroke; return this.drawSmoothedPolygon(polygon, factor); }; PIXI.LegacyGraphics.prototype.drawSmoothedPath = PIXI.Graphics.prototype.drawSmoothedPath; PIXI.smooth.SmoothGraphics.prototype.drawSmoothedPath = PIXI.Graphics.prototype.drawSmoothedPath; /** * A custom Transform class allowing to observe changes with a callback. * @extends PIXI.Transform * * @param {Function} callback The callback called to observe changes. * @param {Object} scope The scope of the callback. */ class ObservableTransform extends PIXI.Transform { constructor(callback, scope) { super(); if ( !(callback instanceof Function) ) { throw new Error("The callback bound to an ObservableTransform class must be a valid function.") } if ( !(scope instanceof Object) ) { throw new Error("The scope bound to an ObservableTransform class must be a valid object/class.") } this.scope = scope; this.cb = callback; } /** * The callback which is observing the changes. * @type {Function} */ cb; /** * The scope of the callback. * @type {Object} */ scope; /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** @inheritDoc */ onChange() { super.onChange(); this.cb.call(this.scope); } /* -------------------------------------------- */ /** @inheritDoc */ updateSkew() { super.updateSkew(); this.cb.call(this.scope); } } /** * Test whether the polygon is has a positive signed area. * Using a y-down axis orientation, this means that the polygon is "clockwise". * @type {boolean} */ Object.defineProperties(PIXI.Polygon.prototype, { isPositive: { get: function() { if ( this._isPositive !== undefined ) return this._isPositive; if ( this.points.length < 6 ) return undefined; return this._isPositive = this.signedArea() > 0; } }, _isPositive: {value: undefined, writable: true, enumerable: false} }); /* -------------------------------------------- */ /** * Clear the cached signed orientation. */ PIXI.Polygon.prototype.clearCache = function() { this._isPositive = undefined; }; /* -------------------------------------------- */ /** * Compute the signed area of polygon using an approach similar to ClipperLib.Clipper.Area. * The math behind this is based on the Shoelace formula. https://en.wikipedia.org/wiki/Shoelace_formula. * The area is positive if the orientation of the polygon is positive. * @returns {number} The signed area of the polygon */ PIXI.Polygon.prototype.signedArea = function() { const points = this.points; const ln = points.length; if ( ln < 6 ) return 0; // Compute area let area = 0; let x1 = points[ln - 2]; let y1 = points[ln - 1]; for ( let i = 0; i < ln; i += 2 ) { const x2 = points[i]; const y2 = points[i + 1]; area += (x2 - x1) * (y2 + y1); x1 = x2; y1 = y2; } // Negate the area because in Foundry canvas, y-axis is reversed // See https://sourceforge.net/p/jsclipper/wiki/documentation/#clipperlibclipperorientation // The 1/2 comes from the Shoelace formula return area * -0.5; }; /* -------------------------------------------- */ /** * Reverse the order of the polygon points in-place, replacing the points array into the polygon. * Note: references to the old points array will not be affected. * @returns {PIXI.Polygon} This polygon with its orientation reversed */ PIXI.Polygon.prototype.reverseOrientation = function() { const reversed_pts = []; const pts = this.points; const ln = pts.length - 2; for ( let i = ln; i >= 0; i -= 2 ) reversed_pts.push(pts[i], pts[i + 1]); this.points = reversed_pts; if ( this._isPositive !== undefined ) this._isPositive = !this._isPositive; return this; }; /* -------------------------------------------- */ /** * Add a de-duplicated point to the Polygon. * @param {Point} point The point to add to the Polygon * @returns {PIXI.Polygon} A reference to the polygon for method chaining */ PIXI.Polygon.prototype.addPoint = function({x, y}={}) { const l = this.points.length; if ( (x === this.points[l-2]) && (y === this.points[l-1]) ) return this; this.points.push(x, y); this.clearCache(); return this; }; /* -------------------------------------------- */ /** * Return the bounding box for a PIXI.Polygon. * The bounding rectangle is normalized such that the width and height are non-negative. * @returns {PIXI.Rectangle} The bounding PIXI.Rectangle */ PIXI.Polygon.prototype.getBounds = function() { if ( this.points.length < 2 ) return new PIXI.Rectangle(0, 0, 0, 0); let maxX; let maxY; let minX = maxX = this.points[0]; let minY = maxY = this.points[1]; for ( let i=3; 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); }; /* -------------------------------------------- */ /** * @typedef {Object} ClipperPoint * @property {number} X * @property {number} Y */ /** * Construct a PIXI.Polygon instance from an array of clipper points [{X,Y}, ...]. * @param {ClipperPoint[]} 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 {ClipperPoint[]} 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.round(this.points[i-1] * scalingFactor), Y: Math.round(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} The intersected polygon */ PIXI.Polygon.prototype.intersectPolygon = function(other, {clipType, scalingFactor}={}) { const otherPts = other.toClipperPoints({scalingFactor}); const solution = this.intersectClipper(otherPts, {clipType, scalingFactor}); return PIXI.Polygon.fromClipperPoints(solution.length ? solution[0] : [], {scalingFactor}); }; /* -------------------------------------------- */ /** * Intersect this PIXI.Polygon with an array of ClipperPoints. * @param {ClipperPoint[]} clipperPoints Array of clipper points generated by PIXI.Polygon.toClipperPoints() * @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 {ClipperPoint[]} The resulting ClipperPaths */ PIXI.Polygon.prototype.intersectClipper = function(clipperPoints, {clipType, scalingFactor} = {}) { clipType ??= ClipperLib.ClipType.ctIntersection; const c = new ClipperLib.Clipper(); c.AddPath(this.toClipperPoints({scalingFactor}), ClipperLib.PolyType.ptSubject, true); c.AddPath(clipperPoints, ClipperLib.PolyType.ptClip, true); const solution = new ClipperLib.Paths(); c.Execute(clipType, solution); return solution; }; /* -------------------------------------------- */ /** * 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); }; /** * 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 center of this rectangle. * @type {Point} */ Object.defineProperty(PIXI.Rectangle.prototype, "center", { get: function() { return { x: this.x + (this.width * 0.5), y: this.y + (this.height * 0.5) }; }}); /* -------------------------------------------- */ /** * 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)); }; /* -------------------------------------------- */ /** * Determine if a point is on or nearly on this rectangle. * @param {Point} p Point to test * @returns {boolean} Is the point on the rectangle boundary? */ PIXI.Rectangle.prototype.pointIsOn = function(p) { const CSZ = PIXI.Rectangle.CS_ZONES; return this._getZone(p) === CSZ.INSIDE && this._getEdgeZone(p) !== CSZ.INSIDE; }; /* -------------------------------------------- */ /** * Calculate the rectangle Zone for a given point located around, on, or in the rectangle. * See https://en.wikipedia.org/wiki/Cohen%E2%80%93Sutherland_algorithm * This differs from _getZone in how points on the edge are treated: they are not considered inside. * @param {Point} point A point to test for location relative to the rectangle * @returns {PIXI.Rectangle.CS_ZONES} Which edge zone does the point belong to? */ PIXI.Rectangle.prototype._getEdgeZone = function(point) { const CSZ = PIXI.Rectangle.CS_ZONES; let code = CSZ.INSIDE; if ( point.x < this.x || point.x.almostEqual(this.x) ) code |= CSZ.LEFT; else if ( point.x > this.right || point.x.almostEqual(this.right) ) code |= CSZ.RIGHT; if ( point.y < this.y || point.y.almostEqual(this.y) ) code |= CSZ.TOP; else if ( point.y > this.bottom || point.y.almostEqual(this.bottom) ) code |= CSZ.BOTTOM; return code; }; /* -------------------------------------------- */ /** * Get all the points (corners) for a polygon approximation of a rectangle between two points on the rectangle. * The two points can be anywhere in 2d space on or outside the rectangle. * The starting and ending side are based on the zone of the corresponding a and b points. * (See PIXI.Rectangle.CS_ZONES.) * This is the rectangular version of PIXI.Circle.prototype.pointsBetween, and is similarly used * to draw the portion of the shape between two intersection points on that shape. * @param { Point } a A point on or outside the rectangle, representing the starting position. * @param { Point } b A point on or outside the rectangle, representing the starting position. * @returns { Point[]} Points returned are clockwise from start to end. */ PIXI.Rectangle.prototype.pointsBetween = function(a, b) { const CSZ = PIXI.Rectangle.CS_ZONES; // Assume the point could be outside the rectangle but not inside (which would be undefined). const zoneA = this._getEdgeZone(a); if ( !zoneA ) return []; const zoneB = this._getEdgeZone(b); if ( !zoneB ) return []; // If on the same wall, return none if end is counterclockwise to start. if ( zoneA === zoneB && foundry.utils.orient2dFast(this.center, a, b) <= 0 ) return []; let z = zoneA; const pts = []; for ( let i = 0; i < 4; i += 1) { if ( (z & CSZ.LEFT) ) { if ( z !== CSZ.TOPLEFT ) pts.push({ x: this.left, y: this.top }); z = CSZ.TOP; } else if ( (z & CSZ.TOP) ) { if ( z !== CSZ.TOPRIGHT ) pts.push({ x: this.right, y: this.top }); z = CSZ.RIGHT; } else if ( (z & CSZ.RIGHT) ) { if ( z !== CSZ.BOTTOMRIGHT ) pts.push({ x: this.right, y: this.bottom }); z = CSZ.BOTTOM; } else if ( (z & CSZ.BOTTOM) ) { if ( z !== CSZ.BOTTOMLEFT ) pts.push({ x: this.left, y: this.bottom }); z = CSZ.LEFT; } if ( z & zoneB ) break; } return pts; }; /* -------------------------------------------- */ /** * Get all intersection points for a segment A|B * Intersections are sorted from A to B. * @param {Point} a Endpoint A of the segment * @param {Point} b Endpoint B of the segment * @returns {Point[]} Array of intersections or empty if no intersection. * If A|B is parallel to an edge of this rectangle, returns the two furthest points on * the segment A|B that are on the edge. * The return object's t0 property signifies the location of the intersection on segment A|B. * This will be NaN if the segment is a point. * The return object's t1 property signifies the location of the intersection on the rectangle edge. * The t1 value is measured relative to the intersecting edge of the rectangle. */ PIXI.Rectangle.prototype.segmentIntersections = function(a, b) { // The segment is collinear with a vertical edge if ( a.x.almostEqual(b.x) && (a.x.almostEqual(this.left) || a.x.almostEqual(this.right)) ) { const minY1 = Math.min(a.y, b.y); const minY2 = Math.min(this.top, this.bottom); const maxY1 = Math.max(a.y, b.y); const maxY2 = Math.max(this.top, this.bottom); const minIxY = Math.max(minY1, minY2); const maxIxY = Math.min(maxY1, maxY2); // Test whether the two segments intersect const pointIntersection = minIxY.almostEqual(maxIxY); if ( pointIntersection || (minIxY < maxIxY) ) { // Determine t-values of the a|b segment intersections (t0) and the rectangle edge (t1). const distAB = Math.abs(b.y - a.y); const distRect = this.height; const y = (b.y - a.y) > 0 ? a.y : b.y; const rectY = a.x.almostEqual(this.right) ? this.top : this.bottom; const minRes = {x: a.x, y: minIxY, t0: (minIxY - y) / distAB, t1: Math.abs((minIxY - rectY) / distRect)}; // If true, the a|b segment is nearly a point and t0 is likely NaN. if ( pointIntersection ) return [minRes]; // Return in order nearest a, nearest b const maxRes = {x: a.x, y: maxIxY, t0: (maxIxY - y) / distAB, t1: Math.abs((maxIxY - rectY) / distRect)}; return Math.abs(minIxY - a.y) < Math.abs(maxIxY - a.y) ? [minRes, maxRes] : [maxRes, minRes]; } } // The segment is collinear with a horizontal edge else if ( a.y.almostEqual(b.y) && (a.y.almostEqual(this.top) || a.y.almostEqual(this.bottom))) { const minX1 = Math.min(a.x, b.x); const minX2 = Math.min(this.right, this.left); const maxX1 = Math.max(a.x, b.x); const maxX2 = Math.max(this.right, this.left); const minIxX = Math.max(minX1, minX2); const maxIxX = Math.min(maxX1, maxX2); // Test whether the two segments intersect const pointIntersection = minIxX.almostEqual(maxIxX); if ( pointIntersection || (minIxX < maxIxX) ) { // Determine t-values of the a|b segment intersections (t0) and the rectangle edge (t1). const distAB = Math.abs(b.x - a.x); const distRect = this.width; const x = (b.x - a.x) > 0 ? a.x : b.x; const rectX = a.y.almostEqual(this.top) ? this.left : this.right; const minRes = {x: minIxX, y: a.y, t0: (minIxX - x) / distAB, t1: Math.abs((minIxX - rectX) / distRect)}; // If true, the a|b segment is nearly a point and t0 is likely NaN. if ( pointIntersection ) return [minRes]; // Return in order nearest a, nearest b const maxRes = {x: maxIxX, y: a.y, t0: (maxIxX - x) / distAB, t1: Math.abs((maxIxX - rectX) / distRect)}; return Math.abs(minIxX - a.x) < Math.abs(maxIxX - a.x) ? [minRes, maxRes] : [maxRes, minRes]; } } // Follows structure of lineSegmentIntersects const zoneA = this._getZone(a); const zoneB = this._getZone(b); if ( !(zoneA | zoneB) ) return []; // Bitwise OR is 0: both points inside rectangle. // Regular AND: one point inside, one outside // Otherwise, both points outside const zones = !(zoneA && zoneB) ? [zoneA || zoneB] : [zoneA, zoneB]; // If 2 zones, line likely intersects two edges. // It is possible to have a line that starts, for example, at center left and moves to center top. // In this case it may not cross the rectangle. if ( zones.length === 2 && !this.lineSegmentIntersects(a, b) ) return []; const CSZ = PIXI.Rectangle.CS_ZONES; const lsi = foundry.utils.lineSegmentIntersects; const lli = foundry.utils.lineLineIntersection; const { leftEdge, rightEdge, bottomEdge, topEdge } = this; const ixs = []; for ( const z of zones ) { let ix; if ( (z & CSZ.LEFT) && lsi(leftEdge.A, leftEdge.B, a, b)) ix = lli(a, b, leftEdge.A, leftEdge.B); if ( !ix && (z & CSZ.RIGHT) && lsi(rightEdge.A, rightEdge.B, a, b)) ix = lli(a, b, rightEdge.A, rightEdge.B); if ( !ix && (z & CSZ.TOP) && lsi(topEdge.A, topEdge.B, a, b)) ix = lli(a, b, topEdge.A, topEdge.B); if ( !ix && (z & CSZ.BOTTOM) && lsi(bottomEdge.A, bottomEdge.B, a, b)) ix = lli(a, b, bottomEdge.A, bottomEdge.B); // The ix should always be a point by now if ( !ix ) throw new Error("PIXI.Rectangle.prototype.segmentIntersections returned an unexpected null point."); ixs.push(ix); } return ixs; }; /* -------------------------------------------- */ /** * Compute the intersection of this Rectangle with some other Rectangle. * @param {PIXI.Rectangle} other Some other rectangle which intersects this one * @returns {PIXI.Rectangle} The intersected 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 }}; }}); /* -------------------------------------------- */ /** * 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 {PIXI.Rectangle.CS_ZONES} */ 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 for precision * @param {string} [options.weilerAtherton=true] Use the Weiler-Atherton algorithm. Otherwise, use Clipper. * @param {boolean} [options.canMutate] If the WeilerAtherton constructor could mutate or not * @returns {PIXI.Polygon} The intersected polygon */ PIXI.Rectangle.prototype.intersectPolygon = function(polygon, {clipType, scalingFactor, canMutate, weilerAtherton=true}={}) { if ( !this.width || !this.height ) return new PIXI.Polygon([]); clipType ??= ClipperLib.ClipType.ctIntersection; // Use Weiler-Atherton for efficient intersection or union if ( weilerAtherton && polygon.isPositive ) { const res = WeilerAthertonClipper.combine(polygon, this, {clipType, canMutate, scalingFactor}); if ( !res.length ) return new PIXI.Polygon([]); return res[0]; } // Use Clipper polygon intersection return polygon.intersectPolygon(this.toPolygon(), {clipType, canMutate, scalingFactor}); }; /* -------------------------------------------- */ /** * Intersect this PIXI.Rectangle with an array of ClipperPoints. Currently, uses the clipper library. * In the future we may replace this with more specialized logic which uses the line-line intersection formula. * @param {ClipperPoint[]} clipperPoints An array of ClipperPoints generated by PIXI.Polygon.toClipperPoints() * @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.intersectClipper = function(clipperPoints, {clipType, scalingFactor}={}) { if ( !this.width || !this.height ) return []; return this.toPolygon().intersectPolygon(clipperPoints, {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; }; /* -------------------------------------------- */ /** * Fits this rectangle around this rectangle rotated around the given pivot counterclockwise by the given angle in radians. * @param {number} radians The angle of rotation. * @param {PIXI.Point} [pivot] An optional pivot point (normalized). * @returns {this} This rectangle. */ PIXI.Rectangle.prototype.rotate = function(radians, pivot) { if ( radians === 0 ) return this; return this.constructor.fromRotation(this.x, this.y, this.width, this.height, radians, pivot, this); }; /* -------------------------------------------- */ /** * 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 * @param {PIXI.Point} [pivot] An optional pivot point (if not provided, the pivot is the centroid) * @param {PIXI.Rectangle} [_outRect] (Internal) * @returns {PIXI.Rectangle} The constructed rotated rectangle bounds */ PIXI.Rectangle.fromRotation = function(x, y, width, height, radians, pivot, _outRect) { const cosAngle = Math.cos(radians); const sinAngle = Math.sin(radians); // Create the output rect if necessary _outRect ??= new PIXI.Rectangle(); // Is it possible to do with the simple computation? if ( pivot === undefined || ((pivot.x === 0.5) && (pivot.y === 0.5)) ) { _outRect.height = (height * Math.abs(cosAngle)) + (width * Math.abs(sinAngle)); _outRect.width = (height * Math.abs(sinAngle)) + (width * Math.abs(cosAngle)); _outRect.x = x + ((width - _outRect.width) / 2); _outRect.y = y + ((height - _outRect.height) / 2); return _outRect; } // Calculate the pivot point in absolute coordinates const pivotX = x + (width * pivot.x); const pivotY = y + (height * pivot.y); // Calculate vectors from pivot to the rectangle's corners const tlX = x - pivotX; const tlY = y - pivotY; const trX = x + width - pivotX; const trY = y - pivotY; const blX = x - pivotX; const blY = y + height - pivotY; const brX = x + width - pivotX; const brY = y + height - pivotY; // Apply rotation to the vectors const rTlX = (cosAngle * tlX) - (sinAngle * tlY); const rTlY = (sinAngle * tlX) + (cosAngle * tlY); const rTrX = (cosAngle * trX) - (sinAngle * trY); const rTrY = (sinAngle * trX) + (cosAngle * trY); const rBlX = (cosAngle * blX) - (sinAngle * blY); const rBlY = (sinAngle * blX) + (cosAngle * blY); const rBrX = (cosAngle * brX) - (sinAngle * brY); const rBrY = (sinAngle * brX) + (cosAngle * brY); // Find the new corners of the bounding rectangle const minX = Math.min(rTlX, rTrX, rBlX, rBrX); const minY = Math.min(rTlY, rTrY, rBlY, rBrY); const maxX = Math.max(rTlX, rTrX, rBlX, rBrX); const maxY = Math.max(rTlY, rTrY, rBlY, rBrY); // Assign the new computed bounding box _outRect.x = pivotX + minX; _outRect.y = pivotY + minY; _outRect.width = maxX - minX; _outRect.height = maxY - minY; return _outRect; }; /** * @typedef {foundry.utils.Collection} EffectsCollection */ /** * A container group which contains visual effects rendered above the primary group. * * TODO: * The effects canvas group is now only performing shape initialization, logic that needs to happen at * the placeable or object level is now their burden. * - [DONE] Adding or removing a source from the EffectsCanvasGroup collection. * - [TODO] A change in a darkness source should re-initialize all overlaping light and vision source. * * ### Hook Events * - {@link hookEvents.lightingRefresh} * * @category - Canvas */ class EffectsCanvasGroup extends CanvasGroupMixin(PIXI.Container) { /** * The name of the darkness level animation. * @type {string} */ static #DARKNESS_ANIMATION_NAME = "lighting.animateDarkness"; /** * 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 {EffectsCollection} */ lightSources = new foundry.utils.Collection(); /** * A mapping of darkness sources which are active within the rendered Scene. * @type {EffectsCollection} */ darknessSources = new foundry.utils.Collection(); /** * A Collection of vision sources which are currently active within the rendered Scene. * @type {EffectsCollection} */ visionSources = new foundry.utils.Collection(); /** * A set of vision mask filters used in visual effects group * @type {Set} */ visualEffectsMaskingFilters = new Set(); /* -------------------------------------------- */ /** * Iterator for all light and darkness sources. * @returns {Generator} * @yields foundry.canvas.sources.PointDarknessSource|foundry.canvas.sources.PointLightSource */ * allSources() { for ( const darknessSource of this.darknessSources ) yield darknessSource; for ( const lightSource of this.lightSources ) yield lightSource; } /* -------------------------------------------- */ /** @override */ _createLayers() { /** * 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 adds darkness effects to the scene. * @type {CanvasDarknessEffects} */ this.darkness = this.addChild(new CanvasDarknessEffects()); return { background: this.background, illumination: this.illumination, coloration: this.coloration, darkness: this.darkness }; } /* -------------------------------------------- */ /** * Clear all effects containers and animated sources. */ clearEffects() { this.background.clear(); this.illumination.clear(); this.coloration.clear(); this.darkness.clear(); } /* -------------------------------------------- */ /** @override */ async _draw(options) { // Draw each component layer await this.background.draw(); await this.illumination.draw(); await this.coloration.draw(); await this.darkness.draw(); // Call hooks Hooks.callAll("drawEffectsCanvasGroup", this); // Activate animation of drawn objects this.activateAnimation(); } /* -------------------------------------------- */ /* Perception Management Methods */ /* -------------------------------------------- */ /** * Initialize positive light sources which exist within the active Scene. * Packages can use the "initializeLightSources" hook to programmatically add light sources. */ initializeLightSources() { for ( let source of this.lightSources ) source.initialize(); Hooks.callAll("initializeLightSources", this); } /* -------------------------------------------- */ /** * Re-initialize the shapes of all darkness sources in the Scene. * This happens before initialization of light sources because darkness sources contribute additional edges which * limit perception. * Packages can use the "initializeDarknessSources" hook to programmatically add darkness sources. */ initializeDarknessSources() { for ( let source of this.darknessSources ) source.initialize(); Hooks.callAll("initializeDarknessSources", this); } /* -------------------------------------------- */ /** * Refresh the state and uniforms of all light sources and darkness sources objects. */ refreshLightSources() { for ( const source of this.allSources() ) source.refresh(); // FIXME: We need to refresh the field of an AmbientLight only after the initialization of the light source when // the shape of the source could have changed. We don't need to refresh all fields whenever lighting is refreshed. canvas.lighting.refreshFields(); } /* -------------------------------------------- */ /** * Refresh the state and uniforms of all VisionSource objects. */ refreshVisionSources() { for ( const visionSource of this.visionSources ) visionSource.refresh(); } /* -------------------------------------------- */ /** * Refresh the active display of lighting. */ refreshLighting() { // Apply illumination and visibility background color change this.illumination.backgroundColor = canvas.colors.background; if ( this.illumination.darknessLevelMeshes.clearColor[0] !== canvas.environment.darknessLevel ) { this.illumination.darknessLevelMeshes.clearColor[0] = canvas.environment.darknessLevel; this.illumination.invalidateDarknessLevelContainer(true); } const v = canvas.visibility.filter; if ( v ) { v.uniforms.visionTexture = canvas.masks.vision.renderTexture; v.uniforms.primaryTexture = canvas.primary.renderTexture; canvas.colors.fogExplored.applyRGB(v.uniforms.exploredColor); canvas.colors.fogUnexplored.applyRGB(v.uniforms.unexploredColor); canvas.colors.background.applyRGB(v.uniforms.backgroundColor); } // Clear effects canvas.effects.clearEffects(); // Add effect meshes for active light and darkness sources for ( const source of this.allSources() ) this.#addLightEffect(source); // Add effect meshes for active vision sources for ( const visionSource of this.visionSources ) this.#addVisionEffect(visionSource); // Update vision filters state this.background.vision.filter.enabled = !!this.background.vision.children.length; this.background.visionPreferred.filter.enabled = !!this.background.visionPreferred.children.length; // Hide the background and/or coloration layers if possible const lightingOptions = canvas.visibility.visionModeData.activeLightingOptions; this.background.vision.visible = (this.background.vision.children.length > 0); this.background.visionPreferred.visible = (this.background.visionPreferred.children.length > 0); this.background.lighting.visible = (this.background.lighting.children.length > 0) || (lightingOptions.background?.postProcessingModes?.length > 0); this.coloration.visible = (this.coloration.children.length > 1) || (lightingOptions.coloration?.postProcessingModes?.length > 0); // Call hooks Hooks.callAll("lightingRefresh", this); } /* -------------------------------------------- */ /** * Add a vision source to the effect layers. * @param {RenderedEffectSource & PointVisionSource} source The vision source to add mesh layers */ #addVisionEffect(source) { if ( !source.active || (source.radius <= 0) ) return; const meshes = source.drawMeshes(); if ( meshes.background ) { // Is this vision source background need to be rendered into the preferred vision container, over other VS? const parent = source.preferred ? this.background.visionPreferred : this.background.vision; parent.addChild(meshes.background); } if ( meshes.illumination ) this.illumination.lights.addChild(meshes.illumination); if ( meshes.coloration ) this.coloration.addChild(meshes.coloration); } /* -------------------------------------------- */ /** * Add a light source or a darkness source to the effect layers * @param {RenderedEffectSource & BaseLightSource} source The light or darkness source to add to the effect layers. */ #addLightEffect(source) { if ( !source.active ) return; const meshes = source.drawMeshes(); if ( meshes.background ) this.background.lighting.addChild(meshes.background); if ( meshes.illumination ) this.illumination.lights.addChild(meshes.illumination); if ( meshes.coloration ) this.coloration.addChild(meshes.coloration); if ( meshes.darkness ) this.darkness.addChild(meshes.darkness); } /* -------------------------------------------- */ /** * Test whether the point is inside light. * @param {Point} point The point. * @param {number} elevation The elevation of the point. * @returns {boolean} Is inside light? */ testInsideLight(point, elevation) { // First test light source excluding the global light source for ( const lightSource of this.lightSources ) { if ( !lightSource.active || (lightSource instanceof foundry.canvas.sources.GlobalLightSource) ) continue; if ( lightSource.shape.contains(point.x, point.y) ) return true; } // Second test Global Illumination and Darkness Level meshes const globalLightSource = canvas.environment.globalLightSource; if ( !globalLightSource.active ) return false; const {min, max} = globalLightSource.data.darkness; const darknessLevel = this.getDarknessLevel(point, elevation); return (darknessLevel >= min) && (darknessLevel <= max); } /* -------------------------------------------- */ /** * Test whether the point is inside darkness. * @param {Point} point The point. * @param {number} elevation The elevation of the point. * @returns {boolean} Is inside a darkness? */ testInsideDarkness({x, y}, elevation) { for ( const source of this.darknessSources ) { if ( !source.active || source.isPreview ) continue; for ( let dx = -1; dx <= 1; dx += 1 ) { for ( let dy = -1; dy <= 1; dy += 1 ) { if ( source.shape.contains(x + dx, y + dy) ) return true; } } } return false; } /* -------------------------------------------- */ /** * Get the darkness level at the given point. * @param {Point} point The point. * @param {number} elevation The elevation of the point. * @returns {number} The darkness level. */ getDarknessLevel(point, elevation) { const darknessLevelMeshes = canvas.effects.illumination.darknessLevelMeshes.children; for ( let i = darknessLevelMeshes.length - 1; i >= 0; i-- ) { const darknessLevelMesh = darknessLevelMeshes[i]; if ( darknessLevelMesh.region.testPoint(point, elevation) ) { return darknessLevelMesh.shader.uniforms.darknessLevel; } } return canvas.environment.darknessLevel; } /* -------------------------------------------- */ /** @override */ async _tearDown(options) { CanvasAnimation.terminateAnimation(EffectsCanvasGroup.#DARKNESS_ANIMATION_NAME); this.deactivateAnimation(); this.darknessSources.clear(); this.lightSources.clear(); for ( const c of this.children ) { if ( c.clear ) c.clear(); else if ( c.tearDown ) await c.tearDown(); else c.destroy(); } this.visualEffectsMaskingFilters.clear(); } /* -------------------------------------------- */ /** * 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.uniforms.mode === 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 and darkness sources if ( this.animateLightSources ) { for ( const source of this.allSources() ) { source.animate(dt); } } // Animate vision sources if ( this.animateVisionSources ) { for ( const source of this.visionSources.values() ) { 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}={}) { CanvasAnimation.terminateAnimation(EffectsCanvasGroup.#DARKNESS_ANIMATION_NAME); if ( target === canvas.environment.darknessLevel ) return false; if ( duration <= 0 ) return canvas.environment.initialize({environment: {darknessLevel: target}}); // Update with an animation const animationData = [{ parent: {darkness: canvas.environment.darknessLevel}, attribute: "darkness", to: Math.clamp(target, 0, 1) }]; return CanvasAnimation.animate(animationData, { name: EffectsCanvasGroup.#DARKNESS_ANIMATION_NAME, duration: duration, ontick: (dt, animation) => canvas.environment.initialize({environment: {darknessLevel: animation.attributes[0].parent.darkness}}) }).then(completed => { if ( !completed ) canvas.environment.initialize({environment: {darknessLevel: target}}); }); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get visibility() { const msg = "EffectsCanvasGroup#visibility has been deprecated and moved to " + "Canvas#visibility."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return canvas.visibility; } /** * @deprecated since v12 * @ignore */ get globalLightSource() { const msg = "EffectsCanvasGroup#globalLightSource has been deprecated and moved to " + "EnvironmentCanvasGroup#globalLightSource."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return canvas.environment.globalLightSource; } /** * @deprecated since v12 * @ignore */ updateGlobalLightSource() { const msg = "EffectsCanvasGroup#updateGlobalLightSource has been deprecated and is part of " + "EnvironmentCanvasGroup#initialize workflow."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); canvas.environment.initialize(); } } /** * A container group which contains the primary canvas group and the effects canvas group. * * @category - Canvas */ class EnvironmentCanvasGroup extends CanvasGroupMixin(PIXI.Container) { constructor(...args) { super(...args); this.eventMode = "static"; /** * The global light source attached to the environment * @type {GlobalLightSource} */ Object.defineProperty(this, "globalLightSource", { value: new CONFIG.Canvas.globalLightSourceClass({object: this, sourceId: "globalLight"}), configurable: false, enumerable: true, writable: false }); } /** @override */ static groupName = "environment"; /** @override */ static tearDownChildren = false; /** * 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: 0x999999, fogUnexplored: 0x000000, fogExplored: 0x000000 }; /** * Contains a list of subscribed function for darkness handler. * @type {PIXI.EventBoundary} */ #eventBoundary; /* -------------------------------------------- */ /* Properties */ /* -------------------------------------------- */ /** * Get the darkness level of this scene. * @returns {number} */ get darknessLevel() { return this.#darknessLevel; } /* -------------------------------------------- */ /* Rendering */ /* -------------------------------------------- */ /** @override */ async _draw(options) { await super._draw(options); this.#eventBoundary = new PIXI.EventBoundary(this); this.initialize(); } /* -------------------------------------------- */ /* Ambience Methods */ /* -------------------------------------------- */ /** * Initialize the scene environment options. * @param {object} [config={}] * @param {ColorSource} [config.backgroundColor] The background canvas color * @param {ColorSource} [config.brightestColor] The brightest ambient color * @param {ColorSource} [config.darknessColor] The color of darkness * @param {ColorSource} [config.daylightColor] The ambient daylight color * @param {ColorSource} [config.fogExploredColor] The color applied to explored areas * @param {ColorSource} [config.fogUnexploredColor] The color applied to unexplored areas * @param {SceneEnvironmentData} [config.environment] The scene environment data * @fires PIXI.FederatedEvent type: "darknessChange" - event: {environmentData: {darknessLevel, priorDarknessLevel}} */ initialize({backgroundColor, brightestColor, darknessColor, daylightColor, fogExploredColor, fogUnexploredColor, darknessLevel, environment={}}={}) { const scene = canvas.scene; // Update base ambient colors, and darkness level const fbc = EnvironmentCanvasGroup.#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); /** * @deprecated since v12 */ if ( darknessLevel !== undefined ) { const msg = "config.darknessLevel parameter into EnvironmentCanvasGroup#initialize is deprecated. " + "You should pass the darkness level into config.environment.darknessLevel"; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); environment.darknessLevel = darknessLevel; } // Darkness Level Control const priorDarknessLevel = this.#darknessLevel ?? 0; const dl = environment.darknessLevel ?? scene.environment.darknessLevel; const darknessChanged = (dl !== this.#darknessLevel); this.#darknessLevel = scene.environment.darknessLevel = dl; // Update weights Object.assign(this.weights, CONFIG.Canvas.lightLevels ?? { dark: 0, halfdark: 0.5, dim: 0.25, bright: 1 }); // Compute colors this.#configureColors({fogExploredColor, fogUnexploredColor, backgroundColor}); // Configure the scene environment this.#configureEnvironment(environment); // Update primary cached container and renderer clear color with scene background color canvas.app.renderer.background.color = this.colors.rendererBackground; canvas.primary._backgroundColor = this.colors.sceneBackground.rgb; // Dispatching the darkness change event if ( darknessChanged ) { const event = new PIXI.FederatedEvent(this.#eventBoundary); event.type = "darknessChange"; event.environmentData = { darknessLevel: this.#darknessLevel, priorDarknessLevel }; this.dispatchEvent(event); } // Push a perception update to refresh lighting and sources with the new computed color values canvas.perception.update({ refreshPrimary: true, refreshLighting: true, refreshVision: true }); } /* -------------------------------------------- */ /** * Configure all colors pertaining to a scene. * @param {object} [options={}] Preview options. * @param {ColorSource} [options.fogExploredColor] A preview fog explored color. * @param {ColorSource} [options.fogUnexploredColor] A preview fog unexplored color. * @param {ColorSource} [options.backgroundColor] The background canvas color. */ #configureColors({fogExploredColor, fogUnexploredColor, backgroundColor}={}) { const scene = canvas.scene; const fbc = EnvironmentCanvasGroup.#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, 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.fog.colors.unexplored ?? cfg.unexploredColor ?? fbc.fogUnexplored); this.colors.fogUnexplored = this.colors.background.multiply(uc); const ec = Color.from(fogExploredColor ?? scene.fog.colors.explored ?? 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); } /* -------------------------------------------- */ /** * Configure the ambience filter for scene ambient lighting. * @param {SceneEnvironmentData} [environment] The scene environment data object. */ #configureEnvironment(environment={}) { const currentEnvironment = canvas.scene.toObject().environment; /** * @type {SceneEnvironmentData} */ const data = foundry.utils.mergeObject(environment, currentEnvironment, { inplace: false, insertKeys: true, insertValues: true, overwrite: false }); // First configure the ambience filter this.#configureAmbienceFilter(data); // Then configure the global light this.#configureGlobalLight(data); } /* -------------------------------------------- */ /** * Configure the ambience filter. * @param {SceneEnvironmentData} environment * @param {boolean} environment.cycle The cycle option. * @param {EnvironmentData} environment.base The base environement data. * @param {EnvironmentData} environment.dark The dark environment data. */ #configureAmbienceFilter({cycle, base, dark}) { const ambienceFilter = canvas.primary._ambienceFilter; if ( !ambienceFilter ) return; const u = ambienceFilter.uniforms; // Assigning base ambience parameters const bh = Color.fromHSL([base.hue, 1, 0.5]).linear; Color.applyRGB(bh, u.baseTint); u.baseLuminosity = base.luminosity; u.baseShadows = base.shadows; u.baseIntensity = base.intensity; u.baseSaturation = base.saturation; const baseAmbienceHasEffect = (base.luminosity !== 0) || (base.shadows > 0) || (base.intensity > 0) || (base.saturation !== 0); // Assigning dark ambience parameters const dh = Color.fromHSL([dark.hue, 1, 0.5]).linear; Color.applyRGB(dh, u.darkTint); u.darkLuminosity = dark.luminosity; u.darkShadows = dark.shadows; u.darkIntensity = dark.intensity; u.darkSaturation = dark.saturation; const darkAmbienceHasEffect = ((dark.luminosity !== 0) || (dark.shadows > 0) || (dark.intensity > 0) || (dark.saturation !== 0)) && cycle; // Assigning the cycle option u.cycle = cycle; // Darkness level texture u.darknessLevelTexture = canvas.effects.illumination.renderTexture; // Enable ambience filter if it is impacting visuals ambienceFilter.enabled = baseAmbienceHasEffect || darkAmbienceHasEffect; } /* -------------------------------------------- */ /** * Configure the global light. * @param {SceneEnvironmentData} environment * @param {GlobalLightData} environment.globalLight */ #configureGlobalLight({globalLight}) { const maxR = canvas.dimensions.maxR * 1.2; const globalLightData = foundry.utils.mergeObject({ z: -Infinity, elevation: Infinity, dim: globalLight.bright ? 0 : maxR, bright: globalLight.bright ? maxR : 0, disabled: !globalLight.enabled }, globalLight, {overwrite: false}); this.globalLightSource.initialize(globalLightData); this.globalLightSource.add(); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get darknessPenalty() { const msg = "EnvironmentCanvasGroup#darknessPenalty is deprecated without replacement. " + "The darkness penalty is no longer applied on light and vision sources."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return 0; } } /** * A specialized canvas group for rendering hidden containers before all others (like masks). * @extends {PIXI.Container} */ class HiddenCanvasGroup extends CanvasGroupMixin(PIXI.Container) { constructor() { super(); this.eventMode = "none"; 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); } /* -------------------------------------------- */ /** * Invalidate the masks: flag them for rerendering. */ invalidateMasks() { for ( const mask of this.masks.children ) { if ( !(mask instanceof CachedContainer) ) continue; mask.renderDirty = true; } } /* -------------------------------------------- */ /* Rendering */ /* -------------------------------------------- */ /** @inheritDoc */ async _draw(options) { this.invalidateMasks(); this.addChild(this.masks); await this.#drawMasks(); await super._draw(options); } /* -------------------------------------------- */ /** * 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); // The scene scissor mask const scene = new PIXI.LegacyGraphics(); this.addMask("scene", scene); // 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 */ /* -------------------------------------------- */ /** @inheritDoc */ async _tearDown(options) { this.removeChild(this.masks); // Clear all masks (children of masks) this.masks.children.forEach(c => c.clear()); // Then proceed normally await super._tearDown(options); } } /** * A container group which displays interface elements rendered above other canvas groups. * @extends {CanvasGroupMixin(PIXI.Container)} */ class InterfaceCanvasGroup extends CanvasGroupMixin(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; /** * The interface drawings container. * @type {PIXI.Container} */ #drawings; /* -------------------------------------------- */ /* Drawing Management */ /* -------------------------------------------- */ /** * Add a PrimaryGraphics to the group. * @param {Drawing} drawing The Drawing being added * @returns {PIXI.Graphics} The created Graphics instance */ addDrawing(drawing) { const name = drawing.objectId; const shape = this.drawings.graphics.get(name) ?? this.#drawings.addChild(new PIXI.Graphics()); shape.name = name; this.drawings.graphics.set(name, shape); return shape; } /* -------------------------------------------- */ /** * Remove a PrimaryGraphics from the group. * @param {Drawing} drawing The Drawing being removed */ removeDrawing(drawing) { const name = drawing.objectId; if ( !this.drawings.graphics.has(name) ) return; const shape = this.drawings.graphics.get(name); if ( shape?.destroyed === false ) shape.destroy({children: true}); this.drawings.graphics.delete(name); } /* -------------------------------------------- */ /* Rendering */ /* -------------------------------------------- */ /** @inheritDoc */ async _draw(options) { this.#drawOutline(); this.#createInterfaceDrawingsContainer(); this.#drawScrollingText(); await super._draw(options); // Necessary so that Token#voidMesh don't earse non-interface elements this.filters = [new VoidFilter()]; this.filterArea = canvas.app.screen; } /* -------------------------------------------- */ /** * 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.eventMode = "none"; this.#scrollingText.interactiveChildren = false; this.#scrollingText.zIndex = CONFIG.Canvas.groups.interface.zIndexScrollingText; } /* -------------------------------------------- */ /** * Create the interface drawings container. */ #createInterfaceDrawingsContainer() { this.#drawings = this.addChild(new PIXI.Container()); this.#drawings.sortChildren = function() { const children = this.children; for ( let i = 0, n = children.length; i < n; i++ ) children[i]._lastSortedIndex = i; children.sort(InterfaceCanvasGroup.#compareObjects); this.sortDirty = false; }; this.#drawings.sortableChildren = true; this.#drawings.eventMode = "none"; this.#drawings.interactiveChildren = false; this.#drawings.zIndex = CONFIG.Canvas.groups.interface.zIndexDrawings; } /* -------------------------------------------- */ /** * The sorting function used to order objects inside the Interface Drawings Container * Overrides the default sorting function defined for the PIXI.Container. * @param {PrimaryCanvasObject|PIXI.DisplayObject} a An object to display * @param {PrimaryCanvasObject|PIXI.DisplayObject} b Some other object to display * @returns {number} */ static #compareObjects(a, b) { return ((a.elevation || 0) - (b.elevation || 0)) || ((a.sort || 0) - (b.sort || 0)) || (a.zIndex - b.zIndex) || (a._lastSortedIndex - b._lastSortedIndex); } /* -------------------------------------------- */ /** * Display scrolling status text originating from an origin point on the Canvas. * @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: CanvasAnimation.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: CanvasAnimation.easeInOutCosine }); // Clean-up this.#scrollingText.removeChild(text); text.destroy(); } } /** * A container group which is not bound to the stage world transform. * * @category - Canvas */ class OverlayCanvasGroup extends CanvasGroupMixin(UnboundContainer) { /** @override */ static groupName = "overlay"; /** @override */ static tearDownChildren = false; } /** * 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 {CachedContainer} * @mixes CanvasGroupMixin * @category - Canvas */ class PrimaryCanvasGroup extends CanvasGroupMixin(CachedContainer) { constructor(sprite) { sprite ||= new SpriteMesh(undefined, BaseSamplerShader); super(sprite); this.eventMode = "none"; this.#createAmbienceFilter(); this.on("childAdded", this.#onChildAdded); this.on("childRemoved", this.#onChildRemoved); } /** * Sort order to break ties on the group/layer level. * @enum {number} */ static SORT_LAYERS = Object.freeze({ SCENE: 0, TILES: 500, DRAWINGS: 600, TOKENS: 700, WEATHER: 1000 }); /** @override */ static groupName = "primary"; /** @override */ static textureConfiguration = { scaleMode: PIXI.SCALE_MODES.NEAREST, format: PIXI.FORMATS.RGB, multisample: PIXI.MSAA_QUALITY.NONE }; /** @override */ clearColor = [0, 0, 0, 0]; /** * The background color in RGB. * @type {[red: number, green: number, blue: number]} * @internal */ _backgroundColor; /** * Track the set of HTMLVideoElements which are currently playing as part of this group. * @type {Set} */ videoMeshes = new Set(); /** * Occludable objects above this elevation are faded on hover. * @type {number} */ hoverFadeElevation = 0; /** * 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; /** * A Quadtree which partitions and organizes primary canvas objects. * @type {CanvasQuadtree} */ quadtree = new CanvasQuadtree(); /** * 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(); /** * The ambience filter which is applying post-processing effects. * @type {PrimaryCanvasGroupAmbienceFilter} * @internal */ _ambienceFilter; /** * The objects that are currently hovered in reverse sort order. * @type {PrimaryCanvasObjec[]>} */ #hoveredObjects = []; /** * Trace the tiling sprite error to avoid multiple warning. * FIXME: Remove when the deprecation period for the tiling sprite error is over. * @type {boolean} * @internal */ #tilingSpriteError = false; /* -------------------------------------------- */ /* 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 */ /* -------------------------------------------- */ /** * Create the ambience filter bound to the primary group. */ #createAmbienceFilter() { if ( this._ambienceFilter ) this._ambienceFilter.enabled = false; else { this.filters ??= []; const f = this._ambienceFilter = PrimaryCanvasGroupAmbienceFilter.create(); f.enabled = false; this.filterArea = canvas.app.renderer.screen; this.filters.push(f); } } /* -------------------------------------------- */ /** * Refresh the primary mesh. */ refreshPrimarySpriteMesh() { const singleSource = canvas.visibility.visionModeData.source; const vmOptions = singleSource?.visionMode.canvas; const isBaseSampler = (this.sprite.shader.constructor === BaseSamplerShader); 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.environment.darknessLevel; vmOptions.uniforms.darknessLevelTexture = canvas.effects.illumination.renderTexture; vmOptions.uniforms.screenDimensions = canvas.screenDimensions; // Assigning color from source if any vmOptions.uniforms.tint = singleSource?.visionModeOverrides.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; } } /* -------------------------------------------- */ /** * Update this group. Calculates the canvas transform and bounds of all its children and updates the quadtree. */ update() { if ( this.sortDirty ) this.sortChildren(); const children = this.children; for ( let i = 0, n = children.length; i < n; i++ ) { children[i].updateCanvasTransform?.(); } canvas.masks.depth._update(); if ( !CONFIG.debug.canvas.primary.bounds ) return; const dbg = canvas.controls.debug.clear().lineStyle(5, 0x30FF00); for ( const child of this.children ) { if ( child.canvasBounds ) dbg.drawShape(child.canvasBounds); } } /* -------------------------------------------- */ /** @inheritDoc */ async _draw(options) { this.#drawBackground(); this.#drawForeground(); this.#drawPadding(); this.hoverFadeElevation = 0; await super._draw(options); } /* -------------------------------------------- */ /** @inheritDoc */ _render(renderer) { const [r, g, b] = this._backgroundColor; renderer.framebuffer.clear(r, g, b, 1, PIXI.BUFFER_BITS.COLOR); super._render(renderer); } /* -------------------------------------------- */ /** * Draw the Scene background image. */ #drawBackground() { const bg = this.background = this.addChild(new PrimarySpriteMesh({name: "background", object: this})); bg.elevation = this.constructor.BACKGROUND_ELEVATION; const bgTextureSrc = canvas.sceneTextures.background ?? canvas.scene.background.src; const bgTexture = bgTextureSrc instanceof PIXI.Texture ? bgTextureSrc : getTexture(bgTextureSrc); this.#drawSceneMesh(bg, bgTexture); } /* -------------------------------------------- */ /** * Draw the Scene foreground image. */ #drawForeground() { const fg = this.foreground = this.addChild(new PrimarySpriteMesh({name: "foreground", object: this})); fg.elevation = canvas.scene.foregroundElevation; const fgTextureSrc = canvas.sceneTextures.foreground ?? canvas.scene.foreground; const fgTexture = fgTextureSrc instanceof PIXI.Texture ? fgTextureSrc : getTexture(fgTextureSrc); // Compare dimensions with background texture and draw the mesh const bg = this.background.texture; if ( fgTexture && bg && ((fgTexture.width !== bg.width) || (fgTexture.height !== bg.height)) ) { ui.notifications.warn("WARNING.ForegroundDimensionsMismatch", {localize: true}); } this.#drawSceneMesh(fg, fgTexture); } /* -------------------------------------------- */ /** * Draw a PrimarySpriteMesh that fills the entire Scene rectangle. * @param {PrimarySpriteMesh} mesh The target PrimarySpriteMesh * @param {PIXI.Texture|null} texture The loaded Texture or null */ #drawSceneMesh(mesh, texture) { const d = canvas.dimensions; mesh.texture = texture ?? PIXI.Texture.EMPTY; mesh.textureAlphaThreshold = 0.75; mesh.occludedAlpha = 0.5; mesh.visible = mesh.texture !== PIXI.Texture.EMPTY; mesh.position.set(d.sceneX, d.sceneY); mesh.width = d.sceneWidth; mesh.height = d.sceneHeight; mesh.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.SCENE; mesh.zIndex = -Infinity; mesh.hoverFade = false; // 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")}); } } /* -------------------------------------------- */ /** * Draw the Scene padding. */ #drawPadding() { const d = canvas.dimensions; const g = this.addChild(new PIXI.LegacyGraphics()); g.beginFill(0x000000, 0.025) .drawShape(d.rect) .beginHole() .drawShape(d.sceneRect) .endHole() .endFill(); g.elevation = -Infinity; g.sort = -Infinity; } /* -------------------------------------------- */ /* Tear-Down */ /* -------------------------------------------- */ /** @inheritDoc */ async _tearDown(options) { // Stop video playback for ( const mesh of this.videoMeshes ) game.video.stop(mesh.sourceElement); await super._tearDown(options); // Clear collections this.videoMeshes.clear(); this.tokens.clear(); this.tiles.clear(); // Clear the quadtree this.quadtree.clear(); // Reset the tiling sprite tracker this.#tilingSpriteError = false; } /* -------------------------------------------- */ /* Token Management */ /* -------------------------------------------- */ /** * Draw the SpriteMesh for a specific Token object. * @param {Token} token The Token being added * @returns {PrimarySpriteMesh} The added PrimarySpriteMesh */ addToken(token) { const name = token.objectId; // Create the token mesh const mesh = this.tokens.get(name) ?? this.addChild(new PrimarySpriteMesh({name, object: token})); mesh.texture = token.texture ?? PIXI.Texture.EMPTY; this.tokens.set(name, 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 name = token.objectId; const mesh = this.tokens.get(name); if ( mesh?.destroyed === false ) mesh.destroy({children: true}); this.tokens.delete(name); this.videoMeshes.delete(mesh); } /* -------------------------------------------- */ /* Tile Management */ /* -------------------------------------------- */ /** * Draw the SpriteMesh for a specific Token object. * @param {Tile} tile The Tile being added * @returns {PrimarySpriteMesh} The added PrimarySpriteMesh */ addTile(tile) { /** @deprecated since v12 */ if ( !this.#tilingSpriteError && tile.document.getFlag("core", "isTilingSprite") ) { this.#tilingSpriteError = true; ui.notifications.warn("WARNING.TilingSpriteDeprecation", {localize: true, permanent: true}); const msg = "Tiling Sprites are deprecated without replacement."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); } const name = tile.objectId; let mesh = this.tiles.get(name) ?? this.addChild(new PrimarySpriteMesh({name, object: tile})); mesh.texture = tile.texture ?? PIXI.Texture.EMPTY; this.tiles.set(name, 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 name = tile.objectId; const mesh = this.tiles.get(name); if ( mesh?.destroyed === false ) mesh.destroy({children: true}); this.tiles.delete(name); this.videoMeshes.delete(mesh); } /* -------------------------------------------- */ /* Drawing Management */ /* -------------------------------------------- */ /** * Add a PrimaryGraphics to the group. * @param {Drawing} drawing The Drawing being added * @returns {PrimaryGraphics} The created PrimaryGraphics instance */ addDrawing(drawing) { const name = drawing.objectId; const shape = this.drawings.get(name) ?? this.addChild(new PrimaryGraphics({name, object: drawing})); this.drawings.set(name, shape); return shape; } /* -------------------------------------------- */ /** * Remove a PrimaryGraphics from the group. * @param {Drawing} drawing The Drawing being removed */ removeDrawing(drawing) { const name = drawing.objectId; if ( !this.drawings.has(name) ) return; const shape = this.drawings.get(name); if ( shape?.destroyed === false ) shape.destroy({children: true}); this.drawings.delete(name); } /* -------------------------------------------- */ /** * Override the default PIXI.Container behavior for how objects in this container are sorted. * @override */ sortChildren() { const children = this.children; for ( let i = 0, n = children.length; i < n; i++ ) children[i]._lastSortedIndex = i; children.sort(PrimaryCanvasGroup.#compareObjects); 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 Tokens PCO above other objects except WeatherEffects, then Drawings PCO, 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} */ static #compareObjects(a, b) { return ((a.elevation || 0) - (b.elevation || 0)) || ((a.sortLayer || 0) - (b.sortLayer || 0)) || ((a.sort || 0) - (b.sort || 0)) || (a.zIndex - b.zIndex) || (a._lastSortedIndex - b._lastSortedIndex); } /* -------------------------------------------- */ /* PIXI Events */ /* -------------------------------------------- */ /** * Called when a child is added. * @param {PIXI.DisplayObject} child */ #onChildAdded(child) { if ( child.shouldRenderDepth ) canvas.masks.depth._elevationDirty = true; } /* -------------------------------------------- */ /** * Called when a child is removed. * @param {PIXI.DisplayObject} child */ #onChildRemoved(child) { if ( child.shouldRenderDepth ) canvas.masks.depth._elevationDirty = true; } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** * Handle mousemove events on the primary group to update the hovered state of its children. * @internal */ _onMouseMove() { const time = canvas.app.ticker.lastTime; // Unset the hovered state of the hovered PCOs for ( const object of this.#hoveredObjects ) { if ( !object._hoverFadeState.hovered ) continue; object._hoverFadeState.hovered = false; object._hoverFadeState.hoveredTime = time; } this.#updateHoveredObjects(); // Set the hovered state of the hovered PCOs for ( const object of this.#hoveredObjects ) { if ( !object.hoverFade || !(object.elevation > this.hoverFadeElevation) ) break; object._hoverFadeState.hovered = true; object._hoverFadeState.hoveredTime = time; } } /* -------------------------------------------- */ /** * Update the hovered objects. Returns the hovered objects. */ #updateHoveredObjects() { this.#hoveredObjects.length = 0; // Get all PCOs that contain the mouse position const position = canvas.mousePosition; const collisionTest = ({t}) => t.visible && t.renderable && t._hoverFadeState && t.containsCanvasPoint(position); for ( const object of canvas.primary.quadtree.getObjects( new PIXI.Rectangle(position.x, position.y, 0, 0), {collisionTest} )) { this.#hoveredObjects.push(object); } // Sort the hovered PCOs in reverse primary order this.#hoveredObjects.sort((a, b) => PrimaryCanvasGroup.#compareObjects(b, a)); // Discard hit objects below the hovered placeable const hoveredPlaceable = canvas.activeLayer?.hover; if ( hoveredPlaceable ) { let elevation = 0; let sortLayer = Infinity; let sort = Infinity; let zIndex = Infinity; if ( (hoveredPlaceable instanceof Token) || (hoveredPlaceable instanceof Tile) ) { const mesh = hoveredPlaceable.mesh; if ( mesh ) { elevation = mesh.elevation; sortLayer = mesh.sortLayer; sort = mesh.sort; zIndex = mesh.zIndex; } } else if ( hoveredPlaceable instanceof Drawing ) { const shape = hoveredPlaceable.shape; if ( shape ) { elevation = shape.elevation; sortLayer = shape.sortLayer; sort = shape.sort; zIndex = shape.zIndex; } } else if ( hoveredPlaceable.document.schema.has("elevation") ) { elevation = hoveredPlaceable.document.elevation; } const threshold = {elevation, sortLayer, sort, zIndex, _lastSortedIndex: Infinity}; while ( this.#hoveredObjects.length && PrimaryCanvasGroup.#compareObjects(this.#hoveredObjects.at(-1), threshold) <= 0 ) { this.#hoveredObjects.pop(); } } } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ mapElevationToDepth(elevation) { const msg = "PrimaryCanvasGroup#mapElevationAlpha is deprecated. " + "Use canvas.masks.depth.mapElevation(elevation) instead."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return canvas.masks.depth.mapElevation(elevation); } /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ mapElevationAlpha(elevation) { const msg = "PrimaryCanvasGroup#mapElevationAlpha is deprecated. " + "Use canvas.masks.depth.mapElevation(elevation) instead."; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return canvas.masks.depth.mapElevation(elevation); } } /** * A container group which contains the environment canvas group and the interface canvas group. * * @category - Canvas */ class RenderedCanvasGroup extends CanvasGroupMixin(PIXI.Container) { /** @override */ static groupName = "rendered"; /** @override */ static tearDownChildren = false; } /** * @typedef {Map} VertexMap */ /** * @typedef {Set} EdgeSet */ /** * @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 { /** * 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 = []; /** * The squared maximum distance of a ray that is needed for this Scene. * @type {number} */ #rayDistance2; /* -------------------------------------------- */ /* Initialization */ /* -------------------------------------------- */ /** @inheritDoc */ initialize(origin, config) { super.initialize(origin, config); this.#rayDistance2 = Math.pow(canvas.dimensions.maxR, 2); } /* -------------------------------------------- */ /** @inheritDoc */ clone() { const poly = super.clone(); for ( const attr of ["vertices", "edges", "rays", "#rayDistance2"] ) { // Shallow clone only poly[attr] = this[attr]; } return poly; } /* -------------------------------------------- */ /* 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 */ /* -------------------------------------------- */ /** * 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. * @protected */ _identifyEdges() { const bounds = this.config.boundingBox = this._defineBoundingBox(); const edgeTypes = this._determineEdgeTypes(); for ( const edge of canvas.edges.values() ) { if ( this._testEdgeInclusion(edge, edgeTypes, bounds) ) { this.edges.add(edge.clone()); } } } /* -------------------------------------------- */ /** * Determine the edge types and their manner of inclusion for this polygon instance. * @returns {Record} * @protected */ _determineEdgeTypes() { const {type, useInnerBounds, includeDarkness} = this.config; const edgeTypes = {}; if ( type !== "universal" ) edgeTypes.wall = 1; if ( includeDarkness ) edgeTypes.darkness = 1; if ( useInnerBounds && canvas.scene.padding ) edgeTypes.innerBounds = 2; else edgeTypes.outerBounds = 2; return edgeTypes; } /* -------------------------------------------- */ /** * Test whether a wall should be included in the computed polygon for a given origin and type * @param {Edge} edge The Edge being considered * @param {Record} edgeTypes Which types of edges are being used? 0=no, 1=maybe, 2=always * @param {PIXI.Rectangle} bounds The overall bounding box * @returns {boolean} Should the edge be included? * @protected */ _testEdgeInclusion(edge, edgeTypes, bounds) { const { type, boundaryShapes, useThreshold, wallDirectionMode, externalRadius } = this.config; // Only include edges of the appropriate type const m = edgeTypes[edge.type]; if ( !m ) return false; if ( m === 2 ) return true; // Test for inclusion in the overall bounding box if ( !bounds.lineSegmentIntersects(edge.a, edge.b, { inside: true }) ) return false; // Specific boundary shapes may impose additional requirements for ( const shape of boundaryShapes ) { if ( shape._includeEdge && !shape._includeEdge(edge.a, edge.b) ) return false; } // Ignore edges which do not block this polygon type if ( edge[type] === CONST.WALL_SENSE_TYPES.NONE ) return false; // Ignore edges which are collinear with the origin const side = edge.orientPoint(this.origin); if ( !side ) return false; // Ignore one-directional walls which are facing away from the origin const wdm = PointSourcePolygon.WALL_DIRECTION_MODES; if ( edge.direction && (wallDirectionMode !== wdm.BOTH) ) { if ( (wallDirectionMode === wdm.NORMAL) === (side === edge.direction) ) return false; } // Ignore threshold walls which do not satisfy their required proximity if ( useThreshold ) return !edge.applyThreshold(type, this.origin, externalRadius); return true; } /* -------------------------------------------- */ /** * 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); } /* -------------------------------------------- */ /* Vertex Identification */ /* -------------------------------------------- */ /** * Consolidate all vertices from identified edges and register them as part of the vertex mapping. * @protected */ _identifyVertices() { const edgeMap = new Map(); for ( let edge of this.edges ) { edgeMap.set(edge.id, edge); // Create or reference vertex A const ak = foundry.canvas.edges.PolygonVertex.getKey(edge.a.x, edge.a.y); if ( this.vertices.has(ak) ) edge.vertexA = this.vertices.get(ak); else { edge.vertexA = new foundry.canvas.edges.PolygonVertex(edge.a.x, edge.a.y); this.vertices.set(ak, edge.vertexA); } // Create or reference vertex B const bk = foundry.canvas.edges.PolygonVertex.getKey(edge.b.x, edge.b.y); if ( this.vertices.has(bk) ) edge.vertexB = this.vertices.get(bk); else { edge.vertexB = new foundry.canvas.edges.PolygonVertex(edge.b.x, edge.b.y); this.vertices.set(bk, edge.vertexB); } // Learn edge orientation with respect to the origin and ensure B is clockwise of A const o = foundry.utils.orient2dFast(this.origin, edge.vertexA, edge.vertexB); if ( o > 0 ) Object.assign(edge, {vertexA: edge.vertexB, vertexB: edge.vertexA}); // Reverse vertices if ( o !== 0 ) { // Attach non-collinear edges edge.vertexA.attachEdge(edge, -1, this.config.type); edge.vertexB.attachEdge(edge, 1, this.config.type); } } // Add edge intersections this._identifyIntersections(edgeMap); } /* -------------------------------------------- */ /** * Add additional vertices for intersections between edges. * @param {Map} edgeMap * @protected */ _identifyIntersections(edgeMap) { const processed = new Set(); for ( let edge of this.edges ) { for ( const x of edge.intersections ) { // Is the intersected edge also included in the polygon? const other = edgeMap.get(x.edge.id); if ( !other || processed.has(other) ) continue; const i = x.intersection; // Register the intersection point as a vertex const vk = foundry.canvas.edges.PolygonVertex.getKey(Math.round(i.x), Math.round(i.y)); let v = this.vertices.get(vk); if ( !v ) { v = new foundry.canvas.edges.PolygonVertex(i.x, i.y); v._intersectionCoordinates = i; this.vertices.set(vk, 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. // It is important that we use the true intersection coordinates (i) for this orientation test. if ( !v.edges.has(edge) ) { const dir = foundry.utils.orient2dFast(this.origin, edge.vertexB, i) < 0 ? 1 // Edge is fully CCW of v : (foundry.utils.orient2dFast(this.origin, edge.vertexA, i) > 0 ? -1 : 0); // Edge is fully CW of v v.attachEdge(edge, dir, this.config.type); } if ( !v.edges.has(other) ) { const dir = foundry.utils.orient2dFast(this.origin, other.vertexB, i) < 0 ? 1 // Other is fully CCW of v : (foundry.utils.orient2dFast(this.origin, other.vertexA, i) > 0 ? -1 : 0); // Other is fully CW of v v.attachEdge(other, dir, this.config.type); } } processed.add(edge); } } /* -------------------------------------------- */ /* Radial Sweep */ /* -------------------------------------------- */ /** * Execute the sweep over wall vertices * @private */ _executeSweep() { // Initialize the set of active walls const 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 let i = 1; for ( const vertex of vertices ) { if ( vertex._visited ) continue; vertex._index = i++; this.#updateActiveEdges(vertex, activeEdges); // Include collinear vertices in this iteration of the sweep, treating their edges as active also const hasCollinear = vertex.collinearVertices.size > 0; if ( hasCollinear ) { this.#includeCollinearVertices(vertex, vertex.collinearVertices); for ( const cv of vertex.collinearVertices ) { cv._index = i++; this.#updateActiveEdges(cv, activeEdges); } } // 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 * Remove counter-clockwise edges which have now concluded. * Add clockwise edges which are ongoing or beginning. * @param {PolygonVertex} vertex The current vertex * @param {EdgeSet} activeEdges A set of currently active edges */ #updateActiveEdges(vertex, activeEdges) { for ( const ccw of vertex.ccwEdges ) { if ( !vertex.cwEdges.has(ccw) ) activeEdges.delete(ccw); } for ( const cw of vertex.cwEdges ) { if ( cw.vertexA._visited && cw.vertexB._visited ) continue; // Safeguard in case we have already visited the edge activeEdges.add(cw); } vertex._visited = true; // Record that we have already visited this vertex } /* -------------------------------------------- */ /** * 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.round(this.origin.x - this.#rayDistance2), y: this.origin.y}; const edges = new Set(); for ( let edge of this.edges ) { const x = foundry.utils.lineSegmentIntersects(this.origin, initial, edge.vertexA, edge.vertexB); 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.vertexA is ccw to edge.vertexB 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.vertexA, edge.vertexB, vertex) > 0 ) { if ( ( edge.isLimited(this.config.type) ) && !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 foundry.canvas.edges.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.#rayDistance2); ray.result = result; this.rays.push(ray); // For visualization and debugging // Create a sorted array of collisions containing the target vertex, other collinear vertices, and collision points const vertices = [result.target, ...result.target.collinearVertices]; const keys = new Set(); for ( const v of vertices ) { keys.add(v.key); v._d2 ??= Math.pow(v.x - origin.x, 2) + Math.pow(v.y - origin.y, 2); } this.#addInternalEdgeCollisions(vertices, keys, ray, activeEdges); vertices.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 vertices ) { 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 {PolygonVertex[]} vertices Active vertices * @param {Set} keys Active vertex keys * @param {PolygonRay} ray The candidate ray to test * @param {EdgeSet} activeEdges The set of edges to check for collisions against the ray */ #addInternalEdgeCollisions(vertices, keys, ray, activeEdges) { for ( const edge of activeEdges ) { if ( keys.has(edge.vertexA.key) || keys.has(edge.vertexB.key) ) continue; const x = foundry.utils.lineLineIntersection(ray.A, ray.B, edge.vertexA, edge.vertexB); if ( !x ) continue; const c = foundry.canvas.edges.PolygonVertex.fromPoint(x); c.attachEdge(edge, 0, this.config.type); c.isInternal = true; c._d2 = Math.pow(x.x - ray.A.x, 2) + Math.pow(x.y - ray.A.y, 2); vertices.push(c); } } /* -------------------------------------------- */ /* Collision Testing */ /* -------------------------------------------- */ /** @override */ _testCollision(ray, mode) { const {debug, type} = this.config; // 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.isLimited(type) || collisions.size) ) return true; let c = foundry.canvas.edges.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, 0, type); } if ( mode === "any" ) return false; // Sort collisions collisions = Array.from(collisions.values()).sort((a, b) => a._distance - b._distance); if ( collisions[0]?.isLimited ) collisions.shift(); // Visualize result if ( 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, [CONST.WALL_SENSE_TYPES.PROXIMITY]: 0xFFFFBB, [CONST.WALL_SENSE_TYPES.DISTANCE]: 0xFFFFBB }; // 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 ) { const c = limitColors[edge[this.config.type]]; dg.lineStyle(4, c).moveTo(edge.a.x, edge.a.y).lineTo(edge.b.x, edge.b.y); } // Draw vertices for ( let vertex of this.vertices.values() ) { const r = vertex.restriction; if ( r ) dg.lineStyle(1, 0x000000).beginFill(limitColors[r]).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(); } } } return dg; } /* -------------------------------------------- */ /** * 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, [CONST.WALL_SENSE_TYPES.PROXIMITY]: 0xFFFFBB, [CONST.WALL_SENSE_TYPES.DISTANCE]: 0xFFFFBB }; // Draw edges for ( let edge of this.edges.values() ) { const c = limitColors[edge[this.config.type]]; dg.lineStyle(4, c).moveTo(edge.a.x, edge.b.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 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 angle: new fields.BooleanField({initial: true}), // If this DM is constrained by the vision angle 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; static { /** * The type of the detection mode. * @enum {number} */ Object.defineProperty(this, "DETECTION_TYPES", {value: Object.freeze({ 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} */ Object.defineProperty(this, "BASIC_MODE_ID", {value: "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; const isSight = this.type === DetectionMode.DETECTION_TYPES.SIGHT; // Sight-based detection fails when blinded if ( isSight && src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) ) return false; // Detection fails if burrowing unless walls are ignored if ( this.walls && src.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; if ( target instanceof Token ) { const tgt = target.document; // Sight-based detection cannot see invisible tokens if ( isSight && tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE) ) return false; // Burrowing tokens cannot be detected unless walls are ignored if ( this.walls && tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; } return true; } /* -------------------------------------------- */ /** * 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._testRange(visionSource, mode, target, test) ) return false; return this._testLOS(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 this._testAngle(visionSource, mode, target, test); const type = visionSource.constructor.sourceType; const isSight = type === "sight"; if ( isSight && visionSource.blinded.darkness ) return false; if ( !this.angle && (visionSource.data.angle < 360) ) { // Constrained by walls but not by vision angle return !CONFIG.Canvas.polygonBackends[type].testCollision( { x: visionSource.x, y: visionSource.y }, test.point, { type, mode: "any", source: visionSource, useThreshold: true, includeDarkness: isSight } ); } // Constrained by walls and vision angle 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; } /* -------------------------------------------- */ /** * Test whether the target is within the vision angle. * @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 point within the vision angle? * @protected */ _testAngle(visionSource, mode, target, test) { if ( !this.angle ) return true; const { angle, rotation, externalRadius } = visionSource.data; if ( angle >= 360 ) return true; const point = test.point; const dx = point.x - visionSource.x; const dy = point.y - visionSource.y; if ( (dx * dx) + (dy * dy) <= (externalRadius * externalRadius) ) return true; const aMin = rotation + 90 - (angle / 2); const a = Math.toDegrees(Math.atan2(dy, dx)); return (((a - aMin) % 360) + 360) % 360 <= angle; } /* -------------------------------------------- */ /** * 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 === null ) return true; 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); } } /* -------------------------------------------- */ /** * This detection mode tests whether the target is visible due to being illuminated by a light source. * By default tokens have light perception with an infinite range if light perception isn't explicitely * configured. */ class DetectionModeLightPerception extends DetectionMode { /** @override */ _canDetect(visionSource, target) { // Cannot see while blinded or burrowing const src = visionSource.object.document; if ( src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) || src.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; // Cannot see invisible or burrowing creatures if ( target instanceof Token ) { const tgt = target.document; if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE) || tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; } return true; } /* -------------------------------------------- */ /** @inheritDoc */ _testPoint(visionSource, mode, target, test) { if ( !super._testPoint(visionSource, mode, target, test) ) return false; return canvas.effects.testInsideLight(test.point, test.elevation); } } /* -------------------------------------------- */ /** * A special detection mode which models a form of darkvision (night vision). * This mode is the default case which is tested first when evaluating visibility of objects. */ class DetectionModeBasicSight extends DetectionMode { /** @override */ _canDetect(visionSource, target) { // Cannot see while blinded or burrowing const src = visionSource.object.document; if ( src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) || src.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; // Cannot see invisible or burrowing creatures if ( target instanceof Token ) { const tgt = target.document; if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE) || tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; } return true; } } /* -------------------------------------------- */ /** * 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) { if ( !(target instanceof Token) ) return false; const tgt = target.document; // Only invisible tokens can be detected if ( !tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE) ) return false; const src = visionSource.object.document; const isSight = this.type === DetectionMode.DETECTION_TYPES.SIGHT; // Sight-based detection fails when blinded if ( isSight && src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) ) return false; // Detection fails when the source or target token is burrowing unless walls are ignored if ( this.walls ) { if ( src.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; } return true; } } /* -------------------------------------------- */ /** * 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) { if ( !(target instanceof Token) ) return false; const tgt = target.document; // Flying and hovering tokens cannot be detected if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.FLY) ) return false; if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.HOVER) ) return false; return true; } } /* -------------------------------------------- */ /** * 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) { const src = visionSource.object.document; const isSight = this.type === DetectionMode.DETECTION_TYPES.SIGHT; // Sight-based detection fails when blinded if ( isSight && src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) ) return false; // Detection fails when the source or target token is burrowing unless walls are ignored if ( !this.walls ) return true; if ( src.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; if ( target instanceof Token ) { const tgt = target.document; if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false; } return true; } } /** * 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} * @internal */ _updated = false; /** * Texture extractor * @type {TextureExtractor} */ get extractor() { return this.#extractor; } #extractor; /** * The fog refresh count. * If > to the refresh threshold, the fog texture is saved to database. It is then reinitialized to 0. * @type {number} */ #refreshCount = 0; /** * Matrix used for fog rendering transformation. * @type {PIXI.Matrix} */ #renderTransform = new PIXI.Matrix(); /** * Define the number of fog refresh needed before the fog texture is extracted and pushed to the server. * @type {number} */ static COMMIT_THRESHOLD = 70; /** * A debounced function to save fog of war exploration once a continuous stream of updates has concluded. * @type {Function} */ #debouncedSave; /** * Handling of the concurrency for fog loading, saving and reset. * @type {Semaphore} */ #queue = new foundry.utils.Semaphore(); /* -------------------------------------------- */ /* Fog Manager Properties */ /* -------------------------------------------- */ /** * The exploration SpriteMesh which holds the fog exploration texture. * @type {SpriteMesh} */ get sprite() { return this.#explorationSprite || (this.#explorationSprite = this._createExplorationObject()); } #explorationSprite; /* -------------------------------------------- */ /** * The configured options used for the saved fog-of-war texture. * @type {FogTextureConfiguration} */ get textureConfiguration() { return canvas.visibility.textureConfiguration; } /* -------------------------------------------- */ /** * 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.fog.exploration; } /* -------------------------------------------- */ /* Fog of War Management */ /* -------------------------------------------- */ /** * Create the exploration display object with or without a provided texture. * @param {PIXI.Texture|PIXI.RenderTexture} [tex] Optional exploration texture. * @returns {DisplayObject} * @internal */ _createExplorationObject(tex) { return new SpriteMesh(tex ?? Canvas.getRenderTexture({ clearColor: [0, 0, 0, 1], textureConfiguration: this.textureConfiguration }), FogSamplerShader); } /* -------------------------------------------- */ /** * Initialize fog of war - resetting it when switching scenes or re-drawing the canvas * @returns {Promise} */ async initialize() { this.#initialized = false; // Create a TextureExtractor instance if ( this.#extractor === undefined ) { try { this.#extractor = new TextureExtractor(canvas.app.renderer, { callerName: "FogExtractor", controlHash: true, format: PIXI.FORMATS.RED }); } catch(e) { this.#extractor = null; console.error(e); } } this.#extractor?.reset(); // Bind a debounced save handler this.#debouncedSave = foundry.utils.debounce(this.save.bind(this), 2000); // Load the initial fog texture 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 try { await this.save(); } catch(e) { ui.notifications.error("Failed to save fog exploration"); console.error(e); } // 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. * Once the number of refresh is > to the commit threshold, save the fog texture to the database. */ commit() { const vision = canvas.visibility.vision; if ( !vision?.children.length || !this.fogExploration || !this.tokenVision ) return; if ( !this.#explorationSprite?.texture.valid ) return; // Get a staging texture or clear and render into the sprite if its texture is a RT // and render the entire fog container to it const dims = canvas.dimensions; const isRenderTex = this.#explorationSprite.texture instanceof PIXI.RenderTexture; const tex = isRenderTex ? this.#explorationSprite.texture : Canvas.getRenderTexture({ clearColor: [0, 0, 0, 1], textureConfiguration: this.textureConfiguration }); this.#renderTransform.tx = -dims.sceneX; this.#renderTransform.ty = -dims.sceneY; // Render the currently revealed vision (preview excluded) to the texture vision.containmentFilter.enabled = canvas.visibility.needsContainment; vision.light.preview.visible = false; vision.light.mask.preview.visible = false; vision.sight.preview.visible = false; canvas.app.renderer.render(isRenderTex ? vision : this.#explorationSprite, { renderTexture: tex, clear: false, transform: this.#renderTransform }); vision.light.preview.visible = true; vision.light.mask.preview.visible = true; vision.sight.preview.visible = true; vision.containmentFilter.enabled = false; if ( !isRenderTex ) this.#explorationSprite.texture.destroy(true); this.#explorationSprite.texture = tex; this._updated = true; if ( !this.exploration ) { const fogExplorationCls = getDocumentClass("FogExploration"); this.exploration = new fogExplorationCls(); } // Schedule saving the texture to the database if ( this.#refreshCount > FogManager.COMMIT_THRESHOLD ) { this.#debouncedSave(); this.#refreshCount = 0; } else this.#refreshCount++; } /* -------------------------------------------- */ /** * Load existing fog of war data from local storage and populate the initial exploration sprite * @returns {Promise<(PIXI.Texture|void)>} */ async load() { return await this.#queue.add(this.#load.bind(this)); } /* -------------------------------------------- */ /** * 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.manager ) console.debug("FogManager | Loading saved FogExploration for Scene."); this.#deactivate(); // 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 = /** @type {typeof FogExploration} */ getDocumentClass("FogExploration"); this.exploration = await fogExplorationCls.load(); // Extract and assign the fog data image const assign = (tex, resolve) => { if ( this.#explorationSprite?.texture === tex ) return resolve(tex); this.#explorationSprite?.destroy(true); this.#explorationSprite = this._createExplorationObject(tex); canvas.visibility.resetExploration(); canvas.perception.initialize(); resolve(tex); }; // Initialize the exploration sprite if no exploration data exists if ( !this.exploration ) { return await new Promise(resolve => { assign(Canvas.getRenderTexture({ clearColor: [0, 0, 0, 1], textureConfiguration: this.textureConfiguration }), resolve); }); } // Otherwise load the texture from the exploration data return await new Promise(resolve => { let tex = this.exploration.getTexture(); if ( tex === null ) assign(Canvas.getRenderTexture({ clearColor: [0, 0, 0, 1], textureConfiguration: this.textureConfiguration }), 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.manager ) console.debug("FogManager | Resetting fog of war exploration for Scene."); game.socket.emit("resetFog", canvas.scene.id); } /* -------------------------------------------- */ /** * Request a fog of war save operation. * Note: if a save operation is pending, we're waiting for its conclusion. */ async save() { return await this.#queue.add(this.#save.bind(this)); } /* -------------------------------------------- */ /** * Request a fog of war save operation. * Note: if a save operation is pending, we're waiting for its conclusion. */ async #save() { if ( !this._updated ) return; this._updated = false; const exploration = this.exploration; if ( CONFIG.debug.fog.manager ) { console.debug("FogManager | Initiate non-blocking extraction of the fog of war progress."); } if ( !this.#extractor ) { console.error("FogManager | Browser does not support texture extraction."); return; } // Get compressed base64 image from the fog texture const base64Image = await this._extractBase64(); // If the exploration changed, the fog was reloaded while the pixels were extracted if ( this.exploration !== exploration ) return; // Need to skip? if ( !base64Image ) { if ( CONFIG.debug.fog.manager ) console.debug("FogManager | Fog of war has not changed. Skipping db operation."); return; } // Update the fog exploration document const updateData = this._prepareFogUpdateData(base64Image); await this.#updateFogExploration(updateData); } /* -------------------------------------------- */ /** * Extract fog data as a base64 string * @returns {Promise} * @protected */ async _extractBase64() { try { return this.#extractor.extract({ texture: this.#explorationSprite.texture, compression: TextureExtractor.COMPRESSION_MODES.BASE64, type: "image/webp", quality: 0.8, debug: CONFIG.debug.fog.extractor }); } catch(err) { // FIXME this is needed because for some reason .extract() may throw a boolean false instead of an Error throw new Error("Fog of War base64 extraction failed"); } } /* -------------------------------------------- */ /** * Prepare the data that will be used to update the FogExploration document. * @param {string} base64Image The extracted base64 image data * @returns {Partial} Exploration data to update * @protected */ _prepareFogUpdateData(base64Image) { return {explored: base64Image, timestamp: Date.now()}; } /* -------------------------------------------- */ /** * Update the fog exploration document with provided data. * @param {object} updateData * @returns {Promise} */ async #updateFogExploration(updateData) { if ( !game.scenes.has(canvas.scene?.id) ) return; if ( !this.exploration ) return; if ( CONFIG.debug.fog.manager ) console.debug("FogManager | Saving fog of war progress into exploration document."); if ( !this.exploration.id ) { this.exploration.updateSource(updateData); this.exploration = await this.exploration.constructor.create(this.exploration.toJSON(), {loadFog: false}); } else await this.exploration.update(updateData, {loadFog: false}); } /* -------------------------------------------- */ /** * 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; this.#extractor?.reset(); // Destroy current exploration texture and provide a new one with transparency if ( this.#explorationSprite && !this.#explorationSprite.destroyed ) this.#explorationSprite.destroy(true); this.#explorationSprite = undefined; this._updated = false; this.#refreshCount = 0; } /* -------------------------------------------- */ /** * If fog of war data is reset from the server, deactivate the current fog and initialize the exploration. * @returns {Promise} * @internal */ async _handleReset() { return await this.#queue.add(this.#handleReset.bind(this)); } /* -------------------------------------------- */ /** * If fog of war data is reset from the server, deactivate the current fog and initialize the exploration. * @returns {Promise} */ async #handleReset() { ui.notifications.info("Fog of War exploration progress was reset for this Scene"); // Remove the current exploration document this.#deactivate(); // Reset exploration in the visibility layer canvas.visibility.resetExploration(); // Refresh perception canvas.perception.initialize(); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ get pending() { const msg = "pending is deprecated and redirected to the exploration container"; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return canvas.visibility.explored; } /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ get revealed() { const msg = "revealed is deprecated and redirected to the exploration container"; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return canvas.visibility.explored; } /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ update(source, force=false) { const msg = "update is obsolete and always returns true. The fog exploration does not record position anymore."; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return true; } /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ get resolution() { const msg = "resolution is deprecated and redirected to CanvasVisibility#textureConfiguration"; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return canvas.visibility.textureConfiguration; } } /** * 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 {@link Canvas#perception}. */ class PerceptionManager extends RenderFlagsMixin(Object) { /** * @typedef {RenderFlags} PerceptionManagerFlags * @property {boolean} initializeLighting Re-initialize the entire lighting configuration. An aggregate behavior * which does no work directly but propagates to set several other flags. * @property {boolean} initializeVision Re-initialize the entire vision configuration. * See {@link CanvasVisibility#initializeSources}. * @property {boolean} initializeVisionModes Initialize the active vision modes. * See {@link CanvasVisibility#initializeVisionMode}. * @property {boolean} initializeSounds Re-initialize the entire ambient sound configuration. * See {@link SoundsLayer#initializeSources}. * @property {boolean} refreshEdges Recompute intersections between all registered edges. * See {@link CanvasEdges#refresh}. * @property {boolean} refreshLighting Refresh the rendered appearance of lighting * @property {boolean} refreshLightSources Update the configuration of light sources * @property {boolean} refreshOcclusion Refresh occlusion * @property {boolean} refreshPrimary Refresh the contents of the PrimaryCanvasGroup mesh * @property {boolean} refreshSounds Refresh the audio state of ambient sounds * @property {boolean} refreshVision Refresh the rendered appearance of vision * @property {boolean} refreshVisionSources Update the configuration of vision sources * @property {boolean} soundFadeDuration Apply a fade duration to sound refresh workflow */ /** @override */ static RENDER_FLAGS = { // Edges refreshEdges: {}, // Light and Darkness Sources initializeLighting: {propagate: ["initializeDarknessSources", "initializeLightSources"]}, initializeDarknessSources: {propagate: ["refreshLighting", "refreshVision", "refreshEdges"]}, initializeLightSources: {propagate: ["refreshLighting", "refreshVision"]}, refreshLighting: {propagate: ["refreshLightSources"]}, refreshLightSources: {}, // Vision initializeVisionModes: {propagate: ["refreshVisionSources", "refreshLighting", "refreshPrimary"]}, initializeVision: {propagate: ["initializeVisionModes", "refreshVision"]}, refreshVision: {propagate: ["refreshVisionSources", "refreshOcclusionMask"]}, refreshVisionSources: {}, // Primary Canvas Group refreshPrimary: {}, refreshOcclusion: {propagate: ["refreshOcclusionStates", "refreshOcclusionMask"]}, refreshOcclusionStates: {}, refreshOcclusionMask: {}, // Sound initializeSounds: {propagate: ["refreshSounds"]}, refreshSounds: {}, soundFadeDuration: {}, /** @deprecated since v12 */ refreshTiles: { propagate: ["refreshOcclusion"], deprecated: {message: "The refreshTiles flag is deprecated in favor of refreshOcclusion", since: 12, until: 14, alias: true} }, /** @deprecated since v12 */ identifyInteriorWalls: { propagate: ["initializeLighting", "initializeVision"], deprecated: { message: "The identifyInteriorWalls is now obsolete and has no replacement.", since: 12, until: 14, alias: true } }, /** @deprecated since v11 */ forceUpdateFog: { propagate: ["refreshVision"], deprecated: { message: "The forceUpdateFog flag is now obsolete and has no replacement. " + "The fog is now always updated when the visibility is refreshed", since: 11, until: 13, alias: true } } }; static #deprecatedFlags = ["refreshTiles", "identifyInteriorWalls", "forceUpdateFog"]; /** @override */ static RENDER_FLAG_PRIORITY = "PERCEPTION"; /* -------------------------------------------- */ /** @override */ applyRenderFlags() { if ( !this.renderFlags.size ) return; const flags = this.renderFlags.clear(); // Initialize darkness sources if ( flags.initializeDarknessSources ) canvas.effects.initializeDarknessSources(); // Recompute edge intersections if ( flags.refreshEdges ) canvas.edges.refresh(); // Initialize positive light sources if ( flags.initializeLightSources ) canvas.effects.initializeLightSources(); // Initialize active vision sources if ( flags.initializeVision ) canvas.visibility.initializeSources(); // Initialize the active vision mode if ( flags.initializeVisionModes ) canvas.visibility.initializeVisionMode(); // Initialize active sound sources if ( flags.initializeSounds ) canvas.sounds.initializeSources(); // Refresh light, vision, and sound sources if ( flags.refreshLightSources ) canvas.effects.refreshLightSources(); if ( flags.refreshVisionSources ) canvas.effects.refreshVisionSources(); if ( flags.refreshSounds ) canvas.sounds.refresh({fade: flags.soundFadeDuration ? 250 : 0}); // Refresh the appearance of the Primary Canvas Group environment if ( flags.refreshPrimary ) canvas.primary.refreshPrimarySpriteMesh(); if ( flags.refreshLighting ) canvas.effects.refreshLighting(); if ( flags.refreshVision ) canvas.visibility.refresh(); // Update roof occlusion states based on token positions and vision // TODO: separate occlusion state testing from CanvasOcclusionMask if ( flags.refreshOcclusion ) canvas.masks.occlusion.updateOcclusion(); else { if ( flags.refreshOcclusionMask ) canvas.masks.occlusion._updateOcclusionMask(); if ( flags.refreshOcclusionStates ) canvas.masks.occlusion._updateOcclusionStates(); } // Deprecated flags for ( const f of PerceptionManager.#deprecatedFlags ) { if ( flags[f] ) { const {message, since, until} = PerceptionManager.RENDER_FLAGS[f].deprecated; foundry.utils.logCompatibilityWarning(message, {since, until}); } } } /* -------------------------------------------- */ /** * 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 */ update(flags) { if ( !canvas.ready ) return; this.renderFlags.set(flags); } /* -------------------------------------------- */ /** * A helper function to perform an immediate initialization plus incremental refresh. */ initialize() { return this.update({ refreshEdges: true, initializeLighting: true, initializeVision: true, initializeSounds: true, refreshOcclusion: true }); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ refresh() { foundry.utils.logCompatibilityWarning("PerceptionManager#refresh is deprecated in favor of assigning granular " + "refresh flags", {since: 12, until: 14}); return this.update({ refreshLighting: true, refreshVision: true, refreshSounds: true, refreshOcclusion: true }); } } /** * 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(), darkness: lightingSchema(), levels: new fields.ObjectField({ validate: o => { const values = Object.values(CONST.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(CONST.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.SchemaField({ color: new fields.ColorField({required: false, initial: undefined}), attenuation: new fields.AlphaField({required: false, initial: undefined}), brightness: new fields.NumberField({required: false, initial: undefined, nullable: false, min: -1, max: 1}), saturation: new fields.NumberField({required: false, initial: undefined, nullable: false, min: -1, max: 1}), contrast: new fields.NumberField({required: false, initial: undefined, nullable: false, min: -1, max: 1}) }), preferred: new fields.BooleanField({initial: false}) }) }; } /** * The lighting illumination levels which are supported. * @enum {number} */ static LIGHTING_LEVELS = CONST.LIGHTING_LEVELS; /** * 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; /** * Does this vision mode enable light sources? * True unless it disables lighting entirely. * @type {boolean} */ get perceivesLight() { const {background, illumination, coloration} = this.lighting; return !!(background.visibility || illumination.visibility || coloration.visibility); } /** * Special activation handling that could be implemented by VisionMode subclasses * @param {VisionSource} source Activate this VisionMode for a specific source * @abstract */ _activate(source) {} /** * Special deactivation handling that could be implemented by VisionMode subclasses * @param {VisionSource} source Deactivate this VisionMode for a specific source * @abstract */ _deactivate(source) {} /** * 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) { if ( source._visionModeActivated ) return; source._visionModeActivated = true; this._activate(source); } /** * 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) { if ( !source._visionModeActivated ) return; source._visionModeActivated = false; this._deactivate(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 foundry.canvas.sources.PointVisionSource.prototype.animateTime.call(this, dt); } } /** * An implementation of the Weiler Atherton algorithm for clipping polygons. * This currently only handles combinations that will not result in any holes. * Support may be added for holes in the future. * * This algorithm is faster than the Clipper library for this task because it relies on the unique properties of the * circle, ellipse, or convex simple clip object. * It is also more precise in that it uses the actual intersection points between the circle/ellipse and polygon, * instead of relying on the polygon approximation of the circle/ellipse to find the intersection points. * * For more explanation of the underlying algorithm, see: * https://en.wikipedia.org/wiki/Weiler%E2%80%93Atherton_clipping_algorithm * https://www.geeksforgeeks.org/weiler-atherton-polygon-clipping-algorithm * https://h-educate.in/weiler-atherton-polygon-clipping-algorithm/ */ class WeilerAthertonClipper { /** * Construct a WeilerAthertonClipper instance used to perform the calculation. * @param {PIXI.Polygon} polygon Polygon to clip * @param {PIXI.Rectangle|PIXI.Circle} clipObject Object used to clip the polygon * @param {number} clipType Type of clip to use * @param {object} clipOpts Object passed to the clippingObject methods toPolygon and pointsBetween */ constructor(polygon, clipObject, clipType, clipOpts) { if ( !polygon.isPositive ) { const msg = "WeilerAthertonClipper#constructor needs a subject polygon with a positive signed area."; throw new Error(msg); } clipType ??= this.constructor.CLIP_TYPES.INTERSECT; clipOpts ??= {}; this.polygon = polygon; this.clipObject = clipObject; this.config = { clipType, clipOpts }; } /** * The supported clip types. * Values are equivalent to those in ClipperLib.ClipType. * @enum {number} */ static CLIP_TYPES = Object.freeze({ INTERSECT: 0, UNION: 1 }); /** * The supported intersection types. * @enum {number} */ static INTERSECTION_TYPES = Object.freeze({ OUT_IN: -1, IN_OUT: 1, TANGENT: 0 }); /** @type {PIXI.Polygon} */ polygon; /** @type {PIXI.Rectangle|PIXI.Circle} */ clipObject; /** * Configuration settings * @type {object} [config] * @param {WeilerAthertonClipper.CLIP_TYPES} [config.clipType] One of CLIP_TYPES * @param {object} [config.clipOpts] Object passed to the clippingObject methods * toPolygon and pointsBetween */ config = {}; /* -------------------------------------------- */ /** * Union a polygon and clipObject using the Weiler Atherton algorithm. * @param {PIXI.Polygon} polygon Polygon to clip * @param {PIXI.Rectangle|PIXI.Circle} clipObject Object to clip against the polygon * @param {object} clipOpts Options passed to the clipping object * methods toPolygon and pointsBetween * @returns {PIXI.Polygon[]} */ static union(polygon, clipObject, clipOpts = {}) { return this.combine(polygon, clipObject, {clipType: this.CLIP_TYPES.UNION, ...clipOpts}); } /* -------------------------------------------- */ /** * Intersect a polygon and clipObject using the Weiler Atherton algorithm. * @param {PIXI.Polygon} polygon Polygon to clip * @param {PIXI.Rectangle|PIXI.Circle} clipObject Object to clip against the polygon * @param {object} clipOpts Options passed to the clipping object * methods toPolygon and pointsBetween * @returns {PIXI.Polygon[]} */ static intersect(polygon, clipObject, clipOpts = {}) { return this.combine(polygon, clipObject, {clipType: this.CLIP_TYPES.INTERSECT, ...clipOpts}); } /* -------------------------------------------- */ /** * Clip a given clipObject using the Weiler-Atherton algorithm. * * At the moment, this will return a single PIXI.Polygon in the array unless clipType is a union and the polygon * and clipObject do not overlap, in which case the [polygon, clipObject.toPolygon()] array will be returned. * If this algorithm is expanded in the future to handle holes, an array of polygons may be returned. * * @param {PIXI.Polygon} polygon Polygon to clip * @param {PIXI.Rectangle|PIXI.Circle} clipObject Object to clip against the polygon * @param {object} [options] Options which configure how the union or intersection is computed * @param {WeilerAthertonClipper.CLIP_TYPES} [options.clipType] One of CLIP_TYPES * @param {boolean} [options.canMutate] If the WeilerAtherton constructor could mutate or not * the subject polygon points * @param {object} [options.clipOpts] Options passed to the WeilerAthertonClipper constructor * @returns {PIXI.Polygon[]} Array of polygons and clipObjects */ static combine(polygon, clipObject, {clipType, canMutate, ...clipOpts}={}) { if ( (clipType !== this.CLIP_TYPES.INTERSECT) && (clipType !== this.CLIP_TYPES.UNION) ) { throw new Error("The Weiler-Atherton clipping algorithm only supports INTERSECT or UNION clip types."); } if ( canMutate && !polygon.isPositive ) polygon.reverseOrientation(); const wa = new this(polygon, clipObject, clipType, clipOpts); const trackingArray = wa.#buildPointTrackingArray(); if ( !trackingArray.length ) return this.testForEnvelopment(polygon, clipObject, clipType, clipOpts); return wa.#combineNoHoles(trackingArray); } /* -------------------------------------------- */ /** * Clip the polygon with the clipObject, assuming no holes will be created. * For a union or intersect with no holes, a single pass through the intersections will * build the resulting union shape. * @param {PolygonVertex[]} trackingArray Array of linked points and intersections * @returns {[PIXI.Polygon]} */ #combineNoHoles(trackingArray) { const clipType = this.config.clipType; const ln = trackingArray.length; let prevIx = trackingArray[ln - 1]; let wasTracingPolygon = (prevIx.type === this.constructor.INTERSECTION_TYPES.OUT_IN) ^ clipType; const newPoly = new PIXI.Polygon(); for ( let i = 0; i < ln; i += 1 ) { const ix = trackingArray[i]; this.#processIntersection(ix, prevIx, wasTracingPolygon, newPoly); wasTracingPolygon = !wasTracingPolygon; prevIx = ix; } return [newPoly]; } /* -------------------------------------------- */ /** * Given an intersection and the previous intersection, fill the points * between the two intersections, in clockwise order. * @param {PolygonVertex} ix Intersection to process * @param {PolygonVertex} prevIx Previous intersection to process * @param {boolean} wasTracingPolygon Whether we were tracing the polygon (true) or the clipObject (false). * @param {PIXI.Polygon} newPoly The new polygon that results from this clipping operation */ #processIntersection(ix, prevIx, wasTracingPolygon, newPoly) { const clipOpts = this.config.clipOpts; const pts = wasTracingPolygon ? ix.leadingPoints : this.clipObject.pointsBetween(prevIx, ix, clipOpts); for ( const pt of pts ) newPoly.addPoint(pt); newPoly.addPoint(ix); } /* -------------------------------------------- */ /** * Test if one shape envelops the other. Assumes the shapes do not intersect. * 1. Polygon is contained within the clip object. Union: clip object; Intersect: polygon * 2. Clip object is contained with polygon. Union: polygon; Intersect: clip object * 3. Polygon and clip object are outside one another. Union: both; Intersect: null * @param {PIXI.Polygon} polygon Polygon to clip * @param {PIXI.Rectangle|PIXI.Circle} clipObject Object to clip against the polygon * @param {WeilerAthertonClipper.CLIP_TYPES} clipType One of CLIP_TYPES * @param {object} clipOpts Clip options which are forwarded to toPolygon methods * @returns {PIXI.Polygon[]} Returns the polygon, the clipObject.toPolygon(), both, or neither. */ static testForEnvelopment(polygon, clipObject, clipType, clipOpts) { const points = polygon.points; if ( points.length < 6 ) return []; const union = clipType === this.CLIP_TYPES.UNION; // Option 1: Polygon contained within clipObject // We search for the first point of the polygon that is not on the boundary of the clip object. // One of these points can be used to determine whether the polygon is contained in the clip object. // If all points of the polygon are on the boundary of the clip object, which is either a circle // or a rectangle, then the polygon is contained within the clip object. let polygonInClipObject = true; for ( let i = 0; i < points.length; i += 2 ) { const point = { x: points[i], y: points[i + 1] }; if ( !clipObject.pointIsOn(point) ) { polygonInClipObject = clipObject.contains(point.x, point.y); break; } } if ( polygonInClipObject ) return union ? [clipObject.toPolygon(clipOpts)] : [polygon]; // Option 2: ClipObject contained within polygon const center = clipObject.center; // PointSourcePolygons need to have a bounds defined in order for polygon.contains to work. if ( polygon instanceof PointSourcePolygon ) polygon.bounds ??= polygon.getBounds(); const clipObjectInPolygon = polygon.contains(center.x, center.y); if ( clipObjectInPolygon ) return union ? [polygon] : [clipObject.toPolygon(clipOpts)]; // Option 3: Neither contains the other return union ? [polygon, clipObject.toPolygon(clipOpts)] : []; } /* -------------------------------------------- */ /** * Construct an array of intersections between the polygon and the clipping object. * The intersections follow clockwise around the polygon. * Round all intersections and polygon vertices to the nearest pixel (integer). * @returns {Point[]} */ #buildPointTrackingArray() { const labeledPoints = this.#buildIntersectionArray(); if ( !labeledPoints.length ) return []; return WeilerAthertonClipper.#consolidatePoints(labeledPoints); } /* -------------------------------------------- */ /** * Construct an array that holds all the points of the polygon with all the intersections with the clipObject * inserted, in correct position moving clockwise. * If an intersection and endpoint are nearly the same, prefer the intersection. * Intersections are labeled with isIntersection and type = out/in or in/out. Tangents are removed. * @returns {Point[]} Labeled array of points */ #buildIntersectionArray() { const { polygon, clipObject } = this; const points = polygon.points; const ln = points.length; if ( ln < 6 ) return []; // Minimum 3 Points required // Need to start with a non-intersecting point on the polygon. let startIdx = -1; let a; for ( let i = 0; i < ln; i += 2 ) { a = { x: points[i], y: points[i + 1] }; if ( !clipObject.pointIsOn(a) ) { startIdx = i; break; } } if ( !~startIdx ) return []; // All intersections, so all tangent // For each edge a|b, find the intersection point(s) with the clipObject. // Add intersections and endpoints to the pointsIxs array, taking care to avoid duplicating // points. For example, if the intersection equals a, add only the intersection, not both. let previousInside = clipObject.contains(a.x, a.y); let numPrevIx = 0; let lastIx = undefined; let secondLastIx = undefined; const pointsIxs = [a]; const types = this.constructor.INTERSECTION_TYPES; const nIter = startIdx + ln + 2; // Add +2 to close the polygon. for ( let i = startIdx + 2; i < nIter; i += 2 ) { const j = i >= ln ? i % ln : i; // Circle back around the points as necessary. const b = { x: points[j], y: points[j + 1] }; const ixs = clipObject.segmentIntersections(a, b); const ixsLn = ixs.length; let bIsIx = false; if ( ixsLn ) { bIsIx = b.x.almostEqual(ixs[ixsLn - 1].x) && b.y.almostEqual(ixs[ixsLn - 1].y); // If the intersection equals the current b, get that intersection next iteration. if ( bIsIx ) ixs.pop(); // Determine whether the intersection is out-->in or in-->out numPrevIx += ixs.length; for ( const ix of ixs ) { ix.isIntersection = true; ix.type = lastIx ? -lastIx.type : previousInside ? types.IN_OUT : types.OUT_IN; secondLastIx = lastIx; lastIx = ix; } pointsIxs.push(...ixs); } // If b is an intersection, we will return to it next iteration. if ( bIsIx ) { a = b; continue; } // Each intersection represents a move across the clipObject border. // Count them and determine if we are now inside or outside the clipObject. if ( numPrevIx ) { const isInside = clipObject.contains(b.x, b.y); const changedSide = isInside ^ previousInside; const isOdd = numPrevIx & 1; // If odd number of intersections, should switch. e.g., outside --> ix --> inside // If even number of intersections, should stay same. e.g., outside --> ix --> ix --> outside. if ( isOdd ^ changedSide ) { if ( numPrevIx === 1 ) lastIx.isIntersection = false; else { secondLastIx.isIntersection = false; lastIx.type = secondLastIx.type; } } previousInside = isInside; numPrevIx = 0; secondLastIx = undefined; lastIx = undefined; } pointsIxs.push(b); a = b; } return pointsIxs; } /* -------------------------------------------- */ /** * Given an array of labeled points, consolidate into a tracking array of intersections, * where each intersection contains its array of leadingPoints. * @param {Point[]} labeledPoints Array of points, from _buildLabeledIntersectionsArray * @returns {Point[]} Array of intersections */ static #consolidatePoints(labeledPoints) { // Locate the first intersection const startIxIdx = labeledPoints.findIndex(pt => pt.isIntersection); if ( !~startIxIdx ) return []; // No intersections, so no tracking array const labeledLn = labeledPoints.length; let leadingPoints = []; const trackingArray = []; // Closed polygon, so use the last point to circle back for ( let i = 0; i < labeledLn; i += 1 ) { const j = (i + startIxIdx) % labeledLn; const pt = labeledPoints[j]; if ( pt.isIntersection ) { pt.leadingPoints = leadingPoints; leadingPoints = []; trackingArray.push(pt); } else leadingPoints.push(pt); } // Add leading points to first intersection trackingArray[0].leadingPoints = leadingPoints; return trackingArray; } } /** * The Drawing object is an implementation of the PlaceableObject container. * Each Drawing is a placeable object in the DrawingsLayer. * * @category - Canvas * @property {DrawingsLayer} layer Each Drawing object belongs to the DrawingsLayer * @property {DrawingDocument} document Each Drawing object provides an interface for a DrawingDocument */ class Drawing extends PlaceableObject { /** * The texture that is used to fill this Drawing, if any. * @type {PIXI.Texture} */ texture; /** * 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 in the interface or a PrimaryGraphics in the Primary Group. * @type {PrimaryGraphics|PIXI.Graphics} */ shape; /** * An internal timestamp for the previous freehand draw time, to limit sampling. * @type {number} */ #drawTime = 0; /** * An internal flag for the permanent points of the polygon. * @type {number[]} */ #fixedPoints = foundry.utils.deepClone(this.document.shape.points); /* -------------------------------------------- */ /** @inheritdoc */ static embeddedName = "Drawing"; /** @override */ static RENDER_FLAGS = { redraw: {propagate: ["refresh"]}, refresh: {propagate: ["refreshState", "refreshTransform", "refreshText", "refreshElevation"], alias: true}, refreshState: {}, refreshTransform: {propagate: ["refreshPosition", "refreshRotation", "refreshSize"], alias: true}, refreshPosition: {}, refreshRotation: {propagate: ["refreshFrame"]}, refreshSize: {propagate: ["refreshPosition", "refreshFrame", "refreshShape", "refreshText"]}, refreshShape: {}, refreshText: {}, refreshFrame: {}, refreshElevation: {}, /** @deprecated since v12 */ refreshMesh: { propagate: ["refreshTransform", "refreshShape", "refreshElevation"], deprecated: {since: 12, until: 14, alias: true} } }; /** * 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 */ /* -------------------------------------------- */ /** * A convenient reference for whether the current User is the author of the Drawing document. * @type {boolean} */ get isAuthor() { return this.document.isAuthor; } /* -------------------------------------------- */ /** * Is this Drawing currently visible on the Canvas? * @type {boolean} */ get isVisible() { return !this.document.hidden || this.isAuthor || game.user.isGM || this.isPreview; } /* -------------------------------------------- */ /** @override */ get bounds() { const {x, y, shape, rotation} = this.document; return rotation === 0 ? new PIXI.Rectangle(x, y, shape.width, shape.height).normalize() : PIXI.Rectangle.fromRotation(x, y, shape.width, shape.height, Math.toRadians(rotation)).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._pendingText !== undefined) || !!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; } /* -------------------------------------------- */ /** * The pending text. * @type {string} * @internal */ _pendingText; /* -------------------------------------------- */ /** * The registered keydown listener. * @type {Function|null} * @internal */ _onkeydown = null; /* -------------------------------------------- */ /** * Delete the Drawing if the text is empty once text editing ends? * @type {boolean} */ #deleteIfEmptyText = false; /* -------------------------------------------- */ /* Initial Rendering */ /* -------------------------------------------- */ /** @inheritDoc */ _destroy(options) { this.#removeDrawing(this); this.texture?.destroy(); } /* -------------------------------------------- */ /** @override */ async _draw(options) { // Load the background texture, if one is defined const texture = this.document.texture; if ( this._original ) this.texture = this._original.texture?.clone(); else this.texture = texture ? await loadTexture(texture, {fallback: "icons/svg/hazard.svg"}) : null; // Create the drawing container in the primary group or in the interface group this.shape = this.#addDrawing(); this.shape.visible = true; // Control Border this.frame = this.addChild(this.#drawFrame()); // Drawing text this.text = this.hasText ? this.shape.addChild(this.#drawText()) : null; // Interactivity this.cursor = this.document.isOwner ? "pointer" : null; } /* -------------------------------------------- */ /** * Add a drawing object according to interface configuration. * @returns {PIXI.Graphics|PrimaryGraphics} */ #addDrawing() { const targetGroup = this.document.interface ? canvas.interface : canvas.primary; const removeGroup = this.document.interface ? canvas.primary : canvas.interface; removeGroup.removeDrawing(this); return targetGroup.addDrawing(this); } /* -------------------------------------------- */ /** * Remove a drawing object. */ #removeDrawing() { canvas.interface.removeDrawing(this); canvas.primary.removeDrawing(this); } /* -------------------------------------------- */ /** * Create elements for the Drawing border and handles * @returns {PIXI.Container} */ #drawFrame() { const frame = new PIXI.Container(); frame.eventMode = "passive"; frame.bounds = new PIXI.Rectangle(); frame.interaction = frame.addChild(new PIXI.Container()); frame.interaction.hitArea = frame.bounds; frame.interaction.eventMode = "auto"; frame.border = frame.addChild(new PIXI.Graphics()); frame.border.eventMode = "none"; frame.handle = frame.addChild(new ResizeHandle([1, 1])); frame.handle.eventMode = "static"; return frame; } /* -------------------------------------------- */ /** * Create a PreciseText element to be displayed as part of this drawing. * @returns {PreciseText} */ #drawText() { const text = new PreciseText(this.document.text || "", this._getTextStyle()); text.eventMode = "none"; return text; } /* -------------------------------------------- */ /** * Get the line style used for drawing the shape of this Drawing. * @returns {object} The line style options (`PIXI.ILineStyleOptions`). * @protected */ _getLineStyle() { const {strokeWidth, strokeColor, strokeAlpha} = this.document; return {width: strokeWidth, color: strokeColor, alpha: strokeAlpha}; } /* -------------------------------------------- */ /** * Get the fill style used for drawing the shape of this Drawing. * @returns {object} The fill style options (`PIXI.IFillStyleOptions`). * @protected */ _getFillStyle() { const {fillType, fillColor, fillAlpha} = this.document; const style = {color: fillColor, alpha: fillAlpha}; if ( (fillType === CONST.DRAWING_FILL_TYPES.PATTERN) && this.texture?.valid ) style.texture = this.texture; else if ( !fillType ) style.alpha = 0; return style; } /* -------------------------------------------- */ /** * 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: "center", wordWrap: true, wordWrapWidth: shape.width, padding: stroke * 4 }); } /* -------------------------------------------- */ /** @inheritDoc */ clone() { const c = super.clone(); c._pendingText = this._pendingText; return c; } /* -------------------------------------------- */ /* Incremental Refresh */ /* -------------------------------------------- */ /** @override */ _applyRenderFlags(flags) { if ( flags.refreshState ) this._refreshState(); if ( flags.refreshPosition ) this._refreshPosition(); if ( flags.refreshRotation ) this._refreshRotation(); if ( flags.refreshShape ) this._refreshShape(); if ( flags.refreshText ) this._refreshText(); if ( flags.refreshFrame ) this._refreshFrame(); if ( flags.refreshElevation ) this._refreshElevation(); } /* -------------------------------------------- */ /** * Refresh the position. * @protected */ _refreshPosition() { const {x, y, shape: {width, height}} = this.document; if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent(); this.position.set(x, y); this.shape.position.set(x + (width / 2), y + (height / 2)); this.shape.pivot.set(width / 2, height / 2); if ( !this.text ) return; this.text.position.set(width / 2, height / 2); this.text.anchor.set(0.5, 0.5); } /* -------------------------------------------- */ /** * Refresh the rotation. * @protected */ _refreshRotation() { const rotation = Math.toRadians(this.document.rotation); this.shape.rotation = rotation; } /* -------------------------------------------- */ /** * Refresh the displayed state of the Drawing. * Used to update aspects of the Drawing which change based on the user interaction state. * @protected */ _refreshState() { const {hidden, locked, sort} = this.document; const wasVisible = this.visible; this.visible = this.isVisible; if ( this.visible !== wasVisible ) MouseInteractionManager.emulateMoveEvent(); this.alpha = this._getTargetAlpha(); const colors = CONFIG.Canvas.dispositionColors; this.frame.border.tint = this.controlled ? (locked ? colors.HOSTILE : colors.CONTROLLED) : colors.INACTIVE; this.frame.border.visible = this.controlled || this.hover || this.layer.highlightObjects; this.frame.handle.visible = this.controlled && !locked; this.zIndex = this.shape.zIndex = this.controlled ? 2 : this.hover ? 1 : 0; const oldEventMode = this.eventMode; this.eventMode = this.layer.active && (this.controlled || ["select", "text"].includes(game.activeTool)) ? "static" : "none"; if ( this.eventMode !== oldEventMode ) MouseInteractionManager.emulateMoveEvent(); this.shape.visible = this.visible; this.shape.sort = sort; this.shape.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.DRAWINGS; this.shape.alpha = this.alpha * (hidden ? 0.5 : 1); this.shape.hidden = hidden; if ( !this.text ) return; this.text.alpha = this.document.textAlpha; } /* -------------------------------------------- */ /** * Clear and then draw the shape. * @protected */ _refreshShape() { this.shape.clear(); this.shape.lineStyle(this._getLineStyle()); this.shape.beginTextureFill(this._getFillStyle()); const lineWidth = this.shape.line.width; const shape = this.document.shape; switch ( shape.type ) { case Drawing.SHAPE_TYPES.RECTANGLE: this.shape.drawRect( lineWidth / 2, lineWidth / 2, Math.max(shape.width - lineWidth, 0), Math.max(shape.height - lineWidth, 0) ); break; case Drawing.SHAPE_TYPES.ELLIPSE: this.shape.drawEllipse( shape.width / 2, shape.height / 2, Math.max(shape.width - lineWidth, 0) / 2, Math.max(shape.height - lineWidth, 0) / 2 ); break; case Drawing.SHAPE_TYPES.POLYGON: const isClosed = this.document.fillType || (shape.points.slice(0, 2).equals(shape.points.slice(-2))); if ( isClosed ) this.shape.drawSmoothedPolygon(shape.points, this.document.bezierFactor * 2); else this.shape.drawSmoothedPath(shape.points, this.document.bezierFactor * 2); break; } this.shape.endFill(); this.shape.line.reset(); } /* -------------------------------------------- */ /** * Update sorting of this Drawing relative to other PrimaryCanvasGroup siblings. * Called when the elevation or sort order for the Drawing changes. * @protected */ _refreshElevation() { this.shape.elevation = this.document.elevation; } /* -------------------------------------------- */ /** * Refresh the border frame that encloses the Drawing. * @protected */ _refreshFrame() { const thickness = CONFIG.Canvas.objectBorderThickness; // Update the frame bounds const {shape: {width, height}, rotation} = this.document; const bounds = this.frame.bounds; bounds.x = 0; bounds.y = 0; bounds.width = width; bounds.height = height; bounds.rotate(Math.toRadians(rotation)); const minSize = thickness * 0.25; if ( bounds.width < minSize ) { bounds.x -= ((minSize - bounds.width) / 2); bounds.width = minSize; } if ( bounds.height < minSize ) { bounds.y -= ((minSize - bounds.height) / 2); bounds.height = minSize; } MouseInteractionManager.emulateMoveEvent(); // Draw the border const border = this.frame.border; border.clear(); border.lineStyle({width: thickness, color: 0x000000, join: PIXI.LINE_JOIN.ROUND, alignment: 0.75}) .drawShape(bounds); border.lineStyle({width: thickness / 2, color: 0xFFFFFF, join: PIXI.LINE_JOIN.ROUND, alignment: 1}) .drawShape(bounds); // Draw the handle this.frame.handle.refresh(bounds); } /* -------------------------------------------- */ /** * Refresh the content and appearance of text. * @protected */ _refreshText() { if ( !this.text ) return; const {text, textAlpha} = this.document; this.text.text = this._pendingText ?? text ?? ""; this.text.alpha = textAlpha; this.text.style = this._getTextStyle(); } /* -------------------------------------------- */ /* 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 = this.layer.getSnappedPoint(position); if ( round ) { position.x = Math.round(position.x); position.y = Math.round(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 * @internal */ _removePoint() { this.#fixedPoints.splice(-2); this.document.shape.updateSource({points: this.#fixedPoints}); } /* -------------------------------------------- */ /** @inheritDoc */ _onControl(options) { super._onControl(options); this.enableTextEditing(options); } /* -------------------------------------------- */ /** @inheritDoc */ _onRelease(options) { super._onRelease(options); if ( this._onkeydown ) { document.removeEventListener("keydown", this._onkeydown); this._onkeydown = null; } if ( canvas.scene.drawings.has(this.id) ) { if ( (this._pendingText === "") && this.#deleteIfEmptyText ) this.document.delete(); else if ( this._pendingText !== undefined ) { // Submit pending text this.#deleteIfEmptyText = false; this.document.update({text: this._pendingText}).then(() => { this._pendingText = undefined; this.renderFlags.set({redraw: this.hasText === !this.text, refreshText: true}); }); } } } /* -------------------------------------------- */ /** @override */ _overlapsSelection(rectangle) { if ( !this.frame ) return false; const localRectangle = new PIXI.Rectangle( rectangle.x - this.document.x, rectangle.y - this.document.y, rectangle.width, rectangle.height ); return localRectangle.overlaps(this.frame.bounds); } /* -------------------------------------------- */ /** * Enable text editing for this drawing. * @param {object} [options] */ enableTextEditing(options={}) { if ( (game.activeTool === "text") || options.forceTextEditing ) { this._pendingText = this.document.text || ""; this._onkeydown = this.#onDrawingTextKeydown.bind(this); document.addEventListener("keydown", this._onkeydown); if ( options.isNew ) this.#deleteIfEmptyText = true; this.renderFlags.set({refreshPosition: !this.text, refreshText: true}); this.text ??= this.shape.addChild(this.#drawText()); } } /* -------------------------------------------- */ /** * Handle text entry in an active text tool * @param {KeyboardEvent} event */ #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 = false; let refresh = false; // Enter (submit) or Escape (cancel) if ( ["Escape", "Enter"].includes(event.key) ) { conclude = 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(); } // Conclude the workflow if ( conclude ) { this.release(); } // Refresh the display else if ( refresh ) { this.renderFlags.set({refreshText: true}); } } /* -------------------------------------------- */ /* Document Event Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { super._onUpdate(changed, options, userId); // Update pending text if ( ("text" in changed) && (this._pendingText !== undefined) ) this._pendingText = this.document.text || ""; // Sort the interface drawings container if necessary if ( this.shape?.parent && (("elevation" in changed) || ("sort" in changed)) ) this.shape.parent.sortDirty = true; // Refresh the Tile this.renderFlags.set({ redraw: ("interface" in changed) || ("texture" in changed) || (("text" in changed) && (this.hasText === !this.text)), refreshState: ("sort" in changed) || ("hidden" in changed) || ("locked" in changed), refreshPosition: ("x" in changed) || ("y" in changed), refreshRotation: "rotation" in changed, refreshSize: ("shape" in changed) && (("width" in changed.shape) || ("height" in changed.shape)), refreshElevation: "elevation" in changed, refreshShape: ["shape", "bezierFactor", "strokeWidth", "strokeColor", "strokeAlpha", "fillType", "fillColor", "fillAlpha"].some(k => k in changed), refreshText: ["text", "fontFamily", "fontSize", "textColor", "textAlpha"].some(k => k in changed) }); } /* -------------------------------------------- */ /** @inheritDoc */ _onDelete(options, userId) { super._onDelete(options, userId); if ( this._onkeydown ) document.removeEventListener("keydown", this._onkeydown); } /* -------------------------------------------- */ /* Interactivity */ /* -------------------------------------------- */ /** @inheritDoc */ activateListeners() { super.activateListeners(); this.frame.handle.off("pointerover").off("pointerout") .on("pointerover", this._onHandleHoverIn.bind(this)) .on("pointerout", this._onHandleHoverOut.bind(this)); } /* -------------------------------------------- */ /** @override */ _canControl(user, event) { if ( !this.layer.active || this.isPreview ) return false; if ( this._creating ) { // Allow one-time control immediately following creation delete this._creating; return true; } if ( this.controlled ) return true; if ( !["select", "text"].includes(game.activeTool) ) return false; return user.isGM || (user === this.document.author); } /* -------------------------------------------- */ /** @override */ _canConfigure(user, event) { return this.controlled; } /* -------------------------------------------- */ /** * Handle mouse movement which modifies the dimensions of the drawn shape. * @param {PIXI.FederatedEvent} event * @protected */ _onMouseDraw(event) { const {destination, origin} = event.interactionData; const isShift = event.shiftKey; const isAlt = event.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: if ( !isShift ) position = this.layer.getSnappedPoint(position); const shape = this.document.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.renderFlags.set({refreshPosition: true, refreshSize: true}); } /* -------------------------------------------- */ /* Interactivity */ /* -------------------------------------------- */ /** @inheritdoc */ _onClickLeft(event) { if ( event.target === this.frame.handle ) { event.interactionData.dragHandle = true; event.stopPropagation(); return; } return super._onClickLeft(event); } /* -------------------------------------------- */ /** @override */ _onDragLeftStart(event) { if ( event.interactionData.dragHandle ) return this._onHandleDragStart(event); return super._onDragLeftStart(event); } /* -------------------------------------------- */ /** @override */ _onDragLeftMove(event) { if ( event.interactionData.dragHandle ) return this._onHandleDragMove(event); return super._onDragLeftMove(event); } /* -------------------------------------------- */ /** @override */ _onDragLeftDrop(event) { if ( event.interactionData.dragHandle ) return this._onHandleDragDrop(event); return super._onDragLeftDrop(event); } /* -------------------------------------------- */ /** @inheritDoc */ _onDragLeftCancel(event) { if ( event.interactionData.dragHandle ) return this._onHandleDragCancel(event); return super._onDragLeftCancel(event); } /* -------------------------------------------- */ /* Resize Handling */ /* -------------------------------------------- */ /** * Handle mouse-over event on a control handle * @param {PIXI.FederatedEvent} event The mouseover event * @protected */ _onHandleHoverIn(event) { const handle = event.target; handle?.scale.set(1.5, 1.5); } /* -------------------------------------------- */ /** * Handle mouse-out event on a control handle * @param {PIXI.FederatedEvent} event The mouseout event * @protected */ _onHandleHoverOut(event) { const handle = event.target; handle?.scale.set(1.0, 1.0); } /* -------------------------------------------- */ /** * Starting the resize handle drag event, initialize the original data. * @param {PIXI.FederatedEvent} event The mouse interaction event * @protected */ _onHandleDragStart(event) { event.interactionData.originalData = this.document.toObject(); const handle = this.frame.handle; event.interactionData.handleOrigin = {x: handle.position.x, y: handle.position.y}; } /* -------------------------------------------- */ /** * Handle mousemove while dragging a tile scale handler * @param {PIXI.FederatedEvent} event The mouse interaction event * @protected */ _onHandleDragMove(event) { // Pan the canvas if the drag event approaches the edge canvas._onDragCanvasPan(event); // Update Drawing dimensions const {destination, origin, handleOrigin, originalData} = event.interactionData; let handleDestination = { x: handleOrigin.x + (destination.x - origin.x), y: handleOrigin.y + (destination.y - origin.y) }; if ( !event.shiftKey ) handleDestination = this.layer.getSnappedPoint(handleDestination); const dx = handleDestination.x - handleOrigin.x; const dy = handleDestination.y - handleOrigin.y; const normalized = Drawing.rescaleDimensions(originalData, dx, dy); // Update the drawing, catching any validation failures this.document.updateSource(normalized); this.document.rotation = 0; this.renderFlags.set({refreshTransform: true}); } /* -------------------------------------------- */ /** * Handle mouseup after dragging a tile scale handler * @param {PIXI.FederatedEvent} event The mouseup event * @protected */ _onHandleDragDrop(event) { event.interactionData.restoreOriginalData = false; const {destination, origin, handleOrigin, originalData} = event.interactionData; let handleDestination = { x: handleOrigin.x + (destination.x - origin.x), y: handleOrigin.y + (destination.y - origin.y) }; if ( !event.shiftKey ) handleDestination = this.layer.getSnappedPoint(handleDestination); const dx = handleDestination.x - handleOrigin.x; const dy = handleDestination.y - handleOrigin.y; const update = Drawing.rescaleDimensions(originalData, dx, dy); this.document.update(update, {diff: false}) .then(() => this.renderFlags.set({refreshTransform: true})); } /* -------------------------------------------- */ /** * Handle cancellation of a drag event for one of the resizing handles * @param {PointerEvent} event The drag cancellation event * @protected */ _onHandleDragCancel(event) { if ( event.interactionData.restoreOriginalData !== false ) { this.document.updateSource(event.interactionData.originalData); this.renderFlags.set({refreshTransform: true}); } } /* -------------------------------------------- */ /** * Get a vectorized rescaling transformation for drawing data and dimensions passed in parameter * @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 * @returns {object} The adjusted shape data */ static rescaleDimensions(original, dx, dy) { let {type, points, width, height} = original.shape; width += dx; height += dy; points = points || []; // Rescale polygon points if ( type === Drawing.SHAPE_TYPES.POLYGON ) { const scaleX = 1 + (original.shape.width > 0 ? dx / original.shape.width : 0); const scaleY = 1 + (original.shape.height > 0 ? dy / original.shape.height : 0); points = points.map((p, i) => p * (i % 2 ? scaleY : scaleX)); } // Normalize the shape return this.normalizeShape({ x: original.x, y: original.y, shape: {width: Math.round(width), height: Math.round(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 */ 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