Files
FoundryVTT/src/FoundryVTT-11.315/resources/app/public/scripts/foundry.js
2025-11-06 14:04:48 +01:00

93446 lines
3.0 MiB

/** @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 {Object<Application>}
* @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<void>}
*/
async copyPlainText(text) {
// The clipboard-write permission name is not supported in Firefox.
try {
const result = await navigator.permissions.query({name: "clipboard-write"});
if ( ["granted", "prompt"].includes(result.state) ) {
return navigator.clipboard.writeText(text);
}
} catch(err) {}
// Fallback to deprecated execCommand here if writeText is not supported in this browser or security context.
document.addEventListener("copy", event => {
event.clipboardData.setData("text/plain", text);
event.preventDefault();
}, {once: true});
document.execCommand("copy");
}
}
/**
* A data structure for quickly retrieving objects by a string prefix.
* Note that this works well for languages with alphabets (latin, cyrillic, korean, etc.), but may need more nuanced
* handling for languages that compose characters and letters.
*/
class WordTree {
/**
* A leaf entry in the tree.
* @typedef {object} WordTreeEntry
* @property {Document|object} entry An object that this entry represents.
* @property {string} documentName The document type.
* @property {string} uuid The document's UUID.
* @property {string} [pack] The pack ID.
*/
/**
* A word tree node consists of zero or more 1-character keys, and a leaves property that contains any objects that
* terminate at the current string prefix.
* @typedef {object} WordTreeNode
* @property {WordTreeEntry[]} leaves Any leaves at this node.
*/
/**
* The tree's root.
* @type {WordTreeNode}
* @private
*/
#root = this.node;
/* -------------------------------------------- */
/**
* Create a new node.
* @returns {WordTreeNode}
*/
get node() {
return {leaves: []};
}
/* -------------------------------------------- */
/**
* Insert an entry into the tree.
* @param {string} string The string key for the entry.
* @param {WordTreeEntry} entry The entry to store.
* @returns {WordTreeNode} The node the entry was added to.
*/
addLeaf(string, entry) {
let node = this.#root;
string = string.toLocaleLowerCase(game.i18n.lang);
// Use Array.from here to make sure the string is split up along UTF-8 codepoints rather than individual UTF-16
// chunks.
for ( const c of Array.from(string) ) {
node[c] ??= this.node;
node = node[c];
}
// Once we've traversed the tree, we add our entry.
node.leaves.push(entry);
return node;
}
/* -------------------------------------------- */
/**
* Return entries that match the given string prefix.
* @param {string} prefix The prefix.
* @param {object} [options] Additional options to configure behaviour.
* @param {number} [options.limit=10] The maximum number of items to retrieve. It is important to set this value as
* very short prefixes will naturally match large numbers of entries.
* @returns {WordTreeEntry[]} A number of entries that have the given prefix.
*/
lookup(prefix, {limit=10}={}) {
const entries = [];
const node = this.nodeAtPrefix(prefix);
if ( !node ) return []; // No matching entries.
const queue = [node];
while ( queue.length ) {
if ( entries.length >= limit ) break;
this._breadthFirstSearch(queue.shift(), entries, queue, {limit});
}
return entries;
}
/* -------------------------------------------- */
/**
* Returns the node at the given prefix.
* @param {string} prefix The prefix.
* @returns {WordTreeNode}
*/
nodeAtPrefix(prefix) {
prefix = prefix.toLocaleLowerCase(game.i18n.lang);
let node = this.#root;
for ( const c of Array.from(prefix) ) {
node = node[c];
if ( !node ) return;
}
return node;
}
/* -------------------------------------------- */
/**
* Perform a breadth-first search starting from the given node and retrieving any entries along the way, until we
* reach the limit.
* @param {WordTreeNode} node The starting node.
* @param {WordTreeEntry[]} entries The accumulated entries.
* @param {WordTreeNode[]} queue The working queue of nodes to search.
* @param {object} [options] Additional options for the search.
* @param {number} [options.limit=10] The maximum number of entries to retrieve before stopping.
* @protected
*/
_breadthFirstSearch(node, entries, queue, {limit=10}={}) {
// Retrieve the entries at this node.
entries.push(...node.leaves);
if ( entries.length >= limit ) return;
// Push this node's children onto the end of the queue.
for ( const c of Object.keys(node) ) {
if ( c === "leaves" ) continue;
queue.push(node[c]);
}
}
}
/**
* This class is responsible for indexing all documents available in the world and storing them in a word tree structure
* that allows for fast searching.
*/
class DocumentIndex {
constructor() {
/**
* A collection of WordTree structures for each document type.
* @type {Object<WordTree>}
*/
Object.defineProperty(this, "trees", {value: {}});
/**
* A reverse-lookup of a document's UUID to its parent node in the word tree.
* @type {Object<WordTreeNode>}
*/
Object.defineProperty(this, "uuids", {value: {}});
}
/**
* While we are indexing, we store a Promise that resolves when the indexing is complete.
* @type {Promise<void>|null}
* @private
*/
#ready = null;
/* -------------------------------------------- */
/**
* Returns a Promise that resolves when the indexing process is complete.
* @returns {Promise<void>|null}
*/
get ready() {
return this.#ready;
}
/* -------------------------------------------- */
/**
* Index all available documents in the world and store them in a word tree.
* @returns {Promise<void>}
*/
async index() {
// Conclude any existing indexing.
await this.#ready;
const indexedCollections = CONST.DOCUMENT_TYPES.filter(c => CONFIG[c].documentClass.metadata.indexed);
// TODO: Consider running this process in a web worker.
const start = performance.now();
return this.#ready = new Promise(resolve => {
for ( const documentName of indexedCollections ) {
this._indexWorldCollection(documentName);
}
for ( const pack of game.packs ) {
if ( !indexedCollections.includes(pack.documentName) ) continue;
this._indexCompendium(pack);
}
resolve();
console.debug(`${vtt} | Document indexing complete in ${performance.now() - start}ms.`);
});
}
/* -------------------------------------------- */
/**
* Return entries that match the given string prefix.
* @param {string} prefix The prefix.
* @param {object} [options] Additional options to configure behaviour.
* @param {string[]} [options.documentTypes] Optionally provide an array of document types. Only entries of that type
* will be searched for.
* @param {number} [options.limit=10] The maximum number of items per document type to retrieve. It is
* important to set this value as very short prefixes will naturally match
* large numbers of entries.
* @returns {Object<WordTreeEntry[]>} A number of entries that have the given prefix, grouped by document
* type.
*/
lookup(prefix, {limit=10, documentTypes=[]}={}) {
const types = documentTypes.length ? documentTypes : Object.keys(this.trees);
const results = {};
for ( const type of types ) {
results[type] = [];
const tree = this.trees[type];
if ( !tree ) continue;
results[type].push(...tree.lookup(prefix, {limit}));
}
return results;
}
/* -------------------------------------------- */
/**
* Add an entry to the index.
* @param {Document} doc The document entry.
*/
addDocument(doc) {
if ( doc.pack ) {
if ( doc.isEmbedded ) return; // Only index primary documents inside compendium packs
const pack = game.packs.get(doc.pack);
const index = pack.index.get(doc.id);
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.leaves.findSplice(e => e.uuid === doc.uuid);
delete this.uuids[doc.uuid];
}
/* -------------------------------------------- */
/**
* Replace an entry in the index with an updated one.
* @param {Document} doc The document entry.
*/
replaceDocument(doc) {
this.removeDocument(doc);
this.addDocument(doc);
}
/* -------------------------------------------- */
/**
* Add a leaf node to the word tree index.
* @param {Document|object} doc The document or compendium index entry to add.
* @param {object} [options] Additional information for indexing.
* @param {CompendiumCollection} [options.pack] The compendium that the index belongs to.
* @protected
*/
_addLeaf(doc, {pack}={}) {
const entry = {entry: doc, documentName: doc.documentName, uuid: doc.uuid};
if ( pack ) foundry.utils.mergeObject(entry, {
documentName: pack.documentName,
uuid: `Compendium.${pack.collection}.${doc._id}`,
pack: pack.collection
});
const tree = this.trees[entry.documentName] ??= new WordTree();
this.uuids[entry.uuid] = tree.addLeaf(doc.name, entry);
}
/* -------------------------------------------- */
/**
* Aggregate the compendium index and add it to the word tree index.
* @param {CompendiumCollection} pack The compendium pack.
* @protected
*/
_indexCompendium(pack) {
for ( const entry of pack.index ) {
this._addLeaf(entry, {pack});
}
}
/* -------------------------------------------- */
/**
* Add all of a parent document's embedded documents to the index.
* @param {Document} parent The parent document.
* @protected
*/
_indexEmbeddedDocuments(parent) {
const embedded = parent.constructor.metadata.embedded;
for ( const embeddedName of Object.keys(embedded) ) {
if ( !CONFIG[embeddedName].documentClass.metadata.indexed ) continue;
for ( const doc of parent[embedded[embeddedName]] ) {
this._addLeaf(doc);
}
}
}
/* -------------------------------------------- */
/**
* Aggregate all documents and embedded documents in a world collection and add them to the index.
* @param {string} documentName The name of the documents to index.
* @protected
*/
_indexWorldCollection(documentName) {
const cls = CONFIG[documentName].documentClass;
const collection = cls.metadata.collection;
for ( const doc of game[collection] ) {
this._addLeaf(doc);
this._indexEmbeddedDocuments(doc);
}
}
}
/**
* Management class for Gamepad events
*/
class GamepadManager {
constructor() {
this._gamepadPoller = null;
/**
* The connected Gamepads
* @type {Map<string, ConnectedGamepad>}
* @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 {Object<HookedFunction[]>}
*/
static get events() {
return this.#events;
}
/**
* @type {Object<HookedFunction[]>}
* @private
* @ignore
*/
static #events = {};
/**
* A mapping of hooked functions by their assigned ID
* @type {Map<number, HookedFunction>}
*/
static #ids = new Map();
/**
* An incrementing counter for assigned hooked function IDs
* @type {number}
*/
static #id = 1;
/* -------------------------------------------- */
/**
* Register a callback handler which should be triggered when a hook is triggered.
* @param {string} hook The unique name of the hooked event
* @param {Function} fn The callback function which should be triggered when the hook event occurs
* @param {object} options Options which customize hook registration
* @param {boolean} options.once Only trigger the hooked function once
* @returns {number} An ID number of the hooked function which can be used to turn off the hook later
*/
static on(hook, fn, {once=false}={}) {
console.debug(`${vtt} | Registered callback for ${hook} hook`);
const id = this.#id++;
if ( !(hook in this.#events) ) {
Object.defineProperty(this.#events, hook, {value: [], writable: false});
}
const entry = {hook, id, fn, once};
this.#events[hook].push(entry);
this.#ids.set(id, entry);
return id;
}
/* -------------------------------------------- */
/**
* Register a callback handler for an event which is only triggered once the first time the event occurs.
* An alias for Hooks.on with {once: true}
* @param {string} hook The unique name of the hooked event
* @param {Function} fn The callback function which should be triggered when the hook event occurs
* @returns {number} An ID number of the hooked function which can be used to turn off the hook later
*/
static once(hook, fn) {
return this.on(hook, fn, {once: true});
}
/* -------------------------------------------- */
/**
* Unregister a callback handler for a particular hook event
* @param {string} hook The unique name of the hooked event
* @param {Function|number} fn The function, or ID number for the function, that should be turned off
*/
static off(hook, fn) {
let entry;
// Provided an ID
if ( typeof fn === "number" ) {
const id = fn;
entry = this.#ids.get(id);
if ( !entry ) return;
this.#ids.delete(id);
const event = this.#events[entry.hook];
event.findSplice(h => h.id === id);
}
// Provided a Function
else {
const event = this.#events[hook];
const entry = event.findSplice(h => h.fn === fn);
if ( !entry ) return;
this.#ids.delete(entry.id);
}
console.debug(`${vtt} | Unregistered callback for ${hook} hook`);
}
/* -------------------------------------------- */
/**
* Call all hook listeners in the order in which they were registered
* Hooks called this way can not be handled by returning false and will always trigger every hook callback.
*
* @param {string} hook The hook being triggered
* @param {...*} args Arguments passed to the hook callback functions
* @returns {boolean} Were all hooks called without execution being prevented?
*/
static callAll(hook, ...args) {
if ( CONFIG.debug.hooks ) {
console.log(`DEBUG | Calling ${hook} hook with args:`);
console.log(args);
}
if ( !(hook in this.#events) ) return true;
for ( const entry of Array.from(this.#events[hook]) ) {
this.#call(entry, args);
}
return true;
}
/* -------------------------------------------- */
/**
* Call hook listeners in the order in which they were registered.
* Continue calling hooks until either all have been called or one returns false.
*
* Hook listeners which return false denote that the original event has been adequately handled and no further
* hooks should be called.
*
* @param {string} hook The hook being triggered
* @param {...*} args Arguments passed to the hook callback functions
* @returns {boolean} Were all hooks called without execution being prevented?
*/
static call(hook, ...args) {
if ( CONFIG.debug.hooks ) {
console.log(`DEBUG | Calling ${hook} hook with args:`);
console.log(args);
}
if ( !(hook in this.#events) ) return true;
for ( const entry of Array.from(this.#events[hook]) ) {
let callAdditional = this.#call(entry, args);
if ( callAdditional === false ) return false;
}
return true;
}
/* -------------------------------------------- */
/**
* Call a hooked function using provided arguments and perhaps unregister it.
* @param {HookedFunction} entry The hooked function entry
* @param {any[]} args Arguments to be passed
* @private
*/
static #call(entry, args) {
const {hook, id, fn, once} = entry;
if ( once ) this.off(hook, id);
try {
return entry.fn(...args);
} catch(err) {
const msg = `Error thrown in hooked function '${fn?.name}' for hook '${hook}'`;
console.warn(`${vtt} | ${msg}`);
if ( hook !== "error" ) this.onError("Hooks.#call", err, {msg, hook, fn, log: "error"});
}
}
/* --------------------------------------------- */
/**
* Notify subscribers that an error has occurred within foundry.
* @param {string} location The method where the error was caught.
* @param {Error} error The error.
* @param {object} [options={}] Additional options to configure behaviour.
* @param {string} [options.msg=""] A message which should prefix the resulting error or notification.
* @param {?string} [options.log=null] The level at which to log the error to console (if at all).
* @param {?string} [options.notify=null] The level at which to spawn a notification in the UI (if at all).
* @param {object} [options.data={}] Additional data to pass to the hook subscribers.
*/
static onError(location, error, {msg="", notify=null, log=null, ...data}={}) {
if ( !(error instanceof Error) ) return;
if ( msg ) error.message = `${msg}. ${error.message}`;
if ( log ) console[log]?.(error);
if ( notify ) ui.notifications[notify]?.(msg || error.message);
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<object>} 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<string>} 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<string>} 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<string>} 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<object>} 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 {Object<Object<number>>} 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<string, ModuleSubTypeCounts>}
*/
#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 {Object<UsabilityIssue>}
*/
#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 {Object<BrowserTest>}
*/
static #BROWSER_TESTS = {
Electron: {
minimum: 24,
match: /Electron\/(\d+)\./,
message: "ERROR.ElectronVersion"
},
Chromium: {
minimum: 92,
match: /Chrom(?:e|ium)\/(\d+)\./,
message: "ERROR.BrowserVersion"
},
Firefox: {
minimum: 90,
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 {string} documentName The Document name.
* @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(documentName, source, options={}) {
const cls = getDocumentClass(documentName);
if ( cls.hasTypeData ) this.#countDocumentSubType(documentName, source.type, options);
for ( const [embeddedName, collection] of Object.entries(cls.metadata.embedded ?? {}) ) {
if ( !getDocumentClass(embeddedName).hasTypeData ) continue;
for ( const embedded of source[collection] ) this.#countDocumentSubType(embeddedName, embedded.type, 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<string, ModuleSubTypeCounts>}
*/
getAllSubTypeCounts() {
return this.#moduleTypeMap.entries();
}
/* -------------------------------------------- */
/**
* Retrieve the tracked validation failures.
* @returns {object}
*/
get validationFailures() {
return this.#documentValidationFailures;
}
/* -------------------------------------------- */
/**
* Retrieve the tracked usability issues.
* @returns {Object<UsabilityIssue>}
*/
get usabilityIssues() {
return this.#usabilityIssues;
}
/* -------------------------------------------- */
/**
* @typedef {object} PackageCompatibilityIssue
* @property {string[]} error Error messages.
* @property {string[]} warning Warning messages.
*/
/**
* Retrieve package compatibility issues.
* @returns {Object<PackageCompatibilityIssue>}
*/
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<string, KeybindingActionConfig>}
*/
this.actions = new Map();
/**
* A mapping of a string key to possible Actions that might execute off it
* @type {Map<string, KeybindingAction[]>}
*/
this.activeKeys = new Map();
/**
* A stored cache of Keybind Actions Ids to Bindings
* @type {Map<string, KeybindingActionBinding[]>}
*/
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<string>}>
*/
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
*/
_registerCoreKeybindings() {
const {SHIFT, CONTROL, ALT} = KeyboardManager.MODIFIER_KEYS;
game.keybindings.register("core", "cycleView", {
name: "KEYBINDINGS.CycleView",
editable: [
{key: "Tab"}
],
onDown: ClientKeybindings._onCycleView,
reservedModifiers: [SHIFT],
repeat: true
});
game.keybindings.register("core", "dismiss", {
name: "KEYBINDINGS.Dismiss",
uneditable: [
{key: "Escape"}
],
onDown: ClientKeybindings._onDismiss,
precedence: CONST.KEYBINDING_PRECEDENCE.DEFERRED
});
game.keybindings.register("core", "measuredRulerMovement", {
name: "KEYBINDINGS.MoveAlongMeasuredRuler",
editable: [
{key: "Space"}
],
onDown: ClientKeybindings._onMeasuredRulerMovement,
precedence: CONST.KEYBINDING_PRECEDENCE.PRIORITY,
reservedModifiers: [CONTROL]
});
game.keybindings.register("core", "pause", {
name: "KEYBINDINGS.Pause",
restricted: true,
editable: [
{key: "Space"}
],
onDown: ClientKeybindings._onPause,
precedence: CONST.KEYBINDING_PRECEDENCE.DEFERRED
});
game.keybindings.register("core", "delete", {
name: "KEYBINDINGS.Delete",
uneditable: [
{key: "Delete"}
],
editable: [
{key: "Backspace"}
],
onDown: ClientKeybindings._onDelete
});
game.keybindings.register("core", "highlight", {
name: "KEYBINDINGS.Highlight",
editable: [
{key: "AltLeft"},
{key: "AltRight"}
],
onUp: ClientKeybindings._onHighlight,
onDown: ClientKeybindings._onHighlight
});
game.keybindings.register("core", "selectAll", {
name: "KEYBINDINGS.SelectAll",
uneditable: [
{key: "KeyA", modifiers: [CONTROL]}
],
onDown: ClientKeybindings._onSelectAllObjects
});
game.keybindings.register("core", "undo", {
name: "KEYBINDINGS.Undo",
uneditable: [
{key: "KeyZ", modifiers: [CONTROL]}
],
onDown: ClientKeybindings._onUndo
});
game.keybindings.register("core", "copy", {
name: "KEYBINDINGS.Copy",
uneditable: [
{key: "KeyC", modifiers: [CONTROL]}
],
onDown: ClientKeybindings._onCopy
});
game.keybindings.register("core", "paste", {
name: "KEYBINDINGS.Paste",
uneditable: [
{key: "KeyV", modifiers: [CONTROL]}
],
onDown: ClientKeybindings._onPaste,
reservedModifiers: [ALT, SHIFT]
});
game.keybindings.register("core", "target", {
name: "KEYBINDINGS.Target",
editable: [
{key: "KeyT"}
],
onDown: ClientKeybindings._onTarget,
reservedModifiers: [SHIFT]
});
game.keybindings.register("core", "characterSheet", {
name: "KEYBINDINGS.ToggleCharacterSheet",
editable: [
{key: "KeyC"}
],
onDown: ClientKeybindings._onToggleCharacterSheet,
precedence: CONST.KEYBINDING_PRECEDENCE.PRIORITY
});
game.keybindings.register("core", "panUp", {
name: "KEYBINDINGS.PanUp",
uneditable: [
{key: "ArrowUp"},
{key: "Numpad8"}
],
editable: [
{key: "KeyW"}
],
onUp: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP]),
onDown: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP]),
reservedModifiers: [CONTROL, SHIFT],
repeat: true
});
game.keybindings.register("core", "panLeft", {
name: "KEYBINDINGS.PanLeft",
uneditable: [
{key: "ArrowLeft"},
{key: "Numpad4"}
],
editable: [
{key: "KeyA"}
],
onUp: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
onDown: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
reservedModifiers: [CONTROL, SHIFT],
repeat: true
});
game.keybindings.register("core", "panDown", {
name: "KEYBINDINGS.PanDown",
uneditable: [
{key: "ArrowDown"},
{key: "Numpad2"}
],
editable: [
{key: "KeyS"}
],
onUp: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN]),
onDown: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN]),
reservedModifiers: [CONTROL, SHIFT],
repeat: true
});
game.keybindings.register("core", "panRight", {
name: "KEYBINDINGS.PanRight",
uneditable: [
{key: "ArrowRight"},
{key: "Numpad6"}
],
editable: [
{key: "KeyD"}
],
onUp: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
onDown: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
reservedModifiers: [CONTROL, SHIFT],
repeat: true
});
game.keybindings.register("core", "panUpLeft", {
name: "KEYBINDINGS.PanUpLeft",
uneditable: [
{key: "Numpad7"}
],
onUp: context => this._onPan(context,
[ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
onDown: context => this._onPan(context,
[ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
reservedModifiers: [CONTROL, SHIFT],
repeat: true
});
game.keybindings.register("core", "panUpRight", {
name: "KEYBINDINGS.PanUpRight",
uneditable: [
{key: "Numpad9"}
],
onUp: context => this._onPan(context,
[ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
onDown: context => this._onPan(context,
[ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
reservedModifiers: [CONTROL, SHIFT],
repeat: true
});
game.keybindings.register("core", "panDownLeft", {
name: "KEYBINDINGS.PanDownLeft",
uneditable: [
{key: "Numpad1"}
],
onUp: context => this._onPan(context,
[ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
onDown: context => this._onPan(context,
[ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
reservedModifiers: [CONTROL, SHIFT],
repeat: true
});
game.keybindings.register("core", "panDownRight", {
name: "KEYBINDINGS.PanDownRight",
uneditable: [
{key: "Numpad3"}
],
onUp: context => this._onPan(context,
[ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
onDown: context => this._onPan(context,
[ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
reservedModifiers: [CONTROL, SHIFT],
repeat: true
});
game.keybindings.register("core", "zoomIn", {
name: "KEYBINDINGS.ZoomIn",
uneditable: [
{key: "NumpadAdd"}
],
editable: [
{key: "PageUp"}
],
onDown: context => { ClientKeybindings._onZoom(context, ClientKeybindings.ZOOM_DIRECTIONS.IN); },
repeat: true
});
game.keybindings.register("core", "zoomOut", {
name: "KEYBINDINGS.ZoomOut",
uneditable: [
{key: "NumpadSubtract"}
],
editable: [
{key: "PageDown"}
],
onDown: context => { ClientKeybindings._onZoom(context, ClientKeybindings.ZOOM_DIRECTIONS.OUT); },
repeat: true
});
for ( const number of 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 {KeyboardEvent} event The originating keyboard event
* @param {KeyboardEventContext} context The context data of the event
* @private
*/
static _onSelectAllObjects(event, context) {
if ( !canvas.ready) return false;
canvas.activeLayer.controlAll();
return true;
}
/* -------------------------------------------- */
/**
* Handle Cycle View actions
* @param {KeyboardEventContext} context The context data of the event
* @private
*/
static _onCycleView(context) {
if ( !canvas.ready ) return false;
// Attempt to cycle tokens, otherwise re-center the canvas
if ( canvas.tokens.active ) {
let cycled = canvas.tokens.cycleTokens(!context.isShift, false);
if ( !cycled ) canvas.recenter();
}
return true;
}
/* -------------------------------------------- */
/**
* Handle Dismiss actions
* @param {KeyboardEventContext} context The context data of the event
* @private
*/
static _onDismiss(context) {
// Save fog of war if there are pending changes
if ( canvas.ready ) canvas.fog.commit();
// Case 1 - dismiss an open context menu
if (ui.context && ui.context.menu.length) {
ui.context.close();
return true;
}
// Case 2 - dismiss an open Tour
if (Tour.tourInProgress) {
Tour.activeTour.exit();
return true;
}
// Case 3 - close open UI windows
if (Object.keys(ui.windows).length) {
Object.values(ui.windows).forEach(app => app.close());
return true;
}
// Case 4 (GM) - release controlled objects (if not in a preview)
if (game.user.isGM && (canvas.activeLayer 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 ) canvas.fog.save();
return true;
}
/* -------------------------------------------- */
/**
* Open Character sheet for current token or controlled actor
* @param {KeyboardEvent} event The initiating keyboard event
* @param {KeyboardEventContext} context The context data of the event
* @private
*/
static _onToggleCharacterSheet(event, context) {
return game.toggleCharacterSheet();
}
/* -------------------------------------------- */
/**
* Handle action to target the currently hovered token.
* @param {KeyboardEventContext} context The context data of the event
* @private
*/
static _onTarget(context) {
if ( !canvas.ready ) return false;
const layer = canvas.activeLayer;
if ( !(layer instanceof TokenLayer) ) return false;
const hovered = layer.hover;
if ( !hovered ) return false;
if ( (hovered.document.disposition === CONST.TOKEN_DISPOSITIONS.SECRET) && !hovered.isOwner ) return false;
hovered.setTarget(!hovered.isTargeted, {releaseOthers: !context.isShift});
return true;
}
/* -------------------------------------------- */
/**
* Handle DELETE Keypress Events
* @param {KeyboardEvent} event The originating keyboard event
* @param {KeyboardEventContext} context The context data of the event
* @private
*/
static _onDelete(event, context) {
// Remove hotbar Macro
if ( ui.hotbar._hover ) {
game.user.assignHotbarMacro(null, ui.hotbar._hover);
return true;
}
// Delete placeables from Canvas layer
else if ( canvas.ready && ( canvas.activeLayer instanceof PlaceablesLayer ) ) {
canvas.activeLayer._onDeleteKey(event);
return true;
}
}
/* -------------------------------------------- */
/**
* Handle keyboard movement once a small delay has elapsed to allow for multiple simultaneous key-presses.
* @param {KeyboardEventContext} context The context data of the event
* @param {InteractionLayer} layer The active InteractionLayer instance
* @private
*/
_handleMovement(context, layer) {
if ( !this.moveKeys.size ) return;
// Get controlled objects
let objects = layer.placeables.filter(o => o.controlled);
if ( objects.length === 0 ) return;
// Define movement offsets and get moved directions
const directions = this.moveKeys;
let dx = 0;
let dy = 0;
// Assign movement offsets
if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT) ) dx -= 1;
else if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT) ) dx += 1;
if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.UP) ) dy -= 1;
else if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN) ) dy += 1;
// Perform the shift or rotation
layer.moveMany({dx, dy, rotate: context.isShift});
}
/* -------------------------------------------- */
/**
* Handle panning the canvas using CTRL + directional keys
*/
_handleCanvasPan() {
// Determine movement offsets
let dx = 0;
let dy = 0;
if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT)) dx -= 1;
if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.UP)) dy -= 1;
if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT)) dx += 1;
if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN)) dy += 1;
// Clear the pending set
this.moveKeys.clear();
// Pan by the grid size
const s = canvas.dimensions.size;
return canvas.animatePan({
x: canvas.stage.pivot.x + (dx * s),
y: canvas.stage.pivot.y + (dy * s),
duration: 100
});
}
/* -------------------------------------------- */
/**
* Handle Measured Ruler Movement Action
* @param {KeyboardEventContext} context The context data of the event
* @private
*/
static _onMeasuredRulerMovement(context) {
// Move along a measured ruler
const ruler = canvas.controls?.ruler;
if ( canvas.ready && ruler.active ) {
ruler.moveToken();
return true;
}
}
/* -------------------------------------------- */
/**
* Handle Pause Action
* @param {KeyboardEventContext} context The context data of the event
* @private
*/
static _onPause(context) {
game.togglePause(undefined, true);
return true;
}
/* -------------------------------------------- */
/**
* Handle Highlight action
* @param {KeyboardEventContext} context The context data of the event
* @private
*/
static _onHighlight(context) {
if ( !canvas.ready ) return false;
canvas.highlightObjects(!context.up);
return true;
}
/* -------------------------------------------- */
/**
* Handle Pan action
* @param {KeyboardEventContext} context The context data of the event
* @param {string[]} movementDirections The Directions being panned in
* @private
*/
_onPan(context, movementDirections) {
// Case 1: Check for Tour
if ( (Tour.tourInProgress) && (!context.repeat) && (!context.up) ) {
Tour.onMovementAction(movementDirections);
return true;
}
// Case 2: Check for Canvas
if ( !canvas.ready ) return false;
// Remove Keys on Up
if ( context.up ) {
for ( let d of movementDirections ) {
this.moveKeys.delete(d);
}
return true;
}
// Keep track of when we last moved
const now = Date.now();
const delta = now - this._moveTime;
// Track the movement set
for ( let d of movementDirections ) {
this.moveKeys.add(d);
}
// Handle canvas pan using CTRL
if ( context.isControl ) {
if ( ["KeyW", "KeyA", "KeyS", "KeyD"].includes(context.key) ) return false;
this._handleCanvasPan();
return true;
}
// Delay 50ms before shifting tokens in order to capture diagonal movements
const layer = canvas.activeLayer;
if ( (layer === canvas.tokens) || (layer === canvas.tiles) ) {
if ( delta < 100 ) return true; // Throttle keyboard movement once per 100ms
setTimeout(() => this._handleMovement(context, layer), 50);
}
this._moveTime = now;
return true;
}
/* -------------------------------------------- */
/**
* Handle Macro executions
* @param {KeyboardEventContext} context The context data of the event
* @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;
if ( layer.history.length ) {
layer.undoHistory();
return true;
}
}
/* -------------------------------------------- */
/**
* Handle presses to keyboard zoom keys
* @param {KeyboardEventContext} context The context data of the event
* @param {ClientKeybindings.ZOOM_DIRECTIONS} zoomDirection The direction to zoom
* @private
*/
static _onZoom(context, zoomDirection ) {
if ( !canvas.ready ) return false;
const delta = zoomDirection === ClientKeybindings.ZOOM_DIRECTIONS.IN ? 1.05 : 0.95;
canvas.animatePan({scale: delta * canvas.stage.scale.x, duration: 100});
return true;
}
/* -------------------------------------------- */
/**
* Bring the chat window into view and focus the input
* @param {KeyboardEventContext} context The context data of the event
* @returns {boolean}
* @private
*/
static _onFocusChat(context) {
const sidebar = ui.sidebar._element[0];
ui.sidebar.activateTab(ui.chat.tabName);
// If the sidebar is collapsed and the chat popover is not visible, open it
if ( sidebar.classList.contains("collapsed") && !ui.chat._popout ) {
const popout = ui.chat.createPopout();
popout._render(true).then(() => {
popout.element.find("#chat-message").focus();
});
}
else {
ui.chat.element.find("#chat-message").focus();
}
return true;
}
}
/**
* A set of helpers and management functions for dealing with user input from keyboard events.
* {@link https://keycode.info/}
*/
class KeyboardManager {
constructor() {
this._reset();
}
/* -------------------------------------------- */
/**
* 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<string>}
*/
downKeys = new Set();
/* -------------------------------------------- */
/**
* The set of movement keys which were recently pressed
* @type {Set<string>}
*/
moveKeys = new Set();
/* -------------------------------------------- */
/**
* Allowed modifier keys
* @enum {string}
*/
static MODIFIER_KEYS = {
CONTROL: "Control",
SHIFT: "Shift",
ALT: "Alt"
};
/* -------------------------------------------- */
/**
* Track which KeyboardEvent#code presses associate with each modifier
* @enum {string[]}
*/
static MODIFIER_CODES = {
[this.MODIFIER_KEYS.ALT]: ["AltLeft", "AltRight"],
[this.MODIFIER_KEYS.CONTROL]: ["ControlLeft", "ControlRight", "MetaLeft", "MetaRight", "Meta", "OsLeft", "OsRight"],
[this.MODIFIER_KEYS.SHIFT]: ["ShiftLeft", "ShiftRight"]
};
/* -------------------------------------------- */
/**
* Key codes which are "protected" and should not be used because they are reserved for browser-level actions.
* @type {string[]}
*/
static PROTECTED_KEYS = ["F5", "F11", "F12", "PrintScreen", "ScrollLock", "NumLock", "CapsLock"];
/* -------------------------------------------- */
/**
* The OS-specific string display for what their Command key is
* @type {string}
*/
static CONTROL_KEY_STRING = navigator.appVersion.includes("Mac") ? "⌘" : "Control";
/* -------------------------------------------- */
/**
* An special mapping of how special KeyboardEvent#code values should map to displayed strings or symbols.
* Values in this configuration object override any other display formatting rules which may be applied.
* @type {Object<string, string>}
*/
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));
}
/* -------------------------------------------- */
/**
* 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 = [`
<h3 class="nue">${game.i18n.localize("NUE.FirstLaunchHeader")}</h3>
<p class="nue">${game.i18n.localize("NUE.FirstLaunchBody")}</p>
<p class="nue">${game.i18n.localize("NUE.FirstLaunchKB")}</p>
<footer class="nue">${game.i18n.localize("NUE.FirstLaunchHint")}</footer>
`, `
<h3 class="nue">${game.i18n.localize("NUE.FirstLaunchInvite")}</h3>
<p class="nue">${game.i18n.localize("NUE.FirstLaunchInviteBody")}</p>
<p class="nue">${game.i18n.localize("NUE.FirstLaunchTroubleshooting")}</p>
<footer class="nue">${game.i18n.localize("NUE.FirstLaunchHint")}</footer>
`];
const chatData = content.map(c => {
return {
whisper: gms,
speaker: {alias: game.i18n.localize("Foundry Virtual Tabletop")},
flags: {core: {nue: true, canPopout: true}},
content: c
};
});
ChatMessage.implementation.createDocuments(chatData);
// Store flag indicating this was shown
game.settings.set("core", "nue.shownTips", true);
}
/* -------------------------------------------- */
/**
* Create a default scene for the new world.
* @private
*/
async _createDefaultScene() {
if ( !game.user.isGM ) return;
const filePath = foundry.utils.getRoute("/nue/defaultscene/scene.json");
const response = await fetchWithTimeout(filePath, {
method: "GET",
});
const json = await response.json();
const scene = await Scene.create(json);
await scene.activate();
canvas.animatePan({scale: 0.7, duration: 100});
}
/* -------------------------------------------- */
/**
* Automatically show uncompleted Tours related to new worlds.
* @private
*/
async _showNewWorldTour() {
const tour = game.tours.get("core.welcome");
if ( tour?.status === Tour.STATUS.UNSTARTED ) {
await this._createDefaultScene();
tour.start();
}
}
/* -------------------------------------------- */
/**
* Add event listeners to the chat card links.
* @param {ChatMessage} msg The ChatMessage being rendered.
* @param {jQuery} html The HTML content of the message.
* @private
*/
_activateListeners(msg, html) {
if ( !msg.getFlag("core", "nue") ) return;
html.find(".nue-tab").click(this._onTabLink.bind(this));
html.find(".nue-action").click(this._onActionLink.bind(this));
}
/* -------------------------------------------- */
/**
* Perform some special action triggered by clicking on a link in a NUE chat card.
* @param {TriggeredEvent} event The click event.
* @private
*/
_onActionLink(event) {
event.preventDefault();
const action = event.currentTarget.dataset.action;
switch ( action ) {
case "invite": return new InvitationLinks().render(true);
}
}
/* -------------------------------------------- */
/**
* Switch to the appropriate tab when a user clicks on a link in the chat message.
* @param {TriggeredEvent} event The click event.
* @private
*/
_onTabLink(event) {
event.preventDefault();
const tab = event.currentTarget.dataset.tab;
ui.sidebar.activateTab(tab);
}
}
/**
* @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<PackageManifestData>} data The compatibility data.
* @param {object} [options]
* @param {Collection<string, Module>} [options.modules] A specific collection of modules to test availability
* against. Tests against the currently installed modules by
* default.
* @param {Collection<string, System>} [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<PackageManifestData>} data The compatibility data.
* @param {Iterable<RelatedPackage>} deps The dependencies to format.
* @param {object} [options]
* @param {Collection<string, Module>} [options.modules] A specific collection of modules to test availability
* against. Tests against the currently installed modules by
* default.
* @param {Collection<string, System>} [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<PackageManifestData>} data The compatibility data.
* @param {Iterable<RelatedPackage>} relationships The system relationships.
* @param {object} [options]
* @param {Collection<string, System>} [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<ClientPackage|null>} 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) {}
/**
* @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 = `<p>${system.title}</p><p>${badge.tooltip}</p>`;
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<string, SettingsConfig>}
*/
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: Number,
* range: { // If range is specified, the resulting setting will be a range slider
* min: 0,
* max: 100,
* step: 10
* }
* default: 50, // The default value for the setting
* onChange: value => { // A callback function which triggers when the setting is changed
* console.log(value)
* }
* });
* ```
*/
register(namespace, key, data) {
if ( !namespace || !key ) throw new Error("You must specify both namespace and key portions of the setting");
data.key = key;
data.namespace = namespace;
data.scope = ["client", "world"].includes(data.scope) ? data.scope : "client";
if ( data.type && !(data.type instanceof Function) ) {
throw new Error(`Setting ${key} type must be a constructable object or callable function`);
}
key = `${namespace}.${key}`;
this.settings.set(key, data);
if ( data.scope === "world" ) {
// Reinitialize to cast the value of the Setting into its defined type
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 || !(data.type.prototype instanceof FormApplication) ) {
throw new Error("You must provide a menu type that is FormApplication instance or subclass");
}
this.menus.set(data.key, data);
}
/* -------------------------------------------- */
/**
* Get the value of a game setting for a certain namespace and setting key
*
* @param {string} namespace The namespace under which the setting is registered
* @param {string} key The setting key to retrieve
*
* @example Retrieve the current setting value
* ```js
* game.settings.get("myModule", "myClientSetting");
* ```
*/
get(namespace, key) {
if ( !namespace || !key ) throw new Error("You must specify both namespace and key portions of the setting");
key = `${namespace}.${key}`;
if ( !this.settings.has(key) ) throw new Error("This is not a registered game setting");
// Retrieve the setting configuration and its storage backend
const config = this.settings.get(key);
const storage = this.storage.get(config.scope);
// Get the Setting instance
let setting;
switch ( config.scope ) {
case "client":
const value = storage.getItem(key) ?? config.default;
setting = new Setting({key, value});
break;
case "world":
setting = storage.getSetting(key);
if ( !setting ) {
setting = new Setting({key, value: config.default});
}
}
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={}) {
if ( !namespace || !key ) throw new Error("You must specify both namespace and key portions of the setting");
key = `${namespace}.${key}`;
if ( !this.settings.has(key) ) throw new Error("This is not a registered game setting");
// Obtain the setting data and serialize the value
const setting = this.settings.get(key);
if ( value === undefined ) value = setting.default;
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;
}
/* -------------------------------------------- */
/**
* 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<Setting>} 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;
}
}
class SocketInterface {
/**
* Standardize the way that socket messages are dispatched and their results are handled
* @param {string} eventName The socket event name being handled
* @param {SocketRequest} request Data provided to the Socket event
* @returns {Promise<SocketResponse>} A Promise which resolves to the SocketResponse
*/
static dispatch(eventName, request) {
return new Promise((resolve, reject) => {
game.socket.emit(eventName, request, response => {
if ( response.error ) {
const err = this._handleError(response.error);
reject(err);
}
else resolve(response);
});
});
}
/* -------------------------------------------- */
/**
* Handle an error returned from the database, displaying it on screen and in the console
* @param {Error} err The provided Error message
* @private
*/
static _handleError(err) {
let error = err instanceof Error ? err : new Error(err.message);
if ( err.stack ) error.stack = err.stack;
if ( ui.notifications ) ui.notifications.error(error.message);
return error;
}
}
/**
* A collection of functions related to sorting objects within a parent container.
*/
class SortingHelpers {
/**
* Given a source object to sort, a target to sort relative to, and an Array of siblings in the container:
* Determine the updated sort keys for the source object, or all siblings if a reindex is required.
* Return an Array of updates to perform, it is up to the caller to dispatch these updates.
* Each update is structured as:
* {
* target: object,
* update: {sortKey: sortValue}
* }
*
* @param {object} source The source object being sorted
* @param {object} [options] Options which modify the sort behavior
* @param {object|null} [options.target] The target object relative which to sort
* @param {object[]} [options.siblings] The Array of siblings which the source should be sorted within
* @param {string} [options.sortKey=sort] The property name within the source object which defines the sort key
* @param {boolean} [options.sortBefore] Explicitly sort before (true) or sort after( false).
* If undefined the sort order will be automatically determined.
* @returns {object[]} An Array of updates for the caller of the helper function to perform
*/
static performIntegerSort(source, {target=null, siblings=[], sortKey="sort", sortBefore}={}) {
// Automatically determine the sorting direction
if ( sortBefore === undefined ) {
sortBefore = (source[sortKey] || 0) > (target?.[sortKey] || 0);
}
// Ensure the siblings are sorted
siblings = 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, 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<number>} 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<GameTime>}
*/
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
* <span data-tooltip="Some Tooltip" data-tooltip-direction="LEFT">I have a tooltip</span>
* <ol data-tooltip-direction="RIGHT">
* <li data-tooltip="The First One">One</li>
* <li data-tooltip="The Second One">Two</li>
* <li data-tooltip="The Third One">Three</li>
* </ol>
* ```
*/
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<HTMLElement>, 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} = event;
const buffer = this.#locked.boundingBox?.clone?.().pad(this.constructor.LOCKED_TOOLTIP_BUFFER_PX);
if ( buffer && !buffer.contains(x, 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.clamped(position.left, pad, maxW - pad);
if ( position.right ) position.right = Math.clamped(position.right, pad, maxW - pad);
// Top or Bottom
const maxH = window.innerHeight - this.tooltip.offsetHeight;
if ( position.top ) position.top = Math.clamped(position.top, pad, maxH - pad);
if ( position.bottom ) position.bottom = Math.clamped(position.bottom, pad, maxH - pad);
// Assign styles
for ( let k of ["top", "right", "bottom", "left"] ) {
const v = position[k];
style[k] = v ? `${v}px` : null;
}
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
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<Tour>}
*/
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.<String>} 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<Document|null>} Returns the Document if it could be found, otherwise null.
*/
async function fromUuid(uuid, options={}) {
/** @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 {collection, documentId, documentType, embedded, doc} = foundry.utils.parseUuid(uuid, {relative});
if ( collection instanceof CompendiumCollection ) {
if ( documentType === "Folder" ) return collection.folders.get(documentId);
doc = await collection.getDocument(documentId);
}
else doc = doc ?? collection?.get(documentId, {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={}) {
/** @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 {collection, documentId, documentType, 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.");
}
if ( collection instanceof CompendiumCollection ) {
if ( documentType === "Folder" ) return collection.folders.get(documentId);
doc = doc ?? collection.get(documentId, {invalid}) ?? collection.index.get(documentId);
if ( doc ) doc.pack = collection.collection;
} else {
doc = doc ?? collection?.get(documentId, {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 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<string,string>}
*/
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<PIXI.Texture>} 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.clamped(offset, 0, video.duration);
return video.play();
}
/* -------------------------------------------- */
/**
* Stop a single video source
* @param {HTMLElement} video The VIDEO element to stop
*/
stop(video) {
video.pause();
video.currentTime = 0;
}
/* -------------------------------------------- */
/**
* Register an event listener to await the first mousemove gesture and begin playback once observed
* A user interaction must involve a mouse click or keypress.
* Listen for any of these events, and handle the first observed gesture.
*/
awaitFirstGesture() {
if ( !this.locked ) return;
const interactions = ["contextmenu", "auxclick", "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<string>} 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<YT.Player>}
*/
async getYouTubePlayer(id, config={}) {
this.#youTubeReady ??= this.#injectYouTubeAPI();
await this.#youTubeReady;
return new Promise(resolve => new YT.Player(id, foundry.utils.mergeObject(config, {
events: {
onReady: event => resolve(event.target)
}
})));
}
/* -------------------------------------------- */
/**
* Retrieve a YouTube video ID from a URL.
* @param {string} url The URL.
* @returns {string}
*/
getYouTubeId(url) {
const [, id1, id2] = url?.match(this.#youTubeRegex) || [];
return id1 || id2 || "";
}
/* -------------------------------------------- */
/**
* Take a URL to a YouTube video and convert it into a URL suitable for embedding in a YouTube iframe.
* @param {string} url The URL to convert.
* @param {object} vars YouTube player parameters.
* @returns {string} The YouTube embed URL.
*/
getYouTubeEmbedURL(url, vars={}) {
const videoId = this.getYouTubeId(url);
if ( !videoId ) return "";
const embed = new URL(`https://www.youtube.com/embed/${videoId}`);
embed.searchParams.append("enablejsapi", "1");
Object.entries(vars).forEach(([k, v]) => embed.searchParams.append(k, v));
// To loop a video with iframe parameters, we must additionally supply the playlist parameter that points to the
// same video: https://developers.google.com/youtube/player_parameters#Parameters
if ( vars.loop ) embed.searchParams.append("playlist", videoId);
return embed.href;
}
/* -------------------------------------------- */
/**
* Test a URL to see if it points to a YouTube video.
* @param {string} url The URL to test.
* @returns {boolean}
*/
isYouTubeURL(url="") {
return this.#youTubeRegex.test(url);
}
/* -------------------------------------------- */
/**
* Inject the YouTube API into the page.
* @returns {Promise} A Promise that resolves when the API has initialized.
*/
#injectYouTubeAPI() {
const script = document.createElement("script");
script.src = "https://www.youtube.com/iframe_api";
document.head.appendChild(script);
return new Promise(resolve => {
window.onYouTubeIframeAPIReady = () => {
delete window.onYouTubeIframeAPIReady;
resolve();
};
});
}
}
/**
* @typedef {Object<string, *>} WorkerTask
* @property {number} [taskId] An incrementing task ID used to reference task progress
* @property {WorkerManager.WORKER_TASK_ACTIONS} action The task action being performed, from WorkerManager.WORKER_TASK_ACTIONS
* @property {function} [resolve] A Promise resolution handler
* @property {function} [reject] A Promise rejection handler
*/
/**
* An asynchronous web Worker which can load user-defined functions and await execution using Promises.
* @param {string} name The worker name to be initialized
* @param {object} [options={}] Worker initialization options
* @param {boolean} [options.debug=false] Should the worker run in debug mode?
* @param {boolean} [options.loadPrimitives=false] Should the worker automatically load the primitives library?
* @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));
/**
* A Promise which resolves once the Worker is ready to accept tasks
* @type {Promise}
*/
this.ready = this._dispatchTask({
action: WorkerManager.WORKER_TASK_ACTIONS.INIT,
workerName: name,
debug,
loadPrimitives,
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<number, WorkerTask>}
*/
tasks = new Map();
/**
* An auto-incrementing task index.
* @type {number}
*/
#taskIndex = 0;
/* -------------------------------------------- */
/* Task Management */
/* -------------------------------------------- */
/**
* Load a function onto a given Worker.
* The function must be a pure function with no external dependencies or requirements on global scope.
* @param {string} functionName The name of the function to load
* @param {Function} functionRef A reference to the function that should be loaded
* @returns {Promise<unknown>} 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<unknown>} A Promise which resolves with the returned result of the function once complete.
*/
async executeFunction(functionName, args=[], transfer=[]) {
if ( !Array.isArray(args) ) args = [args];
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.
* @private
*/
async _dispatchTask(taskData={}, transfer=[]) {
const taskId = taskData.taskId = this.#taskIndex++;
taskData.transfer = transfer;
return new Promise((resolve, reject) => {
this.tasks.set(taskId, {resolve, reject, ...taskData});
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<AsyncWorker>} 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 = 0;
let _maxZ = 100;
const MIN_WINDOW_WIDTH = 200;
const MIN_WINDOW_HEIGHT = 50;
/**
* @typedef {object} ApplicationOptions
* @property {string|null} [baseApplication] A named "base application" which generates an additional hook
* @property {number|null} [width] The default pixel width for the rendered HTML
* @property {number|string|null} [height] The default pixel height for the rendered HTML
* @property {number|null} [top] The default offset-top position for the rendered HTML
* @property {number|null} [left] The default offset-left position for the rendered HTML
* @property {number|null} [scale] A transformation scale for the rendered HTML
* @property {boolean} [popOut] Whether to display the application as a pop-out container
* @property {boolean} [minimizable] Whether the rendered application can be minimized (popOut only)
* @property {boolean} [resizable] Whether the rendered application can be drag-resized (popOut only)
* @property {string} [id] The default CSS id to assign to the rendered HTML
* @property {string[]} [classes] An array of CSS string classes to apply to the rendered HTML
* @property {string} [title] A default window title string (popOut only)
* @property {string|null} [template] The default HTML template path to render for this Application
* @property {string[]} [scrollY] A list of unique CSS selectors which target containers that should have their
* vertical scroll positions preserved during a re-render.
* @property {TabsConfiguration[]} [tabs] An array of tabbed container configurations which should be enabled for the
* application.
* @property {DragDropConfiguration[]} dragDrop An array of CSS selectors for configuring the application's
* {@link DragDrop} behaviour.
* @property {SearchFilterConfiguration[]} filters An array of {@link SearchFilter} configuration objects.
*/
/**
* The standard application window that is rendered for a large variety of UI elements in Foundry VTT.
* @abstract
* @param {ApplicationOptions} [options] Configuration options which control how the application is rendered.
* Application subclasses may add additional supported options, but these base
* configurations are supported for all Applications. The values passed to the
* constructor are combined with the defaultOptions defined at the class level.
*/
class Application {
constructor(options={}) {
/**
* The options provided to this application upon initialization
* @type {object}
*/
this.options = foundry.utils.mergeObject(this.constructor.defaultOptions, options, {
insertKeys: true,
insertValues: true,
overwrite: true,
inplace: false
});
/**
* The application ID is a unique incrementing integer which is used to identify every application window
* drawn by the VTT
* @type {number}
*/
this.appId = _appId += 1;
/**
* An internal reference to the HTML element this application renders
* @type {jQuery}
*/
this._element = null;
/**
* Track the current position and dimensions of the Application UI
* @type {object}
*/
this.position = {
width: this.options.width,
height: this.options.height,
left: this.options.left,
top: this.options.top,
scale: this.options.scale,
zIndex: 0
};
/**
* DragDrop workflow handlers which are active for this Application
* @type {DragDrop[]}
*/
this._dragDrop = this._createDragDropHandlers();
/**
* Tab navigation handlers which are active for this Application
* @type {Tabs[]}
*/
this._tabs = this._createTabHandlers();
/**
* SearchFilter handlers which are active for this Application
* @type {SearchFilter[]}
*/
this._searchFilters = this._createSearchFilters();
/**
* Track whether the Application is currently minimized
* @type {boolean|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 sequence of rendering states that track the Application life-cycle.
* @enum {number}
*/
static RENDER_STATES = Object.freeze({
CLOSING: -2,
CLOSED: -1,
NONE: 0,
RENDERING: 1,
RENDERED: 2,
ERROR: 3
});
/* -------------------------------------------- */
/**
* Create drag-and-drop workflow handlers for this Application
* @returns {DragDrop[]} An array of DragDrop handlers
* @private
*/
_createDragDropHandlers() {
return this.options.dragDrop.map(d => {
d.permissions = {
dragstart: this._canDragStart.bind(this),
drop: this._canDragDrop.bind(this)
};
d.callbacks = {
dragstart: this._onDragStart.bind(this),
dragover: this._onDragOver.bind(this),
drop: this._onDrop.bind(this)
};
return new DragDrop(d);
});
}
/* -------------------------------------------- */
/**
* Create tabbed navigation handlers for this Application
* @returns {Tabs[]} An array of Tabs handlers
* @private
*/
_createTabHandlers() {
return this.options.tabs.map(t => {
t.callback = this._onChangeTab.bind(this);
return new Tabs(t);
});
}
/* -------------------------------------------- */
/**
* Create search filter handlers for this Application
* @returns {SearchFilter[]} An array of SearchFilter handlers
* @private
*/
_createSearchFilters() {
return this.options.filters.map(f => {
f.callback = this._onSearchFilter.bind(this);
return new SearchFilter(f);
});
}
/* -------------------------------------------- */
/**
* Assign the default options configuration which is used by this Application class. The options and values defined
* in this object are merged with any provided option values which are passed to the constructor upon initialization.
* Application subclasses may include additional options which are specific to their usage.
* @returns {ApplicationOptions}
*/
static get defaultOptions() {
return {
baseApplication: null,
width: null,
height: null,
top: null,
left: null,
scale: null,
popOut: true,
minimizable: true,
resizable: false,
id: "",
classes: [],
dragDrop: [],
tabs: [],
filters: [],
title: "",
template: null,
scrollY: []
};
}
/* -------------------------------------------- */
/**
* Return the CSS application ID which uniquely references this UI element
* @type {string}
*/
get id() {
return this.options.id ? this.options.id : `app-${this.appId}`;
}
/* -------------------------------------------- */
/**
* Return the active application element, if it currently exists in the DOM
* @type {jQuery}
*/
get element() {
if ( this._element ) return this._element;
let selector = `#${this.id}`;
return $(selector);
}
/* -------------------------------------------- */
/**
* The path to the HTML template file which should be used to render the inner content of the app
* @type {string}
*/
get template() {
return this.options.template;
}
/* -------------------------------------------- */
/**
* Control the rendering style of the application. If popOut is true, the application is rendered in its own
* wrapper window, otherwise only the inner app content is rendered
* @type {boolean}
*/
get popOut() {
return this.options.popOut ?? true;
}
/* -------------------------------------------- */
/**
* Return a flag for whether the Application instance is currently rendered
* @type {boolean}
*/
get rendered() {
return this._state === Application.RENDER_STATES.RENDERED;
}
/* -------------------------------------------- */
/**
* 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<object>}
*/
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<void>} 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;
const data = await this.getData(this.options);
// Store scroll positions
if ( element.length && this.options.scrollY ) this._saveScrollPositions(element);
// Render the inner content
const inner = await this._renderInner(data);
let html = inner;
// If the application already exists in the DOM, replace the inner content
if ( element.length ) this._replaceHTML(element, html);
// Otherwise render a new app
else {
// Wrap a popOut application in an outer frame
if ( this.popOut ) {
html = await this._renderOuter();
html.find(".window-content").append(inner);
ui.windows[this.appId] = this;
}
// Add the HTML to the DOM and record the element
this._injectHTML(html);
}
if ( !this.popOut && this.options.resizable ) new Draggable(this, html, false, this.options.resizable);
// Activate event listeners on the inner HTML
this._activateCoreListeners(inner);
this.activateListeners(inner);
// Set the application position (if it's not currently minimized)
if ( !this._minimized ) {
foundry.utils.mergeObject(this.position, options, {insertKeys: false});
this.setPosition(this.position);
}
// Apply focus to the application, maximizing it and bringing it to the top
if ( this.popOut && (options.focus === true) ) this.maximize().then(() => this.bringToTop());
// Dispatch Hooks for rendering the base and subclass applications
for ( let cls of this.constructor._getInheritanceChain() ) {
Hooks.callAll(`render${cls.name}`, this, html, data);
}
// Restore prior scroll positions
if ( this.options.scrollY ) this._restoreScrollPositions(html);
this._state = states.RENDERED;
}
/* -------------------------------------------- */
/**
* Return the inheritance chain for this Application class up to (and including) it's base Application class.
* @returns {Function[]}
* @private
*/
static _getInheritanceChain() {
const parents = foundry.utils.getParentClasses(this);
const base = this.defaultOptions.baseApplication;
const chain = [this];
for ( let cls of parents ) {
chain.push(cls);
if ( cls.name === base ) break;
}
return chain;
}
/* -------------------------------------------- */
/**
* Persist the scroll positions of containers within the app before re-rendering the content
* @param {jQuery} html The HTML object being traversed
* @protected
*/
_saveScrollPositions(html) {
const selectors = this.options.scrollY || [];
this._scrollPositions = selectors.reduce((pos, sel) => {
const el = html.find(sel);
pos[sel] = Array.from(el).map(el => el.scrollTop);
return pos;
}, {});
}
/* -------------------------------------------- */
/**
* Restore the scroll positions of containers within the app after re-rendering the content
* @param {jQuery} html The HTML object being traversed
* @protected
*/
_restoreScrollPositions(html) {
const selectors = this.options.scrollY || [];
const positions = this._scrollPositions || {};
for ( let sel of selectors ) {
const el = html.find(sel);
el.each((i, el) => el.scrollTop = positions[sel]?.[i] || 0);
}
}
/* -------------------------------------------- */
/**
* Render the outer application wrapper
* @returns {Promise<jQuery>} A promise resolving to the constructed jQuery object
* @protected
*/
async _renderOuter() {
// Gather basic application data
const classes = this.options.classes;
const windowData = {
id: this.id,
classes: classes.join(" "),
appId: this.appId,
title: this.title,
headerButtons: this._getHeaderButtons()
};
// Render the template and return the promise
let html = await renderTemplate("templates/app-window.html", windowData);
html = $(html);
// Activate header button click listeners after a slight timeout to prevent immediate interaction
setTimeout(() => {
html.find(".header-button").click(event => {
event.preventDefault();
const button = windowData.headerButtons.find(b => event.currentTarget.classList.contains(b.class));
button.onclick(event);
});
}, 500);
// Make the outer window draggable
const header = html.find("header")[0];
new Draggable(this, html, header, this.options.resizable);
// Make the outer window minimizable
if ( this.options.minimizable ) {
header.addEventListener("dblclick", this._onToggleMinimize.bind(this));
}
// Set the outer frame z-index
if ( Object.keys(ui.windows).length === 0 ) _maxZ = 100 - 1;
this.position.zIndex = Math.min(++_maxZ, 9999);
html.css({zIndex: this.position.zIndex});
ui.activeWindow = this;
// Return the outer frame
return html;
}
/* -------------------------------------------- */
/**
* Render the inner application content
* @param {object} data The data used to render the inner template
* @returns {Promise<jQuery>} 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()
}
];
for ( let cls of this.constructor._getInheritanceChain() ) {
Hooks.call(`get${cls.name}HeaderButtons`, this, buttons);
}
return buttons;
}
/* -------------------------------------------- */
/**
* Create a {@link ContextMenu} for this Application.
* @param {jQuery} html The Application's HTML.
* @private
*/
_contextMenu(html) {}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/**
* Activate required listeners which must be enabled on every Application.
* These are internal interactions which should not be overridden by downstream subclasses.
* @param {jQuery} html
* @protected
*/
_activateCoreListeners(html) {
const 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() {
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<void>} A Promise which resolves once the application is closed
*/
async close(options={}) {
const states = Application.RENDER_STATES;
if ( !options.force && ![states.RENDERED, states.ERROR].includes(this._state) ) return;
this._state = states.CLOSING;
// Get the element
let el = this.element;
if ( !el ) return this._state = states.CLOSED;
el.css({minHeight: 0});
// Dispatch Hooks for closing the base and subclass applications
for ( let cls of this.constructor._getInheritanceChain() ) {
Hooks.call(`close${cls.name}`, this, el);
}
// Animate closing the element
return new Promise(resolve => {
el.slideUp(200, () => {
el.remove();
// Clean up data
this._element = null;
delete ui.windows[this.appId];
this._minimized = false;
this._scrollPositions = null;
this._state = states.CLOSED;
resolve();
});
});
}
/* -------------------------------------------- */
/**
* Minimize the pop-out window, collapsing it to a small tab
* Take no action for applications which are not of the pop-out variety or apps which are already minimized
* @returns {Promise<void>} 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<void>} 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.clamped(tarW, minW, maxW);
el.style.width = `${width}px`;
if ( ((width * scale) + currentPosition.left) > window.innerWidth ) left = currentPosition.left;
}
width = el.offsetWidth;
// Update height if an explicit value is passed, or if no height value is set on the element
if ( !el.style.height || height ) {
const tarH = height || (el.offsetHeight + 1);
const minH = parseInt(styles.minHeight) || (pop ? MIN_WINDOW_HEIGHT : 0);
const maxH = el.style.maxHeight || (window.innerHeight / scale);
currentPosition.height = height = Math.clamped(tarH, minH, maxH);
el.style.height = `${height}px`;
if ( ((height * scale) + currentPosition.top) > window.innerHeight + 1 ) top = currentPosition.top - 1;
}
height = el.offsetHeight;
// Update Left
if ( (pop && !el.style.left) || Number.isFinite(left) ) {
const scaledWidth = width * scale;
const tarL = Number.isFinite(left) ? left : (window.innerWidth - scaledWidth) / 2;
const maxL = Math.max(window.innerWidth - scaledWidth, 0);
currentPosition.left = left = Math.clamped(tarL, 0, maxL);
el.style.left = `${left}px`;
}
// Update Top
if ( (pop && !el.style.top) || Number.isFinite(top) ) {
const scaledHeight = height * scale;
const tarT = Number.isFinite(top) ? top : (window.innerHeight - scaledHeight) / 2;
const maxT = Math.max(window.innerHeight - scaledHeight, 0);
currentPosition.top = Math.clamped(tarT, 0, maxT);
el.style.top = `${currentPosition.top}px`;
}
// Update Scale
if ( scale ) {
currentPosition.scale = Math.max(scale, 0);
if ( scale === 1 ) el.style.transform = "";
else el.style.transform = `scale(${scale})`;
}
// Return the updated position object
return currentPosition;
}
/* -------------------------------------------- */
/**
* Handle application minimization behavior - collapsing content and reducing the size of the header
* @param {Event} ev
* @private
*/
_onToggleMinimize(ev) {
ev.preventDefault();
if ( this._minimized ) this.maximize(ev);
else this.minimize(ev);
}
/* -------------------------------------------- */
/**
* Additional actions to take when the application window is resized
* @param {Event} event
* @private
*/
_onResize(event) {}
/* -------------------------------------------- */
/**
* Wait for any images present in the Application to load.
* @returns {Promise<void>} 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 FilePicker instances which are associated with this form
* The values of this Array are inner-objects with references to the FilePicker instances and other metadata
* @type {FilePicker[]}
*/
this.filepickers = [];
/**
* Keep track of any mce editors which may be active as part of this form
* The values of this object are inner-objects with references to the MCE editor and other metadata
* @type {Object<string, object>}
*/
this.editors = {};
}
/* -------------------------------------------- */
/**
* Assign the default options which are supported by the document edit sheet.
* In addition to the default options object supported by the parent Application class, the Form Application
* supports the following additional keys and values:
*
* @returns {FormApplicationOptions} The default options for this FormApplication class
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["form"],
closeOnSubmit: true,
editable: true,
sheetConfig: false,
submitOnChange: false,
submitOnClose: false
});
}
/* -------------------------------------------- */
/**
* Is the Form Application currently editable?
* @type {boolean}
*/
get isEditable() {
return this.options.editable;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* @inheritdoc
* @returns {object|Promise<object>}
*/
getData(options={}) {
return {
object: this.object,
options: this.options,
title: this.title
};
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
// Identify the focused element
let focus = this.element.find(":focus");
focus = focus.length ? focus[0] : null;
// Render the application and restore focus
await super._render(force, options);
if ( focus && focus.name ) {
const input = this.form?.[focus.name];
if ( input && (input.focus instanceof Function) ) input.focus();
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _renderInner(...args) {
const html = await super._renderInner(...args);
this.form = html.filter((i, el) => el instanceof HTMLFormElement)[0];
if ( !this.form ) this.form = html.find("form")[0];
return html;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
_activateCoreListeners(html) {
super._activateCoreListeners(html);
if ( !this.form ) return;
if ( !this.isEditable ) {
return this._disableFields(this.form);
}
this.form.onsubmit = this._onSubmit.bind(this);
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
if ( !this.isEditable ) return;
html.on("change", "input,select,textarea", this._onChangeInput.bind(this));
html.find(".editor-content[data-edit]").each((i, div) => this._activateEditor(div));
for ( let fp of html.find("button.file-picker") ) {
fp.onclick = this._activateFilePicker.bind(this);
}
if ( this._priorState <= this.constructor.RENDER_STATES.NONE ) html.find("[autofocus]")[0]?.focus();
}
/* -------------------------------------------- */
/**
* If the form is not editable, disable its input fields
* @param {HTMLElement} form The form HTML
* @protected
*/
_disableFields(form) {
const inputs = ["INPUT", "SELECT", "TEXTAREA", "BUTTON"];
for ( let i of inputs ) {
for ( let el of form.getElementsByTagName(i) ) {
if ( i === "TEXTAREA" ) el.readOnly = true;
else el.disabled = true;
}
}
}
/* -------------------------------------------- */
/**
* Handle standard form submission steps
* @param {Event} event The submit event which triggered this handler
* @param {object | null} [updateData] Additional specific data keys/values which override or extend the contents of
* the parsed form. This can be used to update other flags or data fields at the
* same time as processing a form submission to avoid multiple database operations.
* @param {boolean} [preventClose] Override the standard behavior of whether to close the form on submit
* @param {boolean} [preventRender] Prevent the application from re-rendering as a result of form submission
* @returns {Promise} A promise which resolves to the validated update data
* @protected
*/
async _onSubmit(event, {updateData=null, preventClose=false, preventRender=false}={}) {
event.preventDefault();
// Prevent double submission
const states = this.constructor.RENDER_STATES;
if ( (this._state === states.NONE) || !this.isEditable || this._submitting ) return false;
this._submitting = true;
// Process the form data
const formData = this._getSubmitData(updateData);
// Handle the form state prior to submission
let closeForm = this.options.closeOnSubmit && !preventClose;
const priorState = this._state;
if ( preventRender ) this._state = states.RENDERING;
if ( closeForm ) this._state = states.CLOSING;
// Trigger the object update
try {
await this._updateObject(event, formData);
}
catch(err) {
console.error(err);
closeForm = false;
this._state = priorState;
}
// Restore flags and optionally close the form
this._submitting = false;
if ( preventRender ) this._state = priorState;
if ( closeForm ) await this.close({submit: false, force: true});
return formData;
}
/* -------------------------------------------- */
/**
* Get an object of update data used to update the form's target object
* @param {object} updateData Additional data that should be merged with the form data
* @returns {object} The prepared update data
* @protected
*/
_getSubmitData(updateData={}) {
if ( !this.form ) throw new Error("The FormApplication subclass has no registered form element");
const fd = new FormDataExtended(this.form, {editors: this.editors});
let data = fd.object;
if ( updateData ) data = foundry.utils.flattenObject(foundry.utils.mergeObject(data, updateData));
return data;
}
/* -------------------------------------------- */
/**
* Handle changes to an input element, submitting the form if options.submitOnChange is true.
* Do not preventDefault in this handler as other interactions on the form may also be occurring.
* @param {Event} event The initial change event
* @protected
*/
async _onChangeInput(event) {
// Do not fire change listeners for form inputs inside text editors.
if ( event.currentTarget.closest(".editor") ) return;
// Handle changes to specific input types
const el = event.target;
if ( (el.type === "color") && el.dataset.edit ) this._onChangeColorPicker(event);
else if ( el.type === "range" ) this._onChangeRange(event);
// Maybe submit the form
if ( this.options.submitOnChange ) {
return this._onSubmit(event);
}
}
/* -------------------------------------------- */
/**
* Handle the change of a color picker input which enters it's chosen value into a related input field
* @param {Event} event The color picker change event
* @protected
*/
_onChangeColorPicker(event) {
const input = event.target;
const form = input.form;
form[input.dataset.edit].value = input.value;
}
/* -------------------------------------------- */
/**
* Handle changes to a range type input by propagating those changes to the sibling range-value element
* @param {Event} event The initial change event
* @protected
*/
_onChangeRange(event) {
const field = event.target.parentElement.querySelector(".range-value");
if ( field ) {
if ( field.tagName === "INPUT" ) field.value = event.target.value;
else field.innerHTML = event.target.value;
}
}
/* -------------------------------------------- */
/**
* Additional handling which should trigger when a FilePicker contained within this FormApplication is submitted.
* @param {string} selection The target path which was selected
* @param {FilePicker} filePicker The FilePicker instance which was submitted
* @protected
*/
_onSelectFile(selection, filePicker) {}
/* -------------------------------------------- */
/**
* This method is called upon form submission after form data is validated
* @param {Event} event The initial triggering submission event
* @param {object} formData The object of validated form data with which to update the object
* @returns {Promise} A Promise which resolves once the update operation has completed
* @abstract
*/
async _updateObject(event, formData) {
throw new Error("A subclass of the FormApplication must implement the _updateObject method.");
}
/* -------------------------------------------- */
/* TinyMCE Editor */
/* -------------------------------------------- */
/**
* Activate a named TinyMCE text editor
* @param {string} name The named data field which the editor modifies.
* @param {object} options Editor initialization options passed to {@link TextEditor.create}.
* @param {string} initialContent Initial text content for the editor area.
* @returns {Promise<TinyMCE.Editor|ProseMirror.EditorView>}
*/
async activateEditor(name, options={}, initialContent="") {
const editor = this.editors[name];
if ( !editor ) throw new Error(`${name} is not a registered editor name!`);
options = foundry.utils.mergeObject(editor.options, options);
if ( !options.fitToSize ) options.height = options.target.offsetHeight;
if ( editor.hasButton ) editor.button.style.display = "none";
const instance = editor.instance = editor.mce = await TextEditor.create(options, initialContent || editor.initial);
options.target.closest(".editor")?.classList.add(options.engine ?? "tinymce");
editor.changed = false;
editor.active = true;
/** @deprecated since v10 */
if ( options.engine !== "prosemirror" ) {
instance.focus();
instance.on("change", () => editor.changed = true);
}
return instance;
}
/* -------------------------------------------- */
/**
* Handle saving the content of a specific editor by name
* @param {string} name The named editor to save
* @param {boolean} [remove] Remove the editor after saving its content
* @returns {Promise<void>}
*/
async saveEditor(name, {remove=true}={}) {
const editor = this.editors[name];
if ( !editor || !editor.instance ) throw new Error(`${name} is not an active editor name!`);
editor.active = false;
const instance = editor.instance;
await this._onSubmit(new Event("submit"));
// Remove the editor
if ( remove ) {
instance.destroy();
editor.instance = editor.mce = null;
if ( editor.hasButton ) editor.button.style.display = "block";
this.render();
}
editor.changed = false;
}
/* -------------------------------------------- */
/**
* Activate an editor instance present within the form
* @param {HTMLElement} div The element which contains the editor
* @protected
*/
_activateEditor(div) {
// Get the editor content div
const name = div.dataset.edit;
const engine = div.dataset.engine || "tinymce";
const collaborate = div.dataset.collaborate === "true";
const button = div.previousElementSibling;
const hasButton = button && button.classList.contains("editor-edit");
const wrap = div.parentElement.parentElement;
const wc = div.closest(".window-content");
// Determine the preferred editor height
const heights = [wrap.offsetHeight, wc ? wc.offsetHeight : null];
if ( div.offsetHeight > 0 ) heights.push(div.offsetHeight);
const height = Math.min(...heights.filter(h => Number.isFinite(h)));
// Get initial content
const options = {
target: div,
fieldName: name,
save_onsavecallback: () => this.saveEditor(name),
height, engine, collaborate
};
if ( engine === "prosemirror" ) options.plugins = this._configureProseMirrorPlugins(name, {remove: hasButton});
/**
* Handle legacy data references.
* @deprecated since v10
*/
const isDocument = this.object instanceof foundry.abstract.Document;
const data = (name?.startsWith("data.") && isDocument) ? this.object.data : this.object;
// Define the editor configuration
const editor = this.editors[name] = {
options,
target: name,
button: button,
hasButton: hasButton,
mce: null,
instance: null,
active: !hasButton,
changed: false,
initial: foundry.utils.getProperty(data, name)
};
// Activate the editor immediately, or upon button click
const activate = () => {
editor.initial = foundry.utils.getProperty(data, name);
this.activateEditor(name, {}, editor.initial);
};
if ( hasButton ) button.onclick = activate;
else activate();
}
/* -------------------------------------------- */
/**
* Configure ProseMirror plugins for this sheet.
* @param {string} name The name of the editor.
* @param {object} [options] Additional options to configure the plugins.
* @param {boolean} [options.remove=true] Whether the editor should destroy itself on save.
* @returns {object}
* @protected
*/
_configureProseMirrorPlugins(name, {remove=true}={}) {
return {
menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
destroyOnSave: remove,
onSave: () => this.saveEditor(name, {remove})
}),
keyMaps: ProseMirror.ProseMirrorKeyMaps.build(ProseMirror.defaultSchema, {
onSave: () => this.saveEditor(name, {remove})
})
};
}
/* -------------------------------------------- */
/* FilePicker UI
/* -------------------------------------------- */
/**
* Activate a FilePicker instance present within the form
* @param {PointerEvent} event The mouse click event on a file picker activation button
* @protected
*/
_activateFilePicker(event) {
event.preventDefault();
const options = this._getFilePickerOptions(event);
const fp = new FilePicker(options);
this.filepickers.push(fp);
return fp.browse();
}
/* -------------------------------------------- */
/**
* Determine the configuration options used to initialize a FilePicker instance within this FormApplication.
* Subclasses can extend this method to customize the behavior of pickers within their form.
* @param {PointerEvent} event The initiating mouse click event which opens the picker
* @returns {object} Options passed to the FilePicker constructor
* @protected
*/
_getFilePickerOptions(event) {
const button = event.currentTarget;
const target = button.dataset.target;
const field = button.form[target] || null;
return {
field: field,
type: button.dataset.type,
current: field?.value ?? "",
button: button,
callback: this._onSelectFile.bind(this)
};
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
const states = Application.RENDER_STATES;
if ( !options.force && ![states.RENDERED, states.ERROR].includes(this._state) ) return;
// Trigger saving of the form
const submit = options.submit ?? this.options.submitOnClose;
if ( submit ) await this.submit({preventClose: true, preventRender: true});
// Close any open FilePicker instances
for ( let fp of this.filepickers ) {
fp.close();
}
this.filepickers = [];
// Close any open MCE editors
for ( let ed of Object.values(this.editors) ) {
if ( ed.mce ) ed.mce.destroy();
}
this.editors = {};
// Close the application itself
return super.close(options);
}
/* -------------------------------------------- */
/**
* Submit the contents of a Form Application, processing its content as defined by the Application
* @param {object} [options] Options passed to the _onSubmit event handler
* @returns {FormApplication} Return a self-reference for convenient method chaining
*/
async submit(options={}) {
if ( this._submitting ) return;
const submitEvent = new Event("submit");
await this._onSubmit(submitEvent, options);
return this;
}
}
/* -------------------------------------------- */
/**
* @typedef {FormApplicationOptions} DocumentSheetOptions
* @property {number} viewPermission The default permissions required to view this Document sheet.
* @property {HTMLSecretConfiguration[]} [secrets] An array of {@link HTMLSecret} configuration objects.
*/
/**
* Extend the FormApplication pattern to incorporate specific logic for viewing or editing Document instances.
* See the FormApplication documentation for more complete description of this interface.
*
* @extends {FormApplication}
* @abstract
* @interface
*/
class DocumentSheet extends FormApplication {
/**
* @param {Document} object A Document instance which should be managed by this form.
* @param {DocumentSheetOptions} [options={}] Optional configuration parameters for how the form behaves.
*/
constructor(object, options={}) {
super(object, options);
this._secrets = this._createSecretHandlers();
}
/* -------------------------------------------- */
/**
* The list of handlers for secret block functionality.
* @type {HTMLSecret[]}
* @protected
*/
_secrets = [];
/* -------------------------------------------- */
/**
* @override
* @returns {DocumentSheetOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet"],
template: `templates/sheets/${this.name.toLowerCase()}.html`,
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED,
sheetConfig: true,
secrets: []
});
}
/* -------------------------------------------- */
/**
* A semantic convenience reference to the Document instance which is the target object for this form.
* @type {ClientDocument}
*/
get document() {
return this.object;
}
/* -------------------------------------------- */
/** @inheritdoc */
get id() {
return `${this.constructor.name}-${this.document.uuid.replace(/\./g, "-")}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
get isEditable() {
let editable = this.options.editable && this.document.isOwner;
if ( this.document.pack ) {
const pack = game.packs.get(this.document.pack);
if ( pack.locked ) editable = false;
}
return editable;
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
const reference = this.document.name ? ` ${this.document.name}` : "";
return `${game.i18n.localize(this.document.constructor.metadata.label)}${reference}`;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
await super.close(options);
delete this.object.apps?.[this.appId];
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const data = this.document.toObject(false);
const isEditable = this.isEditable;
return {
cssClass: isEditable ? "editable" : "locked",
editable: isEditable,
document: this.document,
data: data,
limited: this.document.limited,
options: this.options,
owner: this.document.isOwner,
title: this.title
};
}
/* -------------------------------------------- */
/** @inheritdoc */
_activateCoreListeners(html) {
super._activateCoreListeners(html);
if ( this.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 */
render(force=false, options={}) {
if ( !this._canUserView(game.user) ) {
if ( !force ) return this; // If rendering is not being forced, fail silently
const err = game.i18n.format("SHEETS.DocumentSheetPrivate", {
type: game.i18n.localize(this.object.constructor.metadata.label)
});
ui.notifications.warn(err);
return this;
}
// Update editable permission
options.editable = options.editable ?? this.object.isOwner;
// Register the active Application with the referenced Documents
this.object.apps[this.appId] = this;
return super.render(force, options);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _renderOuter() {
const html = await super._renderOuter();
this._createDocumentIdLink(html);
return html;
}
/* -------------------------------------------- */
/**
* Create an ID link button in the document sheet header which displays the document ID and copies to clipboard
* @param {jQuery} html
* @protected
*/
_createDocumentIdLink(html) {
if ( !(this.object instanceof foundry.abstract.Document) || !this.object.id ) return;
const title = html.find(".window-title");
const label = game.i18n.localize(this.object.constructor.metadata.label);
const idLink = document.createElement("a");
idLink.classList.add("document-id-link");
idLink.setAttribute("alt", "Copy document id");
idLink.dataset.tooltip = `${label}: ${this.object.id}`;
idLink.dataset.tooltipDirection = "UP";
idLink.innerHTML = '<i class="fa-solid fa-passport"></i>';
idLink.addEventListener("click", event => {
event.preventDefault();
game.clipboard.copyPlainText(this.object.id);
ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type: "id", id: this.object.id}));
});
idLink.addEventListener("contextmenu", event => {
event.preventDefault();
game.clipboard.copyPlainText(this.object.uuid);
ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type: "uuid", id: this.object.uuid}));
});
title.append(idLink);
}
/* -------------------------------------------- */
/**
* Test whether a certain User has permission to view this Document Sheet.
* @param {User} user The user requesting to render the sheet
* @returns {boolean} Does the User have permission to view this sheet?
* @protected
*/
_canUserView(user) {
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<ClientDocument>} 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 {Object<Intl.ListFormat>}
*/
#formatters = {};
/* -------------------------------------------- */
/**
* Initialize the Localization module
* Discover available language translations and apply the current language setting
* @returns {Promise<void>} A Promise which resolves once languages are initialized
*/
async initialize() {
const clientLanguage = await game.settings.get("core", "language") || this.lang;
// Discover which modules available to the client
this._discoverSupportedLanguages();
// Activate the configured language
if ( clientLanguage !== this.lang ) this.defaultModule = "core";
await this.setLanguage(clientLanguage || this.lang);
// Define type labels
if ( game.system ) {
for ( let [documentName, types] of Object.entries(game.documentTypes) ) {
const config = CONFIG[documentName];
config.typeLabels = config.typeLabels || {};
for ( let t of types ) {
if ( config.typeLabels[t] ) continue;
const key = `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;
}
}
}
}
Hooks.callAll("i18nInit");
}
/* -------------------------------------------- */
/**
* Set a language as the active translation source for the session
* @param {string} lang A language string in CONFIG.supportedLanguages
* @returns {Promise<void>} A Promise which resolves once the translations for the requested language are ready
*/
async setLanguage(lang) {
if ( !Object.keys(CONFIG.supportedLanguages).includes(lang) ) {
console.error(`Cannot set language ${lang}, as it is not in the supported set. Falling back to English`);
lang = "en";
}
this.lang = lang;
document.documentElement.setAttribute("lang", this.lang);
// Load translations and English fallback strings
this.translations = await this._getTranslations(lang);
if ( lang !== "en" ) this._fallback = await this._getTranslations("en");
}
/* -------------------------------------------- */
/**
* Discover the available supported languages from the set of packages which are provided
* @returns {object} The resulting configuration of supported languages
* @private
*/
_discoverSupportedLanguages() {
const sl = CONFIG.supportedLanguages;
// Define packages
const packages = Array.from(game.modules.values());
if ( game.world ) packages.push(game.world);
if ( game.system ) packages.push(game.system);
if ( game.worlds ) packages.push(...game.worlds.values());
if ( game.systems ) packages.push(...game.systems.values());
// Registration function
const register = pkg => {
if ( !pkg.languages.size ) return;
for ( let l of pkg.languages ) {
if ( !sl.hasOwnProperty(l.lang) ) sl[l.lang] = l.name;
}
};
// Register core translation languages first
for ( let m of game.modules ) {
if ( m.coreTranslation ) register(m);
}
// Discover and register languages
for ( let p of packages ) {
if ( p.coreTranslation ) continue;
register(p);
}
return sl;
}
/* -------------------------------------------- */
/**
* Prepare the dictionary of translation strings for the requested language
* @param {string} lang The language for which to load translations
* @returns {Promise<object>} 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<object>} 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"}} <!-- Hello, this is my module! -->
* ```
*/
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"}} <!-- Hello, this is my module! -->
* ```
*/
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 */
/* -------------------------------------------- */
// Global template cache
_templateCache = {};
/**
* Get a template from the server by fetch request and caching the retrieved result
* @param {string} path The web-accessible HTML template URL
* @param {string} [id] An ID to register the partial with.
* @returns {Promise<Function>} A Promise which resolves to the compiled Handlebars template
*/
async function getTemplate(path, id) {
if ( !_templateCache.hasOwnProperty(path) ) {
await new Promise((resolve, reject) => {
game.socket.emit("template", path, resp => {
if ( resp.error ) return reject(new Error(resp.error));
const compiled = Handlebars.compile(resp.html);
Handlebars.registerPartial(id ?? path, compiled);
_templateCache[path] = compiled;
console.log(`Foundry VTT | Retrieved and compiled template ${path}`);
resolve(compiled);
});
});
}
return _templateCache[path];
}
/* -------------------------------------------- */
/**
* Load and cache a set of templates by providing an Array of paths
* @param {string[]|Object<string>} paths An array of template file paths to load, or an object of Handlebars partial
* IDs to paths.
* @returns {Promise<Function[]>}
*
* @example Loading a list of templates.
* ```js
* await loadTemplates(["templates/apps/foo.html", "templates/apps/bar.html"]);
* ```
* ```hbs
* <!-- Include a pre-loaded template as a partial -->
* {{> "templates/apps/foo.html" }}
* ```
*
* @example Loading an object of templates.
* ```js
* await loadTemplates({
* foo: "templates/apps/foo.html",
* bar: "templates/apps/bar.html"
* });
* ```
* ```hbs
* <!-- Include a pre-loaded template as a partial -->
* {{> 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<string>} 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
* <label>My Checkbox</label>
* <input type="checkbox" name="myCheckbox" {{checked myCheckbox}}>
* ```
*/
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
* <button type="submit" {{disabled myValue}}>Submit</button>
* ```
*/
static disabled(value) {
return value ? "disabled" : "";
}
/* -------------------------------------------- */
/**
* Concatenate a number of string terms into a single string.
* This is useful for passing arguments with variable names.
* @param {string[]} values The values to concatenate
* @returns {Handlebars.SafeString}
*
* @example Concatenate several string parts to create a dynamic variable
* ```hbs
* {{filePicker target=(concat "faces." i ".img") type="image"}}
* ```
*/
static concat(...values) {
const options = values.pop();
const join = options.hash?.join || "";
return new Handlebars.SafeString(values.join(join));
}
/* -------------------------------------------- */
/**
* Render a pair of inputs for selecting a color.
* @param {object} options Helper options
* @param {string} [options.name] The name of the field to create
* @param {string} [options.value] The current color value
* @param {string} [options.default] A default color string if a value is not provided
* @returns {Handlebars.SafeString}
*
* @example
* ```hbs
* {{colorPicker name="myColor" value=myColor default="#000000"}}
* ```
*/
static colorPicker(options) {
let {name, value} = options.hash;
name = name || "color";
value = value || "";
const safeValue = Color.from(value || options.hash.default || "#000000").css;
const html =
`<input class="color" type="text" name="${name}" value="${value}"/>
<input type="color" value="${safeValue}" data-edit="${name}"/>`;
return new Handlebars.SafeString(html);
}
/* -------------------------------------------- */
/**
* @typedef {object} TextEditorOptions
* @property {string} [target] The named target data element
* @property {boolean} [button] Include a button used to activate the editor later?
* @property {string} [class] A specific CSS class to add to the editor container
* @property {boolean} [editable=true] Is the text editor area currently editable?
* @property {string} [engine=tinymce] The editor engine to use, see {@link TextEditor.create}.
* @property {boolean} [collaborate=false] Whether to turn on collaborative editing features for ProseMirror.
*
* The below options are deprecated since v10 and should be avoided.
* @property {boolean} [owner] Is the current user an owner of the data?
* @property {boolean} [documents=true] Replace dynamic document links?
* @property {Object|Function} [rollData] The data object providing context for inline rolls
* @property {string} [content=""] The original HTML content as a string
*/
/**
* Construct an editor element for rich text editing with TinyMCE or ProseMirror.
* @param {[string, TextEditorOptions]} args The content to display and edit, followed by handlebars options.
* @returns {Handlebars.SafeString}
*
* @example
* ```hbs
* {{editor world.description target="description" button=false engine="prosemirror" collaborate=false}}
* ```
*/
static editor(...args) {
const options = args.pop();
let content = args.pop() ?? "";
const target = options.hash.target;
if ( !target ) throw new Error("You must define the name of a target field.");
const button = Boolean(options.hash.button);
const editable = "editable" in options.hash ? Boolean(options.hash.editable) : true;
/**
* @deprecated since v10
*/
if ( "content" in options.hash ) {
foundry.utils.logCompatibilityWarning("The content option for the editor handlebars helper has been deprecated. "
+ "Please pass the content in as the first option to the helper and ensure it has already been enriched by "
+ "TextEditor.enrichHTML if necessary", {since: 10, until: 12});
// Enrich the content
const documents = options.hash.documents !== false;
const owner = Boolean(options.hash.owner);
const rollData = options.hash.rollData;
content = TextEditor.enrichHTML(options.hash.content, {secrets: owner, documents, rollData, async: false});
}
// Construct the HTML
const editorClasses = ["editor-content", options.hash.class ?? null].filterJoin(" ");
let editorHTML = '<div class="editor">';
if ( button && editable ) editorHTML += '<a class="editor-edit"><i class="fas fa-edit"></i></a>';
let dataset = {
engine: options.hash.engine || "tinymce",
collaborate: !!options.hash.collaborate
};
if ( editable ) dataset.edit = target;
dataset = Object.entries(dataset).map(([k, v]) => `data-${k}="${v}"`).join(" ");
editorHTML += `<div class="${editorClasses}" ${dataset}>${content}</div></div>`;
return new Handlebars.SafeString(editorHTML);
}
/* -------------------------------------------- */
/**
* Render a file-picker button linked to an `<input>` field
* @param {object} options Helper options
* @param {string} [options.type] The type of FilePicker instance to display
* @param {string} [options.target] The field name in the target data
* @returns {Handlebars.SafeString|string}
*
* @example
* ```hbs
* {{filePicker type="image" target="img"}}
* ```
*/
static filePicker(options) {
const type = options.hash.type;
const target = options.hash.target;
if ( !target ) throw new Error("You must define the name of the target field.");
// Do not display the button for users who do not have browse permission
if ( game.world && !game.user.can("FILES_BROWSE" ) ) return "";
// Construct the HTML
const tooltip = game.i18n.localize("FILES.BrowseTooltip");
return new Handlebars.SafeString(`
<button type="button" class="file-picker" data-type="${type}" data-target="${target}" title="${tooltip}" tabindex="-1">
<i class="fas fa-file-import fa-fw"></i>
</button>`);
}
/* -------------------------------------------- */
/**
* 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
* <label>{{localize "ACTOR.Create"}}</label> <!-- "Create Actor" -->
* <label>{{localize "CHAT.InvalidCommand" command=foo}}</label> <!-- "foo is not a valid chat message command." -->
* ```
*/
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} 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}} <!-- 5.5 -->
* {{formatNumber 5.5 decimals=2}} <!-- 5.50 -->
* {{formatNumber 5.5 decimals=2 sign=true}} <!-- +5.50 -->
* ```
*/
static numberFormat(value, options) {
const dec = options.hash['decimals'] ?? 0;
const sign = options.hash['sign'] || false;
value = parseFloat(value).toFixed(dec);
if (sign ) return ( value >= 0 ) ? "+"+value : value;
return value;
}
/* --------------------------------------------- */
/**
* Render a form input field of type number with value appropriately rounded to step size.
* @returns {Handlebars.SafeString}
*
* @example
* ```hbs
* {{numberInput value name="numberField" step=1 min=0 max=10}}
* ```
*/
static numberInput(value, options) {
const properties = [];
for ( let k of ["class", "name", "placeholder", "min", "max"] ) {
if ( k in options.hash ) properties.push(`${k}="${options.hash[k]}"`);
}
const step = options.hash.step ?? "any";
properties.unshift(`step="${step}"`);
if ( options.hash.disabled === true ) properties.push("disabled");
if ( options.hash.readonly === true ) properties.push("readonly");
let safe = Number.isNumeric(value) ? Number(value) : "";
if ( Number.isNumeric(step) && (typeof safe === "number") ) safe = safe.toNearest(Number(step));
return new Handlebars.SafeString(`<input type="number" value="${safe}" ${properties.join(" ")}>`);
}
/* -------------------------------------------- */
/**
* 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
* <div class="form-group">
* <label>Radio Group Label</label>
* <div class="form-fields">
* {{radioBoxes groupName choices checked=chosen localize=true}}
* </div>
* </div>
* ```
*/
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 += `<label class="checkbox"><input type="radio" name="${name}" value="${key}" ${isChecked ? "checked" : ""}> ${label}</label>`;
}
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 =
`<input type="range" name="${name}" value="${value}" min="${min}" max="${max}" step="${step}"/>
<span class="range-value">${value}</span>`;
return new Handlebars.SafeString(html);
}
/* -------------------------------------------- */
/**
* A helper to assign an `<option>` within a `<select>` block as selected based on its value
* Escape the string as handlebars would, then escape any regexp characters in it
* @param {string} value The value of the option
* @returns {Handlebars.SafeString}
*
* @example
* ```hbs
* <select>
* {{#select selected}}
* <option value="a">Choice A</option>
* <option value="b">Choice B</option>
* {{/select}}
* </select>
*/
static select(selected, options) {
const escapedValue = RegExp.escape(Handlebars.escapeExpression(selected));
const rgx = new RegExp(' value=[\"\']' + escapedValue + '[\"\']');
const html = options.fn(this);
return html.replace(rgx, "$& selected");
}
/* -------------------------------------------- */
/**
* A helper to create a set of &lt;option> elements in a &lt;select> block based on a provided dictionary.
* The provided keys are the option values while the provided values are human readable labels.
* This helper supports both single-select as well as multi-select input fields.
*
* @param {object|Array<object>>} choices A mapping of radio checkbox values to human-readable labels
* @param {object} options Helper options
* @param {string|string[]} [options.selected] Which key or array of keys that are currently selected?
* @param {boolean} [options.localize=false] Pass each label through string localization?
* @param {string} [options.blank] Add a blank option as the first option with this label
* @param {boolean} [options.sort] Sort the options by their label after localization
* @param {string} [options.nameAttr] Look up a property in the choice object values to use as the option value
* @param {string} [options.labelAttr] Look up a property in the choice object values to use as the option label
* @param {boolean} [options.inverted=false] Use the choice object value as the option value, and the key as the label
* instead of vice-versa
* @returns {Handlebars.SafeString}
*
* @example The provided input data
* ```js
* let choices = {a: "Choice A", b: "Choice B"};
* let value = "a";
* ```
* The template HTML structure
* ```hbs
* <select name="importantChoice">
* {{selectOptions choices selected=value localize=true}}
* </select>
* ```
* The resulting HTML
* ```html
* <select name="importantChoice">
* <option value="a" selected>Choice A</option>
* <option value="b">Choice B</option>
* </select>
* ```
*
* @example Using inverted choices
* ```js
* let choices = {"Choice A": "a", "Choice B": "b"};
* let value = "a";
* ```
* The template HTML structure
* ```hbs
* <select name="importantChoice">
* {{selectOptions choices selected=value inverted=true}}
* </select>
* ```
*
* @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
* <select name="importantChoice">
* {{selectOptions choices selected=value nameAttr="key" labelAttr="label"}}
* </select>
* ```
*
* @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
* <select name="importantChoice">
* {{selectOptions choices selected=value nameAttr="key" labelAttr="label"}}
* </select>
* ```
*/
static selectOptions(choices, options) {
let {localize=false, selected=null, blank=null, sort=false, nameAttr, labelAttr, inverted} = options.hash;
selected = selected instanceof Array ? selected.map(String) : [String(selected)];
// Prepare the choices as an array of objects
const selectOptions = [];
if ( choices instanceof Array ) {
for ( const choice of choices ) {
const name = String(choice[nameAttr]);
let label = choice[labelAttr];
if ( localize ) label = game.i18n.localize(label);
selectOptions.push({name, label});
}
}
else {
for ( const choice of Object.entries(choices) ) {
const [key, value] = inverted ? choice.reverse() : choice;
const name = String(nameAttr ? value[nameAttr] : key);
let label = labelAttr ? value[labelAttr] : value;
if ( localize ) label = game.i18n.localize(label);
selectOptions.push({name, label});
}
}
// Sort the array of options
if ( sort ) selectOptions.sort((a, b) => a.label.localeCompare(b.label));
// Prepend a blank option
if ( blank !== null ) {
const label = localize ? game.i18n.localize(blank) : blank;
selectOptions.unshift({name: "", label});
}
// Create the HTML
let html = "";
for ( const option of selectOptions ) {
const label = Handlebars.escapeExpression(option.label);
const isSelected = selected.includes(option.name);
html += `<option value="${option.name}" ${isSelected ? "selected" : ""}>${label}</option>`;
}
return new Handlebars.SafeString(html);
}
}
// Register all handlebars helpers
Handlebars.registerHelper({
checked: HandlebarsHelpers.checked,
disabled: HandlebarsHelpers.disabled,
colorPicker: HandlebarsHelpers.colorPicker,
concat: HandlebarsHelpers.concat,
editor: HandlebarsHelpers.editor,
filePicker: HandlebarsHelpers.filePicker,
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 {
/**
* @param {string} view The named view which is active for this game instance.
* @param {object} data An object of all the World data vended by the server when the client first connects
* @param {string} sessionId The ID of the currently active client session retrieved from the browser cookie
* @param {Socket} socket The open web-socket which should be used to transact game-state data
*/
constructor(view, data, sessionId, socket) {
/**
* The named view which is currently active.
* Game views include: join, setup, players, license, game, stream
* @type {string}
*/
Object.defineProperty(this, "view", {value: view, writable: false, enumerable: true});
/**
* The object of world data passed from the server
* @type {object}
*/
Object.defineProperty(this, "data", {value: data, writable: false, enumerable: true});
/**
* The Release data for this version of Foundry
* @type {config.ReleaseData}
*/
Object.defineProperty(this, "release", {
value: new foundry.config.ReleaseData(data.release),
writable: false,
enumerable: true
});
/**
* The id of the active World user, if any
* @type {string|null}
*/
Object.defineProperty(this, "userId", {value: data.userId || null, writable: false, enumerable: true});
// Set up package data
this.setupPackages(data);
/**
* A mapping of WorldCollection instances, one per primary Document type.
* @type {Collection<string,WorldCollection>}
*/
Object.defineProperty(this, "collections", {
value: new foundry.utils.Collection(),
writable: false,
enumerable: true
});
/**
* A mapping of CompendiumCollection instances, one per Compendium pack.
* @type {CompendiumPacks<string,CompendiumCollection>}
*/
Object.defineProperty(this, "packs", {
value: new CompendiumPacks(),
writable: false,
enumerable: true
});
/**
* A singleton web Worker manager.
* @type {WorkerManager}
*/
Object.defineProperty(this, "workers", {value: new WorkerManager(), writable: false, enumerable: true});
/**
* Localization support
* @type {Localization}
*/
Object.defineProperty(this, "i18n", {
value: new Localization(data?.options?.language),
writable: false,
enumerable: true
});
/**
* The Keyboard Manager
* @type {KeyboardManager}
*/
Object.defineProperty(this, "keyboard", {value: new KeyboardManager(), writable: false, enumerable: true});
/**
* The Mouse Manager
* @type {MouseManager}
*/
Object.defineProperty(this, "mouse", {value: new MouseManager(), writable: false, enumerable: true});
/**
* The Gamepad Manager
* @type {GamepadManager}
*/
Object.defineProperty(this, "gamepad", {value: new GamepadManager(), writable: false, enumerable: true});
/**
* The New User Experience manager.
* @type {NewUserExperience}
*/
Object.defineProperty(this, "nue", {value: new NewUserExperience(), writable: false, enumerable: true});
/**
* The user role permissions setting
* @type {object}
*/
this.permissions = null;
/**
* The client session id which is currently active
* @type {string}
*/
Object.defineProperty(this, "sessionId", {value: sessionId, writable: false, enumerable: true});
/**
* Client settings which are used to configure application behavior
* @type {ClientSettings}
*/
Object.defineProperty(this, "settings", {
value: new ClientSettings(data.settings || []),
writable: false,
enumerable: true
});
/**
* Client keybindings which are used to configure application behavior
* @type {ClientKeybindings}
*/
Object.defineProperty(this, "keybindings", {value: new ClientKeybindings(), writable: false, enumerable: true});
/**
* A reference to the open Socket.io connection
* @type {WebSocket|null}
*/
Object.defineProperty(this, "socket", {value: socket, writable: false, enumerable: true});
/**
* A singleton GameTime instance which manages the progression of time within the game world.
* @type {GameTime}
*/
Object.defineProperty(this, "time", {value: new GameTime(socket), writable: false, enumerable: true});
const canvas = new Canvas();
/**
* A singleton reference to the Canvas object which may be used.
* @type {Canvas}
*/
Object.defineProperty(this, "canvas", {value: canvas, writable: false, enumerable: true});
Object.defineProperty(globalThis, "canvas", {value: canvas, writable: true, enumerable: false});
/**
* A singleton instance of the Audio Helper class
* @type {AudioHelper}
*/
Object.defineProperty(this, "audio", {value: new AudioHelper(), writable: false, enumerable: true});
/**
* A singleton instance of the Video Helper class
* @type {VideoHelper}
*/
Object.defineProperty(this, "video", {value: new VideoHelper(), writable: false, enumerable: true});
/**
* A singleton instance of the TooltipManager class.
* @type {TooltipManager}
*/
Object.defineProperty(this, "tooltip", {value: new TooltipManager(), configurable: true, enumerable: true});
/**
* A singleton instance of the Clipboard Helper class.
* @type {ClipboardHelper}
*/
Object.defineProperty(this, "clipboard", {value: new ClipboardHelper(), writable: false, enumerable: true});
/**
* A singleton instance of the Tour collection class
* @type {Tours}
*/
Object.defineProperty(this, "tours", {value: new Tours(), writable: false, enumerable: true});
/**
* The global document index.
* @type {DocumentIndex}
*/
Object.defineProperty(this, "documentIndex", {value: new DocumentIndex(), writable: false, enumerable: true});
/**
* The singleton instance of the ClientIssues manager.
* @type {ClientIssues}
*/
Object.defineProperty(this, "issues", {value: new ClientIssues(), writable: false, enumerable: true});
/**
* Whether the Game is running in debug mode
* @type {boolean}
*/
this.debug = false;
/**
* A flag for whether texture assets for the game canvas are currently loading
* @type {boolean}
*/
this.loading = false;
/**
* A flag for whether the Game has successfully reached the "ready" hook
* @type {boolean}
*/
this.ready = false;
}
/**
* The game World which is currently active.
* @type {World}
*/
world;
/**
* The System which is used to power this game World.
* @type {System}
*/
system;
/**
* A Map of active Modules which are currently eligible to be enabled in this World.
* The subset of Modules which are designated as active are currently enabled.
* @type {Map<string, Module>}
*/
modules;
/**
* Returns the current version of the Release, usable for comparisons using isNewerVersion
* @type {string}
*/
get version() {
return this.release.version;
}
/* -------------------------------------------- */
/**
* Fetch World data and return a Game instance
* @param {string} view The named view being created
* @param {string|null} sessionId The current sessionId of the connecting client
* @returns {Promise<Game>} 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<object>} A promise which resolves to the connected socket, if successful
*/
static async connect(sessionId) {
return new Promise((resolve, reject) => {
const socket = io.connect({
path: foundry.utils.getRoute("socket.io"),
transports: ["websocket"], // Require websocket transport instead of XHR polling
upgrade: false, // Prevent "upgrading" to websocket since it is enforced
reconnection: true, // Automatically reconnect
reconnectionDelay: 500, // Time before reconnection is attempted
reconnectionAttempts: 10, // Maximum reconnection attempts
reconnectionDelayMax: 500, // The maximum delay between reconnection attempts
query: {session: sessionId}, // Pass session info
cookie: false
});
// Confirm successful session creation
socket.on("session", response => {
socket.session = response;
const id = response.sessionId;
if ( !id || (sessionId && (sessionId !== id)) ) return foundry.utils.debouncedReload();
console.log(`${vtt} | Connected to server socket using session ${id}`);
resolve(socket);
});
// Fail to establish an initial connection
socket.on("connectTimeout", () => {
reject(new Error("Failed to establish a socket connection within allowed timeout."));
});
socket.on("connectError", err => reject(err));
});
}
/* -------------------------------------------- */
/**
* Retrieve the cookies which are attached to the client session
* @returns {object} The session cookies
*/
static getCookies() {
const cookies = {};
for (let cookie of document.cookie.split("; ")) {
let [name, value] = cookie.split("=");
cookies[name] = decodeURIComponent(value);
}
return cookies;
}
/* -------------------------------------------- */
/**
* Request World data from server and return it
* @param {Socket} socket The active socket connection
* @param {string} view The view for which data is being requested
* @returns {Promise<object>}
*/
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<boolean>}
*/
static async getWorldStatus(socket) {
const status = await new Promise(resolve => {
socket.emit("getWorldStatus", resolve);
});
console.log(`${vtt} | The game World is currently ${status ? "active" : "not active"}`);
return status;
}
/* -------------------------------------------- */
/**
* Configure package data that is currently enabled for this world
* @param {object} data Game data provided by the server socket
*/
setupPackages(data) {
if ( data.world ) {
this.world = new World(data.world);
}
if ( data.system ) {
this.system = new System(data.system);
if ( data.documentTypes ) this.documentTypes = data.documentTypes;
if ( data.template ) this.template = data.template;
if ( data.model ) this.model = data.model;
}
this.modules = new foundry.utils.Collection(data.modules.map(m => [m.id, new Module(m)]));
}
/* -------------------------------------------- */
/**
* Return the named scopes which can exist for packages.
* Scopes are returned in the prioritization order that their content is loaded.
* @returns {string[]} An array of string package scopes
*/
getPackageScopes() {
return CONFIG.DatabaseBackend.getFlagScopes();
}
/* -------------------------------------------- */
/**
* Initialize the Game for the current window location
*/
async initialize() {
console.log(`${vtt} | Initializing Foundry Virtual Tabletop Game`);
this.ready = false;
Hooks.callAll("init");
// Register game settings
this.registerSettings();
// Initialize language translations
await this.i18n.initialize();
// Register Tours
await this.registerTours();
// Activate event listeners
this.activateListeners();
// Initialize the current view
await this._initializeView();
// Display usability warnings or errors
this.issues._detectUsabilityIssues();
}
/* -------------------------------------------- */
/**
* Shut down the currently active Game. Requires GameMaster user permission.
* @returns {Promise<void>}
*/
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: `<p>${game.i18n.format(warning, {number: othersActive})}</p>`
});
if ( !confirm ) return;
}
// Dispatch the request
const setupUrl = foundry.utils.getRoute("setup");
const response = await fetchWithTimeout(setupUrl, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({shutdown: true}),
redirect: "manual"
});
// 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<void>}
*/
async setupGame() {
// Store permission settings
this.permissions = await this.settings.get("core", "permissions");
// Initialize world data
this.initializePacks(); // Do this first since documents may reference compendium content
this.initializeDocuments(); // Next initialize world-level documents
// Monkeypatch a search method on EmbeddedCollection
foundry.abstract.EmbeddedCollection.prototype.search = DocumentCollection.prototype.search;
// Call world setup hook
Hooks.callAll("setup");
// Initialize AV conferencing
// noinspection ES6MissingAwait
this.initializeRTC();
// Initialize user interface
this.initializeMouse();
this.initializeGamepads();
this.initializeKeyboard();
// Call this here to set up a promise that dependent UI elements can await.
this.canvas.initializing = this.initializeCanvas();
this.initializeUI();
DocumentSheetConfig.initializeSheets();
// Canvas initialization
await this.canvas.initializing;
this.activateSocketListeners();
// If the player is not a GM and does not have an impersonated character, prompt for selection
if ( !this.user.isGM && !this.user.character ) {
this.user.sheet.render(true);
}
// Call all game ready hooks
this.ready = true;
// Initialize New User Experience
this.nue.initialize();
// Index available documents
await this.documentIndex.index();
Hooks.callAll("ready");
}
/* -------------------------------------------- */
/**
* Initialize game state data by creating WorldCollection instances for every primary Document type
*/
initializeDocuments() {
const initOrder = ["User", "Folder", "Actor", "Item", "Scene", "Combat", "JournalEntry", "Macro", "Playlist",
"RollTable", "Cards", "ChatMessage"];
if ( initOrder.length !== CONST.DOCUMENT_TYPES.length ) {
throw new Error("Missing Document initialization type!");
}
// Warn developers about collision with V10 DataModel changes
const v10DocumentMigrationErrors = [];
for ( const documentName of CONST.DOCUMENT_TYPES ) {
const cls = getDocumentClass(documentName);
for ( const key of cls.schema.keys() ) {
if ( key in cls.prototype ) {
const err = `The ${cls.name} class defines the "${key}" attribute which collides with the "${key}" key in `
+ `the ${cls.documentName} data schema`;
v10DocumentMigrationErrors.push(err);
}
}
}
if ( v10DocumentMigrationErrors.length ) {
v10DocumentMigrationErrors.unshift("Version 10 Compatibility Failure",
"-".repeat(90),
"Several Document class definitions include properties which collide with the new V10 DataModel:",
"-".repeat(90));
throw new Error(v10DocumentMigrationErrors.join("\n"));
}
// Initialize world document collections
this._documentsReady = false;
const t0 = performance.now();
for ( let documentName of initOrder ) {
const documentClass = CONFIG[documentName].documentClass;
const collectionClass = CONFIG[documentName].collection;
const collectionName = documentClass.metadata.collection;
this[collectionName] = new collectionClass(foundry.utils.deepClone(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<string,CompendiumCollection>}
*/
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() {
// 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<void>}
*/
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();
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: PermissionConfig,
restricted: true
});
// User Role Permissions
game.settings.register("core", "permissions", {
name: "Permissions",
scope: "world",
default: {},
type: Object,
config: false,
onChange: permissions => {
game.permissions = permissions;
if ( ui.controls ) ui.controls.initialize();
if ( ui.sidebar ) ui.sidebar.render();
}
});
// WebRTC Control Menu
game.settings.registerMenu("core", "webrtc", {
name: "WEBRTC.Title",
label: "WEBRTC.MenuLabel",
hint: "WEBRTC.MenuHint",
icon: "fas fa-headset",
type: AVConfig,
restricted: false
});
// RTC World Settings
game.settings.register("core", "rtcWorldSettings", {
name: "WebRTC (Audio/Video Conferencing) World Settings",
scope: "world",
default: AVSettings.DEFAULT_WORLD_SETTINGS,
type: Object,
onChange: () => game.webrtc.settings.changed()
});
// RTC Client Settings
game.settings.register("core", "rtcClientSettings", {
name: "WebRTC (Audio/Video Conferencing) Client specific Configuration",
scope: "client",
default: AVSettings.DEFAULT_CLIENT_SETTINGS,
type: Object,
onChange: () => game.webrtc.settings.changed()
});
// Default Token Configuration
game.settings.registerMenu("core", DefaultTokenConfig.SETTING, {
name: "SETTINGS.DefaultTokenN",
label: "SETTINGS.DefaultTokenL",
hint: "SETTINGS.DefaultTokenH",
icon: "fas fa-user-alt",
type: DefaultTokenConfig,
restricted: true
});
// Default Token Settings
game.settings.register("core", DefaultTokenConfig.SETTING, {
name: "SETTINGS.DefaultTokenN",
hint: "SETTINGS.DefaultTokenL",
scope: "world",
type: Object,
default: {}
});
// Font Configuration
game.settings.registerMenu("core", FontConfig.SETTING, {
name: "SETTINGS.FontConfigN",
label: "SETTINGS.FontConfigL",
hint: "SETTINGS.FontConfigH",
icon: "fa-solid fa-font",
type: FontConfig,
restricted: true
});
// Font Configuration Settings
game.settings.register("core", FontConfig.SETTING, {
scope: "world",
type: Object,
default: {}
});
// 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: Boolean,
default: false,
requiresReload: true
});
// Language preference
game.settings.register("core", "language", {
name: "SETTINGS.LangN",
hint: "SETTINGS.LangL",
scope: "client",
config: true,
default: game.i18n.lang,
type: String,
choices: CONFIG.supportedLanguages,
requiresReload: true
});
// Chat message roll mode
game.settings.register("core", "rollMode", {
name: "Default Roll Mode",
scope: "client",
config: false,
default: CONST.DICE_ROLL_MODES.PUBLIC,
type: String,
choices: CONFIG.Dice.rollModes,
onChange: ChatLog._setRollMode
});
// World time
game.settings.register("core", "time", {
name: "World Time",
scope: "world",
config: false,
default: 0,
type: Number,
onChange: this.time.onUpdateWorldTime.bind(this.time)
});
// Register module configuration settings
game.settings.register("core", ModuleManagement.CONFIG_SETTING, {
name: "Module Configuration Settings",
scope: "world",
config: false,
default: {},
type: Object,
requiresReload: true
});
// Register compendium visibility setting
game.settings.register("core", CompendiumCollection.CONFIG_SETTING, {
name: "Compendium Configuration",
scope: "world",
config: false,
default: {},
type: Object,
onChange: () => {
this.initializePacks();
ui.compendium.render();
}
});
// Combat Tracker Configuration
game.settings.register("core", Combat.CONFIG_SETTING, {
name: "Combat Tracker Configuration",
scope: "world",
config: false,
default: {},
type: Object,
onChange: () => {
if (game.combat) {
game.combat.reset();
game.combats.render();
}
}
});
// Document Sheet Class Configuration
game.settings.register("core", "sheetClasses", {
name: "Sheet Class Configuration",
scope: "world",
config: false,
default: {},
type: Object,
onChange: setting => DocumentSheetConfig.updateDefaultSheets(setting)
});
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,
default: true,
type: Boolean
});
// Pan to Token Speaker
game.settings.register("core", "chatBubblesPan", {
name: "SETTINGS.CBubPN",
hint: "SETTINGS.CBubPL",
scope: "client",
config: true,
default: true,
type: Boolean
});
// Scrolling Status Text
game.settings.register("core", "scrollingStatusText", {
name: "SETTINGS.ScrollStatusN",
hint: "SETTINGS.ScrollStatusL",
scope: "world",
config: true,
default: true,
type: Boolean
});
// Disable Resolution Scaling
game.settings.register("core", "pixelRatioResolutionScaling", {
name: "SETTINGS.ResolutionScaleN",
hint: "SETTINGS.ResolutionScaleL",
scope: "client",
config: true,
default: true,
type: Boolean,
requiresReload: true
});
// Left-Click Deselection
game.settings.register("core", "leftClickRelease", {
name: "SETTINGS.LClickReleaseN",
hint: "SETTINGS.LClickReleaseL",
scope: "client",
config: true,
default: false,
type: Boolean
});
// Canvas Performance Mode
game.settings.register("core", "performanceMode", {
name: "SETTINGS.PerformanceModeN",
hint: "SETTINGS.PerformanceModeL",
scope: "client",
config: true,
type: Number,
default: -1,
choices: {
[CONST.CANVAS_PERFORMANCE_MODES.LOW]: "SETTINGS.PerformanceModeLow",
[CONST.CANVAS_PERFORMANCE_MODES.MED]: "SETTINGS.PerformanceModeMed",
[CONST.CANVAS_PERFORMANCE_MODES.HIGH]: "SETTINGS.PerformanceModeHigh",
[CONST.CANVAS_PERFORMANCE_MODES.MAX]: "SETTINGS.PerformanceModeMax"
},
onChange: () => {
canvas._configurePerformanceMode();
return canvas.ready ? canvas.draw() : null;
}
});
// Maximum Framerate
game.settings.register("core", "maxFPS", {
name: "SETTINGS.MaxFPSN",
hint: "SETTINGS.MaxFPSL",
scope: "client",
config: true,
type: Number,
range: {min: 10, max: 60, step: 10},
default: 60,
onChange: () => {
canvas._configurePerformanceMode();
return canvas.ready ? canvas.draw() : null;
}
});
// FPS Meter
game.settings.register("core", "fpsMeter", {
name: "SETTINGS.FPSMeterN",
hint: "SETTINGS.FPSMeterL",
scope: "client",
config: true,
type: Boolean,
default: false,
onChange: enabled => {
if ( enabled ) return canvas.activateFPSMeter();
else return canvas.deactivateFPSMeter();
}
});
// Font scale
game.settings.register("core", "fontSize", {
name: "SETTINGS.FontSizeN",
hint: "SETTINGS.FontSizeL",
scope: "client",
config: true,
type: Number,
range: {min: 1, max: 10, step: 1},
default: 5,
onChange: () => game.scaleFonts()
});
// Photosensitivity mode.
game.settings.register("core", "photosensitiveMode", {
name: "SETTINGS.PhotosensitiveModeN",
hint: "SETTINGS.PhotosensitiveModeL",
scope: "client",
config: true,
type: Boolean,
default: false,
requiresReload: true
});
// Live Token Drag Preview
game.settings.register("core", "tokenDragPreview", {
name: "SETTINGS.TokenDragPreviewN",
hint: "SETTINGS.TokenDragPreviewL",
scope: "world",
config: true,
default: false,
type: Boolean
});
// Animated Token Vision
game.settings.register("core", "visionAnimation", {
name: "SETTINGS.AnimVisionN",
hint: "SETTINGS.AnimVisionL",
config: true,
type: Boolean,
default: true
});
// Light Source Flicker
game.settings.register("core", "lightAnimation", {
name: "SETTINGS.AnimLightN",
hint: "SETTINGS.AnimLightL",
config: true,
type: Boolean,
default: true,
onChange: () => canvas.effects?.activateAnimation()
});
// Mipmap Antialiasing
game.settings.register("core", "mipmap", {
name: "SETTINGS.MipMapN",
hint: "SETTINGS.MipMapL",
config: true,
type: Boolean,
default: true,
onChange: () => canvas.ready ? canvas.draw() : null
});
// Default Drawing Configuration
game.settings.register("core", DrawingsLayer.DEFAULT_CONFIG_SETTING, {
name: "Default Drawing Configuration",
scope: "client",
config: false,
default: {},
type: Object
});
// Keybindings
game.settings.register("core", "keybindings", {
scope: "client",
config: false,
type: Object,
default: {},
onChange: () => game.keybindings.initialize()
});
// New User Experience
game.settings.register("core", "nue.shownTips", {
scope: "world",
type: Boolean,
default: false,
config: false
});
// Tours
game.settings.register("core", "tourProgress", {
scope: "client",
config: false,
type: Object,
default: {}
});
// Editor autosave.
game.settings.register("core", "editorAutosaveSecs", {
name: "SETTINGS.EditorAutosaveN",
hint: "SETTINGS.EditorAutosaveH",
scope: "world",
config: true,
type: Number,
default: 60,
range: {min: 30, max: 300, step: 10}
});
// Link recommendations.
game.settings.register("core", "pmHighlightDocumentMatches", {
name: "SETTINGS.EnableHighlightDocumentMatches",
hint: "SETTINGS.EnableHighlightDocumentMatchesH",
scope: "world",
config: false,
type: Boolean,
default: true
});
// Combat Theme
game.settings.register("core", "combatTheme", {
name: "SETTINGS.CombatThemeN",
hint: "SETTINGS.CombatThemeL",
scope: "client",
config: false,
type: String,
choices: Object.entries(CONFIG.Combat.sounds).reduce( (choices, s) => {
choices[s[0]] = game.i18n.localize(s[1].label);
return choices;
}, {none: game.i18n.localize("SETTINGS.None") }),
default: "none"
});
// Show Toolclips
game.settings.register("core", "showToolclips", {
name: "SETTINGS.ShowToolclips",
hint: "SETTINGS.ShowToolclipsH",
scope: "client",
config: true,
type: Boolean,
default: 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: Boolean,
default: false
});
// Adventure imports
game.settings.register("core", "adventureImports", {
scope: "world",
config: false,
type: Object,
default: {}
});
// Document-specific settings
RollTables.registerSettings();
// Audio playback settings
AudioHelper.registerSettings();
// Register CanvasLayer settings
NotesLayer.registerSettings();
TemplateLayer.registerSettings();
}
/* -------------------------------------------- */
/**
* Register core Tours
* @returns {Promise<void>}
*/
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`;
}
/* -------------------------------------------- */
/* Socket Listeners and Handlers */
/* -------------------------------------------- */
/**
* Activate Socket event listeners which are used to transact game state data with the server
*/
activateSocketListeners() {
let disconnectedTime = 0;
let reconnectTimeRequireRefresh = 5000;
// Disconnection and reconnection attempts
this.socket.on("disconnect", () => {
disconnectedTime = Date.now();
ui.notifications.error("You have lost connection to the server, attempting to re-establish.");
});
// Reconnect attempt
this.socket.io.on("reconnect_attempt", () => {
const t = Date.now();
console.log(`${vtt} | Attempting to re-connect: ${((t - disconnectedTime) / 1000).toFixed(2)} seconds`);
});
// Reconnect failed
this.socket.io.on("reconnect_failed", () => {
ui.notifications.error(`${vtt} | Server connection lost.`);
window.location.href = foundry.utils.getRoute("no");
});
// Reconnect succeeded
this.socket.io.on("reconnect", () => {
ui.notifications.info(`${vtt} | Server connection re-established.`);
if ( (Date.now() - disconnectedTime) >= reconnectTimeRequireRefresh ) {
foundry.utils.debouncedReload();
}
});
// Game pause
this.socket.on("pause", pause => {
game.togglePause(pause, false);
});
// Game shutdown
this.socket.on("shutdown", () => {
ui.notifications.info("The game world is shutting down and you will be returned to the server homepage.", {
permanent: true
});
setTimeout(() => window.location.href = foundry.utils.getRoute("/"), 1000);
});
// Application reload.
this.socket.on("reload", () => foundry.utils.debouncedReload());
// Hot Reload
this.socket.on("hotReload", this.#handleHotReload.bind(this));
// Database Operations
CONFIG.DatabaseBackend.activateSocketListeners(this.socket);
// Additional events
AudioHelper._activateSocketListeners(this.socket);
Users._activateSocketListeners(this.socket);
Scenes._activateSocketListeners(this.socket);
Journal._activateSocketListeners(this.socket);
FogExplorations._activateSocketListeners(this.socket);
ChatBubbles._activateSocketListeners(this.socket);
ProseMirrorEditor._activateSocketListeners(this.socket);
CompendiumCollection._activateSocketListeners(this.socket);
}
/* -------------------------------------------- */
/**
* @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 the href has a query string, remove it
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);
_templateCache[data.path] = template;
for ( const appId in ui.windows ) {
ui.windows[appId].render(true);
}
}
/* -------------------------------------------- */
/**
* 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 appId in ui.windows ) {
ui.windows[appId].render(true);
}
}
/* -------------------------------------------- */
/* 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 ) return;
event.preventDefault();
event.stopPropagation();
// Adjust the range slider by the step size
const step = (parseFloat(r.step) || 1.0) * Math.sign(-1 * event.deltaY);
r.value = Math.clamped(parseFloat(r.value) + step, parseFloat(r.min), parseFloat(r.max));
// Dispatch a change event that can bubble upwards to the parent form
const ev = new Event("change", {bubbles: true});
r.dispatchEvent(ev);
}
/* -------------------------------------------- */
/**
* On left mouse clicks, check if the element is contained in a valid hyperlink and open it in a new tab.
* @param {MouseEvent} event
* @private
*/
_onClickHyperlink(event) {
const a = event.target.closest("a[href]");
if ( !a || (a.href === "javascript:void(0)") || a.closest(".editor-content.ProseMirror") ) return;
event.preventDefault();
window.open(a.href, "_blank");
}
/* -------------------------------------------- */
/**
* Prevent starting a drag and drop workflow on elements within the document unless the element has the draggable
* attribute explicitly defined or overrides the dragstart handler.
* @param {DragEvent} event The initiating drag start event
* @private
*/
_onPreventDragstart(event) {
const target = event.target;
const inProseMirror = (target.nodeType === Node.TEXT_NODE) && target.parentElement.closest(".ProseMirror");
if ( (target.getAttribute?.("draggable") === "true") || inProseMirror ) return;
event.preventDefault();
return false;
}
/* -------------------------------------------- */
/**
* Disallow dragging of external content onto anything but a file input element
* @param {DragEvent} event The requested drag event
* @private
*/
_onPreventDragover(event) {
const target = event.target;
if ( (target.tagName !== "INPUT") || (target.type !== "file") ) event.preventDefault();
}
/* -------------------------------------------- */
/**
* Disallow dropping of external content onto anything but a file input element
* @param {DragEvent} event The requested drag event
* @private
*/
_onPreventDrop(event) {
const target = event.target;
if ( (target.tagName !== "INPUT") || (target.type !== "file") ) event.preventDefault();
}
/* -------------------------------------------- */
/**
* On a left-click event, remove any currently displayed inline roll tooltip
* @param {PointerEvent} event The mousedown pointer event
* @private
*/
_onPointerDown(event) {
if ([3, 4, 5].includes(event.button)) event.preventDefault();
const inlineRoll = document.querySelector(".inline-roll.expanded");
if ( inlineRoll && !event.target.closest(".inline-roll") ) {
return Roll.defaultImplementation.collapseInlineResult(inlineRoll);
}
}
/* -------------------------------------------- */
/**
* Fallback handling for mouse-up events which aren't handled further upstream.
* @param {PointerEvent} event The mouseup pointer event
* @private
*/
_onPointerUp(event) {
const cmm = canvas.currentMouseManager;
if ( !cmm || event.defaultPrevented ) return;
cmm.cancel(event);
}
/* -------------------------------------------- */
/**
* Handle resizing of the game window by adjusting the canvas and repositioning active interface applications.
* @param {Event} event The window resize event which has occurred
* @private
*/
_onWindowResize(event) {
Object.values(ui.windows).forEach(app => {
app.setPosition({top: app.position.top, left: app.position.left});
});
ui.webrtc?.setPosition({height: "auto"});
if (canvas && canvas.ready) return canvas._onResize(event);
}
/* -------------------------------------------- */
/**
* Handle window unload operations to clean up any data which may be pending a final save
* @param {Event} event The window unload event which is about to occur
* @private
*/
_onWindowBeforeUnload(event) {
if ( canvas.ready ) {
canvas.fog.commit();
// Save the fog immediately rather than waiting for the 3s debounced save as part of commitFog.
return canvas.fog.save();
}
}
/* -------------------------------------------- */
/**
* Handle cases where the browser window loses focus to reset detection of currently pressed keys
* @param {Event} event The originating window.blur event
* @private
*/
_onWindowBlur(event) {
game.keyboard?.releaseKeys();
}
/* -------------------------------------------- */
_onWindowPopState(event) {
if ( game._goingBack ) return;
history.pushState(null, null, location.href);
if ( confirm(game.i18n.localize("APP.NavigateBackConfirm")) ) {
game._goingBack = true;
history.back();
history.back();
}
}
/* -------------------------------------------- */
/* View Handlers */
/* -------------------------------------------- */
/**
* Initialize elements required for the current view
* @private
*/
async _initializeView() {
switch (this.view) {
case "game":
return this._initializeGameView();
case "stream":
return this._initializeStreamView();
default:
throw new Error(`Unknown view URL ${this.view} provided`);
}
}
/* -------------------------------------------- */
/**
* Initialization steps for the primary Game view
* @private
*/
async _initializeGameView() {
// Require a valid user cookie and EULA acceptance
if ( !globalThis.SIGNED_EULA ) window.location.href = foundry.utils.getRoute("license");
if (!this.userId) {
console.error("Invalid user session provided - returning to login screen.");
this.logOut();
}
// Set up the game
await this.setupGame();
// Set a timeout of 10 minutes before kicking the user off
if ( this.data.demoMode && !this.user.isGM ) {
setTimeout(() => {
console.log(`${vtt} | Ending demo session after 10 minutes. Thanks for testing!`);
this.logOut();
}, 1000 * 60 * 10);
}
// Context menu listeners
ContextMenu.eventListeners();
}
/* -------------------------------------------- */
/**
* Initialization steps for the Stream helper view
* @private
*/
async _initializeStreamView() {
if ( !globalThis.SIGNED_EULA ) window.location.href = foundry.utils.getRoute("license");
this.initializeDocuments();
ui.chat = new ChatLog({stream: true});
ui.chat.render(true);
CONFIG.DatabaseBackend.activateSocketListeners(this.socket);
}
}
/**
* An interface and API for constructing and evaluating dice rolls.
* The basic structure for a dice roll is a string formula and an object of data against which to parse it.
*
* @param {string} formula The string formula to parse
* @param {object} data The data object against which to parse attributes within the formula
*
* @example Attack with advantage
* ```js
* // Construct the Roll instance
* let r = new Roll("2d20kh + @prof + @strMod", {prof: 2, strMod: 4});
*
* // The parsed terms of the roll formula
* console.log(r.terms); // [Die, OperatorTerm, NumericTerm, OperatorTerm, NumericTerm]
*
* // Execute the roll
* await r.evaluate();
*
* // The resulting equation after it was rolled
* console.log(r.result); // 16 + 2 + 4
*
* // The total resulting from the roll
* console.log(r.total); // 22
* ```
*/
class Roll {
constructor(formula, data={}, options={}) {
/**
* The original provided data object which substitutes into attributes of the roll formula
* @type {Object}
*/
this.data = this._prepareData(data);
/**
* Options which modify or describe the Roll
* @type {object}
*/
this.options = options;
/**
* The identified terms of the Roll
* @type {RollTerm[]}
*/
this.terms = this.constructor.parse(formula, this.data);
/**
* An array of inner DiceTerms which were evaluated as part of the Roll evaluation
* @type {DiceTerm[]}
*/
this._dice = [];
/**
* Store the original cleaned formula for the Roll, prior to any internal evaluation or simplification
* @type {string}
*/
this._formula = this.resetFormula();
/**
* Track whether this Roll instance has been evaluated or not. Once evaluated the Roll is immutable.
* @type {boolean}
*/
this._evaluated = false;
/**
* Cache the numeric total generated through evaluation of the Roll.
* @type {number}
* @private
*/
this._total = undefined;
}
/**
* A Proxy environment for safely evaluating a string using only available Math functions
* @type {Math}
*/
static MATH_PROXY = new Proxy(Math, {
has: () => true, // Include everything
get: (t, k) => k === Symbol.unscopables ? undefined : t[k],
set: () => console.error("You may not set properties of the Roll.MATH_PROXY environment") // No-op
});
/**
* The HTML template path used to render a complete Roll object to the chat log
* @type {string}
*/
static CHAT_TEMPLATE = "templates/dice/roll.html";
/**
* The HTML template used to render an expanded Roll tooltip to the chat log
* @type {string}
*/
static TOOLTIP_TEMPLATE = "templates/dice/tooltip.html";
/* -------------------------------------------- */
/**
* Prepare the data structure used for the Roll.
* This is factored out to allow for custom Roll classes to do special data preparation using provided input.
* @param {object} data Provided roll data
* @returns {object} The prepared data object
* @protected
*/
_prepareData(data) {
return data;
}
/* -------------------------------------------- */
/* Roll Attributes */
/* -------------------------------------------- */
/**
* Return an Array of the individual DiceTerm instances contained within this Roll.
* @type {DiceTerm[]}
*/
get dice() {
return this._dice.concat(this.terms.reduce((dice, t) => {
if ( t instanceof DiceTerm ) dice.push(t);
else if ( t instanceof PoolTerm ) dice = dice.concat(t.dice);
return dice;
}, []));
}
/* -------------------------------------------- */
/**
* Return a standardized representation for the displayed formula associated with this Roll.
* @type {string}
*/
get formula() {
return this.constructor.getFormula(this.terms);
}
/* -------------------------------------------- */
/**
* The resulting arithmetic expression after rolls have been evaluated
* @type {string}
*/
get result() {
return this.terms.map(t => t.total).join("");
}
/* -------------------------------------------- */
/**
* Return the total result of the Roll expression if it has been evaluated.
* @type {number}
*/
get total() {
return this._total;
}
/* -------------------------------------------- */
/**
* Whether this Roll contains entirely deterministic terms or whether there is some randomness.
* @type {boolean}
*/
get isDeterministic() {
return this.terms.every(t => t.isDeterministic);
}
/* -------------------------------------------- */
/* Roll Instance Methods */
/* -------------------------------------------- */
/**
* Alter the Roll expression by adding or multiplying the number of dice which are rolled
* @param {number} multiply A factor to multiply. Dice are multiplied before any additions.
* @param {number} add A number of dice to add. Dice are added after multiplication.
* @param {boolean} [multiplyNumeric] Apply multiplication factor to numeric scalar terms
* @returns {Roll} The altered Roll expression
*/
alter(multiply, add, {multiplyNumeric=false}={}) {
if ( this._evaluated ) throw new Error("You may not alter a Roll which has already been evaluated");
// Alter dice and numeric terms
this.terms = this.terms.map(term => {
if ( term instanceof DiceTerm ) return term.alter(multiply, add);
else if ( (term instanceof NumericTerm) && multiplyNumeric ) term.number *= multiply;
return term;
});
// Update the altered formula and return the altered Roll
this.resetFormula();
return this;
}
/* -------------------------------------------- */
/**
* Clone the Roll instance, returning a new Roll instance that has not yet been evaluated.
* @returns {Roll}
*/
clone() {
return new this.constructor(this._formula, this.data, this.options);
}
/* -------------------------------------------- */
/**
* Execute the Roll, replacing dice and evaluating the total result
* @param {object} [options={}] Options which inform how the Roll is evaluated
* @param {boolean} [options.minimize=false] Minimize the result, obtaining the smallest possible value.
* @param {boolean} [options.maximize=false] Maximize the result, obtaining the largest possible value.
* @param {boolean} [options.async=true] Evaluate the roll asynchronously. false is deprecated
* @returns {Roll|Promise<Roll>} The evaluated Roll instance
*
* @example Evaluate a Roll expression
* ```js
* let r = new Roll("2d6 + 4 + 1d4");
* await r.evaluate();
* console.log(r.result); // 5 + 4 + 2
* console.log(r.total); // 11
* ```
*/
evaluate({minimize=false, maximize=false, async=true}={}) {
if ( this._evaluated ) {
throw new Error(`The ${this.constructor.name} has already been evaluated and is now immutable`);
}
this._evaluated = true;
if ( CONFIG.debug.dice ) console.debug(`Evaluating roll with formula ${this.formula}`);
// Migration path for async rolls
if ( minimize || maximize ) async = false;
if ( async === undefined ) {
foundry.utils.logCompatibilityWarning("Roll#evaluate is becoming asynchronous. In the short term, you may pass "
+ "async=true or async=false to evaluation options to nominate your preferred behavior.", {since: 8, until: 10});
async = true;
}
return async ? this._evaluate({minimize, maximize}) : this._evaluateSync({minimize, maximize});
}
/* -------------------------------------------- */
/**
* Evaluate the roll asynchronously.
* A temporary helper method used to migrate behavior from 0.7.x (sync by default) to 0.9.x (async by default).
* @param {object} [options] Options which inform how evaluation is performed
* @param {boolean} [options.minimize] Force the result to be minimized
* @param {boolean} [options.maximize] Force the result to be maximized
* @returns {Promise<Roll>}
* @private
*/
async _evaluate({minimize=false, maximize=false}={}) {
// Step 1 - Replace intermediate terms with evaluated numbers
const intermediate = [];
for ( let term of this.terms ) {
if ( !(term instanceof RollTerm) ) {
throw new Error("Roll evaluation encountered an invalid term which was not a RollTerm instance");
}
if ( term.isIntermediate ) {
await term.evaluate({minimize, maximize, async: true});
this._dice = this._dice.concat(term.dice);
term = new NumericTerm({number: term.total, options: term.options});
}
intermediate.push(term);
}
this.terms = intermediate;
// Step 2 - Simplify remaining terms
this.terms = this.constructor.simplifyTerms(this.terms);
// Step 3 - Evaluate remaining terms
for ( let term of this.terms ) {
if ( !term._evaluated ) await term.evaluate({minimize, maximize, async: true});
}
// Step 4 - Evaluate the final expression
this._total = this._evaluateTotal();
return this;
}
/* -------------------------------------------- */
/**
* Evaluate the roll synchronously.
* A temporary helper method used to migrate behavior from 0.7.x (sync by default) to 0.9.x (async by default).
* @param {object} [options] Options which inform how evaluation is performed
* @param {boolean} [options.minimize] Force the result to be minimized
* @param {boolean} [options.maximize] Force the result to be maximized
* @returns {Roll}
* @private
*/
_evaluateSync({minimize=false, maximize=false}={}) {
// Step 1 - Replace intermediate terms with evaluated numbers
this.terms = this.terms.map(term => {
if ( !(term instanceof RollTerm) ) {
throw new Error("Roll evaluation encountered an invalid term which was not a RollTerm instance");
}
if ( term.isIntermediate ) {
term.evaluate({minimize, maximize, async: false});
this._dice = this._dice.concat(term.dice);
return new NumericTerm({number: term.total, options: term.options});
}
return term;
});
// Step 2 - Simplify remaining terms
this.terms = this.constructor.simplifyTerms(this.terms);
// Step 3 - Evaluate remaining terms
for ( let term of this.terms ) {
if ( !term._evaluated ) term.evaluate({minimize, maximize, async: false});
}
// Step 4 - Evaluate the final expression
this._total = this._evaluateTotal();
return this;
}
/* -------------------------------------------- */
/**
* Safely evaluate the final total result for the Roll using its component terms.
* @returns {number} The evaluated total
* @private
*/
_evaluateTotal() {
const expression = this.terms.map(t => t.total).join(" ");
const total = this.constructor.safeEval(expression);
if ( !Number.isNumeric(total) ) {
throw new Error(game.i18n.format("DICE.ErrorNonNumeric", {formula: this.formula}));
}
return total;
}
/* -------------------------------------------- */
/**
* Alias for evaluate.
* @param {object} options Options passed to Roll#evaluate
* @see {Roll#evaluate}
*/
roll(options={}) {
return this.evaluate(options);
}
/* -------------------------------------------- */
/**
* Create a new Roll object using the original provided formula and data.
* Each roll is immutable, so this method returns a new Roll instance using the same data.
* @param {object} [options={}] Evaluation options passed to Roll#evaluate
* @returns {Roll} A new Roll object, rolled using the same formula and data
*/
reroll(options={}) {
const r = this.clone();
return r.evaluate(options);
}
/* -------------------------------------------- */
/**
* Recompile the formula string that represents this Roll instance from its component terms.
* @returns {string} The re-compiled formula
*/
resetFormula() {
return this._formula = this.constructor.getFormula(this.terms);
}
/* -------------------------------------------- */
/* Static Class Methods */
/* -------------------------------------------- */
/**
* A factory method which constructs a Roll instance using the default configured Roll class.
* @param {string} formula The formula used to create the Roll instance
* @param {object} [data={}] The data object which provides component data for the formula
* @param {object} [options={}] Additional options which modify or describe this Roll
* @returns {Roll} The constructed Roll instance
*/
static create(formula, data={}, options={}) {
const cls = CONFIG.Dice.rolls[0];
return new cls(formula, data, options);
}
/* -------------------------------------------- */
/**
* Get the default configured Roll class.
* @returns {typeof Roll}
*/
static get defaultImplementation() {
return CONFIG.Dice.rolls[0];
}
/* -------------------------------------------- */
/**
* Transform an array of RollTerm objects into a cleaned string formula representation.
* @param {RollTerm[]} terms An array of terms to represent as a formula
* @returns {string} The string representation of the formula
*/
static getFormula(terms) {
return terms.map(t => t.formula).join("");
}
/* -------------------------------------------- */
/**
* A sandbox-safe evaluation function to execute user-input code with access to scoped Math methods.
* @param {string} expression The input string expression
* @returns {number} The numeric evaluated result
*/
static safeEval(expression) {
let result;
try {
// eslint-disable-next-line no-new-func
const evl = new Function("sandbox", `with (sandbox) { return ${expression}}`);
result = evl(this.MATH_PROXY);
} catch(err) {
result = undefined;
}
if ( !Number.isNumeric(result) ) {
throw new Error(`Roll.safeEval produced a non-numeric result from expression "${expression}"`);
}
return result;
}
/* -------------------------------------------- */
/**
* After parenthetical and arithmetic terms have been resolved, we need to simplify the remaining expression.
* Any remaining string terms need to be combined with adjacent non-operators in order to construct parsable terms.
* @param {RollTerm[]} terms An array of terms which is eligible for simplification
* @returns {RollTerm[]} An array of simplified terms
*/
static simplifyTerms(terms) {
// Simplify terms by combining with pending strings
let simplified = terms.reduce((terms, term) => {
const prior = terms[terms.length - 1];
const isOperator = term instanceof OperatorTerm;
// Combine a non-operator term with prior StringTerm
if ( !isOperator && (prior instanceof StringTerm) ) {
prior.term += term.total;
foundry.utils.mergeObject(prior.options, term.options);
return terms;
}
// Combine StringTerm with a prior non-operator term
const priorOperator = prior instanceof OperatorTerm;
if ( prior && !priorOperator && (term instanceof StringTerm) ) {
term.term = String(prior.total) + term.term;
foundry.utils.mergeObject(term.options, prior.options);
terms[terms.length - 1] = term;
return terms;
}
// Otherwise continue
terms.push(term);
return terms;
}, []);
// Convert remaining String terms to a RollTerm which can be evaluated
simplified = simplified.map(term => {
if ( !(term instanceof StringTerm) ) return term;
const t = this._classifyStringTerm(term.formula, {intermediate: false});
t.options = foundry.utils.mergeObject(term.options, t.options, {inplace: false});
return t;
});
// Eliminate leading or trailing arithmetic
if ( (simplified[0] instanceof OperatorTerm) && (simplified[0].operator !== "-") ) simplified.shift();
if ( simplified.at(-1) instanceof OperatorTerm ) simplified.pop();
return simplified;
}
/* -------------------------------------------- */
/**
* Simulate a roll and evaluate the distribution of returned results
* @param {string} formula The Roll expression to simulate
* @param {number} n The number of simulations
* @returns {Promise<number[]>} The rolled totals
*/
static async simulate(formula, n=10000) {
const results = await Promise.all([...Array(n)].map(async () => {
const r = new this(formula);
return (await r.evaluate({async: true})).total;
}, []));
const summary = results.reduce((sum, v) => {
sum.total = sum.total + v;
if ( (sum.min === null) || (v < sum.min) ) sum.min = v;
if ( (sum.max === null) || (v > sum.max) ) sum.max = v;
return sum;
}, {total: 0, min: null, max: null});
summary.mean = summary.total / n;
console.log(`Formula: ${formula} | Iterations: ${n} | Mean: ${summary.mean} | Min: ${summary.min} | Max: ${summary.max}`);
return results;
}
/* -------------------------------------------- */
/* Roll Formula Parsing */
/* -------------------------------------------- */
/**
* Parse a formula by following an order of operations:
*
* Step 1: Replace formula data
* Step 2: Split outer-most parenthetical groups
* Step 3: Further split outer-most dice pool groups
* Step 4: Further split string terms on arithmetic operators
* Step 5: Classify all remaining strings
*
* @param {string} formula The original string expression to parse
* @param {object} data A data object used to substitute for attributes in the formula
* @returns {RollTerm[]} A parsed array of RollTerm instances
*/
static parse(formula, data) {
if ( !formula ) return [];
// Step 1: Replace formula data and remove all spaces
let replaced = this.replaceFormulaData(formula, data, {missing: "0"});
// Step 2: Split outer-most outer-most parenthetical groups
let terms = this._splitParentheses(replaced);
// Step 3: Split additional dice pool groups which may contain inner rolls
terms = terms.flatMap(term => {
return typeof term === "string" ? this._splitPools(term) : term;
});
// Step 4: Further split string terms on arithmetic operators
terms = terms.flatMap(term => {
return typeof term === "string" ? this._splitOperators(term) : term;
});
// Step 5: Classify all remaining strings
terms = terms.map((t, i) => this._classifyStringTerm(t, {
intermediate: true,
prior: terms[i-1],
next: terms[i+1]
}));
return terms;
}
/* -------------------------------------------- */
/**
* Replace referenced data attributes in the roll formula with values from the provided data.
* Data references in the formula use the @attr syntax and would reference the corresponding attr key.
*
* @param {string} formula The original formula within which to replace
* @param {object} data The data object which provides replacements
* @param {object} [options] Options which modify formula replacement
* @param {string} [options.missing] The value that should be assigned to any unmatched keys.
* If null, the unmatched key is left as-is.
* @param {boolean} [options.warn=false] Display a warning notification when encountering an un-matched key.
* @static
*/
static replaceFormulaData(formula, data, {missing, warn=false}={}) {
let dataRgx = new RegExp(/@([a-z.0-9_-]+)/gi);
return formula.replace(dataRgx, (match, term) => {
let value = foundry.utils.getProperty(data, term);
if ( value == null ) {
if ( warn && ui.notifications ) ui.notifications.warn(game.i18n.format("DICE.WarnMissingData", {match}));
return (missing !== undefined) ? String(missing) : match;
}
return String(value).trim();
});
}
/* -------------------------------------------- */
/**
* Validate that a provided roll formula can represent a valid
* @param {string} formula A candidate formula to validate
* @returns {boolean} Is the provided input a valid dice formula?
*/
static validate(formula) {
// Replace all data references with an arbitrary number
formula = formula.replace(/@([a-z.0-9_-]+)/gi, "1");
// Attempt to evaluate the roll
try {
const r = new this(formula);
r.evaluate({async: false});
return true;
}
// If we weren't able to evaluate, the formula is invalid
catch(err) {
return false;
}
}
/* -------------------------------------------- */
/**
* Split a formula by identifying its outer-most parenthetical and math terms
* @param {string} _formula The raw formula to split
* @returns {string[]} An array of terms, split on parenthetical terms
* @private
*/
static _splitParentheses(_formula) {
return this._splitGroup(_formula, {
openRegexp: ParentheticalTerm.OPEN_REGEXP,
closeRegexp: ParentheticalTerm.CLOSE_REGEXP,
openSymbol: "(",
closeSymbol: ")",
onClose: group => {
// Extract group arguments
const fn = group.open.slice(0, -1);
const expression = group.terms.join("");
const options = { flavor: group.flavor ? group.flavor.slice(1, -1) : undefined };
// Classify the resulting terms
const terms = [];
if ( fn in Math ) {
const args = this._splitMathArgs(expression);
terms.push(new MathTerm({fn, terms: args, options}));
}
else {
if ( fn ) terms.push(fn);
terms.push(new ParentheticalTerm({term: expression, options}));
}
return terms;
}
});
}
/* -------------------------------------------- */
/**
* Handle closing of a parenthetical term to create a MathTerm expression with a function and arguments
* @param {string} expression The expression to split
* @returns {MathTerm[]} An array of evaluated MathTerm instances
* @private
*/
static _splitMathArgs(expression) {
return expression.split(",").reduce((args, t) => {
t = t.trim();
if ( !t ) return args; // Blank args
if ( !args.length ) { // First arg
args.push(t);
return args;
}
const p = args[args.length-1]; // Prior arg
const priorValid = this.validate(p);
if ( priorValid ) args.push(t);
else args[args.length-1] = [p, t].join(","); // Collect inner parentheses or pools
return args;
}, []);
}
/* -------------------------------------------- */
/**
* Split a formula by identifying its outermost dice pool terms.
* @param {string} _formula The raw formula to split
* @returns {string[]} An array of terms, split on parenthetical terms
* @private
*/
static _splitPools(_formula) {
return this._splitGroup(_formula, {
openRegexp: PoolTerm.OPEN_REGEXP,
closeRegexp: PoolTerm.CLOSE_REGEXP,
openSymbol: "{",
closeSymbol: "}",
onClose: group => {
const terms = this._splitMathArgs(group.terms.join(""));
const modifiers = Array.from(group.close.slice(1).matchAll(DiceTerm.MODIFIER_REGEXP)).map(m => m[0]);
const options = { flavor: group.flavor ? group.flavor.slice(1, -1) : undefined };
return [new PoolTerm({terms, modifiers, options})];
}
});
}
/* -------------------------------------------- */
/**
* Split a formula by identifying its outermost groups using a certain group symbol like parentheses or brackets.
* @param {string} _formula The raw formula to split
* @param {object} options Options that configure how groups are split
* @param {RegExp} [options.openRegexp] A regular expression that identifies opening groups
* @param {RegExp} [options.closeRegexp] A regular expression that identifies closing groups
* @param {string} [options.openSymbol] The string symbol that opens a group
* @param {string} [options.closeSymbol] The string symbol that closes a group
* @param {Function} [options.onClose] A callback function invoked when a group is closed
* @returns {string[]} An array of terms, split on dice pool terms
* @private
*/
static _splitGroup(_formula, {openRegexp, closeRegexp, openSymbol, closeSymbol, onClose}={}) {
let {formula, flavors} = this._extractFlavors(_formula);
// Split the formula on parentheses
const parts = formula.replace(openRegexp, ";$&;").replace(closeRegexp, ";$&;").split(";");
let terms = [];
let nOpen = 0;
let group = {openIndex: undefined, open: "", terms: [], close: "", closeIndex: undefined, flavor: undefined};
// Handle closing a group
const closeGroup = t => {
// Identify closing flavor text (and remove it)
const flavor = t.match(/\$\$F[0-9]+\$\$/);
if ( flavor ) {
group.flavor = this._restoreFlavor(flavor[0], flavors);
t = t.slice(0, flavor.index);
}
// Treat the remainder as the closing symbol
group.close = t;
// Restore flavor to member terms
group.terms = group.terms.map(t => this._restoreFlavor(t, flavors));
terms = terms.concat(onClose(group));
};
// Map parts to parenthetical groups
for ( let t of parts ) {
t = t.trim();
if ( !t ) continue;
// New open group
if ( t.endsWith(openSymbol) ) {
nOpen++;
// Open a new group
if ( nOpen === 1 ) {
group = {open: t, terms: [], close: "", flavor: undefined};
continue;
}
}
// Continue an opened group
if ( nOpen > 0 ) {
if ( t.startsWith(closeSymbol) ) {
nOpen--;
// Close the group
if ( nOpen === 0 ) {
closeGroup(t);
continue;
}
}
group.terms.push(t);
continue;
}
// Regular remaining terms
terms.push(t);
}
// If the group was not completely closed, continue closing it
if ( nOpen !== 0 ) {
throw new Error(`Unbalanced group missing opening ${openSymbol} or closing ${closeSymbol}`);
}
// Restore withheld flavor text and re-combine strings
terms = terms.reduce((terms, t) => {
if ( typeof t === "string" ) { // Re-combine string terms
t = this._restoreFlavor(t, flavors);
if ( typeof terms[terms.length-1] === "string" ) terms[terms.length-1] = terms[terms.length-1] + t;
else terms.push(t);
}
else terms.push(t); // Intermediate terms
return terms;
}, []);
return terms;
}
/* -------------------------------------------- */
/**
* Split a formula by identifying arithmetic terms
* @param {string} _formula The raw formula to split
* @returns {Array<(string|OperatorTerm)>} An array of terms, split on arithmetic operators
* @private
*/
static _splitOperators(_formula) {
let {formula, flavors} = this._extractFlavors(_formula);
const parts = formula.replace(OperatorTerm.REGEXP, ";$&;").split(";");
return parts.reduce((terms, t) => {
t = t.trim();
if ( !t ) return terms;
const isOperator = OperatorTerm.OPERATORS.includes(t);
terms.push(isOperator ? new OperatorTerm({operator: t}) : this._restoreFlavor(t, flavors));
return terms;
}, []);
}
/* -------------------------------------------- */
/**
* Temporarily remove flavor text from a string formula allowing it to be accurately parsed.
* @param {string} formula The formula to extract
* @returns {{formula: string, flavors: object}} The cleaned formula and extracted flavor mapping
* @private
*/
static _extractFlavors(formula) {
const flavors = {};
let fn = 0;
formula = formula.replace(RollTerm.FLAVOR_REGEXP, flavor => {
let key = `$$F${fn++}$$`;
flavors[key] = flavor;
return key;
});
return {formula, flavors};
}
/* -------------------------------------------- */
/**
* Restore flavor text to a string term
* @param {string} term The string term possibly containing flavor symbols
* @param {Object<string>} flavors The extracted flavors object
* @returns {string} The restored term containing flavor text
* @private
*/
static _restoreFlavor(term, flavors) {
for ( let [key, flavor] of Object.entries(flavors) ) {
if ( term.indexOf(key) !== -1 ) {
delete flavors[key];
term = term.replace(key, flavor);
}
}
return term;
}
/* -------------------------------------------- */
/**
* Classify a remaining string term into a recognized RollTerm class
* @param {string} term A remaining un-classified string
* @param {object} [options={}] Options which customize classification
* @param {boolean} [options.intermediate=true] Allow intermediate terms
* @param {RollTerm|string} [options.prior] The prior classified term
* @param {RollTerm|string} [options.next] The next term to classify
* @returns {RollTerm} A classified RollTerm instance
* @internal
*/
static _classifyStringTerm(term, {intermediate=true, prior, next}={}) {
// Terms already classified
if ( term instanceof RollTerm ) return term;
// Numeric terms
const numericMatch = NumericTerm.matchTerm(term);
if ( numericMatch ) return NumericTerm.fromMatch(numericMatch);
// Dice terms
const diceMatch = DiceTerm.matchTerm(term, {imputeNumber: !intermediate});
if ( diceMatch ) {
if ( intermediate && (prior?.isIntermediate || next?.isIntermediate) ) return new StringTerm({term});
return DiceTerm.fromMatch(diceMatch);
}
// Remaining strings
return new StringTerm({term});
}
/* -------------------------------------------- */
/* Chat Messages */
/* -------------------------------------------- */
/**
* Render the tooltip HTML for a Roll instance
* @returns {Promise<string>} The rendered HTML tooltip as a string
*/
async getTooltip() {
const parts = this.dice.map(d => d.getTooltipData());
return renderTemplate(this.constructor.TOOLTIP_TEMPLATE, { parts });
}
/* -------------------------------------------- */
/**
* Render a Roll instance to HTML
* @param {object} [options={}] Options which affect how the Roll is rendered
* @param {string} [options.flavor] Flavor text to include
* @param {string} [options.template] A custom HTML template path
* @param {boolean} [options.isPrivate=false] Is the Roll displayed privately?
* @returns {Promise<string>} The rendered HTML template as a string
*/
async render({flavor, template=this.constructor.CHAT_TEMPLATE, isPrivate=false}={}) {
if ( !this._evaluated ) await this.evaluate({async: true});
const chatData = {
formula: isPrivate ? "???" : this._formula,
flavor: isPrivate ? null : flavor,
user: game.user.id,
tooltip: isPrivate ? "" : await this.getTooltip(),
total: isPrivate ? "?" : Math.round(this.total * 100) / 100
};
return renderTemplate(template, chatData);
}
/* -------------------------------------------- */
/**
* Transform a Roll instance into a ChatMessage, displaying the roll result.
* This function can either create the ChatMessage directly, or return the data object that will be used to create.
*
* @param {object} messageData The data object to use when creating the message
* @param {options} [options] Additional options which modify the created message.
* @param {string} [options.rollMode] The template roll mode to use for the message from CONFIG.Dice.rollModes
* @param {boolean} [options.create=true] Whether to automatically create the chat message, or only return the
* prepared chatData object.
* @returns {Promise<ChatMessage|object>} A promise which resolves to the created ChatMessage document if create is
* true, or the Object of prepared chatData otherwise.
*/
async toMessage(messageData={}, {rollMode, create=true}={}) {
// Perform the roll, if it has not yet been rolled
if ( !this._evaluated ) await this.evaluate({async: true});
// Prepare chat data
messageData = foundry.utils.mergeObject({
user: game.user.id,
type: CONST.CHAT_MESSAGE_TYPES.ROLL,
content: String(this.total),
sound: CONFIG.sounds.dice
}, messageData);
messageData.rolls = [this];
// Either create the message or just return the chat data
const cls = getDocumentClass("ChatMessage");
const msg = new cls(messageData);
// Either create or return the data
if ( create ) return cls.create(msg.toObject(), { rollMode });
else {
if ( rollMode ) msg.applyRollMode(rollMode);
return msg.toObject();
}
}
/* -------------------------------------------- */
/* Interface Helpers */
/* -------------------------------------------- */
/**
* Expand an inline roll element to display its contained dice result as a tooltip.
* @param {HTMLAnchorElement} a The inline-roll button
* @returns {Promise<void>}
*/
static async expandInlineResult(a) {
if ( !a.classList.contains("inline-roll") ) return;
if ( a.classList.contains("expanded") ) return;
// Create a new tooltip
const roll = this.fromJSON(unescape(a.dataset.roll));
const tip = document.createElement("div");
tip.innerHTML = await roll.getTooltip();
// Add the tooltip
const tooltip = tip.querySelector(".dice-tooltip");
if ( !tooltip ) return;
a.appendChild(tooltip);
a.classList.add("expanded");
// Set the position
const pa = a.getBoundingClientRect();
const pt = tooltip.getBoundingClientRect();
tooltip.style.left = `${Math.min(pa.x, window.innerWidth - (pt.width + 3))}px`;
tooltip.style.top = `${Math.min(pa.y + pa.height + 3, window.innerHeight - (pt.height + 3))}px`;
const zi = getComputedStyle(a).zIndex;
tooltip.style.zIndex = Number.isNumeric(zi) ? zi + 1 : 100;
// Disable tooltip while expanded
delete a.dataset.tooltip;
game.tooltip.deactivate();
}
/* -------------------------------------------- */
/**
* Collapse an expanded inline roll to conceal its tooltip.
* @param {HTMLAnchorElement} a The inline-roll button
*/
static collapseInlineResult(a) {
if ( !a.classList.contains("inline-roll") ) return;
if ( !a.classList.contains("expanded") ) return;
const tooltip = a.querySelector(".dice-tooltip");
if ( tooltip ) tooltip.remove();
const roll = this.fromJSON(unescape(a.dataset.roll));
a.dataset.tooltip = roll.formula;
return a.classList.remove("expanded");
}
/* -------------------------------------------- */
/**
* Construct an inline roll link for this Roll.
* @param {object} [options] Additional options to configure how the link is constructed.
* @param {string} [options.label] A custom label for the total.
* @param {object<string>} [options.attrs] Attributes to set on the link.
* @param {object<string>} [options.dataset] Custom data attributes to set on the link.
* @param {string[]} [options.classes] Additional classes to add to the link. The classes `inline-roll`
* and `inline-result` are added by default.
* @param {string} [options.icon] A font-awesome icon class to use as the icon instead of a d20.
* @returns {HTMLAnchorElement}
*/
toAnchor({attrs={}, dataset={}, classes=[], label, icon}={}) {
dataset = foundry.utils.mergeObject({roll: escape(JSON.stringify(this))}, dataset);
const a = document.createElement("a");
a.classList.add("inline-roll", "inline-result", ...classes);
a.dataset.tooltip = this.formula;
Object.entries(attrs).forEach(([k, v]) => a.setAttribute(k, v));
Object.entries(dataset).forEach(([k, v]) => a.dataset[k] = v);
label = label ? `${label}: ${this.total}` : this.total;
a.innerHTML = `<i class="${icon ?? "fas fa-dice-d20"}"></i>${label}`;
return a;
}
/* -------------------------------------------- */
/* Serialization and Loading */
/* -------------------------------------------- */
/**
* Represent the data of the Roll as an object suitable for JSON serialization.
* @returns {object} Structured data which can be serialized into JSON
*/
toJSON() {
return {
class: this.constructor.name,
options: this.options,
dice: this._dice,
formula: this._formula,
terms: this.terms.map(t => t.toJSON()),
total: this._total,
evaluated: this._evaluated
};
}
/* -------------------------------------------- */
/**
* Recreate a Roll instance using a provided data object
* @param {object} data Unpacked data representing the Roll
* @returns {Roll} A reconstructed Roll instance
*/
static fromData(data) {
// Redirect to the proper Roll class definition
if ( data.class && (data.class !== this.name) ) {
const cls = CONFIG.Dice.rolls.find(cls => cls.name === data.class);
if ( !cls ) throw new Error(`Unable to recreate ${data.class} instance from provided data`);
return cls.fromData(data);
}
// Create the Roll instance
const roll = new this(data.formula, data.data, data.options);
// Expand terms
roll.terms = data.terms.map(t => {
if ( t.class ) {
if ( t.class === "DicePool" ) t.class = "PoolTerm"; // Backwards compatibility
return RollTerm.fromData(t);
}
return t;
});
// Repopulate evaluated state
if ( data.evaluated ?? true ) {
roll._total = data.total;
roll._dice = (data.dice || []).map(t => DiceTerm.fromData(t));
roll._evaluated = true;
}
return roll;
}
/* -------------------------------------------- */
/**
* Recreate a Roll instance using a provided JSON string
* @param {string} json Serialized JSON data representing the Roll
* @returns {Roll} A reconstructed Roll instance
*/
static fromJSON(json) {
return this.fromData(JSON.parse(json));
}
/* -------------------------------------------- */
/**
* Manually construct a Roll object by providing an explicit set of input terms
* @param {RollTerm[]} terms The array of terms to use as the basis for the Roll
* @param {object} [options={}] Additional options passed to the Roll constructor
* @returns {Roll} The constructed Roll instance
*
* @example Construct a Roll instance from an array of component terms
* ```js
* const t1 = new Die({number: 4, faces: 8};
* const plus = new OperatorTerm({operator: "+"});
* const t2 = new NumericTerm({number: 8});
* const roll = Roll.fromTerms([t1, plus, t2]);
* roll.formula; // 4d8 + 8
* ```
*/
static fromTerms(terms, options={}) {
// Validate provided terms
if ( !terms.every(t => t instanceof RollTerm ) ) {
throw new Error("All provided terms must be RollTerm instances");
}
const allEvaluated = terms.every(t => t._evaluated);
const noneEvaluated = !terms.some(t => t._evaluated);
if ( !(allEvaluated || noneEvaluated) ) {
throw new Error("You can only call Roll.fromTerms with an array of terms which are either all evaluated, or none evaluated");
}
// Construct the roll
const formula = this.getFormula(terms);
const roll = new this(formula, {}, options);
roll.terms = terms;
roll._evaluated = allEvaluated;
if ( roll._evaluated ) roll._total = roll._evaluateTotal();
return roll;
}
}
/**
* An abstract class which represents a single token that can be used as part of a Roll formula.
* Every portion of a Roll formula is parsed into a subclass of RollTerm in order for the Roll to be fully evaluated.
*/
class RollTerm {
constructor({options={}}={}) {
/**
* An object of additional options which describes and modifies the term.
* @type {object}
*/
this.options = options;
/**
* An internal flag for whether the term has been evaluated
* @type {boolean}
*/
this._evaluated = false;
}
/**
* Is this term intermediate, and should be evaluated first as part of the simplification process?
* @type {boolean}
*/
isIntermediate = false;
/**
* A regular expression pattern which identifies optional term-level flavor text
* @type {string}
*/
static FLAVOR_REGEXP_STRING = "(?:\\[([^\\]]+)\\])";
/**
* A regular expression which identifies term-level flavor text
* @type {RegExp}
*/
static FLAVOR_REGEXP = new RegExp(RollTerm.FLAVOR_REGEXP_STRING, "g");
/**
* A regular expression used to match a term of this type
* @type {RegExp}
*/
static REGEXP = undefined;
/**
* An array of additional attributes which should be retained when the term is serialized
* @type {string[]}
*/
static SERIALIZE_ATTRIBUTES = [];
/* -------------------------------------------- */
/* RollTerm Attributes */
/* -------------------------------------------- */
/**
* A string representation of the formula expression for this RollTerm, prior to evaluation.
* @type {string}
*/
get expression() {
throw new Error(`The ${this.constructor.name} class must implement the expression attribute`);
}
/**
* A string representation of the formula, including optional flavor text.
* @type {string}
*/
get formula() {
let f = this.expression;
if ( this.flavor ) f += `[${this.flavor}]`;
return f;
}
/**
* A string or numeric representation of the final output for this term, after evaluation.
* @type {number|string}
*/
get total() {
throw new Error(`The ${this.constructor.name} class must implement the total attribute`);
}
/**
* Optional flavor text which modifies and describes this term.
* @type {string}
*/
get flavor() {
return this.options.flavor || "";
}
/**
* Whether this term is entirely deterministic or contains some randomness.
* @type {boolean}
*/
get isDeterministic() {
return true;
}
/* -------------------------------------------- */
/* RollTerm Methods */
/* -------------------------------------------- */
/**
* Evaluate the term, processing its inputs and finalizing its total.
* @param {object} [options={}] Options which modify how the RollTerm is evaluated
* @param {boolean} [options.minimize=false] Minimize the result, obtaining the smallest possible value.
* @param {boolean} [options.maximize=false] Maximize the result, obtaining the largest possible value.
* @param {boolean} [options.async=false] Evaluate the term asynchronously, receiving a Promise as the returned value.
* This will become the default behavior in version 10.x
* @returns {RollTerm} The evaluated RollTerm
*/
evaluate({minimize=false, maximize=false, async=false}={}) {
if ( this._evaluated ) {
throw new Error(`The ${this.constructor.name} has already been evaluated and is now immutable`);
}
this._evaluated = true;
return async ? this._evaluate({minimize, maximize}) : this._evaluateSync({minimize, maximize});
}
/**
* Evaluate the term.
* @param {object} [options={}] Options which modify how the RollTerm is evaluated, see RollTerm#evaluate
* @returns {Promise<RollTerm>}
* @private
*/
async _evaluate({minimize=false, maximize=false}={}) {
return this._evaluateSync({minimize, maximize});
}
/**
* This method is temporarily factored out in order to provide different behaviors synchronous evaluation.
* This will be removed in 0.10.x
* @private
*/
_evaluateSync({minimize=false, maximize=false}={}) {
return this;
}
/* -------------------------------------------- */
/* Serialization and Loading */
/* -------------------------------------------- */
/**
* Construct a RollTerm from a provided data object
* @param {object} data Provided data from an un-serialized term
* @return {RollTerm} The constructed RollTerm
*/
static fromData(data) {
let cls = CONFIG.Dice.termTypes[data.class];
if ( !cls ) cls = Object.values(CONFIG.Dice.terms).find(c => c.name === data.class) || Die;
return cls._fromData(data);
}
/* -------------------------------------------- */
/**
* Define term-specific logic for how a de-serialized data object is restored as a functional RollTerm
* @param {object} data The de-serialized term data
* @returns {RollTerm} The re-constructed RollTerm object
* @protected
*/
static _fromData(data) {
const term = new this(data);
term._evaluated = data.evaluated ?? true;
return term;
}
/* -------------------------------------------- */
/**
* Reconstruct a RollTerm instance from a provided JSON string
* @param {string} json A serialized JSON representation of a DiceTerm
* @return {RollTerm} A reconstructed RollTerm from the provided JSON
*/
static fromJSON(json) {
let data;
try {
data = JSON.parse(json);
} catch(err) {
throw new Error("You must pass a valid JSON string");
}
return this.fromData(data);
}
/* -------------------------------------------- */
/**
* Serialize the RollTerm to a JSON string which allows it to be saved in the database or embedded in text.
* This method should return an object suitable for passing to the JSON.stringify function.
* @return {object}
*/
toJSON() {
const data = {
class: this.constructor.name,
options: this.options,
evaluated: this._evaluated
};
for ( let attr of this.constructor.SERIALIZE_ATTRIBUTES ) {
data[attr] = this[attr];
}
return data;
}
}
/**
* A standalone, pure JavaScript implementation of the Mersenne Twister pseudo random number generator.
*
* @author Raphael Pigulla <pigulla@four66.com>
* @version 0.2.3
* @license
* Copyright (C) 1997 - 2002, Makoto Matsumoto and Takuji Nishimura,
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* 3. The names of its contributors may not be used to endorse or promote
* products derived from this software without specific prior written
* permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
class MersenneTwister {
/**
* Instantiates a new Mersenne Twister.
* @param {number} [seed] The initial seed value, if not provided the current timestamp will be used.
* @constructor
*/
constructor(seed) {
// Initial values
this.MAX_INT = 4294967296.0;
this.N = 624;
this.M = 397;
this.UPPER_MASK = 0x80000000;
this.LOWER_MASK = 0x7fffffff;
this.MATRIX_A = 0x9908b0df;
// Initialize sequences
this.mt = new Array(this.N);
this.mti = this.N + 1;
this.SEED = this.seed(seed ?? new Date().getTime());
};
/**
* Initializes the state vector by using one unsigned 32-bit integer "seed", which may be zero.
*
* @since 0.1.0
* @param {number} seed The seed value.
*/
seed(seed) {
this.SEED = seed;
let s;
this.mt[0] = seed >>> 0;
for (this.mti = 1; this.mti < this.N; this.mti++) {
s = this.mt[this.mti - 1] ^ (this.mt[this.mti - 1] >>> 30);
this.mt[this.mti] =
(((((s & 0xffff0000) >>> 16) * 1812433253) << 16) + (s & 0x0000ffff) * 1812433253) + this.mti;
this.mt[this.mti] >>>= 0;
}
return seed;
};
/**
* Initializes the state vector by using an array key[] of unsigned 32-bit integers of the specified length. If
* length is smaller than 624, then each array of 32-bit integers gives distinct initial state vector. This is
* useful if you want a larger seed space than 32-bit word.
*
* @since 0.1.0
* @param {array} vector The seed vector.
*/
seedArray(vector) {
let i = 1, j = 0, k = this.N > vector.length ? this.N : vector.length, s;
this.seed(19650218);
for (; k > 0; k--) {
s = this.mt[i - 1] ^ (this.mt[i - 1] >>> 30);
this.mt[i] = (this.mt[i] ^ (((((s & 0xffff0000) >>> 16) * 1664525) << 16) + ((s & 0x0000ffff) * 1664525))) +
vector[j] + j;
this.mt[i] >>>= 0;
i++;
j++;
if (i >= this.N) {
this.mt[0] = this.mt[this.N-1];
i = 1;
}
if (j >= vector.length) {
j = 0;
}
}
for (k = this.N-1; k; k--) {
s = this.mt[i - 1] ^ (this.mt[i - 1] >>> 30);
this.mt[i] =
(this.mt[i] ^ (((((s & 0xffff0000) >>> 16) * 1566083941) << 16) + (s & 0x0000ffff) * 1566083941)) - i;
this.mt[i] >>>= 0;
i++;
if (i >= this.N) {
this.mt[0] = this.mt[this.N - 1];
i = 1;
}
}
this.mt[0] = 0x80000000;
};
/**
* Generates a random unsigned 32-bit integer.
*
* @since 0.1.0
* @returns {number}
*/
int() {
let y, kk, mag01 = [0, this.MATRIX_A];
if (this.mti >= this.N) {
if (this.mti === this.N+1) {
this.seed(5489);
}
for (kk = 0; kk < this.N - this.M; kk++) {
y = (this.mt[kk] & this.UPPER_MASK) | (this.mt[kk + 1] & this.LOWER_MASK);
this.mt[kk] = this.mt[kk + this.M] ^ (y >>> 1) ^ mag01[y & 1];
}
for (; kk < this.N - 1; kk++) {
y = (this.mt[kk] & this.UPPER_MASK) | (this.mt[kk + 1] & this.LOWER_MASK);
this.mt[kk] = this.mt[kk + (this.M - this.N)] ^ (y >>> 1) ^ mag01[y & 1];
}
y = (this.mt[this.N - 1] & this.UPPER_MASK) | (this.mt[0] & this.LOWER_MASK);
this.mt[this.N - 1] = this.mt[this.M - 1] ^ (y >>> 1) ^ mag01[y & 1];
this.mti = 0;
}
y = this.mt[this.mti++];
y ^= (y >>> 11);
y ^= (y << 7) & 0x9d2c5680;
y ^= (y << 15) & 0xefc60000;
y ^= (y >>> 18);
return y >>> 0;
};
/**
* Generates a random unsigned 31-bit integer.
*
* @since 0.1.0
* @returns {number}
*/
int31() {
return this.int() >>> 1;
};
/**
* Generates a random real in the interval [0;1] with 32-bit resolution.
*
* @since 0.1.0
* @returns {number}
*/
real() {
return this.int() * (1.0 / (this.MAX_INT - 1));
};
/**
* Generates a random real in the interval ]0;1[ with 32-bit resolution.
*
* @since 0.1.0
* @returns {number}
*/
realx() {
return (this.int() + 0.5) * (1.0 / this.MAX_INT);
};
/**
* Generates a random real in the interval [0;1[ with 32-bit resolution.
*
* @since 0.1.0
* @returns {number}
*/
rnd() {
return this.int() * (1.0 / this.MAX_INT);
};
/**
* Generates a random real in the interval [0;1[ with 32-bit resolution.
*
* Same as .rnd() method - for consistency with Math.random() interface.
*
* @since 0.2.0
* @returns {number}
*/
random() {
return this.rnd();
};
/**
* Generates a random real in the interval [0;1[ with 53-bit resolution.
*
* @since 0.1.0
* @returns {number}
*/
rndHiRes() {
const a = this.int() >>> 5;
const b = this.int() >>> 6;
return (a * 67108864.0 + b) * (1.0 / 9007199254740992.0);
};
/**
* A pseudo-normal distribution using the Box-Muller transform.
* @param {number} mu The normal distribution mean
* @param {number} sigma The normal distribution standard deviation
* @returns {number}
*/
normal(mu, sigma) {
let u = 0;
while (u === 0) u = this.random(); // Converting [0,1) to (0,1)
let v = 0;
while (v === 0) v = this.random(); // Converting [0,1) to (0,1)
let n = Math.sqrt( -2.0 * Math.log(u) ) * Math.cos(2.0 * Math.PI * v);
return (n * sigma) + mu;
}
/**
* A factory method for generating random uniform rolls
* @returns {number}
*/
static random() {
return twist.random();
}
/**
* A factory method for generating random normal rolls
* @return {number}
*/
static normal(...args) {
return twist.normal(...args);
}
}
// Global singleton
const twist = new MersenneTwister(Date.now());
/**
* @typedef {Object} DiceTermResult
* @property {number} result The numeric result
* @property {boolean} [active] Is this result active, contributing to the total?
* @property {number} [count] A value that the result counts as, otherwise the result is not used directly as
* @property {boolean} [success] Does this result denote a success?
* @property {boolean} [failure] Does this result denote a failure?
* @property {boolean} [discarded] Was this result discarded?
* @property {boolean} [rerolled] Was this result rerolled?
* @property {boolean} [exploded] Was this result exploded?
*/
/**
* An abstract base class for any type of RollTerm which involves randomized input from dice, coins, or other devices.
* @extends RollTerm
*
* @param {object} termData Data used to create the Dice Term, including the following:
* @param {number} [termData.number=1] The number of dice of this term to roll, before modifiers are applied
* @param {number} termData.faces The number of faces on each die of this type
* @param {string[]} [termData.modifiers] An array of modifiers applied to the results
* @param {object[]} [termData.results] An optional array of pre-cast results for the term
* @param {object} [termData.options] Additional options that modify the term
*/
class DiceTerm extends RollTerm {
constructor({number=1, faces=6, modifiers=[], results=[], options={}}) {
super({options});
/**
* The number of dice of this term to roll, before modifiers are applied
* @type {number}
*/
this.number = number;
/**
* The number of faces on the die
* @type {number}
*/
this.faces = faces;
/**
* An Array of dice term modifiers which are applied
* @type {string[]}
*/
this.modifiers = modifiers;
/**
* The array of dice term results which have been rolled
* @type {DiceTermResult[]}
*/
this.results = results;
// If results were explicitly passed, the term has already been evaluated
if ( results.length ) this._evaluated = true;
}
/* -------------------------------------------- */
/**
* Define the denomination string used to register this DiceTerm type in CONFIG.Dice.terms
* @type {string}
*/
static DENOMINATION = "";
/**
* Define the named modifiers that can be applied for this particular DiceTerm type.
* @type {{string: (string|Function)}}
*/
static MODIFIERS = {};
/**
* A regular expression pattern which captures the full set of term modifiers
* Anything until a space, group symbol, or arithmetic operator
* @type {string}
*/
static MODIFIERS_REGEXP_STRING = "([^ (){}[\\]+\\-*/]+)";
/**
* A regular expression used to separate individual modifiers
* @type {RegExp}
*/
static MODIFIER_REGEXP = /([A-z]+)([^A-z\s()+\-*\/]+)?/g
/** @inheritdoc */
static REGEXP = new RegExp(`^([0-9]+)?[dD]([A-z]|[0-9]+)${DiceTerm.MODIFIERS_REGEXP_STRING}?${DiceTerm.FLAVOR_REGEXP_STRING}?$`);
/** @inheritdoc */
static SERIALIZE_ATTRIBUTES = ["number", "faces", "modifiers", "results"];
/* -------------------------------------------- */
/* Dice Term Attributes */
/* -------------------------------------------- */
/** @inheritdoc */
get expression() {
const x = this.constructor.DENOMINATION === "d" ? this.faces : this.constructor.DENOMINATION;
return `${this.number}d${x}${this.modifiers.join("")}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
get total() {
if ( !this._evaluated ) return undefined;
return this.results.reduce((t, r) => {
if ( !r.active ) return t;
if ( r.count !== undefined ) return t + r.count;
else return t + r.result;
}, 0);
}
/* -------------------------------------------- */
/**
* Return an array of rolled values which are still active within this term
* @type {number[]}
*/
get values() {
return this.results.reduce((arr, r) => {
if ( !r.active ) return arr;
arr.push(r.result);
return arr;
}, []);
}
/* -------------------------------------------- */
/** @inheritdoc */
get isDeterministic() {
return false;
}
/* -------------------------------------------- */
/* Dice Term Methods */
/* -------------------------------------------- */
/**
* Alter the DiceTerm by adding or multiplying the number of dice which are rolled
* @param {number} multiply A factor to multiply. Dice are multiplied before any additions.
* @param {number} add A number of dice to add. Dice are added after multiplication.
* @return {DiceTerm} The altered term
*/
alter(multiply, add) {
if ( this._evaluated ) throw new Error(`You may not alter a DiceTerm after it has already been evaluated`);
multiply = Number.isFinite(multiply) && (multiply >= 0) ? multiply : 1;
add = Number.isInteger(add) ? add : 0;
if ( multiply >= 0 ) this.number = Math.round(this.number * multiply);
if ( add ) this.number += add;
return this;
}
/* -------------------------------------------- */
/** @inheritdoc */
_evaluateSync({minimize=false, maximize=false}={}) {
if ( (this.number > 999) ) {
throw new Error(`You may not evaluate a DiceTerm with more than 999 requested results`);
}
for ( let n=1; n <= this.number; n++ ) {
this.roll({minimize, maximize});
}
this._evaluateModifiers();
return this;
}
/* -------------------------------------------- */
/**
* Roll the DiceTerm by mapping a random uniform draw against the faces of the dice term.
* @param {object} [options={}] Options which modify how a random result is produced
* @param {boolean} [options.minimize=false] Minimize the result, obtaining the smallest possible value.
* @param {boolean} [options.maximize=false] Maximize the result, obtaining the largest possible value.
* @return {DiceTermResult} The produced result
*/
roll({minimize=false, maximize=false}={}) {
const roll = {result: undefined, active: true};
if ( minimize ) roll.result = Math.min(1, this.faces);
else if ( maximize ) roll.result = this.faces;
else roll.result = Math.ceil(CONFIG.Dice.randomUniform() * this.faces);
this.results.push(roll);
return roll;
}
/* -------------------------------------------- */
/**
* Return a string used as the label for each rolled result
* @param {DiceTermResult} result The rolled result
* @return {string} The result label
*/
getResultLabel(result) {
return String(result.result);
}
/* -------------------------------------------- */
/**
* Get the CSS classes that should be used to display each rolled result
* @param {DiceTermResult} result The rolled result
* @return {string[]} The desired classes
*/
getResultCSS(result) {
const hasSuccess = result.success !== undefined;
const hasFailure = result.failure !== undefined;
const isMax = result.result === this.faces;
const isMin = result.result === 1;
return [
this.constructor.name.toLowerCase(),
"d" + this.faces,
result.success ? "success" : null,
result.failure ? "failure" : null,
result.rerolled ? "rerolled" : null,
result.exploded ? "exploded" : null,
result.discarded ? "discarded" : null,
!(hasSuccess || hasFailure) && isMin ? "min" : null,
!(hasSuccess || hasFailure) && isMax ? "max" : null
]
}
/* -------------------------------------------- */
/**
* Render the tooltip HTML for a Roll instance
* @return {object} The data object used to render the default tooltip template for this DiceTerm
*/
getTooltipData() {
return {
formula: this.expression,
total: this.total,
faces: this.faces,
flavor: this.flavor,
rolls: this.results.map(r => {
return {
result: this.getResultLabel(r),
classes: this.getResultCSS(r).filterJoin(" ")
}
})
};
}
/* -------------------------------------------- */
/* Modifier Methods */
/* -------------------------------------------- */
/**
* Sequentially evaluate each dice roll modifier by passing the term to its evaluation function
* Augment or modify the results array.
* @private
*/
_evaluateModifiers() {
const cls = this.constructor;
const requested = foundry.utils.deepClone(this.modifiers);
this.modifiers = [];
// Iterate over requested modifiers
for ( let m of requested ) {
let command = m.match(/[A-z]+/)[0].toLowerCase();
// Matched command
if ( command in cls.MODIFIERS ) {
this._evaluateModifier(command, m);
continue;
}
// Unmatched compound command
// Sort modifiers from longest to shortest to ensure that the matching algorithm greedily matches the longest
// prefixes first.
const modifiers = Object.keys(cls.MODIFIERS).sort((a, b) => b.length - a.length);
while ( !!command ) {
let matched = false;
for ( let cmd of modifiers ) {
if ( command.startsWith(cmd) ) {
matched = true;
this._evaluateModifier(cmd, cmd);
command = command.replace(cmd, "");
break;
}
}
if ( !matched ) command = "";
}
}
}
/* -------------------------------------------- */
/**
* Evaluate a single modifier command, recording it in the array of evaluated modifiers
* @param {string} command The parsed modifier command
* @param {string} modifier The full modifier request
* @private
*/
_evaluateModifier(command, modifier) {
let fn = this.constructor.MODIFIERS[command];
if ( typeof fn === "string" ) fn = this[fn];
if ( fn instanceof Function ) {
const result = fn.call(this, modifier);
const earlyReturn = (result === false) || (result === this); // handling this is backwards compatibility
if ( !earlyReturn ) this.modifiers.push(modifier.toLowerCase());
}
}
/* -------------------------------------------- */
/**
* A helper comparison function.
* Returns a boolean depending on whether the result compares favorably against the target.
* @param {number} result The result being compared
* @param {string} comparison The comparison operator in [=,&lt;,&lt;=,>,>=]
* @param {number} target The target value
* @return {boolean} Is the comparison true?
*/
static compareResult(result, comparison, target) {
switch ( comparison ) {
case "=":
return result === target;
case "<":
return result < target;
case "<=":
return result <= target;
case ">":
return result > target;
case ">=":
return result >= target;
}
}
/* -------------------------------------------- */
/**
* A helper method to modify the results array of a dice term by flagging certain results are kept or dropped.
* @param {object[]} results The results array
* @param {number} number The number to keep or drop
* @param {boolean} [keep] Keep results?
* @param {boolean} [highest] Keep the highest?
* @return {object[]} The modified results array
*/
static _keepOrDrop(results, number, {keep=true, highest=true}={}) {
// Sort remaining active results in ascending (keep) or descending (drop) order
const ascending = keep === highest;
const values = results.reduce((arr, r) => {
if ( r.active ) arr.push(r.result);
return arr;
}, []).sort((a, b) => ascending ? a - b : b - a);
// Determine the cut point, beyond which to discard
number = Math.clamped(keep ? values.length - number : number, 0, values.length);
const cut = values[number];
// Track progress
let discarded = 0;
const ties = [];
let comp = ascending ? "<" : ">";
// First mark results on the wrong side of the cut as discarded
results.forEach(r => {
if ( !r.active ) return; // Skip results which have already been discarded
let discard = this.compareResult(r.result, comp, cut);
if ( discard ) {
r.discarded = true;
r.active = false;
discarded++;
}
else if ( r.result === cut ) ties.push(r);
});
// Next discard ties until we have reached the target
ties.forEach(r => {
if ( discarded < number ) {
r.discarded = true;
r.active = false;
discarded++;
}
});
return results;
}
/* -------------------------------------------- */
/**
* A reusable helper function to handle the identification and deduction of failures
*/
static _applyCount(results, comparison, target, {flagSuccess=false, flagFailure=false}={}) {
for ( let r of results ) {
let success = this.compareResult(r.result, comparison, target);
if (flagSuccess) {
r.success = success;
if (success) delete r.failure;
}
else if (flagFailure ) {
r.failure = success;
if (success) delete r.success;
}
r.count = success ? 1 : 0;
}
}
/* -------------------------------------------- */
/**
* A reusable helper function to handle the identification and deduction of failures
*/
static _applyDeduct(results, comparison, target, {deductFailure=false, invertFailure=false}={}) {
for ( let r of results ) {
// Flag failures if a comparison was provided
if (comparison) {
const fail = this.compareResult(r.result, comparison, target);
if ( fail ) {
r.failure = true;
delete r.success;
}
}
// Otherwise treat successes as failures
else {
if ( r.success === false ) {
r.failure = true;
delete r.success;
}
}
// Deduct failures
if ( deductFailure ) {
if ( r.failure ) r.count = -1;
}
else if ( invertFailure ) {
if ( r.failure ) r.count = -1 * r.result;
}
}
}
/* -------------------------------------------- */
/* Factory Methods */
/* -------------------------------------------- */
/**
* Determine whether a string expression matches this type of term
* @param {string} expression The expression to parse
* @param {object} [options={}] Additional options which customize the match
* @param {boolean} [options.imputeNumber=true] Allow the number of dice to be optional, i.e. "d6"
* @return {RegExpMatchArray|null}
*/
static matchTerm(expression, {imputeNumber=true}={}) {
const match = expression.match(this.REGEXP);
if ( !match ) return null;
if ( (match[1] === undefined) && !imputeNumber ) return null;
return match;
}
/* -------------------------------------------- */
/**
* Construct a term of this type given a matched regular expression array.
* @param {RegExpMatchArray} match The matched regular expression array
* @return {DiceTerm} The constructed term
*/
static fromMatch(match) {
let [number, denomination, modifiers, flavor] = match.slice(1);
// Get the denomination of DiceTerm
denomination = denomination.toLowerCase();
const cls = denomination in CONFIG.Dice.terms ? CONFIG.Dice.terms[denomination] : CONFIG.Dice.terms.d;
if ( !foundry.utils.isSubclass(cls, DiceTerm) ) {
throw new Error(`DiceTerm denomination ${denomination} not registered to CONFIG.Dice.terms as a valid DiceTerm class`);
}
// Get the term arguments
number = Number.isNumeric(number) ? parseInt(number) : 1;
const faces = Number.isNumeric(denomination) ? parseInt(denomination) : null;
// Match modifiers
modifiers = Array.from((modifiers || "").matchAll(DiceTerm.MODIFIER_REGEXP)).map(m => m[0]);
// Construct a term of the appropriate denomination
return new cls({number, faces, modifiers, options: {flavor}});
}
}
/**
* A type of RollTerm used to apply a function from the Math library.
* @extends {RollTerm}
*/
class MathTerm extends RollTerm {
constructor({fn, terms=[], options}={}) {
super({options});
/**
* The named function in the Math environment which should be applied to the term
* @type {string}
*/
this.fn = fn;
/**
* An array of string argument terms for the function
* @type {string[]}
*/
this.terms = terms;
}
/**
* The cached Roll instances for each function argument
* @type {Roll[]}
*/
rolls = [];
/**
* The cached result of evaluating the method arguments
* @type {number}
*/
result = undefined;
/** @inheritdoc */
isIntermediate = true;
/** @inheritdoc */
static SERIALIZE_ATTRIBUTES = ["fn", "terms"];
/* -------------------------------------------- */
/* Math Term Attributes */
/* -------------------------------------------- */
/**
* An array of evaluated DiceTerm instances that should be bubbled up to the parent Roll
* @type {DiceTerm[]}
*/
get dice() {
return this._evaluated ? this.rolls.reduce((arr, r) => arr.concat(r.dice), []) : undefined;
}
/** @inheritdoc */
get total() {
return this.result;
}
/** @inheritdoc */
get expression() {
return `${this.fn}(${this.terms.join(",")})`;
}
/** @inheritdoc */
get isDeterministic() {
return this.terms.every(t => Roll.create(t).isDeterministic);
}
/* -------------------------------------------- */
/* Math Term Methods */
/* -------------------------------------------- */
/** @inheritdoc */
_evaluateSync({minimize=false, maximize=false}={}) {
this.rolls = this.terms.map(a => {
const roll = Roll.create(a);
roll.evaluate({minimize, maximize, async: false});
if ( this.flavor ) roll.terms.forEach(t => t.options.flavor = t.options.flavor ?? this.flavor);
return roll;
});
const args = this.rolls.map(r => r.total).join(", ");
this.result = Roll.defaultImplementation.safeEval(`${this.fn}(${args})`);
return this;
}
/** @inheritdoc */
async _evaluate({minimize=false, maximize=false}={}) {
for ( let term of this.terms ) {
const roll = Roll.create(term);
await roll.evaluate({minimize, maximize, async: true});
if ( this.flavor ) roll.terms.forEach(t => t.options.flavor = t.options.flavor ?? this.flavor);
this.rolls.push(roll);
}
const args = this.rolls.map(r => r.total).join(", ");
this.result = Roll.defaultImplementation.safeEval(`${this.fn}(${args})`);
return this;
}
}
/**
* A type of RollTerm used to represent static numbers.
* @extends {RollTerm}
*/
class NumericTerm extends RollTerm {
constructor({number, options}={}) {
super({options});
this.number = Number(number);
}
/** @inheritdoc */
static REGEXP = new RegExp(`^([0-9]+(?:\\.[0-9]+)?)${RollTerm.FLAVOR_REGEXP_STRING}?$`);
/** @inheritdoc */
static SERIALIZE_ATTRIBUTES = ["number"];
/** @inheritdoc */
get expression() {
return String(this.number);
}
/** @inheritdoc */
get total() {
return this.number;
}
/* -------------------------------------------- */
/* Factory Methods */
/* -------------------------------------------- */
/**
* Determine whether a string expression matches a NumericTerm
* @param {string} expression The expression to parse
* @return {RegExpMatchArray|null}
*/
static matchTerm(expression) {
return expression.match(this.REGEXP) || null;
}
/* -------------------------------------------- */
/**
* Construct a term of this type given a matched regular expression array.
* @param {RegExpMatchArray} match The matched regular expression array
* @return {NumericTerm} The constructed term
*/
static fromMatch(match) {
let [number, flavor] = match.slice(1);
return new this({number, options: {flavor}});
}
}
/**
* A type of RollTerm used to denote and perform an arithmetic operation.
* @extends {RollTerm}
*/
class OperatorTerm extends RollTerm {
constructor({operator, options}={}) {
super({options});
this.operator = operator;
}
/**
* An array of operators which represent arithmetic operations
* @type {string[]}
*/
static OPERATORS = ["+", "-", "*", "/", "%"];
/** @inheritdoc */
static REGEXP = new RegExp(OperatorTerm.OPERATORS.map(o => "\\"+o).join("|"), "g");
/** @inheritdoc */
static SERIALIZE_ATTRIBUTES = ["operator"];
/** @inheritdoc */
get flavor() {
return ""; // Operator terms cannot have flavor text
}
/** @inheritdoc */
get expression() {
return ` ${this.operator} `;
}
/** @inheritdoc */
get total() {
return ` ${this.operator} `;
}
}
/**
* A type of RollTerm used to enclose a parenthetical expression to be recursively evaluated.
* @extends {RollTerm}
*/
class ParentheticalTerm extends RollTerm {
constructor({term, roll, options}) {
super({options});
/**
* The original provided string term used to construct the parenthetical
* @type {string}
*/
this.term = term;
/**
* Alternatively, an already-evaluated Roll instance may be passed directly
* @type {Roll}
*/
this.roll = roll;
// If a roll was explicitly passed in, the parenthetical has already been evaluated
if ( this.roll ) {
this.term = roll.formula;
this._evaluated = this.roll._evaluated;
}
}
/** @inheritdoc */
isIntermediate = true;
/**
* The regular expression pattern used to identify the opening of a parenthetical expression.
* This could also identify the opening of a math function.
* @type {RegExp}
*/
static OPEN_REGEXP = /([A-z][A-z0-9]+)?\(/g;
/**
* A regular expression pattern used to identify the closing of a parenthetical expression.
* @type {RegExp}
*/
static CLOSE_REGEXP = new RegExp("\\)(?:\\$\\$F[0-9]+\\$\\$)?", "g");
/** @inheritdoc */
static SERIALIZE_ATTRIBUTES = ["term"];
/* -------------------------------------------- */
/* Parenthetical Term Attributes */
/* -------------------------------------------- */
/**
* An array of evaluated DiceTerm instances that should be bubbled up to the parent Roll
* @type {DiceTerm[]}
*/
get dice() {
return this.roll?.dice;
}
/** @inheritdoc */
get total() {
return this.roll.total;
}
/** @inheritdoc */
get expression() {
return `(${this.term})`;
}
/** @inheritdoc */
get isDeterministic() {
return Roll.create(this.term).isDeterministic;
}
/* -------------------------------------------- */
/* Parenthetical Term Methods */
/* -------------------------------------------- */
/** @inheritdoc */
_evaluateSync({minimize=false, maximize=false}={}) {
// Evaluate the inner Roll
const roll = this.roll || Roll.create(this.term);
this.roll = roll.evaluate({minimize, maximize, async: false});
// Propagate flavor text to inner terms
if ( this.flavor ) this.roll.terms.forEach(t => t.options.flavor = t.options.flavor ?? this.flavor);
return this;
}
/** @inheritdoc */
async _evaluate({minimize=false, maximize=false}={}) {
// Evaluate the inner Roll
const roll = this.roll || Roll.create(this.term);
this.roll = await roll.evaluate({minimize, maximize, async: true});
// Propagate flavor text to inner terms
if ( this.flavor ) this.roll.terms.forEach(t => t.options.flavor = t.options.flavor ?? this.flavor);
return this;
}
/* -------------------------------------------- */
/**
* Construct a ParentheticalTerm from an Array of component terms which should be wrapped inside the parentheses.
* @param {RollTerm[]} terms The array of terms to use as internal parts of the parenthetical
* @param {object} [options={}] Additional options passed to the ParentheticalTerm constructor
* @returns {ParentheticalTerm} The constructed ParentheticalTerm instance
*
* @example Create a Parenthetical Term from an array of component RollTerm instances
* ```js
* const d6 = new Die({number: 4, faces: 6});
* const plus = new OperatorTerm({operator: "+"});
* const bonus = new NumericTerm({number: 4});
* t = ParentheticalTerm.fromTerms([d6, plus, bonus]);
* t.formula; // (4d6 + 4)
* ```
*/
static fromTerms(terms, options) {
const roll = Roll.defaultImplementation.fromTerms(terms);
return new this({roll, options});
}
}
/**
* A type of RollTerm which encloses a pool of multiple inner Rolls which are evaluated jointly.
*
* A dice pool represents a set of Roll expressions which are collectively modified to compute an effective total
* across all Rolls in the pool. The final total for the pool is defined as the sum over kept rolls, relative to any
* success count or margin.
*
* @example Keep the highest of the 3 roll expressions
* ```js
* let pool = new PoolTerm({
* terms: ["4d6", "3d8 - 1", "2d10 + 3"],
* modifiers: ["kh"]
* });
* pool.evaluate();
* ```
*/
class PoolTerm extends RollTerm {
constructor({terms=[], modifiers=[], rolls=[], results=[], options={}}={}) {
super({options});
/**
* The original provided terms to the Dice Pool
* @type {string[]}
*/
this.terms = terms;
/**
* The string modifiers applied to resolve the pool
* @type {string[]}
*/
this.modifiers = modifiers;
/**
* Each component term of a dice pool is evaluated as a Roll instance
* @type {Roll[]}
*/
this.rolls = (rolls.length === terms.length) ? rolls : this.terms.map(t => Roll.create(t));
/**
* The array of dice pool results which have been rolled
* @type {DiceTermResult[]}
*/
this.results = results;
// If rolls and results were explicitly passed, the term has already been evaluated
if ( rolls.length && results.length ) this._evaluated = true;
}
/* -------------------------------------------- */
/**
* Define the modifiers that can be used for this particular DiceTerm type.
* @type {Object<string, Function>}
*/
static MODIFIERS = {
"k": "keep",
"kh": "keep",
"kl": "keep",
"d": "drop",
"dh": "drop",
"dl": "drop",
"cs": "countSuccess",
"cf": "countFailures"
};
/**
* The regular expression pattern used to identify the opening of a dice pool expression.
* @type {RegExp}
*/
static OPEN_REGEXP = /{/g;
/**
* A regular expression pattern used to identify the closing of a dice pool expression.
* @type {RegExp}
*/
static CLOSE_REGEXP = new RegExp(`}${DiceTerm.MODIFIERS_REGEXP_STRING}?(?:\\$\\$F[0-9]+\\$\\$)?`, "g");
/**
* A regular expression pattern used to match the entirety of a DicePool expression.
* @type {RegExp}
*/
static REGEXP = new RegExp(`{([^}]+)}${DiceTerm.MODIFIERS_REGEXP_STRING}?(?:\\$\\$F[0-9]+\\$\\$)?`);
/** @inheritdoc */
static SERIALIZE_ATTRIBUTES = ["terms", "modifiers", "rolls", "results"];
/* -------------------------------------------- */
/* Dice Pool Attributes */
/* -------------------------------------------- */
/**
* Return an Array of each individual DiceTerm instances contained within the PoolTerm.
* @return {DiceTerm[]}
*/
get dice() {
return this.rolls.flatMap(r => r.dice);
}
/* -------------------------------------------- */
/** @inheritdoc */
get expression() {
return `{${this.terms.join(",")}}${this.modifiers.join("")}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
get total() {
if ( !this._evaluated ) return undefined;
return this.results.reduce((t, r) => {
if ( !r.active ) return t;
if ( r.count !== undefined ) return t + r.count;
else return t + r.result;
}, 0);
}
/* -------------------------------------------- */
/**
* Return an array of rolled values which are still active within the PoolTerm
* @type {number[]}
*/
get values() {
return this.results.reduce((arr, r) => {
if ( !r.active ) return arr;
arr.push(r.result);
return arr;
}, []);
}
/* -------------------------------------------- */
/** @inheritdoc */
get isDeterministic() {
return this.terms.every(t => Roll.create(t).isDeterministic);
}
/* -------------------------------------------- */
/**
* Alter the DiceTerm by adding or multiplying the number of dice which are rolled
* @param {any[]} args Arguments passed to each contained Roll#alter method.
* @return {PoolTerm} The altered pool
*/
alter(...args) {
this.rolls.forEach(r => r.alter(...args));
return this;
}
/* -------------------------------------------- */
/** @inheritdoc */
_evaluateSync({minimize=false, maximize=false}={}) {
for ( let roll of this.rolls ) {
roll.evaluate({minimize, maximize, async: false});
if ( this.flavor ) roll.terms.forEach(t => t.options.flavor = t.options.flavor ?? this.flavor);
this.results.push({
result: roll.total,
active: true
});
}
this._evaluateModifiers();
return this;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _evaluate({minimize=false, maximize=false}={}) {
for ( let roll of this.rolls ) {
await roll.evaluate({minimize, maximize, async: true});
if ( this.flavor ) roll.terms.forEach(t => t.options.flavor = t.options.flavor ?? this.flavor);
this.results.push({
result: roll.total,
active: true
});
}
this._evaluateModifiers();
return this;
}
/* -------------------------------------------- */
/**
* Use the same logic as for the DiceTerm to avoid duplication
* @see DiceTerm#_evaluateModifiers
*/
_evaluateModifiers() {
return DiceTerm.prototype._evaluateModifiers.call(this);
}
/* -------------------------------------------- */
/**
* Use the same logic as for the DiceTerm to avoid duplication
* @see DiceTerm#_evaluateModifier
*/
_evaluateModifier(command, modifier) {
return DiceTerm.prototype._evaluateModifier.call(this, command, modifier);
}
/* -------------------------------------------- */
/* Saving and Loading */
/* -------------------------------------------- */
/** @inheritdoc */
static _fromData(data) {
data.rolls = (data.rolls || []).map(r => {
const cls = CONFIG.Dice.rolls.find(cls => cls.name === r.class) || Roll;
return cls.fromData(r)
});
return super._fromData(data);
}
/* -------------------------------------------- */
/** @inheritdoc */
toJSON() {
const data = super.toJSON();
data.rolls = data.rolls.map(r => r.toJSON());
return data;
}
/* -------------------------------------------- */
/**
* Given a string formula, create and return an evaluated PoolTerm object
* @param {string} formula The string formula to parse
* @param {object} [options] Additional options applied to the PoolTerm
* @return {PoolTerm|null} The evaluated PoolTerm object or null if the formula is invalid
*/
static fromExpression(formula, options={}) {
const rgx = formula.trim().match(this.REGEXP);
if ( !rgx ) return null;
let [terms, modifiers] = rgx.slice(1);
terms = terms.split(",");
modifiers = Array.from((modifiers || "").matchAll(DiceTerm.MODIFIER_REGEXP)).map(m => m[0]);
return new this({terms, modifiers, options});
}
/* -------------------------------------------- */
/**
* Create a PoolTerm by providing an array of existing Roll objects
* @param {Roll[]} rolls An array of Roll objects from which to create the pool
* @returns {RollTerm} The constructed PoolTerm comprised of the provided rolls
*/
static fromRolls(rolls=[]) {
const allEvaluated = rolls.every(t => t._evaluated);
const noneEvaluated = !rolls.some(t => t._evaluated);
if ( !(allEvaluated || noneEvaluated) ) {
throw new Error("You can only call PoolTerm.fromRolls with an array of Roll instances which are either all evaluated, or none evaluated");
}
const pool = new this({
terms: rolls.map(r => r.formula),
modifiers: [],
rolls: rolls,
results: allEvaluated ? rolls.map(r => ({result: r.total, active: true})) : []
});
pool._evaluated = allEvaluated;
return pool;
}
/* -------------------------------------------- */
/* Modifiers */
/* -------------------------------------------- */
/**
* Keep a certain number of highest or lowest dice rolls from the result set.
*
* {1d6,1d8,1d10,1d12}kh2 Keep the 2 best rolls from the pool
* {1d12,6}kl Keep the lowest result in the pool
*
* @param {string} modifier The matched modifier query
*/
keep(modifier) {
return Die.prototype.keep.call(this, modifier);
}
/* -------------------------------------------- */
/**
* Keep a certain number of highest or lowest dice rolls from the result set.
*
* {1d6,1d8,1d10,1d12}dl3 Drop the 3 worst results in the pool
* {1d12,6}dh Drop the highest result in the pool
*
* @param {string} modifier The matched modifier query
*/
drop(modifier) {
return Die.prototype.drop.call(this, modifier);
}
/* -------------------------------------------- */
/**
* Count the number of successful results which occurred in the pool.
* Successes are counted relative to some target, or relative to the maximum possible value if no target is given.
* Applying a count-success modifier to the results re-casts all results to 1 (success) or 0 (failure)
*
* 20d20cs Count the number of dice which rolled a 20
* 20d20cs>10 Count the number of dice which rolled higher than 10
* 20d20cs<10 Count the number of dice which rolled less than 10
*
* @param {string} modifier The matched modifier query
*/
countSuccess(modifier) {
return Die.prototype.countSuccess.call(this, modifier);
}
/* -------------------------------------------- */
/**
* Count the number of failed results which occurred in a given result set.
* Failures are counted relative to some target, or relative to the lowest possible value if no target is given.
* Applying a count-failures modifier to the results re-casts all results to 1 (failure) or 0 (non-failure)
*
* 6d6cf Count the number of dice which rolled a 1 as failures
* 6d6cf<=3 Count the number of dice which rolled less than 3 as failures
* 6d6cf>4 Count the number of dice which rolled greater than 4 as failures
*
* @param {string} modifier The matched modifier query
*/
countFailures(modifier) {
return Die.prototype.countFailures.call(this, modifier);
}
}
/**
* A type of RollTerm used to represent strings which have not yet been matched.
* @extends {RollTerm}
*/
class StringTerm extends RollTerm {
constructor({term, options}={}) {
super({options});
this.term = term;
}
/** @inheritdoc */
static SERIALIZE_ATTRIBUTES = ["term"];
/** @inheritdoc */
get expression() {
return this.term;
}
/** @inheritdoc */
get total() {
return this.term;
}
/** @inheritdoc */
get isDeterministic() {
const classified = Roll.defaultImplementation._classifyStringTerm(this.term, {intermediate: false});
if ( classified instanceof StringTerm ) return true;
return classified.isDeterministic;
}
/** @inheritdoc */
evaluate(options={}) {
throw new Error(`Unresolved StringTerm ${this.term} requested for evaluation`);
}
}
/**
* A type of DiceTerm used to represent flipping a two-sided coin.
* @implements {DiceTerm}
*/
class Coin extends DiceTerm {
constructor(termData) {
super(termData);
this.faces = 2;
}
/** @inheritdoc */
static DENOMINATION = "c";
/** @inheritdoc */
static MODIFIERS = {
"c": "call"
};
/* -------------------------------------------- */
/** @inheritdoc */
roll({minimize=false, maximize=false}={}) {
const roll = {result: undefined, active: true};
if ( minimize ) roll.result = 0;
else if ( maximize ) roll.result = 1;
else roll.result = Math.round(CONFIG.Dice.randomUniform());
this.results.push(roll);
return roll;
}
/* -------------------------------------------- */
/** @inheritdoc */
getResultLabel(result) {
return {
"0": "T",
"1": "H"
}[result.result];
}
/* -------------------------------------------- */
/** @inheritdoc */
getResultCSS(result) {
return [
this.constructor.name.toLowerCase(),
result.result === 1 ? "heads" : "tails",
result.success ? "success" : null,
result.failure ? "failure" : null
]
}
/* -------------------------------------------- */
/* Term Modifiers */
/* -------------------------------------------- */
/**
* Call the result of the coin flip, marking any coins that matched the called target as a success
* 3dcc1 Flip 3 coins and treat "heads" as successes
* 2dcc0 Flip 2 coins and treat "tails" as successes
* @param {string} modifier The matched modifier query
*/
call(modifier) {
// Match the modifier
const rgx = /c([01])/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [target] = match.slice(1);
target = parseInt(target);
// Treat each result which matched the call as a success
for ( let r of this.results ) {
const match = r.result === target;
r.count = match ? 1 : 0;
r.success = match;
}
}
}
/**
* A type of DiceTerm used to represent rolling a fair n-sided die.
* @implements {DiceTerm}
*
* @example Roll four six-sided dice
* ```js
* let die = new Die({faces: 6, number: 4}).evaluate();
* ```
*/
class Die extends DiceTerm {
constructor(termData={}) {
super(termData);
if ( typeof this.faces !== "number" ) {
throw new Error("A Die term must have a numeric number of faces.");
}
}
/** @inheritdoc */
static DENOMINATION = "d";
/** @inheritdoc */
static MODIFIERS = {
r: "reroll",
rr: "rerollRecursive",
x: "explode",
xo: "explodeOnce",
k: "keep",
kh: "keep",
kl: "keep",
d: "drop",
dh: "drop",
dl: "drop",
min: "minimum",
max: "maximum",
even: "countEven",
odd: "countOdd",
cs: "countSuccess",
cf: "countFailures",
df: "deductFailures",
sf: "subtractFailures",
ms: "marginSuccess"
};
/* -------------------------------------------- */
/** @inheritdoc */
get total() {
const total = super.total;
if ( this.options.marginSuccess ) return total - parseInt(this.options.marginSuccess);
else if ( this.options.marginFailure ) return parseInt(this.options.marginFailure) - total;
else return total;
}
/* -------------------------------------------- */
/* Term Modifiers */
/* -------------------------------------------- */
/**
* Re-roll the Die, rolling additional results for any values which fall within a target set.
* If no target number is specified, re-roll the lowest possible result.
*
* 20d20r reroll all 1s
* 20d20r1 reroll all 1s
* 20d20r=1 reroll all 1s
* 20d20r1=1 reroll a single 1
*
* @param {string} modifier The matched modifier query
* @param {boolean} recursive Reroll recursively, continuing to reroll until the condition is no longer met
* @returns {boolean|void} False if the modifier was unmatched
*/
reroll(modifier, {recursive=false}={}) {
// Match the re-roll modifier
const rgx = /rr?([0-9]+)?([<>=]+)?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [max, comparison, target] = match.slice(1);
// If no comparison or target are provided, treat the max as the target
if ( max && !(target || comparison) ) {
target = max;
max = null;
}
// Determine target values
max = Number.isNumeric(max) ? parseInt(max) : null;
target = Number.isNumeric(target) ? parseInt(target) : 1;
comparison = comparison || "=";
// Recursively reroll until there are no remaining results to reroll
let checked = 0;
let initial = this.results.length;
while ( checked < this.results.length ) {
let r = this.results[checked];
checked++;
if (!r.active) continue;
// Maybe we have run out of rerolls
if ( (max !== null) && (max <= 0) ) break;
// Determine whether to re-roll the result
if ( DiceTerm.compareResult(r.result, comparison, target) ) {
r.rerolled = true;
r.active = false;
this.roll();
if ( max !== null ) max -= 1;
}
// Limit recursion
if ( !recursive && (checked >= initial) ) checked = this.results.length;
if ( checked > 1000 ) throw new Error("Maximum recursion depth for exploding dice roll exceeded");
}
}
/**
* @see {@link Die#reroll}
*/
rerollRecursive(modifier) {
return this.reroll(modifier, {recursive: true});
}
/* -------------------------------------------- */
/**
* Explode the Die, rolling additional results for any values which match the target set.
* If no target number is specified, explode the highest possible result.
* Explosion can be a "small explode" using a lower-case x or a "big explode" using an upper-case "X"
*
* @param {string} modifier The matched modifier query
* @param {boolean} recursive Explode recursively, such that new rolls can also explode?
*/
explode(modifier, {recursive=true}={}) {
// Match the "explode" or "explode once" modifier
const rgx = /xo?([0-9]+)?([<>=]+)?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [max, comparison, target] = match.slice(1);
// If no comparison or target are provided, treat the max as the target value
if ( max && !(target || comparison) ) {
target = max;
max = null;
}
// Determine target values
target = Number.isNumeric(target) ? parseInt(target) : this.faces;
comparison = comparison || "=";
// Determine the number of allowed explosions
max = Number.isNumeric(max) ? parseInt(max) : null;
// Recursively explode until there are no remaining results to explode
let checked = 0;
const initial = this.results.length;
while ( checked < this.results.length ) {
let r = this.results[checked];
checked++;
if (!r.active) continue;
// Maybe we have run out of explosions
if ( (max !== null) && (max <= 0) ) break;
// Determine whether to explode the result and roll again!
if ( DiceTerm.compareResult(r.result, comparison, target) ) {
r.exploded = true;
this.roll();
if ( max !== null ) max -= 1;
}
// Limit recursion
if ( !recursive && (checked === initial) ) break;
if ( checked > 1000 ) throw new Error("Maximum recursion depth for exploding dice roll exceeded");
}
}
/**
* @see {@link Die#explode}
*/
explodeOnce(modifier) {
return this.explode(modifier, {recursive: false});
}
/* -------------------------------------------- */
/**
* Keep a certain number of highest or lowest dice rolls from the result set.
*
* 20d20k Keep the 1 highest die
* 20d20kh Keep the 1 highest die
* 20d20kh10 Keep the 10 highest die
* 20d20kl Keep the 1 lowest die
* 20d20kl10 Keep the 10 lowest die
*
* @param {string} modifier The matched modifier query
*/
keep(modifier) {
const rgx = /k([hl])?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [direction, number] = match.slice(1);
direction = direction ? direction.toLowerCase() : "h";
number = parseInt(number) || 1;
DiceTerm._keepOrDrop(this.results, number, {keep: true, highest: direction === "h"});
}
/* -------------------------------------------- */
/**
* Drop a certain number of highest or lowest dice rolls from the result set.
*
* 20d20d Drop the 1 lowest die
* 20d20dh Drop the 1 highest die
* 20d20dl Drop the 1 lowest die
* 20d20dh10 Drop the 10 highest die
* 20d20dl10 Drop the 10 lowest die
*
* @param {string} modifier The matched modifier query
*/
drop(modifier) {
const rgx = /d([hl])?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [direction, number] = match.slice(1);
direction = direction ? direction.toLowerCase() : "l";
number = parseInt(number) || 1;
DiceTerm._keepOrDrop(this.results, number, {keep: false, highest: direction !== "l"});
}
/* -------------------------------------------- */
/**
* Count the number of successful results which occurred in a given result set.
* Successes are counted relative to some target, or relative to the maximum possible value if no target is given.
* Applying a count-success modifier to the results re-casts all results to 1 (success) or 0 (failure)
*
* 20d20cs Count the number of dice which rolled a 20
* 20d20cs>10 Count the number of dice which rolled higher than 10
* 20d20cs<10 Count the number of dice which rolled less than 10
*
* @param {string} modifier The matched modifier query
*/
countSuccess(modifier) {
const rgx = /(?:cs)([<>=]+)?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [comparison, target] = match.slice(1);
comparison = comparison || "=";
target = parseInt(target) ?? this.faces;
DiceTerm._applyCount(this.results, comparison, target, {flagSuccess: true});
}
/* -------------------------------------------- */
/**
* Count the number of failed results which occurred in a given result set.
* Failures are counted relative to some target, or relative to the lowest possible value if no target is given.
* Applying a count-failures modifier to the results re-casts all results to 1 (failure) or 0 (non-failure)
*
* 6d6cf Count the number of dice which rolled a 1 as failures
* 6d6cf<=3 Count the number of dice which rolled less than 3 as failures
* 6d6cf>4 Count the number of dice which rolled greater than 4 as failures
*
* @param {string} modifier The matched modifier query
*/
countFailures(modifier) {
const rgx = /(?:cf)([<>=]+)?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [comparison, target] = match.slice(1);
comparison = comparison || "=";
target = parseInt(target) ?? 1;
DiceTerm._applyCount(this.results, comparison, target, {flagFailure: true});
}
/* -------------------------------------------- */
/**
* Count the number of even results which occurred in a given result set.
* Even numbers are marked as a success and counted as 1
* Odd numbers are marked as a non-success and counted as 0.
*
* 6d6even Count the number of even numbers rolled
*
* @param {string} modifier The matched modifier query
*/
countEven(modifier) {
for ( let r of this.results ) {
r.success = ( (r.result % 2) === 0 );
r.count = r.success ? 1 : 0;
}
}
/* -------------------------------------------- */
/**
* Count the number of odd results which occurred in a given result set.
* Odd numbers are marked as a success and counted as 1
* Even numbers are marked as a non-success and counted as 0.
*
* 6d6odd Count the number of odd numbers rolled
*
* @param {string} modifier The matched modifier query
*/
countOdd(modifier) {
for ( let r of this.results ) {
r.success = ( (r.result % 2) !== 0 );
r.count = r.success ? 1 : 0;
}
}
/* -------------------------------------------- */
/**
* Deduct the number of failures from the dice result, counting each failure as -1
* Failures are identified relative to some target, or relative to the lowest possible value if no target is given.
* Applying a deduct-failures modifier to the results counts all failed results as -1.
*
* 6d6df Subtract the number of dice which rolled a 1 from the non-failed total.
* 6d6cs>3df Subtract the number of dice which rolled a 3 or less from the non-failed count.
* 6d6cf<3df Subtract the number of dice which rolled less than 3 from the non-failed count.
*
* @param {string} modifier The matched modifier query
*/
deductFailures(modifier) {
const rgx = /(?:df)([<>=]+)?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [comparison, target] = match.slice(1);
if ( comparison || target ) {
comparison = comparison || "=";
target = parseInt(target) ?? 1;
}
DiceTerm._applyDeduct(this.results, comparison, target, {deductFailure: true});
}
/* -------------------------------------------- */
/**
* Subtract the value of failed dice from the non-failed total, where each failure counts as its negative value.
* Failures are identified relative to some target, or relative to the lowest possible value if no target is given.
* Applying a deduct-failures modifier to the results counts all failed results as -1.
*
* 6d6df<3 Subtract the value of results which rolled less than 3 from the non-failed total.
*
* @param {string} modifier The matched modifier query
*/
subtractFailures(modifier) {
const rgx = /(?:sf)([<>=]+)?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [comparison, target] = match.slice(1);
if ( comparison || target ) {
comparison = comparison || "=";
target = parseInt(target) ?? 1;
}
DiceTerm._applyDeduct(this.results, comparison, target, {invertFailure: true});
}
/* -------------------------------------------- */
/**
* Subtract the total value of the DiceTerm from a target value, treating the difference as the final total.
* Example: 6d6ms>12 Roll 6d6 and subtract 12 from the resulting total.
* @param {string} modifier The matched modifier query
*/
marginSuccess(modifier) {
const rgx = /(?:ms)([<>=]+)?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [comparison, target] = match.slice(1);
target = parseInt(target);
if ( [">", ">=", "=", undefined].includes(comparison) ) this.options.marginSuccess = target;
else if ( ["<", "<="].includes(comparison) ) this.options.marginFailure = target;
}
/* -------------------------------------------- */
/**
* Constrain each rolled result to be at least some minimum value.
* Example: 6d6min2 Roll 6d6, each result must be at least 2
* @param {string} modifier The matched modifier query
*/
minimum(modifier) {
const rgx = /(?:min)([0-9]+)/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [target] = match.slice(1);
target = parseInt(target);
for ( let r of this.results ) {
if ( r.result < target ) {
r.count = target;
r.rerolled = true;
}
}
}
/* -------------------------------------------- */
/**
* Constrain each rolled result to be at most some maximum value.
* Example: 6d6max5 Roll 6d6, each result must be at most 5
* @param {string} modifier The matched modifier query
*/
maximum(modifier) {
const rgx = /(?:max)([0-9]+)/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [target] = match.slice(1);
target = parseInt(target);
for ( let r of this.results ) {
if ( r.result > target ) {
r.count = target;
r.rerolled = true;
}
}
}
}
/**
* A type of DiceTerm used to represent a three-sided Fate/Fudge die.
* Mathematically behaves like 1d3-2
* @extends {DiceTerm}
*/
class FateDie extends DiceTerm {
constructor(termData) {
super(termData);
this.faces = 3;
}
/** @inheritdoc */
static DENOMINATION = "f";
/** @inheritdoc */
static MODIFIERS = {
"r": Die.prototype.reroll,
"rr": Die.prototype.rerollRecursive,
"k": Die.prototype.keep,
"kh": Die.prototype.keep,
"kl": Die.prototype.keep,
"d": Die.prototype.drop,
"dh": Die.prototype.drop,
"dl": Die.prototype.drop
}
/* -------------------------------------------- */
/** @inheritdoc */
roll({minimize=false, maximize=false}={}) {
const roll = {result: undefined, active: true};
if ( minimize ) roll.result = -1;
else if ( maximize ) roll.result = 1;
else roll.result = Math.ceil((CONFIG.Dice.randomUniform() * this.faces) - 2);
if ( roll.result === -1 ) roll.failure = true;
if ( roll.result === 1 ) roll.success = true;
this.results.push(roll);
return roll;
}
/* -------------------------------------------- */
/** @inheritdoc */
getResultLabel(result) {
return {
"-1": "-",
"0": "&nbsp;",
"1": "+"
}[result.result];
}
}
/**
* 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;
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @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);
}
};
}
/**
* The client-side database backend implementation which handles Document modification operations.
*/
class ClientDatabaseBackend extends foundry.abstract.DatabaseBackend {
/* -------------------------------------------- */
/* Document Modification Operations */
/* -------------------------------------------- */
/** @inheritdoc */
async _getDocuments(documentClass, {query, options, pack}, user) {
const type = documentClass.documentName;
// Dispatch the request
const request = {action: "get", type, query, options, pack};
const response = await SocketInterface.dispatch("modifyDocument", request);
// Return the index only
if ( options.index ) return response.result;
// Create Document objects
return response.result.map(data => {
return documentClass.fromSource(data, {pack});
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async _createDocuments(documentClass, context, user) {
const {options, pack, parent} = context;
// Prepare to create documents
const toCreate = await ClientDatabaseBackend.#preCreateDocumentArray(documentClass, context, user);
if ( !toCreate.length || options.temporary ) return toCreate;
// Dispatch the request
const request = ClientDatabaseBackend.#buildRequest({action: "create", data: toCreate, documentClass,
options, pack, parent});
const response = await SocketInterface.dispatch("modifyDocument", request);
// Handle document creation
return this.#handleCreateDocuments(response);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateDocuments(documentClass, context, user) {
const {options, parent, pack} = context;
const type = documentClass.documentName;
const collection = ClientDatabaseBackend.#getCollection(type, parent, pack);
// Prepare to update documents
const toUpdate = await ClientDatabaseBackend.#preUpdateDocumentArray(collection, context, user);
if ( !toUpdate.length ) return [];
// Dispatch the request
const request = ClientDatabaseBackend.#buildRequest({action: "update", updates: toUpdate, documentClass,
options, pack, parent});
const response = await SocketInterface.dispatch("modifyDocument", request);
// Handle document update
return this.#handleUpdateDocuments(response);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _deleteDocuments(documentClass, context, user) {
const {ids, options, parent, pack} = context;
const type = documentClass.documentName;
const collection = ClientDatabaseBackend.#getCollection(type, parent, pack);
// Prepare to delete documents
const deleteIds = options.deleteAll ? Array.from(collection instanceof CompendiumCollection
? collection.index.keys() : collection.keys()) : ids;
const toDelete = await ClientDatabaseBackend.#preDeleteDocumentArray(collection, deleteIds, options, user);
if ( !toDelete.length ) return [];
// Dispatch the request
const request = ClientDatabaseBackend.#buildRequest({action: "delete", ids: toDelete, documentClass,
options, pack, parent});
const response = await SocketInterface.dispatch("modifyDocument", request);
// Handle document deletions
return this.#handleDeleteDocuments(response);
}
/* -------------------------------------------- */
/* Socket Workflows */
/* -------------------------------------------- */
/**
* Activate the Socket event listeners used to receive responses from events which modify database documents
* @param {Socket} socket The active game socket
*/
activateSocketListeners(socket) {
// Document Operations
socket.on("modifyDocument", response => {
const {request} = response;
switch ( request.action ) {
case "create": return this.#handleCreateDocuments(response);
case "update": return this.#handleUpdateDocuments(response);
case "delete": return this.#handleDeleteDocuments(response);
default:
throw new Error(`Invalid Document modification action ${request.action} provided`);
}
});
}
/* -------------------------------------------- */
/* Internal Helper Methods */
/* -------------------------------------------- */
/**
* Perform a standardized pre-creation workflow for all Document types. For internal use only.
* @param {typeof Document} documentClass
* @param {SocketRequest} request
* @param {User} user
*/
static async #preCreateDocumentArray(documentClass, request, user) {
const {data, options, pack, parent} = request;
user = user || game.user;
const type = documentClass.documentName;
const toCreate = [];
for ( let d of data ) {
// Handle DataModel instances
if ( d instanceof foundry.abstract.DataModel ) d = d.toObject();
else if ( Object.keys(d).some(k => k.indexOf(".") !== -1) ) d = foundry.utils.expandObject(d);
else d = foundry.utils.deepClone(d);
// Migrate the creation data specifically for downstream compatibility
const createData = foundry.utils.deepClone(documentClass.migrateData(d));
// Perform pre-creation operations
let doc;
try {
doc = new documentClass(d, {parent, pack});
} catch(err) {
Hooks.onError("ClientDatabaseBackend##preCreateDocumentArray", err, {id: d._id, log: "error", notify: "error"});
continue;
}
let allowed = await doc._preCreate(createData, options, user) ?? true;
allowed &&= (options.noHook || Hooks.call(`preCreate${type}`, doc, createData, options, user.id));
if ( allowed === false ) {
console.debug(`${vtt} | ${type} creation prevented during pre-create`);
continue;
}
toCreate.push(doc);
}
return toCreate;
}
/* -------------------------------------------- */
/**
* Handle a SocketResponse from the server when one or multiple documents were created
* @param {SocketResponse} response The provided Socket response
* @param {SocketRequest} [response.request] The initial socket request
* @param {object[]} [response.result] An Array of created data objects
* @param {string} [response.userId] The id of the requesting User
* @returns {Promise<Document[]>} An Array of created Document instances
*/
async #handleCreateDocuments({request, result=[], userId}) {
const {type, options, pack, parentUuid} = request;
const parent = await ClientDatabaseBackend.#getParent(parentUuid);
const collection = ClientDatabaseBackend.#getCollection(type, parent, pack);
// Pre-operation actions
const preArgs = [result, options, userId];
if ( parent ) parent._dispatchDescendantDocumentEvents("preCreate", collection.name, preArgs);
else collection._preCreateDocuments(...preArgs);
// Perform creations and execute callbacks
const callbacks = ClientDatabaseBackend.#postCreateDocumentCallbacks(collection, result, parent, pack, options,
userId);
parent?.reset();
const documents = callbacks.map(fn => fn());
// Post-operation actions
const postArgs = [documents, result, options, userId];
if ( parent ) parent._dispatchDescendantDocumentEvents("onCreate", collection.name, postArgs);
else collection._onCreateDocuments(...postArgs);
// Log and return result
this._logOperation("Created", type, documents, {level: "info", parent, pack});
return ClientDatabaseBackend.#buildResponse({ action: "create", documents, options });
}
/* -------------------------------------------- */
/**
* Perform a standardized post-creation workflow for all Document types. For internal use only.
* @param {DocumentCollection} collection
* @param {object[]} result
* @param {ClientDocument} parent
* @param {string} pack
* @param {object} options
* @param {string} userId
* @returns {Array<function():Document>} An array of callback operations performed after every Document is created
*/
static #postCreateDocumentCallbacks(collection, result, parent, pack, options, userId) {
const cls = collection.documentClass;
const callback = (doc, data) => {
doc._onCreate(data, options, userId);
Hooks.callAll(`create${cls.documentName}`, doc, options, userId);
return doc;
};
return result.map(data => {
const doc = collection.createDocument(data, {parent, pack});
collection.set(doc.id, doc, options);
return callback.bind(this, doc, data);
});
}
/* -------------------------------------------- */
/**
* Perform a standardized pre-update workflow for all Document types.
* @param {DocumentCollection} collection
* @param {SocketRequest} request
* @param {User} user
*/
static async #preUpdateDocumentArray(collection, request, user) {
const {updates, options} = request;
user = user || game.user;
const cls = collection.documentClass;
const toUpdate = [];
if ( collection instanceof CompendiumCollection ) {
const updateIds = updates.reduce((arr, u) => {
if ( u._id && !collection.has(u._id) ) arr.push(u._id);
return arr;
}, []);
await collection.getDocuments({_id__in: updateIds});
}
// Iterate over requested changes
for ( let update of updates ) {
if ( !update._id ) throw new Error("You must provide an _id for every object in the update data Array.");
// Retrieve the change object
let changes;
if ( update instanceof foundry.abstract.DataModel ) changes = update.toObject();
else changes = foundry.utils.expandObject(update);
// Get the Document being updated
const doc = collection.get(update._id, {strict: true, invalid: true});
// Ensure that Document sub-type is included
const addType = ("type" in doc) && !("type" in changes);
if ( addType ) changes.type = doc.type;
// Migrate changes
changes = cls.migrateData(changes);
// Attempt updating the document to validate the changes
let diff = {};
try {
diff = doc.updateSource(changes, {dryRun: true, fallback: false, restoreDelta: options.restoreDelta});
} catch(err) {
ui.notifications.error(err.message.split("] ").pop());
Hooks.onError("ClientDatabaseBackend##preUpdateDocumentArray", err, {id: doc.id, log: "error"});
continue;
}
// Retain only the differences against the current source
if ( options.diff ) {
if ( foundry.utils.isEmpty(diff) ) continue;
diff._id = doc.id;
changes = cls.shimData(diff); // Re-apply shims for backwards compatibility in _preUpdate hooks
}
else if ( addType ) delete changes.type;
// Perform pre-update operations
let allowed = await doc._preUpdate(changes, options, user) ?? true;
allowed &&= (options.noHook || Hooks.call(`preUpdate${doc.documentName}`, doc, changes, options, user.id));
if ( allowed === false ) {
console.debug(`${vtt} | ${doc.documentName} update prevented during pre-update`);
continue;
}
toUpdate.push(changes);
}
return toUpdate;
}
/* -------------------------------------------- */
/**
* Handle a SocketResponse from the server when one or multiple documents were updated
* @param {SocketResponse} response The provided Socket response
* @param {SocketRequest} [response.request] The initial socket request
* @param {object[]} [response.result] An Array of incremental data objects
* @param {string} [response.userId] The id of the requesting User
* @returns {Promise<Document[]>} An Array of updated Document instances
*/
async #handleUpdateDocuments({request, result=[], userId}={}) {
const { type, options, parentUuid, pack} = request;
const parent = await ClientDatabaseBackend.#getParent(parentUuid, {invalid: true});
if ( parentUuid && !parent ) {
throw new Error(`Unable to update embedded documents in parent '${parentUuid}'. The parent does not exist.`);
}
const collection = ClientDatabaseBackend.#getCollection(type, parent, pack);
// Pre-operation actions
const preArgs = [result, options, userId];
if ( parent ) parent._dispatchDescendantDocumentEvents("preUpdate", collection.name, preArgs);
else collection._preUpdateDocuments(...preArgs);
// Perform updates and execute callbacks
options.type = type;
const callbacks = ClientDatabaseBackend.#postUpdateDocumentCallbacks(collection, result, options, userId);
parent?.reset();
const documents = callbacks.map(fn => fn());
// Post-operation actions
const postArgs = [documents, result, options, userId];
if ( parent ) parent._dispatchDescendantDocumentEvents("onUpdate", collection.name, postArgs);
else collection._onUpdateDocuments(...postArgs);
// Log and return result
if ( CONFIG.debug.documents ) this._logOperation("Updated", type, documents, {level: "debug", parent, pack});
return ClientDatabaseBackend.#buildResponse({ action: "update", documents, options });
}
/* -------------------------------------------- */
/**
* Perform a standardized post-update workflow for all Document types.
* @param {DocumentCollection} collection
* @param {object[]} result
* @param {object} options
* @param {string} userId
* @returns {Array<function():Document>} An array of callback operations performed after every Document is updated
*/
static #postUpdateDocumentCallbacks(collection, result, options, userId) {
const cls = collection.documentClass;
const callback = (doc, change) => {
change = cls.shimData(change);
doc._onUpdate(change, options, userId);
Hooks.callAll(`update${doc.documentName}`, doc, change, options, userId);
return doc;
};
const callbacks = [];
for ( let change of result ) {
const doc = collection.get(change._id, {strict: false});
if ( !doc ) continue;
doc.updateSource(change, options);
collection.set(doc.id, doc, options);
callbacks.push(callback.bind(this, doc, change));
}
return callbacks;
}
/* -------------------------------------------- */
/**
* Perform a standardized pre-delete workflow for all Document types.
* @param {DocumentCollection} collection
* @param {string[]} ids
* @param {object} options
* @param {User} user
*/
static async #preDeleteDocumentArray(collection, ids, options, user) {
user = user || game.user;
const toDelete = [];
if ( collection instanceof CompendiumCollection ) {
await collection.getDocuments({_id__in: ids.filter(id => !collection.has(id))});
}
// Iterate over ids requested for deletion
for ( let id of ids ) {
// Get the Document being deleted
const doc = collection.get(id, {strict: true, invalid: true});
// Perform pre-deletion operations
let allowed = await doc._preDelete(options, user) ?? true;
allowed &&= (options.noHook || Hooks.call(`preDelete${doc.documentName}`, doc, options, user.id));
if ( allowed === false ) {
console.debug(`${vtt} | ${doc.documentName} deletion prevented during pre-delete`);
continue;
}
toDelete.push(id);
}
return toDelete;
}
/* -------------------------------------------- */
/**
* Handle a SocketResponse from the server where Documents are deleted.
* @param {SocketResponse} response The provided Socket response
* @param {SocketRequest} [response.request] The initial socket request
* @param {string[]} [response.result] An Array of deleted Document ids
* @param {string} [response.userId] The id of the requesting User
* @returns {Promise<Document[]>} An Array of deleted Document instances
*/
async #handleDeleteDocuments({request, result=[], userId}={}) {
const {type, options, parentUuid, pack} = request;
const parent = await ClientDatabaseBackend.#getParent(parentUuid);
const collection = ClientDatabaseBackend.#getCollection(type, parent, pack);
result = options.deleteAll ? Array.from(collection instanceof CompendiumCollection
? collection.index.keys() : collection.keys()) : result;
if ( !result.length ) return [];
// Pre-operation actions
const preArgs = [result, options, userId];
if ( parent ) parent._dispatchDescendantDocumentEvents("preDelete", collection.name, preArgs);
else collection._preDeleteDocuments(...preArgs);
// Perform deletions and execute callbacks
const callbacks = ClientDatabaseBackend.#postDeleteDocumentCallbacks(collection, result, options, userId);
parent?.reset();
const documents = callbacks.map(fn => fn());
// Post-operation actions
const postArgs = [documents, result, options, userId];
if ( parent ) parent._dispatchDescendantDocumentEvents("onDelete", collection.name, postArgs);
else collection._onDeleteDocuments(...postArgs);
// Log and return result
this._logOperation("Deleted", type, documents, {level: "info", parent, pack});
return ClientDatabaseBackend.#buildResponse({ action: "delete", documents, options });
}
/* -------------------------------------------- */
/**
* Perform a standardized post-deletion workflow for all Document types.
* @param {DocumentCollection} collection
* @param {string[]} ids
* @param {object} options
* @param {string} userId
* @returns {Array<function():Document>} An array of callback operations performed after every Document is deleted
*/
static #postDeleteDocumentCallbacks(collection, ids, options, userId) {
const callback = doc => {
doc._onDelete(options, userId);
Hooks.callAll(`delete${doc.documentName}`, doc, options, userId);
return doc;
};
const callbacks = [];
for ( let id of ids ) {
const doc = collection.get(id, {strict: false});
if ( !doc ) continue;
collection.delete(id);
callbacks.push(callback.bind(this, doc));
}
return callbacks;
}
/* -------------------------------------------- */
/* Helper Methods */
/* -------------------------------------------- */
/** @inheritdoc */
getFlagScopes() {
if ( this.#flagScopes ) return this.#flagScopes;
const scopes = ["core", "world", game.system.id];
for ( const module of game.modules ) {
if ( module.active ) scopes.push(module.id);
}
return this.#flagScopes = scopes;
}
/**
* A cached array of valid flag scopes which can be read and written.
* @type {string[]}
*/
#flagScopes;
/* -------------------------------------------- */
/** @inheritdoc */
getCompendiumScopes() {
return Array.from(game.packs.keys());
}
/* -------------------------------------------- */
/**
* Get the parent document for given request from its provided UUID, if any.
* @param {string|null} uuid The parent document UUID, or null
* @param {object} [options] Options which customize how the parent document is retrieved by UUID
* @returns {Promise<ClientDocument>} The parent document for the transaction
*/
static async #getParent(uuid, options={}) {
return uuid ? fromUuid(uuid, options) : null;
}
/* -------------------------------------------- */
/**
* Obtain the document collection for a given Document type, parent, and compendium pack.
* @param {string} documentName The Document name
* @param {ClientDocument|null} parent A parent Document, if applicable
* @param {string} pack A compendium pack identifier, if applicable
* @returns {DocumentCollection|CompendiumCollection} The relevant collection instance for this request
*/
static #getCollection(documentName, parent, pack) {
if ( parent ) return parent.getEmbeddedCollection(documentName);
if ( pack ) {
const collection = game.packs.get(pack);
return documentName === "Folder" ? collection.folders : collection;
}
return game.collections.get(documentName);
}
/* -------------------------------------------- */
/**
* Build a CRUD request.
* @param {SocketRequest} request The initial request data.
* @returns {SocketRequest}
*/
static #buildRequest({documentClass, action, data, updates, ids, options, pack, parent}) {
let parentUuid = parent?.uuid;
let type = documentClass.documentName;
// Translate updates to a token actor to the token's ActorDelta instead.
if ( foundry.utils.isSubclass(documentClass, Actor) && (parent instanceof TokenDocument) ) {
type = "ActorDelta";
updates[0]._id = parent.delta.id;
options.syntheticActorUpdate = true;
}
// Translate operations on a token actor's embedded children to the token's ActorDelta instead.
const token = ClientDatabaseBackend.#getTokenAncestor(parent);
if ( token && !(parent instanceof TokenDocument) ) {
const {embedded} = foundry.utils.parseUuid(parentUuid);
parentUuid = [token.delta.uuid, embedded.slice(4).join(".")].filterJoin(".");
}
return {type, action, data, updates, ids, options, pack, parentUuid};
}
/* -------------------------------------------- */
/**
* Build a CRUD response.
* @param {object} response The response data.
* @param {string} response.action The type of response.
* @param {ClientDocument[]} response.documents The initial response result.
* @param {object} response.options The response options.
* @returns {ClientDocument[]}
*/
static #buildResponse({ action, documents, options }) {
if ( options.syntheticActorUpdate ) return documents.map(delta => delta.syntheticActor);
return documents;
}
/* -------------------------------------------- */
/**
* Retrieve a Document's Token ancestor, if it exists.
* @param {ClientDocument} parent The parent Document
* @returns {TokenDocument|null} The Token ancestor, or null
*/
static #getTokenAncestor(parent) {
if ( !parent ) return null;
if ( parent instanceof TokenDocument ) return parent;
return this.#getTokenAncestor(parent.parent);
}
}
/**
* 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 {Object<Application>}
* @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 or not the current game User has ownership rights for this Document.
* Different Document types may have more specialized rules for what constitutes ownership.
* @type {boolean}
* @memberof ClientDocumentMixin#
*/
get isOwner() {
return this.testUserPermission(game.user, "OWNER");
}
/* -------------------------------------------- */
/**
* Test whether this Document is owned by any non-Gamemaster User.
* @type {boolean}
* @memberof ClientDocumentMixin#
*/
get hasPlayerOwner() {
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 {FormApplication|null}
* @memberof ClientDocumentMixin#
*/
get sheet() {
if ( !this._sheet ) {
const cls = this._getSheetClass();
if ( !cls ) return null;
this._sheet = new cls(this, {editable: this.isOwner});
}
return this._sheet;
}
/* -------------------------------------------- */
/**
* A Universally Unique Identifier (uuid) for this Document instance.
* @type {string}
* @memberof ClientDocumentMixin#
*/
get uuid() {
let parts = [this.documentName, this.id];
if ( this.parent ) parts = [this.parent.uuid].concat(parts);
else if ( this.pack ) parts = ["Compendium", this.pack].concat(parts);
return parts.join(".");
}
/* -------------------------------------------- */
/**
* A boolean indicator for whether the current game User has at least limited visibility for this Document.
* Different Document types may have more specialized rules for what determines visibility.
* @type {boolean}
* @memberof ClientDocumentMixin#
*/
get visible() {
if ( this.isEmbedded ) return this.parent.visible;
return this.testUserPermission(game.user, "LIMITED");
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Obtain the FormApplication class constructor which should be used to configure this Document.
* @returns {Function|null}
* @private
*/
_getSheetClass() {
const cfg = CONFIG[this.documentName];
const type = this.type ?? CONST.BASE_DOCUMENT_TYPE;
const sheets = cfg.sheetClasses[type] || {};
// Sheet selection overridden at the instance level
const override = this.getFlag("core", "sheetClass");
if ( sheets[override] ) return sheets[override].cls;
// Default sheet selection for the type
const classes = Object.values(sheets);
if ( !classes.length ) return 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 instanceof Function) ) this.system.prepareBaseData();
this.prepareBaseData();
this.prepareEmbeddedDocuments();
if ( isTypeData || (this.system?.prepareDerivedData instanceof Function) ) 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 of the Application instances which are connected to this document by calling their respective
* @see Application#render
* @param {boolean} [force=false] Force rendering
* @param {object} [context={}] Optional context
* @memberof ClientDocumentMixin#
*/
render(force=false, context={}) {
for ( let app of Object.values(this.apps) ) {
app.render(force, context);
}
}
/* -------------------------------------------- */
/**
* Determine the sort order for this Document by positioning it relative a target sibling.
* See SortingHelper.performIntegerSort for more details
* @param {object} [options] Sorting options provided to SortingHelper.performIntegerSort
* @param {object} [updateData] Additional data changes which are applied to each sorted document
* @param {object} [sortOptions] Options which are passed to the SortingHelpers.performIntegerSort method
* @returns {Promise<Document>} The Document after it has been re-sorted
* @memberof ClientDocumentMixin#
*/
async sortRelative({updateData={}, ...sortOptions}={}) {
const sorting = SortingHelpers.performIntegerSort(this, sortOptions);
const updates = [];
for ( let s of sorting ) {
const doc = s.target;
const update = foundry.utils.mergeObject(updateData, s.update, {inplace: false});
update._id = doc._id;
if ( doc.sheet && doc.sheet.rendered ) await doc.sheet.submit({updateData: update});
else updates.push(update);
}
if ( updates.length ) await this.constructor.updateDocuments(updates, {parent: this.parent, pack: this.pack});
return this;
}
/* -------------------------------------------- */
/**
* Construct a UUID relative to another document.
* @param {ClientDocument} doc The document to compare against.
*/
getRelativeUUID(doc) {
if ( this.compendium && (this.compendium !== doc.compendium) ) return this.uuid;
// This Document is a child of the relative Document.
if ( doc === this.parent ) return `.${this.documentName}.${this.id}`;
// This Document is a sibling of the relative Document.
if ( this.isEmbedded && (this.collection === doc.collection) ) return `.${this.id}`;
return this.uuid;
}
/* -------------------------------------------- */
/**
* Create a content link for this document.
* @param {object} eventData The parsed object of data provided by the drop transfer event.
* @param {object} [options] Additional options to configure link generation.
* @param {ClientDocument} [options.relativeTo] A document to generate a link relative to.
* @param {string} [options.label] A custom label to use instead of the document's name.
* @returns {string}
* @internal
*/
_createDocumentLink(eventData, {relativeTo, label}={}) {
if ( !relativeTo && !label ) return this.link;
label ??= this.name;
if ( relativeTo ) return `@UUID[${this.getRelativeUUID(relativeTo)}]{${label}}`;
return `@UUID[${this.uuid}]{${label}}`;
}
/* -------------------------------------------- */
/**
* Handle clicking on a content link for this document.
* @param {MouseEvent} event The triggering click event.
* @returns {any}
* @protected
*/
_onClickDocumentLink(event) {
return this.sheet.render(true);
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @override */
_onCreate(data, options, userId) {
if ( options.renderSheet && (userId === game.user.id) ) {
if ( this.sheet ) this.sheet.render(true, {
action: "create",
data: data
});
}
game.issues._countDocumentSubType(this.documentName, this._source);
// Update global index
if ( this.constructor.metadata.indexed ) game.documentIndex.addDocument(this);
}
/* -------------------------------------------- */
/** @override */
_onUpdate(data, options, userId) {
// Clear cached sheet if a new sheet is chosen, or the Document's sub-type changes.
const sheetChange = ("type" in data) || ("sheetClass" in (data.flags?.core || {}));
if ( !options.preview && sheetChange ) this._onSheetChange();
// Otherwise re-render associated applications.
else if ( options.render !== false ) {
this.render(false, {
action: "update",
data: data
});
}
// Update Compendium index
if ( this.pack && !this.isEmbedded ) {
if ( options.type === "Folder" ) this.compendium.folders.set(this.id, this);
else this.compendium.indexDocument(this);
}
// Update global index.
if ( "name" in data ) game.documentIndex.replaceDocument(this);
}
/* -------------------------------------------- */
/** @override */
_onDelete(options, userId) {
Object.values(this.apps).forEach(a => a.close({submit: false}));
game.issues._countDocumentSubType(this.documentName, this._source, {decrement: true});
game.documentIndex.removeDocument(this);
}
/* -------------------------------------------- */
/**
* 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}`});
}
/* -------------------------------------------- */
/**
* 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}`});
}
/* -------------------------------------------- */
/**
* 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}`});
}
/* -------------------------------------------- */
/**
* 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
* @returns {string}
*/
static defaultName() {
const label = game.i18n.localize(this.metadata.label);
const documentName = this.metadata.name;
const count = game.collections.get(documentName)?.size;
let defaultName = game.i18n.format("DOCUMENT.New", {type: label});
if ( count > 0 ) defaultName += ` (${count + 1})`;
return defaultName;
}
/* -------------------------------------------- */
/* Importing and Exporting */
/* -------------------------------------------- */
/**
* Present a Dialog form to create a new Document of this type.
* Choose a name and a type from a select menu of types.
* @param {object} data Initial data with which to populate the creation form
* @param {object} [context={}] Additional context options or dialog positioning options
* @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 {Promise<Document|null>} A Promise which resolves to the created Document, or null if the dialog was
* closed.
* @memberof ClientDocumentMixin
*/
static async createDialog(data={}, {parent=null, pack=null, ...options}={}) {
// Collect data
const documentName = this.metadata.name;
const types = game.documentTypes[documentName].filter(t => t !== CONST.BASE_DOCUMENT_TYPE);
let collection;
if ( !parent ) {
if ( pack ) collection = game.packs.get(pack);
else collection = game.collections.get(documentName);
}
const folders = collection?._formatFolderSelectOptions() ?? [];
const label = game.i18n.localize(this.metadata.label);
const title = game.i18n.format("DOCUMENT.Create", {type: label});
// Render the document creation form
const html = await renderTemplate("templates/sidebar/document-create.html", {
folders,
name: data.name || game.i18n.format("DOCUMENT.New", {type: label}),
folder: data.folder,
hasFolders: folders.length >= 1,
type: data.type || CONFIG[documentName]?.defaultType || types[0],
types: types.reduce((obj, t) => {
const label = CONFIG[documentName]?.typeLabels?.[t] ?? t;
obj[t] = game.i18n.has(label) ? game.i18n.localize(label) : t;
return obj;
}, {}),
hasTypes: types.length > 1
});
// Render the confirmation dialog window
return Dialog.prompt({
title: title,
content: html,
label: title,
callback: html => {
const form = html[0].querySelector("form");
const fd = new FormDataExtended(form);
foundry.utils.mergeObject(data, fd.object, {inplace: true});
if ( !data.folder ) delete data.folder;
if ( types.length === 1 ) data.type = types[0];
if ( !data.name?.trim() ) data.name = this.defaultName();
return this.create(data, {parent, pack, renderSheet: true});
},
rejectClose: false,
options
});
}
/* -------------------------------------------- */
/**
* Present a Dialog form to confirm deletion of this Document.
* @param {object} [options] Positioning and sizing options for the resulting dialog
* @returns {Promise<Document>} 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: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.format("SIDEBAR.DeleteWarning", {type})}</p>`,
yes: this.delete.bind(this),
options: options
});
}
/* -------------------------------------------- */
/**
* Export document data to a JSON file which can be saved by the client and later imported into a different session.
* @param {object} [options] Additional options passed to the {@link ClientDocumentMixin#toCompendium} method
* @memberof ClientDocumentMixin#
*/
exportToJSON(options) {
const data = this.toCompendium(null, options);
data.flags.exportSource = {
world: game.world.id,
system: game.system.id,
coreVersion: game.version,
systemVersion: game.system.version
};
const filename = ["fvtt", this.documentName, this.name?.slugify(), this.id].filterJoin("-");
saveDataToFile(JSON.stringify(data, null, 2), "text/json", `${filename}.json`);
}
/* -------------------------------------------- */
/**
* Create a content link for this Document.
* @param {object} [options] Additional options to configure how the link is constructed.
* @param {object<string>} [options.attrs] Attributes to set on the link.
* @param {object<string>} [options.dataset] Custom data- attributes to set on the link.
* @param {string[]} [options.classes] Additional classes to add to the link.
* The `content-link` class is added by default.
* @param {string} [options.name] A name to use for the Document, if different from the Document's name.
* @param {string} [options.icon] A font-awesome icon class to use as the icon, if different to the
* Document's configured sidebarIcon.
* @returns {HTMLAnchorElement}
*/
toAnchor({attrs={}, dataset={}, classes=[], name, icon}={}) {
// Build dataset
const documentConfig = CONFIG[this.documentName];
const documentName = game.i18n.localize(`DOCUMENT.${this.documentName}`);
let anchorIcon = icon ?? documentConfig.sidebarIcon;
dataset = foundry.utils.mergeObject({
uuid: this.uuid,
id: this.id,
type: this.documentName,
pack: this.pack,
tooltip: documentName
}, dataset);
// If this is a typed document, add the type to the dataset
if ( this.type ) {
const typeLabel = documentConfig.typeLabels[this.type];
const typeName = game.i18n.has(typeLabel) ? `${game.i18n.localize(typeLabel)}` : "";
dataset.tooltip = typeName ? game.i18n.format("DOCUMENT.TypePageFormat", {type: typeName, page: documentName})
: documentName;
anchorIcon = icon ?? documentConfig.typeIcons?.[this.type] ?? documentConfig.sidebarIcon;
}
// Construct Link
const a = document.createElement("a");
a.classList.add("content-link", ...classes);
Object.entries(attrs).forEach(([k, v]) => a.setAttribute(k, v));
for ( const [k, v] of Object.entries(dataset) ) {
if ( v !== null ) a.dataset[k] = v;
}
a.innerHTML = `<i class="${anchorIcon}"></i>${name ?? this.name}`;
return a;
}
/* -------------------------------------------- */
/**
* Serialize salient information about this Document when dragging it.
* @returns {object} An object of drag data.
*/
toDragData() {
const dragData = {type: this.documentName};
if ( this.id ) dragData.uuid = this.uuid;
else dragData.data = this.toObject();
return dragData;
}
/* -------------------------------------------- */
/**
* A helper function to handle obtaining the relevant Document from dropped data provided via a DataTransfer event.
* The dropped data could have:
* 1. A data object explicitly provided
* 2. A UUID
* @memberof ClientDocumentMixin
*
* @param {object} data The data object extracted from a DataTransfer event
* @param {object} options Additional options which affect drop data behavior
* @returns {Promise<Document>} The resolved Document
* @throws If a Document could not be retrieved from the provided data.
*/
static async fromDropData(data, options={}) {
let document = null;
/**
* @deprecated since v10
*/
if ( options.importWorld ) {
const msg = "The importWorld option for ClientDocumentMixin.fromDropData is deprecated. The Document returned "
+ "by fromDropData should instead be persisted using the normal Document creation API.";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
}
// Case 1 - Data explicitly provided
if ( data.data ) document = new this(data.data);
// Case 2 - UUID provided
else if ( data.uuid ) document = await fromUuid(data.uuid);
// Ensure that we retrieved a valid document
if ( !document ) {
throw new Error("Failed to resolve Document from provided DragData. Either data or a UUID must be provided.");
}
if ( document.documentName !== this.documentName ) {
throw new Error(`Invalid Document type '${document.type}' provided to ${this.name}.fromDropData.`);
}
// Flag the source UUID
if ( document.id && !document.getFlag("core", "sourceId") ) {
document.updateSource({"flags.core.sourceId": document.uuid});
}
return document;
}
/* -------------------------------------------- */
/**
* Update this Document using a provided JSON string.
* @this {ClientDocument}
* @param {string} json Raw JSON data to import
* @returns {Promise<ClientDocument>} The updated Document instance
*/
async importFromJSON(json) {
if ( !CONFIG[this.documentName]?.collection ) throw new Error("Only primary Documents may be imported from JSON");
// Construct a document class to (strictly) clean and validate the source data
const doc = new this.constructor(JSON.parse(json), {strict: true});
// Treat JSON import using the same workflows that are used when importing from a compendium pack
const data = this.collection.fromCompendium(doc, {addFlags: false});
// Preserve certain fields from the destination document
const preserve = Object.fromEntries(this.constructor.metadata.preserveOnImport.map(k => {
return [k, foundry.utils.getProperty(this, k)];
}));
preserve.folder = this.folder?.id;
foundry.utils.mergeObject(data, preserve);
// Commit the import as an update to this document
await this.update(data, {diff: false, recursive: false, noHook: true});
ui.notifications.info(game.i18n.format("DOCUMENT.Imported", {document: this.documentName, name: this.name}));
return this;
}
/* -------------------------------------------- */
/**
* Render an import dialog for updating the data related to this Document through an exported JSON file
* @returns {Promise<void>}
* @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: '<i class="fas fa-file-import"></i>',
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: '<i class="fas fa-times"></i>',
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 sourceId flag
* @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.flags?.core?.sourceId;
if ( clearOwnership ) delete data.ownership;
if ( clearState ) delete data.active;
return data;
}
/* -------------------------------------------- */
/* 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<string, Folder>}
*/
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);
}
/* -------------------------------------------- */
/**
* 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.documentName, 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 {Function}
*/
get documentClass() {
return getDocumentClass(this.documentName);
}
/** @inheritdoc */
get documentName() {
const name = this.constructor.documentName;
if ( !name ) throw new Error("A subclass of DocumentCollection must define its static documentName");
return name;
}
/**
* The base Document type which is contained within this DocumentCollection
* @type {string}
*/
static documentName;
/**
* Record the set of document ids where the Document was not initialized because of invalid source data
* @type {Set<string>}
*/
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 {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 {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);
this._source.findSplice(e => e._id === id);
}
/* -------------------------------------------- */
/**
* Render any Applications associated with this DocumentCollection.
*/
render(force, options) {
for (let a of this.apps) a.render(force, options);
}
/* -------------------------------------------- */
/**
* The cache of search fields for each data model
* @type {Map<string, Set<string>>}
*/
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<string>} 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 dataModel
const textSearchFields = new Set(["name"]);
const dataModel = (isSubtype && !isEmbedded) ? docConfig.dataModels[documentSubtype] : docConfig.documentClass;
if ( !dataModel ) return textSearchFields;
dataModel.schema.apply(function() {
if ( (this instanceof foundry.data.fields.StringField) && this.textSearch ) {
const [, ...path] = this.fieldPath.split(".");
const searchPath = (isSubtype && !isEmbedded) ? ["system", ...path].join(".") : [...path].join(".");
textSearchFields.add(searchPath);
}
});
// 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);
for ( const doc of this.index ?? this.contents ) {
if ( exclude.includes(doc._id) ) continue;
let isMatch = !query ? true : false;
// 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 field of textSearchFields ) {
const value = foundry.utils.getProperty(doc, field);
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.update
* @return {Promise<Document[]>} An array of updated data once the operation is complete
*/
async updateAll(transformation, condition=null, options={}) {
const hasTransformer = transformation instanceof Function;
if ( !hasTransformer && (foundry.utils.getType(transformation) !== "Object") ) {
throw new Error("You must provide a data object or transformation function");
}
const hasCondition = condition instanceof Function;
const updates = [];
for ( let doc of this ) {
if ( hasCondition && !condition(doc) ) continue;
const update = hasTransformer ? transformation(doc) : foundry.utils.deepClone(transformation);
update._id = doc.id;
updates.push(update);
}
return this.documentClass.updateDocuments(updates, options);
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/**
* Preliminary actions taken before a set of Documents in this Collection are created.
* @param {object[]} result An Array of created data objects
* @param {object} options Options which modified the creation operation
* @param {string} userId The ID of the User who triggered the operation
* @internal
*/
_preCreateDocuments(result, options, userId) {}
/* -------------------------------------------- */
/**
* Follow-up actions taken after a set of Documents in this Collection are created.
* @param {Document[]} documents An Array of created Documents
* @param {object[]} result An Array of created data objects
* @param {object} options Options which modified the creation operation
* @param {string} userId The ID of the User who triggered the operation
* @internal
*/
_onCreateDocuments(documents, result, options, userId) {
if ( options.render !== false ) this.render(false, this._getRenderContext("create", documents, result));
}
/* -------------------------------------------- */
/**
* Preliminary actions taken before a set of Documents in this Collection are updated.
* @param {object[]} result An Array of incremental data objects
* @param {object} options Options which modified the update operation
* @param {string} userId The ID of the User who triggered the operation
* @internal
*/
_preUpdateDocuments(result, options, userId) {}
/* -------------------------------------------- */
/**
* Follow-up actions taken after a set of Documents in this Collection are updated.
* @param {Document[]} documents An Array of updated Documents
* @param {object[]} result An Array of incremental data objects
* @param {object} options Options which modified the update operation
* @param {string} userId The ID of the User who triggered the operation
* @internal
*/
_onUpdateDocuments(documents, result, options, userId) {
if ( options.render !== false ) this.render(false, this._getRenderContext("update", documents, result));
}
/* -------------------------------------------- */
/**
* Preliminary actions taken before a set of Documents in this Collection are deleted.
* @param {string[]} result An Array of document IDs being deleted
* @param {object} options Options which modified the deletion operation
* @param {string} userId The ID of the User who triggered the operation
* @internal
*/
_preDeleteDocuments(result, options, userId) {}
/* -------------------------------------------- */
/**
* Follow-up actions taken after a set of Documents in this Collection are deleted.
* @param {Document[]} documents An Array of deleted Documents
* @param {string[]} result An Array of document IDs being deleted
* @param {object} options Options which modified the deletion operation
* @param {string} userId The ID of the User who triggered the operation
* @internal
*/
_onDeleteDocuments(documents, result, options, userId) {
if ( options.render !== false ) this.render(false, this._getRenderContext("delete", documents, result));
}
/* -------------------------------------------- */
/**
* Handle shifting documents in a deleted folder to a new parent folder.
* @param {Folder} parentFolder The parent folder to which documents should be shifted
* @param {string} deleteFolderId The ID of the folder being deleted
* @param {boolean} deleteContents Whether to delete the contents of the folder
* @returns {string[]} An array of document IDs to deleted
* @internal
*/
_onDeleteFolder(parentFolder, deleteFolderId, deleteContents) {
const deleteDocumentIds = [];
for ( let d of this ) {
if ( d._source.folder !== deleteFolderId ) continue;
if ( deleteContents ) deleteDocumentIds.push(d.id ?? d._id);
else d.updateSource({folder: parentFolder});
}
return deleteDocumentIds;
}
/* -------------------------------------------- */
/**
* Generate the render context information provided for CRUD operations.
* @param {string} action The CRUD operation.
* @param {Document[]} documents The documents being operated on.
* @param {object[]|string[]} data An array of creation or update objects, or an array of document IDs, depending on
* the operation.
* @returns {{action: string, documentType: string, documents: Document[], data: object[]|string[]}}
* @private
*/
_getRenderContext(action, documents, data) {
const documentType = this.documentName;
return {action, documentType, documents, data};
}
}
/**
* A collection of world-level Document objects with a singleton instance per primary Document type.
* Each primary Document type has an associated subclass of WorldCollection which contains them.
* @extends {DocumentCollection}
* @abstract
* @see {Game#collections}
*
* @param {object[]} data An array of data objects from which to create Document instances
*/
class WorldCollection extends DirectoryCollectionMixin(DocumentCollection) {
/* -------------------------------------------- */
/* Collection Properties */
/* -------------------------------------------- */
/**
* Reference the set of Folders which contain documents in this collection
* @type {Collection<string, Folder>}
*/
get folders() {
return game.folders.reduce((collection, folder) => {
if (folder.type === this.documentName) {
collection.set(folder.id, folder);
}
return collection;
}, new 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<Document>} The imported Document instance
*/
async importFromCompendium(pack, id, updateData={}, options={}) {
const cls = this.documentClass;
if (pack.documentName !== cls.documentName) {
throw new Error(`The ${pack.documentName} Document type provided by Compendium ${pack.collection} is incorrect for this Collection`);
}
// Prepare the source data from which to create the Document
const document = await pack.getDocument(id);
const sourceData = this.fromCompendium(document, options);
const createData = foundry.utils.mergeObject(sourceData, updateData);
// Create the Document
console.log(`${vtt} | Importing ${cls.documentName} ${document.name} from ${pack.collection}`);
this.directory.activate();
options.fromCompendium = true;
return this.documentClass.create(createData, options);
}
/* -------------------------------------------- */
/**
* Apply data transformations when importing a Document from a Compendium pack
* @param {Document|object} document The source Document, or a plain data object
* @param {object} [options] Additional options which modify how the document is imported
* @param {boolean} [options.addFlags=true] Add flags which track the import source
* @param {boolean} [options.clearFolder=false] Clear the currently assigned folder
* @param {boolean} [options.clearSort=true] Clear the currently assigned folder and sort order
* @param {boolean} [options.clearOwnership=true] Clear document ownership
* @param {boolean} [options.keepId=false] Retain the Document id from the source Compendium
* @returns {object} The processed data ready for world Document creation
*/
fromCompendium(document, {addFlags=true, clearFolder=false, clearSort=true, clearOwnership=true, keepId=false}={}) {
// Prepare the data structure
let data = document;
if (document instanceof foundry.abstract.Document) {
data = document.toObject();
if ( document.pack && addFlags ) foundry.utils.setProperty(data, "flags.core.sourceId", 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 {Object<string, Actor>}
*/
get tokens() {
if ( !canvas.ready || !canvas.scene ) return {};
return canvas.scene.tokens.reduce((obj, t) => {
if ( t.actorLink ) return obj;
obj[t.id] = t.actor;
return obj;
}, {});
}
/* -------------------------------------------- */
/** @override */
static documentName = "Actor";
/* -------------------------------------------- */
/** @inheritdoc */
fromCompendium(document, options={}) {
const data = super.fromCompendium(document, options);
// Re-associate imported Active Effects which are sourced to Items owned by this same Actor
if ( data._id ) {
const ownItemIds = new Set(data.items.map(i => i._id));
for ( let effect of data.effects ) {
if ( !effect.origin ) continue;
const effectItemId = effect.origin.split(".").pop();
if ( ownItemIds.has(effectItemId) ) {
effect.origin = `Actor.${data._id}.Item.${effectItemId}`;
}
}
}
return data;
}
}
/**
* The collection of Cards documents which exist within the active World.
* This Collection is accessible within the Game object as game.cards.
* @extends {WorldCollection}
* @see {@link Cards} The Cards document
*/
class CardStacks extends WorldCollection {
/** @override */
static documentName = "Cards";
}
/**
* The singleton collection of Combat documents which exist within the active World.
* This Collection is accessible within the Game object as game.combats.
* @extends {WorldCollection}
*
* @see {@link Combat} The Combat document
* @see {@link CombatTracker} The CombatTracker sidebar directory
*/
class CombatEncounters extends WorldCollection {
/** @override */
static documentName = "Combat";
/* -------------------------------------------- */
/**
* Provide the settings object which configures the Combat document
* @type {object}
*/
static get settings() {
return game.settings.get("core", Combat.CONFIG_SETTING);
}
/* -------------------------------------------- */
/** @inheritdoc */
get directory() {
return ui.combat;
}
/* -------------------------------------------- */
/**
* Get an Array of Combat instances which apply to the current canvas scene
* @type {Combat[]}
*/
get combats() {
return this.filter(c => (c.scene === null) || (c.scene === game.scenes.current));
}
/* -------------------------------------------- */
/**
* The currently active Combat instance
* @type {Combat}
*/
get active() {
return this.combats.find(c => c.active);
}
/* -------------------------------------------- */
/**
* The currently viewed Combat encounter
* @type {Combat|null}
*/
get viewed() {
return ui.combat?.viewed ?? null;
}
/* -------------------------------------------- */
/**
* When a Token is deleted, remove it as a combatant from any combat encounters which included the Token
* @param {string} sceneId The Scene id within which a Token is being deleted
* @param {string} tokenId The Token id being deleted
* @protected
*/
async _onDeleteToken(sceneId, tokenId) {
for ( let combat of this ) {
const toDelete = [];
for ( let c of combat.combatants ) {
if ( (c.sceneId === sceneId) && (c.tokenId === tokenId) ) toDelete.push(c.id);
}
if ( toDelete.length ) await combat.deleteEmbeddedDocuments("Combatant", toDelete);
}
}
}
/**
* @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<string, object>}
*/
this.index = new foundry.utils.Collection();
/**
* A subsidiary collection which contains the folders within the pack
* @type {Collection<string, Folder>}
*/
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}
*/
get banner() {
return this.metadata.banner ?? CONFIG[this.metadata.type]?.compendiumBanner;
}
/**
* 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<void>} 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.
* A value in CONST.USER_ROLES
* @type {number}
*/
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<string>}
*/
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<string>}
* @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);
}
/* -------------------------------------------- */
/**
* 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<Collection>}
*/
async getIndex({fields=[]}={}) {
const cls = this.documentClass;
// Maybe reuse the existing index if we have already indexed all fields
const indexFields = new Set([...this.indexFields, ...fields]);
if ( indexFields.isSubset(this.#indexedFields) ) return this.index;
// Request the new index from the server
const index = await cls.database.get(cls, {
query: {},
options: { index: true, indexFields: Array.from(indexFields) },
pack: this.collection
}, game.user);
// Assign the index to the collection
for ( let i of index ) {
const x = this.index.get(i._id);
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<Document>|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<Document[]>} 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<Document>} 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<void>}
*/
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<void>}
*/
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<Document[]>} 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: this.documentName,
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<nBatches; n++ ) {
const chunk = createData.slice(n*chunkSize, (n+1)*chunkSize);
const docs = await this.documentClass.createDocuments(chunk, options);
created = created.concat(docs);
}
// Notify of success
ui.notifications.info(game.i18n.format("COMPENDIUM.ImportAllFinish", {
number: created.length,
folderNumber: folders.size,
type: this.documentName,
folder: parentFolder.name
}));
return created;
}
/* -------------------------------------------- */
/**
* Provide a dialog form that prompts the user to import the full contents of a Compendium pack into the World.
* @param {object} [options={}] Additional options passed to the Dialog.confirm method
* @returns {Promise<Document[]|boolean|null>} 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<Object<string>>} 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",
buttons: {
reset: {
label: game.i18n.localize("COMPENDIUM.OwnershipReset"),
icon: '<i class="fas fa-undo"></i>',
callback: () => this.configure({ ownership: undefined })
},
ok: {
label: game.i18n.localize("OWNERSHIP.Configure"),
icon: '<i class="fas fa-check"></i>',
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<CompendiumCollection>}
*/
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.<collection>.<documentName>.<id>"
*/
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<CompendiumCollection>}
*/
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<CompendiumCollection>}
*/
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<CompendiumCollection>}
*/
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 */
_onCreateDocuments(documents, result, options, userId) {
super._onCreateDocuments(documents, result, options, userId);
this._onModifyContents(documents, options, userId);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onUpdateDocuments(documents, result, options, userId) {
super._onUpdateDocuments(documents, result, options, userId);
this._onModifyContents(documents, options, userId);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDeleteDocuments(documents, result, options, userId) {
super._onDeleteDocuments(documents, result, options, userId);
// Remove deleted documents from index
result.forEach(d => this.index.delete(d));
this._onModifyContents(documents, options, userId);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDeleteFolder(parentFolder, deleteFolderId, deleteContents) {
const deleteDocumentIds = [];
for ( let d of this.index ) {
if ( d.folder !== deleteFolderId ) continue;
if ( deleteContents ) deleteDocumentIds.push(d.id ?? d._id);
else {
const existingDoc = this.get(d.id ?? d._id);
d.folder = parentFolder;
if ( existingDoc ) existingDoc.updateSource({folder: parentFolder});
}
}
return deleteDocumentIds;
}
/* -------------------------------------------- */
/**
* Follow-up actions taken when Documents within this Compendium pack are modified
* @private
*/
_onModifyContents(documents, options, userId) {
Hooks.callAll("updateCompendium", this, documents, options, userId);
this.render();
}
/* -------------------------------------------- */
/**
* 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 Compendium Folders
* @extends {DocumentCollection}
* @type {DocumentCollection}
*/
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);
}
}
class CompendiumPacks extends DirectoryCollectionMixin(Collection) {
/**
* Get a Collection of Folders which contain Compendium Packs
* @returns {Collection<Folder>}
*/
get folders() {
return game.folders.reduce((collection, folder) => {
if ( folder.type === "Compendium" ) {
collection.set(folder.id, folder);
}
return collection;
}, new 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);
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 {
constructor(...args) {
super(...args);
/**
* Track which Folders are currently expanded in the UI
*/
this._expanded = {};
}
/* -------------------------------------------- */
/** @override */
static documentName = "Folder";
/* -------------------------------------------- */
/** @override */
render(force, context) {
if ( context && context.documents.length ) {
const folder = context.documents[0];
if ( folder.type === "Compendium" ) {
return ui.sidebar.tabs.compendium.render(force);
}
const collection = game.collections.get(folder.type);
collection.render(force, context);
if ( folder.type === "JournalEntry" ) {
this._refreshJournalEntrySheets();
}
}
}
/* -------------------------------------------- */
/**
* Refresh the display of any active JournalSheet instances where the folder list will change.
* @private
*/
_refreshJournalEntrySheets() {
for ( let app of Object.values(ui.windows) ) {
if ( !(app instanceof JournalSheet) ) continue;
app.submit();
}
}
}
/**
* The singleton collection of Item documents which exist within the active World.
* This Collection is accessible within the Game object as game.items.
* @extends {WorldCollection}
*
* @see {@link Item} The Item document
* @see {@link ItemDirectory} The ItemDirectory sidebar directory
*/
class Items extends WorldCollection {
/** @override */
static documentName = "Item";
}
/**
* The singleton collection of JournalEntry documents which exist within the active World.
* This Collection is accessible within the Game object as game.journal.
* @extends {WorldCollection}
*
* @see {@link JournalEntry} The JournalEntry document
* @see {@link JournalDirectory} The JournalDirectory sidebar directory
*/
class Journal extends WorldCollection {
/** @override */
static documentName = "JournalEntry";
/* -------------------------------------------- */
/* Interaction Dialogs */
/* -------------------------------------------- */
/**
* Display a dialog which prompts the user to show a JournalEntry or JournalEntryPage to other players.
* @param {JournalEntry|JournalEntryPage} doc The JournalEntry or JournalEntryPage to show.
* @returns {Promise<JournalEntry|JournalEntryPage|void>}
*/
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<JournalEntry|JournalEntryPage>} A Promise that resolves back to the shown document once the
* request is processed.
* @throws If the user does not own the document they are trying to show.
*/
static show(doc, {force=false, users=[]}={}) {
if ( !((doc instanceof JournalEntry) || (doc instanceof JournalEntryPage)) ) return;
if ( !doc.isOwner ) throw new Error(game.i18n.localize("JOURNAL.ShowBadPermissions"));
const strings = Object.fromEntries(["all", "authorized", "selected"].map(k => [k, game.i18n.localize(k)]));
return new Promise(resolve => {
game.socket.emit("showEntry", doc.uuid, {force, users}, () => {
Journal._showEntry(doc.uuid, force);
ui.notifications.info(game.i18n.format("JOURNAL.ActionShowSuccess", {
title: doc.name,
which: users.length ? strings.selected : force ? strings.all : strings.authorized
}));
return resolve(doc);
});
});
}
/* -------------------------------------------- */
/**
* Share an image with connected players.
* @param {string} src The image URL to share.
* @param {ShareImageConfig} [config] Image sharing configuration.
*/
static showImage(src, {users=[], ...options}={}) {
game.socket.emit("shareImage", {image: src, users, ...options});
const strings = Object.fromEntries(["all", "selected"].map(k => [k, game.i18n.localize(k)]));
ui.notifications.info(game.i18n.format("JOURNAL.ImageShowSuccess", {
which: users.length ? strings.selected : strings.all
}));
}
/* -------------------------------------------- */
/* Socket Listeners and Handlers */
/* -------------------------------------------- */
/**
* Open Socket listeners which transact JournalEntry data
* @param {Socket} socket The open websocket
*/
static _activateSocketListeners(socket) {
socket.on("showEntry", this._showEntry.bind(this));
socket.on("shareImage", ImagePopout._handleShareImage);
}
/* -------------------------------------------- */
/**
* Handle a received request to show a JournalEntry or JournalEntryPage to the current client
* @param {string} uuid The UUID of the document to display for other players
* @param {boolean} [force=false] Display the document regardless of normal permissions
* @internal
*/
static async _showEntry(uuid, force=false) {
let entry = await fromUuid(uuid);
const options = {tempOwnership: force, mode: JournalSheet.VIEW_MODES.MULTIPLE, pageIndex: 0};
if ( entry instanceof JournalEntryPage ) {
options.mode = JournalSheet.VIEW_MODES.SINGLE;
options.pageId = entry.id;
// Set temporary observer permissions for this page.
if ( entry.getUserLevel(game.user) < CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER ) {
entry.ownership[game.userId] = CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER;
}
entry = entry.parent;
}
else if ( entry instanceof JournalEntry ) entry.ownership[game.userId] = CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER;
else return;
if ( !force && !entry.visible ) return;
// Show the sheet with the appropriate mode
entry.sheet.render(true, options);
}
}
/**
* The singleton collection of Macro documents which exist within the active World.
* This Collection is accessible within the Game object as game.macros.
* @extends {WorldCollection}
*
* @see {@link Macro} The Macro document
* @see {@link MacroDirectory} The MacroDirectory sidebar directory
*/
class Macros extends WorldCollection {
/** @override */
static documentName = "Macro";
/* -------------------------------------------- */
/** @override */
get directory() {
return ui.macros;
}
/* -------------------------------------------- */
/** @inheritdoc */
fromCompendium(document, options={}) {
const data = super.fromCompendium(document, options);
if ( options.clearOwnership ) data.author = game.user.id;
return data;
}
}
/**
* The singleton collection of ChatMessage documents which exist within the active World.
* This Collection is accessible within the Game object as game.messages.
* @extends {WorldCollection}
*
* @see {@link ChatMessage} The ChatMessage document
* @see {@link ChatLog} The ChatLog sidebar directory
*/
class Messages extends WorldCollection {
/** @override */
static documentName = "ChatMessage";
/* -------------------------------------------- */
/**
* @override
* @returns {SidebarTab}
* */
get directory() {
return ui.chat;
}
/* -------------------------------------------- */
/** @override */
render(force=false) {}
/* -------------------------------------------- */
/**
* If requested, dispatch a Chat Bubble UI for the newly created message
* @param {ChatMessage} message The ChatMessage document to say
* @private
*/
sayBubble(message) {
const {content, type, speaker} = message;
if ( speaker.scene === canvas.scene.id ) {
const token = canvas.tokens.get(speaker.token);
if ( token ) canvas.hud.bubbles.say(token, content, {
cssClasses: type === CONST.CHAT_MESSAGE_TYPES.EMOTE ? ["emote"] : []
});
}
}
/* -------------------------------------------- */
/**
* Handle export of the chat log to a text file
* @private
*/
export() {
const log = this.contents.map(m => m.export()).join("\n---------------------------\n");
let date = new Date().toDateString().replace(/\s/g, "-");
const filename = `fvtt-log-${date}.txt`;
saveDataToFile(log, "text/plain", filename);
}
/* -------------------------------------------- */
/**
* Allow for bulk deletion of all chat messages, confirm first with a yes/no dialog.
* @see {@link Dialog.confirm}
*/
async flush() {
return Dialog.confirm({
title: game.i18n.localize("CHAT.FlushTitle"),
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("CHAT.FlushWarning")}</p>`,
yes: () => {
this.documentClass.deleteDocuments([], {deleteAll: true});
const jumpToBottomElement = document.querySelector(".jump-to-bottom");
jumpToBottomElement.classList.toggle("hidden", true);
},
options: {
top: window.innerHeight - 150,
left: window.innerWidth - 720
}
});
}
}
/**
* The singleton collection of Playlist documents which exist within the active World.
* This Collection is accessible within the Game object as game.playlists.
* @extends {WorldCollection}
*
* @see {@link Playlist} The Playlist document
* @see {@link PlaylistDirectory} The PlaylistDirectory sidebar directory
*/
class Playlists extends WorldCollection {
constructor(...args) {
super(...args);
this.initialize();
}
/* -------------------------------------------- */
/** @override */
static documentName = "Playlist";
/* -------------------------------------------- */
/**
* Return the subset of Playlist documents which are currently playing
* @type {Playlist[]}
*/
get playing() {
return this.filter(s => s.playing);
}
/* -------------------------------------------- */
/**
* Perform one-time initialization to begin playback of audio
*/
initialize() {
for ( let playlist of this ) {
for ( let sound of playlist.sounds ) {
sound.sync();
}
}
}
/* -------------------------------------------- */
/**
* Handle changes to a Scene to determine whether to trigger changes to Playlist documents.
* @param {Scene} scene The Scene document being updated
* @param {Object} data The incremental update data
*/
async _onChangeScene(scene, data) {
const currentScene = game.scenes.active;
const p0 = currentScene?.playlist;
const s0 = currentScene?.playlistSound;
const p1 = ("playlist" in data) ? game.playlists.get(data.playlist) : scene.playlist;
const s1 = "playlistSound" in data ? p1?.sounds.get(data.playlistSound) : scene.playlistSound;
const soundChange = (p0 !== p1) || (s0 !== s1);
if ( soundChange ) {
if ( s0 ) await s0.update({playing: false});
else if ( p0 ) await p0.stopAll();
if ( s1 ) await s1.update({playing: true});
else if ( p1 ) await p1.playAll();
}
}
}
/**
* The singleton collection of Scene documents which exist within the active World.
* This Collection is accessible within the Game object as game.scenes.
* @extends {WorldCollection}
*
* @see {@link Scene} The Scene document
* @see {@link SceneDirectory} The SceneDirectory sidebar directory
*/
class Scenes extends WorldCollection {
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/** @override */
static documentName = "Scene";
/* -------------------------------------------- */
/**
* Return a reference to the Scene which is currently active
* @type {Scene}
*/
get active() {
return this.find(s => s.active);
}
/* -------------------------------------------- */
/**
* Return the current Scene target.
* This is the viewed scene if the canvas is active, otherwise it is the currently active scene.
* @type {Scene}
*/
get current() {
const canvasInitialized = canvas.ready || game.settings.get("core", "noCanvas");
return canvasInitialized ? this.viewed : this.active;
}
/* -------------------------------------------- */
/**
* Return a reference to the Scene which is currently viewed
* @type {Scene}
*/
get viewed() {
return this.find(s => s.isView);
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Handle preloading the art assets for a Scene
* @param {string} sceneId The Scene id to begin loading
* @param {boolean} push Trigger other connected clients to also preload Scene resources
*/
async preload(sceneId, push=false) {
if ( push ) return game.socket.emit("preloadScene", sceneId, () => this.preload(sceneId));
let scene = this.get(sceneId);
const promises = [];
// Preload sounds
if ( scene.playlistSound?.path ) promises.push(AudioHelper.preloadSound(scene.playlistSound.path));
else if ( scene.playlist?.playbackOrder.length ) {
const first = scene.playlist.sounds.get(scene.playlist.playbackOrder[0]);
if ( first ) promises.push(AudioHelper.preloadSound(first.path));
}
// Preload textures without expiring current ones
promises.push(TextureLoader.loadSceneTextures(scene, {expireCache: false}));
return Promise.all(promises);
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @override */
static _activateSocketListeners(socket) {
socket.on("preloadScene", sceneId => this.instance.preload(sceneId));
socket.on("pullToScene", this._pullToScene);
}
/* -------------------------------------------- */
/**
* Handle requests pulling the current User to a specific Scene
* @param {string} sceneId
* @private
*/
static _pullToScene(sceneId) {
const scene = game.scenes.get(sceneId);
if ( scene ) scene.view();
}
/* -------------------------------------------- */
/* Importing and Exporting */
/* -------------------------------------------- */
/** @inheritdoc */
fromCompendium(document, options={}) {
const data = super.fromCompendium(document, options);
if ( options.clearState ) delete data.active;
if ( options.clearSort ) {
delete data.navigation;
delete data.navOrder;
}
return data;
}
}
/**
* The Collection of Setting documents which exist within the active World.
* This collection is accessible as game.settings.storage.get("world")
* @extends {WorldCollection}
*
* @see {@link Setting} The Setting document
*/
class WorldSettings extends WorldCollection {
/** @override */
static documentName = "Setting";
/* -------------------------------------------- */
/** @override */
get directory() {
return null;
}
/* -------------------------------------------- */
/* World Settings Methods */
/* -------------------------------------------- */
/**
* Return the Setting document with the given key.
* @param {string} key The setting key
* @returns {Setting} The Setting
*/
getSetting(key) {
return this.find(s => s.key === key);
}
/**
* Return the serialized value of the world setting as a string
* @param {string} key The setting key
* @returns {string|null} The serialized setting string
*/
getItem(key) {
return this.getSetting(key)?.value ?? null;
}
}
/**
* The singleton collection of RollTable documents which exist within the active World.
* This Collection is accessible within the Game object as game.tables.
* @extends {WorldCollection}
*
* @see {@link RollTable} The RollTable document
* @see {@link RollTableDirectory} The RollTableDirectory sidebar directory
*/
class RollTables extends WorldCollection {
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/** @override */
static documentName = "RollTable";
/* -------------------------------------------- */
/** @override */
get directory() {
return ui.tables;
}
/* -------------------------------------------- */
/**
* Register world settings related to RollTable documents
*/
static registerSettings() {
// Show Player Cursors
game.settings.register("core", "animateRollTable", {
name: "TABLE.AnimateSetting",
hint: "TABLE.AnimateSettingHint",
scope: "world",
config: true,
default: true,
type: Boolean
});
}
}
/**
* The singleton collection of User documents which exist within the active World.
* This Collection is accessible within the Game object as game.users.
* @extends {WorldCollection}
*
* @see {@link User} The User document
*/
class Users extends WorldCollection {
constructor(...args) {
super(...args);
/**
* The User document of the currently connected user
* @type {User|null}
*/
this.current = this.current || null;
}
/* -------------------------------------------- */
/**
* Initialize the Map object and all its contained documents
* @private
* @override
*/
_initialize() {
super._initialize();
// Flag the current user
this.current = this.get(game.data.userId) || null;
if ( this.current ) this.current.active = true;
// Set initial user activity state
for ( let activeId of game.data.activeUsers || [] ) {
this.get(activeId).active = true;
}
}
/* -------------------------------------------- */
/** @override */
static documentName = "User";
/* -------------------------------------------- */
/**
* Get the users with player roles
* @returns {User[]}
*/
get players() {
return this.filter(u => !u.isGM && u.hasRole("PLAYER"));
}
/* -------------------------------------------- */
/**
* Get one User who is an active Gamemaster, 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) => a.id > b.id ? 1 : -1); // 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 ) return;
// Update User active state
const active = "active" in activityData ? activityData.active : true;
if ( user.active !== active ) {
user.active = active;
game.users.render();
if ( (active === false) && ui.nav ) ui.nav.render();
Hooks.callAll("userConnected", user, active);
}
// Everything below here requires the game to be ready
if ( !game.ready ) return;
// Set viewed scene
const sceneChange = ("sceneId" in activityData) && (activityData.sceneId !== user.viewedScene);
if ( sceneChange ) {
user.viewedScene = activityData.sceneId;
ui.nav.render();
}
if ( "av" in activityData ) {
game.webrtc.settings.handleUserActivity(userId, activityData.av);
}
// Everything below requires an active canvas
if ( !canvas.ready ) return;
// User control deactivation
if ( (active === false) || (user.viewedScene !== canvas.id) ) {
canvas.controls.updateCursor(user, null);
canvas.controls.updateRuler(user, null);
user.updateTokenTargets([]);
return;
}
// Re-broadcast our targets if the user is switching to the scene we're on.
if ( sceneChange && (activityData.sceneId === canvas.id) ) {
game.user.broadcastActivity({targets: game.user.targets.ids});
}
// Cursor position
if ( "cursor" in activityData ) {
canvas.controls.updateCursor(user, activityData.cursor);
}
// Was it a ping?
if ( "ping" in activityData ) {
canvas.controls.handlePing(user, activityData.cursor, activityData.ping);
}
// Ruler measurement
if ( "ruler" in activityData ) {
canvas.controls.updateRuler(user, activityData.ruler);
}
// Token targets
if ( "targets" in activityData ) {
user.updateTokenTargets(activityData.targets);
}
}
}
/**
* @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 documents.BaseActiveEffect
* @mixes ClientDocumentMixin
*
* @see {@link documents.Actor} The Actor document which contains ActiveEffect embedded documents
* @see {@link documents.Item} The Item document which contains ActiveEffect embedded documents
*
* @property {ActiveEffectDuration} duration Expanded effect duration data.
*/
class ActiveEffect extends ClientDocumentMixin(foundry.documents.BaseActiveEffect) {
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* Is there some system logic that makes this active effect ineligible for application?
* @type {boolean}
*/
get isSuppressed() {
return false;
}
/**
* Provide forward-compatibility with other Document types which use img as their primary image or icon.
* We are likely to formally migrate this in the future, but for now this getter provides compatible read access.
* @type {string}
*/
get img() {
return this.icon;
}
/* --------------------------------------------- */
/**
* 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) && !!game.combat.getCombatantByActor(this.target);
}
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;
}
/* -------------------------------------------- */
/**
* 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 this ActiveEffect to a provided Actor.
* TODO: This method is poorly conceived. Its functionality is static, applying a provided change to an Actor
* TODO: When we revisit this in Active Effects V2 this should become an Actor method, or a static method
* @param {Actor} actor The Actor to whom this effect should be applied
* @param {EffectChangeData} change The change data being applied
* @returns {*} The resulting applied value
*/
apply(actor, change) {
// Determine the data type of the target field
const current = foundry.utils.getProperty(actor, change.key) ?? null;
let target = current;
if ( current === null ) {
const model = game.model.Actor[actor.type] || {};
target = foundry.utils.getProperty(model, change.key) ?? null;
}
let targetType = foundry.utils.getType(target);
// Cast the effect change value to the correct type
let delta;
try {
if ( targetType === "Array" ) {
const innerType = target.length ? foundry.utils.getType(target[0]) : "string";
delta = this._castArray(change.value, innerType);
}
else delta = this._castDelta(change.value, targetType);
} catch(err) {
console.warn(`Actor [${actor.id}] | Unable to parse active effect change for ${change.key}: "${change.value}"`);
return;
}
// Apply the change depending on the application mode
const modes = CONST.ACTIVE_EFFECT_MODES;
const changes = {};
switch ( change.mode ) {
case modes.ADD:
this._applyAdd(actor, change, current, delta, changes);
break;
case modes.MULTIPLY:
this._applyMultiply(actor, change, current, delta, changes);
break;
case modes.OVERRIDE:
this._applyOverride(actor, change, current, delta, changes);
break;
case modes.UPGRADE:
case modes.DOWNGRADE:
this._applyUpgrade(actor, change, current, delta, changes);
break;
default:
this._applyCustom(actor, change, current, delta, changes);
break;
}
// Apply all changes to the Actor data
foundry.utils.mergeObject(actor, changes);
return changes;
}
/* -------------------------------------------- */
/**
* Cast a raw EffectChangeData change string to the desired data type.
* @param {string} raw The raw string value
* @param {string} type The target data type that the raw value should be cast to match
* @returns {*} The parsed delta cast to the target data type
* @private
*/
_castDelta(raw, type) {
let delta;
switch ( type ) {
case "boolean":
delta = Boolean(this._parseOrString(raw));
break;
case "number":
delta = Number.fromString(raw);
if ( Number.isNaN(delta) ) delta = 0;
break;
case "string":
delta = String(raw);
break;
default:
delta = this._parseOrString(raw);
}
return delta;
}
/* -------------------------------------------- */
/**
* Cast a raw EffectChangeData change string to an Array of an inner type.
* @param {string} raw The raw string value
* @param {string} type The target data type of inner array elements
* @returns {Array<*>} The parsed delta cast as a typed array
* @private
*/
_castArray(raw, type) {
let delta;
try {
delta = this._parseOrString(raw);
delta = delta instanceof Array ? delta : [delta];
} catch(e) {
delta = [raw];
}
return delta.map(d => this._castDelta(d, type));
}
/* -------------------------------------------- */
/**
* Parse serialized JSON, or retain the raw string.
* @param {string} raw A raw serialized string
* @returns {*} The parsed value, or the original value if parsing failed
* @private
*/
_parseOrString(raw) {
try {
return JSON.parse(raw);
} catch(err) {
return raw;
}
}
/* -------------------------------------------- */
/**
* Apply an ActiveEffect that uses an ADD application mode.
* The way that effects are added depends on the data type of the current value.
*
* If the current value is null, the change value is assigned directly.
* If the current type is a string, the change value is concatenated.
* If the current type is a number, the change value is cast to numeric and added.
* If the current type is an array, the change value is appended to the existing array if it matches in type.
*
* @param {Actor} actor The Actor to whom this effect should be applied
* @param {EffectChangeData} change The change data being applied
* @param {*} current The current value being modified
* @param {*} delta The parsed value of the change object
* @param {object} changes An object which accumulates changes to be applied
* @private
*/
_applyAdd(actor, change, current, delta, changes) {
let update;
const ct = foundry.utils.getType(current);
switch ( ct ) {
case "boolean":
update = current || delta;
break;
case "null":
update = delta;
break;
case "Array":
update = current.concat(delta);
break;
default:
update = current + delta;
break;
}
changes[change.key] = update;
}
/* -------------------------------------------- */
/**
* Apply an ActiveEffect that uses a MULTIPLY application mode.
* Changes which MULTIPLY must be numeric to allow for multiplication.
* @param {Actor} actor The Actor to whom this effect should be applied
* @param {EffectChangeData} change The change data being applied
* @param {*} current The current value being modified
* @param {*} delta The parsed value of the change object
* @param {object} changes An object which accumulates changes to be applied
* @private
*/
_applyMultiply(actor, change, current, delta, changes) {
let update;
const ct = foundry.utils.getType(current);
switch ( ct ) {
case "boolean":
update = current && delta;
break;
case "number":
update = current * delta;
break;
}
changes[change.key] = update;
}
/* -------------------------------------------- */
/**
* Apply an ActiveEffect that uses an OVERRIDE application mode.
* Numeric data is overridden by numbers, while other data types are overridden by any value
* @param {Actor} actor The Actor to whom this effect should be applied
* @param {EffectChangeData} change The change data being applied
* @param {*} current The current value being modified
* @param {*} delta The parsed value of the change object
* @param {object} changes An object which accumulates changes to be applied
* @private
*/
_applyOverride(actor, change, current, delta, changes) {
return changes[change.key] = delta;
}
/* -------------------------------------------- */
/**
* Apply an ActiveEffect that uses an UPGRADE, or DOWNGRADE application mode.
* Changes which UPGRADE or DOWNGRADE must be numeric to allow for comparison.
* @param {Actor} actor The Actor to whom this effect should be applied
* @param {EffectChangeData} change The change data being applied
* @param {*} current The current value being modified
* @param {*} delta The parsed value of the change object
* @param {object} changes An object which accumulates changes to be applied
* @private
*/
_applyUpgrade(actor, change, current, delta, changes) {
let update;
const ct = foundry.utils.getType(current);
switch ( ct ) {
case "boolean":
case "number":
if ( (change.mode === CONST.ACTIVE_EFFECT_MODES.UPGRADE) && (delta > current) ) update = delta;
else if ( (change.mode === CONST.ACTIVE_EFFECT_MODES.DOWNGRADE) && (delta < current) ) update = delta;
break;
}
changes[change.key] = update;
}
/* -------------------------------------------- */
/**
* Apply an ActiveEffect that uses a CUSTOM application mode.
* @param {Actor} actor The Actor to whom this effect should be applied
* @param {EffectChangeData} change The change data being applied
* @param {*} current The current value being modified
* @param {*} delta The parsed value of the change object
* @param {object} changes An object which accumulates changes to be applied
* @private
*/
_applyCustom(actor, change, current, delta, changes) {
const preHook = foundry.utils.getProperty(actor, change.key);
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) {
await super._preCreate(data, options, user);
if ( 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(data, options, userId) {
if ( hasProperty(data, "flags.core.statusId") || 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});
}
if ( ("statuses" in data) && (this._source.flags.core?.statusId !== undefined) ) {
setProperty(data, "flags.core.-=statusId", null);
}
return super._preUpdate(data, options, userId);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onUpdate(data, options, userId) {
super._onUpdate(data, options, userId);
if ( !(this.target instanceof Actor) ) return;
const activeChanged = "disabled" in data;
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.renderable ) 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 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 });
}
}
/* -------------------------------------------- */
/* 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<Actor>} 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 */
/* -------------------------------------------- */
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);
}
/* -------------------------------------------- */
/** @override */
_onUpdate(data, options, userId) {
if ( this.parent.isLinked ) return;
this.syntheticActor._onUpdate(data, options, userId);
Hooks.callAll("updateActor", this.syntheticActor, data, 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.
*
* @extends foundry.documents.BaseActor
* @mixes ClientDocumentMixin
* @category - Documents
*
* @see {@link documents.Actors} The world-level collection of Actor documents
* @see {@link applications.ActorSheet} The Actor configuration application
*
* @example Create a new Actor
* ```js
* let actor = await Actor.create({
* name: "New Test Actor",
* type: "character",
* img: "artwork/character-profile.jpg"
* });
* ```
*
* @example Retrieve an existing Actor
* ```js
* let actor = game.actors.get(actorId);
* ```
*/
class Actor extends ClientDocumentMixin(foundry.documents.BaseActor) {
/** @inheritdoc */
_configure(options={}) {
super._configure(options);
/**
* Maintain a list of Token Documents that represent this Actor, stored by Scene.
* @type {IterableWeakMap<Scene, IterableWeakSet<TokenDocument>>}
* @private
*/
Object.defineProperty(this, "_dependentTokens", { value: new foundry.utils.IterableWeakMap() });
}
/**
* 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<string>}
*/
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 {Object<Item[]>}
*/
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?.getCombatantByActor(this);
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Apply any transformations to the Actor data which are caused by ActiveEffects.
*/
applyActiveEffects() {
const overrides = {};
this.statuses ??= new Set();
// Identify which special statuses had been active
const specialStatuses = new Map();
for ( const statusId of Object.values(CONFIG.specialStatusEffects) ) {
specialStatuses.set(statusId, this.statuses.has(statusId));
}
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);
// 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.getActiveTokens();
for ( const token of tokens ) token._onApplyStatusEffect(statusId, isActive);
}
}
/* -------------------------------------------- */
/**
* 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<TokenDocument|Token>} 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<ActiveEffect, void, void>}
*/
*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;
}
}
}
/* -------------------------------------------- */
/**
* Prepare a data object which defines the data schema used by dice roll commands against this Actor
* @returns {object}
*/
getRollData() {
return this.system;
}
/* -------------------------------------------- */
/**
* Create a new Token document, not yet saved to the database, which represents the Actor.
* @param {object} [data={}] Additional data, such as x, y, rotation, etc. for the created token data
* @returns {Promise<TokenDocument>} The created TokenDocument instance
*/
async getTokenDocument(data={}) {
const tokenData = this.prototypeToken.toObject();
tokenData.actorId = this.id;
if ( tokenData.randomImg && !data.texture?.src ) {
let images = await this.getTokenImages();
if ( (images.length > 1) && this._lastWildcard ) {
images = images.filter(i => i !== this._lastWildcard);
}
const image = images[Math.floor(Math.random() * images.length)];
tokenData.texture.src = this._lastWildcard = image;
}
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, {actor: this});
}
/* -------------------------------------------- */
/**
* Get an Array of Token images which could represent this Actor
* @returns {Promise<string[]>}
*/
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<documents.Actor>} The updated Actor document
*/
async modifyTokenAttribute(attribute, value, isDelta=false, isBar=true) {
const current = foundry.utils.getProperty(this.system, attribute);
// Determine the updates to make to the actor data
let updates;
if ( isBar ) {
if (isDelta) value = Math.clamped(0, Number(current.value) + value, current.max);
updates = {[`system.${attribute}.value`]: value};
} else {
if ( isDelta ) value = Number(current) + value;
updates = {[`system.${attribute}`]: value};
}
const allowed = Hooks.call("modifyTokenAttribute", {attribute, value, isDelta, isBar}, updates);
return allowed !== false ? this.update(updates) : this;
}
/* -------------------------------------------- */
/** @inheritdoc */
prepareEmbeddedDocuments() {
super.prepareEmbeddedDocuments();
this.applyActiveEffects();
}
/* -------------------------------------------- */
/**
* Roll initiative for all Combatants in the currently active Combat encounter which are associated with this Actor.
* If viewing a full Actor document, all Tokens which map to that actor will be targeted for initiative rolls.
* If viewing a synthetic Token actor, only that particular Token will be targeted for an initiative roll.
*
* @param {object} options Configuration for how initiative for this Actor is rolled.
* @param {boolean} [options.createCombatants=false] Create new Combatant entries for Tokens associated with
* this actor.
* @param {boolean} [options.rerollInitiative=false] Re-roll the initiative for this Actor if it has already
* been rolled.
* @param {object} [options.initiativeOptions={}] Additional options passed to the Combat#rollInitiative method.
* @returns {Promise<documents.Combat|null>} 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;
}
/* -------------------------------------------- */
/**
* 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<string[]>} 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 */
async _preCreate(data, options, userId) {
await super._preCreate(data, options, userId);
this._applyDefaultTokenSettings(data, options);
}
/* -------------------------------------------- */
/**
* When an Actor is being created, apply default token configuration settings to its prototype token.
* @param {object} data Data explicitly provided to the creation workflow
* @param {object} options Options which configure creation
* @param {boolean} [options.fromCompendium] Does this creation workflow originate via compendium import?
* @protected
*/
_applyDefaultTokenSettings(data, {fromCompendium=false}={}) {
const defaults = foundry.utils.deepClone(game.settings.get("core", DefaultTokenConfig.SETTING));
// System bar attributes
const {primaryTokenAttribute, secondaryTokenAttribute} = game.system;
if ( primaryTokenAttribute && !("bar1" in defaults) ) defaults.bar1 = {attribute: primaryTokenAttribute};
if ( secondaryTokenAttribute && !("bar2" in defaults) ) defaults.bar2 = {attribute: secondaryTokenAttribute};
// If the creation originates from a compendium, prefer default token settings
if ( fromCompendium ) return this.updateSource({prototypeToken: defaults});
// Otherwise, prefer explicitly provided data
const prototypeToken = foundry.utils.mergeObject(defaults, data.prototypeToken || {});
return this.updateSource({prototypeToken});
}
/* -------------------------------------------- */
/** @override */
_onUpdate(data, options, userId) {
// 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(data.prototypeToken ?? {});
});
super._onUpdate(data, options, userId);
// Get the changed attributes
const keys = Object.keys(data).filter(k => k !== "_id");
const changed = new Set(keys);
// Additional options only apply to base Actors
if ( this.isToken ) return;
this._updateDependentTokens(data, options);
// If the prototype token was changed, expire any cached token images
if ( changed.has("prototypeToken") ) this._tokenImages = null;
// If ownership changed for the actor reset token control
if ( changed.has("permission") && 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 {object} [update] The update delta.
* @param {DocumentModificationContext} [options] The update context.
* @protected
*/
_updateDependentTokens(update={}, options={}) {
for ( const token of this.getDependentTokens() ) {
token._onUpdateBaseActor(update, options);
}
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
async getTokenData(data) {
foundry.utils.logCompatibilityWarning("The Actor#getTokenData method has been renamed to Actor#getTokenDocument",
{since: 10, until: 12});
return this.getTokenDocument(data);
}
}
/**
* @typedef {Object} AdventureImportData
* @property {Object<object[]>} toCreate Arrays of document data to create, organized by document name
* @property {Object<object[]>} 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 {Object<Document[]>} created Documents created as a result of the import, organized by document name
* @property {Object<Document[]>} 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 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) {
/**
* 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<AdventureImportResult>} 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: `<h4><strong>${game.i18n.localize("Warning")}:</strong></h4>
<p>${game.i18n.format("ADVENTURE.ImportOverwriteWarning", {name: this.name})}</p>`
});
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<AdventureImportData>}
*/
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<AdventureImportResult>} 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 documents.BaseAmbientLight
* @mixes ClientDocumentMixin
*
* @see {@link Scene} The Scene document type which contains AmbientLight documents
* @see {@link AmbientLightConfig} The AmbientLight configuration application
*/
class AmbientLightDocument extends CanvasDocumentMixin(foundry.documents.BaseAmbientLight) {
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
_onUpdate(data, options, userId) {
const configs = Object.values(this.apps).filter(app => app instanceof AmbientLightConfig);
configs.forEach(app => {
if ( app.preview ) options.animate = false;
app._previewChanges(data);
});
super._onUpdate(data, 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 abstract.BaseAmbientSound
* @mixes ClientDocumentMixin
*
* @see {@link Scene} The Scene document type which contains AmbientSound documents
* @see {@link AmbientSoundConfig} The AmbientSound configuration application
*/
class AmbientSoundDocument extends CanvasDocumentMixin(foundry.documents.BaseAmbientSound) {}
/**
* The client-side Card document which extends the common BaseCard document model.
* @extends documents.BaseCard
* @mixes ClientDocumentMixin
*
* @see {@link Cards} The Cards document type which contains Card embedded documents
* @see {@link CardConfig} The Card configuration application
*/
class Card extends ClientDocumentMixin(foundry.documents.BaseCard) {
/**
* The current card face
* @type {CardFaceData|null}
*/
get currentFace() {
if ( this.face === null ) return null;
const n = Math.clamped(this.face, 0, this.faces.length-1);
return this.faces[n] || null;
}
/**
* The image of the currently displayed card face or back
* @type {string}
*/
get img() {
return this.currentFace?.img || this.back.img || Card.DEFAULT_ICON;
}
/**
* A reference to the source Cards document which defines this Card.
* @type {Cards|null}
*/
get source() {
return this.parent?.type === "deck" ? this.parent : this.origin;
}
/**
* A convenience property for whether the Card is within its source Cards stack. Cards in decks are always
* considered home.
* @type {boolean}
*/
get isHome() {
return (this.parent?.type === "deck") || (this.origin === this.parent);
}
/**
* Whether to display the face of this card?
* @type {boolean}
*/
get showFace() {
return this.faces[this.face] !== undefined;
}
/**
* Does this Card have a next face available to flip to?
* @type {boolean}
*/
get hasNextFace() {
return (this.face === null) || (this.face < this.faces.length - 1);
}
/**
* Does this Card have a previous face available to flip to?
* @type {boolean}
*/
get hasPreviousFace() {
return this.face !== null;
}
/* -------------------------------------------- */
/* Core Methods */
/* -------------------------------------------- */
/** @override */
prepareDerivedData() {
super.prepareDerivedData();
this.back.img ||= this.source.img || Card.DEFAULT_ICON;
this.name = (this.showFace ? (this.currentFace.name || this._source.name) : this.back.name)
|| game.i18n.format("CARD.Unknown", {source: this.source.name});
}
/* -------------------------------------------- */
/* API Methods */
/* -------------------------------------------- */
/**
* Flip this card to some other face. A specific face may be requested, otherwise:
* If the card currently displays a face the card is flipped to the back.
* If the card currently displays the back it is flipped to the first face.
* @param {number|null} [face] A specific face to flip the card to
* @returns {Promise<Card>} 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<Card>} 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<Card>} 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<ChatMessage>} The created chat message
*/
async toMessage(messageData={}, options={}) {
messageData = foundry.utils.mergeObject({
content: `<div class="card-draw flexrow">
<img class="card-face" src="${this.img}" alt="${this.name}"/>
<h4 class="card-name">${this.name}</h4>
</div>`
}, 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 documents.BaseCards
* @mixes ClientDocumentMixin
*
* @see {@link CardStacks} The world-level collection of Cards documents
* @see {@link CardsConfig} The Cards configuration application
*/
class Cards extends ClientDocumentMixin(foundry.documents.BaseCards) {
/**
* Provide a thumbnail image path used to represent this document.
* @type {string}
*/
get thumbnail() {
return this.img;
}
/**
* The Card documents within this stack which are available to be drawn.
* @type {Card[]}
*/
get availableCards() {
return this.cards.filter(c => (this.type !== "deck") || !c.drawn);
}
/**
* The Card documents which belong to this stack but have already been drawn.
* @type {Card[]}
*/
get drawnCards() {
return this.cards.filter(c => c.drawn);
}
/**
* Returns the localized Label for the type of Card Stack this is
* @type {string}
*/
get typeLabel() {
switch ( this.type ) {
case "deck": return game.i18n.localize("CARDS.TypeDeck");
case "hand": return game.i18n.localize("CARDS.TypeHand");
case "pile": return game.i18n.localize("CARDS.TypePile");
default: throw new Error(`Unexpected type ${this.type}`);
}
}
/**
* Can this Cards document be cloned in a duplicate workflow?
* @type {boolean}
*/
get canClone() {
if ( this.type === "deck" ) return true;
else return this.cards.size === 0;
}
/* -------------------------------------------- */
/* API Methods */
/* -------------------------------------------- */
/** @inheritdoc */
static async createDocuments(data=[], context={}) {
if ( context.keepEmbeddedIds === undefined ) context.keepEmbeddedIds = false;
return super.createDocuments(data, context);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _preCreate(data, options, user) {
await super._preCreate(data, options, user);
for ( const card of this.cards ) {
card.updateSource({drawn: false});
}
}
/* -------------------------------------------- */
/**
* Deal one or more cards from this Cards document to each of a provided array of Cards destinations.
* Cards are allocated from the top of the deck in cyclical order until the required number of Cards have been dealt.
* @param {Cards[]} to An array of other Cards documents to which cards are dealt
* @param {number} [number=1] The number of cards to deal to each other document
* @param {object} [options={}] Options which modify how the deal operation is performed
* @param {number} [options.how=0] How to draw, a value from CONST.CARD_DRAW_MODES
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the deal operation,
* for example the displayed face
* @param {string} [options.action=deal] The name of the action being performed, used as part of the dispatched
* Hook event
* @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
* @returns {Promise<Cards>} 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<total; i++ ) {
const n = i % to.length;
const card = drawn[i];
const createData = foundry.utils.mergeObject(card.toObject(), updateData);
if ( card.isHome || !createData.origin ) createData.origin = this.id;
createData.drawn = true;
toCreate[n].push(createData);
if ( card.isHome ) toUpdate.push({_id: card.id, drawn: true});
else toDelete.push(card.id);
}
const allowed = Hooks.call("dealCards", this, to, {
action: action,
toCreate: toCreate,
fromUpdate: toUpdate,
fromDelete: toDelete
});
if ( allowed === false ) {
console.debug(`${vtt} | The Cards#deal operation was prevented by a hooked function`);
return this;
}
// Perform database operations
const promises = to.map((cards, i) => {
return cards.createEmbeddedDocuments("Card", toCreate[i], {keepId: true});
});
promises.push(this.updateEmbeddedDocuments("Card", toUpdate));
promises.push(this.deleteEmbeddedDocuments("Card", toDelete));
await Promise.all(promises);
// Dispatch chat notification
if ( chatNotification ) {
const chatActions = {
deal: "CARDS.NotifyDeal",
pass: "CARDS.NotifyPass"
};
this._postChatNotification(this, chatActions[action], {number, link: to.map(t => t.link).join(", ")});
}
return this;
}
/* -------------------------------------------- */
/**
* Pass an array of specific Card documents from this document to some other Cards stack.
* @param {Cards} to Some other Cards document that is the destination for the pass operation
* @param {string[]} ids The embedded Card ids which should be passed
* @param {object} [options={}] Additional options which modify the pass operation
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the pass operation,
* for example the displayed face
* @param {string} [options.action=pass] The name of the action being performed, used as part of the dispatched
* Hook event
* @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
* @returns {Promise<Card[]>} An array of the Card embedded documents created within the destination stack
*/
async pass(to, ids, {updateData={}, action="pass", chatNotification=true}={}) {
if ( !(to instanceof Cards) ) {
throw new Error("You must provide a Cards document as the recipient for the Cards#pass operation");
}
// Allocate cards to different required operations
const toCreate = [];
const toUpdate = [];
const fromUpdate = [];
const fromDelete = [];
// Validate the provided cards
for ( let id of ids ) {
const card = this.cards.get(id, {strict: true});
// Prevent drawing cards from decks multiple times
if ( (this.type === "deck") && card.isHome && card.drawn ) {
throw new Error(`You may not pass Card ${id} which has already been drawn`);
}
// Return drawn cards to their origin deck
if ( card.origin === to ) {
toUpdate.push({_id: card.id, drawn: false});
}
// Create cards in a new destination
else {
const createData = foundry.utils.mergeObject(card.toObject(), updateData);
const copyCard = card.isHome && (to.type === "deck");
if ( copyCard ) createData.origin = to.id;
else if ( card.isHome || !createData.origin ) createData.origin = this.id;
if ( !copyCard ) createData.drawn = true;
toCreate.push(createData);
}
// Update cards in their home deck
if ( card.isHome && (to.type !== "deck") ) fromUpdate.push({_id: card.id, drawn: true});
// Remove cards from their current stack
else if ( !card.isHome ) fromDelete.push(card.id);
}
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<Card[]>} 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<Cards>} The Cards document after the shuffle operation has completed
*/
async shuffle({updateData={}, chatNotification=true}={}) {
const order = this.cards.map(c => [twist.random(), c]);
order.sort((a, b) => a[0] - b[0]);
const toUpdate = order.map((x, i) => {
const card = x[1];
return foundry.utils.mergeObject({_id: card.id, sort: i}, updateData);
});
// Post a chat notification and return
await this.updateEmbeddedDocuments("Card", toUpdate);
if ( chatNotification ) {
this._postChatNotification(this, "CARDS.NotifyShuffle", {link: this.link});
}
return this;
}
/* -------------------------------------------- */
/**
* Recall the Cards stack, retrieving all original cards from other stacks where they may have been drawn if this is a
* deck, otherwise returning all the cards in this stack to the decks where they originated.
* @param {object} [options={}] Options which modify the recall operation
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the recall operation,
* for example the displayed face
* @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
* @returns {Promise<Cards>} 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<Cards>} 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<Cards>} 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 ( !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.
* @param {Card} a The card being sorted
* @param {Card} b Another card being sorted against
* @returns {number}
* @protected
*/
sortStandard(a, b) {
if ( a.suit === b.suit ) return a.value - b.value;
return a.suit.localeCompare(b.suit);
}
/* -------------------------------------------- */
/**
* A sorting function that is used to determine the order of Card documents within a shuffled stack.
* @param {Card} a The card being sorted
* @param {Card} b Another card being sorted against
* @returns {number}
* @protected
*/
sortShuffled(a, b) {
return a.sort - b.sort;
}
/* -------------------------------------------- */
/**
* An internal helper method for drawing a certain number of Card documents from this Cards stack.
* @param {number} number The number of cards to draw
* @param {number} how A draw mode from CONST.CARD_DRAW_MODES
* @returns {Card[]} An array of drawn Card documents
* @protected
*/
_drawCards(number, how) {
// Confirm that sufficient cards are available
let available = this.availableCards;
if ( available.length < number ) {
throw new Error(`There are not ${number} available cards remaining in Cards [${this.id}]`);
}
// Draw from the stack
let drawn;
switch ( how ) {
case CONST.CARD_DRAW_MODES.FIRST:
available.sort(this.sortShuffled.bind(this));
drawn = available.slice(0, number);
break;
case CONST.CARD_DRAW_MODES.LAST:
available.sort(this.sortShuffled.bind(this));
drawn = available.slice(-number);
break;
case CONST.CARD_DRAW_MODES.RANDOM:
const shuffle = available.map(c => [Math.random(), c]);
shuffle.sort((a, b) => a[0] - b[0]);
drawn = shuffle.slice(-number).map(x => x[1]);
break;
}
return drawn;
}
/* -------------------------------------------- */
/**
* Create a ChatMessage which provides a notification of the operation which was just performed.
* Visibility of the resulting message is linked to the default roll mode selected in the chat log dropdown.
* @param {Cards} source The source Cards document from which the action originated
* @param {string} action The localization key which formats the chat message notification
* @param {object} context Data passed to the Localization#format method for the localization key
* @returns {ChatMessage} A created ChatMessage document
* @private
*/
_postChatNotification(source, action, context) {
const messageData = {
type: CONST.CHAT_MESSAGE_TYPES.OTHER,
speaker: {user: game.user},
content: `
<div class="cards-notification flexrow">
<img class="icon" src="${source.thumbnail}" alt="${source.name}">
<p>${game.i18n.format(action, context)}</p>
</div>`
};
ChatMessage.applyRollMode(messageData, game.settings.get("core", "rollMode"));
return ChatMessage.implementation.create(messageData);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
_onUpdate(data, options, userId) {
if ( "type" in data ) {
this.sheet?.close();
this._sheet = undefined;
}
super._onUpdate(data, options, userId);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _preDelete(options, user) {
await this.recall();
return super._preDelete(options, user);
}
/* -------------------------------------------- */
/* Interaction Dialogs */
/* -------------------------------------------- */
/**
* Display a dialog which prompts the user to deal cards to some number of hand-type Cards documents.
* @see {@link Cards#deal}
* @returns {Promise<Cards|null>}
*/
async dealDialog() {
const hands = game.cards.filter(c => (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED"));
if ( !hands.length ) return ui.notifications.warn("CARDS.DealWarnNoTargets", {localize: true});
// Construct the dialog HTML
const html = await renderTemplate("templates/cards/dialog-deal.html", {
hands: hands,
modes: {
[CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop",
[CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom",
[CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom"
}
});
// Display the prompt
return Dialog.prompt({
title: game.i18n.localize("CARDS.DealTitle"),
label: game.i18n.localize("CARDS.Deal"),
content: html,
callback: html => {
const form = html.querySelector("form.cards-dialog");
const fd = new FormDataExtended(form).object;
if ( !fd.to ) return this;
const toIds = fd.to instanceof Array ? fd.to : [fd.to];
const to = toIds.reduce((arr, id) => {
const c = game.cards.get(id);
if ( c ) arr.push(c);
return arr;
}, []);
const options = {how: fd.how, updateData: fd.down ? {face: null} : {}};
return this.deal(to, fd.number, options).catch(err => {
ui.notifications.error(err.message);
return this;
});
},
rejectClose: false,
options: {jQuery: false}
});
}
/* -------------------------------------------- */
/**
* Display a dialog which prompts the user to draw cards from some other deck-type Cards documents.
* @see {@link Cards#draw}
* @returns {Promise<Card[]|null>}
*/
async drawDialog() {
const decks = game.cards.filter(c => (c.type === "deck") && c.testUserPermission(game.user, "LIMITED"));
if ( !decks.length ) return ui.notifications.warn("CARDS.DrawWarnNoSources", {localize: true});
// Construct the dialog HTML
const html = await renderTemplate("templates/cards/dialog-draw.html", {
decks: decks,
modes: {
[CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop",
[CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom",
[CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom"
}
});
// Display the prompt
return Dialog.prompt({
title: game.i18n.localize("CARDS.DrawTitle"),
label: game.i18n.localize("CARDS.Draw"),
content: html,
callback: html => {
const form = html.querySelector("form.cards-dialog");
const fd = new FormDataExtended(form).object;
const from = game.cards.get(fd.from);
const options = {how: fd.how, updateData: fd.down ? {face: null} : {}};
return this.draw(from, fd.number, options).catch(err => {
ui.notifications.error(err.message);
return [];
});
},
rejectClose: false,
options: {jQuery: false}
});
}
/* -------------------------------------------- */
/**
* Display a dialog which prompts the user to pass cards from this document to some other Cards document.
* @see {@link Cards#deal}
* @returns {Promise<Cards|null>}
*/
async passDialog() {
const cards = game.cards.filter(c => (c !== this) && (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED"));
if ( !cards.length ) return ui.notifications.warn("CARDS.PassWarnNoTargets", {localize: true});
// Construct the dialog HTML
const html = await renderTemplate("templates/cards/dialog-pass.html", {
cards: cards,
modes: {
[CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop",
[CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom",
[CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom"
}
});
// Display the prompt
return Dialog.prompt({
title: game.i18n.localize("CARDS.PassTitle"),
label: game.i18n.localize("CARDS.Pass"),
content: html,
callback: html => {
const form = html.querySelector("form.cards-dialog");
const fd = new FormDataExtended(form).object;
const to = game.cards.get(fd.to);
const options = {action: "pass", how: fd.how, updateData: fd.down ? {face: null} : {}};
return this.deal([to], fd.number, options).catch(err => {
ui.notifications.error(err.message);
return this;
});
},
rejectClose: false,
options: {jQuery: false}
});
}
/* -------------------------------------------- */
/**
* Display a dialog which prompts the user to play a specific Card to some other Cards document
* @see {@link Cards#pass}
* @param {Card} card The specific card being played as part of this dialog
* @returns {Promise<Card[]|null>}
*/
async playDialog(card) {
const cards = game.cards.filter(c => (c !== this) && (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED"));
if ( !cards.length ) return ui.notifications.warn("CARDS.PassWarnNoTargets", {localize: true});
// Construct the dialog HTML
const html = await renderTemplate("templates/cards/dialog-play.html", {card, cards});
// Display the prompt
return Dialog.prompt({
title: game.i18n.localize("CARD.Play"),
label: game.i18n.localize("CARD.Play"),
content: html,
callback: html => {
const form = html.querySelector("form.cards-dialog");
const fd = new FormDataExtended(form).object;
const to = game.cards.get(fd.to);
const options = {action: "play", updateData: fd.down ? {face: null} : {}};
return this.pass(to, [card.id], options).catch(err => {
return ui.notifications.error(err.message);
});
},
rejectClose: false,
options: {jQuery: false}
});
}
/* -------------------------------------------- */
/**
* Display a confirmation dialog for whether or not the user wishes to reset a Cards stack
* @see {@link Cards#recall}
* @returns {Promise<Cards|false|null>}
*/
async resetDialog() {
return Dialog.confirm({
title: game.i18n.localize("CARDS.Reset"),
content: `<p>${game.i18n.format(`CARDS.${this.type === "deck" ? "Reset" : "Return"}Confirm`, {name: this.name})}</p>`,
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: `
<h4>${game.i18n.localize("CARDS.DeleteCannot")}</h4>
<p>${game.i18n.format("CARDS.DeleteMustReset", {type})}</p>
`,
buttons: {
reset: {
icon: '<i class="fas fa-undo"></i>',
label: game.i18n.localize("CARDS.DeleteReset"),
callback: () => resolve(this.delete())
},
cancel: {
icon: '<i class="fas fa-times"></i>',
label: game.i18n.localize("Cancel"),
callback: () => resolve(false)
}
},
close: () => resolve(null),
default: "reset"
}, options);
dialog.render(true);
});
}
/* -------------------------------------------- */
/** @override */
static async createDialog(data={}, {parent=null, pack=null, ...options}={}) {
// Collect data
const types = game.documentTypes[this.documentName].filter(t => t !== CONST.BASE_DOCUMENT_TYPE);
const folders = parent ? [] : game.cards._formatFolderSelectOptions();
const label = game.i18n.localize(this.metadata.label);
const title = game.i18n.format("DOCUMENT.Create", {type: label});
// Render the document creation form
const html = await renderTemplate("templates/sidebar/cards-create.html", {
folders,
name: data.name || game.i18n.format("DOCUMENT.New", {type: label}),
folder: data.folder,
hasFolders: folders.length >= 1,
type: data.type || types[0],
types: types.reduce((obj, t) => {
const label = CONFIG[this.documentName]?.typeLabels?.[t] ?? t;
obj[t] = game.i18n.has(label) ? game.i18n.localize(label) : t;
return obj;
}, {}),
hasTypes: types.length > 1,
presets: CONFIG.Cards.presets
});
// Render the confirmation dialog window
return Dialog.prompt({
title: title,
content: html,
label: title,
callback: async html => {
const form = html[0].querySelector("form");
const fd = new FormDataExtended(form);
foundry.utils.mergeObject(data, fd.object, {inplace: true});
if ( !data.folder ) delete data.folder;
if ( !data.name?.trim() ) data.name = this.defaultName();
const preset = CONFIG.Cards.presets[data.preset];
if ( preset && (preset.type === data.type) ) {
const presetData = await fetch(preset.src).then(r => r.json());
data = foundry.utils.mergeObject(presetData, data);
}
return this.create(data, {parent, pack, renderSheet: true});
},
rejectClose: false,
options
});
}
}
/**
* The client-side ChatMessage document which extends the common BaseChatMessage model.
*
* @extends documents.BaseChatMessage
* @mixes ClientDocumentMixin
*
* @see {@link documents.Messages} The world-level collection of ChatMessage documents
*
* @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.user?.name ?? game.i18n.localize("CHAT.UnknownUser");
}
/* -------------------------------------------- */
/**
* Is the current User the author of this message?
* @type {boolean}
*/
get isAuthor() {
return !!this.user?.isSelf;
}
/* -------------------------------------------- */
/**
* Return whether the content of the message is visible to the current user.
* For certain dice rolls, for example, the message itself may be visible while the content of that message is not.
* @type {boolean}
*/
get isContentVisible() {
if ( this.isRoll ) {
const whisper = this.whisper || [];
const isBlind = whisper.length && this.blind;
if ( whisper.length ) return whisper.includes(game.user.id) || (this.isAuthor && !isBlind);
return true;
}
else return this.visible;
}
/* -------------------------------------------- */
/**
* Test whether the chat message contains a dice roll
* @type {boolean}
*/
get isRoll() {
return this.type === CONST.CHAT_MESSAGE_TYPES.ROLL;
}
/* -------------------------------------------- */
/**
* Return whether the ChatMessage is visible to the current User.
* Messages may not be visible if they are private whispers.
* @type {boolean}
*/
get visible() {
if ( this.whisper.length ) {
if ( this.type === CONST.CHAT_MESSAGE_TYPES.ROLL ) return true;
return this.isAuthor || (this.whisper.indexOf(game.user.id) !== -1);
}
return true;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
prepareDerivedData() {
super.prepareDerivedData();
// Create Roll instances for contained dice rolls
this.rolls = this.rolls.reduce((rolls, rollData) => {
try {
rolls.push(Roll.fromData(rollData));
} catch(err) {
Hooks.onError("ChatMessage#rolls", err, {rollData, log: "error"});
}
return rolls;
}, []);
}
/* -------------------------------------------- */
/**
* Transform a provided object of ChatMessage data by applying a certain rollMode to the data object.
* @param {object} chatData The object of ChatMessage data prior to applying a rollMode preference
* @param {string} rollMode The rollMode preference to apply to this message data
* @returns {object} The modified ChatMessage data with rollMode preferences applied
*/
static applyRollMode(chatData, rollMode) {
const modes = CONST.DICE_ROLL_MODES;
if ( rollMode === "roll" ) rollMode = game.settings.get("core", "rollMode");
if ( [modes.PRIVATE, modes.BLIND].includes(rollMode) ) {
chatData.whisper = ChatMessage.getWhisperRecipients("GM").map(u => u.id);
}
else if ( rollMode === modes.SELF ) chatData.whisper = [game.user.id];
else if ( rollMode === modes.PUBLIC ) chatData.whisper = [];
chatData.blind = rollMode === modes.BLIND;
return chatData;
}
/* -------------------------------------------- */
/**
* Update the data of a ChatMessage instance to apply a requested rollMode
* @param {string} rollMode The rollMode preference to apply to this message data
*/
applyRollMode(rollMode) {
const updates = {};
this.constructor.applyRollMode(updates, rollMode);
this.updateSource(updates);
}
/* -------------------------------------------- */
/**
* Attempt to determine who is the speaking character (and token) for a certain Chat Message
* First assume that the currently controlled Token is the speaker
*
* @param {object} [options={}] Options which affect speaker identification
* @param {Scene} [options.scene] The Scene in which the speaker resides
* @param {Actor} [options.actor] The Actor who is speaking
* @param {TokenDocument} [options.token] The Token who is speaking
* @param {string} [options.alias] The name of the speaker to display
*
* @returns {object} The identified speaker data
*/
static getSpeaker({scene, actor, token, alias}={}) {
// CASE 1 - A Token is explicitly provided
const hasToken = (token instanceof Token) || (token instanceof TokenDocument);
if ( hasToken ) return this._getSpeakerFromToken({token, alias});
const hasActor = actor instanceof Actor;
if ( hasActor && actor.isToken ) return this._getSpeakerFromToken({token: actor.token, alias});
// CASE 2 - An Actor is explicitly provided
if ( hasActor ) {
alias = alias || actor.name;
const tokens = actor.getActiveTokens();
if ( !tokens.length ) return this._getSpeakerFromActor({scene, actor, alias});
const controlled = tokens.filter(t => t.controlled);
token = controlled.length ? controlled.shift() : tokens.shift();
return this._getSpeakerFromToken({token: token.document, alias});
}
// CASE 3 - Not the viewed Scene
else if ( ( scene instanceof Scene ) && !scene.isView ) {
const char = game.user.character;
if ( char ) return this._getSpeakerFromActor({scene, actor: char, alias});
return this._getSpeakerFromUser({scene, user: game.user, alias});
}
// CASE 4 - Infer from controlled tokens
if ( canvas.ready ) {
let controlled = canvas.tokens.controlled;
if (controlled.length) return this._getSpeakerFromToken({token: controlled.shift().document, alias});
}
// CASE 5 - Infer from impersonated Actor
const char = game.user.character;
if ( char ) {
const tokens = char.getActiveTokens(false, true);
if ( tokens.length ) return this._getSpeakerFromToken({token: tokens.shift(), alias});
return this._getSpeakerFromActor({actor: char, alias});
}
// CASE 6 - From the alias and User
return this._getSpeakerFromUser({scene, user: game.user, alias});
}
/* -------------------------------------------- */
/**
* A helper to prepare the speaker object based on a target TokenDocument
* @param {object} [options={}] Options which affect speaker identification
* @param {TokenDocument} options.token The TokenDocument of the speaker
* @param {string} [options.alias] The name of the speaker to display
* @returns {object} The identified speaker data
* @private
*/
static _getSpeakerFromToken({token, alias}) {
return {
scene: token.parent?.id || null,
token: token.id,
actor: token.actor?.id || null,
alias: alias || token.name
};
}
/* -------------------------------------------- */
/**
* A helper to prepare the speaker object based on a target Actor
* @param {object} [options={}] Options which affect speaker identification
* @param {Scene} [options.scene] The Scene is which the speaker resides
* @param {Actor} [options.actor] The Actor that is speaking
* @param {string} [options.alias] The name of the speaker to display
* @returns {Object} The identified speaker data
* @private
*/
static _getSpeakerFromActor({scene, actor, alias}) {
return {
scene: (scene || canvas.scene)?.id || null,
actor: actor.id,
token: null,
alias: alias || actor.name
};
}
/* -------------------------------------------- */
/**
* A helper to prepare the speaker object based on a target User
* @param {object} [options={}] Options which affect speaker identification
* @param {Scene} [options.scene] The Scene in which the speaker resides
* @param {User} [options.user] The User who is speaking
* @param {string} [options.alias] The name of the speaker to display
* @returns {Object} The identified speaker data
* @private
*/
static _getSpeakerFromUser({scene, user, alias}) {
return {
scene: (scene || canvas.scene)?.id || null,
actor: null,
token: null,
alias: alias || user.name
};
}
/* -------------------------------------------- */
/**
* Obtain an Actor instance which represents the speaker of this message (if any)
* @param {Object} speaker The speaker data object
* @returns {Actor|null}
*/
static getSpeakerActor(speaker) {
if ( !speaker ) return null;
let actor = null;
// Case 1 - Token actor
if ( speaker.scene && speaker.token ) {
const scene = game.scenes.get(speaker.scene);
const token = scene ? scene.tokens.get(speaker.token) : null;
actor = token?.actor;
}
// Case 2 - explicit actor
if ( speaker.actor && !actor ) {
actor = game.actors.get(speaker.actor);
}
return actor || null;
}
/* -------------------------------------------- */
/**
* Obtain a data object used to evaluate any dice rolls associated with this particular chat message
* @returns {object}
*/
getRollData() {
const actor = this.constructor.getSpeakerActor(this.speaker) ?? this.user?.character;
return actor ? actor.getRollData() : {};
}
/* -------------------------------------------- */
/**
* Given a string whisper target, return an Array of the user IDs which should be targeted for the whisper
*
* @param {string} name The target name of the whisper target
* @returns {User[]} An array of User instances
*/
static getWhisperRecipients(name) {
// Whisper to groups
if (["GM", "DM"].includes(name.toUpperCase())) {
return game.users.filter(u => u.isGM);
}
else if (name.toLowerCase() === "players") {
return game.users.players;
}
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<jQuery>}
*/
async getHTML() {
// Determine some metadata
const data = this.toObject(false);
data.content = await TextEditor.enrichHTML(this.content, {async: true, rollData: this.getRollData()});
const isWhisper = this.whisper.length;
// Construct message data
const messageData = {
message: data,
user: game.user,
author: this.user,
alias: this.alias,
cssClass: [
this.type === CONST.CHAT_MESSAGE_TYPES.IC ? "ic" : null,
this.type === CONST.CHAT_MESSAGE_TYPES.EMOTE ? "emote" : null,
isWhisper ? "whisper" : null,
this.blind ? "blind": null
].filterJoin(" "),
isWhisper: this.whisper.length,
canDelete: game.user.isGM, // Only GM users are allowed to have the trash-bin icon in the chat log itself
whisperTo: this.whisper.map(u => {
let user = game.users.get(u);
return user ? user.name : null;
}).filterJoin(", ")
};
// Render message data specifically for ROLL type messages
if ( this.isRoll ) {
await this._renderRollContent(messageData);
}
// Define a border color
if ( this.type === CONST.CHAT_MESSAGE_TYPES.OOC ) {
messageData.borderColor = this.user?.color;
}
// Render the chat message
let html = await renderTemplate(CONFIG.ChatMessage.template, messageData);
html = $(html);
// Flag expanded state of dice rolls
if ( this._rollExpanded ) html.find(".dice-tooltip").addClass("expanded");
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.user?.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<string>} 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 */
/* -------------------------------------------- */
/** @override */
async _preCreate(data, options, user) {
await super._preCreate(data, options, user);
if ( foundry.utils.getType(data.content) === "string" ) {
// Evaluate any immediately-evaluated inline rolls.
const matches = data.content.matchAll(/\[\[[^/].*?]{2,3}/g);
let content = data.content;
for ( const [expression] of matches ) {
content = content.replace(expression, await TextEditor.enrichHTML(expression, {
async: true,
documents: false,
secrets: false,
links: false,
rolls: true,
rollData: this.getRollData()
}));
}
this.updateSource({content});
}
if ( this.isRoll ) {
if ( !("sound" in data) ) this.updateSource({sound: CONFIG.Dice.sound});
const rollMode = options.rollMode || data.rollMode || game.settings.get("core", "rollMode");
if ( rollMode ) this.applyRollMode(rollMode);
}
}
/* -------------------------------------------- */
/** @override */
_onCreate(data, options, userId) {
super._onCreate(data, options, userId);
if ( options.temporary ) return;
ui.chat.postOne(this, {notify: true});
if ( options.chatBubble && canvas.ready ) {
game.messages.sayBubble(this);
}
}
/* -------------------------------------------- */
/** @override */
_onUpdate(data, options, userId) {
if ( !this.visible ) ui.chat.deleteMessage(this.id);
else ui.chat.updateMessage(this);
super._onUpdate(data, options, userId);
}
/* -------------------------------------------- */
/** @override */
_onDelete(options, userId) {
ui.chat.deleteMessage(this.id, options);
super._onDelete(options, userId);
}
/* -------------------------------------------- */
/* Importing and Exporting */
/* -------------------------------------------- */
/**
* Export the content of the chat message into a standardized log format
* @returns {string}
*/
export() {
let content = [];
// Handle HTML content
if ( this.content ) {
const html = $("<article>").html(this.content.replace(/<\/div>/g, "</div>|n"));
const text = html.length ? html.text() : this.content;
const lines = text.replace(/\n/g, "").split(" ").filter(p => p !== "").join(" ");
content = lines.split("|n").map(l => l.trim());
}
// Add Roll content
for ( const roll of this.rolls ) {
content.push(`${roll.formula} = ${roll.result} = ${roll.total}`);
}
// Author and timestamp
const time = new Date(this.timestamp).toLocaleDateString("en-US", {
hour: "numeric",
minute: "numeric",
second: "numeric"
});
// Format logged result
return `[${time}] ${this.alias}\n${content.filterJoin("\n")}`;
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* Return the first Roll instance contained in this chat message, if one is present
* @deprecated since v10
* @ignore
* @type {Roll|null}
*/
get roll() {
const msg = "You are calling ChatMessage#roll which is deprecated in V10 in favor of ChatMessage#rolls.";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return this.rolls[0] || null;
}
}
/**
* @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 documents.BaseCombat
* @mixes ClientDocumentMixin
*
* @see {@link documents.Combats} The world-level collection of Combat documents
* @see {@link Combatant} The Combatant embedded document which exists within a Combat document
* @see {@link CombatConfig} The Combat configuration application
*/
class Combat extends ClientDocumentMixin(foundry.documents.BaseCombat) {
constructor(data, context) {
super(data, context);
/**
* Track the sorted turn order of this combat encounter
* @type {Combatant[]}
*/
this.turns = this.turns || [];
/**
* Record the current round, turn, and tokenId to understand changes in the encounter state
* @type {CombatHistoryData}
*/
this.current = this.current || {
round: null,
turn: null,
tokenId: null,
combatantId: null
};
/**
* Track the previous round, turn, and tokenId to understand changes in the encounter state
* @type {CombatHistoryData}
*/
this.previous = this.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.turns.length > 0 ) && ( this.round > 0 );
}
/* -------------------------------------------- */
/** @inheritdoc */
get visible() {
return true;
}
/* -------------------------------------------- */
/**
* Is this combat active in the current scene?
* @type {boolean}
*/
get isActive() {
if ( !this.scene ) return this.active;
return this.scene.isView && this.active;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Set the current Combat encounter as active within the Scene.
* Deactivate all other Combat encounters within the viewed Scene and set this one as active
* @param {object} [options] Additional context to customize the update workflow
* @returns {Promise<Combat>}
*/
async activate(options) {
const updates = this.collection.reduce((arr, c) => {
if ( c.isActive ) arr.push({_id: c.id, active: false});
return arr;
}, []);
updates.push({_id: this.id, active: true});
return this.constructor.updateDocuments(updates, options);
}
/* -------------------------------------------- */
/** @override */
prepareDerivedData() {
if ( this.combatants.size && !this.turns?.length ) this.setupTurns();
}
/* -------------------------------------------- */
/**
* Get a Combatant using its Token id
* @param {string} tokenId The id of the Token for which to acquire the combatant
* @returns {Combatant}
*/
getCombatantByToken(tokenId) {
return this.combatants.find(c => c.tokenId === tokenId);
}
/* -------------------------------------------- */
/**
* Get a Combatant that represents the given Actor or Actor ID.
* @param {string|Actor} actorOrId An Actor ID or an Actor instance.
* @returns {Combatant}
*/
getCombatantByActor(actorOrId) {
const isActor = actorOrId instanceof getDocumentClass(Actor.documentName);
if ( isActor && actorOrId.isToken ) return this.getCombatantByToken(actorOrId.token.id);
return this.combatants.find(c => c.actorId === (isActor ? actorOrId.id : actorOrId));
}
/* -------------------------------------------- */
/**
* Begin the combat encounter, advancing to round 1 and turn 1
* @returns {Promise<Combat>}
*/
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<Combat>}
*/
async nextRound() {
let turn = this.turn === null ? null : 0; // Preserve the fact that it's no-one's turn currently.
if ( this.settings.skipDefeated && (turn !== null) ) {
turn = this.turns.findIndex(t => !t.isDefeated);
if (turn === -1) {
ui.notifications.warn("COMBAT.NoneRemaining", {localize: true});
turn = 0;
}
}
let advanceTime = Math.max(this.turns.length - this.turn, 0) * CONFIG.time.turnTime;
advanceTime += CONFIG.time.roundTime;
let nextRound = this.round + 1;
// Update the document, passing data through a hook first
const updateData = {round: nextRound, turn};
const updateOptions = {advanceTime, direction: 1};
Hooks.callAll("combatRound", this, updateData, updateOptions);
return this.update(updateData, updateOptions);
}
/* -------------------------------------------- */
/**
* Rewind the combat to the previous round
* @returns {Promise<Combat>}
*/
async previousRound() {
let turn = ( this.round === 0 ) ? 0 : Math.max(this.turns.length - 1, 0);
if ( this.turn === null ) turn = null;
let round = Math.max(this.round - 1, 0);
let advanceTime = -1 * (this.turn || 0) * CONFIG.time.turnTime;
if ( round > 0 ) advanceTime -= CONFIG.time.roundTime;
// Update the document, passing data through a hook first
const updateData = {round, turn};
const updateOptions = {advanceTime, direction: -1};
Hooks.callAll("combatRound", this, updateData, updateOptions);
return this.update(updateData, updateOptions);
}
/* -------------------------------------------- */
/**
* Advance the combat to the next turn
* @returns {Promise<Combat>}
*/
async nextTurn() {
let turn = this.turn ?? -1;
let skip = this.settings.skipDefeated;
// Determine the next turn number
let next = null;
if ( skip ) {
for ( let [i, t] of this.turns.entries() ) {
if ( i <= turn ) continue;
if ( t.isDefeated ) continue;
next = i;
break;
}
}
else next = turn + 1;
// Maybe advance to the next round
let round = this.round;
if ( (this.round === 0) || (next === null) || (next >= this.turns.length) ) {
return this.nextRound();
}
// Update the document, passing data through a hook first
const updateData = {round, turn: next};
const updateOptions = {advanceTime: CONFIG.time.turnTime, direction: 1};
Hooks.callAll("combatTurn", this, updateData, updateOptions);
return this.update(updateData, updateOptions);
}
/* -------------------------------------------- */
/**
* Rewind the combat to the previous turn
* @returns {Promise<Combat>}
*/
async previousTurn() {
if ( (this.turn === 0) && (this.round === 0) ) return this;
else if ( (this.turn <= 0) && (this.turn !== null) ) return this.previousRound();
let advanceTime = -1 * CONFIG.time.turnTime;
let previousTurn = (this.turn ?? this.turns.length) - 1;
// Update the document, passing data through a hook first
const updateData = {round: this.round, turn: previousTurn};
const updateOptions = {advanceTime, direction: -1};
Hooks.callAll("combatTurn", this, updateData, updateOptions);
return this.update(updateData, updateOptions);
}
/* -------------------------------------------- */
/**
* Display a dialog querying the GM whether they wish to end the combat encounter and empty the tracker
* @returns {Promise<Combat>}
*/
async endCombat() {
return Dialog.confirm({
title: game.i18n.localize("COMBAT.EndTitle"),
content: `<p>${game.i18n.localize("COMBAT.EndConfirmation")}</p>`,
yes: () => this.delete()
});
}
/* -------------------------------------------- */
/**
* Toggle whether this combat is linked to the scene or globally available.
* @returns {Promise<Combat>}
*/
async toggleSceneLink() {
const scene = this.scene ? null : (game.scenes.current?.id || null);
return this.update({scene});
}
/* -------------------------------------------- */
/**
* Reset all combatant initiative scores, setting the turn back to zero
* @returns {Promise<Combat>}
*/
async resetAll() {
for ( let c of this.combatants ) {
c.updateSource({initiative: null});
}
return this.update({turn: 0, combatants: this.combatants.toObject()}, {diff: false});
}
/* -------------------------------------------- */
/**
* Roll initiative for one or multiple Combatants within the Combat document
* @param {string|string[]} ids A Combatant id or Array of ids for which to roll
* @param {object} [options={}] Additional options which modify how initiative rolls are created or presented.
* @param {string|null} [options.formula] A non-default initiative formula to roll. Otherwise, the system
* default is used.
* @param {boolean} [options.updateTurn=true] Update the Combat turn after adding new initiative scores to
* keep the turn on the same Combatant.
* @param {object} [options.messageOptions={}] Additional options with which to customize created Chat Messages
* @returns {Promise<Combat>} A promise which resolves to the updated Combat document once updates are complete.
*/
async rollInitiative(ids, {formula=null, updateTurn=true, messageOptions={}}={}) {
// Structure input data
ids = typeof ids === "string" ? [ids] : ids;
const currentId = this.combatant?.id;
const chatRollMode = game.settings.get("core", "rollMode");
// Iterate over Combatants, performing an initiative roll for each
const updates = [];
const messages = [];
for ( let [i, id] of ids.entries() ) {
// Get Combatant data (non-strictly)
const combatant = this.combatants.get(id);
if ( !combatant?.isOwner ) continue;
// Produce an initiative roll for the Combatant
const roll = combatant.getInitiativeRoll(formula);
await roll.evaluate({async: true});
updates.push({_id: id, initiative: roll.total});
// Construct chat message data
let messageData = foundry.utils.mergeObject({
speaker: ChatMessage.getSpeaker({
actor: combatant.actor,
token: combatant.token,
alias: combatant.name
}),
flavor: game.i18n.format("COMBAT.RollsInitiative", {name: combatant.name}),
flags: {"core.initiativeRoll": true}
}, messageOptions);
const chatData = await roll.toMessage(messageData, {create: false});
// If the combatant is hidden, use a private roll unless an alternative rollMode was explicitly requested
chatData.rollMode = "rollMode" in messageOptions ? messageOptions.rollMode
: (combatant.hidden ? CONST.DICE_ROLL_MODES.PRIVATE : chatRollMode );
// Play 1 sound for the whole rolled set
if ( i > 0 ) chatData.sound = null;
messages.push(chatData);
}
if ( !updates.length ) return this;
// Update multiple combatants
await this.updateEmbeddedDocuments("Combatant", updates);
// Ensure the turn order remains with the same combatant
if ( updateTurn && currentId ) {
await this.update({turn: this.turns.findIndex(t => t.id === currentId)});
}
// Create multiple chat messages
await ChatMessage.implementation.create(messages);
return this;
}
/* -------------------------------------------- */
/**
* Roll initiative for all combatants which have not already rolled
* @param {object} [options={}] Additional options forwarded to the Combat.rollInitiative method
*/
async rollAll(options) {
const ids = this.combatants.reduce((ids, c) => {
if ( c.isOwner && (c.initiative === null) ) ids.push(c.id);
return ids;
}, []);
return this.rollInitiative(ids, options);
}
/* -------------------------------------------- */
/**
* Roll initiative for all non-player actors who have not already rolled
* @param {object} [options={}] Additional options forwarded to the Combat.rollInitiative method
*/
async rollNPC(options={}) {
const ids = this.combatants.reduce((ids, c) => {
if ( c.isOwner && c.isNPC && (c.initiative === null) ) ids.push(c.id);
return ids;
}, []);
return this.rollInitiative(ids, options);
}
/* -------------------------------------------- */
/**
* Assign initiative for a single Combatant within the Combat encounter.
* Update the Combat turn order to maintain the same combatant as the current turn.
* @param {string} id The combatant ID for which to set initiative
* @param {number} value A specific initiative value to set
*/
async setInitiative(id, value) {
const combatant = this.combatants.get(id, {strict: true});
await combatant.update({initiative: value});
}
/* -------------------------------------------- */
/**
* Return the Array of combatants sorted into initiative order, breaking ties alphabetically by name.
* @returns {Combatant[]}
*/
setupTurns() {
// Determine the turn order and the current turn
const turns = this.combatants.contents.sort(this._sortCombatants);
if ( this.turn !== null) this.turn = Math.clamped(this.turn, 0, turns.length-1);
// Update state tracking
let c = turns[this.turn];
this.current = {
round: this.round,
turn: this.turn,
combatantId: c ? c.id : null,
tokenId: c ? c.tokenId : null
};
// 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)];
const volume = game.settings.get("core", "globalInterfaceVolume");
game.audio.play(src, {volume});
}
/* -------------------------------------------- */
/**
* 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 ) ui.combat.initialize({combat: this, render: false});
this._manageTurnEvents();
}
/* -------------------------------------------- */
/** @inheritdoc */
_onUpdate(data, options, userId) {
super._onUpdate(data, options, userId);
// Update turn order
const priorState = foundry.utils.deepClone(this.current);
if ( "combatants" in data ) this.setupTurns(); // Update all combatants
else { // Update turn or round
const combatant = this.combatant;
this.current = {
round: this.round,
turn: this.turn,
combatantId: combatant?.id || null,
tokenId: combatant?.tokenId || null
};
}
this.#recordPreviousState(priorState);
// Update rendering for actors involved in the Combat
this.updateCombatantActors();
// Dispatch Turn Events
if ( options.turnEvents !== false ) this._manageTurnEvents();
// 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");
}
// Render the sidebar
if ( (data.active === true) && this.isActive ) ui.combat.initialize({combat: this});
else if ( "scene" in data ) ui.combat.initialize();
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDelete(options, userId) {
super._onDelete(options, userId);
if ( this.collection.viewed === this ) ui.combat.initialize({render: false});
if ( userId === game.userId ) this.collection.viewed?.activate();
}
/* -------------------------------------------- */
/** @inheritdoc */
_onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
if ( parent === this ) this._refreshTokenHUD(documents);
// Update turn order
const priorState = foundry.utils.deepClone(this.current);
const combatant = this.combatant;
this.setupTurns();
this.#recordPreviousState(priorState);
// Adjust turn order to keep the current Combatant the same
const adjustedTurn = combatant ? Math.max(this.turns.findIndex(t => t.id === combatant.id), 0) : undefined;
if ( options.turnEvents !== false ) this._manageTurnEvents(adjustedTurn);
// Render the Collection
if ( this.active && (options.render !== false) ) this.collection.render();
}
/* -------------------------------------------- */
/** @inheritdoc */
_onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
// Update the turn order
const priorState = foundry.utils.deepClone(this.current);
const combatant = this.combatant;
this.setupTurns();
this.#recordPreviousState(priorState);
// Adjust turn order to keep the current Combatant the same
const sameTurn = combatant ? this.turns.findIndex(t => t.id === combatant.id) : this.turn;
const adjustedTurn = sameTurn !== this.turn ? sameTurn : undefined;
if ( options.turnEvents !== false ) this._manageTurnEvents(adjustedTurn);
// Render the Collection
if ( this.active && (options.render !== false) ) this.collection.render();
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
if ( parent === this ) this._refreshTokenHUD(documents);
// Update the turn order, taking note of which surviving combatants remain
const priorState = foundry.utils.deepClone(this.current);
const combatant = this.combatant;
const {prevSurvivor, nextSurvivor} = this.turns.reduce((obj, t, i) => {
let valid = !ids.includes(t.id);
if ( this.settings.skipDefeated ) valid &&= !t.isDefeated;
if ( !valid ) return obj;
if ( i < this.turn ) obj.prevSurvivor = t;
if ( !obj.nextSurvivor && (i >= this.turn) ) obj.nextSurvivor = t;
return obj;
}, {});
this.setupTurns();
this.#recordPreviousState(priorState);
// If the current combatant was removed progress to the next survivor
// Otherwise, keep the combatant the same
let adjustedTurn;
if ( ids.includes(combatant?.id) ) {
const survivor = nextSurvivor || prevSurvivor;
if ( survivor ) adjustedTurn = this.turns.findIndex(t => t.id === survivor.id);
}
else if ( combatant ) {
const sameTurn = this.turns.findIndex(t => t.id === combatant.id);
adjustedTurn = sameTurn !== this.turn ? sameTurn : undefined;
}
if ( options.turnEvents !== false ) this._manageTurnEvents(adjustedTurn);
// Render the Collection
if ( this.active && (options.render !== false) ) this.collection.render();
}
/* -------------------------------------------- */
/**
* 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
*/
#recordPreviousState(priorState) {
const current = this.current;
const hasChanged = (current.combatantId !== priorState.combatantId) || (current.round !== priorState.round)
|| (current.turn !== priorState.turn);
if ( hasChanged ) this.previous = priorState;
}
/* -------------------------------------------- */
/* 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.
* @param {number} [adjustedTurn] Optionally, an adjusted turn to commit to the Combat.
* @returns {Promise<void>}
* @protected
*/
async _manageTurnEvents(adjustedTurn) {
if ( !game.users.activeGM?.isSelf ) return;
const prior = this.combatants.get(this.previous.combatantId);
// Adjust the turn order before proceeding. Used for embedded document workflows
if ( Number.isNumeric(adjustedTurn) ) await this.update({turn: adjustedTurn}, {turnEvents: false});
if ( !this.started ) return;
// Identify what progressed
const advanceRound = this.current.round > (this.previous.round ?? -1);
const advanceTurn = this.current.turn > (this.previous.turn ?? -1);
if ( !(advanceTurn || advanceRound) ) return;
// Conclude prior turn
if ( prior ) await this._onEndTurn(prior);
// Conclude prior round
if ( advanceRound && (this.previous.round !== null) ) await this._onEndRound();
// Begin new round
if ( advanceRound ) await this._onStartRound();
// Begin a new turn
await this._onStartTurn(this.combatant);
}
/* -------------------------------------------- */
/**
* 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<void>}
* @protected
*/
async _onEndTurn(combatant) {
if ( CONFIG.debug.combat ) {
console.debug(`${vtt} | Combat End Turn: ${this.combatants.get(this.previous.combatantId).name}`);
}
}
/* -------------------------------------------- */
/**
* 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<void>}
* @protected
*/
async _onEndRound() {
if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat End Round: ${this.previous.round}`);
}
/* -------------------------------------------- */
/**
* 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<void>}
* @protected
*/
async _onStartRound() {
if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat Start Round: ${this.round}`);
}
/* -------------------------------------------- */
/**
* 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<void>}
* @protected
*/
async _onStartTurn(combatant) {
if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat Start Turn: ${this.combatant.name}`);
}
/* -------------------------------------------- */
/* 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();
}
}
/**
* The client-side Combatant document which extends the common BaseCombatant model.
*
* @extends documents.BaseCombatant
* @mixes ClientDocumentMixin
*
* @see {@link Combat} The Combat document which contains Combatant embedded documents
* @see {@link CombatantConfig} The application which configures a Combatant.
*/
class Combatant extends ClientDocumentMixin(foundry.documents.BaseCombatant) {
/**
* The token video source image (if any)
* @type {string|null}
* @internal
*/
_videoSrc = null;
/**
* The current value of the special tracked resource which pertains to this Combatant
* @type {object|null}
*/
resource = null;
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* A convenience alias of Combatant#parent which is more semantically intuitive
* @type {Combat|null}
*/
get combat() {
return this.parent;
}
/* -------------------------------------------- */
/**
* This is treated as a non-player combatant if it has no associated actor and no player users who can control it
* @type {boolean}
*/
get isNPC() {
return !this.actor || !this.hasPlayerOwner;
}
/* -------------------------------------------- */
/** @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<Combatant>} The updated Combatant.
*/
async rollInitiative(formula) {
const roll = this.getInitiativeRoll(formula);
await roll.evaluate({async: true});
return this.update({initiative: roll.total});
}
/* -------------------------------------------- */
/** @override */
prepareDerivedData() {
// Check for video source and save it if present
this._videoSrc = VideoHelper.hasVideoExtension(this.token?.texture.src) ? this.token.texture.src : null;
// Assign image for combatant (undefined if the token src image is a video)
this.img ||= (this._videoSrc ? undefined : (this.token?.texture.src || this.actor?.img));
this.name ||= this.token?.name || this.actor?.name || game.i18n.localize("COMBAT.UnknownCombatant");
this.updateResource();
}
/* -------------------------------------------- */
/**
* Update the value of the tracked resource for this Combatant.
* @returns {null|object}
*/
updateResource() {
if ( !this.actor || !this.combat ) return this.resource = null;
return this.resource = foundry.utils.getProperty(this.actor.system, this.parent.settings.resource) || null;
}
/* -------------------------------------------- */
/**
* Acquire the default dice formula which should be used to roll initiative for this combatant.
* Modules or systems could choose to override or extend this to accommodate special situations.
* @returns {string} The initiative formula to use for this combatant.
* @protected
*/
_getInitiativeFormula() {
return String(CONFIG.Combat.initiative.formula || game.system.initiative);
}
}
/**
* The client-side Drawing document which extends the common BaseDrawing model.
*
* @extends documents.BaseDrawing
* @mixes ClientDocumentMixin
*
* @see {@link Scene} The Scene document type which contains Drawing embedded documents
* @see {@link DrawingConfig} The Drawing configuration application
*/
class DrawingDocument extends CanvasDocumentMixin(foundry.documents.BaseDrawing) {
/**
* Define an elevation property on the Drawing Document which in the future will become a part of its data schema.
* @type {number}
*/
get elevation() {
return this.#elevation ?? this.z ?? 0;
}
set elevation(value) {
if ( !Number.isFinite(value) && (value !== undefined) ) {
throw new Error("Elevation must be a finite Number or undefined");
}
this.#elevation = value;
if ( this.rendered ) {
canvas.primary.sortDirty = true;
canvas.perception.update({refreshTiles: true});
// TODO: Temporary workaround. Delete when elevation will be a real drawing document property
this._object.renderFlags.set({refreshShape: true});
}
}
#elevation;
/* -------------------------------------------- */
/**
* Define a sort property on the Drawing Document which in the future will become a core part of its data schema.
* @type {number}
*/
get sort() {
return this.z;
}
}
/**
* The client-side FogExploration document which extends the common BaseFogExploration model.
* @extends documents.BaseFogExploration
* @mixes ClientDocumentMixin
*/
class FogExploration extends ClientDocumentMixin(foundry.documents.BaseFogExploration) {
/**
* 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<FogExploration|null>}
*/
static async get({scene, user}={}, options={}) {
const collection = game.collections.get("FogExploration");
const sceneId = (scene || canvas.scene)?.id || null;
const userId = (user || game.user)?.id;
if ( !sceneId || !userId ) return null;
if ( !(game.user.isGM || (userId === game.user.id)) ) {
throw new Error("You do not have permission to access the FogExploration object of another user");
}
// Return cached exploration
let exploration = collection.find(x => (x.user === userId) && (x.scene === sceneId));
if ( exploration ) return exploration;
// Return persisted exploration
const response = await this.database.get(this, {
query: {scene: sceneId, user: userId},
options: options
});
exploration = response.length ? response.shift() : null;
if ( exploration ) collection.set(exploration.id, exploration);
return exploration;
}
/* -------------------------------------------- */
/**
* Transform the explored base64 data into a PIXI.Texture object
* @returns {PIXI.Texture|null}
*/
getTexture() {
if ( !this.explored ) return null;
const bt = new PIXI.BaseTexture(this.explored);
return new PIXI.Texture(bt);
}
/* -------------------------------------------- */
/** @override */
_onCreate(data, options, userId) {
super._onCreate(data, options, userId);
if ( (options.loadFog !== false) && (this.user === game.user) && (this.scene === canvas.scene) ) canvas.fog.load();
}
/* -------------------------------------------- */
/** @override */
_onUpdate(data, options, userId) {
super._onUpdate(data, options, userId);
if ( (options.loadFog !== false) && (this.user === game.user) && (this.scene === canvas.scene) ) canvas.fog.load();
}
/* -------------------------------------------- */
/** @override */
_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;
}
}
/**
* The client-side Folder document which extends the common BaseFolder model.
* @extends documents.BaseFolder
* @mixes ClientDocumentMixin
*
* @see {@link Folders} The world-level collection of Folder documents
* @see {@link FolderConfig} The Folder configuration application
*/
class Folder extends ClientDocumentMixin(foundry.documents.BaseFolder) {
/**
* The depth of this folder in its sidebar tree
* @type {number}
*/
depth;
/**
* An array of other Folders which are the displayed children of this one. This differs from the results of
* {@link Folder.getSubfolders} because reports the subset of child folders which are displayed to the current User
* in the UI.
* @type {Folder[]}
*/
children;
/**
* Return whether the folder is displayed in the sidebar to the current User.
* @type {boolean}
*/
displayed = false;
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* 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.
* @type {WorldCollection|Collection}
*/
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 */
/* -------------------------------------------- */
/** @override */
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}));
}
super._preCreate(data, options, user);
}
/* -------------------------------------------- */
/**
* Present a Dialog form to create a new Folder.
* @see ClientDocumentMixin.createDialog
* @param {object} data Initial data with which to populate the creation form
* @param {object} [context={}] Additional context options or dialog positioning options
* @param {object} [context.options={}] Dialog options
* @param {string} [context.options.pack=""] The name of a Compendium pack to which the folder should be created in
* @returns {Promise<Folder|null>} A Promise which resolves to the created Folder, or null if the dialog was
* closed.
*/
static async createDialog(data={}, options={}) {
const folder = new Folder.implementation(foundry.utils.mergeObject({
name: Folder.defaultName(),
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<CompendiumCollection>} 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: this.type,
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: this.type, compendium: pack.collection}));
pack.render(false);
return pack;
}
/* -------------------------------------------- */
/**
* Provide a dialog form that allows for exporting the contents of a Folder into an eligible Compendium pack.
* @param {string} pack A pack ID to set as the default choice in the select input
* @param {object} options Additional options passed to the Dialog.prompt method
* @returns {Promise<void>} A Promise which resolves or rejects once the dialog has been submitted or closed
*/
async exportDialog(pack, options={}) {
// Get eligible pack destinations
const packs = game.packs.filter(p => (p.documentName === this.type) && !p.locked);
if ( !packs.length ) {
return ui.notifications.warn(game.i18n.format("FOLDER.ExportWarningNone", {type: this.type}));
}
// Render the HTML form
const html = await renderTemplate("templates/sidebar/apps/folder-export.html", {
packs: packs.reduce((obj, p) => {
obj[p.collection] = p.title;
return obj;
}, {}),
pack: options.pack ?? null,
merge: options.merge ?? true,
keepId: options.keepId ?? true,
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;
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
get content() {
foundry.utils.logCompatibilityWarning("Folder#content is deprecated in favor of Folder#contents.",
{since: 10, until: 12});
return this.contents;
}
}
/**
* The client-side Item document which extends the common BaseItem model.
* @extends documents.BaseItem
* @mixes ClientDocumentMixin
*
* @see {@link documents.Items} The world-level collection of Item documents
* @see {@link applications.ItemSheet} The Item configuration application
*/
class Item extends ClientDocumentMixin(foundry.documents.BaseItem) {
/**
* A convenience alias of Item#parent which is more semantically intuitive
* @type {Actor|null}
*/
get actor() {
return this.parent instanceof Actor ? this.parent : null;
}
/* -------------------------------------------- */
/**
* Provide a thumbnail image path used to represent this document.
* @type {string}
*/
get thumbnail() {
return this.img;
}
/* -------------------------------------------- */
/**
* A convenience alias of Item#isEmbedded which is preserves legacy support
* @type {boolean}
*/
get isOwned() {
return this.isEmbedded;
}
/* -------------------------------------------- */
/**
* Return an array of the Active Effect instances which originated from this Item.
* The returned instances are the ActiveEffect instances which exist on the Item itself.
* @type {ActiveEffect[]}
*/
get transferredEffects() {
return this.effects.filter(e => e.transfer === true);
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Prepare a data object which defines the data schema used by dice roll commands against this Item
* @returns {object}
*/
getRollData() {
return this.system;
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
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);
}
/* -------------------------------------------- */
/** @inheritdoc */
static async _onCreateDocuments(items, context) {
if ( !(context.parent instanceof Actor) || !CONFIG.ActiveEffect.legacyTransferral ) return;
const toCreate = [];
for ( let item of items ) {
for ( let e of item.effects ) {
if ( !e.transfer ) continue;
const effectData = e.toJSON();
effectData.origin = item.uuid;
toCreate.push(effectData);
}
}
if ( !toCreate.length ) return [];
const cls = getDocumentClass("ActiveEffect");
return cls.createDocuments(toCreate, context);
}
/* -------------------------------------------- */
/** @inheritdoc */
static async _onDeleteDocuments(items, context) {
if ( !(context.parent instanceof Actor) || !CONFIG.ActiveEffect.legacyTransferral ) return;
const actor = context.parent;
const deletedUUIDs = new Set(items.map(i => {
if ( actor.isToken ) return i.uuid.split(".").slice(-2).join(".");
return i.uuid;
}));
const toDelete = [];
for ( const e of actor.effects ) {
let origin = e.origin || "";
if ( actor.isToken ) origin = origin.split(".").slice(-2).join(".");
if ( deletedUUIDs.has(origin) ) toDelete.push(e.id);
}
if ( !toDelete.length ) return [];
const cls = getDocumentClass("ActiveEffect");
return cls.deleteDocuments(toDelete, context);
}
}
/**
* The client-side JournalEntryPage document which extends the common BaseJournalEntryPage document model.
* @extends documents.BaseJournalEntryPage
* @mixes ClientDocumentMixin
*
* @see {@link JournalEntry} The JournalEntry document type which contains JournalEntryPage embedded documents.
*/
class JournalEntryPage extends ClientDocumentMixin(foundry.documents.BaseJournalEntryPage) {
/**
* @typedef {object} JournalEntryPageHeading
* @property {number} level The heading level, 1-6.
* @property {string} text The raw heading text with any internal tags omitted.
* @property {string} slug The generated slug for this heading.
* @property {HTMLHeadingElement} [element] The currently rendered element for this heading, if it exists.
* @property {string[]} children Any child headings of this one.
*/
/**
* The cached table of contents for this JournalEntryPage.
* @type {Object<JournalEntryPageHeading>}
* @protected
*/
_toc;
/* -------------------------------------------- */
/**
* The table of contents for this JournalEntryPage.
* @type {Object<JournalEntryPageHeading>}
*/
get toc() {
if ( this.type !== "text" ) return {};
if ( this._toc ) return this._toc;
const renderTarget = document.createElement("template");
renderTarget.innerHTML = this.text.content;
this._toc = this.constructor.buildTOC(Array.from(renderTarget.content.children), {includeElement: false});
return this._toc;
}
/* -------------------------------------------- */
/** @inheritdoc */
get permission() {
if ( game.user.isGM ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER;
return this.getUserLevel(game.user);
}
/* -------------------------------------------- */
/**
* Return a reference to the Note instance for this Journal Entry Page in the current Scene, if any.
* If multiple notes are placed for this Journal Entry, only the first will be returned.
* @type {Note|null}
*/
get sceneNote() {
if ( !canvas.ready ) return null;
return canvas.notes.placeables.find(n => {
return (n.document.entryId === this.parent.id) && (n.document.pageId === this.id);
}) || null;
}
/* -------------------------------------------- */
/* Table of Contents */
/* -------------------------------------------- */
/**
* Convert a heading into slug suitable for use as an identifier.
* @param {HTMLHeadingElement|string} heading The heading element or some text content.
* @returns {string}
*/
static slugifyHeading(heading) {
if ( heading instanceof HTMLElement ) heading = heading.textContent;
return heading.slugify().replace(/["']/g, "").substring(0, 64);
}
/* -------------------------------------------- */
/**
* Build a table of contents for the given HTML content.
* @param {HTMLElement[]} html The HTML content to generate a ToC outline for.
* @param {object} [options] Additional options to configure ToC generation.
* @param {boolean} [options.includeElement=true] Include references to the heading DOM elements in the returned ToC.
* @returns {Object<JournalEntryPageHeading>}
*/
static buildTOC(html, {includeElement=true}={}) {
// A pseudo root heading element to start at.
const root = {level: 0, children: []};
// Perform a depth-first-search down the DOM to locate heading nodes.
const stack = [root];
const searchHeadings = element => {
if ( element instanceof HTMLHeadingElement ) {
const node = this._makeHeadingNode(element, {includeElement});
let parent = stack.at(-1);
if ( node.level <= parent.level ) {
stack.pop();
parent = stack.at(-1);
}
parent.children.push(node);
stack.push(node);
}
for ( const child of (element.children || []) ) {
searchHeadings(child);
}
};
html.forEach(searchHeadings);
return this._flattenTOC(root.children);
}
/* -------------------------------------------- */
/**
* Flatten the tree structure into a single object with each node's slug as the key.
* @param {JournalEntryPageHeading[]} nodes The root ToC nodes.
* @returns {Object<JournalEntryPageHeading>}
* @protected
*/
static _flattenTOC(nodes) {
const toc = {};
const addNode = node => {
if ( toc[node.slug] ) {
let i = 1;
while ( toc[`${node.slug}$${i}`] ) i++;
node.slug = `${node.slug}$${i}`;
}
toc[node.slug] = node;
return node.slug;
};
const flattenNode = node => {
const slug = addNode(node);
while ( node.children.length ) {
if ( typeof node.children[0] === "string" ) break;
const child = node.children.shift();
node.children.push(flattenNode(child));
}
return slug;
};
nodes.forEach(flattenNode);
return toc;
}
/* -------------------------------------------- */
/**
* Construct a table of contents node from a heading element.
* @param {HTMLHeadingElement} heading The heading element.
* @param {object} [options] Additional options to configure the returned node.
* @param {boolean} [options.includeElement=true] Whether to include the DOM element in the returned ToC node.
* @returns {JournalEntryPageHeading}
* @protected
*/
static _makeHeadingNode(heading, {includeElement=true}={}) {
const node = {
text: heading.innerText,
level: Number(heading.tagName[1]),
slug: heading.id || this.slugifyHeading(heading),
children: []
};
if ( includeElement ) node.element = heading;
return node;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
_createDocumentLink(eventData, {relativeTo, label}={}) {
const uuid = relativeTo ? this.getRelativeUUID(relativeTo) : this.uuid;
if ( eventData.anchor?.slug ) {
label ??= eventData.anchor.name;
return `@UUID[${uuid}#${eventData.anchor.slug}]{${label}}`;
}
return super._createDocumentLink(eventData, {relativeTo, label});
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickDocumentLink(event) {
const target = event.currentTarget;
return this.parent.sheet.render(true, {pageId: this.id, anchor: target.dataset.hash});
}
/* -------------------------------------------- */
/** @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());
}
}
}
/**
* The client-side JournalEntry document which extends the common BaseJournalEntry model.
* @extends documents.BaseJournalEntry
* @mixes ClientDocumentMixin
*
* @see {@link Journal} The world-level collection of JournalEntry documents
* @see {@link JournalSheet} The JournalEntry configuration application
*/
class JournalEntry extends ClientDocumentMixin(foundry.documents.BaseJournalEntry) {
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* A boolean indicator for whether the JournalEntry is visible to the current user in the directory sidebar
* @type {boolean}
*/
get visible() {
return this.testUserPermission(game.user, "OBSERVER");
}
/* -------------------------------------------- */
/** @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<JournalEntry>} 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<void>} A Promise which resolves once the pan animation has concluded
*/
panToNote(options={}) {
return canvas.notes.panToNote(this.sceneNote, options);
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
async _preCreate(data, options, user) {
/**
* Migrate content to pages.
* @deprecated since v10
*/
if ( (("img" in data) || ("content" in data)) && !this.pages.size ) {
this.updateSource({pages: this.constructor.migrateContentToPages(data)});
}
return super._preCreate(data, options, user);
}
/* ---------------------------------------- */
/** @inheritdoc */
async _preUpdate(changed, options, user) {
/**
* Migrate content to pages.
* @deprecated since v10
*/
if ( ("img" in changed) || ("content" in changed) ) {
const pages = this.toObject().pages;
const addPages = this.constructor.migrateContentToPages(changed);
if ( "img" in changed ) {
const addImgPage = addPages.shift();
const imgPage = pages.find(p => p.type === "image");
if ( imgPage ) foundry.utils.mergeObject(imgPage, addImgPage);
else pages.push(addImgPage);
}
if ( "content" in changed ) {
const addContentPage = addPages.shift();
const contentPage = pages.find(p => p.type === "text");
if ( contentPage ) foundry.utils.mergeObject(contentPage, addContentPage);
else pages.push(addContentPage);
}
this.updateSource({pages});
}
return super._preUpdate(changed, options, user);
}
/* -------------------------------------------- */
/** @override */
_onUpdate(data, options, userId) {
super._onUpdate(data, options, userId);
if ( !canvas.ready ) return;
if ( ["name", "ownership"].some(k => k in data) ) {
canvas.notes.placeables.filter(n => n.document.entryId === this.id).forEach(n => n.draw());
}
}
/* -------------------------------------------- */
/** @override */
_onDelete(options, userId) {
super._onDelete(options, userId);
if ( !canvas.ready ) return;
for ( let n of canvas.notes.placeables ) {
if ( n.document.entryId === this.id ) n.draw();
}
}
}
/**
* The client-side Macro document which extends the common BaseMacro model.
* @extends documents.BaseMacro
* @mixes ClientDocumentMixin
*
* @see {@link Macros} The world-level collection of Macro documents
* @see {@link MacroConfig} The Macro configuration application
*/
class Macro extends ClientDocumentMixin(foundry.documents.BaseMacro) {
/* -------------------------------------------- */
/* Model Properties */
/* -------------------------------------------- */
/**
* Is the current User the author of this macro?
* @type {boolean}
*/
get isAuthor() {
return game.user === this.author;
}
/* -------------------------------------------- */
/**
* Test whether the current user is capable of executing a Macro script
* @type {boolean}
*/
get canExecute() {
if ( !this.testUserPermission(game.user, "LIMITED") ) return false;
return this.type === "script" ? game.user.can("MACRO_SCRIPT") : true;
}
/* -------------------------------------------- */
/**
* Provide a thumbnail image path used to represent this document.
* @type {string}
*/
get thumbnail() {
return this.img;
}
/* -------------------------------------------- */
/* Model Methods */
/* -------------------------------------------- */
/**
* Execute the Macro command.
* @param {object} [scope={}] Macro execution scope which is passed to script macros
* @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 {ChatMessage|*} A created ChatMessage from chat macros or returned value from script macros
*/
execute(scope={}) {
if ( !this.canExecute ) {
return ui.notifications.warn(`You do not have permission to execute Macro "${this.name}".`);
}
switch ( this.type ) {
case "chat":
return this.#executeChat();
case "script":
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.
*/
#executeChat() {
ui.chat.processMessage(this.command).catch(err => {
Hooks.onError("Macro#_executeChat", err, {
msg: "There was an error in your chat message syntax.",
log: "error",
notify: "error",
command: this.command
});
});
}
/* -------------------------------------------- */
/**
* Execute the command as a script macro.
* Script Macros are wrapped in an async IIFE to allow the use of asynchronous commands and await statements.
* @param {object} [scope={}] Macro execution scope which is passed to script macros
* @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
*/
#executeScript({actor, token, ...scope}={}) {
// Add variables to the evaluation scope
const speaker = ChatMessage.implementation.getSpeaker({actor, token});
const character = game.user.character;
token = token || (canvas.ready ? canvas.tokens.get(speaker.token) : null);
actor = actor || token?.actor || game.actors.get(speaker.actor);
// 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
const AsyncFunction = (async function() {}).constructor;
// eslint-disable-next-line no-new-func
const fn = new 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();
}
}
/**
* The client-side MeasuredTemplate document which extends the common BaseMeasuredTemplate document model.
* @extends documents.BaseMeasuredTemplate
* @mixes ClientDocumentMixin
*
* @see {@link Scene} The Scene document type which contains MeasuredTemplate documents
* @see {@link MeasuredTemplateConfig} The MeasuredTemplate configuration application
*/
class MeasuredTemplateDocument extends CanvasDocumentMixin(foundry.documents.BaseMeasuredTemplate) {
/* -------------------------------------------- */
/* Model Properties */
/* -------------------------------------------- */
/**
* A reference to the User who created the MeasuredTemplate document.
* @type {User|null}
*/
get author() {
return this.user;
}
/**
* Rotation is an alias for direction
* @returns {number}
*/
get rotation() {
return this.direction;
}
}
/**
* The client-side Note document which extends the common BaseNote document model.
* @extends documents.BaseNote
* @mixes ClientDocumentMixin
*
* @see {@link Scene} The Scene document type which contains Note documents
* @see {@link NoteConfig} The Note configuration application
*/
class NoteDocument extends CanvasDocumentMixin(foundry.documents.BaseNote) {
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* The associated JournalEntry which is referenced by this Note
* @type {JournalEntry}
*/
get entry() {
return game.journal.get(this.entryId);
}
/* -------------------------------------------- */
/**
* The specific JournalEntryPage within the associated JournalEntry referenced by this Note.
* @type {JournalEntryPage}
*/
get page() {
return this.entry?.pages.get(this.pageId);
}
/* -------------------------------------------- */
/**
* The text label used to annotate this Note
* @type {string}
*/
get label() {
return this.text || this.page?.name || this.entry?.name || game?.i18n?.localize("NOTE.Unknown") || "Unknown";
}
}
/**
* The client-side PlaylistSound document which extends the common BasePlaylistSound model.
* Each PlaylistSound belongs to the sounds collection of a Playlist document.
* @extends documents.BasePlaylistSound
* @mixes ClientDocumentMixin
*
* @see {@link Playlist} The Playlist document which contains PlaylistSound embedded documents
* @see {@link PlaylistSoundConfig} The PlaylistSound configuration application
* @see {@link Sound} The Sound API which manages web audio playback
*/
class PlaylistSound extends ClientDocumentMixin(foundry.documents.BasePlaylistSound) {
constructor(data, context) {
super(data, context);
/**
* The Sound which manages playback for this playlist sound
* @type {Sound|null}
*/
this.sound = this._createSound();
/**
* A debounced function, accepting a single volume parameter to adjust the volume of this sound
* @type {Function}
* @param {number} volume The desired volume level
*/
this.debounceVolume = foundry.utils.debounce(volume => {
this.update({volume}, {diff: false, render: false});
}, PlaylistSound.VOLUME_DEBOUNCE_MS);
}
/**
* The debounce tolerance for processing rapid volume changes into database updates in milliseconds
* @type {number}
*/
static VOLUME_DEBOUNCE_MS = 100;
/* -------------------------------------------- */
/**
* Create a Sound used to play this PlaylistSound document
* @returns {Sound|null}
* @private
*/
_createSound() {
if ( !this.id || !this.path ) return null;
const sound = game.audio.create({
src: this.path,
preload: false,
singleton: false
});
sound.on("start", this._onStart.bind(this));
sound.on("end", this._onEnd.bind(this));
sound.on("stop", this._onStop.bind(this));
return sound;
}
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* The effective volume at which this playlist sound is played, incorporating the global playlist volume setting.
* @type {number}
*/
get effectiveVolume() {
return this.volume * game.settings.get("core", "globalPlaylistVolume");
}
/* -------------------------------------------- */
/**
* Determine the fade duration for this PlaylistSound based on its own configuration and that of its parent.
* @type {number}
*/
get fadeDuration() {
if ( !this.sound.duration ) return 0;
const halfDuration = Math.ceil(this.sound.duration / 2) * 1000;
return Math.clamped(this.fade ?? this.parent.fade ?? 0, 0, halfDuration);
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Synchronize playback for this particular PlaylistSound instance
*/
sync() {
if ( !this.sound || this.sound.failed ) return;
const fade = this.fadeDuration;
// Conclude current playback
if ( !this.playing ) {
if ( fade && !this.pausedTime && this.sound.playing ) {
return this.sound.fade(0, {duration: fade}).then(() => this.sound.stop());
}
else return this.sound.stop();
}
// Determine playback configuration
const playback = {
loop: this.repeat,
volume: this.effectiveVolume,
fade: fade
};
if ( this.pausedTime && this.playing && !this.sound.playing ) playback.offset = this.pausedTime;
// Load and autoplay, or play directly if already loaded
if ( this.sound.loaded ) return this.sound.play(playback);
return this.sound.load({autoplay: true, autoplayOptions: playback});
}
/* -------------------------------------------- */
/** @inheritdoc */
toAnchor({classes=[], ...options}={}) {
if ( this.playing ) classes.push("playing");
if ( !game.user.isGM ) classes.push("disabled");
return super.toAnchor({classes, ...options});
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickDocumentLink(event) {
if ( this.playing ) return this.parent.stopSound(this);
return this.parent.playSound(this);
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @override */
_onCreate(data, options, userId) {
super._onCreate(data, options, userId);
if ( this.parent ) this.parent._playbackOrder = undefined;
}
/* -------------------------------------------- */
/** @override */
_onUpdate(changed, options, userId) {
super._onUpdate(changed, options, userId);
if ( "path" in changed ) {
if ( this.sound ) this.sound.stop();
this.sound = this._createSound();
}
if ( ("sort" in changed) && this.parent ) {
this.parent._playbackOrder = undefined;
}
this.sync();
}
/* -------------------------------------------- */
/** @override */
_onDelete(options, userId) {
super._onDelete(options, userId);
if ( this.parent ) this.parent._playbackOrder = undefined;
this.playing = false;
this.sync();
}
/* -------------------------------------------- */
/**
* Special handling that occurs when a PlaylistSound reaches the natural conclusion of its playback.
* @private
*/
async _onEnd() {
if (!game.user.isGM) return;
return this.parent._onSoundEnd(this);
}
/* -------------------------------------------- */
/**
* Special handling that occurs when playback of a PlaylistSound is started.
* @private
*/
async _onStart() {
if ( !this.playing ) return this.sound.stop();
// Apply fade timings
const fade = this.fadeDuration;
if ( fade ) {
this._fadeIn(this.sound);
if ( !this.repeat && Number.isFinite(this.sound.duration) ) {
// noinspection ES6MissingAwait
this.sound.schedule(this._fadeOut.bind(this), this.sound.duration - (fade / 1000));
}
}
// Playlist-level orchestration actions
return this.parent._onSoundStart(this);
}
/* -------------------------------------------- */
/**
* Special handling that occurs when a PlaylistSound is manually stopped before its natural conclusion.
* @private
*/
async _onStop() {}
/* -------------------------------------------- */
/**
* Handle fading in the volume for this sound when it begins to play (or loop)
* @param {Sound} sound The sound fading-in
* @private
*/
_fadeIn(sound) {
if ( !sound.node ) return;
const fade = this.fadeDuration;
if ( !fade || sound.pausedTime ) return;
sound.fade(this.effectiveVolume, {duration: fade, from: 0});
}
/* -------------------------------------------- */
/**
* Handle fading out the volume for this sound when it begins to play (or loop)
* @param {Sound} sound The sound fading-out
* @private
*/
_fadeOut(sound) {
if ( !sound.node ) return;
const fade = this.fadeDuration;
if ( !fade ) return;
sound.fade(0, {duration: fade});
}
}
/**
* The client-side Playlist document which extends the common BasePlaylist model.
* @extends documents.BasePlaylist
* @mixes ClientDocumentMixin
*
* @see {@link Playlists} The world-level collection of Playlist documents
* @see {@link PlaylistSound} The PlaylistSound embedded document within a parent Playlist
* @see {@link PlaylistConfig} The Playlist configuration application
*/
class Playlist extends ClientDocumentMixin(foundry.documents.BasePlaylist) {
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* Playlists may have a playback order which defines the sequence of Playlist Sounds
* @type {string[]}
*/
_playbackOrder;
/**
* The order in which sounds within this playlist will be played (if sequential or shuffled)
* Uses a stored seed for randomization to guarantee that all clients generate the same random order.
* @type {string[]}
*/
get playbackOrder() {
if ( this._playbackOrder !== undefined ) return this._playbackOrder;
switch ( this.mode ) {
// Shuffle all tracks
case CONST.PLAYLIST_MODES.SHUFFLE:
let ids = this.sounds.map(s => s.id);
const mt = new MersenneTwister(this.seed ?? 0);
let shuffle = ids.reduce((shuffle, id) => {
shuffle[id] = mt.random();
return shuffle;
}, {});
ids.sort((a, b) => shuffle[a] - shuffle[b]);
return this._playbackOrder = ids;
// Sorted sequential playback
default:
const sorted = this.sounds.contents.sort(this._sortSounds.bind(this));
return this._playbackOrder = sorted.map(s => s.id);
}
}
/* -------------------------------------------- */
/** @inheritdoc */
get visible() {
return game.user.isGM || this.playing;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Find all content links belonging to a given {@link Playlist} or {@link PlaylistSound}.
* @param {Playlist|PlaylistSound} doc The Playlist or PlaylistSound.
* @returns {NodeListOf<Element>}
* @protected
*/
static _getSoundContentLinks(doc) {
return document.querySelectorAll(`a.content-link[data-uuid="${doc.uuid}"]`);
}
/* -------------------------------------------- */
/** @inheritdoc */
prepareDerivedData() {
this.playing = this.sounds.some(s => s.playing);
}
/* -------------------------------------------- */
/**
* Begin simultaneous playback for all sounds in the Playlist.
* @returns {Promise<Playlist>} 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<Playlist>} 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<Playlist>} 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<Playlist>} 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<Playlist>} 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.<Playlist>} A promise which resolves to the updated Playlist instance
*/
async cycleMode() {
const modes = Object.values(CONST.PLAYLIST_MODES);
let mode = this.mode + 1;
mode = mode > Math.max(...modes) ? modes[0] : mode;
for ( let s of this.sounds ) {
s.playing = false;
}
return this.update({sounds: this.sounds.toJSON(), mode: mode});
}
/* -------------------------------------------- */
/**
* Get the next sound in the cached playback order. For internal use.
* @private
*/
_getNextSound(soundId) {
const order = this.playbackOrder;
let idx = order.indexOf(soundId);
if (idx === order.length - 1) idx = -1;
return this.sounds.get(order[idx+1]);
}
/* -------------------------------------------- */
/**
* Get the previous sound in the cached playback order. For internal use.
* @private
*/
_getPreviousSound(soundId) {
const order = this.playbackOrder;
let idx = order.indexOf(soundId);
if ( idx === -1 ) idx = 1;
else if (idx === 0) idx = order.length;
return this.sounds.get(order[idx-1]);
}
/* -------------------------------------------- */
/**
* Define the sorting order for the Sounds within this Playlist. For internal use.
* @private
*/
_sortSounds(a, b) {
switch ( this.sorting ) {
case CONST.PLAYLIST_SORT_MODES.ALPHABETICAL: return a.name.localeCompare(b.name);
case CONST.PLAYLIST_SORT_MODES.MANUAL: return a.sort - b.sort;
}
}
/* -------------------------------------------- */
/** @inheritdoc */
toAnchor({classes=[], ...options}={}) {
if ( this.playing ) classes.push("playing");
if ( !game.user.isGM ) classes.push("disabled");
return super.toAnchor({classes, ...options});
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickDocumentLink(event) {
if ( this.playing ) return this.stopAll();
return this.playAll();
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
async _preUpdate(changed, options, user) {
if ((("mode" in changed) || ("playing" in changed)) && !("seed" in changed)) {
changed.seed = Math.floor(Math.random() * 1000);
}
return super._preUpdate(changed, options, user);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onUpdate(changed, options, userId) {
super._onUpdate(changed, options, userId);
if ( "seed" in changed || "mode" in changed || "sorting" in changed ) this._playbackOrder = undefined;
if ( "sounds" in changed ) this.sounds.forEach(s => s.sync());
this._updateContentLinkPlaying(changed);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDelete(options, userId) {
super._onDelete(options, userId);
this.sounds.forEach(s => s.sound.stop());
}
/* -------------------------------------------- */
/** @inheritdoc */
_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
* @private
*/
async _onSoundEnd(sound) {
switch ( this.mode ) {
case CONST.PLAYLIST_MODES.SEQUENTIAL:
case CONST.PLAYLIST_MODES.SHUFFLE:
return this.playNext(sound.id);
case CONST.PLAYLIST_MODES.SIMULTANEOUS:
case CONST.PLAYLIST_MODES.DISABLED:
const updates = {playing: true, sounds: [{_id: sound.id, playing: false, pausedTime: null}]};
for ( let s of this.sounds ) {
if ( (s !== sound) && s.playing ) break;
updates.playing = false;
}
return this.update(updates);
}
}
/* -------------------------------------------- */
/**
* Handle callback logic when playback for an individual sound within the Playlist is started.
* Schedule auto-preload of next track
* @param {PlaylistSound} sound
* @private
*/
async _onSoundStart(sound) {
if ( ![CONST.PLAYLIST_MODES.SEQUENTIAL, CONST.PLAYLIST_MODES.SHUFFLE].includes(this.mode) ) return;
const apl = CONFIG.Playlist.autoPreloadSeconds;
if ( Number.isNumeric(apl) && Number.isFinite(sound.sound.duration) ) {
setTimeout(() => {
if ( !sound.playing ) return;
const next = this._getNextSound(sound.id);
if ( next ) next.sound.load();
}, (sound.sound.duration - apl) * 1000);
}
}
/* -------------------------------------------- */
/**
* Update the playing status of this Playlist in content links.
* @param {object} changed The data changes.
* @private
*/
_updateContentLinkPlaying(changed) {
if ( "playing" in changed ) {
this.constructor._getSoundContentLinks(this).forEach(el => el.classList.toggle("playing", changed.playing));
}
if ( "sounds" in changed ) changed.sounds.forEach(update => {
const sound = this.sounds.get(update._id);
if ( !("playing" in update) || !sound ) return;
this.constructor._getSoundContentLinks(sound).forEach(el => el.classList.toggle("playing", update.playing));
});
}
/* -------------------------------------------- */
/* Importing and Exporting */
/* -------------------------------------------- */
/** @inheritdoc */
toCompendium(pack, options={}) {
const data = super.toCompendium(pack, options);
if ( options.clearState ) {
data.playing = false;
for ( let s of data.sounds ) {
s.playing = false;
}
}
return data;
}
}
/**
* The client-side Scene document which extends the common BaseScene model.
* @extends documents.BaseItem
* @mixes ClientDocumentMixin
*
* @see {@link Scenes} The world-level collection of Scene documents
* @see {@link SceneConfig} The Scene configuration application
*/
class Scene extends ClientDocumentMixin(foundry.documents.BaseScene) {
/**
* Track the viewed position of each scene (while in memory only, not persisted)
* When switching back to a previously viewed scene, we can automatically pan to the previous position.
* @type {CanvasViewPosition}
*/
_viewPosition = {};
/**
* Track whether the scene is the active view
* @type {boolean}
*/
_view = this.active;
/**
* Determine the canvas dimensions this Scene would occupy, if rendered
* @type {object}
*/
dimensions = this.dimensions; // Workaround for subclass property instantiation issue.
/* -------------------------------------------- */
/* Scene Properties */
/* -------------------------------------------- */
/**
* Provide a thumbnail image path used to represent this document.
* @type {string}
*/
get thumbnail() {
return this.thumb;
}
/* -------------------------------------------- */
/**
* A convenience accessor for whether the Scene is currently viewed
* @type {boolean}
*/
get isView() {
return this._view;
}
/* -------------------------------------------- */
/* Scene Methods */
/* -------------------------------------------- */
/**
* Set this scene as currently active
* @returns {Promise<Scene>} 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<Scene>}
*/
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 */
prepareBaseData() {
this.dimensions = this.getDimensions();
this.playlistSound = this.playlist ? this.playlist.sounds.get(this._source.playlistSound) : null;
// A temporary assumption until a more robust long-term solution when we implement Scene Levels.
this.foregroundElevation = this.foregroundElevation || (this.grid.distance * 4);
}
/* -------------------------------------------- */
/**
* @typedef {object} SceneDimensions
* @property {number} width The width of the canvas.
* @property {number} height The height of the canvas.
* @property {number} size The grid size.
* @property {Rectangle} rect The canvas rectangle.
* @property {number} sceneX The X coordinate of the scene rectangle within the larger canvas.
* @property {number} sceneY The Y coordinate of the scene rectangle within the larger canvas.
* @property {number} sceneWidth The width of the scene.
* @property {number} sceneHeight The height of the scene.
* @property {Rectangle} sceneRect The scene rectangle.
* @property {number} distance The number of distance units in a single grid space.
* @property {number} ratio The aspect ratio of the scene rectangle.
* @property {number} maxR The length of the longest line that can be drawn on the canvas.
*/
/**
* Get the Canvas dimensions which would be used to display this Scene.
* Apply padding to enlarge the playable space and round to the nearest 2x grid size to ensure symmetry.
* The rounding accomplishes that the padding buffer around the map always contains whole grid spaces.
* @returns {SceneDimensions}
*/
getDimensions() {
// Get Scene data
const grid = this.grid;
const size = grid.size || 100;
const sceneWidth = this.width || (size * 30);
const sceneHeight = this.height || (size * 20);
// Compute the correct grid sizing
const gridType = grid.type ?? CONST.GRID_TYPES.SQUARE;
const gridCls = BaseGrid.implementationFor(gridType);
const gridPadding = gridCls.calculatePadding(gridType, sceneWidth, sceneHeight, grid.size, this.padding, {
legacy: this.flags.core?.legacyHex
});
const {width, height} = gridPadding;
const sceneX = gridPadding.x - this.background.offsetX;
const sceneY = gridPadding.y - this.background.offsetY;
// Define Scene dimensions
return {
width, height, size,
rect: new PIXI.Rectangle(0, 0, width, height),
sceneX, sceneY, sceneWidth, sceneHeight,
sceneRect: new PIXI.Rectangle(sceneX, sceneY, sceneWidth, sceneHeight),
distance: this.grid.distance,
distancePixels: size / this.grid.distance,
ratio: sceneWidth / sceneHeight,
maxR: Math.hypot(width, height)
};
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickDocumentLink(event) {
if ( this.journal ) return this.journal._onClickDocumentLink(event);
return super._onClickDocumentLink(event);
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @override */
async _preCreate(data, options, user) {
await super._preCreate(data, options, user);
// Set a Scene as active if none currently are
if ( !("active" in data) && !game.scenes.active ) this.updateSource({active: true});
// Create a base64 thumbnail for the scene
if ( !("thumb" in data) && canvas.ready && this.background.src ) {
const t = await this.createThumbnail({img: this.background.src});
this.updateSource({thumb: t.thumb});
}
// Trigger Playlist Updates
if ( this.active ) return game.playlists._onChangeScene(this, data);
/**
* If this was a purely programmatic creation of a Scene, i.e. not via compendium import, then tag the version to
* avoid potentially marking it as a legacy hex Scene.
* @deprecated since v10
*/
if ( !this.getFlag("core", "sourceId") ) this.updateSource({"_stats.coreVersion": game.release.version});
}
/* -------------------------------------------- */
/** @override */
_onCreate(data, options, userId) {
super._onCreate(data, options, userId);
if ( data.active === true ) this._onActivate(true);
}
/* -------------------------------------------- */
/** @override */
async _preUpdate(data, options, user) {
await super._preUpdate(data, options, user);
if ( "thumb" in data ) {
options.thumb ??= [];
options.thumb.push(this.id);
}
// If the canvas size has changed, translate the placeable objects
if ( options.autoReposition ) {
try {
data = this._repositionObjects(data);
}
catch (err) {
delete data.width;
delete data.height;
delete data.padding;
delete data.background;
return ui.notifications.error(err.message);
}
}
const audioChange = ("active" in data) || (this.active && ["playlist", "playlistSound"].some(k => k in data));
if ( audioChange ) return game.playlists._onChangeScene(this, data);
}
/* -------------------------------------------- */
/**
* 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.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;
}
/* -------------------------------------------- */
/** @override */
_onUpdate(data, options, userId) {
if ( !("thumb" in data) && (options.thumb ?? []).includes(this.id) ) data.thumb = this.thumb;
super._onUpdate(data, options, userId);
const changed = new Set(Object.keys(foundry.utils.flattenObject(data)).filter(k => k !== "_id"));
// If the Scene became active, go through the full activation procedure
if ( changed.has("active") ) this._onActivate(data.active);
// If the Thumbnail was updated, bust the image cache
if ( changed.has("thumb") && this.thumb ) {
this.thumb = `${this.thumb.split("?")[0]}?${Date.now()}`;
}
// If the scene is already active, maybe re-draw the canvas
if ( canvas.scene === this ) {
const redraw = [
"foreground", "fogOverlay", "width", "height", "padding", // Scene Dimensions
"grid.type", "grid.size", "grid.distance", "grid.units", // Grid Configuration
"drawings", "lights", "sounds", "templates", "tiles", "tokens", "walls", // Placeable Objects
"weather" // Ambience
];
if ( redraw.some(k => changed.has(k)) || ("background" in data) ) return canvas.draw();
if ( ["grid.color", "grid.alpha"].some(k => changed.has(k)) ) canvas.grid.grid.draw();
// Modify vision conditions
const perceptionAttrs = ["globalLight", "globalLightThreshold", "tokenVision", "fogExploration"];
if ( perceptionAttrs.some(k => changed.has(k)) ) canvas.perception.initialize();
// Progress darkness level
if ( changed.has("darkness") && options.animateDarkness ) {
return canvas.effects.animateDarkness(data.darkness, {
duration: typeof options.animateDarkness === "number" ? options.animateDarkness : undefined
});
}
// Initialize the color manager with the new darkness level and/or scene background color
if ( ["darkness", "backgroundColor", "fogUnexploredColor", "fogExploredColor"].some(k => changed.has(k)) ) {
canvas.colorManager.initialize();
}
// New initial view position
if ( ["initial.x", "initial.y", "initial.scale", "width", "height"].some(k => changed.has(k)) ) {
this._viewPosition = {};
canvas.initializeCanvasPosition();
}
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _preDelete(options, user) {
await super._preDelete(options, user);
if ( this.active ) game.playlists._onChangeScene(this, {active: false});
}
/* -------------------------------------------- */
/** @override */
_onDelete(options, userId) {
super._onDelete(options, userId);
if ( canvas.scene?.id === this.id ) canvas.draw(null);
for ( const token of this.tokens ) {
token.baseActor?._unregisterDependentScene(this);
}
}
/* -------------------------------------------- */
/**
* 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);
}
}
/* -------------------------------------------- */
/** @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 updatedIds = new Set(changes.map(r => r._id));
const originals = documentCollection.reduce((arr, d) => {
if ( updatedIds.has(d.id) ) arr.push(d.toJSON());
return arr;
}, []);
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 = documentCollection.reduce((arr, d) => {
if ( ids.includes(d.id) ) arr.push(d.toJSON());
return arr;
}, []);
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.fogReset;
if ( options.clearSort ) {
delete data.navigation;
delete data.navOrder;
}
return data;
}
/* -------------------------------------------- */
/**
* Create a 300px by 100px thumbnail image for this scene background
* @param {object} [options] Options which modify thumbnail creation
* @param {string|null} [options.img] A background image to use for thumbnail creation, otherwise the current scene
* background is used.
* @param {number} [options.width] The desired thumbnail width. Default is 300px
* @param {number} [options.height] The desired thumbnail height. Default is 100px;
* @param {string} [options.format] Which image format should be used? image/png, image/jpg, or image/webp
* @param {number} [options.quality] What compression quality should be used for jpeg or webp, between 0 and 1
* @returns {Promise<object>} The created thumbnail data.
*/
async createThumbnail({img, width=300, height=100, format="image/webp", quality=0.8}={}) {
if ( game.settings.get("core", "noCanvas") ) throw new Error(game.i18n.localize("SCENES.GenerateThumbNoCanvas"));
// Create counter-factual scene data
const newImage = img !== undefined;
img = img ?? this.background.src;
const scene = this.clone({"background.src": img});
// Load required textures to create the thumbnail
const tiles = this.tiles.filter(t => t.texture.src && !t.hidden).sort((a, b) => a.z - b.z);
const toLoad = tiles.map(t => t.texture.src);
if ( img ) toLoad.push(img);
if ( this.foreground ) toLoad.push(this.foreground);
await TextureLoader.loader.load(toLoad);
// Update the cloned image with new background image dimensions
const backgroundTexture = img ? getTexture(img) : null;
if ( newImage && backgroundTexture ) {
scene.updateSource({width: backgroundTexture.width, height: backgroundTexture.height});
}
const d = scene.getDimensions();
// Create a container and add a transparent graphic to enforce the size
const baseContainer = new PIXI.Container();
const sceneRectangle = new PIXI.Rectangle(0, 0, d.sceneWidth, d.sceneHeight);
const baseGraphics = baseContainer.addChild(new PIXI.LegacyGraphics());
baseGraphics.beginFill(0xFFFFFF, 1.0).drawShape(sceneRectangle).endFill();
baseGraphics.zIndex = -1;
baseContainer.mask = baseGraphics;
baseContainer.sortableChildren = true;
// Simulate the way a TileMesh is drawn
const drawTile = async tile => {
const tex = getTexture(tile.texture.src);
if ( !tex ) return;
const s = new PIXI.Sprite(tex);
const {x, y, rotation, width, height} = tile;
const {scaleX, scaleY, tint} = tile.texture;
s.anchor.set(0.5, 0.5);
s.width = Math.abs(width);
s.height = Math.abs(height);
s.scale.x *= scaleX;
s.scale.y *= scaleY;
s.tint = Color.from(tint ?? 0xFFFFFF);
s.position.set(x + (width/2) - d.sceneRect.x, y + (height/2) - d.sceneRect.y);
s.angle = rotation;
s.zIndex = tile.elevation;
return s;
};
// Background container
if ( backgroundTexture ) {
const bg = new PIXI.Sprite(backgroundTexture);
bg.width = d.sceneWidth;
bg.height = d.sceneHeight;
bg.zIndex = 0;
baseContainer.addChild(bg);
}
// Foreground container
if ( this.foreground ) {
const fgTex = getTexture(this.foreground);
const fg = new PIXI.Sprite(fgTex);
fg.width = d.sceneWidth;
fg.height = d.sceneHeight;
fg.zIndex = scene.foregroundElevation;
baseContainer.addChild(fg);
}
// Tiles
for ( let t of tiles ) {
const sprite = await drawTile(t);
if ( sprite ) baseContainer.addChild(sprite);
}
// Render the container to a thumbnail
const stage = new PIXI.Container();
stage.addChild(baseContainer);
return ImageHelper.createThumbnail(stage, {width, height, format, quality});
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
static getDimensions(data) {
throw new Error("The Scene.getDimensions static method is deprecated in favor of the Scene#getDimensions "
+ "instance method");
}
}
/**
* The client-side Setting document which extends the common BaseSetting model.
* @extends documents.BaseSetting
* @mixes ClientDocumentMixin
*
* @see {@link WorldSettings} The world-level collection of Setting documents
*/
class Setting extends ClientDocumentMixin(foundry.documents.BaseSetting) {
/**
* 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();
}
/* -------------------------------------------- */
/** @override */
_onCreate(data, options, userId) {
super._onCreate(data, options, userId);
const onChange = this.config?.onChange;
if ( onChange instanceof Function ) onChange(this.value, options, userId);
}
/* -------------------------------------------- */
/** @override */
_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);
}
// 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 documents.BaseTableResult
* @mixes ClientDocumentMixin
*
* @see {@link RollTable} The RollTable document type which contains TableResult documents
*/
class TableResult extends ClientDocumentMixin(foundry.documents.BaseTableResult) {
/**
* A path reference to the icon image used to represent this result
*/
get icon() {
return this.img || CONFIG.RollTable.resultIcon;
}
/**
* Prepare a string representation for the result which (if possible) will be a dynamic link or otherwise plain text
* @returns {string} The text to display
*/
getChatText() {
switch (this.type) {
case CONST.TABLE_RESULT_TYPES.DOCUMENT:
return `@${this.documentCollection}[${this.documentId}]{${this.text}}`;
case CONST.TABLE_RESULT_TYPES.COMPENDIUM:
return `@Compendium[${this.documentCollection}.${this.documentId}]{${this.text}}`;
default:
return this.text;
}
}
}
/**
* @typedef {Object} RollTableDraw An object containing the executed Roll and the produced results
* @property {Roll} roll The Dice roll which generated the draw
* @property {TableResult[]} results An array of drawn TableResult documents
*/
/**
* The client-side RollTable document which extends the common BaseRollTable model.
* @extends documents.BaseRollTable
* @mixes ClientDocumentMixin
*
* @see {@link RollTables} The world-level collection of RollTable documents
* @see {@link TableResult} The embedded TableResult document
* @see {@link RollTableConfig} The RollTable configuration application
*/
class RollTable extends ClientDocumentMixin(foundry.documents.BaseRollTable) {
/**
* Provide a thumbnail image path used to represent this document.
* @type {string}
*/
get thumbnail() {
return this.img;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Display a result drawn from a RollTable in the Chat Log along.
* Optionally also display the Roll which produced the result and configure aspects of the displayed messages.
*
* @param {TableResult[]} results An Array of one or more TableResult Documents which were drawn and should
* be displayed.
* @param {object} [options={}] Additional options which modify message creation
* @param {Roll} [options.roll] An optional Roll instance which produced the drawn results
* @param {Object} [options.messageData={}] Additional data which customizes the created messages
* @param {Object} [options.messageOptions={}] Additional options which customize the created messages
*/
async toMessage(results, {roll=null, messageData={}, messageOptions={}}={}) {
const speaker = ChatMessage.getSpeaker();
// Construct chat data
const flavorKey = `TABLE.DrawFlavor${results.length > 1 ? "Plural" : ""}`;
messageData = foundry.utils.mergeObject({
flavor: game.i18n.format(flavorKey, {number: results.length, name: this.name}),
user: game.user.id,
speaker: speaker,
type: roll ? CONST.CHAT_MESSAGE_TYPES.ROLL : CONST.CHAT_MESSAGE_TYPES.OTHER,
roll: roll,
sound: roll ? CONFIG.sounds.dice : null,
flags: {"core.RollTable": this.id}
}, messageData);
// Render the chat card which combines the dice roll with the drawn results
messageData.content = await renderTemplate(CONFIG.RollTable.resultTemplate, {
description: await TextEditor.enrichHTML(this.description, {documents: true, async: true}),
results: results.map(result => {
const r = result.toObject(false);
r.text = result.getChatText();
r.icon = result.icon;
return r;
}),
rollHTML: this.displayRoll && roll ? await roll.render() : null,
table: this
});
// Create the chat message
return ChatMessage.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<number; n++ ) {
let draw = await this.roll({roll, recursive});
if ( draw.results.length ) {
rolls.push(draw.roll);
results = results.concat(draw.results);
}
else break;
// Mark results as drawn, if replacement is not used, and we are not in a Compendium pack
if ( !this.replacement && !this.pack) {
updates = updates.concat(draw.results.map(r => {
r.drawn = true;
return {_id: r.id, drawn: true};
}));
}
}
// Construct a Roll object using the constructed pool
const pool = PoolTerm.fromRolls(rolls);
roll = Roll.defaultImplementation.fromTerms([pool]);
// Commit updates to child results
if ( updates.length ) {
await this.updateEmbeddedDocuments("TableResult", updates, {diff: false});
}
// Forward drawn results to create chat messages
if ( displayChat && results.length ) {
await this.toMessage(results, {
roll: roll,
messageOptions: {rollMode}
});
}
// Return the Roll and the array of results
return {roll, results};
}
/* -------------------------------------------- */
/**
* Normalize the probabilities of rolling each item in the RollTable based on their assigned weights
* @returns {Promise<RollTable>}
*/
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<RollTable>}
*/
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<RollTableDraw>} 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, async: true})).total;
const maxRoll = (await roll.reroll({maximize: true, async: true})).total;
const availableRange = available.reduce((range, result) => {
const r = result.range;
if ( !range[0] || (r[0] < range[0]) ) range[0] = r[0];
if ( !range[1] || (r[1] > range[1]) ) range[1] = r[1];
return range;
}, [null, null]);
if ( (availableRange[0] > maxRoll) || (availableRange[1] < minRoll) ) {
ui.notifications.warn("No results can possibly be drawn from this table and formula.");
return {roll, results};
}
// Continue rolling until one or more results are recovered
let iter = 0;
while ( !results.length ) {
if ( iter >= 10000 ) {
ui.notifications.error(`Failed to draw an available entry from Table ${this.name}, maximum iteration reached`);
break;
}
roll = await roll.reroll({async: true});
results = this.getResultsForRoll(roll.total);
iter++;
}
// Draw results recursively from any inner Roll Tables
if ( recursive ) {
let inner = [];
for ( let result of results ) {
let pack;
let documentName;
if ( result.type === CONST.TABLE_RESULT_TYPES.DOCUMENT ) documentName = result.documentCollection;
else if ( result.type === CONST.TABLE_RESULT_TYPES.COMPENDIUM ) {
pack = game.packs.get(result.documentCollection);
documentName = pack?.documentName;
}
if ( documentName === "RollTable" ) {
const id = result.documentId;
const innerTable = pack ? await pack.getDocument(id) : game.tables.get(id);
if (innerTable) {
const innerRoll = await innerTable.roll({_depth: _depth + 1});
inner = inner.concat(innerRoll.results);
}
}
else inner.push(result);
}
results = inner;
}
// Return the Roll and the results
return { roll, results };
}
/* -------------------------------------------- */
/**
* Get an Array of valid results for a given rolled total
* @param {number} value The rolled value
* @returns {TableResult[]} An Array of results
*/
getResultsForRoll(value) {
return this.results.filter(r => !r.drawn && Number.between(value, ...r.range));
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
_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<RollTable>}
*/
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 documents.BaseTile
* @mixes ClientDocumentMixin
*
* @see {@link Scene} The Scene document type which contains Tile documents
* @see {@link TileConfig} The Tile configuration application
*/
class TileDocument extends CanvasDocumentMixin(foundry.documents.BaseTile) {
/**
* Define an elevation property on the Tile Document which in the future will become a core part of its data schema.
* @type {number}
*/
get elevation() {
return this.#elevation ?? (this.overhead
? this.parent.foregroundElevation : PrimaryCanvasGroup.BACKGROUND_ELEVATION);
}
set elevation(value) {
if ( !Number.isFinite(value) && (value !== undefined) ) {
throw new Error("Elevation must be a finite Number or undefined");
}
this.#elevation = value;
if ( this.rendered ) {
canvas.primary.sortDirty = true;
canvas.perception.update({refreshTiles: true});
// TODO: Temporary workaround. Delete when elevation will be a real tile document property
this._object.renderFlags.set({refreshElevation: true});
}
}
#elevation;
/* -------------------------------------------- */
/**
* Define a sort property on the Tile Document which in the future will become a core part of its data schema.
* @type {number}
*/
get sort() {
return this.z;
}
/* -------------------------------------------- */
/** @inheritdoc */
prepareDerivedData() {
super.prepareDerivedData();
const d = this.parent?.dimensions;
if ( !d ) return;
const securityBuffer = Math.max(d.size / 5, 20).toNearest(0.1);
const maxX = d.width - securityBuffer;
const maxY = d.height - securityBuffer;
const minX = (this.width - securityBuffer) * -1;
const minY = (this.height - securityBuffer) * -1;
this.x = Math.clamped(this.x.toNearest(0.1), minX, maxX);
this.y = Math.clamped(this.y.toNearest(0.1), minY, maxY);
}
}
/**
* The client-side Token document which extends the common BaseToken document model.
* @extends documents.BaseToken
* @mixes ClientDocumentMixin
*
* @see {@link Scene} The Scene document type which contains Token documents
* @see {@link TokenConfig} The Token configuration application
*/
class TokenDocument extends CanvasDocumentMixin(foundry.documents.BaseToken) {
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* A singleton collection which holds a reference to the synthetic token actor by its base actor's ID.
* @type {Collection<Actor>}
*/
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;
}
/* -------------------------------------------- */
/**
* Return a reference to a Combatant that represents this Token, if one is present in the current encounter.
* @type {Combatant|null}
*/
get combatant() {
return game.combat?.getCombatantByToken(this.id) || null;
}
/* -------------------------------------------- */
/**
* An indicator for whether this Token is currently involved in the active combat encounter.
* @type {boolean}
*/
get inCombat() {
return !!this.combatant;
}
/* -------------------------------------------- */
/**
* Define a sort order for this TokenDocument.
* This controls its rendering order in the PrimaryCanvasGroup relative to siblings at the same elevation.
* In the future this will be replaced with a persisted database field for permanent adjustment of token stacking.
* In case of ties, Tokens will be sorted above other types of objects.
* @type {number}
*/
get sort() {
return this.#sort;
}
set sort(value) {
if ( !Number.isFinite(value) ) throw new Error("TokenDocument sort must be a finite Number");
this.#sort = value;
if ( this.rendered ) {
canvas.tokens.objects.sortDirty = true;
canvas.primary.sortDirty = true;
canvas.perception.update({refreshTiles: true});
}
}
#sort = 0;
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
_initialize(options = {}) {
super._initialize(options);
this.baseActor?._registerDependentToken(this);
}
/* -------------------------------------------- */
/** @inheritdoc */
prepareBaseData() {
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.ready && !this.delta ) this.updateSource({ delta: { _id: this.id } });
}
/* -------------------------------------------- */
/**
* Prepare detection modes which are available to the Token.
* Ensure that every Token has the basic sight detection mode configured.
* @protected
*/
_prepareDetectionModes() {
if ( !this.sight.enabled ) return;
const basicId = DetectionMode.BASIC_MODE_ID;
const basicMode = this.detectionModes.find(m => m.id === basicId);
if ( !basicMode ) this.detectionModes.push({id: basicId, enabled: true, range: this.sight.range});
}
/* -------------------------------------------- */
/**
* 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;
}
/* -------------------------------------------- */
/**
* A helper function to toggle a status effect which includes an Active Effect template
* @param {{id: string, label: string, icon: string}} effectData The Active Effect data
* @param {object} [options] Options to configure application of the Active Effect
* @param {boolean} [options.overlay=false] Should the Active Effect icon be displayed as an
* overlay on the token?
* @param {boolean} [options.active] Force a certain active state for the effect.
* @returns {Promise<boolean>} Whether the Active Effect is now on or off
*/
async toggleActiveEffect(effectData, {overlay=false, active}={}) {
if ( !this.actor || !effectData.id ) return false;
// Remove existing single-status effects.
const existing = this.actor.effects.reduce((arr, e) => {
if ( (e.statuses.size === 1) && e.statuses.has(effectData.id) ) arr.push(e.id);
return arr;
}, []);
const state = active ?? !existing.length;
if ( !state && existing.length ) await this.actor.deleteEmbeddedDocuments("ActiveEffect", existing);
// Add a new effect
else if ( state ) {
const cls = getDocumentClass("ActiveEffect");
const createData = foundry.utils.deepClone(effectData);
createData.statuses = [effectData.id];
delete createData.id;
cls.migrateDataSafe(createData);
cls.cleanData(createData);
createData.name = game.i18n.localize(createData.name);
if ( overlay ) createData["flags.core.overlay"] = true;
await cls.create(createData, {parent: this.actor});
}
return state;
}
/* -------------------------------------------- */
/**
* Test whether a Token has a specific status effect.
* @param {string} statusId The status effect ID as defined in CONFIG.statusEffects
* @returns {boolean} Does the Token have this status effect?
*/
hasStatusEffect(statusId) {
// Case 1 - No Actor
if ( !this.actor ) {
const icon = CONFIG.statusEffects.find(e => e.id === statusId)?.icon;
return this.effects.includes(icon);
}
// Case 2 - Actor Active Effects
return this.actor.statuses.has(statusId);
}
/* -------------------------------------------- */
/* Actor Data Operations */
/* -------------------------------------------- */
/**
* Convenience method to change a token vision mode.
* @param {string} visionMode The vision mode to apply to this token.
* @param {boolean} [defaults=true] If the vision mode should be updated with its defaults.
* @returns {Promise<*>}
*/
async updateVisionMode(visionMode, defaults=true) {
if ( !(visionMode in CONFIG.Canvas.visionModes) ) {
throw new Error("The provided vision mode does not exist in CONFIG.Canvas.visionModes");
}
let update = {sight: {visionMode: visionMode}};
if ( defaults ) foundry.utils.mergeObject(update.sight, CONFIG.Canvas.visionModes[visionMode].vision.defaults);
return this.update(update);
}
/* -------------------------------------------- */
/** @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 */
async _preUpdate(data, options, user) {
await super._preUpdate(data, options, user);
if ( "width" in data ) data.width = Math.max((data.width || 1).toNearest(0.5), 0.5);
if ( "height" in data ) data.height = Math.max((data.height || 1).toNearest(0.5), 0.5);
if ( "actorId" in data ) options.previousActorId = this.actorId;
if ( ("actorData" in data) ) {
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(data, options, userId) {
const configs = Object.values(this.apps).filter(app => app instanceof TokenConfig);
configs.forEach(app => {
if ( app.preview ) options.animate = false;
app._previewChanges(data);
});
// If the Actor association has changed, expire the cached Token actor
if ( ("actorId" in data) || ("actorLink" in data) ) {
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 });
}
// Post-update the Token itself
super._onUpdate(data, options, userId);
configs.forEach(app => app._previewChanges());
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDelete(options, userId) {
super._onDelete(options, userId);
this.baseActor?._unregisterDependentToken(this);
}
/* -------------------------------------------- */
/**
* 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 {DocumentModificationContext} [options] The options provided to the update.
* @protected
*/
_onRelatedUpdate(update={}, options={}) {
// 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});
}
}
/* -------------------------------------------- */
/**
* @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();
attributes.bar = attributes.bar.map(v => v.join("."));
attributes.bar.sort((a, b) => a.localeCompare(b));
attributes.value = attributes.value.map(v => v.join("."));
attributes.value.sort((a, b) => a.localeCompare(b));
return {
[game.i18n.localize("TOKEN.BarAttributes")]: attributes.bar,
[game.i18n.localize("TOKEN.BarValues")]: attributes.value
};
}
/* -------------------------------------------- */
/* 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});
}
}
/* -------------------------------------------- */
/* Proxy Prototype Token Methods */
/* -------------------------------------------- */
foundry.data.PrototypeToken.prototype.getBarAttribute = TokenDocument.prototype.getBarAttribute;
/**
* @deprecated since v10
* @see data.PrototypeToken
* @ignore
*/
class PrototypeTokenDocument extends foundry.data.PrototypeToken {
constructor(...args) {
foundry.utils.logCompatibilityWarning("You are using the PrototypeTokenDocument class which has been deprecated in"
+ " favor of using foundry.data.PrototypeToken directly.", {since: 10, until: 12});
super(...args);
}
}
/**
* The client-side User document which extends the common BaseUser model.
* Each User document contains UserData which defines its data schema.
*
* @extends documents.BaseUser
* @mixes ClientDocumentMixin
*
* @see {@link documents.Users} The world-level collection of User documents
* @see {@link applications.UserConfig} The User configuration application
*/
class User extends ClientDocumentMixin(foundry.documents.BaseUser) {
/**
* Track whether the user is currently active in the game
* @type {boolean}
*/
active = false;
/**
* Track references to the current set of Tokens which are targeted by the User
* @type {Set<Token>}
*/
targets = new UserTargets(this);
/**
* Track the ID of the Scene that is currently being viewed by the User
* @type {string|null}
*/
viewedScene = null;
/**
* A flag for whether the current User is a Trusted Player
* @type {boolean}
*/
get isTrusted() {
return this.hasRole("TRUSTED");
}
/**
* A flag for whether this User is the connected client
* @type {boolean}
*/
get isSelf() {
return game.userId === this.id;
}
/* ---------------------------------------- */
/** @inheritdoc */
prepareDerivedData() {
super.prepareDerivedData();
this.avatar = this.avatar || this.character?.img || CONST.DEFAULT_TOKEN;
const rgb = Color.from(this.color).rgb;
this.border = Color.fromRGB(rgb.map(c => Math.min(c * 2, 1)));
}
/* ---------------------------------------- */
/* User Methods */
/* ---------------------------------------- */
/**
* Assign a Macro to a numbered hotbar slot between 1 and 50
* @param {Macro|null} macro The Macro document to assign
* @param {number|string} [slot] A specific numbered hotbar slot to fill
* @param {number} [fromSlot] An optional origin slot from which the Macro is being shifted
* @returns {Promise<User>} A Promise which resolves once the User update is complete
*/
async assignHotbarMacro(macro, slot, {fromSlot}={}) {
if ( !(macro instanceof Macro) && (macro !== null) ) throw new Error("Invalid Macro provided");
const hotbar = this.hotbar;
// If a slot was not provided, get the first available slot
if ( Number.isNumeric(slot) ) slot = Number(slot);
else {
for ( let i=1; i<=50; i++ ) {
if ( !(i in hotbar ) ) {
slot = i;
break;
}
}
}
if ( !slot ) throw new Error("No available Hotbar slot exists");
if ( slot < 1 || slot > 50 ) throw new Error("Invalid Hotbar slot requested");
if ( macro && (hotbar[slot] === macro.id) ) return this;
// Update the hotbar data
const update = foundry.utils.deepClone(hotbar);
if ( macro ) update[slot] = macro.id;
else delete update[slot];
if ( Number.isNumeric(fromSlot) && (fromSlot in hotbar) ) delete update[fromSlot];
return this.update({hotbar: update}, {diff: false, recursive: false, noHook: true});
}
/* -------------------------------------------- */
/**
* Assign a specific boolean permission to this user.
* Modifies the user permissions to grant or restrict access to a feature.
*
* @param {string} permission The permission name from USER_PERMISSIONS
* @param {boolean} allowed Whether to allow or restrict the permission
*/
assignPermission(permission, allowed) {
if ( !game.user.isGM ) throw new Error(`You are not allowed to modify the permissions of User ${this.id}`);
const permissions = {[permission]: allowed};
return this.update({permissions});
}
/* -------------------------------------------- */
/**
* @typedef {object} PingData
* @property {boolean} [pull=false] Pulls all connected clients' views to the pinged co-ordinates.
* @property {string} style The ping style, see CONFIG.Canvas.pings.
* @property {string} scene The ID of the scene that was pinged.
* @property {number} zoom The zoom level at which the ping was made.
*/
/**
* @typedef {object} ActivityData
* @property {string|null} [sceneId] The ID of the scene that the user is viewing.
* @property {{x: number, y: number}} [cursor] The position of the user's cursor.
* @property {RulerData|null} [ruler] The state of the user's ruler, if they are currently using one.
* @property {string[]} [targets] The IDs of the tokens the user has targeted in the currently viewed
* scene.
* @property {boolean} [active] Whether the user has an open WS connection to the server or not.
* @property {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}={}) {
if ( !this.active ) {
this.active = true;
ui.players.render();
}
activityData.sceneId = canvas.ready ? canvas.scene.id : null;
if ( this.viewedScene !== activityData.sceneId ) {
this.viewedScene = activityData.sceneId;
ui.nav.render();
}
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(data, options, userId) {
super._onUpdate(data, options, userId);
// If the user role changed, we need to re-build the immutable User object
if ( this._source.role !== this.role ) {
const user = this.clone({}, {keepId: true});
game.users.set(user.id, user);
return user._onUpdate(data, options, userId);
}
// Get the changed attributes
let changed = Object.keys(data).filter(k => k !== "_id");
// If your own password or role changed - you must re-authenticate
const isSelf = data._id === game.userId;
if ( isSelf && changed.some(p => ["password", "role"].includes(p) ) ) return game.logOut();
if ( !game.ready ) return;
// Redraw Navigation
if ( changed.some(p => ["active", "color", "role"].includes(p)) ) ui.nav?.render();
// Redraw Players UI
if ( changed.some(p => ["active", "character", "color", "role"].includes(p)) ) ui.players?.render();
// Redraw Hotbar
if ( isSelf && changed.includes("hotbar") ) ui.hotbar?.render();
// Reconnect to Audio/Video conferencing, or re-render camera views
const webRTCReconnect = ["permissions", "role"].some(k => k in data);
if ( webRTCReconnect && (data._id === game.userId) ) {
game.webrtc?.client.updateLocalStream().then(() => game.webrtc.render());
} else if ( ["name", "avatar", "character"].some(k => k in data) ) game.webrtc?.render();
// Update Canvas
if ( canvas.ready ) {
// Redraw Cursor
if ( changed.includes("color") ) {
canvas.controls.drawCursor(this);
const ruler = canvas.controls.getRulerForUser(this.id);
if ( ruler ) ruler.color = Color.from(data.color);
}
if ( changed.includes("active") ) canvas.controls.updateCursor(this, null);
// Modify impersonated character
if ( isSelf && changed.includes("character") ) {
canvas.perception.initialize();
canvas.tokens.cycleTokens(true, true);
}
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDelete(options, userId) {
super._onDelete(options, userId);
if ( this.id === game.user.id ) return game.logOut();
}
}
/**
* The client-side Wall document which extends the common BaseWall document model.
* @extends documents.BaseWall
* @mixes ClientDocumentMixin
*
* @see {@link Scene} The Scene document type which contains Wall documents
* @see {@link WallConfig} The Wall configuration application
*/
class WallDocument extends CanvasDocumentMixin(foundry.documents.BaseWall) {}
/**
* The virtual tabletop environment is implemented using a WebGL powered HTML 5 canvas using the powerful PIXI.js
* library. The canvas is comprised by an ordered sequence of layers which define rendering groups and collections of
* objects that are drawn on the canvas itself.
*
* ### Hook Events
* {@link hookEvents.canvasConfig}
* {@link hookEvents.canvasInit}
* {@link hookEvents.canvasReady}
* {@link hookEvents.canvasPan}
* {@link hookEvents.canvasTearDown}
*
* @category - Canvas
*
* @example Canvas State
* ```js
* canvas.ready; // Is the canvas ready for use?
* canvas.scene; // The currently viewed Scene document.
* canvas.dimensions; // The dimensions of the current Scene.
* ```
* @example Canvas Methods
* ```js
* canvas.draw(); // Completely re-draw the game canvas (this is usually unnecessary).
* canvas.pan(x, y, zoom); // Pan the canvas to new coordinates and scale.
* canvas.recenter(); // Re-center the canvas on the currently controlled Token.
* ```
*/
class Canvas {
constructor() {
/**
* A perception manager interface for batching lighting, sight, and sound updates
* @type {PerceptionManager}
*/
this.perception = new PerceptionManager();
/**
* A flag to indicate whether a new Scene is currently being drawn.
* @type {boolean}
*/
this.loading = false;
/**
* A promise that resolves when the canvas is first initialized and ready.
* @type {Promise<void>|null}
*/
this.initializing = null;
/**
* Track the last automatic pan time to throttle
* @type {number}
* @private
*/
this._panTime = 0;
// Define an immutable object for the canvas dimensions
Object.defineProperty(this, "dimensions", {value: {}, writable: false});
}
/**
* A set of blur filter instances which are modified by the zoom level and the "soft shadows" setting
* @type {Set<PIXI.filters>}
*/
blurFilters = new Set();
/**
* A reference to the MouseInteractionManager that is currently controlling pointer-based interaction, or null.
* @type {MouseInteractionManager|null}
*/
currentMouseManager = null;
/**
* The current pixel dimensions of the displayed Scene, or null if the Canvas is blank.
* @type {SceneDimensions}
*/
dimensions;
/**
* 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]: PIXI.Texture, [foreground]: PIXI.Texture, [fogOverlay]: PIXI.Texture}}
*/
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 {{enabled: boolean, illumination: boolean}} blur Blur filter configuration
* @property {string} mipmap Whether to use mipmaps, "ON" or "OFF"
* @property {boolean} msaa Whether to apply MSAA at the overall canvas level
* @property {number} fps Maximum framerate which should be the render target
* @property {boolean} tokenAnimation Whether to display token movement animation
* @property {boolean} lightAnimation Whether to display light source animation
* @property {boolean} lightSoftEdges Whether to render soft edges for light sources
* @property {{enabled: boolean, maxSize: number, p2Steps: number, p2StepsMax: 2}} textures Texture configuration
*/
/**
* Configured performance settings which affect the behavior of the Canvas and its renderer.
* @type {CanvasPerformanceSettings}
*/
performance;
/**
* @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];
/**
* The singleton Fog of War manager instance.
* @type {FogManager}
* @private
*/
_fog = new CONFIG.Canvas.fogManager();
/**
* The singleton color manager instance.
* @type {CanvasColorManager}
*/
#colorManager = new CONFIG.Canvas.colorManager();
/**
* The DragDrop instance which handles interactivity resulting from DragTransfer events.
* @type {DragDrop}
* @private
*/
#dragDrop;
/**
* An object of data which caches data which should be persisted across re-draws of the game canvas.
* @type {{scene: string, layer: string, controlledTokens: string[], targetedTokens: string[]}}
* @private
*/
#reload = {};
/* -------------------------------------------- */
/**
* Track the timestamp when the last mouse move event was captured
* @type {number}
*/
#mouseMoveTime = 0;
/**
* The debounce timer in milliseconds for tracking mouse movements on the Canvas.
* @type {number}
*/
#mouseMoveDebounceMS = 100;
/**
* A debounced function which tracks movements of the mouse on the game canvas.
* @type {function(PIXI.FederatedEvent)}
*/
#debounceMouseMove = foundry.utils.debounce(this._onMouseMove.bind(this), this.#mouseMoveDebounceMS);
/* -------------------------------------------- */
/* Canvas Groups and Layers */
/* -------------------------------------------- */
/**
* The singleton PIXI.Application instance rendered on the Canvas.
* @type {PIXI.Application}
*/
app;
/**
* The primary stage container of the PIXI.Application.
* @type {PIXI.Container}
*/
stage;
/**
* The primary Canvas group which generally contains tangible physical objects which exist within the Scene.
* This group is a {@link CachedContainer} which is rendered to the Scene as a {@link SpriteMesh}.
* This allows the rendered result of the Primary Canvas Group to be affected by a {@link BaseSamplerShader}.
* @type {PrimaryCanvasGroup}
*/
primary;
/**
* The effects Canvas group which modifies the result of the {@link PrimaryCanvasGroup} by adding special effects.
* This includes lighting, weather, vision, and other visual effects which modify the appearance of the Scene.
* @type {EffectsCanvasGroup}
*/
effects;
/**
* The interface Canvas group which is rendered above other groups and contains all interactive elements.
* The various {@link InteractionLayer} instances of the interface group provide different control sets for
* interacting with different types of {@link Document}s which can be represented on the Canvas.
* @type {InterfaceCanvasGroup}
*/
interface;
/**
* The 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();
/* -------------------------------------------- */
/* Properties and Attributes
/* -------------------------------------------- */
/**
* A flag for whether the game Canvas is fully initialized and ready for additional content to be drawn.
* @type {boolean}
*/
get initialized() {
return this.#initialized;
}
/** @ignore */
#initialized = false;
/* -------------------------------------------- */
/**
* A reference to the currently displayed Scene document, or null if the Canvas is currently blank.
* @type {Scene|null}
*/
get scene() {
return this.#scene;
}
/** @ignore */
#scene = null;
/* -------------------------------------------- */
/**
* A flag for whether the game Canvas is ready to be used. False if the canvas is not yet drawn, true otherwise.
* @type {boolean}
*/
get ready() {
return this.#ready;
}
/** @ignore */
#ready = false;
/* -------------------------------------------- */
/**
* The fog of war bound to this canvas
* @type {FogManager}
*/
get fog() {
return this._fog;
}
/* -------------------------------------------- */
/**
* The color manager class bound to this canvas
* @type {CanvasColorManager}
*/
get colorManager() {
return this.#colorManager;
}
/* -------------------------------------------- */
/**
* The colors bound to this scene and handled by the color manager.
* @type {Color}
*/
get colors() {
return this.#colorManager.colors;
}
/* -------------------------------------------- */
/**
* Shortcut to get the masks container from HiddenCanvasGroup.
* @type {PIXI.Container}
*/
get masks() {
return this.hidden.masks;
}
/* -------------------------------------------- */
/**
* The id of the currently displayed Scene.
* @type {string|null}
*/
get id() {
return this.#scene?.id || null;
}
/* -------------------------------------------- */
/**
* A mapping of named CanvasLayer classes which defines the layers which comprise the Scene.
* @type {Object<CanvasLayer>}
*/
static get layers() {
return CONFIG.Canvas.layers;
}
/* -------------------------------------------- */
/**
* An Array of all CanvasLayer instances which are active on the Canvas board
* @type {CanvasLayer[]}
*/
get layers() {
return Object.keys(this.constructor.layers).map(k => this[k]);
}
/* -------------------------------------------- */
/**
* Return a reference to the active Canvas Layer
* @type {CanvasLayer}
*/
get activeLayer() {
for ( let name of Object.keys(this.constructor.layers) ) {
const layer = this[name];
if ( layer?.active ) return layer;
}
return null;
}
/* -------------------------------------------- */
/**
* The currently displayed darkness level, which may override the saved Scene value.
* @type {number}
*/
get darknessLevel() {
return this.#colorManager.darknessLevel;
}
/* -------------------------------------------- */
/* Initialization */
/* -------------------------------------------- */
/**
* Initialize the Canvas by creating the HTML element and PIXI application.
* This step should only ever be performed once per client session.
* Subsequent requests to reset the canvas should go through Canvas#draw
*/
initialize() {
if ( this.#initialized ) throw new Error("The Canvas is already initialized and cannot be re-initialized");
// If the game canvas is disabled by "no canvas" mode, we don't need to initialize anything
if ( game.settings.get("core", "noCanvas") ) return;
// Verify that WebGL is available
Canvas.#configureWebGL();
// Create the HTML Canvas element
const canvas = Canvas.#createHTMLCanvas();
// Configure canvas settings
const config = Canvas.#configureCanvasSettings();
// Create the PIXI Application
this.#createApplication(canvas, config);
// Configure the desired performance mode
this._configurePerformanceMode();
// Display any performance warnings which suggest that the created Application will not function well
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.#initialized = true;
this.#ready = false;
}
/* -------------------------------------------- */
/**
* Configure the usage of WebGL for the PIXI.Application that will be created.
* @throws an Error if WebGL is not supported by this browser environment.
* @private
*/
static #configureWebGL() {
if ( !PIXI.utils.isWebGLSupported() ) {
const err = new Error(game.i18n.localize("ERROR.NoWebGL"));
ui.notifications.error(err.message, {permanent: true});
throw err;
}
PIXI.settings.PREFER_ENV = PIXI.ENV.WEBGL2;
}
/* -------------------------------------------- */
/**
* Create the Canvas element which will be the render target for the PIXI.Application instance.
* Replace the template element which serves as a placeholder in the initially served HTML response.
* @returns {HTMLCanvasElement}
* @private
*/
static #createHTMLCanvas() {
const board = document.getElementById("board");
const canvas = document.createElement("canvas");
canvas.id = "board";
canvas.style.display = "none";
board.replaceWith(canvas);
return canvas;
}
/* -------------------------------------------- */
/**
* Configure the settings used to initialize the PIXI.Application instance.
* @returns {object} Options passed to the PIXI.Application constructor.
* @private
*/
static #configureCanvasSettings() {
const config = {
width: window.innerWidth,
height: window.innerHeight,
transparent: false,
resolution: game.settings.get("core", "pixelRatioResolutionScaling") ? window.devicePixelRatio : 1,
autoDensity: true,
antialias: false, // Not needed because we use SmoothGraphics
powerPreference: "high-performance" // Prefer high performance GPU for devices with dual graphics cards
};
Hooks.callAll("canvasConfig", config);
return config;
}
/* -------------------------------------------- */
/**
* Initialize custom pixi plugins.
*/
#initializePlugins() {
MonochromaticSamplerShader.registerPlugin();
OcclusionSamplerShader.registerPlugin();
}
/* -------------------------------------------- */
/**
* Create the PIXI.Application and update references to the created app and stage.
* @param {HTMLCanvasElement} canvas The target canvas view element
* @param {object} config Desired PIXI.Application configuration options
*/
#createApplication(canvas, config) {
this.#initializePlugins();
// Create the Application instance
const app = new PIXI.Application({view: canvas, ...config});
Object.defineProperty(this, "app", {value: app, writable: false});
// Reference the Stage
Object.defineProperty(this, "stage", {value: this.app.stage, writable: false});
// 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.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
* @private
*/
#createGroups(parentName, parent) {
for ( const [name, config] of Object.entries(CONFIG.Canvas.groups) ) {
if ( config.parent !== parentName ) continue;
const group = new config.groupClass();
Object.defineProperty(this, name, {value: group, writable: false}); // Reference on the Canvas
Object.defineProperty(parent, name, {value: group, writable: false}); // Reference on the parent
parent.addChild(group);
this.#createGroups(name, group); // Recursive
}
}
/* -------------------------------------------- */
/**
* TODO: Add a quality parameter
* Compute the blur parameters according to grid size and performance mode.
* @param options Blur options.
* @private
*/
_initializeBlur(options={}) {
// Discard shared filters
this.blurFilters.clear();
// Compute base values from grid size
const blurStrength = this.grid.size / 25;
const blurFactor = this.grid.size / 100;
// Lower stress for MEDIUM performance mode
const level =
Math.max(0, this.performance.mode - (this.performance.mode < CONST.CANVAS_PERFORMANCE_MODES.HIGH ? 1 : 0));
const maxKernels = Math.max(5 + (level * 2), 5);
const maxPass = 2 + (level * 2);
// Compute blur parameters
this.blur = new Proxy(Object.seal({
enabled: 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.clamped(level + Math.floor(blurFactor), 2, maxPass),
kernels: options.kernels
?? Math.clamped((2 * Math.ceil((1 + (2 * level) + Math.floor(blurFactor)) / 2)) - 1, 5, maxKernels)
}), {
set(obj, prop, value) {
if ( prop !== "strength" ) throw new Error(`canvas.blur.${prop} is immutable`);
const v = Reflect.set(obj, prop, value);
canvas.updateBlur();
return v;
}
});
// Immediately update blur
this.updateBlur();
}
/* -------------------------------------------- */
/**
* Configure performance settings for hte canvas application based on the selected performance mode.
* @returns {CanvasPerformanceSettings}
* @internal
*/
_configurePerformanceMode() {
const modes = CONST.CANVAS_PERFORMANCE_MODES;
// Get client settings
let mode = game.settings.get("core", "performanceMode");
const fps = game.settings.get("core", "maxFPS");
const mip = game.settings.get("core", "mipmap");
// Deprecation shim for textures
const gl = this.app.renderer.context.gl;
const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
// Configure default performance mode if one is not set
if ( !Number.isFinite(mode) || (mode === -1) ) {
if ( maxTextureSize <= Math.pow(2, 12) ) mode = CONST.CANVAS_PERFORMANCE_MODES.LOW;
else if ( maxTextureSize <= Math.pow(2, 13) ) mode = CONST.CANVAS_PERFORMANCE_MODES.MED;
else 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,
fps: Math.clamped(fps, 0, 60),
tokenAnimation: true,
lightAnimation: true,
lightSoftEdges: false
};
// Deprecation shim for blur
settings.blur = new Proxy({
enabled: false,
illumination: false
}, {
get(obj, prop, receiver) {
foundry.utils.logCompatibilityWarning("canvas.performance.blur is deprecated and replaced by canvas.blur", {
since: 10, until: 12});
return Reflect.get(obj, prop, receiver);
}
});
settings.textures = new Proxy({
enabled: false,
maxSize: maxTextureSize,
p2Steps: 2,
p2StepsMax: 3
}, {
get(obj, prop, receiver) {
foundry.utils.logCompatibilityWarning("canvas.performance.textures is deprecated", {
since: 10, until: 12});
return Reflect.get(obj, prop, receiver);
}
});
// Low settings
if ( mode >= modes.LOW ) {
settings.tokenAnimation = false;
settings.lightAnimation = false;
}
// Medium settings
if ( mode >= modes.MED ) {
settings.blur.enabled = true;
settings.textures.enabled = true;
settings.textures.p2Steps = 3;
settings.lightSoftEdges = true;
}
// High settings
if ( mode >= modes.HIGH ) {
settings.blur.illumination = true;
settings.textures.p2Steps = 2;
}
// Max settings
if ( mode === modes.MAX ) {
settings.textures.p2Steps = 1;
if ( settings.fps === 60 ) settings.fps = 0;
}
// Configure performance settings
PIXI.BaseTexture.defaultOptions.mipmap = PIXI.MIPMAP_MODES[settings.mipmap];
PIXI.Filter.defaultResolution = canvas.app.renderer.resolution;
this.app.ticker.maxFPS = PIXI.Ticker.shared.maxFPS = PIXI.Ticker.system.maxFPS = settings.fps;
return this.performance = settings;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* Draw the game canvas.
* @param {Scene} [scene] A specific Scene document to render on the Canvas
* @returns {Promise<Canvas>} A Promise which resolves once the Canvas is fully drawn
*/
async draw(scene) {
// If the canvas had not yet been initialized, we have done something out of order
if ( !this.#initialized ) {
throw new Error("You may not call Canvas#draw before Canvas#initialize");
}
// Identify the Scene which should be drawn
if ( scene === undefined ) scene = game.scenes.current;
if ( !((scene instanceof Scene) || (scene === null)) ) {
throw new Error("You must provide a Scene Document to draw the Canvas.");
}
// Assign status flags
const wasReady = this.#ready;
this.#ready = false;
this.stage.visible = false;
this.loading = true;
// Tear down any existing scene
if ( wasReady ) {
try {
await this.tearDown();
} catch(err) {
err.message = `Encountered an error while tearing down the previous scene: ${err.message}`;
logger.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();
// Initialize color manager for this scene
this.colorManager.initialize();
// Configure Scene dimensions
foundry.utils.mergeObject(this.dimensions, scene.getDimensions());
canvas.app.view.style.display = "block";
document.documentElement.style.setProperty("--gridSize", `${this.dimensions.size}px`);
// Call Canvas initialization hooks
this.loadTexturesOptions = {expireCache: true, additionalSources: []};
this.visibilityOptions = {persistentVision: false};
console.log(`${vtt} | Drawing game canvas for scene ${this.#scene.name}`);
Hooks.callAll("canvasInit", this);
// Configure attributes of the Stage
this.stage.position.set(window.innerWidth / 2, window.innerHeight / 2);
this.stage.hitArea = new PIXI.Rectangle(0, 0, this.dimensions.width, this.dimensions.height);
this.stage.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 this;
}
// Activate ticker render workflows
this.#activateTicker();
// Draw canvas groups
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 this;
}
}
// Mask primary and effects layers by the overall canvas
const cr = canvas.dimensions.rect;
this.masks.canvas.clear().beginFill(0xFFFFFF, 1.0).drawRect(cr.x, cr.y, cr.width, cr.height).endFill();
this.primary.sprite.mask = this.primary.mask = this.effects.mask = this.interface.grid.mask = this.masks.canvas;
// 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;
Hooks.call("canvasReady", this);
// Record that loading was complete and return
this.loading = false;
return this;
}
/* -------------------------------------------- */
/**
* When re-drawing the canvas, first tear down or discontinue some existing processes
* @returns {Promise<void>}
*/
async tearDown() {
this.stage.visible = false;
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();
}
// Call tear-down hooks
Hooks.callAll("canvasTearDown", this);
// Tear down groups
for ( const name of Object.keys(CONFIG.Canvas.groups).reverse() ) {
const group = this[name];
await group.tearDown();
}
// Tear down every layer
await this.effects.tearDown();
for ( let l of this.layers.reverse() ) {
await l.tearDown();
}
// Discard shared filters
this.blurFilters.clear();
// Create a new event boundary for the stage
this.app.renderer.events.rootBoundary = new PIXI.EventBoundary(this.stage);
}
/* -------------------------------------------- */
/**
* A special workflow to perform when rendering a blank Canvas with no active Scene.
* @returns {Canvas}
*/
#drawBlank() {
console.log(`${vtt} | Skipping game canvas - no active scene.`);
canvas.app.view.style.display = "none";
ui.controls.render();
this.loading = this.#ready = false;
return this;
}
/* -------------------------------------------- */
/**
* Get the value of a GL parameter
* @param {string} parameter The GL parameter to retrieve
* @returns {*} The GL parameter value
*/
getGLParameter(parameter) {
const gl = this.app.renderer.context.gl;
return gl.getParameter(gl[parameter]);
}
/* -------------------------------------------- */
/**
* Once the canvas is drawn, initialize control, visibility, and audio states
* @returns {Promise<void>}
* @private
*/
async #initialize() {
this.#ready = true;
// Clear the set of targeted Tokens for the current user
game.user.targets.clear();
// Render the HUD layer
this.hud.render(true);
// Compute Wall intersections and identify interior walls
canvas.walls.initialize();
// Initialize canvas conditions
this.#initializeCanvasLayer();
this.#initializeTokenControl();
this._onResize();
this.#reload = {};
// Initialize perception manager
this.perception.initialize();
// Broadcast user presence in the Scene
game.user.broadcastActivity({sceneId: this.#scene.id});
// 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.clamped(Math.min(window.innerHeight / r.height, window.innerWidth / r.width), 0.25, 3);
position = {x, y, scale};
}
// Pan to the initial view
this.pan(position);
}
/* -------------------------------------------- */
/**
* Initialize a CanvasLayer in the activation state
* @private
*/
#initializeCanvasLayer() {
const layer = this[this.#reload.layer] ?? this.tokens;
layer.activate();
}
/* -------------------------------------------- */
/**
* Initialize a token or set of tokens which should be controlled.
* Restore controlled and targeted tokens from before the re-draw.
* @private
*/
#initializeTokenControl() {
let panToken = null;
let controlledTokens = [];
let targetedTokens = [];
// Initial tokens based on reload data
let isReload = this.#reload.scene === this.#scene.id;
if ( isReload ) {
controlledTokens = this.#reload.controlledTokens.map(id => canvas.tokens.get(id));
targetedTokens = this.#reload.targetedTokens.map(id => canvas.tokens.get(id));
}
// Initialize tokens based on player character
else if ( !game.user.isGM ) {
controlledTokens = game.user.character?.getActiveTokens() || [];
if (!controlledTokens.length) {
controlledTokens = canvas.tokens.placeables.filter(t => t.actor?.testUserPermission(game.user, "OWNER"));
}
if (!controlledTokens.length) {
const observed = canvas.tokens.placeables.filter(t => t.actor?.testUserPermission(game.user, "OBSERVER"));
panToken = observed.shift() || null;
}
}
// Initialize Token Control
for ( let token of controlledTokens ) {
if ( !panToken ) panToken = token;
token?.control({releaseOthers: false});
}
// Display a warning if the player has no vision tokens in a visibility-restricted scene
if ( !game.user.isGM && this.#scene.tokenVision && !controlledTokens.some(t => t.document.sight.enabled) ) {
ui.notifications.warn("TOKEN.WarningNoVision", {localize: true});
}
// Initialize Token targets
for ( const token of targetedTokens ) {
token?.setTarget(true, {releaseOthers: false, groupSelection: true});
}
// Pan camera to controlled token
if ( panToken && !isReload ) this.pan({x: panToken.center.x, y: panToken.center.y, duration: 250});
}
/* -------------------------------------------- */
/**
* Given an embedded object name, get the canvas layer for that object
* @param {string} embeddedName
* @returns {PlaceablesLayer|null}
*/
getLayerByEmbeddedName(embeddedName) {
return {
AmbientLight: this.lighting,
AmbientSound: this.sounds,
Drawing: this.drawings,
Note: this.notes,
MeasuredTemplate: this.templates,
Tile: this.tiles,
Token: this.tokens,
Wall: this.walls
}[embeddedName] || null;
}
/* -------------------------------------------- */
/**
* 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 {
lights: this.lighting,
sounds: this.sounds,
drawings: this.drawings,
notes: this.notes,
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
* @private
*/
#measureFPS() {
const lastTime = this.app.ticker.lastTime;
// Push fps values every frame
this.fps.values.push(1000 / this.app.ticker.elapsedMS);
if ( this.fps.values.length > 60 ) this.fps.values.shift();
// Do some computations and rendering occasionally
if ( (lastTime - this.fps.render) < 250 ) return;
if ( !this.fps.element ) return;
// Compute average fps
const total = this.fps.values.reduce((fps, total) => total + fps, 0);
this.fps.average = (total / this.fps.values.length);
// Render it
this.fps.element.innerHTML = `<label>FPS:</label> <span>${this.fps.average.toFixed(2)}</span>`;
this.fps.render = lastTime;
}
/* -------------------------------------------- */
/**
* @typedef {Object} CanvasViewPosition
* @property {number|null} x The x-coordinate which becomes stage.pivot.x
* @property {number|null} y The y-coordinate which becomes stage.pivot.y
* @property {number|null} scale The zoom level up to CONFIG.Canvas.maxZoom which becomes stage.scale.x and y
*/
/**
* Pan the canvas to a certain {x,y} coordinate and a certain zoom level
* @param {CanvasViewPosition} position The canvas position to pan to
*/
pan({x=null, y=null, scale=null}={}) {
// Constrain the resulting canvas view
const constrained = this._constrainView({x, y, scale});
const scaleChange = constrained.scale !== this.stage.scale.x;
// Set the pivot point
this.stage.pivot.set(constrained.x, constrained.y);
// Set the zoom level
if ( scaleChange ) {
this.stage.scale.set(constrained.scale, constrained.scale);
this.updateBlur();
}
// Update the scene tracked position
canvas.scene._viewPosition = constrained;
// Call hooks
Hooks.callAll("canvasPan", this, constrained);
// Update controls
this.controls._onCanvasPan();
// Align the HUD
this.hud.align();
}
/* -------------------------------------------- */
/**
* Animate panning the canvas to a certain destination coordinate and zoom scale
* Customize the animation speed with additional options
* Returns a Promise which is resolved once the animation has completed
*
* @param {CanvasViewPosition} view The desired view parameters
* @param {number} [view.duration=250] The total duration of the animation in milliseconds; used if speed is not set
* @param {number} [view.speed] The speed of animation in pixels per second; overrides duration if set
* @returns {Promise} A Promise which resolves once the animation has been completed
*/
async animatePan({x, y, scale, duration=250, speed}={}) {
// Determine the animation duration to reach the target
if ( speed ) {
let ray = new Ray(this.stage.pivot, {x, y});
duration = Math.round(ray.distance * 1000 / speed);
}
// Constrain the resulting dimensions and construct animation attributes
const constrained = this._constrainView({x, y, scale});
const attributes = [
{ parent: this.stage.pivot, attribute: "x", to: constrained.x },
{ parent: this.stage.pivot, attribute: "y", to: constrained.y },
{ parent: this.stage.scale, attribute: "x", to: constrained.scale },
{ parent: this.stage.scale, attribute: "y", to: constrained.scale }
].filter(a => a.to !== undefined);
// Trigger the animation function
const animation = await CanvasAnimation.animate(attributes, {
name: "canvas.animatePan",
duration: duration,
easing: CanvasAnimation.easeInOutCosine,
ontick: () => {
this.hud.align();
const stage = this.stage;
Hooks.callAll("canvasPan", this, {x: stage.pivot.x, y: stage.pivot.y, scale: stage.scale.x});
}
});
// Record final values
this.updateBlur();
canvas.scene._viewPosition = constrained;
return animation;
}
/* -------------------------------------------- */
/**
* Recenter the canvas with a pan animation that ends in the center of the canvas rectangle.
* @param {CanvasViewPosition} initial A desired initial position from which to begin the animation
* @returns {Promise<void>} 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});
}
}
/** @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<boolean>}
*/
async ping(origin, options) {
// Configure the ping to be dispatched
const types = CONFIG.Canvas.pings.types;
const isPull = game.user.isGM && game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.SHIFT);
const isAlert = game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.ALT);
let style = types.PULSE;
if ( isPull ) style = types.PULL;
else if ( isAlert ) style = types.ALERT;
let ping = {scene: this.scene?.id, pull: isPull, style, zoom: canvas.stage.scale.x};
ping = foundry.utils.mergeObject(ping, options);
// Broadcast the ping to other connected clients
/** @type ActivityData */
const activity = {cursor: origin, ping};
game.user.broadcastActivity(activity);
// Display the ping locally
return this.controls.handlePing(game.user, origin, ping);
}
/* -------------------------------------------- */
/**
* Get the constrained zoom scale parameter which is allowed by the maxZoom parameter
* @param {CanvasViewPosition} position The unconstrained position
* @returns {CanvasViewPosition} The constrained position
* @internal
*/
_constrainView({x, y, scale}) {
const d = canvas.dimensions;
// Constrain the maximum zoom level
if ( Number.isNumeric(scale) && (scale !== this.stage.scale.x) ) {
const max = CONFIG.Canvas.maxZoom;
const ratio = Math.max(d.width / window.innerWidth, d.height / window.innerHeight, max);
scale = Math.clamped(scale, 1 / ratio, max);
} else {
scale = this.stage.scale.x;
}
// Constrain the pivot point using the new scale
if ( Number.isNumeric(x) && x !== this.stage.pivot.x ) {
const padw = 0.4 * (window.innerWidth / scale);
x = Math.clamped(x, -padw, d.width + padw);
}
else x = this.stage.pivot.x;
if ( Number.isNumeric(y) && y !== this.stage.pivot.y ) {
const padh = 0.4 * (window.innerHeight / scale);
y = Math.clamped(y, -padh, d.height + padh);
}
else y = this.stage.pivot.y;
// Return the constrained view dimensions
return {x, y, scale};
}
/* -------------------------------------------- */
/**
* Create a BlurFilter instance and register it to the array for updates when the zoom level changes.
* @param {number} blurStrength The desired blur strength to use for this filter
* @returns {PIXI.filters.BlurFilter}
*/
createBlurFilter(blurStrength=CONFIG.Canvas.blurStrength) {
const f = new PIXI.filters.BlurFilter(blurStrength);
f.blur = this.blur.strength;
this.blurFilters.add(f);
return f;
}
/* -------------------------------------------- */
/**
* Add a filter to the blur filter list. The filter must have the blur property
* @param {PIXI.filters.BlurFilter} filter The Filter instance to add
* @returns {PIXI.filters.BlurFilter} The added filter for method chaining
*/
addBlurFilter(filter) {
if ( filter.blur === undefined ) return;
filter.blur = this.blur.strength * this.stage.scale.x;
this.blurFilters.add(filter); // Save initial blur of the filter in the set
return filter;
}
/* -------------------------------------------- */
/**
* Update the blur strength depending on the scale of the canvas stage.
* This number is zero if "soft shadows" are disabled
* @param {number} [strength] Optional blur strength to apply
* @private
*/
updateBlur(strength) {
for ( const filter of this.blurFilters ) {
filter.blur = (strength ?? this.blur.strength) * this.stage.scale.x;
}
}
/* -------------------------------------------- */
/**
* Convert canvas co-ordinates to the client's viewport.
* @param {Point} origin The canvas coordinates.
* @returns {Point} The corresponding co-ordinates relative to the client's viewport.
*/
clientCoordinatesFromCanvas(origin) {
const t = this.stage.worldTransform;
return {
x: (origin.x * this.stage.scale.x) + t.tx,
y: (origin.y * this.stage.scale.y) + t.ty
};
}
/* -------------------------------------------- */
/**
* Convert client viewport co-ordinates to canvas co-ordinates.
* @param {Point} origin The client coordinates.
* @returns {Point} The corresponding canvas co-ordinates.
*/
canvasCoordinatesFromClient(origin) {
const t = this.stage.worldTransform;
return {
x: (origin.x - t.tx) / this.stage.scale.x,
y: (origin.y - t.ty) / this.stage.scale.y
};
}
/* -------------------------------------------- */
/**
* Determine whether given canvas co-ordinates are off-screen.
* @param {Point} position The canvas co-ordinates.
* @returns {boolean} Is the coordinate outside the screen bounds?
*/
isOffscreen(position) {
const { clientWidth, clientHeight } = document.documentElement;
const { x, y } = this.clientCoordinatesFromCanvas(position);
return (x < 0) || (y < 0) || (x >= clientWidth) || (y >= clientHeight);
}
/* -------------------------------------------- */
/**
* Remove all children of the display object and call one cleaning method:
* clean first, then tearDown, and destroy if no cleaning method is found.
* @param {PIXI.DisplayObject} displayObject The display object to clean.
* @param {boolean} destroy If textures should be destroyed.
*/
static clearContainer(displayObject, destroy=true) {
const children = displayObject.removeChildren();
for ( const child of children ) {
if ( child.clear ) child.clear(destroy);
else if ( child.tearDown ) child.tearDown();
else child.destroy(destroy);
}
}
/* -------------------------------------------- */
/**
* 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
* @private
*/
#addListeners() {
// Remove all existing listeners
this.stage.removeAllListeners();
// Define callback functions for mouse interaction events
const callbacks = {
clickLeft: this._onClickLeft.bind(this),
clickLeft2: this._onClickLeft2.bind(this),
clickRight: this._onClickRight.bind(this),
clickRight2: this._onClickRight2.bind(this),
dragLeftStart: this._onDragLeftStart.bind(this),
dragLeftMove: this._onDragLeftMove.bind(this),
dragLeftDrop: this._onDragLeftDrop.bind(this),
dragLeftCancel: this._onDragLeftCancel.bind(this),
dragRightStart: null,
dragRightMove: this._onDragRightMove.bind(this),
dragRightDrop: this._onDragRightDrop.bind(this),
dragRightCancel: null,
longPress: this._onLongPress.bind(this)
};
// Create and activate the interaction manager
const permissions = { clickRight2: false };
const mgr = new MouseInteractionManager(this.stage, this.stage, permissions, callbacks);
this.mouseInteractionManager = mgr.activate();
// Debug average FPS
if ( game.settings.get("core", "fpsMeter") ) this.activateFPSMeter();
// Add a listener for cursor movement
this.stage.on("mousemove", event => {
const now = event.now = Date.now();
const dt = now - this.#mouseMoveTime;
if ( dt > this.#mouseMoveDebounceMS ) return this._onMouseMove(event); // Handle immediately
else return this.#debounceMouseMove(event); // Handle on debounced delay
});
}
/* -------------------------------------------- */
/**
* Handle mouse movement on the game canvas.
* This handler fires on both a throttle and a debounce, ensuring that the final update is always recorded.
* @param {PIXI.FederatedEvent} event
* @private
*/
_onMouseMove(event) {
this.mousePosition = event.getLocalPosition(this.stage);
this.#mouseMoveTime = event.now;
canvas.controls._onMouseMove(event);
canvas.sounds._onMouseMove(event);
}
/* -------------------------------------------- */
/**
* Handle left mouse-click events occurring on the Canvas.
* @see {MouseInteractionManager##handleClickLeft}
* @param {PIXI.FederatedEvent} event
* @private
*/
_onClickLeft(event) {
const layer = this.activeLayer;
if ( layer instanceof InteractionLayer ) layer._onClickLeft(event);
}
/* -------------------------------------------- */
/**
* Handle double left-click events occurring on the Canvas.
* @see {MouseInteractionManager##handleClickLeft2}
* @param {PIXI.FederatedEvent} event
*/
_onClickLeft2(event) {
const layer = this.activeLayer;
if ( layer instanceof InteractionLayer ) layer._onClickLeft2(event);
}
/* -------------------------------------------- */
/**
* Handle long press events occurring on the Canvas.
* @see {MouseInteractionManager##handleLongPress}
* @param {PIXI.FederatedEvent} event The triggering canvas interaction event.
* @param {PIXI.Point} origin The local canvas coordinates of the mousepress.
* @private
*/
_onLongPress(event, origin) {
canvas.controls._onLongPress(event, origin);
}
/* -------------------------------------------- */
/**
* Handle the beginning of a left-mouse drag workflow on the Canvas stage or its active Layer.
* @see {MouseInteractionManager##handleDragStart}
* @param {PIXI.FederatedEvent} event
* @internal
*/
_onDragLeftStart(event) {
const layer = this.activeLayer;
// Begin ruler measurement
if ( (layer instanceof TokenLayer) && CONFIG.Canvas.rulerClass.canMeasure ) {
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 ) layer._onDragLeftStart(event);
}
/* -------------------------------------------- */
/**
* Handle mouse movement events occurring on the Canvas.
* @see {MouseInteractionManager##handleDragMove}
* @param {PIXI.FederatedEvent} event
* @internal
*/
_onDragLeftMove(event) {
const layer = this.activeLayer;
// Pan the canvas if the drag event approaches the edge
this._onDragCanvasPan(event);
// Continue a Ruler measurement
const ruler = this.controls.ruler;
if ( ruler._state > 0 ) return ruler._onMouseMove(event);
// Continue a select event
const isSelect = ["select", "target"].includes(game.activeTool);
if ( isSelect && canvas.controls.select.active ) return this._onDragSelect(event);
// Dispatch the event to the active layer
if ( layer instanceof InteractionLayer ) layer._onDragLeftMove(event);
}
/* -------------------------------------------- */
/**
* Handle the conclusion of a left-mouse drag workflow when the mouse button is released.
* @see {MouseInteractionManager##handleDragDrop}
* @param {PIXI.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
const ruler = canvas.controls.ruler;
if ( ruler.active ) {
if ( game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL) ) event.preventDefault();
return ruler._onMouseUp(event);
}
// Conclude a select event
const isSelect = ["select", "target"].includes(tool);
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" ) return layer.selectObjects(coords, {releaseOthers});
else if ( tool === "target") return layer.targetObjects(coords, {releaseOthers});
}
// Dispatch the event to the active layer
if ( layer instanceof InteractionLayer ) layer._onDragLeftDrop(event);
}
/* -------------------------------------------- */
/**
* Handle the cancellation of a left-mouse drag workflow
* @see {MouseInteractionManager##handleDragCancel}
* @param {PointerEvent} event
* @internal
*/
_onDragLeftCancel(event) {
const layer = canvas.activeLayer;
const tool = game.activeTool;
// Don't cancel ruler measurement
const ruler = canvas.controls.ruler;
if ( ruler.active ) {
event.preventDefault();
return true;
}
// Clear selection
const isSelect = ["select", "target"].includes(tool);
if ( isSelect ) {
canvas.controls.select.clear();
return true;
}
// 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
* @private
*/
_onClickRight(event) {
const ruler = canvas.controls.ruler;
if ( ruler.active ) return ruler._onClickRight(event);
// Dispatch to the active layer
const layer = this.activeLayer;
if ( layer instanceof InteractionLayer ) layer._onClickRight(event);
}
/* -------------------------------------------- */
/**
* Handle double right-click events occurring on the Canvas.
* @see {MouseInteractionManager##handleClickRight}
* @param {PIXI.FederatedEvent} event
* @private
*/
_onClickRight2(event) {
const layer = this.activeLayer;
if ( layer instanceof InteractionLayer ) layer._onClickRight2(event);
}
/* -------------------------------------------- */
/**
* Handle right-mouse drag events occurring on the Canvas.
* @see {MouseInteractionManager##handleDragMove}
* @param {PIXI.FederatedEvent} event
* @private
*/
_onDragRightMove(event) {
// Extract event data
const cursorTime = event.interactionData.cursorTime;
const {origin, destination} = event.interactionData;
const dx = destination.x - origin.x;
const dy = destination.y - origin.y;
// Update the client's cursor position every 100ms
const now = Date.now();
if ( (now - (cursorTime || 0)) > 100 ) {
if ( this.controls ) this.controls._onMouseMove(event, destination);
event.interactionData.cursorTime = now;
}
// 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
* @private
*/
_onDragRightDrop(event) {}
/* -------------------------------------------- */
/**
* Determine selection coordinate rectangle during a mouse-drag workflow
* @param {PIXI.FederatedEvent} event
* @private
*/
_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] = [event.clientX, event.clientY];
const t = this.stage.worldTransform;
data.x = (x - t.tx) / canvas.stage.scale.x;
data.y = (y - t.ty) / canvas.stage.scale.y;
/**
* A hook event that fires when some useful data is dropped onto the
* Canvas.
* @function dropCanvasData
* @memberof hookEvents
* @param {Canvas} canvas The Canvas
* @param {object} data The data that has been dropped onto the Canvas
*/
const allowed = Hooks.call("dropCanvasData", this, data);
if ( allowed === false ) return;
// Handle different data types
switch ( data.type ) {
case "Actor":
return canvas.tokens._onDropActorData(event, data);
case "JournalEntry": case "JournalEntryPage":
return canvas.notes._onDropData(event, data);
case "Macro":
return game.user.assignHotbarMacro(null, Number(data.slot));
case "PlaylistSound":
return canvas.sounds._onDropData(event, data);
case "Tile":
return canvas.tiles._onDropData(event, data);
}
}
/* -------------------------------------------- */
/* Pre-Rendering Workflow */
/* -------------------------------------------- */
/**
* Track objects which have pending render flags.
* @enum {Set<RenderFlagObject>}
*/
pendingRenderFlags;
/**
* Cached references to bound ticker functions which can be removed later.
* @type {Object<Function>}
*/
#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,
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);
canvas.app.ticker.add(this.#tickerFunctions.OBJECTS, undefined, p.OBJECTS);
// Update Perception
this.#tickerFunctions.PERCEPTION = this.#applyRenderFlags.bind(this, this.pendingRenderFlags.PERCEPTION);
canvas.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<RenderFlagObject>} 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 v10
* @ignore
*/
get blurDistance() {
const msg = "canvas.blurDistance is deprecated in favor of canvas.blur.strength";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return this.blur.strength;
}
/**
* @deprecated since v10
* @ignore
*/
set blurDistance(value) {
const msg = "Setting canvas.blurDistance is replaced by setting canvas.blur.strength";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
this.blur.strength = value;
}
/**
* @deprecated since v10
* @ignore
*/
activateLayer(layerName) {
const msg = "Canvas#activateLayer is deprecated in favor of CanvasLayer#activate";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
this[layerName].activate();
}
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
static getDimensions(scene) {
const msg = "Canvas.getDimensions is deprecated in favor of Scene#getDimensions";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return scene.getDimensions();
}
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
setBackgroundColor(color) {
const msg = "Canvas#setBackgroundColor is deprecated in favor of Canvas#colorManager#initialize";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
this.#colorManager.initialize({backgroundColor: color});
}
/* -------------------------------------------- */
/**
* @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 [];
}
}
// Temporary workaround until PIXI 7.3.0 (pixijs/pixijs#9441)
if ( !isNewerVersion("7.3.0", PIXI.VERSION) ) throw new Error("REMOVE THIS CODE");
PIXI.BaseImageResource.prototype.upload = function(renderer, baseTexture, glTexture, source) {
const gl = renderer.gl;
const width = baseTexture.realWidth;
const height = baseTexture.realHeight;
source = source || this.source;
if ( typeof HTMLImageElement !== "undefined" && source instanceof HTMLImageElement ) {
if ( !source.complete || source.naturalWidth === 0 ) return false;
} else if ( typeof HTMLVideoElement !== "undefined" && source instanceof HTMLVideoElement ) {
if ( source.readyState <= 1 ) return false;
}
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, baseTexture.alphaMode === PIXI.ALPHA_MODES.UNPACK);
if ( !this.noSubImage
&& baseTexture.target === gl.TEXTURE_2D
&& glTexture.width === width
&& glTexture.height === height ) {
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, baseTexture.format, glTexture.type, source);
} else {
glTexture.width = width;
glTexture.height = height;
gl.texImage2D(baseTexture.target, 0, glTexture.internalFormat, baseTexture.format, glTexture.type, source);
}
return true;
};
// Temporary workaround until PIXI 7.3.0
if ( !isNewerVersion("7.3.0", PIXI.VERSION) ) throw new Error("REMOVE THIS CODE");
PIXI.utils.detectVideoAlphaMode = (() => {
let promise;
return async function()
{
promise ??= (async () =>
{
const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl");
if ( !gl ) {
return PIXI.ALPHA_MODES.UNPACK;
}
const video = await new Promise(resolve => {
const video = document.createElement("video");
video.onloadeddata = () => resolve(video);
video.onerror = () => resolve(null);
video.autoplay = false;
video.crossOrigin = "anonymous";
video.preload = "auto";
// eslint-disable-next-line max-len
video.src = "data:video/webm;base64,GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwEAAAAAAAHTEU2bdLpNu4tTq4QVSalmU6yBoU27i1OrhBZUrmtTrIHGTbuMU6uEElTDZ1OsggEXTbuMU6uEHFO7a1OsggG97AEAAAAAAABZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVSalmoCrXsYMPQkBNgIRMYXZmV0GETGF2ZkSJiEBEAAAAAAAAFlSua8yuAQAAAAAAAEPXgQFzxYgAAAAAAAAAAZyBACK1nIN1bmSIgQCGhVZfVlA5g4EBI+ODhAJiWgDglLCBArqBApqBAlPAgQFVsIRVuYEBElTDZ9Vzc9JjwItjxYgAAAAAAAAAAWfInEWjh0VOQ09ERVJEh49MYXZjIGxpYnZweC12cDlnyKJFo4hEVVJBVElPTkSHlDAwOjAwOjAwLjA0MDAwMDAwMAAAH0O2dcfngQCgwqGggQAAAIJJg0IAABAAFgA4JBwYSgAAICAAEb///4r+AAB1oZ2mm+6BAaWWgkmDQgAAEAAWADgkHBhKAAAgIABIQBxTu2uRu4+zgQC3iveBAfGCAXHwgQM=";
video.load();
});
if ( !video ) {
return PIXI.ALPHA_MODES.UNPACK;
}
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
const framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
texture,
0
);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video);
const pixel = new Uint8Array(4);
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
gl.deleteFramebuffer(framebuffer);
gl.deleteTexture(texture);
gl.getExtension("WEBGL_lose_context")?.loseContext();
return pixel[0] <= pixel[3] ? PIXI.ALPHA_MODES.PMA : PIXI.ALPHA_MODES.UNPACK;
})();
return promise;
};
})();
// Temporary workaround until PIXI 7.3.0
if ( !isNewerVersion("7.3.0", PIXI.VERSION) ) throw new Error("REMOVE THIS CODE");
PIXI.loadVideo = {
name: "loadVideo",
extension: {
type: PIXI.ExtensionType.LoadParser,
priority: PIXI.LoaderParserPriority.High
},
config: {
defaultAutoPlay: true
},
test: url => PIXI.checkDataUrl(url, Object.values(CONST.VIDEO_FILE_EXTENSIONS))
|| PIXI.checkExtension(url, Object.keys(CONST.VIDEO_FILE_EXTENSIONS).map(ext => `.${ext}`)),
load: async function(url, asset, loader) {
let texture;
const response = await fetch(url);
const blob = await response.blob();
const objectURL = URL.createObjectURL(blob);
const video = document.createElement("video");
try {
await new Promise((resolve, reject) => {
video.oncanplay = () => {
video.oncanplay = null;
video.onerror = null;
resolve();
};
video.onerror = error => {
video.oncanplay = null;
video.onerror = null;
reject(error);
};
video.autoplay = false;
video.crossOrigin = "anonymous";
video.preload = "auto";
video.src = objectURL;
video.load();
});
const src = new PIXI.VideoResource(video, {
autoPlay: this.config.defaultAutoPlay,
...asset?.data?.resourceOptions
});
await src.load();
const base = new PIXI.BaseTexture(src, {
alphaMode: await PIXI.utils.detectVideoAlphaMode(),
resolution: PIXI.utils.getResolutionOfUrl(url),
...asset?.data
});
base.resource.internal = true;
base.resource.src = url;
texture = new PIXI.Texture(base);
texture.baseTexture.on("dispose", () => {
delete loader.promiseCache[url];
URL.revokeObjectURL(objectURL);
});
} catch(e) {
URL.revokeObjectURL(objectURL);
throw e;
}
return texture;
},
unload: async texture => texture.destroy(true)
};
PIXI.extensions.add({
extension: PIXI.ExtensionType.Asset,
detection: {
test: async () => true,
add: async formats => [...formats, ...Object.keys(CONST.VIDEO_FILE_EXTENSIONS)],
remove: async formats => formats.filter(format => !(format in CONST.VIDEO_FILE_EXTENSIONS))
},
loader: PIXI.loadVideo
});
// Temporary workaround until PIXI 7.3.0
if ( !isNewerVersion("7.3.0", PIXI.VERSION) ) throw new Error("REMOVE THIS CODE");
PIXI.VideoResource.prototype.load = function() {
if (this._load) {
return this._load;
}
const source = this.source;
if ((source.readyState === source.HAVE_ENOUGH_DATA || source.readyState === source.HAVE_FUTURE_DATA)
&& source.width && source.height) {
source.complete = true;
}
source.addEventListener("play", this._onPlayStart.bind(this));
source.addEventListener("pause", this._onPlayStop.bind(this));
source.addEventListener("seeked", this._onSeeked.bind(this));
if (!this._isSourceReady()) {
source.addEventListener("canplay", this._onCanPlay);
source.addEventListener("canplaythrough", this._onCanPlay);
source.addEventListener("error", this._onError, true);
} else {
this._onCanPlay();
}
this._load = new Promise(resolve => {
if (this.valid) {
resolve(this);
} else {
this._resolve = resolve;
source.load();
}
});
return this._load;
};
PIXI.VideoResource.prototype._onSeeked = function() {
if (this._autoUpdate && !this._isSourcePlaying()) {
this._msToNextUpdate = 0;
this.update();
this._msToNextUpdate = 0;
}
};
/**
* 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;
/**
* The underlying data object which provides the basis for this placeable object
* @type {abstract.DataModel}
*/
Object.defineProperty(this, "data", {
get: () => {
const msg = "You are accessing PlaceableObject#data which is no longer used and instead the Document class "
+ "should be referenced directly as PlaceableObject#document.";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return document;
}
});
/**
* Track the field of vision for the placeable object.
* This is necessary to determine whether a player has line-of-sight towards a placeable object or vice-versa
* @type {{fov: PIXI.Circle, los: PointSourcePolygon}}
*/
this.vision = {fov: undefined, los: undefined};
/**
* A control icon for interacting with the object
* @type {ControlIcon}
*/
this.controlIcon = null;
/**
* A mouse interaction manager instance which handles mouse workflows related to this object.
* @type {MouseInteractionManager}
*/
this.mouseInteractionManager = null;
// Allow objects to be culled when off-screen
this.cullable = true;
}
/* -------------------------------------------- */
/* 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: {}
};
/**
* Passthrough certain drag operations on locked objects.
* @type {boolean}
* @protected
*/
_dragPassthrough = false;
/**
* Know if a placeable is in the hover-in state.
* @type {boolean}
* @protected
*/
_isHoverIn = false;
/**
* 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<PlaceableObject>}
*/
#drawing = Promise.resolve(this);
/**
* Has this Placeable Object been drawn and is there no drawing in progress?
* @type {boolean}
*/
#drawn = false;
/* -------------------------------------------- */
/**
* 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
* @returns {Rectangle}
*/
get bounds() {
throw new Error("Each subclass of PlaceableObject must define its own bounds rectangle");
}
/* -------------------------------------------- */
/**
* The central coordinate pair of the placeable object based on it's own width and height
* @type {PIXI.Point}
*/
get center() {
const d = this.document;
if ( ("width" in d) && ("height" in d) ) {
return new PIXI.Point(d.x + (d.width / 2), d.y + (d.height / 2));
}
return new PIXI.Point(d.x, d.y);
}
/* -------------------------------------------- */
/**
* The id of the corresponding Document which this PlaceableObject represents.
* @type {string}
*/
get id() {
return this.document.id;
}
/* -------------------------------------------- */
/**
* A unique identifier which is used to uniquely identify elements on the canvas related to this object.
* @type {string}
*/
get objectId() {
let id = `${this.document.documentName}.${this.document.id}`;
if ( this.isPreview ) id += ".preview";
return id;
}
/* -------------------------------------------- */
/**
* The named identified for the source object associated with this PlaceableObject.
* This differs from the objectId because the sourceId is the same for preview objects as for the original.
* @type {string}
*/
get sourceId() {
return `${this.document.documentName}.${this._original?.id ?? this.document.id ?? "preview"}`;
}
/* -------------------------------------------- */
/**
* Is this placeable object a temporary preview?
* @type {boolean}
*/
get isPreview() {
return !!this._original || !this.document.id;
}
/* -------------------------------------------- */
/**
* Does there exist a temporary preview of this placeable object?
* @type {boolean}
*/
get hasPreview() {
return !!this._preview;
}
/* -------------------------------------------- */
/**
* The field-of-vision polygon for the object, if it has been computed
* @type {PIXI.Circle}
*/
get fov() {
return this.vision.fov;
}
/* -------------------------------------------- */
/**
* Provide a reference to the CanvasLayer which contains this PlaceableObject.
* @type {PlaceablesLayer}
*/
get layer() {
return this.document.layer;
}
/* -------------------------------------------- */
/**
* The line-of-sight polygon for the object, if it has been computed
* @type {PointSourcePolygon|null}
*/
get los() {
return this.vision.los;
}
/* -------------------------------------------- */
/**
* A Form Application which is used to configure the properties of this Placeable Object or the Document it
* represents.
* @type {FormApplication}
*/
get sheet() {
return this.document.sheet;
}
/**
* An indicator for whether the object is currently controlled
* @type {boolean}
*/
get controlled() {
return this.#controlled;
}
#controlled = false;
/* -------------------------------------------- */
/**
* An indicator for whether the object is currently a hover target
* @type {boolean}
*/
get hover() {
return this.#hover;
}
set hover(state) {
this.#hover = typeof state === "boolean" ? state : false;
}
#hover = false;
/* -------------------------------------------- */
/* 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 {Object<boolean>} 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) {
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<PlaceableObject>} 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();
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;
});
}
/**
* 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`);
}
/* -------------------------------------------- */
/**
* 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});
}
}
/* -------------------------------------------- */
/**
* 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} data
* @param {object} options
* @param {string} userId
* @protected
*/
_onUpdate(data, options, userId) {
this._updateQuadtree();
}
/* -------------------------------------------- */
/**
* 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 = this._isHoverIn = false;
if ( this === layer.hover ) layer.hover = null;
if ( layer.hud && (layer.hud.object === this) ) 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<PlaceableObject>} The rotated object
*/
async rotate(angle, snap) {
if ( this.document.rotation === undefined ) return this;
const rotation = this._updateRotation({angle, snap});
this.layer.hud?.clear();
await this.document.update({rotation});
return this;
}
/* -------------------------------------------- */
/**
* Determine a new angle of rotation for a PlaceableObject either from an explicit angle or from a delta offset.
* @param {object} options An object which defines the rotation update parameters
* @param {number} [options.angle] An explicit angle, either this or delta must be provided
* @param {number} [options.delta=0] A relative angle delta, either this or the angle must be provided
* @param {number} [options.snap=0] A precision (in degrees) to which the resulting angle should snap. Default is 0.
* @returns {number} The new rotation angle for the object
*/
_updateRotation({angle, delta=0, snap=0}={}) {
let degrees = Number.isNumeric(angle) ? angle : this.document.rotation + delta;
if ( snap > 0 ) degrees = degrees.toNearest(snap);
let isHexRow = [CONST.GRID_TYPES.HEXODDR, CONST.GRID_TYPES.HEXEVENR].includes(canvas.grid.type);
const offset = isHexRow && (snap > 30) ? 30 : 0;
return Math.normalizeDegrees(degrees - offset);
}
/* -------------------------------------------- */
/**
* Obtain a shifted position for the Placeable Object
* @param {number} dx The number of grid units to shift along the X-axis
* @param {number} dy The number of grid units to shift along the Y-axis
* @returns {{x:number, y:number}} The shifted target coordinates
*/
_getShiftedPosition(dx, dy) {
let [x, y] = canvas.grid.grid.shiftPosition(this.document.x, this.document.y, dx, dy);
return {x, y};
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
/**
* Activate interactivity for the Placeable Object
*/
activateListeners() {
const mgr = this._createInteractionManager();
this.mouseInteractionManager = mgr.activate();
}
/* -------------------------------------------- */
/**
* Create a standard MouseInteractionManager for the PlaceableObject
* @protected
*/
_createInteractionManager() {
// Handle permissions to perform various actions
const permissions = {
hoverIn: this._canHover,
clickLeft: this._canControl,
clickLeft2: this._canView,
clickRight: this._canHUD,
clickRight2: this._canConfigure,
dragStart: this._canDrag
};
// Define callback functions for each workflow step
const callbacks = {
hoverIn: this._onHoverIn,
hoverOut: this._onHoverOut,
clickLeft: this._onClickLeft,
clickLeft2: this._onClickLeft2,
clickRight: this._onClickRight,
clickRight2: this._onClickRight2,
dragLeftStart: this._onDragLeftStart,
dragLeftMove: this._onDragLeftMove,
dragLeftDrop: this._onDragLeftDrop,
dragLeftCancel: this._onDragLeftCancel,
dragRightStart: null,
dragRightMove: null,
dragRightDrop: null,
dragRightCancel: null,
longPress: this._onLongPress
};
// Define options
const options = { target: this.controlIcon ? "controlIcon" : null };
// Create the interaction manager
return new MouseInteractionManager(this, canvas.stage, permissions, callbacks, options);
}
/* -------------------------------------------- */
/**
* Test whether a user can perform a certain interaction regarding a Placeable Object
* @param {User} user The User performing the action
* @param {string} action The named action being attempted
* @returns {boolean} Does the User have rights to perform the action?
*/
can(user, action) {
const fn = this[`_can${action.titleCase()}`];
return fn ? fn.call(this, user) : false;
}
/* -------------------------------------------- */
/**
* Can the User access the HUD for this Placeable Object?
* @param {User} user The User performing the action.
* @param {object} event The event object.
* @returns {boolean} The returned status.
* @protected
*/
_canHUD(user, event) {
return this.controlled;
}
/* -------------------------------------------- */
/**
* Does the User have permission to configure the Placeable Object?
* @param {User} user The User performing the action.
* @param {object} event The event object.
* @returns {boolean} The returned status.
* @protected
*/
_canConfigure(user, event) {
return this.document.canUserModify(user, "update");
}
/* -------------------------------------------- */
/**
* Does the User have permission to control the Placeable Object?
* @param {User} user The User performing the action.
* @param {object} event The event object.
* @returns {boolean} The returned status.
* @protected
*/
_canControl(user, event) {
if ( !this.layer.active || 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 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##handleMouseOver
* @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
* @returns {boolean} True if the event was handled, otherwise false
* @protected
*/
_onHoverIn(event, {hoverOutOthers=false}={}) {
const layer = this.layer;
if ( event.buttons & 0x03 ) return; // Returning if hovering is happening with pressed left or right button
if ( !this.document.locked ) this._isHoverIn = true;
if ( this.hover || this.document.locked ) return;
// Handle the event
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##handleMouseOut
* @param {PIXI.FederatedEvent} event The triggering canvas interaction event
* @returns {boolean} True if the event was handled, otherwise false
* @protected
*/
_onHoverOut(event) {
const layer = this.layer;
if ( !this.document.locked ) this._isHoverIn = false;
if ( !this.hover || this.document.locked ) return;
// Handle the event
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) {
const hud = this.layer.hud;
if ( hud ) hud.clear();
// Add or remove the Placeable Object from the currently controlled set
if ( this.#controlled ) {
if ( event.shiftKey ) this.release();
}
else this.control({releaseOthers: !event.shiftKey});
// 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) {
const hud = this.layer.hud;
if ( hud ) {
const releaseOthers = !this.#controlled && !event.shiftKey;
this.control({releaseOthers});
if ( hud.object === this) hud.clear();
else hud.bind(this);
}
// Propagate the 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) {
if ( this.document.locked ) {
this._dragPassthrough = true;
return canvas._onDragLeftStart(event);
}
const objects = this.layer.options.controllableObjects ? this.layer.controlled : [this];
const clones = [];
for ( let o of objects ) {
if ( o.document.locked ) continue;
// Clone the object
const c = o.clone();
clones.push(c);
// Draw the clone
c.draw().then(c => {
this.layer.preview.addChild(c);
c._onDragStart();
});
}
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({refresh: true});
this.visible = true;
}
/* -------------------------------------------- */
/**
* Conclude a drag operation from the perspective of the preview clone.
* Modify the appearance of both the clone (this) and the original (_original) object.
* @protected
*/
_onDragEnd() {
this.visible = false;
const o = this._original;
if ( o ) {
o.document.locked = false;
o.renderFlags.set({refresh: 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) {
if ( this._dragPassthrough ) return canvas._onDragLeftMove(event);
const {clones, destination, origin} = event.interactionData;
canvas._onDragCanvasPan(event);
const dx = destination.x - origin.x;
const dy = destination.y - origin.y;
for ( let c of clones || [] ) {
c.document.x = c._original.document.x + dx;
c.document.y = c._original.document.y + dy;
c.renderFlags.set({refresh: true}); // Refresh everything. Can we do better?
}
}
/* -------------------------------------------- */
/**
* Callback actions which occur on a mouse-move operation.
* @see MouseInteractionManager##handleDragDrop
* @param {PIXI.FederatedEvent} event The triggering canvas interaction event
* @returns {Promise<*>}
* @protected
*/
async _onDragLeftDrop(event) {
if ( this._dragPassthrough ) {
this._dragPassthrough = false;
return canvas._onDragLeftDrop(event);
}
const {clones, destination} = event.interactionData;
if ( !clones || !canvas.dimensions.rect.contains(destination.x, destination.y) ) return false;
event.interactionData.clearPreviewContainer = false;
const updates = clones.map(c => {
let dest = {x: c.document.x, y: c.document.y};
if ( !event.shiftKey ) {
dest = canvas.grid.getSnappedPosition(c.document.x, c.document.y, this.layer.gridPrecision);
}
return {_id: c._original.id, x: dest.x, y: dest.y, rotation: c.document.rotation};
});
try {
return await canvas.scene.updateEmbeddedDocuments(this.document.documentName, updates);
} finally {
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 ( this._dragPassthrough ) {
this._dragPassthrough = false;
return canvas._onDragLeftCancel(event);
}
if ( event.interactionData.clearPreviewContainer !== false ) {
this.layer.clearPreviewContainer();
}
}
/* -------------------------------------------- */
/**
* 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<PIXI.BaseTexture|PIXI.Spritesheet,{src:string,time:number}>}
*/
static #cacheTime = new Map();
/**
* A mapping of url to cached texture buffer data
* @type {Map<string,object>}
*/
static textureBufferDataMap = new Map();
/**
* Create a fixed retry string to use for CORS retries.
* @type {string}
*/
static #retryString = Date.now().toString();
/* -------------------------------------------- */
/**
* 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);
}
/* -------------------------------------------- */
/**
* 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<void[]>}
*/
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.fogOverlay ) toLoad.push(scene.fogOverlay);
// Tiles
toLoad = toLoad.concat(scene.tiles.reduce((arr, t) => {
if ( t.texture.src ) arr.push(t.texture.src);
return arr;
}, []));
// Tokens
toLoad = toLoad.concat(scene.tokens.reduce((arr, t) => {
if ( t.texture.src ) arr.push(t.texture.src);
return arr;
}, []));
// Control Icons
toLoad = toLoad.concat(Object.values(CONFIG.controlIcons)).concat(CONFIG.statusEffects.map(e => e.icon ?? e));
// 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.
* @returns {Promise<void[]>} A Promise which resolves once all textures are loaded
*/
async load(sources, {message, expireCache=false, maxConcurrent}={}) {
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);
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<PIXI.BaseTexture|PIXI.Spritesheet|null>} 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<Blob>} 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);
}
}
/**
* 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<boolean>} 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 BaseCanvasMixin} A ContainerClass subclass mixed with BaseCanvasMixin features.
*/
const BaseCanvasMixin = ContainerClass => {
return class BaseCanvasMixin extends ContainerClass {
constructor(...args) {
super(...args);
this.sortableChildren = true;
this.layers = this.#createLayers();
}
/**
* The name of this canvas group.
* @type {string}
* @abstract
*/
static groupName;
/**
* If this canvas group should teardown non-layers children.
* @type {boolean}
*/
static tearDownChildren = true;
/**
* A mapping of CanvasLayer classes which belong to this group.
* @type {Object<CanvasLayer>}
*/
layers;
/* -------------------------------------------- */
/**
* Create CanvasLayer instances which belong to the primary group.
* @private
*/
#createLayers() {
const layers = {};
for ( let [name, config] of Object.entries(CONFIG.Canvas.layers) ) {
if ( config.group !== this.constructor.groupName ) continue;
const layer = layers[name] = new config.layerClass();
Object.defineProperty(this, name, {value: layer, writable: false});
Object.defineProperty(canvas, name, {value: layer, writable: false});
}
return layers;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* Draw the canvas group and all its component layers.
* @returns {Promise<void>}
*/
async draw() {
// Draw CanvasLayer instances
for ( const layer of Object.values(this.layers) ) {
this.addChild(layer);
await layer.draw();
}
}
/* -------------------------------------------- */
/* Tear-Down */
/* -------------------------------------------- */
/**
* Remove and destroy all layers from the base canvas.
* @param {object} [options={}]
* @returns {Promise<void>}
*/
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});
}
}
};
};
/**
* 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;
/**
* An map of render textures, linked to their render function and an optional RGBA clear color.
* @type {Map<PIXI.RenderTexture,Object<Function, number[]>>}
* @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;
/* ---------------------------------------- */
/**
* 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 ?? window.innerWidth,
height: renderer?.screen.height ?? window.innerHeight,
resolution: renderer.resolution ?? PIXI.settings.RESOLUTION,
multisample: conf.multisample ?? PIXI.MSAA_QUALITY.NONE,
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
// Return a reference to the render texture
return renderTexture;
}
/* ---------------------------------------- */
/**
* Remove a previously created render texture.
* @param {PIXI.RenderTexture} renderTexture The render texture to remove.
* @param {boolean} [destroy=true] Should the render texture be destroyed?
*/
removeRenderTexture(renderTexture, destroy=true) {
this._renderPaths.delete(renderTexture);
if ( destroy ) renderTexture?.destroy(true);
}
/* ---------------------------------------- */
/**
* Clear the cached container, removing its current contents.
* @param {boolean} [destroy=true] Tell children that we should destroy texture as well.
* @returns {CachedContainer} A reference to the cleared container for chaining.
*/
clear(destroy=true) {
Canvas.clearContainer(this, destroy);
return this;
}
/* ---------------------------------------- */
/** @inheritdoc */
destroy(options) {
if ( this.#onResize ) canvas.app.renderer.off("resize", this.#onResize);
for ( const [rt] of this._renderPaths ) rt?.destroy(true);
this._renderPaths.clear();
super.destroy(options);
}
/* ---------------------------------------- */
/** @inheritdoc */
render(renderer) {
if ( !this.renderable ) return; // Skip updating the cached texture
this.#bindPrimaryBuffer(renderer); // Bind the primary buffer (RT)
super.render(renderer); // Draw into the primary buffer
this.#renderSecondary(renderer); // Draw into the secondary buffer(s)
this.#bindOriginalBuffer(renderer); // Restore the original buffer
this.#sprite?.render(renderer); // Render the bound sprite
if ( this.displayed ) super.render(renderer); // Optionally draw to the screen
}
/* ---------------------------------------- */
/**
* Custom rendering for secondary render textures
* @param {PIXI.Renderer} renderer The active canvas renderer.
* @protected
*/
#renderSecondary(renderer) {
if ( this._renderPaths.size <= 1 ) return;
// Bind the render texture and call the custom render method for each render path
for ( const [rt, ro] of this._renderPaths ) {
if ( !ro.renderFunction ) continue;
this.#bind(renderer, rt, ro.clearColor);
ro.renderFunction.call(this, renderer);
}
}
/* ---------------------------------------- */
/**
* Bind the primary render texture to the renderer, replacing and saving the original buffer and source frame.
* @param {PIXI.Renderer} renderer The active canvas renderer.
* @private
*/
#bindPrimaryBuffer(renderer) {
// Get the RenderTexture to bind
const tex = this.renderTexture;
const rt = renderer.renderTexture;
// Backup the current render target
this.#backup.renderTexture = rt.current;
this.#backup.sourceFrame.copyFrom(rt.sourceFrame);
// Bind the render texture
this.#bind(renderer, tex);
}
/* ---------------------------------------- */
/**
* Bind a render texture to this renderer.
* Must be called after bindPrimaryBuffer and before bindInitialBuffer.
* @param {PIXI.Renderer} renderer The active canvas renderer.
* @param {PIXI.RenderTexture} tex The texture to bind.
* @param {number[]} [clearColor] A custom clear color.
* @protected
*/
#bind(renderer, tex, clearColor) {
const rt = renderer.renderTexture;
// Bind our texture to the renderer
renderer.batch.flush();
rt.bind(tex, undefined, undefined);
rt.clear(clearColor ?? this.clearColor);
// Enable Filters which are applied to this Container to apply to our cached RenderTexture
const fs = renderer.filter.defaultFilterStack;
if ( fs.length > 1 ) {
fs[fs.length - 1].renderTexture = tex;
}
}
/* ---------------------------------------- */
/**
* Remove the render texture from the Renderer, re-binding the original buffer.
* @param {PIXI.Renderer} renderer The active canvas renderer.
* @private
*/
#bindOriginalBuffer(renderer) {
renderer.batch.flush();
// Restore Filters to apply to the original RenderTexture
const fs = renderer.filter.defaultFilterStack;
if ( fs.length > 1 ) {
fs[fs.length - 1].renderTexture = this.#backup.renderTexture;
}
// Re-bind the original RenderTexture to the renderer
renderer.renderTexture.bind(this.#backup.renderTexture, this.#backup.sourceFrame, undefined);
this.#backup.renderTexture = undefined;
}
/* ---------------------------------------- */
/**
* Resize bound render texture(s) when the dimensions or resolution of the Renderer have changed.
* @param {PIXI.Renderer} renderer The active canvas renderer.
* @private
*/
#resize(renderer) {
for ( const [rt] of this._renderPaths ) CachedContainer.resizeRenderTexture(renderer, rt);
if ( this.#sprite ) this.#sprite._boundsID++; // Inform PIXI that bounds need to be recomputed for this sprite mesh
}
/* ---------------------------------------- */
/**
* Resize a render texture passed as a parameter with the renderer.
* @param {PIXI.Renderer} renderer The active canvas renderer.
* @param {PIXI.RenderTexture} rt The render texture to resize.
*/
static resizeRenderTexture(renderer, rt) {
const screen = renderer?.screen;
if ( !rt || !screen ) return;
if ( rt.baseTexture.resolution !== renderer.resolution ) rt.baseTexture.resolution = renderer.resolution;
if ( (rt.width !== screen.width) || (rt.height !== screen.height) ) rt.resize(screen.width, screen.height);
}
}
/**
* 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 */
addChildren() {
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} shaderCls The shader class to use.
*/
class QuadMesh extends PIXI.Container {
constructor(shaderCls) {
super();
// Create the basic quad geometry
this.geometry = new PIXI.Geometry()
.addAttribute("aVertexPosition", [0, 0, 1, 0, 1, 1, 0, 1], 2)
.addIndex([0, 1, 2, 0, 2, 3]);
// Assign shader, state and properties
if ( !AbstractBaseShader.isPrototypeOf(shaderCls) ) {
throw new Error("QuadMesh shader class must inherit from AbstractBaseShader.");
}
this.state = new PIXI.State();
this.setShaderClass(shaderCls);
this.cullable = true;
this.blendMode = PIXI.BLEND_MODES.NORMAL;
}
/**
* @type {PIXI.Geometry}
* @protected
*/
#geometry;
/**
* The shader.
* @type {BaseSamplerShader}
*/
shader;
/**
* The state.
* @type {PIXI.State}
*/
state;
/* ---------------------------------------- */
/**
* Assigned geometry to this mesh.
* We need to handle the refCount.
* @type {PIXI.Geometry}
*/
get geometry() {
return this.#geometry;
}
set geometry(value) {
// Same geometry?
if ( this.#geometry === value ) return;
// Unlink previous geometry and update refCount
if ( this.#geometry ) {
this.#geometry.refCount--;
// Dispose geometry if necessary
if ( this.#geometry.refCount === 0 ) this.#geometry.dispose();
}
// Link geometry and update refCount
this.#geometry = value;
if ( this.#geometry ) this.#geometry.refCount++;
}
/* ---------------------------------------- */
/**
* Assigned blend mode to this mesh.
* @type {PIXI.BLEND_MODES}
*/
get blendMode() {
return this.state.blendMode;
}
set blendMode(value) {
this.state.blendMode = value;
}
/* ---------------------------------------- */
/**
* Initialize shader based on the shader class type.
* @param {class} shaderCls Shader class used. Must inherit from AbstractBaseShader.
*/
setShaderClass(shaderCls) {
// Escape conditions
if ( !AbstractBaseShader.isPrototypeOf(shaderCls) ) {
throw new Error("QuadMesh shader class must inherit from AbstractBaseShader.");
}
if ( this.shader?.constructor === shaderCls ) return;
// Create shader program
this.shader = shaderCls.create();
}
/* ---------------------------------------- */
/** @override */
_render(renderer) {
const {geometry, shader, state} = this;
shader._preRender?.(this);
shader.uniforms.translationMatrix = this.transform.worldTransform.toArray(true);
// Flush batch renderer
renderer.batch.flush();
// Set state
renderer.state.set(state);
// Bind shader and geometry
renderer.shader.bind(shader);
renderer.geometry.bind(geometry, shader);
// Draw the geometry
renderer.geometry.draw(PIXI.DRAW_MODES.TRIANGLES);
}
/* ---------------------------------------- */
/** @override */
get width() {
return Math.abs(this.scale.x);
}
set width(width) {
const s = Math.sign(this.scale.x) || 1;
this.scale.x = s * width;
this._width = width;
}
_width;
/* ---------------------------------------- */
/** @override */
get height() {
return Math.abs(this.scale.y);
}
set height(height) {
const s = Math.sign(this.scale.y) || 1;
this.scale.y = s * height;
this._height = height;
}
_height;
/* ---------------------------------------- */
/** @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(...args) {
super.destroy(...args);
this.geometry = null;
this.shader = null;
this.state = null;
}
}
/**
* @typedef {object} QuadtreeObject
* @property {Rectangle} r
* @property {*} t
* @property {Set<Quadtree>} [n]
*/
/**
* A Quadtree implementation that supports collision detection for rectangles.
*
* @param {Rectangle} bounds The outer bounds of the region
* @param {object} [options] Additional options which configure the Quadtree
* @param {number} [options.maxObjects=20] The maximum number of objects per node
* @param {number} [options.maxDepth=4] The maximum number of levels within the root Quadtree
* @param {number} [options._depth=0] The depth level of the sub-tree. For internal use
* @param {number} [options._root] The root of the quadtree. For internal use
*/
class Quadtree {
constructor(bounds, {maxObjects=20, maxDepth=4, _depth=0, _root}={}) {
/**
* The bounding rectangle of the region
* @type {Rectangle}
*/
this.bounds = bounds;
/**
* The maximum number of objects allowed within this node before it must split
* @type {number}
*/
this.maxObjects = maxObjects;
/**
* The maximum number of levels that the base quadtree is allowed
* @type {number}
*/
this.maxDepth = maxDepth;
/**
* The depth of this node within the root Quadtree
* @type {number}
*/
this.depth = _depth;
/**
* The objects contained at this level of the tree
* @type {QuadtreeObject[]}
*/
this.objects = [];
/**
* Children of this node
* @type {Quadtree[]}
*/
this.nodes = [];
/**
* The root Quadtree
* @type {Quadtree}
*/
this.root = _root || this;
}
/**
* A constant that enumerates the index order of the quadtree nodes from top-left to bottom-right.
* @enum {number}
*/
static INDICES = {tl: 0, tr: 1, bl: 2, br: 3};
/* -------------------------------------------- */
/**
* Return an array of all the objects in the Quadtree (recursive)
* @returns {QuadtreeObject[]}
*/
get all() {
if ( this.nodes.length ) {
return this.nodes.reduce((arr, n) => arr.concat(n.all), []);
}
return this.objects;
}
/* -------------------------------------------- */
/* Tree Management */
/* -------------------------------------------- */
/**
* Split this node into 4 sub-nodes.
* @returns {Quadtree} The split Quadtree
*/
split() {
const b = this.bounds;
const w = b.width / 2;
const h = b.height / 2;
const options = {
maxObjects: this.maxObjects,
maxDepth: this.maxDepth,
_depth: this.depth + 1,
_root: this.root
};
// Create child quadrants
this.nodes[Quadtree.INDICES.tl] = new Quadtree(new PIXI.Rectangle(b.x, b.y, w, h), options);
this.nodes[Quadtree.INDICES.tr] = new Quadtree(new PIXI.Rectangle(b.x+w, b.y, w, h), options);
this.nodes[Quadtree.INDICES.bl] = new Quadtree(new PIXI.Rectangle(b.x, b.y+h, w, h), options);
this.nodes[Quadtree.INDICES.br] = new Quadtree(new PIXI.Rectangle(b.x+w, b.y+h, w, h), options);
// Assign current objects to child nodes
for ( let o of this.objects ) {
o.n.delete(this);
this.insert(o);
}
this.objects = [];
return this;
}
/* -------------------------------------------- */
/* Object Management */
/* -------------------------------------------- */
/**
* Clear the quadtree of all existing contents
* @returns {Quadtree} The cleared Quadtree
*/
clear() {
this.objects = [];
for ( let n of this.nodes ) {
n.clear();
}
this.nodes = [];
return this;
}
/* -------------------------------------------- */
/**
* Add a rectangle object to the tree
* @param {QuadtreeObject} obj The object being inserted
* @returns {Quadtree[]} The Quadtree nodes the object was added to.
*/
insert(obj) {
obj.n = obj.n || new Set();
// If we will exceeded the maximum objects we need to split
if ( (this.objects.length === this.maxObjects - 1) && (this.depth < this.maxDepth) ) {
if ( !this.nodes.length ) this.split();
}
// If this node has children, recursively insert
if ( this.nodes.length ) {
let nodes = this.getChildNodes(obj.r);
return nodes.reduce((arr, n) => arr.concat(n.insert(obj)), []);
}
// Otherwise store the object here
obj.n.add(this);
this.objects.push(obj);
return [this];
}
/* -------------------------------------------- */
/**
* Remove an object from the quadtree
* @param {*} target The quadtree target being removed
* @returns {Quadtree} The Quadtree for method chaining
*/
remove(target) {
this.objects.findSplice(o => o.t === target);
for ( let n of this.nodes ) {
n.remove(target);
}
return this;
}
/* -------------------------------------------- */
/**
* Remove an existing object from the quadtree and re-insert it with a new position
* @param {QuadtreeObject} obj The object being inserted
* @returns {Quadtree[]} The Quadtree nodes the object was added to
*/
update(obj) {
this.remove(obj.t);
return this.insert(obj);
}
/* -------------------------------------------- */
/* Target Identification */
/* -------------------------------------------- */
/**
* Get all the objects which could collide with the provided rectangle
* @param {Rectangle} rect The normalized target rectangle
* @param {object} [options] Options affecting the collision test.
* @param {Function} [options.collisionTest] Function to further refine objects to return
* after a potential collision is found. Parameters are the object and rect, and the
* function should return true if the object should be added to the result set.
* @param {Set} [options._s] The existing result set, for internal use.
* @returns {Set} The objects in the Quadtree which represent potential collisions
*/
getObjects(rect, { collisionTest, _s } = {}) {
const objects = _s || new Set();
// Recursively retrieve objects from child nodes
if ( this.nodes.length ) {
const nodes = this.getChildNodes(rect);
for ( let n of nodes ) {
n.getObjects(rect, {collisionTest, _s: objects});
}
}
// Otherwise, retrieve from this node
else {
for ( let o of this.objects) {
if ( rect.overlaps(o.r) && (!collisionTest || collisionTest(o, rect)) ) objects.add(o.t);
}
}
// Return the result set
return objects;
}
/* -------------------------------------------- */
/**
* Obtain the leaf nodes to which a target rectangle belongs.
* This traverses the quadtree recursively obtaining the final nodes which have no children.
* @param {Rectangle} rect The target rectangle.
* @returns {Quadtree[]} The Quadtree nodes to which the target rectangle belongs
*/
getLeafNodes(rect) {
if ( !this.nodes.length ) return [this];
const nodes = this.getChildNodes(rect);
return nodes.reduce((arr, n) => arr.concat(n.getLeafNodes(rect)), []);
}
/* -------------------------------------------- */
/**
* Obtain the child nodes within the current node which a rectangle belongs to.
* Note that this function is not recursive, it only returns nodes at the current or child level.
* @param {Rectangle} rect The target rectangle.
* @returns {Quadtree[]} The Quadtree nodes to which the target rectangle belongs
*/
getChildNodes(rect) {
// If this node has no children, use it
if ( !this.nodes.length ) return [this];
// Prepare data
const nodes = [];
const hx = this.bounds.x + (this.bounds.width / 2);
const hy = this.bounds.y + (this.bounds.height / 2);
// Determine orientation relative to the node
const startTop = rect.y <= hy;
const startLeft = rect.x <= hx;
const endBottom = (rect.y + rect.height) > hy;
const endRight = (rect.x + rect.width) > hx;
// Top-left
if ( startLeft && startTop ) nodes.push(this.nodes[Quadtree.INDICES.tl]);
// Top-right
if ( endRight && startTop ) nodes.push(this.nodes[Quadtree.INDICES.tr]);
// Bottom-left
if ( startLeft && endBottom ) nodes.push(this.nodes[Quadtree.INDICES.bl]);
// Bottom-right
if ( endRight && endBottom ) nodes.push(this.nodes[Quadtree.INDICES.br]);
return nodes;
}
/* -------------------------------------------- */
/**
* Identify all nodes which are adjacent to this one within the parent Quadtree.
* @returns {Quadtree[]}
*/
getAdjacentNodes() {
const bounds = this.bounds.clone().pad(1);
return this.root.getLeafNodes(bounds);
}
/* -------------------------------------------- */
/**
* Visualize the nodes and objects in the quadtree
* @param {boolean} [objects] Visualize the rectangular bounds of objects in the Quadtree. Default is false.
* @private
*/
visualize({objects=false}={}) {
const debug = canvas.controls.debug;
if ( this.depth === 0 ) debug.clear().endFill();
debug.lineStyle(2, 0x00FF00, 0.5).drawRect(this.bounds.x, this.bounds.y, this.bounds.width, this.bounds.height);
if ( objects ) {
for ( let o of this.objects ) {
debug.lineStyle(2, 0xFF0000, 0.5).drawRect(o.r.x, o.r.y, Math.max(o.r.width, 1), Math.max(o.r.height, 1));
}
}
for ( let n of this.nodes ) {
n.visualize({objects});
}
}
}
/* -------------------------------------------- */
/**
* A subclass of Quadtree specifically intended for classifying the location of objects on the game canvas.
*/
class CanvasQuadtree extends Quadtree {
constructor(options={}) {
super({}, options);
Object.defineProperty(this, "bounds", {get: () => canvas.dimensions.rect});
}
}
/**
* An extension of PIXI.Mesh which emulate a PIXI.Sprite with a specific shader.
* @param [texture=PIXI.Texture.EMPTY] Texture bound to this sprite mesh.
* @param [shaderClass=BaseSamplerShader] Shader class used by this sprite mesh.
* @extends PIXI.Mesh
*/
class SpriteMesh extends PIXI.Mesh {
constructor(texture, shaderCls = BaseSamplerShader) {
// Create geometry
const geometry = new PIXI.Geometry()
.addAttribute("aVertexPosition", new PIXI.Buffer(new Float32Array(8), false), 2)
.addAttribute("aTextureCoord", new PIXI.Buffer(new Float32Array(8), true), 2)
.addIndex([0, 1, 2, 0, 2, 3]);
// Create shader program
if ( !AbstractBaseShader.isPrototypeOf(shaderCls) ) shaderCls = BaseSamplerShader;
const shader = shaderCls.create({
sampler: texture ?? PIXI.Texture.EMPTY
});
// Create state
const state = new PIXI.State();
// Init draw mode
const drawMode = PIXI.DRAW_MODES.TRIANGLES;
// Create the mesh
super(geometry, shader, state, drawMode);
/** @override */
this._cachedTint = [1, 1, 1, 1];
// Initialize other data to emulate sprite
this.vertexData = this.verticesBuffer.data;
this.uvs = this.uvBuffer.data;
this.indices = geometry.indexBuffer.data;
this._texture = null;
this._anchor = new PIXI.ObservablePoint(
this._onAnchorUpdate,
this,
(texture ? texture.defaultAnchor.x : 0),
(texture ? texture.defaultAnchor.y : 0)
);
this.texture = texture || PIXI.Texture.EMPTY;
this.alpha = 1;
this.tint = 0xFFFFFF;
this.blendMode = PIXI.BLEND_MODES.NORMAL;
// Assigning some batch data that will not change during the life of this sprite mesh
this._batchData.vertexData = this.vertexData;
this._batchData.indices = this.indices;
this._batchData.uvs = this.uvs;
this._batchData.object = this;
}
/**
* Snapshot of some parameters of this display object to render in batched mode.
* TODO: temporary object until the upstream issue is fixed: https://github.com/pixijs/pixijs/issues/8511
* @type {{_tintRGB: number, _texture: PIXI.Texture, indices: number[],
* uvs: number[], blendMode: PIXI.BLEND_MODES, vertexData: number[], worldAlpha: number}}
* @protected
*/
_batchData = {
_texture: undefined,
vertexData: undefined,
indices: undefined,
uvs: undefined,
worldAlpha: undefined,
_tintRGB: undefined,
blendMode: undefined,
object: undefined
};
/** @override */
_transformID = -1;
/** @override */
_textureID = -1;
/** @override */
_textureTrimmedID = -1;
/** @override */
_transformTrimmedID = -1;
/** @override */
_roundPixels = false; // Forced to false for SpriteMesh
/** @override */
vertexTrimmedData = null;
/** @override */
isSprite = true;
/**
* Used to track a tint or alpha change to execute a recomputation of _cachedTint.
* @type {boolean}
*/
#tintAlphaDirty = true;
/**
* Used to force an alpha mode on this sprite mesh.
* If this property is non null, this value will replace the texture alphaMode when computing color channels.
* Affects how tint, worldAlpha and alpha are computed each others.
* @type {PIXI.ALPHA_MODES|undefined}
*/
get alphaMode() {
return this.#alphaMode ?? this._texture?.baseTexture.alphaMode;
}
set alphaMode(mode) {
if ( this.#alphaMode === mode ) return;
this.#alphaMode = mode;
this.#tintAlphaDirty = true;
}
#alphaMode = null;
/* ---------------------------------------- */
/**
* Returns the SpriteMesh associated batch plugin. By default the returned plugin is that of the associated shader.
* If a plugin is forced, it will returns the forced plugin.
* @type {string}
*/
get pluginName() {
return this.#pluginName ?? this.shader.pluginName;
}
set pluginName(name) {
this.#pluginName = name;
}
#pluginName = null;
/* ---------------------------------------- */
/** @override */
get width() {
return Math.abs(this.scale.x) * this._texture.orig.width;
}
set width(width) {
const s = Math.sign(this.scale.x) || 1;
this.scale.x = s * width / this._texture.orig.width;
this._width = width;
}
_width;
/* ---------------------------------------- */
/** @override */
get height() {
return Math.abs(this.scale.y) * this._texture.orig.height;
}
set height(height) {
const s = Math.sign(this.scale.y) || 1;
this.scale.y = s * height / this._texture.orig.height;
this._height = height;
}
_height;
/* ---------------------------------------- */
/** @override */
get texture() {
return this._texture;
}
set texture(texture) {
texture = texture ?? null;
if ( this._texture === texture ) return;
if ( this._texture ) this._texture.off("update", this._onTextureUpdate, this);
this._texture = texture || PIXI.Texture.EMPTY;
this._textureID = this._textureTrimmedID = -1;
this.#tintAlphaDirty = true;
if ( texture ) {
if ( this._texture.baseTexture.valid ) this._onTextureUpdate();
else this._texture.once("update", this._onTextureUpdate, this);
this.updateUvs();
}
this.shader.uniforms.sampler = this._texture;
}
_texture;
/* ---------------------------------------- */
/** @override */
get anchor() {
return this._anchor;
}
set anchor(anchor) {
this._anchor.copyFrom(anchor);
}
_anchor;
/* ---------------------------------------- */
/** @override */
get tint() {
return this._tintColor.value;
}
set tint(tint) {
tint ??= 0xFFFFFF;
if ( tint === this.tint ) return;
this._tintColor.setValue(tint);
this._tintRGB = this._tintColor.toLittleEndianNumber();
this.#tintAlphaDirty = true;
}
_tintColor = new PIXI.Color(0xFFFFFF);
_tintRGB = 0xFFFFFF;
/* ---------------------------------------- */
/**
* The HTML source element for this SpriteMesh texture.
* @type {HTMLImageElement|HTMLVideoElement|null}
*/
get sourceElement() {
if ( !this.texture.valid ) return null;
return this.texture?.baseTexture.resource?.source || null;
}
/* ---------------------------------------- */
/**
* Is this SpriteMesh rendering a video texture?
* @type {boolean}
*/
get isVideo() {
const source = this.sourceElement;
return source?.tagName === "VIDEO";
}
/* ---------------------------------------- */
/** @override */
_onTextureUpdate() {
this._textureID = this._textureTrimmedID = this._transformID = this._transformTrimmedID = -1;
if ( this._width ) this.scale.x = Math.sign(this.scale.x) * this._width / this._texture.orig.width;
if ( this._height ) this.scale.y = Math.sign(this.scale.y) * this._height / this._texture.orig.height;
// Alpha mode of the texture could have changed
this.#tintAlphaDirty = true;
}
/* ---------------------------------------- */
/** @override */
_onAnchorUpdate() {
this._textureID = this._textureTrimmedID = this._transformID = this._transformTrimmedID = -1;
}
/* ---------------------------------------- */
/**
* Update uvs and push vertices and uv buffers on GPU if necessary.
*/
updateUvs() {
if ( this._textureID !== this._texture._updateID ) {
this.uvs.set(this._texture._uvs.uvsFloat32);
this.uvBuffer.update();
}
}
/* ---------------------------------------- */
/**
* Initialize shader based on the shader class type.
* @param {class} shaderCls Shader class used. Must inherit from AbstractBaseShader.
*/
setShaderClass(shaderCls) {
// Escape conditions
if ( !AbstractBaseShader.isPrototypeOf(shaderCls) ) {
throw new Error("SpriteMesh shader class must inherit from AbstractBaseShader.");
}
if ( this.shader.constructor === shaderCls ) return;
// Create shader program
this.shader = shaderCls.create({
sampler: this.texture ?? PIXI.Texture.EMPTY
});
}
/* ---------------------------------------- */
/** @override */
updateTransform(parentTransform) {
super.updateTransform(parentTransform);
// We set tintAlphaDirty to true if the worldAlpha has changed
// It is needed to recompute the _cachedTint vec4 which is a combination of tint and alpha
if ( this.#worldAlpha !== this.worldAlpha ) {
this.#worldAlpha = this.worldAlpha;
this.#tintAlphaDirty = true;
}
}
#worldAlpha;
/* ---------------------------------------- */
/** @override */
calculateVertices() {
if ( this._transformID === this.transform._worldID && this._textureID === this._texture._updateID ) return;
// Update uvs if necessary
this.updateUvs();
this._transformID = this.transform._worldID;
this._textureID = this._texture._updateID;
// Set the vertex data
const {a, b, c, d, tx, ty} = this.transform.worldTransform;
const orig = this._texture.orig;
const trim = this._texture.trim;
let w1; let w0; let h1; let h0;
if ( trim ) {
// If the sprite is trimmed and is not a tilingsprite then we need to add the extra
// space before transforming the sprite coords
w1 = trim.x - (this._anchor._x * orig.width);
w0 = w1 + trim.width;
h1 = trim.y - (this._anchor._y * orig.height);
h0 = h1 + trim.height;
}
else {
w1 = -this._anchor._x * orig.width;
w0 = w1 + orig.width;
h1 = -this._anchor._y * orig.height;
h0 = h1 + orig.height;
}
this.vertexData[0] = (a * w1) + (c * h1) + tx;
this.vertexData[1] = (d * h1) + (b * w1) + ty;
this.vertexData[2] = (a * w0) + (c * h1) + tx;
this.vertexData[3] = (d * h1) + (b * w0) + ty;
this.vertexData[4] = (a * w0) + (c * h0) + tx;
this.vertexData[5] = (d * h0) + (b * w0) + ty;
this.vertexData[6] = (a * w1) + (c * h0) + tx;
this.vertexData[7] = (d * h0) + (b * w1) + ty;
this.verticesBuffer.update();
}
/* ---------------------------------------- */
/** @override */
calculateTrimmedVertices(...args) {
return PIXI.Sprite.prototype.calculateTrimmedVertices.call(this, ...args);
}
/* ---------------------------------------- */
/** @override */
_render(renderer) {
this.calculateVertices();
// Update tint if necessary
if ( this.#tintAlphaDirty ) {
PIXI.Color.shared.setValue(this._tintColor)
.premultiply(this.worldAlpha, this.alphaMode > 0)
.toArray(this._cachedTint);
this.#tintAlphaDirty = false;
}
// Render by batch if a batched plugin is defined (or do a standard rendering)
if ( this.pluginName in renderer.plugins ) this._renderToBatch(renderer);
else this._renderDefault(renderer);
}
/* ---------------------------------------- */
/** @override */
_renderToBatch(renderer) {
this._updateBatchData();
const batchRenderer = renderer.plugins[this.pluginName];
renderer.batch.setObjectRenderer(batchRenderer);
batchRenderer.render(this._batchData);
}
/* ---------------------------------------- */
/** @override */
_renderDefault(renderer) {
// Update properties of the shader
this.shader?._preRender(this);
// Draw the SpriteMesh
renderer.batch.flush();
renderer.shader.bind(this.shader);
renderer.state.set(this.state);
renderer.geometry.bind(this.geometry, this.shader);
renderer.geometry.draw(this.drawMode, this.size, this.start);
}
/* ---------------------------------------- */
/**
* Update the batch data object.
* TODO: temporary method until the upstream issue is fixed: https://github.com/pixijs/pixijs/issues/8511
* @protected
*/
_updateBatchData() {
this._batchData._texture = this._texture;
this._batchData.worldAlpha = this.worldAlpha;
this._batchData._tintRGB = this._tintRGB;
this._batchData.blendMode = this.blendMode;
}
/* ---------------------------------------- */
/** @override */
_calculateBounds(...args) {
return PIXI.Sprite.prototype._calculateBounds.call(this, ...args);
}
/* ---------------------------------------- */
/** @override */
getLocalBounds(...args) {
return PIXI.Sprite.prototype.getLocalBounds.call(this, ...args);
}
/* ---------------------------------------- */
/** @override */
containsPoint(...args) {
return PIXI.Sprite.prototype.containsPoint.call(this, ...args);
}
/* ---------------------------------------- */
/** @override */
destroy(...args) {
this.geometry = null;
return PIXI.Sprite.prototype.destroy.call(this, ...args);
}
/* ---------------------------------------- */
/**
* Create a SpriteMesh from another source.
* You can specify texture options and a specific shader class derived from AbstractBaseShader.
* @param {string|PIXI.Texture|HTMLCanvasElement|HTMLVideoElement} source Source to create texture from.
* @param {object} [textureOptions] See {@link PIXI.BaseTexture}'s constructor for options.
* @param {AbstractBaseShader} [shaderCls] The shader class to use. BaseSamplerShader by default.
* @returns {SpriteMesh}
*/
static from(source, textureOptions, shaderCls) {
const texture = source instanceof PIXI.Texture ? source : PIXI.Texture.from(source, textureOptions);
return new SpriteMesh(texture, shaderCls);
}
}
/**
* 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} [name] A unique name which can be used to reference the in-progress animation
* @property {number} [duration] A duration in milliseconds over which the animation should occur
* @property {number} [priority] A priority in PIXI.UPDATE_PRIORITY which defines when the animation
* should be evaluated related to others
* @property {Function|string} [easing] An easing function used to translate animation time or the string name
* of a static member of the CanvasAnimation class
* @property {function(number, CanvasAnimationData)} [ontick] A callback function which fires after every frame
*/
/**
* @typedef {CanvasAnimationOptions} CanvasAnimationData
* @property {Function} fn The animation function being executed each frame
* @property {number} time The current time of the animation, in milliseconds
* @property {CanvasAnimationAttribute[]} attributes The attributes being animated
* @property {Promise} [promise] A Promise which resolves once the animation is complete
* @property {Function} [resolve] The resolution function, allowing animation to be ended early
* @property {Function} [reject] The rejection function, allowing animation to be ended early
*/
/**
* A helper class providing utility methods for PIXI Canvas animation
*/
class CanvasAnimation {
static get ticker() {
return canvas.app.ticker;
}
/**
* Track an object of active animations by name, context, and function
* This allows a currently playing animation to be referenced and terminated
* @type {Object<string, CanvasAnimationData>}
*/
static animations = {};
/* -------------------------------------------- */
/**
* Apply an animation from the current value of some attribute to a new value
* Resolve a Promise once the animation has concluded and the attributes have reached their new target
*
* @param {CanvasAnimationAttribute[]} attributes An array of attributes to animate
* @param {CanvasAnimationOptions} options Additional options which customize the animation
*
* @returns {Promise} A Promise which resolves to true once the animation has concluded
* or false if the animation was prematurely terminated
*
* @example Animate Token Position
* ```js
* let animation = [
* {
* parent: token,
* attribute: "x",
* to: 1000
* },
* {
* parent: token,
* attribute: "y",
* to: 2000
* }
* ];
* CanvasAnimation.animate(attributes, {duration:500});
* ```
*/
static async animate(attributes, {context=canvas.stage, name, duration=1000, easing, ontick, priority}={}) {
priority ??= PIXI.UPDATE_PRIORITY.LOW + 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};
animation.fn = dt => CanvasAnimation.#animateFrame(dt, animation);
// Create a promise which manages the animation lifecycle
const promise = new Promise((resolve, reject) => {
animation.resolve = resolve;
animation.reject = reject;
this.ticker.add(animation.fn, context, priority);
})
// Log any errors
.catch(err => console.error(err))
// Remove the animation once completed
.finally(() => {
this.ticker.remove(animation.fn, context);
const wasCompleted = name && (this.animations[name]?.fn === animation.fn);
if ( wasCompleted ) delete this.animations[name];
});
// Record the animation and return
if ( name ) {
animation.promise = promise;
this.animations[name] = animation;
}
return promise;
}
/* -------------------------------------------- */
/**
* Retrieve an animation currently in progress by its name
* @param {string} name The animation name to retrieve
* @returns {CanvasAnimationData} The animation data, or undefined
*/
static getAnimation(name) {
return this.animations[name];
}
/* -------------------------------------------- */
/**
* If an animation using a certain name already exists, terminate it
* @param {string} name The animation name to terminate
*/
static terminateAnimation(name) {
let animation = this.animations[name];
if (animation) animation.resolve(false);
}
/* -------------------------------------------- */
/**
* Cosine based easing with smooth in-out.
* @param {number} pt The proportional animation timing on [0,1]
* @returns {number} The eased animation progress on [0,1]
*/
static easeInOutCosine(pt) {
return (1 - Math.cos(Math.PI * pt)) * 0.5;
}
/* -------------------------------------------- */
/**
* Shallow ease out.
* @param {number} pt The proportional animation timing on [0,1]
* @returns {number} The eased animation progress on [0,1]
*/
static easeOutCircle(pt) {
return Math.sqrt(1 - Math.pow(pt - 1, 2));
}
/* -------------------------------------------- */
/**
* Shallow ease in.
* @param {number} pt The proportional animation timing on [0,1]
* @returns {number} The eased animation progress on [0,1]
*/
static easeInCircle(pt) {
return 1 - Math.sqrt(1 - Math.pow(pt, 2));
}
/* -------------------------------------------- */
/**
* Generic ticker function to implement the animation.
* This animation wrapper executes once per frame for the duration of the animation event.
* Once the animated attributes have converged to their targets, it resolves the original Promise.
* The user-provided ontick function runs each frame update to apply additional behaviors.
*
* @param {number} deltaTime The incremental time which has elapsed
* @param {CanvasAnimationData} animation The animation which is being performed
*/
static #animateFrame(deltaTime, animation) {
const {attributes, duration, ontick} = animation;
// Compute animation timing and progress
const dt = this.ticker.elapsedMS; // Delta time in MS
animation.time += dt; // Total time which has elapsed
const pt = animation.time / duration; // Proportion of total duration
const complete = animation.time >= duration;
const pa = complete ? 1 : (animation.easing ? animation.easing(pt) : pt);
// Update each attribute
try {
for ( let a of attributes ) 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;
}
/* -------------------------------------------- */
/* DEPRECATIONS */
/* -------------------------------------------- */
/**
* @alias CanvasAnimation.animate
* @see {CanvasAnimation.animate}
* @deprecated since v10
* @ignore
*/
static async animateLinear(attributes, options) {
foundry.utils.logCompatibilityWarning("You are calling CanvasAnimation.animateLinear which is deprecated in favor "
+ "of CanvasAnimation.animate", {since: 10, until: 12});
return this.animate(attributes, options);
}
}
/**
* A generic helper for drawing a standard Control Icon
* @type {PIXI.Container}
*/
class ControlIcon extends PIXI.Container {
constructor({texture, size=40, borderColor=0xFF5500, tint=null}={}, ...args) {
super(...args);
// Define arguments
this.iconSrc = texture;
this.size = size;
this.rect = [-2, -2, size+4, size+4];
this.borderColor = borderColor;
/**
* The color of the icon tint, if any
* @type {number|null}
*/
this.tintColor = tint;
// Define hit area
this.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;
// Draw asynchronously
this.draw();
}
/* -------------------------------------------- */
/**
* Initial drawing of the ControlIcon
* @returns {Promise<ControlIcon>}
*/
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;
return this.refresh();
}
/* -------------------------------------------- */
/**
* Incremental refresh for ControlIcon appearance.
*/
refresh({visible, iconColor, borderColor, borderVisible}={}) {
if ( iconColor ) this.tintColor = iconColor;
this.icon.tint = Number.isNumeric(this.tintColor) ? this.tintColor : 0xFFFFFF;
if ( borderColor ) 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;
return this;
}
}
/**
* Handle mouse interaction events for a Canvas object.
* There are three phases of events: hover, click, and drag
*
* Hover Events:
* _handleMouseOver
* action: hoverIn
* _handleMouseOut
* action: hoverOut
*
* Left Click and Double-Click
* _handleMouseDown
* action: clickLeft
* action: clickLeft2
*
* Right Click and Double-Click
* _handleRightDown
* action: clickRight
* action: clickRight2
*
* Drag and Drop
* _handleMouseMove
* action: dragLeftStart
* action: dragRightStart
* action: dragLeftMove
* action: dragRightMove
* _handleMouseUp
* action: dragLeftDrop
* action: dragRightDrop
* _handleDragCancel
* action: dragLeftCancel
* action: dragRightCancel
*/
class MouseInteractionManager {
constructor(object, layer, permissions={}, callbacks={}, options={}) {
this.object = object;
this.layer = layer;
this.permissions = permissions;
this.callbacks = callbacks;
/**
* Interaction options which configure handling workflows
* @type {{target: PIXI.DisplayObject, dragResistance: number}}
*/
this.options = options;
/**
* The current interaction state
* @type {number}
*/
this.state = this.states.NONE;
/**
* Bound interaction data object to populate with custom data.
* @type {Object<any>}
*/
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}
*/
this.controlIcon = this.options.target ? this.object[this.options.target] : undefined;
/**
* The view id pertaining to the PIXI Application.
* If not provided, default to canvas.app.view.id
* @type {ControlIcon}
*/
const app = this.options.application ?? canvas.app;
this.viewId = app.view.id;
}
/**
* Bound handlers which can be added and removed
* @type {Object<Function>}
*/
#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: DRAG - the object is being dragged
* 4: DROP - the object is being dropped
* @enum {number}
*/
static INTERACTION_STATES = {
NONE: 0,
HOVER: 1,
CLICKED: 2,
DRAG: 3,
DROP: 4
};
/**
* 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 number of milliseconds of mouse click depression to consider it a long press.
* @type {number}
*/
static LONG_PRESS_DURATION_MS = 500;
/**
* Global timeout for the long-press event.
* @type {number|null}
*/
static longPressTimeout = null;
/* -------------------------------------------- */
/**
* Get the target.
* @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 = {
mouseover: this.#handleMouseOver.bind(this),
mouseout: this.#handleMouseOut.bind(this),
mousedown: this.#handleMouseDown.bind(this),
rightdown: this.#handleRightDown.bind(this),
mousemove: this.#handleMouseMove.bind(this),
mouseup: this.#handleMouseUp.bind(this),
contextmenu: this.#handleDragCancel.bind(this)
};
// Activate hover events to start the workflow
this.#activateHoverEvents();
// Set the target as interactive
this.target.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 {Object<string, number>}
*/
get states() {
return this.constructor.INTERACTION_STATES;
}
/* -------------------------------------------- */
/**
* A reference to the possible interaction states which can be observed
* @returns {Object<string, number>}
*/
get handlerOutcomes() {
return this.constructor.#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.mouseover).on("pointerover", this.#handlers.mouseover);
this.target.off("pointerout", this.#handlers.mouseout).on("pointerout", this.#handlers.mouseout);
}
/* -------------------------------------------- */
/**
* Activate a new set of listeners for click events on the target object.
*/
#activateClickEvents() {
this.#deactivateClickEvents();
this.target.on("pointerdown", this.#handlers.mousedown);
this.target.on("pointerup", this.#handlers.mouseup);
this.target.on("mouseupoutside", this.#handlers.mouseup);
this.target.on("rightdown", this.#handlers.rightdown);
this.target.on("rightup", this.#handlers.mouseup);
this.target.on("rightupoutside", this.#handlers.mouseup);
}
/* -------------------------------------------- */
/**
* Deactivate event listeners for click events on the target object.
*/
#deactivateClickEvents() {
this.target.off("pointerdown", this.#handlers.mousedown);
this.target.off("pointerup", this.#handlers.mouseup);
this.target.off("mouseupoutside", this.#handlers.mouseup);
this.target.off("rightdown", this.#handlers.rightdown);
this.target.off("rightup", this.#handlers.mouseup);
this.target.off("rightupoutside", this.#handlers.mouseup);
}
/* -------------------------------------------- */
/**
* Activate events required for handling a drag-and-drop workflow
*/
#activateDragEvents() {
this.#deactivateDragEvents();
this.layer.on("pointermove", this.#handlers.mousemove);
if ( !this._dragRight ) {
canvas.app.view.addEventListener("contextmenu", this.#handlers.contextmenu, {capture: true});
}
}
/* -------------------------------------------- */
/**
* Deactivate events required for handling drag-and-drop workflow.
* @param {boolean} [silent] Set to true to activate the silent mode.
*/
#deactivateDragEvents(silent) {
this.layer.off("pointermove", this.#handlers.mousemove);
canvas.app.view.removeEventListener("contextmenu", this.#handlers.contextmenu, {capture: true});
}
/* -------------------------------------------- */
/* Hover In and Hover Out */
/* -------------------------------------------- */
/**
* Handle mouse-over events which activate downstream listeners and do not stop propagation.
* @param {PIXI.FederatedEvent} event
*/
#handleMouseOver(event) {
// Verify if the event can be handled
const action = "hoverIn";
if ( (this.state !== this.states.NONE) || !(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
const handled = this.callback(action, event);
if ( !handled ) return this.#debug(action, event, this.handlerOutcomes.REFUSED);
// Advance the workflow state and activate click events
this.state = Math.max(this.state || 0, this.states.HOVER);
this.#activateClickEvents();
return this.#debug(action, event);
}
/* -------------------------------------------- */
/**
* Handle mouse-out events which terminate hover workflows and do not stop propagation.
* @param {PIXI.FederatedEvent} event
*/
#handleMouseOut(event) {
if ( event.pointerType === "touch" ) return; // Ignore Touch events
const action = "hoverOut";
if ( (this.state !== this.states.HOVER) || !(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?
if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED);
// Downgrade the workflow state and deactivate click events
if ( this.state === this.states.HOVER ) {
this.state = this.states.NONE;
this.#deactivateClickEvents();
}
return this.#debug(action, event);
}
/* -------------------------------------------- */
/* Left Click and Double Click */
/* -------------------------------------------- */
/**
* Handle mouse-down events which activate downstream listeners.
* Stop further propagation only if the event is allowed by either single or double-click.
* @param {PIXI.FederatedEvent} event
*/
#handleMouseDown(event) {
if ( event.button !== 0 ) return; // Only support standard left-click
if ( ![this.states.HOVER, this.states.CLICKED, this.states.DRAG].includes(this.state) ) return;
// Determine double vs single click
const now = Date.now();
const isDouble = (now - this.lcTime) <= 250;
this.lcTime = now;
// 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?
if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED);
// Upgrade the workflow state and activate drag event handlers
if ( this.state === this.states.HOVER ) this.state = this.states.CLICKED;
canvas.currentMouseManager = this;
if ( (this.state < this.states.DRAG) && this.can("dragStart", event) ) 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 co-ordinates 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.states.HOVER, this.states.CLICKED, this.states.DRAG].includes(this.state) ) return;
if ( event.button !== 2 ) return; // Only support standard left-click
// Determine double vs single click
const now = Date.now();
const isDouble = (now - this.rcTime) <= 250;
this.rcTime = now;
// 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?
if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED);
// Upgrade the workflow state and activate drag event handlers
if ( this.state === this.states.HOVER ) this.state = this.states.CLICKED;
canvas.currentMouseManager = this;
if ( (this.state < this.states.DRAG) && this.can("dragRight", event) ) 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
*/
#handleMouseMove(event) {
if ( ![this.states.CLICKED, this.states.DRAG].includes(this.state) ) 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);
// 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.CLICKED ) {
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 ) {
return this.#handleDragStart(event);
}
}
// Continue a drag event
else return 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) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
const handled = this.callback(action, event);
if ( handled ) this.state = this.states.DRAG;
return this.#debug(action, event, handled ? this.handlerOutcomes.ACCEPTED : this.handlerOutcomes.REFUSED);
}
/* -------------------------------------------- */
/**
* 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);
if ( handled ) this.state = this.states.DRAG;
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
*/
#handleMouseUp(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);
// Handling of a degenerate case:
// When the manager is in a clicked state and that the button is released in another object
const emulateHoverOut = (this.state === this.states.CLICKED) && !event.defaultPrevented
&& (event.target !== this.object) && (event.target?.parent !== this.object);
if ( emulateHoverOut ) {
event.stopPropagation();
this.state = this.states.HOVER;
this.#deactivateClickEvents();
this.#handleMouseOut(event);
}
if ( this.state >= this.states.DRAG ) {
event.stopPropagation();
if ( event.type.startsWith("right") && !this._dragRight ) return;
this.#handleDragDrop(event);
}
// Continue a multi-click drag workflow
if ( event.defaultPrevented ) {
this.state = priorState;
return this.#debug("mouseUp", event, this.handlerOutcomes.SKIPPED);
}
// Cancel the workflow
return 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?
if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
// Update the workflow state
this.state = this.states.DROP;
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);
}
/* -------------------------------------------- */
/**
* 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.#handleMouseOver(event);
break;
case "pointerout":
this.#handleMouseOut(event);
break;
case "pointerup":
this.#handleMouseUp(event);
break;
case "pointerdown":
if ( event.button === 2 ) this.#handleRightDown(event);
else this.#handleMouseDown(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 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;
this.#deactivateDragEvents();
return this.#debug(action, 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: 12});
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: 12});
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 {PIXI.Point} origin The canvas co-ordinates 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<boolean>} 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
*/
/**
* 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 {Object<RenderFlag>} 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 {Object<RenderFlag>}
*/
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 {Object<boolean>} 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 {Object<boolean>} 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<string>} 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.alias ) this.add(flag);
for ( const r of cfg.reset ) this.delete(r);
for ( const p of cfg.propagate ) this.#set(p, true, seen);
}
}
/* -------------------------------------------- */
/**
* Add RenderFlags functionality to some other object.
* This mixin standardizes the interface for such functionality.
* @param {typeof PIXI.DisplayObject} Base The base class being mixed
* @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 {Object<RenderFlag>}
*/
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);
}
}
/**
* An internal data structure for polygon vertices
* @private
* @ignore
*/
class PolygonVertex {
constructor(x, y, {distance, index}={}) {
this.x = Math.round(x);
this.y = Math.round(y);
this.key = PolygonVertex.#getSortKey(this.x, this.y);
this._distance = distance;
this._d2 = undefined;
this._index = index;
/**
* The set of edges which connect to this vertex.
* This set is initially empty and populated later after vertices are de-duplicated.
* @type {EdgeSet}
*/
this.edges = new Set();
/**
* The subset of edges which continue clockwise from this vertex.
* @type {EdgeSet}
*/
this.cwEdges = new Set();
/**
* The subset of edges which continue counter-clockwise from this vertex.
* @type {EdgeSet}
*/
this.ccwEdges = new Set();
/**
* The set of vertices collinear to this vertex
* @type {Set<PolygonVertex>}
*/
this.collinearVertices = new Set();
/**
* The maximum restriction type of this vertex
* @type {number|null}
*/
this.type = null;
}
/**
* The effective maximum texture size that Foundry VTT "ever" has to worry about.
* @type {number}
*/
static #MAX_TEXTURE_SIZE = Math.pow(2, 16);
/**
* Determine the sort key to use for this vertex, arranging points from north-west to south-east.
* @param {number} x The x-coordinate
* @param {number} y The y-coordinate
*/
static #getSortKey(x, y) {
return (this.#MAX_TEXTURE_SIZE * x) + y;
}
/**
* Is this vertex an endpoint of one or more edges?
* @type {boolean}
*/
isEndpoint;
/**
* Does this vertex have a single counterclockwise limiting edge?
* @type {boolean}
*/
isLimitingCCW;
/**
* Does this vertex have a single clockwise limiting edge?
* @type {boolean}
*/
isLimitingCW;
/**
* Does this vertex have non-limited edges or 2+ limited edges counterclockwise?
* @type {boolean}
*/
isBlockingCCW;
/**
* Does this vertex have non-limited edges or 2+ limited edges clockwise?
* @type {boolean}
*/
isBlockingCW;
/**
* Associate an edge with this vertex.
* @param {PolygonEdge} edge The edge being attached
* @param {number} orientation The orientation of the edge with respect to the origin
*/
attachEdge(edge, orientation=0) {
this.edges.add(edge);
this.type = Math.max(this.type ?? 0, edge.type);
if ( orientation <= 0 ) this.cwEdges.add(edge);
if ( orientation >= 0 ) this.ccwEdges.add(edge);
this.#updateFlags();
}
/**
* Is this vertex limited in type?
* @returns {boolean}
*/
get isLimited() {
return this.type === CONST.WALL_SENSE_TYPES.LIMITED;
}
/**
* Is this vertex terminal (at the maximum radius)
* @returns {boolean}
*/
get isTerminal() {
return this._distance === 1;
}
/**
* Update flags for whether this vertex is limiting or blocking in certain direction.
*/
#updateFlags() {
const classify = edges => {
const s = edges.size;
if ( s === 0 ) return {isLimiting: false, isBlocking: false};
if ( s > 1 ) return {isLimiting: false, isBlocking: true};
else {
const isLimiting = edges.first().isLimited;
return {isLimiting, isBlocking: !isLimiting};
}
};
// Flag endpoint
this.isEndpoint = this.edges.some(edge => edge.A.equals(this) || edge.B.equals(this));
// Flag CCW edges
const ccwFlags = classify(this.ccwEdges);
this.isLimitingCCW = ccwFlags.isLimiting;
this.isBlockingCCW = ccwFlags.isBlocking;
// Flag CW edges
const cwFlags = classify(this.cwEdges);
this.isLimitingCW = cwFlags.isLimiting;
this.isBlockingCW = cwFlags.isBlocking;
}
/**
* Is this vertex the same point as some other vertex?
* @param {PolygonVertex} other Some other vertex
* @returns {boolean} Are they the same point?
*/
equals(other) {
return this.key === other.key;
}
/**
* Construct a PolygonVertex instance from some other Point structure.
* @param {Point} point The point
* @param {object} [options] Additional options that apply to this vertex
* @returns {PolygonVertex} The constructed vertex
*/
static fromPoint(point, options) {
return new this(point.x, point.y, options);
}
}
/* -------------------------------------------- */
/**
* An internal data structure for polygon edges
* @private
* @ignore
*/
class PolygonEdge {
constructor(a, b, type=CONST.WALL_SENSE_TYPES.NORMAL, wall) {
this.A = new PolygonVertex(a.x, a.y);
this.B = new PolygonVertex(b.x, b.y);
this.type = type;
this.wall = wall;
}
/**
* An internal flag used to record whether an Edge represents a canvas boundary.
* @type {boolean}
* @internal
*/
_isBoundary = false;
/**
* Is this edge limited in type?
* @returns {boolean}
*/
get isLimited() {
return this.type === CONST.WALL_SENSE_TYPES.LIMITED;
}
/**
* Construct a PolygonEdge instance from a Wall placeable object.
* @param {Wall|WallDocument} wall The Wall from which to construct an edge
* @param {string} type The type of polygon being constructed
* @returns {PolygonEdge}
*/
static fromWall(wall, type) {
const c = wall.document.c;
return new this({x: c[0], y: c[1]}, {x: c[2], y: c[3]}, wall.document[type], wall);
}
}
/* -------------------------------------------- */
/**
* An object containing the result of a collision test.
* @private
* @ignore
*/
class CollisionResult {
constructor({target=null, collisions=[], cwEdges, ccwEdges, isBehind, isLimited, wasLimited}={}) {
/**
* The vertex that was the target of this result
* @type {PolygonVertex}
*/
this.target = target;
/**
* The array of collision points which apply to this result
* @type {PolygonVertex[]}
*/
this.collisions = collisions;
/**
* The set of edges connected to the target vertex that continue clockwise
* @type {EdgeSet}
*/
this.cwEdges = cwEdges || new Set();
/**
* The set of edges connected to the target vertex that continue counter-clockwise
* @type {EdgeSet}
*/
this.ccwEdges = ccwEdges || new Set();
/**
* Is the target vertex for this result behind some closer active edge?
* @type {boolean}
*/
this.isBehind = isBehind;
/**
* Does the target vertex for this result impose a limited collision?
* @type {boolean}
*/
this.isLimited = isLimited;
/**
* Has the set of collisions for this result encountered a limited edge?
* @type {boolean}
*/
this.wasLimited = wasLimited;
}
/**
* Is this result limited in the clockwise direction?
* @type {boolean}
*/
limitedCW = false;
/**
* Is this result limited in the counter-clockwise direction?
* @type {boolean}
*/
limitedCCW = false;
/**
* Is this result blocking in the clockwise direction?
* @type {boolean}
*/
blockedCW = false;
/**
* Is this result blocking in the counter-clockwise direction?
* @type {boolean}
*/
blockedCCW = false;
/**
* Previously blocking in the clockwise direction?
* @type {boolean}
*/
blockedCWPrev = false;
/**
* Previously blocking in the counter-clockwise direction?
*/
blockedCCWPrev = false;
}
// 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 = foundry.utils.mergeObject(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 {Object<string,boolean|number>}
*/
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 {Object<string,boolean|number>}
*/
options = {};
/**
* Contains some options values scaled by the constant factor
* @type {Object<string,number>}
* @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 {Object} PointSourcePolygonConfig
* @property {string} [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] Compute the polygon with threshold wall constraints applied
* @property {boolean} [debug] Display debugging visualization and logging for the polygon
* @property {PointSource} [source] The object (if any) that spawned this polygon.
* @property {Array<PIXI.Rectangle|PIXI.Circle|PIXI.Polygon>} [boundaryShapes] Limiting polygon boundary shapes
* @property {Readonly<boolean>} [useInnerBounds] Does this polygon use the Scene inner or outer bounding rectangle
* @property {Readonly<boolean>} [hasLimitedRadius] Does this polygon have a limited radius?
* @property {Readonly<boolean>} [hasLimitedAngle] Does this polygon have a limited angle?
* @property {Readonly<PIXI.Rectangle>} [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;
// 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));
}
/* -------------------------------------------- */
/* Wall Identification */
/* -------------------------------------------- */
/**
* Get the super-set of walls which could potentially apply to this polygon.
* Define a custom collision test used by the Quadtree to obtain candidate Walls.
* @returns {Set<Wall>}
* @protected
*/
_getWalls() {
const bounds = this.config.boundingBox = this._defineBoundingBox();
const collisionTest = (o, rect) => this._testWallInclusion(o.t, rect);
return canvas.walls.quadtree.getObjects(bounds, { collisionTest });
}
/* -------------------------------------------- */
/**
* Test whether a wall should be included in the computed polygon for a given origin and type
* @param {Wall} wall The Wall being considered
* @param {PIXI.Rectangle} bounds The overall bounding box
* @returns {boolean} Should the wall be included?
* @protected
*/
_testWallInclusion(wall, bounds) {
const { type, boundaryShapes, useThreshold, wallDirectionMode, externalRadius } = this.config;
// First test for inclusion in our overall bounding box
if ( !bounds.lineSegmentIntersects(wall.A, wall.B, { inside: true }) ) return false;
// Specific boundary shapes may impose additional requirements
for ( const shape of boundaryShapes ) {
if ( shape._includeEdge && !shape._includeEdge(wall.A, wall.B) ) return false;
}
// Ignore walls which are nearly collinear with the origin, except for movement
const side = wall.orientPoint(this.origin);
if ( !side ) return false;
// Always include interior walls underneath active roof tiles
if ( (type === "sight") && wall.hasActiveRoof ) return true;
// Otherwise, ignore walls that are not blocking for this polygon type
else if ( !wall.document[type] || wall.isOpen ) return false;
// Ignore one-directional walls which are facing away from the origin
const wdm = PointSourcePolygon.WALL_DIRECTION_MODES;
if ( wall.document.dir && (wallDirectionMode !== wdm.BOTH) ) {
if ( (wallDirectionMode === wdm.NORMAL) === (side === wall.document.dir) ) return false;
}
// Condition walls on whether their threshold proximity is met
if ( useThreshold ) return !wall.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);
}
/* -------------------------------------------- */
/**
* 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 ( !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 {string} [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, thresholdWalls} = PointSourcePolygon.#getThresholdWalls(polygon.origin, config);
if ( !nAttenuated ) return polygon;
// Create attenuation shapes for all threshold walls
const attenuationShapes = PointSourcePolygon.#createThresholdShapes(polygon, thresholdWalls);
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 walls in the Scene which include an active threshold.
* @param {Point} origin
* @param {object} config
* @returns {{thresholdWalls: Wall[], nAttenuated: number}}
*/
static #getThresholdWalls(origin, config) {
let nAttenuated = 0;
const thresholdWalls = [];
for ( const wall of canvas.walls.placeables ) {
if ( wall.applyThreshold(config.type, origin, config.externalRadius) ) {
thresholdWalls.push(wall);
nAttenuated += wall.document.threshold.attenuation;
}
}
return {thresholdWalls, 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 {Wall[]} thresholdWalls The identified array of threshold walls
* @returns {ClipperPoints[]} The resulting array of intersected threshold shapes
*/
static #createThresholdShapes(thresholdPolygon, thresholdWalls) {
const cps = thresholdPolygon.toClipperPoints();
const origin = thresholdPolygon.origin;
const {radius, externalRadius, type} = thresholdPolygon.config;
const shapes = [];
// Iterate over threshold walls
for ( const wall of thresholdWalls ) {
let thresholdShape;
// Create attenuated shape
if ( wall.document.threshold.attenuation ) {
const r = PointSourcePolygon.#calculateThresholdAttenuation(wall, 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 {Wall} wall The wall 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(wall, origin, radius, externalRadius, type) {
const document = wall.document;
const d = document.threshold[type];
if ( !d ) return { inside: radius, outside: radius };
const proximity = document[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, wall.A, wall.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 thresholdDistance = d * document.parent.dimensions.distancePixels;
const percentDistance = sourceDistance / thresholdDistance;
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 * thresholdDistance, 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 {PIXI.Point} origin The canvas co-ordinates 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<PIXI.Sprite>}
* @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 {PIXI.Point} origin The canvas co-ordinates 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.filters.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 co-ordinates 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 co-ordinates 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"
* canvas.grid.name -> "GridLayer"
*/
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;
}
/* -------------------------------------------- */
/* 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<CanvasLayer>}
*/
async draw(options={}) {
console.log(`${vtt} | Drawing the ${this.constructor.name} canvas layer`);
await this.tearDown();
await this._draw(options);
Hooks.callAll(`draw${this.hookName}`, this);
return this;
}
/**
* 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<CanvasLayer>}
*/
async tearDown(options={}) {
this.renderable = false;
await this._tearDown();
Hooks.callAll(`tearDown${this.hookName}`, this);
this.renderable = true;
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;
/**
* Customize behaviors of this CanvasLayer by modifying some behaviors at a class level.
* @type {{name: string, sortActiveTop: boolean, zIndex: number}}
*/
static get layerOptions() {
return Object.assign(super.layerOptions, {
baseClass: InteractionLayer,
sortActiveTop: false,
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();
}
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;
// Re-render Scene controls
if ( ui.controls ) ui.controls.initialize({layer: this.constructor.layerOptions.name, tool});
// Call layer-specific activation procedures
this._activate();
Hooks.callAll(`activate${this.hookName}`, 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() {
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() {
const options = this.constructor.layerOptions;
if ( this.#active && options.sortActiveTop ) {
return canvas.layers.reduce((max, l) => {
if ( l.zIndex > max ) max = l.zIndex;
return max;
}, 0);
}
return 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) {}
/* -------------------------------------------- */
/**
* 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
*/
async _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
*/
async _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
*/
/**
* 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 {
/**
* 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 */
/* -------------------------------------------- */
/**
* @inheritdoc
* @property {boolean} canDragCreate Does this layer support a mouse-drag workflow to create new objects?
* @property {boolean} canDelete Can objects be deleted from this layer?
* @property {boolean} controllableObjects Can placeable objects in this layer be controlled?
* @property {boolean} rotatableObjects Can placeable objects in this layer be rotated?
* @property {boolean} snapToGrid Do objects in this layer snap to the grid
* @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?
* @property {boolean} elevationSorting Are contained objects sorted based on elevation instead of zIndex
*/
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
baseClass: PlaceablesLayer,
canDragCreate: game.user.isGM,
controllableObjects: false,
rotatableObjects: false,
snapToGrid: true,
objectClass: CONFIG[this.documentName]?.objectClass,
quadtree: true,
elevationSorting: false
});
}
/* -------------------------------------------- */
/**
* 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;
}
/* -------------------------------------------- */
/**
* Return the precision relative to the Scene grid with which Placeable objects should be snapped
* @type {number}
*/
get gridPrecision() {
if ( canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ) return 0; // No snapping for gridless
if ( canvas.grid.isHex ) return this.options.controllableObjects ? 2 : 5; // Snap to corners or vertices
return 2; // Default handling, corners and centers
}
/* -------------------------------------------- */
/**
* If objects on this PlaceableLayer 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<PlaceableObject>}
*/
*controllableObjects() {
for ( const placeable of this.placeables ) {
if ( placeable.visible && placeable.renderable && (placeable.control instanceof Function) ) yield placeable;
}
}
/* -------------------------------------------- */
/**
* Track the set of PlaceableObjects on this layer which are currently controlled.
* @type {Map<string,PlaceableObject>}
*/
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;
/* -------------------------------------------- */
/* Rendering
/* -------------------------------------------- */
/**
* Obtain an iterable of objects which should be added to this PlaceableLayer
* @returns {Document[]}
*/
getDocuments() {
return this.documentCollection || [];
}
/* -------------------------------------------- */
/** @override */
async _draw(options) {
// Clear quadtree
if ( this.quadtree ) this.quadtree.clear();
// Create objects container which can be sorted
this.objects = this.addChild(new PIXI.Container());
this.objects.sortableChildren = true;
this.objects.visible = false;
if ( this.constructor.layerOptions.elevationSorting ) {
this.objects.sortChildren = this._sortObjectsByElevation.bind(this.objects);
}
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
this.visible = true;
await Promise.all(promises);
this.objects.visible = true;
}
/* -------------------------------------------- */
/**
* 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, canvas.scene);
}
/* -------------------------------------------- */
/** @override */
async _tearDown(options) {
this.history = [];
if ( this.options.controllableObjects ) {
this.controlledObjects.clear();
}
if ( this.hud ) this.hud.clear();
this.objects = null;
return super._tearDown();
}
/* -------------------------------------------- */
/**
* Override the default PIXI.Container behavior for how objects in this container are sorted.
* @internal
*/
_sortObjectsByElevation() {
this.children.sort((a, b) => {
return ( a.document.elevation - b.document.elevation ) || ( a.document.sort - b.document.sort );
});
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.update.
* If rotating only a single object, it is better to use the PlaceableObject.rotate instance method.
*
* @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
* @returns {Promise<PlaceableObject[]>} An array of objects which were rotated
*/
async rotateMany({angle, delta, snap, ids}={}) {
if ((!this.constructor.layerOptions.rotatableObjects ) || (game.paused && !game.user.isGM)) return [];
if ( (angle ?? delta ?? null) === null ) {
throw new Error("Either a target angle or incremental delta must be provided.");
}
// Determine the set of rotatable objects
const rotatable = this.controlled.filter(o => {
if ( ids && !ids.includes(o.id) ) return false;
return !o.document.locked;
});
if ( !rotatable.length ) return [];
// Conceal any active HUD
const hud = this.hud;
if ( hud ) hud.clear();
// Update the objects with a single operation
const updateData = rotatable.map(o => {
return {_id: o.id, rotation: o._updateRotation({angle, delta, snap})}
});
await canvas.scene.updateEmbeddedDocuments(this.constructor.documentName, updateData);
return rotatable;
}
/* -------------------------------------------- */
/**
* Simultaneously move multiple PlaceableObjects via keyboard movement offsets.
* This executes a single database operation using Scene.update.
* If moving only a single object, this will delegate to PlaceableObject.update for performance reasons.
*
* @param {object} options Options which configure how multiple objects are moved
* @param {number} [options.dx=0] The number of incremental grid units in the horizontal direction
* @param {number} [options.dy=0] The number of incremental grid units in the vertical direction
* @param {boolean} [options.rotate=false] Rotate the token to the keyboard direction instead of moving
* @param {Array} [options.ids] An Array of object IDs to target for movement
*
* @returns {Promise<PlaceableObject[]>} An array of objects which were moved during the operation
*/
async moveMany({dx=0, dy=0, rotate=false, ids}={}) {
if ( !dx && !dy ) return [];
if ( game.paused && !game.user.isGM ) {
return ui.notifications.warn("GAME.PausedWarning", {localize: true});
}
// Determine the set of movable object IDs unless some were explicitly provided
ids = ids instanceof Array ? ids : this.controlled.filter(o => !o.document.locked).map(o => o.id);
if ( !ids.length ) return [];
// 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;
if ( canvas.grid.type >= CONST.GRID_TYPES.HEXODDQ ) angles = rotationAngles.hexQ;
else if ( canvas.grid.type >= 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
const hud = this.hud;
if ( hud ) hud.clear();
// Construct the update Array
const moved = [];
const updateData = ids.map(id => {
let obj = this.get(id);
let update = {_id: id};
if ( rotate ) update.rotation = angle;
else foundry.utils.mergeObject(update, obj._getShiftedPosition(...offsets));
moved.push(obj);
return update;
});
await canvas.scene.updateEmbeddedDocuments(this.constructor.documentName, updateData);
return moved;
}
/* -------------------------------------------- */
/**
* Undo a change to the objects in this layer
* This method is typically activated using CTRL+Z while the layer is active
* @returns {Promise<Document[]>} An array of documents which were modified by the undo operation
*/
async undoHistory() {
if ( !this.history.length ) return Promise.reject("No more tracked history to undo!");
let event = this.history.pop();
const type = this.constructor.documentName;
// Undo creation with deletion
if ( event.type === "create" ) {
const ids = event.data.map(d => d._id);
return canvas.scene.deleteEmbeddedDocuments(type, ids, {isUndo: true});
}
// 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" ) {
return canvas.scene.createEmbeddedDocuments(type, event.data, {isUndo: true, keepId: true});
}
}
/* -------------------------------------------- */
/**
* 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<Document[]>} 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.`);
}
return Dialog.confirm({
title: game.i18n.localize("CONTROLS.ClearAll"),
content: `<p>${game.i18n.format("CONTROLS.ClearAllHint", {type})}</p>`,
yes: () => canvas.scene.deleteEmbeddedDocuments(type, [], {deleteAll: true})
});
}
/* -------------------------------------------- */
/**
* 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 ( 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 = [];
ui.notifications.info(`Copied data for ${this._copy.length} ${this.constructor.documentName} objects`);
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] Paste data in a hidden state, if applicable. Default is false.
* @param {boolean} [options.snap] Snap the resulting objects to the grid. Default is true.
* @returns {Promise<Document[]>} An Array of created Document instances
*/
async pasteObjects(position, {hidden=false, snap=true}={}) {
if ( !this._copy.length ) return [];
const d = canvas.dimensions;
// Adjust the pasted position for half a grid space
if ( snap ) {
position.x -= canvas.dimensions.size / 2;
position.y -= canvas.dimensions.size / 2;
}
// Get the left-most object in the set
this._copy.sort((a, b) => a.document.x - b.document.x);
const {x, y} = this._copy[0].document;
// Iterate over objects
const toCreate = [];
for ( let c of this._copy ) {
const data = c.document.toObject();
delete data._id;
// Constrain the destination position
let dest = {x: position.x + (data.x - x), y: position.y + (data.y - y)};
dest.x = Math.clamped(dest.x, 0, d.width-1);
dest.y = Math.clamped(dest.y, 0, d.height-1);
if ( snap ) dest = canvas.grid.getSnappedPosition(dest.x, dest.y);
// Stage the creation
toCreate.push(foundry.utils.mergeObject(data, {
x: dest.x,
y: dest.y,
hidden: data.hidden || hidden
}));
}
/**
* 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
let created = await canvas.scene.createEmbeddedDocuments(this.constructor.documentName, toCreate);
ui.notifications.info(`Pasted data for ${toCreate.length} ${this.constructor.documentName} objects.`);
return created;
}
/* -------------------------------------------- */
/**
* 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 = this.controlled;
// Identify controllable objects
const selectionRect = new PIXI.Rectangle(x, y, width, height);
const newSet = [];
for ( const placeable of this.controllableObjects() ) {
const c = placeable.center;
if ( selectionRect.contains(c.x, c.y) ) newSet.push(placeable);
}
// Maybe release objects no longer controlled
const toRelease = oldSet.filter(placeable => !newSet.includes(placeable));
if ( releaseOthers ) toRelease.forEach(placeable => placeable.release(releaseOptions));
// Control new objects
if ( foundry.utils.isEmpty(controlOptions) ) controlOptions.releaseOthers = false;
const toControl = newSet.filter(placeable => !oldSet.includes(placeable));
toControl.forEach(placeable => placeable.control(controlOptions));
// Return a boolean for whether the control set was changed
return (releaseOthers && toRelease.length) || (toControl.length > 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<Document[]>} 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 co-ordinates of the center of the nearest grid element.
* @returns {number[]|boolean} Returns the transformed x, y co-ordinates, 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});
coords = [coords.x, coords.y];
if ( center ) coords = canvas.grid.getCenter(coords[0], coords[1]);
if ( canvas.dimensions.rect.contains(coords[0], coords[1]) ) return coords;
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 ( this.hud ) this.hud.clear();
if ( this.options.controllableObjects && game.settings.get("core", "leftClickRelease") && !this.hover ) {
this.releaseAll();
}
}
/* -------------------------------------------- */
/** @override */
async _onDragLeftStart(event) {
const interaction = event.interactionData;
if ( !this.options.canDragCreate ) {
interaction.layerDragState = 0;
return;
}
// Clear any existing preview
this.clearPreviewContainer();
// Snap the origin to the grid
if ( this.options.snapToGrid && !event.shiftKey ) {
interaction.origin =
canvas.grid.getSnappedPosition(interaction.origin.x, interaction.origin.y, this.gridPrecision);
}
// Register the ongoing creation
interaction.layerDragState = 1;
}
/* -------------------------------------------- */
/** @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 */
async _onDragLeftDrop(event) {
const preview = event.interactionData.preview;
if ( !preview || preview._destroyed ) return;
event.interactionData.clearPreviewContainer = false;
const cls = getDocumentClass(this.constructor.documentName);
try {
return await 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 ( this.hud ) this.hud.clear();
}
/* -------------------------------------------- */
/** @override */
_onMouseWheel(event) {
// Prevent wheel rotation for non-GM users if the game is paused
if ( game.paused && !game.user.isGM ) return;
// Determine the incremental angle of rotation from event data
const dBig = canvas.grid.isHex ? 60 : 45;
let snap = event.shiftKey ? dBig : 15;
let delta = snap * Math.sign(event.delta);
// Case 1 - rotate preview objects
if ( this.preview.children.length ) {
for ( let p of this.preview.children ) {
p.document.rotation = p._updateRotation({delta, snap});
p.renderFlags.set({refresh: true}); // Refresh everything, can we do better?
}
}
// Case 2 - Update multiple objects
else return this.rotateMany({delta, snap});
}
/* -------------------------------------------- */
/** @override */
async _onDeleteKey(event) {
// 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 ) return canvas.scene.deleteEmbeddedDocuments(this.constructor.documentName, ids);
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get _highlight() {
const msg = "PlaceableLayer#_highlight is deprecated. Use PlaceableLayer#highlightObjects instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this.highlightObjects;
}
/**
* @deprecated since v11
* @ignore
*/
set _highlight(state) {
const msg = "PlaceableLayer#_highlight is deprecated. Use PlaceableLayer#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.
* @param {object} [options] Options provided to the ParticleEffect constructor which can be used to customize
* configuration values for created emitters.
* @returns {PIXI.particles.Emitter[]}
*/
getParticleEmitters(options={}) {
return [];
}
/* -------------------------------------------- */
/** @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;
}
}
}
/**
* @deprecated since v10
* @ignore
*/
class SpecialEffect extends ParticleEffect {
constructor() {
foundry.utils.logCompatibilityWarning("You are using the SpecialEffect class which is renamed to ParticleEffect.",
{since: 10, until: 12});
super();
}
}
/**
* 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);
// Register and add animation
canvas.app.ticker.add(this._animate, this);
}
/* -------------------------------------------- */
/**
* 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());
const color = user.color.replace("#", "0x") || 0x42F4E2;
d.beginFill(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;
}
/* -------------------------------------------- */
/**
* Move an existing cursor to a new position smoothly along the animation loop
*/
_animate() {
let dy = this.target.y - this.y,
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) {
canvas.app.ticker.remove(this._animate, this);
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.
* @returns {PIXI.Point|PIXI.Point|boolean|*}
*/
get center() {
return this.wall.center;
}
/* -------------------------------------------- */
/**
* Draw the DoorControl icon, displaying its icon texture and border
* @returns {Promise<DoorControl>}
*/
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.effects.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.effects.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});
}
}
/**
* @deprecated since v10
* @ignore
*/
class SynchronizedTransform extends PIXI.Transform {
constructor(transform) {
super();
this.reference = transform;
const msg = "The SynchronizedTransform class is deprecated and should no longer be used.";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
}
/**
* A list of attributes from the transform reference which should be synchronized
* @type {string}
*/
static synchronizedAttributes = [
"localTransform", "position", "scale", "pivot", "skew", "_rotation",
"_cx", "_sx", "_cy", "_sy", "_localID", "_currentLocalID"
];
/**
* A Transform instance which defines the reference point for the worldTransform
* @type {PIXI.Transform}
*/
get reference() {
return this._reference;
}
set reference(value) {
this._reference = value;
this._syncLocalID = -1;
}
/** @override */
updateTransform(parentTransform) {
if ( this._localID !== this._currentLocalID ) this._reference._parentID = -1;
else if ( this._localID !== this._syncLocalID ) this._parentID = -1;
this._syncLocalID = this._localID;
super.updateTransform(parentTransform);
}
/** @override */
updateLocalTransform() {
if (this._localID !== this._currentLocalID) {
this._reference._parentID = -1;
super.updateLocalTransform();
}
}
}
for ( let attr of SynchronizedTransform.synchronizedAttributes ) {
Object.defineProperty(SynchronizedTransform.prototype, attr, {
get() { return this._reference[attr]; },
set(value) {
if ( !this._reference ) return;
this._reference[attr] = value;
}
});
}
/**
* @deprecated since v10
* @ignore
*/
class ObjectHUD extends PIXI.Container {
constructor(object) {
super();
const msg = "The ObjectHUD class is deprecated and should no longer be used.";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
/**
* The object that this HUD container is linked to
* @type {PIXI.DisplayObject}
*/
this.object = object;
/**
* Use the linked object's transform matrix to easily synchronize position
* @type {PIXI.Transform}
*/
this.transform = new SynchronizedTransform(this.object.transform);
// Allow the HUD to be culled when off-screen
this.cullable = true;
}
/** @override */
get visible() {
return this.object.visible;
}
set visible(value) {}
/** @override */
get renderable() {
return this.object.renderable;
}
set renderable(value) {}
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
async createScrollingText(content, {direction=CONST.TEXT_ANCHOR_POINTS.TOP, ...options}={}) {
const msg = "You are calling ObjectHUD#createScrollingText which has been migrated and refactored to"
+ " CanvasInterfaceGroup#createScrollingText";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
if ( !this.visible || !this.renderable ) return;
const w = this.object.w;
const h = this.object.h;
let distance;
if ( [CONST.TEXT_ANCHOR_POINTS.BOTTOM, CONST.TEXT_ANCHOR_POINTS.TOP].includes(direction) ) distance = h;
else if ( [CONST.TEXT_ANCHOR_POINTS.LEFT, CONST.TEXT_ANCHOR_POINTS.RIGHT].includes(direction) ) distance = w;
return canvas.interface.createScrollingText(this.object.center, content, {direction, distance, ...options});
}
}
/**
* 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();
/**
* A container of DoorControl instances
* @type {PIXI.Container}
*/
this.doors = this.addChild(new PIXI.Container());
/**
* A container of HUD interface elements
* @type {PIXI.Container}
*/
this.hud = 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";
/**
* 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 {Object<string, Cursor>}
*/
_cursors = {};
/**
* A mapping of user IDs to Ruler instances for quick access
* @type {Object<string, Ruler>}
* @private
*/
_rulers = {};
/**
* The positions of any offscreen pings we are tracking.
* @type {Object<string, Point>}
* @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 */
/* -------------------------------------------- */
/** @override */
async _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;
this.zIndex = this.getZIndex();
this.interactiveChildren = true;
}
/* -------------------------------------------- */
/** @override */
async _tearDown(options) {
this._cursors = {};
this._rulers = {};
this.doors.removeChildren();
this.cursors.removeChildren();
this.rulers.removeChildren();
this.hud.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.visible = true;
this.interactiveChildren = true;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/**
* Handle mousemove events on the game canvas to broadcast activity of the user's cursor position
* @param {PIXI.FederatedEvent} event
*/
_onMouseMove(event) {
const sc = game.user.hasPermission("SHOW_CURSOR");
const sr = game.user.hasPermission("SHOW_RULER");
if ( !(sc || sr) ) return;
const position = event.getLocalPosition(this);
const ruler = sr && (this.ruler?._state > 0) ? this.ruler.toJSON() : undefined;
game.user.broadcastActivity({
cursor: position,
ruler: ruler
});
}
/* -------------------------------------------- */
/**
* 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;
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;
}
// Ignore cursors for users who are not permitted to share
if ( (user === game.user) || !user.hasPermission("SHOW_CURSOR") ) {
if ( cursor ) cursor.visible = false;
return;
}
// Show the cursor in its currently tracked position
cursor.visible = true;
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 {object} 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
let ruler = this.getRulerForUser(user.id);
if ( !ruler ) return;
if ( rulerData === null ) ruler.clear();
else ruler.update(rulerData);
}
/* -------------------------------------------- */
/**
* Handle a broadcast ping.
* @param {User} user The user who pinged.
* @param {PIXI.Point} position The position on the canvas that was pinged.
* @param {PingData} [data] The broadcast ping data.
* @returns {Promise<boolean>} {@see Ping#animate}
*/
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 ) {
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.
* @param {PIXI.Point} position The co-ordinates 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<boolean>} {@see Ping#animate}
*/
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.
* @param {PIXI.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<boolean>} {@see Ping#animate}
*/
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 an off-screen co-ordinate, determine the closest point at the edge of the viewport to that co-ordinate.
* @param {Point} position The off-screen co-ordinate.
* @returns {{ray: Ray, intersection: LineIntersection|null}} The closest point at the edge of the viewport to that
* co-ordinate 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 {string} text The string text displayed in the label
* @property {boolean} last Is this segment the last one?
*/
/**
* The Ruler - used to measure distances and trigger movements
* @param {User} The User for whom to construct the Ruler instance
* @type {PIXI.Container}
*/
class Ruler extends PIXI.Container {
constructor(user, {color=null}={}) {
super();
user = user || game.user;
/**
* 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 || 0x42F4E2);
/**
* This Array tracks individual waypoints along the ruler's measured path.
* The first waypoint is always the origin of the route.
* @type {Array<PIXI.Point>}
*/
this.waypoints = [];
/**
* 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());
/**
* Track the current measurement state
* @type {number}
*/
this._state = Ruler.STATES.INACTIVE;
}
/**
* The current destination point at the end of the measurement
* @type {PIXI.Point}
*/
destination = {x: undefined, y: undefined};
/**
* The array of most recently computed ruler measurement segments
* @type {RulerMeasurementSegment[]}
*/
segments;
/**
* The computed total distance of the Ruler.
* @type {number}
*/
totalDistance;
/**
* An enumeration of the possible Ruler measurement states.
* @enum {number}
*/
static STATES = {
INACTIVE: 0,
STARTING: 1,
MEASURING: 2,
MOVING: 3
};
/* -------------------------------------------- */
/**
* Is the ruler ready for measure?
* @returns {boolean}
*/
static get canMeasure() {
return (game.activeTool === "ruler") || game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL);
}
/* -------------------------------------------- */
/**
* Is the Ruler being actively used to measure distance?
* @type {boolean}
*/
get active() {
return this.waypoints.length > 0;
}
/* -------------------------------------------- */
/**
* Get a GridHighlight layer for this Ruler
* @type {GridHighlight}
*/
get highlightLayer() {
return canvas.grid.highlightLayers[this.name] || canvas.grid.addHighlightLayer(this.name);
}
/* -------------------------------------------- */
/* Ruler Methods */
/* -------------------------------------------- */
/**
* Clear display of the current Ruler
*/
clear() {
this._state = Ruler.STATES.INACTIVE;
this.waypoints = [];
this.segments = undefined;
this.ruler?.clear();
this.labels.removeChildren().forEach(c => c.destroy());
canvas.grid.clearHighlightLayer(this.name);
}
/* -------------------------------------------- */
/**
* Measure the distance between two points and render the ruler UI to illustrate it
* @param {PIXI.Point} destination The destination point to which to measure
* @param {boolean} [gridSpaces=true] Restrict measurement only to grid spaces
* @param {boolean} [force=false] Do the measure whatever is the destination point?
* @returns {RulerMeasurementSegment[]} The array of measured segments
*/
measure(destination, {gridSpaces=true, force=false}={}) {
// Compute the measurement destination, segments, and distance
const d = this._getMeasurementDestination(destination);
if ( ( d.x === this.destination.x ) && ( d.y === this.destination.y ) && !force ) return;
this.destination = d;
this.segments = this._getMeasurementSegments();
this._computeDistance(gridSpaces);
// 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;
}
/* -------------------------------------------- */
/**
* While measurement is in progress, update the destination to be the central point of the target grid space.
* @param {Point} destination The current pixel coordinates of the mouse movement
* @returns {Point} The destination point, a center of a grid space
* @protected
*/
_getMeasurementDestination(destination) {
const center = canvas.grid.getCenter(destination.x, destination.y);
return new PIXI.Point(...center);
}
/* -------------------------------------------- */
/**
* 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 waypoints = this.waypoints.concat([this.destination]);
return waypoints.reduce((segments, p1, i) => {
if ( i === 0 ) return segments;
const p0 = waypoints[i-1];
const label = this.labels.children[i-1];
const ray = new Ray(p0, p1);
if ( ray.distance < 10 ) {
if ( label ) label.visible = false;
return segments;
}
segments.push({ray, label});
return segments;
}, []);
}
/* -------------------------------------------- */
/**
* Compute the distance of each segment and the total distance of the measured path.
* @param {boolean} gridSpaces Base distance on the number of grid spaces moved?
* @protected
*/
_computeDistance(gridSpaces) {
const distances = canvas.grid.measureDistances(this.segments, {gridSpaces});
let totalDistance = 0;
for ( let [i, d] of distances.entries() ) {
totalDistance += d;
let s = this.segments[i];
s.last = i === (this.segments.length - 1);
s.distance = d;
}
this.totalDistance = totalDistance;
}
/* -------------------------------------------- */
/**
* Get the text label for a segment of the measured path
* @param {RulerMeasurementSegment} segment
* @param {number} totalDistance
* @returns {string}
* @protected
*/
_getSegmentLabel(segment, totalDistance) {
const units = canvas.scene.grid.units;
let label = `${Math.round(segment.distance * 100) / 100} ${units}`;
if ( segment.last ) label += ` [${Math.round(totalDistance * 100) / 100} ${units}]`;
return label;
}
/* -------------------------------------------- */
/**
* Draw each segment of the measured path.
* @protected
*/
_drawMeasuredPath() {
const r = this.ruler.beginFill(this.color, 0.25);
for ( const segment of this.segments ) {
const {ray, distance, label, last} = segment;
if ( distance === 0 ) continue;
// Draw Line
r.moveTo(ray.A.x, ray.A.y).lineStyle(6, 0x000000, 0.5).lineTo(ray.B.x, ray.B.y)
.lineStyle(4, this.color, 0.25).moveTo(ray.A.x, ray.A.y).lineTo(ray.B.x, ray.B.y);
// Draw Waypoints
r.lineStyle(2, 0x000000, 0.5).drawCircle(ray.A.x, ray.A.y, 8);
if ( last ) r.drawCircle(ray.B.x, ray.B.y, 8);
// Draw Label
if ( label ) {
const text = this._getSegmentLabel(segment, this.totalDistance);
if ( text ) {
label.text = text;
label.alpha = last ? 1.0 : 0.5;
label.visible = true;
let labelPosition = ray.project((ray.distance + 50) / ray.distance);
label.position.set(labelPosition.x, labelPosition.y);
}
else label.visible = false;
}
}
r.endFill();
}
/* -------------------------------------------- */
/**
* Highlight the measurement required to complete the move in the minimum number of discrete spaces
* @param {RulerMeasurementSegment} segment
* @protected
*/
_highlightMeasurementSegment(segment) {
const {ray, distance} = segment;
if ( distance === 0 ) return;
const spacer = canvas.scene.grid.type === CONST.GRID_TYPES.SQUARE ? 1.41 : 1;
const nMax = Math.max(Math.floor(ray.distance / (spacer * Math.min(canvas.grid.w, canvas.grid.h))), 1);
const tMax = Array.fromRange(nMax+1).map(t => t / nMax);
// Track prior position
let prior = null;
// Iterate over ray portions
for ( let [i, t] of tMax.entries() ) {
let {x, y} = ray.project(t);
// Get grid position
let [r0, c0] = (i === 0) ? [null, null] : prior;
let [r1, c1] = canvas.grid.grid.getGridPositionFromPixels(x, y);
if ( r0 === r1 && c0 === c1 ) continue;
// Highlight the grid position
let [x1, y1] = canvas.grid.grid.getPixelsFromGridPosition(r1, c1);
canvas.grid.highlightPosition(this.name, {x: x1, y: y1, color: this.color});
// Skip the first one
prior = [r1, c1];
if ( i === 0 ) continue;
// If the positions are not neighbors, also highlight their halfway point
if ( !canvas.grid.isNeighbor(r0, c0, r1, c1) ) {
let th = tMax[i - 1] + (0.5 / nMax);
let {x, y} = ray.project(th);
let [rh, ch] = canvas.grid.grid.getGridPositionFromPixels(x, y);
let [xh, yh] = canvas.grid.grid.getPixelsFromGridPosition(rh, ch);
canvas.grid.highlightPosition(this.name, {x: xh, y: yh, color: this.color});
}
}
}
/* -------------------------------------------- */
/* Token Movement Execution */
/* -------------------------------------------- */
/**
* Determine whether a SPACE keypress event entails a legal token movement along a measured ruler
* @returns {Promise<boolean>} 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 ( game.paused && !game.user.isGM ) {
ui.notifications.warn("GAME.PausedWarning", {localize: true});
return false;
}
if ( !this.visible || !this.destination ) return false;
// Get the Token which should move
const token = this._getMovementToken();
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
await this._preMove(token);
await this._animateMovement(token);
await this._postMove(token);
// Clear the Ruler
this._endMeasurement();
return true;
}
/* -------------------------------------------- */
/**
* Acquire a Token, if any, which is eligible to perform a movement based on the starting point of the Ruler
* @returns {Token}
* @protected
*/
_getMovementToken() {
let [x0, y0] = Object.values(this.waypoints[0]);
let tokens = canvas.tokens.controlled;
if ( !tokens.length && game.user.character ) tokens = game.user.character.getActiveTokens();
if ( !tokens.length ) return null;
return tokens.find(t => {
let pos = new PIXI.Rectangle(t.x - 1, t.y - 1, t.w + 2, t.h + 2);
return pos.contains(x0, y0);
});
}
/* -------------------------------------------- */
/**
* 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 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<void>} A Promise which resolves once all animation is completed
* @protected
*/
async _animateMovement(token) {
this._state = Ruler.STATES.MOVING;
const wasPaused = game.paused;
// Determine offset of the initial origin relative to the Token top-left.
// This is important to position the token relative to the ruler origin for non-1x1 tokens.
const origin = this.segments[0].ray.A;
const s2 = canvas.scene.grid.type === CONST.GRID_TYPES.GRIDLESS ? 1 : (canvas.dimensions.size / 2);
const dx = Math.round((token.document.x - origin.x) / s2) * s2;
const dy = Math.round((token.document.y - origin.y) / s2) * s2;
// Iterate over each measured segment
let priorDest = undefined;
for ( const segment of this.segments ) {
const r = segment.ray;
const {x, y} = token.document;
// 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 = canvas.grid.grid._getRulerDestination(r, {x: dx, y: dy}, token);
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
* @returns {Promise<unknown>} A Promise which resolves once the animation for this segment is done
* @protected
*/
async _animateSegment(token, segment, destination) {
await token.document.update(destination);
const anim = CanvasAnimation.getAnimation(token.animationName);
return anim.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<void>}
* @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<void>}
* @protected
*/
async _postMove(token) {}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/**
* Handle the beginning of a new Ruler measurement workflow
* @param {PIXI.FederatedEvent} event The drag start event
* @see {Canvas._onDragLeftStart}
*/
_onDragStart(event) {
this.clear();
this._state = Ruler.STATES.STARTING;
this._addWaypoint(event.interactionData.origin);
}
/* -------------------------------------------- */
/**
* Handle left-click events on the Canvas during Ruler measurement.
* @param {PIXI.FederatedEvent} event The pointer-down event
* @see {Canvas._onClickLeft}
*/
_onClickLeft(event) {
const isCtrl = game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL);
if ( (this._state === 2) && isCtrl ) this._addWaypoint(event.interactionData.origin);
}
/* -------------------------------------------- */
/**
* Handle right-click events on the Canvas during Ruler measurement.
* @param {PIXI.FederatedEvent} event The pointer-down event
* @see {Canvas._onClickRight}
*/
_onClickRight(event) {
if ( (this._state === 2) && (this.waypoints.length > 1) ) {
this._removeWaypoint(event.interactionData.origin, {snap: !event.shiftKey});
return canvas.mouseInteractionManager._dragRight = false;
}
else return this._endMeasurement();
}
/* -------------------------------------------- */
/**
* Continue a Ruler measurement workflow for left-mouse movements on the Canvas.
* @param {PIXI.FederatedEvent} event The mouse move event
* @see {Canvas._onDragLeftMove}
*/
_onMouseMove(event) {
if ( this._state === Ruler.STATES.MOVING ) return;
// Extract event data
const mt = event.interactionData._measureTime || 0;
const {origin, destination} = event.interactionData;
if ( !canvas.dimensions.rect.contains(destination.x, destination.y)) return;
// Do not begin measuring unless we have moved at least 1/4 of a grid space
const dx = destination.x - origin.x;
const dy = destination.y - origin.y;
const distance = Math.hypot(dy, dx);
if ( !this.waypoints.length && (distance < (canvas.dimensions.size / 4))) return;
// Hide any existing Token HUD
canvas.hud.token.clear();
delete event.interactionData.hudState;
// Draw measurement updates
if ( Date.now() - mt > 50 ) {
this.measure(destination, {gridSpaces: !event.shiftKey});
event.interactionData._measureTime = Date.now();
this._state = Ruler.STATES.MEASURING;
}
}
/* -------------------------------------------- */
/**
* Conclude a Ruler measurement workflow by releasing the left-mouse button.
* @param {PIXI.FederatedEvent} event The pointer-up event
* @see {Canvas._onDragLeftDrop}
*/
_onMouseUp(event) {
const isCtrl = event.ctrlKey || event.metaKey;
if ( !isCtrl ) this._endMeasurement();
}
/* -------------------------------------------- */
/**
* Handle the addition of a new waypoint in the Ruler measurement path
* @param {PIXI.Point} point
* @private
*/
_addWaypoint(point) {
const center = canvas.grid.getCenter(point.x, point.y);
this.waypoints.push(new PIXI.Point(center[0], center[1]));
this.labels.addChild(new PreciseText("", CONFIG.canvasTextStyle));
}
/* -------------------------------------------- */
/**
* Handle the removal of a waypoint in the Ruler measurement path
* @param {PIXI.Point} point The current cursor position to snap to
* @param {boolean} [snap] Snap exactly to grid spaces?
* @private
*/
_removeWaypoint(point, {snap=true}={}) {
this.waypoints.pop();
if ( this.labels.children.length ) this.labels.removeChildAt(this.labels.children.length - 1).destroy();
this.measure(point, {gridSpaces: snap, force: true});
}
/* -------------------------------------------- */
/**
* Handle the conclusion of a Ruler measurement workflow
* @private
*/
_endMeasurement() {
this.clear();
game.user.broadcastActivity({ruler: null});
canvas.mouseInteractionManager.state = MouseInteractionManager.INTERACTION_STATES.HOVER;
}
/* -------------------------------------------- */
/* Saving and Loading
/* -------------------------------------------- */
/**
* @typedef {object} RulerData
* @property {number} _state The ruler measurement state.
* @property {string} name A unique name for the ruler containing the owning user's ID.
* @property {PIXI.Point} destination The current point the ruler has been extended to.
* @property {string} class The class name of this ruler instance.
* @property {PIXI.Point[]} waypoints Additional waypoints along the ruler's length, including the starting point.
*/
/**
* Package Ruler data to an object which can be serialized to a string.
* @returns {RulerData}
*/
toJSON() {
return {
class: "Ruler",
name: `Ruler.${game.user.id}`,
waypoints: this.waypoints,
destination: this.destination,
_state: this._state
};
}
/* -------------------------------------------- */
/**
* Update a Ruler instance using data provided through the cursor activity socket
* @param {Object} data Ruler data with which to update the display
*/
update(data) {
if ( data.class !== "Ruler" ) throw new Error("Unable to recreate Ruler instance from provided data");
// Populate data
this.waypoints = data.waypoints;
this._state = data._state;
// Ensure labels are created
for ( let i=0; i<this.waypoints.length - this.labels.children.length; i++) {
this.labels.addChild(new PreciseText("", CONFIG.canvasTextStyle));
}
// Measure current distance
if ( data.destination ) this.measure(data.destination);
}
}
/**
* 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 lf = this.lighting.filter = VisualEffectsMaskingFilter.create({
filterMode: VisualEffectsMaskingFilter.FILTER_MODES.BACKGROUND,
uVisionSampler: canvas.masks.vision.renderTexture
});
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) {
this.filter = VisualEffectsMaskingFilter.create({
filterMode: VisualEffectsMaskingFilter.FILTER_MODES.COLORATION,
uVisionSampler: canvas.masks.vision.renderTexture
});
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 CanvasLayer for displaying illumination visual effects
* @category - Canvas
*/
class CanvasIlluminationEffects extends CanvasLayer {
constructor() {
super();
/**
* A minimalist texture that holds the background color.
* @type {PIXI.Texture}
*/
this.backgroundColorTexture = this._createBackgroundColorTexture();
// Other initializations
this.background = this.addChild(new PIXI.LegacyGraphics());
this.lights = this.addChild(new PIXI.Container());
this.lights.sortableChildren = true;
}
/**
* Is global illumination currently applied to the canvas?
* @type {boolean}
*/
get globalLight() {
return canvas.effects.globalLightSource && !canvas.effects.globalLightSource.disabled;
}
/**
* The filter used to mask visual effects on this layer
* @type {VisualEffectsMaskingFilter}
*/
filter;
/* -------------------------------------------- */
/**
* Set or retrieve the illumination background color.
* @param {number} color
*/
set backgroundColor(color) {
this.background.tint = color;
const cb = 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();
}
/* -------------------------------------------- */
/**
* 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) {
this.darknessLevel = canvas.darknessLevel;
this.filter = VisualEffectsMaskingFilter.create({
filterMode: VisualEffectsMaskingFilter.FILTER_MODES.ILLUMINATION,
uVisionSampler: canvas.masks.vision.renderTexture
});
this.filter.blendMode = PIXI.BLEND_MODES.MULTIPLY;
this.filterArea = canvas.app.renderer.screen;
this.filters = [this.filter];
canvas.effects.visualEffectsMaskingFilters.add(this.filter);
this.drawBaseline();
}
/* -------------------------------------------- */
/** @override */
async _tearDown(options) {
canvas.effects.visualEffectsMaskingFilters.delete(this.filter);
this.background.clear();
this.clear();
}
/* -------------------------------------------- */
/**
* Draw illumination baseline
*/
drawBaseline() {
const bgRect = canvas.dimensions.rect.clone().pad(CONFIG.Canvas.blurStrength * 2);
this.background.clear().beginFill(0xFFFFFF, 1.0).drawShape(bgRect).endFill();
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
updateGlobalLight() {
const msg = "CanvasIlluminationEffects#updateGlobalLight has been deprecated.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return false;
}
}
// 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}
* @category - Canvas
*
* @property {PIXI.Container} explored The exploration container which tracks exploration progress
* @property {CanvasVisionContainer} vision The container of current vision exploration
*/
class CanvasVisibility extends CanvasLayer {
/**
* The current vision container which provides line-of-sight for vision sources and field-of-view of light sources.
* @type {PIXI.Container}
*/
vision;
/**
* The canonical line-of-sight polygon which defines current Token visibility.
* @type {PIXI.Graphics}
*/
los;
/**
* The optional visibility overlay sprite that should be drawn instead of the unexplored color in the fog of war.
* @type {PIXI.Sprite}
*/
visibilityOverlay;
/**
* 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 SpriteMesh which holds a cached texture of lights field of vision.
* These elements are less likely to change during the course of a game.
* @type {SpriteMesh}
*/
#lightsSprite;
/**
* 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, any: boolean}}
*/
lightingVisibility = {
background: VisionMode.LIGHTING_VISIBILITY.ENABLED,
illumination: VisionMode.LIGHTING_VISIBILITY.ENABLED,
coloration: VisionMode.LIGHTING_VISIBILITY.ENABLED,
any: true
};
/**
* Map of the point sources active and updateId states
* - The wasActive
* - The updateId
* @type {Map<number, object<boolean, number>>}
*/
#pointSourcesStates = 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;
/* -------------------------------------------- */
/**
* 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;
/* -------------------------------------------- */
/* Layer Initialization */
/* -------------------------------------------- */
/**
* Initialize all Token vision sources which are present on this layer
*/
initializeSources() {
// Deactivate vision masking before destroying textures
canvas.effects.toggleMaskingFilters(false);
// Get an array of tokens from the vision source collection
const sources = canvas.effects.visionSources;
// Update vision sources
sources.clear();
for ( const token of canvas.tokens.placeables ) {
token.updateVisionSource({defer: true});
}
for ( const token of canvas.tokens.preview.children ) {
token.updateVisionSource({defer: true});
}
// Initialize vision modes
this.visionModeData.source = this.#getSingleVisionSource();
this.#configureLightingVisibility();
this.#updateLightingPostProcessing();
this.#updateTintPostProcessing();
// Call hooks
Hooks.callAll("initializeVisionSources", sources);
}
/* -------------------------------------------- */
/**
* Identify whether there is one singular vision source active (excluding previews).
* @returns {VisionSource|null} A singular source, or null
*/
#getSingleVisionSource() {
let singleVisionSource = null;
for ( const visionSource of canvas.effects.visionSources ) {
if ( !visionSource.active ) continue;
if ( singleVisionSource && visionSource.isPreview ) continue;
singleVisionSource = visionSource;
if ( !singleVisionSource.isPreview ) return singleVisionSource;
}
return singleVisionSource;
}
/* -------------------------------------------- */
/**
* Configure the visibility of individual lighting channels based on the currently active vision source(s).
*/
#configureLightingVisibility() {
const vm = this.visionModeData.source?.visionMode;
const lv = this.lightingVisibility;
const lvs = VisionMode.LIGHTING_VISIBILITY;
foundry.utils.mergeObject(lv, {
background: CanvasVisibility.#requireBackgroundShader(vm),
illumination: vm?.lighting.illumination.visibility ?? lvs.ENABLED,
coloration: vm?.lighting.coloration.visibility ?? lvs.ENABLED
});
lv.any = (lv.background + lv.illumination + lv.coloration) > 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
canvas.effects.resetPostProcessingFilters();
for ( const layer of ["background", "illumination", "coloration"] ) {
if ( layer in lightingOptions ) {
const options = lightingOptions[layer];
canvas.effects.activatePostProcessingFilters(layer, 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 defaultTint = VisualEffectsMaskingFilter.defaultUniforms.tint;
const color = singleSource?.colorRGB;
for ( const f of canvas.effects.visualEffectsMaskingFilters ) {
const tintedLayer = activeOptions[f.filterMode]?.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);
// 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);
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();
// Base vision to provide minimum sight
vision.base = vision.addChild(new PIXI.LegacyGraphics());
vision.base.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
// The field of vision container
vision.fov = vision.addChild(new PIXI.Container());
// SpriteMesh that holds the cached elements that provides contribution to the field of vision
vision.fov.lightsSprite = this.#lightsSprite = vision.fov.addChild(new SpriteMesh(Canvas.getRenderTexture({
textureConfiguration: this.textureConfiguration
})));
vision.fov.lightsSprite.position.set(dims.sceneX, dims.sceneY);
vision.fov.lightsSprite.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
// Graphic that holds elements which are not changing often during the course of a game (light sources)
// This graphics is cached in the lightsSprite SpriteMesh
vision.fov.lights = vision.fov.addChild(new PIXI.LegacyGraphics());
vision.fov.lights.cullable = false;
vision.fov.lights.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
vision.fov.lights.renderable = false;
// Graphic that holds elements which are changing often (token vision and light sources)
vision.fov.tokens = vision.fov.addChild(new PIXI.LegacyGraphics());
vision.fov.tokens.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
// Handling of the line of sight
vision.los = vision.addChild(new PIXI.LegacyGraphics());
vision.los.preview = vision.los.addChild(new PIXI.LegacyGraphics());
vision.mask = vision.los;
return vision;
}
/* -------------------------------------------- */
/** @override */
async _tearDown(options) {
if ( this.#initialized ) {
canvas.masks.vision.detachVision();
this.#pointSourcesStates.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();
}
/* -------------------------------------------- */
/**
* 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() {
if ( !this.vision?.children.length ) return;
const fillColor = 0xFF0000;
const vision = this.vision;
// A flag to know if the lights cache render texture need to be refreshed
let refreshCache = false;
// A flag to know if fog need to be refreshed.
let commitFog = false;
// Checking if the lights cache need a full redraw
let lightsFullRedraw = this.#checkLights();
if ( lightsFullRedraw ) {
this.#pointSourcesStates.clear();
vision.fov.lights.clear();
}
vision.base.clear();
vision.base.beginFill(fillColor, 1.0);
vision.fov.lights.beginFill(fillColor, 1.0);
vision.fov.tokens.clear();
vision.fov.tokens.beginFill(fillColor, 1.0);
vision.los.clear();
vision.los.beginFill(fillColor, 1.0);
vision.los.preview.clear();
vision.los.preview.beginFill(fillColor, 1.0);
// Iterating over each light source
for ( const lightSource of canvas.effects.lightSources ) {
// The light source is providing vision and has an active layer?
if ( lightSource.active && lightSource.data.vision ) {
if ( !lightSource.isPreview ) vision.los.drawShape(lightSource.shape);
else vision.los.preview.drawShape(lightSource.shape);
}
// The light source is emanating from a token?
if ( lightSource.object instanceof Token ) {
if ( !lightSource.active ) continue;
if ( !lightSource.isPreview ) vision.fov.tokens.drawShape(lightSource.shape);
else vision.base.drawShape(lightSource.shape);
continue;
}
// Determine whether this light source needs to be drawn to the texture
let draw = lightsFullRedraw;
if ( !lightsFullRedraw ) {
const priorState = this.#pointSourcesStates.get(lightSource);
if ( !priorState || priorState.wasActive === false ) draw = lightSource.active;
}
// Save the state of this light source
this.#pointSourcesStates.set(lightSource,
{wasActive: lightSource.active, updateId: lightSource.updateId});
if ( !lightSource.active ) continue;
refreshCache = true;
if ( draw ) vision.fov.lights.drawShape(lightSource.shape);
}
// Do we need to cache the lights into the lightsSprite render texture?
// Note: With a full redraw, we need to refresh the texture cache, even if no elements are present
if ( refreshCache || lightsFullRedraw ) this.#cacheLights(lightsFullRedraw);
// Iterating over each vision source
for ( const visionSource of canvas.effects.visionSources ) {
if ( !visionSource.active ) continue;
// Draw FOV polygon or provide some baseline visibility of the token's space
if ( (visionSource.radius > 0) && !visionSource.data.blinded && !visionSource.isPreview ) {
vision.fov.tokens.drawShape(visionSource.fov);
} else vision.base.drawShape(visionSource.fov);
// Draw LOS mask (with exception for blinded tokens)
if ( !visionSource.data.blinded && !visionSource.isPreview ) {
vision.los.drawShape(visionSource.los);
commitFog = true;
} else vision.los.preview.drawShape(visionSource.data.blinded ? visionSource.fov : visionSource.los);
}
// Fill operations are finished for LOS and FOV lights and tokens
vision.base.endFill();
vision.fov.lights.endFill();
vision.fov.tokens.endFill();
vision.los.endFill();
vision.los.preview.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());
}
/* -------------------------------------------- */
/**
* Check if the lightsSprite render texture cache needs to be fully redrawn.
* @returns {boolean} return true if the lights need to be redrawn.
*/
#checkLights() {
// Counter to detect deleted light source
let lightCount = 0;
// First checking states changes for the current effects lightsources
for ( const lightSource of canvas.effects.lightSources ) {
if ( lightSource.object instanceof Token ) continue;
const state = this.#pointSourcesStates.get(lightSource);
if ( !state ) continue;
if ( (state.updateId !== lightSource.updateId) || (state.wasActive && !lightSource.active) ) return true;
lightCount++;
}
// Then checking if some lightsources were deleted
return this.#pointSourcesStates.size > lightCount;
}
/* -------------------------------------------- */
/**
* Cache into the lightsSprite render texture elements contained into vision.fov.lights
* 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) {
this.vision.fov.lights.renderable = true;
const dims = canvas.dimensions;
this.#renderTransform.tx = -dims.sceneX;
this.#renderTransform.ty = -dims.sceneY;
// Render the currently revealed vision to the texture
canvas.app.renderer.render(this.vision.fov.lights, {
renderTexture: this.#lightsSprite.texture,
clear: clearTexture,
transform: this.#renderTransform
});
this.vision.fov.lights.renderable = false;
}
/* -------------------------------------------- */
/* 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
for ( let t of canvas.tokens.placeables ) {
t._refreshVisibility(); // TODO: set render flag instead in the future
}
// Door Icons
for ( let d of canvas.controls.doors.children ) {
d.visible = d.isVisible;
}
// Map Notes
for ( let n of canvas.notes.placeables ) {
n._refreshVisibility(); // TODO: set render flag instead in the future
}
Hooks.callAll("sightRefresh", this);
}
/* -------------------------------------------- */
/**
* @typedef {Object} CanvasVisibilityTestConfig
* @property {PlaceableObject} object The target object
* @property {CanvasVisibilityTest[]} tests An array of visibility tests
*/
/**
* @typedef {Object} CanvasVisibilityTest
* @property {PIXI.Point} point
* @property {Map<VisionSource, boolean>} 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 {PlaceableObject|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, {tolerance=2, object=null}={}) {
// 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;
// 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);
// Prepare an array of test points depending on the requested tolerance
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 config = {
object,
tests: offsets.map(o => ({
point: new PIXI.Point(point.x + o[0], point.y + o[1]),
los: new Map()
}))
};
const modes = CONFIG.Canvas.detectionModes;
// First test basic detection for light sources which specifically provide vision
for ( const lightSource of canvas.effects.lightSources.values() ) {
if ( !lightSource.data.vision || !lightSource.active ) continue;
const result = lightSource.testVisibility(config);
if ( result === true ) return true;
}
// Second test basic detection tests for vision sources
for ( const visionSource of canvas.effects.visionSources.values() ) {
if ( !visionSource.active ) continue;
// Skip sources that are not both inside the scene or both inside the buffer
if ( inBuffer === sr.contains(visionSource.x, visionSource.y) ) continue;
const token = visionSource.object.document;
const basic = token.detectionModes.find(m => m.id === DetectionMode.BASIC_MODE_ID);
if ( !basic ) continue;
const result = modes.basicSight.testVisibility(visionSource, basic, config);
if ( result === true ) return true;
}
// Lastly test special detection modes for vision sources
if ( !(object instanceof Token) ) return false; // Special detection modes can only detect tokens
for ( const visionSource of canvas.effects.visionSources.values() ) {
if ( !visionSource.active ) continue;
// Skip sources that are not both inside the scene or both inside the buffer
if ( inBuffer === sr.contains(visionSource.x, visionSource.y) ) continue;
const token = visionSource.object.document;
for ( const mode of token.detectionModes ) {
if ( mode.id === DetectionMode.BASIC_MODE_ID ) 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;
}
/* -------------------------------------------- */
/* 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 overlayTexture = canvas.sceneTextures.fogOverlay ?? getTexture(canvas.scene.fogOverlay);
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.#initializeInverseOcclusionFilter();
this.mask = canvas.masks.scene;
this.sortableChildren = true;
this.eventMode = "none";
}
/**
* Sorting values to deal with ties.
* @type {number}
*/
static PRIMARY_SORT_ORDER = 1000;
/* -------------------------------------------- */
/**
* Initialize the inverse occlusion filter.
*/
#initializeInverseOcclusionFilter() {
this.occlusionFilter = WeatherOcclusionMaskFilter.create({
occlusionTexture: canvas.masks.depth.renderTexture
});
this.occlusionFilter.enabled = false;
this.occlusionFilter.elevation = this.#elevation;
this.filterArea = canvas.app.renderer.screen;
this.filters = [this.occlusionFilter];
}
/* -------------------------------------------- */
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {name: "effects"});
}
/* -------------------------------------------- */
/**
* Array of weather effects linked to this weather container.
* @type {Map<string,(ParticleEffect|WeatherShaderEffect)[]>}
*/
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;
/* -------------------------------------------- */
/**
* Define an elevation property on the WeatherEffects layer.
* This approach is used for now until the weather elevation property is formally added to the Scene data schema.
* @type {number}
*/
get elevation() {
return this.#elevation;
}
set elevation(value) {
this.occlusionFilter.elevation = this.#elevation = value;
canvas.primary.sortDirty = true;
canvas.perception.update({refreshTiles: true});
}
#elevation = Infinity;
/* -------------------------------------------- */
/** @override */
async _draw(options) {
const effect = CONFIG.weatherEffects[canvas.scene.weather];
this.initializeEffects(effect);
}
/* -------------------------------------------- */
/** @override */
async _tearDown(options) {
this.clearEffects();
}
/* -------------------------------------------- */
/* 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 ) return this.occlusionFilter.enabled = false;
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;
}
// 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.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;
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;
}
}
/**
* The base grid class.
* This double-dips to implement the "gridless" option
*/
class BaseGrid extends PIXI.Container {
constructor(options) {
super();
this.options = options;
/**
* Grid Unit Width
*/
this.w = options.dimensions.size;
/**
* Grid Unit Height
*/
this.h = options.dimensions.size;
}
/* -------------------------------------------- */
/**
* Returns the class responsible for the implementation of a given grid type.
* @param {number} gridType The grid type. {@see CONST.GRID_TYPES}
* @returns {Function} (typeof BaseGrid) A constructor for a grid of the given type.
*/
static implementationFor(gridType) {
const types = CONST.GRID_TYPES;
if ( gridType === types.SQUARE ) return SquareGrid;
else if ( [types.HEXEVENR, types.HEXODDR, types.HEXEVENQ, types.HEXODDQ].includes(gridType) ) return HexagonalGrid;
return BaseGrid;
}
/* -------------------------------------------- */
/**
* Calculate the total size of the canvas with padding applied, as well as the top-left co-ordinates of the inner
* rectangle that houses the scene.
* @param {number} gridType The grid type to calculate padding for. {@see CONST.GRID_TYPES}
* @param {number} width The width of the scene.
* @param {number} height The height of the scene.
* @param {number} size The grid size.
* @param {number} padding The percentage of padding.
* @param {object} [options] Options to configure the padding calculation.
* @param {boolean} [options.legacy] Are we computing padding for a legacy scene?
* @returns {{width: number, height: number, x: number, y: number}}
*/
static calculatePadding(gridType, width, height, size, padding, options={}) {
const x = (padding * width).toNearest(size, "ceil");
const y = (padding * height).toNearest(size, "ceil");
return {
width: width + (2 * x),
height: height + (2 * y),
x, y
};
}
/* -------------------------------------------- */
/**
* Draw the grid. Subclasses are expected to override this method to perform their type-specific drawing logic.
* @param {object} [options] Override settings used in place of those saved to the scene data.
* @param {string|null} [options.color=null] The grid color.
* @param {number|null} [options.alpha=null] The grid transparency.
* @returns {BaseGrid}
*/
draw(options={}) {
const {color, alpha, gridColor, gridAlpha} = options;
/** @deprecated since v10 */
if ( gridColor !== undefined ) {
foundry.utils.logCompatibilityWarning("You are passing the gridColor parameter to SquareGrid#draw which is "
+ "deprecated in favor of the color parameter.", {since: 10, until: 12});
if ( color === undefined ) options.color = gridColor;
}
/** @deprecated since v10 */
if ( gridAlpha !== undefined ) {
foundry.utils.logCompatibilityWarning("You are passing the gridAlpha parameter to SquareGrid#draw which is "
+ "deprecate in favor of the alpha parameter.", {since: 10, until: 12});
if ( alpha === undefined ) options.alpha = gridAlpha;
}
this.removeChildren().forEach(c => c.destroy(true));
return this;
}
/* -------------------------------------------- */
/**
* Highlight a grid position for a certain coordinate
* @param {GridHighlight} layer The highlight layer to use
* @param {object} [options] Additional options to configure behaviour.
* @param {number} [options.x] The x-coordinate of the highlighted position
* @param {number} [options.y] The y-coordinate of the highlighted position
* @param {number} [options.color=0x33BBFF] The hex fill color of the highlight
* @param {number|null} [options.border=null] The hex 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
*/
highlightGridPosition(layer, {x, y, color=0x33BBFF, border=null, alpha=0.25, shape=null}={}) {
if ( !shape ) return;
layer.beginFill(color, alpha);
if ( Number.isFinite(border) ) layer.lineStyle(2, border, Math.min(alpha*1.5, 1.0));
layer.drawShape(shape).endFill();
}
/* -------------------------------------------- */
/**
* Tests whether the given co-ordinates at the center of a grid space are contained within a given shape.
* @param {number} x The X co-ordinate.
* @param {number} y The Y co-ordinate.
* @param {PIXI.Polygon} shape The shape.
* @returns {boolean}
* @private
*/
_testShape(x, y, shape) {
for ( let dx = -0.5; dx <= 0.5; dx += 0.5 ) {
for ( let dy = -0.5; dy <= 0.5; dy += 0.5 ) {
if ( shape.contains(x + dx, y + dy) ) return true;
}
}
return false;
}
/* -------------------------------------------- */
/* Grid Measurement Methods
/* -------------------------------------------- */
/**
* Given a pair of coordinates (x, y) - return the top-left of the grid square which contains that point
* @return {number[]} An Array [x, y] of the top-left coordinate of the square which contains (x, y)
*/
getTopLeft(x, y) {
let [row, col] = this.getGridPositionFromPixels(x,y);
return this.getPixelsFromGridPosition(row, col);
}
/* -------------------------------------------- */
/**
* Given a pair of coordinates (x, y), return the center of the grid square which contains that point
* @param {number} x The x-coordinate
* @param {number} y The y-coordinate
* @return {number[]} An array [cx, cy] of the central point of the grid space which contains (x, y)
*/
getCenter(x, y) {
return [x, y];
}
/* -------------------------------------------- */
/**
* Given a pair of coordinates (x1,y1), return the grid coordinates (x2,y2) which represent the snapped position
* Under a "gridless" system, every pixel position is a valid snapping position
*
* @param {number} x The exact target location x
* @param {number} y The exact target location y
* @param {number|null} [interval] An interval of grid spaces at which to snap.
* At interval=1, snapping occurs at pixel intervals defined by the grid size
* At interval=2, snapping would occur at the center-points of each grid size
* At interval=null, no snapping occurs
* @param {object} [options] Additional options to configure snapping behaviour.
* @param {Token} [options.token] The token that is being moved.
* @returns {{x, y}} An object containing the coordinates of the snapped location
*/
getSnappedPosition(x, y, interval=null, options={}) {
interval = interval ?? 1;
return {
x: x.toNearest(this.w / interval),
y: y.toNearest(this.h / interval)
};
}
/* -------------------------------------------- */
/**
* Given a pair of pixel coordinates, return the grid position as an Array.
* Always round down to the nearest grid position so the pixels are within the grid space (from top-left).
* @param {number} x The x-coordinate pixel position
* @param {number} y The y-coordinate pixel position
* @returns {number[]} An array representing the position in grid units
*/
getGridPositionFromPixels(x, y) {
return [x, y].map(Math.round);
}
/* -------------------------------------------- */
/**
* Given a pair of grid coordinates, return the pixel position as an Array.
* Always round up to a whole pixel so the pixel is within the grid space (from top-left).
* @param {number} x The x-coordinate grid position
* @param {number} y The y-coordinate grid position
* @returns {number[]} An array representing the position in pixels
*/
getPixelsFromGridPosition(x, y) {
return [x, y].map(Math.round);
}
/* -------------------------------------------- */
/**
* Shift a pixel position [x,y] by some number of grid units dx and dy
* @param {number} x The starting x-coordinate in pixels
* @param {number} y The starting y-coordinate in pixels
* @param {number} dx The number of grid positions to shift horizontally
* @param {number} dy The number of grid positions to shift vertically
* @param {object} [options] Additional options to configure shift behaviour.
* @param {Token} [options.token] The token that is being shifted.
*/
shiftPosition(x, y, dx, dy, options={}) {
let s = canvas.dimensions.size;
return [x + (dx*s), y + (dy*s)];
}
/* -------------------------------------------- */
/**
* Measure the distance traversed over an array of measured segments
* @param {object[]} segments An Array of measured movement segments
* @param {MeasureDistancesOptions} options Additional options which modify the measurement
* @returns {number[]} An Array of distance measurements for each segment
*/
measureDistances(segments, options={}) {
const d = canvas.dimensions;
return segments.map(s => {
return (s.ray.distance / d.size) * d.distance;
});
}
/* -------------------------------------------- */
/**
* Get the grid row and column positions which are neighbors of a certain position
* @param {number} row The grid row coordinate against which to test for neighbors
* @param {number} col The grid column coordinate against which to test for neighbors
* @returns {Array<[number, number]>} An array of grid positions which are neighbors of the row and column
*/
getNeighbors(row, col) {
return [];
}
/* -------------------------------------------- */
/**
* Determine a placeable's bounding box based on the size of the grid.
* @param {number} w The width in grid spaces.
* @param {number} h The height in grid spaces.
* @returns {PIXI.Rectangle}
*/
getRect(w, h) {
return new PIXI.Rectangle(0, 0, w * this.w, h * this.h);
}
/* -------------------------------------------- */
/**
* Calculate the resulting token position after moving along a ruler segment.
* @param {Ray} ray The ray being moved along.
* @param {Point} offset The offset of the ruler's origin relative to the token's position.
* @param {Token} token The token placeable being moved.
* @internal
*/
_getRulerDestination(ray, offset, token) {
const [x, y] = this.getTopLeft(ray.B.x + offset.x, ray.B.y + offset.y);
return {x, y};
}
}
/**
* @typedef {Object} HexGridConfiguration
* @property {boolean} columns Columnar orientation?
* @property {boolean} even Offset even rows?
* @property {number} size Hex size in pixels
* @property {number} [width] Hex width in pixels
* @property {number} [height] Hex height in pixels
* @property {boolean} [legacy] Legacy hex grid computation (not recommended)
*/
/**
* @typedef {Object} HexCubeCoordinate
* @property {number} q Coordinate along the SW - NE axis
* @property {number} r Coordinate along the S - N axis
* @property {number} s Coordinate along the NW - SE axis
*/
/**
* @typedef {Object} HexOffsetCoordinate
* @property {number} row The row coordinate
* @property {number} col The column coordinate
*/
/**
* A helper class which represents a single hexagon as part of a HexagonalGrid.
* This class relies on having an active canvas scene in order to know the configuration of the hexagonal grid.
*/
class GridHex {
/**
* Construct a GridHex instance by providing a hex coordinate.
* @param {HexOffsetCoordinate|HexCubeCoordinate} coordinate The coordinates of the hex to construct
* @param {HexGridConfiguration} config The grid configuration used for this hex
*/
constructor(coordinate, config) {
// Verify config data
config.columns ??= true;
config.even ??= false;
config.size ??= 100;
/**
* The hexagonal grid type which this hex belongs to.
* @type {HexGridConfiguration}
*/
this.config = config;
// Cube coordinate provided
if ( ["q", "r", "s"].every(k => k in coordinate) ) {
this.cube = coordinate;
this.offset = HexagonalGrid.cubeToOffset(this.cube, this.config);
}
// Offset coordinate provided
else if ( ["row", "col"].every(k => k in coordinate) ) {
this.offset = coordinate;
this.cube = HexagonalGrid.offsetToCube(this.offset, this.config);
}
// Invalid input
else throw new Error("The GridHex constructor must be passed a HexCubeCoordinate or a HexOffsetCoordinate");
}
/**
* The cube coordinates representation of this Hexagon
* @type {HexCubeCoordinate}
*/
cube;
/**
* The offset coordinates representation of this Hexagon
* @type {HexOffsetCoordinate}
*/
offset;
/* -------------------------------------------- */
/**
* Return a reference to the pixel point in the center of this hexagon.
* @type {Point}
*/
get center() {
const {x, y} = this.topLeft;
const {width, height} = this.config;
return {x: x + (width / 2), y: y + (height / 2)};
}
/* -------------------------------------------- */
/**
* Return a reference to the pixel point of the top-left corner of this hexagon.
* @type {Point}
*/
get topLeft() {
const {x, y} = HexagonalGrid.offsetToPixels(this.offset, this.config);
return new PIXI.Point(x, y);
}
/* -------------------------------------------- */
/**
* Return the array of hexagons which are neighbors of this one.
* This result is un-bounded by the confines of the game canvas and may include hexes which are off-canvas.
* @returns {GridHex[]}
*/
getNeighbors() {
const neighbors = [];
const vectors = [[1, 0, -1], [1, -1, 0], [0, -1, 1], [-1, 0, 1], [-1, 1, 0], [0, 1, -1]];
for ( const v of vectors ) {
const n = this.shiftCube(...v);
if ( n ) neighbors.push(n);
}
return neighbors;
}
/* -------------------------------------------- */
/**
* Get a neighboring hex by shifting along cube coordinates
* @param {number} dq A number of hexes to shift along the q axis
* @param {number} dr A number of hexes to shift along the r axis
* @param {number} ds A number of hexes to shift along the s axis
* @returns {GridHex} The shifted hex
*/
shiftCube(dq, dr, ds) {
const {q, r, s} = this.cube;
return new this.constructor({q: q + dq, r: r + dr, s: s + ds}, this.config);
}
/* -------------------------------------------- */
/**
* Return whether this GridHex equals the same position as some other GridHex instance.
* @param {GridHex} other Some other GridHex
* @returns {boolean} Are the positions equal?
*/
equals(other) {
return (this.offset.row === other.offset.row) && (this.offset.col === other.offset.col);
}
}
/* -------------------------------------------- */
/**
* Construct a hexagonal grid
* @param {HexGridConfiguration} config The hexagonal grid configuration
* @extends {BaseGrid}
*/
class HexagonalGrid extends BaseGrid {
constructor(config) {
super(config);
/**
* Is this hex grid column-based (flat-topped), or row-based (pointy-topped)?
* @type {boolean}
*/
this.columnar = !!config.columns;
/**
* Is this hex grid even or odd?
* @type {boolean}
*/
this.even = !!config.even;
// Compute and cache hex dimensions
const {width, height} = HexagonalGrid.computeDimensions(this.options);
this.w = this.options.width = width;
this.h = this.options.height = height;
}
/* -------------------------------------------- */
/**
* Compute the grid configuration from a provided type
* @param {number} type The grid type
* @param {number} size The grid size in pixels
*/
static getConfig(type, size) {
const T = CONST.GRID_TYPES;
const config = {
columns: [T.HEXODDQ, T.HEXEVENQ].includes(type),
even: [T.HEXEVENR, T.HEXEVENQ].includes(type),
size: size
};
const {width, height} = HexagonalGrid.computeDimensions(config);
config.width = width;
config.height = height;
return config;
}
/* -------------------------------------------- */
/**
* Special border polygons for different token sizes.
* @type {Object<PointArray[]>}
*/
static POINTY_HEX_BORDERS = {
0.5: [[0, 0.25], [0.5, 0], [1, 0.25], [1, 0.75], [0.5, 1], [0, 0.75]],
1: [[0, 0.25], [0.5, 0], [1, 0.25], [1, 0.75], [0.5, 1], [0, 0.75]],
2: [
[.5, 0], [.75, 1/7], [.75, 3/7], [1, 4/7], [1, 6/7], [.75, 1], [.5, 6/7], [.25, 1], [0, 6/7], [0, 4/7],
[.25, 3/7], [.25, 1/7]
],
3: [
[.5, .1], [2/3, 0], [5/6, .1], [5/6, .3], [1, .4], [1, .6], [5/6, .7], [5/6, .9], [2/3, 1], [.5, .9], [1/3, 1],
[1/6, .9], [1/6, .7], [0, .6], [0, .4], [1/6, .3], [1/6, .1], [1/3, 0]
],
4: [
[.5, 0], [5/8, 1/13], [.75, 0], [7/8, 1/13], [7/8, 3/13], [1, 4/13], [1, 6/13], [7/8, 7/13], [7/8, 9/13],
[.75, 10/13], [.75, 12/13], [5/8, 1], [.5, 12/13], [3/8, 1], [.25, 12/13], [.25, 10/13], [1/8, 9/13],
[1/8, 7/13], [0, 6/13], [0, 4/13], [1/8, 3/13], [1/8, 1/13], [.25, 0], [3/8, 1/13]
]
};
/* -------------------------------------------- */
/**
* Special border polygons for different token sizes.
* @type {Object<PointArray[]>}
*/
static FLAT_HEX_BORDERS = {
0.5: [[0, 0.5], [0.25, 0], [0.75, 0], [1, 0.5], [0.75, 1], [0.25, 1]],
1: [[0, 0.5], [0.25, 0], [0.75, 0], [1, 0.5], [0.75, 1], [0.25, 1]],
2: [
[3/7, .25], [4/7, 0], [6/7, 0], [1, .25], [6/7, .5], [1, .75], [6/7, 1], [4/7, 1], [3/7, .75], [1/7, .75],
[0, .5], [1/7, .25]
],
3: [
[.4, 0], [.6, 0], [.7, 1/6], [.9, 1/6], [1, 1/3], [.9, .5], [1, 2/3], [.9, 5/6], [.7, 5/6], [.6, 1], [.4, 1],
[.3, 5/6], [.1, 5/6], [0, 2/3], [.1, .5], [0, 1/3], [.1, 1/6], [.3, 1/6]
],
4: [
[6/13, 0], [7/13, 1/8], [9/13, 1/8], [10/13, .25], [12/13, .25], [1, 3/8], [12/13, .5], [1, 5/8], [12/13, .75],
[10/13, .75], [9/13, 7/8], [7/13, 7/8], [6/13, 1], [4/13, 1], [3/13, 7/8], [1/13, 7/8], [0, .75], [1/13, 5/8],
[0, .5], [1/13, 3/8], [0, .25], [1/13, 1/8], [3/13, 1/8], [4/13, 0]
]
};
/* -------------------------------------------- */
/**
* A matrix of x and y offsets which is multiplied by the width/height vector to get pointy-top polygon coordinates
* @type {Array<number[]>}
*/
static get pointyHexPoints() {
return this.POINTY_HEX_BORDERS[1];
}
/* -------------------------------------------- */
/**
* A matrix of x and y offsets which is multiplied by the width/height vector to get flat-top polygon coordinates
* @type {Array<number[]>}
*/
static get flatHexPoints() {
return this.FLAT_HEX_BORDERS[1];
}
/* -------------------------------------------- */
/**
* An array of the points which define a hexagon for this grid shape
* @returns {PointArray[]}
*/
get hexPoints() {
return this.columnar ? this.constructor.flatHexPoints : this.constructor.pointyHexPoints;
}
/* -------------------------------------------- */
/* Grid Rendering
/* -------------------------------------------- */
/** @inheritdoc */
draw(options={}) {
super.draw(options);
let {color, alpha, dimensions} = foundry.utils.mergeObject(this.options, options);
if ( alpha === 0 ) return this;
// Set dimensions
this.width = dimensions.width;
this.height = dimensions.height;
// Draw grid polygons
this.addChild(this._drawGrid({color, alpha}));
return this;
}
/* -------------------------------------------- */
/**
* A convenience method for getting all the polygon points relative to a top-left [x,y] coordinate pair
* @param {number} x The top-left x-coordinate
* @param {number} y The top-right y-coordinate
* @param {number} [w] An optional polygon width
* @param {number} [h] An optional polygon height
* @param {PointArray[]} [points] An optional list of polygon points.
*/
getPolygon(x, y, w, h, points) {
w = w ?? this.w;
h = h ?? this.h;
points ??= this.hexPoints;
const poly = [];
for ( let i=0; i < points.length; i++ ) {
poly.push(x + (w * points[i][0]), y + (h * points[i][1]));
}
return poly;
}
/* -------------------------------------------- */
/**
* Get a border polygon based on the width and height of a given token.
* @param {number} w The width of the token in hexes.
* @param {number} h The height of the token in hexes.
* @param {number} p The padding size in pixels.
* @returns {number[]|null}
*/
getBorderPolygon(w, h, p) {
const points = this.columnar ? this.constructor.FLAT_HEX_BORDERS[w] : this.constructor.POINTY_HEX_BORDERS[w];
if ( (w !== h) || !points ) return null;
const p2 = p / 2;
const p4 = p / 4;
({width: w, height: h} = this.getRect(w, h));
return this.getPolygon(-p4, -p4, w + p2, h + p2, points);
}
/* -------------------------------------------- */
/**
* Draw the grid lines.
* @param {object} [preview] Override settings used in place of those saved to the scene data.
* @param {string|null} [preview.color=null] The grid color.
* @param {number|null} [preview.alpha=null] The grid transparency.
* @returns {Graphics}
* @private
*/
_drawGrid({color=null, alpha=null}={}) {
color = color ?? this.options.color;
alpha = alpha ?? this.options.alpha;
const columnar = this.columnar;
const ncols = Math.ceil(canvas.dimensions.width / this.w);
const nrows = Math.ceil(canvas.dimensions.height / this.h);
// Draw Grid graphic
const grid = new PIXI.Graphics();
grid.lineStyle({width: 1, color, alpha});
// Draw hex rows
if ( columnar ) this._drawColumns(grid, nrows, ncols);
else this._drawRows(grid, nrows, ncols);
return grid;
}
/* -------------------------------------------- */
/**
* Compute and draw row style hexagons.
* @param {PIXI.Graphics} grid Reference to the grid graphics.
* @param {number} nrows Number of rows.
* @param {number} ncols Number of columns.
* @protected
*/
_drawRows(grid, nrows, ncols) {
let shift = this.even ? 0 : 1;
nrows /= 0.75;
for ( let r=0; r<nrows; r++ ) {
let sx = (r % 2) === shift ? 0 : -0.5;
let y0 = r * this.h * 0.75;
for ( let c=0; c<ncols; c++ ) {
let x0 = (c+sx) * this.w;
this._drawHexagon(grid, this.getPolygon(x0, y0));
}
}
}
/* -------------------------------------------- */
/**
* Compute and draw column style hexagons.
* @param {PIXI.Graphics} grid Reference to the grid graphics.
* @param {number} nrows Number of rows.
* @param {number} ncols Number of columns.
* @protected
*/
_drawColumns(grid, nrows, ncols) {
let shift = this.even ? 0 : 1;
ncols /= 0.75;
for ( let c=0; c<ncols; c++ ) {
let sy = (c % 2) === shift ? 0 : -0.5;
let x0 = c * this.w * 0.75;
for ( let r=0; r<nrows; r++ ) {
let y0 = (r+sy) * this.h;
this._drawHexagon(grid, this.getPolygon(x0, y0));
}
}
}
/* -------------------------------------------- */
/**
* Draw a hexagon from polygon points.
* @param {PIXI.Graphics} grid Reference to the grid graphics.
* @param {number[]} poly Array of points to draw the hexagon.
* @protected
*/
_drawHexagon(grid, poly) {
grid.moveTo(poly[0], poly[1]);
for ( let i = 2; i < poly.length; i+=2 ) {
grid.lineTo(poly[i], poly[i+1]);
}
grid.lineTo(poly[0], poly[1]);
}
/* -------------------------------------------- */
/* Grid Measurement Methods
/* -------------------------------------------- */
/** @override */
getGridPositionFromPixels(x, y) {
let {row, col} = HexagonalGrid.pixelsToOffset({x, y}, this.options);
return [row, col];
}
/* -------------------------------------------- */
/** @override */
getPixelsFromGridPosition(row, col) {
const {x, y} = HexagonalGrid.offsetToPixels({row, col}, this.options);
return [x, y];
}
/* -------------------------------------------- */
/** @override */
getCenter(x, y) {
let [x0, y0] = this.getTopLeft(x, y);
return [x0 + (this.w / 2), y0 + (this.h / 2)];
}
/* -------------------------------------------- */
/** @override */
getSnappedPosition(x, y, interval=1, {token}={}) {
// At precision 5, return the center or nearest vertex
if ( interval === 5) {
const w4 = this.w / 4;
const h4 = this.h / 4;
// Distance relative to center
let [xc, yc] = this.getCenter(x, y);
let dx = x - xc;
let dy = y - yc;
let ox = dx.between(-w4, w4) ? 0 : Math.sign(dx);
let oy = dy.between(-h4, h4) ? 0 : Math.sign(dy);
// Closest to the center
if ( (ox === 0) && (oy === 0) ) return {x: xc, y: yc};
// Closest vertex based on offset
if ( this.columnar && (ox === 0) ) ox = Math.sign(dx) ?? -1;
if ( !this.columnar && (oy === 0) ) oy = Math.sign(dy) ?? -1;
return this._getClosestVertex(xc, yc, ox, oy);
}
// Start with the closest top-left grid position
if ( token ) {
if ( this.columnar && (token.document.height > 1) ) y += this.h / 2;
if ( !this.columnar && (token.document.width > 1) ) x += this.w / 2;
}
const offset = HexagonalGrid.pixelsToOffset({x, y}, this.options, "round");
const point = HexagonalGrid.offsetToPixels(offset, this.options);
// Adjust pixel coordinate for token size
let x0 = point.x;
let y0 = point.y;
if ( token ) [x0, y0] = this._adjustSnapForTokenSize(x0, y0, token);
// Snap directly at interval 1
if ( interval === 1 ) return {x: x0, y: y0};
// Round the remainder
const dx = (x - x0).toNearest(this.w / interval);
const dy = (y - y0).toNearest(this.h / interval);
return {x: x0 + dx, y: y0 + dy};
}
/* -------------------------------------------- */
_getClosestVertex(xc, yc, ox, oy) {
const b = ox + (oy << 2); // Bit shift to make a unique reference
const vertices = this.columnar
? {"-1": 0, "-5": 1, "-3": 2, 1: 3, 5: 4, 3: 5} // Flat hex vertices
: {"-5": 0, "-4": 1, "-3": 2, 5: 3, 4: 4, 3: 5}; // Pointy hex vertices
const idx = vertices[b];
const pt = this.hexPoints[idx];
return {
x: (xc - (this.w/2)) + (pt[0]*this.w),
y: (yc - (this.h/2)) + (pt[1]*this.h)
};
}
/* -------------------------------------------- */
/** @override */
shiftPosition(x, y, dx, dy, {token}={}) {
let [row, col] = this.getGridPositionFromPixels(x, y);
// Adjust diagonal moves for offset
let isDiagonal = (dx !== 0) && (dy !== 0);
if ( isDiagonal ) {
// Column orientation
if ( this.columnar ) {
let isEven = ((col+1) % 2 === 0) === this.options.even;
if ( isEven && (dy > 0)) dy--;
else if ( !isEven && (dy < 0)) dy++;
}
// Row orientation
else {
let isEven = ((row + 1) % 2 === 0) === this.options.even;
if ( isEven && (dx > 0) ) dx--;
else if ( !isEven && (dx < 0 ) ) dx++;
}
}
const [shiftX, shiftY] = this.getPixelsFromGridPosition(row+dy, col+dx);
if ( token ) return this._adjustSnapForTokenSize(shiftX, shiftY, token);
return [shiftX, shiftY];
}
/* -------------------------------------------- */
/** @inheritdoc */
_getRulerDestination(ray, offset, token) {
// Determine the number of hexes the ruler segment spans.
const from = this.getGridPositionFromPixels(ray.A.x, ray.A.y);
const to = this.getGridPositionFromPixels(ray.B.x, ray.B.y);
let [drow, dcol] = [to[0] - from[0], to[1] - from[1]];
// Adjust the token's position as though it had been shifted by that amount of hexes.
let [r, c] = this.getGridPositionFromPixels(token.x, token.y);
[r, c] = this._adjustPositionForTokenSize(r, c, token);
// Account for the alternating row/column pattern.
if ( this.columnar && ((c - from[1]) % 2) && (dcol % 2) ) {
const shift = this.even ? 1 : -1;
if ( from[1] % 2 ) drow += shift;
else drow -= shift;
}
if ( !this.columnar && ((r - from[0]) % 2) && (drow % 2) ) {
const shift = this.even ? 1 : -1;
if ( from[0] % 2 ) dcol += shift;
else dcol -= shift;
}
let [x, y] = this.getPixelsFromGridPosition(r + drow, c + dcol);
[x, y] = this._adjustSnapForTokenSize(x, y, token);
return {x, y};
}
/* -------------------------------------------- */
/**
* Implement special rules for snapping tokens of various sizes on a hex grid.
* @param {number} x The X co-ordinate of the hexagon's top-left bounding box.
* @param {number} y The Y co-ordinate of the hexagon's top-left bounding box.
* @param {Token} token The token.
* @returns {[number, number]}
* @protected
*/
_adjustSnapForTokenSize(x, y, token) {
if ( (token.document.width <= 1) && (token.document.height <= 1) ) {
const [x0, y0] = this.getCenter(x, y);
return [x0 - (token.w / 2), y0 - (token.h / 2)];
}
if ( this.columnar && (token.document.height > 1) ) y -= this.h / 2;
if ( !this.columnar && (token.document.width > 1) ) x -= this.w / 2;
return [x, y];
}
/* -------------------------------------------- */
/**
* Implement special rules for determining the grid position of tokens of various sizes on a hex grid.
* @param {number} row The row number.
* @param {number} col The column number.
* @param {Token} token The token.
* @returns {[number, number]} The adjusted row and column number.
* @protected
*/
_adjustPositionForTokenSize(row, col, token) {
if ( this.columnar && (token.document.height > 1) ) row++;
if ( !this.columnar && (token.document.width > 1) ) col++;
return [row, col];
}
/* -------------------------------------------- */
/** @inheritdoc */
getRect(w, h) {
if ( !this.columnar || (w < 1) ) w *= this.w;
else w = (this.w * .75 * (w - 1)) + this.w;
if ( this.columnar || (h < 1) ) h *= this.h;
else h = (this.h * .75 * (h - 1)) + this.h;
return new PIXI.Rectangle(0, 0, w, h);
}
/* -------------------------------------------- */
/** @inheritdoc */
static calculatePadding(gridType, width, height, size, padding, {legacy}={}) {
if ( legacy ) return super.calculatePadding(gridType, width, height, size, padding);
if ( !padding ) return { width, height, x: 0, y: 0 };
// Compute the hexagonal grid configuration
const gridConfig = this.getConfig(gridType, size);
const columns = gridConfig.columns;
const w = gridConfig.width;
const h = gridConfig.height;
// The grid size is equal to the short diagonal of the hexagon, so padding in that axis will divide evenly by the
// grid size. In the cross-axis, however, the hexagons do not stack but instead interleave. Multiplying the long
// diagonal by 75% gives us the amount of space each hexagon takes up in that axis without overlapping.
const x = columns ? w * .75 : h * .75;
let offsetX = Math.round((padding * width).toNearest(columns ? x : w, "ceil"));
let offsetY = Math.round((padding * height).toNearest(columns ? h : x, "ceil"));
// Ensure that the top-left hexagon of the scene rectangle is always a full hexagon for even grids and always a
// half hexagon for odd grids, by shifting the padding in the main axis by half a hex if the number of hexagons in
// the cross-axis is odd.
const crossEven = (Math.round((columns ? offsetX : offsetY) / x) % 2) === 0;
if ( !crossEven ) {
if ( columns ) offsetY += h * .5;
else offsetX += w * .5;
}
width = (width + (2 * offsetX)).toNearest(columns ? x : w);
height = (height + (2 * offsetY)).toNearest(columns ? h : x);
if ( columns ) width += w - x;
else height += h - x;
// Return the padding data
return {
width, height,
x: offsetX,
y: offsetY
};
}
/* -------------------------------------------- */
/* Grid Highlighting
/* -------------------------------------------- */
/** @override */
highlightGridPosition(layer, options={}) {
const {x, y} = options;
if ( !layer.highlight(x, y) ) return;
options.shape = new PIXI.Polygon(this.getPolygon(x, y, Math.ceil(this.w), Math.ceil(this.h)));
return super.highlightGridPosition(layer, options);
}
/* -------------------------------------------- */
/** @override */
getNeighbors(row, col) {
const hex = new GridHex({row, col}, this.options);
return hex.getNeighbors().map(n => [n.offset.row, n.offset.col]);
}
/* -------------------------------------------- */
/** @override */
measureDistances(segments, options={}) {
if ( !options.gridSpaces ) return super.measureDistances(segments, options);
return segments.map(s => {
let r = s.ray;
return this.measureDistance(r.A, r.B) * canvas.dimensions.distance;
});
}
/* -------------------------------------------- */
/**
* Measure the distance in grid units between two pixel-based coordinates.
* @param {Point} p0 The initial point
* @param {Point} p1 The terminal point
* @returns {number} The measured distance in grid units
*/
measureDistance(p0, p1) {
const [r0, c0] = this.getGridPositionFromPixels(p0.x, p0.y);
const [r1, c1] = this.getGridPositionFromPixels(p1.x, p1.y);
let hex0 = HexagonalGrid.offsetToCube({row: r0, col: c0}, this.options);
let hex1 = HexagonalGrid.offsetToCube({row: r1, col: c1}, this.options);
return HexagonalGrid.cubeDistance(hex0, hex1);
}
/* -------------------------------------------- */
/**
* Compute the shortest path between two hexagons using the A-star algorithm.
* See https://www.redblobgames.com/pathfinding/a-star/introduction.html for reference
* @param {GridHex} start The starting hexagon
* @param {GridHex} goal The objective hexagon
* @returns {{cost: number, path: GridHex[]}} The optimal path of hexagons to traverse
*/
getAStarPath(start, goal) {
const costs = new Map();
// Create a prioritized frontier sorted by increasing cost and heuristic distance
const frontier = [];
const {row, col} = goal.offset;
const explore = (hex, from, cost) => {
const dr = row - hex.offset.row;
const dc = col - hex.offset.col;
const heuristic = Math.pow(dr, 2) + Math.pow(dc, 2);
const idx = frontier.findIndex(l => (l.cost > cost) && (l.heuristic > heuristic));
if ( idx === -1 ) frontier.push({hex, cost, heuristic, from});
else frontier.splice(idx, 0, {hex, cost, heuristic, from});
costs.set(hex, cost);
};
explore(start, null, 0);
// Expand the frontier, exploring towards the goal
let current;
let solution;
while ( frontier.length ) {
current = frontier.shift();
if ( current.hex.equals(goal) ) {
solution = current;
if ( current.cost < Infinity ) break;
}
for ( const next of current.hex.getNeighbors() ) {
const deltaCost = next.getTravelCost?.call(next, current.hex) ?? 1;
const newCost = current.cost + deltaCost; // Total cost of reaching this hex
if ( costs.get(next) <= newCost ) continue; // We already made it here in the lowest-cost way
explore(next, current, newCost);
}
}
// Ensure a path was achieved
if ( !solution ) {
throw new Error("No valid path between these positions exists");
}
// Return the optimal path and cost
const path = [];
let c = solution;
while ( c.from ) {
path.unshift(c.hex);
c = c.from;
}
return {from: start, to: goal, cost: solution.cost, path};
}
/* -------------------------------------------- */
/* Conversion Functions */
/* -------------------------------------------- */
/**
* Convert an offset coordinate (row, col) into a cube coordinate (q, r, s).
* See https://www.redblobgames.com/grids/hexagons/ for reference
* Source code available https://www.redblobgames.com/grids/hexagons/codegen/output/lib-functions.js
* @param {HexOffsetCoordinate} offset The offset coordinate
* @param {{columns: boolean, even: boolean}} config The hex grid configuration
* @returns {HexCubeCoordinate} The cube coordinate
*/
static offsetToCube({row, col}={}, {columns=true, even=false}={}) {
const offset = even ? 1 : -1;
// Column orientation
if ( columns ) {
const q = col;
const r = row - ((col + (offset * (col & 1))) / 2);
return {q, r, s: 0 - q - r};
}
// Row orientation
else {
const q = col - ((row + (offset * (row & 1))) / 2);
const r = row;
return {q, r, s: 0 - q - r};
}
}
/* -------------------------------------------- */
/**
* Convert a cube coordinate (q, r, s) into an offset coordinate (row, col).
* See https://www.redblobgames.com/grids/hexagons/ for reference
* Source code available https://www.redblobgames.com/grids/hexagons/codegen/output/lib-functions.js
* @param {HexCubeCoordinate} cube The cube coordinate
* @param {HexGridConfiguration} config The hex grid configuration
* @returns {HexOffsetCoordinate} The offset coordinate
*/
static cubeToOffset({q, r, s}={}, {columns=true, even=false}={}) {
const offset = even ? 1 : -1;
// Column orientation
if ( columns ) {
const col = q;
const row = r + ((q + (offset * (q & 1))) / 2);
return {row, col};
}
// Row orientation
else {
const row = r;
const col = q + ((r + (offset * (r & 1))) / 2);
return {row, col};
}
}
/* -------------------------------------------- */
/**
* Given a cursor position (x, y), obtain the cube coordinate hex (q, r, s) of the hex which contains it
* http://justinpombrio.net/programming/2020/04/28/pixel-to-hex.html
* @param {Point} point The pixel point
* @param {HexGridConfiguration} config The hex grid configuration
* @returns {HexCubeCoordinate} The cube coordinate
*/
static pixelToCube({x, y}={}, config) {
const {size} = config;
const cx = x / (size / 2);
const cy = y / (size / 2);
// Fractional hex coordinates, might not satisfy (fx + fy + fz = 0) due to rounding
const fr = (2/3) * cx;
const fq = ((-1/3) * cx) + ((1 / Math.sqrt(3)) * cy);
const fs = ((-1/3) * cx) - ((1 / Math.sqrt(3)) * cy);
// Convert to integer triangle coordinates
const a = Math.ceil(fr - fq);
const b = Math.ceil(fq - fs);
const c = Math.ceil(fs - fr);
// Convert back to cube coordinates
return {
q: Math.round((a - c) / 3),
r: Math.round((c - b) / 3),
s: Math.round((b - a) / 3)
};
}
/* -------------------------------------------- */
/**
* Measure the distance in hexagons between two cube coordinates.
* @param {HexCubeCoordinate} a The first cube coordinate
* @param {HexCubeCoordinate} b The second cube coordinate
* @returns {number} The distance between the two cube coordinates in hexagons
*/
static cubeDistance(a, b) {
let diff = {q: a.q - b.q, r: a.r - b.r, s: a.s - b.s};
return (Math.abs(diff.q) + Math.abs(diff.r) + Math.abs(diff.s)) / 2;
}
/* -------------------------------------------- */
/**
* Compute the top-left pixel coordinate of a hexagon from its offset coordinate.
* @param {HexOffsetCoordinate} offset The offset coordinate
* @param {HexGridConfiguration} config The hex grid configuration
* @returns {Point} The coordinate in pixels
*/
static offsetToPixels({row, col}, {columns, even, size, width, height}) {
let x;
let y;
// Flat-topped hexes
if ( columns ) {
x = Math.ceil(col * (width * 0.75));
const isEven = (col + 1) % 2 === 0;
y = Math.ceil((row - (even === isEven ? 0.5 : 0)) * height);
}
// Pointy-topped hexes
else {
y = Math.ceil(row * (height * 0.75));
const isEven = (row + 1) % 2 === 0;
x = Math.ceil((col - (even === isEven ? 0.5 : 0)) * width);
}
// Return the pixel coordinate
return {x, y};
}
/* -------------------------------------------- */
/**
* Compute the offset coordinate of a hexagon from a pixel coordinate contained within that hex.
* @param {Point} point The pixel coordinate
* @param {HexGridConfiguration} config The hex grid configuration
* @param {string} [method=floor] Which Math rounding method to use
* @returns {HexOffsetCoordinate} The offset coordinate
*/
static pixelsToOffset({x, y}, config, method="floor") {
const {columns, even, width, height} = config;
const fn = Math[method];
let row;
let col;
// Columnar orientation
if ( columns ) {
col = fn(x / (width * 0.75));
const isEven = (col + 1) % 2 === 0;
row = fn((y / height) + (even === isEven ? 0.5 : 0));
}
// Row orientation
else {
row = fn(y / (height * 0.75));
const isEven = (row + 1) % 2 === 0;
col = fn((x / width) + (even === isEven ? 0.5 : 0));
}
return {row, col};
}
/* -------------------------------------------- */
/**
* We set the 'size' of a hexagon (the distance from a hexagon's centre to a vertex) to be equal to the grid size
* divided by √3. This makes the distance from top-to-bottom on a flat-topped hexagon, or left-to-right on a pointy-
* topped hexagon equal to the grid size.
* @param {HexGridConfiguration} config The grid configuration
* @returns {{width: number, height: number}} The width and height of a single hexagon, in pixels.
*/
static computeDimensions(config) {
const {size, columns, legacy} = config;
// Legacy dimensions (deprecated)
if ( legacy ) {
if ( columns ) return { width: size, height: Math.sqrt(3) * 0.5 * size };
return { width: Math.sqrt(3) * 0.5 * size, height: size };
}
// Columnar orientation
if ( columns ) return { width: (2 * size) / Math.sqrt(3), height: size };
// Row orientation
return { width: size, height: (2 * size) / Math.sqrt(3) };
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @see {@link HexagonalGrid.offsetToCube}
* @deprecated since v11
* @ignore
*/
offsetToCube(offset) {
foundry.utils.logCompatibilityWarning("HexagonalGrid#offsetToCube is deprecated in favor of the "
+ "HexagonalGrid.offsetToCube static method.", {since: 11, until: 13});
return this.constructor.offsetToCube(offset, this.options);
}
/**
* @see {@link HexagonalGrid.cubeToOffset}
* @deprecated since v11
* @ignore
*/
cubeToOffset(cube) {
foundry.utils.logCompatibilityWarning("HexagonalGrid#cubeToOffset is deprecated in favor of the "
+ "HexagonalGrid.cubeToOffset static method.", {since: 11, until: 13});
return HexagonalGrid.cubeToOffset(cube, this.options);
}
}
/**
* 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.grid.highlightLayers[this.name];
return super.destroy(...args);
}
}
/**
* A CanvasLayer responsible for drawing a square grid
*/
class GridLayer extends CanvasLayer {
/**
* The Grid container
* @type {BaseGrid}
*/
grid;
/**
* The Grid Highlight container
* @type {PIXI.Container}
*/
highlight;
/**
* Map named highlight layers
* @type {Object<GridHighlight>}
*/
highlightLayers = {};
/**
* Placeable Object borders which are drawn overtop of the Grid
* @type {PIXI.Container}
*/
borders;
/* -------------------------------------------- */
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {name: "grid"});
}
/* -------------------------------------------- */
/**
* The grid type rendered in this Scene
* @type {number}
*/
get type() {
return canvas.scene.grid.type;
}
/**
* A convenient reference to the pixel grid size used throughout this layer
* @type {number}
*/
get size() {
return canvas.dimensions.size;
}
/**
* Get grid unit width
*/
get w() {
return this.grid.w;
}
/**
* Get grid unit height
*/
get h() {
return this.grid.h;
}
/**
* A boolean flag for whether the current grid is hexagonal
* @type {boolean}
*/
get isHex() {
const gt = CONST.GRID_TYPES;
return [gt.HEXODDQ, gt.HEXEVENQ, gt.HEXODDR, gt.HEXEVENR].includes(this.type);
}
/* -------------------------------------------- */
/**
* Draw the grid
* @param {Object} preview Override settings used in place of those saved to the Scene data
* @param {number|null} [preview.type]
* @param {object|null} [preview.dimensions]
* @param {number} preview.color
* @param {number} preview.alpha
* @param {number} preview.gridColor
* @param {number} preview.gridAlpha
*/
async _draw({type=null, dimensions=null, color, alpha, gridColor, gridAlpha}={}) {
/** @deprecated since v10 */
if ( gridColor !== undefined ) {
foundry.utils.logCompatibilityWarning("You are passing the gridColor parameter to GridLayer#draw which is "
+ "deprecated in favor of the color parameter.", {since: 10, until: 12});
if ( color === undefined ) color = gridColor;
}
/** @deprecated since v10 */
if ( gridAlpha !== undefined ) {
foundry.utils.logCompatibilityWarning("You are passing the gridAlpha parameter to GridLayer#draw which is "
+ "deprecated in favor of the alpha parameter instead.", {since: 10, until: 12});
if ( alpha === undefined ) alpha = gridAlpha;
}
// Get grid data
const gt = type !== null ? type : this.type;
// Create the grid class
let gridOptions = {
dimensions: dimensions || canvas.dimensions,
color: color || canvas.scene.grid.color.replace("#", "0x") || "0x000000",
alpha: alpha ?? canvas.scene.grid.alpha,
legacy: canvas.scene.flags.core?.legacyHex
};
const gridCls = BaseGrid.implementationFor(gt);
if ( gridCls.getConfig ) Object.assign(gridOptions, gridCls.getConfig(gt, dimensions?.size ?? this.size));
const grid = new gridCls(gridOptions);
// Draw the highlight layer
this.highlightLayers = {};
this.highlight = this.addChild(new PIXI.Container());
// Draw the grid
this.grid = this.addChild(grid.draw());
// Draw object borders container
this.borders = this.addChild(new PIXI.Container());
// Add the reverse mask filter
this.filterArea = canvas.app.renderer.screen;
this.filters = [ReverseMaskFilter.create({
uMaskSampler: canvas.primary.tokensRenderTexture,
channel: "a"
})];
}
/* -------------------------------------------- */
/**
* Given a pair of coordinates (x1,y1), return the grid coordinates (x2,y2) which represent the snapped position
* @param {number} x The exact target location x
* @param {number} y The exact target location y
* @param {number} [interval=1] An interval of grid spaces at which to snap, default is 1.
* If the interval is zero, no snapping occurs.
* @param {object} [options] Additional options to configure snapping behaviour.
* @param {Token} [options.token] The token.
*/
getSnappedPosition(x, y, interval=1, options={}) {
if ( interval === 0 ) return {x, y};
return this.grid.getSnappedPosition(x, y, interval, options);
}
/* -------------------------------------------- */
/**
* Given a pair of coordinates (x, y) - return the top-left of the grid square which contains that point
* @param {number} x Coordinate X.
* @param {number} y Coordinate Y.
* @returns {number[]} An Array [x, y] of the top-left coordinate of the square which contains (x, y)
*/
getTopLeft(x, y) {
return this.grid.getTopLeft(x, y);
}
/* -------------------------------------------- */
/**
* Given a pair of coordinates (x, y), return the center of the grid square which contains that point
* @param {number} x Coordinate X.
* @param {number} y Coordinate Y.
* @returns {number[]} An Array [x, y] of the central point of the square which contains (x, y)
*/
getCenter(x, y) {
return this.grid.getCenter(x, y);
}
/* -------------------------------------------- */
/**
* @typedef {object} MeasureDistancesOptions
* @property {boolean} [gridSpaces] Return the distance in grid increments rather than the co-ordinate distance.
*/
/**
* Measure the distance between two point coordinates.
* @param {{x: number, y: number}} origin The origin point
* @param {{x: number, y: number}} target The target point
* @param {MeasureDistancesOptions} options Additional options which modify the measurement
* @returns {number} The measured distance between these points
*
* @example Measure grid distance between two points
* ```js
* let distance = canvas.grid.measureDistance({x: 1000, y: 1000}, {x: 2000, y: 2000});
* ```
*/
measureDistance(origin, target, options={}) {
const ray = new Ray(origin, target);
const segments = [{ray}];
return this.grid.measureDistances(segments, options)[0];
}
/* -------------------------------------------- */
/**
* Measure the distance traveled over an array of distance segments.
* @param {object[]} segments An array of measured segments
* @param {MeasureDistancesOptions} options Additional options which modify the measurement
*/
measureDistances(segments, options={}) {
return this.grid.measureDistances(segments, options);
}
/* -------------------------------------------- */
/* 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
*/
highlightPosition(name, options) {
const layer = this.highlightLayers[name];
if ( !layer ) return false;
this.grid.highlightGridPosition(layer, options);
}
/* -------------------------------------------- */
/**
* Test if a specific row and column position is a neighboring location to another row and column coordinate
* @param {number} r0 The original row position
* @param {number} c0 The original column position
* @param {number} r1 The candidate row position
* @param {number} c1 The candidate column position
*/
isNeighbor(r0, c0, r1, c1) {
let neighbors = this.grid.getNeighbors(r0, c0);
return neighbors.some(n => (n[0] === r1) && (n[1] === c1));
}
}
/**
* Construct a square grid container
* @type {BaseGrid}
*/
class SquareGrid extends BaseGrid {
/** @inheritdoc */
draw(options={}) {
super.draw(options);
let {color, alpha, dimensions} = foundry.utils.mergeObject(this.options, options);
// Set dimensions
this.width = dimensions.width;
this.height = dimensions.height;
// Need to draw?
if ( alpha === 0 ) return this;
// Vertical lines
let nx = Math.floor(dimensions.width / dimensions.size);
const grid = new PIXI.Graphics();
for ( let i = 1; i < nx; i++ ) {
let x = i * dimensions.size;
this.#drawLine(grid, [x, 0, x, dimensions.height], color, alpha);
}
// Horizontal lines
let ny = Math.ceil(dimensions.height / dimensions.size);
for ( let i = 1; i < ny; i++ ) {
let y = i * dimensions.size;
this.#drawLine(grid, [0, y, dimensions.width, y], color, alpha);
}
this.addChild(grid);
return this;
}
/* -------------------------------------------- */
/**
* Draw a line on the square grid.
* @param {PIXI.Graphics} grid The grid on which to draw the line.
* @param {number[]} points A pair of points coordinates.
* @param {number} lineColor The line color.
* @param {number} lineAlpha The line alpha.
*/
#drawLine(grid, points, lineColor, lineAlpha) {
grid.lineStyle(1, lineColor, lineAlpha).moveTo(points[0], points[1]).lineTo(points[2], points[3]);
}
/* -------------------------------------------- */
/* Grid Measurement Methods
/* -------------------------------------------- */
/** @override */
getCenter(x, y) {
const gs = canvas.dimensions.size;
return this.getTopLeft(x, y).map(c => c + (gs / 2));
}
/* -------------------------------------------- */
/** @override */
getGridPositionFromPixels(x, y) {
let gs = canvas.dimensions.size;
return [Math.floor(y / gs), Math.floor(x / gs)];
}
/* -------------------------------------------- */
/** @override */
getPixelsFromGridPosition(row, col) {
let gs = canvas.dimensions.size;
return [col*gs, row*gs];
}
/* -------------------------------------------- */
/** @override */
getSnappedPosition(x, y, interval=1, options={}) {
let [x0, y0] = this._getNearestVertex(x, y);
let dx = 0;
let dy = 0;
if ( interval !== 1 ) {
let delta = canvas.dimensions.size / interval;
dx = Math.round((x - x0) / delta) * delta;
dy = Math.round((y - y0) / delta) * delta;
}
return {
x: x0 + dx,
y: y0 + dy
}
}
/* -------------------------------------------- */
/** @inheritdoc */
shiftPosition(x, y, dx, dy, options={}) {
let [row, col] = canvas.grid.grid.getGridPositionFromPixels(x, y);
return canvas.grid.grid.getPixelsFromGridPosition(row+dy, col+dx);
}
/* -------------------------------------------- */
_getNearestVertex(x, y) {
const gs = canvas.dimensions.size;
return [Math.round(x / gs) * gs, Math.round(y / gs) * gs];
}
/* -------------------------------------------- */
/** @override */
highlightGridPosition(layer, options={}) {
const {x, y} = options;
if ( !layer.highlight(x, y) ) return;
let s = canvas.dimensions.size;
options.shape = new PIXI.Rectangle(x, y, s, s);
return super.highlightGridPosition(layer, options);
}
/* -------------------------------------------- */
/** @override */
measureDistances(segments, options={}) {
if ( !options.gridSpaces ) return super.measureDistances(segments, options);
const d = canvas.dimensions;
return segments.map(s => {
let r = s.ray;
let nx = Math.abs(Math.ceil(r.dx / d.size));
let ny = Math.abs(Math.ceil(r.dy / d.size));
// Determine the number of straight and diagonal moves
let nd = Math.min(nx, ny);
let ns = Math.abs(ny - nx);
// Linear distance for all moves
return (nd + ns) * d.distance;
});
}
/* -------------------------------------------- */
/** @override */
getNeighbors(row, col) {
let offsets = [[-1,-1], [-1,0], [-1,1], [0,-1], [0,1], [1,-1], [1,0], [1,1]];
return offsets.map(o => [row+o[0], col+o[1]]);
}
}
/**
* The depth mask which contains a mapping of elevation. Needed to know if we must render objects according to depth.
* @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
};
/** @override */
clearColor = [0, 0, 0, 0];
/* -------------------------------------------- */
/**
* 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.
* @category - Canvas
*/
class CanvasOcclusionMask extends CachedContainer {
constructor(...args) {
super(...args);
this.#createOcclusion();
}
/** @override */
static textureConfiguration = {
scaleMode: PIXI.SCALE_MODES.NEAREST,
format: PIXI.FORMATS.RGB
};
/**
* Graphics in which token radial and vision occlusion shapes are drawn.
* @type {PIXI.LegacyGraphics}
*/
tokens;
/** @override */
clearColor = [1, 1, 1, 1];
/* -------------------------------------------- */
/**
* 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 */
/* -------------------------------------------- */
/**
* Update the state of occlusion, rendering a new occlusion mask and updating the occluded flag on all Tiles.
*/
updateOcclusion() {
const tokens = canvas.tokens._getOccludableTokens();
this.#drawTokenOcclusion(tokens);
this.#updateTileOcclusion(tokens);
}
/* -------------------------------------------- */
/**
* Draw occlusion shapes to the Tile occlusion mask.
* Radial occlusion draws to the green channel with varying intensity from [1/255, 1] based on elevation.
* Vision occlusion draws to the blue channel with varying intensity from [1/255, 1] based on elevation.
* @param {Token[]} tokens An array of currently controlled or observed tokens
*/
#drawTokenOcclusion(tokens) {
tokens.sort((a, b) => b.document.elevation - a.document.elevation);
const g = canvas.masks.occlusion.tokens;
g.clear();
for ( const token of tokens ) {
const a = canvas.primary.mapElevationToDepth(token.document.elevation);
const c = token.center;
// The token has a flag with an occlusion radius?
const o = Number(token.document.flags.core?.occlusionRadius) || 0;
const r = Math.max(token.externalRadius, token.getLightRadius(o));
// Token has vision and a fov?
const hasVisionLOS = !!(token.hasSight && token.vision.los);
g.beginFill([1, a, !hasVisionLOS ? a : 1]).drawCircle(c.x, c.y, r).endFill();
if ( hasVisionLOS ) g.beginFill([1, 1, a]).drawShape(token.vision.los).endFill();
}
}
/* -------------------------------------------- */
/**
* Update the current occlusion status of all Tile objects.
* @param {Token[]} tokens The set of currently controlled Token objects
*/
#updateTileOcclusion(tokens) {
const occluded = this._identifyOccludedObjects(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<PrimaryCanvasObjectMixin>} 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.data.roof}) ) 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 {PIXI.Container} CanvasVisionContainer
* @property {PIXI.Graphics} los LOS polygons
* @property {PIXI.Graphics} base Base vision
* @property {PIXI.Graphics} fov FOV polygons
* @property {PIXI.Graphics} mask Alias of los
* @property {boolean} _explored Does this vision point represent an explored position?
*/
/**
* 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
};
/** @override */
clearColor = [0, 0, 0, 0];
/**
* 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",
canDragCreate: true,
controllableObjects: true,
rotatableObjects: true,
elevationSorting: true,
zIndex: 20
});
}
/** @inheritdoc */
static documentName = "Drawing";
/**
* The named game setting which persists default drawing configuration for the User
* @type {string}
*/
static DEFAULT_CONFIG_SETTING = "defaultDrawingConfig";
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* Use an adaptive precision depending on the size of the grid
* @type {number}
*/
get gridPrecision() {
if ( canvas.scene.grid.type === CONST.GRID_TYPES.GRIDLESS ) return 0;
return canvas.dimensions.size >= 128 ? 16 : 8;
}
/* -------------------------------------------- */
/** @inheritdoc */
get hud() {
return canvas.hud.drawing;
}
/* -------------------------------------------- */
/** @inheritdoc */
get hookName() {
return DrawingsLayer.name;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* 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();
if (this.objects) 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 data = foundry.utils.mergeObject(defaults, {
fillColor: game.user.color,
strokeColor: game.user.color,
fontFamily: CONFIG.defaultFontFamily
}, {overwrite: false, inplace: false});
// Mandatory additions
delete data._id;
if ( tool !== "freehand" ) origin = canvas.grid.getSnappedPosition(origin.x, origin.y, this.gridPrecision);
data.x = origin.x;
data.y = origin.y;
data.author = game.user.id;
data.shape = {};
// 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 = data.text || "New 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 ) {
let point = destination;
const snap = !event.shiftKey;
preview._addPoint(point, {snap, 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 this._onDragLeftDrop(event);
}
// Standard double-click handling
super._onClickLeft2(event);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDragLeftStart(event) {
await super._onDragLeftStart(event);
const interaction = event.interactionData;
const cls = getDocumentClass("Drawing");
const document = new cls(this._getNewDrawingData(interaction.origin), {parent: canvas.scene});
const drawing = new this.constructor.placeableClass(document);
interaction.preview = this.preview.addChild(drawing);
interaction.drawingsState = 1;
return 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
*/
async _onDragLeftDrop(event) {
const {drawingsState, destination, origin, preview} = event.interactionData;
// 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);
let drawing;
try {
drawing = await cls.create(createData, {parent: canvas.scene});
} finally {
this.clearPreviewContainer();
}
const o = drawing.object;
o._creating = true;
o._pendingText = "";
if ( game.activeTool !== "freehand" ) o.control({isNew: true});
}
// Cancel the preview
return this._onDragLeftCancel(event);
}
// In-progress polygon
if ( (drawingsState === 1) && preview.isPolygon ) {
event.preventDefault();
if ( preview._chain ) return;
return this._onClickLeft(event);
}
// Incomplete drawing
return this._onDragLeftCancel(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);
}
}
/**
* 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: 300
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get hookName() {
return LightingLayer.name;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @override */
_activate() {
super._activate();
for ( const p of this.placeables ) p.renderFlags.set({refreshField: true});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
async _onDragLeftStart(event) {
await super._onDragLeftStart(event);
// Create a pending AmbientLightDocument
const interaction = event.interactionData;
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
canvas.effects.lightSources.set(preview.sourceId, preview.source);
return 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.updateSource();
// Confirm the creation state
event.interactionData.lightsState = 2;
}
/* -------------------------------------------- */
/** @override */
_onDragLeftCancel(event) {
super._onDragLeftCancel(event);
canvas.effects.lightSources.delete(`${this.constructor.documentName}.preview`);
canvas.effects.refreshLighting();
event.interactionData.lightsState = 0;
}
/* -------------------------------------------- */
/** @override */
_onMouseWheel(event) {
// Identify the hovered light source
const light = this.hover;
if ( !light || (light.document.config.angle === 360) ) return;
// Determine the incremental angle of rotation from event data
let snap = event.shiftKey ? 15 : 3;
let 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 {number} darkness The new darkness level
* @param {number} prior The prior darkness level
* @internal
*/
_onDarknessChange(darkness, prior) {
for ( const light of this.placeables ) {
if ( light.emitsLight === light.source.disabled ) light.updateSource();
if ( this.active ) light.renderFlags.set({refreshState: true, refreshField: true});
}
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
get background() {
const msg = "LightingLayer#background has been refactored to EffectsCanvasGroup#background";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return canvas.effects.background;
}
/**
* @deprecated since v10
* @ignore
*/
get illumination() {
const msg = "LightingLayer#illumination has been refactored to EffectsCanvasGroup#illumination";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return canvas.effects.illumination;
}
/**
* @deprecated since v10
* @ignore
*/
get channels() {
const msg = "LightingLayer#channels has been refactored to EffectsCanvasGroup#lightingChannelColors";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return canvas.effects.lightingChannelColors;
}
/**
* @deprecated since v10
* @ignore
*/
get coloration() {
const msg = "LightingLayer#coloration has been refactored to EffectsCanvasGroup#coloration";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return canvas.effects.coloration;
}
/**
* @deprecated since v10
* @ignore
*/
get darknessLevel() {
const msg = "LightingLayer#darknessLevel has been refactored to Canvas#darknessLevel";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return canvas.darknessLevel;
}
/**
* @deprecated since v10
* @ignore
*/
get globalLight() {
const msg = "LightingLayer#globalLight has been refactored to CanvasIlluminationEffects#globalLight";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return canvas.effects.illumination.globalLight;
}
/**
* @deprecated since v10
* @ignore
*/
get sources() {
const msg = "LightingLayer#sources has been refactored to EffectsCanvasGroup#lightSources";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return canvas.effects.lightSources;
}
/**
* @deprecated since v10
* @ignore
*/
get version() {
const msg = "LightingLayer#version has been refactored to EffectsCanvasGroup#lightingVersion";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return canvas.effects.lightingVersion;
}
/**
* @deprecated since v10
* @ignore
*/
activateAnimation() {
const msg = "LightingLayer#activateAnimation has been refactored to EffectsCanvasGroup#activateAnimation";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return canvas.effects.activateAnimation();
}
/**
* @deprecated since v10
* @ignore
*/
deactivateAnimation() {
const msg = "LightingLayer#deactivateAnimation has been refactored to EffectsCanvasGroup#deactivateAnimation";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return canvas.effects.deactivateAnimation();
}
/**
* @deprecated since v10
* @ignore
*/
animateDarkness(...args) {
const msg = "LightingLayer#animateDarkness has been refactored to EffectsCanvasGroup#animateDarkness";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return canvas.effects.animateDarkness(...args);
}
/**
* @deprecated since v10
* @ignore
*/
initializeSources() {
const msg = "LightingLayer#initializeSources has been refactored to EffectsCanvasGroup#initializeLightSources";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return canvas.effects.initializeLightSources();
}
/**
* @deprecated since v10
* @ignore
*/
refresh(options) {
const msg = "LightingLayer#refresh has been refactored to EffectsCanvasGroup#refreshLighting";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return canvas.effects.refreshLighting(options);
}
}
/**
* 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",
canDragCreate: false,
sortActiveTop: true, // TODO this needs to be removed
zIndex: 200
});
}
/** @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;
}
/* -------------------------------------------- */
/* Methods
/* -------------------------------------------- */
/** @override */
_deactivate() {
super._deactivate();
const isToggled = game.settings.get("core", this.constructor.TOGGLE_SETTING);
this.objects.visible = this.interactiveChildren = isToggled;
}
/* -------------------------------------------- */
/**
* Register game settings used by the NotesLayer
*/
static registerSettings() {
game.settings.register("core", this.TOGGLE_SETTING, {
name: "Map Note Toggle",
scope: "client",
type: Boolean,
config: false,
default: false
});
}
/* -------------------------------------------- */
/**
* 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<void>} 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
let origin = event.getLocalPosition(canvas.stage);
const [x, y] = canvas.grid.getCenter(origin.x, origin.y);
// 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: `
<div class="form-group">
<label style="display: flex;">
<input type="checkbox" name="journal">
${game.i18n.localize("NOTE.CreateJournal")}
</label>
</div>
`
});
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 noteData;
if ( (data.x === undefined) || (data.y === undefined) ) {
const coords = this._canvasCoordinatesFromDrop(event, {center: false});
if ( !coords ) return false;
noteData = {x: coords[0], y: coords[1]};
} else {
noteData = {x: data.x, y: data.y};
}
if ( !event.shiftKey ) [noteData.x, noteData.y] = canvas.grid.getCenter(noteData.x, noteData.y);
if ( !canvas.dimensions.rect.contains(noteData.x, noteData.y) ) return false;
if ( data.type === "JournalEntry" ) entry = await JournalEntry.implementation.fromDropData(data);
if ( data.type === "JournalEntryPage" ) {
const page = await JournalEntryPage.implementation.fromDropData(data);
entry = page.parent;
noteData.pageId = page.id;
}
if ( entry?.compendium ) {
const journalData = game.journal.fromCompendium(entry);
entry = await JournalEntry.implementation.create(journalData);
}
noteData.entryId = entry?.id;
return this._createPreview(noteData, {top: event.clientY - 20, left: event.clientX + 40});
}
}
/**
* This Canvas Layer provides a container for AmbientSound objects.
* @category - Canvas
*/
class SoundsLayer extends PlaceablesLayer {
/**
* Track whether to actively preview ambient sounds with mouse cursor movements
* @type {boolean}
*/
livePreview = false;
/**
* A mapping of ambient audio sources which are active within the rendered Scene
* @type {Collection<string,SoundSource>}
*/
sources = new foundry.utils.Collection();
/* -------------------------------------------- */
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
name: "sounds",
zIndex: 300
});
}
/** @inheritdoc */
static documentName = "AmbientSound";
/* -------------------------------------------- */
/** @inheritdoc */
get hookName() {
return SoundsLayer.name;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @override */
_activate() {
super._activate();
for ( const p of this.placeables ) p.renderFlags.set({refreshField: true});
}
/* -------------------------------------------- */
/** @inheritdoc */
async _tearDown(options) {
this.stopAll();
return super._tearDown(options);
}
/* -------------------------------------------- */
/**
* Initialize all AmbientSound sources which are present on this layer
*/
initializeSources() {
for ( let sound of this.placeables ) {
sound.updateSource({defer: true});
}
for ( let sound of this.preview.children ) {
sound.updateSource({defer: true});
}
}
/* -------------------------------------------- */
/**
* Update all AmbientSound effects in the layer by toggling their playback status.
* Sync audio for the positions of tokens which are capable of hearing.
* @param {object} [options={}] Additional options forwarded to AmbientSound synchronization
*/
refresh(options={}) {
if ( !this.placeables.length ) return;
for ( const sound of this.placeables ) sound.source.refresh();
if ( game.audio.locked ) {
return game.audio.pending.push(() => this.refresh(options));
}
let listeners = canvas.tokens.controlled.map(t => t.center);
if ( !listeners.length && !game.user.isGM ) listeners = canvas.tokens.placeables.reduce((arr, t) => {
if ( t.actor?.isOwner && t.isVisible ) arr.push(t.center);
return arr;
}, []);
this._syncPositions(listeners, options);
}
/* -------------------------------------------- */
/**
* Preview ambient audio for a given mouse cursor position
* @param {Point} position The cursor position to preview
*/
previewSound(position) {
if ( !this.placeables.length || game.audio.locked ) return;
return this._syncPositions([position], {fade: 50});
}
/* -------------------------------------------- */
/**
* Terminate playback of all ambient audio sources
*/
stopAll() {
this.placeables.forEach(s => s.sync(false));
}
/* -------------------------------------------- */
/**
* Sync the playing state and volume of all AmbientSound objects based on the position of listener points
* @param {Point[]} listeners Locations of listeners which have the capability to hear
* @param {object} [options={}] Additional options forwarded to AmbientSound synchronization
* @private
*/
_syncPositions(listeners, options) {
if ( !this.placeables.length || game.audio.locked ) return;
const sounds = {};
for ( let sound of this.placeables ) {
const p = sound.document.path;
const r = sound.radius;
if ( !p ) continue;
// Track one audible object per unique sound path
if ( !(p in sounds) ) sounds[p] = {path: p, audible: false, volume: 0, sound};
const s = sounds[p];
if ( !sound.isAudible ) continue; // The sound may not be currently audible
// Determine whether the sound is audible, and its greatest audible volume
for ( let l of listeners ) {
if ( !sound.source.active || !sound.source.shape?.contains(l.x, l.y) ) continue;
s.audible = true;
const distance = Math.hypot(l.x - sound.x, l.y - sound.y);
let volume = sound.document.volume;
if ( sound.document.easing ) volume *= this._getEasingVolume(distance, r);
if ( !s.volume || (volume > s.volume) ) s.volume = volume;
}
}
// For each audible sound, sync at the target volume
for ( let s of Object.values(sounds) ) {
s.sound.sync(s.audible, s.volume, options);
}
}
/* -------------------------------------------- */
/**
* Define the easing function used to map radial distance to volume.
* Uses cosine easing which graduates from volume 1 at distance 0 to volume 0 at distance 1
* @returns {number} The target volume level
* @private
*/
_getEasingVolume(distance, radius) {
const x = Math.clamped(distance, 0, radius) / radius;
return (Math.cos(Math.PI * x) + 1) * 0.5;
}
/* -------------------------------------------- */
/**
* Actions to take when the darkness level of the Scene is changed
* @param {number} darkness The new darkness level
* @param {number} prior The prior darkness level
* @internal
*/
_onDarknessChange(darkness, prior) {
for ( const sound of this.placeables ) {
if ( sound.isAudible === sound.source.disabled ) sound.updateSource();
if ( this.active ) sound.renderFlags.set({refreshState: true, refreshField: true});
}
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Handle mouse cursor movements which may cause ambient audio previews to occur
* @param {PIXI.FederatedEvent} event The initiating mouse move interaction event
*/
_onMouseMove(event) {
if ( !this.livePreview ) return;
if ( canvas.tokens.active && canvas.tokens.controlled.length ) return;
const position = event.getLocalPosition(this);
this.previewSound(position);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDragLeftStart(event) {
await super._onDragLeftStart(event);
// Create a pending AmbientSoundDocument
const cls = getDocumentClass("AmbientSound");
const doc = new cls({type: "l", ...event.interactionData.origin}, {parent: canvas.scene});
// Create the preview AmbientSound object
const sound = new this.constructor.placeableClass(doc);
event.interactionData.preview = this.preview.addChild(sound);
event.interactionData.soundState = 1;
this.preview._creating = false;
return sound.draw();
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftMove(event) {
const {destination, soundState, preview, origin} = event.interactionData;
if ( soundState === 0 ) return;
const d = canvas.dimensions;
const radius = Math.hypot(destination.x - origin.x, destination.y - origin.y);
preview.document.radius = radius * (d.distance / d.size);
preview.updateSource();
event.interactionData.soundState = 2;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDragLeftDrop(event) {
const {soundState, destination, origin, preview} = event.interactionData;
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;
// Clean the data and render the creation sheet
preview.updateSource({
x: Math.round(preview.document.x),
y: Math.round(preview.document.y),
radius: Math.floor(preview.document.radius * 100) / 100
});
preview.sheet.render(true);
this.preview._creating = true;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftCancel(event) {
if ( this.preview._creating ) return;
return super._onDragLeftCancel(event);
}
/* -------------------------------------------- */
/**
* Handle PlaylistSound document drop data.
* @param {DragEvent} event The drag drop event
* @param {object} data The dropped transfer data.
*/
async _onDropData(event, data) {
const playlistSound = await PlaylistSound.implementation.fromDropData(data);
if ( !playlistSound ) return false;
let soundData;
if ( (data.x === undefined) || (data.y === undefined) ) {
const coords = this._canvasCoordinatesFromDrop(event, {center: false});
if ( !coords ) return false;
soundData = {x: coords[0], y: coords[1]};
} else {
soundData = {x: data.x, y: data.y};
}
if ( !event.shiftKey ) [soundData.x, soundData.y] = canvas.grid.getCenter(soundData.x, soundData.y);
if ( !canvas.dimensions.rect.contains(soundData.x, soundData.y) ) return false;
Object.assign(soundData, {
path: playlistSound.path,
volume: playlistSound.volume,
radius: canvas.dimensions.distance * 2
});
return this._createPreview(soundData, {top: event.clientY - 20, left: event.clientX + 40});
}
}
/**
* This Canvas Layer provides a container for MeasuredTemplate objects.
* @category - Canvas
*/
class TemplateLayer extends PlaceablesLayer {
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
name: "templates",
canDragCreate: true,
rotatableObjects: true,
sortActiveTop: true, // TODO this needs to be removed
zIndex: 50
});
}
/** @inheritdoc */
static documentName = "MeasuredTemplate";
/* -------------------------------------------- */
/** @inheritdoc */
get hookName() {
return TemplateLayer.name;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritDoc */
_deactivate() {
super._deactivate();
this.objects.visible = true;
}
/* -------------------------------------------- */
/**
* Register game settings used by the TemplatesLayer
*/
static registerSettings() {
game.settings.register("core", "coneTemplateType", {
name: "TEMPLATE.ConeTypeSetting",
hint: "TEMPLATE.ConeTypeSettingHint",
scope: "world",
config: true,
default: "round",
type: String,
choices: {
flat: "TEMPLATE.ConeTypeFlat",
round: "TEMPLATE.ConeTypeRound"
},
onChange: () => canvas.templates?.placeables.filter(t => t.document.t === "cone").forEach(t => t.draw())
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
async _onDragLeftStart(event) {
await super._onDragLeftStart(event);
const interaction = event.interactionData;
// Create a pending MeasuredTemplateDocument
const tool = game.activeTool;
const previewData = {
user: game.user.id,
t: tool,
x: interaction.origin.x,
y: interaction.origin.y,
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);
return template.draw();
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftMove(event) {
const interaction = event.interactionData;
const {destination, layerDragState, preview, origin} = interaction;
if ( layerDragState === 0 ) return;
// Snap the destination to the grid
interaction.destination = canvas.grid.getSnappedPosition(destination.x, destination.y, this.gridPrecision);
// Compute the ray
const ray = new Ray(origin, destination);
const ratio = (canvas.dimensions.size / canvas.dimensions.distance);
// Update the preview object
preview.document.direction = Math.normalizeDegrees(Math.toDegrees(ray.angle));
preview.document.distance = ray.distance / ratio;
preview.renderFlags.set({refreshShape: true});
// Confirm the creation state
interaction.layerDragState = 2;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onMouseWheel(event) {
// Determine whether we have a hovered template?
const template = this.hover;
if ( !template ) return;
// Determine the incremental angle of rotation from event data
let snap = event.shiftKey ? 15 : 5;
let delta = snap * Math.sign(event.delta);
return template.rotate(template.document.direction + delta, snap);
}
}
/**
* A PlaceablesLayer designed for rendering the visual Scene for a specific vertical cross-section.
* @category - Canvas
*/
class TilesLayer extends PlaceablesLayer {
/** @inheritdoc */
static documentName = "Tile";
/* -------------------------------------------- */
/* Layer Attributes */
/* -------------------------------------------- */
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
name: "tiles",
zIndex: 0,
controllableObjects: true,
rotatableObjects: true,
elevationSorting: true
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get hookName() {
return TilesLayer.name;
}
/* -------------------------------------------- */
/** @inheritdoc */
get hud() {
return canvas.hud.tile;
}
/* -------------------------------------------- */
/**
* An array of Tile objects which are rendered within the objects container
* @type {Tile[]}
*/
get tiles() {
return this.objects?.children || [];
}
/* -------------------------------------------- */
/**
* Get an array of overhead Tile objects which are roofs
* @returns {Tile[]}
*/
get roofs() {
return this.placeables.filter(t => t.isRoof);
}
/* -------------------------------------------- */
/**
* Determine whether to display roofs
* @type {boolean}
*/
get displayRoofs() {
const tilesTool = (ui.controls.activeControl === "tiles");
const restrictVision = !game.user.isGM
|| (canvas.tokens.controlled.length > 0) || canvas.effects.visionSources.some(s => s.active);
return (this.active && ui.controls.control.foreground) || (restrictVision && !tilesTool);
}
/* -------------------------------------------- */
/**
* A convenience reference to the tile occlusion mask on the primary canvas group.
* @type {CachedContainer}
*/
get depthMask() {
return canvas.masks.depth;
}
/* -------------------------------------------- */
/** @override */
*controllableObjects() {
const foreground = ui.controls.control.foreground ?? false;
for ( const placeable of super.controllableObjects() ) {
if ( placeable.document.overhead === foreground ) yield placeable;
}
}
/* -------------------------------------------- */
/* Layer Methods */
/* -------------------------------------------- */
/** @inheritdoc */
_activate() {
super._activate();
this._activateSubLayer(!!ui.controls.control.foreground);
canvas.perception.update({refreshLighting: true, refreshTiles: true});
}
/* -------------------------------------------- */
/** @inheritdoc */
_deactivate() {
super._deactivate();
this.objects.visible = true;
canvas.perception.update({refreshLighting: true, refreshTiles: true});
}
/* -------------------------------------------- */
/**
* Activate a sublayer of the tiles layer, which controls interactivity of placeables and release controlled objects.
* @param {boolean} [foreground=false] Which sublayer need to be activated? Foreground or background?
* @internal
*/
_activateSubLayer(foreground=false) {
for ( const tile of this.tiles ) {
tile.eventMode = (tile.document.overhead === foreground) ? "static" : "none";
if ( tile.controlled ) tile.release();
else tile.renderFlags.set({refreshShape: true});
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _tearDown(options) {
for ( const tile of this.tiles ) {
if ( tile.isVideo ) {
game.video.stop(tile.sourceElement);
}
}
TextureLoader.textureBufferDataMap.clear();
return super._tearDown(options);
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
async _onDragLeftStart(event) {
await super._onDragLeftStart(event);
const interaction = event.interactionData;
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;
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);
if ( !event.shiftKey ) {
const half = canvas.dimensions.size / 2;
preview.document.width = preview.document.width.toNearest(half);
preview.document.height = preview.document.height.toNearest(half);
}
preview.renderFlags.set({refreshShape: true});
// Confirm the creation state
interaction.tilesState = 2;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftDrop(event) {
const { tilesState, preview } = event.interactionData;
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;
data.overhead = ui.controls.controls.find(c => c.layer === "tiles").foreground ?? false;
// Determine the final position and snap to grid unless SHIFT is pressed
data.x = data.x - (data.width / 2);
data.y = data.y - (data.height / 2);
if ( !event.shiftKey ) {
const {x, y} = canvas.grid.getSnappedPosition(data.x, data.y);
data.x = x;
data.y = y;
}
// Create the tile as hidden if the ALT key is pressed
if ( event.altKey ) data.hidden = true;
return data;
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @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;
}
}
/**
* The Tokens Container.
* @category - Canvas
*/
class TokenLayer extends PlaceablesLayer {
/**
* The current index position in the tab cycle
* @type {number|null}
* @private
*/
_tabIndex = null;
/* -------------------------------------------- */
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
name: "tokens",
canDragCreate: false,
controllableObjects: true,
rotatableObjects: true,
elevationSorting: true,
zIndex: 100
});
}
/** @inheritdoc */
static documentName = "Token";
/* -------------------------------------------- */
/** @inheritdoc */
get hookName() {
return TokenLayer.name;
}
/* -------------------------------------------- */
/** @inheritdoc */
get gridPrecision() {
return 1; // Snap tokens to top-left
}
/* -------------------------------------------- */
/* Properties
/* -------------------------------------------- */
/**
* Token objects on this layer utilize the TokenHUD
*/
get hud() {
return canvas.hud.token;
}
/**
* An Array of tokens which belong to actors which are owned
* @type {Token[]}
*/
get ownedTokens() {
return this.placeables.filter(t => t.actor && t.actor.isOwner);
}
/* -------------------------------------------- */
/* Methods
/* -------------------------------------------- */
/** @inheritDoc */
async _draw(options) {
await super._draw(options);
canvas.app.ticker.add(this._animateTargets, this);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _tearDown(options) {
this.concludeAnimation();
return super._tearDown(options);
}
/* -------------------------------------------- */
/** @inheritDoc */
_activate() {
super._activate();
if ( canvas.controls ) canvas.controls.doors.visible = true;
this._tabIndex = null;
}
/* -------------------------------------------- */
/** @inheritDoc */
_deactivate() {
super._deactivate();
if ( this.objects ) this.objects.visible = true;
if ( canvas.controls ) canvas.controls.doors.visible = false;
}
/* -------------------------------------------- */
/**
* Target all Token instances which fall within a coordinate rectangle.
*
* @param {object} rectangle The selection rectangle.
* @param {number} rectangle.x The top-left x-coordinate of the selection rectangle
* @param {number} rectangle.y The top-left y-coordinate of the selection rectangle
* @param {number} rectangle.width The width of the selection rectangle
* @param {number} rectangle.height The height of the selection rectangle
* @param {object} [options] Additional options to configure targeting behaviour.
* @param {boolean} [options.releaseOthers=true] Whether or not to release other targeted tokens
* @returns {number} The number of Token instances which were targeted.
*/
targetObjects({x, y, width, height}, {releaseOthers=true}={}) {
const user = game.user;
// Get the set of targeted tokens
const targets = this.placeables.filter(t => {
if ( !t.visible ) return false;
if ( (t.document.disposition === CONST.TOKEN_DISPOSITIONS.SECRET) && !t.isOwner ) return false;
const c = t.center;
return Number.between(c.x, x, x+width) && Number.between(c.y, y, y+height);
});
// Maybe release other targets
if ( releaseOthers ) {
for ( let t of user.targets ) {
if ( !targets.includes(t) ) t.setTarget(false, {releaseOthers: false, groupSelection: true});
}
}
// Acquire targets for tokens which are not yet targeted
targets.forEach(t => {
if ( !user.targets.has(t) ) t.setTarget(true, {releaseOthers: false, groupSelection: true});
});
// Broadcast the target change
user.broadcastActivity({targets: user.targets.ids});
// Return the number of targeted tokens
return user.targets.size;
}
/* -------------------------------------------- */
/**
* Cycle the controlled token by rotating through the list of Owned Tokens that are available within the Scene
* Tokens are currently sorted in order of their TokenID
*
* @param {boolean} forwards Which direction to cycle. A truthy value cycles forward, while a false value
* cycles backwards.
* @param {boolean} reset Restart the cycle order back at the beginning?
* @returns {Token|null} The Token object which was cycled to, or null
*/
cycleTokens(forwards, reset) {
let next = null;
if ( reset ) this._tabIndex = null;
const order = this._getCycleOrder();
// If we are not tab cycling, try and jump to the currently controlled or impersonated token
if ( this._tabIndex === null ) {
this._tabIndex = 0;
// Determine the ideal starting point based on controlled tokens or the primary character
let current = this.controlled.length ? order.find(t => this.controlled.includes(t)) : null;
if ( !current && game.user.character ) {
const actorTokens = game.user.character.getActiveTokens();
current = actorTokens.length ? order.find(t => actorTokens.includes(t)) : null;
}
current = current || order[this._tabIndex] || null;
// Either start cycling, or cancel
if ( !current ) return null;
next = current;
}
// Otherwise, cycle forwards or backwards
else {
if ( forwards ) this._tabIndex = this._tabIndex < (order.length - 1) ? this._tabIndex + 1 : 0;
else this._tabIndex = this._tabIndex > 0 ? this._tabIndex - 1 : order.length - 1;
next = order[this._tabIndex];
if ( !next ) return null;
}
// Pan to the token and control it (if possible)
canvas.animatePan({x: next.center.x, y: next.center.y, duration: 250});
next.control();
return next;
}
/* -------------------------------------------- */
/**
* Add or remove the set of currently controlled Tokens from the active combat encounter
* @param {boolean} state The desired combat state which determines if each Token is added (true) or
* removed (false)
* @param {Combat|null} combat A Combat encounter from which to add or remove the Token
* @param {Token|null} [token] A specific Token which is the origin of the group toggle request
* @return {Promise<Combatant[]>} The Combatants added or removed
*/
async toggleCombat(state=true, combat=null, {token=null}={}) {
// Process each controlled token, as well as the reference token
const tokens = this.controlled.filter(t => t.inCombat !== state);
if ( token && !token.controlled && (token.inCombat !== state) ) tokens.push(token);
// Reference the combat encounter displayed in the Sidebar if none was provided
combat = combat ?? game.combats.viewed;
if ( !combat ) {
if ( game.user.isGM ) {
const cls = getDocumentClass("Combat");
combat = await cls.create({scene: canvas.scene.id, active: true}, {render: !state || !tokens.length});
} else {
ui.notifications.warn("COMBAT.NoneActive", {localize: true});
return [];
}
}
// Add tokens to the Combat encounter
if ( state ) {
const createData = tokens.map(t => {
return {
tokenId: t.id,
sceneId: t.scene.id,
actorId: t.document.actorId,
hidden: t.document.hidden
};
});
return combat.createEmbeddedDocuments("Combatant", createData);
}
// Remove Tokens from combat
if ( !game.user.isGM ) return [];
const tokenIds = new Set(tokens.map(t => t.id));
const combatantIds = combat.combatants.reduce((ids, c) => {
if ( tokenIds.has(c.tokenId) ) ids.push(c.id);
return ids;
}, []);
return combat.deleteEmbeddedDocuments("Combatant", combatantIds);
}
/* -------------------------------------------- */
/**
* Get the tab cycle order for tokens by sorting observable tokens based on their distance from top-left.
* @returns {Token[]}
* @private
*/
_getCycleOrder() {
const observable = this.placeables.filter(token => {
if ( game.user.isGM ) return true;
if ( !token.actor?.testUserPermission(game.user, "OBSERVER") ) return false;
return !token.document.hidden;
});
observable.sort((a, b) => Math.hypot(a.x, a.y) - Math.hypot(b.x, b.y));
return observable;
}
/* -------------------------------------------- */
/**
* Immediately conclude the animation of any/all tokens
*/
concludeAnimation() {
this.placeables.filter(t => t._animation).forEach(t => {
t.stopAnimation();
t.document.reset();
t.renderFlags.set({refreshSize: true, refreshPosition: true, refreshMesh: true});
});
canvas.app.ticker.remove(this._animateTargets, this);
}
/* -------------------------------------------- */
/**
* Animate targeting arrows on targeted tokens.
* @private
*/
_animateTargets() {
if ( !game.user.targets.size ) return;
if ( this._t === undefined ) this._t = 0;
else this._t += canvas.app.ticker.elapsedMS;
const duration = 2000;
const pause = duration * .6;
const fade = (duration - pause) * .25;
const minM = .5; // Minimum margin is half the size of the arrow.
const maxM = 1; // Maximum margin is the full size of the arrow.
// The animation starts with the arrows halfway across the token bounds, then move fully inside the bounds.
const rm = maxM - minM;
const t = this._t % duration;
let dt = Math.max(0, t - pause) / (duration - pause);
dt = CanvasAnimation.easeOutCircle(dt);
const m = t < pause ? minM : minM + (rm * dt);
const ta = Math.max(0, t - duration + fade);
const a = 1 - (ta / fade);
for ( const t of game.user.targets ) {
t._refreshTarget({
margin: m,
alpha: a,
color: CONFIG.Canvas.targeting.color,
size: CONFIG.Canvas.targeting.size
});
}
}
/* -------------------------------------------- */
/**
* Provide an array of Tokens which are eligible subjects for overhead tile occlusion.
* By default, only tokens which are currently controlled or owned by a player are included as subjects.
* @protected
*/
_getOccludableTokens() {
return game.user.isGM ? canvas.tokens.controlled : canvas.tokens.ownedTokens.filter(t => !t.document.hidden);
}
/* -------------------------------------------- */
/** @inheritdoc */
storeHistory(type, data) {
super.storeHistory(type, 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;
return d;
}));
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Handle dropping of Actor data onto the Scene canvas
* @private
*/
async _onDropActorData(event, data) {
// Ensure the user has permission to drop the actor and create a Token
if ( !game.user.can("TOKEN_CREATE") ) {
return ui.notifications.warn("You do not have permission to create new Tokens!");
}
// Acquire dropped data and import the actor
let actor = await Actor.implementation.fromDropData(data);
if ( !actor.isOwner ) {
return ui.notifications.warn(`You do not have permission to create a new Token for the ${actor.name} Actor.`);
}
if ( actor.compendium ) {
const actorData = game.actors.fromCompendium(actor);
actor = await Actor.implementation.create(actorData, {fromCompendium: true});
}
// Prepare the Token document
const td = await actor.getTokenDocument({x: data.x, y: data.y, hidden: game.user.isGM && event.altKey});
// Bypass snapping
if ( event.shiftKey ) td.updateSource({
x: td.x - (td.width * canvas.grid.w / 2),
y: td.y - (td.height * canvas.grid.h / 2)
});
// Otherwise, snap to the nearest vertex, adjusting for large tokens
else {
const hw = canvas.grid.w/2;
const hh = canvas.grid.h/2;
td.updateSource(canvas.grid.getSnappedPosition(td.x - (td.width*hw), td.y - (td.height*hh)));
}
// Validate the final position
if ( !canvas.dimensions.rect.contains(td.x, td.y) ) return false;
// Submit the Token creation request and activate the Tokens layer (if not already active)
this.activate();
return td.constructor.create(td, {parent: canvas.scene});
}
/* -------------------------------------------- */
/** @inheritDoc */
_onClickLeft(event) {
let tool = game.activeTool;
// If Control is being held, we always want the Tool to be Ruler
if ( game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL) ) tool = "ruler";
switch ( tool ) {
// Clear targets if Left Click Release is set
case "target":
if ( game.settings.get("core", "leftClickRelease") ) {
game.user.updateTokenTargets([]);
game.user.broadcastActivity({targets: []});
}
break;
// Place Ruler waypoints
case "ruler":
return canvas.controls.ruler._onClickLeft(event);
}
// If we don't explicitly return from handling the tool, use the default behavior
super._onClickLeft(event);
}
/* -------------------------------------------- */
/**
* Reset canvas and tokens mouse manager.
*/
onClickTokenTools() {
canvas.mouseInteractionManager?.reset({state: false});
for ( const token of this.placeables ) {
token.mouseInteractionManager?.reset();
}
}
}
/**
* The Walls canvas layer which provides a container for Wall objects within the rendered Scene.
* @category - Canvas
*/
class WallsLayer extends PlaceablesLayer {
/**
* Synthetic Wall instances which represent the outer boundaries of the game canvas.
* @type {Wall[]}
*/
outerBounds = [];
/**
* Synthetic Wall instances which represent the inner boundaries of the scene rectangle.
* @type {Wall[]}
*/
innerBounds = [];
/**
* A graphics layer used to display chained Wall selection
* @type {PIXI.Graphics}
*/
chain = null;
/**
* Track whether we are currently within a chained placement workflow
* @type {boolean}
*/
_chain = false;
/**
* Track whether the layer is currently toggled to snap at exact grid precision
* @type {boolean}
*/
_forceSnap = false;
/**
* Track the most recently created or updated wall data for use with the clone tool
* @type {Object|null}
* @private
*/
_cloneType = null;
/**
* Reference the last interacted wall endpoint for the purposes of chaining
* @type {{point: PointArray}}
* @private
*/
last = {
point: null
};
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
name: "walls",
controllableObjects: true,
sortActiveTop: true, // TODO this needs to be removed
zIndex: 40
});
}
/** @inheritdoc */
static documentName = "Wall";
/* -------------------------------------------- */
/** @inheritdoc */
get hookName() {
return WallsLayer.name;
}
/* -------------------------------------------- */
/**
* An Array of Wall instances in the current Scene which act as Doors.
* @type {Wall[]}
*/
get doors() {
return this.objects.children.filter(w => w.document.door > CONST.WALL_DOOR_TYPES.NONE);
}
/* -------------------------------------------- */
/**
* Gate the precision of wall snapping to become less precise for small scale maps.
* @type {number}
*/
get gridPrecision() {
// Force snapping to grid vertices
if ( this._forceSnap ) return canvas.grid.type <= CONST.GRID_TYPES.SQUARE ? 1 : 5;
// Normal snapping precision
let size = canvas.dimensions.size;
if ( size >= 128 ) return 16;
else if ( size >= 64 ) return 8;
else if ( size >= 32 ) return 4;
return 1;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
async _draw(options) {
await super._draw(options);
this.#defineBoundaries();
this.chain = this.addChildAt(new PIXI.Graphics(), 0);
this.last = {point: null};
}
/* -------------------------------------------- */
/** @inheritdoc */
_deactivate() {
super._deactivate();
this.chain?.clear();
}
/* -------------------------------------------- */
/**
* Perform initialization steps for the WallsLayer whenever the composition of walls in the Scene is changed.
* Cache unique wall endpoints and identify interior walls using overhead roof tiles.
*/
initialize() {
this.identifyWallIntersections();
this.identifyInteriorWalls();
}
/* -------------------------------------------- */
/**
* Define the canvas boundaries for outer and inner regions
*/
#defineBoundaries() {
const d = canvas.dimensions;
const cls = getDocumentClass("Wall");
const ctx = {parent: canvas.scene};
const define = (name, r) => {
const docs = [
new cls({_id: `Bound${name}Top`.padEnd(16, "0"), c: [r.x, r.y, r.right, r.y]}, ctx),
new cls({_id: `Bound${name}Right`.padEnd(16, "0"), c: [r.right, r.y, r.right, r.bottom]}, ctx),
new cls({_id: `Bound${name}Bottom`.padEnd(16, "0"), c: [r.right, r.bottom, r.x, r.bottom]}, ctx),
new cls({_id: `Bound${name}Left`.padEnd(16, "0"), c: [r.x, r.bottom, r.x, r.y]}, ctx)
];
return docs.map(d => new Wall(d));
};
this.outerBounds = define("Outer", d.rect);
this.innerBounds = d.rect.x === d.sceneRect.x ? this.outerBounds : define("Inner", d.sceneRect);
}
/* -------------------------------------------- */
/**
* Initialization to identify all intersections between walls.
* These intersections are cached and used later when computing point source polygons.
*/
identifyWallIntersections() {
// Preprocess wall segments and canvas boundaries
const segments = [];
const process = wall => {
const isNW = wall.A.key - wall.B.key < 0;
const nw = isNW ? wall.A : wall.B;
const se = isNW ? wall.B : wall.A;
segments.push({wall, nw, se});
};
for ( const wall of this.outerBounds ) process(wall);
let boundaries = this.outerBounds;
if ( boundaries !== this.innerBounds ) boundaries = boundaries.concat(this.innerBounds);
for ( const wall of boundaries ) process(wall);
for ( const wall of this.placeables ) process(wall);
// Sort segments by their north-west X value, breaking ties with the south-east X value
segments.sort((s1, s2) => (s1.nw.x - s2.nw.x) || (s1.se.x - s2.se.x));
// Iterate over all endpoints, identifying intersections
const ln = segments.length;
for ( let i=0; i<ln; i++ ) {
const s1 = segments[i];
for ( let j=i+1; j<ln; j++ ) {
const s2 = segments[j];
if ( s2.nw.x > s1.se.x ) break; // Segment s2 is entirely right of segment s1
s1.wall._identifyIntersectionsWith(s2.wall);
}
}
}
/* -------------------------------------------- */
/**
* Identify walls which are treated as "interior" because they are contained fully within a roof tile.
*/
identifyInteriorWalls() {
for ( const wall of this.placeables ) {
wall.identifyInteriorState();
}
}
/* -------------------------------------------- */
/**
* Given a point and the coordinates of a wall, determine which endpoint is closer to the point
* @param {Point} point The origin point of the new Wall placement
* @param {Wall} wall The existing Wall object being chained to
* @returns {PointArray} The [x,y] coordinates of the starting endpoint
*/
static getClosestEndpoint(point, wall) {
const c = wall.coords;
const a = [c[0], c[1]];
const b = [c[2], c[3]];
// Exact matches
if ( a.equals([point.x, point.y]) ) return a;
else if ( b.equals([point.x, point.y]) ) return b;
// Closest match
const da = Math.hypot(point.x - a[0], point.y - a[1]);
const db = Math.hypot(point.x - b[0], point.y - b[1]);
return da < db ? a : b;
}
/* -------------------------------------------- */
/** @inheritdoc */
releaseAll(options) {
if ( this.chain ) this.chain.clear();
return super.releaseAll(options);
}
/* -------------------------------------------- */
/** @inheritdoc */
async pasteObjects(position, options) {
if ( !this._copy.length ) return [];
// Transform walls to reference their upper-left coordinates as {x,y}
const [xs, ys] = this._copy.reduce((arr, w) => {
arr[0].push(Math.min(w.document.c[0], w.document.c[2]));
arr[1].push(Math.min(w.document.c[1], w.document.c[3]));
return arr;
}, [[], []]);
// Get the top-left most coordinate
const topX = Math.min(...xs);
const topY = Math.min(...ys);
// Get the magnitude of shift
const dx = Math.floor(topX - position.x);
const dy = Math.floor(topY - position.y);
const shift = [dx, dy, dx, dy];
// Iterate over objects
const toCreate = [];
for ( let w of this._copy ) {
let data = w.document.toJSON();
data.c = data.c.map((c, i) => c - shift[i]);
delete data._id;
toCreate.push(data);
}
// Call paste hooks
Hooks.call("pasteWall", this._copy, toCreate);
// Create all objects
let created = await canvas.scene.createEmbeddedDocuments("Wall", toCreate);
ui.notifications.info(`Pasted data for ${toCreate.length} Wall objects.`);
return created;
}
/* -------------------------------------------- */
/**
* Pan the canvas view when the cursor position gets close to the edge of the frame
* @param {MouseEvent} event The originating mouse movement event
* @param {number} x The x-coordinate
* @param {number} y The y-coordinate
* @private
*/
_panCanvasEdge(event, x, y) {
// Throttle panning by 20ms
const now = Date.now();
if ( now - (event.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 = canvas.grid.getSnappedPosition(point.x, point.y, this.gridPrecision);
return [point.x, point.y].map(Math.floor);
}
/* -------------------------------------------- */
/**
* The Scene Controls tools provide several different types of prototypical Walls to choose from
* This method helps to translate each tool into a default wall data configuration for that type
* @param {string} tool The active canvas tool
* @private
*/
_getWallDataFromActiveTool(tool) {
// Using the clone tool
if ( tool === "clone" && this._cloneType ) return this._cloneType;
// Default wall data
const wallData = {
light: CONST.WALL_SENSE_TYPES.NORMAL,
sight: CONST.WALL_SENSE_TYPES.NORMAL,
sound: CONST.WALL_SENSE_TYPES.NORMAL,
move: CONST.WALL_SENSE_TYPES.NORMAL
};
// Tool-based wall restriction types
switch ( tool ) {
case "invisible":
wallData.sight = wallData.light = wallData.sound = CONST.WALL_SENSE_TYPES.NONE; break;
case "terrain":
wallData.sight = wallData.light = wallData.sound = CONST.WALL_SENSE_TYPES.LIMITED; break;
case "ethereal":
wallData.move = wallData.sound = CONST.WALL_SENSE_TYPES.NONE; break;
case "doors":
wallData.door = CONST.WALL_DOOR_TYPES.DOOR; break;
case "secret":
wallData.door = CONST.WALL_DOOR_TYPES.SECRET; break;
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;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftStart(event) {
this.clearPreviewContainer();
const interaction = event.interactionData;
const origin = interaction.origin;
interaction.wallsState = WallsLayer.CREATION_STATES.NONE;
// Create a pending WallDocument
const data = this._getWallDataFromActiveTool(game.activeTool);
const snap = this._forceSnap || !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
preview.document.updateSource({
c: preview.document.c.slice(0, 2).concat([destination.x, destination.y])
});
preview.refresh();
interaction.wallsState = WallsLayer.CREATION_STATES.CONFIRMED;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _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 this._onDragLeftCancel(event);
}
// 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 = this._forceSnap || !event.shiftKey;
let dest = this._getWallEndpointCoordinates(destination, {snap});
const coords = preview.document.c.slice(0, 2).concat(dest);
preview.document.updateSource({c: coords});
// Ignore walls which are collapsed
if ( (coords[0] === coords[2]) && (coords[1] === coords[3]) ) return this._onDragLeftCancel(event);
event.interactionData.clearPreviewContainer = false;
// Create the Wall
this.last = {point: dest};
const cls = getDocumentClass(this.constructor.documentName);
try {
await cls.create(preview.document.toObject(), {parent: canvas.scene});
} finally {
this.clearPreviewContainer();
}
// Maybe chain
if ( this._chain ) {
interaction.origin = {x: dest[0], y: dest[1]};
return this._onDragLeftStart(event);
}
}
// Partial wall completion
return this._onDragLeftCancel(event);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftCancel(event) {
this._chain = false;
this.last = {point: null};
event.interactionData.clearPreviewContainer = true;
super._onDragLeftCancel(event);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickRight(event) {
if ( event.interactionData.wallsState > WallsLayer.CREATION_STATES.NONE ) return this._onDragLeftCancel(event);
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
get boundaries() {
const msg = "WallsLayer#boundaries is deprecated in favor of WallsLayer#outerBounds and WallsLayer#innerBounds";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return new Set(this.outerBounds);
}
/**
* @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});
}
}
/**
* An abstract base class which defines a framework for effect sources which originate radially from a specific point.
* This abstraction is used by the LightSource, VisionSource, SoundSource, and MovementSource subclasses.
*
* @example A standard PointSource lifecycle:
* ```js
* const source = new PointSource({object}); // Create the point source
* source.initialize(data); // Configure the point source with new data
* source.refresh(); // Refresh the point source
* source.destroy(); // Destroy the point source
* ```
*
* @param {object} [options]
* @param {object} [options.object] Some other object which is responsible for this source
* @abstract
*/
class PointSource {
constructor(options) {
if ( options instanceof PlaceableObject ) {
const warning = "The constructor PointSource(PlaceableObject) is deprecated. "
+ "Use new PointSource({ object }) instead.";
foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
this.object = options;
}
else this.object = options?.object ?? null;
}
/**
* The type of source represented by this data structure.
* Each subclass must implement this attribute.
* @type {string}
*/
static sourceType;
/* -------------------------------------------- */
/* Point Source Attributes */
/* -------------------------------------------- */
/**
* @typedef {Object} PointSourceData
* @property {number} x The x-coordinate of the source location
* @property {number} y The y-coordinate of the source location
* @property {number} elevation The elevation of the point source
* @property {number|null} z An index for sorting the source relative to others at the same elevation
* @property {number} radius The radius of the source
* @property {number} externalRadius A secondary radius used for limited angles
* @property {number} rotation The angle of rotation for this point source
* @property {number} angle The angle of emission for this point source
* @property {boolean} walls Whether or not the source is constrained by walls
* @property {boolean} disabled Whether or not the source is disabled
*/
/**
* Some other object which is responsible for this source.
* @type {object|null}
*/
object;
/**
* The data of this source.
* @type {PointSourceData}
*/
data = {};
/**
* The polygonal shape of the point source, generated from its origin, radius, and other data.
* @type {PointSourcePolygon|PIXI.Polygon}
*/
shape;
/**
* A collection of boolean flags which control rendering and refresh behavior for the source.
* @type {Object<string,boolean|number>}
* @protected
*/
_flags = {};
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* Returns the update ID associated with this point source.
* The update ID is increased whenever the source is initialized.
* @type {number}
*/
get updateId() {
return this.#updateId;
}
#updateId = 0;
/**
* Is this point source currently active?
* Returns false if the source is disabled, temporarily suppressed, or not initialized.
* @type {boolean}
*/
get active() {
return this.#active;
}
#active = false;
/**
* Is this source currently disabled?
* Returns false if the source hasn't been initialized yet.
* @type {boolean}
*/
get disabled() {
return this.data.disabled ?? true;
}
/**
* Has this point source been initialized?
* @type {boolean}
*/
get initialized() {
return this.#initialized;
}
#initialized = false;
/**
* The x-coordinate of the point source origin.
* @type {number}
*/
get x() {
return this.data.x;
}
/**
* The y-coordinate of the point source origin.
* @type {number}
*/
get y() {
return this.data.y;
}
/**
* The elevation bound to this source.
* @type {number}
*/
get elevation() {
return this.data.elevation;
}
/**
* A convenience reference to the radius of the source.
* @type {number}
*/
get radius() {
return this.data.radius ?? 0;
}
/* -------------------------------------------- */
/* Point Source Initialization */
/* -------------------------------------------- */
/**
* Initialize and configure the PointSource using provided data.
* @param {object} data Provided data for configuration
* @returns {PointSource} The configured source
*/
initialize(data={}) {
// Initialize data and record changes
const prior = foundry.utils.deepClone(this.data) || {};
this._initialize(data);
const changes = foundry.utils.flattenObject(foundry.utils.diffObject(prior, this.data));
// Compute the new polygon shape
this.shape = this._createPolygon();
if ( !this.shape ) return this;
// Configure the point source
this._configure(changes);
this.#updateId++;
this.#initialized = true;
this.#active = this._isActive();
return this;
}
/**
* Subclass specific data initialization steps.
* This method is responsible for populating the instance data object.
* @param {object} data Provided data for configuration
* @protected
*/
_initialize(data) {
this.data = {
x: data.x ?? 0,
y: data.y ?? 0,
z: data.z ?? null,
elevation: data.elevation ?? 0,
radius: data.radius ?? 0,
externalRadius: data.externalRadius ?? 0,
rotation: data.rotation ?? 0,
angle: data.angle ?? 360,
walls: data.walls ?? true,
disabled: data.disabled ?? false
};
if ( this.data.radius > 0 ) this.data.radius = Math.max(this.data.radius, this.data.externalRadius);
}
/**
* Subclass specific configuration steps. Occurs after data initialization and shape computation.
* @param {object} changes The fields of data which changed during initialization
* @protected
*/
_configure(changes={}) {}
/* -------------------------------------------- */
/* Point Source Refresh */
/* -------------------------------------------- */
/**
* Refresh the state and uniforms of the PointSource.
*/
refresh() {
// Skip sources which have not been initialized
if ( !this.#initialized ) return;
// Subclass refresh steps
this._refresh();
// Update active state
this.#active = this._isActive();
}
/* -------------------------------------------- */
/**
* Test whether this source should be active under current conditions?
* @returns {boolean}
* @protected
*/
_isActive() {
return !this.disabled;
}
/* -------------------------------------------- */
/**
* Subclass-specific refresh steps.
* @protected
* @abstract
*/
_refresh() {}
/* -------------------------------------------- */
/* Point Source Destruction */
/* -------------------------------------------- */
/**
* Steps that must be performed when the base source is destroyed.
*/
destroy() {
this.#initialized = false;
this._destroy();
}
/* -------------------------------------------- */
/**
* Subclass specific destruction steps.
* @protected
* @abstract
*/
_destroy() {}
/* -------------------------------------------- */
/* Point Source Geometry Methods */
/* -------------------------------------------- */
/**
* Configure the parameters of the polygon that is generated for this source.
* @returns {PointSourcePolygonConfig}
* @protected
*/
_getPolygonConfiguration() {
return {
type: this.data.walls ? this.constructor.sourceType : "universal",
radius: this.data.radius,
externalRadius: this.data.externalRadius,
angle: this.data.angle,
rotation: this.data.rotation,
source: this
};
}
/* -------------------------------------------- */
/**
* Create the polygon shape for this source using configured data.
* @returns {PointSourcePolygon}
* @protected
*/
_createPolygon() {
const origin = {x: this.data.x, y: this.data.y};
const config = this._getPolygonConfiguration();
if ( this.disabled ) config.radius = 0;
const polygonClass = CONFIG.Canvas.polygonBackends[this.constructor.sourceType];
return polygonClass.create(origin, config);
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get sourceType() {
const msg = "PointSource#sourceType is deprecated. Use PointSource#constructor.sourceType instead.";
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
return this.constructor.sourceType;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
set radius(radius) {
const msg = "The setter PointSource#radius is deprecated. "
+ "The radius should not be set anywhere except in PointSource#_initialize.";
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
this.data.radius = radius;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get los() {
const msg = "PointSource#los is deprecated in favor of PointSource#shape.";
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
return this.shape;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
set los(shape) {
const msg = "PointSource#los is deprecated in favor of PointSource#shape.";
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
this.shape = shape;
}
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
refreshSource() {
const msg = "PointSource#refreshSource is deprecated in favor of PointSource#refresh.";
foundry.utils.logCompatibilityWarning(msg, { since: 10, until: 12});
this.refresh();
}
}
/**
* @typedef {PointSourceData} RenderedPointSourceData
* @property {number|null} color A color applied to the rendered effect
* @property {number|null} seed An integer seed to synchronize (or de-synchronize) animations
* @property {boolean} preview Is this source a temporary preview?
*/
/**
* @typedef {Object} RenderedPointSourceAnimationConfig
* @property {string} [label] The human-readable (localized) label for the animation
* @property {Function} [animation] The animation function that runs every frame
* @property {AdaptiveIlluminationShader} [illuminationShader] A custom illumination shader used by this animation
* @property {AdaptiveColorationShader} [colorationShader] A custom coloration shader used by this animation
* @property {AdaptiveBackgroundShader} [backgroundShader] A custom background shader used by this animation
* @property {number} [seed] The animation seed
* @property {number} [time] The animation time
*/
/**
* An abstract class which extends the base PointSource to provide common functionality for rendering.
* This class is extended by both the LightSource and VisionSource subclasses.
*/
class RenderedPointSource extends PointSource {
/**
* Keys of the data object which require shaders to be re-initialized.
* @type {string[]}
* @protected
*/
static _initializeShaderKeys = [];
/**
* Keys of the data object which require uniforms to be refreshed.
* @type {string[]}
* @protected
*/
static _refreshUniformsKeys = [];
/**
* The offset in pixels applied to create soft edges.
* @type {number}
*/
static EDGE_OFFSET = -8;
/* -------------------------------------------- */
/* Rendered Source Attributes */
/* -------------------------------------------- */
/**
* The animation configuration applied to this source
* @type {RenderedPointSourceAnimationConfig}
*/
animation = {};
/**
* The object of data which configures how the source is rendered
* @type {RenderedPointSourceData}
*/
data = this.data;
/**
* @typedef {Object} RenderedPointSourceLayer
* @property {boolean} active Is this layer actively rendered?
* @property {boolean} reset Do uniforms need to be reset?
* @property {boolean} suppressed Is this layer temporarily suppressed?
* @property {PointSourceMesh} mesh The rendered mesh for this layer
* @property {AdaptiveLightingShader} shader The shader instance used for the layer
*/
/**
* Track the status of rendering layers
* @type {{background: RenderedPointSourceLayer, coloration: RenderedPointSourceLayer, illumination: RenderedPointSourceLayer}}
*/
layers = {
background: {active: true, reset: true, suppressed: false, mesh: undefined, shader: undefined, vmUniforms: undefined},
coloration: {active: true, reset: true, suppressed: false, mesh: undefined, shader: undefined, vmUniforms: undefined},
illumination: {active: true, reset: true, suppressed: false, mesh: undefined, shader: undefined, vmUniforms: undefined}
};
/**
* The color of the source as a RGB vector.
* @type {[number, number, number]|null}
*/
colorRGB = null;
/**
* PIXI Geometry generated to draw meshes.
* @type {PIXI.Geometry|null}
*/
#geometry = null;
/* -------------------------------------------- */
/* Rendered Source Properties */
/* -------------------------------------------- */
/**
* A convenience accessor to the background layer mesh.
* @type {PointSourceMesh}
*/
get background() {
return this.layers.background.mesh;
}
/**
* A convenience accessor to the coloration layer mesh.
* @type {PointSourceMesh}
*/
get coloration() {
return this.layers.coloration.mesh;
}
/**
* A convenience accessor to the illumination layer mesh.
* @type {PointSourceMesh}
*/
get illumination() {
return this.layers.illumination.mesh;
}
/**
* Is the rendered source animated?
* @type {boolean}
*/
get isAnimated() {
return this.active && !!this.animation.animation;
}
/**
* Has the rendered source at least one active layer?
* @type {boolean}
*/
get hasActiveLayer() {
return this.#hasActiveLayer;
}
#hasActiveLayer = false;
/**
* Is this RenderedPointSource a temporary preview?
* @returns {boolean}
*/
get isPreview() {
return !!this.data.preview;
}
/* -------------------------------------------- */
/* Rendered Source Initialization */
/* -------------------------------------------- */
/** @override */
_initialize(data) {
super._initialize(data);
this.data.seed = data.seed ?? null;
this.data.preview = data.preview ?? false;
this.data.color = Color.from(data.color).valueOf();
if ( Number.isNaN(this.data.color) ) this.data.color = null;
}
/* -------------------------------------------- */
/** @override */
_configure(changes) {
// Configure the derived color attributes with main data color
this._configureColorAttributes(this.data.color);
// Initialize the soft edges flag
this._configureSoftEdges();
// Initialize meshes using the computed shape
const initializeShaders = this.#initializeMeshes();
// Initialize shaders
if ( initializeShaders || this.constructor._initializeShaderKeys.some(k => k in changes) ) {
this.#initializeShaders();
}
// Refresh uniforms
else if ( this.constructor._refreshUniformsKeys.some(k => k in changes) ) {
for ( const config of Object.values(this.layers) ) {
config.reset = true;
}
}
// Configure blend modes and sorting
this._initializeBlending();
// Update the visible state the layers
this.#updateVisibleLayers();
}
/* -------------------------------------------- */
/**
* Decide whether to render soft edges with a blur.
* @protected
*/
_configureSoftEdges() {
this._flags.renderSoftEdges = canvas.performance.lightSoftEdges && !this.isPreview
&& !((this.shape instanceof PointSourcePolygon) && this.shape.isCompleteCircle());
}
/* -------------------------------------------- */
/**
* Configure the derived color attributes and associated flag.
* @param {number|null} color The color to configure (usually a color coming for the rendered point source data)
* or null if no color is configured for this rendered source.
* @protected
*/
_configureColorAttributes(color) {
// Record hasColor flags and assign derived attributes
const hasColor = this._flags.hasColor = (color !== null);
if ( hasColor ) Color.applyRGB(color, this.colorRGB ??= [0, 0, 0]);
else this.colorRGB = null;
// We need to update the hasColor uniform attribute immediately
for ( const layer of Object.values(this.layers) ) {
if ( layer.shader ) layer.shader.uniforms.hasColor = hasColor;
}
}
/* -------------------------------------------- */
/**
* Configure which shaders are used for each rendered layer.
* @returns {{
* background: AdaptiveLightingShader,
* coloration: AdaptiveLightingShader,
* illumination: AdaptiveLightingShader
* }}
* @private
*/
_configureShaders() {
const a = this.animation;
return {
background: a.backgroundShader || AdaptiveBackgroundShader,
coloration: a.colorationShader || AdaptiveColorationShader,
illumination: a.illuminationShader || AdaptiveIlluminationShader
};
}
/* -------------------------------------------- */
/**
* Specific configuration for a layer.
* @param {object} layer
* @param {string} layerId
* @protected
*/
_configureLayer(layer, layerId) {}
/* -------------------------------------------- */
/**
* Initialize the shaders used for this source, swapping to a different shader if the animation has changed.
*/
#initializeShaders() {
const shaders = this._configureShaders();
for ( const [layerId, layer] of Object.entries(this.layers) ) {
layer.shader = RenderedPointSource.#createShader(shaders[layerId], layer.mesh);
this._configureLayer(layer, layerId);
}
this.#updateUniforms();
Hooks.callAll(`initialize${this.constructor.name}Shaders`, this);
}
/* -------------------------------------------- */
/**
* Create a new shader using a provider shader class
* @param {typeof AdaptiveLightingShader} cls The shader class to create
* @param {PointSourceMesh} container The container which requires a new shader
* @returns {AdaptiveLightingShader} The shader instance used
*/
static #createShader(cls, container) {
const current = container.shader;
if ( current?.constructor === cls ) return current;
const shader = cls.create({
primaryTexture: canvas.primary.renderTexture
});
shader.container = container;
container.shader = shader;
container.uniforms = shader.uniforms;
if ( current ) current.destroy();
return shader;
}
/* -------------------------------------------- */
/**
* Initialize the blend mode and vertical sorting of this source relative to others in the container.
* @protected
*/
_initializeBlending() {
const BM = PIXI.BLEND_MODES;
const blending = {
background: {blendMode: BM.MAX_COLOR, zIndex: 0},
illumination: {blendMode: BM.MAX_COLOR, zIndex: 0},
coloration: {blendMode: BM.SCREEN, zIndex: 0}
};
for ( const [l, layer] of Object.entries(this.layers) ) {
const b = blending[l];
layer.mesh.blendMode = b.blendMode;
layer.mesh.zIndex = b.zIndex;
}
}
/* -------------------------------------------- */
/**
* Create or update the source geometry and create meshes if necessary
* @returns {boolean} True if the shaders need to be initialized.
*/
#initializeMeshes() {
const createMeshes = !this.#geometry;
this.#updateGeometry();
if ( createMeshes ) this.#createMeshes();
return createMeshes;
}
/* -------------------------------------------- */
/**
* Create meshes for each layer of the RenderedPointSource that is drawn to the canvas.
*/
#createMeshes() {
const shaders = this._configureShaders();
for ( const [l, layer] of Object.entries(this.layers) ) {
layer.mesh = this.#createMesh(shaders[l]);
layer.shader = layer.mesh.shader;
}
}
/* -------------------------------------------- */
/**
* Create a new Mesh for this source using a provided shader class
* @param {typeof AdaptiveLightingShader} shaderCls The shader class used for this mesh
* @returns {PointSourceMesh} The created Mesh
*/
#createMesh(shaderCls) {
const state = new PIXI.State();
const mesh = new PointSourceMesh(this.#geometry, shaderCls.create(), state);
mesh.drawMode = PIXI.DRAW_MODES.TRIANGLES;
mesh.uniforms = mesh.shader.uniforms;
mesh.cullable = true;
return mesh;
}
/* -------------------------------------------- */
/**
* Create the geometry for the source shape that is used in shaders and compute its bounds for culling purpose.
* Triangulate the form and create buffers.
*/
#updateGeometry() {
const {x, y, radius} = this.data;
const offset = this._flags.renderSoftEdges ? this.constructor.EDGE_OFFSET : 0;
const pm = new PolygonMesher(this.shape, {x, y, radius, normalize: true, offset});
this.#geometry = pm.triangulate(this.#geometry);
// Compute bounds of the geometry (used for optimizing culling)
const bounds = new PIXI.Rectangle(0, 0, 0, 0);
if ( radius > 0 ) {
const b = this.shape instanceof PointSourcePolygon ? this.shape.bounds : this.shape.getBounds();
bounds.x = (b.x - x) / radius;
bounds.y = (b.y - y) / radius;
bounds.width = b.width / radius;
bounds.height = b.height / radius;
}
if ( this.#geometry.bounds ) this.#geometry.bounds.copyFrom(bounds);
else this.#geometry.bounds = bounds;
}
/* -------------------------------------------- */
/* Rendered Source Canvas Rendering */
/* -------------------------------------------- */
/**
* Render the containers used to represent this light source within the LightingLayer
* @returns {{background: PIXI.Mesh, coloration: PIXI.Mesh, illumination: PIXI.Mesh}}
*/
drawMeshes() {
const meshes = {};
if ( !this.initialized ) return meshes;
for ( const layerId of Object.keys(this.layers) ) {
meshes[layerId] = this.#drawMesh(layerId);
}
return meshes;
}
/* -------------------------------------------- */
/**
* Create a Mesh for the background component of this source which will be added to CanvasBackgroundEffects.
* @param {string} layerId The layer key in layers to draw
* @returns {PIXI.Mesh|null} The drawn mesh for this layer, or null if no mesh is required
*/
#drawMesh(layerId) {
const layer = this.layers[layerId];
const mesh = layer.mesh;
if ( layer.reset ) {
const fn = this[`_update${layerId.titleCase()}Uniforms`];
fn.call(this);
}
if ( !layer.active || (this.data.radius <= 0) ) {
mesh.visible = false;
return null;
}
// Update the mesh
const {x, y, radius} = this.data;
mesh.position.set(x, y);
mesh.scale.set(radius);
mesh.visible = mesh.renderable = true;
return layer.mesh;
}
/* -------------------------------------------- */
/* Rendered Source Refresh */
/* -------------------------------------------- */
/** @override */
_refresh() {
this.#updateUniforms();
this.#updateVisibleLayers();
}
/* -------------------------------------------- */
/** @inheritDoc */
_isActive() {
return this.#hasActiveLayer && super._isActive();
}
/* -------------------------------------------- */
/**
* Update uniforms for all rendered layers.
*/
#updateUniforms() {
if ( this.disabled ) return;
this._updateBackgroundUniforms();
this._updateIlluminationUniforms();
this._updateColorationUniforms();
}
/* -------------------------------------------- */
/**
* Update the visible state of the component channels of this RenderedPointSource.
*/
#updateVisibleLayers() {
let hasActiveLayer = false;
for ( const layer of Object.values(this.layers) ) {
layer.active = !this.disabled && (layer.shader?.isRequired !== false);
if ( layer.active ) hasActiveLayer = true;
}
this.#hasActiveLayer = hasActiveLayer;
}
/* -------------------------------------------- */
/**
* Update shader uniforms used for the background layer.
* @protected
*/
_updateBackgroundUniforms() {}
/* -------------------------------------------- */
/**
* Update shader uniforms used for the coloration layer.
* @protected
*/
_updateColorationUniforms() {}
/* -------------------------------------------- */
/**
* Update shader uniforms used for the illumination layer.
* @protected
*/
_updateIlluminationUniforms() {}
/* -------------------------------------------- */
/* Rendered Source Destruction */
/* -------------------------------------------- */
/** @override */
_destroy() {
for ( const layer of Object.values(this.layers) ) {
layer.mesh?.destroy();
}
this.#geometry?.destroy();
}
/* -------------------------------------------- */
/* Animation Functions */
/* -------------------------------------------- */
/**
* Animate the PointSource, if an animation is enabled and if it currently has rendered containers.
* @param {number} dt Delta time.
*/
animate(dt) {
if ( !this.isAnimated ) return;
const {animation, ...options} = this.animation;
return animation.call(this, dt, options);
}
/* -------------------------------------------- */
/**
* Generic time-based animation used for Rendered Point Sources.
* @param {number} dt Delta time.
* @param {object} [options] Options which affect the time animation
* @param {number} [options.speed=5] The animation speed, from 1 to 10
* @param {number} [options.intensity=5] The animation intensity, from 1 to 10
* @param {boolean} [options.reverse=false] Reverse the animation direction
*/
animateTime(dt, {speed=5, intensity=5, reverse=false}={}) {
// Determine the animation timing
let t = canvas.app.ticker.lastTime;
if ( reverse ) t *= -1;
this.animation.time = ( (speed * t) / 5000 ) + this.animation.seed;
// Update uniforms
for ( const layer of Object.values(this.layers) ) {
const u = layer.mesh.uniforms;
u.time = this.animation.time;
u.intensity = intensity;
}
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get preview() {
const msg = "The RenderedPointSource#preview is deprecated. "
+ "Use RenderedPointSource#isPreview instead.";
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
return this.isPreview;
}
/**
* @deprecated since v11
* @ignore
*/
set preview(preview) {
const msg = "The RenderedPointSource#preview is deprecated. "
+ "Set RenderedPointSourceData#preview as part of RenderedPointSourceData#initialize instead.";
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
this.data.preview = preview;
}
}
/**
* @typedef {RenderedPointSourceData} LightSourceData
* @see {@link foundry.data.LightData}
* @property {number} alpha An opacity for the emitted light, if any
* @property {object} animation An animation configuration for the source
* @property {number} bright The allowed radius of bright vision or illumination
* @property {number} coloration The coloration technique applied in the shader
* @property {number} contrast The amount of contrast this light applies to the background texture
* @property {number} dim The allowed radius of dim vision or illumination
* @property {number} attenuation Strength of the attenuation between bright, dim, and dark
* @property {number} luminosity The luminosity applied in the shader
* @property {number} saturation The amount of color saturation this light applies to the background texture
* @property {number} shadows The depth of shadows this light applies to the background texture
* @property {boolean} vision Whether or not this source provides a source of vision
*/
/**
* A specialized subclass of the PointSource abstraction which is used to control the rendering of light sources.
*/
class LightSource extends RenderedPointSource {
/** @inheritDoc */
static sourceType = "light";
/** @override */
static _initializeShaderKeys = ["animation.type", "walls"];
/** @override */
static _refreshUniformsKeys = ["dim", "bright", "attenuation", "alpha", "coloration", "color", "contrast",
"saturation", "shadows", "luminosity"];
/* -------------------------------------------- */
/* Light Source Attributes */
/* -------------------------------------------- */
/**
* The object of data which configures how the source is rendered
* @type {LightSourceData}
*/
data = this.data;
/**
* The ratio of dim:bright as part of the source radius
* @type {number}
*/
ratio = 0;
/* -------------------------------------------- */
/* Light Source Properties */
/* -------------------------------------------- */
/**
* Is this darkness?
* @type {boolean}
*/
get isDarkness() {
return this.data.luminosity < 0;
}
/* -------------------------------------------- */
/* Light Source Initialization */
/* -------------------------------------------- */
/** @override */
_initialize(data) {
super._initialize(data);
this.data.alpha = data.alpha ?? 0.5;
this.data.animation = data.animation ?? {};
this.data.bright = data.bright ?? 0;
this.data.coloration = data.coloration ?? 1;
this.data.contrast = data.contrast ?? 0;
this.data.dim = data.dim ?? 0;
this.data.attenuation = data.attenuation ?? 0.5;
this.data.luminosity = data.luminosity ?? 0.5;
this.data.saturation = data.saturation ?? 0;
this.data.shadows = data.shadows ?? 0;
this.data.vision = data.vision ?? false;
this.data.radius = Math.max(Math.abs(this.data.dim), Math.abs(this.data.bright));
if ( this.data.radius > 0 ) this.data.radius = Math.max(this.data.radius, this.data.externalRadius);
}
/* -------------------------------------------- */
/** @override */
_configure(changes) {
// Record the requested animation configuration
const seed = this.data.seed ?? this.animation.seed ?? Math.floor(Math.random() * 100000);
const animationConfig = foundry.utils.deepClone(CONFIG.Canvas.lightAnimations[this.data.animation.type] || {});
this.animation = Object.assign(animationConfig, this.data.animation, {seed});
// Compute data attributes
this.ratio = Math.clamped(Math.abs(this.data.bright) / this.data.radius, 0, 1);
// Parent class configuration
return super._configure(changes);
}
/* -------------------------------------------- */
/** @inheritDoc */
_getPolygonConfiguration() {
return Object.assign(super._getPolygonConfiguration(), {useThreshold: true});
}
/* -------------------------------------------- */
/** @override */
_initializeBlending() {
// Configure blending data
const BM = PIXI.BLEND_MODES;
const i = this.layers.illumination;
const z = this.data.z ?? (this.isDarkness ? 10 : 0);
const blending = {
background: {blendMode: BM.MAX_COLOR, zIndex: 0},
illumination: {blendMode: BM[this.isDarkness ? "MIN_COLOR" : "MAX_COLOR"], zIndex: z},
coloration: {blendMode: BM[this.isDarkness ? "MULTIPLY" : "SCREEN"], zIndex: z}
};
// Assign blending data
for ( const [l, layer] of Object.entries(this.layers) ) {
const b = blending[l];
layer.mesh.blendMode = b.blendMode;
layer.mesh.zIndex = b.zIndex;
}
// Special logic to temporarily suppress the illumination layer when the darkness state changes
if ( i.reset && (i.mesh.blendMode !== blending.illumination.blendMode) ) {
i.suppressed = true;
i.mesh.renderable = false;
}
}
/* -------------------------------------------- */
/* Shader Management */
/* -------------------------------------------- */
/** @override */
_updateColorationUniforms() {
const shader = this.layers.coloration.shader;
if ( !shader ) return;
const u = shader.uniforms;
this._updateCommonUniforms(shader);
// Adapting color intensity to the coloration technique
switch (this.data.coloration) {
case 0: // Legacy
// Default 0.25 -> Legacy technique needs quite low intensity default to avoid washing background
u.colorationAlpha = Math.pow(this.data.alpha, 2);
break;
case 4: // Color burn
case 5: // Internal burn
case 6: // External burn
case 9: // Invert absorption
// Default 0.5 -> These techniques are better at low color intensity
u.colorationAlpha = this.data.alpha;
break;
default:
// Default 1 -> The remaining techniques use adaptive lighting,
// which produces interesting results in the [0, 2] range.
u.colorationAlpha = this.data.alpha * 2;
}
u.useSampler = this.data.coloration > 0; // Not needed for legacy coloration (technique id 0)
// Flag uniforms as updated
this.layers.coloration.reset = false;
}
/* -------------------------------------------- */
/** @override */
_updateIlluminationUniforms() {
const shader = this.layers.illumination.shader;
if ( !shader ) return;
const c = canvas.colors;
const u = shader.uniforms;
const colorIntensity = this.data.alpha;
let colorDim;
let colorBright;
// Inner function to get a corrected color according to the vision mode lighting levels configuration
const getCorrectedColor = (level, colorDim, colorBright, colorBackground=c.background) => {
// Retrieving the lighting mode and the corrected level, if any
const lightingOptions = canvas.effects.visibility.visionModeData?.activeLightingOptions;
const correctedLevel = (lightingOptions?.levels?.[level]) ?? level;
// Returning the corrected color according to the lighting options
const levels = VisionMode.LIGHTING_LEVELS;
switch ( correctedLevel ) {
case levels.HALFDARK:
case levels.DIM: return colorDim;
case levels.BRIGHT:
case levels.DARKNESS: return colorBright;
case levels.BRIGHTEST: return c.ambientBrightest;
case levels.UNLIT: return colorBackground;
default: return colorDim;
}
};
// Darkness [-1, 0)
if ( this.isDarkness ) {
let lc; let cdim1; let cdim2; let cbr1; let cbr2;
// Creating base colors for darkness
const iMid = Color.mix(c.background, c.darkness, 0.5);
const mid = this.data.color
? Color.multiplyScalar(Color.multiply(this.data.color, iMid), colorIntensity * 2)
: iMid;
const black = this.data.color
? Color.multiplyScalar(Color.multiply(this.data.color, c.darkness), colorIntensity * 2)
: c.darkness;
if ( this.data.luminosity < -0.5 ) {
lc = Math.abs(this.data.luminosity) - 0.5;
cdim1 = black;
cdim2 = Color.multiplyScalar(black, 0.625);
cbr1 = Color.multiplyScalar(black, 0.5);
cbr2 = Color.multiplyScalar(black, 0.125);
}
else {
lc = Math.sqrt(Math.abs(this.data.luminosity) * 2); // Accelerating easing toward dark tone with sqrt
cdim1 = mid;
cdim2 = black;
cbr1 = mid;
cbr2 = Color.multiplyScalar(black, 0.5);
}
colorDim = Color.mix(cdim1, cdim2, lc);
colorBright = Color.mix(cbr1, cbr2, lc);
Color.applyRGB(getCorrectedColor(VisionMode.LIGHTING_LEVELS.HALFDARK, colorDim, colorBright), u.colorDim);
Color.applyRGB(getCorrectedColor(VisionMode.LIGHTING_LEVELS.DARKNESS, colorDim, colorBright), u.colorBright);
}
// Light [0,1]
else {
const lum = this.data.luminosity;
// Get the luminosity penalty for the bright color
const lumPenalty = Math.clamped(lum * 2, 0, 1);
// Attenuate darkness penalty applied to bright color according to light source luminosity level
const correctedBright = Color.mix(c.bright, c.ambientBrightest, Math.clamped((lum * 2) - 1, 0, 1));
// Assign colors and apply luminosity penalty on the bright channel
colorBright = Color.maximize(Color.multiplyScalar(correctedBright, lumPenalty), c.background);
// Recompute dim colors with the updated luminosity
colorDim = Color.mix(c.background, colorBright, canvas.colorManager.weights.dim);
Color.applyRGB(getCorrectedColor(VisionMode.LIGHTING_LEVELS.BRIGHT, colorDim, colorBright), u.colorBright);
Color.applyRGB(getCorrectedColor(VisionMode.LIGHTING_LEVELS.DIM, colorDim, colorBright), u.colorDim);
}
c.background.applyRGB(u.colorBackground);
u.useSampler = false;
// Update shared uniforms
this._updateCommonUniforms(shader);
// Flag uniforms as updated
const i = this.layers.illumination;
i.reset = i.suppressed = false;
}
/* -------------------------------------------- */
/** @override */
_updateBackgroundUniforms() {
const shader = this.layers.background.shader;
if ( !shader ) return;
const u = shader.uniforms;
canvas.colors.background.applyRGB(u.colorBackground);
u.backgroundAlpha = this.data.alpha;
u.darknessLevel = canvas.colorManager.darknessLevel;
u.useSampler = true;
// Update shared uniforms
this._updateCommonUniforms(shader);
// Flag uniforms as updated
this.layers.background.reset = false;
}
/* -------------------------------------------- */
/**
* Update shader uniforms shared by all shader types
* @param {AdaptiveLightingShader} shader The shader being updated
* @protected
*/
_updateCommonUniforms(shader) {
const u = shader.uniforms;
// Passing advanced color correction values
u.exposure = this._mapLuminosity(this.data.luminosity);
u.contrast = (this.data.contrast < 0 ? this.data.contrast * 0.5 : this.data.contrast);
u.saturation = this.data.saturation;
u.shadows = this.data.shadows;
u.darkness = this.isDarkness;
u.hasColor = this._flags.hasColor;
u.ratio = this.ratio;
u.technique = this.data.coloration;
// Graph: https://www.desmos.com/calculator/e7z0i7hrck
// mapping [0,1] attenuation user value to [0,1] attenuation shader value
if ( this.cachedAttenuation !== this.data.attenuation ) {
this.computedAttenuation = (Math.cos(Math.PI * Math.pow(this.data.attenuation, 1.5)) - 1) / -2;
this.cachedAttenuation = this.data.attenuation;
}
u.attenuation = this.computedAttenuation;
u.depthElevation = canvas.primary.mapElevationToDepth(this.data.elevation);
u.color = this.colorRGB ?? shader._defaults.color;
// Passing screenDimensions to use screen size render textures
u.screenDimensions = canvas.screenDimensions;
if ( !u.depthTexture ) u.depthTexture = canvas.masks.depth.renderTexture;
if ( !u.primaryTexture ) u.primaryTexture = canvas.primary.renderTexture;
}
/* -------------------------------------------- */
/**
* Map luminosity value to exposure value
* luminosity[-1 , 0 [ => Darkness => map to exposure ] 0, 1]
* luminosity[ 0 , 0.5[ => Light => map to exposure [-0.5, 0[
* luminosity[ 0.5, 1 ] => Light => map to exposure [ 0, 1]
* @param {number} lum The luminosity value
* @returns {number} The exposure value
* @private
*/
_mapLuminosity(lum) {
if ( lum < 0 ) return lum + 1;
if ( lum < 0.5 ) return lum - 0.5;
return ( lum - 0.5 ) * 2;
}
/* -------------------------------------------- */
/* Animation Functions */
/* -------------------------------------------- */
/**
* An animation with flickering ratio and light intensity.
* @param {number} dt Delta time
* @param {object} [options={}] Additional options which modify the flame animation
* @param {number} [options.speed=5] The animation speed, from 1 to 10
* @param {number} [options.intensity=5] The animation intensity, from 1 to 10
* @param {boolean} [options.reverse=false] Reverse the animation direction
*/
animateTorch(dt, {speed=5, intensity=5, reverse=false} = {}) {
this.animateFlickering(dt, {speed, intensity, reverse, amplification: intensity / 5});
}
/* -------------------------------------------- */
/**
* An animation with flickering ratio and light intensity
* @param {number} dt Delta time
* @param {object} [options={}] Additional options which modify the flame animation
* @param {number} [options.speed=5] The animation speed, from 1 to 10
* @param {number} [options.intensity=5] The animation intensity, from 1 to 10
* @param {number} [options.amplification=1] Noise amplification (>1) or dampening (<1)
* @param {boolean} [options.reverse=false] Reverse the animation direction
*/
animateFlickering(dt, {speed=5, intensity=5, reverse=false, amplification=1} = {}) {
this.animateTime(dt, {speed, intensity, reverse});
// Create the noise object for the first frame
const amplitude = amplification * 0.45;
if ( !this._noise ) this._noise = new SmoothNoise({amplitude: amplitude, scale: 3, maxReferences: 2048});
// Update amplitude
if ( this._noise.amplitude !== amplitude ) this._noise.amplitude = amplitude;
// Create noise from animation time. Range [0.0, 0.45]
let n = this._noise.generate(this.animation.time);
// Update brightnessPulse and ratio with some noise in it
const co = this.layers.coloration.shader;
const il = this.layers.illumination.shader;
co.uniforms.brightnessPulse = il.uniforms.brightnessPulse = 0.55 + n; // Range [0.55, 1.0 <* amplification>]
co.uniforms.ratio = il.uniforms.ratio = (this.ratio * 0.9) + (n * 0.222);// Range [ratio * 0.9, ratio * ~1.0 <* amplification>]
}
/* -------------------------------------------- */
/**
* A basic "pulse" animation which expands and contracts.
* @param {number} dt Delta time
* @param {object} [options={}] Additional options which modify the pulse animation
* @param {number} [options.speed=5] The animation speed, from 1 to 10
* @param {number} [options.intensity=5] The animation intensity, from 1 to 10
* @param {boolean} [options.reverse=false] Reverse the animation direction
*/
animatePulse(dt, {speed=5, intensity=5, reverse=false}={}) {
// Determine the animation timing
let t = canvas.app.ticker.lastTime;
if ( reverse ) t *= -1;
this.animation.time = ((speed * t)/5000) + this.animation.seed;
// Define parameters
const i = (10 - intensity) * 0.1;
const w = 0.5 * (Math.cos(this.animation.time * 2.5) + 1);
const wave = (a, b, w) => ((a - b) * w) + b;
// Pulse coloration
const co = this.layers.coloration.shader;
co.uniforms.intensity = intensity;
co.uniforms.time = this.animation.time;
co.uniforms.pulse = wave(1.2, i, w);
// Pulse illumination
const il = this.layers.illumination.shader;
il.uniforms.intensity = intensity;
il.uniforms.time = this.animation.time;
il.uniforms.ratio = wave(this.ratio, this.ratio * i, w);
}
/* -------------------------------------------- */
/* Visibility Testing */
/* -------------------------------------------- */
/**
* Test whether this LightSource provides visibility to see a certain target object.
* @param {object} config The visibility test configuration
* @param {CanvasVisibilityTest[]} config.tests The sequence of tests to perform
* @param {PlaceableObject} config.object The target object being tested
* @returns {boolean} Is the target object visible to this source?
*/
testVisibility({tests, object}={}) {
if ( !(this.data.vision && this._canDetectObject(object)) ) return false;
return tests.some(test => {
const {x, y} = test.point;
return this.shape.contains(x, y);
});
}
/* -------------------------------------------- */
/**
* Can this LightSource theoretically detect a certain object based on its properties?
* This check should not consider the relative positions of either object, only their state.
* @param {PlaceableObject} target The target object being tested
* @returns {boolean} Can the target object theoretically be detected by this vision source?
*/
_canDetectObject(target) {
const tgt = target?.document;
const isInvisible = ((tgt instanceof TokenDocument) && tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE));
return !isInvisible;
}
}
/* -------------------------------------------- */
/**
* A specialized subclass of the LightSource which is used to render global light source linked to the scene.
*/
class GlobalLightSource extends LightSource {
/** @override */
_createPolygon() {
return canvas.dimensions.sceneRect.toPolygon();
}
/* -------------------------------------------- */
/** @override */
_configureSoftEdges() {
this._flags.renderSoftEdges = false;
}
/* -------------------------------------------- */
/** @override */
_initialize(data) {
super._initialize(data);
// Force attenuation to 0
this.data.attenuation = 0;
// Inflate radius to avoid seeing the edges of the GlobalLight in huge maps without padding
// TODO: replace with better handling of rectangular shapes and custom shader
this.data.radius *= 1.2;
}
}
/**
* A subclass of PointSource which is used when computing the polygonal area where movement is possible.
*/
class MovementSource extends PointSource {
/** @override */
static sourceType = "move";
}
/**
* A specialized subclass of the PointSource abstraction which is used to control the rendering of sound sources.
*/
class SoundSource extends PointSource {
/** @inheritdoc */
static sourceType = "sound";
/* -------------------------------------------- */
/** @inheritDoc */
_getPolygonConfiguration() {
return Object.assign(super._getPolygonConfiguration(), {useThreshold: true});
}
}
/**
* @typedef {RenderedPointSourceData} VisionSourceData
* @property {number} contrast The amount of contrast
* @property {number} attenuation Strength of the attenuation between bright, dim, and dark
* @property {number} saturation The amount of color saturation
* @property {number} brightness The vision brightness.
* @property {string} visionMode The vision mode.
* @property {boolean} blinded Is this vision source blinded?
*/
/**
* A specialized subclass of the PointSource abstraction which is used to control the rendering of vision sources.
* @property {VisionSourceData} data
*/
class VisionSource extends RenderedPointSource {
/** @inheritdoc */
static sourceType = "sight";
/** @override */
static _initializeShaderKeys = ["visionMode", "blinded"];
/** @override */
static _refreshUniformsKeys = ["radius", "color", "attenuation", "brightness", "contrast", "saturation", "visionMode"];
/** @inheritdoc */
static EDGE_OFFSET = -2;
/* -------------------------------------------- */
/* Vision Source Attributes */
/* -------------------------------------------- */
/**
* The object of data which configures how the source is rendered
* @type {VisionSourceData}
*/
data = this.data;
/**
* The vision mode linked to this VisionSource
* @type {VisionMode|null}
*/
visionMode = null;
/**
* The vision mode activation flag for handlers
* @type {boolean}
* @internal
*/
_visionModeActivated = false;
/**
* The unconstrained LOS polygon.
* @type {PointSourcePolygon}
*/
los;
/* -------------------------------------------- */
/* Vision Source Attributes */
/* -------------------------------------------- */
/**
* An alias for the shape of the vision source.
* @type {PointSourcePolygon|PIXI.Polygon}
*/
get fov() {
return this.shape;
}
/* -------------------------------------------- */
/**
* If this vision source background is rendered into the lighting container.
* @type {boolean}
*/
get preferred() {
return this.visionMode?.vision.preferred;
}
/* -------------------------------------------- */
/** @override */
get isAnimated() {
return super.isAnimated && this.visionMode.animated;
}
/* -------------------------------------------- */
/* Vision Source Initialization */
/* -------------------------------------------- */
/** @override */
_initialize(data) {
super._initialize(data);
this.data.contrast = data.contrast ?? 0;
this.data.attenuation = data.attenuation ?? 0.5;
this.data.saturation = data.saturation ?? 0;
this.data.brightness = data.brightness ?? 0;
this.data.visionMode = data.visionMode ?? "basic";
this.data.blinded = data.blinded ?? false;
}
/* -------------------------------------------- */
/** @override */
_configure(changes) {
this.los = this.shape;
// Determine the active VisionMode
this._initializeVisionMode();
if ( !(this.visionMode instanceof VisionMode) ) {
throw new Error("The VisionSource was not provided a valid VisionMode identifier");
}
// Configure animation, if any
this.animation = {
animation: this.visionMode.animate,
seed: this.data.seed ?? this.animation.seed ?? Math.floor(Math.random() * 100000)
};
// Compute the constrained vision polygon
this.shape = this._createRestrictedPolygon();
// Parent class configuration
return super._configure(changes);
}
/* -------------------------------------------- */
/** @override */
_configureLayer(layer, layerId) {
const vmUniforms = this.visionMode.vision[layerId].uniforms;
layer.vmUniforms = Object.entries(vmUniforms);
}
/* -------------------------------------------- */
/**
* Responsible for assigning the Vision Mode and handling exceptions based on vision special status.
* @protected
*/
_initializeVisionMode() {
const blinded = this.data.blinded;
const previousVM = this.visionMode;
const visionMode = this.data.visionMode in CONFIG.Canvas.visionModes ? this.data.visionMode : "basic";
this.visionMode = blinded ? CONFIG.Canvas.visionModes.blindness : CONFIG.Canvas.visionModes[visionMode];
const deactivateHandler = ((previousVM?.id !== this.visionMode.id) && previousVM);
// Call specific configuration for handling the blinded condition
if ( blinded ) {
this.data.radius = this.data.externalRadius;
this._configureColorAttributes(null);
foundry.utils.mergeObject(this.data, this.visionMode.vision.defaults);
}
// Process deactivation and activation handlers
if ( deactivateHandler ) previousVM.deactivate(this);
this.visionMode.activate(this);
}
/* -------------------------------------------- */
/** @override */
_getPolygonConfiguration() {
return Object.assign(super._getPolygonConfiguration(), {
radius: canvas.dimensions.maxR,
useThreshold: true
});
}
/* -------------------------------------------- */
/**
* Create a restricted FOV polygon by limiting the radius of the unrestricted LOS polygon.
* @returns {PointSourcePolygon}
* @protected
*/
_createRestrictedPolygon() {
const origin = {x: this.data.x, y: this.data.y};
const radius = this.data.radius || this.data.externalRadius;
const circle = new PIXI.Circle(origin.x, origin.y, radius);
const density = PIXI.Circle.approximateVertexDensity(radius);
return this.los.applyConstraint(circle, {density, scalingFactor: 100});
}
/* -------------------------------------------- */
/* Shader Management */
/* -------------------------------------------- */
/** @override */
_configureShaders() {
const vm = this.visionMode.vision;
return {
background: vm.background.shader || BackgroundVisionShader,
coloration: vm.coloration.shader || ColorationVisionShader,
illumination: vm.illumination.shader || IlluminationVisionShader
};
}
/* -------------------------------------------- */
/**
* Update shader uniforms by providing data from this VisionSource.
* @protected
*/
_updateColorationUniforms() {
const shader = this.layers.coloration.shader;
if ( !shader ) return;
const u = shader.uniforms;
const d = shader._defaults;
u.colorEffect = this.colorRGB ?? d.colorEffect;
u.useSampler = true;
this._updateCommonUniforms(shader);
const vmUniforms = this.layers.coloration.vmUniforms;
if ( vmUniforms.length ) this._updateVisionModeUniforms(shader, vmUniforms);
}
/* -------------------------------------------- */
/**
* Update shader uniforms by providing data from this VisionSource.
* @protected
*/
_updateIlluminationUniforms() {
const shader = this.layers.illumination.shader;
if ( !shader ) return;
const u = shader.uniforms;
const colorBright = Color.maximize(canvas.colors.bright, canvas.colors.background);
const colorDim = canvas.colors.dim;
const colorBackground = canvas.colors.background;
// Modify and assign vision color according to brightness.
// (brightness 0.5 = dim color, brightness 1.0 = bright color)
if ( this.data.brightness <= 0 ) {
Color.applyRGB(Color.mix(colorBackground, colorDim, this.data.brightness + 1), u.colorVision);
}
else Color.applyRGB(Color.mix(colorDim, colorBright, this.data.brightness), u.colorVision);
u.useSampler = false; // We don't need to use the background sampler into vision illumination
this._updateCommonUniforms(shader);
const vmUniforms = this.layers.illumination.vmUniforms;
if ( vmUniforms.length ) this._updateVisionModeUniforms(shader, vmUniforms);
}
/* -------------------------------------------- */
/**
* Update shader uniforms by providing data from this PointSource
* @private
*/
_updateBackgroundUniforms() {
const shader = this.layers.background.shader;
if ( !shader ) return;
const u = shader.uniforms;
u.technique = 0;
u.contrast = this.data.contrast;
u.useSampler = true;
this._updateCommonUniforms(shader);
const vmUniforms = this.layers.background.vmUniforms;
if ( vmUniforms.length ) this._updateVisionModeUniforms(shader, vmUniforms);
}
/* -------------------------------------------- */
/**
* Update shader uniforms shared by all shader types
* @param {AdaptiveVisionShader} shader The shader being updated
* @private
*/
_updateCommonUniforms(shader) {
const u = shader.uniforms;
const d = shader._defaults;
u.attenuation = Math.max(this.data.attenuation, 0.0125);
u.saturation = this.data.saturation;
u.screenDimensions = canvas.screenDimensions;
u.colorTint = this.colorRGB ?? d.colorTint;
canvas.colors.background.applyRGB(u.colorBackground);
u.brightness = (this.data.brightness + 1) / 2;
u.darknessLevel = canvas.colorManager.darknessLevel;
u.linkedToDarknessLevel = this.visionMode.vision.darkness.adaptive;
u.depthElevation = canvas.primary.mapElevationToDepth(this.data.elevation);
if ( !u.depthTexture ) u.depthTexture = canvas.masks.depth.renderTexture;
if ( !u.primaryTexture ) u.primaryTexture = canvas.primary.renderTexture;
}
/* -------------------------------------------- */
/**
* Update layer uniforms according to vision mode uniforms, if any.
* @param {AdaptiveVisionShader} shader The shader being updated.
* @param {Array} vmUniforms The targeted layer.
* @private
*/
_updateVisionModeUniforms(shader, vmUniforms) {
const shaderUniforms = shader.uniforms;
for ( const [uniform, value] of vmUniforms ) {
if ( Array.isArray(value) ) {
const u = (shaderUniforms[uniform] ??= []);
for ( const i in value ) u[i] = value[i];
}
else shaderUniforms[uniform] = value;
}
}
}
/**
* A batch renderer with a customizable data transfer function to packed geometries.
* @extends PIXI.AbstractBatchRenderer
*/
class BatchRenderer extends PIXI.BatchRenderer {
/**
* The PackInterleavedGeometry function provided by the sampler.
* @type {Function}
* @protected
*/
_packInterleavedGeometry;
/**
* The preRender function provided by the sampler and that is called just before a flush.
* @type {Function}
* @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;
/* -------------------------------------------- */
/**
* 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 = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS);
// 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, this.#reservedTextureUnits);
// Initialize packed geometries
for ( let i = 0; i < this._packedGeometryPoolSize; i++ ) {
this._packedGeometries[i] = new (this.geometryClass)();
}
this.initFlushBuffers();
}
/* -------------------------------------------- */
/** @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 batch shader generator that could handle extra uniforms during initialization.
*/
class BatchShaderGenerator extends PIXI.BatchShaderGenerator {
constructor(vertexSrc, fragTemplate, batchDefaultUniforms) {
super(vertexSrc, fragTemplate);
this._batchDefaultUniforms = batchDefaultUniforms?.bind(this);
}
/**
* Extra uniforms that could be handled by a custom batch shader.
* @type {Function|undefined}
*/
_batchDefaultUniforms;
/* -------------------------------------------- */
/** @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);
}
// Constructing the standard uniforms for batches
const uniforms = {
tint: new Float32Array([1, 1, 1, 1]),
translationMatrix: new PIXI.Matrix(),
default: this.defaultGroupCache[maxTextures]
};
// Adding the extra uniforms
if ( this._batchDefaultUniforms ) foundry.utils.mergeObject(uniforms, this._batchDefaultUniforms(maxTextures));
return new PIXI.Shader(this.programCache[maxTextures], uniforms);
}
}
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
];
/**
* 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.
*/
const BaseShaderMixin = ShaderClass => {
class BaseShaderMixin extends ShaderClass {
/**
* 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 uniforms shared by fragment shaders.
* @type {string}
*/
static FRAGMENT_UNIFORMS = `
uniform int technique;
uniform bool useSampler;
uniform bool darkness;
uniform bool hasColor;
uniform bool linkedToDarknessLevel;
uniform float attenuation;
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 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 vec4 weights;
uniform sampler2D primaryTexture;
uniform sampler2D framebufferTexture;
uniform sampler2D depthTexture;
// 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;
`;
/**
* Useful constant values computed at compile time
* @type {string}
*/
static CONSTANTS = `
const float PI = 3.14159265359;
const float TWOPI = 2.0 * PI;
const float INVTWOPI = 1.0 / TWOPI;
const float INVTHREE = 1.0 / 3.0;
const vec2 PIVOT = vec2(0.5);
const vec3 BT709 = vec3(0.2126, 0.7152, 0.0722);
const vec4 ALLONES = vec4(1.0);
`;
/* -------------------------------------------- */
/**
* 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( BT709.x * color.r * color.r +
BT709.y * color.g * color.g +
BT709.z * color.b * color.b );
}
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);
}`;
/* -------------------------------------------- */
/**
* 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);
}
`;
}
return BaseShaderMixin;
};
/* -------------------------------------------- */
/**
* 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.
*/
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
* @param {string} [options.channel=r] A color channel to target for masking.
* @returns {InverseOcclusionMaskFilter}
*/
static create({channel="r", ...uniforms}={}) {
uniforms = {...this.defaultUniforms, ...uniforms};
this.fragmentShader = this.adaptiveFragmentShader(channel);
return super.create(uniforms);
}
}
return AdaptiveFragmentChannelMixin;
};
/* -------------------------------------------- */
/**
* This class defines an interface which all shaders utilize
* @extends {PIXI.Shader}
* @property {object} uniforms The current uniforms of the Shader
* @interface
*/
class AbstractBaseShader extends BaseShaderMixin(PIXI.Shader) {
constructor(program, uniforms) {
super(program, foundry.utils.deepClone(uniforms));
/**
* The initial default values of shader uniforms
* @type {object}
*/
this._defaults = 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} defaultUniforms
* @returns {AbstractBaseShader}
*/
static create(defaultUniforms) {
const program = PIXI.Program.from(this.vertexShader, this.fragmentShader);
const uniforms = mergeObject(this.defaultUniforms, defaultUniforms, {inplace: false, insertKeys: false});
return new this(program, uniforms);
}
/* -------------------------------------------- */
/**
* Reset the shader uniforms back to their provided default values
* @private
*/
reset() {
for (let [k, v] of Object.entries(this._defaults)) {
this.uniforms[k] = v;
}
}
}
/* -------------------------------------------- */
/**
* An abstract filter which provides a framework for reusable definition
* @extends {PIXI.Filter}
*/
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} [uniforms] Initial uniform values which override filter defaults
* @returns {AbstractBaseFilter} The constructed AbstractFilter instance.
*/
static create(uniforms={}) {
uniforms = { ...this.defaultUniforms, ...uniforms};
return new this(this.vertexShader, this.fragmentShader, uniforms);
}
/**
* Always target the resolution of the render texture or renderer
* @type {number}
*/
get resolution() {
const renderer = canvas.app.renderer;
const renderTextureSystem = renderer.renderTexture;
if (renderTextureSystem.current) {
return renderTextureSystem.current.resolution;
}
return renderer.resolution;
}
set resolution(value) {}
/**
* Always target the MSAA level of the render texture or renderer
* @type {PIXI.MSAA_QUALITY}
*/
get multisample() {
const renderer = canvas.app.renderer;
const renderTextureSystem = renderer.renderTexture;
if (renderTextureSystem.current) {
return renderTextureSystem.current.multisample;
}
return renderer.multisample;
}
set multisample(value) { }
}
/* ---------------------------------------- */
/**
* The base sampler shader exposes a simple sprite shader and all the framework to handle:
* - Batched shaders and plugin subscription
* - And pre-rendering method
* All othe sampler shaders (batched or not) should extend BaseSamplerShader
*/
class BaseSamplerShader extends AbstractBaseShader {
constructor(...args) {
super(...args);
/**
* The plugin name associated for this instance.
* @type {string}
*/
this.pluginName = this.constructor.classPluginName;
}
/**
* The named batch sampler plugin that is used by this shader, or null if no batching is used.
* @type {string}
*/
static classPluginName = "batch";
/**
* 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;
/**
* 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;`;
}
/** @inheritdoc */
static vertexShader = `
precision ${PIXI.settings.PRECISION_VERTEX} float;
attribute vec2 aVertexPosition;
attribute vec2 aTextureCoord;
uniform mat3 projectionMatrix;
varying vec2 vUvs;
void main() {
vUvs = aTextureCoord;
gl_Position = vec4((projectionMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);
}`;
/** @inheritdoc */
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;
}`;
/**
* Batch default vertex
* @type {string}
*/
static batchVertexShader = `
precision ${PIXI.settings.PRECISION_VERTEX} float;
attribute vec2 aVertexPosition;
attribute vec2 aTextureCoord;
attribute vec4 aColor;
attribute float aTextureId;
uniform mat3 projectionMatrix;
uniform mat3 translationMatrix;
uniform vec4 tint;
varying vec2 vTextureCoord;
varying vec4 vColor;
varying 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;
}`;
/**
* Batch default fragment
* @type {string}
*/
static batchFragmentShader = `
precision ${PIXI.settings.PRECISION_FRAGMENT} float;
varying vec2 vTextureCoord;
varying vec4 vColor;
varying float vTextureId;
uniform sampler2D uSamplers[%count%];
void main(void){
vec4 color;
%forloop%
gl_FragColor = color * vColor;
}`;
/** @inheritdoc */
static defaultUniforms = {
tintAlpha: [1, 1, 1, 1],
sampler: 0
};
/**
* Batch geometry associated with this sampler.
* @type {typeof PIXI.BatchGeometry}
*/
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 {Function}
* @protected
*/
static _preRenderBatch() {}
/**
* A function that returns default uniforms associated with the batched version of this sampler.
* @abstract
* @type {Function|undefined}
*/
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.
* @abstract
*/
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 {batchVertexShader, batchFragmentShader, batchGeometry, batchVertexSize,
batchDefaultUniforms, batchShaderGeneratorClass, reservedTextureUnits} = this;
const packGeometry = this._packInterleavedGeometry;
const preRender = this._preRenderBatch;
return class BatchPlugin extends this.batchRendererClass {
constructor(renderer) {
super(renderer);
this.shaderGenerator =
new batchShaderGeneratorClass(batchVertexShader, batchFragmentShader, batchDefaultUniforms);
this.geometryClass = batchGeometry;
this.vertexSize = batchVertexSize;
this._packInterleavedGeometry = packGeometry?.bind(this);
this._preRenderBatch = preRender.bind(this);
this.reservedTextureUnits = reservedTextureUnits;
}
};
}
/* ---------------------------------------- */
/**
* Register the plugin for this sampler.
*/
static registerPlugin() {
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 ( 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
});
}
/* ---------------------------------------- */
/**
* Perform operations which are required before binding the Shader to the Renderer.
* @param {SpriteMesh} mesh The mesh linked to this shader.
* @internal
*/
_preRender(mesh) {
this.uniforms.tintAlpha = mesh._cachedTint;
}
}
/**
* The base shader class for weather shaders.
*/
class AbstractWeatherShader extends AbstractBaseShader {
constructor(...args) {
super(...args);
Object.defineProperties(this, Object.keys(this.constructor.defaultUniforms).reduce((obj, k) => {
obj[k] = {
get() {
return this.uniforms[k];
},
set(value) {
this.uniforms[k] = value;
},
enumerable: false
};
return obj;
}, {}));
}
/**
* Compute the weather masking value.
* @type {string}
*/
static COMPUTE_MASK = `
// Base mask value
float mask = 1.0;
// Process the occlusion mask
if ( useOcclusion ) {
float oMask = 1.0 - step((255.5 / 255.0) -
dot(occlusionWeights, texture2D(occlusionTexture, vUvsOcclusion)),
depthElevation);
if ( reverseOcclusion ) oMask = 1.0 - oMask;
mask *= oMask;
}
// Process the terrain mask
if ( useTerrain ) {
float tMask = dot(terrainWeights, texture2D(terrainTexture, vUvsTerrain));
if ( reverseTerrain ) tMask = 1.0 - tMask;
mask *= tMask;
}
`;
/**
* Compute the weather masking value.
* @type {string}
*/
static FRAGMENT_HEADER = `
precision ${PIXI.settings.PRECISION_FRAGMENT} float;
// Occlusion mask uniforms
uniform bool useOcclusion;
uniform sampler2D occlusionTexture;
uniform bool reverseOcclusion;
uniform vec4 occlusionWeights;
// Terrain mask uniforms
uniform bool useTerrain;
uniform sampler2D terrainTexture;
uniform bool reverseTerrain;
uniform vec4 terrainWeights;
// Other uniforms and varyings
uniform vec3 tint;
uniform float time;
uniform float depthElevation;
uniform float alpha;
varying vec2 vUvsOcclusion;
varying vec2 vUvsTerrain;
varying vec2 vStaticUvs;
varying vec2 vUvs;
`;
/**
* Common uniforms for all weather shaders.
* @type {{
* useOcclusion: boolean,
* occlusionTexture: PIXI.Texture|null,
* reverseOcclusion: boolean,
* occlusionWeights: number[],
* useTerrain: boolean,
* terrainTexture: PIXI.Texture|null,
* reverseTerrain: boolean,
* terrainWeights: number[],
* alpha: number,
* tint: number[],
* screenDimensions: [number, number],
* effectDimensions: [number, number],
* depthElevation: number,
* time: number
* }}
*/
static commonUniforms = {
useOcclusion: false,
occlusionTexture: null,
reverseOcclusion: false,
occlusionWeights: [0, 0, 1, 0],
useTerrain: false,
terrainTexture: null,
reverseTerrain: false,
terrainWeights: [1, 0, 0, 0],
alpha: 1,
tint: [1, 1, 1],
screenDimensions: [1, 1],
effectDimensions: [1, 1],
depthElevation: 1,
time: 0
};
/**
* Default uniforms for a specific class
* @abstract
*/
static defaultUniforms;
/* -------------------------------------------- */
/** @override */
static create(initialUniforms) {
const program = this.createProgram();
const uniforms = {...this.commonUniforms, ...this.defaultUniforms, ...initialUniforms};
return new this(program, uniforms);
}
/* -------------------------------------------- */
/**
* Create the shader program.
* @returns {PIXI.Program}
*/
static createProgram() {
return PIXI.Program.from(this.vertexShader, this.fragmentShader);
}
/* -------------------------------------------- */
/** @inheritdoc */
static vertexShader = `
precision ${PIXI.settings.PRECISION_VERTEX} float;
attribute vec2 aVertexPosition;
uniform mat3 translationMatrix;
uniform mat3 projectionMatrix;
uniform vec2 screenDimensions;
uniform vec2 effectDimensions;
varying vec2 vUvsOcclusion;
varying vec2 vUvsTerrain;
varying vec2 vUvs;
varying vec2 vStaticUvs;
void main() {
vec3 tPos = translationMatrix * vec3(aVertexPosition, 1.0);
vStaticUvs = aVertexPosition;
vUvs = vStaticUvs * effectDimensions;
vUvsOcclusion = tPos.xy / screenDimensions;
vUvsTerrain = aVertexPosition;
gl_Position = vec4((projectionMatrix * tPos).xy, 0.0, 1.0);
}
`;
/* -------------------------------------------- */
/* Common Management and Parameters */
/* -------------------------------------------- */
/**
* Update the scale of this effect with new values
* @param {number|{x: number, y: number}} scale The desired scale
*/
set scale(scale) {
this.#scale.x = typeof scale === "object" ? scale.x : scale;
this.#scale.y = (typeof scale === "object" ? scale.y : scale) ?? this.#scale.x;
}
set scaleX(x) {
this.#scale.x = x ?? 1;
}
set scaleY(y) {
this.#scale.y = y ?? 1;
}
#scale = {
x: 1,
y: 1
};
/* -------------------------------------------- */
/**
* The speed multiplier applied to animation.
* 0 stops animation.
* @type {number}
*/
speed = 1;
/* -------------------------------------------- */
/**
* Perform operations which are required before binding the Shader to the Renderer.
* @param {QuadMesh} mesh The mesh linked to this shader.
* @internal
*/
_preRender(mesh) {
this.uniforms.alpha = mesh.worldAlpha;
this.uniforms.depthElevation = canvas.primary.mapElevationToDepth(canvas.weather.elevation);
this.uniforms.time += (canvas.app.ticker.deltaMS / 1000 * this.speed);
this.uniforms.screenDimensions = canvas.screenDimensions;
this.uniforms.effectDimensions[0] = this.#scale.x * mesh.scale.x / 10000;
this.uniforms.effectDimensions[1] = this.#scale.y * mesh.scale.y / 10000;
}
}
/**
* An interface for defining shader-based weather effects
* @param {object} config The config object to create the shader effect
*/
class WeatherShaderEffect extends QuadMesh {
constructor(config, shaderClass) {
super(shaderClass);
this.stop();
this._initialize(config);
}
/* -------------------------------------------- */
/**
* Set shader parameters.
* @param {object} [config={}]
*/
configure(config={}) {
for ( const [k, v] of Object.entries(config) ) {
if ( k in this.shader ) this.shader[k] = v;
else if ( k in this.shader.uniforms ) this.shader.uniforms[k] = v;
}
}
/* -------------------------------------------- */
/**
* Begin animation
*/
play() {
this.visible = true;
}
/* -------------------------------------------- */
/**
* Stop animation
*/
stop() {
this.visible = false;
}
/* -------------------------------------------- */
/**
* Initialize the weather effect.
* @param {object} config Config object.
* @protected
*/
_initialize(config) {
this.configure(config);
const sr = canvas.dimensions.sceneRect;
this.position.set(sr.x, sr.y);
this.width = sr.width;
this.height = sr.height;
}
}
/**
* Fog shader effect.
*/
class FogShader extends AbstractWeatherShader {
/** @inheritdoc */
static defaultUniforms = {
intensity: 1,
rotation: 0,
slope: 0.25
};
/* ---------------------------------------- */
/**
* Configure the number of octaves into the shaders.
* @param {number} mode
* @returns {string}
*/
static OCTAVES(mode) {
return `${mode + 2}`;
}
/* -------------------------------------------- */
/**
* Configure the fog complexity according to mode (performance).
* @param {number} mode
* @returns {string}
*/
static FOG(mode) {
if ( mode === 0 ) {
return `vec2 mv = vec2(fbm(uv * 4.5 + time * 0.115)) * (1.0 + r * 0.25);
mist += fbm(uv * 4.5 + mv - time * 0.0275) * (1.0 + r * 0.25);`;
}
return `for ( int i=0; i<2; i++ ) {
vec2 mv = vec2(fbm(uv * 4.5 + time * 0.115 + vec2(float(i) * 250.0))) * (0.50 + r * 0.25);
mist += fbm(uv * 4.5 + mv - time * 0.0275) * (0.50 + r * 0.25);
}`;
}
/* -------------------------------------------- */
/** @override */
static createProgram() {
const mode = canvas?.performance.mode ?? 2;
return PIXI.Program.from(this.vertexShader, this.fragmentShader(mode));
}
/* -------------------------------------------- */
/** @inheritdoc */
static fragmentShader(mode) {
return `
${this.FRAGMENT_HEADER}
uniform float intensity;
uniform float slope;
uniform float rotation;
${this.CONSTANTS}
${this.PERCEIVED_BRIGHTNESS}
${this.PRNG}
${this.ROTATION}
// ********************************************************* //
float fnoise(in vec2 coords) {
vec2 i = floor(coords);
vec2 f = fract(coords);
float a = random(i);
float b = random(i + vec2(1.0, 0.0));
float c = random(i + vec2(0.0, 1.0));
float d = random(i + vec2(1.0, 1.0));
vec2 cb = f * f * (3.0 - 2.0 * f);
return mix(a, b, cb.x) + (c - a) * cb.y * (1.0 - cb.x) + (d - b) * cb.x * cb.y;
}
// ********************************************************* //
float fbm(in vec2 uv) {
float r = 0.0;
float scale = 1.0;
uv += time * 0.03;
uv *= 2.0;
for (int i = 0; i < ${this.OCTAVES(mode)}; i++) {
r += fnoise(uv + time * 0.03) * scale;
uv *= 3.0;
scale *= 0.3;
}
return r;
}
// ********************************************************* //
vec3 mist(in vec2 uv, in float r) {
float mist = 0.0;
${this.FOG(mode)}
return vec3(0.9, 0.85, 1.0) * mist;
}
// ********************************************************* //
void main() {
${this.COMPUTE_MASK}
vec2 ruv;
if ( rotation != 0.0 ) {
ruv = vUvs - 0.5;
ruv *= rot(rotation);
ruv += 0.5;
}
else {
ruv = vUvs;
}
vec3 col = mist(ruv * 2.0 - 1.0, 0.0) * 1.33;
float pb = perceivedBrightness(col);
pb = smoothstep(slope * 0.5, slope + 0.001, pb);
gl_FragColor = vec4( mix(vec3(0.05, 0.05, 0.08), col * clamp(slope, 1.0, 2.0), pb), 1.0)
* vec4(tint, 1.0) * intensity * mask * alpha;
}
`;
}
}
/**
* Rain shader effect.
*/
class RainShader extends AbstractWeatherShader {
/** @inheritdoc */
static defaultUniforms = {
opacity: 1,
intensity: 1,
strength: 1,
rotation: 0.5,
resolution: [3200, 80] // The resolution to have nice rain ropes with the voronoi cells
};
/* -------------------------------------------- */
/** @inheritdoc */
static fragmentShader = `
${this.FRAGMENT_HEADER}
${this.CONSTANTS}
${this.PERCEIVED_BRIGHTNESS}
${this.ROTATION}
${this.PRNG2D}
${this.VORONOI}
uniform float intensity;
uniform float opacity;
uniform float strength;
uniform float rotation;
uniform vec2 resolution;
// Compute rain according to uv and dimensions for layering
float computeRain(in vec2 uv, in float t) {
vec2 tuv = uv;
vec2 ruv = ((tuv + 0.5) * rot(rotation)) - 0.5;
ruv.y -= t * 0.8;
vec2 st = ruv * resolution;
vec3 d2 = voronoi(vec3(st - t * 0.5, t * 0.8), 10.0);
float df = perceivedBrightness(d2);
return (1.0 - smoothstep(-df * strength, df * strength + 0.001, 1.0 - smoothstep(0.3, 1.0, d2.z))) * intensity;
}
void main() {
${this.COMPUTE_MASK}
gl_FragColor = vec4(vec3(computeRain(vUvs, time)) * tint, 1.0) * alpha * mask * opacity;
}
`;
}
/**
* Snow shader effect.
*/
class SnowShader extends AbstractWeatherShader {
/** @inheritdoc */
static defaultUniforms = {
direction: 1.2
};
/* -------------------------------------------- */
/** @inheritdoc */
static fragmentShader = `
${this.FRAGMENT_HEADER}
uniform float direction;
// Contribute to snow PRNG
const mat3 prng = mat3(13.323122, 23.5112, 21.71123, 21.1212,
28.731200, 11.9312, 21.81120, 14.7212, 61.3934);
// Compute snow density according to uv and layer
float computeSnowDensity(in vec2 uv, in float layer) {
vec3 snowbase = vec3(floor(uv), 31.189 + layer);
vec3 m = floor(snowbase) / 10000.0 + fract(snowbase);
vec3 mp = (31415.9 + m) / fract(prng * m);
vec3 r = fract(mp);
vec2 s = abs(fract(uv) - 0.5 + 0.9 * r.xy - 0.45) + 0.01 * abs( 2.0 * fract(10.0 * uv.yx) - 1.0);
float d = 0.6 * (s.x + s.y) + max(s.x, s.y) - 0.01;
float edge = 0.005 + 0.05 * min(0.5 * abs(layer - 5.0 - sin(time * 0.1)), 1.0);
return smoothstep(edge * 2.0, -edge * 2.0, d) * r.x / (0.5 + 0.01 * layer * 1.5);
}
void main() {
${this.COMPUTE_MASK}
// Snow accumulation
float accumulation = 0.0;
// Compute layers
for ( float i=5.0; i<25.0; i++ ) {
// Compute uv layerization
vec2 snowuv = vUvs.xy * (1.0 + i * 1.5);
snowuv += vec2(snowuv.y * 1.2 * (fract(i * 6.258817) - direction), -time / (1.0 + i * 1.5 * 0.03));
// Perform accumulation layer after layer
accumulation += computeSnowDensity(snowuv, i);
}
// Output the accumulated snow pixel
gl_FragColor = vec4(vec3(accumulation) * tint, 1.0) * mask * alpha;
}
`;
}
/**
* Determine the center of the circle.
* Trivial, but used to match center method for other shapes.
* @type {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 ) {
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);
};
/**
* 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<this.points.length; i+=2 ) {
const x = this.points[i-1];
const y = this.points[i];
if ( x < minX ) minX = x;
else if ( x > 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|null} The intersected polygon or null if no solution was present
*/
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.
*/
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
if ( minIxY.almostEqual(maxIxY) ) return [{x: a.x, y: minIxY}];
// Return in order nearest a, nearest b
else if ( minIxY < maxIxY ) return Math.abs(minIxY - a.y) < Math.abs(maxIxY - a.y)
? [{x: a.x, y: minIxY}, {x: a.x, y: maxIxY}]
: [{x: a.x, y: maxIxY}, {x: a.x, y: minIxY}];
}
// 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
if ( minIxX.almostEqual(maxIxX) ) return [{x: minIxX, y: a.y}];
// Return in order nearest a, nearest b
else if ( minIxX < maxIxX ) return Math.abs(minIxX - a.x) < Math.abs(maxIxX - a.x)
? [{x: minIxX, y: a.y}, {x: maxIxX, y: a.y}]
: [{x: maxIxX, y: a.y}, {x: minIxX, y: a.y}];
}
// 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(leftEdge.A, leftEdge.B, a, b);
if ( !ix && (z & CSZ.RIGHT)
&& lsi(rightEdge.A, rightEdge.B, a, b)) ix = lli(rightEdge.A, rightEdge.B, a, b);
if ( !ix && (z & CSZ.TOP)
&& lsi(topEdge.A, topEdge.B, a, b)) ix = lli(topEdge.A, topEdge.B, a, b);
if ( !ix && (z & CSZ.BOTTOM)
&& lsi(bottomEdge.A, bottomEdge.B, a, b)) ix = lli(bottomEdge.A, bottomEdge.B, a, 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.
* @returns {PIXI.Polygon|null} The intersected polygon or null if no solution was present
*/
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 ) {
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;
};
/* -------------------------------------------- */
/**
* Generate a new rectangle by rotating this one clockwise about its center by a certain number of radians
* @param {number} radians The angle of rotation
* @returns {PIXI.Rectangle} A new rotated rectangle
*/
PIXI.Rectangle.prototype.rotate = function(radians) {
return this.constructor.fromRotation(this.x, this.y, this.width, this.height, radians);
};
/* -------------------------------------------- */
/**
* Create normalized rectangular bounds given a rectangle shape and an angle of central rotation.
* @param {number} x The top-left x-coordinate of the un-rotated rectangle
* @param {number} y The top-left y-coordinate of the un-rotated rectangle
* @param {number} width The width of the un-rotated rectangle
* @param {number} height The height of the un-rotated rectangle
* @param {number} radians The angle of rotation about the center
* @returns {PIXI.Rectangle} The constructed rotated rectangle bounds
*/
PIXI.Rectangle.fromRotation = function(x, y, width, height, radians) {
const rh = (height * Math.abs(Math.cos(radians))) + (width * Math.abs(Math.sin(radians)));
const rw = (height * Math.abs(Math.sin(radians))) + (width * Math.abs(Math.cos(radians)));
const rx = x + ((width - rw) / 2);
const ry = y + ((height - rh) / 2);
return new PIXI.Rectangle(rx, ry, rw, rh);
};
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* A PIXI.Rectangle where the width and height are always positive and the x and y are always the top-left
* @extends {PIXI.Rectangle}
*/
class NormalizedRectangle extends PIXI.Rectangle {
constructor(...args) {
super(...args);
foundry.utils.logCompatibilityWarning("You are using the NormalizedRectangle class which has been deprecated in"
+ " favor of PIXI.Rectangle.prototype.normalize", {since: 10, until: 12});
this.normalize();
}
}
/**
* A container group which contains visual effects rendered above the primary group.
*
* ### Hook Events
* - {@link hookEvents.drawEffectsCanvasGroup}
* - {@link hookEvents.createEffectsCanvasGroup}
* - {@link hookEvents.lightingRefresh}
*
* @category - Canvas
*/
class EffectsCanvasGroup extends PIXI.Container {
constructor() {
super();
this.#createLayers();
}
/**
* The current global light source.
* @type {GlobalLightSource}
*/
globalLightSource;
/**
* Whether to currently animate light sources.
* @type {boolean}
*/
animateLightSources = true;
/**
* Whether to currently animate vision sources.
* @type {boolean}
*/
animateVisionSources = true;
/**
* A mapping of light sources which are active within the rendered Scene.
* @type {Collection<string, LightSource>}
*/
lightSources = new foundry.utils.Collection();
/**
* A Collection of vision sources which are currently active within the rendered Scene.
* @type {Collection<string, VisionSource>}
*/
visionSources = new foundry.utils.Collection();
/* -------------------------------------------- */
/**
* Create the child layers of the effects group.
* @private
*/
#createLayers() {
/**
* A set of vision mask filters used in visual effects group
* @type {Set<VisualEffectsMaskingFilter>}
*/
this.visualEffectsMaskingFilters = new Set();
/**
* A layer of background alteration effects which change the appearance of the primary group render texture.
* @type {CanvasBackgroundAlterationEffects}
*/
this.background = this.addChild(new CanvasBackgroundAlterationEffects());
/**
* A layer which adds illumination-based effects to the scene.
* @type {CanvasIlluminationEffects}
*/
this.illumination = this.addChild(new CanvasIlluminationEffects());
/**
* A layer which adds color-based effects to the scene.
* @type {CanvasColorationEffects}
*/
this.coloration = this.addChild(new CanvasColorationEffects());
/**
* A layer which controls the current visibility of the scene.
* @type {CanvasVisibility}
*/
this.visibility = this.addChild(new CanvasVisibility());
// Call hooks
Hooks.callAll("createEffectsCanvasGroup", this);
}
/* -------------------------------------------- */
/**
* Clear all effects containers and animated sources.
*/
clearEffects() {
this.background.clear();
this.illumination.clear();
this.coloration.clear();
}
/* -------------------------------------------- */
/**
* Draw the component layers of the canvas group.
* @returns {Promise<void>}
*/
async draw() {
this.globalLightSource = new GlobalLightSource();
this.updateGlobalLightSource();
// Draw each component layer
await this.background.draw();
await this.illumination.draw();
await this.coloration.draw();
await this.visibility.draw();
// Call hooks
Hooks.callAll("drawEffectsCanvasGroup", this);
// Activate animation of drawn objects
this.activateAnimation();
}
/* -------------------------------------------- */
/**
* Actions to take when the darkness level is changed
* @param {number} darkness The new darkness level
* @param {number} prior The prior darkness level
* @internal
*/
_onDarknessChange(darkness, prior) {
this.updateGlobalLightSource();
}
/* -------------------------------------------- */
/**
* Initialize LightSource objects for all AmbientLightDocument instances which exist within the active Scene.
*/
initializeLightSources() {
this.lightSources.clear();
// Global light source
this.updateGlobalLightSource({defer: true});
// Ambient Light sources
for ( let light of canvas.lighting.placeables ) {
light.updateSource({defer: true});
}
for ( let light of canvas.lighting.preview.children ) {
light.updateSource({defer: true});
}
// Token light sources
for ( let token of canvas.tokens.placeables ) {
token.updateLightSource({defer: true});
}
for ( let token of canvas.tokens.preview.children ) {
token.updateLightSource({defer: true});
}
Hooks.callAll("initializeLightSources", this);
}
/* -------------------------------------------- */
/**
* Update the global light source which provides global illumination to the Scene.
* @param {object} [options={}] Options which modify how the source is updated
* @param {boolean} [options.defer] Defer updating perception to manually update it later
*/
updateGlobalLightSource({defer=false}={}) {
if ( !this.globalLightSource ) return;
const {sceneX, sceneY, maxR} = canvas.dimensions;
const {globalLight, globalLightThreshold} = canvas.scene;
const disabled = !(globalLight && ((globalLightThreshold === null)
|| (canvas.darknessLevel <= globalLightThreshold)));
this.globalLightSource.initialize(foundry.utils.mergeObject({
x: sceneX,
y: sceneY,
elevation: Infinity,
dim: maxR,
walls: false,
vision: false,
luminosity: 0,
disabled
}, CONFIG.Canvas.globalLightConfig));
this.lightSources.set("globalLight", this.globalLightSource);
if ( !defer ) canvas.perception.update({refreshLighting: true, refreshVision: true});
}
/* -------------------------------------------- */
/**
* Refresh the state and uniforms of all LightSource objects.
*/
refreshLightSources() {
for ( const lightSource of this.lightSources ) lightSource.refresh();
}
/* -------------------------------------------- */
/**
* 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;
const v = this.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 lighting effects
for ( const lightSource of this.lightSources.values() ) {
if ( !lightSource.active ) continue;
// Draw the light update
const meshes = lightSource.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);
}
// Add effect meshes for active vision sources
this.#addVisionEffects();
// Call hooks
Hooks.callAll("lightingRefresh", this);
}
/* -------------------------------------------- */
/**
* Add effect meshes for active vision sources.
* @private
*/
#addVisionEffects() {
for ( const visionSource of this.visionSources ) {
if ( !visionSource.active || (visionSource.radius <= 0) ) continue;
const meshes = visionSource.drawMeshes();
if ( meshes.background ) {
// Is this vision source background need to be rendered into the preferred vision container, over other VS?
const parent = visionSource.preferred ? this.background.visionPreferred : this.background.vision;
parent.addChild(meshes.background);
}
if ( meshes.illumination ) this.illumination.lights.addChild(meshes.illumination);
if ( meshes.coloration ) this.coloration.addChild(meshes.coloration);
}
this.background.vision.filter.enabled = !!this.background.vision.children.length;
this.background.visionPreferred.filter.enabled = !!this.background.visionPreferred.children.length;
}
/* -------------------------------------------- */
/**
* Perform a deconstruction workflow for this canvas group when the canvas is retired.
* @returns {Promise<void>}
*/
async tearDown() {
this.deactivateAnimation();
this.lightSources.clear();
this.globalLightSource?.destroy();
this.globalLightSource = undefined;
for ( const c of this.children ) {
if ( c.clear ) c.clear();
else if ( c.tearDown ) await c.tearDown();
else c.destroy();
}
this.visualEffectsMaskingFilters.clear();
Hooks.callAll("tearDownEffectsCanvasGroup", this);
}
/* -------------------------------------------- */
/**
* Activate vision masking for visual effects
* @param {boolean} [enabled=true] Whether to enable or disable vision masking
*/
toggleMaskingFilters(enabled=true) {
for ( const f of this.visualEffectsMaskingFilters ) {
f.uniforms.enableVisionMasking = enabled;
}
}
/* -------------------------------------------- */
/**
* Activate post-processing effects for a certain effects channel.
* @param {string} filterMode The filter mode to target.
* @param {string[]} [postProcessingModes=[]] The post-processing modes to apply to this filter.
* @param {Object} [uniforms={}] The uniforms to update.
*/
activatePostProcessingFilters(filterMode, postProcessingModes=[], uniforms={}) {
for ( const f of this.visualEffectsMaskingFilters ) {
if ( f.filterMode === filterMode ) {
f.updatePostprocessModes(postProcessingModes, uniforms);
}
}
}
/* -------------------------------------------- */
/**
* Reset post-processing modes on all Visual Effects masking filters.
*/
resetPostProcessingFilters() {
for ( const f of this.visualEffectsMaskingFilters ) {
f.reset();
}
}
/* -------------------------------------------- */
/* Animation Management */
/* -------------------------------------------- */
/**
* Activate light source animation for AmbientLight objects within this layer
*/
activateAnimation() {
this.deactivateAnimation();
if ( game.settings.get("core", "lightAnimation") === false ) return;
canvas.app.ticker.add(this.#animateSources, this);
}
/* -------------------------------------------- */
/**
* Deactivate light source animation for AmbientLight objects within this layer
*/
deactivateAnimation() {
canvas.app.ticker.remove(this.#animateSources, this);
}
/* -------------------------------------------- */
/**
* The ticker handler which manages animation delegation
* @param {number} dt Delta time
* @private
*/
#animateSources(dt) {
// Animate Light Sources
if ( this.animateLightSources ) {
for ( const source of this.lightSources.values() ) {
source.animate(dt);
}
}
// Animate Vision Sources
if ( this.animateVisionSources ) {
for ( const source of this.visionSources.values() ) {
source.animate(dt);
}
}
}
/* -------------------------------------------- */
/**
* Animate a smooth transition of the darkness overlay to a target value.
* Only begin animating if another animation is not already in progress.
* @param {number} target The target darkness level between 0 and 1
* @param {number} duration The desired animation time in milliseconds. Default is 10 seconds
* @returns {Promise} A Promise which resolves once the animation is complete
*/
async animateDarkness(target=1.0, {duration=10000}={}) {
const animationName = "lighting.animateDarkness";
CanvasAnimation.terminateAnimation(animationName);
if ( target === canvas.darknessLevel ) return false;
if ( duration <= 0 ) return canvas.colorManager.initialize({darknessLevel: target});
// Update with an animation
const animationData = [{
parent: {darkness: canvas.darknessLevel},
attribute: "darkness",
to: Math.clamped(target, 0, 1)
}];
return CanvasAnimation.animate(animationData, {
name: animationName,
duration: duration,
ontick: (dt, animation) =>
canvas.colorManager.initialize({darknessLevel: animation.attributes[0].parent.darkness})
});
}
}
/**
* A container group which contains the primary canvas group and the effects canvas group.
*
* @category - Canvas
*/
class EnvironmentCanvasGroup extends BaseCanvasMixin(PIXI.Container) {
/** @override */
static groupName = "environment";
/** @override */
static tearDownChildren = false;
/**
* The environment antialiasing filter.
* @type {AdaptiveFXAAFilter}
*/
#fxaaFilter;
/* -------------------------------------------- */
/** @override */
async draw() {
this.#createFilter();
await super.draw();
}
/* -------------------------------------------- */
/**
* Activate the environment group post-processing.
* Note: only for performance mode intermediate, high or maximum.
*/
#createFilter() {
this.filters ??= [];
this.filters.findSplice(f => f === this.#fxaaFilter);
if ( canvas.performance.mode < CONST.CANVAS_PERFORMANCE_MODES.MED ) return;
this.#fxaaFilter ??= new AdaptiveFXAAFilter();
this.filters.push(this.#fxaaFilter);
}
}
/**
* A specialized canvas group for rendering hidden containers before all others (like masks).
* @extends {PIXI.Container}
*/
class HiddenCanvasGroup extends BaseCanvasMixin(PIXI.Container) {
constructor() {
super();
this.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);
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @override */
async draw() {
this.addChild(this.masks);
await this.#drawMasks();
await super.draw();
}
/* -------------------------------------------- */
/**
* Perform necessary draw operations.
*/
async #drawMasks() {
await this.masks.vision.draw();
}
/* -------------------------------------------- */
/**
* Attach masks container to this canvas layer and create tile occlusion, vision masks and depth mask.
*/
#createMasks() {
// The canvas scissor mask is the first thing to render
const canvas = new PIXI.LegacyGraphics();
this.addMask("canvas", canvas);
// 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 */
/* -------------------------------------------- */
/** @override */
async tearDown() {
this.removeChild(this.masks);
// Clear all masks (children of masks)
this.masks.children.forEach(c => c.clear());
// Then proceed normally
await super.tearDown();
}
}
/**
* A container group which displays interface elements rendered above other canvas groups.
* @extends {BaseCanvasMixin(PIXI.Container)}
*/
class InterfaceCanvasGroup extends BaseCanvasMixin(PIXI.Container) {
/** @override */
static groupName = "interface";
/**
* A container dedicated to the display of scrolling text.
* @type {PIXI.Container}
*/
#scrollingText;
/**
* A graphics which represent the scene outline.
* @type {PIXI.Graphics}
*/
#outline;
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* Draw the canvas group and all its component layers.
* @returns {Promise<void>}
*/
async draw() {
this.#drawOutline();
this.#drawScrollingText();
await super.draw();
}
/* -------------------------------------------- */
/**
* Draw a background outline which emphasizes what portion of the canvas is playable space and what is buffer.
*/
#drawOutline() {
// Create Canvas outline
const outline = this.#outline = this.addChild(new PIXI.Graphics());
const {scene, dimensions} = canvas;
const displayCanvasBorder = scene.padding !== 0;
const displaySceneOutline = !scene.background.src;
if ( !(displayCanvasBorder || displaySceneOutline) ) return;
if ( displayCanvasBorder ) outline.lineStyle({
alignment: 1,
alpha: 0.75,
color: 0x000000,
join: PIXI.LINE_JOIN.BEVEL,
width: 4
}).drawShape(dimensions.rect);
if ( displaySceneOutline ) outline.lineStyle({
alignment: 1,
alpha: 0.25,
color: 0x000000,
join: PIXI.LINE_JOIN.BEVEL,
width: 4
}).drawShape(dimensions.sceneRect).endFill();
}
/* -------------------------------------------- */
/* Scrolling Text */
/* -------------------------------------------- */
/**
* Draw the scrolling text.
*/
#drawScrollingText() {
this.#scrollingText = this.addChild(new PIXI.Container());
const {width, height} = canvas.dimensions;
this.#scrollingText.width = width;
this.#scrollingText.height = height;
this.#scrollingText.zIndex = 1000;
}
/* -------------------------------------------- */
/**
* Display scrolling status text originating from this ObjectHUD container.
* @param {Point} origin An origin point where the text should first emerge
* @param {string} content The text content to display
* @param {object} [options] Options which customize the text animation
* @param {number} [options.duration=2000] The duration of the scrolling effect in milliseconds
* @param {number} [options.distance] The distance in pixels that the scrolling text should travel
* @param {TEXT_ANCHOR_POINTS} [options.anchor] The original anchor point where the text appears
* @param {TEXT_ANCHOR_POINTS} [options.direction] The direction in which the text scrolls
* @param {number} [options.jitter=0] An amount of randomization between [0, 1] applied to the initial position
* @param {object} [options.textStyle={}] Additional parameters of PIXI.TextStyle which are applied to the text
* @returns {Promise<PreciseText|null>} 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();
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get reverseMaskfilter() {
foundry.utils.logCompatibilityWarning("InterfaceCanvasGroup.reverseMaskfilter is deprecated. "
+ "Please create your own ReverseMaskFilter, or instead of attaching the filter to each of your "
+ "objects extend the already masked GridLayer with a container for these objects, "
+ "which is much better for performance.", {since: 11, until: 13});
return ReverseMaskFilter.create({
uMaskSampler: canvas.primary.tokensRenderTexture,
channel: "a"
});
}
}
/**
* A container group which is not bound to the stage world transform.
*
* @category - Canvas
*/
class OverlayCanvasGroup extends BaseCanvasMixin(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 {BaseCanvasMixin(CachedContainer)}
* @category - Canvas
*/
class PrimaryCanvasGroup extends BaseCanvasMixin(CachedContainer) {
constructor(sprite) {
sprite ||= new SpriteMesh(undefined, BaseSamplerShader);
super(sprite);
this.eventMode = "none";
this.tokensRenderTexture =
this.createRenderTexture({renderFunction: this._renderTokens.bind(this), clearColor: [0, 0, 0, 0]});
}
/* -------------------------------------------- */
/** @override */
static groupName = "primary";
/** @override */
clearColor = [0, 0, 0, 0];
/**
* Track the set of HTMLVideoElements which are currently playing as part of this group.
* @type {Set<SpriteMesh>}
*/
videoMeshes = new Set();
/**
* Allow API users to override the default elevation of the background layer.
* This is a temporary solution until more formal support for scene levels is added in a future release.
* @type {number}
*/
static BACKGROUND_ELEVATION = 0;
/* -------------------------------------------- */
/* Group Attributes */
/* -------------------------------------------- */
/**
* The primary background image configured for the Scene, rendered as a SpriteMesh.
* @type {SpriteMesh}
*/
background;
/**
* The primary foreground image configured for the Scene, rendered as a SpriteMesh.
* @type {SpriteMesh}
*/
foreground;
/**
* 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<string, PrimaryDrawingContainer>}
*/
drawings = new foundry.utils.Collection();
/**
* The collection of SpriteMesh objects which are rendered in the Scene.
* @type {Collection<string, TokenMesh>}
*/
tokens = new foundry.utils.Collection();
/**
* The collection of SpriteMesh objects which are rendered in the Scene.
* @type {Collection<string, TileMesh|TileSprite>}
*/
tiles = new foundry.utils.Collection();
/**
* Track the current elevation range which is present in the Scene.
* @type {{min: number, max: number}}
* @private
*/
#elevation = {min: 0, max: 1};
/* -------------------------------------------- */
/* Custom Rendering */
/* -------------------------------------------- */
/**
* Render all tokens in their own render texture.
* @param {PIXI.Renderer} renderer The renderer to use.
* @private
*/
_renderTokens(renderer) {
for ( const tokenMesh of this.tokens ) {
tokenMesh.render(renderer);
}
}
/* -------------------------------------------- */
/* Group Properties */
/* -------------------------------------------- */
/**
* Return the base HTML image or video element which provides the background texture.
* @type {HTMLImageElement|HTMLVideoElement}
*/
get backgroundSource() {
if ( !this.background.texture.valid || this.background.texture === PIXI.Texture.WHITE ) return null;
return this.background.texture.baseTexture.resource.source;
}
/* -------------------------------------------- */
/**
* Return the base HTML image or video element which provides the foreground texture.
* @type {HTMLImageElement|HTMLVideoElement}
*/
get foregroundSource() {
if ( !this.foreground.texture.valid ) return null;
return this.foreground.texture.baseTexture.resource.source;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* Refresh the primary mesh.
*/
refreshPrimarySpriteMesh() {
const singleSource = canvas.effects.visibility.visionModeData.source;
const vmOptions = singleSource?.visionMode.canvas;
const isBaseSampler = (this.sprite.shader.constructor === 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.colorManager.darknessLevel;
// Assigning color from source if any
vmOptions.uniforms.tint = singleSource?.colorRGB ?? this.sprite.shader.constructor.defaultUniforms.tint;
// Updating uniforms in the primary sprite shader
for ( const [uniform, value] of Object.entries(vmOptions?.uniforms ?? {}) ) {
if ( uniform in this.sprite.shader.uniforms ) this.sprite.shader.uniforms[uniform] = value;
}
}
/* -------------------------------------------- */
/**
* Draw the canvas group and all its component layers.
* @returns {Promise<void>}
*/
async draw() {
this.clearColor = [...canvas.colors.sceneBackground.rgb, 1];
this.quadtree.clear();
this.#drawBackground();
this.#drawForeground();
await super.draw();
}
/* -------------------------------------------- */
/**
* Draw the Scene background image.
*/
#drawBackground() {
const bg = this.background = this.addChild(new SpriteMesh());
bg.elevation = this.constructor.BACKGROUND_ELEVATION;
bg.sort = -9999999999;
const tex = canvas.sceneTextures.background ?? getTexture(canvas.scene.background.src);
this.#drawSceneMesh(this.background, tex);
}
/* -------------------------------------------- */
/**
* Draw the Scene foreground image.
*/
#drawForeground() {
const fg = this.foreground = this.addChild(new SpriteMesh());
fg.visible = false;
const tex = canvas.sceneTextures.foreground ?? getTexture(canvas.scene.foreground);
if ( !tex ) return;
// Configure visibility and ordering
fg.visible = true;
fg.elevation = canvas.scene.foregroundElevation;
fg.sort = -9999999999;
// Compare dimensions with background texture and draw the mesh
const bg = this.background.texture;
if ( tex && bg && ((tex.width !== bg.width) || (tex.height !== bg.height)) ) {
ui.notifications.warn("WARNING.ForegroundDimensionsMismatch", {localize: true});
}
this.#drawSceneMesh(fg, tex);
}
/* -------------------------------------------- */
/**
* Draw a SpriteMesh texture that fills the entire Scene rectangle.
* @param {SpriteMesh} mesh The target SpriteMesh
* @param {PIXI.Texture|null} texture The loaded Texture or null
*/
#drawSceneMesh(mesh, texture) {
// No background texture? In this case a PIXI.Texture.WHITE is assigned with alpha 0.025
mesh.alpha = texture ? 1 : 0.025;
texture ??= PIXI.Texture.WHITE;
// Assign the texture and configure dimensions
const d = canvas.dimensions;
mesh.texture = texture;
mesh.position.set(d.sceneX, d.sceneY);
mesh.width = d.sceneWidth;
mesh.height = d.sceneHeight;
// Manage video playback
const video = game.video.getVideoSource(mesh);
if ( video ) {
this.videoMeshes.add(mesh);
game.video.play(video, {volume: game.settings.get("core", "globalAmbientVolume")});
}
}
/* -------------------------------------------- */
/* Tear-Down */
/* -------------------------------------------- */
/**
* Remove and destroy all children from the group.
* Clear container references to rendered objects.
* @returns {Promise<void>}
*/
async tearDown() {
// Stop video playback
for ( const mesh of this.videoMeshes ) {
game.video.stop(mesh.sourceElement);
mesh.texture.baseTexture.destroy();
}
await super.tearDown();
// Clear collections
this.videoMeshes.clear();
this.tokens.clear();
this.tiles.clear();
}
/* -------------------------------------------- */
/* Token Management */
/* -------------------------------------------- */
/**
* Draw the SpriteMesh for a specific Token object.
* @param {Token} token The Token being added
* @returns {TokenMesh} The added TokenMesh
*/
addToken(token) {
let mesh = this.tokens.get(token.objectId);
if ( !mesh ) mesh = this.addChild(new TokenMesh(token));
else mesh.object = token;
mesh.anchor.set(0.5, 0.5);
mesh.texture = token.texture ?? PIXI.Texture.EMPTY;
this.tokens.set(token.objectId, mesh);
if ( mesh.isVideo ) this.videoMeshes.add(mesh);
return mesh;
}
/* -------------------------------------------- */
/**
* Remove a TokenMesh from the group.
* @param {Token} token The Token being removed
*/
removeToken(token) {
const mesh = this.tokens.get(token.objectId);
if ( mesh ) {
this.removeChild(mesh);
this.tokens.delete(token.objectId);
this.videoMeshes.delete(mesh);
if ( !mesh._destroyed ) mesh.destroy({children: true});
}
}
/* -------------------------------------------- */
/* Tile Management */
/* -------------------------------------------- */
/**
* Draw the SpriteMesh for a specific Token object.
* @param {Tile} tile The Tile being added
* @returns {TileMesh|TileSprite} The added TileMesh or TileSprite
*/
addTile(tile) {
let mesh = this.tiles.get(tile.objectId);
mesh?.destroy();
const cls = tile.document.getFlag("core", "isTilingSprite") ? TileSprite : TileMesh;
mesh = this.addChild(new cls(tile));
mesh.texture = tile.texture ?? PIXI.Texture.EMPTY;
mesh.anchor.set(0.5, 0.5);
this.tiles.set(tile.objectId, mesh);
if ( mesh.isVideo ) this.videoMeshes.add(mesh);
return mesh;
}
/* -------------------------------------------- */
/**
* Remove a TokenMesh from the group.
* @param {Tile} tile The Tile being removed
*/
removeTile(tile) {
const mesh = this.tiles.get(tile.objectId);
if ( mesh ) {
this.removeChild(mesh);
this.tiles.delete(tile.objectId);
this.videoMeshes.delete(mesh);
if ( !mesh._destroyed ) mesh.destroy({children: true});
}
}
/* -------------------------------------------- */
/* Drawing Management */
/* -------------------------------------------- */
/**
* Add a DrawingShape to the group.
* @param {Drawing} drawing The Drawing being added
* @returns {DrawingShape} The created DrawingShape instance
*/
addDrawing(drawing) {
let shape = this.drawings.get(drawing.objectId);
if ( !shape ) shape = this.addChild(new DrawingShape(drawing));
else shape.object = drawing;
shape.texture = drawing.texture ?? null;
this.drawings.set(drawing.objectId, shape);
return shape;
}
/* -------------------------------------------- */
/**
* Remove a DrawingShape from the group.
* @param {Drawing} drawing The Drawing being removed
*/
removeDrawing(drawing) {
const shape = this.drawings.get(drawing.objectId);
if ( shape ) {
this.removeChild(shape);
this.drawings.delete(drawing.objectId);
if ( !shape._destroyed ) shape.destroy({children: true});
}
}
/* -------------------------------------------- */
/**
* Map an elevation value to a depth value with the right precision.
* @param {number} elevation A current elevation (or zIndex) in distance units.
* @returns {number} The depth value for this elevation on the range [1/255, 1]
*/
mapElevationToDepth(elevation) {
const {min, max} = this.#elevation;
if ( elevation < min ) return 1 / 255;
if ( elevation > max ) return 1;
const pct = (elevation - min) / (max - min) || 0;
const depth = (Math.round(pct * 252) + 2) / 255;
return depth;
}
/* -------------------------------------------- */
/**
* Override the default PIXI.Container behavior for how objects in this container are sorted.
* @override
*/
sortChildren() {
this.#elevation.min = Infinity;
this.#elevation.max = -Infinity;
// Test objects that should render their depth
let minElevation = 0;
let maxElevation = 1;
for ( let i=0; i<this.children.length; i++ ) {
const child = this.children[i];
child._lastSortedIndex = i;
const elevation = child.elevation || 0;
// We do not take into account an infinite value
if ( elevation === Infinity ) continue;
// Save min and max elevation of all placeable with finite values
minElevation = Math.min(minElevation, elevation);
maxElevation = Math.max(maxElevation, elevation);
// If the children is not rendering its depth, do not count it
if ( !child.shouldRenderDepth ) continue;
// Assign elevation to min/max
if ( elevation < this.#elevation.min ) this.#elevation.min = elevation;
if ( elevation > this.#elevation.max ) this.#elevation.max = elevation;
}
// Handle Infinity/-Infinity special case for min/max
// If the above computation does not lead to finite values, we're using the finite "bounds" for min/max
if ( !Number.isFinite(this.#elevation.min) ) this.#elevation.min = minElevation;
if ( !Number.isFinite(this.#elevation.max) ) this.#elevation.max = maxElevation;
this.children.sort(PrimaryCanvasGroup._sortObjects);
this.sortDirty = false;
}
/* -------------------------------------------- */
/**
* The sorting function used to order objects inside the Primary Canvas Group.
* Overrides the default sorting function defined for the PIXI.Container.
* Sort TokenMesh above other objects except WeatherEffects, then DrawingShape, all else held equal.
* @param {PrimaryCanvasObject|PIXI.DisplayObject} a An object to display
* @param {PrimaryCanvasObject|PIXI.DisplayObject} b Some other object to display
* @returns {number}
* @private
*/
static _sortObjects(a, b) {
return ((a.elevation || 0) - (b.elevation || 0))
|| (a.constructor.PRIMARY_SORT_ORDER || 0) - (b.constructor.PRIMARY_SORT_ORDER || 0)
|| ((a.sort || 0) - (b.sort || 0))
|| (a._lastSortedIndex || 0) - (b._lastSortedIndex || 0);
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
mapElevationAlpha(elevation) {
const msg = "PrimaryCanvasGroup#mapElevationAlpha is deprecated in favor of PrimaryCanvasGroup#mapElevationToDepth";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this.mapElevationToDepth(elevation);
}
}
/**
* A container group which contains the environment canvas group and the interface canvas group.
*
* @category - Canvas
*/
class RenderedCanvasGroup extends BaseCanvasMixin(PIXI.Container) {
/** @override */
static groupName = "rendered";
/** @override */
static tearDownChildren = false;
}
/**
* @typedef {Map<number,PolygonVertex>} VertexMap
*/
/**
* @typedef {Set<PolygonEdge>} 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 */
/* -------------------------------------------- */
/**
* Translate walls and other obstacles into edges which limit visibility
* @private
*/
_identifyEdges() {
// Add edges for placed Wall objects
const walls = this._getWalls();
for ( let wall of walls ) {
const edge = PolygonEdge.fromWall(wall, this.config.type);
this.edges.add(edge);
}
// Add edges for the canvas boundaries
const boundaries = this.config.useInnerBounds ? canvas.walls.innerBounds : canvas.walls.outerBounds;
for ( let boundary of boundaries ) {
const edge = PolygonEdge.fromWall(boundary, this.config.type);
edge._isBoundary = true;
this.edges.add(edge);
}
}
/* -------------------------------------------- */
/* Vertex Identification */
/* -------------------------------------------- */
/**
* Consolidate all vertices from identified edges and register them as part of the vertex mapping.
* @private
*/
_identifyVertices() {
const wallEdgeMap = new Map();
// Register vertices for all edges
for ( let edge of this.edges ) {
// Get unique vertices A and B
const ak = edge.A.key;
if ( this.vertices.has(ak) ) edge.A = this.vertices.get(ak);
else this.vertices.set(ak, edge.A);
const bk = edge.B.key;
if ( this.vertices.has(bk) ) edge.B = this.vertices.get(bk);
else this.vertices.set(bk, edge.B);
// Learn edge orientation with respect to the origin
const o = foundry.utils.orient2dFast(this.origin, edge.A, edge.B);
// Ensure B is clockwise of A
if ( o > 0 ) {
let a = edge.A;
edge.A = edge.B;
edge.B = a;
}
// Attach edges to each vertex
edge.A.attachEdge(edge, -1);
edge.B.attachEdge(edge, 1);
// Record the wall->edge mapping
if ( edge.wall ) wallEdgeMap.set(edge.wall.id, edge);
}
// Add edge intersections
this._identifyIntersections(wallEdgeMap);
}
/* -------------------------------------------- */
/**
* Add additional vertices for intersections between edges.
* @param {Map<string,PolygonEdge>} wallEdgeMap A mapping of wall IDs to PolygonEdge instances
* @private
*/
_identifyIntersections(wallEdgeMap) {
const processed = new Set();
for ( let edge of this.edges ) {
// If the edge has no intersections, skip it
if ( !edge.wall?.intersectsWith.size ) continue;
// Check each intersecting wall
for ( let [wall, i] of edge.wall.intersectsWith.entries() ) {
// Some other walls may not be included in this polygon
const other = wallEdgeMap.get(wall.id);
if ( !other || processed.has(other) ) continue;
// Register the intersection point as a vertex
let v = PolygonVertex.fromPoint(i);
v._intersectionCoordinates = i;
if ( this.vertices.has(v.key) ) v = this.vertices.get(v.key);
else this.vertices.set(v.key, v);
// Attach edges to the intersection vertex
// Due to rounding, it is possible for an edge to be completely cw or ccw or only one of the two
// We know from _identifyVertices that vertex B is clockwise of vertex A for every edge
if ( !v.edges.has(edge) ) {
const dir = foundry.utils.orient2dFast(this.origin, edge.B, v) < 0 ? 1 // Edge is fully CCW of v
: (foundry.utils.orient2dFast(this.origin, edge.A, v) > 0 ? -1 : 0); // Edge is fully CW of v
v.attachEdge(edge, dir);
}
if ( !v.edges.has(other) ) {
const dir = foundry.utils.orient2dFast(this.origin, other.B, v) < 0 ? 1 // Other is fully CCW of v
: (foundry.utils.orient2dFast(this.origin, other.A, v) > 0 ? -1 : 0); // Other is fully CW of v
v.attachEdge(other, dir);
}
}
processed.add(edge);
}
}
/* -------------------------------------------- */
/* Radial Sweep */
/* -------------------------------------------- */
/**
* Execute the sweep over wall vertices
* @private
*/
_executeSweep() {
// Initialize the set of active walls
let activeEdges = this._initializeActiveEdges();
// Sort vertices from clockwise to counter-clockwise and begin the sweep
const vertices = this._sortVertices();
// Iterate through the vertices, adding polygon points
const ln = vertices.length;
for ( let i=0; i<ln; i++ ) {
const vertex = vertices[i];
vertex._index = i+1;
const hasCollinear = vertex.collinearVertices.size > 0;
this._updateActiveEdges(vertex, activeEdges);
this.#includeCollinearVertices(vertex, vertex.collinearVertices);
// Look ahead and add any cw walls for vertices collinear with this one
for ( const cv of vertex.collinearVertices ) this._updateActiveEdges(cv, activeEdges);
i += vertex.collinearVertices.size; // Skip processing collinear vertices next loop iteration
// Determine the result of the sweep for the given vertex
this._determineSweepResult(vertex, activeEdges, hasCollinear);
}
}
/**
* Include collinear vertices until they have all been added.
* Do not include the original vertex in the set.
* @param {PolygonVertex} vertex The current vertex
* @param {PolygonVertexSet} collinearVertices
*/
#includeCollinearVertices(vertex, collinearVertices) {
for ( const cv of collinearVertices) {
for ( const ccv of cv.collinearVertices ) {
collinearVertices.add(ccv);
}
}
collinearVertices.delete(vertex);
}
/* -------------------------------------------- */
/**
* Update active edges at a given vertex
* Must delete first, in case the edge is in both sets.
* @param {PolygonVertex} vertex The current vertex
* @param {EdgeSet} activeEdges A set of currently active edges
* @private
*/
_updateActiveEdges(vertex, activeEdges) {
for ( const ccw of vertex.ccwEdges ) activeEdges.delete(ccw);
for ( const cw of vertex.cwEdges ) activeEdges.add(cw);
}
/* -------------------------------------------- */
/**
* Determine the initial set of active edges as those which intersect with the initial ray
* @returns {EdgeSet} A set of initially active edges
* @private
*/
_initializeActiveEdges() {
const initial = {x: Math.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.A, edge.B);
if ( x ) edges.add(edge);
}
return edges;
}
/* -------------------------------------------- */
/**
* Sort vertices clockwise from the initial ray (due west).
* @returns {PolygonVertex[]} The array of sorted vertices
* @private
*/
_sortVertices() {
if ( !this.vertices.size ) return [];
let vertices = Array.from(this.vertices.values());
const o = this.origin;
// Sort vertices
vertices.sort((a, b) => {
// Use true intersection coordinates if they are defined
let pA = a._intersectionCoordinates || a;
let pB = b._intersectionCoordinates || b;
// Sort by hemisphere
const ya = pA.y > o.y ? 1 : -1;
const yb = pB.y > o.y ? 1 : -1;
if ( ya !== yb ) return ya; // Sort N, S
// Sort by quadrant
const qa = pA.x < o.x ? -1 : 1;
const qb = pB.x < o.x ? -1 : 1;
if ( qa !== qb ) { // Sort NW, NE, SE, SW
if ( ya === -1 ) return qa;
else return -qa;
}
// Sort clockwise within quadrant
const orientation = foundry.utils.orient2dFast(o, pA, pB);
if ( orientation !== 0 ) return orientation;
// At this point, we know points are collinear; track for later processing.
a.collinearVertices.add(b);
b.collinearVertices.add(a);
// Otherwise, sort closer points first
a._d2 ||= Math.pow(pA.x - o.x, 2) + Math.pow(pA.y - o.y, 2);
b._d2 ||= Math.pow(pB.x - o.x, 2) + Math.pow(pB.y - o.y, 2);
return a._d2 - b._d2;
});
return vertices;
}
/* -------------------------------------------- */
/**
* Test whether a target vertex is behind some closer active edge.
* If the vertex is to the left of the edge, is must be behind the edge relative to origin.
* If the vertex is collinear with the edge, it should be considered "behind" and ignored.
* We know edge.A is ccw to edge.B because of the logic in _identifyVertices.
* @param {PolygonVertex} vertex The target vertex
* @param {EdgeSet} activeEdges The set of active edges
* @returns {{isBehind: boolean, wasLimited: boolean}} Is the target vertex behind some closer edge?
* @private
*/
_isVertexBehindActiveEdges(vertex, activeEdges) {
let wasLimited = false;
for ( let edge of activeEdges ) {
if ( vertex.edges.has(edge) ) continue;
if ( foundry.utils.orient2dFast(edge.A, edge.B, vertex) > 0 ) {
if ( ( edge.isLimited ) && !wasLimited ) wasLimited = true;
else return {isBehind: true, wasLimited};
}
}
return {isBehind: false, wasLimited};
}
/* -------------------------------------------- */
/**
* Determine the result for the sweep at a given vertex
* @param {PolygonVertex} vertex The target vertex
* @param {EdgeSet} activeEdges The set of active edges
* @param {boolean} hasCollinear Are there collinear vertices behind the target vertex?
* @private
*/
_determineSweepResult(vertex, activeEdges, hasCollinear=false) {
// Determine whether the target vertex is behind some other active edge
const {isBehind, wasLimited} = this._isVertexBehindActiveEdges(vertex, activeEdges);
// Case 1 - Some vertices can be ignored because they are behind other active edges
if ( isBehind ) return;
// Construct the CollisionResult object
const result = new CollisionResult({
target: vertex,
cwEdges: vertex.cwEdges,
ccwEdges: vertex.ccwEdges,
isLimited: vertex.isLimited,
isBehind,
wasLimited
});
// Case 2 - No counter-clockwise edge, so begin a new edge
// Note: activeEdges always contain the vertex edge, so never empty
const nccw = vertex.ccwEdges.size;
if ( !nccw ) {
this._switchEdge(result, activeEdges);
result.collisions.forEach(pt => this.addPoint(pt));
return;
}
// Case 3 - Limited edges in both directions
// We can only guarantee this case if we don't have collinear endpoints
const ccwLimited = !result.wasLimited && vertex.isLimitingCCW;
const cwLimited = !result.wasLimited && vertex.isLimitingCW;
if ( !hasCollinear && cwLimited && ccwLimited ) return;
// Case 4 - Non-limited edges in both directions
if ( !ccwLimited && !cwLimited && nccw && vertex.cwEdges.size ) {
result.collisions.push(result.target);
this.addPoint(result.target);
return;
}
// Case 5 - Otherwise switching edges or edge types
this._switchEdge(result, activeEdges);
result.collisions.forEach(pt => this.addPoint(pt));
}
/* -------------------------------------------- */
/**
* Switch to a new active edge.
* Moving from the origin, a collision that first blocks a side must be stored as a polygon point.
* Subsequent collisions blocking that side are ignored. Once both sides are blocked, we are done.
*
* Collisions that limit a side will block if that side was previously limited.
*
* If neither side is blocked and the ray internally collides with a non-limited edge, n skip without adding polygon
* endpoints. Sight is unaffected before this edge, and the internal collision can be ignored.
* @private
*
* @param {CollisionResult} result The pending collision result
* @param {EdgeSet} activeEdges The set of currently active edges
*/
_switchEdge(result, activeEdges) {
const origin = this.origin;
// Construct the ray from the origin
const ray = Ray.towardsPointSquared(origin, result.target, this.#rayDistance2);
ray.result = result;
this.rays.push(ray); // For visualization and debugging
// Construct sorted array of collisions, moving away from origin
// Collisions are either a collinear vertex or an internal collision to an edge.
const vertices = [result.target, ...result.target.collinearVertices];
// Set vertex distances for sorting
vertices.forEach(v => v._d2 ??= Math.pow(v.x - origin.x, 2) + Math.pow(v.y - origin.y, 2));
// Get all edge collisions for edges not already represented by a collinear vertex
const internalEdges = activeEdges.filter(e => {
return !vertices.some(v => v.equals(e.A) || v.equals(e.B));
});
let xs = this._getInternalEdgeCollisions(ray, internalEdges);
// Combine the collisions and vertices
xs.push(...vertices);
// Sort collisions on proximity to the origin
xs.sort((a, b) => a._d2 - b._d2);
// As we iterate over intersection points we will define the insertion method
let insert = undefined;
const c = result.collisions;
for ( const x of xs ) {
if ( x.isInternal ) { // Handle internal collisions
// If neither side yet blocked and this is a non-limited edge, return
if ( !result.blockedCW && !result.blockedCCW && !x.isLimited ) return;
// Assume any edge is either limited or normal, so if not limited, must block. If already limited, must block
result.blockedCW ||= !x.isLimited || result.limitedCW;
result.blockedCCW ||= !x.isLimited || result.limitedCCW;
result.limitedCW = true;
result.limitedCCW = true;
} else { // Handle true endpoints
result.blockedCW ||= (result.limitedCW && x.isLimitingCW) || x.isBlockingCW;
result.blockedCCW ||= (result.limitedCCW && x.isLimitingCCW) || x.isBlockingCCW;
result.limitedCW ||= x.isLimitingCW;
result.limitedCCW ||= x.isLimitingCCW;
}
// Define the insertion method and record a collision point
if ( result.blockedCW ) {
insert ||= c.unshift;
if ( !result.blockedCWPrev ) insert.call(c, x);
}
if ( result.blockedCCW ) {
insert ||= c.push;
if ( !result.blockedCCWPrev ) insert.call(c, x);
}
// Update blocking flags
if ( result.blockedCW && result.blockedCCW ) return;
result.blockedCWPrev ||= result.blockedCW;
result.blockedCCWPrev ||= result.blockedCCW;
}
}
/* -------------------------------------------- */
/**
* Identify the collision points between an emitted Ray and a set of active edges.
* @param {PolygonRay} ray The candidate ray to test
* @param {EdgeSet} internalEdges The set of edges to check for collisions against the ray
* @returns {PolygonVertex[]} A sorted array of collision points
* @private
*/
_getInternalEdgeCollisions(ray, internalEdges) {
const collisions = [];
const A = ray.A;
const B = ray.B;
for ( let edge of internalEdges ) {
const x = foundry.utils.lineLineIntersection(A, B, edge.A, edge.B);
if ( !x ) continue;
const c = PolygonVertex.fromPoint(x);
c.attachEdge(edge, 0);
c.isInternal = true;
// Use the true distance so that collisions can be distinguished from nearby vertices.
c._d2 = Math.pow(x.x - A.x, 2) + Math.pow(x.y - A.y, 2);
collisions.push(c);
}
return collisions;
}
/* -------------------------------------------- */
/* Collision Testing */
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
static getRayCollisions(ray, config={}) {
const msg = "ClockwiseSweepPolygon.getRayCollisions has been renamed to ClockwiseSweepPolygon.testCollision";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return this.testCollision(ray.A, ray.B, config);
}
/** @override */
_testCollision(ray, mode) {
// Identify candidate edges
this._identifyEdges();
// Identify collision points
let collisions = new Map();
for ( const edge of this.edges ) {
const x = foundry.utils.lineSegmentIntersection(this.origin, ray.B, edge.A, edge.B);
if ( !x || (x.t0 <= 0) ) continue;
if ( (mode === "any") && (!edge.isLimited || collisions.size) ) return true;
let c = PolygonVertex.fromPoint(x, {distance: x.t0});
if ( collisions.has(c.key) ) c = collisions.get(c.key);
else collisions.set(c.key, c);
c.attachEdge(edge);
}
if ( mode === "any" ) return false;
// Sort collisions
collisions = Array.from(collisions.values()).sort((a, b) => a._distance - b._distance);
if ( collisions[0]?.type === CONST.WALL_SENSE_TYPES.LIMITED ) collisions.shift();
// Visualize result
if ( this.config.debug ) this._visualizeCollision(ray, collisions);
// Return collision result
if ( mode === "all" ) return collisions;
else return collisions[0] || null;
}
/* -------------------------------------------- */
/* Visualization */
/* -------------------------------------------- */
/** @override */
visualize() {
let dg = canvas.controls.debug;
dg.clear();
// Text debugging
if ( !canvas.controls.debug.debugText ) {
canvas.controls.debug.debugText = canvas.controls.addChild(new PIXI.Container());
}
const text = canvas.controls.debug.debugText;
text.removeChildren().forEach(c => c.destroy({children: true}));
// Define limitation colors
const limitColors = {
[CONST.WALL_SENSE_TYPES.NONE]: 0x77E7E8,
[CONST.WALL_SENSE_TYPES.NORMAL]: 0xFFFFBB,
[CONST.WALL_SENSE_TYPES.LIMITED]: 0x81B90C,
[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 ) {
dg.lineStyle(4, limitColors[edge.type]).moveTo(edge.A.x, edge.A.y).lineTo(edge.B.x, edge.B.y);
}
// Draw vertices
for ( let vertex of this.vertices.values() ) {
if ( vertex.type ) {
dg.lineStyle(1, 0x000000).beginFill(limitColors[vertex.type]).drawCircle(vertex.x, vertex.y, 8).endFill();
}
if ( vertex._index ) {
let t = text.addChild(new PIXI.Text(String(vertex._index), CONFIG.canvasTextStyle));
t.position.set(vertex.x, vertex.y);
}
}
// Draw emitted rays
for ( let ray of this.rays ) {
const r = ray.result;
if ( r ) {
dg.lineStyle(2, 0x00FF00, r.collisions.length ? 1.0 : 0.33).moveTo(ray.A.x, ray.A.y).lineTo(ray.B.x, ray.B.y);
for ( let c of r.collisions ) {
dg.lineStyle(1, 0x000000).beginFill(0xFF0000).drawCircle(c.x, c.y, 6).endFill();
}
}
}
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() ) {
dg.lineStyle(4, limitColors[edge.type]).moveTo(edge.A.x, edge.A.y).lineTo(edge.B.x, edge.B.y);
}
// Draw the attempted ray
dg.lineStyle(4, 0x0066CC).moveTo(ray.A.x, ray.A.y).lineTo(ray.B.x, ray.B.y);
// Draw collision points
for ( let x of collisions ) {
dg.lineStyle(1, 0x000000).beginFill(0xFF0000).drawCircle(x.x, x.y, 6).endFill();
}
}
}
/**
* A singleton class dedicated to manage the color spaces associated with the scene and the canvas.
* @category - Canvas
*/
class CanvasColorManager {
/**
* The scene darkness level.
* @type {number}
*/
#darknessLevel;
/**
* Colors exposed by the manager.
* @enum {Color}
*/
colors = {
darkness: undefined,
halfdark: undefined,
background: undefined,
dim: undefined,
bright: undefined,
ambientBrightest: undefined,
ambientDaylight: undefined,
ambientDarkness: undefined,
sceneBackground: undefined,
fogExplored: undefined,
fogUnexplored: undefined
};
/**
* Weights used by the manager to compute colors.
* @enum {number}
*/
weights = {
dark: undefined,
halfdark: undefined,
dim: undefined,
bright: undefined
};
/**
* Fallback colors.
* @enum {Color}
*/
static #fallbackColors = {
darknessColor: 0x242448,
daylightColor: 0xEEEEEE,
brightestColor: 0xFFFFFF,
backgroundColor: 0x909090,
fogUnexplored: 0x000000,
fogExplored: 0x000000
};
/* -------------------------------------------- */
/**
* Returns the darkness penalty for the actual scene configuration.
* @returns {number}
*/
get darknessPenalty() {
return this.darknessLevel * CONFIG.Canvas.darknessLightPenalty;
}
/* -------------------------------------------- */
/**
* Get the darkness level of this scene.
* @returns {number}
*/
get darknessLevel() {
return this.#darknessLevel;
}
/* -------------------------------------------- */
/**
* Initialize color space pertaining to a specific scene.
* @param {object} [colors={}]
* @param {Color|number|string} [colors.backgroundColor] The background canvas color
* @param {Color|number|string} [colors.brightestColor] The brightest ambient color
* @param {Color|number|string} [colors.darknessColor] The color of darkness
* @param {number} [colors.darknessLevel] A preview darkness level
* @param {Color|number|string} [colors.daylightColor] The ambient daylight color
* @param {number} [colors.fogExploredColor] The color applied to explored areas
* @param {number} [colors.fogUnexploredColor] The color applied to unexplored areas
*/
initialize({backgroundColor, brightestColor, darknessColor, darknessLevel, daylightColor, fogExploredColor,
fogUnexploredColor}={}) {
const scene = canvas.scene;
// Update base ambient colors, and darkness level
const fbc = CanvasColorManager.#fallbackColors;
this.colors.ambientDarkness = Color.from(darknessColor ?? CONFIG.Canvas.darknessColor ?? fbc.darknessColor);
this.colors.ambientDaylight = Color.from(daylightColor
?? (scene?.tokenVision ? (CONFIG.Canvas.daylightColor ?? fbc.daylightColor) : 0xFFFFFF));
this.colors.ambientBrightest = Color.from(brightestColor ?? CONFIG.Canvas.brightestColor ?? fbc.brightestColor);
// Darkness level control
const priorDarknessLevel = this.#darknessLevel ?? 0;
const dl = darknessLevel ?? scene?.darkness ?? 0;
const darknessChanged = (dl !== this.#darknessLevel);
this.#darknessLevel = scene.darkness = dl;
// Update weights
Object.assign(this.weights, CONFIG.Canvas.lightLevels ?? {
dark: 0,
halfdark: 0.5,
dim: 0.25,
bright: 1
});
// Compute colors
this.#configureColors(scene, {fogExploredColor, fogUnexploredColor, backgroundColor});
// Update primary cached container and renderer clear color with scene background color
canvas.app.renderer.background.color = this.colors.rendererBackground;
canvas.primary.clearColor = [...this.colors.sceneBackground.rgb, 1];
// If darkness changed, activate some darkness handlers to refresh controls.
if ( darknessChanged ) {
canvas.effects._onDarknessChange(this.#darknessLevel, priorDarknessLevel);
canvas.lighting._onDarknessChange(this.#darknessLevel, priorDarknessLevel);
canvas.sounds._onDarknessChange(this.#darknessLevel, priorDarknessLevel);
}
// Push a perception update to refresh lighting and sources with the new computed color values
canvas.perception.update({
refreshPrimary: true,
refreshLighting: true,
refreshVisionSources: true
});
}
/* -------------------------------------------- */
/**
* Configure all colors pertaining to a scene.
* @param {Scene} scene The scene document for which colors are configured.
* @param {object} [options={}] Preview options.
* @param {number} [options.fogExploredColor] A preview fog explored color.
* @param {number} [options.fogUnexploredColor] A preview fog unexplored color.
* @param {number} [options.backgroundColor] The background canvas color.
*/
#configureColors(scene, {fogExploredColor, fogUnexploredColor, backgroundColor}={}) {
const fbc = CanvasColorManager.#fallbackColors;
// Compute the middle ambient color
this.colors.background = this.colors.ambientDarkness.mix(this.colors.ambientDaylight, 1.0 - this.darknessLevel);
// Compute dark ambient colors
this.colors.darkness = this.colors.ambientDarkness.mix(this.colors.background, this.weights.dark);
this.colors.halfdark = this.colors.darkness.mix(this.colors.background, this.weights.halfdark);
// Compute light ambient colors
this.colors.bright =
this.colors.background.mix(this.colors.ambientBrightest, (1 - this.darknessPenalty) * this.weights.bright);
this.colors.dim = this.colors.background.mix(this.colors.bright, this.weights.dim);
// Compute fog colors
const cfg = CONFIG.Canvas;
const uc = Color.from(fogUnexploredColor ?? scene.fogUnexploredColor ?? cfg.unexploredColor ?? fbc.fogUnexplored);
this.colors.fogUnexplored = this.colors.background.multiply(uc);
const ec = Color.from(fogExploredColor ?? scene.fogExploredColor ?? cfg.exploredColor ?? fbc.fogExplored);
this.colors.fogExplored = this.colors.background.multiply(ec);
// Compute scene background color
const sceneBG = Color.from(backgroundColor ?? scene?.backgroundColor ?? fbc.backgroundColor);
this.colors.sceneBackground = sceneBG;
this.colors.rendererBackground = sceneBG.multiply(this.colors.background);
}
}
/**
* A Detection Mode which can be associated with any kind of sense/vision/perception.
* A token could have multiple detection modes.
*/
class DetectionMode extends foundry.abstract.DataModel {
/** @inheritDoc */
static defineSchema() {
const fields = foundry.data.fields;
return {
id: new fields.StringField({blank: false}),
label: new fields.StringField({blank: false}),
tokenConfig: new fields.BooleanField({initial: true}), // If this DM is available in Token Config UI
walls: new fields.BooleanField({initial: true}), // If this DM is constrained by walls
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;
/**
* The type of the detection mode. If its sight based, sound based, etc.
* It is related to wall's WALL_RESTRICTION_TYPES
* @see CONST.WALL_RESTRICTION_TYPES
* @enum {number}
*/
static DETECTION_TYPES = {
SIGHT: 0, // Sight, and anything depending on light perception
SOUND: 1, // What you can hear. Includes echolocation for bats per example
MOVE: 2, // This is mostly a sense for touch and vibration, like tremorsense, movement detection, etc.
OTHER: 3 // Can't fit in other types (smell, life sense, trans-dimensional sense, sense of humor...)
};
/**
* The identifier of the basic sight detection mode.
* @type {string}
*/
static BASIC_MODE_ID = "basicSight";
/* -------------------------------------------- */
/* Visibility Testing */
/* -------------------------------------------- */
/**
* Test visibility of a target object or array of points for a specific vision source.
* @param {VisionSource} visionSource The vision source being tested
* @param {TokenDetectionMode} mode The detection mode configuration
* @param {CanvasVisibilityTestConfig} config The visibility test configuration
* @returns {boolean} Is the test target visible?
*/
testVisibility(visionSource, mode, {object, tests}={}) {
if ( !mode.enabled ) return false;
if ( !this._canDetect(visionSource, object) ) return false;
return tests.some(test => this._testPoint(visionSource, mode, object, test));
}
/* -------------------------------------------- */
/**
* Can this VisionSource theoretically detect a certain object based on its properties?
* This check should not consider the relative positions of either object, only their state.
* @param {VisionSource} visionSource The vision source being tested
* @param {PlaceableObject} target The target object being tested
* @returns {boolean} Can the target object theoretically be detected by this vision source?
* @protected
*/
_canDetect(visionSource, target) {
const src = visionSource.object.document;
if ( (src instanceof TokenDocument) && src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) ) return false;
const tgt = target?.document;
const isInvisible = (tgt instanceof TokenDocument) && tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE);
return !isInvisible;
}
/* -------------------------------------------- */
/**
* Evaluate a single test point to confirm whether it is visible.
* Standard detection rules require that the test point be both within LOS and within range.
* @param {VisionSource} visionSource The vision source being tested
* @param {TokenDetectionMode} mode The detection mode configuration
* @param {PlaceableObject} target The target object being tested
* @param {CanvasVisibilityTest} test The test case being evaluated
* @returns {boolean}
* @protected
*/
_testPoint(visionSource, mode, target, test) {
if ( !this._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);
if ( !this.angle && (visionSource.data.angle < 360) ) {
// Constrained by walls but not by vision angle
const type = visionSource.constructor.sourceType;
return !CONFIG.Canvas.polygonBackends[type].testCollision(
{ x: visionSource.x, y: visionSource.y },
test.point,
{ type, mode: "any", source: visionSource, useThreshold: true }
);
}
// 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 <= 0 ) return false;
const radius = visionSource.object.getLightRadius(mode.range);
const dx = test.point.x - visionSource.x;
const dy = test.point.y - visionSource.y;
return ((dx * dx) + (dy * dy)) <= (radius * radius);
}
}
/* -------------------------------------------- */
/**
* A special detection mode which models standard human vision.
* This mode is the default case which is tested first when evaluating visibility of objects.
* It is also a special case, in that it is the only detection mode which considers the area of distant light sources.
*/
class DetectionModeBasicSight extends DetectionMode {
/** @override */
_testPoint(visionSource, mode, target, test) {
if ( !this._testLOS(visionSource, mode, target, test) ) return false;
if ( this._testRange(visionSource, mode, target, test) ) return true;
for ( const lightSource of canvas.effects.lightSources.values() ) {
if ( !lightSource.active ) continue;
if ( lightSource.shape.contains(test.point.x, test.point.y) ) return true;
}
return false;
}
}
/* -------------------------------------------- */
/**
* Detection mode that see invisible creatures.
* This detection mode allows the source to:
* - See/Detect the invisible target as if visible.
* - The "See" version needs sight and is affected by blindness
*/
class DetectionModeInvisibility extends DetectionMode {
/** @override */
static getDetectionFilter() {
return this._detectionFilter ??= GlowOverlayFilter.create({
glowColor: [0, 0.60, 0.33, 1]
});
}
/** @override */
_canDetect(visionSource, target) {
// See/Detect Invisibility can ONLY detect invisible status
const tgt = target?.document;
const isInvisible = (tgt instanceof TokenDocument) && tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE);
if ( !isInvisible ) return false;
// The source may not be blind if the detection mode requires sight
const src = visionSource.object.document;
const isBlind = ( (src instanceof TokenDocument) && (this.type === DetectionMode.DETECTION_TYPES.SIGHT)
&& src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) );
return !isBlind;
}
}
/* -------------------------------------------- */
/**
* Detection mode that see creatures in contact with the ground.
*/
class DetectionModeTremor extends DetectionMode {
/** @override */
static getDetectionFilter() {
return this._detectionFilter ??= OutlineOverlayFilter.create({
outlineColor: [1, 0, 1, 1],
knockout: true,
wave: true
});
}
/** @override */
_canDetect(visionSource, target) {
const tgt = target?.document;
return (tgt instanceof TokenDocument) && (tgt.elevation <= canvas.primary.background.elevation);
}
}
/* -------------------------------------------- */
/**
* Detection mode that see ALL creatures (no blockers).
* If not constrained by walls, see everything within the range.
*/
class DetectionModeAll extends DetectionMode {
/** @override */
static getDetectionFilter() {
return this._detectionFilter ??= OutlineOverlayFilter.create({
outlineColor: [0.85, 0.85, 1.0, 1],
knockout: true
});
}
/** @override */
_canDetect(visionSource, target) {
// The source may not be blind if the detection mode requires sight
const src = visionSource.object.document;
const isBlind = ( (src instanceof TokenDocument) && (this.type === DetectionMode.DETECTION_TYPES.SIGHT)
&& src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) );
return !isBlind;
}
}
/**
* A fog of war management class which is the singleton canvas.fog instance.
* @category - Canvas
*/
class FogManager {
/**
* The FogExploration document which applies to this canvas view
* @type {FogExploration|null}
*/
exploration = null;
/**
* A status flag for whether the layer initialization workflow has succeeded
* @type {boolean}
* @private
*/
#initialized = false;
/**
* Track whether we have pending fog updates which have not yet been saved to the database
* @type {boolean}
* @private
*/
#updated = false;
/**
* Texture extractor
* @type {TextureExtractor}
*/
#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 = foundry.utils.debounce(this.save.bind(this), 2000);
/**
* 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 = new SpriteMesh(Canvas.getRenderTexture({
clearColor: [0, 0, 0, 1],
textureConfiguration: this.textureConfiguration
}), FogSamplerShader));
}
#explorationSprite;
/* -------------------------------------------- */
/**
* The configured options used for the saved fog-of-war texture.
* @type {FogTextureConfiguration}
*/
get textureConfiguration() {
return canvas.effects.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.fogExploration;
}
/* -------------------------------------------- */
/* Fog of War Management */
/* -------------------------------------------- */
/**
* Initialize fog of war - resetting it when switching scenes or re-drawing the canvas
* @returns {Promise<void>}
*/
async initialize() {
this.#initialized = false;
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();
await this.load();
this.#initialized = true;
}
/* -------------------------------------------- */
/**
* Clear the fog and reinitialize properties (commit and save in non reset mode)
* @returns {Promise<void>}
*/
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.effects.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;
// Base vision not committed
vision.base.visible = false;
vision.los.preview.visible = false;
// Render the currently revealed vision to the texture
canvas.app.renderer.render(isRenderTex ? vision : this.#explorationSprite, {
renderTexture: tex,
clear: false,
transform: this.#renderTransform
});
vision.base.visible = true;
vision.los.preview.visible = true;
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 = getDocumentClass("FogExploration");
this.exploration = await fogExplorationCls.get();
// 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 = new SpriteMesh(tex, FogSamplerShader);
canvas.effects.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
let base64image;
try {
base64image = await 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");
}
// 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;
}
// Generate fog exploration with base64 image and time stamp
const updateData = {
explored: base64image,
timestamp: Date.now()
};
// Update the fog exploration document
await this.#updateFogExploration(updateData);
}
/* -------------------------------------------- */
/**
* Update the fog exploration document with provided data.
* @param {object} updateData
* @returns {Promise<void>}
*/
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.effects.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.effects.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.effects.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.effects.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 canvas#perception.
* @see {Canvas#perception}
*/
class PerceptionManager extends RenderFlagsMixin(Object) {
/**
* @typedef {RenderFlags} PerceptionManagerFlags
* @property {boolean} initializeLighting Re-initialize the entire lighting configuration
* @property {boolean} refreshLighting Refresh the rendered appearance of lighting
* @property {boolean} refreshLightSources Update the configuration of light sources
* @property {boolean} initializeVision Re-initialize the entire vision configuration
* @property {boolean} refreshVisionSources Update the configuration of vision sources
* @property {boolean} refreshVision Refresh the rendered appearance of vision
* @property {boolean} initializeSounds Re-initialize the entire ambient sound configuration
* @property {boolean} refreshSounds Refresh the audio state of ambient sounds
* @property {boolean} soundFadeDuration Apply a fade duration to sound refresh workflow
* @property {boolean} refreshTiles Refresh the visual appearance of tiles
* @property {boolean} refreshPrimary Refresh the contents of the PrimaryCanvasGroup mesh
*/
/** @override */
static RENDER_FLAGS = {
initializeLighting: {propagate: ["refreshLighting", "refreshVision"]},
refreshLighting: {propagate: ["refreshLightSources"]},
refreshLightSources: {},
refreshVisionSources: {},
refreshPrimary: {},
initializeVision: {propagate: ["refreshVision", "refreshTiles", "refreshLighting", "refreshLightSources",
"refreshPrimary"]},
refreshVision: {propagate: ["refreshVisionSources"]},
initializeSounds: {propagate: ["refreshSounds"]},
refreshSounds: {},
refreshTiles: {propagate: ["refreshLightSources", "refreshVisionSources"]},
soundFadeDuration: {},
identifyInteriorWalls: {propagate: ["initializeLighting", "initializeVision"]},
forceUpdateFog: {propagate: ["refreshVision"]}
};
/** @override */
static RENDER_FLAG_PRIORITY = "PERCEPTION";
/* -------------------------------------------- */
/** @override */
applyRenderFlags() {
if ( !this.renderFlags.size ) return;
const flags = this.renderFlags.clear();
// Sort the children of the primary canvas group
if ( flags.refreshTiles && canvas.primary.sortDirty ) canvas.primary.sortChildren();
// Identify interior walls
if ( flags.identifyInteriorWalls ) canvas.walls.identifyInteriorWalls();
// Initialize perception sources for each layer
if ( flags.initializeLighting ) canvas.effects.initializeLightSources();
if ( flags.initializeVision ) canvas.effects.visibility.initializeSources();
if ( flags.initializeSounds ) canvas.sounds.initializeSources();
// Next refresh sources uniforms and states
if ( flags.refreshLightSources ) canvas.effects.refreshLightSources();
if ( flags.refreshVisionSources ) canvas.effects.refreshVisionSources();
if ( flags.refreshPrimary ) canvas.primary.refreshPrimarySpriteMesh();
// Next refresh lighting to establish the coloration channels for the Scene
if ( flags.refreshLighting ) canvas.effects.refreshLighting();
// Next refresh vision and fog of war
if ( flags.refreshVision ) canvas.effects.visibility.refresh();
// Update the playback of ambient sounds
if ( flags.refreshSounds ) canvas.sounds.refresh({fade: flags.soundFadeDuration ? 250 : 0});
// Update roof occlusion states based on token positions and vision
if ( flags.refreshTiles ) canvas.masks.occlusion.updateOcclusion();
// Call deprecated flag
if ( flags.forceUpdateFog ) PerceptionManager.forceUpdateFog();
}
/* -------------------------------------------- */
/**
* A shim mapping which supports backwards compatibility for old-style (V9 and before) perception manager flags.
* @enum {string}
*/
static COMPATIBILITY_MAPPING = {
"lighting.initialize": "initializeLighting",
"lighting.refresh": "refreshLighting",
"sight.initialize": "initializeVision",
"sight.refresh": "refreshVision",
"sounds.initialize": "initializeSounds",
"sounds.refresh": "refreshSounds",
"sounds.fade": "soundFadeDuration",
"foreground.refresh": "refreshTiles"
};
/**
* Update perception manager flags which configure which behaviors occur on the next frame render.
* @param {object} flags Flag values (true) to assign where the keys belong to PerceptionManager.FLAGS
* @param {boolean} [v2=true] Opt-in to passing v2 flags, otherwise a backwards compatibility shim will be applied
*/
update(flags, v2=true) {
if ( !canvas.ready ) return;
// Backwards compatibility for V1 flags
let _flags = v2 ? flags : {};
if ( !v2 ) {
const msg = "The data structure of PerceptionManager flags have changed. You are assigning flags with the old "
+ "data structure and must migrate to assigning new flags.";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
flags = foundry.utils.flattenObject(flags);
for ( const [flag, value] of Object.entries(flags) ) {
_flags[PerceptionManager.COMPATIBILITY_MAPPING[flag]] = value;
}
}
// Set flags
this.renderFlags.set(_flags);
}
/* -------------------------------------------- */
/**
* A helper function to perform an immediate initialization plus incremental refresh.
*/
initialize() {
return this.update({
initializeLighting: true,
initializeVision: true,
initializeSounds: true,
identifyInteriorWalls: true
});
}
/* -------------------------------------------- */
/**
* A helper function to perform an incremental refresh only.
*/
refresh() {
return this.update({
refreshLighting: true,
refreshVision: true,
refreshSounds: true,
refreshTiles: true
});
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
cancel() {
foundry.utils.logCompatibilityWarning("PerceptionManager#cancel is renamed to PerceptionManager#deactivate", {
since: 10,
until: 12
});
return this.deactivate();
}
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
schedule(options={}) {
foundry.utils.logCompatibilityWarning("PerceptionManager#schedule is replaced by PerceptionManager#update", {
since: 10,
until: 12
});
this.update(options, false);
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
static forceUpdateFog() {
foundry.utils.logCompatibilityWarning("forceUpdateFog flag is now obsolete and has no replacement. The fog " +
"is now always updated when the visibility is refreshed", {
since: 11,
until: 13
});
}
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
static get DEFAULTS() {
throw new Error("PerceptionManager#DEFAULTS is deprecated without replacement");
}
}
/**
* A special subclass of DataField used to reference an AbstractBaseShader definition.
*/
class ShaderField extends foundry.data.fields.DataField {
/** @inheritdoc */
static get _defaults() {
const defaults = super._defaults;
defaults.nullable = true;
defaults.initial = undefined;
return defaults;
}
/** @override */
_cast(value) {
if ( !foundry.utils.isSubclass(value, AbstractBaseShader) ) {
throw new Error("The value provided to a ShaderField must be an AbstractBaseShader subclass.");
}
return value;
}
}
/**
* A Vision Mode which can be selected for use by a Token.
* The selected Vision Mode alters the appearance of various aspects of the canvas while that Token is the POV.
*/
class VisionMode extends foundry.abstract.DataModel {
/**
* Construct a Vision Mode using provided configuration parameters and callback functions.
* @param {object} data Data which fulfills the model defined by the VisionMode schema.
* @param {object} [options] Additional options passed to the DataModel constructor.
*/
constructor(data={}, options={}) {
super(data, options);
this.animated = options.animated ?? false;
}
/** @inheritDoc */
static defineSchema() {
const fields = foundry.data.fields;
const shaderSchema = () => new fields.SchemaField({
shader: new ShaderField(),
uniforms: new fields.ObjectField()
});
const lightingSchema = () => new fields.SchemaField({
visibility: new fields.NumberField({
initial: this.LIGHTING_VISIBILITY.ENABLED,
choices: Object.values(this.LIGHTING_VISIBILITY)
}),
postProcessingModes: new fields.ArrayField(new fields.StringField()),
uniforms: new fields.ObjectField()
});
// Return model schema
return {
id: new fields.StringField({blank: false}),
label: new fields.StringField({blank: false}),
tokenConfig: new fields.BooleanField({initial: true}),
canvas: new fields.SchemaField({
shader: new ShaderField(),
uniforms: new fields.ObjectField()
}),
lighting: new fields.SchemaField({
background: lightingSchema(),
coloration: lightingSchema(),
illumination: lightingSchema(),
levels: new fields.ObjectField({
validate: o => {
const values = Object.values(this.LIGHTING_LEVELS);
return Object.entries(o).every(([k, v]) => values.includes(Number(k)) && values.includes(v));
},
validationError: "may only contain a mapping of keys from VisionMode.LIGHTING_LEVELS"
}),
multipliers: new fields.ObjectField({
validate: o => {
const values = Object.values(this.LIGHTING_LEVELS);
return Object.entries(o).every(([k, v]) => values.includes(Number(k)) && Number.isFinite(v));
},
validationError: "must provide a mapping of keys from VisionMode.LIGHTING_LEVELS to numeric multiplier values"
})
}),
vision: new fields.SchemaField({
background: shaderSchema(),
coloration: shaderSchema(),
illumination: shaderSchema(),
darkness: new fields.SchemaField({
adaptive: new fields.BooleanField({initial: true})
}),
defaults: new fields.ObjectField(),
preferred: new fields.BooleanField({initial: false})
})
};
}
/**
* The lighting illumination levels which are supported.
* @enum {number}
*/
static LIGHTING_LEVELS = {
DARKNESS: -2,
HALFDARK: -1,
UNLIT: 0,
DIM: 1,
BRIGHT: 2,
BRIGHTEST: 3
};
/**
* Flags for how each lighting channel should be rendered for the currently active vision modes:
* - Disabled: this lighting layer is not rendered, the shaders does not decide.
* - Enabled: this lighting layer is rendered normally, and the shaders can choose if they should be rendered or not.
* - Required: the lighting layer is rendered, the shaders does not decide.
* @enum {number}
*/
static LIGHTING_VISIBILITY = {
DISABLED: 0,
ENABLED: 1,
REQUIRED: 2
};
/**
* A flag for whether this vision source is animated
* @type {boolean}
*/
animated = false;
/**
* Special 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 VisionSource.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 border frame and resizing handles for the drawing.
* @type {PIXI.Container}
*/
frame;
/**
* A text label that may be displayed as part of the interface layer for the Drawing.
* @type {PreciseText|null}
*/
text = null;
/**
* The drawing shape which is rendered as a PIXI.Graphics subclass in the PrimaryCanvasGroup.
* @type {DrawingShape}
*/
shape;
/**
* An internal timestamp for the previous freehand draw time, to limit sampling.
* @type {number}
*/
#drawTime = 0;
/**
* An internal flag for the permanent points of the polygon.
* @type {number[]}
*/
#fixedPoints = foundry.utils.deepClone(this.document.shape.points);
/**
* The computed bounds of the Drawing.
* @type {PIXI.Rectangle}
*/
#bounds;
/* -------------------------------------------- */
/** @inheritdoc */
static embeddedName = "Drawing";
/** @override */
static RENDER_FLAGS = {
redraw: {propagate: ["refresh"]},
refresh: {propagate: ["refreshState", "refreshShape"], alias: true},
refreshState: {propagate: ["refreshFrame"]},
refreshShape: {propagate: ["refreshFrame", "refreshText", "refreshMesh"]},
refreshFrame: {},
refreshText: {},
refreshMesh: {}
};
/**
* The rate at which points are sampled (in milliseconds) during a freehand drawing workflow
* @type {number}
*/
static FREEHAND_SAMPLE_RATE = 75;
/**
* A convenience reference to the possible shape types.
* @enum {string}
*/
static SHAPE_TYPES = foundry.data.ShapeData.TYPES;
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/** @override */
get bounds() {
const {x, y, shape, rotation} = this.document;
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.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;
}
/* -------------------------------------------- */
/* Initial Rendering */
/* -------------------------------------------- */
/** @inheritdoc */
clear() {
this._pendingText = this.document.text ?? "";
this.text = undefined;
return super.clear();
}
/* -------------------------------------------- */
/** @inheritDoc */
_destroy(options) {
canvas.primary.removeDrawing(this);
this.texture?.destroy();
}
/* -------------------------------------------- */
/** @override */
async _draw(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 primary group drawing container
this.shape = canvas.primary.addDrawing(this);
// Control Border
this.frame = this.addChild(this.#drawFrame());
// Drawing text
this.text = this.hasText ? this.addChild(this.#drawText()) : null;
// Interactivity
this.cursor = this.document.isOwner ? "pointer" : null;
}
/* -------------------------------------------- */
/**
* Create elements for the Drawing border and handles
* @returns {PIXI.Container}
*/
#drawFrame() {
const frame = new PIXI.Container();
frame.border = frame.addChild(new PIXI.Graphics());
frame.handle = frame.addChild(new ResizeHandle([1, 1]));
return frame;
}
/* -------------------------------------------- */
/**
* Create a PreciseText element to be displayed as part of this drawing.
* @returns {PreciseText}
*/
#drawText() {
const textStyle = this._getTextStyle();
return new PreciseText(this.document.text || undefined, textStyle);
}
/* -------------------------------------------- */
/**
* 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 || 0xFFFFFF,
strokeThickness: stroke,
dropShadowBlur: Math.max(Math.round(fontSize / 16), 2),
align: "center",
wordWrap: true,
wordWrapWidth: shape.width,
padding: stroke * 4
});
}
/* -------------------------------------------- */
/* Incremental Refresh */
/* -------------------------------------------- */
/** @override */
_applyRenderFlags(flags) {
if ( flags.refreshShape ) this.#refreshShape();
if ( flags.refreshFrame ) this.#refreshFrame();
if ( flags.refreshText ) this.#refreshText();
if ( flags.refreshState ) this.#refreshState();
if ( flags.refreshMesh ) this.#refreshMesh();
}
/* -------------------------------------------- */
/**
* Refresh the primary canvas object bound to this drawing.
*/
#refreshMesh() {
if ( !this.shape ) return;
this.shape.initialize(this.document);
this.shape.alpha = Math.min(this.shape.alpha, this.alpha);
}
/* -------------------------------------------- */
/**
* Refresh the displayed state of the Drawing.
* Used to update aspects of the Drawing which change based on the user interaction state.
*/
#refreshState() {
const {hidden, locked} = this.document;
this.visible = !hidden || game.user.isGM;
this.frame.border.visible = this.controlled || this.hover || this.layer.highlightObjects;
this.frame.handle.visible = this.controlled && !locked;
// Update the alpha of the text (if any) according to the hidden state
if ( !this.text ) return;
const textAlpha = this.document.textAlpha;
this.text.alpha = hidden ? Math.min(0.5, textAlpha) : (textAlpha ?? 1.0);
}
/* -------------------------------------------- */
/**
* Refresh the displayed shape of the Drawing.
* This refresh occurs when the underlying shape of the drawing has been modified.
*/
#refreshShape() {
const {x, y, shape, rotation, sort} = this.document;
// Compute drawing bounds
this.#bounds = rotation === 0
? new PIXI.Rectangle(0, 0, shape.width, shape.height).normalize()
: PIXI.Rectangle.fromRotation(0, 0, shape.width, shape.height, Math.toRadians(rotation)).normalize();
// Refresh hit area
this.hitArea = this.#bounds.clone().pad(20);
// Set Position, zIndex, alpha
this.position.set(x, y);
this.zIndex = sort;
this.alpha = this._getTargetAlpha();
}
/* -------------------------------------------- */
/**
* Refresh the border frame that encloses the Drawing.
*/
#refreshFrame() {
// Determine the border color
const colors = CONFIG.Canvas.dispositionColors;
let bc = colors.INACTIVE;
if ( this.controlled ) {
bc = this.document.locked ? colors.HOSTILE : colors.CONTROLLED;
}
// Draw the padded border
const pad = 6;
const t = CONFIG.Canvas.objectBorderThickness;
const h = Math.round(t/2);
const o = Math.round(h/2) + pad;
const border = this.#bounds.clone().pad(o);
this.frame.border.clear().lineStyle(t, 0x000000).drawShape(border).lineStyle(h, bc).drawShape(border);
// Draw the handle
this.frame.handle.refresh(border);
}
/* -------------------------------------------- */
/**
* Refresh the appearance of text displayed above the drawing.
* This refresh occurs when the shape is refreshed or the position or opacity of drawing text has changed.
*/
#refreshText() {
if ( !this.text ) return;
this.text.style = this._getTextStyle();
const {rotation, shape, hidden} = this.document;
this.text.pivot.set(this.text.width / 2, this.text.height / 2);
this.text.position.set(
(this.text.width / 2) + ((shape.width - this.text.width) / 2),
(this.text.height / 2) + ((shape.height - this.text.height) / 2)
);
this.text.angle = rotation;
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
/**
* Add a new polygon point to the drawing, ensuring it differs from the last one
* @param {Point} position The drawing point to add
* @param {object} [options] Options which configure how the point is added
* @param {boolean} [options.round=false] Should the point be rounded to integer coordinates?
* @param {boolean} [options.snap=false] Should the point be snapped to grid precision?
* @param {boolean} [options.temporary=false] Is this a temporary control point?
* @internal
*/
_addPoint(position, {round=false, snap=false, temporary=false}={}) {
if ( snap ) position = canvas.grid.getSnappedPosition(position.x, position.y, this.layer.gridPrecision);
else if ( round ) {
position.x = Math.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 ( game.activeTool === "text" ) {
if ( !canvas.scene.drawings.has(this.id) ) return;
let text = this._pendingText ?? this.document.text;
if ( text === "" ) return this.document.delete();
if ( this._pendingText ) { // Submit pending text
this.document.update({
text: this._pendingText,
width: this.document.shape.width,
height: this.document.shape.height
});
this._pendingText = "";
}
}
}
/* -------------------------------------------- */
/** @override */
_onDelete(options, userId) {
super._onDelete(options, userId);
if ( this._onkeydown ) document.removeEventListener("keydown", this._onkeydown);
}
/* -------------------------------------------- */
/**
* Enable text editing for this drawing.
* @param {object} [options]
*/
enableTextEditing(options={}) {
if ( (game.activeTool === "text") || options.forceTextEditing ) {
if ( this.text === null ) this.text = this.addChild(this.#drawText());
this._onkeydown = this.#onDrawingTextKeydown.bind(this);
if ( !options.isNew ) this._pendingText = this.document.text;
document.addEventListener("keydown", this._onkeydown);
}
}
/* -------------------------------------------- */
/**
* 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 = ["Escape", "Enter"].includes(event.key);
let refresh = false;
// Submitting the change, update or delete
if ( event.key === "Enter" ) {
if ( this._pendingText ) {
return this.document.update({
text: this._pendingText,
width: this.document.shape.width,
height: this.document.shape.height
}).then(() => this.release());
}
else return this.document.delete();
}
// Cancelling the change
else if ( event.key === "Escape" ) {
this._pendingText = this.document.text;
refresh = true;
}
// Deleting a character
else if ( event.key === "Backspace" ) {
this._pendingText = this._pendingText.slice(0, -1);
refresh = true;
}
// Typing text (any single char)
else if ( /^.$/.test(event.key) ) {
this._pendingText += event.key;
refresh = true;
}
// Stop propagation if the event was handled
if ( refresh || conclude ) {
event.preventDefault();
event.stopPropagation();
}
// Refresh the display
if ( refresh ) {
this.text.text = this._pendingText;
this.document.shape.width = this.text.width + 100;
this.document.shape.height = this.text.height + 50;
this.renderFlags.set({refreshShape: true});
}
// Conclude the workflow
if ( conclude ) {
this.release();
}
}
/* -------------------------------------------- */
/* Document Event Handlers */
/* -------------------------------------------- */
/** @override */
_onUpdate(data, options, userId) {
super._onUpdate(data, options, userId);
// Full re-draw
const redraw = ("type" in (data.shape || {})) || ("texture" in data) || ("text" in data)
|| (!!this.document.text && ["fontFamily", "fontSize", "textColor"].some(k => k in data));
if ( redraw ) return this.renderFlags.set({redraw: true});
const refreshHUD = ((this.layer.hud.object === this) && ["z", "hidden", "locked"].some(k => k in data));
const refreshShape = ["x", "y", "elevation", "z", "shape", "rotation", "strokeWidth", "strokeColor",
"strokeAlpha", "bezierFactor", "fillType", "fillAlpha"].some(k => k in data);
// Incremental refresh
this.renderFlags.set({
refreshState: ["hidden", "locked", "textAlpha"].some(k => k in data),
refreshMesh: "hidden" in data,
refreshShape
});
if ( refreshHUD ) this.layer.hud.render();
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners() {
super.activateListeners();
this.frame.handle.off("pointerover").off("pointerout").off("pointerdown")
.on("pointerover", this._onHandleHoverIn.bind(this))
.on("pointerout", this._onHandleHoverOut.bind(this))
.on("pointerdown", this._onHandleMouseDown.bind(this));
this.frame.handle.eventMode = "static";
}
/* -------------------------------------------- */
/** @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 ( game.activeTool !== "select" ) 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:
const shape = this.shape;
const minSize = canvas.dimensions.size * 0.5;
let dx = position.x - origin.x;
let dy = position.y - origin.y;
if ( Math.abs(dx) < minSize ) dx = minSize * Math.sign(shape.width);
if ( Math.abs(dy) < minSize ) dy = minSize * Math.sign(shape.height);
if ( isAlt ) {
dx = Math.abs(dy) < Math.abs(dx) ? Math.abs(dy) * Math.sign(dx) : dx;
dy = Math.abs(dx) < Math.abs(dy) ? Math.abs(dx) * Math.sign(dy) : dy;
}
const r = new PIXI.Rectangle(origin.x, origin.y, dx, dy).normalize();
this.document.updateSource({
x: r.x,
y: r.y,
shape: {
width: r.width,
height: r.height
}
});
break;
}
// Refresh the display
this.renderFlags.set({refreshShape: true});
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
/** @override */
_onDragLeftStart(event) {
if ( this._dragHandle ) return this._onHandleDragStart(event);
if ( this._pendingText ) this.document.text = this._pendingText;
return super._onDragLeftStart(event);
}
/* -------------------------------------------- */
/** @override */
_onDragLeftMove(event) {
if ( this._dragHandle ) return this._onHandleDragMove(event);
return super._onDragLeftMove(event);
}
/* -------------------------------------------- */
/** @override */
async _onDragLeftDrop(event) {
if ( this._dragHandle ) return this._onHandleDragDrop(event);
if ( this._dragPassthrough ) return canvas._onDragLeftDrop(event);
event.interactionData.clearPreviewContainer = false;
// Update each dragged Drawing, confirming pending text
const clones = event.interactionData.clones || [];
const updates = clones.map(c => {
let dest = {x: c.document.x, y: c.document.y};
if ( !event.shiftKey ) dest = canvas.grid.getSnappedPosition(dest.x, dest.y, this.layer.gridPrecision);
// Define the update
const update = {
_id: c._original.id,
x: dest.x,
y: dest.y,
rotation: c.document.rotation,
text: c._original._pendingText ? c._original._pendingText : c.document.text
};
// Commit pending text
if ( c._original._pendingText ) {
update.text = c._original._pendingText;
}
c.visible = false;
c._original.visible = false;
return update;
});
try {
return await canvas.scene.updateEmbeddedDocuments("Drawing", updates, {diff: false});
} finally {
this.layer.clearPreviewContainer();
}
}
/* -------------------------------------------- */
/** @inheritDoc */
_onDragLeftCancel(event) {
if ( this._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);
}
/* -------------------------------------------- */
/**
* When clicking the resize handle, initialize the drag property.
* @param {PIXI.FederatedEvent} event The mousedown event
* @protected
*/
_onHandleMouseDown(event) {
if ( !this.document.locked ) this._dragHandle = true;
}
/* -------------------------------------------- */
/**
* 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();
event.interactionData.drawingOrigin = {x: this.#bounds.right, y: this.#bounds.bottom};
}
/* -------------------------------------------- */
/**
* 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, originalData} = event.interactionData;
const dx = destination.x - origin.x;
const dy = destination.y - origin.y;
const normalized = Drawing.rescaleDimensions(originalData, dx, dy);
// Update the drawing, catching any validation failures
this.document.updateSource(normalized);
this.renderFlags.set({refreshShape: 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, originalData, drawingOrigin} = event.interactionData;
let drawingDestination = {
x: drawingOrigin.x + (destination.x - origin.x),
y: drawingOrigin.y + (destination.y - origin.y)
};
if ( !event.shiftKey ) {
drawingDestination = canvas.grid.getSnappedPosition(
drawingDestination.x, drawingDestination.y, this.layer.gridPrecision);
}
const dx = drawingDestination.x - drawingOrigin.x;
const dy = drawingDestination.y - drawingOrigin.y;
const update = Drawing.rescaleDimensions(originalData, dx, dy);
return this.document.update(update, {diff: false});
}
/* -------------------------------------------- */
/**
* Handle cancellation of a drag event for one of the resizing handles
* @param {PointerEvent} event The drag cancellation event
* @protected
*/
_onHandleDragCancel(event) {
this._dragHandle = false;
if ( event.interactionData.restoreOriginalData !== false ) {
this.document.updateSource(event.interactionData.originalData);
this.renderFlags.set({refreshShape: 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 + (dx / original.shape.width);
const scaleY = 1 + (dy / original.shape.height);
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<rawPoints.length; i+=2 ) {
const x0 = rawPoints[i-3];
const y0 = rawPoints[i-2];
const x1 = rawPoints[i-1];
const y1 = rawPoints[i];
if ( (x1 === x0) && (y1 === y0) ) {
continue;
}
xs.push(x1);
ys.push(y1);
}
// Determine minimal and maximal points
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
// Normalize points relative to minX and minY
const points = [];
for ( let i=0; i<xs.length; i++ ) {
points.push(xs[i] - minX, ys[i] - minY);
}
// Update data
data.x += minX;
data.y += minY;
data.shape.width = maxX - minX;
data.shape.height = maxY - minY;
data.shape.points = points;
}
// Adjust rectangles
else {
const normalized = new PIXI.Rectangle(data.x, data.y, data.shape.width, data.shape.height).normalize();
data.x = normalized.x;
data.y = normalized.y;
data.shape.width = normalized.width;
data.shape.height = normalized.height;
}
return data;
}
}
/**
* An AmbientLight is an implementation of PlaceableObject which represents a dynamic light source within the Scene.
* @category - Canvas
* @see {@link AmbientLightDocument}
* @see {@link LightingLayer}
*/
class AmbientLight extends PlaceableObject {
constructor(document) {
super(document);
/**
* A reference to the PointSource object which defines this light source area of effect
* @type {LightSource}
*/
this.source = new LightSource({object: this});
}
/**
* A reference to the ControlIcon used to configure this light
* @type {ControlIcon}
*/
controlIcon;
/* -------------------------------------------- */
/** @inheritdoc */
static embeddedName = "AmbientLight";
/** @override */
static RENDER_FLAGS = {
redraw: {propagate: ["refresh"]},
refresh: {propagate: ["refreshField"], alias: true},
refreshField: {propagate: ["refreshPosition", "refreshState"]},
refreshPosition: {},
refreshState: {}
};
/* -------------------------------------------- */
/** @inheritdoc */
get bounds() {
const {x, y} = this.document;
const r = Math.max(this.dimRadius, this.brightRadius);
return new PIXI.Rectangle(x-r, y-r, 2*r, 2*r);
}
/* -------------------------------------------- */
/**
* A convenience accessor to the LightData configuration object
* @returns {LightData}
*/
get config() {
return this.document.config;
}
/* -------------------------------------------- */
/**
* Test whether a specific AmbientLight source provides global illumination
* @type {boolean}
*/
get global() {
return this.document.isGlobal;
}
/* -------------------------------------------- */
/**
* The maximum radius in pixels of the light field
* @type {number}
*/
get radius() {
return Math.max(Math.abs(this.dimRadius), Math.abs(this.brightRadius));
}
/* -------------------------------------------- */
/**
* Get the pixel radius of dim light emitted by this light source
* @type {number}
*/
get dimRadius() {
let d = canvas.dimensions;
return ((this.config.dim / d.distance) * d.size);
}
/* -------------------------------------------- */
/**
* Get the pixel radius of bright light emitted by this light source
* @type {number}
*/
get brightRadius() {
let d = canvas.dimensions;
return ((this.config.bright / d.distance) * d.size);
}
/* -------------------------------------------- */
/**
* Is this Ambient Light currently visible? By default, true only if the source actively emits light.
* @type {boolean}
*/
get isVisible() {
return this.emitsLight;
}
/* -------------------------------------------- */
/**
* Does this Ambient Light actively emit light given its properties and the current darkness level of the Scene?
* @type {boolean}
*/
get emitsLight() {
const {hidden, config} = this.document;
// Lights which are disabled are not visible
if ( hidden ) return false;
// Lights which have no radius are not visible
if ( this.radius === 0 ) return false;
// Some lights are inactive based on the current darkness level
const darkness = canvas.darknessLevel;
return darkness.between(config.darkness.min, config.darkness.max);
}
/* -------------------------------------------- */
/* Rendering
/* -------------------------------------------- */
/** @override */
_destroy(options) {
this.source.destroy();
}
/* -------------------------------------------- */
/** @override */
async _draw(options) {
this.field = this.addChild(new PIXI.Graphics());
this.field.eventMode = "none";
this.controlIcon = this.addChild(this.#drawControlIcon());
}
/* -------------------------------------------- */
/**
* Draw the ControlIcon for the AmbientLight
* @returns {ControlIcon}
*/
#drawControlIcon() {
const size = Math.max(Math.round((canvas.dimensions.size * 0.5) / 20) * 20, 40);
let icon = new ControlIcon({texture: CONFIG.controlIcons.light, size: size });
icon.x -= (size * 0.5);
icon.y -= (size * 0.5);
return icon;
}
/* -------------------------------------------- */
/* Incremental Refresh */
/* -------------------------------------------- */
/** @override */
_applyRenderFlags(flags) {
if ( flags.refreshField ) this.#refreshField();
if ( flags.refreshPosition ) this.#refreshPosition();
if ( flags.refreshState ) this.#refreshState();
}
/* -------------------------------------------- */
/**
* Refresh the shape of the light field-of-effect. This is refreshed when the AmbientLight fov polygon changes.
*/
#refreshField() {
this.field.clear();
if ( !this.source.disabled ) this.field.lineStyle(2, 0xEEEEEE, 0.4).drawShape(this.source.shape);
}
/* -------------------------------------------- */
/**
* Refresh the position of the AmbientLight. Called with the coordinates change.
*/
#refreshPosition() {
const {x, y} = this.document;
this.position.set(x, y);
this.field.position.set(-x, -y);
}
/* -------------------------------------------- */
/**
* Refresh the state of the light. Called when the disabled state or darkness conditions change.
*/
#refreshState() {
this.alpha = this._getTargetAlpha();
this.refreshControl();
}
/* -------------------------------------------- */
/**
* Refresh the display of the ControlIcon for this AmbientLight source.
*/
refreshControl() {
const isHidden = this.id && this.document.hidden;
this.controlIcon.texture = getTexture(this.isVisible ? CONFIG.controlIcons.light : CONFIG.controlIcons.lightOff);
this.controlIcon.tintColor = isHidden ? 0xFF3300 : 0xFFFFFF;
this.controlIcon.borderColor = isHidden ? 0xFF3300 : 0xFF5500;
this.controlIcon.draw();
this.controlIcon.visible = this.layer.active;
this.controlIcon.border.visible = this.hover || this.layer.highlightObjects;
}
/* -------------------------------------------- */
/* Light Source Management */
/* -------------------------------------------- */
/**
* Update the LightSource associated with this AmbientLight object.
* @param {object} [options={}] Options which modify how the source is updated
* @param {boolean} [options.defer] Defer updating perception to manually update it later
* @param {boolean} [options.deleted] Indicate that this light source has been deleted
*/
updateSource({defer=false, deleted=false}={}) {
// Remove the light source from the active map
if ( deleted ) canvas.effects.lightSources.delete(this.sourceId);
// Update source data and add the source to the active map
else {
const d = canvas.dimensions;
const sourceData = foundry.utils.mergeObject(this.config.toObject(false), {
x: this.document.x,
y: this.document.y,
rotation: this.document.rotation,
dim: Math.clamped(this.dimRadius, 0, d.maxR),
bright: Math.clamped(this.brightRadius, 0, d.maxR),
walls: this.document.walls,
vision: this.document.vision,
z: this.document.getFlag("core", "priority") ?? null,
seed: this.document.getFlag("core", "animationSeed"),
disabled: !this.emitsLight,
preview: this.isPreview
});
this.source.initialize(sourceData);
canvas.effects.lightSources.set(this.sourceId, this.source);
}
// Schedule a perception refresh, unless that operation is deferred for some later workflow
if ( !defer ) canvas.perception.update({refreshLighting: true, refreshVision: true});
if ( this.layer.active ) this.renderFlags.set({refreshField: true});
}
/* -------------------------------------------- */
/* Document Event Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
_onCreate(data, options, userId) {
super._onCreate(data, options, userId);
this.updateSource();
}
/* -------------------------------------------- */
/** @override */
_onUpdate(data, options, userId) {
super._onUpdate(data, options, userId);
// Refresh Light Source
this.updateSource();
// Incremental Refresh
this.renderFlags.set({
refreshState: ["hidden", "config"].some(k => k in data),
refreshField: ["hidden", "x", "y", "config", "rotation", "walls"].some(k => k in data)
});
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDelete(options, userId) {
this.updateSource({deleted: true});
super._onDelete(options, userId);
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
/** @inheritdoc */
_canHUD(user, event) {
return user.isGM; // Allow GMs to single right-click
}
/* -------------------------------------------- */
/** @inheritdoc */
_canConfigure(user, event) {
return false; // Double-right does nothing
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickRight(event) {
this.document.update({hidden: !this.document.hidden});
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftMove(event) {
super._onDragLeftMove(event);
const clones = event.interactionData.clones || [];
for ( let c of clones ) {
c.updateSource({defer: true});
}
canvas.effects.refreshLighting();
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragEnd() {
this.updateSource({deleted: true});
this._original?.updateSource();
super._onDragEnd();
}
}
/**
* A Note is an implementation of PlaceableObject which represents an annotated location within the Scene.
* Each Note links to a JournalEntry document and represents its location on the map.
* @category - Canvas
* @see {@link NoteDocument}
* @see {@link NotesLayer}
*/
class Note extends PlaceableObject {
/** @inheritdoc */
static embeddedName = "Note";
/** @override */
static RENDER_FLAGS = {
redraw: {propagate: ["refresh"]},
refresh: {propagate: ["refreshState", "refreshPosition", "refreshText"], alias: true},
refreshPosition: {propagate: ["refreshVisibility"]},
refreshState: {propagate: ["refreshVisibility"]},
refreshVisibility: {},
refreshText: {}
};
/* -------------------------------------------- */
/** @override */
get bounds() {
const {x, y, iconSize} = this.document;
const r = iconSize / 2;
return new PIXI.Rectangle(x - r, y - r, 2*r, 2*r);
}
/* -------------------------------------------- */
/**
* The associated JournalEntry which is referenced by this Note
* @type {JournalEntry}
*/
get entry() {
return this.document.entry;
}
/* -------------------------------------------- */
/**
* The specific JournalEntryPage within the associated JournalEntry referenced by this Note.
*/
get page() {
return this.document.page;
}
/* -------------------------------------------- */
/**
* The text label used to annotate this Note
* @type {string}
*/
get text() {
return this.document.label;
}
/* -------------------------------------------- */
/**
* The Map Note icon size
* @type {number}
*/
get size() {
return this.document.iconSize || 40;
}
/* -------------------------------------------- */
/**
* Determine whether the Note is visible to the current user based on their perspective of the Scene.
* Visibility depends on permission to the underlying journal entry, as well as the perspective of controlled Tokens.
* If Token Vision is required, the user must have a token with vision over the note to see it.
* @type {boolean}
*/
get isVisible() {
const accessTest = this.page ? this.page : this.entry;
const access = accessTest?.testUserPermission(game.user, "LIMITED") ?? true;
if ( (access === false) || !canvas.effects.visibility.tokenVision || this.document.global ) return access;
const point = {x: this.document.x, y: this.document.y};
const tolerance = this.document.iconSize / 4;
return canvas.effects.visibility.testVisibility(point, {tolerance, object: this});
}
/* -------------------------------------------- */
/* Rendering
/* -------------------------------------------- */
/** @override */
async _draw(options) {
this.controlIcon = this.addChild(this._drawControlIcon());
this._drawTooltip();
}
/* -------------------------------------------- */
/**
* Draw the ControlIcon for the Map Note.
* This method replaces any prior controlIcon with the new one.
* @returns {ControlIcon}
* @protected
*/
_drawControlIcon() {
let tint = Color.from(this.document.texture.tint || null);
let icon = new ControlIcon({texture: this.document.texture.src, size: this.size, tint});
icon.x -= (this.size / 2);
icon.y -= (this.size / 2);
return icon;
}
/* -------------------------------------------- */
/**
* Draw the map note Tooltip as a Text object.
* This method replaces any prior text with the new one.
* @returns {PIXI.Text}
* @protected
*/
_drawTooltip() {
// Destroy any prior text
if ( this.tooltip ) {
this.removeChild(this.tooltip);
this.tooltip = undefined;
}
// Create the Text object
const textStyle = this._getTextStyle();
const text = new PreciseText(this.text, textStyle);
text.visible = false;
text.eventMode = "none";
const halfPad = (0.5 * this.size) + 12;
// Configure Text position
switch ( this.document.textAnchor ) {
case CONST.TEXT_ANCHOR_POINTS.CENTER:
text.anchor.set(0.5, 0.5);
text.position.set(0, 0);
break;
case CONST.TEXT_ANCHOR_POINTS.BOTTOM:
text.anchor.set(0.5, 0);
text.position.set(0, halfPad);
break;
case CONST.TEXT_ANCHOR_POINTS.TOP:
text.anchor.set(0.5, 1);
text.position.set(0, -halfPad);
break;
case CONST.TEXT_ANCHOR_POINTS.LEFT:
text.anchor.set(1, 0.5);
text.position.set(-halfPad, 0);
break;
case CONST.TEXT_ANCHOR_POINTS.RIGHT:
text.anchor.set(0, 0.5);
text.position.set(halfPad, 0);
break;
}
// Add child and return
return this.tooltip = this.addChild(text);
}
/* -------------------------------------------- */
/**
* Define a PIXI TextStyle object which is used for the tooltip displayed for this Note
* @returns {PIXI.TextStyle}
* @protected
*/
_getTextStyle() {
const style = CONFIG.canvasTextStyle.clone();
// Positioning
if ( this.document.textAnchor === CONST.TEXT_ANCHOR_POINTS.LEFT ) style.align = "right";
else if ( this.document.textAnchor === CONST.TEXT_ANCHOR_POINTS.RIGHT ) style.align = "left";
// Font preferences
style.fontFamily = this.document.fontFamily || CONFIG.defaultFontFamily;
style.fontSize = this.document.fontSize;
// Toggle stroke style depending on whether the text color is dark or light
const color = Color.from(this.document.textColor ?? 0xFFFFFF);
style.fill = color;
style.strokeThickness = 4;
style.stroke = color.hsv[2] > 0.6 ? 0x000000 : 0xFFFFFF;
return style;
}
/* -------------------------------------------- */
/* Incremental Refresh */
/* -------------------------------------------- */
/** @override */
_applyRenderFlags(flags) {
if ( flags.refreshVisibility ) this._refreshVisibility();
if ( flags.refreshPosition ) this.#refreshPosition();
if ( flags.refreshText ) this._drawTooltip();
if ( flags.refreshState ) this.#refreshState();
}
/* -------------------------------------------- */
/**
* Refresh the visibility.
* @protected
*/
_refreshVisibility() {
const wasVisible = this.visible;
this.visible = this.isVisible;
if ( this.controlIcon ) this.controlIcon.refresh({
visible: this.visible,
borderVisible: this.hover || this.layer.highlightObjects
});
if ( wasVisible !== this.visible ) this.layer.hintMapNotes();
}
/* -------------------------------------------- */
/**
* Refresh the state of the Note. Called the Note enters a different interaction state.
*/
#refreshState() {
this.alpha = this._getTargetAlpha();
this.tooltip.visible = this.hover || this.layer.highlightObjects;
}
/* -------------------------------------------- */
/**
* Refresh the position of the Note. Called with the coordinates change.
*/
#refreshPosition() {
this.position.set(this.document.x, this.document.y);
}
/* -------------------------------------------- */
/* Document Event Handlers */
/* -------------------------------------------- */
/** @override */
_onUpdate(data, options, userId) {
super._onUpdate(data, options, userId);
// Full Re-Draw
const changed = new Set(Object.keys(data));
if ( ["texture", "iconSize"].some(k => changed.has(k)) ) {
return this.renderFlags.set({redraw: true});
}
// Incremental Refresh
this.renderFlags.set({
refreshState: ["entryId", "pageId", "global"].some(k => changed.has(k)),
refreshPosition: ["x", "y"].some(k => changed.has(k)),
refreshText: ["text", "fontSize", "textAnchor", "textColor"].some(k => changed.has(k))
});
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
/** @override */
_canHover(user) {
return true;
}
/* -------------------------------------------- */
/** @override */
_canView(user) {
if ( !this.entry ) return false;
if ( game.user.isGM ) return true;
if ( this.page?.testUserPermission(game.user, "LIMITED", {exact: true}) ) {
// Special-case handling for image pages.
return this.page?.type === "image";
}
const accessTest = this.page ? this.page : this.entry;
return accessTest.testUserPermission(game.user, "OBSERVER");
}
/* -------------------------------------------- */
/** @override */
_canConfigure(user) {
return canvas.notes.active && this.document.canUserModify(game.user, "update");
}
/* -------------------------------------------- */
/** @inheritdoc */
_onHoverIn(event, options) {
this.zIndex = this.parent.children.at(-1).zIndex + 1;
return super._onHoverIn(event, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickLeft2(event) {
const options = {};
if ( this.page ) {
options.mode = JournalSheet.VIEW_MODES.SINGLE;
options.pageId = this.page.id;
}
const allowed = Hooks.call("activateNote", this, options);
if ( !allowed || !this.entry ) return;
if ( this.page?.type === "image" ) {
return new ImagePopout(this.page.src, {
uuid: this.page.uuid,
title: this.page.name,
caption: this.page.image.caption
}).render(true);
}
this.entry.sheet.render(true, options);
}
}
/**
* An AmbientSound is an implementation of PlaceableObject which represents a dynamic audio source within the Scene.
* @category - Canvas
* @see {@link AmbientSoundDocument}
* @see {@link SoundsLayer}
*/
class AmbientSound extends PlaceableObject {
/**
* The Sound which manages playback for this AmbientSound effect
* @type {Sound|null}
*/
sound = this.#createSound();
/**
* A SoundSource object which manages the area of effect for this ambient sound
* @type {SoundSource}
*/
source = new SoundSource({object: this});
/** @inheritdoc */
static embeddedName ="AmbientSound";
/** @override */
static RENDER_FLAGS = {
redraw: {propagate: ["refresh"]},
refresh: {propagate: ["refreshField"], alias: true},
refreshField: {propagate: ["refreshPosition", "refreshState"]},
refreshPosition: {},
refreshState: {}
};
/* -------------------------------------------- */
/**
* Create a Sound used to play this AmbientSound object
* @returns {Sound|null}
*/
#createSound() {
if ( !this.id || !this.document.path ) return null;
return game.audio.create({
src: this.document.path,
preload: true,
autoplay: false,
singleton: true
});
}
/* -------------------------------------------- */
/* Properties
/* -------------------------------------------- */
/**
* Is this ambient sound is currently audible based on its hidden state and the darkness level of the Scene?
* @type {boolean}
*/
get isAudible() {
if ( this.document.hidden || !this.document.radius ) return false;
return canvas.darknessLevel.between(this.document.darkness.min ?? 0, this.document.darkness.max ?? 1);
}
/* -------------------------------------------- */
/** @inheritdoc */
get bounds() {
const {x, y} = this.document;
const r = this.radius;
return new PIXI.Rectangle(x-r, y-r, 2*r, 2*r);
}
/* -------------------------------------------- */
/**
* A convenience accessor for the sound radius in pixels
* @type {number}
*/
get radius() {
let d = canvas.dimensions;
return ((this.document.radius / d.distance) * d.size);
}
/* -------------------------------------------- */
/* Methods
/* -------------------------------------------- */
/**
* Toggle playback of the sound depending on whether it is audible.
* @param {boolean} isAudible Is the sound audible?
* @param {number} volume The target playback volume
* @param {object} [options={}] Additional options which affect sound synchronization
* @param {number} [options.fade=250] A duration in milliseconds to fade volume transition
*/
sync(isAudible, volume, {fade=250}={}) {
const sound = this.sound;
if ( !sound ) return;
if ( !sound.loaded ) {
if ( sound.loading instanceof Promise ) {
sound.loading.then(() => this.sync(isAudible, volume, {fade}));
}
return;
}
// Fade the sound out if not currently audible
if ( !isAudible ) {
if ( !sound.playing || (sound.volume === 0) ) return;
if ( fade ) sound.fade(0, {duration: fade});
else sound.volume = 0;
return;
}
// Begin playback at the desired volume
if ( !sound.playing ) sound.play({volume: 0, loop: true});
// Adjust the target volume
const targetVolume = (volume ?? this.document.volume) * game.settings.get("core", "globalAmbientVolume");
if ( fade ) sound.fade(targetVolume, {duration: fade});
else sound.volume = targetVolume;
}
/* -------------------------------------------- */
/* Rendering
/* -------------------------------------------- */
/** @inheritdoc */
clear() {
if ( this.controlIcon ) {
this.controlIcon.parent.removeChild(this.controlIcon).destroy();
this.controlIcon = null;
}
return super.clear();
}
/* -------------------------------------------- */
/** @override */
async _draw() {
this.field = this.addChild(new PIXI.Graphics());
this.field.eventMode = "none";
this.controlIcon = this.addChild(this.#drawControlIcon());
}
/* -------------------------------------------- */
/** @override */
_destroy(options) {
this.source.destroy();
}
/* -------------------------------------------- */
/**
* Draw the ControlIcon for the AmbientLight
* @returns {ControlIcon}
*/
#drawControlIcon() {
const size = Math.max(Math.round((canvas.dimensions.size * 0.5) / 20) * 20, 40);
let icon = new ControlIcon({texture: CONFIG.controlIcons.sound, size: size});
icon.x -= (size * 0.5);
icon.y -= (size * 0.5);
return icon;
}
/* -------------------------------------------- */
/* Incremental Refresh */
/* -------------------------------------------- */
/** @override */
_applyRenderFlags(flags) {
if ( flags.refreshField ) this.#refreshField();
if ( flags.refreshPosition ) this.#refreshPosition();
if ( flags.refreshState ) this.#refreshState();
}
/* -------------------------------------------- */
/**
* Refresh the shape of the sound field-of-effect. This is refreshed when the SoundSource fov polygon changes.
*/
#refreshField() {
this.field.clear();
if ( !this.source.disabled ) {
this.field.beginFill(0xAADDFF, 0.15).lineStyle(1, 0xFFFFFF, 0.5).drawShape(this.source.shape).endFill();
}
}
/* -------------------------------------------- */
/**
* Refresh the position of the AmbientSound. Called with the coordinates change.
*/
#refreshPosition() {
const {x, y} = this.document;
this.position.set(x, y);
this.field.position.set(-x, -y);
}
/* -------------------------------------------- */
/**
* Refresh the state of the light. Called when the disabled state or darkness conditions change.
*/
#refreshState() {
this.alpha = this._getTargetAlpha();
this.refreshControl();
}
/* -------------------------------------------- */
/**
* Refresh the display of the ControlIcon for this AmbientSound source.
*/
refreshControl() {
const isHidden = this.id && (this.document.hidden || !this.document.path);
this.controlIcon.tintColor = isHidden ? 0xFF3300 : 0xFFFFFF;
this.controlIcon.borderColor = isHidden ? 0xFF3300 : 0xFF5500;
this.controlIcon.texture = getTexture(this.isAudible ? CONFIG.controlIcons.sound : CONFIG.controlIcons.soundOff);
this.controlIcon.draw();
this.controlIcon.visible = this.layer.active;
this.controlIcon.border.visible = this.hover || this.layer.highlightObjects;
}
/* -------------------------------------------- */
/**
* Compute the field-of-vision for an object, determining its effective line-of-sight and field-of-vision polygons
* @param {object} [options={}] Options which modify how the audio source is updated
* @param {boolean} [options.defer] Defer updating perception to manually update it later
* @param {boolean} [options.deleted] Indicate that this SoundSource has been deleted.
*/
updateSource({defer=false, deleted=false}={}) {
// Remove the audio source from the Scene
if ( deleted ) {
this.layer.sources.delete(this.sourceId);
}
// Update the source and add it to the Scene
else {
this.source.initialize({
x: this.document.x,
y: this.document.y,
radius: Math.clamped(this.radius, 0, canvas.dimensions.maxR),
walls: this.document.walls,
z: this.document.getFlag("core", "priority") ?? null,
disabled: !this.isAudible,
preview: this.isPreview
});
this.layer.sources.set(this.sourceId, this.source);
}
// Schedule a perception refresh, unless that operation is deferred for some later workflow
if ( !defer ) canvas.perception.update({refreshSounds: true});
if ( this.layer.active ) this.renderFlags.set({refreshField: true});
}
/* -------------------------------------------- */
/* Document Event Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
_onCreate(data, options, userId) {
super._onCreate(data, options, userId);
this.updateSource();
}
/* -------------------------------------------- */
/** @override */
_onUpdate(data, options, userId) {
super._onUpdate(data, options, userId);
// Change the Sound buffer
if ( "path" in data ) {
if ( this.sound ) this.sound.stop();
this.sound = this.#createSound();
}
// Re-initialize SoundSource
this.updateSource();
// Incremental Refresh
this.renderFlags.set({
refreshField: ["x", "y", "radius", "darkness", "walls"].some(k => k in data),
refreshState: ["path", "hidden", "darkness"].some(k => k in data)
});
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDelete(options, userId) {
// Stop audio playback
if ( this.sound ) {
if ( !this.sound.loaded && (this.sound.loading instanceof Promise) ) {
this.sound.loading.then(() => this.sound.stop());
}
else this.sound.stop();
}
// Decommission the SoundSource
this.updateSource({deleted: true});
super._onDelete(options, userId);
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
/** @inheritdoc */
_canHUD(user, event) {
return user.isGM; // Allow GMs to single right-click
}
/* -------------------------------------------- */
/** @inheritdoc */
_canConfigure(user, event) {
return false; // Double-right does nothing
}
/* -------------------------------------------- */
/** @override */
_onClickRight(event) {
this.document.update({hidden: !this.document.hidden});
}
/* -------------------------------------------- */
/** @override */
_onDragLeftMove(event) {
canvas._onDragCanvasPan(event);
const {clones, destination, origin} = event.interactionData;
const dx = destination.x - origin.x;
const dy = destination.y - origin.y;
for ( let c of clones || [] ) {
c.document.x = c._original.document.x + dx;
c.document.y = c._original.document.y + dy;
c.updateSource();
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragEnd() {
this.updateSource({deleted: true});
this._original?.updateSource();
super._onDragEnd();
}
}
/**
* A type of Placeable Object which highlights an area of the grid as covered by some area of effect.
* @category - Canvas
* @see {@link MeasuredTemplateDocument}
* @see {@link TemplateLayer}
*/
class MeasuredTemplate extends PlaceableObject {
/**
* The geometry shape used for testing point intersection
* @type {PIXI.Circle | PIXI.Ellipse | PIXI.Polygon | PIXI.Rectangle | PIXI.RoundedRectangle}
*/
shape;
/**
* The tiling texture used for this template, if any
* @type {PIXI.Texture}
*/
texture;
/**
* The template graphics
* @type {PIXI.Graphics}
*/
template;
/**
* The template control icon
* @type {ControlIcon}
*/
controlIcon;
/**
* The measurement ruler label
* @type {PreciseText}
*/
ruler;
/**
* Internal property used to configure the control border thickness
* @type {number}
* @protected
*/
_borderThickness = 3;
/** @inheritdoc */
static embeddedName = "MeasuredTemplate";
/** @override */
static RENDER_FLAGS = {
redraw: {propagate: ["refresh"]},
refresh: {propagate: ["refreshState", "refreshShape"], alias: true},
refreshState: {},
refreshShape: {propagate: ["refreshPosition", "refreshGrid", "refreshText", "refreshTemplate"]},
refreshTemplate: {},
refreshPosition: {propagate: ["refreshGrid"]},
refreshGrid: {},
refreshText: {}
};
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/** @inheritdoc */
get bounds() {
const {x, y} = this.document;
const d = canvas.dimensions;
const r = this.document.distance * (d.size / d.distance);
return new PIXI.Rectangle(x-r, y-r, 2*r, 2*r);
}
/* -------------------------------------------- */
/**
* A convenience accessor for the border color as a numeric hex code
* @returns {number}
*/
get borderColor() {
return this.document.borderColor ? Color.fromString(this.document.borderColor).valueOf() : 0x000000;
}
/* -------------------------------------------- */
/**
* A convenience accessor for the fill color as a numeric hex code
* @returns {number}
*/
get fillColor() {
return this.document.fillColor ? Color.fromString(this.document.fillColor).valueOf() : 0x000000;
}
/* -------------------------------------------- */
/**
* A flag for whether the current User has full ownership over the MeasuredTemplate document.
* @type {boolean}
*/
get owner() {
return this.document.isOwner;
}
/* -------------------------------------------- */
/**
* Is this MeasuredTemplate currently visible on the Canvas?
* @type {boolean}
*/
get isVisible() {
return !this.document.hidden || this.owner;
}
/* -------------------------------------------- */
/**
* A unique identifier which is used to uniquely identify related objects like a template effect or grid highlight.
* @type {string}
*/
get highlightId() {
return this.objectId;
}
/* -------------------------------------------- */
/* Initial Drawing */
/* -------------------------------------------- */
/** @override */
async _draw() {
// Load Fill Texture
if ( this.document.texture ) {
this.texture = await loadTexture(this.document.texture, {fallback: "icons/svg/hazard.svg"});
} else {
this.texture = null;
}
// Template Shape
this.template = this.addChild(new PIXI.Graphics());
// Control Icon
this.controlIcon = this.addChild(this.#createControlIcon());
await this.controlIcon.draw();
// Ruler Text
this.ruler = this.addChild(this.#drawRulerText());
// Enable highlighting for this template
canvas.grid.addHighlightLayer(this.highlightId);
}
/* -------------------------------------------- */
/**
* Draw the ControlIcon for the MeasuredTemplate
* @returns {ControlIcon}
*/
#createControlIcon() {
const size = Math.max(Math.round((canvas.dimensions.size * 0.5) / 20) * 20, 40);
let icon = new ControlIcon({texture: CONFIG.controlIcons.template, size: size});
icon.x -= (size * 0.5);
icon.y -= (size * 0.5);
return icon;
}
/* -------------------------------------------- */
/**
* Draw the Text label used for the MeasuredTemplate
* @returns {PreciseText}
*/
#drawRulerText() {
const style = CONFIG.canvasTextStyle.clone();
style.fontSize = Math.max(Math.round(canvas.dimensions.size * 0.36 * 12) / 12, 36);
const text = new PreciseText(null, style);
text.anchor.set(0, 1);
return text;
}
/* -------------------------------------------- */
/** @override */
_destroy(options) {
canvas.grid.destroyHighlightLayer(this.highlightId);
this.texture?.destroy();
}
/* -------------------------------------------- */
/* Incremental Refresh */
/* -------------------------------------------- */
/** @override */
_applyRenderFlags(flags) {
if ( flags.refreshState ) this.#refreshState();
if ( flags.refreshPosition ) this.#refreshPosition();
if ( flags.refreshShape ) this.#refreshShape();
if ( flags.refreshTemplate ) this._refreshTemplate();
if ( flags.refreshGrid ) this.highlightGrid();
if ( flags.refreshText ) this._refreshRulerText();
}
/* -------------------------------------------- */
/**
* Refresh the displayed state of the MeasuredTemplate.
* This refresh occurs when the user interaction state changes.
*/
#refreshState() {
// Template Visibility
this.visible = this.isVisible && !this.hasPreview;
// Control Icon Visibility
const isHidden = this.document.hidden;
this.controlIcon.refresh({
visible: this.visible && this.layer.active && this.document.isOwner,
iconColor: isHidden ? 0xFF3300 : 0xFFFFFF,
borderColor: isHidden ? 0xFF3300 : 0xFF5500,
borderVisible: this.hover || this.layer.highlightObjects
});
// Alpha transparency
const alpha = isHidden ? 0.5 : 1;
this.template.alpha = alpha;
this.ruler.alpha = alpha;
const highlightLayer = canvas.grid.getHighlightLayer(this.highlightId);
highlightLayer.visible = this.visible;
highlightLayer.alpha = alpha;
this.alpha = this._getTargetAlpha();
// Ruler Visibility
this.ruler.visible = this.visible && this.layer.active;
}
/* -------------------------------------------- */
/** @override */
_getTargetAlpha() {
return this.isPreview ? 0.8 : 1.0;
}
/* -------------------------------------------- */
/**
* Refresh the position of the MeasuredTemplate
*/
#refreshPosition() {
let {x, y} = this.document;
this.position.set(x, y);
}
/* -------------------------------------------- */
/**
* Refresh the underlying geometric shape of the MeasuredTemplate.
*/
#refreshShape() {
let {x, y, direction, distance} = this.document;
distance *= canvas.dimensions.distancePixels;
direction = Math.toRadians(direction);
// Create a Ray from origin to endpoint
this.ray = Ray.fromAngle(x, y, direction, distance);
// Get the Template shape
this.shape = this._computeShape();
}
/* -------------------------------------------- */
/**
* Compute the geometry for the template using its document data.
* Subclasses can override this method to take control over how different shapes are rendered.
* @returns {PIXI.Circle|PIXI.Rectangle|PIXI.Polygon}
* @protected
*/
_computeShape() {
let {angle, width, t} = this.document;
const {angle: direction, distance} = this.ray;
width *= canvas.dimensions.distancePixels;
switch ( t ) {
case "circle":
return this.constructor.getCircleShape(distance);
case "cone":
return this.constructor.getConeShape(direction, angle, distance);
case "rect":
return this.constructor.getRectShape(direction, distance);
case "ray":
return this.constructor.getRayShape(direction, distance, width);
}
}
/* -------------------------------------------- */
/**
* Refresh the display of the template outline and shape.
* Subclasses may override this method to take control over how the template is visually rendered.
* @protected
*/
_refreshTemplate() {
const t = this.template.clear();
// Draw the Template outline
t.lineStyle(this._borderThickness, this.borderColor, 0.75).beginFill(0x000000, 0.0);
// Fill Color or Texture
if ( this.texture ) t.beginTextureFill({texture: this.texture});
else t.beginFill(0x000000, 0.0);
// Draw the shape
t.drawShape(this.shape);
// Draw origin and destination points
t.lineStyle(this._borderThickness, 0x000000)
.beginFill(0x000000, 0.5)
.drawCircle(0, 0, 6)
.drawCircle(this.ray.dx, this.ray.dy, 6)
.endFill();
}
/* -------------------------------------------- */
/**
* Get a Circular area of effect given a radius of effect
* @param {number} distance
* @returns {PIXI.Circle}
*/
static getCircleShape(distance) {
return new PIXI.Circle(0, 0, distance);
}
/* -------------------------------------------- */
/**
* Get a Conical area of effect given a direction, angle, and distance
* @param {number} direction
* @param {number} angle
* @param {number} distance
* @returns {PIXI.Polygon}
*/
static getConeShape(direction, angle, distance) {
angle = angle || 90;
const coneType = game.settings.get("core", "coneTemplateType");
// For round cones - approximate the shape with a ray every 3 degrees
let angles;
if ( coneType === "round" ) {
const da = Math.min(angle, 3);
angles = Array.fromRange(Math.floor(angle/da)).map(a => (angle/-2) + (a*da)).concat([angle/2]);
}
// For flat cones, direct point-to-point
else {
angles = [(angle/-2), (angle/2)];
distance /= Math.cos(Math.toRadians(angle/2));
}
// Get the cone shape as a polygon
const rays = angles.map(a => Ray.fromAngle(0, 0, direction + Math.toRadians(a), distance+1));
const points = rays.reduce((arr, r) => {
return arr.concat([r.B.x, r.B.y]);
}, [0, 0]).concat([0, 0]);
return new PIXI.Polygon(points);
}
/* -------------------------------------------- */
/**
* Get a Rectangular area of effect given a width and height
* @param {number} direction
* @param {number} distance
* @returns {PIXI.Rectangle}
*/
static getRectShape(direction, distance) {
let d = canvas.dimensions;
let r = Ray.fromAngle(0, 0, direction, distance);
let dx = Math.round(r.dx / (d.size / 2)) * (d.size / 2);
let dy = Math.round(r.dy / (d.size / 2)) * (d.size / 2);
return new PIXI.Rectangle(0, 0, dx, dy).normalize();
}
/* -------------------------------------------- */
/**
* Get a rotated Rectangular area of effect given a width, height, and direction
* @param {number} direction
* @param {number} distance
* @param {number} width
* @returns {PIXI.Polygon}
*/
static getRayShape(direction, distance, width) {
let up = Ray.fromAngle(0, 0, direction - Math.toRadians(90), (width / 2)+1);
let down = Ray.fromAngle(0, 0, direction + Math.toRadians(90), (width / 2)+1);
let l1 = Ray.fromAngle(up.B.x, up.B.y, direction, distance+1);
let l2 = Ray.fromAngle(down.B.x, down.B.y, direction, distance+1);
// Create Polygon shape and draw
const points = [down.B.x, down.B.y, up.B.x, up.B.y, l1.B.x, l1.B.y, l2.B.x, l2.B.y];
return new PIXI.Polygon(points);
}
/* -------------------------------------------- */
/**
* Update the displayed ruler tooltip text
* @protected
*/
_refreshRulerText() {
let text;
const {distance, t} = this.document;
let u = canvas.scene.grid.units;
if ( t === "rect" ) {
let d = canvas.dimensions;
let dx = Math.round(this.ray.dx) * (d.distance / d.size);
let dy = Math.round(this.ray.dy) * (d.distance / d.size);
let w = Math.round(dx * 10) / 10;
let h = Math.round(dy * 10) / 10;
text = `${w}${u} x ${h}${u}`;
} else {
let d = Math.round(distance * 10) / 10;
text = `${d}${u}`;
}
this.ruler.text = text;
this.ruler.position.set(this.ray.dx + 10, this.ray.dy + 5);
}
/* -------------------------------------------- */
/**
* Highlight the grid squares which should be shown under the area of effect
*/
highlightGrid() {
if ( !this.visible ) return;
// Clear the existing highlight layer
const grid = canvas.grid;
const hl = grid.getHighlightLayer(this.highlightId);
hl.clear();
if ( !this.isVisible ) return;
// Highlight colors
const border = this.borderColor;
const color = this.fillColor;
// If we are in grid-less mode, highlight the shape directly
if ( grid.type === CONST.GRID_TYPES.GRIDLESS ) {
const shape = this._getGridHighlightShape();
grid.grid.highlightGridPosition(hl, {border, color, shape});
}
// Otherwise, highlight specific grid positions
else {
const positions = this._getGridHighlightPositions();
for ( const {x, y} of positions ) {
grid.grid.highlightGridPosition(hl, {x, y, border, color});
}
}
}
/* -------------------------------------------- */
/**
* Get the shape to highlight on a Scene which uses grid-less mode.
* @returns {PIXI.Polygon|PIXI.Circle|PIXI.Rectangle}
* @protected
*/
_getGridHighlightShape() {
const shape = this.shape.clone();
if ( "points" in shape ) {
shape.points = shape.points.map((p, i) => {
if ( i % 2 ) return this.y + p;
else return this.x + p;
});
} else {
shape.x += this.x;
shape.y += this.y;
}
return shape;
}
/* -------------------------------------------- */
/**
* Get an array of points which define top-left grid spaces to highlight for square or hexagonal grids.
* @returns {Point[]}
* @protected
*/
_getGridHighlightPositions() {
const grid = canvas.grid.grid;
const d = canvas.dimensions;
const {x, y, distance} = this.document;
// Get number of rows and columns
const [maxRow, maxCol] = grid.getGridPositionFromPixels(d.width, d.height);
let nRows = Math.ceil(((distance * 1.5) / d.distance) / (d.size / grid.h));
let nCols = Math.ceil(((distance * 1.5) / d.distance) / (d.size / grid.w));
[nRows, nCols] = [Math.min(nRows, maxRow), Math.min(nCols, maxCol)];
// Get the offset of the template origin relative to the top-left grid space
const [tx, ty] = grid.getTopLeft(x, y);
const [row0, col0] = grid.getGridPositionFromPixels(tx, ty);
const [hx, hy] = [Math.ceil(grid.w / 2), Math.ceil(grid.h / 2)];
const isCenter = (x - tx === hx) && (y - ty === hy);
// Identify grid coordinates covered by the template Graphics
const positions = [];
for ( let r = -nRows; r < nRows; r++ ) {
for ( let c = -nCols; c < nCols; c++ ) {
const [gx, gy] = grid.getPixelsFromGridPosition(row0 + r, col0 + c);
const [testX, testY] = [(gx+hx) - x, (gy+hy) - y];
const contains = ((r === 0) && (c === 0) && isCenter ) || grid._testShape(testX, testY, this.shape);
if ( !contains ) continue;
positions.push({x: gx, y: gy});
}
}
return positions;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @override */
async rotate(angle, snap) {
const direction = this._updateRotation({angle, snap});
return this.document.update({direction});
}
/* -------------------------------------------- */
/* Document Event Handlers */
/* -------------------------------------------- */
/** @override */
_onUpdate(data, options, userId) {
super._onUpdate(data, options, userId);
// Full re-draw
const changed = new Set(Object.keys(data));
if ( changed.has("texture") ) return this.renderFlags.set({redraw: true});
// Incremental Refresh
this.renderFlags.set({
refreshState: changed.has("hidden"),
refreshShape: ["angle", "direction", "distance", "width", "t"].some(k => changed.has(k)),
refreshTemplate: changed.has("borderColor"),
refreshPosition: ["x", "y"].some(k => changed.has(k)),
refreshGrid: ["hidden", "borderColor", "fillColor"].some(k => changed.has(k))
});
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
/** @override */
_canControl(user, event) {
if ( !this.layer.active || this.isPreview ) return false;
return user.isGM || (user === this.document.user);
}
/** @inheritdoc */
_canHUD(user, event) {
return this.owner; // Allow template owners to right-click
}
/** @inheritdoc */
_canConfigure(user, event) {
return false; // Double-right does nothing
}
/** @override */
_canView(user, event) {
return this._canControl(user, event);
}
/** @inheritdoc */
_onClickRight(event) {
this.document.update({hidden: !this.document.hidden});
}
}
/**
* A Tile is an implementation of PlaceableObject which represents a static piece of artwork or prop within the Scene.
* Tiles are drawn inside the {@link TilesLayer} container.
* @category - Canvas
*
* @see {@link TileDocument}
* @see {@link TilesLayer}
*/
class Tile extends PlaceableObject {
/* -------------------------------------------- */
/* Attributes */
/* -------------------------------------------- */
/** @inheritdoc */
static embeddedName = "Tile";
/** @override */
static RENDER_FLAGS = {
redraw: {propagate: ["refresh"]},
refresh: {propagate: ["refreshState", "refreshShape", "refreshElevation", "refreshVideo"],
alias: true},
refreshState: {propagate: ["refreshFrame"]},
refreshShape: {propagate: ["refreshMesh", "refreshPerception", "refreshFrame"]},
refreshMesh: {},
refreshFrame: {},
refreshElevation: {propagate: ["refreshMesh"]},
refreshPerception: {},
refreshVideo: {},
};
/**
* The Tile border frame
* @extends {PIXI.Container}
* @property {PIXI.Graphics} border
* @property {ResizeHandle} handle
*/
frame;
/**
* The primary tile image texture
* @type {PIXI.Texture}
*/
texture;
/**
* The Tile image sprite
* @type {PIXI.Sprite}
*/
tile;
/**
* A Tile background which is displayed if no valid image texture is present
* @type {PIXI.Graphics}
*/
bg;
/**
* A flag which tracks if the Tile is currently playing
* @type {boolean}
*/
playing = false;
/**
* The true computed bounds of the Tile.
* These true bounds are padded when the Tile is controlled to assist with interaction.
* @type {PIXI.Rectangle}
*/
#bounds;
/**
* A flag to capture whether this Tile has an unlinked video texture
* @type {boolean}
*/
#unlinkedVideo = false;
/**
* Video options passed by the HUD
* @type {object}
*/
#hudVideoOptions = {
playVideo: undefined,
offset: undefined
};
/**
* Keep track the roof state so that we know when it has changed.
* @type {boolean}
*/
#wasRoof = this.isRoof;
/* -------------------------------------------- */
/**
* Get the native aspect ratio of the base texture for the Tile sprite
* @type {number}
*/
get aspectRatio() {
if ( !this.texture ) return 1;
let tex = this.texture.baseTexture;
return (tex.width / tex.height);
}
/* -------------------------------------------- */
/** @override */
get bounds() {
let {x, y, width, height, texture, rotation} = this.document;
// Adjust top left coordinate and dimensions according to scale
if ( texture.scaleX !== 1 ) {
const w0 = width;
width *= Math.abs(texture.scaleX);
x += (w0 - width) / 2;
}
if ( texture.scaleY !== 1 ) {
const h0 = height;
height *= Math.abs(texture.scaleY);
y += (h0 - height) / 2;
}
// If the tile is rotated, return recomputed bounds according to rotation
if ( rotation !== 0 ) return PIXI.Rectangle.fromRotation(x, y, width, height, Math.toRadians(rotation)).normalize();
// Normal case
return new PIXI.Rectangle(x, y, width, height).normalize();
}
/* -------------------------------------------- */
/**
* The HTML source element for the primary Tile texture
* @type {HTMLImageElement|HTMLVideoElement}
*/
get sourceElement() {
return this.texture?.baseTexture.resource.source;
}
/* -------------------------------------------- */
/**
* Does this Tile depict an animated video texture?
* @type {boolean}
*/
get isVideo() {
const source = this.sourceElement;
return source?.tagName === "VIDEO";
}
/* -------------------------------------------- */
/**
* Is this tile a roof?
* @returns {boolean}
*/
get isRoof() {
return this.document.overhead && this.document.roof;
}
/* -------------------------------------------- */
/**
* Is this tile occluded?
* @returns {boolean}
*/
get occluded() {
return this.mesh?.occluded ?? false;
}
/* -------------------------------------------- */
/**
* The effective volume at which this Tile should be playing, including the global ambient volume modifier
* @type {number}
*/
get volume() {
return this.document.video.volume * game.settings.get("core", "globalAmbientVolume");
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* Debounce assignment of the Tile occluded state to avoid cases like animated token movement which can rapidly
*/
debounceSetOcclusion = occluded => this.mesh?.debounceOcclusion(occluded);
/* -------------------------------------------- */
/**
* Create a preview tile with a background texture instead of an image
* @param {object} data Initial data with which to create the preview Tile
* @returns {PlaceableObject}
*/
static createPreview(data) {
data.width = data.height = 1;
data.overhead = data.overhead ?? ui.controls.control.foreground ?? false;
// Create a pending TileDocument
const cls = getDocumentClass("Tile");
const doc = new cls(data, {parent: canvas.scene});
// Render the preview Tile object
const tile = doc.object;
tile.control({releaseOthers: false});
tile.draw().then(() => { // Swap the z-order of the tile and the frame
tile.removeChild(tile.frame);
tile.addChild(tile.frame);
});
return tile;
}
/* -------------------------------------------- */
/** @override */
async _draw(options={}) {
// Load Tile texture
let texture;
if ( this._original ) texture = this._original.texture?.clone();
else if ( this.document.texture.src ) {
texture = await loadTexture(this.document.texture.src, {fallback: "icons/svg/hazard.svg"});
}
// Manage video playback and clone texture for unlinked video
let video = game.video.getVideoSource(texture);
this.#unlinkedVideo = !!video && !this._original;
if ( this.#unlinkedVideo ) {
texture = await game.video.cloneTexture(video);
video = game.video.getVideoSource(texture);
if ( (this.document.getFlag("core", "randomizeVideo") !== false) && Number.isFinite(video.duration) ) {
video.currentTime = Math.random() * video.duration;
}
}
if ( !video ) this.#hudVideoOptions.playVideo = undefined;
this.#hudVideoOptions.offset = undefined;
this.texture = texture;
// Draw the Token mesh
if ( this.texture ) {
this.mesh = canvas.primary.addTile(this);
this.bg = undefined;
}
// Draw a placeholder background
else {
canvas.primary.removeTile(this);
this.texture = this.mesh = null;
this.bg = this.addChild(new PIXI.Graphics());
}
// Create the outer frame for the border and interaction handles
this.frame = this.addChild(new PIXI.Container());
this.frame.border = this.frame.addChild(new PIXI.Graphics());
this.frame.handle = this.frame.addChild(new ResizeHandle([1, 1]));
// Interactivity
this.cursor = "pointer";
}
/* -------------------------------------------- */
/** @inheritdoc */
clear(options) {
if ( this.#unlinkedVideo ) this.texture?.baseTexture?.destroy(); // Base texture destroyed for non preview video
this.#unlinkedVideo = false;
super.clear(options);
}
/* -------------------------------------------- */
/** @inheritdoc */
_destroy(options) {
canvas.primary.removeTile(this);
if ( this.texture ) {
if ( this.#unlinkedVideo ) this.texture?.baseTexture?.destroy(); // Base texture destroyed for non preview video
this.texture = undefined;
this.#unlinkedVideo = false;
}
canvas.perception.update({
refreshTiles: true,
identifyInteriorWalls: (this.isRoof || this.#wasRoof) && !this.isPreview
});
}
/* -------------------------------------------- */
/* Incremental Refresh */
/* -------------------------------------------- */
/** @override */
_applyRenderFlags(flags) {
if ( flags.refreshShape ) this.#refreshShape();
if ( flags.refreshFrame ) this.#refreshFrame();
if ( flags.refreshElevation ) this.#refreshElevation();
if ( flags.refreshVideo ) this.#refreshVideo();
if ( flags.refreshState ) this.#refreshState();
if ( flags.refreshMesh ) this.#refreshMesh();
if ( flags.refreshPerception ) this.#refreshPerception();
}
/* -------------------------------------------- */
/**
* Refresh the Primary Canvas Object associated with this tile.
*/
#refreshMesh() {
if ( !this.mesh ) return;
this.mesh.initialize(this.document);
this.mesh.alpha = Math.min(this.mesh.alpha, this.alpha);
}
/* -------------------------------------------- */
/**
* Refresh the displayed state of the Tile.
* Updated when the tile interaction state changes, when it is hidden, or when it changes overhead state.
*/
#refreshState() {
const {hidden, locked} = this.document;
this.visible = !hidden || game.user.isGM;
this.alpha = this._getTargetAlpha();
this.frame.border.visible = this.controlled || this.hover || this.layer.highlightObjects;
this.frame.handle.visible = this.controlled && !locked;
this.mesh?.initialize({hidden});
}
/* -------------------------------------------- */
/**
* Refresh the displayed shape and bounds of the Tile.
* Called when the tile location, size, rotation, or other visible attributes are modified.
*/
#refreshShape() {
const {x, y, width, height, rotation} = this.document;
// Compute true bounds
const aw = Math.abs(width);
const ah = Math.abs(height);
const r = Math.toRadians(rotation);
this.#bounds = (aw === ah)
? new PIXI.Rectangle(0, 0, aw, ah) // Square tiles
: PIXI.Rectangle.fromRotation(0, 0, aw, ah, r); // Non-square tiles
this.#bounds.normalize();
// TODO: Temporary FIX for the quadtree (The HitArea need a local bound => w and h, while the quadtree need global bounds)
// TODO: We need an easy way to get local and global bounds for every placeable object
const globalBounds = new PIXI.Rectangle(x, y, aw + x, ah + y);
// Set position
this.position.set(x, y);
// Refresh hit area
this.hitArea = this.#bounds.clone().pad(20);
// Refresh temporary background
if ( !this.mesh && this.bg ) this.bg.clear().beginFill(0xFFFFFF, 0.5).drawRect(0, 0, aw, ah).endFill();
}
/* -------------------------------------------- */
/**
* Update sorting of this Tile relative to other PrimaryCanvasGroup siblings.
* Called when the elevation or sort order for the Tile changes.
*/
#refreshElevation() {
this.zIndex = this.document.sort;
this.parent.sortDirty = true;
}
/* -------------------------------------------- */
/**
* Update interior wall states.
* Refresh lighting and vision to reflect changes in overhead tiles.
*/
#refreshPerception() {
const wasRoof = this.#wasRoof;
const isRoof = this.#wasRoof = this.isRoof;
canvas.perception.update({
refreshTiles: true,
identifyInteriorWalls: (isRoof || wasRoof) && !this.isPreview
});
}
/* -------------------------------------------- */
/**
* Refresh the border frame that encloses the Tile.
*/
#refreshFrame() {
const border = this.frame.border;
const b = this.#bounds;
// Determine border color
const colors = CONFIG.Canvas.dispositionColors;
let bc = colors.INACTIVE;
if ( this.controlled ) {
bc = this.document.locked ? colors.HOSTILE : colors.CONTROLLED;
}
// Draw the tile border
const t = CONFIG.Canvas.objectBorderThickness;
const h = Math.round(t / 2);
const o = Math.round(h / 2);
border.clear()
.lineStyle(t, 0x000000, 1.0).drawRoundedRect(b.x - o, b.y - o, b.width + h, b.height + h, 3)
.lineStyle(h, bc, 1.0).drawRoundedRect(b.x - o, b.y - o, b.width + h, b.height + h, 3);
// Refresh drag handle
this._refreshHandle();
}
/* -------------------------------------------- */
/**
* Refresh the display of the Tile resizing handle.
* Shift the position of the drag handle from the bottom-right (default) depending on which way we are dragging.
* @protected
*/
_refreshHandle() {
let b = this.#bounds.clone();
if ( this._dragHandle ) {
const {scaleX, scaleY} = this.document.texture;
if ( Math.sign(scaleX) === Math.sign(this._dragScaleX) ) b.width = b.x;
if ( Math.sign(scaleY) === Math.sign(this._dragScaleY) ) b.height = b.y;
}
this.frame.handle.refresh(b);
}
/* -------------------------------------------- */
/**
* Refresh changes to the video playback state.
*/
#refreshVideo() {
if ( !this.texture || !this.#unlinkedVideo ) return;
const video = game.video.getVideoSource(this.texture);
if ( !video ) return;
const playOptions = {...this.document.video, volume: this.volume};
playOptions.playing = this.playing = (this.#hudVideoOptions.playVideo ?? playOptions.autoplay);
playOptions.offset = this.#hudVideoOptions.offset;
this.#hudVideoOptions.offset = undefined;
game.video.play(video, playOptions);
// Refresh HUD if necessary
if ( this.layer.hud.object === this ) this.layer.hud.render();
}
/* -------------------------------------------- */
/* Document Event Handlers */
/* -------------------------------------------- */
/** @override */
_onUpdate(data, options, userId) {
super._onUpdate(data, options, userId);
if ( this.layer.hud.object === this ) this.layer.hud.render();
// Video options from the HUD
this.#hudVideoOptions.playVideo = options.playVideo;
this.#hudVideoOptions.offset = options.offset;
const keys = Object.keys(foundry.utils.flattenObject(data));
const changed = new Set(keys);
// Full re-draw
if ( changed.has("texture.src") ) return this.renderFlags.set({redraw: true});
// Incremental Refresh
const shapeChange = ["width", "height", "texture.scaleX", "texture.scaleY"].some(k => changed.has(k));
const positionChange = ["x", "y", "rotation"].some(k => changed.has(k));
const overheadChange = ["overhead", "roof", "z"].some(k => changed.has(k));
this.renderFlags.set({
refreshState: ["hidden", "locked"].some(k => changed.has(k)),
refreshShape: positionChange || shapeChange,
refreshMesh: ("texture" in data) || ("alpha" in data) || overheadChange || ("occlusion" in data),
refreshElevation: overheadChange,
refreshPerception: overheadChange || changed.has("occlusion.mode") || changed.has("hidden"),
refreshVideo: ("video" in data) || ("playVideo" in options) || ("offset" in options)
});
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners() {
super.activateListeners();
this.frame.handle.off("pointerover").off("pointerout").off("pointerdown")
.on("pointerover", this._onHandleHoverIn.bind(this))
.on("pointerout", this._onHandleHoverOut.bind(this))
.on("pointerdown", this._onHandleMouseDown.bind(this));
this.frame.handle.eventMode = "static";
}
/* -------------------------------------------- */
/** @inheritdoc */
_canConfigure(user, event) {
if ( this.document.locked && !this.controlled ) return false;
return super._canConfigure(user);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickLeft(event) {
if ( this._dragHandle ) return event.stopPropagation();
return super._onClickLeft(event);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickLeft2(event) {
this._dragHandle = false;
return super._onClickLeft2(event);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftStart(event) {
if ( this._dragHandle ) return this._onHandleDragStart(event);
return super._onDragLeftStart(event);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftMove(event) {
if ( this._dragHandle ) return this._onHandleDragMove(event);
if ( this._dragPassthrough ) return canvas._onDragLeftMove(event);
const {clones, destination, origin} = event.interactionData;
const dx = destination.x - origin.x;
const dy = destination.y - origin.y;
for ( let c of clones || [] ) {
c.document.x = c._original.document.x + dx;
c.document.y = c._original.document.y + dy;
c.mesh?.setPosition();
}
return super._onDragLeftMove(event);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDragLeftDrop(event) {
if ( this._dragHandle ) return await this._onHandleDragDrop(event);
return await super._onDragLeftDrop(event);
}
/* -------------------------------------------- */
/* Resize Handling */
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftCancel(event) {
if ( this._dragHandle ) return this._onHandleDragCancel(event);
return super._onDragLeftCancel(event);
}
/* -------------------------------------------- */
/**
* 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);
}
/* -------------------------------------------- */
/**
* When clicking the resize handle, initialize the handle properties.
* @param {PIXI.FederatedEvent} event The mousedown event
* @protected
*/
_onHandleMouseDown(event) {
if ( !this.document.locked ) {
this._dragHandle = true;
this._dragScaleX = this.document.texture.scaleX * -1;
this._dragScaleY = this.document.texture.scaleY * -1;
}
}
/* -------------------------------------------- */
/**
* Handle the beginning of a drag event on a resize handle.
* @param {PIXI.FederatedEvent} event The mousedown event
* @protected
*/
_onHandleDragStart(event) {
const handle = this.frame.handle;
const aw = this.document.width;
const ah = this.document.height;
const x0 = this.document.x + (handle.offset[0] * aw);
const y0 = this.document.y + (handle.offset[1] * ah);
event.interactionData.origin = {x: x0, y: y0, width: aw, height: ah};
}
/* -------------------------------------------- */
/**
* Handle mousemove while dragging a tile scale handler
* @param {PIXI.FederatedEvent} event The mousemove event
* @protected
*/
_onHandleDragMove(event) {
canvas._onDragCanvasPan(event);
const d = this.#getResizedDimensions(event);
this.document.x = d.x;
this.document.y = d.y;
this.document.width = d.width;
this.document.height = d.height;
this.document.rotation = 0;
// Mirror horizontally or vertically
this.document.texture.scaleX = d.sx;
this.document.texture.scaleY = d.sy;
this.renderFlags.set({refreshShape: true});
}
/* -------------------------------------------- */
/**
* Handle mouseup after dragging a tile scale handler
* @param {PIXI.FederatedEvent} event The mouseup event
* @protected
*/
_onHandleDragDrop(event) {
event.interactionData.resetDocument = false;
if ( !event.shiftKey ) {
const destination = event.interactionData.destination;
event.interactionData.destination =
canvas.grid.getSnappedPosition(destination.x, destination.y, this.layer.gridPrecision);
}
const d = this.#getResizedDimensions(event);
return this.document.update({
x: d.x, y: d.y, width: d.width, height: d.height, "texture.scaleX": d.sx, "texture.scaleY": d.sy
});
}
/* -------------------------------------------- */
/**
* Get resized Tile dimensions
* @param {PIXI.FederatedEvent} event
* @returns {Rectangle}
*/
#getResizedDimensions(event) {
const o = this.document._source;
const {origin, destination} = event.interactionData;
// Identify the new width and height as positive dimensions
const dx = destination.x - origin.x;
const dy = destination.y - origin.y;
let w = Math.abs(o.width) + dx;
let h = Math.abs(o.height) + dy;
// Constrain the aspect ratio using the ALT key
if ( event.altKey && this.texture?.valid ) {
const ar = this.texture.width / this.texture.height;
if ( Math.abs(w) > Math.abs(h) ) h = w / ar;
else w = h * ar;
}
const nr = new PIXI.Rectangle(o.x, o.y, w, h).normalize();
// Comparing destination coord and source coord to apply mirroring and append to nr
nr.sx = (Math.sign(destination.x - o.x) || 1) * o.texture.scaleX;
nr.sy = (Math.sign(destination.y - o.y) || 1) * o.texture.scaleY;
return nr;
}
/* -------------------------------------------- */
/**
* Handle cancellation of a drag event for one of the resizing handles
* @param {PIXI.FederatedEvent} event The mouseup event
* @protected
*/
_onHandleDragCancel(event) {
this._dragHandle = false;
if ( event.interactionData.resetDocument !== false ) {
this.document.reset();
this.renderFlags.set({refreshShape: true});
}
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
// eslint-disable-next-line no-dupe-class-members
get tile() {
foundry.utils.logCompatibilityWarning("Tile#tile has been renamed to Tile#mesh.", {since: 10, until: 12});
return this.mesh;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
testOcclusion(...args) {
const msg = "Tile#testOcclusion has been deprecated in favor of PrimaryCanvasObject#testOcclusion"
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this.mesh?.testOcclusion(...args) ?? false;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
containsPixel(...args) {
const msg = "Tile#containsPixel has been deprecated in favor of PrimaryCanvasObject#containsPixel"
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this.mesh?.containsPixel(...args) ?? false;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
getPixelAlpha(...args) {
const msg = "Tile#getPixelAlpha has been deprecated in favor of PrimaryCanvasObject#getPixelAlpha"
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this.mesh?.getPixelAlpha(...args) ?? null;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
_getAlphaBounds() {
const msg = "Tile#_getAlphaBounds has been deprecated in favor of PrimaryCanvasObject#_getAlphaBounds"
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this.mesh?._getAlphaBounds();
}
}
/**
* A Token is an implementation of PlaceableObject which represents an Actor within a viewed Scene on the game canvas.
* @category - Canvas
* @see {TokenDocument}
* @see {TokenLayer}
*/
class Token extends PlaceableObject {
constructor(document) {
super(document);
this.#initialize();
}
/** @inheritdoc */
static embeddedName = "Token";
/** @override */
static RENDER_FLAGS = {
redraw: {propagate: ["refresh"]},
redrawEffects: {},
refresh: {propagate: ["refreshState", "refreshSize", "refreshPosition", "refreshElevation", "refreshBars",
"refreshNameplate", "refreshBorder", "refreshShader"], alias: true},
refreshState: {propagate: ["refreshVisibility", "refreshBorder"]},
refreshSize: {propagate: ["refreshMesh", "refreshBorder", "refreshBars", "refreshPosition", "refreshTarget", "refreshEffects"]},
refreshPosition: {propagate: ["refreshMesh", "refreshVisibility"]},
refreshElevation: {propagate: ["refreshMesh"]},
refreshVisibility: {},
refreshEffects: {},
refreshMesh: {},
refreshShader: {},
refreshBars: {},
refreshNameplate: {},
refreshBorder: {},
refreshTarget: {}
};
/**
* Defines the filter to use for detection.
* @param {PIXI.Filter|null} filter
*/
detectionFilter = null;
/**
* A Graphics instance which renders the border frame for this Token inside the GridLayer.
* @type {PIXI.Graphics}
*/
border;
/**
* Track the set of User documents which are currently targeting this Token
* @type {Set<User>}
*/
targeted = new Set([]);
/**
* A reference to the SpriteMesh which displays this Token in the PrimaryCanvasGroup.
* @type {TokenMesh}
*/
mesh;
/**
* A reference to the VisionSource object which defines this vision source area of effect
* @type {VisionSource}
*/
vision = new VisionSource({object: this});
/**
* A reference to the LightSource object which defines this light source area of effect
* @type {LightSource}
*/
light = new LightSource({object: this});
/**
* A reference to an animation that is currently in progress for this Token, if any
* @type {Promise|null}
* @internal
*/
_animation = null;
/**
* An Object which records the Token's prior velocity dx and dy.
* This can be used to determine which direction a Token was previously moving.
* @type {{dx: number, dy: number, ox: number, oy: number}}
*/
#priorMovement;
/**
* The Token central coordinate, adjusted for its most recent movement vector.
* @type {Point}
*/
#adjustedCenter;
/**
* @typedef {Point} TokenPosition
* @property {number} rotation The token's last valid rotation.
*/
/**
* The Token's most recent valid position and rotation.
* @type {TokenPosition}
*/
#validPosition;
/**
* A flag to capture whether this Token has an unlinked video texture
* @type {boolean}
*/
#unlinkedVideo = false;
/* -------------------------------------------- */
/**
* Establish an initial velocity of the token based on its direction of facing.
* Assume the Token made some prior movement towards the direction that it is currently facing.
*/
#initialize() {
// Initialize prior movement
const {x, y, rotation} = this.document;
const r = Ray.fromAngle(x, y, Math.toRadians(rotation + 90), canvas.dimensions.size);
// Initialize valid position
this.#validPosition = {x, y, rotation};
this.#priorMovement = {dx: r.dx, dy: r.dy, ox: Math.sign(r.dx), oy: Math.sign(r.dy)};
this.#adjustedCenter = this.getMovementAdjustedPoint(this.center);
}
/* -------------------------------------------- */
/* Permission Attributes
/* -------------------------------------------- */
/**
* A convenient reference to the Actor object associated with the Token embedded document.
* @returns {Actor|null}
*/
get actor() {
return this.document.actor;
}
/* -------------------------------------------- */
/**
* A convenient reference for whether the current User has full control over the Token document.
* @type {boolean}
*/
get owner() {
return this.document.isOwner;
}
get isOwner() {
return this.document.isOwner;
}
/* -------------------------------------------- */
/**
* A boolean flag for whether the current game User has observer permission for the Token
* @type {boolean}
*/
get observer() {
return game.user.isGM || !!this.actor?.testUserPermission(game.user, "OBSERVER");
}
/* -------------------------------------------- */
/**
* Is the HUD display active for this token?
* @returns {boolean}
*/
get hasActiveHUD() {
return this.layer.hud.object === this;
}
/* -------------------------------------------- */
/**
* Convenience access to the token's nameplate string
* @type {string}
*/
get name() {
return this.document.name;
}
/* -------------------------------------------- */
/* Rendering Attributes
/* -------------------------------------------- */
/** @override */
get bounds() {
const {x, y} = this.document;
return new PIXI.Rectangle(x, y, this.w, this.h);
}
/* -------------------------------------------- */
/**
* Translate the token's grid width into a pixel width based on the canvas size
* @type {number}
*/
get w() {
return canvas.grid.grid.getRect(this.document.width, this.document.height).width;
}
/* -------------------------------------------- */
/**
* Translate the token's grid height into a pixel height based on the canvas size
* @type {number}
*/
get h() {
return canvas.grid.grid.getRect(this.document.width, this.document.height).height;
}
/* -------------------------------------------- */
/**
* The Token's current central position
* @type {Point}
*/
get center() {
return this.getCenter(this.document.x, this.document.y);
}
/* -------------------------------------------- */
/**
* The Token's central position, adjusted in each direction by one or zero pixels to offset it relative to walls.
* @type {Point}
*/
getMovementAdjustedPoint(point, {offsetX, offsetY}={}) {
const x = Math.round(point.x);
const y = Math.round(point.y);
const r = new PIXI.Rectangle(x, y, 0, 0);
const walls = canvas.walls.quadtree.getObjects(r, {collisionTest: o => {
return foundry.utils.orient2dFast(o.t.A, o.t.B, {x, y}) === 0;
}});
if ( walls.size ) {
const {ox, oy} = this.#priorMovement;
return {x: x - (offsetX ?? ox), y: y - (offsetY ?? oy)};
}
return {x, y};
}
/* -------------------------------------------- */
/**
* The HTML source element for the primary Tile texture
* @type {HTMLImageElement|HTMLVideoElement}
*/
get sourceElement() {
return this.texture?.baseTexture.resource.source;
}
/* -------------------------------------------- */
/** @override */
get sourceId() {
let id = `${this.document.documentName}.${this.document.id}`;
if ( this.isPreview ) id += ".preview";
return id;
}
/* -------------------------------------------- */
/**
* Does this Tile depict an animated video texture?
* @type {boolean}
*/
get isVideo() {
const source = this.sourceElement;
return source?.tagName === "VIDEO";
}
/* -------------------------------------------- */
/* State Attributes
/* -------------------------------------------- */
/**
* An indicator for whether or not this token is currently involved in the active combat encounter.
* @type {boolean}
*/
get inCombat() {
return this.document.inCombat;
}
/* -------------------------------------------- */
/**
* Return a reference to a Combatant that represents this Token, if one is present in the current encounter.
* @type {Combatant|null}
*/
get combatant() {
return this.document.combatant;
}
/* -------------------------------------------- */
/**
* An indicator for whether the Token is currently targeted by the active game User
* @type {boolean}
*/
get isTargeted() {
return this.targeted.has(game.user);
}
/* -------------------------------------------- */
/**
* Return a reference to the detection modes array.
* @type {[object]}
*/
get detectionModes() {
return this.document.detectionModes;
}
/* -------------------------------------------- */
/**
* Determine whether the Token is visible to the calling user's perspective.
* Hidden Tokens are only displayed to GM Users.
* Non-hidden Tokens are always visible if Token Vision is not required.
* Controlled tokens are always visible.
* All Tokens are visible to a GM user if no Token is controlled.
*
* @see {CanvasVisibility#testVisibility}
* @type {boolean}
*/
get isVisible() {
// Clear the detection filter
this.detectionFilter = undefined;
// Only GM users can see hidden tokens
const gm = game.user.isGM;
if ( this.document.hidden && !gm ) return false;
// Some tokens are always visible
if ( !canvas.effects.visibility.tokenVision ) return true;
if ( this.controlled ) return true;
// Otherwise, test visibility against current sight polygons
if ( canvas.effects.visionSources.get(this.sourceId)?.active ) return true;
const tolerance = Math.min(this.w, this.h) / 4;
return canvas.effects.visibility.testVisibility(this.center, {tolerance, object: this});
}
/* -------------------------------------------- */
/**
* The animation name used for Token movement
* @type {string}
*/
get animationName() {
return `${this.objectId}.animate`;
}
/* -------------------------------------------- */
/* Lighting and Vision Attributes
/* -------------------------------------------- */
/**
* Test whether the Token has sight (or blindness) at any radius
* @type {boolean}
*/
get hasSight() {
return this.document.sight.enabled;
}
/* -------------------------------------------- */
/**
* Does this Token actively emit light given its properties and the current darkness level of the Scene?
* @type {boolean}
*/
get emitsLight() {
const {hidden, light} = this.document;
if ( hidden ) return false;
if ( !(light.dim || light.bright) ) return false;
const darkness = canvas.darknessLevel;
return darkness.between(light.darkness.min, light.darkness.max);
}
/* -------------------------------------------- */
/**
* Test whether the Token uses a limited angle of vision or light emission.
* @type {boolean}
*/
get hasLimitedSourceAngle() {
const doc = this.document;
return (this.hasSight && (doc.sight.angle !== 360)) || (this.emitsLight && (doc.light.angle !== 360));
}
/* -------------------------------------------- */
/**
* Translate the token's dim light distance in units into a radius in pixels.
* @type {number}
*/
get dimRadius() {
return this.getLightRadius(this.document.light.dim);
}
/* -------------------------------------------- */
/**
* Translate the token's bright light distance in units into a radius in pixels.
* @type {number}
*/
get brightRadius() {
return this.getLightRadius(this.document.light.bright);
}
/* -------------------------------------------- */
/**
* Translate the token's vision range in units into a radius in pixels.
* @type {number}
*/
get sightRange() {
return this.getLightRadius(this.document.sight.range);
}
/* -------------------------------------------- */
/**
* Translate the token's maximum vision range that takes into account lights.
* @type {number}
*/
get optimalSightRange() {
const r = Math.max(Math.abs(this.document.light.bright), Math.abs(this.document.light.dim));
return this.getLightRadius(Math.max(this.document.sight.range, r));
}
/* -------------------------------------------- */
/** @inheritDoc */
clone() {
const clone = super.clone();
clone.#priorMovement = this.#priorMovement;
clone.#validPosition = this.#validPosition;
return clone;
}
/* -------------------------------------------- */
/**
* Update the light and vision source objects associated with this Token.
* @param {object} [options={}] Options which configure how perception sources are updated
* @param {boolean} [options.defer=false] Defer updating perception to manually update it later
* @param {boolean} [options.deleted=false] Indicate that this light and vision source has been deleted
*/
updateSource({defer=false, deleted=false}={}) {
this.#adjustedCenter = this.getMovementAdjustedPoint(this.center);
this.updateLightSource({defer, deleted});
this.updateVisionSource({defer, deleted});
}
/* -------------------------------------------- */
/**
* Update an emitted light source associated with this Token.
* @param {object} [options={}]
* @param {boolean} [options.defer] Defer updating perception to manually update it later.
* @param {boolean} [options.deleted] Indicate that this light source has been deleted.
*/
updateLightSource({defer=false, deleted=false}={}) {
// Prepare data
const origin = this.#adjustedCenter;
const sourceId = this.sourceId;
const d = canvas.dimensions;
const isLightSource = this.emitsLight;
// Initialize a light source
if ( isLightSource && !deleted ) {
const lightConfig = foundry.utils.mergeObject(this.document.light.toObject(false), {
x: origin.x,
y: origin.y,
elevation: this.document.elevation,
dim: Math.clamped(this.getLightRadius(this.document.light.dim), 0, d.maxR),
bright: Math.clamped(this.getLightRadius(this.document.light.bright), 0, d.maxR),
externalRadius: this.externalRadius,
z: this.document.getFlag("core", "priority"),
seed: this.document.getFlag("core", "animationSeed"),
rotation: this.document.rotation,
preview: this.isPreview
});
this.light.initialize(lightConfig);
canvas.effects.lightSources.set(sourceId, this.light);
}
// Remove a light source
else deleted = canvas.effects.lightSources.delete(sourceId);
// Schedule a perception update
if ( !defer && (isLightSource || deleted) ) {
canvas.perception.update({
refreshLighting: true,
refreshVision: true
});
}
}
/* -------------------------------------------- */
/**
* Update the VisionSource instance associated with this Token.
* @param {object} [options] Options which affect how the vision source is updated
* @param {boolean} [options.defer] Defer updating perception to manually update it later.
* @param {boolean} [options.deleted] Indicate that this vision source has been deleted.
*/
updateVisionSource({defer=false, deleted=false}={}) {
// Prepare data
const origin = this.#adjustedCenter;
const sourceId = this.sourceId;
const d = canvas.dimensions;
const isVisionSource = this._isVisionSource();
let initializeVision = false;
// Initialize vision source
if ( isVisionSource && !deleted ) {
const previousVisionMode = this.vision.visionMode;
this.vision.initialize({
x: origin.x,
y: origin.y,
elevation: this.document.elevation,
radius: Math.clamped(this.sightRange, 0, d.maxR),
externalRadius: this.externalRadius,
angle: this.document.sight.angle,
contrast: this.document.sight.contrast,
saturation: this.document.sight.saturation,
brightness: this.document.sight.brightness,
attenuation: this.document.sight.attenuation,
rotation: this.document.rotation,
visionMode: this.document.sight.visionMode,
color: Color.from(this.document.sight.color),
blinded: this.document.hasStatusEffect(CONFIG.specialStatusEffects.BLIND),
preview: this.isPreview
});
if ( !canvas.effects.visionSources.has(sourceId) || (this.vision.visionMode !== previousVisionMode) ) {
initializeVision = true;
}
canvas.effects.visionSources.set(sourceId, this.vision);
}
// Remove vision source and deactivate current vision mode
else deleted = canvas.effects.visionSources.delete(sourceId);
if ( deleted ) {
initializeVision = true;
this.vision.visionMode?.deactivate(this.vision);
}
// Schedule a perception update
if ( !defer && (isVisionSource || deleted) ) {
canvas.perception.update({
refreshLighting: true,
refreshVision: true,
initializeVision
});
}
}
/* -------------------------------------------- */
/**
* Test whether this Token is a viable vision source for the current User
* @returns {boolean}
* @protected
*/
_isVisionSource() {
if ( !canvas.effects.visibility.tokenVision || !this.hasSight ) return false;
// Only display hidden tokens for the GM
const isGM = game.user.isGM;
if (this.document.hidden && !isGM) return false;
// Always display controlled tokens which have vision
if ( this.controlled ) return true;
// Otherwise, vision is ignored for GM users
if ( isGM ) return false;
// If a non-GM user controls no other tokens with sight, display sight
const canObserve = this.actor?.testUserPermission(game.user, "OBSERVER") ?? false;
if ( !canObserve ) return false;
const others = this.layer.controlled.filter(t => !t.document.hidden && t.hasSight);
return !others.length;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @override */
render(renderer) {
if ( this.detectionFilter ) this._renderDetectionFilter(renderer);
super.render(renderer);
}
/* -------------------------------------------- */
/**
* Render the bound mesh detection filter.
* Note: this method does not verify that the detection filter exists.
* @param {PIXI.Renderer} renderer
* @protected
*/
_renderDetectionFilter(renderer) {
if ( !this.mesh ) return;
// Pushing the detection filter on the mesh
this.mesh.filters ??= [];
this.mesh.filters.push(this.detectionFilter);
// Rendering the mesh
const originalTint = this.mesh.tint;
const originalAlpha = this.mesh.worldAlpha;
this.mesh.tint = 0xFFFFFF;
this.mesh.worldAlpha = 1;
this.mesh.pluginName = BaseSamplerShader.classPluginName;
this.mesh.render(renderer);
this.mesh.tint = originalTint;
this.mesh.worldAlpha = originalAlpha;
this.mesh.pluginName = null;
// Removing the detection filter on the mesh
this.mesh.filters.pop();
}
/* -------------------------------------------- */
/** @override */
clear() {
if ( this.mesh ) this.mesh.texture = PIXI.Texture.EMPTY;
if ( this.#unlinkedVideo ) this.texture?.baseTexture?.destroy(); // Destroy base texture if the token has an unlinked video
this.#unlinkedVideo = false;
if ( this.border ) this.border.visible = false;
if ( this.hasActiveHUD ) this.layer.hud.clear();
}
/* -------------------------------------------- */
/** @inheritdoc */
_destroy(options) {
this.stopAnimation(); // Cancel movement animations
canvas.primary.removeToken(this); // Remove the TokenMesh from the PrimaryCanvasGroup
this.border?.destroy(); // Remove the border Graphics from the GridLayer
this.light.destroy(); // Destroy the LightSource
this.vision.destroy(); // Destroy the VisionSource
if ( this.#unlinkedVideo ) this.texture?.baseTexture?.destroy(); // Destroy base texture if the token has an unlinked video
this.removeChildren().forEach(c => c.destroy({children: true}));
this.texture = undefined;
this.#unlinkedVideo = false;
}
/* -------------------------------------------- */
/** @override */
async _draw() {
this.#cleanData();
// Load token texture
let texture;
if ( this._original ) texture = this._original.texture?.clone();
else texture = await loadTexture(this.document.texture.src, {fallback: CONST.DEFAULT_TOKEN});
// Manage video playback
let video = game.video.getVideoSource(texture);
this.#unlinkedVideo = !!video && !this._original;
if ( this.#unlinkedVideo ) {
texture = await game.video.cloneTexture(video);
video = game.video.getVideoSource(texture);
const playOptions = {volume: 0};
if ( (this.document.getFlag("core", "randomizeVideo") !== false) && Number.isFinite(video.duration) ) {
playOptions.offset = Math.random() * video.duration;
}
game.video.play(video, playOptions);
}
this.texture = texture;
// Draw the TokenMesh in the PrimaryCanvasGroup
this.mesh = canvas.primary.addToken(this);
// Draw the border frame in the GridLayer
this.border ||= canvas.grid.borders.addChild(new PIXI.Graphics());
// Draw Token interface components
this.bars ||= this.addChild(this.#drawAttributeBars());
this.tooltip ||= this.addChild(this.#drawTooltip());
this.effects ||= this.addChild(new PIXI.Container());
this.target ||= this.addChild(new PIXI.Graphics());
this.nameplate ||= this.addChild(this.#drawNameplate());
// Draw elements
await this.drawEffects();
// Define initial interactivity and visibility state
this.hitArea = new PIXI.Rectangle(0, 0, this.w, this.h);
}
/* -------------------------------------------- */
/**
* Apply initial sanitizations to the provided input data to ensure that a Token has valid required attributes.
* Constrain the Token position to remain within the Canvas rectangle.
*/
#cleanData() {
if ( !canvas || !this.scene?.active ) return;
const d = canvas.dimensions;
this.document.x = Math.clamped(this.document.x, 0, d.width - this.w);
this.document.y = Math.clamped(this.document.y, 0, d.height - this.h);
}
/* -------------------------------------------- */
/**
* Draw resource bars for the Token
*/
#drawAttributeBars() {
const bars = new PIXI.Container();
bars.bar1 = bars.addChild(new PIXI.Graphics());
bars.bar2 = bars.addChild(new PIXI.Graphics());
return bars;
}
/* -------------------------------------------- */
/* Incremental Refresh */
/* -------------------------------------------- */
/** @override */
_applyRenderFlags(flags) {
if ( flags.refreshVisibility ) this._refreshVisibility();
if ( flags.refreshPosition ) this.#refreshPosition();
if ( flags.refreshElevation ) this.#refreshElevation();
if ( flags.refreshBars ) this.drawBars();
if ( flags.refreshNameplate ) this._refreshNameplate();
if ( flags.refreshBorder ) this._refreshBorder();
if ( flags.refreshSize ) this.#refreshSize();
if ( flags.refreshTarget ) this._refreshTarget();
if ( flags.refreshState ) this.#refreshState();
if ( flags.refreshMesh ) this._refreshMesh();
if ( flags.refreshShader ) this._refreshShader();
if ( flags.refreshEffects ) this._refreshEffects();
if ( flags.redrawEffects ) this.drawEffects();
}
/* -------------------------------------------- */
/**
* Refresh the visibility.
* @protected
*/
_refreshVisibility() {
this.visible = this.isVisible;
if ( this.border ) this.border.visible = this.visible && this.renderable
&& (this.controlled || this.hover || this.layer.highlightObjects)
&& !((this.document.disposition === CONST.TOKEN_DISPOSITIONS.SECRET) && !this.isOwner);
}
/* -------------------------------------------- */
/**
* Refresh aspects of the user interaction state.
* For example the border, nameplate, or bars may be shown on Hover or on Control.
*/
#refreshState() {
this.alpha = this._getTargetAlpha();
this.nameplate.visible = this._canViewMode(this.document.displayName);
this.bars.visible = this.actor && this._canViewMode(this.document.displayBars);
const activePointer = !((this.document.disposition === CONST.TOKEN_DISPOSITIONS.SECRET) && !this.isOwner);
this.cursor = activePointer ? "pointer" : null;
}
/* -------------------------------------------- */
/**
* Handle changes to the width or height of the Token base.
*/
#refreshSize() {
// Hit Area
this.hitArea.width = this.w;
this.hitArea.height = this.h;
// Nameplate and tooltip position
this.nameplate.position.set(this.w / 2, this.h + 2);
this.tooltip.position.set(this.w / 2, -2);
}
/* -------------------------------------------- */
/**
* Refresh position of the Token. Called when x/y coordinates change.
*/
#refreshPosition() {
this.position.set(this.document.x, this.document.y);
this.border.position.set(this.document.x, this.document.y);
}
/* -------------------------------------------- */
/**
* Refresh elevation of the Token. Called when its elevation or sort attributes change.
*/
#refreshElevation() {
canvas.primary.sortDirty = true;
// Elevation tooltip text
const tt = this._getTooltipText();
if ( tt !== this.tooltip.text ) this.tooltip.text = tt;
}
/* -------------------------------------------- */
/**
* Refresh the text content, position, and visibility of the Token nameplate.
* @protected
*/
_refreshNameplate() {
this.nameplate.text = this.document.name;
this.nameplate.visible = this._canViewMode(this.document.displayName);
}
/* -------------------------------------------- */
/**
* Refresh the token mesh.
* @protected
*/
_refreshMesh() {
this.mesh?.initialize(this.document);
if ( this.mesh ) this.mesh.alpha = Math.min(this.mesh.alpha, this.alpha);
}
/* -------------------------------------------- */
/**
* Refresh the token mesh shader.
* @protected
*/
_refreshShader() {
if ( !this.mesh ) return;
const isInvisible = this.document.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE);
this.mesh.setShaderClass(isInvisible ? TokenInvisibilitySamplerShader : InverseOcclusionSamplerShader);
}
/* -------------------------------------------- */
/**
* Draw the Token border, taking into consideration the grid type and border color
* @protected
*/
_refreshBorder() {
const b = this.border;
b.clear();
// Determine the desired border color
const borderColor = this._getBorderColor();
if ( !borderColor ) return;
// Draw Hex border for size 1 tokens on a hex grid
const t = CONFIG.Canvas.objectBorderThickness;
if ( canvas.grid.isHex ) {
const polygon = canvas.grid.grid.getBorderPolygon(this.document.width, this.document.height, t);
if ( polygon ) {
b.lineStyle(t, 0x000000, 0.8).drawPolygon(polygon);
b.lineStyle(t/2, borderColor, 1.0).drawPolygon(polygon);
}
}
// Otherwise, draw square border
else {
const h = Math.round(t/2);
const o = Math.round(h/2);
b.lineStyle(t, 0x000000, 0.8).drawRoundedRect(-o, -o, this.w+h, this.h+h, 3);
b.lineStyle(h, borderColor, 1.0).drawRoundedRect(-o, -o, this.w+h, this.h+h, 3);
}
}
/* -------------------------------------------- */
/**
* Get the hex color that should be used to render the Token border
* @param {object} [options]
* @param {boolean} [options.hover] Return a border color for this hover state, otherwise use the token's current
* state.
* @returns {number|null} The hex color used to depict the border color
* @protected
*/
_getBorderColor({hover}={}) {
const colors = CONFIG.Canvas.dispositionColors;
if ( this.controlled ) return colors.CONTROLLED;
else if ( (hover ?? this.hover) || this.layer.highlightObjects ) {
let d = this.document.disposition;
if ( !game.user.isGM && this.isOwner ) return colors.CONTROLLED;
else if ( this.actor?.hasPlayerOwner ) return colors.PARTY;
else if ( d === CONST.TOKEN_DISPOSITIONS.FRIENDLY ) return colors.FRIENDLY;
else if ( d === CONST.TOKEN_DISPOSITIONS.NEUTRAL ) return colors.NEUTRAL;
else if ( d === CONST.TOKEN_DISPOSITIONS.HOSTILE ) return colors.HOSTILE;
else if ( d === CONST.TOKEN_DISPOSITIONS.SECRET ) return this.isOwner ? colors.SECRET : null;
}
return null;
}
/* -------------------------------------------- */
/**
* @typedef {object} ReticuleOptions
* @property {number} [margin=0] The amount of margin between the targeting arrows and the token's bounding
* box, expressed as a fraction of an arrow's size.
* @property {number} [alpha=1] The alpha value of the arrows.
* @property {number} [size=0.15] The size of the arrows as a proportion of grid size.
* @property {number} [color=0xFF6400] The color of the arrows.
* @property {object} [border] The arrows' border style configuration.
* @property {number} [border.color=0] The border color.
* @property {number} [border.width=2] The border width.
*/
/**
* Refresh the target indicators for the Token.
* Draw both target arrows for the primary User and indicator pips for other Users targeting the same Token.
* @param {ReticuleOptions} [reticule] Additional parameters to configure how the targeting reticule is drawn.
* @protected
*/
_refreshTarget(reticule) {
this.target.clear();
// We don't show the target arrows for a secret token disposition and non-GM users
const isSecret = (this.document.disposition === CONST.TOKEN_DISPOSITIONS.SECRET) && !this.isOwner;
if ( !this.targeted.size || isSecret ) return;
// Determine whether the current user has target and any other users
const [others, user] = Array.from(this.targeted).partition(u => u === game.user);
// For the current user, draw the target arrows
if ( user.length ) this._drawTarget(reticule);
// For other users, draw offset pips
const hw = (this.w / 2) + (others.length % 2 === 0 ? 8 : 0);
for ( let [i, u] of others.entries() ) {
const offset = Math.floor((i+1) / 2) * 16;
const sign = i % 2 === 0 ? 1 : -1;
const x = hw + (sign * offset);
this.target.beginFill(Color.from(u.color), 1.0).lineStyle(2, 0x0000000).drawCircle(x, 0, 6);
}
}
/* -------------------------------------------- */
/**
* Draw the targeting arrows around this token.
* @param {ReticuleOptions} [reticule] Additional parameters to configure how the targeting reticule is drawn.
* @protected
*/
_drawTarget({margin: m=0, alpha=1, size=.15, color, border: {width=2, color: lineColor=0}={}}={}) {
const l = canvas.dimensions.size * size; // Side length.
const {h, w} = this;
const lineStyle = {color: lineColor, alpha, width, cap: PIXI.LINE_CAP.ROUND, join: PIXI.LINE_JOIN.BEVEL};
color ??= (this._getBorderColor({hover: true}) ?? CONFIG.Canvas.dispositionColors.NEUTRAL);
m *= l * -1;
this.target.beginFill(color, alpha).lineStyle(lineStyle)
.drawPolygon([-m, -m, -m-l, -m, -m, -m-l]) // Top left
.drawPolygon([w+m, -m, w+m+l, -m, w+m, -m-l]) // Top right
.drawPolygon([-m, h+m, -m-l, h+m, -m, h+m+l]) // Bottom left
.drawPolygon([w+m, h+m, w+m+l, h+m, w+m, h+m+l]); // Bottom right
}
/* -------------------------------------------- */
/**
* Refresh the display of Token attribute bars, rendering its latest resource data.
* If the bar attribute is valid (has a value and max), draw the bar. Otherwise hide it.
*/
drawBars() {
if ( !this.actor || (this.document.displayBars === CONST.TOKEN_DISPLAY_MODES.NONE) ) return;
["bar1", "bar2"].forEach((b, i) => {
const bar = this.bars[b];
const attr = this.document.getBarAttribute(b);
if ( !attr || (attr.type !== "bar") || (attr.max === 0) ) return bar.visible = false;
this._drawBar(i, bar, attr);
bar.visible = true;
});
}
/* -------------------------------------------- */
/**
* Draw a single resource bar, given provided data
* @param {number} number The Bar number
* @param {PIXI.Graphics} bar The Bar container
* @param {Object} data Resource data for this bar
* @protected
*/
_drawBar(number, bar, data) {
const val = Number(data.value);
const pct = Math.clamped(val, 0, data.max) / data.max;
// Determine sizing
let h = Math.max((canvas.dimensions.size / 12), 8);
const w = this.w;
const bs = Math.clamped(h / 8, 1, 2);
if ( this.document.height >= 2 ) h *= 1.6; // Enlarge the bar for large tokens
// Determine the color to use
const blk = 0x000000;
let color;
if ( number === 0 ) color = Color.fromRGB([(1-(pct/2)), pct, 0]);
else color = Color.fromRGB([(0.5 * pct), (0.7 * pct), 0.5 + (pct / 2)]);
// Draw the bar
bar.clear();
bar.beginFill(blk, 0.5).lineStyle(bs, blk, 1.0).drawRoundedRect(0, 0, this.w, h, 3);
bar.beginFill(color, 1.0).lineStyle(bs, blk, 1.0).drawRoundedRect(0, 0, pct*w, h, 2);
// Set position
let posY = number === 0 ? this.h - h : 0;
bar.position.set(0, posY);
return true;
}
/* -------------------------------------------- */
/**
* Draw the token's nameplate as a text object
* @returns {PIXI.Text} The Text object for the Token nameplate
*/
#drawNameplate() {
const style = this._getTextStyle();
const name = new PreciseText(this.document.name, style);
name.anchor.set(0.5, 0);
name.position.set(this.w / 2, this.h + 2);
return name;
}
/* -------------------------------------------- */
/**
* Draw a text tooltip for the token which can be used to display Elevation or a resource value
* @returns {PreciseText} The text object used to render the tooltip
*/
#drawTooltip() {
let text = this._getTooltipText();
const style = this._getTextStyle();
const tip = new PreciseText(text, style);
tip.anchor.set(0.5, 1);
tip.position.set(this.w / 2, -2);
return tip;
}
/* -------------------------------------------- */
/**
* Return the text which should be displayed in a token's tooltip field
* @returns {string}
* @protected
*/
_getTooltipText() {
let el = this.document.elevation;
if ( !Number.isFinite(el) || el === 0 ) return "";
let units = canvas.scene.grid.units;
return el > 0 ? `+${el} ${units}` : `${el} ${units}`;
}
/* -------------------------------------------- */
/**
* Get the text style that should be used for this Token's tooltip.
* @returns {string}
* @protected
*/
_getTextStyle() {
const style = CONFIG.canvasTextStyle.clone();
style.fontSize = 24;
if (canvas.dimensions.size >= 200) style.fontSize = 28;
else if (canvas.dimensions.size < 50) style.fontSize = 20;
style.wordWrapWidth = this.w * 2.5;
return style;
}
/* -------------------------------------------- */
/**
* Draw the active effects and overlay effect icons which are present upon the Token
*/
async drawEffects() {
const wasVisible = this.effects.visible;
this.effects.visible = false;
this.effects.removeChildren().forEach(c => c.destroy());
this.effects.bg = this.effects.addChild(new PIXI.Graphics());
this.effects.bg.visible = false;
this.effects.overlay = null;
// Categorize new effects
const tokenEffects = this.document.effects;
const actorEffects = this.actor?.temporaryEffects || [];
let overlay = {
src: this.document.overlayEffect,
tint: null
};
// Draw status effects
if ( tokenEffects.length || actorEffects.length ) {
const promises = [];
// Draw actor effects first
for ( let f of actorEffects ) {
if ( !f.icon ) continue;
const tint = Color.from(f.tint ?? null);
if ( f.getFlag("core", "overlay") ) {
if ( overlay ) promises.push(this._drawEffect(overlay.src, overlay.tint));
overlay = {src: f.icon, tint};
continue;
}
promises.push(this._drawEffect(f.icon, tint));
}
// Next draw token effects
for ( let f of tokenEffects ) promises.push(this._drawEffect(f, null));
await Promise.all(promises);
}
// Draw overlay effect
this.effects.overlay = await this._drawOverlay(overlay.src, overlay.tint);
this.effects.bg.visible = true;
this.effects.visible = wasVisible;
this._refreshEffects();
}
/* -------------------------------------------- */
/**
* Draw a status effect icon
* @param {string} src
* @param {number|null} tint
* @returns {Promise<PIXI.Sprite|undefined>}
* @protected
*/
async _drawEffect(src, tint) {
if ( !src ) return;
let tex = await loadTexture(src, {fallback: "icons/svg/hazard.svg"});
let icon = new PIXI.Sprite(tex);
if ( tint ) icon.tint = tint;
return this.effects.addChild(icon);
}
/* -------------------------------------------- */
/**
* Draw the overlay effect icon
* @param {string} src
* @param {number|null} tint
* @returns {Promise<PIXI.Sprite>}
* @protected
*/
async _drawOverlay(src, tint) {
const icon = await this._drawEffect(src, tint);
if ( icon ) icon.alpha = 0.8;
return icon;
}
/* -------------------------------------------- */
/**
* Refresh the display of status effects, adjusting their position for the token width and height.
* @protected
*/
_refreshEffects() {
let i = 0;
const w = Math.round(canvas.dimensions.size / 2 / 5) * 2;
const rows = Math.floor(this.document.height * 5);
const bg = this.effects.bg.clear().beginFill(0x000000, 0.40).lineStyle(1.0, 0x000000);
for ( const effect of this.effects.children ) {
if ( effect === bg ) continue;
// Overlay effect
if ( effect === this.effects.overlay ) {
const size = Math.min(this.w * 0.6, this.h * 0.6);
effect.width = effect.height = size;
effect.position.set((this.w - size) / 2, (this.h - size) / 2);
}
// Status effect
else {
effect.width = effect.height = w;
effect.x = Math.floor(i / rows) * w;
effect.y = (i % rows) * w;
bg.drawRoundedRect(effect.x + 1, effect.y + 1, w - 2, w - 2, 2);
i++;
}
}
}
/* -------------------------------------------- */
/**
* Helper method to determine whether a token attribute is viewable under a certain mode
* @param {number} mode The mode from CONST.TOKEN_DISPLAY_MODES
* @returns {boolean} Is the attribute viewable?
* @protected
*/
_canViewMode(mode) {
if ( mode === CONST.TOKEN_DISPLAY_MODES.NONE ) return false;
else if ( mode === CONST.TOKEN_DISPLAY_MODES.ALWAYS ) return true;
else if ( mode === CONST.TOKEN_DISPLAY_MODES.CONTROL ) return this.controlled;
else if ( mode === CONST.TOKEN_DISPLAY_MODES.HOVER ) return this.hover || this.layer.highlightObjects;
else if ( mode === CONST.TOKEN_DISPLAY_MODES.OWNER_HOVER ) return this.isOwner
&& (this.hover || this.layer.highlightObjects);
else if ( mode === CONST.TOKEN_DISPLAY_MODES.OWNER ) return this.isOwner;
return false;
}
/* -------------------------------------------- */
/* Token Animation */
/* -------------------------------------------- */
/**
* Animate changes to the appearance of the Token.
* Animations are performed over differences between the TokenDocument and the current Token and TokenMesh appearance.
* @param {object} updateData A record of the differential data which changed, for reference only
* @param {CanvasAnimationOptions} [options] Options which configure the animation behavior
* @param {Function} [options.ontick] An optional function called each animation frame
* @param {number} [options.movementSpeed] A desired token movement speed in grid spaces per second
* @param {TokenMeshDisplayAttributes} [options.a0] The animation starting attributes if different from those cached.
* @param {TokenMeshDisplayAttributes} [options.hoverInOut] The placeable need hover/un-hover emulation.
* @returns {Promise<void>} A promise which resolves once the animation is complete
*/
async animate(updateData, {hoverInOut, name, duration, easing, movementSpeed=6, ontick, a0}={}) {
// Start from current Mesh attributes
a0 ??= this.mesh.getDisplayAttributes();
// Prepare animation targets
const d = this.document;
const a1 = {
x: d.x,
y: d.y,
width: d.width,
height: d.height,
alpha: d.alpha,
rotation: d.rotation,
scaleX: d.texture.scaleX,
scaleY: d.texture.scaleY
};
// Special handling for rotation direction
let dr = a1.rotation - a0.rotation;
if ( dr ) {
if ( dr > 180 ) a1.rotation -= 360;
if ( dr < -180 ) a1.rotation += 360;
dr = a1.rotation - a0.rotation;
}
// Prepare animation attributes
const documentData = {texture: {}};
const attributes = [];
for ( const k of Object.keys(a1) ) {
const parent = ["scaleX", "scaleY"].includes(k) ? documentData.texture : documentData;
if ( a1[k] !== a0[k] ) attributes.push({attribute: k, from: a0[k], to: a1[k], parent});
}
// Special handling for texture tint
let tint = Color.from(d.texture.tint || 0xFFFFFF);
if ( !tint.equals(a0.tint) ) {
attributes.push({attribute: "tint", from: a0.tint, to: tint, parent: documentData.texture});
}
// Configure animation
if ( !attributes.length ) return this.renderFlags.set({refreshMesh: true});
const emits = this.emitsLight;
const isPerceptionChange = ["x", "y", "rotation"].some(k => k in updateData);
const visionAnimation = game.settings.get("core", "visionAnimation") && isPerceptionChange;
const config = {
animatePerception: visionAnimation ? (this._isVisionSource() || emits) : false,
sound: this.observer
};
// Configure animation duration aligning movement and rotation speeds
if ( !duration ) {
const durations = [];
const dx = a1.x - a0.x;
const dy = a1.y - a0.y;
if ( dx || dy ) durations.push((Math.hypot(dx, dy) * 1000) / (canvas.dimensions.size * movementSpeed));
if ( dr ) durations.push((Math.abs(dr) * 1000) / (movementSpeed * 60));
if ( durations.length ) duration = Math.max(...durations);
}
// Release hover state if any
if ( hoverInOut ) this.#forceReleaseHover();
// Dispatch animation
this._animation = CanvasAnimation.animate(attributes, {
name: name || this.animationName,
context: this,
duration: duration,
easing: easing,
priority: PIXI.UPDATE_PRIORITY.OBJECTS + 1, // Before perception updates and Token render flags
ontick: (dt, anim) => {
this.#animateFrame(documentData, config);
if ( ontick ) ontick(dt, anim, documentData, config);
}
});
await this._animation;
this._animation = null;
// Render the completed animation
config.animatePerception = true;
this.#animateFrame(documentData, config);
// Force hover state if mouse is over the token
if ( hoverInOut ) this.#forceCheckHover();
}
/* -------------------------------------------- */
/**
* Handle a single frame of a token animation.
* @param {object} documentData The current animation frame
* @param {object} config The animation configuration
* @param {boolean} [config.animatePerception] Animate perception changes
* @param {boolean} [config.sound] Animate ambient sound changes
*/
#animateFrame(documentData, {animatePerception, sound}={}) {
// Update the document
documentData = this.document.constructor.cleanData(documentData, {partial: true});
foundry.utils.mergeObject(this.document, documentData, {insertKeys: false});
// Refresh the Token and TokenMesh
this.renderFlags.set({
refreshSize: ("width" in documentData) || ("height" in documentData),
refreshPosition: ("x" in documentData) || ("y" in documentData),
refreshMesh: true
});
// Animate perception changes if necessary
if ( !animatePerception && !sound ) return;
const refreshOptions = {refreshSounds: sound}
if ( animatePerception ) {
this.updateSource({defer: true});
refreshOptions.refreshLighting = refreshOptions.refreshVision = refreshOptions.refreshTiles = true;
}
canvas.perception.update(refreshOptions);
}
/* -------------------------------------------- */
/**
* Terminate animation of this particular Token.
*/
stopAnimation() {
return CanvasAnimation.terminateAnimation(this.animationName);
}
/* -------------------------------------------- */
/* Methods
/* -------------------------------------------- */
/**
* Check for collision when attempting a move to a new position
* @param {Point} destination The central destination point of the attempted movement
* @param {object} [options={}] Additional options forwarded to WallsLayer#checkCollision
* @returns {boolean|object[]|object} The result of the WallsLayer#checkCollision test
*/
checkCollision(destination, {origin, type="move", mode="any"}={}) {
// The test origin is the last confirmed valid position of the Token
const center = origin || this.getCenter(this.#validPosition.x, this.#validPosition.y);
origin = this.getMovementAdjustedPoint(center);
// The test destination is the adjusted point based on the proposed movement vector
const dx = destination.x - center.x;
const dy = destination.y - center.y;
const offsetX = dx === 0 ? this.#priorMovement.ox : Math.sign(dx);
const offsetY = dy === 0 ? this.#priorMovement.oy : Math.sign(dy);
destination = this.getMovementAdjustedPoint(destination, {offsetX, offsetY});
// Reference the correct source object
let source;
switch ( type ) {
case "move":
source = this.#getMovementSource(origin); break;
case "sight":
source = this.vision; break;
case "light":
source = this.light; break;
case "sound":
throw new Error("Collision testing for Token sound sources is not supported at this time");
}
// Create a movement source passed to the polygon backend
return CONFIG.Canvas.polygonBackends[type].testCollision(origin, destination, {type, mode, source});
}
/* -------------------------------------------- */
/**
* Prepare a MovementSource for the document
* @returns {MovementSource}
*/
#getMovementSource(origin) {
const movement = new MovementSource({object: this});
movement.initialize({x: origin.x, y: origin.y, elevation: this.document.elevation});
return movement;
}
/* -------------------------------------------- */
/**
* Get the center-point coordinate for a given grid position
* @param {number} x The grid x-coordinate that represents the top-left of the Token
* @param {number} y The grid y-coordinate that represents the top-left of the Token
* @returns {Object} The coordinate pair which represents the Token's center at position (x, y)
*/
getCenter(x, y) {
return {
x: x + (this.w / 2),
y: y + (this.h / 2)
};
}
/* -------------------------------------------- */
/**
* Set this Token as an active target for the current game User.
* Note: If the context is set with groupSelection:true, you need to manually broadcast the activity for other users.
* @param {boolean} targeted Is the Token now targeted?
* @param {object} [context={}] Additional context options
* @param {User|null} [context.user=null] Assign the token as a target for a specific User
* @param {boolean} [context.releaseOthers=true] Release other active targets for the same player?
* @param {boolean} [context.groupSelection=false] Is this target being set as part of a group selection workflow?
*/
setTarget(targeted=true, {user=null, releaseOthers=true, groupSelection=false}={}) {
// Do not allow setting a preview token as a target
if ( this.isPreview ) return;
// Release other targets
user = user || game.user;
if ( user.targets.size && releaseOthers ) {
user.targets.forEach(t => {
if ( t !== this ) t.setTarget(false, {user, releaseOthers: false, groupSelection});
});
}
const wasTargeted = this.targeted.has(user);
// Acquire target
if ( targeted ) {
this.targeted.add(user);
user.targets.add(this);
}
// Release target
else {
this.targeted.delete(user);
user.targets.delete(this);
}
if ( wasTargeted !== targeted ) {
// Refresh Token display
this.renderFlags.set({refreshTarget: true});
// Refresh the Token HUD
if ( this.hasActiveHUD ) this.layer.hud.render();
}
// Broadcast the target change
if ( !groupSelection ) user.broadcastActivity({targets: user.targets.ids});
}
/* -------------------------------------------- */
/**
* Add or remove the currently controlled Tokens from the active combat encounter
* @param {Combat} [combat] A specific combat encounter to which this Token should be added
* @returns {Promise<Token>} The Token which initiated the toggle
*/
async toggleCombat(combat) {
await this.layer.toggleCombat(!this.inCombat, combat, {token: this});
return this;
}
/* -------------------------------------------- */
/**
* Toggle an active effect by its texture path.
* Copy the existing Array in order to ensure the update method detects the data as changed.
*
* @param {string|object} effect The texture file-path of the effect icon to toggle on the Token.
* @param {object} [options] Additional optional arguments which configure how the effect is handled.
* @param {boolean} [options.active] Force a certain active state for the effect
* @param {boolean} [options.overlay] Whether to set the effect as the overlay effect?
* @returns {Promise<boolean>} Was the texture applied (true) or removed (false)
*/
async toggleEffect(effect, {active, overlay=false}={}) {
const fx = this.document.effects;
const texture = effect.icon ?? effect;
// Case 1 - handle an active effect object
if ( effect.icon ) await this.document.toggleActiveEffect(effect, {active, overlay});
// Case 2 - overlay effect
else if ( overlay ) await this.#toggleOverlayEffect(texture, {active});
// Case 3 - add or remove a standard effect icon
else {
const idx = fx.findIndex(e => e === texture);
if ((idx !== -1) && (active !== true)) fx.splice(idx, 1);
else if ((idx === -1) && (active !== false)) fx.push(texture);
await this.document.update({effects: fx}, {
diff: false,
toggleEffect: CONFIG.statusEffects.find(e => e.icon === texture)?.id
});
}
// Update the Token HUD
if ( this.hasActiveHUD ) canvas.tokens.hud.refreshStatusIcons();
return active;
}
/* -------------------------------------------- */
/**
* A helper function to toggle the overlay status icon on the Token
* @param {string} texture
* @param {object} options
* @param {boolean} [options.active]
* @returns {Promise<*>}
*/
async #toggleOverlayEffect(texture, {active}) {
// Assign the overlay effect
active = active ?? this.document.overlayEffect !== texture;
let effect = active ? texture : "";
await this.document.update({overlayEffect: effect});
// Set the defeated status in the combat tracker
// TODO - deprecate this and require that active effects be used instead
if ( (texture === CONFIG.controlIcons.defeated) && game.combat ) {
const combatant = game.combat.getCombatantByToken(this.id);
if ( combatant ) await combatant.update({defeated: active});
}
return this;
}
/* -------------------------------------------- */
/**
* Toggle the visibility state of any Tokens in the currently selected set
* @returns {Promise<TokenDocument[]>} A Promise which resolves to the updated Token documents
*/
async toggleVisibility() {
let isHidden = this.document.hidden;
const tokens = this.controlled ? canvas.tokens.controlled : [this];
const updates = tokens.map(t => { return {_id: t.id, hidden: !isHidden};});
return canvas.scene.updateEmbeddedDocuments("Token", updates);
}
/* -------------------------------------------- */
/**
* The external radius of the token in pixels.
* @type {number}
*/
get externalRadius() {
return Math.max(this.w, this.h) / 2;
}
/* -------------------------------------------- */
/**
* A generic transformation to turn a certain number of grid units into a radius in canvas pixels.
* This function adds additional padding to the light radius equal to the external radius of the token.
* This causes light to be measured from the outer token edge, rather than from the center-point.
* @param {number} units The radius in grid units
* @returns {number} The radius in pixels
*/
getLightRadius(units) {
if ( units === 0 ) return 0;
return ((Math.abs(units) * canvas.dimensions.distancePixels) + this.externalRadius) * Math.sign(units);
}
/* -------------------------------------------- */
/** @override */
_getShiftedPosition(dx, dy) {
let {x, y, width, height} = this.document;
const s = canvas.dimensions.size;
// Identify the coordinate of the starting grid space
let x0 = x;
let y0 = y;
if ( canvas.grid.type !== CONST.GRID_TYPES.GRIDLESS ) {
const c = this.center;
x0 = width <= 1 ? c.x : x + (s / 2);
y0 = height <= 1 ? c.y : y + ( s / 2);
}
// Shift the position and test collision
const [x1, y1] = canvas.grid.grid.shiftPosition(x0, y0, dx, dy, {token: this});
let collide = this.checkCollision(this.getCenter(x1, y1));
return collide ? {x, y} : {x: x1, y: y1};
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
_onCreate(data, options, userId) {
super._onCreate(data, options, userId);
this.updateSource(); // Update vision and lighting sources
if ( !game.user.isGM && this.isOwner && !this.document.hidden ) this.control({pan: true}); // Assume control
}
/* -------------------------------------------- */
/** @override */
_onUpdate(data, options, userId) {
super._onUpdate(data, options, userId);
const animate = options.animate !== false;
// Identify what has changed
const keys = Object.keys(foundry.utils.flattenObject(data));
const changed = new Set(keys);
const displayBarsChange = ("displayBars" in data);
const barChanges = ("bar1" in data) || ("bar2" in data);
const dispositionChange = changed.has("disposition");
const positionChange = ["x", "y"].some(c => changed.has(c));
const rotationChange = changed.has("rotation");
const lockRotationChange = changed.has("lockRotation");
const shapeChange = ["width", "height"].some(k => changed.has(k));
const visibilityChange = changed.has("hidden");
const elevationChange = changed.has("elevation");
const perspectiveChange = visibilityChange || positionChange || elevationChange || shapeChange
|| (rotationChange && this.hasLimitedSourceAngle);
const visionChange = ("sight" in data) || (this.hasSight && perspectiveChange) || ("detectionModes" in data);
const lightChange = ("light" in data) || (this.emitsLight && perspectiveChange);
// Record movement
if ( positionChange || rotationChange || shapeChange ) {
this.#recordPosition(positionChange, rotationChange, shapeChange);
}
// Handle special case for effect(s) toggled for actorless token
const statusId = options.toggleEffect;
if ( !this.actor && changed.has("effects") && Object.values(CONFIG.specialStatusEffects).includes(statusId) ) {
this._onApplyStatusEffect(statusId, this.document.hasStatusEffect(statusId));
}
// Full re-draw
if ( ["texture.src", "actorId", "actorLink"].some(r => changed.has(r)) ) {
this.renderFlags.set({redraw: true});
}
// Incremental refresh
const refreshMeshRequired = (visibilityChange || rotationChange || lockRotationChange
|| ("texture" in data) || ("alpha" in data));
this.renderFlags.set({
refreshVisibility: visibilityChange && (!animate || data.hidden === false),
refreshPosition: positionChange && !animate, // Triggers refreshMesh
refreshSize: shapeChange && !animate, // Triggers refreshMesh
refreshMesh: refreshMeshRequired && !animate,
refreshElevation: elevationChange,
refreshBars: barChanges,
refreshNameplate: ["name", "appendNumber", "prependAdjective", "displayName"].some(k => changed.has(k)) || dispositionChange,
refreshState: displayBarsChange || shapeChange || dispositionChange,
refreshTarget: dispositionChange,
redrawEffects: ["effects", "overlayEffect"].some(k => changed.has(k))
});
// Perception updates
if ( visionChange || lightChange ) this.updateSource({defer: true});
canvas.perception.update({
initializeVision: visionChange,
refreshVision: lightChange || elevationChange,
refreshLighting: lightChange,
refreshTiles: perspectiveChange,
refreshSounds: perspectiveChange
});
// Animate changes
if ( animate && (refreshMeshRequired || positionChange || shapeChange) ) {
const animationConfig = (options.animation ||= {});
animationConfig.hoverInOut = positionChange || shapeChange;
this.animate(data, animationConfig);
}
// Acquire or release Token control
if ( visibilityChange ) {
if ( this.controlled && data.hidden && !game.user.isGM ) this.release();
else if ( (data.hidden === false) && !canvas.tokens.controlled.length ) this.control({pan: true});
}
// Automatically pan the canvas
if ( positionChange && this.controlled && (options.pan !== false) ) this.#panCanvas();
// Update the Token HUD
if ( this.hasActiveHUD && (positionChange || shapeChange) ) {
if ( positionChange || shapeChange ) this.layer.hud.render();
}
// Process Combat Tracker changes
if ( this.inCombat ) {
if ( changed.has("name") ) game.combat.debounceSetup();
else if ( ["effects", "name", "overlayEffect"].some(k => changed.has(k)) ) ui.combat.render();
}
}
/* -------------------------------------------- */
/**
* When Token position or rotation changes, record the movement vector.
* Update cached values for both #validPosition and #priorMovement.
* @param {boolean} positionChange Did the x/y position change?
* @param {boolean} rotationChange Did rotation change?
* @param {boolean} shapeChange Did the width or height change?
*/
#recordPosition(positionChange, rotationChange, shapeChange) {
// Update rotation
const position = {};
if ( rotationChange ) {
position.rotation = this.document.rotation;
}
// Update movement vector
if ( positionChange ) {
const origin = this._animation ? this.position : this.#validPosition;
position.x = this.document.x;
position.y = this.document.y;
const ray = new Ray(origin, position);
// Offset movement relative to prior vector
const prior = this.#priorMovement;
const ox = ray.dx === 0 ? prior.ox : Math.sign(ray.dx);
const oy = ray.dy === 0 ? prior.oy : Math.sign(ray.dy);
this.#priorMovement = {dx: ray.dx, dy: ray.dy, ox, oy};
}
// Update valid position
foundry.utils.mergeObject(this.#validPosition, position);
}
/* -------------------------------------------- */
/**
* Automatically pan the canvas when a controlled Token moves offscreen.
*/
#panCanvas() {
// Target center point in screen coordinates
const c = this.center;
const {x: sx, y: sy} = canvas.stage.transform.worldTransform.apply(c);
// Screen rectangle minus padding space
const pad = 50;
const sidebarPad = $("#sidebar").width() + pad;
const rect = new PIXI.Rectangle(pad, pad, window.innerWidth - sidebarPad, window.innerHeight - pad);
// Pan the canvas if the target center-point falls outside the screen rect
if ( !rect.contains(sx, sy) ) canvas.animatePan(this.center);
}
/* -------------------------------------------- */
/** @override */
_onDelete(options, userId) {
// Remove target (if applicable)
game.user.targets.delete(this);
// Process changes to perception
const sourceId = this.sourceId;
if ( canvas.effects.lightSources.has(sourceId) ) this.updateLightSource({deleted: true});
if ( canvas.effects.visionSources.has(sourceId) ) this.updateVisionSource({deleted: true});
// Remove Combatants
if (userId === game.user.id) {
game.combats._onDeleteToken(this.scene.id, this.id);
}
// Parent class deletion handlers
return super._onDelete(options, userId);
}
/* -------------------------------------------- */
/**
* Handle changes to Token behavior when a significant status effect is applied
* @param {string} statusId The status effect ID being applied, from CONFIG.specialStatusEffects
* @param {boolean} active Is the special status effect now active?
* @internal
*/
_onApplyStatusEffect(statusId, active) {
switch ( statusId ) {
case CONFIG.specialStatusEffects.INVISIBLE:
canvas.perception.update({refreshVision: true});
this.renderFlags.set({refreshMesh: true, refreshShader: true});
break;
case CONFIG.specialStatusEffects.BLIND:
canvas.perception.update({initializeVision: true});
break;
}
Hooks.callAll("applyTokenStatusEffect", this, statusId, active);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onControl({releaseOthers=true, pan=false, ...options}={}) {
super._onControl(options);
_token = this; // Debugging global window variable
this.document.sort += 1;
if ( this.mesh ) this.mesh.initialize({sort: this.document.sort});
canvas.perception.update({
initializeVision: true,
refreshLighting: true,
refreshSounds: true,
refreshTiles: true
});
// Pan to the controlled Token
if ( pan ) canvas.animatePan(this.center);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onRelease(options) {
super._onRelease(options);
this.document.sort -= 1;
if ( this.mesh ) this.mesh.initialize({sort: this.document.sort});
canvas.perception.update({
initializeVision: true,
refreshLighting: true,
refreshSounds: true,
refreshTiles: true
});
}
/* -------------------------------------------- */
/**
* Force the release of the hover state for this token.
* - On the layer if necessary
* - Initialize the interaction manager state to NONE
* - Call the hover hook
*/
#forceReleaseHover() {
if ( !this.hover ) return;
// Emulate an onHoverOut event and set manually the interaction manager
this._onHoverOut(new PIXI.FederatedEvent("pointerout"));
this.mouseInteractionManager.state = MouseInteractionManager.INTERACTION_STATES.NONE;
}
/* -------------------------------------------- */
/**
* Check the position of the mouse and assign hover to the token if the mouse is inside the bounds.
*/
#forceCheckHover() {
if ( this.hover ) return;
// Get mouse position and check the token bounds
const mousePos = canvas.mousePosition;
if ( !this.bounds.contains(mousePos.x, mousePos.y) ) return;
// If inside the bounds, emulate an onHoverIn event and set manually the interaction manager
this._onHoverIn(new PIXI.FederatedEvent("pointerover"), {hoverOutOthers: true});
this.mouseInteractionManager.state = MouseInteractionManager.INTERACTION_STATES.HOVER;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
_canControl(user, event) {
if ( !this.layer.active || this.isPreview ) return false;
if ( canvas.controls.ruler.active ) return false;
const tool = game.activeTool;
if ( (tool === "target") && !this.isPreview ) return true;
return super._canControl(user, event);
}
/* -------------------------------------------- */
/** @override */
_canHUD(user, event) {
if ( canvas.controls.ruler.active ) return false;
return user.isGM || (this.actor?.testUserPermission(user, "OWNER") ?? false);
}
/* -------------------------------------------- */
/** @override */
_canConfigure(user, event) {
return !this.isPreview;
}
/* -------------------------------------------- */
/** @override */
_canHover(user, event) {
return !this.isPreview;
}
/* -------------------------------------------- */
/** @override */
_canView(user, event) {
if ( !this.actor ) ui.notifications.warn("TOKEN.WarningNoActor", {localize: true});
return this.actor?.testUserPermission(user, "LIMITED");
}
/* -------------------------------------------- */
/** @override */
_canDrag(user, event) {
if ( !this.controlled || this._animation ) return false;
if ( !this.layer.active || (game.activeTool !== "select") ) return false;
if ( CONFIG.Canvas.rulerClass.canMeasure ) return false;
return game.user.isGM || !game.paused;
}
/* -------------------------------------------- */
/** @inheritDoc */
_onHoverIn(event, options) {
const combatant = this.combatant;
if ( combatant ) ui.combat.hoverCombatant(combatant, true);
return super._onHoverIn(event, options);
}
/* -------------------------------------------- */
/** @inheritDoc */
_onHoverOut(event) {
const combatant = this.combatant;
if ( combatant ) ui.combat.hoverCombatant(combatant, false);
return super._onHoverOut(event);
}
/* -------------------------------------------- */
/** @inheritDoc */
_onClickLeft(event) {
const tool = game.activeTool;
if ( tool === "target" ) {
event.stopPropagation();
return this.setTarget(!this.isTargeted, {releaseOthers: !event.shiftKey});
}
super._onClickLeft(event);
}
/** @override */
_propagateLeftClick(event) {
return CONFIG.Canvas.rulerClass.canMeasure;
}
/* -------------------------------------------- */
/** @override */
_onClickLeft2(event) {
if ( !this._propagateLeftClick(event) ) event.stopPropagation();
const sheet = this.actor?.sheet;
if ( sheet?.rendered ) {
sheet.maximize();
sheet.bringToTop();
}
else sheet?.render(true, {token: this.document});
}
/* -------------------------------------------- */
/** @override */
_onClickRight2(event) {
if ( !this._propagateRightClick(event) ) event.stopPropagation();
if ( this.isOwner && game.user.can("TOKEN_CONFIGURE") ) return super._onClickRight2(event);
return this.setTarget(!this.targeted.has(game.user), {releaseOthers: !event.shiftKey});
}
/* -------------------------------------------- */
/** @override */
_onDragLeftDrop(event) {
const clones = event.interactionData.clones || [];
const destination = event.interactionData.destination;
// Ensure the cursor destination is within bounds
if ( !canvas.dimensions.rect.contains(destination.x, destination.y) ) return false;
event.interactionData.clearPreviewContainer = false;
// Compute the final dropped positions
const updates = clones.reduce((updates, c) => {
// Get the snapped top-left coordinate
let dest = {x: c.document.x, y: c.document.y};
if ( !event.shiftKey && (canvas.grid.type !== CONST.GRID_TYPES.GRIDLESS) ) {
const isTiny = (c.document.width < 1) && (c.document.height < 1);
const interval = canvas.grid.isHex ? 1 : isTiny ? 2 : 1;
dest = canvas.grid.getSnappedPosition(dest.x, dest.y, interval, {token: c});
}
// Test collision for each moved token vs the central point of its destination space
const target = c.getCenter(dest.x, dest.y);
if ( !game.user.isGM ) {
let collides = c._original.checkCollision(target);
if ( collides ) {
ui.notifications.error("RULER.MovementCollision", {localize: true, console: false});
return updates;
}
}
// Otherwise, ensure the final token center is in-bounds
else if ( !canvas.dimensions.rect.contains(target.x, target.y) ) return updates;
// Perform updates where no collision occurs
updates.push({_id: c._original.id, x: dest.x, y: dest.y});
return updates;
}, []);
// Submit the data update
try {
return canvas.scene.updateEmbeddedDocuments("Token", updates);
} finally {
this.layer.clearPreviewContainer();
}
}
/* -------------------------------------------- */
/** @override */
_onDragLeftMove(event) {
const {clones, destination, origin} = event.interactionData;
const preview = game.settings.get("core", "tokenDragPreview");
// Pan the canvas if the drag event approaches the edge
canvas._onDragCanvasPan(event);
// Determine dragged distance
const dx = destination.x - origin.x;
const dy = destination.y - origin.y;
// Update the position of each clone
for ( let c of clones || [] ) {
const o = c._original;
const x = o.document.x + dx;
const y = o.document.y + dy;
if ( preview && !game.user.isGM ) {
const collision = o.checkCollision(o.getCenter(x, y));
if ( collision ) continue;
}
c.document.x = x;
c.document.y = y;
c.refresh();
if ( preview ) c.updateSource({defer: true});
}
// Update perception immediately
if ( preview ) canvas.perception.update({refreshLighting: true, refreshVision: true});
}
/* -------------------------------------------- */
/** @override */
_onDragEnd() {
this.updateSource({deleted: true});
this._original?.updateSource();
super._onDragEnd();
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
get hasLimitedVisionAngle() {
const msg = "Token#hasLimitedVisionAngle has been renamed to Token#hasLimitedSourceAngle";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return this.hasLimitedSourceAngle;
}
/**
* @deprecated since v10
* @ignore
*/
getSightOrigin() {
const msg = "Token#getSightOrigin has been deprecated in favor of Token#getMovementAdjustedPoint";
foundry.utils.logCompatibilityWarning(msg, {since: 10, until: 12});
return this.getMovementAdjustedPoint(this.center);
}
/**
* @deprecated since v10
* @ignore
*/
get icon() {
foundry.utils.logCompatibilityWarning("Token#icon has been renamed to Token#mesh.", {since: 10, until: 12});
return this.mesh;
}
/**
* @deprecated since v10
* @ignore
*/
async setPosition(x, y, {animate=true, movementSpeed, recenter=true}={}) {
throw new Error("The Token#setPosition method is deprecated in favor of a standard TokenDocument#update");
}
/**
* @deprecated since v10
* @ignore
*/
async animateMovement(ray, {movementSpeed=6}={}) {
throw new Error("The Token#animateMovement method is deprecated in favor Token#animate");
}
/**
* @deprecated since v11
* @ignore
*/
updatePosition() {
const msg = "Token#updatePosition has been deprecated without replacement as it is no longer required.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
}
/**
* @deprecated since 11
* @ignore
*/
refreshHUD({bars=true, border=true, effects=true, elevation=true, nameplate=true}={}) {
const msg = "Token#refreshHUD is deprecated in favor of token.renderFlags.set()";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
this.renderFlags.set({
refreshBars: bars,
refreshBorder: border,
refreshElevation: elevation,
refreshNameplate: nameplate,
redrawEffects: effects
});
}
/**
* @deprecated since 11
* @ignore
*/
getDisplayAttributes() {
const msg = "Token#getDisplayAttributes is deprecated in favor of TokenMesh#getDisplayAttributes";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this.mesh.getDisplayAttributes();
}
}
/**
* A "secret" global to help debug attributes of the currently controlled Token.
* This is only for debugging, and may be removed in the future, so it's not safe to use.
* @type {Token}
* @ignore
*/
let _token = null;
/**
* A Wall is an implementation of PlaceableObject which represents a physical or visual barrier within the Scene.
* Walls are used to restrict Token movement or visibility as well as to define the areas of effect for ambient lights
* and sounds.
* @category - Canvas
* @see {@link WallDocument}
* @see {@link WallsLayer}
*/
class Wall extends PlaceableObject {
constructor(document) {
super(document);
this.#initializeVertices();
this.#priorDoorState = this.document.ds;
}
/** @inheritdoc */
static embeddedName = "Wall";
/** @override */
static RENDER_FLAGS = {
redraw: {propagate: ["refresh"]},
refresh: {propagate: ["refreshState", "refreshLine"], alias: true},
refreshState: {propagate: ["refreshEndpoints", "refreshHighlight"]},
refreshLine: {propagate: ["refreshEndpoints", "refreshHighlight", "refreshDirection"]},
refreshEndpoints: {},
refreshDirection: {},
refreshHighlight: {}
};
/**
* A reference the Door Control icon associated with this Wall, if any
* @type {DoorControl|null}
* @protected
*/
doorControl;
/**
* A reference to an overhead Tile that is a roof, interior to which this wall is contained
* @type {Tile}
*/
roof;
/**
* A Graphics object used to highlight this wall segment. Only used when the wall is controlled.
* @type {PIXI.Graphics}
*/
highlight;
/**
* A set which tracks other Wall instances that this Wall intersects with (excluding shared endpoints)
* @type {Map<Wall,LineIntersection>}
*/
intersectsWith = new Map();
/**
* Cache the prior door state so that we can identify changes in the door state.
* @type {number}
*/
#priorDoorState;
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* A convenience reference to the coordinates Array for the Wall endpoints, [x0,y0,x1,y1].
* @type {number[]}
*/
get coords() {
return this.document.c;
}
/* -------------------------------------------- */
/**
* The endpoints of the wall expressed as {@link PolygonVertex} instances.
* @type {{a: PolygonVertex, b: PolygonVertex}}
*/
get vertices() {
return this.#vertices;
}
/** @ignore */
#vertices;
/* -------------------------------------------- */
/**
* The initial endpoint of the Wall.
* @type {PolygonVertex}
*/
get A() {
return this.#vertices.a;
}
/* -------------------------------------------- */
/**
* The second endpoint of the Wall.
* @type {PolygonVertex}
*/
get B() {
return this.#vertices.b;
}
/* -------------------------------------------- */
/**
* A set of vertex sort keys which identify this Wall's endpoints.
* @type {Set<number>}
*/
get wallKeys() {
return this.#wallKeys;
}
/** @ignore */
#wallKeys;
/* -------------------------------------------- */
/** @inheritdoc */
get bounds() {
const [x0, y0, x1, y1] = this.document.c;
return new PIXI.Rectangle(x0, y0, x1-x0, y1-y0).normalize();
}
/* -------------------------------------------- */
/**
* A boolean for whether this wall contains a door
* @type {boolean}
*/
get isDoor() {
return this.document.door > CONST.WALL_DOOR_TYPES.NONE;
}
/* -------------------------------------------- */
/**
* A boolean for whether the wall contains an open door
* @returns {boolean}
*/
get isOpen() {
return this.isDoor && (this.document.ds === CONST.WALL_DOOR_STATES.OPEN);
}
/* -------------------------------------------- */
/**
* Is this Wall interior to a non-occluded roof Tile?
* @type {boolean}
*/
get hasActiveRoof() {
if ( !this.roof ) return false;
return !this.roof.occluded && (this.roof.document.occlusion.mode !== CONST.OCCLUSION_MODES.VISION);
}
/* -------------------------------------------- */
/**
* Return the coordinates [x,y] at the midpoint of the wall segment
* @returns {Array<number>}
*/
get midpoint() {
return [(this.coords[0] + this.coords[2]) / 2, (this.coords[1] + this.coords[3]) / 2];
}
/* -------------------------------------------- */
/** @inheritdoc */
get center() {
const [x, y] = this.midpoint;
return new PIXI.Point(x, y);
}
/* -------------------------------------------- */
/**
* Get the direction of effect for a directional Wall
* @type {number|null}
*/
get direction() {
let d = this.document.dir;
if ( !d ) return null;
let c = this.coords;
let angle = Math.atan2(c[3] - c[1], c[2] - c[0]);
if ( d === CONST.WALL_DIRECTIONS.LEFT ) return angle + (Math.PI / 2);
else return angle - (Math.PI / 2);
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Create PolygonVertex instances for the Wall endpoints and register the set of vertex keys.
*/
#initializeVertices() {
this.#vertices = {
a: new PolygonVertex(...this.document.c.slice(0, 2)),
b: new PolygonVertex(...this.document.c.slice(2, 4))
};
this.#wallKeys = new Set([this.#vertices.a.key, this.#vertices.b.key]);
}
/* -------------------------------------------- */
/**
* This helper converts the wall segment to a Ray
* @returns {Ray} The wall in Ray representation
*/
toRay() {
return Ray.fromArrays(this.coords.slice(0, 2), this.coords.slice(2));
}
/* -------------------------------------------- */
/** @override */
async _draw() {
this.line = this.addChild(new PIXI.Graphics());
this.directionIcon = this.addChild(this.#drawDirection());
this.endpoints = this.addChild(new PIXI.Graphics());
this.endpoints.cursor = "pointer";
}
/* -------------------------------------------- */
/** @override */
clear() {
this.clearDoorControl();
return super.clear();
}
/* -------------------------------------------- */
/**
* Draw a control icon that is used to manipulate the door's open/closed state
* @returns {DoorControl}
*/
createDoorControl() {
if ((this.document.door === CONST.WALL_DOOR_TYPES.SECRET) && !game.user.isGM) return null;
this.doorControl = canvas.controls.doors.addChild(new DoorControl(this));
this.doorControl.draw();
return this.doorControl;
}
/* -------------------------------------------- */
/**
* Clear the door control if it exists.
*/
clearDoorControl() {
if ( this.doorControl ) {
this.doorControl.destroy({children: true});
this.doorControl = null;
}
}
/* -------------------------------------------- */
/**
* Determine the orientation of this wall with respect to a reference point
* @param {Point} point Some reference point, relative to which orientation is determined
* @returns {number} An orientation in CONST.WALL_DIRECTIONS which indicates whether the Point is left,
* right, or collinear (both) with the Wall
*/
orientPoint(point) {
const orientation = foundry.utils.orient2dFast(this.A, this.B, point);
if ( orientation === 0 ) return CONST.WALL_DIRECTIONS.BOTH;
return orientation < 0 ? CONST.WALL_DIRECTIONS.LEFT : CONST.WALL_DIRECTIONS.RIGHT;
}
/* -------------------------------------------- */
/**
* Test whether to apply a configured threshold of this wall.
* When the proximity threshold is met, this wall is excluded as an edge in perception calculations.
* @param {string} sourceType Sense type for the source
* @param {Point} sourceOrigin The origin or position of the source on the canvas
* @param {number} [externalRadius=0] The external radius of the source
* @returns {boolean} True if the wall has a threshold greater than 0 for the
* source type, and the source type is within that distance.
*/
applyThreshold(sourceType, sourceOrigin, externalRadius=0) {
const document = this.document;
const d = document.threshold[sourceType];
if ( !d ) return false; // No threshold applies
const proximity = document[sourceType] === CONST.WALL_SENSE_TYPES.PROXIMITY;
const pt = foundry.utils.closestPointToSegment(sourceOrigin, this.A, this.B); // Closest point
const sourceDistance = Math.hypot(pt.x - sourceOrigin.x, pt.y - sourceOrigin.y);
const thresholdDistance = d * document.parent.dimensions.distancePixels;
return proximity
? Math.max(sourceDistance - externalRadius, 0) < thresholdDistance
: (sourceDistance + externalRadius) > thresholdDistance;
}
/* -------------------------------------------- */
/**
* Draw a directional prompt icon for one-way walls to illustrate their direction of effect.
* @returns {PIXI.Sprite|null} The drawn icon
*/
#drawDirection() {
if ( this.directionIcon ) return;
// Create the icon
const tex = getTexture(CONFIG.controlIcons.wallDirection);
const icon = new PIXI.Sprite(tex);
// Set icon initial state
icon.width = icon.height = 32;
icon.anchor.set(0.5, 0.5);
icon.visible = false;
return icon;
}
/* -------------------------------------------- */
/**
* Compute an approximate Polygon which encloses the line segment providing a specific hitArea for the line
* @param {number} pad The amount of padding to apply
* @returns {PIXI.Polygon} A constructed Polygon for the line
*/
#getHitPolygon(pad) {
const c = this.document.c;
// Identify wall orientation
const dx = c[2] - c[0];
const dy = c[3] - c[1];
// Define the array of polygon points
let points;
if ( Math.abs(dx) >= Math.abs(dy) ) {
const sx = Math.sign(dx);
points = [
c[0]-(pad*sx), c[1]-pad,
c[2]+(pad*sx), c[3]-pad,
c[2]+(pad*sx), c[3]+pad,
c[0]-(pad*sx), c[1]+pad
];
} else {
const sy = Math.sign(dy);
points = [
c[0]-pad, c[1]-(pad*sy),
c[2]-pad, c[3]+(pad*sy),
c[2]+pad, c[3]+(pad*sy),
c[0]+pad, c[1]-(pad*sy)
];
}
// Return a Polygon which pads the line
return new PIXI.Polygon(points);
}
/* -------------------------------------------- */
/** @inheritDoc */
control({chain=false, ...options}={}) {
const controlled = super.control(options);
if ( controlled && chain ) {
const links = this.getLinkedSegments();
for ( let l of links.walls ) {
l.control({releaseOthers: false});
this.layer.controlledObjects.set(l.id, l);
}
}
return controlled;
}
/* -------------------------------------------- */
/** @override */
_destroy(options) {
this.clearDoorControl();
}
/* -------------------------------------------- */
/**
* Test whether the Wall direction lies between two provided angles
* This test is used for collision and vision checks against one-directional walls
* @param {number} lower The lower-bound limiting angle in radians
* @param {number} upper The upper-bound limiting angle in radians
* @returns {boolean}
*/
isDirectionBetweenAngles(lower, upper) {
let d = this.direction;
if ( d < lower ) {
while ( d < lower ) d += (2 * Math.PI);
} else if ( d > upper ) {
while ( d > upper ) d -= (2 * Math.PI);
}
return ( d > lower && d < upper );
}
/* -------------------------------------------- */
/**
* A simple test for whether a Ray can intersect a directional wall
* @param {Ray} ray The ray to test
* @returns {boolean} Can an intersection occur?
*/
canRayIntersect(ray) {
if ( this.direction === null ) return true;
return this.isDirectionBetweenAngles(ray.angle - (Math.PI/2), ray.angle + (Math.PI/2));
}
/* -------------------------------------------- */
/**
* Get an Array of Wall objects which are linked by a common coordinate
* @returns {Object} An object reporting ids and endpoints of the linked segments
*/
getLinkedSegments() {
const test = new Set();
const done = new Set();
const ids = new Set();
const objects = [];
// Helper function to add wall points to the set
const _addPoints = w => {
let p0 = w.coords.slice(0, 2).join(".");
if ( !done.has(p0) ) test.add(p0);
let p1 = w.coords.slice(2).join(".");
if ( !done.has(p1) ) test.add(p1);
};
// Helper function to identify other walls which share a point
const _getWalls = p => {
return canvas.walls.placeables.filter(w => {
if ( ids.has(w.id) ) return false;
let p0 = w.coords.slice(0, 2).join(".");
let p1 = w.coords.slice(2).join(".");
return ( p === p0 ) || ( p === p1 );
});
};
// Seed the initial search with this wall's points
_addPoints(this);
// Begin recursively searching
while ( test.size > 0 ) {
const testIds = new Array(...test);
for ( let p of testIds ) {
let walls = _getWalls(p);
walls.forEach(w => {
_addPoints(w);
if ( !ids.has(w.id) ) objects.push(w);
ids.add(w.id);
});
test.delete(p);
done.add(p);
}
}
// Return the wall IDs and their endpoints
return {
ids: new Array(...ids),
walls: objects,
endpoints: new Array(...done).map(p => p.split(".").map(Number))
};
}
/* -------------------------------------------- */
/**
* Determine whether this wall is beneath a roof tile, and is considered "interior", or not.
* Tiles which are hidden do not count as roofs for the purposes of defining interior walls.
*/
identifyInteriorState() {
this.roof = null;
for ( const tile of canvas.tiles.roofs ) {
if ( tile.document.hidden || !tile.mesh ) continue;
const [x1, y1, x2, y2] = this.document.c;
const isInterior = tile.mesh.containsPixel(x1, y1) && tile.mesh.containsPixel(x2, y2);
if ( isInterior ) this.roof = tile;
}
}
/* -------------------------------------------- */
/**
* Update any intersections with this wall.
*/
updateIntersections() {
this.#removeIntersections();
for ( let other of canvas.walls.placeables ) {
this._identifyIntersectionsWith(other);
}
for ( let boundary of canvas.walls.outerBounds ) {
this._identifyIntersectionsWith(boundary);
}
if ( canvas.walls.outerBounds !== canvas.walls.innerBounds ) {
for ( const boundary of canvas.walls.innerBounds ) {
this._identifyIntersectionsWith(boundary);
}
}
}
/* -------------------------------------------- */
/**
* Record the intersection points between this wall and another, if any.
* @param {Wall} other The other wall.
*/
_identifyIntersectionsWith(other) {
if ( this === other ) return;
const {a: wa, b: wb} = this.#vertices;
const {a: oa, b: ob} = other.#vertices;
// Ignore walls which share an endpoint
if ( this.#wallKeys.intersects(other.#wallKeys) ) return;
// Record any intersections
if ( !foundry.utils.lineSegmentIntersects(wa, wb, oa, ob) ) return;
const i = foundry.utils.lineLineIntersection(wa, wb, oa, ob, {t1: true});
if ( !i ) return; // This eliminates co-linear lines, should not be necessary
this.intersectsWith.set(other, i);
other.intersectsWith.set(this, {x: i.x, y: i.y, t0: i.t1, t1: i.t0});
}
/* -------------------------------------------- */
/**
* Remove this wall's intersections.
*/
#removeIntersections() {
for ( const other of this.intersectsWith.keys() ) {
other.intersectsWith.delete(this);
}
this.intersectsWith.clear();
}
/* -------------------------------------------- */
/* Incremental Refresh */
/* -------------------------------------------- */
/** @override */
_applyRenderFlags(flags) {
if ( flags.refreshLine ) this.#refreshLine();
if ( flags.refreshEndpoints ) this.#refreshEndpoints();
if ( flags.refreshDirection ) this.#refreshDirection();
if ( flags.refreshHighlight ) this.#refreshHighlight();
if ( flags.refreshState ) this.#refreshState();
}
/* -------------------------------------------- */
/**
* Refresh the displayed position of the wall which refreshes when the wall coordinates or type changes.
*/
#refreshLine() {
const c = this.document.c;
const wc = this._getWallColor();
const lw = Wall.#getLineWidth();
// Draw line
this.line.clear()
.lineStyle(lw * 3, 0x000000, 1.0) // Background black
.moveTo(c[0], c[1])
.lineTo(c[2], c[3]);
this.line.lineStyle(lw, wc, 1.0) // Foreground color
.lineTo(c[0], c[1]);
// Tint direction icon
if ( this.directionIcon ) {
this.directionIcon.position.set((c[0] + c[2]) / 2, (c[1] + c[3]) / 2);
this.directionIcon.tint = wc;
}
// Re-position door control icon
if ( this.doorControl ) this.doorControl.reposition();
// Update hit area for interaction
this.line.hitArea = this.#getHitPolygon(lw * 3);
}
/* -------------------------------------------- */
/**
* Refresh the display of wall endpoints which refreshes when the wall position or state changes.
*/
#refreshEndpoints() {
const c = this.coords;
const wc = this._getWallColor();
const lw = Wall.#getLineWidth();
const cr = (this.hover || this.layer.highlightObjects) ? lw * 4 : lw * 3;
this.endpoints.clear()
.lineStyle(lw, 0x000000, 1.0)
.beginFill(wc, 1.0)
.drawCircle(c[0], c[1], cr)
.drawCircle(c[2], c[3], cr)
.endFill();
}
/* -------------------------------------------- */
/**
* Draw a directional prompt icon for one-way walls to illustrate their direction of effect.
* @returns {PIXI.Sprite|null} The drawn icon
*/
#refreshDirection() {
if ( !this.document.dir ) return this.directionIcon.visible = false;
// Set icon state and rotation
const icon = this.directionIcon;
const iconAngle = -Math.PI / 2;
const angle = this.direction;
icon.rotation = iconAngle + angle;
icon.visible = true;
}
/* -------------------------------------------- */
/**
* Refresh the appearance of the wall control highlight graphic. Occurs when wall control or position changes.
*/
#refreshHighlight() {
// Remove highlight
if ( !this.controlled ) {
if ( this.highlight ) {
this.removeChild(this.highlight).destroy();
this.highlight = undefined;
}
return;
}
// Add highlight
if ( !this.highlight ) {
this.highlight = this.addChildAt(new PIXI.Graphics(), 0);
this.highlight.eventMode = "none";
}
else this.highlight.clear();
// Configure highlight
const c = this.coords;
const lw = Wall.#getLineWidth();
const cr = lw * 2;
let cr2 = cr * 2;
let cr4 = cr * 4;
// Draw highlight
this.highlight.lineStyle({width: cr, color: 0xFF9829})
.drawRoundedRect(c[0] - cr2, c[1] - cr2, cr4, cr4, cr)
.drawRoundedRect(c[2] - cr2, c[3] - cr2, cr4, cr4, cr)
.lineStyle({width: cr2, color: 0xFF9829})
.moveTo(c[0], c[1]).lineTo(c[2], c[3]);
}
/* -------------------------------------------- */
/**
* Refresh the displayed state of the Wall.
*/
#refreshState() {
this.alpha = this._getTargetAlpha();
}
/* -------------------------------------------- */
/**
* Given the properties of the wall - decide upon a color to render the wall for display on the WallsLayer
* @returns {number}
* @protected
*/
_getWallColor() {
const senses = CONST.WALL_SENSE_TYPES;
// Invisible Walls
if ( this.document.sight === senses.NONE ) return 0x77E7E8;
// Terrain Walls
else if ( this.document.sight === senses.LIMITED ) return 0x81B90C;
// Windows (Sight Proximity)
else if ( [senses.PROXIMITY, senses.DISTANCE].includes(this.document.sight) ) return 0xc7d8ff;
// Ethereal Walls
else if ( this.document.move === senses.NONE ) return 0xCA81FF;
// Doors
else if ( this.document.door === CONST.WALL_DOOR_TYPES.DOOR ) {
let ds = this.document.ds || CONST.WALL_DOOR_STATES.CLOSED;
if ( ds === CONST.WALL_DOOR_STATES.CLOSED ) return 0x6666EE;
else if ( ds === CONST.WALL_DOOR_STATES.OPEN ) return 0x66CC66;
else if ( ds === CONST.WALL_DOOR_STATES.LOCKED ) return 0xEE4444;
}
// Secret Doors
else if ( this.document.door === CONST.WALL_DOOR_TYPES.SECRET ) {
let ds = this.document.ds || CONST.WALL_DOOR_STATES.CLOSED;
if ( ds === CONST.WALL_DOOR_STATES.CLOSED ) return 0xA612D4;
else if ( ds === CONST.WALL_DOOR_STATES.OPEN ) return 0x7C1A9b;
else if ( ds === CONST.WALL_DOOR_STATES.LOCKED ) return 0xEE4444;
}
// Standard Walls
return 0xFFFFBB;
}
/* -------------------------------------------- */
/**
* Adapt the width that the wall should be rendered based on the grid size.
* @returns {number}
*/
static #getLineWidth() {
const s = canvas.dimensions.size;
if ( s > 150 ) return 4;
else if ( s > 100 ) return 3;
return 2;
}
/* -------------------------------------------- */
/* Socket Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
_onCreate(data, options, userId) {
super._onCreate(data, options, userId);
this.layer._cloneType = this.document.toJSON();
this.updateIntersections();
this.identifyInteriorState();
this.#onModifyWall(this.document.door !== CONST.WALL_DOOR_TYPES.NONE);
}
/* -------------------------------------------- */
/** @override */
_onUpdate(data, options, userId) {
super._onUpdate(data, options, userId);
// Incremental Refresh
const changed = new Set(Object.keys(data));
this.renderFlags.set({
refreshLine: ["c", "sight", "move", "door", "ds"].some(k => changed.has(k)),
refreshDirection: changed.has("dir")
});
// Update the clone tool wall data
this.layer._cloneType = this.document.toJSON();
// Handle wall changes which require perception changes.
const rebuildEndpoints = changed.has("c") || CONST.WALL_RESTRICTION_TYPES.some(k => changed.has(k));
const doorChange = ["door", "ds"].some(k => changed.has(k));
if ( rebuildEndpoints ) {
this.#initializeVertices();
this.updateIntersections();
this.identifyInteriorState();
}
if ( rebuildEndpoints || doorChange || ("threshold" in data) ) this.#onModifyWall(doorChange);
// Trigger door interaction sounds
if ( "ds" in data ) {
const states = CONST.WALL_DOOR_STATES;
let interaction;
if ( data.ds === states.LOCKED ) interaction = "lock";
else if ( data.ds === states.OPEN ) interaction = "open";
else if ( data.ds === states.CLOSED ) {
if ( this.#priorDoorState === states.OPEN ) interaction = "close";
else if ( this.#priorDoorState === states.LOCKED ) interaction = "unlock";
}
if ( options.sound !== false ) this._playDoorSound(interaction);
this.#priorDoorState = data.ds;
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDelete(options, userId) {
super._onDelete(options, userId);
this.clearDoorControl();
this.#removeIntersections();
this.#onModifyWall(false);
}
/* -------------------------------------------- */
/**
* Callback actions when a wall that contains a door is moved or its state is changed
* @param {boolean} doorChange Update vision and sound restrictions
*/
#onModifyWall(doorChange=false) {
// Re-initialize perception
canvas.perception.update({
initializeLighting: true,
initializeVision: true,
initializeSounds: true,
refreshTiles: true
});
// Re-draw door icons
if ( doorChange ) {
const dt = this.document.door;
const hasCtrl = (dt === CONST.WALL_DOOR_TYPES.DOOR) || ((dt === CONST.WALL_DOOR_TYPES.SECRET) && game.user.isGM);
if ( hasCtrl ) {
if ( this.doorControl ) this.doorControl.draw(); // Asynchronous
else this.createDoorControl();
}
else this.clearDoorControl();
}
}
/* -------------------------------------------- */
/**
* Play a door interaction sound.
* This plays locally, each client independently applies this workflow.
* @param {string} interaction The door interaction: "open", "close", "lock", "unlock", or "test".
* @protected
* @internal
*/
_playDoorSound(interaction) {
if ( !CONST.WALL_DOOR_INTERACTIONS.includes(interaction) ) {
throw new Error(`"${interaction}" is not a valid door interaction type`);
}
if ( !this.isDoor ) return;
const doorSound = CONFIG.Wall.doorSounds[this.document.doorSound];
let sounds = doorSound?.[interaction];
if ( sounds && !Array.isArray(sounds) ) sounds = [sounds];
else if ( !sounds?.length ) {
if ( interaction !== "test" ) return;
sounds = [CONFIG.sounds.lock];
}
const src = sounds[Math.floor(Math.random() * sounds.length)];
AudioHelper.play({src});
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
/** @inheritdoc */
_createInteractionManager() {
const mgr = super._createInteractionManager();
mgr.options.target = "endpoints";
return mgr;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners() {
super.activateListeners();
this.line.eventMode = "static";
this.line.cursor = "pointer";
this.line.on("pointerdown", this.mouseInteractionManager.handleEvent, this.mouseInteractionManager)
.on("pointerup", this.mouseInteractionManager.handleEvent, this.mouseInteractionManager)
.on("mouseupoutside", this.mouseInteractionManager.handleEvent, this.mouseInteractionManager)
.on("pointerout", this.mouseInteractionManager.handleEvent, this.mouseInteractionManager)
.on("pointerover", this._onMouseOverLine, this);
}
/* -------------------------------------------- */
/** @inheritdoc */
_canControl(user, event) {
if ( !this.layer.active || this.isPreview ) return false;
// If the User is chaining walls, we don't want to control the last one
const isChain = this.hover && (game.keyboard.downKeys.size === 1)
&& game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL);
return !isChain;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onHoverIn(event, options) {
// contrary to hover out, hover in is prevented in chain mode to avoid distracting the user
if ( this.layer._chain ) return false;
this.zIndex = 1;
const dest = event.getLocalPosition(this.layer);
this.layer.last = {
point: WallsLayer.getClosestEndpoint(dest, this)
};
return super._onHoverIn(event, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onHoverOut(event) {
this.zIndex = 0;
const mgr = canvas.mouseInteractionManager;
if ( this.hover && !this.layer._chain && (mgr.state < mgr.states.CLICKED) ) this.layer.last = {point: null};
return super._onHoverOut(event);
}
/* -------------------------------------------- */
/**
* Handle mouse-hover events on the line segment itself, pulling the Wall to the front of the container stack
* @param {PIXI.FederatedEvent} event
* @protected
*/
_onMouseOverLine(event) {
if ( this.layer._chain ) return false;
event.stopPropagation();
if ( this.layer.preview.children.length ) return;
this.mouseInteractionManager.handleEvent(event);
this.zIndex = 1;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickLeft(event) {
if ( this.layer._chain ) return false;
event.stopPropagation();
const alt = game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.ALT);
const shift = game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.SHIFT);
if ( this.controlled && !alt ) {
if ( shift ) return this.release();
else if ( this.layer.controlled.length > 1 ) return this.layer._onDragLeftStart(event);
}
return this.control({releaseOthers: !shift, chain: alt});
}
/* -------------------------------------------- */
/** @override */
_onClickLeft2(event) {
event.stopPropagation();
const sheet = this.sheet;
sheet.render(true, {walls: this.layer.controlled});
}
/* -------------------------------------------- */
/** @override */
_onClickRight2(event) {
event.stopPropagation();
const sheet = this.sheet;
sheet.render(true, {walls: this.layer.controlled});
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftStart(event) {
const origin = event.interactionData.origin;
const dLeft = Math.hypot(origin.x - this.coords[0], origin.y - this.coords[1]);
const dRight = Math.hypot(origin.x - this.coords[2], origin.y - this.coords[3]);
event.interactionData.fixed = dLeft < dRight ? 1 : 0; // Affix the opposite point
return super._onDragLeftStart(event);
}
/* -------------------------------------------- */
/** @override */
_onDragLeftMove(event) {
// Pan the canvas if the drag event approaches the edge
canvas._onDragCanvasPan(event);
// Group movement
const {destination, fixed, origin} = event.interactionData;
let clones = event.interactionData.clones || [];
if ( clones.length > 1 ) {
// Drag a group of walls - snap to the end point maintaining relative positioning
const p0 = fixed ? this.coords.slice(0, 2) : this.coords.slice(2, 4);
// Get the snapped final point
const pt = this.layer._getWallEndpointCoordinates({
x: destination.x + (p0[0] - origin.x),
y: destination.y + (p0[1] - origin.y)
}, {snap: false});
const dx = pt[0] - p0[0];
const dy = pt[1] - p0[1];
for ( let c of clones ) {
c.document.c = c._original.document.c.map((p, i) => i % 2 ? p + dy : p + dx);
}
}
// Single-wall pivot
else if ( clones.length === 1 ) {
const w = clones[0];
const pt = this.layer._getWallEndpointCoordinates(destination, {snap: false});
w.document.c = fixed ? pt.concat(this.coords.slice(2, 4)) : this.coords.slice(0, 2).concat(pt);
}
// Refresh display
clones.forEach(c => c.refresh());
}
/* -------------------------------------------- */
/** @override */
async _onDragLeftDrop(event) {
const {origin, destination, fixed} = event.interactionData;
event.interactionData.clearPreviewContainer = false;
let clones = event.interactionData.clones || [];
const layer = this.layer;
const snap = layer._forceSnap || !event.shiftKey;
// Pivot a single wall
if ( clones.length === 1 ) {
// Get the snapped final point
const pt = this.layer._getWallEndpointCoordinates(destination, {snap});
const p0 = fixed ? this.coords.slice(2, 4) : this.coords.slice(0, 2);
const coords = fixed ? pt.concat(p0) : p0.concat(pt);
try {
// If we collapsed the wall, delete it
if ( (coords[0] === coords[2]) && (coords[1] === coords[3]) ) {
return this.document.delete();
}
// Otherwise shift the last point
this.layer.last.point = pt;
return this.document.update({c: coords});
} finally {
this.layer.clearPreviewContainer();
}
}
// Drag a group of walls - snap to the end point maintaining relative positioning
const p0 = fixed ? this.coords.slice(0, 2) : this.coords.slice(2, 4);
// Get the snapped final point
const pt = this.layer._getWallEndpointCoordinates({
x: destination.x + (p0[0] - origin.x),
y: destination.y + (p0[1] - origin.y)
}, {snap});
const dx = pt[0] - p0[0];
const dy = pt[1] - p0[1];
const updates = clones.map(w => {
const c = w._original.document.c;
return {_id: w._original.id, c: [c[0]+dx, c[1]+dy, c[2]+dx, c[3]+dy]};
});
try {
return await canvas.scene.updateEmbeddedDocuments("Wall", updates);
} finally {
this.layer.clearPreviewContainer();
}
}
}
/**
* Wrapper for a web worker meant to convert a pixel buffer to the specified image format
* and quality and return a base64 image
* @param {string} name The worker name to be initialized
* @param {object} [config={}] Worker initialization options
* @param {boolean} [config.debug=false] Should the worker run in debug mode?
*/
class TextureCompressor extends AsyncWorker {
constructor(name="Texture Compressor", config={}) {
config.debug ??= false;
config.scripts ??= ["./workers/image-compressor.js", "./spark-md5.min.js"];
config.loadPrimitives ??= false;
super(name, config);
// Do we need to control the hash?
this.#controlHash = config.controlHash ?? false;
}
/**
* Boolean to know if the texture compressor should control the hash.
* @type {boolean}
*/
#controlHash;
/**
* Previous texture hash.
* @type {string}
*/
#textureHash = "";
/* -------------------------------------------- */
/**
* Process the non-blocking image compression to a base64 string.
* @param {Uint8ClampedArray} buffer Buffer used to create the image data.
* @param {number} width Buffered image width.
* @param {number} height Buffered image height.
* @param {object} options
* @param {string} [options.type="image/png"] The required image type.
* @param {number} [options.quality=1] The required image quality.
* @param {boolean} [options.debug] The debug option.
* @returns {Promise<*>}
*/
async compressBufferBase64(buffer, width, height, options={}) {
if ( this.#controlHash ) options.hash = this.#textureHash;
const params = {buffer, width, height, ...options};
const result = await this.executeFunction("processBufferToBase64", [params], [buffer.buffer]);
if ( result.hash ) this.#textureHash = result.hash;
return result;
}
/* -------------------------------------------- */
/**
* Expand a buffer in RED format to a buffer in RGBA format.
* @param {Uint8ClampedArray} buffer Buffer used to create the image data.
* @param {number} width Buffered image width.
* @param {number} height Buffered image height.
* @param {object} options
* @param {boolean} [options.debug] The debug option.
* @returns {Promise<*>}
*/
async expandBufferRedToBufferRGBA(buffer, width, height, options={}) {
if ( this.#controlHash ) options.hash = this.#textureHash;
const params = {buffer, width, height, ...options};
const result = await this.executeFunction("processBufferRedToBufferRGBA", [params], [buffer.buffer]);
if ( result.hash ) this.#textureHash = result.hash;
return result;
}
/* -------------------------------------------- */
/**
* Reduce a buffer in RGBA format to a buffer in RED format.
* @param {Uint8ClampedArray} buffer Buffer used to create the image data.
* @param {number} width Buffered image width.
* @param {number} height Buffered image height.
* @param {object} options
* @param {boolean} [options.debug] The debug option.
* @returns {Promise<*>}
*/
async reduceBufferRGBAToBufferRED(buffer, width, height, options={}) {
if ( this.#controlHash ) options.hash = this.#textureHash;
const params = {buffer, width, height, ...options};
const result = await this.executeFunction("processBufferRGBAToBufferRED", [params], [buffer.buffer]);
if ( result.hash ) this.#textureHash = result.hash;
return result;
}
}
/**
* A special subclass of PIXI.Container used to represent a Drawing in the PrimaryCanvasGroup.
*/
class DrawingShape extends PrimaryCanvasObjectMixin(PIXI.Graphics) {
/**
* Sorting values to deal with ties.
* @type {number}
*/
static PRIMARY_SORT_ORDER = 500;
/**
* @typedef {Object} PrimaryCanvasObjectDrawingShapeData
* @property {object} shape The shape
* @property {number} x The x-coordinate of the PCO location
* @property {number} y The y-coordinate of the PCO location
* @property {number} z The z-index of the PCO
* @property {number} bezierFactor The bezier factor
* @property {number} fillType The fill type
* @property {number} fillColor The fill color
* @property {number} fillAlpha The fill alpha
* @property {number} strokeWidth The stroke width
* @property {number} strokeColor The stroke color
* @property {number} strokeAlpha The stroke alpha
* @property {string} text The text
* @property {string} fontFamily The text font family
* @property {number} fontSize The font size
* @property {number} textColor The text color
* @property {number} textAlpha The text alpha
* @property {number} rotation The rotation of this PCO
* @property {boolean} hidden The PCO is hidden?
* @property {number} elevation The elevation of the PCO
* @property {number} sort The sort key that resolves ties among the same elevation
* @property {boolean} roof The PCO is considered as a roof?
* @property {boolean} overhead The PCO is considered as overhead?
* @property {object} occlusion The occlusion object for this PCO
* @property {object} texture The data texture values
*/
static get defaultData() {
return foundry.utils.mergeObject(super.defaultData, {
shape: {
type: "",
width: 0,
height: 0,
radius: null,
points: []
},
bezierFactor: 0,
fillType: 0,
fillColor: 0x7C7C7C,
fillAlpha: 0.5,
strokeWidth: 8,
strokeColor: 0xFFFFFF,
strokeAlpha: 1,
text: "New Text",
fontFamily: "Signika",
fontSize: 48,
textColor: 0xFFFFFF,
textAlpha: 1
});
};
/* -------------------------------------------- */
/** @inheritDoc */
refresh() {
if ( this._destroyed || !this.data.shape ) return;
const hidden = this.data.hidden;
this.clear();
// Alpha and visibility
this.alpha = hidden ? 0.5 : 1.0;
this.visible = !hidden || game.user.isGM;
// Outer Stroke
const {strokeWidth, strokeColor, strokeAlpha} = this.data;
if ( strokeWidth ) {
let sc = Color.from(strokeColor || "#FFFFFF");
const sw = strokeWidth ?? 8;
this.lineStyle(sw, sc, strokeAlpha ?? 1);
}
// Fill Color or Texture
const {fillType, fillColor, fillAlpha} = this.data;
if ( fillType ) {
const fc = Color.from(fillColor || "#FFFFFF");
if ( (fillType === CONST.DRAWING_FILL_TYPES.PATTERN) && this.texture ) {
this.beginTextureFill({
texture: this.texture,
color: fc || 0xFFFFFF,
alpha: fc ? fillAlpha : 1
});
}
else this.beginFill(fc, fillAlpha);
}
// Draw the shape
const {shape, bezierFactor} = this.data;
switch ( shape.type ) {
case Drawing.SHAPE_TYPES.RECTANGLE:
this._drawRectangle();
break;
case Drawing.SHAPE_TYPES.ELLIPSE:
this._drawEllipse();
break;
case Drawing.SHAPE_TYPES.POLYGON:
if ( bezierFactor ) this._drawFreehand();
else this._drawPolygon();
break;
}
// Conclude fills
this.lineStyle(0x000000, 0.0).closePath().endFill();
// Set the drawing shape position
this.setPosition();
}
/* -------------------------------------------- */
/** @inheritDoc */
setPosition() {
const {x, y, z, hidden, shape, rotation} = this.data;
this.pivot.set(shape.width / 2, shape.height / 2);
this.position.set(x + this.pivot.x, y + this.pivot.y);
this.zIndex = z; // This is a temporary solution to ensure the sort order updates
this.angle = rotation;
}
/* -------------------------------------------- */
/** @inheritDoc */
_getCanvasDocumentData(data) {
const dt = super._getCanvasDocumentData(data);
dt.width = data.shape.width;
dt.height = data.shape.height;
return dt;
}
/* -------------------------------------------- */
/**
* Draw rectangular shapes.
* @protected
*/
_drawRectangle() {
const {shape, strokeWidth} = this.data;
const hs = strokeWidth / 2;
this.drawRect(hs, hs, shape.width - (2*hs), shape.height - (2*hs));
}
/* -------------------------------------------- */
/**
* Draw ellipsoid shapes.
* @protected
*/
_drawEllipse() {
const {shape, strokeWidth} = this.data;
const hw = shape.width / 2;
const hh = shape.height / 2;
const hs = strokeWidth / 2;
const width = Math.max(Math.abs(hw) - hs, 0);
const height = Math.max(Math.abs(hh) - hs, 0);
this.drawEllipse(hw, hh, width, height);
}
/* -------------------------------------------- */
/**
* Draw polygonal shapes.
* @protected
*/
_drawPolygon() {
const {shape, fillType} = this.data;
const points = shape.points;
if ( points.length < 4 ) return;
else if ( points.length === 4 ) this.endFill();
// Get drawing points
const first = points.slice(0, 2);
const last = points.slice(-2);
const isClosed = first.equals(last);
// If the polygon is closed, or if we are filling it, we can shortcut using the drawPolygon helper
if ( (points.length > 4) && (isClosed || fillType) ) return this.drawPolygon(points);
// Otherwise, draw each line individually
this.moveTo(...first);
for ( let i=3; i<points.length; i+=2 ) {
this.lineTo(points[i-1], points[i]);
}
}
/* -------------------------------------------- */
/**
* Draw freehand shapes with bezier spline smoothing.
* @protected
*/
_drawFreehand() {
const {bezierFactor, fillType, shape} = this.data;
// Get drawing points
let points = shape.points;
const first = points.slice(0, 2);
const last = points.slice(-2);
const isClosed = first.equals(last);
// Draw simple polygons if only 2 points are present
if ( points.length <= 4 ) return this._drawPolygon();
// Set initial conditions
const factor = bezierFactor ?? 0.5;
let previous = first;
let point = points.slice(2, 4);
points = points.concat(last); // Repeat the final point so the bezier control points know how to finish
let cp0 = DrawingShape.#getBezierControlPoints(factor, last, previous, point).nextCP;
let cp1;
let nextCP;
// Begin iteration
this.moveTo(first[0], first[1]);
for ( let i=4; i<points.length-1; i+=2 ) {
const next = [points[i], points[i+1]];
if ( next ) {
let bp = DrawingShape.#getBezierControlPoints(factor, previous, point, next);
cp1 = bp.cp1;
nextCP = bp.nextCP;
}
// First point
if ( (i === 4) && !isClosed ) {
this.quadraticCurveTo(cp1.x, cp1.y, point[0], point[1]);
}
// Last Point
else if ( (i === points.length-2) && !isClosed ) {
this.quadraticCurveTo(cp0.x, cp0.y, point[0], point[1]);
}
// Bezier points
else {
this.bezierCurveTo(cp0.x, cp0.y, cp1.x, cp1.y, point[0], point[1]);
}
// Increment
previous = point;
point = next;
cp0 = nextCP;
}
// Close the figure if a fill is required
if ( fillType && !isClosed ) this.lineTo(first[0], first[1]);
}
/* -------------------------------------------- */
/**
* Attribution: The equations for how to calculate the bezier control points are derived from Rob Spencer's article:
* http://scaledinnovation.com/analytics/splines/aboutSplines.html
* @param {number} factor The smoothing factor
* @param {number[]} previous The prior point
* @param {number[]} point The current point
* @param {number[]} next The next point
* @returns {{cp1: Point, nextCP: Point}} The bezier control points
* @private
*/
static #getBezierControlPoints(factor, previous, point, next) {
// Calculate distance vectors
const vector = {x: next[0] - previous[0], y: next[1] - previous[1]};
const preDistance = Math.hypot(point[0] - previous[0], point[1] - previous[1]);
const postDistance = Math.hypot(next[0] - point[0], next[1] - point[1]);
const distance = preDistance + postDistance;
// Compute control point locations
const cp0d = distance === 0 ? 0 : factor * (preDistance / distance);
const cp1d = distance === 0 ? 0 : factor * (postDistance / distance);
// Return points
return {
cp1: {
x: point[0] - (vector.x * cp0d),
y: point[1] - (vector.y * cp0d)
},
nextCP: {
x: point[0] + (vector.x * cp1d),
y: point[1] + (vector.y * cp1d)
}
};
}
}
/**
* A mixin which decorates a DisplayObject with depth and/or occlusion properties.
* @category - Mixins
* @param {typeof PIXI.DisplayObject} DisplayObject The parent DisplayObject class being mixed
* @returns {typeof OccludableObject} A DisplayObject subclass mixed with OccludableObject features
*/
function OccludableObjectMixin(DisplayObject) {
// Verify that the display object is a prototype of SpriteMesh (for occlusion, we need the shader class support)
// TODO: Remove PIXI.TilingSprite as soon as possible!
if ( !(foundry.utils.isSubclass(DisplayObject, SpriteMesh) ||
foundry.utils.isSubclass(DisplayObject, PIXI.TilingSprite)) ) {
throw new Error("Occludable objects must be a subclass of SpriteMesh.");
}
return class OccludableObject extends PrimaryCanvasObjectMixin(DisplayObject) {
constructor(...args) {
super(...args);
this.setShaderClass(InverseOcclusionSamplerShader);
this.shader.enabled = false;
this.updateTextureData();
}
/**
* @typedef {Object} OccludableObjectData
* @property {boolean} roof The PCO is considered as a roof?
* @property {object} occlusion The occlusion object for this PCO
*/
static get defaultData() {
return foundry.utils.mergeObject(super.defaultData, {
roof: false,
occlusion: {
mode: CONST.OCCLUSION_MODES.NONE,
alpha: 0,
radius: null
}
});
};
/**
* Contains :
* - the bounds of the texture data
* - the cached mapping of non-transparent pixels (if roof)
* - the filtered render texture (if roof)
* @type {{minX: number, minY: number, maxX: number, maxY: number, pixels: Uint8Array, texture: PIXI.RenderTexture}}
* @protected
*/
_textureData;
/**
* A flag which tracks whether the primary canvas object is currently in an occluded state.
* @type {boolean}
*/
occluded = false;
/**
* Force or cancel the rendering of the PCO depth. If undefined, the underlying logic decide.
* @type {boolean}
*/
forceRenderDepth;
/**
* A flag which tracks occluded state change for PCO with roof quality.
* @type {boolean}
*/
#prevOccludedState = false;
/* -------------------------------------------- */
/**
* Is this occludable object... occludable?
* @type {boolean}
*/
get isOccludable() {
return this.data.occlusion.mode > CONST.OCCLUSION_MODES.NONE;
}
/* -------------------------------------------- */
/**
* Should this PCO render its depth?
* @type {boolean}
*/
get shouldRenderDepth() {
return this.forceRenderDepth ?? (this.data.roof && !this.data.hidden);
}
/* -------------------------------------------- */
/**
* Debounce assignment of the PCO occluded state to avoid cases like animated token movement which can rapidly
* change PCO appearance.
* Uses a 50ms debounce threshold.
* @type {function(occluded: boolean): void}
*/
debounceSetOcclusion = foundry.utils.debounce(occluded => {
this.occluded = occluded;
this.refreshOcclusion();
}, 50);
/* -------------------------------------------- */
/**
* Compute and returns the normal and occlusion alpha for this occludable object.
* @returns {{alphaNormal: number, alphaOccluded: number}}
* @protected
*/
_getOcclusionAlpha() {
const {alpha, hidden, occlusion, roof} = this.data;
const foreground = roof || ((this.data.elevation > canvas.primary.background.elevation) && canvas.tiles.active);
const alphaForeground = foreground ? (canvas.tiles.displayRoofs ? alpha : 0.5) : alpha;
const alphaNormal = hidden ? 0.25 : (foreground ? alphaForeground : alpha);
const alphaOccluded = this.occluded ? occlusion.alpha : 1.0;
return {alphaNormal, alphaOccluded};
}
/* -------------------------------------------- */
/**
* Refresh the appearance of the occlusion state for tiles which are affected by a Token beneath them.
*/
refreshOcclusion() {
if ( !this.visible || !this.renderable ) return;
const {hidden, occlusion} = this.data;
const {alphaNormal, alphaOccluded} = this._getOcclusionAlpha();
// Tracking if roof has an occlusion state change to initialize vision
if ( this.#prevOccludedState !== this.occluded ) {
canvas.perception.update({initializeVision: true});
this.#prevOccludedState = this.occluded;
}
// Other modes
const mode = occlusion.mode;
const modes = CONST.OCCLUSION_MODES;
switch ( mode ) {
// Fade Entire occludable object
case modes.FADE:
this.shader.enabled = false;
this.alpha = Math.min(alphaNormal, alphaOccluded);
break;
// Radial Occlusion
case modes.RADIAL:
this.shader.enabled = this.occluded && !hidden;
this.shader.uniforms.alpha = alphaNormal;
this.shader.uniforms.alphaOcclusion = alphaOccluded;
this.shader.uniforms.depthElevation = canvas.primary.mapElevationToDepth(this.elevation);
this.alpha = this.occluded ? (hidden ? alphaOccluded : 1.0) : alphaNormal;
break;
// Vision-Based Occlusion
case modes.VISION:
const visionEnabled = !hidden && canvas.effects.visionSources.some(s => s.active);
this.shader.enabled = visionEnabled;
this.shader.uniforms.alpha = alphaNormal;
this.shader.uniforms.alphaOcclusion = occlusion.alpha;
this.shader.uniforms.depthElevation = canvas.primary.mapElevationToDepth(this.elevation);
this.alpha = this.occluded ? (visionEnabled ? 1.0 : alphaOccluded) : alphaNormal;
break;
// Default state (as well as None occlusion mode)
default:
this.shader.enabled = false;
this.alpha = alphaNormal;
}
// FIXME in V12
if ( this.object instanceof PlaceableObject ) this.alpha = Math.min(this.alpha, this.object.alpha);
// Called here redundantly as a special case to allow modules to react when rendered occlusion changes
Hooks.callAll("refreshOcclusion", this);
// TODO: Deprecated: this hook will disappear in version 13 and is keeped for compatibility
if ( this.object instanceof Tile ) Hooks.callAll("refreshTile", this.object);
}
/* -------------------------------------------- */
/**
* Render the depth of this primary canvas object.
* @param {PIXI.Renderer} renderer
*/
renderDepthData(renderer) {
if ( !this.shouldRenderDepth ) return;
const modes = CONST.OCCLUSION_MODES;
const occluded = this.occluded;
let occlusionMode = this.data.occlusion.mode;
if ( ((occlusionMode === modes.RADIAL) && !occluded)
|| ((occlusionMode === modes.VISION) && !canvas.effects.visionSources.some(s => s.active)) ) {
occlusionMode = modes.FADE;
}
const isModeNone = (occlusionMode === modes.NONE);
const isModeFade = (occlusionMode === modes.FADE);
const isMaskingLight = (isModeFade && !occluded) || !isModeFade;
const isMaskingWeather = (isModeFade && occluded) || !(isModeNone || isModeFade);
// Forcing the batch plugin to render roof mask
this.pluginName = OcclusionSamplerShader.classPluginName;
// Saving the value from the mesh
const originalTint = this.tint;
const originalBlendMode = this.blendMode;
const originalAlpha = this.worldAlpha;
// Rendering the roof sprite
this.tint = 0xFF0000 + (isMaskingLight ? 0xFF00 : 0x0) + (isMaskingWeather ? 0xFF : 0x0);
this.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
this.worldAlpha = canvas.primary.mapElevationToDepth(this.elevation);
this._batchData.occlusionMode = occlusionMode;
if ( this.visible && this.renderable ) this._render(renderer);
// Restoring original values
this.tint = originalTint;
this.blendMode = originalBlendMode;
this.worldAlpha = originalAlpha;
// Stop forcing batched plugin
this.pluginName = null;
}
/* -------------------------------------------- */
/**
* Process the PCO texture :
* Use the texture to create a cached mapping of pixel alpha for this Tile with real base texture size.
* Cache the bounding box of non-transparent pixels for the un-rotated shape.
* @returns {{minX: number, minY: number, maxX: number, maxY: number, pixels: Uint8Array|undefined}}
*/
updateTextureData() {
if ( !this.isOccludable || !this.texture?.valid ) return;
const aw = Math.abs(this.data.width);
const ah = Math.abs(this.data.height);
// If no tile texture is present
if ( !this.texture ) return this._textureData = {minX: 0, minY: 0, maxX: aw, maxY: ah};
// If texture date exists for this texture, we return it
const src = this.data.texture.src ?? this.texture?.baseTexture?.textureCacheIds[0];
this._textureData = TextureLoader.textureBufferDataMap.get(src);
if ( this._textureData ) return this._textureData;
else this._textureData = {
pixels: undefined,
minX: undefined,
maxX: undefined,
minY: undefined,
maxY: undefined
};
// Else, we are preparing the texture data creation
const map = this._textureData;
// Create a temporary Sprite using the Tile texture
const sprite = new PIXI.Sprite(this.texture);
sprite.width = map.aw = Math.ceil(this.texture.baseTexture.realWidth / 4);
sprite.height = map.ah = Math.ceil(this.texture.baseTexture.realHeight / 4);
// Create or update the alphaMap render texture
const tex = PIXI.RenderTexture.create({width: map.aw, height: map.ah});
// Render the sprite to the texture and extract its pixels
// Destroy sprite and texture when they are no longer needed
canvas.app.renderer.render(sprite, {renderTexture: tex});
sprite.destroy(false);
const pixels = canvas.app.renderer.extract.pixels(tex);
tex.destroy(true);
// Create new buffer for storing alpha channel only
map.pixels = new Uint8Array(pixels.length / 4);
// Map the alpha pixels
for ( let i = 0; i < map.pixels.length; i++ ) {
const a = map.pixels[i] = pixels[(i * 4) + 3];
if ( a > 0 ) {
const x = i % map.aw;
const y = Math.floor(i / map.aw);
if ( (map.minX === undefined) || (x < map.minX) ) map.minX = x;
else if ( (map.maxX === undefined) || (x + 1 > map.maxX) ) map.maxX = x + 1;
if ( (map.minY === undefined) || (y < map.minY) ) map.minY = y;
else if ( (map.maxY === undefined) || (y + 1 > map.maxY) ) map.maxY = y + 1;
}
}
// Saving the texture data
TextureLoader.textureBufferDataMap.set(src, map);
return this._textureData;
}
/* -------------------------------------------- */
/**
* Test whether a specific Token occludes this PCO.
* Occlusion is tested against 9 points, the center, the four corners-, and the four cardinal directions
* @param {Token} token The Token to test
* @param {object} [options] Additional options that affect testing
* @param {boolean} [options.corners=true] Test corners of the hit-box in addition to the token center?
* @returns {boolean} Is the Token occluded by the PCO?
*/
testOcclusion(token, {corners=true}={}) {
const {elevation, occlusion} = this.data;
if ( occlusion.mode === CONST.OCCLUSION_MODES.NONE ) return false;
if ( token.document.elevation >= elevation ) return false;
const {x, y, w, h} = token;
let testPoints = [[w / 2, h / 2]];
if ( corners ) {
const pad = 2;
const cornerPoints = [
[pad, pad],
[w / 2, pad],
[w - pad, pad],
[w - pad, h / 2],
[w - pad, h - pad],
[w / 2, h - pad],
[pad, h - pad],
[pad, h / 2]
];
testPoints = testPoints.concat(cornerPoints);
}
for ( const [tx, ty] of testPoints ) {
if ( this.containsPixel(x + tx, y + ty) ) return true;
}
return false;
}
/* -------------------------------------------- */
/**
* Test whether the PCO pixel data contains a specific point in canvas space
* @param {number} x
* @param {number} y
* @param {number} alphaThreshold Value from which the pixel is taken into account, in the range [0, 1].
* @returns {boolean}
*/
containsPixel(x, y, alphaThreshold = 0.75) {
return this.getPixelAlpha(x, y) > (alphaThreshold * 255);
}
/* -------------------------------------------- */
/**
* Get alpha value at specific canvas coordinate.
* @param {number} x
* @param {number} y
* @returns {number|null} The alpha value (-1 if outside of the bounds) or null if no mesh or texture is present.
*/
getPixelAlpha(x, y) {
if ( !this._textureData?.pixels ) return null;
const textureCoord = this._getTextureCoordinate(x, y);
return this.#getPixelAlpha(textureCoord.x, textureCoord.y);
}
/* -------------------------------------------- */
/**
* Get PCO alpha map texture coordinate with canvas coordinate
* @param {number} testX Canvas x coordinate.
* @param {number} testY Canvas y coordinate.
* @returns {object} The texture {x, y} coordinates, or null if not able to do the conversion.
* @protected
*/
_getTextureCoordinate(testX, testY) {
const {x, y, width, height, rotation, texture} = this.data;
// Save scale properties
const sscX = Math.sign(texture.scaleX);
const sscY = Math.sign(texture.scaleY);
const ascX = Math.abs(texture.scaleX);
const ascY = Math.abs(texture.scaleY);
// Adjusting point by taking scale into account
testX -= (x - (width / 2) * sscX * (ascX - 1));
testY -= (y - (height / 2) * sscY * (ascY - 1));
// Mirroring the point on x/y axis if scale is negative
if ( sscX < 0 ) testX = (width - testX);
if ( sscY < 0 ) testY = (height - testY);
// Account for tile rotation and scale
if ( rotation !== 0 ) {
// Anchor is recomputed with scale and document dimensions
const anchor = {
x: this.anchor.x * width * ascX,
y: this.anchor.y * height * ascY
};
let r = new Ray(anchor, {x: testX, y: testY});
r = r.shiftAngle(-this.rotation * sscX * sscY); // Reverse rotation if scale is negative for just one axis
testX = r.B.x;
testY = r.B.y;
}
// Convert to texture data coordinates
testX *= (this._textureData.aw / this.width);
testY *= (this._textureData.ah / this.height);
return {x: testX, y: testY};
}
/* -------------------------------------------- */
/**
* Get alpha value at specific texture coordinate.
* @param {number} x
* @param {number} y
* @returns {number} The alpha value (or -1 if outside of the bounds).
*/
#getPixelAlpha(x, y) {
// First test against the bounding box
if ( (x < this._textureData.minX) || (x >= this._textureData.maxX) ) return -1;
if ( (y < this._textureData.minY) || (y >= this._textureData.maxY) ) return -1;
// Next test a specific pixel
const px = (Math.floor(y) * this._textureData.aw) + Math.floor(x);
return this._textureData.pixels[px];
}
/* -------------------------------------------- */
/**
* Compute the alpha-based bounding box for the tile, including an angle of rotation.
* @returns {PIXI.Rectangle}
* @private
*/
_getAlphaBounds() {
const m = this._textureData;
const r = Math.toRadians(this.data.rotation);
return PIXI.Rectangle.fromRotation(m.minX, m.minY, m.maxX - m.minX, m.maxY - m.minY, r).normalize();
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
renderOcclusion(renderer) {
const msg = "PrimaryCanvasObject#renderOcclusion is deprecated in favor of PrimaryCanvasObject#renderDepth";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
this.renderDepthData(renderer);
}
}
}
/**
* A mixin which decorates a DisplayObject with additional properties expected for rendering in the PrimaryCanvasGroup.
* @category - Mixins
* @param {typeof PIXI.DisplayObject} DisplayObject The parent DisplayObject class being mixed
* @returns {typeof PrimaryCanvasObject} A DisplayObject subclass mixed with PrimaryCanvasObject features
*/
function PrimaryCanvasObjectMixin(DisplayObject) {
/**
* A display object rendered in the PrimaryCanvasGroup.
* @param {PlaceableObject|object} placeableObjectOrData A linked PlaceableObject, or an independent data object
* @param {...*} args Additional arguments passed to the base class constructor
*/
return class PrimaryCanvasObject extends DisplayObject {
constructor(placeableObjectOrData, ...args) {
super(...args);
this.data = foundry.utils.deepClone(this.constructor.defaultData);
let data = placeableObjectOrData;
// Linked Case: provide a PlaceableObject instance
if ( placeableObjectOrData instanceof PlaceableObject ) {
this.object = placeableObjectOrData;
data = this.object.document;
this.#linkObjectProperties();
}
this.initialize(data);
this.cullable = true;
}
/* -------------------------------------------- */
/**
* The PlaceableObject which is rendered to the PrimaryCanvasGroup (or undefined if no object is associated)
* @type {PlaceableObject}
*/
object;
/**
* Should the behavior of this primary canvas object be linked to an upstream PlaceableObject?
* @type {boolean}
*/
#linkedToObject = false;
/**
* Universal data object for this mesh.
* @type {PrimaryCanvasObjectData}
*/
data = {};
/**
* @typedef {Object} PrimaryCanvasObjectData
* @property {number} x The x-coordinate of the PCO location
* @property {number} y The y-coordinate of the PCO location
* @property {number} z The z-index of the PCO
* @property {number} width The width of the PCO
* @property {number} height The height of the PCO
* @property {number} alpha The alpha of this PCO
* @property {number} rotation The rotation of this PCO
* @property {boolean} hidden The PCO is hidden?
* @property {number} elevation The elevation of the PCO
* @property {number} sort The sort key that resolves ties among the same elevation
* @property {object} texture The data texture values
*/
static defaultData = {
x: 0,
y: 0,
z: 0,
width: 0,
height: 0,
alpha: 1,
rotation: 0,
hidden: false,
elevation: undefined,
sort: 0,
texture: {
scaleX: 1,
scaleY: 1,
src: null,
tint: null
}
};
/* -------------------------------------------- */
/**
* An elevation in distance units which defines how this Object is sorted relative to its siblings.
* @type {number}
*/
get elevation() {
return this.data.elevation;
}
/* -------------------------------------------- */
/**
* A sort key which resolves ties amongst objects at the same elevation.
* @type {number}
*/
get sort() {
return this.data.sort;
}
/* -------------------------------------------- */
/**
* A convenient reference to a Document associated with this display object, if any.
* @type {ClientDocument|null}
*/
get document() {
return this.object?.document || null;
}
/* -------------------------------------------- */
/* Data Initialization */
/* -------------------------------------------- */
/**
* Initialize data using an explicitly provided data object or a canvas document.
* @param {PrimaryCanvasObjectData|Document} data Provided data or canvas document.
*/
initialize(data={}) {
if ( data instanceof foundry.abstract.Document ) data = this._getCanvasDocumentData(data);
foundry.utils.mergeObject(this.data, data, {inplace: true, insertKeys: false, overwrite: true, insertValues: true});
this.refresh();
this.updateBounds();
this._initializeSorting(data.sort);
}
/* -------------------------------------------- */
/**
* Map the document data to an object and process some properties.
* @param {Document} data The document data.
* @returns {Object} The updated data object.
* @protected
*/
_getCanvasDocumentData(data) {
const dt = foundry.utils.filterObject(data, this.constructor.defaultData);
dt.elevation = data.elevation ?? 0;
dt.sort = data.sort ?? 0;
if ( data.texture?.tint !== undefined ) {
dt.texture.tint = Color.from(dt.texture.tint).valueOf();
if ( Number.isNaN(dt.texture.tint) ) dt.texture.tint = null;
}
return dt;
}
/* -------------------------------------------- */
/**
* Initialize sorting of this PCO. Perform checks and call the primary group sorting if necessary.
* @param {number} sort The sort value. Must be a finite number or undefined (in this case, it is ignored)
* @protected
*/
_initializeSorting(sort) {
if ( (this.data.sort === sort) || (sort === undefined) ) return;
this.data.sort = Number.isFinite(sort) ? sort : 0;
canvas.primary.sortDirty = true;
}
/* -------------------------------------------- */
/**
* Define properties according to linked object.
*/
#linkObjectProperties() {
this.#linkedToObject = true;
Object.defineProperties(this, {
visible: {
get: () => this.object.visible,
set(visible) {
this.object.visible = visible;
}
},
renderable: {
get: () => this.object.renderable,
set(renderable) {
this.object.renderable = renderable;
}
}
});
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritDoc */
destroy(...args) {
canvas.primary.quadtree.remove(this);
super.destroy(...args);
}
/* -------------------------------------------- */
/**
* Synchronize the appearance of this ObjectMesh with the properties of its represented Document.
* @abstract
*/
refresh() {}
/* -------------------------------------------- */
/**
* Synchronize the position of the ObjectMesh using the position of its represented Document.
* @abstract
*/
setPosition() {}
/* -------------------------------------------- */
/**
* Synchronize the bounds of the ObjectMesh into the primary group quadtree.
*/
updateBounds() {
if ( this.parent !== canvas.primary ) return;
// Computing the bounds of this PCO and adding it to the primary group quadtree
const {x, y, rotation} = this.data;
const aw = Math.abs(this.width);
const ah = Math.abs(this.height);
const r = Math.toRadians(rotation);
const bounds = ((aw === ah) ? new PIXI.Rectangle(x, y, aw, ah) : PIXI.Rectangle.fromRotation(x, y, aw, ah, r));
canvas.primary.quadtree.update({r: bounds, t: this});
}
};
}
/**
* A SpriteMesh which visualizes a Tile object in the PrimaryCanvasGroup.
*/
class TileMesh extends OccludableObjectMixin(SpriteMesh) {
/** @inheritDoc */
refresh() {
if ( this._destroyed || (this.texture === PIXI.Texture.EMPTY) ) return;
const {x, y, width, height, alpha, occlusion, hidden} = this.data;
const {scaleX, scaleY, tint} = this.data.texture;
// Use the document width explicitly
this.width = width;
this.height = height;
// Apply scale on each axis (a negative scaleX/scaleY is flipping the image on its axis)
this.scale.x = (width / this.texture.width) * scaleX;
this.scale.y = (height / this.texture.height) * scaleY;
// Set opacity and tint
const normalAlpha = hidden ? Math.min(0.5, alpha) : alpha;
this.alpha = this.occluded ? Math.min(occlusion.alpha, normalAlpha) : normalAlpha;
this.tint = Color.from(tint ?? 0xFFFFFF);
// Compute x/y by taking into account scale and mesh anchor
const px = x + ((width - this.width) * this.anchor.x) + (this.anchor.x * this.width);
const py = y + ((height - this.height) * this.anchor.y) + (this.anchor.y * this.height);
this.setPosition(px, py);
// Update the texture data for occlusion
this.updateTextureData();
}
/* -------------------------------------------- */
/** @inheritDoc */
setPosition(x, y) {
const {z, rotation} = this.data;
this.position.set(x ?? this.data.x, y ?? this.data.y);
this.angle = rotation;
this.zIndex = z;
}
/* -------------------------------------------- */
/** @inheritDoc */
updateBounds() {
if ( this.parent !== canvas.primary ) return;
// Computing the bounds of this PCO and adding it to the primary group quadtree
const {x, y, rotation} = this.data;
const aw = Math.abs(this.width);
const ah = Math.abs(this.height);
const r = Math.toRadians(rotation);
const bounds = ((aw === ah) ? new PIXI.Rectangle(x, y, aw, ah) : PIXI.Rectangle.fromRotation(x, y, aw, ah, r));
canvas.primary.quadtree.update({r: bounds, t: this});
}
/* -------------------------------------------- */
/** @inheritDoc */
_getCanvasDocumentData(data) {
const dt = super._getCanvasDocumentData(data);
// Searching for explicit overhead=false, if found, we are considering this PCO as not occludable
// TODO: to remove when the overhead property will be migrated
if ( data.overhead === false ) dt.occlusion.mode = CONST.OCCLUSION_MODES.NONE;
return dt;
}
}
/* -------------------------------------------- */
/**
* A special case subclass of PIXI.TilingSprite which is used in cases where the tile texture needs to repeat.
* This should eventually be refactored in favor of a more generalized TilingMesh.
* FIXME: Workaround until we have our custom TilingMesh class.
*/
class TileSprite extends OccludableObjectMixin(PIXI.TilingSprite) {
constructor(...args) {
super(...args);
// This is a workaround currently needed for TilingSprite textures due to a presumed upstream PIXI bug
this.texture.baseTexture.mipmap = PIXI.MIPMAP_MODES.OFF;
this.texture.baseTexture.update();
}
// TODO: Temporary, just to avoid error with TilingSprite
/** @override */
setShaderClass() {}
/** @override */
renderDepthData() {}
/** @override */
get isOccludable() { return false; }
/** @override */
get shouldRenderDepth() { return false; }
/** @override */
set shader(value) {}
/** @override */
get shader() {
return {};
}
}
Object.defineProperty(TileSprite.prototype, "refresh", Object.getOwnPropertyDescriptor(TileMesh.prototype, "refresh"));
Object.defineProperty(TileSprite.prototype, "setPosition", Object.getOwnPropertyDescriptor(TileMesh.prototype, "setPosition"));
/**
* A SpriteMesh which visualizes a Token object in the PrimaryCanvasGroup.
*/
class TokenMesh extends OccludableObjectMixin(SpriteMesh) {
/**
* Sorting values to deal with ties.
* @type {number}
*/
static PRIMARY_SORT_ORDER = 750;
/**
* The dimensions of this token mesh grid bounds.
* @type {PIXI.Rectangle}
*/
#tokenGridDimensions;
/* -------------------------------------------- */
/**
* @typedef {Object} TokenMeshData
* @property {boolean} lockRotation Is this TokenMesh rotation locked?
*/
static get defaultData() {
return foundry.utils.mergeObject(super.defaultData, {
lockRotation: false
});
};
/* -------------------------------------------- */
/** @inheritDoc */
refresh() {
if ( this._destroyed || (this.texture === PIXI.Texture.EMPTY) ) return;
const display = this.getDisplayAttributes();
// Size the texture
const rect = this.#tokenGridDimensions = canvas.grid.grid.getRect(display.width, display.height);
const aspectRatio = this.texture.width / this.texture.height;
// Ensure that square tokens are scaled consistently on hex grids.
if ( (aspectRatio === 1) && canvas.grid.isHex ) {
const minSide = Math.min(rect.width, rect.height);
display.scaleX = (display.scaleX * minSide) / this.texture.width;
display.scaleY = (display.scaleY * minSide) / this.texture.height;
}
else if ( aspectRatio >= 1 ) {
display.scaleX *= (rect.width / this.texture.width);
display.scaleY *= (rect.width / (this.texture.height * aspectRatio));
} else {
display.scaleY *= (rect.height / this.texture.height);
display.scaleX *= ((rect.height * aspectRatio) / this.texture.width);
}
// Assign scale and attributes
this.scale.set(display.scaleX, display.scaleY);
this.alpha = display.alpha;
this.tint = display.tint;
// Compute x/y by taking into account scale and mesh anchor
const px = display.x + ((rect.width - this.width) * this.anchor.x) + (this.anchor.x * this.width);
const py = display.y + ((rect.height - this.height) * this.anchor.y) + (this.anchor.y * this.height);
this.setPosition(px, py);
// Update the texture data for occlusion
this.updateTextureData();
}
/* -------------------------------------------- */
/** @inheritDoc */
setPosition(x, y) {
const {z, rotation, lockRotation} = this.data;
this.position.set(x ?? this.data.x, y ?? this.data.y);
this.angle = lockRotation ? 0 : rotation;
this.zIndex = z;
}
/* -------------------------------------------- */
/** @inheritDoc */
updateBounds() {
if ( this.parent !== canvas.primary ) return;
const {x, y, rotation, lockRotation} = this.data;
const aw = Math.abs(this.width);
const ah = Math.abs(this.height);
const r = Math.toRadians(lockRotation ? 0 : rotation);
const bounds = PIXI.Rectangle.fromRotation(x, y, aw, ah, r);
canvas.primary.quadtree.update({r: bounds, t: this});
}
/* -------------------------------------------- */
/** @override */
_getTextureCoordinate(testX, testY) {
const {x, y, texture} = this.data;
const rWidth = this.#tokenGridDimensions.width;
const rHeight = this.#tokenGridDimensions.height;
const rotation = this.rotation;
// Save scale properties
const sscX = Math.sign(texture.scaleX);
const sscY = Math.sign(texture.scaleY);
const ascX = Math.abs(texture.scaleX);
const ascY = Math.abs(texture.scaleY);
// Adjusting point by taking scale into account
testX -= (x - ((rWidth / 2) * sscX * (ascX - 1)));
testY -= (y - ((rHeight / 2) * sscY * (ascY - 1)));
// Mirroring the point on the x or y-axis if scale is negative
if ( sscX < 0 ) testX = (rWidth - testX);
if ( sscY < 0 ) testY = (rHeight - testY);
// Account for tile rotation and scale
if ( rotation !== 0 ) {
// Anchor is recomputed with scale and document dimensions
const anchor = {
x: this.anchor.x * rWidth * ascX,
y: this.anchor.y * rHeight * ascY
};
let r = new Ray(anchor, {x: testX, y: testY});
r = r.shiftAngle(-rotation * sscX * sscY); // Reverse rotation if scale is negative for just one axis
testX = r.B.x;
testY = r.B.y;
}
// Convert to texture data coordinates
testX *= (this._textureData.aw / this.width);
testY *= (this._textureData.ah / this.height);
return {x: testX, y: testY};
}
/* -------------------------------------------- */
/**
* @typedef {object} TokenMeshDisplayAttributes
* @property {number} x
* @property {number} y
* @property {number} width
* @property {number} height
* @property {number} alpha
* @property {number} rotation
* @property {number} scaleX
* @property {number} scaleY
* @property {Color} tint
*/
/**
* Get the attributes for this TokenMesh which configure the display of this TokenMesh and are compatible
* with CanvasAnimation.
* @returns {TokenMeshDisplayAttributes}
*/
getDisplayAttributes() {
const {x, y, width, height, alpha, rotation, texture} = this.data;
const {scaleX, scaleY} = texture;
return {x, y, width, height, alpha, rotation, scaleX, scaleY, tint: texture.tint ?? 0xFFFFFF};
}
}
/**
* Provide the necessary methods to get a snapshot of the framebuffer into a render texture.
* Class meant to be used as a singleton.
* Created with the precious advices of dev7355608.
*/
class FramebufferSnapshot {
constructor() {
/**
* The RenderTexture that is the render destination for the framebuffer snapshot.
* @type {PIXI.RenderTexture}
*/
this.framebufferTexture = FramebufferSnapshot.#createRenderTexture();
// Listen for resize events
canvas.app.renderer.on("resize", () => this.#hasResized = true);
}
/**
* To know if we need to update the texture.
* @type {boolean}
*/
#hasResized = true;
/**
* A placeholder for temporary copy.
* @type {PIXI.Rectangle}
*/
#tempSourceFrame = new PIXI.Rectangle();
/* ---------------------------------------- */
/**
* Get the framebuffer texture snapshot.
* @param {PIXI.Renderer} renderer The renderer for this context.
* @returns {PIXI.RenderTexture} The framebuffer snapshot.
*/
getFramebufferTexture(renderer) {
// Need resize?
if ( this.#hasResized ) {
CachedContainer.resizeRenderTexture(renderer, this.framebufferTexture);
this.#hasResized = false;
}
// Flush batched operations before anything else
renderer.batch.flush();
const fb = renderer.framebuffer.current;
const vf = this.#tempSourceFrame.copyFrom(renderer.renderTexture.viewportFrame);
// Inverted Y in the case of canvas
if ( !fb ) vf.y = renderer.view.height - (vf.y + vf.height);
// Empty viewport
if ( !(vf.width > 0 && vf.height > 0) ) return PIXI.Texture.WHITE;
// Computing bounds of the source
let srcX = vf.x;
let srcY = vf.y;
let srcX2 = srcX + vf.width;
let srcY2 = srcY + vf.height;
// Inverted Y in the case of canvas
if ( !fb ) {
srcY = renderer.view.height - 1 - srcY;
srcY2 = srcY - vf.height;
}
// Computing bounds of the destination
let dstX = 0;
let dstY = 0;
let dstX2 = vf.width;
let dstY2 = vf.height;
// Preparing the gl context
const gl = renderer.gl;
const framebufferSys = renderer.framebuffer;
const currentFramebuffer = framebufferSys.current;
// Binding our render texture to the framebuffer
framebufferSys.bind(this.framebufferTexture.framebuffer, framebufferSys.viewport);
// Current framebuffer is binded as a read framebuffer (to prepare the blit)
gl.bindFramebuffer(gl.READ_FRAMEBUFFER, fb?.glFramebuffers[framebufferSys.CONTEXT_UID].framebuffer);
// Blit current framebuffer into our render texture
gl.blitFramebuffer(srcX, srcY, srcX2, srcY2, dstX, dstY, dstX2, dstY2, gl.COLOR_BUFFER_BIT, gl.NEAREST);
// Restore original behavior
framebufferSys.bind(currentFramebuffer, framebufferSys.viewport);
return this.framebufferTexture;
}
/* ---------------------------------------- */
/**
* Create a render texture, provide a render method and an optional clear color.
* @returns {PIXI.RenderTexture} A reference to the created render texture.
*/
static #createRenderTexture() {
const renderer = canvas.app?.renderer;
return PIXI.RenderTexture.create({
width: renderer?.screen.width ?? window.innerWidth,
height: renderer?.screen.height ?? window.innerHeight,
resolution: renderer.resolution ?? PIXI.settings.RESOLUTION
});
}
}
/**
* A smooth noise generator for one-dimensional values.
* @param {object} options Configuration options for the noise process.
* @param {number} [options.amplitude=1] The generated noise will be on the range [0, amplitude].
* @param {number} [options.scale=1] An adjustment factor for the input x values which place them on an
* appropriate range.
* @param {number} [options.maxReferences=256] The number of pre-generated random numbers to generate.
*/
class SmoothNoise {
constructor({amplitude=1, scale=1, maxReferences=256}={}) {
// Configure amplitude
this.amplitude = amplitude;
// Configure scale
this.scale = scale;
// Create pre-generated random references
if ( !Number.isInteger(maxReferences) || !PIXI.utils.isPow2(maxReferences) ) {
throw new Error("SmoothNoise maxReferences must be a positive power-of-2 integer.");
}
Object.defineProperty(this, "_maxReferences", {value: maxReferences || 1, writable: false});
Object.defineProperty(this, "_references", {value: [], writable: false});
for ( let i = 0; i < this._maxReferences; i++ ) {
this._references.push(Math.random());
}
}
/**
* Amplitude of the generated noise output
* The noise output is multiplied by this value
* @type {number[]}
*/
get amplitude() {
return this._amplitude;
}
set amplitude(amplitude) {
if ( !Number.isFinite(amplitude) || (amplitude === 0) ) {
throw new Error("SmoothNoise amplitude must be a finite non-zero number.");
}
this._amplitude = amplitude;
}
_amplitude;
/**
* Scale factor of the random indices
* @type {number[]}
*/
get scale() {
return this._scale;
}
set scale(scale) {
if ( !Number.isFinite(scale) || (scale <= 0 ) ) {
throw new Error("SmoothNoise scale must be a finite positive number.");
}
this._scale = scale;
}
_scale;
/**
* Generate the noise value corresponding to a provided numeric x value.
* @param {number} x Any finite number
* @return {number} The corresponding smoothed noise value
*/
generate(x) {
const scaledX = x * this._scale; // The input x scaled by some factor
const xFloor = Math.floor(scaledX); // The integer portion of x
const t = scaledX - xFloor; // The fractional remainder, zero in the case of integer x
const tSmooth = t * t * (3 - 2 * t); // Smooth cubic [0, 1] for mixing between random numbers
const i0 = xFloor & (this._maxReferences - 1); // The current index of the references array
const i1 = (i0 + 1) & (this._maxReferences - 1); // The next index of the references array
const y = Math.mix(this._references[i0], this._references[i1], tSmooth); // Smoothly mix between random numbers
return y * this._amplitude; // The final result is multiplied by the requested amplitude
};
}
/**
* A class or interface that provide support for WebGL async read pixel/texture data extraction.
*/
class TextureExtractor {
constructor(renderer, {callerName, controlHash, format=PIXI.FORMATS.RED}={}) {
this.#renderer = renderer;
this.#callerName = callerName ?? "TextureExtractor";
this.#compressor = new TextureCompressor("Compressor", {debug: false, controlHash});
// Verify that the required format is supported by the texture extractor
if ( !((format === PIXI.FORMATS.RED) || (format === PIXI.FORMATS.RGBA)) ) {
throw new Error("TextureExtractor supports format RED and RGBA only.")
}
// Assign format, types, and read mode
this.#format = format;
this.#type = PIXI.TYPES.UNSIGNED_BYTE;
this.#readFormat = (((format === PIXI.FORMATS.RED) && !canvas.supported.readPixelsRED)
|| format === PIXI.FORMATS.RGBA) ? PIXI.FORMATS.RGBA : PIXI.FORMATS.RED;
// We need to intercept context change
this.#renderer.runners.contextChange.add(this);
}
/**
* List of compression that could be applied with extraction
* @enum {number}
*/
static COMPRESSION_MODES = {
NONE: 0,
BASE64: 1
};
/**
* The WebGL2 renderer.
* @type {Renderer}
*/
#renderer;
/**
* The reference to a WebGL2 sync object.
* @type {WebGLSync}
*/
#glSync;
/**
* The texture format on which the Texture Extractor must work.
* @type {PIXI.FORMATS}
*/
#format
/**
* The texture type on which the Texture Extractor must work.
* @type {PIXI.TYPES}
*/
#type
/**
* The texture format on which the Texture Extractor should read.
* @type {PIXI.FORMATS}
*/
#readFormat
/**
* The reference to the GPU buffer.
* @type {WebGLBuffer}
*/
#gpuBuffer;
/**
* To know if we need to create a GPU buffer.
* @type {boolean}
*/
#createBuffer;
/**
* Debug flag.
* @type {boolean}
*/
debug;
/**
* The reference to the pixel buffer.
* @type {Uint8ClampedArray}
*/
pixelBuffer;
/**
* The caller name associated with this instance of texture extractor (optional, used for debug)
* @type {string}
*/
#callerName;
/**
* Generated RenderTexture for textures.
* @type {PIXI.RenderTexture}
*/
#generatedRenderTexture;
/* -------------------------------------------- */
/* TextureExtractor Compression Worker */
/* -------------------------------------------- */
/**
* The compressor worker wrapper
* @type {TextureCompressor}
*/
#compressor;
/* -------------------------------------------- */
/* TextureExtractor Properties */
/* -------------------------------------------- */
/**
* Returns the read buffer width/height multiplier.
* @returns {number}
*/
get #readBufferMul() {
return this.#readFormat === PIXI.FORMATS.RED ? 1 : 4;
}
/* -------------------------------------------- */
/* TextureExtractor Synchronization */
/* -------------------------------------------- */
/**
* Handling of the concurrency for the extraction (by default a queue of 1)
* @type {Semaphore}
*/
#queue = new foundry.utils.Semaphore();
/* -------------------------------------------- */
/**
* @typedef {Object} TextureExtractionOptions
* @property {PIXI.Texture|PIXI.RenderTexture|null} [texture] The texture the pixels are extracted from.
* Otherwise, extract from the renderer.
* @property {PIXI.Rectangle} [frame] The rectangle which the pixels are extracted from.
* @property {TextureExtractor.COMPRESSION_MODES} [compression] The compression mode to apply, or NONE
* @property {string} [type] The optional image mime type.
* @property {string} [quality] The optional image quality.
* @property {boolean} [debug] The optional debug flag to use.
*/
/**
* Extract a rectangular block of pixels from the texture (without un-pre-multiplying).
* @param {TextureExtractionOptions} options Options which configure extraction behavior
* @returns {Promise}
*/
async extract(options={}) {
return this.#queue.add(this.#extract.bind(this), options);
}
/* -------------------------------------------- */
/* TextureExtractor Methods/Interface */
/* -------------------------------------------- */
/**
* Extract a rectangular block of pixels from the texture (without un-pre-multiplying).
* @param {TextureExtractionOptions} options Options which configure extraction behavior
* @returns {Promise}
*/
async #extract({texture, frame, compression, type, quality, debug}={}) {
// Set the debug flag
this.debug = debug;
if ( this.debug ) this.#consoleDebug("Begin texture extraction.");
// Checking texture validity
const baseTexture = texture?.baseTexture;
if ( texture && (!baseTexture || !baseTexture.valid || baseTexture.parentTextureArray) ) {
throw new Error("Texture passed to extractor is invalid.");
}
// Checking if texture is in RGBA format and premultiplied
if ( texture && (texture.baseTexture.alphaMode > 0) && (texture.baseTexture.format === PIXI.FORMATS.RGBA) ) {
throw new Error("Texture Extractor is not supporting premultiplied textures yet.");
}
let resolution;
// If the texture is a RT, use its frame and resolution
if ( (texture instanceof PIXI.RenderTexture) && ((baseTexture.format === this.#format)
|| (this.#readFormat === PIXI.FORMATS.RGBA) )
&& (baseTexture.type === this.#type) ) {
frame ??= texture.frame;
resolution = baseTexture.resolution;
}
// Case when the texture is not a render texture
// Generate a render texture and assign frame and resolution from it
else {
texture = this.#generatedRenderTexture = this.#renderer.generateTexture(new PIXI.Sprite(texture), {
format: this.#format,
type: this.#type,
resolution: baseTexture.resolution,
multisample: PIXI.MSAA_QUALITY.NONE
});
frame ??= this.#generatedRenderTexture.frame;
resolution = texture.baseTexture.resolution;
}
// Bind the texture
this.#renderer.renderTexture.bind(texture);
// Get the buffer from the GPU
const data = await this.#readPixels(frame, resolution);
// Return the compressed image or the raw buffer
if ( compression ) {
return await this.#compressBuffer(data.buffer, data.width, data.height, {compression, type, quality});
}
else if ( (this.#format === PIXI.FORMATS.RED) && (this.#readFormat === PIXI.FORMATS.RGBA) ) {
const result = await this.#compressor.reduceBufferRGBAToBufferRED(data.buffer, data.width, data.height, {compression, type, quality});
// Returning control of the buffer to the extractor
this.pixelBuffer = result.buffer;
// Returning the result
return result.redBuffer;
}
return data.buffer;
}
/* -------------------------------------------- */
/**
* Free all the bound objects.
*/
reset() {
if ( this.debug ) this.#consoleDebug("Data reset.");
this.#clear({buffer: true, syncObject: true, rt: true});
}
/* -------------------------------------------- */
/**
* Called by the renderer contextChange runner.
*/
contextChange() {
if ( this.debug ) this.#consoleDebug("WebGL context has changed.");
this.#glSync = undefined;
this.#generatedRenderTexture = undefined;
this.#gpuBuffer = undefined;
this.pixelBuffer = undefined;
}
/* -------------------------------------------- */
/* TextureExtractor Management */
/* -------------------------------------------- */
/**
* Compress the buffer and returns a base64 image.
* @param {*} args
* @returns {Promise<string>}
*/
async #compressBuffer(...args) {
if ( canvas.supported.offscreenCanvas ) return this.#compressBufferWorker(...args);
else return this.#compressBufferLocal(...args);
}
/* -------------------------------------------- */
/**
* Compress the buffer into a worker and returns a base64 image
* @param {Uint8ClampedArray} buffer Buffer to convert into a compressed base64 image.
* @param {number} width Width of the image.
* @param {number} height Height of the image.
* @param {object} options
* @param {string} options.type Format of the image.
* @param {number} options.quality Quality of the compression.
* @returns {Promise<string>}
*/
async #compressBufferWorker(buffer, width, height, {type, quality}={}) {
let result;
try {
// Launch compression
result = await this.#compressor.compressBufferBase64(buffer, width, height, {
type: type ?? "image/png",
quality: quality ?? 1,
debug: this.debug,
readFormat: this.#readFormat
});
}
catch(e) {
this.#consoleError("Buffer compression has failed!");
throw e;
}
// Returning control of the buffer to the extractor
this.pixelBuffer = result.buffer;
// Returning the result
return result.base64img;
}
/* -------------------------------------------- */
/**
* Compress the buffer locally (but expand the buffer into a worker) and returns a base64 image.
* The image format is forced to jpeg.
* @param {Uint8ClampedArray} buffer Buffer to convert into a compressed base64 image.
* @param {number} width Width of the image.
* @param {number} height Height of the image.
* @param {object} options
* @param {number} options.quality Quality of the compression.
* @returns {Promise<string>}
*/
async #compressBufferLocal(buffer, width, height, {quality}={}) {
let rgbaBuffer;
if ( this.#readFormat === PIXI.FORMATS.RED ) {
let result;
try {
// Launch buffer expansion on the worker thread
result = await this.#compressor.expandBufferRedToBufferRGBA(buffer, width, height, {
debug: this.debug
});
} catch(e) {
this.#consoleError("Buffer expansion has failed!");
throw e;
}
// Returning control of the buffer to the extractor
this.pixelBuffer = result.buffer;
rgbaBuffer = result.rgbaBuffer;
} else {
rgbaBuffer = buffer;
}
if ( !rgbaBuffer ) return;
// Proceed at the compression locally and return the base64 image
const element = ImageHelper.pixelsToCanvas(rgbaBuffer, width, height);
return await ImageHelper.canvasToBase64(element, "image/jpeg", quality); // Force jpeg compression
}
/* -------------------------------------------- */
/**
* Prepare data for the asynchronous readPixel.
* @param {PIXI.Rectangle} frame
* @param {number} resolution
* @returns {object}
*/
async #readPixels(frame, resolution) {
const gl = this.#renderer.gl;
// Set dimensions and buffer size
const x = Math.round(frame.left * resolution);
const y = Math.round(frame.top * resolution);
const width = Math.round(frame.width * resolution);
const height = Math.round(frame.height * resolution);
const bufSize = width * height * this.#readBufferMul;
// Set format and type needed for the readPixel command
const format = this.#readFormat;
const type = gl.UNSIGNED_BYTE;
// Useful debug information
if ( this.debug ) console.table({x, y, width, height, bufSize, format, type, extractorFormat: this.#format});
// The buffer that will hold the pixel data
const pixels = this.#getPixelCache(bufSize);
// Start the non-blocking read
// Create or reuse the GPU buffer and bind as buffer data
if ( this.#createBuffer ) {
if ( this.debug ) this.#consoleDebug("Creating buffer.");
this.#createBuffer = false;
if ( this.#gpuBuffer ) this.#clear({buffer: true});
this.#gpuBuffer = gl.createBuffer();
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.#gpuBuffer);
gl.bufferData(gl.PIXEL_PACK_BUFFER, bufSize, gl.DYNAMIC_READ);
}
else {
if ( this.debug ) this.#consoleDebug("Reusing cached buffer.");
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.#gpuBuffer);
}
// Performs read pixels GPU Texture -> GPU Buffer
gl.pixelStorei(gl.PACK_ALIGNMENT, 1);
gl.readPixels(x, y, width, height, format, type, 0);
gl.pixelStorei(gl.PACK_ALIGNMENT, 4);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
// Declare the sync object
this.#glSync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
// Flush all pending gl commands, including the commands above (important: flush is non blocking)
// The glSync will be complete when all commands will be executed
gl.flush();
// Waiting for the sync object to resolve
await this.#wait();
// Retrieve the GPU buffer data
const data = this.#getGPUBufferData(pixels, width, height, bufSize);
// Clear the sync object and possible generated render texture
this.#clear({syncObject: true, rt: true});
// Return the data
if ( this.debug ) this.#consoleDebug("Buffer data sent to caller.");
return data;
}
/* -------------------------------------------- */
/**
* Retrieve the content of the GPU buffer and put it pixels.
* Returns an object with the pixel buffer and dimensions.
* @param {Uint8ClampedArray} buffer The pixel buffer.
* @param {number} width The width of the texture.
* @param {number} height The height of the texture.
* @param {number} bufSize The size of the buffer.
* @returns {object<Uint8ClampedArray, number, number>}
*/
#getGPUBufferData(buffer, width, height, bufSize) {
const gl = this.#renderer.gl;
// Retrieve the GPU buffer data
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.#gpuBuffer);
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, buffer, 0, bufSize);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
return {buffer, width, height};
}
/* -------------------------------------------- */
/**
* Retrieve a pixel buffer of the given length.
* A cache is provided for the last length passed only (to avoid too much memory consumption)
* @param {number} length Length of the required buffer.
* @returns {Uint8ClampedArray} The cached or newly created buffer.
*/
#getPixelCache(length) {
if ( this.pixelBuffer?.length !== length ) {
this.pixelBuffer = new Uint8ClampedArray(length);
// If the pixel cache need to be (re)created, the same for the GPU buffer
this.#createBuffer = true;
}
return this.pixelBuffer;
}
/* -------------------------------------------- */
/**
* Wait for the synchronization object to resolve.
* @returns {Promise}
*/
async #wait() {
// Preparing data for testFence
const gl = this.#renderer.gl;
const sync = this.#glSync;
// Prepare for fence testing
const result = await new Promise((resolve, reject) => {
/**
* Test the fence sync object
*/
function wait() {
const res = gl.clientWaitSync(sync, 0, 0);
if ( res === gl.WAIT_FAILED ) {
reject(false);
return;
}
if ( res === gl.TIMEOUT_EXPIRED ) {
setTimeout(wait, 10);
return;
}
resolve(true);
}
wait();
});
// The promise was rejected?
if ( !result ) {
this.#clear({buffer: true, syncObject: true, data: true, rt: true});
throw new Error("The sync object has failed to wait.");
}
}
/* -------------------------------------------- */
/**
* Clear some key properties.
* @param {object} options
* @param {boolean} [options.buffer=false]
* @param {boolean} [options.syncObject=false]
* @param {boolean} [options.rt=false]
*/
#clear({buffer=false, syncObject=false, rt=false}={}) {
if ( syncObject && this.#glSync ) {
// Delete the sync object
this.#renderer.gl.deleteSync(this.#glSync);
this.#glSync = undefined;
if ( this.debug ) this.#consoleDebug("Free the sync object.");
}
if ( buffer ) {
// Delete the buffers
if ( this.#gpuBuffer ) {
this.#renderer.gl.deleteBuffer(this.#gpuBuffer);
this.#gpuBuffer = undefined;
}
this.pixelBuffer = undefined;
this.#createBuffer = true;
if ( this.debug ) this.#consoleDebug("Free the cached buffers.");
}
if ( rt && this.#generatedRenderTexture ) {
// Delete the generated render texture
this.#generatedRenderTexture.destroy(true);
this.#generatedRenderTexture = undefined;
if ( this.debug ) this.#consoleDebug("Destroy the generated render texture.");
}
}
/* -------------------------------------------- */
/**
* Convenience method to display the debug messages with the extractor.
* @param {string} message The debug message to display.
*/
#consoleDebug(message) {
console.debug(`${this.#callerName} | ${message}`);
}
/* -------------------------------------------- */
/**
* Convenience method to display the error messages with the extractor.
* @param {string} message The error message to display.
*/
#consoleError(message) {
console.error(`${this.#callerName} | ${message}`);
}
}
/* 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.
* @extends {AbstractBaseShader}
* @interface
*/
class AdaptiveLightingShader extends AbstractBaseShader {
/** @inheritdoc */
static vertexShader = `
${this.VERTEX_ATTRIBUTES}
${this.VERTEX_UNIFORMS}
${this.VERTEX_FRAGMENT_VARYINGS}
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 */
/* -------------------------------------------- */
/**
* Determine the correct penalty to apply for a given darkness level and luminosity
* @param {number} darknessLevel The current darkness level on [0,1]
* @param {number} luminosity The light source luminosity on [-1,1]
* @returns {number} The amount of penalty to apply on [0,1]
*/
getDarknessPenalty(darknessLevel, luminosity) {
const l = Math.max(luminosity, 0); // [0,1]
return (darknessLevel / 4) * (1 - l); // [0, 0.25]
}
/* -------------------------------------------- */
/**
* 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 && !darkness ) {
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(colorBright, colorDim, dist);`;
/**
* Incorporate falloff if a attenuation uniform is requested
* @type {string}
*/
static FALLOFF = `
if ( attenuation > 0.0 && !darkness ) finalColor *= smoothstep(0.995 - attenuation * 0.995, 1.0, 1.0 - dist);`;
/**
* Initialize fragment with common properties
* @type {string}
*/
static FRAGMENT_BEGIN = `
float dist = distance(vUvs, vec2(0.5)) * 2.0;
float depth = smoothstep(0.0, 1.0, vDepth);
vec4 baseColor = (useSampler ? texture2D(primaryTexture, vSamplerUvs) : vec4(0.0));
vec4 depthColor = texture2D(depthTexture, vSamplerUvs);
vec3 finalColor = baseColor.rgb;
`;
/**
* Shader final
* @type {string}
*/
static FRAGMENT_END = `
gl_FragColor = vec4(finalColor, 1.0);`;
/* -------------------------------------------- */
/* Shader Techniques for lighting */
/* -------------------------------------------- */
/**
* A mapping of available shader techniques
* @type {Object<string, ShaderTechnique>}
*/
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(colorBackground);
vec3 mutedColor = mix(finalColor,
finalColor * mix(color, colorBackground, ambientColorIntensity),
backgroundAlpha);
finalColor = mix( finalColor,
mutedColor,
darknessLevel);`
}
};
}
/* -------------------------------------------- */
/**
* The default coloration shader used by standard rendering and animations
* A fragment shader which creates a solid light source.
* @implements {AdaptiveLightingShader}
*/
class AdaptiveBackgroundShader extends AdaptiveLightingShader {
/**
* Shader final
* @type {string}
*/
static FRAGMENT_END = `
gl_FragColor = finalColor4c * depth;
`;
/**
* Incorporate falloff if a attenuation uniform is requested
* @type {string}
*/
static FALLOFF = `
vec4 finalColor4c = mix( vec4(finalColor, baseColor.a), vec4(0.0), smoothstep(0.995 - attenuation * 0.995, 1.0, dist));
finalColor4c = mix(finalColor4c, vec4(0.0), 1.0 - step(depthColor.g, depthElevation) * depth);
`;
/**
* Memory allocations for the Adaptive Background Shader
* @type {string}
*/
static SHADER_HEADER = `
${this.FRAGMENT_UNIFORMS}
${this.VERTEX_FRAGMENT_VARYINGS}
${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}
}`;
/** @inheritdoc */
static defaultUniforms = (() => {
const initial = foundry.data.LightData.cleanData();
return {
technique: initial.coloration,
contrast: initial.contrast,
shadows: initial.shadows,
saturation: initial.saturation,
intensity: initial.animation.intensity,
attenuation: initial.attenuation,
exposure: 0,
ratio: 0.5,
darkness: false,
color: [1, 1, 1],
colorBackground: [1, 1, 1],
screenDimensions: [1, 1],
time: 0,
useSampler: true,
primaryTexture: null,
depthTexture: null,
depthElevation: 1
};
})();
/**
* 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.effects.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._defaults[k]);
}
}
/* -------------------------------------------- */
/**
* The default coloration shader used by standard rendering and animations
* A fragment shader which creates a solid light source.
* @implements {AdaptiveLightingShader}
*/
class AdaptiveIlluminationShader extends AdaptiveLightingShader {
/** @override */
static FRAGMENT_BEGIN = `
float dist = distance(vUvs, vec2(0.5)) * 2.0;
float depth = smoothstep(0.0, 1.0, vDepth);
vec4 baseColor = (useSampler ? texture2D(primaryTexture, vSamplerUvs) : vec4(0.0));
vec4 depthColor = texture2D(depthTexture, vSamplerUvs);
vec3 framebufferColor = texture2D(framebufferTexture, vSamplerUvs).rgb;
vec3 finalColor = baseColor.rgb;
`;
/**
* Fragment end
* @type {string}
*/
static FRAGMENT_END = `
// Darkness
if ( !darkness ) framebufferColor = min(framebufferColor, colorBackground);
else framebufferColor = max(framebufferColor, colorBackground);
// Elevation
finalColor = mix(finalColor, framebufferColor, 1.0 - step(depthColor.g, depthElevation));
// Final
gl_FragColor = vec4(mix(framebufferColor, finalColor, depth), depth);
`;
/**
* Incorporate falloff if a attenuation uniform is requested
* @type {string}
*/
static FALLOFF = `
depth *= (1.0 - smoothstep(0.98 - attenuation * 0.98, 1.0, dist));
`;
/**
* 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 && !darkness ) {
// 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.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 = (() => {
const initial = foundry.data.LightData.cleanData();
return {
technique: initial.coloration,
shadows: initial.shadows,
saturation: initial.saturation,
intensity: initial.animation.intensity,
attenuation: initial.attenuation,
contrast: initial.contrast,
exposure: 0,
ratio: 0.5,
darkness: false,
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,
depthElevation: 1
};
})();
/**
* Flag whether the illumination shader is currently required.
* @type {boolean}
*/
get isRequired() {
const vs = canvas.effects.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 default coloration shader used by standard rendering and animations.
* A fragment shader which creates a light source.
* @implements {AdaptiveLightingShader}
*/
class AdaptiveColorationShader extends AdaptiveLightingShader {
/**
* Shader final
* @type {string}
*/
static FRAGMENT_END = `
gl_FragColor = finalColor4c * depth;
`;
/**
* The adjustments made into fragment shaders
* @type {string}
*/
static get ADJUSTMENTS() {
return `
vec3 changedColor = finalColor;\n
${this.SATURATION}
${this.SHADOW}
finalColor = changedColor;\n`;
}
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;
}
`;
/**
* Incorporate falloff if a falloff uniform is requested
* @type {string}
*/
static FALLOFF = `
vec4 finalColor4c;
float smooth = smoothstep(0.98 - attenuation * 0.98, 1.0, dist);
if ( darkness ) {
vec3 final = vec3(1.0);
finalColor4c = vec4(final *= smooth, 1.0);
} else {
finalColor4c = vec4(finalColor *= (1.0 - smooth), 1.0);
}
finalColor4c = mix(finalColor4c, vec4(0.0), 1.0 - step(depthColor.g, depthElevation));
`;
/**
* Memory allocations for the Adaptive Coloration Shader
* @type {string}
*/
static SHADER_HEADER = `
${this.FRAGMENT_UNIFORMS}
${this.VERTEX_FRAGMENT_VARYINGS}
${this.CONSTANTS}
${this.SWITCH_COLOR}
`;
/** @inheritdoc */
static fragmentShader = `
${this.SHADER_HEADER}
${this.PERCEIVED_BRIGHTNESS}
void main() {
${this.FRAGMENT_BEGIN}
finalColor = (darkness ? vec3(0.0) : color * colorationAlpha);
${this.COLORATION_TECHNIQUES}
${this.ADJUSTMENTS}
${this.FALLOFF}
${this.FRAGMENT_END}
}`;
/** @inheritdoc */
static defaultUniforms = (() => {
const initial = foundry.data.LightData.cleanData();
return {
technique: initial.coloration,
shadows: initial.shadows,
saturation: initial.saturation,
colorationAlpha: 1,
intensity: initial.animation.intensity,
attenuation: initial.attenuation,
ratio: 0.5,
color: [1, 1, 1],
time: 0,
darkness: false,
hasColor: false,
screenDimensions: [1, 1],
useSampler: false,
primaryTexture: null,
depthTexture: null,
depthElevation: 1
};
})();
/**
* Flag whether the coloration shader is currently required.
* @type {boolean}
*/
get isRequired() {
const vs = canvas.effects.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 or if it's darkness
return (this.uniforms.hasColor || this.uniforms.darkness);
}
}
/* -------------------------------------------- */
/**
* Allow coloring of illumination
* @extends {AdaptiveIlluminationShader}
* @author SecretFire
*/
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
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
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});
}
/* -------------------------------------------- */
/**
* Pulse animation illumination shader
* @extends {AdaptiveIlluminationShader}
* @author SecretFire
*/
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
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
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});
}
/* -------------------------------------------- */
/**
* Energy field animation coloration shader
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
class EnergyFieldColorationShader extends AdaptiveColorationShader {
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}
}`;
}
/* -------------------------------------------- */
/**
* Chroma animation coloration shader
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
class ChromaColorationShader extends AdaptiveColorationShader {
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}
}`;
}
/* -------------------------------------------- */
/**
* Wave animation illumination shader
* @extends {AdaptiveIlluminationShader}
* @author SecretFire
*/
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
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
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}
}`;
}
/* -------------------------------------------- */
/**
* Bewitching Wave animation illumination shader
* @extends {AdaptiveIlluminationShader}
* @author SecretFire
*/
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
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
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}
}`;
}
/* -------------------------------------------- */
/**
* Fog animation coloration shader
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
class FogColorationShader extends AdaptiveColorationShader {
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}
}`;
}
/* -------------------------------------------- */
/**
* Sunburst animation illumination shader
* @extends {AdaptiveIlluminationShader}
* @author SecretFire
*/
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(colorBright, colorDim, dist);
${this.ADJUSTMENTS}
finalColor = sunBurst(finalColor, uv, dist);
${this.FALLOFF}
${this.FRAGMENT_END}
}`;
}
/**
* Sunburst animation coloration shader
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
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}
}`;
}
/* -------------------------------------------- */
/**
* Light dome animation coloration shader
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
class LightDomeColorationShader extends AdaptiveColorationShader {
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}
}`;
}
/* -------------------------------------------- */
/**
* Emanation animation coloration shader
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
class EmanationColorationShader extends AdaptiveColorationShader {
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}
}`;
}
/* -------------------------------------------- */
/**
* Ghost light animation illumination shader
* @extends {AdaptiveIlluminationShader}
* @author SecretFire
*/
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
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
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
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
class HexaDomeColorationShader extends AdaptiveColorationShader {
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}
}`;
}
/* -------------------------------------------- */
/**
* Roling mass illumination shader - intended primarily for darkness
* @extends {AdaptiveIlluminationShader}
* @author SecretFire
*/
class RoilingIlluminationShader extends AdaptiveIlluminationShader {
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 = distance(uv, PIVOT) * 2.0;
float alphaBright, alphaDim;
// R'lyeh Ftagnh !
float smooth = smoothstep(ratio * 0.95, ratio * 1.05, clamp(ddist, 0.0, 1.0));
float inSmooth = min(smooth, 1.0 - smooth) * 2.0;
// Creating the spooky membrane around the bright area
vec3 membraneColor = vec3(1.0 - inSmooth);
// Intensity modifier
if ( darkness ) {
alphaBright = 1.0 - pow(clamp(ratio - ddist, 0.0, 1.0), 0.75) * sqrt(2.0 - ddist);
alphaDim = 1.0 - pow(clamp(1.0 - ddist, 0.0, 1.0), 0.65);
} else {
alphaBright = 1.0;
alphaDim = 1.0;
}
float intensMod = intensity * 0.25;
if ( !darkness && attenuation > 0.0 && ratio > 0.0 ) {
finalColor = mix(colorBright * intensMod * (darkness ? 1.0 : 1.5),
colorDim * intensMod,
smoothstep(ratio * 0.8, clamp(ratio * 0.95, 0.0001, 1.0), clamp(ddist, 0.0, 1.0)))
* min(alphaBright, alphaDim);
} else finalColor = mix(colorDim, colorBright, step(ddist, ratio)) * min(alphaBright, alphaDim) * intensMod;
finalColor *= membraneColor;
${this.ADJUSTMENTS}
${this.FALLOFF}
${this.FRAGMENT_END}
}`;
}
/* -------------------------------------------- */
/**
* Black Hole animation illumination shader
* @extends {AdaptiveIlluminationShader}
* @author SecretFire
*/
class BlackHoleIlluminationShader extends AdaptiveIlluminationShader {
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, in vec3 pCol) {
float angle = atan(uv.x, uv.y) * INVTWOPI;
// Create the beams
float beams = fract(angle * intensity + sin(dist * 30.0 - time));
// Compose the final beams and reverse beams, to get a nice gradient on EACH side of the beams.
beams = max(beams, 1.0 - beams);
// Compute a darkness modifier.
float darknessPower = (darkness ? pow(beams, 1.5) : 0.8);
// Creating the effect : applying color and darkness power correction. saturate the entire output color.
vec3 smoothie = smoothstep(0.2, 1.1 + (intensity * 0.1), beams * pCol * darknessPower);
return ( darkness ? smoothie : pCol * (1.0 - beams) ) * intensity;
}
void main() {
${this.FRAGMENT_BEGIN}
vec2 uvs = (2.0 * vUvs) - 1.0;
vec3 pColorDim, pColorBright;
if ( darkness ) {
// palette of colors to give the darkness a disturbing purpleish tone
pColorDim = vec3(0.25, 0.10, 0.35);
pColorBright = vec3(0.85, 0.80, 0.95);
} else {
pColorDim = vec3(0.5);
pColorBright = vec3(1.0);
}
// smooth mixing of the palette by distance from center and bright ratio
vec3 pCol = mix(pColorDim, pColorBright, smoothstep(ratio * 0.9, ratio * 1.1, (darkness ? dist : 1.0 - dist) ));
if ( darkness ) {
finalColor = min(colorDim,
mix(colorBright,
beamsEmanation(uvs, dist, pCol),
1.0 - sqrt(1.0 - dist)));
} else {
finalColor = mix(colorBright,
mix(colorDim,
beamsEmanation(uvs, dist, pCol),
sqrt(dist)), (attenuation > 0.0 ? smoothstep(ratio * 0.8, clamp(ratio * 1.2, 0.0001, 1.0), dist) : step(1.0 - dist, ratio)) );
}
// Apply darker components of colorDim and mixed emanations/colorBright.
${this.ADJUSTMENTS}
${this.FALLOFF}
${this.FRAGMENT_END}
}`;
}
/* -------------------------------------------- */
/**
* Vortex animation coloration shader
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
class VortexColorationShader extends AdaptiveColorationShader {
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}
if ( !darkness ) {
// 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}
} else {
finalColor = vec3(0.0);
}
${this.FALLOFF}
${this.FRAGMENT_END}
}`;
}
/* -------------------------------------------- */
/**
* Vortex animation coloration shader
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
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(colorDim, colorBright * colorMod, 1.0 - smoothstep( 0.80, 1.00, lum)) *
smoothstep( 0.25 * intens, 0.85 * intens, lum);
}
void main() {
${this.FRAGMENT_BEGIN}
if ( darkness ) {
// Timed values
float t = time * 0.5;
float cost = cos(t) * 2.0;
float sint = sin(t) * 2.0;
// Rotation matrix
mat2 rotmatrix = mat2(cost, -sint, sint, cost);
// Creating vortex
vec2 svuv = vortex(vUvs, dist, 1.0, 6.24, rotmatrix);
vec2 nvuv = vortex(vUvs, dist, 1.0, 2.12, rotmatrix);
// Applying spice
vec3 normalSpice = spice(nvuv, rotmatrix);
finalColor = convertToDarknessColors( max(normalSpice, spice(svuv, rotmatrix)), dist );
} else {
${this.TRANSITION}
}
${this.ADJUSTMENTS}
${this.FALLOFF}
${this.FRAGMENT_END}
}`;
}
/* -------------------------------------------- */
/**
* Swirling rainbow animation coloration shader
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
class SwirlingRainbowColorationShader extends AdaptiveColorationShader {
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}
}`;
}
/* -------------------------------------------- */
/**
* Radial rainbow animation coloration shader
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
class RadialRainbowColorationShader extends AdaptiveColorationShader {
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}
}`;
}
/* -------------------------------------------- */
/**
* Fairy light animation coloration shader
* @extends {AdaptiveColorationShader}
* @author SecretFire
*/
class FairyLightColorationShader extends AdaptiveColorationShader {
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
* @extends {AdaptiveIlluminationShader}
* @author SecretFire
*/
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
* @extends {AdaptiveIlluminationShader}
*/
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
* @extends {AdaptiveColorationShader}
*/
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});
}
/* -------------------------------------------- */
/**
* A futuristic Force Grid animation.
* @extends {AdaptiveColorationShader}
*/
class ForceGridColorationShader extends AdaptiveColorationShader {
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}
}
`;
}
/* -------------------------------------------- */
/**
* A disco like star light.
* @extends {AdaptiveColorationShader}
*/
class StarLightColorationShader extends AdaptiveColorationShader {
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.40;
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);
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}
}
`;
}
/* -------------------------------------------- */
/**
* A patch of smoke
* @extends {AdaptiveColorationShader}
*/
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
* @extends {AdaptiveIlluminationShader}
*/
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}
if ( darkness ) finalColor = mix(framebufferColor, finalColor, smokefading(dist) * 2.0);
else finalColor *= smokefading(dist);
${this.ADJUSTMENTS}
${this.FALLOFF}
${this.FRAGMENT_END}
}
`;
}
/* -------------------------------------------- */
/**
* Revolving animation coloration shader
* @extends {AdaptiveColorationShader}
*/
class RevolvingColorationShader 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, 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
});
}
/* -------------------------------------------- */
/**
* Siren light animation coloration shader
* @extends {AdaptiveColorationShader}
*/
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
* @extends {AdaptiveIlluminationShader}
*/
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
});
}
/**
* This class defines an interface which all adaptive vision shaders extend.
* @extends {AdaptiveLightingShader}
* @interface
*/
class AdaptiveVisionShader extends AdaptiveLightingShader {
/** @override */
static EXPOSURE = `
// Computing exposed color for background
if ( exposure != 0.0 ) {
changedColor *= (1.0 + exposure);
}`;
/* -------------------------------------------- */
/** @override */
static SHADOW = "";
/* -------------------------------------------- */
/**
* Incorporate falloff if a attenuation uniform is requested
* @type {string}
*/
static FALLOFF = `
if ( attenuation > 0.0 ) finalColor *= smoothstep(0.995 - attenuation * 0.995, 1.0, 1.0 - dist);`;
/**
* Initialize fragment with common properties
* @type {string}
*/
static FRAGMENT_BEGIN = `
float dist = distance(vUvs, vec2(0.5)) * 2.0;
float depth = smoothstep(0.0, 1.0, vDepth);
vec4 baseColor = useSampler ? texture2D(primaryTexture, vSamplerUvs) : vec4(1.0);
vec4 depthColor = texture2D(depthTexture, vSamplerUvs);
vec3 finalColor = baseColor.rgb;`;
/* -------------------------------------------- */
/* Shader Techniques for vision */
/* -------------------------------------------- */
/**
* A mapping of available shader techniques
* @type {Object<string, ShaderTechnique>}
*/
static SHADER_TECHNIQUES = {
LEGACY: {
id: 0,
label: "LIGHT.AdaptiveLuminance",
coloration: `
float reflection = perceivedBrightness(baseColor);
finalColor *= reflection;`
}
};
}
/* -------------------------------------------- */
/**
* The default background shader used for vision sources
* @implements {AdaptiveVisionShader}
*/
class BackgroundVisionShader extends AdaptiveVisionShader {
/**
* Shader final
* @type {string}
*/
static FRAGMENT_END = `
gl_FragColor = finalColor4c * depth * vec4(colorTint, 1.0);`;
/**
* Incorporate falloff if a attenuation uniform is requested
* @type {string}
*/
static FALLOFF = `
if ( linkedToDarknessLevel ) finalColor = mix(baseColor.rgb, finalColor, darknessLevel);
vec4 finalColor4c = mix( vec4(finalColor, baseColor.a), vec4(0.0), smoothstep(0.9985 - attenuation * 0.9985, 1.0, dist));
finalColor4c = mix(finalColor4c, vec4(0.0), 1.0 - step(depthColor.g, depthElevation));
`;
/**
* Memory allocations for the Adaptive Background Shader
* @type {string}
*/
static SHADER_HEADER = `
${this.FRAGMENT_UNIFORMS}
${this.VERTEX_FRAGMENT_VARYINGS}
${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 = (() => {
return {
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,
depthElevation: 1
};
})();
/**
* 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._defaults[k]);
}
}
/* -------------------------------------------- */
/**
* The default illumination shader used for vision sources
* @implements {AdaptiveVisionShader}
*/
class IlluminationVisionShader extends AdaptiveVisionShader {
/** @override */
static FRAGMENT_BEGIN = `
float dist = distance(vUvs, vec2(0.5)) * 2.0;
float depth = smoothstep(0.0, 1.0, vDepth);
vec4 baseColor = useSampler ? texture2D(primaryTexture, vSamplerUvs) : vec4(1.0);
vec4 depthColor = texture2D(depthTexture, vSamplerUvs);
vec3 framebufferColor = texture2D(framebufferTexture, vSamplerUvs).rgb;
vec3 finalColor = baseColor.rgb;
`;
/**
* Constrain light to LOS
* @type {string}
*/
static FRAGMENT_END = `
framebufferColor = min(framebufferColor, colorBackground);
finalColor = mix(finalColor, framebufferColor, 1.0 - step(depthColor.g, depthElevation));
gl_FragColor = vec4(mix(framebufferColor, finalColor, depth), depth);
`;
/**
* Incorporate falloff if a attenuation uniform is requested
* @type {string}
*/
static FALLOFF = `
depth *= (1.0 - smoothstep(0.9985 - attenuation * 0.9985, 1.0, dist));
`;
/**
* Transition between bright and dim colors, if requested
* @type {string}
*/
static VISION_COLOR = `
finalColor = colorVision;
`;
/**
* 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.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 = (() => {
const initial = foundry.data.LightData.cleanData();
return {
technique: initial.technique,
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,
depthElevation: 1
};
})();
}
/* -------------------------------------------- */
/**
* The default coloration shader used for vision sources.
* @implements {AdaptiveLightingShader}
*/
class ColorationVisionShader extends AdaptiveVisionShader {
/**
* Shader final
* @type {string}
*/
static FRAGMENT_END = `
gl_FragColor = finalColor4c * depth;
`;
/** @override */
static EXPOSURE = "";
/** @override */
static CONTRAST = "";
/**
* Incorporate falloff if a falloff uniform is requested
* @type {string}
*/
static FALLOFF = `
vec4 finalColor4c = vec4(finalColor *= (1.0 - smoothstep(0.98 - attenuation * 0.98, 1.0, dist)), 1.0);
finalColor4c = mix(finalColor4c, vec4(0.0), 1.0 - step(depthColor.g, depthElevation));
`;
/**
* Memory allocations for the Adaptive Coloration Shader
* @type {string}
*/
static SHADER_HEADER = `
${this.FRAGMENT_UNIFORMS}
${this.VERTEX_FRAGMENT_VARYINGS}
${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 = (() => {
return {
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
};
})();
/**
* 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._defaults[k]);
}
}
/* -------------------------------------------- */
/**
* Shader specialized in wave like senses (tremorsenses)
* @implements {BackgroundVisionShader}
*/
class WaveBackgroundVisionShader extends BackgroundVisionShader {
/**
* Shader final
* @type {string}
*/
static FRAGMENT_END = `
gl_FragColor = finalColor4c * depth;`;
/** @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)
* @implements {ColorationVisionShader}
*/
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;
}
}
/* -------------------------------------------- */
/**
* Shader specialized in light amplification
* @implements {BackgroundVisionShader}
*/
class AmplificationBackgroundVisionShader extends BackgroundVisionShader {
/**
* Shader final
* @type {string}
*/
static FRAGMENT_END = `
gl_FragColor = finalColor4c * depth;`;
/** @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 - darknessLevel) * 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;
}
}
/**
* Apply a vertical or horizontal gaussian blur going inward by using alpha as the penetrating channel.
* @param {boolean} horizontal If the pass is horizontal (true) or vertical (false).
* @param {number} [strength=8] Strength of the blur (distance of sampling).
* @param {number} [quality=4] Number of passes to generate the blur. More passes = Higher quality = Lower Perf.
* @param {number} [resolution=PIXI.Filter.defaultResolution] Resolution of the filter.
* @param {number} [kernelSize=5] Number of kernels to use. More kernels = Higher quality = Lower Perf.
*/
class AlphaBlurFilterPass extends PIXI.Filter {
constructor(horizontal, strength=8, quality=4, resolution=PIXI.Filter.defaultResolution, kernelSize=5) {
const vertSrc = AlphaBlurFilterPass.vertTemplate(kernelSize, horizontal);
const fragSrc = AlphaBlurFilterPass.fragTemplate(kernelSize);
super(vertSrc, fragSrc);
this.horizontal = horizontal;
this.strength = strength;
this.passes = quality;
this.resolution = resolution;
}
/**
* If the pass is horizontal (true) or vertical (false).
* @type {boolean}
*/
horizontal;
/**
* Strength of the blur (distance of sampling).
* @type {number}
*/
strength;
/**
* The number of passes to generate the blur.
* @type {number}
*/
passes;
/* -------------------------------------------- */
/**
* The quality of the filter is defined by its number of passes.
* @returns {number}
*/
get quality() {
return this.passes;
}
set quality(value) {
this.passes = value;
}
/* -------------------------------------------- */
/**
* The strength of the blur filter in pixels.
* @returns {number}
*/
get blur() {
return this.strength;
}
set blur(value) {
this.padding = 1 + (Math.abs(value) * 2);
this.strength = value;
}
/* -------------------------------------------- */
/**
* The kernels containing the gaussian constants.
* @type {Object<number, number[]>}
*/
static GAUSSIAN_VALUES = {
5: [0.153388, 0.221461, 0.250301],
7: [0.071303, 0.131514, 0.189879, 0.214607],
9: [0.028532, 0.067234, 0.124009, 0.179044, 0.20236],
11: [0.0093, 0.028002, 0.065984, 0.121703, 0.175713, 0.198596],
13: [0.002406, 0.009255, 0.027867, 0.065666, 0.121117, 0.174868, 0.197641],
15: [0.000489, 0.002403, 0.009246, 0.02784, 0.065602, 0.120999, 0.174697, 0.197448]
};
/* -------------------------------------------- */
/**
* The fragment template generator
* @param {number} kernelSize The number of kernels to use.
* @returns {string} The generated fragment shader.
*/
static fragTemplate(kernelSize) {
return `
varying vec2 vBlurTexCoords[${kernelSize}];
varying vec2 vTextureCoords;
uniform sampler2D uSampler;
void main(void) {
vec4 finalColor = vec4(0.0);
${this.generateBlurFragSource(kernelSize)}
finalColor.rgb *= clamp(mix(-1.0, 1.0, finalColor.a), 0.0, 1.0);
gl_FragColor = finalColor;
}
`;
}
/* -------------------------------------------- */
/**
* The vertex template generator
* @param {number} kernelSize The number of kernels to use.
* @param {boolean} horizontal If the vertex should handle horizontal or vertical pass.
* @returns {string} The generated vertex shader.
*/
static vertTemplate(kernelSize, horizontal) {
return `
attribute vec2 aVertexPosition;
uniform mat3 projectionMatrix;
uniform float strength;
varying vec2 vBlurTexCoords[${kernelSize}];
varying vec2 vTextureCoords;
uniform vec4 inputSize;
uniform vec4 outputFrame;
vec4 filterVertexPosition( void ) {
vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;
return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0.0, 1.0);
}
vec2 filterTextureCoord( void ) {
return aVertexPosition * (outputFrame.zw * inputSize.zw);
}
void main(void) {
gl_Position = filterVertexPosition();
vec2 textureCoord = vTextureCoords = filterTextureCoord();
${this.generateBlurVertSource(kernelSize, horizontal)}
}
`;
}
/* -------------------------------------------- */
/**
* Generating the dynamic part of the blur in the fragment
* @param {number} kernelSize The number of kernels to use.
* @returns {string} The dynamic blur part.
*/
static generateBlurFragSource(kernelSize) {
const kernel = AlphaBlurFilterPass.GAUSSIAN_VALUES[kernelSize];
const halfLength = kernel.length;
let value;
let blurLoop = "";
for ( let i = 0; i < kernelSize; i++ ) {
blurLoop += `finalColor += texture2D(uSampler, vBlurTexCoords[${i.toString()}])`;
value = i >= halfLength ? kernelSize - i - 1 : i;
blurLoop += ` * ${kernel[value].toString()};\n`;
}
return blurLoop;
}
/* -------------------------------------------- */
/**
* Generating the dynamic part of the blur in the vertex
* @param {number} kernelSize The number of kernels to use.
* @param {boolean} horizontal If the vertex should handle horizontal or vertical pass.
* @returns {string} The dynamic blur part.
*/
static generateBlurVertSource(kernelSize, horizontal) {
const halfLength = Math.ceil(kernelSize / 2);
let blurLoop = "";
for ( let i = 0; i < kernelSize; i++ ) {
const khl = i - (halfLength - 1);
blurLoop += horizontal
? `vBlurTexCoords[${i.toString()}] = textureCoord + vec2(${khl}.0 * strength, 0.0);`
: `vBlurTexCoords[${i.toString()}] = textureCoord + vec2(0.0, ${khl}.0 * strength);`;
blurLoop += "\n";
}
return blurLoop;
}
/* -------------------------------------------- */
/** @override */
apply(filterManager, input, output, clearMode) {
// Define strength
const ow = output ? output.width : filterManager.renderer.width;
const oh = output ? output.height : filterManager.renderer.height;
this.uniforms.strength = (this.horizontal ? (1 / ow) * (ow / input.width) : (1 / oh) * (oh / input.height))
* this.strength / this.passes;
// Single pass
if ( this.passes === 1 ) {
return filterManager.applyFilter(this, input, output, clearMode);
}
// Multi-pass
const renderTarget = filterManager.getFilterTexture();
const renderer = filterManager.renderer;
let flip = input;
let flop = renderTarget;
// Initial application
this.state.blend = false;
filterManager.applyFilter(this, flip, flop, PIXI.CLEAR_MODES.CLEAR);
// Additional passes
for ( let i = 1; i < this.passes - 1; i++ ) {
filterManager.bindAndClear(flip, PIXI.CLEAR_MODES.BLIT);
this.uniforms.uSampler = flop;
const temp = flop;
flop = flip;
flip = temp;
renderer.shader.bind(this);
renderer.geometry.draw(5);
}
// Final pass and return filter texture
this.state.blend = true;
filterManager.applyFilter(this, flop, output, clearMode);
filterManager.returnFilterTexture(renderTarget);
}
}
/* -------------------------------------------- */
/**
* Apply a gaussian blur going inward by using alpha as the penetrating channel.
* @param {number} [strength=8] Strength of the blur (distance of sampling).
* @param {number} [quality=4] Number of passes to generate the blur. More passes = Higher quality = Lower Perf.
* @param {number} [resolution=PIXI.Filter.defaultResolution] Resolution of the filter.
* @param {number} [kernelSize=5] Number of kernels to use. More kernels = Higher quality = Lower Perf.
*/
class AlphaBlurFilter extends PIXI.Filter {
constructor(strength=8, quality=4, resolution=PIXI.Filter.defaultResolution, kernelSize=5) {
super();
this.blurXFilter = new AlphaBlurFilterPass(true, strength, quality, resolution, kernelSize);
this.blurYFilter = new AlphaBlurFilterPass(false, strength, quality, resolution, kernelSize);
this.resolution = resolution;
this._repeatEdgePixels = false;
this.quality = quality;
this.blur = strength;
}
/* -------------------------------------------- */
/** @override */
apply(filterManager, input, output, clearMode) {
const xStrength = Math.abs(this.blurXFilter.strength);
const yStrength = Math.abs(this.blurYFilter.strength);
// Blur both directions
if ( xStrength && yStrength ) {
const renderTarget = filterManager.getFilterTexture();
this.blurXFilter.apply(filterManager, input, renderTarget, PIXI.CLEAR_MODES.CLEAR);
this.blurYFilter.apply(filterManager, renderTarget, output, clearMode);
filterManager.returnFilterTexture(renderTarget);
}
// Only vertical
else if ( yStrength ) this.blurYFilter.apply(filterManager, input, output, clearMode);
// Only horizontal
else this.blurXFilter.apply(filterManager, input, output, clearMode);
}
/* -------------------------------------------- */
/**
* Update the filter padding according to the blur strength value (0 if _repeatEdgePixels is active)
*/
updatePadding() {
this.padding = this._repeatEdgePixels ? 0
: Math.max(Math.abs(this.blurXFilter.strength), Math.abs(this.blurYFilter.strength)) * 2;
}
/* -------------------------------------------- */
/**
* The amount of blur is forwarded to the X and Y filters.
* @type {number}
*/
get blur() {
return this.blurXFilter.blur;
}
set blur(value) {
this.blurXFilter.blur = this.blurYFilter.blur = value;
this.updatePadding();
}
/* -------------------------------------------- */
/**
* The quality of blur defines the number of passes used by subsidiary filters.
* @type {number}
*/
get quality() {
return this.blurXFilter.quality;
}
set quality(value) {
this.blurXFilter.quality = this.blurYFilter.quality = value;
}
/* -------------------------------------------- */
/**
* Whether to repeat edge pixels, adding padding to the filter area.
* @type {boolean}
*/
get repeatEdgePixels() {
return this._repeatEdgePixels;
}
set repeatEdgePixels(value) {
this._repeatEdgePixels = value;
this.updatePadding();
}
/* -------------------------------------------- */
/**
* Provided for completeness with PIXI.filters.BlurFilter
* @type {number}
*/
get blurX() {
return this.blurXFilter.blur;
}
set blurX(value) {
this.blurXFilter.blur = value;
this.updatePadding();
}
/* -------------------------------------------- */
/**
* Provided for completeness with PIXI.filters.BlurFilter
* @type {number}
*/
get blurY() {
return this.blurYFilter.blur;
}
set blurY(value) {
this.blurYFilter.blur = value;
this.updatePadding();
}
/* -------------------------------------------- */
/**
* Provided for completeness with PIXI.filters.BlurFilter
* @type {number}
*/
get blendMode() {
return this.blurYFilter.blendMode;
}
set blendMode(value) {
this.blurYFilter.blendMode = value;
}
}
/**
* This class defines an interface for masked custom filters
* @interface
*/
class AbstractBaseMaskFilter extends AbstractBaseFilter {
/**
* The default vertex shader used by all instances of AbstractBaseMaskFilter
* @type {string}
*/
static vertexShader = `
attribute vec2 aVertexPosition;
uniform mat3 projectionMatrix;
uniform vec2 screenDimensions;
uniform vec4 inputSize;
uniform vec4 outputFrame;
varying vec2 vTextureCoord;
varying vec2 vMaskTextureCoord;
vec4 filterVertexPosition( void ) {
vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;
return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0., 1.);
}
// getting normalized coord for the tile texture
vec2 filterTextureCoord( void ) {
return aVertexPosition * (outputFrame.zw * inputSize.zw);
}
// getting normalized coord for a screen sized mask render texture
vec2 filterMaskTextureCoord( in vec2 textureCoord ) {
return (textureCoord * inputSize.xy + outputFrame.xy) / screenDimensions;
}
void main() {
vTextureCoord = filterTextureCoord();
vMaskTextureCoord = filterMaskTextureCoord(vTextureCoord);
gl_Position = filterVertexPosition();
}`;
/** @override */
apply(filterManager, input, output, clear, currentState) {
this.uniforms.screenDimensions = canvas.screenDimensions;
filterManager.applyFilter(this, input, output, clear);
}
}
/* -------------------------------------------- */
/**
* A filter used to control channels intensity using an externally provided mask texture.
* The mask channel used must be provided at filter creation.
*/
class InverseOcclusionMaskFilter extends AdaptiveFragmentChannelMixin(AbstractBaseMaskFilter) {
/** @override */
static adaptiveFragmentShader(channel) {
return `
precision mediump float;
varying vec2 vTextureCoord;
varying vec2 vMaskTextureCoord;
uniform sampler2D uSampler;
uniform sampler2D uMaskSampler;
uniform float alphaOcclusion;
uniform float alpha;
uniform float elevation;
void main() {
float tex = texture2D(uMaskSampler, vMaskTextureCoord).${channel};
tex = 1.0 - step(tex, elevation);
float mask = 1.0 - tex + alphaOcclusion * tex;
float calpha = tex + alpha * (1.0 - tex);
gl_FragColor = texture2D(uSampler, vTextureCoord) * mask * calpha;
}`;
}
/** @override */
static defaultUniforms = {
uMaskSampler: null,
alphaOcclusion: 0,
alpha: 1,
depthElevation: 0
};
}
/* -------------------------------------------- */
/**
* A filter used to apply a reverse mask on the target display object.
* The caller must choose a channel to use (alpha is a good candidate).
*/
class ReverseMaskFilter extends AdaptiveFragmentChannelMixin(AbstractBaseMaskFilter) {
/** @override */
static adaptiveFragmentShader(channel) {
return `
precision mediump float;
varying vec2 vTextureCoord;
varying vec2 vMaskTextureCoord;
uniform sampler2D uSampler;
uniform sampler2D uMaskSampler;
void main() {
float mask = 1.0 - texture2D(uMaskSampler, vMaskTextureCoord).${channel};
gl_FragColor = texture2D(uSampler, vTextureCoord) * mask;
}`;
}
/** @override */
static defaultUniforms = {
uMaskSampler: null
};
}
/* -------------------------------------------- */
/**
* A minimalist filter (just used for blending)
*/
class VoidFilter extends AbstractBaseFilter {
static fragmentShader = `
varying vec2 vTextureCoord;
uniform sampler2D uSampler;
void main() {
gl_FragColor = texture2D(uSampler, vTextureCoord);
}`;
}
/* -------------------------------------------- */
/**
* This filter handles masking and post-processing for visual effects.
*/
class VisualEffectsMaskingFilter extends AbstractBaseMaskFilter {
constructor(vertex, fragment, uniforms, filterMode) {
super(vertex, fragment, uniforms);
this.filterMode = filterMode;
}
/** @override */
static create({filterMode, postProcessModes, ...uniforms}={}) {
const fragmentShader = this.fragmentShader(filterMode, postProcessModes);
uniforms = {...this.defaultUniforms, ...uniforms};
return new this(this.vertexShader, fragmentShader, uniforms, filterMode);
}
/**
* Code to determine which post-processing effect is applied in this filter.
* @type {string[]}
*/
#postProcessModes;
/**
* The filter mode.
* @type {string}
*/
filterMode;
/* -------------------------------------------- */
/**
* Update the filter shader with new post-process modes.
* @param {string[]} [postProcessModes=[]] New modes to apply.
* @param {object} [uniforms={}] Uniforms value to update.
*/
updatePostprocessModes(postProcessModes=[], uniforms={}) {
// Update shader uniforms
for ( let [uniform, value] of Object.entries(uniforms) ) {
if ( uniform in this.uniforms ) this.uniforms[uniform] = value;
}
// Update the shader program if post-processing modes have changed
if ( postProcessModes.equals(this.#postProcessModes) ) return;
this.#postProcessModes = postProcessModes;
this.program = PIXI.Program.from(VisualEffectsMaskingFilter.vertexShader,
VisualEffectsMaskingFilter.fragmentShader(this.filterMode, this.#postProcessModes));
}
/* -------------------------------------------- */
/**
* Remove all post-processing modes and reset some key uniforms.
*/
reset() {
this.#postProcessModes = [];
this.program = PIXI.Program.from(VisualEffectsMaskingFilter.vertexShader,
VisualEffectsMaskingFilter.fragmentShader(this.filterMode));
const uniforms = ["tint", "exposure", "contrast", "saturation"];
for ( const uniform of uniforms ) {
this.uniforms[uniform] = VisualEffectsMaskingFilter.defaultUniforms[uniform];
}
}
/* -------------------------------------------- */
/**
* Masking modes.
* @enum {number}
*/
static FILTER_MODES = {
BACKGROUND: "background",
ILLUMINATION: "illumination",
COLORATION: "coloration"
};
/** @override */
static defaultUniforms = {
replacementColor: [0, 0, 0],
tint: [1, 1, 1],
screenDimensions: [1, 1],
enableVisionMasking: true,
uVisionSampler: null,
exposure: 0,
contrast: 0,
saturation: 0
};
/**
* Filter post-process techniques.
* @enum {{id: string, glsl: string}}
*/
static POST_PROCESS_TECHNIQUES = {
EXPOSURE: {
id: "EXPOSURE",
glsl: `if ( exposure != 0.0 ) {
finalColor.rgb *= (1.0 + exposure);
}`
},
CONTRAST: {
id: "CONTRAST",
glsl: `if ( contrast != 0.0 ) {
finalColor.rgb = (finalColor.rgb - 0.5) * (contrast + 1.0) + 0.5;
}`
},
SATURATION: {
id: "SATURATION",
glsl: `if ( saturation != 0.0 ) {
float reflection = perceivedBrightness(finalColor.rgb);
finalColor.rgb = mix(vec3(reflection), finalColor.rgb, 1.0 + saturation) * finalColor.a;
}`
}
};
/**
* Assign the replacement color according to the filter mode.
* @param {number} filterMode Filter mode.
* @returns {string} The replacement color.
*/
static replacementColor(filterMode) {
switch (filterMode) {
case VisualEffectsMaskingFilter.FILTER_MODES.BACKGROUND:
return "vec4 repColor = vec4(0.0);";
default:
return "vec4 repColor = vec4(replacementColor, 1.0);";
}
}
/**
* Memory allocations and headers for the VisualEffectsMaskingFilter
* @param {number} filterMode Filter mode.
* @returns {string} The filter header according to the filter mode.
*/
static fragmentHeader(filterMode) {
return `
varying vec2 vTextureCoord;
varying vec2 vMaskTextureCoord;
uniform float contrast;
uniform float saturation;
uniform float exposure;
uniform vec3 replacementColor;
uniform vec3 tint;
uniform sampler2D uSampler;
uniform sampler2D uVisionSampler;
uniform bool enableVisionMasking;
vec4 baseColor;
vec4 finalColor;
${this.replacementColor(filterMode)}
${this.CONSTANTS}
${this.PERCEIVED_BRIGHTNESS}
`;
}
/**
* The fragment core code.
* @type {string}
*/
static fragmentCore = `
// Get the base color from the filter sampler
finalColor = texture2D(uSampler, vTextureCoord);
// Handling vision masking
if ( enableVisionMasking ) {
finalColor = mix( repColor,
finalColor,
texture2D(uVisionSampler, vMaskTextureCoord).r);
}
`;
/**
* Construct filter post-processing code according to provided value.
* @param {string[]} postProcessModes Post-process modes to construct techniques.
* @returns {string} The constructed shader code for post-process techniques.
*/
static fragmentPostProcess(postProcessModes=[]) {
return postProcessModes.reduce((s, t) => s + this.POST_PROCESS_TECHNIQUES[t].glsl ?? "", "");
}
/**
* Specify the fragment shader to use according to mode
* @param {number} filterMode
* @param {string[]} postProcessModes
* @returns {string}
* @override
*/
static fragmentShader(filterMode=this.FILTER_MODES.BACKGROUND, postProcessModes=[]) {
return `
${this.fragmentHeader(filterMode)}
void main() {
${this.fragmentCore}
${this.fragmentPostProcess(postProcessModes)}
if ( enableVisionMasking ) finalColor *= vec4(tint, 1.0);
gl_FragColor = finalColor;
}
`;
}
}
/* -------------------------------------------- */
/**
* Apply visibility coloration according to the baseLine color.
* Uses very lightweight gaussian vertical and horizontal blur filter passes.
* @extends {AbstractBaseFilter}
*/
class VisibilityFilter extends AbstractBaseMaskFilter {
constructor(...args) {
super(...args);
// Handling inner blur filters configuration
const b = canvas.blur;
if ( b.enabled ) {
const resolution = PIXI.Filter.defaultResolution;
this.#blurXFilter = new b.blurPassClass(true, b.strength, b.passes, resolution, b.kernels);
this.#blurYFilter = new b.blurPassClass(false, b.strength, b.passes, resolution, b.kernels);
}
// Handling fog overlay texture matrix
this.#overlayTex = this.uniforms.overlayTexture;
if ( this.#overlayTex && !this.#overlayTex.uvMatrix ) {
this.#overlayTex.uvMatrix = new PIXI.TextureMatrix(this.#overlayTex.uvMatrix, 0.0);
}
}
/**
* Horizontal inner blur filter
* @type {AlphaBlurFilterPass}
*/
#blurXFilter;
/**
* Vertical inner blur filter
* @type {AlphaBlurFilterPass}
*/
#blurYFilter;
/**
* Optional fog overlay texture
* @type {PIXI.Texture|undefined}
*/
#overlayTex;
/** @override */
static defaultUniforms = {
exploredColor: [1, 1, 1],
unexploredColor: [0, 0, 0],
screenDimensions: [1, 1],
visionTexture: null,
primaryTexture: null,
overlayTexture: null,
overlayMatrix: new PIXI.Matrix(),
hasOverlayTexture: false
};
/** @override */
static create(uniforms={}, options={}) {
uniforms = { ...this.defaultUniforms, ...uniforms};
return new this(this.vertexShader, this.fragmentShader(options), uniforms);
}
static vertexShader = `
attribute vec2 aVertexPosition;
uniform mat3 projectionMatrix;
uniform mat3 overlayMatrix;
varying vec2 vTextureCoord;
varying vec2 vMaskTextureCoord;
varying vec2 vOverlayCoord;
varying vec2 vOverlayTilingCoord;
uniform vec4 inputSize;
uniform vec4 outputFrame;
uniform vec4 dimensions;
uniform vec2 screenDimensions;
uniform bool hasOverlayTexture;
vec4 filterVertexPosition( void ) {
vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;
return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0.0, 1.0);
}
vec2 filterTextureCoord( void ) {
return aVertexPosition * (outputFrame.zw * inputSize.zw);
}
vec2 overlayTilingTextureCoord( void ) {
if ( hasOverlayTexture ) return vOverlayCoord * (dimensions.xy / dimensions.zw);
return vOverlayCoord;
}
// getting normalized coord for a screen sized mask render texture
vec2 filterMaskTextureCoord( in vec2 textureCoord ) {
return (textureCoord * inputSize.xy + outputFrame.xy) / screenDimensions;
}
void main(void) {
gl_Position = filterVertexPosition();
vTextureCoord = filterTextureCoord();
vMaskTextureCoord = filterMaskTextureCoord(vTextureCoord);
vOverlayCoord = (overlayMatrix * vec3(vTextureCoord, 1.0)).xy;
vOverlayTilingCoord = overlayTilingTextureCoord();
}`;
/** @override */
static fragmentShader(options) { return `
varying vec2 vTextureCoord;
varying vec2 vMaskTextureCoord;
varying vec2 vOverlayCoord;
varying vec2 vOverlayTilingCoord;
uniform sampler2D uSampler;
uniform sampler2D primaryTexture;
uniform sampler2D overlayTexture;
uniform vec3 unexploredColor;
uniform vec3 backgroundColor;
uniform bool hasOverlayTexture;
${options.persistentVision ? ``
: `uniform sampler2D visionTexture;
uniform vec3 exploredColor;`}
${this.CONSTANTS}
${this.PERCEIVED_BRIGHTNESS}
// To check if we are out of the bound
float getClip(in vec2 uv) {
return step(3.5,
step(0.0, uv.x) +
step(0.0, uv.y) +
step(uv.x, 1.0) +
step(uv.y, 1.0));
}
// Unpremultiply fog texture
vec4 unPremultiply(in vec4 pix) {
if ( !hasOverlayTexture || (pix.a == 0.0) ) return pix;
return vec4(pix.rgb / pix.a, pix.a);
}
void main() {
float r = texture2D(uSampler, vTextureCoord).r; // Revealed red channel from the filter texture
${options.persistentVision ? `` : `float v = texture2D(visionTexture, vMaskTextureCoord).r;`} // Vision red channel from the vision cached container
vec4 baseColor = texture2D(primaryTexture, vMaskTextureCoord);// Primary cached container renderTexture color
vec4 fogColor = hasOverlayTexture
? texture2D(overlayTexture, vOverlayTilingCoord) * getClip(vOverlayCoord)
: baseColor;
fogColor = unPremultiply(fogColor);
// Compute fog exploration colors
${!options.persistentVision
? `float reflec = perceivedBrightness(baseColor.rgb);
vec4 explored = vec4(min((exploredColor * reflec) + (baseColor.rgb * exploredColor), vec3(1.0)), 0.5);`
: ``}
vec4 unexplored = hasOverlayTexture
? mix(vec4(unexploredColor, 1.0), vec4(fogColor.rgb * backgroundColor, 1.0), fogColor.a)
: vec4(unexploredColor, 1.0);
// Mixing components to produce fog of war
${options.persistentVision
? `gl_FragColor = mix(unexplored, vec4(0.0), r);`
: `vec4 fow = mix(unexplored, explored, max(r,v));
gl_FragColor = mix(fow, vec4(0.0), v);`}
// Output the result
gl_FragColor.rgb *= gl_FragColor.a;
}`
}
/**
* Set the blur strength
* @param {number} value blur strength
*/
set blur(value) {
if ( this.#blurXFilter ) this.#blurXFilter.blur = this.#blurYFilter.blur = value;
}
get blur() {
return this.#blurYFilter?.blur;
}
/** @override */
apply(filterManager, input, output, clear) {
this.calculateMatrix(filterManager);
if ( canvas.blur.enabled ) {
// Get temporary filter textures
const firstRenderTarget = filterManager.getFilterTexture();
// Apply inner filters
this.state.blend = false;
this.#blurXFilter.apply(filterManager, input, firstRenderTarget, PIXI.CLEAR_MODES.NONE);
this.#blurYFilter.apply(filterManager, firstRenderTarget, input, PIXI.CLEAR_MODES.NONE);
this.state.blend = true;
// Inform PIXI that temporary filter textures are not more necessary
filterManager.returnFilterTexture(firstRenderTarget);
}
// Apply visibility
super.apply(filterManager, input, output, clear);
}
/**
* Calculate the fog overlay sprite matrix.
* @param {PIXI.FilterManager} filterManager
*/
calculateMatrix(filterManager) {
if ( !this.uniforms.hasOverlayTexture ) return;
this.#overlayTex.uvMatrix.update();
this.uniforms.overlayMatrix =
filterManager.calculateSpriteMatrix(this.uniforms.overlayMatrix, canvas.effects.visibility.visibilityOverlay)
.prepend(this.#overlayTex.uvMatrix.mapCoord);
}
}
/* -------------------------------------------- */
/**
* A filter which forces all non-transparent pixels to a specific color and transparency.
* @extends {AbstractBaseFilter}
*/
class ForceColorFilter extends AbstractBaseFilter {
static defaultUniforms = {
color: [1, 1, 1],
alpha: 1.0
};
static fragmentShader = `
varying vec2 vTextureCoord;
uniform sampler2D uSampler;
uniform vec3 color;
uniform float alpha;
void main() {
vec4 tex = texture2D(uSampler, vTextureCoord);
if ( tex.a > 0.0 ) gl_FragColor = vec4(color * alpha, 1.0);
else gl_FragColor = vec4(0.0);
}`;
}
/* -------------------------------------------- */
/**
* This filter turns pixels with an alpha channel < alphaThreshold in transparent pixels
* Then, optionally, it can turn the result in the chosen color (default: pure white).
* The alpha [threshold,1] is re-mapped to [0,1] with an hermite interpolation slope to prevent pixelation.
* @extends {PIXI.Filter}
*/
class RoofMaskFilter extends AbstractBaseFilter {
static defaultUniforms = {
alphaThreshold: 0.75,
turnToColor: false,
color: [1, 1, 1]
};
static fragmentShader = `
precision mediump float;
varying vec2 vTextureCoord;
uniform sampler2D uSampler;
uniform float alphaThreshold;
uniform bool turnToColor;
uniform vec3 color;
void main(void) {
vec4 tex = texture2D(uSampler, vTextureCoord);
float zapper = smoothstep(alphaThreshold, 1.0, tex.a);
if (turnToColor) tex = vec4(color, 1.0);
gl_FragColor = tex * zapper;
}`;
}
/* -------------------------------------------- */
/**
* A filter which implements an inner or outer glow around the source texture.
* Inspired from https://github.com/pixijs/filters/tree/main/filters/glow
* @license MIT
*/
class GlowOverlayFilter extends AbstractBaseFilter {
/** @override */
padding = 6;
/**
* The inner strength of the glow.
* @type {number}
*/
innerStrength = 3;
/**
* The outer strength of the glow.
* @type {number}
*/
outerStrength = 3;
/**
* Should this filter auto-animate?
* @type {number}
*/
animated = true;
/** @inheritdoc */
static defaultUniforms = {
distance: 10,
glowColor: [1, 1, 1, 1],
quality: 0.1,
time: 0,
knockout: true,
alpha: 1
};
/** @inheritdoc */
static createFragmentShader(quality, distance) {
return `
precision mediump float;
varying vec2 vTextureCoord;
varying vec4 vColor;
uniform sampler2D uSampler;
uniform float innerStrength;
uniform float outerStrength;
uniform float alpha;
uniform vec4 glowColor;
uniform vec4 inputSize;
uniform vec4 inputClamp;
uniform bool knockout;
const float PI = 3.14159265358979323846264;
const float DIST = ${distance.toFixed(0)}.0;
const float ANGLE_STEP_SIZE = min(${(1 / quality / distance).toFixed(7)}, PI * 2.0);
const float ANGLE_STEP_NUM = ceil(PI * 2.0 / ANGLE_STEP_SIZE);
const float MAX_TOTAL_ALPHA = ANGLE_STEP_NUM * DIST * (DIST + 1.0) / 2.0;
float getClip(in vec2 uv) {
return step(3.5,
step(inputClamp.x, uv.x) +
step(inputClamp.y, uv.y) +
step(uv.x, inputClamp.z) +
step(uv.y, inputClamp.w));
}
void main(void) {
vec2 px = inputSize.zw;
float totalAlpha = 0.0;
vec2 direction;
vec2 displaced;
vec4 curColor;
for (float angle = 0.0; angle < PI * 2.0; angle += ANGLE_STEP_SIZE) {
direction = vec2(cos(angle), sin(angle)) * px;
for (float curDistance = 0.0; curDistance < DIST; curDistance++) {
displaced = vTextureCoord + direction * (curDistance + 1.0);
curColor = texture2D(uSampler, displaced) * getClip(displaced);
totalAlpha += (DIST - curDistance) * (smoothstep(0.5, 1.0, curColor.a));
}
}
curColor = texture2D(uSampler, vTextureCoord);
float alphaRatio = (totalAlpha / MAX_TOTAL_ALPHA);
float innerGlowAlpha = (1.0 - alphaRatio) * innerStrength * smoothstep(0.6, 1.0, curColor.a);
float innerGlowStrength = min(1.0, innerGlowAlpha);
vec4 innerColor = mix(curColor, glowColor, innerGlowStrength);
float outerGlowAlpha = alphaRatio * outerStrength * (1.0 - smoothstep(0.35, 1.0, curColor.a));
float outerGlowStrength = min(1.0 - innerColor.a, outerGlowAlpha);
vec4 outerGlowColor = outerGlowStrength * glowColor.rgba;
if ( knockout ) {
float resultAlpha = outerGlowAlpha + innerGlowAlpha;
gl_FragColor = mix(vec4(glowColor.rgb * resultAlpha, resultAlpha), vec4(0.0), curColor.a);
}
else {
vec4 outerGlowColor = outerGlowStrength * glowColor.rgba * alpha;
gl_FragColor = innerColor + outerGlowColor;
}
}`;
}
/** @inheritdoc */
static vertexShader = `
precision mediump float;
attribute vec2 aVertexPosition;
uniform mat3 projectionMatrix;
uniform vec4 inputSize;
uniform vec4 outputFrame;
varying vec2 vTextureCoord;
void main(void) {
vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.0)) + outputFrame.xy;
gl_Position = vec4((projectionMatrix * vec3(position, 1.0)).xy, 0.0, 1.0);
vTextureCoord = aVertexPosition * (outputFrame.zw * inputSize.zw);
}`;
/** @inheritdoc */
static create(uniforms = {}) {
uniforms = {...this.defaultUniforms, ...uniforms};
const fragmentShader = this.createFragmentShader(uniforms.quality, uniforms.distance);
return new this(this.vertexShader, fragmentShader, uniforms);
}
/* -------------------------------------------- */
/** @override */
apply(filterManager, input, output, clear) {
const z = canvas.stage.worldTransform.d;
if ( !canvas.photosensitiveMode && this.animated ) {
const t = canvas.app.ticker.lastTime;
this.uniforms.outerStrength = Math.oscillation(this.outerStrength * 0.5, this.outerStrength * 2.0, t, 2000) * z;
this.uniforms.innerStrength = Math.oscillation(this.innerStrength * 0.5, this.innerStrength * 2.0, t, 2000) * z;
}
else {
this.uniforms.outerStrength = this.outerStrength * z;
this.uniforms.innerStrength = this.innerStrength * z;
}
filterManager.applyFilter(this, input, output, clear);
}
}
/* -------------------------------------------- */
/**
* A filter which implements an outline.
* Inspired from https://github.com/pixijs/filters/tree/main/filters/outline
* @license MIT
*/
class OutlineOverlayFilter extends AbstractBaseFilter {
/** @override */
padding = 3;
/** @override */
autoFit = false;
/**
* If the filter is animated or not.
* @type {boolean}
*/
animate = true;
/** @inheritdoc */
static defaultUniforms = {
outlineColor: [1, 1, 1, 1],
thickness: [1, 1],
alphaThreshold: 0.60,
knockout: true,
wave: false
};
static vertexShader = `
attribute vec2 aVertexPosition;
uniform mat3 projectionMatrix;
uniform vec2 screenDimensions;
uniform vec4 inputSize;
uniform vec4 outputFrame;
varying vec2 vTextureCoord;
varying vec2 vFilterCoord;
vec4 filterVertexPosition( void ) {
vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;
return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0., 1.);
}
// getting normalized coord for the tile texture
vec2 filterTextureCoord( void ) {
return aVertexPosition * (outputFrame.zw * inputSize.zw);
}
// getting normalized coord for a screen sized mask render texture
vec2 filterCoord( in vec2 textureCoord ) {
return textureCoord * inputSize.xy / outputFrame.zw;
}
void main() {
vTextureCoord = filterTextureCoord();
vFilterCoord = filterCoord(vTextureCoord);
gl_Position = filterVertexPosition();
}`;
/** @inheritdoc */
static createFragmentShader() {
return `
varying vec2 vTextureCoord;
varying vec2 vFilterCoord;
uniform sampler2D uSampler;
uniform vec2 thickness;
uniform vec4 outlineColor;
uniform vec4 filterClamp;
uniform float alphaThreshold;
uniform float time;
uniform bool knockout;
uniform bool wave;
${this.CONSTANTS}
${this.WAVE()}
void main(void) {
float dist = distance(vFilterCoord, vec2(0.5)) * 2.0;
vec4 ownColor = texture2D(uSampler, vTextureCoord);
vec4 wColor = wave ? outlineColor *
wcos(0.0, 1.0, dist * 75.0,
-time * 0.01 + 3.0 * dot(vec4(1.0), ownColor))
* 0.33 * (1.0 - dist) : vec4(0.0);
float texAlpha = smoothstep(alphaThreshold, 1.0, ownColor.a);
vec4 curColor;
float maxAlpha = 0.;
vec2 displaced;
for ( float angle = 0.0; angle <= TWOPI; angle += ${this.#quality.toFixed(7)} ) {
displaced.x = vTextureCoord.x + thickness.x * cos(angle);
displaced.y = vTextureCoord.y + thickness.y * sin(angle);
curColor = texture2D(uSampler, clamp(displaced, filterClamp.xy, filterClamp.zw));
curColor.a = clamp((curColor.a - 0.6) * 2.5, 0.0, 1.0);
maxAlpha = max(maxAlpha, curColor.a);
}
float resultAlpha = max(maxAlpha, texAlpha);
vec3 result = ((knockout ? vec3(0.0) : ownColor.rgb) + outlineColor.rgb * (1.0 - texAlpha)) * resultAlpha;
gl_FragColor = mix(vec4(result, resultAlpha), wColor, texAlpha);
}
`;
}
/* -------------------------------------------- */
/**
* Quality of the outline according to performance mode.
* @returns {number}
*/
static get #quality() {
switch ( canvas.performance.mode ) {
case CONST.CANVAS_PERFORMANCE_MODES.LOW:
return (Math.PI * 2) / 10;
case CONST.CANVAS_PERFORMANCE_MODES.MED:
return (Math.PI * 2) / 20;
default:
return (Math.PI * 2) / 30;
}
}
/* -------------------------------------------- */
/**
* The thickness of the outline.
* @type {number}
*/
get thickness() {
return this.#thickness;
}
set thickness(value) {
this.#thickness = value;
this.padding = value * 1.5;
}
#thickness = 3;
/* -------------------------------------------- */
/** @inheritdoc */
static create(uniforms = {}) {
uniforms = {...this.defaultUniforms, ...uniforms};
return new this(this.vertexShader, this.createFragmentShader(), uniforms);
}
/* -------------------------------------------- */
/** @override */
apply(filterManager, input, output, clear) {
const animate = this.animate && !canvas.photosensitiveMode;
if ( canvas.photosensitiveMode && this.uniforms.wave ) this.uniforms.wave = false;
const oThickness = animate
? Math.oscillation(this.#thickness * 0.75, this.#thickness * 1.25, canvas.app.ticker.lastTime, 1500)
: this.#thickness;
this.uniforms.time = animate ? canvas.app.ticker.lastTime : 0;
this.uniforms.thickness[0] = (oThickness / input._frame.width) * canvas.stage.scale.x;
this.uniforms.thickness[1] = (oThickness / input._frame.height) * canvas.stage.scale.x;
filterManager.applyFilter(this, input, output, clear);
}
}
/* -------------------------------------------- */
/**
* The filter used by the weather layer to mask weather above occluded roofs.
* @see {@link WeatherEffects}
*/
class WeatherOcclusionMaskFilter extends AbstractBaseMaskFilter {
/**
* Elevation of this weather occlusion mask filter.
* @type {number}
*/
elevation = Infinity;
/** @override */
static vertexShader = `
attribute vec2 aVertexPosition;
// Filter globals uniforms
uniform mat3 projectionMatrix;
uniform vec4 inputSize;
uniform vec4 outputFrame;
// Needed to compute mask and terrain normalized coordinates
uniform vec2 screenDimensions;
// Needed for computing scene sized texture coordinates
uniform vec2 sceneAnchor;
uniform vec2 sceneDimensions;
uniform bool useTerrain;
varying vec2 vTextureCoord;
varying vec2 vMaskTextureCoord;
varying vec2 vTerrainTextureCoord;
vec4 filterVertexPosition( void ) {
vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;
return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0., 1.);
}
// getting normalized coord for the tile texture
vec2 filterTextureCoord( void ) {
return aVertexPosition * (outputFrame.zw * inputSize.zw);
}
// getting normalized coord for a screen sized mask render texture
vec2 filterMaskTextureCoord( in vec2 textureCoord ) {
return (textureCoord * inputSize.xy + outputFrame.xy) / screenDimensions;
}
// get normalized terrain texture coordinates
vec2 filterTerrainTextureCoord( in vec2 textureCoord ) {
return (textureCoord - (sceneAnchor / screenDimensions)) * (screenDimensions / sceneDimensions);
}
void main() {
vTextureCoord = filterTextureCoord();
if ( useTerrain ) vTerrainTextureCoord = filterTerrainTextureCoord(vTextureCoord);
vMaskTextureCoord = filterMaskTextureCoord(vTextureCoord);
gl_Position = filterVertexPosition();
}`;
/** @override */
static fragmentShader = `
// Occlusion mask uniforms
uniform bool useOcclusion;
uniform sampler2D occlusionTexture;
uniform bool reverseOcclusion;
uniform vec4 occlusionWeights;
// Terrain mask uniforms
uniform bool useTerrain;
uniform sampler2D terrainTexture;
uniform bool reverseTerrain;
uniform vec4 terrainWeights;
// Other uniforms
varying vec2 vTextureCoord;
varying vec2 vMaskTextureCoord;
varying vec2 vTerrainTextureCoord;
uniform sampler2D uSampler;
uniform float depthElevation;
// Clip the terrain texture if out of bounds
float getTerrainClip(vec2 uv) {
return step(3.5,
step(0.0, uv.x) +
step(0.0, uv.y) +
step(uv.x, 1.0) +
step(uv.y, 1.0));
}
void main() {
// Base mask value
float mask = 1.0;
// Process the occlusion mask
if ( useOcclusion ) {
float oMask = 1.0 - step((255.5 / 255.0) -
dot(occlusionWeights, texture2D(occlusionTexture, vMaskTextureCoord)),
depthElevation);
if ( reverseOcclusion ) oMask = 1.0 - oMask;
mask *= oMask;
}
// Process the terrain mask
if ( useTerrain ) {
float tMask = dot(terrainWeights, texture2D(terrainTexture, vTerrainTextureCoord));
if ( reverseTerrain ) tMask = 1.0 - tMask;
mask *= (tMask * getTerrainClip(vTerrainTextureCoord));
}
// Process filtering and apply mask value
gl_FragColor = texture2D(uSampler, vTextureCoord) * mask;
}`;
/** @override */
static defaultUniforms = {
depthElevation: 0,
useOcclusion: true,
occlusionTexture: null,
reverseOcclusion: false,
occlusionWeights: [0, 0, 1, 0],
useTerrain: false,
terrainTexture: null,
reverseTerrain: false,
terrainWeights: [1, 0, 0, 0],
sceneDimensions: [0, 0],
sceneAnchor: [0, 0]
};
/** @override */
apply(filterManager, input, output, clear, currentState) {
if ( this.uniforms.useTerrain ) {
const wt = canvas.stage.worldTransform;
const z = wt.d;
const sceneDim = canvas.scene.dimensions;
// Computing the scene anchor and scene dimensions for terrain texture coordinates
this.uniforms.sceneAnchor[0] = wt.tx + (sceneDim.sceneX * z);
this.uniforms.sceneAnchor[1] = wt.ty + (sceneDim.sceneY * z);
this.uniforms.sceneDimensions[0] = sceneDim.sceneWidth * z;
this.uniforms.sceneDimensions[1] = sceneDim.sceneHeight * z;
}
this.uniforms.depthElevation = canvas.primary.mapElevationToDepth(this.elevation);
return super.apply(filterManager, input, output, clear, currentState);
}
}
/**
* A FXAA filter based on PIXI.FXAA and slightly improved.
* In brief: The FXAA filter is computing the luma of neighbour pixels and apply correction according to the
* difference. A high luma reduction is reducing correction while a low luma reduction is reinforcing it.
* @param {string} [vertex=AdaptiveFXAAFilter.vertexShader] Optional vertex shader
* @param {string} [fragment=AdaptiveFXAAFilter.fragmentShader] Optional fragment shader
* @param {object} [uniforms=AdaptiveFXAAFilter.defaultUniforms] Optional uniforms
* @param {object} [options={}] Additional options (token knockout, ...)
*/
class AdaptiveFXAAFilter extends AbstractBaseFilter {
constructor(vertex, fragment, uniforms, options={}) {
super(vertex ?? AdaptiveFXAAFilter.vertexShader,
fragment ?? AdaptiveFXAAFilter.fragmentShader,
uniforms ?? AdaptiveFXAAFilter.defaultUniforms);
// Handle token knockout option
const tko = this.uniforms.tokenKnockout = (options.tokenKnockout ??= true);
if ( tko ) this.uniforms.tokenTexture = canvas.primary.tokensRenderTexture;
}
/** @override */
static defaultUniforms = {
lumaMinimum: 0.0078125, // The minimum luma reduction applied
lumaReduction: 0.125, // The luma reduction applied. High value, less blur
spanMax: 8, // Maximum distance at which luma comparisons are made
tokenKnockout: true, // If tokens should be excluded from the FXAA
tokenTexture: null, // Inverse occlusion token texture (if token exclusion is activated)
screenDimensions: [1, 1] // Necessary if token exclusion is activated
};
/* -------------------------------------------- */
/** @override */
static vertexShader = `
attribute vec2 aVertexPosition;
uniform mat3 projectionMatrix;
varying vec2 vcNW;
varying vec2 vcNE;
varying vec2 vcSW;
varying vec2 vcSE;
varying vec2 vcM;
varying vec2 vFilterCoord;
varying vec2 vMaskTextureCoord;
uniform bool tokenKnockout;
uniform vec2 screenDimensions;
uniform vec4 inputSize;
uniform vec4 outputFrame;
vec4 filterVertexPosition() {
vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;
return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0.0, 1.0);
}
// Compute coord for a screen sized mask render texture
vec2 filterMaskTextureCoord() {
return ( aVertexPosition * (outputFrame.zw * inputSize.zw)
* inputSize.xy + outputFrame.xy) / screenDimensions;
}
void main(void) {
gl_Position = filterVertexPosition();
vFilterCoord = aVertexPosition * outputFrame.zw;
if ( tokenKnockout ) vMaskTextureCoord = filterMaskTextureCoord();
vcNW = (vFilterCoord + vec2(-1.0, -1.0)) * inputSize.zw;
vcNE = (vFilterCoord + vec2(1.0, -1.0)) * inputSize.zw;
vcSW = (vFilterCoord + vec2(-1.0, 1.0)) * inputSize.zw;
vcSE = (vFilterCoord + vec2(1.0, 1.0)) * inputSize.zw;
vcM = vec2(vFilterCoord * inputSize.zw);
}
`;
/** @override */
static fragmentShader = `
${this.CONSTANTS}
${this.PERCEIVED_BRIGHTNESS}
varying vec2 vcNW;
varying vec2 vcNE;
varying vec2 vcSW;
varying vec2 vcSE;
varying vec2 vcM;
varying vec2 vFilterCoord;
varying vec2 vMaskTextureCoord;
uniform sampler2D uSampler;
uniform sampler2D tokenTexture;
uniform highp vec4 inputSize;
uniform float lumaMinimum;
uniform float lumaReduction;
uniform float spanMax;
uniform bool tokenKnockout;
vec4 fxaa(in sampler2D tex, in vec2 uv, in vec2 iiSP) {
vec4 color;
// Get neighbour pixels
vec3 rgbNW = texture2D(tex, vcNW).rgb;
vec3 rgbNE = texture2D(tex, vcNE).rgb;
vec3 rgbSW = texture2D(tex, vcSW).rgb;
vec3 rgbSE = texture2D(tex, vcSE).rgb;
// Get the central pixel
vec4 texColor = texture2D(tex, vcM);
vec3 rgbM = texColor.rgb;
// Compute the luma for each pixel
float lumaNW = perceivedBrightness(rgbNW);
float lumaNE = perceivedBrightness(rgbNE);
float lumaSW = perceivedBrightness(rgbSW);
float lumaSE = perceivedBrightness(rgbSE);
float lumaM = perceivedBrightness(rgbM);
// Get the luma max and min for the neighbour pixels
float lumaMin = min(lumaM, min(min(lumaNW, lumaNE), min(lumaSW, lumaSE)));
float lumaMax = max(lumaM, max(max(lumaNW, lumaNE), max(lumaSW, lumaSE)));
// Get direction of the luma shift
mediump vec2 dir;
dir.x = -((lumaNW + lumaNE) - (lumaSW + lumaSE));
dir.y = ((lumaNW + lumaSW) - (lumaNE + lumaSE));
// Compute luma reduction
float dirReduce = max((lumaNW + lumaNE + lumaSW + lumaSE) * (0.25 * lumaReduction), lumaMinimum);
// Apply luma shift and reduction
float rcpDirMin = 1.0 / (min(abs(dir.x), abs(dir.y)) + dirReduce);
dir = min(vec2(spanMax, spanMax), max(vec2(-spanMax, -spanMax), dir * rcpDirMin)) * iiSP;
// Get the new points
vec3 rgbA = 0.5 * (
texture2D(tex, uv * iiSP + dir * (1.0 / 3.0 - 0.5)).rgb +
texture2D(tex, uv * iiSP + dir * (2.0 / 3.0 - 0.5)).rgb
);
vec3 rgbB = rgbA * 0.5 + 0.25 * (
texture2D(tex, uv * iiSP + dir * -0.5).rgb +
texture2D(tex, uv * iiSP + dir * 0.5).rgb
);
// Compare with luma min and max and apply the best choice
float lumaB = perceivedBrightness(rgbB);
if ( (lumaB < lumaMin) || (lumaB > lumaMax) ) color = vec4(rgbA, texColor.a);
else color = vec4(rgbB, texColor.a);
// Exclude the token from the FXAA if necessary
if ( tokenKnockout ) {
float tokenIoTex = texture2D(tokenTexture, vMaskTextureCoord).a;
return mix(color, texColor, tokenIoTex);
}
else return color;
}
void main() {
vec4 color;
color = fxaa(uSampler, vFilterCoord, inputSize.zw);
gl_FragColor = color;
}
`;
/* -------------------------------------------- */
/** @override */
apply(filterManager, input, output, clear, currentState) {
// Adapt the FXAA to the zoom level, to avoid the blurry effect
this.uniforms.lumaReduction = this._computeLumaReduction();
// Get values necessary for token exclusion
if ( this.uniforms.tokenKnockout ) this.uniforms.screenDimensions = canvas.screenDimensions;
filterManager.applyFilter(this, input, output, clear);
}
/* -------------------------------------------- */
/**
* Compute the luma reduction according to the stage zoom level (worldTransform.d)
* The zoom level is converted to a range [0.xxx => max zoom out , 1 => max zoom in]
* With zoom out, the reduction tends to high value, the antialias is discrete to avoid blurring side effect.
* With zoom in, the reduction tends to low value, the antialias is important.
* FXAA checks local contrast to avoid processing non-edges (high contrast difference === edge):
* 0.6 and 0.02 are factors applied to the "contrast range", to apply or not a contrast blend.
* With small values, the contrast blend is applied more often than with high values.
* @returns {number} The luma reduction
* @protected
*/
_computeLumaReduction() {
const max = CONFIG.Canvas.maxZoom;
const zoom = canvas.stage.worldTransform.d / max;
return Math.mix(0.6, 0.02, zoom);
}
}
/**
* A color adjustment shader.
*/
class ColorAdjustmentsSamplerShader extends BaseSamplerShader {
/** @override */
static classPluginName = null;
/** @inheritdoc */
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 float darknessLevel;
uniform bool linkedToDarknessLevel;
varying vec2 vUvs;
${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}
// Multiply rgb with alpha channel
if ( linkedToDarknessLevel == true ) baseColor.rgb = mix(originalColor, baseColor.rgb, darknessLevel);
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,
darknessLevel: 1
};
get linkedToDarknessLevel() {
return this.uniforms.linkedToDarknessLevel;
}
set linkedToDarknessLevel(link) {
this.uniforms.linkedToDarknessLevel = link;
}
get darknessLevel() {
return this.uniforms.darknessLevel;
}
set darknessLevel(darknessLevel) {
this.uniforms.darknessLevel = darknessLevel;
}
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;
/* -------------------------------------------- */
/** @inheritdoc */
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 float darknessLevel;
uniform bool enable;
varying vec2 vUvs;
${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;
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,
darknessLevel: 1,
enable: true
};
/* -------------------------------------------- */
/**
* Level of natural brightness (opposed to darkness level).
* @type {number}
*/
get darknessLevel() {
return this.uniforms.darknessLevel;
}
set darknessLevel(darknessLevel) {
this.uniforms.darknessLevel = darknessLevel;
}
/**
* 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;
}`;
}
/**
* A shader used to control channels intensity using an externally provided mask texture.
*/
class InverseOcclusionSamplerShader extends BaseSamplerShader {
/** @override */
static classPluginName = null;
/** @inheritdoc */
static defaultUniforms = {
roof: false,
vision: false,
tintAlpha: [1, 1, 1, 1],
depthElevation: 0,
sampler: null,
maskSampler: null,
alpha: 1.0,
alphaOcclusion: 1.0,
screenDimensions: [1, 1],
pixelRatio: [1, 1]
};
/** @inheritdoc */
static vertexShader = `
precision ${PIXI.settings.PRECISION_VERTEX} float;
attribute vec2 aVertexPosition;
attribute vec2 aTextureCoord;
uniform mat3 projectionMatrix;
uniform vec2 screenDimensions;
varying vec2 vUvsMask;
varying vec2 vUvs;
void main() {
vUvs = aTextureCoord;
vUvsMask = aVertexPosition / screenDimensions;
gl_Position = vec4((projectionMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);
}
`;
/** @inheritdoc */
static fragmentShader(sampleSize) {
return `
precision ${PIXI.settings.PRECISION_FRAGMENT} float;
varying vec2 vUvs;
varying vec2 vUvsMask;
uniform vec2 pixelRatio;
uniform vec4 tintAlpha;
uniform sampler2D sampler;
uniform sampler2D maskSampler;
uniform float alphaOcclusion;
uniform float alpha;
uniform float depthElevation;
uniform bool roof;
uniform bool vision;
const float sampleSize = ${sampleSize.toFixed(1)};
const float isqrSampleSize = 1.0 / (sampleSize * sampleSize);
const float start = floor(-sampleSize * 0.5);
const float end = abs(start);
// Get the mask value with the required step
float getSample(in vec2 vs) {
vec4 otex = texture2D(maskSampler, vUvsMask + vs);
float occlusionElevation = roof ? otex.g : (vision ? otex.b : otex.g);
return (1.0 - step(depthElevation, occlusionElevation));
}
void main() {
float tex = 0.0;
// Activate blur for vision occlusion or for non-roof overhead with radial and if sampleSize > 0.0
bool activateBlur = (vision || (!vision && !roof)) && (sampleSize > 0.0);
// Box-2-Box blur
if ( !activateBlur ) tex = getSample(vec2(0.0));
else {
for ( float x = start; x < end; x += 1.0 ) {
for ( float y = start; y < end; y += 1.0 ) {
tex += (getSample(pixelRatio * vec2(x, y) * 0.0033) * isqrSampleSize);
}
}
}
float mask = 1.0 - tex + (alphaOcclusion * tex);
float calpha = tex + alpha * (1.0 - tex);
gl_FragColor = texture2D(sampler, vUvs) * mask * calpha * tintAlpha;
}
`;
}
/* -------------------------------------------- */
/**
* A factory method for creating the shader using its defined default values
* @param {object} defaultUniforms
* @returns {AbstractBaseShader}
*/
static create(defaultUniforms) {
let sampleSize = 0;
if ( canvas.performance.mode === CONST.CANVAS_PERFORMANCE_MODES.HIGH ) sampleSize = 3;
else if ( canvas.performance.mode === CONST.CANVAS_PERFORMANCE_MODES.MAX ) sampleSize = 5;
const program = PIXI.Program.from(this.vertexShader, this.fragmentShader(sampleSize));
const uniforms = mergeObject(this.defaultUniforms, defaultUniforms, {inplace: false, insertKeys: false});
return new this(program, uniforms);
}
/* -------------------------------------------- */
/** @override */
_preRender(mesh) {
super._preRender(mesh);
this.uniforms.roof = mesh.isRoof;
this.uniforms.vision = (mesh.data.occlusion.mode === CONST.OCCLUSION_MODES.VISION);
this.uniforms.screenDimensions = canvas.screenDimensions;
const zoom = canvas.stage.worldTransform.d;
this.uniforms.pixelRatio[0] = (Math.min(canvas.screenDimensions[0], canvas.screenDimensions[1])
/ canvas.screenDimensions[0]) * zoom;
this.uniforms.pixelRatio[1] = (Math.min(canvas.screenDimensions[0], canvas.screenDimensions[1])
/ canvas.screenDimensions[1]) * zoom;
const renderTexture = this.uniforms.roof ? canvas.masks.depth.renderTexture : canvas.masks.occlusion.renderTexture;
if ( this.uniforms.maskSampler !== renderTexture ) this.uniforms.maskSampler = renderTexture;
}
}
/**
* A shader that alters the source to adopt a translucent color to simulate invisibility.
*/
class TokenInvisibilitySamplerShader extends BaseSamplerShader {
/** @override */
static classPluginName = null;
/** @inheritdoc */
static fragmentShader = `
precision ${PIXI.settings.PRECISION_FRAGMENT} float;
uniform sampler2D sampler;
uniform vec4 tintAlpha;
uniform vec3 color;
uniform float alpha;
uniform bool enable;
varying vec2 vUvs;
${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;
// Computing halo
float lum = perceivedBrightness(baseColor.rgb);
vec3 haloColor = vec3(lum) * color;
float halo = smoothstep(0.0, 0.4, lum);
// Construct final image
baseColor.a *= halo * alpha;
baseColor.rgb = mix(baseColor.rgb * baseColor.a, haloColor * baseColor.a, 0.65);
}
// Output with tint and alpha
gl_FragColor = baseColor * tintAlpha;
}`;
/** @inheritdoc */
static defaultUniforms = {
tintAlpha: [1, 1, 1, 1],
sampler: null,
color: [0.25, 0.35, 1.0],
alpha: 0.8
};
}
/**
* A monochromatic shader
*/
class MonochromaticSamplerShader extends BaseSamplerShader {
/** @override */
static classPluginName = "monochromatic";
static batchVertexShader = `
precision ${PIXI.settings.PRECISION_VERTEX} float;
attribute vec2 aVertexPosition;
attribute vec2 aTextureCoord;
attribute vec4 aColor;
attribute float aTextureId;
uniform mat3 projectionMatrix;
uniform mat3 translationMatrix;
uniform vec4 tint;
varying vec2 vTextureCoord;
varying vec4 vColor;
varying 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;
}`;
/** @override */
static batchFragmentShader = `
precision ${PIXI.settings.PRECISION_FRAGMENT} float;
varying vec2 vTextureCoord;
varying vec4 vColor;
varying float vTextureId;
uniform sampler2D uSamplers[%count%];
void main(void){
vec4 color;
%forloop%
gl_FragColor = vec4(vColor.rgb, 1.0) * color.a;
}
`;
/** @inheritdoc */
static fragmentShader = `
precision ${PIXI.settings.PRECISION_FRAGMENT} float;
uniform sampler2D sampler;
uniform vec4 tintAlpha;
varying vec2 vUvs;
void main() {
gl_FragColor = vec4(tintAlpha.rgb, 1.0) * texture2D(sampler, vUvs).a;
}
`;
/** @inheritdoc */
static defaultUniforms = {
tintAlpha: [1, 1, 1, 1],
sampler: 0
};
}
/**
* An occlusion shader to reveal certain area with elevation comparisons.
* This shader is also working as a batched plugin.
*/
class OcclusionSamplerShader extends BaseSamplerShader {
/* -------------------------------------------- */
/* Batched version Rendering */
/* -------------------------------------------- */
/** @override */
static classPluginName = "occlusion";
/** @override */
static reservedTextureUnits = 1; // We need a texture unit for the occlusion texture
/** @override */
static batchDefaultUniforms(maxTex) {
return {
screenDimensions: [1, 1],
occlusionTexture: maxTex
};
}
/** @override */
static _preRenderBatch(batchRenderer) {
batchRenderer.renderer.texture.bind(canvas.masks.occlusion.renderTexture, batchRenderer.maxTextures);
batchRenderer._shader.uniforms.screenDimensions = canvas.screenDimensions;
}
/** @override */
static batchVertexSize = 7;
/* ---------------------------------------- */
/** @override */
static initializeBatchGeometry() {
this.batchGeometry =
class BatchGeometry extends PIXI.Geometry {
/** @override */
constructor(_static = false) {
super();
this._buffer = new PIXI.Buffer(null, _static, false);
this._indexBuffer = new PIXI.Buffer(null, _static, true);
// We need to put all the attributes that will be packed into the geometries.
// For the occlusion batched shader, we need:
// all things for the standard batching: tint, texture id, etc.
// and specific to this sampler: occlusion mode.
// For a size of 8 * 32 bits values (batchVertexSize = 7)
this.addAttribute("aVertexPosition", this._buffer, 2, false, PIXI.TYPES.FLOAT)
.addAttribute("aTextureCoord", this._buffer, 2, false, PIXI.TYPES.FLOAT)
.addAttribute("aColor", this._buffer, 4, true, PIXI.TYPES.UNSIGNED_BYTE)
.addAttribute("aTextureId", this._buffer, 1, true, PIXI.TYPES.FLOAT)
.addAttribute("aOcclusionMode", this._buffer, 1, true, PIXI.TYPES.FLOAT)
.addIndex(this._indexBuffer);
}
};
}
/* ---------------------------------------- */
/** @override */
static _packInterleavedGeometry(element, attributeBuffer, indexBuffer, aIndex, iIndex) {
const {uint32View, float32View} = attributeBuffer;
const packedVertices = aIndex / this.vertexSize;
const uvs = element.uvs;
const indices = element.indices;
const occlusionMode = element.occlusionMode;
const vertexData = element.vertexData;
const textureId = element._texture.baseTexture._batchLocation;
const argb = element._tintRGB + (element.worldAlpha * 255 << 24);
for ( let i = 0; i < vertexData.length; i += 2 ) {
float32View[aIndex++] = vertexData[i];
float32View[aIndex++] = vertexData[i + 1];
float32View[aIndex++] = uvs[i];
float32View[aIndex++] = uvs[i + 1];
uint32View[aIndex++] = argb;
float32View[aIndex++] = textureId;
float32View[aIndex++] = occlusionMode;
}
for ( let i = 0; i < indices.length; i++ ) {
indexBuffer[iIndex++] = packedVertices + indices[i];
}
}
/* ---------------------------------------- */
/** @override */
static batchVertexShader = `
precision ${PIXI.settings.PRECISION_VERTEX} float;
attribute vec2 aVertexPosition;
attribute vec2 aTextureCoord;
attribute vec4 aColor;
attribute float aTextureId;
attribute float aOcclusionMode;
uniform mat3 projectionMatrix;
uniform mat3 translationMatrix;
uniform vec4 tint;
uniform vec2 screenDimensions;
varying vec2 vTextureCoord;
varying vec4 vColor;
varying float vTextureId;
varying vec2 vSamplerUvs;
varying float vDepthElevation;
varying float vOcclusionMode;
void main(void) {
vec3 tPos = translationMatrix * vec3(aVertexPosition, 1.0);
vSamplerUvs = tPos.xy / screenDimensions;
vTextureCoord = aTextureCoord;
vTextureId = aTextureId;
vColor = aColor;
vOcclusionMode = aOcclusionMode;
gl_Position = vec4((projectionMatrix * tPos).xy, 0.0, 1.0);
}
`;
/** @override */
static batchFragmentShader = `
precision ${PIXI.settings.PRECISION_FRAGMENT} float;
varying vec2 vTextureCoord;
varying vec2 vSamplerUvs;
varying vec4 vColor;
varying float vTextureId;
varying float vOcclusionMode;
uniform sampler2D occlusionTexture;
uniform sampler2D uSamplers[%count%];
void main(void) {
vec4 color;
%forloop%
float rAlpha = 1.0 - step(color.a, 0.75);
vec4 oTex = texture2D(occlusionTexture, vSamplerUvs);
vec3 tint = vColor.rgb;
tint.rgb *= rAlpha;
tint.g *= vColor.a;
tint.b *= (256.0 / 255.0) - vColor.a;
if ( vOcclusionMode == ${CONST.OCCLUSION_MODES.RADIAL.toFixed(1)} ) {
float oAlpha = step(vColor.a, oTex.g);
tint.g *= oAlpha;
// Weather is masked in the parts of the roof that are cut out.
tint.b *= 1.0 - oAlpha;
}
else if ( vOcclusionMode == ${CONST.OCCLUSION_MODES.VISION.toFixed(1)} ) {
float oAlpha = step(vColor.a, oTex.b);
tint.g *= oAlpha;
tint.b *= 1.0 - oAlpha;
}
gl_FragColor = vec4(tint, 1.0);
}
`;
/* -------------------------------------------- */
/* Non-Batched version Rendering */
/* -------------------------------------------- */
/** @inheritdoc */
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 vSamplerUvs;
void main() {
vUvs = aTextureCoord;
vSamplerUvs = aVertexPosition / screenDimensions;
gl_Position = vec4((projectionMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);
}
`;
/** @inheritdoc */
static fragmentShader = `
precision ${PIXI.settings.PRECISION_FRAGMENT} float;
uniform sampler2D sampler;
uniform sampler2D occlusionTexture;
uniform vec4 tintAlpha;
uniform float occlusionMode;
varying vec2 vUvs;
varying vec2 vSamplerUvs;
void main() {
float rAlpha = 1.0 - step(texture2D(sampler, vUvs).a, 0.75);
vec4 oTex = texture2D(occlusionTexture, vSamplerUvs);
vec3 tint = 1.0 - step(tintAlpha.rgb, vec3(0.0));
tint.rgb *= rAlpha;
tint.g *= tintAlpha.a;
tint.b *= (256.0 / 255.0) - tintAlpha.a;
if ( occlusionMode == ${CONST.OCCLUSION_MODES.RADIAL.toFixed(1)} ) {
float oAlpha = step(tintAlpha.a, oTex.g);
tint.g *= oAlpha;
tint.b *= 1.0 - oAlpha;
}
else if ( occlusionMode == ${CONST.OCCLUSION_MODES.VISION.toFixed(1)} ) {
float oAlpha = step(tintAlpha.a, oTex.b);
tint.g *= oAlpha;
tint.b *= 1.0 - oAlpha;
}
gl_FragColor = vec4(tint, 1.0);
}
`;
/** @inheritdoc */
static defaultUniforms = {
tintAlpha: [1, 1, 1, 1],
sampler: null,
occlusionTexture: null,
occlusionMode: 0,
screenDimensions: [1, 1]
};
/** @override */
_preRender(mesh) {
super._preRender(mesh);
if ( !this.uniforms.occlusionTexture ) {
this.uniforms.occlusionTexture = canvas.masks.occlusion.renderTexture;
}
this.uniforms.occlusionMode = mesh.document.occlusion.mode;
this.uniforms.screenDimensions = canvas.screenDimensions;
}
}
/**
* @typedef {object} ContextMenuEntry
* @property {string} name The context menu label. Can be localized.
* @property {string} icon A string containing an HTML icon element for the menu item
* @property {string} group An identifier for a group this entry belongs to.
* @property {function(jQuery)} callback The function to call when the menu item is clicked. Receives the HTML element
* of the entry that this context menu is for.
* @property {function(jQuery):boolean} [condition] A function to call to determine if this item appears in the menu.
* Receives the HTML element of the entry that this context menu is
* for.
*/
/**
* @callback ContextMenuCallback
* @param {HTMLElement} target The element that the context menu has been triggered for.
*/
/**
* Display a right-click activated Context Menu which provides a dropdown menu of options
* A ContextMenu is constructed by designating a parent HTML container and a target selector
* An Array of menuItems defines the entries of the menu which is displayed
*/
class ContextMenu {
/**
* @param {HTMLElement|jQuery} element The containing HTML element within which the menu is positioned
* @param {string} selector A CSS selector which activates the context menu.
* @param {ContextMenuEntry[]} menuItems An Array of entries to display in the menu
* @param {object} [options] Additional options to configure the context menu.
* @param {string} [options.eventName="contextmenu"] Optionally override the triggering event which can spawn the
* menu
* @param {ContextMenuCallback} [options.onOpen] A function to call when the context menu is opened.
* @param {ContextMenuCallback} [options.onClose] A function to call when the context menu is closed.
*/
constructor(element, selector, menuItems, {eventName="contextmenu", onOpen, onClose}={}) {
/**
* The target HTMLElement being selected
* @type {HTMLElement|jQuery}
*/
this.element = element;
/**
* The target CSS selector which activates the menu
* @type {string}
*/
this.selector = selector || element.attr("id");
/**
* An interaction event name which activates the menu
* @type {string}
*/
this.eventName = eventName;
/**
* The array of menu items being rendered
* @type {ContextMenuEntry[]}
*/
this.menuItems = menuItems;
/**
* A function to call when the context menu is opened.
* @type {Function}
*/
this.onOpen = onOpen;
/**
* A function to call when the context menu is closed.
* @type {Function}
*/
this.onClose = onClose;
/**
* Track which direction the menu is expanded in
* @type {boolean}
*/
this._expandUp = false;
// Bind to the current element
this.bind();
}
/**
* The parent HTML element to which the context menu is attached
* @type {HTMLElement}
*/
#target;
/* -------------------------------------------- */
/**
* A convenience accessor to the context menu HTML object
* @returns {*|jQuery.fn.init|jQuery|HTMLElement}
*/
get menu() {
return $("#context-menu");
}
/* -------------------------------------------- */
/**
* Create a ContextMenu for this Application and dispatch hooks.
* @param {Application} app The Application this ContextMenu belongs to.
* @param {jQuery} html The Application's rendered HTML.
* @param {string} selector The target CSS selector which activates the menu.
* @param {ContextMenuEntry[]} menuItems The array of menu items being rendered.
* @param {object} [options] Additional options to configure context menu initialization.
* @param {string} [options.hookName="EntryContext"] The name of the hook to call.
* @returns {ContextMenu}
*/
static create(app, html, selector, menuItems, {hookName="EntryContext", ...options}={}) {
for ( const cls of app.constructor._getInheritanceChain() ) {
Hooks.call(`get${cls.name}${hookName}`, html, menuItems);
}
if ( menuItems ) return new ContextMenu(html, selector, menuItems, options);
}
/* -------------------------------------------- */
/**
* Attach a ContextMenu instance to an HTML selector
*/
bind() {
const element = this.element instanceof HTMLElement ? this.element : this.element[0];
element.addEventListener(this.eventName, event => {
const matching = event.target.closest(this.selector);
if ( !matching ) return;
event.preventDefault();
this.#target = matching;
const menu = this.menu;
// Remove existing context UI
const prior = document.querySelector(".context");
prior?.classList.remove("context");
if ( this.#target.contains(menu[0]) ) return this.close();
// Render a new context menu
event.stopPropagation();
ui.context = this;
this.onOpen?.(this.#target);
return this.render($(this.#target));
});
}
/* -------------------------------------------- */
/**
* Closes the menu and removes it from the DOM.
* @param {object} [options] Options to configure the closing behavior.
* @param {boolean} [options.animate=true] Animate the context menu closing.
* @returns {Promise<void>}
*/
async close({animate=true}={}) {
if ( animate ) await this._animateClose(this.menu);
this._close();
}
/* -------------------------------------------- */
_close() {
for ( const item of this.menuItems ) {
delete item.element;
}
this.menu.remove();
$(".context").removeClass("context");
delete ui.context;
this.onClose?.(this.#target);
}
/* -------------------------------------------- */
async _animateOpen(menu) {
menu.hide();
return new Promise(resolve => menu.slideDown(200, resolve));
}
/* -------------------------------------------- */
async _animateClose(menu) {
return new Promise(resolve => menu.slideUp(200, resolve));
}
/* -------------------------------------------- */
/**
* Render the Context Menu by iterating over the menuItems it contains.
* Check the visibility of each menu item, and only render ones which are allowed by the item's logical condition.
* Attach a click handler to each item which is rendered.
* @param {jQuery} target The target element to which the context menu is attached
*/
render(target) {
const existing = $("#context-menu");
let html = existing.length ? existing : $('<nav id="context-menu"></nav>');
let ol = $('<ol class="context-items"></ol>');
html.html(ol);
if ( !this.menuItems.length ) return;
const groups = this.menuItems.reduce((acc, entry) => {
const group = entry.group ?? "_none";
acc[group] ??= [];
acc[group].push(entry);
return acc;
}, {});
for ( const [group, entries] of Object.entries(groups) ) {
let parent = ol;
if ( group !== "_none" ) {
const groupItem = $(`<li class="context-group" data-group-id="${group}"><ol></ol></li>`);
ol.append(groupItem);
parent = groupItem.find("ol");
}
for ( const item of entries ) {
// Determine menu item visibility (display unless false)
let display = true;
if ( item.condition !== undefined ) {
display = ( item.condition instanceof Function ) ? item.condition(target) : item.condition;
}
if ( !display ) continue;
// Construct and add the menu item
const name = game.i18n.localize(item.name);
const li = $(`<li class="context-item">${item.icon}${name}</li>`);
li.children("i").addClass("fa-fw");
parent.append(li);
// Record a reference to the item
item.element = li[0];
}
}
// Bail out if there are no children
if ( ol.children().length === 0 ) return;
// Append to target
this._setPosition(html, target);
// Apply interactivity
if ( !existing.length ) this.activateListeners(html);
// Deactivate global tooltip
game.tooltip.deactivate();
// Animate open the menu
return this._animateOpen(html);
}
/* -------------------------------------------- */
/**
* Set the position of the context menu, taking into consideration whether the menu should expand upward or downward
* @private
*/
_setPosition(html, target) {
const container = target[0].parentElement;
// Append to target and get the context bounds
target.css("position", "relative");
html.css("visibility", "hidden");
target.append(html);
const contextRect = html[0].getBoundingClientRect();
const parentRect = target[0].getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
// Determine whether to expand upwards
const contextTop = parentRect.top - contextRect.height;
const contextBottom = parentRect.bottom + contextRect.height;
const canOverflowUp = (contextTop > containerRect.top) || (getComputedStyle(container).overflowY === "visible");
// If it overflows the container bottom, but not the container top
const containerUp = ( contextBottom > containerRect.bottom ) && ( contextTop >= containerRect.top );
const windowUp = ( contextBottom > window.innerHeight ) && ( contextTop > 0 ) && canOverflowUp;
this._expandUp = containerUp || windowUp;
// Display the menu
html.toggleClass("expand-up", this._expandUp);
html.toggleClass("expand-down", !this._expandUp);
html.css("visibility", "");
target.addClass("context");
}
/* -------------------------------------------- */
/**
* Local listeners which apply to each ContextMenu instance which is created.
* @param {jQuery} html
*/
activateListeners(html) {
html.on("click", "li.context-item", this.#onClickItem.bind(this));
}
/* -------------------------------------------- */
/**
* Handle click events on context menu items.
* @param {PointerEvent} event The click event
*/
#onClickItem(event) {
event.preventDefault();
event.stopPropagation();
const li = event.currentTarget;
const item = this.menuItems.find(i => i.element === li);
item?.callback($(this.#target));
this.close();
}
/* -------------------------------------------- */
/**
* Global listeners which apply once only to the document.
*/
static eventListeners() {
document.addEventListener("click", ev => {
if ( ui.context ) ui.context.close();
});
}
}
/* -------------------------------------------- */
/**
* @typedef {ApplicationOptions} DialogOptions
* @property {boolean} [jQuery=true] Whether to provide jQuery objects to callback functions (if true) or plain
* HTMLElement instances (if false). This is currently true by default but in the
* future will become false by default.
*/
/**
* @typedef {Object} DialogButton
* @property {string} icon A Font Awesome icon for the button
* @property {string} label The label for the button
* @property {boolean} disabled Whether the button is disabled
* @property {function(jQuery)} [callback] A callback function that fires when the button is clicked
*/
/**
* @typedef {object} DialogData
* @property {string} title The window title displayed in the dialog header
* @property {string} content HTML content for the dialog form
* @property {Object<DialogButton>} buttons The buttons which are displayed as action choices for the dialog
* @property {string} [default] The name of the default button which should be triggered on Enter keypress
* @property {function(jQuery)} [render] A callback function invoked when the dialog is rendered
* @property {function(jQuery)} [close] Common callback operations to perform when the dialog is closed
*/
/**
* Create a dialog window displaying a title, a message, and a set of buttons which trigger callback functions.
* @param {DialogData} data An object of dialog data which configures how the modal window is rendered
* @param {DialogOptions} [options] Dialog rendering options, see {@link Application}.
*
* @example Constructing a custom dialog instance
* ```js
* let d = new Dialog({
* title: "Test Dialog",
* content: "<p>You must choose either Option 1, or Option 2</p>",
* buttons: {
* one: {
* icon: '<i class="fas fa-check"></i>',
* label: "Option One",
* callback: () => console.log("Chose One")
* },
* two: {
* icon: '<i class="fas fa-times"></i>',
* label: "Option Two",
* callback: () => console.log("Chose Two")
* }
* },
* default: "two",
* render: html => console.log("Register interactivity in the rendered dialog"),
* close: html => console.log("This always is logged no matter which option is chosen")
* });
* d.render(true);
* ```
*/
class Dialog extends Application {
constructor(data, options) {
super(options);
this.data = data;
}
/**
* A bound instance of the _onKeyDown method which is used to listen to keypress events while the Dialog is active.
* @type {function(KeyboardEvent)}
*/
#onKeyDown;
/* -------------------------------------------- */
/**
* @override
* @returns {DialogOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/hud/dialog.html",
focus: true,
classes: ["dialog"],
width: 400,
jQuery: true
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return this.data.title || "Dialog";
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
let buttons = Object.keys(this.data.buttons).reduce((obj, key) => {
let b = this.data.buttons[key];
b.cssClass = (this.data.default === key ? [key, "default", "bright"] : [key]).join(" ");
if ( b.condition !== false ) obj[key] = b;
return obj;
}, {});
return {
content: this.data.content,
buttons: buttons
};
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
html.find(".dialog-button").click(this._onClickButton.bind(this));
// Prevent the default form submission action if any forms are present in this dialog.
html.find("form").each((i, el) => el.onsubmit = evt => evt.preventDefault());
if ( !this.#onKeyDown ) {
this.#onKeyDown = this._onKeyDown.bind(this);
document.addEventListener("keydown", this.#onKeyDown);
}
if ( this.data.render instanceof Function ) this.data.render(this.options.jQuery ? html : html[0]);
if ( this.options.focus ) {
// Focus the default option
html.find(".default").focus();
}
html.find("[autofocus]")[0]?.focus();
}
/* -------------------------------------------- */
/**
* Handle a left-mouse click on one of the dialog choice buttons
* @param {MouseEvent} event The left-mouse click event
* @private
*/
_onClickButton(event) {
const id = event.currentTarget.dataset.button;
const button = this.data.buttons[id];
this.submit(button, event);
}
/* -------------------------------------------- */
/**
* Handle a keydown event while the dialog is active
* @param {KeyboardEvent} event The keydown event
* @private
*/
_onKeyDown(event) {
// Cycle Options
if ( event.key === "Tab" ) {
const dialog = this.element[0];
// If we are already focused on the Dialog, let the default browser behavior take over
if ( dialog.contains(document.activeElement) ) return;
// If we aren't focused on the dialog, bring focus to one of its buttons
event.preventDefault();
event.stopPropagation();
const dialogButtons = Array.from(document.querySelectorAll(".dialog-button"));
const targetButton = event.shiftKey ? dialogButtons.pop() : dialogButtons.shift();
targetButton.focus();
}
// Close dialog
if ( event.key === "Escape" ) {
event.preventDefault();
event.stopPropagation();
return this.close();
}
// Confirm choice
if ( event.key === "Enter" ) {
// Only handle Enter presses if an input element within the Dialog has focus
const dialog = this.element[0];
if ( !dialog.contains(document.activeElement) || (document.activeElement instanceof HTMLTextAreaElement) ) return;
event.preventDefault();
event.stopPropagation();
// Prefer a focused button, or enact the default option for the dialog
const button = document.activeElement.dataset.button || this.data.default;
const choice = this.data.buttons[button];
return this.submit(choice);
}
}
/* -------------------------------------------- */
/** @inheritDoc */
async _renderOuter() {
let html = await super._renderOuter();
const app = html[0];
app.setAttribute("role", "dialog");
app.setAttribute("aria-modal", "true");
return html;
}
/* -------------------------------------------- */
/**
* Submit the Dialog by selecting one of its buttons
* @param {Object} button The configuration of the chosen button
* @param {PointerEvent} event The originating click event
* @private
*/
submit(button, event) {
const target = this.options.jQuery ? this.element : this.element[0];
try {
if ( button.callback ) button.callback.call(this, target, event);
this.close();
} catch(err) {
ui.notifications.error(err);
throw new Error(err);
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
if ( this.data.close ) this.data.close(this.options.jQuery ? this.element : this.element[0]);
if ( this.#onKeyDown ) {
document.removeEventListener("keydown", this.#onKeyDown);
this.#onKeyDown = undefined;
}
return super.close(options);
}
/* -------------------------------------------- */
/* Factory Methods */
/* -------------------------------------------- */
/**
* A helper factory method to create simple confirmation dialog windows which consist of simple yes/no prompts.
* If you require more flexibility, a custom Dialog instance is preferred.
*
* @param {DialogData} config Confirmation dialog configuration
* @param {Function} [config.yes] Callback function upon yes
* @param {Function} [config.no] Callback function upon no
* @param {boolean} [config.defaultYes=true] Make "yes" the default choice?
* @param {boolean} [config.rejectClose=false] Reject the Promise if the Dialog is closed without making a choice.
* @param {DialogOptions} [config.options={}] Additional rendering options passed to the Dialog
*
* @returns {Promise<any>} A promise which resolves once the user makes a choice or closes the
* window.
*
* @example Prompt the user with a yes or no question
* ```js
* let d = Dialog.confirm({
* title: "A Yes or No Question",
* content: "<p>Choose wisely.</p>",
* yes: () => console.log("You chose ... wisely"),
* no: () => console.log("You chose ... poorly"),
* defaultYes: false
* });
* ```
*/
static async confirm({title, content, yes, no, render, defaultYes=true, rejectClose=false, options={}}={}) {
return this.wait({
title, content, render,
focus: true,
default: defaultYes ? "yes" : "no",
close: () => {
if ( rejectClose ) return;
return null;
},
buttons: {
yes: {
icon: '<i class="fas fa-check"></i>',
label: game.i18n.localize("Yes"),
callback: html => yes ? yes(html) : true
},
no: {
icon: '<i class="fas fa-times"></i>',
label: game.i18n.localize("No"),
callback: html => no ? no(html) : false
}
}
}, options);
}
/* -------------------------------------------- */
/**
* A helper factory method to display a basic "prompt" style Dialog with a single button
* @param {DialogData} config Dialog configuration options
* @param {Function} [config.callback] A callback function to fire when the button is clicked
* @param {boolean} [config.rejectClose=true] Reject the promise if the dialog is closed without confirming the
* choice, otherwise resolve as null
* @param {DialogOptions} [config.options] Additional dialog options
* @returns {Promise<any>} The returned value from the provided callback function, if any
*/
static async prompt({title, content, label, callback, render, rejectClose=true, options={}}={}) {
return this.wait({
title, content, render,
default: "ok",
close: () => {
if ( rejectClose ) return;
return null;
},
buttons: {
ok: { icon: '<i class="fas fa-check"></i>', label, callback }
}
}, options);
}
/* -------------------------------------------- */
/**
* Wrap the Dialog with an enclosing Promise which resolves or rejects when the client makes a choice.
* @param {DialogData} [data] Data passed to the Dialog constructor.
* @param {DialogOptions} [options] Options passed to the Dialog constructor.
* @param {object} [renderOptions] Options passed to the Dialog render call.
* @returns {Promise<any>} A Promise that resolves to the chosen result.
*/
static async wait(data={}, options={}, renderOptions={}) {
return new Promise((resolve, reject) => {
// Wrap buttons with Promise resolution.
const buttons = foundry.utils.deepClone(data.buttons);
for ( const [id, button] of Object.entries(buttons) ) {
const cb = button.callback;
function callback(html, event) {
const result = cb instanceof Function ? cb.call(this, html, event) : undefined;
resolve(result === undefined ? id : result);
}
button.callback = callback;
}
// Wrap close with Promise resolution or rejection.
const originalClose = data.close;
const close = () => {
const result = originalClose instanceof Function ? originalClose() : undefined;
if ( result !== undefined ) resolve(result);
else reject(new Error("The Dialog was closed without a choice being made."));
};
// Construct the dialog.
const dialog = new this({ ...data, buttons, close }, options);
dialog.render(true, renderOptions);
});
}
}
/**
* A UI utility to make an element draggable.
* @param {Application} app The Application that is being made draggable.
* @param {jQuery} element A JQuery reference to the Application's outer-most element.
* @param {HTMLElement|boolean} handle The element that acts as a drag handle. Supply false to disable dragging.
* @param {boolean|object} resizable Is the application resizable? Supply an object to configure resizing behaviour
* or true to have it automatically configured.
* @param {string} [resizable.selector] A selector for the resize handle.
* @param {boolean} [resizable.resizeX=true] Enable resizing in the X direction.
* @param {boolean} [resizable.resizeY=true] Enable resizing in the Y direction.
* @param {boolean} [resizable.rtl] Modify the resizing direction to be right-to-left.
*/
class Draggable {
constructor(app, element, handle, resizable) {
// Setup element data
this.app = app;
this.element = element[0];
this.handle = handle ?? this.element;
this.resizable = resizable || false;
/**
* Duplicate the application's starting position to track differences
* @type {Object}
*/
this.position = null;
/**
* Remember event handlers associated with this Draggable class so they may be later unregistered
* @type {Object}
*/
this.handlers = {};
/**
* Throttle mousemove event handling to 60fps
* @type {number}
*/
this._moveTime = 0;
// Activate interactivity
this.activateListeners();
}
/* ----------------------------------------- */
/**
* Activate event handling for a Draggable application
* Attach handlers for floating, dragging, and resizing
*/
activateListeners() {
this._activateDragListeners();
this._activateResizeListeners();
}
/* ----------------------------------------- */
/**
* Attach handlers for dragging and floating.
* @protected
*/
_activateDragListeners() {
if ( !this.handle ) return;
// Float to top
this.handlers["click"] = ["pointerdown", ev => this.app.bringToTop(), {capture: true, passive: true}];
this.element.addEventListener(...this.handlers.click);
// Drag handlers
this.handlers["dragDown"] = ["pointerdown", e => this._onDragMouseDown(e), false];
this.handlers["dragMove"] = ["pointermove", e => this._onDragMouseMove(e), false];
this.handlers["dragUp"] = ["pointerup", e => this._onDragMouseUp(e), false];
this.handle.addEventListener(...this.handlers.dragDown);
this.handle.classList.add("draggable");
}
/* ----------------------------------------- */
/**
* Attach handlers for resizing.
* @protected
*/
_activateResizeListeners() {
if ( !this.resizable ) return;
let handle = this.element.querySelector(this.resizable.selector);
if ( !handle ) {
handle = $('<div class="window-resizable-handle"><i class="fas fa-arrows-alt-h"></i></div>')[0];
this.element.appendChild(handle);
}
// Register handlers
this.handlers["resizeDown"] = ["pointerdown", e => this._onResizeMouseDown(e), false];
this.handlers["resizeMove"] = ["pointermove", e => this._onResizeMouseMove(e), false];
this.handlers["resizeUp"] = ["pointerup", e => this._onResizeMouseUp(e), false];
// Attach the click handler and CSS class
handle.addEventListener(...this.handlers.resizeDown);
if ( this.handle ) this.handle.classList.add("resizable");
}
/* ----------------------------------------- */
/**
* Handle the initial mouse click which activates dragging behavior for the application
* @private
*/
_onDragMouseDown(event) {
event.preventDefault();
// Record initial position
this.position = foundry.utils.deepClone(this.app.position);
this._initial = {x: event.clientX, y: event.clientY};
// Add temporary handlers
window.addEventListener(...this.handlers.dragMove);
window.addEventListener(...this.handlers.dragUp);
}
/* ----------------------------------------- */
/**
* Move the window with the mouse, bounding the movement to ensure the window stays within bounds of the viewport
* @private
*/
_onDragMouseMove(event) {
event.preventDefault();
// Limit dragging to 60 updates per second
const now = Date.now();
if ( (now - this._moveTime) < (1000/60) ) return;
this._moveTime = now;
// Update application position
this.app.setPosition({
left: this.position.left + (event.clientX - this._initial.x),
top: this.position.top + (event.clientY - this._initial.y)
});
}
/* ----------------------------------------- */
/**
* Conclude the dragging behavior when the mouse is release, setting the final position and removing listeners
* @private
*/
_onDragMouseUp(event) {
event.preventDefault();
window.removeEventListener(...this.handlers.dragMove);
window.removeEventListener(...this.handlers.dragUp);
}
/* ----------------------------------------- */
/**
* Handle the initial mouse click which activates dragging behavior for the application
* @private
*/
_onResizeMouseDown(event) {
event.preventDefault();
// Limit dragging to 60 updates per second
const now = Date.now();
if ( (now - this._moveTime) < (1000/60) ) return;
this._moveTime = now;
// Record initial position
this.position = foundry.utils.deepClone(this.app.position);
if ( this.position.height === "auto" ) this.position.height = this.element.clientHeight;
if ( this.position.width === "auto" ) this.position.width = this.element.clientWidth;
this._initial = {x: event.clientX, y: event.clientY};
// Add temporary handlers
window.addEventListener(...this.handlers.resizeMove);
window.addEventListener(...this.handlers.resizeUp);
}
/* ----------------------------------------- */
/**
* Move the window with the mouse, bounding the movement to ensure the window stays within bounds of the viewport
* @private
*/
_onResizeMouseMove(event) {
event.preventDefault();
const scale = this.app.position.scale ?? 1;
let deltaX = (event.clientX - this._initial.x) / scale;
const deltaY = (event.clientY - this._initial.y) / scale;
if ( this.resizable.rtl === true ) deltaX *= -1;
const newPosition = {
width: this.position.width + deltaX,
height: this.position.height + deltaY
};
if ( this.resizable.resizeX === false ) delete newPosition.width;
if ( this.resizable.resizeY === false ) delete newPosition.height;
this.app.setPosition(newPosition);
}
/* ----------------------------------------- */
/**
* Conclude the dragging behavior when the mouse is release, setting the final position and removing listeners
* @private
*/
_onResizeMouseUp(event) {
event.preventDefault();
window.removeEventListener(...this.handlers.resizeMove);
window.removeEventListener(...this.handlers.resizeUp);
this.app._onResize(event);
}
}
/**
* @typedef {object} DragDropConfiguration
* @property {string} dragSelector The CSS selector used to target draggable elements.
* @property {string} dropSelector The CSS selector used to target viable drop targets.
* @property {Object<string,Function>} permissions An object of permission test functions for each action
* @property {Object<string,Function>} callbacks An object of callback functions for each action
*/
/**
* A controller class for managing drag and drop workflows within an Application instance.
* The controller manages the following actions: dragstart, dragover, drop
* @see {@link Application}
*
* @param {DragDropConfiguration}
* @example Activate drag-and-drop handling for a certain set of elements
* ```js
* const dragDrop = new DragDrop({
* dragSelector: ".item",
* dropSelector: ".items",
* permissions: { dragstart: this._canDragStart.bind(this), drop: this._canDragDrop.bind(this) },
* callbacks: { dragstart: this._onDragStart.bind(this), drop: this._onDragDrop.bind(this) }
* });
* dragDrop.bind(html);
* ```
*/
class DragDrop {
constructor({dragSelector, dropSelector, permissions={}, callbacks={}} = {}) {
/**
* The HTML selector which identifies draggable elements
* @type {string}
*/
this.dragSelector = dragSelector;
/**
* The HTML selector which identifies drop targets
* @type {string}
*/
this.dropSelector = dropSelector;
/**
* A set of permission checking functions for each action of the Drag and Drop workflow
* @type {Object}
*/
this.permissions = permissions;
/**
* A set of callback functions for each action of the Drag and Drop workflow
* @type {Object}
*/
this.callbacks = callbacks;
}
/* -------------------------------------------- */
/**
* Bind the DragDrop controller to an HTML application
* @param {HTMLElement} html The HTML element to which the handler is bound
*/
bind(html) {
// Identify and activate draggable targets
if ( this.can("dragstart", this.dragSelector) ) {
const draggables = html.querySelectorAll(this.dragSelector);
for (let el of draggables) {
el.setAttribute("draggable", true);
el.ondragstart = this._handleDragStart.bind(this);
}
}
// Identify and activate drop targets
if ( this.can("drop", this.dropSelector) ) {
const droppables = !this.dropSelector || html.matches(this.dropSelector) ? [html] :
html.querySelectorAll(this.dropSelector);
for ( let el of droppables ) {
el.ondragover = this._handleDragOver.bind(this);
el.ondrop = this._handleDrop.bind(this);
}
}
return this;
}
/* -------------------------------------------- */
/**
* Execute a callback function associated with a certain action in the workflow
* @param {DragEvent} event The drag event being handled
* @param {string} action The action being attempted
*/
callback(event, action) {
const fn = this.callbacks[action];
if ( fn instanceof Function ) return fn(event);
}
/* -------------------------------------------- */
/**
* Test whether the current user has permission to perform a step of the workflow
* @param {string} action The action being attempted
* @param {string} selector The selector being targeted
* @return {boolean} Can the action be performed?
*/
can(action, selector) {
const fn = this.permissions[action];
if ( fn instanceof Function ) return fn(selector);
return true;
}
/* -------------------------------------------- */
/**
* Handle the start of a drag workflow
* @param {DragEvent} event The drag event being handled
* @private
*/
_handleDragStart(event) {
this.callback(event, "dragstart");
if ( event.dataTransfer.items.length ) event.stopPropagation();
}
/* -------------------------------------------- */
/**
* Handle a dragged element over a droppable target
* @param {DragEvent} event The drag event being handled
* @private
*/
_handleDragOver(event) {
event.preventDefault();
this.callback(event, "dragover");
return false;
}
/* -------------------------------------------- */
/**
* Handle a dragged element dropped on a droppable target
* @param {DragEvent} event The drag event being handled
* @private
*/
_handleDrop(event) {
event.preventDefault();
return this.callback(event, "drop");
}
/* -------------------------------------------- */
static createDragImage(img, width, height) {
let div = document.getElementById("drag-preview");
// Create the drag preview div
if ( !div ) {
div = document.createElement("div");
div.setAttribute("id", "drag-preview");
const img = document.createElement("img");
img.classList.add("noborder");
div.appendChild(img);
document.body.appendChild(div);
}
// Add the preview image
const i = div.children[0];
i.src = img.src;
i.width = width;
i.height = height;
return div;
}
}
/**
* A collection of helper functions and utility methods related to the rich text editor
*/
class TextEditor {
/**
* A singleton text area used for HTML decoding.
* @type {HTMLTextAreaElement}
*/
static #decoder = document.createElement("textarea");
/**
* Create a Rich Text Editor. The current implementation uses TinyMCE
* @param {object} options Configuration options provided to the Editor init
* @param {string} [options.engine=tinymce] Which rich text editor engine to use, "tinymce" or "prosemirror". TinyMCE
* is deprecated and will be removed in a later version.
* @param {string} content Initial HTML or text content to populate the editor with
* @returns {Promise<TinyMCE.Editor|ProseMirrorEditor>} The editor instance.
*/
static async create({engine="tinymce", ...options}={}, content="") {
if ( engine === "prosemirror" ) {
const {target, ...rest} = options;
return ProseMirrorEditor.create(target, content, rest);
}
if ( engine === "tinymce" ) return this._createTinyMCE(options, content);
throw new Error(`Provided engine '${engine}' is not a valid TextEditor engine.`);
}
/**
* A list of elements that are retained when truncating HTML.
* @type {Set<string>}
* @private
*/
static _PARAGRAPH_ELEMENTS = new Set([
"header", "main", "section", "article", "div", "footer", // Structural Elements
"h1", "h2", "h3", "h4", "h5", "h6", // Headers
"p", "blockquote", "summary", "span", "a", "mark", // Text Types
"strong", "em", "b", "i", "u" // Text Styles
]);
/* -------------------------------------------- */
/**
* Create a TinyMCE editor instance.
* @param {object} [options] Configuration options passed to the editor.
* @param {string} [content=""] Initial HTML or text content to populate the editor with.
* @returns {Promise<TinyMCE.Editor>} The TinyMCE editor instance.
* @protected
*/
static async _createTinyMCE(options={}, content="") {
const mceConfig = foundry.utils.mergeObject(CONFIG.TinyMCE, options, {inplace: false});
mceConfig.target = options.target;
mceConfig.file_picker_callback = function (pickerCallback, value, meta) {
let filePicker = new FilePicker({
type: "image",
callback: path => {
pickerCallback(path);
// Reset our z-index for next open
$(".tox-tinymce-aux").css({zIndex: ''});
},
});
filePicker.render();
// Set the TinyMCE dialog to be below the FilePicker
$(".tox-tinymce-aux").css({zIndex: Math.min(++_maxZ, 9999)});
};
if ( mceConfig.content_css instanceof Array ) {
mceConfig.content_css = mceConfig.content_css.map(c => foundry.utils.getRoute(c)).join(",");
}
mceConfig.init_instance_callback = editor => {
const window = editor.getWin();
editor.focus();
if ( content ) editor.resetContent(content);
editor.selection.setCursorLocation(editor.getBody(), editor.getBody().childElementCount);
window.addEventListener("wheel", event => {
if ( event.ctrlKey ) event.preventDefault();
}, {passive: false});
editor.off("drop dragover"); // Remove the default TinyMCE dragdrop handlers.
editor.on("drop", event => this._onDropEditorData(event, editor));
};
const [editor] = await tinyMCE.init(mceConfig);
editor.document = options.document;
return editor;
}
/* -------------------------------------------- */
/* HTML Manipulation Helpers
/* -------------------------------------------- */
/**
* Safely decode an HTML string, removing invalid tags and converting entities back to unicode characters.
* @param {string} html The original encoded HTML string
* @returns {string} The decoded unicode string
*/
static decodeHTML(html) {
const d = TextEditor.#decoder;
d.innerHTML = html;
const decoded = d.value;
d.innerHTML = "";
return decoded;
}
/* -------------------------------------------- */
/**
* @typedef {object} EnrichmentOptions
* @property {boolean} [secrets=false] Include unrevealed secret tags in the final HTML? If false, unrevealed
* secret blocks will be removed.
* @property {boolean} [documents=true] Replace dynamic document links?
* @property {boolean} [links=true] Replace hyperlink content?
* @property {boolean} [rolls=true] Replace inline dice rolls?
* @property {object|Function} [rollData] The data object providing context for inline rolls, or a function that
* produces it.
* @property {boolean} [async=true] Perform the operation asynchronously returning a Promise
* @property {ClientDocument} [relativeTo] A document to resolve relative UUIDs against.
*/
/**
* Enrich HTML content by replacing or augmenting components of it
* @param {string} content The original HTML content (as a string)
* @param {EnrichmentOptions} [options={}] Additional options which configure how HTML is enriched
* @returns {string|Promise<string>} The enriched HTML content
*/
static enrichHTML(content, options={}) {
let {secrets=false, documents=true, links=true, rolls=true, async=true, rollData} = options;
/**
* @deprecated since v10
*/
if ( async === undefined ) {
foundry.utils.logCompatibilityWarning("TextEditor.enrichHTML is becoming asynchronous. You may pass async=false"
+ " to temporarily preserve the prior behavior.", {since: 10, until: 12});
async = true;
}
if ( !content?.length ) return async ? Promise.resolve("") : "";
// Create the HTML element
const html = document.createElement("div");
html.innerHTML = String(content || "");
// Remove unrevealed secret blocks
if ( !secrets ) html.querySelectorAll("section.secret:not(.revealed)").forEach(secret => secret.remove());
// Plan text content replacements
let updateTextArray = true;
let text = [];
const fns = [];
if ( documents ) fns.push(this._enrichContentLinks.bind(this));
if ( links ) fns.push(this._enrichHyperlinks.bind(this));
if ( rolls ) fns.push(this._enrichInlineRolls.bind(this, rollData));
if ( async ) {
for ( const config of CONFIG.TextEditor.enrichers ) {
fns.push(this._applyCustomEnrichers.bind(this, config.pattern, config.enricher));
}
}
const enrich = (fn, update) => {
if ( update ) text = this._getTextNodes(html);
return fn(text, options);
};
for ( const fn of fns ) {
if ( updateTextArray instanceof Promise ) updateTextArray = updateTextArray.then(update => enrich(fn, update));
else updateTextArray = enrich(fn, updateTextArray);
}
if ( updateTextArray instanceof Promise ) return updateTextArray.then(() => html.innerHTML);
return async ? Promise.resolve(html.innerHTML) : html.innerHTML;
}
/* -------------------------------------------- */
/**
* Convert text of the form @UUID[uuid]{name} to anchor elements.
* @param {Text[]} text The existing text content
* @param {EnrichmentOptions} [options] Options provided to customize text enrichment
* @param {boolean} [options.async] Whether to resolve UUIDs asynchronously
* @param {ClientDocument} [options.relativeTo] A document to resolve relative UUIDs against.
* @returns {Promise<boolean>|boolean} Whether any content links were replaced and the text nodes need to be
* updated.
* @protected
*/
static _enrichContentLinks(text, {async, relativeTo}={}) {
const documentTypes = CONST.DOCUMENT_LINK_TYPES.concat(["Compendium", "UUID"]);
const rgx = new RegExp(`@(${documentTypes.join("|")})\\[([^#\\]]+)(?:#([^\\]]+))?](?:{([^}]+)})?`, "g");
return this._replaceTextContent(text, rgx, match => this._createContentLink(match, {async, relativeTo}));
}
/* -------------------------------------------- */
/**
* Convert URLs into anchor elements.
* @param {Text[]} text The existing text content
* @param {EnrichmentOptions} [options] Options provided to customize text enrichment
* @returns {boolean} Whether any hyperlinks were replaced and the text nodes need to be updated
* @protected
*/
static _enrichHyperlinks(text, options={}) {
const rgx = /(https?:\/\/)(www\.)?([^\s<]+)/gi;
return this._replaceTextContent(text, rgx, this._createHyperlink);
}
/* -------------------------------------------- */
/**
* Convert text of the form [[roll]] to anchor elements.
* @param {object|Function} rollData The data object providing context for inline rolls.
* @param {Text[]} text The existing text content.
* @param {EnrichmentOptions} [options] Options provided to customize text enrichment
* @param {boolean} [options.async] Whether to resolve immediate inline rolls asynchronously.
* @returns {Promise<boolean>|boolean} Whether any inline rolls were replaced and the text nodes need to be updated.
* @protected
*/
static _enrichInlineRolls(rollData, text, {async}={}) {
rollData = rollData instanceof Function ? rollData() : (rollData || {});
const rgx = /\[\[(\/[a-zA-Z]+\s)?(.*?)(]{2,3})(?:{([^}]+)})?/gi;
return this._replaceTextContent(text, rgx, match => this._createInlineRoll(match, rollData, {async}));
}
/* -------------------------------------------- */
/**
* Match any custom registered regex patterns and apply their replacements.
* @param {RegExp} pattern The pattern to match against.
* @param {TextEditorEnricher} enricher The function that will be run for each match.
* @param {Text[]} text The existing text content.
* @param {EnrichmentOptions} [options] Options provided to customize text enrichment
* @returns {Promise<boolean>} Whether any replacements were made, requiring the text nodes to be updated.
* @protected
*/
static _applyCustomEnrichers(pattern, enricher, text, options) {
return this._replaceTextContent(text, pattern, match => enricher(match, options));
}
/* -------------------------------------------- */
/**
* Preview an HTML fragment by constructing a substring of a given length from its inner text.
* @param {string} content The raw HTML to preview
* @param {number} length The desired length
* @returns {string} The previewed HTML
*/
static previewHTML(content, length=250) {
let div = document.createElement("div");
div.innerHTML = content;
div = this.truncateHTML(div);
div.innerText = this.truncateText(div.innerText, {maxLength: length});
return div.innerHTML;
}
/* --------------------------------------------- */
/**
* Sanitises an HTML fragment and removes any non-paragraph-style text.
* @param {HTMLElement} html The root HTML element.
* @returns {HTMLElement}
*/
static truncateHTML(html) {
const truncate = root => {
for ( const node of root.childNodes ) {
if ( [Node.COMMENT_NODE, Node.TEXT_NODE].includes(node.nodeType) ) continue;
if ( node.nodeType === Node.ELEMENT_NODE ) {
if ( this._PARAGRAPH_ELEMENTS.has(node.tagName.toLowerCase()) ) truncate(node);
else node.remove();
}
}
};
const clone = html.cloneNode(true);
truncate(clone);
return clone;
}
/* -------------------------------------------- */
/**
* Truncate a fragment of text to a maximum number of characters.
* @param {string} text The original text fragment that should be truncated to a maximum length
* @param {object} [options] Options which affect the behavior of text truncation
* @param {number} [options.maxLength] The maximum allowed length of the truncated string.
* @param {boolean} [options.splitWords] Whether to truncate by splitting on white space (if true) or breaking words.
* @param {string|null} [options.suffix] A suffix string to append to denote that the text was truncated.
* @returns {string} The truncated text string
*/
static truncateText(text, {maxLength=50, splitWords=true, suffix="…"}={}) {
if ( text.length <= maxLength ) return text;
// Split the string (on words if desired)
let short;
if ( splitWords ) {
short = text.slice(0, maxLength + 10);
while ( short.length > maxLength ) {
if ( /\s/.test(short) ) short = short.replace(/[\s]+([\S]+)?$/, "");
else short = short.slice(0, maxLength);
}
} else {
short = text.slice(0, maxLength);
}
// Add a suffix and return
suffix = suffix ?? "";
return short + suffix;
}
/* -------------------------------------------- */
/* Text Node Manipulation
/* -------------------------------------------- */
/**
* Recursively identify the text nodes within a parent HTML node for potential content replacement.
* @param {HTMLElement} parent The parent HTML Element
* @returns {Text[]} An array of contained Text nodes
* @private
*/
static _getTextNodes(parent) {
const text = [];
const walk = document.createTreeWalker(parent, NodeFilter.SHOW_TEXT);
while ( walk.nextNode() ) text.push(walk.currentNode);
return text;
}
/* -------------------------------------------- */
/**
* Facilitate the replacement of text node content using a matching regex rule and a provided replacement function.
* @param {Text} text The target text to replace
* @param {RegExp} rgx The provided regular expression for matching and replacement
* @param {function(RegExpMatchArray): HTMLElement|Promise<HTMLElement>} func The replacement function
* @private
*/
static _replaceTextContent(text, rgx, func) {
let replaced = false;
const promises = [];
for ( let t of text ) {
const matches = t.textContent.matchAll(rgx);
for ( let match of Array.from(matches).reverse() ) {
let result;
try {
result = func(match);
} catch ( err ) {
Hooks.onError("TextEditor.enrichHTML", err, { log: "error" });
}
// TODO: This logic can be simplified/replaced entirely with await once enrichHTML becomes fully async.
// We can't mix promises and non-promises.
if ( promises.length && !(result instanceof Promise) ) result = Promise.resolve(result);
if ( result instanceof Promise ) promises.push(result.then(r => [t, match, r]));
else if ( result ) {
this._replaceTextNode(t, match, result);
replaced = true;
}
}
}
if ( promises.length ) {
return Promise.allSettled(promises).then(results => results.reduce((replaced, settled) => {
if ( settled.status === "rejected" ) Hooks.onError("TextEditor.enrichHTML", settled.reason, { log: "error" });
if ( !settled.value ) return replaced;
const [text, match, result] = settled.value;
if ( result ) {
this._replaceTextNode(text, match, result);
return true;
}
return replaced;
}, replaced));
}
return replaced;
}
/* -------------------------------------------- */
/**
* Replace a matched portion of a Text node with a replacement Node
* @param {Text} text
* @param {RegExpMatchArray} match
* @param {Node} replacement
* @private
*/
static _replaceTextNode(text, match, replacement) {
let target = text;
if ( match.index > 0 ) {
target = text.splitText(match.index);
}
if ( match[0].length < target.length ) {
target.splitText(match[0].length);
}
target.replaceWith(replacement);
}
/* -------------------------------------------- */
/* Text Replacement Functions
/* -------------------------------------------- */
/**
* Create a dynamic document link from a regular expression match
* @param {RegExpMatchArray} match The regular expression match
* @param {object} [options] Additional options to configure enrichment behaviour
* @param {boolean} [options.async=false] If asynchronous evaluation is enabled, fromUuid will be
* called, allowing comprehensive UUID lookup, otherwise
* fromUuidSync will be used.
* @param {ClientDocument} [options.relativeTo] A document to resolve relative UUIDs against.
* @returns {HTMLAnchorElement|Promise<HTMLAnchorElement>} An HTML element for the document link, returned as a
* Promise if async was true and the message contained a
* UUID link.
* @protected
*/
static _createContentLink(match, {async=false, relativeTo}={}) {
let [type, target, hash, name] = match.slice(1, 5);
// Prepare replacement data
const data = {
cls: ["content-link"],
icon: null,
dataset: {},
name
};
let doc;
let broken = false;
if ( type === "UUID" ) {
data.dataset = {id: null, uuid: target};
if ( async ) doc = fromUuid(target, {relative: relativeTo});
else {
try {
doc = fromUuidSync(target, {relative: relativeTo});
} catch(err) {
[type, ...target] = target.split(".");
broken = TextEditor._createLegacyContentLink(type, target.join("."), name, data);
}
}
}
else broken = TextEditor._createLegacyContentLink(type, target, name, data);
// Flag a link as broken
if ( broken ) {
data.icon = "fas fa-unlink";
data.cls.push("broken");
}
const constructAnchor = doc => {
if ( doc ) {
if ( doc.documentName ) {
const attrs = {draggable: true};
if ( hash ) attrs["data-hash"] = hash;
return doc.toAnchor({attrs, classes: data.cls, name: data.name});
}
data.name = data.name || doc.name || target;
const type = game.packs.get(doc.pack)?.documentName;
data.dataset.type = type;
data.dataset.id = doc._id;
data.dataset.pack = doc.pack;
if ( hash ) data.dataset.hash = hash;
data.icon = CONFIG[type].sidebarIcon;
} else if ( type === "UUID" ) {
// The UUID lookup failed so this is a broken link.
data.icon = "fas fa-unlink";
data.cls.push("broken");
}
const a = document.createElement("a");
a.classList.add(...data.cls);
a.draggable = true;
for ( let [k, v] of Object.entries(data.dataset) ) {
a.dataset[k] = v;
}
a.innerHTML = `<i class="${data.icon}"></i>${data.name}`;
return a;
};
if ( doc instanceof Promise ) return doc.then(constructAnchor);
return constructAnchor(doc);
}
/* -------------------------------------------- */
/**
* Create a dynamic document link from an old-form document link expression.
* @param {string} type The matched document type, or "Compendium".
* @param {string} target The requested match target (_id or name).
* @param {string} name A customized or overridden display name for the link.
* @param {object} data Data containing the properties of the resulting link element.
* @returns {boolean} Whether the resulting link is broken or not.
* @private
*/
static _createLegacyContentLink(type, target, name, data) {
let broken = false;
// Get a matched World document
if ( CONST.DOCUMENT_TYPES.includes(type) ) {
// Get the linked Document
const config = CONFIG[type];
const collection = game.collections.get(type);
const document = foundry.data.validators.isValidId(target) ? collection.get(target) : collection.getName(target);
if ( !document ) broken = true;
// Update link data
data.name = data.name || (broken ? target : document.name);
data.icon = config.sidebarIcon;
data.dataset = {type, uuid: document?.uuid};
}
// Get a matched PlaylistSound
else if ( type === "PlaylistSound" ) {
const [, playlistId, , soundId] = target.split(".");
const playlist = game.playlists.get(playlistId);
const sound = playlist?.sounds.get(soundId);
if ( !playlist || !sound ) broken = true;
data.name = data.name || (broken ? target : sound.name);
data.icon = CONFIG.Playlist.sidebarIcon;
data.dataset = {type, uuid: sound?.uuid};
if ( sound?.playing ) data.cls.push("playing");
if ( !game.user.isGM ) data.cls.push("disabled");
}
// Get a matched Compendium document
else if ( type === "Compendium" ) {
// Get the linked Document
let [scope, packName, id] = target.split(".");
const pack = game.packs.get(`${scope}.${packName}`);
if ( pack ) {
data.dataset = {pack: pack.collection, uuid: pack.getUuid(id)};
data.icon = CONFIG[pack.documentName].sidebarIcon;
// If the pack is indexed, retrieve the data
if ( pack.index.size ) {
const index = pack.index.find(i => (i._id === id) || (i.name === id));
if ( index ) {
if ( !data.name ) data.name = index.name;
data.dataset.id = index._id;
data.dataset.uuid = index.uuid;
}
else broken = true;
}
// Otherwise assume the link may be valid, since the pack has not been indexed yet
if ( !data.name ) data.name = data.dataset.lookup = id;
}
else broken = true;
}
return broken;
}
/* -------------------------------------------- */
/**
* Replace a hyperlink-like string with an actual HTML &lt;a> tag
* @param {RegExpMatchArray} match The regular expression match
* @param {object} [options] Additional options to configure enrichment behaviour
* @returns {HTMLAnchorElement} An HTML element for the document link
* @private
*/
static _createHyperlink(match, options) {
const href = match[0];
const a = document.createElement("a");
a.classList.add("hyperlink");
a.href = a.textContent = href;
a.target = "_blank";
a.rel = "nofollow noopener";
return a;
}
/* -------------------------------------------- */
/**
* Replace an inline roll formula with a rollable &lt;a> element or an eagerly evaluated roll result
* @param {RegExpMatchArray} match The regular expression match array
* @param {object} rollData Provided roll data for use in roll evaluation
* @param {object} [options] Additional options to configure enrichment behaviour
* @returns {HTMLAnchorElement|null|Promise<HTMLAnchorElement|null>} The replaced match, returned as a Promise if
* async was true and the message contained an
* immediate inline roll.
*/
static _createInlineRoll(match, rollData, options) {
let [command, formula, closing, label] = match.slice(1, 5);
const isDeferred = !!command;
let roll;
// Define default inline data
const data = {
cls: ["inline-roll"],
dataset: {}
};
// Handle the possibility of closing brackets
if ( closing.length === 3 ) formula += "]";
// Extract roll data as a parsed chat command
if ( isDeferred ) {
const chatCommand = `${command}${formula}`;
let parsedCommand = null;
try {
parsedCommand = ChatLog.parse(chatCommand);
}
catch(err) { return null; }
const [cmd, matches] = parsedCommand;
if ( ["invalid", "none"].includes(cmd) ) return null;
const match = ChatLog.MULTILINE_COMMANDS.has(cmd) ? matches.pop() : matches;
const [raw, rollType, fml, flv] = match;
// Set roll data
data.cls.push(parsedCommand[0]);
data.dataset.mode = parsedCommand[0];
data.dataset.flavor = flv?.trim() ?? label ?? "";
data.dataset.formula = Roll.defaultImplementation.replaceFormulaData(fml.trim(), rollData || {});
const a = document.createElement("a");
a.classList.add(...data.cls);
for ( const [k, v] of Object.entries(data.dataset) ) {
a.dataset[k] = v;
}
const title = label || data.dataset.formula;
a.innerHTML = `<i class="fas fa-dice-d20"></i>${title}`;
if ( label ) a.dataset.tooltip = data.dataset.formula;
return a;
}
// Perform the roll immediately
try {
data.cls.push("inline-result");
const anchorOptions = {classes: data.cls, label};
roll = Roll.create(formula, rollData).roll(options);
if ( roll instanceof Promise ) return roll.then(r => r.toAnchor(anchorOptions)).catch(() => null);
return roll.toAnchor(anchorOptions);
}
catch(err) { return null; }
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Activate interaction listeners for the interior content of the editor frame.
*/
static activateListeners() {
const body = $("body");
body.on("click", "a.content-link", this._onClickContentLink);
body.on("dragstart", "a.content-link", this._onDragContentLink);
body.on("click", "a.inline-roll", this._onClickInlineRoll);
}
/* -------------------------------------------- */
/**
* Handle click events on Document Links
* @param {Event} event
* @private
*/
static async _onClickContentLink(event) {
event.preventDefault();
const doc = await fromUuid(event.currentTarget.dataset.uuid);
return doc?._onClickDocumentLink(event);
}
/* -------------------------------------------- */
/**
* Handle left-mouse clicks on an inline roll, dispatching the formula or displaying the tooltip
* @param {MouseEvent} event The initiating click event
* @private
*/
static async _onClickInlineRoll(event) {
event.preventDefault();
const a = event.currentTarget;
// For inline results expand or collapse the roll details
if ( a.classList.contains("inline-result") ) {
if ( a.classList.contains("expanded") ) {
return Roll.defaultImplementation.collapseInlineResult(a);
} else {
return Roll.defaultImplementation.expandInlineResult(a);
}
}
// Get the current speaker
const cls = ChatMessage.implementation;
const speaker = cls.getSpeaker();
let actor = cls.getSpeakerActor(speaker);
let rollData = actor ? actor.getRollData() : {};
// Obtain roll data from the contained sheet, if the inline roll is within an Actor or Item sheet
const sheet = a.closest(".sheet");
if ( sheet ) {
const app = ui.windows[sheet.dataset.appid];
if ( ["Actor", "Item"].includes(app?.object?.documentName) ) rollData = app.object.getRollData();
}
// Execute a deferred roll
const roll = Roll.create(a.dataset.formula, rollData);
return roll.toMessage({flavor: a.dataset.flavor, speaker}, {rollMode: a.dataset.mode});
}
/* -------------------------------------------- */
/**
* Begin a Drag+Drop workflow for a dynamic content link
* @param {Event} event The originating drag event
* @private
*/
static _onDragContentLink(event) {
event.stopPropagation();
const a = event.currentTarget;
let dragData = null;
// Case 1 - Compendium Link
if ( a.dataset.pack ) {
const pack = game.packs.get(a.dataset.pack);
let id = a.dataset.id;
if ( a.dataset.lookup && pack.index.size ) {
const entry = pack.index.find(i => (i._id === a.dataset.lookup) || (i.name === a.dataset.lookup));
if ( entry ) id = entry._id;
}
if ( !a.dataset.uuid && !id ) return false;
const uuid = a.dataset.uuid || pack.getUuid(id);
dragData = { type: a.dataset.type || pack.documentName, uuid };
}
// Case 2 - World Document Link
else {
const doc = fromUuidSync(a.dataset.uuid);
dragData = doc.toDragData();
}
event.originalEvent.dataTransfer.setData("text/plain", JSON.stringify(dragData));
}
/* -------------------------------------------- */
/**
* Handle dropping of transferred data onto the active rich text editor
* @param {DragEvent} event The originating drop event which triggered the data transfer
* @param {TinyMCE} editor The TinyMCE editor instance being dropped on
* @private
*/
static async _onDropEditorData(event, editor) {
event.preventDefault();
const eventData = this.getDragEventData(event);
const link = await TextEditor.getContentLink(eventData, {relativeTo: editor.document});
if ( link ) editor.insertContent(link);
}
/* -------------------------------------------- */
/**
* Extract JSON data from a drag/drop event.
* @param {DragEvent} event The drag event which contains JSON data.
* @returns {object} The extracted JSON data. The object will be empty if the DragEvent did not contain
* JSON-parseable data.
*/
static getDragEventData(event) {
if ( !("dataTransfer" in event) ) { // Clumsy because (event instanceof DragEvent) doesn't work
console.warn("Incorrectly attempted to process drag event data for an event which was not a DragEvent.");
return {};
}
try {
return JSON.parse(event.dataTransfer.getData("text/plain"));
} catch(err) {
return {};
}
}
/* -------------------------------------------- */
/**
* Given a Drop event, returns a Content link if possible such as @Actor[ABC123], else null
* @param {object} eventData The parsed object of data provided by the transfer event
* @param {object} [options] Additional options to configure link creation.
* @param {ClientDocument} [options.relativeTo] A document to generate the link relative to.
* @param {string} [options.label] A custom label to use instead of the document's name.
* @returns {Promise<string|null>}
*/
static async getContentLink(eventData, options={}) {
const cls = getDocumentClass(eventData.type);
if ( !cls ) return null;
const document = await cls.fromDropData(eventData);
if ( !document ) return null;
return document._createDocumentLink(eventData, options);
}
/* -------------------------------------------- */
/**
* Upload an image to a document's asset path.
* @param {string} uuid The document's UUID.
* @param {File} file The image file to upload.
* @returns {Promise<string>} The path to the uploaded image.
* @internal
*/
static async _uploadImage(uuid, file) {
if ( !game.user.hasPermission("FILES_UPLOAD") ) {
ui.notifications.error("EDITOR.NoUploadPermission", {localize: true});
return;
}
ui.notifications.info("EDITOR.UploadingFile", {localize: true});
const response = await FilePicker.upload(null, null, file, {uuid});
return response?.path;
}
}
// Global Export
window.TextEditor = TextEditor;
/**
* @typedef {ApplicationOptions} FilePickerOptions
* @property {string} [type="any"] A type of file to target, in "audio", "image", "video", "imagevideo",
* "folder", "font", "graphics", "text", or "any"
* @property {string} [current] The current file path being modified, if any
* @property {string} [activeSource=data] A current file source in "data", "public", or "s3"
* @property {Function} [callback] A callback function to trigger once a file has been selected
* @property {boolean} [allowUpload=true] A flag which permits explicitly disallowing upload, true by default
* @property {HTMLElement} [field] An HTML form field that the result of this selection is applied to
* @property {HTMLButtonElement} [button] An HTML button element which triggers the display of this picker
* @property {Map<string, FavoriteFolder>} [favorites] The picker display mode in FilePicker.DISPLAY_MODES
* @property {string} [displayMode] The picker display mode in FilePicker.DISPLAY_MODES
* @property {boolean} [tileSize=false] Display the tile size configuration.
* @property {string[]} [redirectToRoot] Redirect to the root directory rather than starting in the source directory
* of one of these files.
*/
/**
* The FilePicker application renders contents of the server-side public directory.
* This app allows for navigating and uploading files to the public path.
*
* @param {FilePickerOptions} [options={}] Options that configure the behavior of the FilePicker
*/
class FilePicker extends Application {
constructor(options={}) {
super(options);
/**
* The full requested path given by the user
* @type {string}
*/
this.request = options.current;
/**
* The file sources which are available for browsing
* @type {object}
*/
this.sources = Object.entries({
data: {
target: "",
label: game.i18n.localize("FILES.SourceUser"),
icon: "fas fa-database"
},
public: {
target: "",
label: game.i18n.localize("FILES.SourceCore"),
icon: "fas fa-server"
},
s3: {
buckets: [],
bucket: "",
target: "",
label: game.i18n.localize("FILES.SourceS3"),
icon: "fas fa-cloud"
}
}).reduce((obj, s) => {
if ( game.data.files.storages.includes(s[0]) ) obj[s[0]] = s[1];
return obj;
}, {});
/**
* Track the active source tab which is being browsed
* @type {string}
*/
this.activeSource = options.activeSource || "data";
/**
* A callback function to trigger once a file has been selected
* @type {Function}
*/
this.callback = options.callback;
/**
* The latest set of results browsed from the server
* @type {object}
*/
this.results = {};
/**
* The general file type which controls the set of extensions which will be accepted
* @type {string}
*/
this.type = options.type ?? "any";
/**
* The target HTML element this file picker is bound to
* @type {HTMLElement}
*/
this.field = options.field;
/**
* A button which controls the display of the picker UI
* @type {HTMLElement}
*/
this.button = options.button;
/**
* The display mode of the FilePicker UI
* @type {string}
*/
this.displayMode = options.displayMode || FilePicker.LAST_DISPLAY_MODE;
/**
* The current set of file extensions which are being filtered upon
* @type {string[]}
*/
this.extensions = this._getExtensions(this.type);
// Infer the source
const [source, target] = this._inferCurrentDirectory(this.request);
this.activeSource = source;
this.sources[source].target = target;
// Track whether we have loaded files
this._loaded = false;
}
/**
* Record the last-browsed directory path so that re-opening a different FilePicker instance uses the same target
* @type {string}
*/
static LAST_BROWSED_DIRECTORY = "";
/**
* Record the last-configured tile size which can automatically be applied to new FilePicker instances
* @type {number|null}
*/
static LAST_TILE_SIZE = null;
/**
* Record the last-configured display mode so that re-opening a different FilePicker instance uses the same mode.
* @type {string}
*/
static LAST_DISPLAY_MODE = "list";
/**
* Enumerate the allowed FilePicker display modes
* @type {string[]}
*/
static DISPLAY_MODES = ["list", "thumbs", "tiles", "images"];
/**
* Cache the names of S3 buckets which can be used
* @type {Array|null}
*/
static S3_BUCKETS = null;
/**
* @typedef FavoriteFolder
* @property {string} source The source of the folder (e.g. "data", "public")
* @property {string} path The full path to the folder
* @property {string} label The label for the path
*/
/**
* Get favorite folders for quick access
* @type {string[]}
* @return {Map<string, FavoriteFolder>}
*/
static get favorites() {
if ( !this._favorites ) this._favorites = game.settings.get("core", "favoritePaths");
return this._favorites;
}
/**
* Set favorite folders for quick access
* @param {Map<string, FavoriteFolder>} favorites An object of Favorite Folders
*/
static set favorites(favorites) {
this._favorites = favorites;
}
/* -------------------------------------------- */
/**
* Add the given path for the source to the favorites
* @param {string} source The source of the folder (e.g. "data", "public")
* @param {string} path The path to a folder
*/
static async setFavorite(source, path ) {
const favorites = FilePicker.favorites;
const lastCharacter = path[path.length - 1];
// Standardize all paths to end with a "/". Has the side benefit of ensuring that the root path which is normally an empty string has content.
path = path.endsWith("/") ? path : `${path}/`;
const alreadyFavorited = Object.keys(favorites).includes(`${source}-${path}`);
if ( alreadyFavorited ) return ui.notifications.info(game.i18n.format("FILES.AlreadyFavorited", {path}));
let label;
if ( path === "/" ) label = "root";
else {
const directories = path.split("/");
label = directories[directories.length - 2]; // Get the final part of the path for the label
}
favorites[`${source}-${path}`] = {source, path, label};
FilePicker.favorites = favorites;
await game.settings.set("core", "favoritePaths", favorites);
}
/* -------------------------------------------- */
/**
* Remove the given path from the favorites
* @param {string} source The source of the folder (e.g. "data", "public")
* @param {string} path The path to a folder
*/
static async removeFavorite(source, path) {
const favorites = FilePicker.favorites;
delete favorites[`${source}-${path}`];
FilePicker.favorites = favorites;
await game.settings.set("core", "favoritePaths", favorites);
}
/* -------------------------------------------- */
/**
* @override
* @returns {FilePickerOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/apps/filepicker.html",
classes: ["filepicker"],
width: 520,
tabs: [{navSelector: ".tabs"}],
dragDrop: [{dragSelector: ".file", dropSelector: ".filepicker-body"}],
tileSize: false,
filters: [{inputSelector: 'input[name="filter"]', contentSelector: ".filepicker-body"}]
});
}
/* -------------------------------------------- */
/**
* Given a current file path, determine the directory it belongs to
* @param {string} target The currently requested target path
* @returns {string[]} An array of the inferred source and target directory path
*/
_inferCurrentDirectory(target) {
// Determine target
const ignored = [CONST.DEFAULT_TOKEN].concat(this.options.redirectToRoot ?? []);
if ( !target || ignored.includes(target) ) target = this.constructor.LAST_BROWSED_DIRECTORY;
let source = "data";
// Check for s3 matches
const s3Match = this.constructor.matchS3URL(target);
if ( s3Match ) {
this.sources.s3.bucket = s3Match.groups.bucket;
source = "s3";
target = s3Match.groups.key;
}
// Non-s3 URL matches
else if ( ["http://", "https://"].some(c => target.startsWith(c)) ) {
target = "";
}
// Local file matches
else {
const p0 = target.split("/").shift();
const publicDirs = ["cards", "css", "fonts", "icons", "lang", "scripts", "sounds", "ui"];
if ( publicDirs.includes(p0) ) source = "public";
}
// If the preferred source is not available, use the next available source.
if ( !this.sources[source] ) {
source = game.data.files.storages[0];
// If that happens to be S3, pick the first available bucket.
if ( source === "s3" ) {
this.sources.s3.bucket = game.data.files.s3.buckets?.[0] ?? null;
target = "";
}
}
// Split off the file name and retrieve just the directory path
let parts = target.split("/");
if ( parts[parts.length - 1].indexOf(".") !== -1 ) parts.pop();
const dir = parts.join("/");
return [source, dir];
}
/* -------------------------------------------- */
/**
* Get the valid file extensions for a given named file picker type
* @param {string} type
* @returns {string[]}
* @private
*/
_getExtensions(type) {
// Identify allowed extensions
let types = [
CONST.IMAGE_FILE_EXTENSIONS,
CONST.AUDIO_FILE_EXTENSIONS,
CONST.VIDEO_FILE_EXTENSIONS,
CONST.TEXT_FILE_EXTENSIONS,
CONST.FONT_FILE_EXTENSIONS,
CONST.GRAPHICS_FILE_EXTENSIONS
].flatMap(extensions => Object.keys(extensions));
if ( type === "folder" ) types = [];
else if ( type === "font" ) types = Object.keys(CONST.FONT_FILE_EXTENSIONS);
else if ( type === "text" ) types = Object.keys(CONST.TEXT_FILE_EXTENSIONS);
else if ( type === "graphics" ) types = Object.keys(CONST.GRAPHICS_FILE_EXTENSIONS);
else if ( type === "image" ) types = Object.keys(CONST.IMAGE_FILE_EXTENSIONS);
else if ( type === "audio" ) types = Object.keys(CONST.AUDIO_FILE_EXTENSIONS);
else if ( type === "video" ) types = Object.keys(CONST.VIDEO_FILE_EXTENSIONS);
else if ( type === "imagevideo") {
types = Object.keys(CONST.IMAGE_FILE_EXTENSIONS).concat(Object.keys(CONST.VIDEO_FILE_EXTENSIONS));
}
return types.map(t => `.${t.toLowerCase()}`);
}
/* -------------------------------------------- */
/**
* Test a URL to see if it matches a well known s3 key pattern
* @param {string} url An input URL to test
* @returns {RegExpMatchArray|null} A regular expression match
*/
static matchS3URL(url) {
const endpoint = game.data.files.s3?.endpoint;
if ( !endpoint ) return null;
// Match new style S3 urls
const s3New = new RegExp(`^${endpoint.protocol}//(?<bucket>.*).${endpoint.host}/(?<key>.*)`);
const matchNew = url.match(s3New);
if ( matchNew ) return matchNew;
// Match old style S3 urls
const s3Old = new RegExp(`^${endpoint.protocol}//${endpoint.host}/(?<bucket>[^/]+)/(?<key>.*)`);
return url.match(s3Old);
}
/* -------------------------------------------- */
/* FilePicker Properties */
/* -------------------------------------------- */
/** @override */
get title() {
let type = this.type || "file";
return game.i18n.localize(type === "imagevideo" ? "FILES.TitleImageVideo" : `FILES.Title${type.capitalize()}`);
}
/* -------------------------------------------- */
/**
* Return the source object for the currently active source
* @type {object}
*/
get source() {
return this.sources[this.activeSource];
}
/* -------------------------------------------- */
/**
* Return the target directory for the currently active source
* @type {string}
*/
get target() {
return this.source.target;
}
/* -------------------------------------------- */
/**
* Return a flag for whether the current user is able to upload file content
* @type {boolean}
*/
get canUpload() {
if ( this.type === "folder" ) return false;
if ( this.options.allowUpload === false ) return false;
if ( !["data", "s3"].includes(this.activeSource) ) return false;
return !game.user || game.user.can("FILES_UPLOAD");
}
/* -------------------------------------------- */
/**
* Return the upload URL to which the FilePicker should post uploaded files
* @type {string}
*/
static get uploadURL() {
return foundry.utils.getRoute("upload");
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const result = this.result;
const source = this.source;
let target = decodeURIComponent(source.target);
const isS3 = this.activeSource === "s3";
// Sort directories alphabetically and store their paths
let dirs = result.dirs.map(d => ({
name: decodeURIComponent(d.split("/").pop()),
path: d,
private: result.private || result.privateDirs.includes(d)
}));
dirs = dirs.sort((a, b) => a.name.localeCompare(b.name));
// Sort files alphabetically and store their client URLs
let files = result.files.map(f => {
let img = f;
if ( VideoHelper.hasVideoExtension(f) ) img = "icons/svg/video.svg";
else if ( AudioHelper.hasAudioExtension(f) ) img = "icons/svg/sound.svg";
else if ( !ImageHelper.hasImageExtension(f) ) img = "icons/svg/book.svg";
return {
name: decodeURIComponent(f.split("/").pop()),
url: f,
img: img
};
});
files = files.sort((a, b) => a.name.localeCompare(b.name));
// Return rendering data
return {
bucket: isS3 ? source.bucket : null,
canGoBack: this.activeSource !== "",
canUpload: this.canUpload,
canSelect: !this.options.tileSize,
cssClass: [this.displayMode, result.private ? "private": "public"].join(" "),
dirs: dirs,
displayMode: this.displayMode,
extensions: this.extensions,
files: files,
isS3: isS3,
noResults: dirs.length + files.length === 0,
selected: this.type === "folder" ? target : this.request,
source: source,
sources: this.sources,
target: target,
tileSize: this.options.tileSize ? (FilePicker.LAST_TILE_SIZE || canvas.dimensions.size) : null,
user: game.user,
submitText: this.type === "folder" ? "FILES.SelectFolder" : "FILES.SelectFile",
favorites: FilePicker.favorites
};
}
/* -------------------------------------------- */
/** @inheritdoc */
setPosition(pos={}) {
const currentPosition = super.setPosition(pos);
const element = this.element[0];
const content = element.querySelector(".window-content");
const lists = element.querySelectorAll(".filepicker-body > ol");
const scroll = content.scrollHeight - content.offsetHeight;
if ( (scroll > 0) && lists.length ) {
let maxHeight = Number(getComputedStyle(lists[0]).maxHeight.slice(0, -2));
maxHeight -= Math.ceil(scroll / lists.length);
lists.forEach(list => list.style.maxHeight = `${maxHeight}px`);
}
return currentPosition;
}
/* -------------------------------------------- */
/**
* Browse to a specific location for this FilePicker instance
* @param {string} [target] The target within the currently active source location.
* @param {object} [options] Browsing options
*/
async browse(target, options={}) {
// If the user does not have permission to browse, do not proceed
if ( game.world && !game.user.can("FILES_BROWSE") ) return;
// Configure browsing parameters
target = typeof target === "string" ? target : this.target;
const source = this.activeSource;
options = foundry.utils.mergeObject({
type: this.type,
extensions: this.extensions,
wildcard: false
}, options);
// Determine the S3 buckets which may be used
if ( source === "s3" ) {
if ( this.constructor.S3_BUCKETS === null ) {
const buckets = await this.constructor.browse("s3", "");
this.constructor.S3_BUCKETS = buckets.dirs;
}
this.sources.s3.buckets = this.constructor.S3_BUCKETS;
if ( !this.source.bucket ) this.source.bucket = this.constructor.S3_BUCKETS[0];
options.bucket = this.source.bucket;
}
// Avoid browsing certain paths
if ( target.startsWith("/") ) target = target.slice(1);
if ( target === CONST.DEFAULT_TOKEN ) target = this.constructor.LAST_BROWSED_DIRECTORY;
// Request files from the server
const result = await this.constructor.browse(source, target, options).catch(error => {
ui.notifications.warn(error);
return this.constructor.browse(source, "", options);
});
// Populate browser content
this.result = result;
this.source.target = result.target;
if ( source === "s3" ) this.source.bucket = result.bucket;
this.constructor.LAST_BROWSED_DIRECTORY = result.target;
this._loaded = true;
// Render the application
this.render(true);
return result;
}
/* -------------------------------------------- */
/**
* Browse files for a certain directory location
* @param {string} source The source location in which to browse. See FilePicker#sources for details
* @param {string} target The target within the source location
* @param {object} options Optional arguments
* @param {string} [options.bucket] A bucket within which to search if using the S3 source
* @param {string[]} [options.extensions] An Array of file extensions to filter on
* @param {boolean} [options.wildcard] The requested dir represents a wildcard path
*
* @returns {Promise} A Promise which resolves to the directories and files contained in the location
*/
static async browse(source, target, options={}) {
const data = {action: "browseFiles", storage: source, target: target};
return this._manageFiles(data, options);
}
/* -------------------------------------------- */
/**
* Configure metadata settings regarding a certain file system path
* @param {string} source The source location in which to browse. See FilePicker#sources for details
* @param {string} target The target within the source location
* @param {object} options Optional arguments which modify the request
* @returns {Promise<object>}
*/
static async configurePath(source, target, options={}) {
const data = {action: "configurePath", storage: source, target: target};
return this._manageFiles(data, options);
}
/* -------------------------------------------- */
/**
* Create a subdirectory within a given source. The requested subdirectory path must not already exist.
* @param {string} source The source location in which to browse. See FilePicker#sources for details
* @param {string} target The target within the source location
* @param {object} options Optional arguments which modify the request
* @returns {Promise<object>}
*/
static async createDirectory(source, target, options={}) {
const data = {action: "createDirectory", storage: source, target: target};
return this._manageFiles(data, options);
}
/* -------------------------------------------- */
/**
* General dispatcher method to submit file management commands to the server
* @param {object} data Request data dispatched to the server
* @param {object} options Options dispatched to the server
* @returns {Promise<object>} The server response
* @private
*/
static async _manageFiles(data, options) {
return new Promise((resolve, reject) => {
game.socket.emit("manageFiles", data, options, result => {
if ( result.error ) return reject(new Error(result.error));
resolve(result);
});
});
}
/* -------------------------------------------- */
/**
* Dispatch a POST request to the server containing a directory path and a file to upload
* @param {string} source The data source to which the file should be uploaded
* @param {string} path The destination path
* @param {File} file The File object to upload
* @param {object} [body={}] Additional file upload options sent in the POST body
* @param {object} [options] Additional options to configure how the method behaves
* @param {boolean} [options.notify=true] Display a UI notification when the upload is processed
* @returns {Promise<object>} The response object
*/
static async upload(source, path, file, body={}, {notify=true}={}) {
// Create the form data to post
const fd = new FormData();
fd.set("source", source);
fd.set("target", path);
fd.set("upload", file);
Object.entries(body).forEach(o => fd.set(...o));
const notifications = Object.fromEntries(["ErrorSomethingWrong", "WarnUploadModules", "ErrorTooLarge"].map(key => {
const i18n = `FILES.${key}`;
return [key, game.i18n.localize(i18n)];
}));
// Dispatch the request
try {
const request = await fetch(this.uploadURL, {method: "POST", body: fd});
const response = await request.json();
// Attempt to obtain the response
if ( response.error ) {
ui.notifications.error(response.error);
return false;
} else if ( !response.path ) {
if ( notify ) ui.notifications.error(notifications.ErrorSomethingWrong);
else console.error(notifications.ErrorSomethingWrong);
return;
}
// Check for uploads to system or module directories.
const [packageType, packageId, folder] = response.path.split("/");
if ( ["modules", "systems"].includes(packageType) ) {
let pkg;
if ( packageType === "modules" ) pkg = game.modules.get(packageId);
else if ( packageId === game.system.id ) pkg = game.system;
if ( !pkg?.persistentStorage || (folder !== "storage") ) {
if ( notify ) ui.notifications.warn(notifications.WarnUploadModules);
else console.warn(notifications.WarnUploadModules);
}
}
// Display additional response messages
if ( response.message ) {
if ( notify ) ui.notifications.info(response.message);
else console.info(response.message);
}
return response;
}
catch(e) {
if ( (e instanceof HttpError) && (e.code === 413) ) {
if ( notify ) ui.notifications.error(notifications.ErrorTooLarge);
else console.error(notifications.ErrorTooLarge);
return;
}
return {};
}
}
/* -------------------------------------------- */
/**
* A convenience function that uploads a file to a given package's persistent /storage/ directory
* @param {string} packageId The id of the package to which the file should be uploaded. Only supports Systems and Modules.
* @param {string} path The relative path in the package's storage directory the file should be uploaded to
* @param {File} file The File object to upload
* @param {object} [body={}] Additional file upload options sent in the POST body
* @param {object} [options] Additional options to configure how the method behaves
* @param {boolean} [options.notify=true] Display a UI notification when the upload is processed
* @returns {Promise<object>} The response object
*/
static async uploadPersistent(packageId, path, file, body={}, {notify=true}={}) {
let pack = game.system.id === packageId ? game.system : game.modules.get(packageId);
if ( !pack ) throw new Error(`Package ${packageId} not found`);
if ( !pack.persistentStorage ) throw new Error(`Package ${packageId} does not have persistent storage enabled. Set the "persistentStorage" flag to true in the package manifest.`);
const source = "data";
const target = `${pack.type}s/${pack.id}/storage/${path}`;
return this.upload(source, target, file, body, {notify});
}
/* -------------------------------------------- */
/** @inheritDoc */
render(force, options) {
if ( game.world && !game.user.can("FILES_BROWSE") ) return this;
this.position.height = null;
this.element.css({height: ""});
this._tabs[0].active = this.activeSource;
if ( !this._loaded ) {
this.browse();
return this;
}
else return super.render(force, options);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
const header = html.find("header.filepicker-header");
const form = html[0];
// Change the directory
const target = header.find('input[name="target"]');
target.on("keydown", this._onRequestTarget.bind(this));
target[0].focus();
// Header Control Buttons
html.find(".current-dir button").click(this._onClickDirectoryControl.bind(this));
// Change the S3 bucket
html.find('select[name="bucket"]').change(this._onChangeBucket.bind(this));
// Change the tile size.
form.elements.tileSize?.addEventListener("change", this._onChangeTileSize.bind(this));
// Activate display mode controls
const modes = html.find(".display-modes");
modes.on("click", ".display-mode", this._onChangeDisplayMode.bind(this));
for ( let li of modes[0].children ) {
li.classList.toggle("active", li.dataset.mode === this.displayMode);
}
// Upload new file
if ( this.canUpload ) form.upload.onchange = ev => this._onUpload(ev);
// Directory-level actions
html.find(".directory").on("click", "li", this._onPick.bind(this));
// Directory-level actions
html.find(".favorites").on("click", "a", this._onClickFavorite.bind(this));
// Flag the current pick
let li = form.querySelector(`.file[data-path="${this.request}"]`);
if ( li ) li.classList.add("picked");
// Form submission
form.onsubmit = ev => this._onSubmit(ev);
// Intersection Observer to lazy-load images
const files = html.find(".files-list");
const observer = new IntersectionObserver(this._onLazyLoadImages.bind(this), {root: files[0]});
files.find("li.file").each((i, li) => observer.observe(li));
}
/* -------------------------------------------- */
/**
* Handle a click event to change the display mode of the File Picker
* @param {MouseEvent} event The triggering click event
* @private
*/
_onChangeDisplayMode(event) {
event.preventDefault();
const a = event.currentTarget;
if ( !FilePicker.DISPLAY_MODES.includes(a.dataset.mode) ) {
throw new Error("Invalid display mode requested");
}
if ( a.dataset.mode === this.displayMode ) return;
FilePicker.LAST_DISPLAY_MODE = this.displayMode = a.dataset.mode;
this.render();
}
/* -------------------------------------------- */
/** @override */
_onChangeTab(event, tabs, active) {
this.activeSource = active;
this.browse(this.source.target);
}
/* -------------------------------------------- */
/** @override */
_canDragStart(selector) {
return game.user?.isGM && (canvas.activeLayer instanceof TilesLayer);
}
/* -------------------------------------------- */
/** @override */
_canDragDrop(selector) {
return this.canUpload;
}
/* -------------------------------------------- */
/** @override */
_onDragStart(event) {
const li = event.currentTarget;
// Get the tile size ratio
const tileSize = parseInt(li.closest("form").tileSize.value) || canvas.dimensions.size;
const ratio = canvas.dimensions.size / tileSize;
// Set drag data
const dragData = {
type: "Tile",
texture: {src: li.dataset.path},
fromFilePicker: true,
tileSize
};
event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
// Create the drag preview for the image
const img = li.querySelector("img");
const w = img.naturalWidth * ratio * canvas.stage.scale.x;
const h = img.naturalHeight * ratio * canvas.stage.scale.y;
const preview = DragDrop.createDragImage(img, w, h);
event.dataTransfer.setDragImage(preview, w/2, h/2);
}
/* -------------------------------------------- */
/** @override */
async _onDrop(event) {
if ( !this.canUpload ) return;
const form = event.currentTarget.closest("form");
form.disabled = true;
const target = form.target.value;
// Process the data transfer
const data = TextEditor.getDragEventData(event);
const files = event.dataTransfer.files;
if ( !files || !files.length || data.fromFilePicker ) return;
// Iterate over dropped files
for ( let upload of files ) {
const name = upload.name.toLowerCase();
if ( !this.extensions.some(ext => name.endsWith(ext)) ) {
ui.notifications.error(`Incorrect ${this.type} file extension. Supports ${this.extensions.join(" ")}.`);
continue;
}
const response = await this.constructor.upload(this.activeSource, target, upload, {
bucket: form.bucket ? form.bucket.value : null
});
if ( response ) this.request = response.path;
}
// Re-enable the form
form.disabled = false;
return this.browse(target);
}
/* -------------------------------------------- */
/**
* Handle user submission of the address bar to request an explicit target
* @param {KeyboardEvent} event The originating keydown event
* @private
*/
_onRequestTarget(event) {
if ( event.key === "Enter" ) {
event.preventDefault();
return this.browse(event.target.value);
}
}
/* -------------------------------------------- */
/**
* Handle user interaction with the favorites
* @param {MouseEvent} event The originating click event
* @private
*/
async _onClickFavorite(event) {
const action = event.currentTarget.dataset.action;
const source = event.currentTarget.dataset.source || this.activeSource;
const path = event.currentTarget.dataset.path || this.target;
switch (action) {
case "goToFavorite":
this.activeSource = source;
await this.browse(path);
break;
case "setFavorite":
await FilePicker.setFavorite(source, path);
break;
case "removeFavorite":
await FilePicker.removeFavorite(source, path);
break;
}
this.render(true);
}
/* -------------------------------------------- */
/**
* Handle requests from the IntersectionObserver to lazily load an image file
* @param {...any} args
* @private
*/
_onLazyLoadImages(...args) {
// Don't load images in list mode.
if ( this.displayMode === "list" ) return;
return SidebarTab.prototype._onLazyLoadImage.call(this, ...args);
}
/* -------------------------------------------- */
/**
* Handle file or folder selection within the file picker
* @param {Event} event The originating click event
* @private
*/
_onPick(event) {
const li = event.currentTarget;
const form = li.closest("form");
if ( li.classList.contains("dir") ) return this.browse(li.dataset.path);
for ( let l of li.parentElement.children ) {
l.classList.toggle("picked", l === li);
}
if ( form.file ) form.file.value = li.dataset.path;
}
/* -------------------------------------------- */
/**
* Handle backwards navigation of the folder structure.
* @param {PointerEvent} event The triggering click event
* @private
*/
_onClickDirectoryControl(event) {
event.preventDefault();
const button = event.currentTarget;
const action = button.dataset.action;
switch (action) {
case "back":
let target = this.target.split("/");
target.pop();
return this.browse(target.join("/"));
case "mkdir":
return this._createDirectoryDialog(this.source);
case "toggle-privacy":
let isPrivate = !this.result.private;
const data = {private: isPrivate, bucket: this.result.bucket};
return this.constructor.configurePath(this.activeSource, this.target, data).then(r => {
this.result.private = r.private;
this.render();
});
}
}
/* -------------------------------------------- */
/**
* Present the user with a dialog to create a subdirectory within their currently browsed file storage location.
* @param {object} source The data source being browsed
* @private
*/
_createDirectoryDialog(source) {
const form = `<form><div class="form-group">
<label>Directory Name</label>
<input type="text" name="dirname" placeholder="directory-name" required/>
</div></form>`;
return Dialog.confirm({
title: game.i18n.localize("FILES.CreateSubfolder"),
content: form,
yes: async html => {
const dirname = html.querySelector("input").value;
const path = [source.target, dirname].filterJoin("/");
try {
await this.constructor.createDirectory(this.activeSource, path, {bucket: source.bucket});
} catch ( err ) {
ui.notifications.error(err.message);
}
return this.browse(this.target);
},
options: {jQuery: false}
});
}
/* -------------------------------------------- */
/**
* Handle changes to the bucket selector
* @param {Event} event The S3 bucket select change event
* @private
*/
_onChangeBucket(event) {
event.preventDefault();
const select = event.currentTarget;
this.sources.s3.bucket = select.value;
return this.browse("/");
}
/* -------------------------------------------- */
/**
* Handle changes to the tile size.
* @param {Event} event The triggering event.
* @protected
*/
_onChangeTileSize(event) {
this.constructor.LAST_TILE_SIZE = event.currentTarget.valueAsNumber;
}
/* -------------------------------------------- */
/** @override */
_onSearchFilter(event, query, rgx, html) {
for ( let ol of html.querySelectorAll(".directory") ) {
let matched = false;
for ( let li of ol.children ) {
let match = rgx.test(SearchFilter.cleanQuery(li.dataset.name));
if ( match ) matched = true;
li.style.display = !match ? "none" : "";
}
ol.style.display = matched ? "" : "none";
}
this.setPosition({height: "auto"});
}
/* -------------------------------------------- */
/** @inheritdoc */
_onSubmit(ev) {
ev.preventDefault();
let path = ev.target.file.value;
if ( !path ) return ui.notifications.error("You must select a file to proceed.");
// Update the target field
if ( this.field ) {
this.field.value = path;
this.field.dispatchEvent(new Event("change", {bubbles: true}));
}
// Trigger a callback and close
if ( this.callback ) this.callback(path, this);
return this.close();
}
/* -------------------------------------------- */
/**
* Handle file upload
* @param {Event} ev The file upload event
* @private
*/
async _onUpload(ev) {
const form = ev.target.form;
const upload = form.upload.files[0];
const name = upload.name.toLowerCase();
// Validate file extension
if ( !this.extensions.some(ext => name.endsWith(ext)) ) {
ui.notifications.error(`Incorrect ${this.type} file extension. Supports ${this.extensions.join(" ")}.`);
return false;
}
// Dispatch the request
const target = form.target.value;
const options = { bucket: form.bucket ? form.bucket.value : null };
const response = await this.constructor.upload(this.activeSource, target, upload, options);
// Handle errors
if ( response.error ) {
return ui.notifications.error(response.error);
}
// Flag the uploaded file as the new request
this.request = response.path;
return this.browse(target);
}
/* -------------------------------------------- */
/* Factory Methods
/* -------------------------------------------- */
/**
* Bind the file picker to a new target field.
* Assumes the user will provide a HTMLButtonElement which has the data-target and data-type attributes
* The data-target attribute should provide the name of the input field which should receive the selected file
* The data-type attribute is a string in ["image", "audio"] which sets the file extensions which will be accepted
*
* @param {HTMLButtonElement} button The button element
*/
static fromButton(button) {
if ( !(button instanceof HTMLButtonElement ) ) throw new Error("You must pass an HTML button");
let type = button.getAttribute("data-type");
const form = button.form;
const field = form[button.dataset.target] || null;
const current = field?.value || "";
return new FilePicker({field, type, current, button});
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
static parseS3URL(key) {
foundry.utils.logCompatibilityWarning("You are using FilePicker.parseS3URL which has been deprecated and will be "
+ "removed in a later version.", {since: 10, until: 12});
return foundry.utils.parseS3URL(key);
}
}
/**
* @typedef {object} SearchFilterConfiguration
* @property {object} options Options which customize the behavior of the filter
* @property {string} options.inputSelector The CSS selector used to target the text input element.
* @property {string} options.contentSelector The CSS selector used to target the content container for these tabs.
* @property {Function} options.callback A callback function which executes when the filter changes.
* @property {string} [options.initial] The initial value of the search query.
* @property {number} [options.delay=200] The number of milliseconds to wait for text input before processing.
*/
/**
* @typedef {object} FieldFilter
* @property {string} field The dot-delimited path to the field being filtered
* @property {string} [operator=SearchFilter.OPERATORS.EQUALS] The search operator, from CONST.OPERATORS
* @property {boolean} negate Negate the filter, returning results which do NOT match the filter criteria
* @property {*} value The value against which to test
*/
/**
* A controller class for managing a text input widget that filters the contents of some other UI element
* @see {@link Application}
*
* @param {SearchFilterConfiguration}
*/
class SearchFilter {
/**
* The allowed Filter Operators which can be used to define a search filter
* @enum {string}
*/
static OPERATORS = Object.freeze({
EQUALS: "equals",
CONTAINS: "contains",
STARTS_WITH: "starts_with",
ENDS_WITH: "ends_with",
LESS_THAN: "lt",
LESS_THAN_EQUAL: "lte",
GREATER_THAN: "gt",
GREATER_THAN_EQUAL: "gte",
BETWEEN: "between",
IS_EMPTY: "is_empty",
});
// Average typing speed is 167 ms per character, per https://stackoverflow.com/a/4098779
constructor({inputSelector, contentSelector, initial="", callback, delay=200}={}) {
/**
* The value of the current query string
* @type {string}
*/
this.query = initial;
/**
* A callback function to trigger when the tab is changed
* @type {Function|null}
*/
this.callback = callback;
/**
* The regular expression corresponding to the query that should be matched against
* @type {RegExp}
*/
this.rgx = undefined;
/**
* The CSS selector used to target the tab navigation element
* @type {string}
*/
this._inputSelector = inputSelector;
/**
* A reference to the HTML navigation element the tab controller is bound to
* @type {HTMLElement|null}
*/
this._input = null;
/**
* The CSS selector used to target the tab content element
* @type {string}
*/
this._contentSelector = contentSelector;
/**
* A reference to the HTML container element of the tab content
* @type {HTMLElement|null}
*/
this._content = null;
/**
* A debounced function which applies the search filtering
* @type {Function}
*/
this._filter = foundry.utils.debounce(this.callback, delay);
}
/* -------------------------------------------- */
/**
* Test whether a given object matches a provided filter
* @param {object} obj An object to test against
* @param {FieldFilter} filter The filter to test
* @returns {boolean} Whether the object matches the filter
*/
static evaluateFilter(obj, filter) {
const docValue = foundry.utils.getProperty(obj, filter.field);
const filterValue = filter.value;
function _evaluate() {
switch (filter.operator) {
case SearchFilter.OPERATORS.EQUALS:
if ( docValue.equals instanceof Function ) return docValue.equals(filterValue);
else return (docValue === filterValue);
case SearchFilter.OPERATORS.CONTAINS:
if ( Array.isArray(filterValue) )
return filterValue.includes(docValue);
else
return [filterValue].includes(docValue);
case SearchFilter.OPERATORS.STARTS_WITH:
return docValue.startsWith(filterValue);
case SearchFilter.OPERATORS.ENDS_WITH:
return docValue.endsWith(filterValue);
case SearchFilter.OPERATORS.LESS_THAN:
return (docValue < filterValue);
case SearchFilter.OPERATORS.LESS_THAN_EQUAL:
return (docValue <= filterValue);
case SearchFilter.OPERATORS.GREATER_THAN:
return (docValue > filterValue);
case SearchFilter.OPERATORS.GREATER_THAN_EQUAL:
return (docValue >= filterValue);
case SearchFilter.OPERATORS.BETWEEN:
if ( !Array.isArray(filterValue) || filterValue.length !== 2 ) {
throw new Error(`Invalid filter value for ${filter.operator} operator. Expected an array of length 2.`);
}
const [min, max] = filterValue;
return (docValue >= min) && (docValue <= max);
case SearchFilter.OPERATORS.IS_EMPTY:
return foundry.utils.isEmpty(docValue);
default:
return (docValue === filterValue);
}
}
const result = _evaluate();
return filter.negate ? !result : result;
}
/* -------------------------------------------- */
/**
* Bind the SearchFilter controller to an HTML application
* @param {HTMLElement} html
*/
bind(html) {
// Identify navigation element
this._input = html.querySelector(this._inputSelector);
if ( !this._input ) return;
this._input.value = this.query;
// Identify content container
if ( !this._contentSelector ) this._content = null;
else if ( html.matches(this._contentSelector) ) this._content = html;
else this._content = html.querySelector(this._contentSelector);
// Register the handler for input changes
// Use the input event which also captures clearing the filter
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event
this._input.addEventListener("input", event => {
event.preventDefault();
this.filter(event, event.currentTarget.value);
});
// Apply the initial filtering conditions
const event = new KeyboardEvent("input", {key: "Enter", code: "Enter"});
this.filter(event, this.query);
}
/* -------------------------------------------- */
/**
* Perform a filtering of the content by invoking the callback function
* @param {KeyboardEvent} event The triggering keyboard event
* @param {string} query The input search string
*/
filter(event, query) {
this.query = SearchFilter.cleanQuery(query);
this.rgx = new RegExp(RegExp.escape(this.query), "i");
this._filter(event, this.query, this.rgx, this._content);
}
/* -------------------------------------------- */
/**
* Clean a query term to standardize it for matching.
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize
* @param {string} query An input string which may contain leading/trailing spaces or diacritics
* @returns {string} A cleaned string of ASCII characters for comparison
*/
static cleanQuery(query) {
return query.trim().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
}
/**
* An extension of the native FormData implementation.
*
* This class functions the same way that the default FormData does, but it is more opinionated about how
* input fields of certain types should be evaluated and handled.
*
* It also adds support for certain Foundry VTT specific concepts including:
* Support for defined data types and type conversion
* Support for TinyMCE editors
* Support for editable HTML elements
*
* @extends {FormData}
*
* @param {HTMLFormElement} form The form being processed
* @param {object} options Options which configure form processing
* @param {Object<object>} [options.editors] A record of TinyMCE editor metadata objects, indexed by their update key
* @param {Object<string>} [options.dtypes] A mapping of data types for form fields
* @param {boolean} [options.disabled=false] Include disabled fields?
* @param {boolean} [options.readonly=false] Include readonly fields?
*/
class FormDataExtended extends FormData {
constructor(form, {dtypes={}, editors={}, disabled=false, readonly=false}={}) {
super();
/**
* A mapping of data types requested for each form field.
* @type {{string, string}}
*/
this.dtypes = dtypes;
/**
* A record of TinyMCE editors which are linked to this form.
* @type {Object<string, object>}
*/
this.editors = editors;
/**
* The object representation of the form data, available once processed.
* @type {object}
*/
Object.defineProperty(this, "object", {value: {}, writable: false, enumerable: false});
// Process the provided form
this.process(form, {disabled, readonly});
}
/* -------------------------------------------- */
/**
* Process the HTML form element to populate the FormData instance.
* @param {HTMLFormElement} form The HTML form being processed
* @param {object} options Options forwarded from the constructor
*/
process(form, options) {
this.#processFormFields(form, options);
this.#processEditableHTML(form, options);
this.#processEditors();
// Emit the formdata event for compatibility with the parent FormData class
form.dispatchEvent(new FormDataEvent("formdata", {formData: this}));
}
/* -------------------------------------------- */
/**
* Assign a value to the FormData instance which always contains JSON strings.
* Also assign the cast value in its preferred data type to the parsed object representation of the form data.
* @param {string} name The field name
* @param {any} value The raw extracted value from the field
* @inheritDoc
*/
set(name, value) {
this.object[name] = value;
if ( value instanceof Array ) value = JSON.stringify(value);
super.set(name, value);
}
/* -------------------------------------------- */
/**
* Append values to the form data, adding them to an array.
* @param {string} name The field name to append to the form
* @param {any} value The value to append to the form data
* @inheritDoc
*/
append(name, value) {
if ( name in this.object ) {
if ( !Array.isArray(this.object[name]) ) this.object[name] = [this.object[name]];
}
else this.object[name] = [];
this.object[name].push(value);
super.append(name, value);
}
/* -------------------------------------------- */
/**
* Process all standard HTML form field elements from the form.
* @param {HTMLFormElement} form The form being processed
* @param {object} options Options forwarded from the constructor
* @param {boolean} [options.disabled] Process disabled fields?
* @param {boolean} [options.readonly] Process readonly fields?
* @private
*/
#processFormFields(form, {disabled, readonly}={}) {
if ( !disabled && form.hasAttribute("disabled") ) return;
const mceEditorIds = Object.values(this.editors).map(e => e.mce?.id);
for ( const element of form.elements ) {
const name = element.name;
// Skip fields which are unnamed or already handled
if ( !name || this.has(name) ) continue;
// Skip buttons and editors
if ( (element.tagName === "BUTTON") || mceEditorIds.includes(name) ) continue;
// Skip disabled or read-only fields
if ( !disabled && (element.disabled || element.closest("fieldset")?.disabled) ) continue;
if ( !readonly && element.readOnly ) continue;
// Extract and process the value of the field
const field = form.elements[name];
const value = this.#getFieldValue(name, field);
this.set(name, value);
}
}
/* -------------------------------------------- */
/**
* Process editable HTML elements (ones with a [data-edit] attribute).
* @param {HTMLFormElement} form The form being processed
* @param {object} options Options forwarded from the constructor
* @param {boolean} [options.disabled] Process disabled fields?
* @param {boolean} [options.readonly] Process readonly fields?
* @private
*/
#processEditableHTML(form, {disabled, readonly}={}) {
const editableElements = form.querySelectorAll("[data-edit]");
for ( const element of editableElements ) {
const name = element.dataset.edit;
if ( this.has(name) || (name in this.editors) ) continue;
if ( (!disabled && element.disabled) || (!readonly && element.readOnly) ) continue;
let value;
if (element.tagName === "IMG") value = element.getAttribute("src");
else value = element.innerHTML.trim();
this.set(name, value);
}
}
/* -------------------------------------------- */
/**
* Process TinyMCE editor instances which are present in the form.
* @private
*/
#processEditors() {
for ( const [name, editor] of Object.entries(this.editors) ) {
if ( !editor.instance ) continue;
if ( editor.options.engine === "tinymce" ) {
const content = editor.instance.getContent();
this.delete(editor.mce.id); // Delete hidden MCE inputs
this.set(name, content);
} else if ( editor.options.engine === "prosemirror" ) {
this.set(name, ProseMirror.dom.serializeString(editor.instance.view.state.doc.content));
}
}
}
/* -------------------------------------------- */
/**
* Obtain the parsed value of a field conditional on its element type and requested data type.
* @param {string} name The field name being processed
* @param {HTMLElement|RadioNodeList} field The HTML field or a RadioNodeList of multiple fields
* @returns {*} The processed field value
* @private
*/
#getFieldValue(name, field) {
// Multiple elements with the same name
if ( field instanceof RadioNodeList ) {
const fields = Array.from(field);
if ( fields.every(f => f.type === "radio") ) {
const chosen = fields.find(f => f.checked);
return chosen ? this.#getFieldValue(name, chosen) : undefined;
}
return Array.from(field).map(f => this.#getFieldValue(name, f));
}
// Record requested data type
const dataType = field.dataset.dtype || this.dtypes[name];
// Checkbox
if ( field.type === "checkbox" ) {
// Non-boolean checkboxes with an explicit value attribute yield that value or null
if ( field.hasAttribute("value") && (dataType !== "Boolean") ) {
return this.#castType(field.checked ? field.value : null, dataType);
}
// Otherwise, true or false based on the checkbox checked state
return this.#castType(field.checked, dataType);
}
// Number and Range
if ( ["number", "range"].includes(field.type) ) {
if ( field.value === "" ) return null;
else return this.#castType(field.value, dataType || "Number");
}
// Multi-Select
if ( field.type === "select-multiple" ) {
return Array.from(field.options).reduce((chosen, opt) => {
if ( opt.selected ) chosen.push(this.#castType(opt.value, dataType));
return chosen;
}, []);
}
// Radio Select
if ( field.type === "radio" ) {
return field.checked ? this.#castType(field.value, dataType) : null;
}
// Other field types
return this.#castType(field.value, dataType);
}
/* -------------------------------------------- */
/**
* Cast a processed value to a desired data type.
* @param {any} value The raw field value
* @param {string} dataType The desired data type
* @returns {any} The resulting data type
* @private
*/
#castType(value, dataType) {
if ( value instanceof Array ) return value.map(v => this.#castType(v, dataType));
if ( [undefined, null].includes(value) || (dataType === "String") ) return value;
// Boolean
if ( dataType === "Boolean" ) {
if ( value === "false" ) return false;
return Boolean(value);
}
// Number
else if ( dataType === "Number" ) {
if ( (value === "") || (value === "null") ) return null;
return Number(value);
}
// Serialized JSON
else if ( dataType === "JSON" ) {
return JSON.parse(value);
}
// Other data types
if ( window[dataType] instanceof Function ) {
try {
return window[dataType](value);
} catch(err) {
console.warn(`The form field value "${value}" was not able to be cast to the requested data type ${dataType}`);
}
}
return value;
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
toObject() {
foundry.utils.logCompatibilityWarning("You are using FormDataExtended#toObject which is deprecated in favor of"
+ " FormDataExtended#object", {since: 10, until: 12});
return this.object;
}
}
/**
* A common framework for displaying notifications to the client.
* Submitted notifications are added to a queue, and up to 3 notifications are displayed at once.
* Each notification is displayed for 5 seconds at which point further notifications are pulled from the queue.
*
* @extends {Application}
*
* @example Displaying Notification Messages
* ```js
* ui.notifications.info("This is an info message");
* ui.notifications.warn("This is a warning message");
* ui.notifications.error("This is an error message");
* ui.notifications.info("This is a 4th message which will not be shown until the first info message is done");
* ```
*/
class Notifications extends Application {
/**
* An incrementing counter for the notification IDs.
* @type {number}
*/
#id = 1;
constructor(options) {
super(options);
/**
* Submitted notifications which are queued for display
* @type {object[]}
*/
this.queue = [];
/**
* Notifications which are currently displayed
* @type {object[]}
*/
this.active = [];
// Initialize any pending messages
this.initialize();
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
popOut: false,
id: "notifications",
template: "templates/hud/notifications.html"
});
}
/* -------------------------------------------- */
/**
* Initialize the Notifications system by displaying any system-generated messages which were passed from the server.
*/
initialize() {
for ( let m of globalThis.MESSAGES ) {
this.notify(game.i18n.localize(m.message), m.type, m.options);
}
}
/* -------------------------------------------- */
/** @override */
async _renderInner(...args) {
return $('<ol id="notifications"></ol>');
}
/* -------------------------------------------- */
/** @override */
async _render(...args) {
await super._render(...args);
while ( this.queue.length && (this.active.length < 3) ) this.fetch();
}
/* -------------------------------------------- */
/**
* @typedef {Object} NotifyOptions
* @property {boolean} [permanent=false] Should the notification be permanently displayed until dismissed
* @property {boolean} [localize=false] Whether to localize the message content before displaying it
* @property {boolean} [console=true] Whether to log the message to the console
*/
/**
* Push a new notification into the queue
* @param {string} message The content of the notification message
* @param {string} type The type of notification, "info", "warning", and "error" are supported
* @param {NotifyOptions} [options={}] Additional options which affect the notification
* @returns {number} The ID of the notification
*/
notify(message, type="info", {localize=false, permanent=false, console=true}={}) {
if ( localize ) message = game.i18n.localize(message);
let n = {
id: this.#id++,
message: message,
type: ["info", "warning", "error"].includes(type) ? type : "info",
timestamp: new Date().getTime(),
permanent: permanent,
console: console
};
this.queue.push(n);
if ( this.rendered ) this.fetch();
return n.id;
}
/* -------------------------------------------- */
/**
* Display a notification with the "info" type
* @param {string} message The content of the notification message
* @param {NotifyOptions} options Notification options passed to the notify function
* @returns {number} The ID of the notification
*/
info(message, options) {
return this.notify(message, "info", options);
}
/* -------------------------------------------- */
/**
* Display a notification with the "warning" type
* @param {string} message The content of the notification message
* @param {NotifyOptions} options Notification options passed to the notify function
* @returns {number} The ID of the notification
*/
warn(message, options) {
return this.notify(message, "warning", options);
}
/* -------------------------------------------- */
/**
* Display a notification with the "error" type
* @param {string} message The content of the notification message
* @param {NotifyOptions} options Notification options passed to the notify function
* @returns {number} The ID of the notification
*/
error(message, options) {
return this.notify(message, "error", options);
}
/* -------------------------------------------- */
/**
* Remove the notification linked to the ID.
* @param {number} id The ID of the notification
*/
remove(id) {
// Remove a queued notification that has not been displayed yet
const queued = this.queue.findSplice(n => n.id === id);
if ( queued ) return;
// Remove an active HTML element
const active = this.active.findSplice(li => li.data("id") === id);
if ( !active ) return;
active.fadeOut(66, () => active.remove());
this.fetch();
}
/* -------------------------------------------- */
/**
* Clear all notifications.
*/
clear() {
this.queue.length = 0;
for ( const li of this.active ) li.fadeOut(66, () => li.remove());
this.active.length = 0;
}
/* -------------------------------------------- */
/**
* Retrieve a pending notification from the queue and display it
* @private
* @returns {void}
*/
fetch() {
if ( !this.queue.length || (this.active.length >= 3) ) return;
const next = this.queue.pop();
const now = Date.now();
// Define the function to remove the notification
const _remove = li => {
li.fadeOut(66, () => li.remove());
const i = this.active.indexOf(li);
if ( i >= 0 ) this.active.splice(i, 1);
return this.fetch();
};
// Construct a new notification
const cls = ["notification", next.type, next.permanent ? "permanent" : null].filterJoin(" ");
const li = $(`<li class="${cls}" data-id="${next.id}">${next.message}<i class="close fas fa-times-circle"></i></li>`);
// Add click listener to dismiss
li.click(ev => {
if ( Date.now() - now > 250 ) _remove(li);
});
this.element.prepend(li);
li.hide().slideDown(132);
this.active.push(li);
// Log to console if enabled
if ( next.console ) console[next.type === "warning" ? "warn" : next.type](next.message);
// Schedule clearing the notification 5 seconds later
if ( !next.permanent ) window.setTimeout(() => _remove(li), 5000);
}
}
/**
* @typedef {object} ProseMirrorHistory
* @property {string} userId The ID of the user who submitted the step.
* @property {Step} step The step that was submitted.
*/
/**
* A class responsible for managing state and collaborative editing of a single ProseMirror instance.
*/
class ProseMirrorEditor {
/**
* @param {string} uuid A string that uniquely identifies this ProseMirror instance.
* @param {EditorView} view The ProseMirror EditorView.
* @param {Plugin} isDirtyPlugin The plugin to track the dirty state of the editor.
* @param {boolean} collaborate Whether this is a collaborative editor.
* @param {object} [options] Additional options.
* @param {ClientDocument} [options.document] A document associated with this editor.
*/
constructor(uuid, view, isDirtyPlugin, collaborate, options={}) {
/**
* A string that uniquely identifies this ProseMirror instance.
* @type {string}
*/
Object.defineProperty(this, "uuid", {value: uuid, writable: false});
/**
* The ProseMirror EditorView.
* @type {EditorView}
*/
Object.defineProperty(this, "view", {value: view, writable: false});
/**
* Whether this is a collaborative editor.
* @type {boolean}
*/
Object.defineProperty(this, "collaborate", {value: collaborate, writable: false});
this.options = options;
this.#isDirtyPlugin = isDirtyPlugin;
}
/* -------------------------------------------- */
/**
* A list of active editor instances by their UUIDs.
* @type {Map<string, ProseMirrorEditor>}
*/
static #editors = new Map();
/* -------------------------------------------- */
/**
* The plugin to track the dirty state of the editor.
* @type {Plugin}
*/
#isDirtyPlugin;
/* -------------------------------------------- */
/**
* Retire this editor instance and clean up.
*/
destroy() {
ProseMirrorEditor.#editors.delete(this.uuid);
this.view.destroy();
if ( this.collaborate ) game.socket.emit("pm.endSession", this.uuid);
}
/* -------------------------------------------- */
/**
* Have the contents of the editor been edited by the user?
* @returns {boolean}
*/
isDirty() {
return this.#isDirtyPlugin.getState(this.view.state);
}
/* -------------------------------------------- */
/**
* Handle new editing steps supplied by the server.
* @param {string} offset The offset into the history, representing the point at which it was last
* truncated.
* @param {ProseMirrorHistory[]} history The entire edit history.
* @protected
*/
_onNewSteps(offset, history) {
this._disableSourceCodeEditing();
this.options.document?.sheet?.onNewSteps?.();
const version = ProseMirror.collab.getVersion(this.view.state);
const newSteps = history.slice(version - offset);
// Flatten out the data into a format that ProseMirror.collab.receiveTransaction can understand.
const [steps, ids] = newSteps.reduce(([steps, ids], entry) => {
steps.push(ProseMirror.Step.fromJSON(ProseMirror.defaultSchema, entry.step));
ids.push(entry.userId);
return [steps, ids];
}, [[], []]);
const tr = ProseMirror.collab.receiveTransaction(this.view.state, steps, ids);
this.view.dispatch(tr);
}
/* -------------------------------------------- */
/**
* Disable source code editing if the user was editing it when new steps arrived.
* @protected
*/
_disableSourceCodeEditing() {
const textarea = this.view.dom.closest(".editor")?.querySelector(":scope > textarea");
if ( !textarea ) return;
textarea.disabled = true;
ui.notifications.warn("EDITOR.EditingHTMLWarning", {localize: true, permanent: true});
}
/* -------------------------------------------- */
/**
* The state of this ProseMirror editor has fallen too far behind the central authority's and must be re-synced.
* @protected
*/
_resync() {
// Copy the editor's current state to the clipboard to avoid data loss.
const existing = this.view.dom;
existing.contentEditable = false;
const selection = document.getSelection();
selection.removeAllRanges();
const range = document.createRange();
range.selectNode(existing);
selection.addRange(range);
// We cannot use navigator.clipboard.write here as it is disabled or not fully implemented in some browsers.
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Interact_with_the_clipboard
document.execCommand("copy");
ui.notifications.warn("EDITOR.Resync", {localize: true, permanent: true});
this.destroy();
this.options.document?.sheet?.render(true, {resync: true});
}
/* -------------------------------------------- */
/**
* Handle users joining or leaving collaborative editing.
* @param {string[]} users The IDs of users currently editing (including ourselves).
* @protected
*/
_updateUserDisplay(users) {
const editor = this.view.dom.closest(".editor");
editor.classList.toggle("collaborating", users.length > 1);
const pips = users.map(id => {
const user = game.users.get(id);
if ( !user ) return "";
return `
<span class="scene-player" style="background: ${user.color}; border: 1px solid ${user.border.css};">
${user.name[0]}
</span>
`;
}).join("");
const collaborating = editor.querySelector("menu .concurrent-users");
collaborating.dataset.tooltip = users.map(id => game.users.get(id)?.name).join(", ");
collaborating.innerHTML = `
<i class="fa-solid fa-user-group"></i>
${pips}
`;
}
/* -------------------------------------------- */
/**
* Handle an autosave update for an already-open editor.
* @param {string} html The updated editor contents.
* @protected
*/
_handleAutosave(html) {
this.options.document?.sheet?.onAutosave?.(html);
}
/* -------------------------------------------- */
/**
* Create a ProseMirror editor instance.
* @param {HTMLElement} target An HTML element to mount the editor to.
* @param {string} [content=""] Content to populate the editor with.
* @param {object} [options] Additional options to configure the ProseMirror instance.
* @param {string} [options.uuid] A string to uniquely identify this ProseMirror instance. Ignored
* for a collaborative editor.
* @param {ClientDocument} [options.document] A Document whose content is being edited. Required for
* collaborative editing and relative UUID generation.
* @param {string} [options.fieldName] The field within the Document that is being edited. Required for
* collaborative editing.
* @param {Object<Plugin>} [options.plugins] Plugins to include with the editor.
* @param {boolean} [options.relativeLinks=false] Whether to generate relative UUID links to Documents that are
* dropped on the editor.
* @param {boolean} [options.collaborate=false] Whether to enable collaborative editing for this editor.
* @returns {Promise<ProseMirrorEditor>}
*/
static async create(target, content="", {uuid, document, fieldName, plugins={}, collaborate=false,
relativeLinks=false}={}) {
if ( collaborate && (!document || !fieldName) ) {
throw new Error("A document and fieldName must be provided when creating an editor with collaborative editing.");
}
uuid = collaborate ? `${document.uuid}#${fieldName}` : uuid ?? `ProseMirror.${foundry.utils.randomID()}`;
const state = ProseMirror.EditorState.create({doc: ProseMirror.dom.parseString(content)});
plugins = foundry.utils.mergeObject(ProseMirror.defaultPlugins, plugins, {inplace: false});
plugins.contentLinks = ProseMirror.ProseMirrorContentLinkPlugin.build(ProseMirror.defaultSchema, {
document, relativeLinks
});
if ( document ) {
plugins.images = ProseMirror.ProseMirrorImagePlugin.build(ProseMirror.defaultSchema, {document});
}
const options = {state};
Hooks.callAll("createProseMirrorEditor", uuid, plugins, options);
const view = collaborate
? await this._createCollaborativeEditorView(uuid, target, options.state, Object.values(plugins))
: this._createLocalEditorView(target, options.state, Object.values(plugins));
const editor = new ProseMirrorEditor(uuid, view, plugins.isDirty, collaborate, {document});
ProseMirrorEditor.#editors.set(uuid, editor);
return editor;
}
/* -------------------------------------------- */
/**
* Create an EditorView with collaborative editing enabled.
* @param {string} uuid The ProseMirror instance UUID.
* @param {HTMLElement} target An HTML element to mount the editor view to.
* @param {EditorState} state The ProseMirror editor state.
* @param {Plugin[]} plugins The editor plugins to load.
* @returns {Promise<EditorView>}
* @protected
*/
static async _createCollaborativeEditorView(uuid, target, state, plugins) {
const authority = await new Promise((resolve, reject) => {
game.socket.emit("pm.editDocument", uuid, state, authority => {
if ( authority.state ) resolve(authority);
else reject();
});
});
return new ProseMirror.EditorView({mount: target}, {
state: ProseMirror.EditorState.fromJSON({
schema: ProseMirror.defaultSchema,
plugins: [
...plugins,
ProseMirror.collab.collab({version: authority.version, clientID: game.userId})
]
}, authority.state),
dispatchTransaction(tr) {
const newState = this.state.apply(tr);
this.updateState(newState);
const sendable = ProseMirror.collab.sendableSteps(newState);
if ( sendable ) game.socket.emit("pm.receiveSteps", uuid, sendable.version, sendable.steps);
}
});
}
/* -------------------------------------------- */
/**
* Create a plain EditorView without collaborative editing.
* @param {HTMLElement} target An HTML element to mount the editor view to.
* @param {EditorState} state The ProseMirror editor state.
* @param {Plugin[]} plugins The editor plugins to load.
* @returns {EditorView}
* @protected
*/
static _createLocalEditorView(target, state, plugins) {
return new ProseMirror.EditorView({mount: target}, {
state: ProseMirror.EditorState.create({doc: state.doc, plugins})
});
}
/* -------------------------------------------- */
/* Socket Handlers */
/* -------------------------------------------- */
/**
* Handle new editing steps supplied by the server.
* @param {string} uuid The UUID that uniquely identifies the ProseMirror instance.
* @param {number} offset The offset into the history, representing the point at which it was last
* truncated.
* @param {ProseMirrorHistory[]} history The entire edit history.
* @protected
*/
static _onNewSteps(uuid, offset, history) {
const editor = ProseMirrorEditor.#editors.get(uuid);
if ( editor ) editor._onNewSteps(offset, history);
else {
console.warn(`New steps were received for UUID '${uuid}' which is not a ProseMirror editor instance.`);
}
}
/* -------------------------------------------- */
/**
* Our client is too far behind the central authority's state and must be re-synced.
* @param {string} uuid The UUID that uniquely identifies the ProseMirror instance.
* @protected
*/
static _onResync(uuid) {
const editor = ProseMirrorEditor.#editors.get(uuid);
if ( editor ) editor._resync();
else {
console.warn(`A resync request was received for UUID '${uuid}' which is not a ProseMirror editor instance.`);
}
}
/* -------------------------------------------- */
/**
* Handle users joining or leaving collaborative editing.
* @param {string} uuid The UUID that uniquely identifies the ProseMirror instance.
* @param {string[]} users The IDs of the users editing (including ourselves).
* @protected
*/
static _onUsersEditing(uuid, users) {
const editor = ProseMirrorEditor.#editors.get(uuid);
if ( editor ) editor._updateUserDisplay(users);
else {
console.warn(`A user update was received for UUID '${uuid}' which is not a ProseMirror editor instance.`);
}
}
/* -------------------------------------------- */
/**
* Update client state when the editor contents are autosaved server-side.
* @param {string} uuid The UUID that uniquely identifies the ProseMirror instance.
* @param {string} html The updated editor contents.
* @protected
*/
static async _onAutosave(uuid, html) {
const editor = ProseMirrorEditor.#editors.get(uuid);
const [docUUID, field] = uuid.split("#");
const doc = await fromUuid(docUUID);
if ( doc ) doc.updateSource({[field]: html});
if ( editor ) editor._handleAutosave(html);
else doc.render(false);
}
/* -------------------------------------------- */
/**
* Listen for ProseMirror collaboration events.
* @param {Socket} socket The open websocket.
* @internal
*/
static _activateSocketListeners(socket) {
socket.on("pm.newSteps", this._onNewSteps.bind(this));
socket.on("pm.resync", this._onResync.bind(this));
socket.on("pm.usersEditing", this._onUsersEditing.bind(this));
socket.on("pm.autosave", this._onAutosave.bind(this));
}
}
/**
* @callback HTMLSecretContentCallback
* @param {HTMLElement} secret The secret element whose surrounding content we wish to retrieve.
* @returns {string} The content where the secret is housed.
*/
/**
* @callback HTMLSecretUpdateCallback
* @param {HTMLElement} secret The secret element that is being manipulated.
* @param {string} content The content block containing the updated secret element.
* @returns {Promise<ClientDocument>} The updated Document.
*/
/**
* @typedef {object} HTMLSecretConfiguration
* @property {string} parentSelector The CSS selector used to target content that contains secret blocks.
* @property {{
* content: HTMLSecretContentCallback,
* update: HTMLSecretUpdateCallback
* }} callbacks An object of callback functions for each operation.
*/
/**
* A composable class for managing functionality for secret blocks within DocumentSheets.
* {@see DocumentSheet}
* @example Activate secret revealing functionality within a certain block of content.
* ```js
* const secrets = new HTMLSecret({
* selector: "section.secret[id]",
* callbacks: {
* content: this._getSecretContent.bind(this),
* update: this._updateSecret.bind(this)
* }
* });
* secrets.bind(html);
* ```
*/
class HTMLSecret {
/**
* @param {HTMLSecretConfiguration} config Configuration options.
*/
constructor({parentSelector, callbacks={}}={}) {
/**
* The CSS selector used to target secret blocks.
* @type {string}
*/
Object.defineProperty(this, "parentSelector", {value: parentSelector, writable: false});
/**
* An object of callback functions for each operation.
* @type {{content: HTMLSecretContentCallback, update: HTMLSecretUpdateCallback}}
*/
Object.defineProperty(this, "callbacks", {value: Object.freeze(callbacks), writable: false});
}
/* -------------------------------------------- */
/**
* Add event listeners to the targeted secret blocks.
* @param {HTMLElement} html The HTML content to select secret blocks from.
*/
bind(html) {
if ( !this.callbacks.content || !this.callbacks.update ) return;
const parents = html.querySelectorAll(this.parentSelector);
for ( const parent of parents ) {
parent.querySelectorAll("section.secret[id]").forEach(secret => {
const revealed = secret.classList.contains("revealed");
const reveal = document.createElement("button");
reveal.type = "button";
reveal.classList.add("reveal");
reveal.textContent = game.i18n.localize(`EDITOR.${revealed ? "Hide" : "Reveal"}`);
secret.insertBefore(reveal, secret.firstChild);
reveal.addEventListener("click", this._onToggleSecret.bind(this));
});
}
}
/* -------------------------------------------- */
/**
* Handle toggling a secret's revealed state.
* @param {MouseEvent} event The triggering click event.
* @returns {Promise<ClientDocument>} The Document whose content was modified.
* @protected
*/
_onToggleSecret(event) {
event.preventDefault();
const secret = event.currentTarget.closest(".secret");
const id = secret?.id;
if ( !id ) return;
const content = this.callbacks.content(secret);
if ( !content ) return;
const revealed = secret.classList.contains("revealed");
const modified = content.replace(new RegExp(`<section[^i]+id="${id}"[^>]*>`), () => {
return `<section class="secret${revealed ? "" : " revealed"}" id="${id}">`;
});
return this.callbacks.update(secret, modified);
}
}
/**
* @typedef {object} TabsConfiguration
* @property {string} [group] The name of the tabs group
* @property {string} navSelector The CSS selector used to target the navigation element for these tabs
* @property {string} contentSelector The CSS selector used to target the content container for these tabs
* @property {string} initial The tab name of the initially active tab
* @property {Function|null} [callback] An optional callback function that executes when the active tab is changed
*/
/**
* A controller class for managing tabbed navigation within an Application instance.
* @see {@link Application}
* @param {TabsConfiguration} config The Tabs Configuration to use for this tabbed container
*
* @example Configure tab-control for a set of HTML elements
* ```html
* <!-- Example HTML -->
* <nav class="tabs" data-group="primary-tabs">
* <a class="item" data-tab="tab1" data-group="primary-tabs">Tab 1</li>
* <a class="item" data-tab="tab2" data-group="primary-tabs">Tab 2</li>
* </nav>
*
* <section class="content">
* <div class="tab" data-tab="tab1" data-group="primary-tabs">Content 1</div>
* <div class="tab" data-tab="tab2" data-group="primary-tabs">Content 2</div>
* </section>
* ```
* Activate tab control in JavaScript
* ```js
* const tabs = new Tabs({navSelector: ".tabs", contentSelector: ".content", initial: "tab1"});
* tabs.bind(html);
* ```
*/
class Tabs {
constructor({group, navSelector, contentSelector, initial, callback}={}) {
/**
* The name of the tabs group
* @type {string}
*/
this.group = group;
/**
* The value of the active tab
* @type {string}
*/
this.active = initial;
/**
* A callback function to trigger when the tab is changed
* @type {Function|null}
*/
this.callback = callback;
/**
* The CSS selector used to target the tab navigation element
* @type {string}
*/
this._navSelector = navSelector;
/**
* A reference to the HTML navigation element the tab controller is bound to
* @type {HTMLElement|null}
*/
this._nav = null;
/**
* The CSS selector used to target the tab content element
* @type {string}
*/
this._contentSelector = contentSelector;
/**
* A reference to the HTML container element of the tab content
* @type {HTMLElement|null}
*/
this._content = null;
}
/* -------------------------------------------- */
/**
* Bind the Tabs controller to an HTML application
* @param {HTMLElement} html
*/
bind(html) {
// Identify navigation element
this._nav = html.querySelector(this._navSelector);
if ( !this._nav ) return;
// Identify content container
if ( !this._contentSelector ) this._content = null;
else if ( html.matches(this._contentSelector )) this._content = html;
else this._content = html.querySelector(this._contentSelector);
// Initialize the active tab
this.activate(this.active);
// Register listeners
this._nav.addEventListener("click", this._onClickNav.bind(this));
}
/* -------------------------------------------- */
/**
* Activate a new tab by name
* @param {string} tabName
* @param {boolean} triggerCallback
*/
activate(tabName, {triggerCallback=false}={}) {
// Validate the requested tab name
const group = this._nav.dataset.group;
const items = this._nav.querySelectorAll("[data-tab]");
if ( !items.length ) return;
const valid = Array.from(items).some(i => i.dataset.tab === tabName);
if ( !valid ) tabName = items[0].dataset.tab;
// Change active tab
for ( let i of items ) {
i.classList.toggle("active", i.dataset.tab === tabName);
}
// Change active content
if ( this._content ) {
const tabs = this._content.querySelectorAll(".tab[data-tab]");
for ( let t of tabs ) {
if ( t.dataset.group && (t.dataset.group !== group) ) continue;
t.classList.toggle("active", t.dataset.tab === tabName);
}
}
// Store the active tab
this.active = tabName;
// Optionally trigger the callback function
if ( triggerCallback ) this.callback(null, this, tabName);
}
/* -------------------------------------------- */
/**
* Handle click events on the tab navigation entries
* @param {MouseEvent} event A left click event
* @private
*/
_onClickNav(event) {
const tab = event.target.closest("[data-tab]");
if ( !tab ) return;
event.preventDefault();
const tabName = tab.dataset.tab;
if ( tabName !== this.active) this.activate(tabName, {triggerCallback: true});
}
}
const TabsV2 = Tabs;
/**
* An abstract pattern followed by the different tabs of the sidebar
* @abstract
* @interface
*/
class SidebarTab extends Application {
constructor(...args) {
super(...args);
/**
* A reference to the pop-out variant of this SidebarTab, if one exists
* @type {SidebarTab}
* @protected
*/
this._popout = null;
/**
* Denote whether this is the original version of the sidebar tab, or a pop-out variant
* @type {SidebarTab}
*/
this._original = null;
// Adjust options
if ( this.options.popOut ) this.options.classes.push("sidebar-popout");
this.options.classes.push(`${this.tabName}-sidebar`);
// Register the tab as the sidebar singleton
if ( !this.popOut && ui.sidebar ) ui.sidebar.tabs[this.tabName] = this;
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: null,
popOut: false,
width: 300,
height: "auto",
classes: ["tab", "sidebar-tab"],
baseApplication: "SidebarTab"
});
}
/* -------------------------------------------- */
/** @override */
get id() {
return `${this.options.id}${this._original ? "-popout" : ""}`;
}
/* -------------------------------------------- */
/**
* The base name of this sidebar tab
* @type {string}
*/
get tabName() {
return this.constructor.defaultOptions.id ?? this.id;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
return {
cssId: this.id,
cssClass: this.options.classes.join(" "),
tabName: this.tabName,
user: game.user
};
}
/* -------------------------------------------- */
/** @override */
async _render(force=false, options={}) {
await super._render(force, options);
if ( this._popout ) await this._popout._render(force, options);
}
/* -------------------------------------------- */
/** @override */
async _renderInner(data) {
let html = await super._renderInner(data);
if ( ui.sidebar?.activeTab === this.id ) html.addClass("active");
if ( this.popOut ) html.removeClass("tab");
return html;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Activate this SidebarTab, switching focus to it
*/
activate() {
ui.sidebar.activateTab(this.tabName);
}
/* -------------------------------------------- */
/** @override */
async close(options) {
if ( this.popOut ) {
const base = this._original;
if ( base ) base._popout = null;
return super.close(options);
}
}
/* -------------------------------------------- */
/**
* Create a second instance of this SidebarTab class which represents a singleton popped-out container
* @returns {SidebarTab} The popped out sidebar tab instance
*/
createPopout() {
if ( this._popout ) return this._popout;
const pop = new this.constructor({popOut: true});
this._popout = pop;
pop._original = this;
return pop;
}
/* -------------------------------------------- */
/**
* Render the SidebarTab as a pop-out container
*/
renderPopout() {
const pop = this.createPopout();
pop.render(true);
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/**
* Handle lazy loading for sidebar images to only load them once they become observed
* @param {HTMLElement[]} entries The entries which are now observed
* @param {IntersectionObserver} observer The intersection observer instance
*/
_onLazyLoadImage(entries, observer) {
for ( let e of entries ) {
if ( !e.isIntersecting ) continue;
const li = e.target;
// Background Image
if ( li.dataset.backgroundImage ) {
li.style["background-image"] = `url("${li.dataset.backgroundImage}")`;
delete li.dataset.backgroundImage;
}
// Avatar image
const img = li.querySelector("img");
if ( img && img.dataset.src ) {
img.src = img.dataset.src;
delete img.dataset.src;
}
// No longer observe the target
observer.unobserve(e.target);
}
}
}
/**
* @typedef {Object} DirectoryMixinEntry
* @property {string} id The unique id of the entry
* @property {Folder|string} folder The folder id or folder object to which this entry belongs
* @property {string} [img] An image path to display for the entry
* @property {string} [sort] A numeric sort value which orders this entry relative to others
* @interface
*/
/**
* Augment an Application instance with functionality that supports rendering as a directory of foldered entries.
* @param {typeof Application} Base The base Application class definition
* @returns {typeof DirectoryApplication} The decorated DirectoryApplication class definition
*/
function DirectoryApplicationMixin(Base) {
return class DirectoryApplication extends Base {
/**
* The path to the template partial which renders a single Entry within this directory
* @type {string}
*/
static entryPartial = "templates/sidebar/partials/entry-partial.html";
/**
* The path to the template partial which renders a single Folder within this directory
* @type {string}
*/
static folderPartial = "templates/sidebar/folder-partial.html";
/* -------------------------------------------- */
/**
* @inheritdoc
* @returns {DocumentDirectoryOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
renderUpdateKeys: ["name", "sort", "sorting", "folder"],
height: "auto",
scrollY: ["ol.directory-list"],
dragDrop: [{dragSelector: ".directory-item", dropSelector: ".directory-list"}],
filters: [{inputSelector: 'input[name="search"]', contentSelector: ".directory-list"}],
contextMenuSelector: ".directory-item.document",
entryClickSelector: ".entry-name"
});
}
/* -------------------------------------------- */
/**
* The type of Entry that is contained in this DirectoryTab.
* @type {string}
*/
get entryType() {
throw new Error("You must implement the entryType getter for this DirectoryTab");
}
/* -------------------------------------------- */
/**
* The maximum depth of folder nesting which is allowed in this DirectoryTab
* @returns {number}
*/
get maxFolderDepth() {
return this.collection.maxFolderDepth;
}
/* -------------------------------------------- */
/**
* Can the current User create new Entries in this DirectoryTab?
* @returns {boolean}
*/
get canCreateEntry() {
return game.user.isGM;
}
/* -------------------------------------------- */
/**
* Can the current User create new Folders in this DirectoryTab?
* @returns {boolean}
*/
get canCreateFolder() {
return this.canCreateEntry;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onSearchFilter(event, query, rgx, html) {
const isSearch = !!query;
let entryIds = new Set();
const folderIds = new Set();
const autoExpandFolderIds = new Set();
// Match entries and folders
if ( isSearch ) {
// Include folders and their parents, auto-expanding parent folders
const includeFolder = (folder, autoExpand = true) => {
if ( !folder ) return;
if ( typeof folder === "string" ) folder = this.collection.folders.get(folder);
if ( !folder ) return;
const folderId = folder._id;
if ( folderIds.has(folderId) ) {
// If this folder is not already auto-expanding, but it should be, add it to the set
if ( autoExpand && !autoExpandFolderIds.has(folderId) ) autoExpandFolderIds.add(folderId);
return;
}
folderIds.add(folderId);
if ( autoExpand ) autoExpandFolderIds.add(folderId);
if ( folder.folder ) includeFolder(folder.folder);
};
// First match folders
this._matchSearchFolders(rgx, includeFolder);
// Next match entries
this._matchSearchEntries(rgx, entryIds, folderIds, includeFolder);
}
// Toggle each directory item
for ( let el of html.querySelectorAll(".directory-item") ) {
if ( el.classList.contains("hidden") ) continue;
if ( el.classList.contains("folder") ) {
let match = isSearch && folderIds.has(el.dataset.folderId);
el.style.display = (!isSearch || match) ? "flex" : "none";
if ( autoExpandFolderIds.has(el.dataset.folderId) ) {
if ( isSearch && match ) el.classList.remove("collapsed");
}
else el.classList.toggle("collapsed", !game.folders._expanded[el.dataset.uuid]);
}
else el.style.display = (!isSearch || entryIds.has(el.dataset.entryId)) ? "flex" : "none";
}
}
/* -------------------------------------------- */
/**
* Identify folders in the collection which match a provided search query.
* This method is factored out to be extended by subclasses, for example to support compendium indices.
* @param {RegExp} query The search query
* @param {Function} includeFolder A callback function to include the folder of any matched entry
* @protected
*/
_matchSearchFolders(query, includeFolder) {
for ( const folder of this.collection.folders ) {
if ( query.test(SearchFilter.cleanQuery(folder.name)) ) {
includeFolder(folder, false);
}
}
}
/* -------------------------------------------- */
/**
* Identify entries in the collection which match a provided search query.
* This method is factored out to be extended by subclasses, for example to support compendium indices.
* @param {RegExp} query The search query
* @param {Set<string>} entryIds The set of matched Entry IDs
* @param {Set<string>} folderIds The set of matched Folder IDs
* @param {Function} includeFolder A callback function to include the folder of any matched entry
* @protected
*/
_matchSearchEntries(query, entryIds, folderIds, includeFolder) {
const nameOnlySearch = (this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME);
const entries = this.collection.index ?? this.collection.contents;
// Copy the folderIds to a new set so we can add to the original set without incorrectly adding child entries
const matchedFolderIds = new Set(folderIds);
for ( const entry of entries ) {
const entryId = this._getEntryId(entry);
// If we matched a folder, add its children entries
if ( matchedFolderIds.has(entry.folder?._id ?? entry.folder) ) entryIds.add(entryId);
// Otherwise, if we are searching by name, match the entry name
else if ( nameOnlySearch && query.test(SearchFilter.cleanQuery(this._getEntryName(entry))) ) {
entryIds.add(entryId);
includeFolder(entry.folder);
}
}
if ( nameOnlySearch ) return;
// Full Text Search
const matches = this.collection.search({query: query.source, exclude: Array.from(entryIds)});
for ( const match of matches ) {
if ( entryIds.has(match._id) ) continue;
entryIds.add(match._id);
includeFolder(match.folder);
}
}
/* -------------------------------------------- */
/**
* Get the name to search against for a given entry
* @param {Document|object} entry The entry to get the name for
* @returns {string} The name of the entry
* @protected
*/
_getEntryName(entry) {
return entry.name;
}
/* -------------------------------------------- */
/**
* Get the ID for a given entry
* @param {Document|object} entry The entry to get the id for
* @returns {string} The id of the entry
* @protected
*/
_getEntryId(entry) {
return entry._id;
}
/* -------------------------------------------- */
/** @inheritDoc */
async getData(options) {
const data = await super.getData(options);
return foundry.utils.mergeObject(data, {
tree: this.collection.tree,
entryPartial: this.#getEntryPartial(),
folderPartial: this.constructor.folderPartial,
canCreateEntry: this.canCreateEntry,
canCreateFolder: this.canCreateFolder,
sortIcon: this.collection.sortingMode === "a" ? "fa-arrow-down-a-z" : "fa-arrow-down-short-wide",
sortTooltip: this.collection.sortingMode === "a" ? "SIDEBAR.SortModeAlpha" : "SIDEBAR.SortModeManual",
searchIcon: this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME ? "fa-search" :
"fa-file-magnifying-glass",
searchTooltip: this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME ? "SIDEBAR.SearchModeName" :
"SIDEBAR.SearchModeFull"
});
}
/* -------------------------------------------- */
/** @inheritDoc */
async _render(force, options) {
await loadTemplates([this.#getEntryPartial(), this.constructor.folderPartial]);
return super._render(force, options);
}
/* -------------------------------------------- */
/**
* Retrieve the entry partial.
* @returns {string}
*/
#getEntryPartial() {
/**
* @deprecated since v11
*/
if ( this.constructor.documentPartial ) {
foundry.utils.logCompatibilityWarning("Your sidebar application defines the documentPartial static property"
+ " which is deprecated. Please use entryPartial instead.", {since: 11, until: 13});
return this.constructor.documentPartial;
}
return this.constructor.entryPartial;
}
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
const directory = html.find(".directory-list");
const entries = directory.find(".directory-item");
// Handle folder depth and collapsing
html.find(`[data-folder-depth="${this.maxFolderDepth}"] .create-folder`).remove();
html.find(".toggle-sort").click(this.#onToggleSort.bind(this));
html.find(".toggle-search-mode").click(this.#onToggleSearchMode.bind(this));
html.find(".collapse-all").click(this.collapseAll.bind(this));
// Intersection Observer
const observer = new IntersectionObserver(this._onLazyLoadImage.bind(this), { root: directory[0] });
entries.each((i, li) => observer.observe(li));
// Entry-level events
directory.on("click", this.options.entryClickSelector, this._onClickEntryName.bind(this));
directory.on("click", ".folder-header", this._toggleFolder.bind(this));
const dh = this._onDragHighlight.bind(this);
html.find(".folder").on("dragenter", dh).on("dragleave", dh);
this._contextMenu(html);
// Allow folder and entry creation
if ( this.canCreateFolder ) html.find(".create-folder").click(this._onCreateFolder.bind(this));
if ( this.canCreateEntry ) html.find(".create-entry").click(this._onCreateEntry.bind(this));
}
/* -------------------------------------------- */
/**
* Swap the sort mode between "a" (Alphabetical) and "m" (Manual by sort property)
* @param {PointerEvent} event The originating pointer event
*/
#onToggleSort(event) {
event.preventDefault();
this.collection.toggleSortingMode();
this.render();
}
/* -------------------------------------------- */
/**
* Swap the search mode between "name" and "full"
* @param {PointerEvent} event The originating pointer event
*/
#onToggleSearchMode(event) {
event.preventDefault();
this.collection.toggleSearchMode();
this.render();
}
/* -------------------------------------------- */
/**
* Collapse all subfolders in this directory
*/
collapseAll() {
this.element.find("li.folder").addClass("collapsed");
for ( let f of this.collection.folders ) {
game.folders._expanded[f.uuid] = false;
}
if ( this.popOut ) this.setPosition();
}
/* -------------------------------------------- */
/**
* Create a new Folder in this SidebarDirectory
* @param {PointerEvent} event The originating button click event
* @protected
*/
_onCreateFolder(event) {
event.preventDefault();
event.stopPropagation();
const button = event.currentTarget;
const li = button.closest(".directory-item");
const data = {folder: li?.dataset?.folderId || null, type: this.entryType};
const options = {top: button.offsetTop, left: window.innerWidth - 310 - FolderConfig.defaultOptions.width};
if ( this.collection instanceof CompendiumCollection ) options.pack = this.collection.collection;
Folder.createDialog(data, options);
}
/* -------------------------------------------- */
/**
* Handle toggling the collapsed or expanded state of a folder within the directory tab
* @param {PointerEvent} event The originating click event
* @protected
*/
_toggleFolder(event) {
let folder = $(event.currentTarget.parentElement);
let collapsed = folder.hasClass("collapsed");
const folderUuid = folder[0].dataset.uuid;
game.folders._expanded[folderUuid] = collapsed;
// Expand
if ( collapsed ) folder.removeClass("collapsed");
// Collapse
else {
folder.addClass("collapsed");
const subs = folder.find(".folder").addClass("collapsed");
subs.each((i, f) => game.folders._expanded[folderUuid] = false);
}
// Resize container
if ( this.popOut ) this.setPosition();
}
/* -------------------------------------------- */
/**
* Handle clicking on a Document name in the Sidebar directory
* @param {PointerEvent} event The originating click event
* @protected
*/
async _onClickEntryName(event) {
event.preventDefault();
const element = event.currentTarget;
const entryId = element.parentElement.dataset.entryId;
const entry = this.collection.get(entryId);
entry.sheet.render(true);
}
/* -------------------------------------------- */
/**
* Handle new Entry creation request
* @param {PointerEvent} event The originating button click event
* @protected
*/
async _onCreateEntry(event) {
throw new Error("You must implement the _onCreateEntry method");
}
/* -------------------------------------------- */
/** @override */
_onDragStart(event) {
if ( ui.context ) ui.context.close({animate: false});
const li = event.currentTarget.closest(".directory-item");
const isFolder = li.classList.contains("folder");
const dragData = isFolder
? this._getFolderDragData(li.dataset.folderId)
: this._getEntryDragData(li.dataset.entryId);
if ( !dragData ) return;
event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
}
/* -------------------------------------------- */
/**
* Get the data transfer object for a Entry being dragged from this SidebarDirectory
* @param {string} entryId The Entry's _id being dragged
* @returns {Object}
* @private
*/
_getEntryDragData(entryId) {
const entry = this.collection.get(entryId);
return entry?.toDragData();
}
/* -------------------------------------------- */
/**
* Get the data transfer object for a Folder being dragged from this SidebarDirectory
* @param {string} folderId The Folder _id being dragged
* @returns {Object}
* @private
*/
_getFolderDragData(folderId) {
const folder = this.collection.folders.get(folderId);
if ( !folder ) return null;
return {
type: "Folder",
uuid: folder.uuid
};
}
/* -------------------------------------------- */
/** @override */
_canDragStart(selector) {
return true;
}
/* -------------------------------------------- */
/**
* Highlight folders as drop targets when a drag event enters or exits their area
* @param {DragEvent} event The DragEvent which is in progress
*/
_onDragHighlight(event) {
const li = event.currentTarget;
if ( !li.classList.contains("folder") ) return;
event.stopPropagation(); // Don't bubble to parent folders
// Remove existing drop targets
if ( event.type === "dragenter" ) {
for ( let t of li.closest(".directory-list").querySelectorAll(".droptarget") ) {
t.classList.remove("droptarget");
}
}
// Remove current drop target
if ( event.type === "dragleave" ) {
const el = document.elementFromPoint(event.clientX, event.clientY);
const parent = el.closest(".folder");
if ( parent === li ) return;
}
// Add new drop target
li.classList.toggle("droptarget", event.type === "dragenter");
}
/* -------------------------------------------- */
/** @override */
_onDrop(event) {
const data = TextEditor.getDragEventData(event);
if ( !data.type ) return;
const target = event.target.closest(".directory-item") || null;
switch ( data.type ) {
case "Folder":
return this._handleDroppedFolder(target, data);
case this.entryType:
return this._handleDroppedEntry(target, data);
}
}
/* -------------------------------------------- */
/**
* Handle Folder data being dropped into the directory.
* @param {HTMLElement} target The target element
* @param {object} data The data being dropped
* @protected
*/
async _handleDroppedFolder(target, data) {
// Determine the closest Folder
const closestFolder = target ? target.closest(".folder") : null;
if ( closestFolder ) closestFolder.classList.remove("droptarget");
const closestFolderId = closestFolder ? closestFolder.dataset.folderId : null;
// Obtain the dropped Folder
let folder = await fromUuid(data.uuid);
if ( !folder ) return;
if ( folder?.type !== this.entryType ) {
ui.notifications.warn(game.i18n.format("FOLDER.InvalidDocumentType", {type: this.collection.documentName}));
return;
}
// Sort into another Folder
const sortData = {sortKey: "sort", sortBefore: true};
const isRelative = target && target.dataset.folderId;
if ( isRelative ) {
const targetFolder = await fromUuid(target.dataset.uuid);
// Sort relative to a collapsed Folder
if ( target.classList.contains("collapsed") ) {
sortData.target = targetFolder;
sortData.parentId = targetFolder.folder?.id;
sortData.parentUuid = targetFolder.folder?.uuid;
}
// Drop into an expanded Folder
else {
sortData.target = null;
sortData.parentId = targetFolder.id;
sortData.parentUuid = targetFolder.uuid;
}
}
// Sort relative to existing Folder contents
else {
sortData.parentId = closestFolderId;
sortData.parentUuid = closestFolder?.dataset?.uuid;
sortData.target = closestFolder && closestFolder.classList.contains("collapsed") ? closestFolder : null;
}
if ( sortData.parentId ) {
const parentFolder = await fromUuid(sortData.parentUuid);
if ( parentFolder === folder ) return; // Prevent assigning a folder as its own parent.
if ( parentFolder.ancestors.includes(folder) ) return; // Prevent creating a cycle.
// Prevent going beyond max depth
const maxDepth = f => Math.max(f.depth, ...f.children.filter(n => n.folder).map(n => maxDepth(n.folder)));
if ( (parentFolder.depth + (maxDepth(folder) - folder.depth + 1)) > this.maxFolderDepth ) {
ui.notifications.error(game.i18n.format("FOLDER.ExceededMaxDepth", {depth: this.maxFolderDepth}), {console: false});
return;
}
}
// Determine siblings
sortData.siblings = this.collection.folders.filter(f => {
return (f.folder?.id === sortData.parentId) && (f.type === folder.type) && (f !== folder);
});
// Handle dropping of some folder that is foreign to this collection
if ( this.collection.folders.get(folder.id) !== folder ) {
const dropped = await this._handleDroppedForeignFolder(folder, closestFolderId, sortData);
if ( !dropped || !dropped.sortNeeded ) return;
folder = dropped.folder;
}
// Resort the collection
sortData.updateData = { folder: sortData.parentId };
return folder.sortRelative(sortData);
}
/* -------------------------------------------- */
/**
* Handle a new Folder being dropped into the directory.
* This case is not handled by default, but subclasses may implement custom handling here.
* @param {Folder} folder The Folder being dropped
* @param {string} closestFolderId The closest Folder _id to the drop target
* @param {object} sortData The sort data for the Folder
* @param {string} sortData.sortKey The sort key to use for sorting
* @param {boolean} sortData.sortBefore Sort before the target?
* @returns {Promise<{folder: Folder, sortNeeded: boolean}|null>} The handled folder creation, or null
* @protected
*/
async _handleDroppedForeignFolder(folder, closestFolderId, sortData) {
return null;
}
/* -------------------------------------------- */
/**
* Handle Entry data being dropped into the directory.
* @param {HTMLElement} target The target element
* @param {object} data The data being dropped
* @protected
*/
async _handleDroppedEntry(target, data) {
// Determine the closest Folder
const closestFolder = target ? target.closest(".folder") : null;
if ( closestFolder ) closestFolder.classList.remove("droptarget");
let folder = closestFolder ? await fromUuid(closestFolder.dataset.uuid) : null;
let entry = await this._getDroppedEntryFromData(data);
if ( !entry ) return;
// Sort relative to another Document
const collection = this.collection.index ?? this.collection;
const sortData = {sortKey: "sort"};
const isRelative = target && target.dataset.entryId;
if ( isRelative ) {
if ( entry.id === target.dataset.entryId ) return; // Don't drop on yourself
const targetDocument = collection.get(target.dataset.entryId);
sortData.target = targetDocument;
folder = targetDocument?.folder;
}
// Sort within to the closest Folder
else sortData.target = null;
// Determine siblings
if ( folder instanceof foundry.abstract.Document ) folder = folder.id;
sortData.siblings = collection.filter(d => !this._entryIsSelf(d, entry) && this._entryBelongsToFolder(d, folder));
if ( !this._entryAlreadyExists(entry) ) {
// Try to predetermine the sort order
const sorted = SortingHelpers.performIntegerSort(entry, sortData);
if ( sorted.length === 1 ) entry = entry.clone({sort: sorted[0].update[sortData.sortKey]}, {keepId: true});
entry = await this._createDroppedEntry(entry, folder);
// No need to resort other documents if the document was created with a specific sort order
if ( sorted.length === 1 ) return;
}
// Resort the collection
sortData.updateData = {folder: folder || null};
return this._sortRelative(entry, sortData);
}
/* -------------------------------------------- */
/**
* Determine if an Entry is being compared to itself
* @param {DirectoryMixinEntry} entry The Entry
* @param {DirectoryMixinEntry} otherEntry The other Entry
* @returns {boolean} Is the Entry being compared to itself?
* @protected
*/
_entryIsSelf(entry, otherEntry) {
return entry._id === otherEntry._id;
}
/* -------------------------------------------- */
/**
* Determine whether an Entry belongs to the target folder
* @param {DirectoryMixinEntry} entry The Entry
* @param {Folder} folder The target folder
* @returns {boolean} Is the Entry a sibling?
* @protected
*/
_entryBelongsToFolder(entry, folder) {
if ( !entry.folder && !folder ) return true;
if ( entry.folder instanceof foundry.abstract.Document ) return entry.folder.id === folder;
return entry.folder === folder;
}
/* -------------------------------------------- */
/**
* Check if an Entry is already present in the Collection
* @param {DirectoryMixinEntry} entry The Entry being dropped
* @returns {boolean} Is the Entry already present?
* @private
*/
_entryAlreadyExists(entry) {
return this.collection.get(entry.id) === entry;
}
/* -------------------------------------------- */
/**
* Get the dropped Entry from the drop data
* @param {object} data The data being dropped
* @returns {Promise<DirectoryMixinEntry>} The dropped Entry
* @protected
*/
async _getDroppedEntryFromData(data) {
throw new Error("The _getDroppedEntryFromData method must be implemented");
}
/* -------------------------------------------- */
/**
* Create a dropped Entry in this Collection
* @param {DirectoryMixinEntry} entry The Entry being dropped
* @param {string} [folderId] The ID of the Folder to which the Entry should be added
* @returns {Promise<DirectoryMixinEntry>} The created Entry
* @protected
*/
async _createDroppedEntry(entry, folderId) {
throw new Error("The _createDroppedEntry method must be implemented");
}
/* -------------------------------------------- */
/**
* Sort a relative entry within a collection
* @param {DirectoryMixinEntry} entry The entry to sort
* @param {object} sortData The sort data
* @param {string} sortData.sortKey The sort key to use for sorting
* @param {boolean} sortData.sortBefore Sort before the target?
* @param {object} sortData.updateData Additional data to update on the entry
* return {Promise<object>} The sorted entry
*/
async _sortRelative(entry, sortData) {
throw new Error("The _sortRelative method must be implemented");
}
/* -------------------------------------------- */
/** @inheritdoc */
_contextMenu(html) {
/**
* A hook event that fires when the context menu for folders in this DocumentDirectory is constructed.
* Substitute the class name in the hook event, for example "getActorDirectoryFolderContext".
* @function getSidebarTabFolderContext
* @memberof hookEvents
* @param {jQuery} html The HTML element to which the context options are attached
* @param {ContextMenuEntry[]} entryOptions The context menu entries
*/
ContextMenu.create(this, html, ".folder .folder-header", this._getFolderContextOptions(), {
hookName: "FolderContext"
});
ContextMenu.create(this, html, this.options.contextMenuSelector, this._getEntryContextOptions());
}
/* -------------------------------------------- */
/**
* Get the set of ContextMenu options which should be used for Folders in a SidebarDirectory
* @returns {object[]} The Array of context options passed to the ContextMenu instance
* @protected
*/
_getFolderContextOptions() {
return [
{
name: "FOLDER.Edit",
icon: '<i class="fas fa-edit"></i>',
condition: game.user.isGM,
callback: async header => {
const li = header.closest(".directory-item")[0];
const folder = await fromUuid(li.dataset.uuid);
const r = li.getBoundingClientRect();
const options = {top: r.top, left: r.left - FolderConfig.defaultOptions.width - 10};
new FolderConfig(folder, options).render(true);
}
},
{
name: "FOLDER.CreateTable",
icon: `<i class="${CONFIG.RollTable.sidebarIcon}"></i>`,
condition: header => {
const li = header.closest(".directory-item")[0];
const folder = fromUuidSync(li.dataset.uuid);
return CONST.COMPENDIUM_DOCUMENT_TYPES.includes(folder.type);
},
callback: async header => {
const li = header.closest(".directory-item")[0];
const folder = await fromUuid(li.dataset.uuid);
return Dialog.confirm({
title: `${game.i18n.localize("FOLDER.CreateTable")}: ${folder.name}`,
content: game.i18n.localize("FOLDER.CreateTableConfirm"),
yes: () => RollTable.fromFolder(folder),
options: {
top: Math.min(li.offsetTop, window.innerHeight - 350),
left: window.innerWidth - 680,
width: 360
}
});
}
},
{
name: "FOLDER.Remove",
icon: '<i class="fas fa-trash"></i>',
condition: game.user.isGM,
callback: async header => {
const li = header.closest(".directory-item")[0];
const folder = await fromUuid(li.dataset.uuid);
return Dialog.confirm({
title: `${game.i18n.localize("FOLDER.Remove")} ${folder.name}`,
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("FOLDER.RemoveWarning")}</p>`,
yes: () => folder.delete({deleteSubfolders: false, deleteContents: false}),
options: {
top: Math.min(li.offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720,
width: 400
}
});
}
},
{
name: "FOLDER.Delete",
icon: '<i class="fas fa-dumpster"></i>',
condition: game.user.isGM,
callback: async header => {
const li = header.closest(".directory-item")[0];
const folder = await fromUuid(li.dataset.uuid);
return Dialog.confirm({
title: `${game.i18n.localize("FOLDER.Delete")} ${folder.name}`,
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("FOLDER.DeleteWarning")}</p>`,
yes: () => folder.delete({deleteSubfolders: true, deleteContents: true}),
options: {
top: Math.min(li.offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720,
width: 400
}
});
}
}
];
}
/* -------------------------------------------- */
/**
* Get the set of ContextMenu options which should be used for Entries in a SidebarDirectory
* @returns {object[]} The Array of context options passed to the ContextMenu instance
* @protected
*/
_getEntryContextOptions() {
return [
{
name: "FOLDER.Clear",
icon: '<i class="fas fa-folder"></i>',
condition: header => {
const li = header.closest(".directory-item");
const entry = this.collection.get(li.data("entryId"));
return game.user.isGM && !!entry.folder;
},
callback: header => {
const li = header.closest(".directory-item");
const entry = this.collection.get(li.data("entryId"));
entry.update({folder: null});
}
},
{
name: "SIDEBAR.Delete",
icon: '<i class="fas fa-trash"></i>',
condition: () => game.user.isGM,
callback: header => {
const li = header.closest(".directory-item");
const entry = this.collection.get(li.data("entryId"));
if ( !entry ) return;
return entry.deleteDialog({
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720
});
}
},
{
name: "SIDEBAR.Duplicate",
icon: '<i class="far fa-copy"></i>',
condition: () => game.user.isGM || this.collection.documentClass.canUserCreate(game.user),
callback: header => {
const li = header.closest(".directory-item");
const original = this.collection.get(li.data("entryId"));
return original.clone({name: `${original._source.name} (Copy)`}, {save: true});
}
}
];
}
};
}
/**
* @typedef {ApplicationOptions} DocumentDirectoryOptions
* @property {string[]} [renderUpdateKeys] A list of data property keys that will trigger a rerender of the tab if
* they are updated on a Document that this tab is responsible for.
* @property {string} [contextMenuSelector] The CSS selector that activates the context menu for displayed Documents.
* @property {string} [entryClickSelector] The CSS selector for the clickable area of an entry in the tab.
*/
/**
* A shared pattern for the sidebar directory which Actors, Items, and Scenes all use
* @extends {SidebarTab}
* @abstract
* @interface
*
* @param {DocumentDirectoryOptions} [options] Application configuration options.
*/
class DocumentDirectory extends DirectoryApplicationMixin(SidebarTab) {
constructor(options={}) {
super(options);
/**
* References to the set of Documents which are displayed in the Sidebar
* @type {ClientDocument[]}
*/
this.documents = null;
/**
* Reference the set of Folders which exist in this Sidebar
* @type {Folder[]}
*/
this.folders = null;
// If a collection was provided, use it instead of the default
this.#collection = options.collection ?? this.constructor.collection;
// Initialize sidebar content
this.initialize();
// Record the directory as an application of the collection if it is not a popout
if ( !this.options.popOut ) this.collection.apps.push(this);
}
/* -------------------------------------------- */
/**
* A reference to the named Document type that this Sidebar Directory instance displays
* @type {string}
*/
static documentName = "Document";
/** @override */
static entryPartial = "templates/sidebar/partials/document-partial.html";
/** @override */
get entryType() {
return this.constructor.documentName;
}
/* -------------------------------------------- */
/**
* @override
* @returns {DocumentDirectoryOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/sidebar/document-directory.html",
renderUpdateKeys: ["name", "img", "thumb", "ownership", "sort", "sorting", "folder"]
});
}
/* -------------------------------------------- */
/** @inheritDoc */
get title() {
const cls = getDocumentClass(this.constructor.documentName);
return `${game.i18n.localize(cls.metadata.labelPlural)} Directory`;
}
/* -------------------------------------------- */
/** @inheritDoc */
get id() {
const cls = getDocumentClass(this.constructor.documentName);
const pack = cls.metadata.collection;
return `${pack}${this._original ? "-popout" : ""}`;
}
/* -------------------------------------------- */
/** @inheritDoc */
get tabName() {
const cls = getDocumentClass(this.constructor.documentName);
return cls.metadata.collection;
}
/* -------------------------------------------- */
/**
* The WorldCollection instance which this Sidebar Directory displays.
* @type {WorldCollection}
*/
static get collection() {
return game.collections.get(this.documentName);
}
/* -------------------------------------------- */
/**
* The collection of Documents which are displayed in this Sidebar Directory
* @type {DocumentCollection}
*/
get collection() {
return this.#collection;
}
/* -------------------------------------------- */
/**
* A per-instance reference to a collection of documents which are displayed in this Sidebar Directory. If set, supersedes the World Collection
* @private
*/
#collection;
/* -------------------------------------------- */
/* Initialization Helpers */
/* -------------------------------------------- */
/**
* Initialize the content of the directory by categorizing folders and documents into a hierarchical tree structure.
*/
initialize() {
// Assign Folders
this.folders = this.collection.folders.contents;
// Assign Documents
this.documents = this.collection.filter(e => e.visible);
// Build Tree
this.collection.initializeTree();
}
/* -------------------------------------------- */
/* Application Rendering
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, context={}) {
// Only re-render the sidebar directory for certain types of updates
const {action, data, documentType} = context;
if ( action && !["create", "update", "delete"].includes(action) ) return this;
if ( (documentType !== "Folder") && (action === "update") && !data.some(d => {
return this.options.renderUpdateKeys.some(k => k in d);
}) ) return;
// Re-build the tree and render
this.initialize();
return super._render(force, context);
}
/* -------------------------------------------- */
/** @override */
get canCreateEntry() {
const cls = getDocumentClass(this.constructor.documentName);
return cls.canUserCreate(game.user);
}
/* -------------------------------------------- */
/** @override */
get canCreateFolder() {
return this.canCreateEntry;
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const context = await super.getData(options);
const cfg = CONFIG[this.collection.documentName];
const cls = cfg.documentClass;
return foundry.utils.mergeObject(context, {
documentCls: cls.documentName.toLowerCase(),
tabName: cls.metadata.collection,
sidebarIcon: cfg.sidebarIcon,
folderIcon: CONFIG.Folder.sidebarIcon,
label: game.i18n.localize(cls.metadata.label),
labelPlural: game.i18n.localize(cls.metadata.labelPlural),
unavailable: game.user.isGM ? cfg.collection?.instance?.invalidDocumentIds?.size : 0
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
html.find(".show-issues").on("click", () => new SupportDetails().render(true, {tab: "documents"}));
}
/* -------------------------------------------- */
/** @override */
async _onClickEntryName(event) {
event.preventDefault();
const element = event.currentTarget;
const documentId = element.parentElement.dataset.documentId;
const document = this.collection.get(documentId) ?? await this.collection.getDocument(documentId);
document.sheet.render(true);
}
/* -------------------------------------------- */
/** @override */
async _onCreateEntry(event, { _skipDeprecated=false }={}) {
/**
* @deprecated since v11
*/
if ( (this._onCreateDocument !== DocumentDirectory.prototype._onCreateDocument) && !_skipDeprecated ) {
foundry.utils.logCompatibilityWarning("DocumentDirectory#_onCreateDocument is deprecated. "
+ "Please use DocumentDirectory#_onCreateEntry instead.", {since: 11, until: 13});
return this._onCreateDocument(event);
}
event.preventDefault();
event.stopPropagation();
const button = event.currentTarget;
const li = button.closest(".directory-item");
const data = {folder: li?.dataset?.folderId};
const options = {width: 320, left: window.innerWidth - 630, top: button.offsetTop };
if ( this.collection instanceof CompendiumCollection ) options.pack = this.collection.collection;
const cls = getDocumentClass(this.collection.documentName);
return cls.createDialog(data, options);
}
/* -------------------------------------------- */
/** @override */
_onDrop(event) {
const data = TextEditor.getDragEventData(event);
if ( !data.type ) return;
const target = event.target.closest(".directory-item") || null;
// Call the drop handler
switch ( data.type ) {
case "Folder":
return this._handleDroppedFolder(target, data);
case this.collection.documentName:
return this._handleDroppedEntry(target, data);
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _handleDroppedEntry(target, data, { _skipDeprecated=false }={}) {
/**
* @deprecated since v11
*/
if ( (this._handleDroppedDocument !== DocumentDirectory.prototype._handleDroppedDocument) && !_skipDeprecated ) {
foundry.utils.logCompatibilityWarning("DocumentDirectory#_handleDroppedDocument is deprecated. "
+ "Please use DocumentDirectory#_handleDroppedEntry instead.", {since: 11, until: 13});
return this._handleDroppedDocument(target, data);
}
return super._handleDroppedEntry(target, data);
}
/* -------------------------------------------- */
/** @override */
async _getDroppedEntryFromData(data) {
const cls = this.collection.documentClass;
return cls.fromDropData(data);
}
/* -------------------------------------------- */
/** @override */
async _sortRelative(entry, sortData) {
return entry.sortRelative(sortData);
}
/* -------------------------------------------- */
/** @override */
async _createDroppedEntry(document, folderId) {
const data = document.toObject();
data.folder = folderId || null;
return document.constructor.create(data, {fromCompendium: !!document.compendium });
}
/* -------------------------------------------- */
/** @override */
async _handleDroppedForeignFolder(folder, closestFolderId, sortData) {
const createdFolders = await this._createDroppedFolderContent(folder, this.collection.folders.get(closestFolderId));
if ( createdFolders.length ) folder = createdFolders[0];
return {
sortNeeded: true,
folder: folder
};
}
/* -------------------------------------------- */
/**
* Create a dropped Folder and its children in this Collection, if they do not already exist
* @param {Folder} folder The Folder being dropped
* @param {Folder} targetFolder The Folder to which the Folder should be added
* @returns {Promise<Array<Folder>>} The created Folders
* @protected
*/
async _createDroppedFolderContent(folder, targetFolder) {
const {foldersToCreate, documentsToCreate} = await this._organizeDroppedFoldersAndDocuments(folder, targetFolder);
// Create Folders
let createdFolders;
try {
createdFolders = await Folder.createDocuments(foldersToCreate, {
pack: this.collection.collection,
keepId: true
});
}
catch (err) {
ui.notifications.error(err.message);
throw err;
}
// Create Documents
await this._createDroppedFolderDocuments(folder, documentsToCreate);
return createdFolders;
}
/* -------------------------------------------- */
/**
* Organize a dropped Folder and its children into a list of folders to create and documents to create
* @param {Folder} folder The Folder being dropped
* @param {Folder} targetFolder The Folder to which the Folder should be added
* @returns {Promise<{foldersToCreate: Array<Folder>, documentsToCreate: Array<Document>}>}
* @private
*/
async _organizeDroppedFoldersAndDocuments(folder, targetFolder) {
let foldersToCreate = [];
let documentsToCreate = [];
let exceededMaxDepth = false;
const addFolder = (folder, currentDepth) => {
if ( !folder ) return;
// If the Folder does not already exist, add it to the list of folders to create
if ( this.collection.folders.get(folder.id) !== folder ) {
const createData = folder.toObject();
if ( targetFolder ) {
createData.folder = targetFolder.id;
targetFolder = undefined;
}
if ( currentDepth > this.maxFolderDepth ) {
exceededMaxDepth = true;
return;
}
createData.pack = this.collection.collection;
foldersToCreate.push(createData);
}
// If the Folder has documents, check those as well
if ( folder.contents?.length ) {
for ( const document of folder.contents ) {
const createData = document.toObject ? document.toObject() : foundry.utils.deepClone(document);
documentsToCreate.push(createData);
}
}
// Recursively check child folders
for ( const child of folder.children ) {
addFolder(child.folder, currentDepth + 1);
}
};
const currentDepth = (targetFolder?.ancestors.length ?? 0) + 1;
addFolder(folder, currentDepth);
if ( exceededMaxDepth ) {
ui.notifications.error(game.i18n.format("FOLDER.ExceededMaxDepth", {depth: this.maxFolderDepth}), {console: false});
foldersToCreate.length = documentsToCreate.length = 0;
}
return {foldersToCreate, documentsToCreate};
}
/* -------------------------------------------- */
/**
* Create a list of documents in a dropped Folder
* @param {Folder} folder The Folder being dropped
* @param {Array<Document>} documentsToCreate The documents to create
* @returns {Promise<void>}
* @protected
*/
async _createDroppedFolderDocuments(folder, documentsToCreate) {
if ( folder.pack ) {
const pack = game.packs.get(folder.pack);
if ( pack ) {
const ids = documentsToCreate.map(d => d._id);
documentsToCreate = await pack.getDocuments({_id__in: ids});
}
}
try {
await this.collection.documentClass.createDocuments(documentsToCreate, {
pack: this.collection.collection,
keepId: true
});
}
catch (err) {
ui.notifications.error(err.message);
throw err;
}
}
/* -------------------------------------------- */
/**
* Get the set of ContextMenu options which should be used for Folders in a SidebarDirectory
* @returns {object[]} The Array of context options passed to the ContextMenu instance
* @protected
*/
_getFolderContextOptions() {
const options = super._getFolderContextOptions();
return options.concat([
{
name: "OWNERSHIP.Configure",
icon: '<i class="fas fa-lock"></i>',
condition: () => game.user.isGM,
callback: async header => {
const li = header.closest(".directory-item")[0];
const folder = await fromUuid(li.dataset.uuid);
new DocumentOwnershipConfig(folder, {
top: Math.min(li.offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720
}).render(true);
}
},
{
name: "FOLDER.Export",
icon: '<i class="fas fa-atlas"></i>',
condition: header => {
const folder = fromUuidSync(header.parent().data("uuid"));
return CONST.COMPENDIUM_DOCUMENT_TYPES.includes(folder.type);
},
callback: async header => {
const li = header.closest(".directory-item")[0];
const folder = await fromUuid(li.dataset.uuid);
return folder.exportDialog(null, {
top: Math.min(li.offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720,
width: 400
});
}
}
]);
}
/* -------------------------------------------- */
/**
* Get the set of ContextMenu options which should be used for Documents in a SidebarDirectory
* @returns {object[]} The Array of context options passed to the ContextMenu instance
* @protected
*/
_getEntryContextOptions() {
const options = super._getEntryContextOptions();
return [
{
name: "OWNERSHIP.Configure",
icon: '<i class="fas fa-lock"></i>',
condition: () => game.user.isGM,
callback: header => {
const li = header.closest(".directory-item");
const document = this.collection.get(li.data("documentId"));
new DocumentOwnershipConfig(document, {
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720
}).render(true);
}
},
{
name: "SIDEBAR.Export",
icon: '<i class="fas fa-file-export"></i>',
condition: header => {
const li = header.closest(".directory-item");
const document = this.collection.get(li.data("documentId"));
return document.isOwner;
},
callback: header => {
const li = header.closest(".directory-item");
const document = this.collection.get(li.data("documentId"));
return document.exportToJSON();
}
},
{
name: "SIDEBAR.Import",
icon: '<i class="fas fa-file-import"></i>',
condition: header => {
const li = header.closest(".directory-item");
const document = this.collection.get(li.data("documentId"));
return document.isOwner;
},
callback: header => {
const li = header.closest(".directory-item");
const document = this.collection.get(li.data("documentId"));
return document.importFromJSONDialog();
}
}
].concat(options);
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
async _onCreateDocument(event) {
foundry.utils.logCompatibilityWarning("DocumentDirectory#_onCreateDocument is deprecated. "
+ "Please use DocumentDirectory#_onCreateEntry instead.", {since: 11, until: 13});
return this._onCreateEntry(event, { _skipDeprecated: true });
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
async _handleDroppedDocument(target, data) {
foundry.utils.logCompatibilityWarning("DocumentDirectory#_handleDroppedDocument is deprecated. "
+ "Please use DocumentDirectory#_handleDroppedEntry instead.", {since: 11, until: 13});
return this._handleDroppedEntry(target, data, { _skipDeprecated: true });
}
}
/**
* @deprecated since v11
*/
Object.defineProperty(globalThis, "SidebarDirectory", {
get() {
foundry.utils.logCompatibilityWarning("SidebarDirectory has been deprecated. Please use DocumentDirectory instead.",
{since: 11, until: 13});
return DocumentDirectory;
}
});
/**
* An application for configuring data across all installed and active packages.
*/
class PackageConfiguration extends FormApplication {
static get categoryOrder() {
return ["all", "core", "system", "module", "unmapped"];
}
/**
* The name of the currently active tab.
* @type {string}
*/
get activeCategory() {
return this._tabs[0].active;
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["package-configuration"],
template: "templates/sidebar/apps/package-configuration.html",
categoryTemplate: undefined,
width: 780,
height: 680,
resizable: true,
scrollY: [".filters", ".categories"],
tabs: [{navSelector: ".tabs", contentSelector: "form .scrollable", initial: "all"}],
filters: [{inputSelector: 'input[name="filter"]', contentSelector: ".categories"}],
submitButton: false
});
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const data = this._prepareCategoryData();
data.categoryTemplate = this.options.categoryTemplate;
data.submitButton = this.options.submitButton;
return data;
}
/* -------------------------------------------- */
/**
* Prepare the structure of category data which is rendered in this configuration form.
* @abstract
* @protected
*/
_prepareCategoryData() {
return {categories: [], total: 0};
}
/* -------------------------------------------- */
/**
* Classify what Category an Action belongs to
* @param {string} namespace The entry to classify
* @returns {{id: string, title: string}} The category the entry belongs to
* @protected
*/
_categorizeEntry(namespace) {
if ( namespace === "core" ) return {
id: "core",
title: game.i18n.localize("PACKAGECONFIG.Core")
};
else if ( namespace === game.system.id ) return {
id: "system",
title: game.system.title
};
else {
const module = game.modules.get(namespace);
if ( module ) return {
id: module.id,
title: module.title
};
return {
id: "unmapped",
title: game.i18n.localize("PACKAGECONFIG.Unmapped")
};
}
}
/* -------------------------------------------- */
/**
* Reusable logic for how categories are sorted in relation to each other.
* @param {object} a
* @param {object} b
* @protected
*/
_sortCategories(a, b) {
const categories = this.constructor.categoryOrder;
let ia = categories.indexOf(a.id);
if ( ia === -1 ) ia = categories.length - 2; // Modules second from last
let ib = this.constructor.categoryOrder.indexOf(b.id);
if ( ib === -1 ) ib = categories.length - 2; // Modules second from last
return (ia - ib) || a.title.localeCompare(b.title);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _render(force, {activeCategory, ...options}={}) {
await loadTemplates([this.options.categoryTemplate]);
await super._render(force, options);
if ( activeCategory ) this._tabs[0].activate(activeCategory);
const activeTab = this._tabs[0]?.active;
if ( activeTab ) this.element[0].querySelector(`.tabs [data-tab="${activeTab}"]`)?.scrollIntoView();
}
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
if ( this.activeCategory === "all" ) {
this._tabs[0]._content.querySelectorAll(".tab").forEach(tab => tab.classList.add("active"));
}
html.find("button.reset-all").click(this._onResetDefaults.bind(this));
html.find("input[name=filter]").focus();
}
/* -------------------------------------------- */
/** @override */
_onChangeTab(event, tabs, active) {
if ( active === "all" ) {
tabs._content.querySelectorAll(".tab").forEach(tab => tab.classList.add("active"));
}
}
/* -------------------------------------------- */
/** @override */
_onSearchFilter(event, query, rgx, html) {
const visibleCategories = new Set();
// Hide entries
for ( const entry of html.querySelectorAll(".form-group") ) {
if ( !query ) {
entry.classList.remove("hidden");
continue;
}
const label = entry.querySelector("label")?.textContent;
const notes = entry.querySelector(".notes")?.textContent;
const match = (label && rgx.test(SearchFilter.cleanQuery(label)))
|| (notes && rgx.test(SearchFilter.cleanQuery(notes)));
entry.classList.toggle("hidden", !match);
if ( match ) visibleCategories.add(entry.parentElement.dataset.category);
}
// Hide categories which have no visible children
for ( const category of html.querySelectorAll(".category") ) {
category.classList.toggle("hidden", query && !visibleCategories.has(category.dataset.category));
}
}
/* -------------------------------------------- */
/**
* Handle button click to reset default settings
* @param {Event} event The initial button click event
* @abstract
* @protected
*/
_onResetDefaults(event) {}
}
/**
* Render the Sidebar container, and after rendering insert Sidebar tabs.
*/
class Sidebar extends Application {
/**
* Singleton application instances for each sidebar tab
* @type {Object<SidebarTab>}
*/
tabs = {};
/**
* Track whether the sidebar container is currently collapsed
* @type {boolean}
*/
_collapsed = false;
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "sidebar",
template: "templates/sidebar/sidebar.html",
popOut: false,
width: 300,
tabs: [{navSelector: ".tabs", contentSelector: "#sidebar", initial: "chat"}]
});
}
/* -------------------------------------------- */
/**
* Return the name of the active Sidebar tab
* @type {string}
*/
get activeTab() {
return this._tabs[0].active;
}
/* -------------------------------------------- */
/**
* Singleton application instances for each popout tab
* @type {Object<SidebarTab>}
*/
get popouts() {
const popouts = {};
for ( let [name, app] of Object.entries(this.tabs) ) {
if ( app._popout ) popouts[name] = app._popout;
}
return popouts;
}
/* -------------------------------------------- */
/* Rendering
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const isGM = game.user.isGM;
// Configure tabs
const tabs = {
chat: {
tooltip: ChatMessage.metadata.labelPlural,
icon: CONFIG.ChatMessage.sidebarIcon,
notification: "<i id=\"chat-notification\" class=\"notification-pip fas fa-exclamation-circle\"></i>"
},
combat: {
tooltip: Combat.metadata.labelPlural,
icon: CONFIG.Combat.sidebarIcon
},
scenes: {
tooltip: Scene.metadata.labelPlural,
icon: CONFIG.Scene.sidebarIcon
},
actors: {
tooltip: Actor.metadata.labelPlural,
icon: CONFIG.Actor.sidebarIcon
},
items: {
tooltip: Item.metadata.labelPlural,
icon: CONFIG.Item.sidebarIcon
},
journal: {
tooltip: "SIDEBAR.TabJournal",
icon: CONFIG.JournalEntry.sidebarIcon
},
tables: {
tooltip: RollTable.metadata.labelPlural,
icon: CONFIG.RollTable.sidebarIcon
},
cards: {
tooltip: Cards.metadata.labelPlural,
icon: CONFIG.Cards.sidebarIcon
},
playlists: {
tooltip: Playlist.metadata.labelPlural,
icon: CONFIG.Playlist.sidebarIcon
},
compendium: {
tooltip: "SIDEBAR.TabCompendium",
icon: "fas fa-atlas"
},
settings: {
tooltip: "SIDEBAR.TabSettings",
icon: "fas fa-cogs"
}
};
if ( !isGM ) delete tabs.scenes;
// Display core or system update notification?
if ( isGM && (game.data.coreUpdate.hasUpdate || game.data.systemUpdate.hasUpdate) ) {
tabs.settings.notification = `<i class="notification-pip fas fa-exclamation-circle"></i>`;
}
return {tabs};
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
// Render the Sidebar container only once
if ( !this.rendered ) await super._render(force, options);
// Render sidebar Applications
const renders = [];
for ( let [name, app] of Object.entries(this.tabs) ) {
renders.push(app._render(true).catch(err => {
Hooks.onError("Sidebar#_render", err, {
msg: `Failed to render Sidebar tab ${name}`,
log: "error",
name
});
}));
}
Promise.all(renders).then(() => this.activateTab(this.activeTab));
}
/* -------------------------------------------- */
/* Methods
/* -------------------------------------------- */
/**
* Expand the Sidebar container from a collapsed state.
* Take no action if the sidebar is already expanded.
*/
expand() {
if ( !this._collapsed ) return;
const sidebar = this.element;
const tab = sidebar.find(".sidebar-tab.active");
const tabs = sidebar.find("#sidebar-tabs");
const icon = tabs.find("a.collapse i");
// Animate the sidebar expansion
tab.hide();
sidebar.animate({width: this.options.width, height: this.position.height}, 150, () => {
sidebar.css({width: "", height: ""}); // Revert to default styling
sidebar.removeClass("collapsed");
tabs[0].dataset.tooltipDirection = TooltipManager.TOOLTIP_DIRECTIONS.DOWN;
tab.fadeIn(250, () => {
tab.css({
display: "",
height: ""
});
});
icon.removeClass("fa-caret-left").addClass("fa-caret-right");
this._collapsed = false;
Hooks.callAll("collapseSidebar", this, this._collapsed);
});
}
/* -------------------------------------------- */
/**
* Collapse the sidebar to a minimized state.
* Take no action if the sidebar is already collapsed.
*/
collapse() {
if ( this._collapsed ) return;
const sidebar = this.element;
const tab = sidebar.find(".sidebar-tab.active");
const tabs = sidebar.find("#sidebar-tabs");
const icon = tabs.find("a.collapse i");
// Animate the sidebar collapse
tab.fadeOut(250, () => {
sidebar.animate({width: 32, height: (32 + 4) * (Object.values(this.tabs).length + 1)}, 150, () => {
sidebar.css("height", ""); // Revert to default styling
sidebar.addClass("collapsed");
tabs[0].dataset.tooltipDirection = TooltipManager.TOOLTIP_DIRECTIONS.LEFT;
tab.css("display", "");
icon.removeClass("fa-caret-right").addClass("fa-caret-left");
this._collapsed = true;
Hooks.callAll("collapseSidebar", this, this._collapsed);
});
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
// Right click pop-out
const nav = this._tabs[0]._nav;
nav.addEventListener("contextmenu", this._onRightClickTab.bind(this));
// Toggle Collapse
const collapse = nav.querySelector(".collapse");
collapse.addEventListener("click", this._onToggleCollapse.bind(this));
// Left click a tab
const tabs = nav.querySelectorAll(".item");
tabs.forEach(tab => tab.addEventListener("click", this._onLeftClickTab.bind(this)));
}
/* -------------------------------------------- */
/** @override */
_onChangeTab(event, tabs, active) {
const app = ui[active];
if ( (active === "chat") && app ) app.scrollBottom();
Hooks.callAll("changeSidebarTab", app);
}
/* -------------------------------------------- */
/**
* Handle the special case of left-clicking a tab when the sidebar is collapsed.
* @param {MouseEvent} event The originating click event
* @private
*/
_onLeftClickTab(event) {
const app = ui[event.currentTarget.dataset.tab];
if ( app && this._collapsed ) app.renderPopout(app);
}
/* -------------------------------------------- */
/**
* Handle right-click events on tab controls to trigger pop-out containers for each tab
* @param {Event} event The originating contextmenu event
* @private
*/
_onRightClickTab(event) {
const li = event.target.closest(".item");
if ( !li ) return;
event.preventDefault();
const tabApp = ui[li.dataset.tab];
tabApp.renderPopout(tabApp);
}
/* -------------------------------------------- */
/**
* Handle toggling of the Sidebar container's collapsed or expanded state
* @param {Event} event
* @private
*/
_onToggleCollapse(event) {
event.preventDefault();
if ( this._collapsed ) this.expand();
else this.collapse();
}
}
/**
* The Application responsible for displaying and editing a single Actor document.
* This Application is responsible for rendering an actor's attributes and allowing the actor to be edited.
* @extends {DocumentSheet}
* @category - Applications
* @param {Actor} actor The Actor instance being displayed within the sheet.
* @param {DocumentSheetOptions} [options] Additional application configuration options.
*/
class ActorSheet extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
height: 720,
width: 800,
template: "templates/sheets/actor-sheet.html",
closeOnSubmit: false,
submitOnClose: true,
submitOnChange: true,
resizable: true,
baseApplication: "ActorSheet",
dragDrop: [{dragSelector: ".item-list .item", dropSelector: null}],
secrets: [{parentSelector: ".editor"}],
token: null
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return this.actor.isToken ? `[Token] ${this.actor.name}` : this.actor.name;
}
/* -------------------------------------------- */
/**
* A convenience reference to the Actor document
* @type {Actor}
*/
get actor() {
return this.object;
}
/* -------------------------------------------- */
/**
* If this Actor Sheet represents a synthetic Token actor, reference the active Token
* @type {Token|null}
*/
get token() {
return this.object.token || this.options.token || null;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
async close(options) {
this.options.token = null;
return super.close(options);
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
context.actor = this.object;
context.items = context.data.items;
context.items.sort((a, b) => (a.sort || 0) - (b.sort || 0));
context.effects = context.data.effects;
return context;
}
/* -------------------------------------------- */
/** @inheritdoc */
_getHeaderButtons() {
let buttons = super._getHeaderButtons();
const canConfigure = game.user.isGM || (this.actor.isOwner && game.user.can("TOKEN_CONFIGURE"));
if ( this.options.editable && canConfigure ) {
const closeIndex = buttons.findIndex(btn => btn.label === "Close");
buttons.splice(closeIndex, 0, {
label: this.token ? "Token" : "TOKEN.TitlePrototype",
class: "configure-token",
icon: "fas fa-user-circle",
onclick: ev => this._onConfigureToken(ev)
});
}
return buttons;
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData = {}) {
const data = super._getSubmitData(updateData);
// Prevent submitting overridden values
const overrides = foundry.utils.flattenObject(this.actor.overrides);
for ( let k of Object.keys(overrides) ) {
if ( k.startsWith("system.") ) delete data[`data.${k.slice(7)}`]; // Band-aid for < v10 data
delete data[k];
}
return data;
}
/* -------------------------------------------- */
/* Event Listeners */
/* -------------------------------------------- */
/**
* Handle requests to configure the Token for the Actor
* @param {PointerEvent} event The originating click event
* @private
*/
_onConfigureToken(event) {
event.preventDefault();
const renderOptions = {
left: Math.max(this.position.left - 560 - 10, 10),
top: this.position.top
};
if ( this.token ) return this.token.sheet.render(true, renderOptions);
else new CONFIG.Token.prototypeSheetClass(this.actor.prototypeToken, renderOptions).render(true);
}
/* -------------------------------------------- */
/* Drag and Drop */
/* -------------------------------------------- */
/** @inheritdoc */
_canDragStart(selector) {
return this.isEditable;
}
/* -------------------------------------------- */
/** @inheritdoc */
_canDragDrop(selector) {
return this.isEditable;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragStart(event) {
const li = event.currentTarget;
if ( event.target.classList.contains("content-link") ) return;
// Create drag data
let dragData;
// Owned Items
if ( li.dataset.itemId ) {
const item = this.actor.items.get(li.dataset.itemId);
dragData = item.toDragData();
}
// Active Effect
if ( li.dataset.effectId ) {
const effect = this.actor.effects.get(li.dataset.effectId);
dragData = effect.toDragData();
}
if ( !dragData ) return;
// Set data transfer
event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDrop(event) {
const data = TextEditor.getDragEventData(event);
const actor = this.actor;
const allowed = Hooks.call("dropActorSheetData", actor, this, data);
if ( allowed === false ) return;
// Handle different data types
switch ( data.type ) {
case "ActiveEffect":
return this._onDropActiveEffect(event, data);
case "Actor":
return this._onDropActor(event, data);
case "Item":
return this._onDropItem(event, data);
case "Folder":
return this._onDropFolder(event, data);
}
}
/* -------------------------------------------- */
/**
* Handle the dropping of ActiveEffect data onto an Actor Sheet
* @param {DragEvent} event The concluding DragEvent which contains drop data
* @param {object} data The data transfer extracted from the event
* @returns {Promise<ActiveEffect|boolean>} The created ActiveEffect object or false if it couldn't be created.
* @protected
*/
async _onDropActiveEffect(event, data) {
const effect = await ActiveEffect.implementation.fromDropData(data);
if ( !this.actor.isOwner || !effect ) return false;
if ( this.actor.uuid === effect.parent?.uuid ) return false;
return ActiveEffect.create(effect.toObject(), {parent: this.actor});
}
/* -------------------------------------------- */
/**
* Handle dropping of an Actor data onto another Actor sheet
* @param {DragEvent} event The concluding DragEvent which contains drop data
* @param {object} data The data transfer extracted from the event
* @returns {Promise<object|boolean>} A data object which describes the result of the drop, or false if the drop was
* not permitted.
* @protected
*/
async _onDropActor(event, data) {
if ( !this.actor.isOwner ) return false;
}
/* -------------------------------------------- */
/**
* Handle dropping of an item reference or item data onto an Actor Sheet
* @param {DragEvent} event The concluding DragEvent which contains drop data
* @param {object} data The data transfer extracted from the event
* @returns {Promise<Item[]|boolean>} The created or updated Item instances, or false if the drop was not permitted.
* @protected
*/
async _onDropItem(event, data) {
if ( !this.actor.isOwner ) return false;
const item = await Item.implementation.fromDropData(data);
const itemData = item.toObject();
// Handle item sorting within the same Actor
if ( this.actor.uuid === item.parent?.uuid ) return this._onSortItem(event, itemData);
// Create the owned item
return this._onDropItemCreate(itemData);
}
/* -------------------------------------------- */
/**
* Handle dropping of a Folder on an Actor Sheet.
* The core sheet currently supports dropping a Folder of Items to create all items as owned items.
* @param {DragEvent} event The concluding DragEvent which contains drop data
* @param {object} data The data transfer extracted from the event
* @returns {Promise<Item[]>}
* @protected
*/
async _onDropFolder(event, data) {
if ( !this.actor.isOwner ) return [];
const folder = await Folder.implementation.fromDropData(data);
if ( folder.type !== "Item" ) return [];
const droppedItemData = await Promise.all(folder.contents.map(async item => {
if ( !(document instanceof Item) ) item = await fromUuid(item.uuid);
return item.toObject();
}));
return this._onDropItemCreate(droppedItemData);
}
/* -------------------------------------------- */
/**
* Handle the final creation of dropped Item data on the Actor.
* This method is factored out to allow downstream classes the opportunity to override item creation behavior.
* @param {object[]|object} itemData The item data requested for creation
* @returns {Promise<Item[]>}
* @private
*/
async _onDropItemCreate(itemData) {
itemData = itemData instanceof Array ? itemData : [itemData];
return this.actor.createEmbeddedDocuments("Item", itemData);
}
/* -------------------------------------------- */
/**
* Handle a drop event for an existing embedded Item to sort that Item relative to its siblings
* @param {Event} event
* @param {Object} itemData
* @private
*/
_onSortItem(event, itemData) {
// Get the drag source and drop target
const items = this.actor.items;
const source = items.get(itemData._id);
const dropTarget = event.target.closest("[data-item-id]");
if ( !dropTarget ) return;
const target = items.get(dropTarget.dataset.itemId);
// Don't sort on yourself
if ( source.id === target.id ) return;
// Identify sibling items based on adjacent HTML elements
const siblings = [];
for ( let el of dropTarget.parentElement.children ) {
const siblingId = el.dataset.itemId;
if ( siblingId && (siblingId !== source.id) ) siblings.push(items.get(el.dataset.itemId));
}
// Perform the sort
const sortUpdates = SortingHelpers.performIntegerSort(source, {target, siblings});
const updateData = sortUpdates.map(u => {
const update = u.update;
update._id = u.target._id;
return update;
});
// Perform the update
return this.actor.updateEmbeddedDocuments("Item", updateData);
}
}
/**
* An interface for packaging Adventure content and loading it to a compendium pack.
* // TODO - add a warning if you are building the adventure with any missing content
* // TODO - add a warning if you are building an adventure that sources content from a different package' compendium
*/
class AdventureExporter extends DocumentSheet {
constructor(document, options={}) {
super(document, options);
if ( !document.pack ) {
throw new Error("You may not export an Adventure that does not belong to a Compendium pack");
}
}
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/adventure/exporter.html",
id: "adventure-exporter",
classes: ["sheet", "adventure", "adventure-exporter"],
width: 560,
height: "auto",
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "summary"}],
dragDrop: [{ dropSelector: "form" }],
scrollY: [".tab.contents"],
submitOnClose: false,
closeOnSubmit: true
});
}
/**
* An alias for the Adventure document
* @type {Adventure}
*/
adventure = this.object;
/**
* @typedef {Object} AdventureContentTreeNode
* @property {string} id An alias for folder.id
* @property {string} name An alias for folder.name
* @property {Folder} folder The Folder at this node level
* @property {string} state The modification state of the Folder
* @property {AdventureContentTreeNode[]} children An array of child nodes
* @property {{id: string, name: string, document: ClientDocument, state: string}[]} documents An array of documents
*/
/**
* @typedef {AdventureContentTreeNode} AdventureContentTreeRoot
* @property {null} id The folder ID is null at the root level
* @property {string} documentName The Document name contained in this tree
* @property {string} collection The Document collection name of this tree
* @property {string} name The name displayed at the root level of the tree
* @property {string} icon The icon displayed at the root level of the tree
* @property {string} collapseIcon The icon which represents the current collapsed state of the tree
* @property {string} cssClass CSS classes which describe the display of the tree
* @property {number} documentCount The number of documents which are present in the tree
*/
/**
* The prepared document tree which is displayed in the form.
* @type {Object<AdventureContentTreeRoot>}
*/
contentTree = {};
/**
* A mapping which allows convenient access to content tree nodes by their folder ID
* @type {Object<AdventureContentTreeNode>}
*/
#treeNodes = {};
/**
* Track data for content which has been added to the adventure.
* @type {Object<Set<ClientDocument>>}
*/
#addedContent = Object.keys(Adventure.contentFields).reduce((obj, f) => {
obj[f] = new Set();
return obj;
}, {});
/**
* Track the IDs of content which has been removed from the adventure.
* @type {Object<Set<string>>}
*/
#removedContent = Object.keys(Adventure.contentFields).reduce((obj, f) => {
obj[f] = new Set();
return obj;
}, {});
/**
* Track which sections of the contents are collapsed.
* @type {Set<string>}
* @private
*/
#collapsedSections = new Set();
/** @override */
get isEditable() {
return game.user.isGM;
}
/* -------------------------------------------- */
/* Application Rendering */
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
this.contentTree = this.#organizeContentTree();
return {
adventure: this.adventure,
contentTree: this.contentTree
};
}
/* -------------------------------------------- */
/** @inheritdoc */
async activateEditor(name, options={}, initialContent="") {
options.plugins = {
menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema),
keyMaps: ProseMirror.ProseMirrorKeyMaps.build(ProseMirror.defaultSchema)
};
return super.activateEditor(name, options, initialContent);
}
/* -------------------------------------------- */
/** @inheritdoc */
_getHeaderButtons() {
return super._getHeaderButtons().filter(btn => btn.label !== "Import");
}
/* -------------------------------------------- */
/**
* Organize content in the adventure into a tree structure which is displayed in the UI.
* @returns {Object<AdventureContentTreeRoot>}
*/
#organizeContentTree() {
const content = {};
let remainingFolders = Array.from(this.adventure.folders).concat(Array.from(this.#addedContent.folders || []));
// Prepare each content section
for ( const [name, cls] of Object.entries(Adventure.contentFields) ) {
if ( name === "folders" ) continue;
// Partition content for the section
let documents = Array.from(this.adventure[name]).concat(Array.from(this.#addedContent[name] || []));
let folders;
[remainingFolders, folders] = remainingFolders.partition(f => f.type === cls.documentName);
if ( !(documents.length || folders.length) ) continue;
// Prepare the root node
const collapsed = this.#collapsedSections.has(cls.documentName);
const section = content[name] = {
documentName: cls.documentName,
collection: cls.collectionName,
id: null,
name: game.i18n.localize(cls.metadata.labelPlural),
icon: CONFIG[cls.documentName].sidebarIcon,
collapseIcon: collapsed ? "fa-solid fa-angle-up" : "fa-solid fa-angle-down",
cssClass: [cls.collectionName, collapsed ? "collapsed" : ""].filterJoin(" "),
documentCount: documents.length - this.#removedContent[name].size,
folder: null,
state: "root",
children: [],
documents: []
};
// Recursively populate the tree
[folders, documents] = this.#populateNode(section, folders, documents);
// Add leftover documents to the section root
for ( const d of documents ) {
const state = this.#getDocumentState(d);
section.documents.push({document: d, id: d.id, name: d.name, state: state, stateLabel: `ADVENTURE.Document${state.titleCase()}`});
}
}
return content;
}
/* -------------------------------------------- */
/**
* Populate one node of the content tree with folders and documents
* @param {AdventureContentTreeNode }node The node being populated
* @param {Folder[]} remainingFolders Folders which have yet to be populated to a node
* @param {ClientDocument[]} remainingDocuments Documents which have yet to be populated to a node
* @returns {Array<Folder[], ClientDocument[]>} Folders and Documents which still have yet to be populated
*/
#populateNode(node, remainingFolders, remainingDocuments) {
// Allocate Documents to this node
let documents;
[remainingDocuments, documents] = remainingDocuments.partition(d => d._source.folder === node.id );
for ( const d of documents ) {
const state = this.#getDocumentState(d);
node.documents.push({document: d, id: d.id, name: d.name, state: state, stateLabel: `ADVENTURE.Document${state.titleCase()}`});
}
// Allocate Folders to this node
let folders;
[remainingFolders, folders] = remainingFolders.partition(f => f._source.folder === node.id);
for ( const folder of folders ) {
const state = this.#getDocumentState(folder);
const child = {folder, id: folder.id, name: folder.name, state: state, stateLabel: `ADVENTURE.Document${state.titleCase()}`,
children: [], documents: []};
[remainingFolders, remainingDocuments] = this.#populateNode(child, remainingFolders, remainingDocuments);
node.children.push(child);
this.#treeNodes[folder.id] = child;
}
return [remainingFolders, remainingDocuments];
}
/* -------------------------------------------- */
/**
* Flag the current state of each document which is displayed
* @param {ClientDocument} document The document being modified
* @returns {string} The document state
*/
#getDocumentState(document) {
const cn = document.collectionName;
if ( this.#removedContent[cn].has(document.id) ) return "remove";
if ( this.#addedContent[cn].has(document) ) return "add";
const worldCollection = game.collections.get(document.documentName);
if ( !worldCollection.has(document.id) ) return "missing";
return "update";
}
/* -------------------------------------------- */
/** @inheritDoc */
async close(options = {}) {
this.adventure.reset(); // Reset any pending changes
return super.close(options);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
html.on("click", "a.control", this.#onClickControl.bind(this));
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, adventureData) {
// Build the adventure data content
for ( const [name, cls] of Object.entries(Adventure.contentFields) ) {
const collection = game.collections.get(cls.documentName);
adventureData[name] = [];
const addDoc = id => {
if ( this.#removedContent[name].has(id) ) return;
const doc = collection.get(id);
if ( !doc ) return;
adventureData[name].push(doc.toObject());
};
for ( const d of this.adventure[name] ) addDoc(d.id);
for ( const d of this.#addedContent[name] ) addDoc(d.id);
}
const pack = game.packs.get(this.adventure.pack);
const restrictedDocuments = adventureData.actors?.length || adventureData.items?.length;
if ( restrictedDocuments && !pack?.metadata.system ) {
return ui.notifications.error("ADVENTURE.ExportPackNoSystem", {localize: true, permanent: true});
}
// Create or update the document
if ( this.adventure.id ) {
const updated = await this.adventure.update(adventureData, {diff: false, recursive: false});
pack.indexDocument(updated);
ui.notifications.info(game.i18n.format("ADVENTURE.UpdateSuccess", {name: this.adventure.name}));
} else {
await this.adventure.constructor.createDocuments([adventureData], {
pack: this.adventure.pack,
keepId: true,
keepEmbeddedIds: true
});
ui.notifications.info(game.i18n.format("ADVENTURE.CreateSuccess", {name: this.adventure.name}));
}
}
/* -------------------------------------------- */
/**
* Save editing progress so that re-renders of the form do not wipe out un-saved changes.
*/
#saveProgress() {
const formData = this._getSubmitData();
this.adventure.updateSource(formData);
}
/* -------------------------------------------- */
/**
* Handle pointer events on a control button
* @param {PointerEvent} event The originating pointer event
*/
#onClickControl(event) {
event.preventDefault();
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "clear":
return this.#onClearSection(button);
case "collapse":
return this.#onCollapseSection(button);
case "remove":
return this.#onRemoveContent(button);
}
}
/* -------------------------------------------- */
/**
* Clear all content from a particular document-type section.
* @param {HTMLAnchorElement} button The clicked control button
*/
#onClearSection(button) {
const section = button.closest(".document-type");
const documentType = section.dataset.documentType;
const cls = getDocumentClass(documentType);
this.#removeNode(this.contentTree[cls.collectionName]);
this.#saveProgress();
this.render();
}
/* -------------------------------------------- */
/**
* Toggle the collapsed or expanded state of a document-type section
* @param {HTMLAnchorElement} button The clicked control button
*/
#onCollapseSection(button) {
const section = button.closest(".document-type");
const icon = button.firstElementChild;
const documentType = section.dataset.documentType;
const isCollapsed = this.#collapsedSections.has(documentType);
if ( isCollapsed ) {
this.#collapsedSections.delete(documentType);
section.classList.remove("collapsed");
icon.classList.replace("fa-angle-up", "fa-angle-down");
} else {
this.#collapsedSections.add(documentType);
section.classList.add("collapsed");
icon.classList.replace("fa-angle-down", "fa-angle-up");
}
}
/* -------------------------------------------- */
/**
* Remove a single piece of content.
* @param {HTMLAnchorElement} button The clicked control button
*/
#onRemoveContent(button) {
const h4 = button.closest("h4");
const isFolder = h4.classList.contains("folder");
const documentName = isFolder ? "Folder" : button.closest(".document-type").dataset.documentType;
const document = this.#getDocument(documentName, h4.dataset.documentId);
if ( document ) {
this.removeContent(document);
this.#saveProgress();
this.render();
}
}
/* -------------------------------------------- */
/**
* Get the Document instance from the clicked content tag.
* @param {string} documentName The document type
* @param {string} documentId The document ID
* @returns {ClientDocument|null} The Document instance, or null
*/
#getDocument(documentName, documentId) {
const cls = getDocumentClass(documentName);
const cn = cls.collectionName;
const existing = this.adventure[cn].find(d => d.id === documentId);
if ( existing ) return existing;
const added = this.#addedContent[cn].find(d => d.id === documentId);
return added || null;
}
/* -------------------------------------------- */
/* Content Drop Handling */
/* -------------------------------------------- */
/** @inheritdoc */
async _onDrop(event) {
const data = TextEditor.getDragEventData(event);
const cls = getDocumentClass(data?.type);
if ( !cls || !(cls.collectionName in Adventure.contentFields) ) return;
const document = await cls.fromDropData(data);
if ( document.pack || document.isEmbedded ) {
return ui.notifications.error("ADVENTURE.ExportPrimaryDocumentsOnly", {localize: true});
}
const pack = game.packs.get(this.adventure.pack);
const type = data?.type === "Folder" ? document.type : data?.type;
if ( !pack?.metadata.system && CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(type) ) {
return ui.notifications.error("ADVENTURE.ExportPackNoSystem", {localize: true});
}
this.addContent(document);
this.#saveProgress();
this.render();
}
/* -------------------------------------------- */
/* Content Management Workflows */
/* -------------------------------------------- */
/**
* Stage a document for addition to the Adventure.
* This adds the document locally, the change is not yet submitted to the database.
* @param {Folder|ClientDocument} document Some document to be added to the Adventure.
*/
addContent(document) {
if ( document instanceof foundry.documents.BaseFolder ) this.#addFolder(document);
if ( document.folder ) this.#addDocument(document.folder);
this.#addDocument(document);
}
/* -------------------------------------------- */
/**
* Remove a single Document from the Adventure.
* @param {ClientDocument} document The Document being removed from the Adventure.
*/
removeContent(document) {
if ( document instanceof foundry.documents.BaseFolder ) {
const node = this.#treeNodes[document.id];
if ( !node ) return;
if ( this.#removedContent.folders.has(node.id) ) return this.#restoreNode(node);
return this.#removeNode(node);
}
else this.#removeDocument(document);
}
/* -------------------------------------------- */
/**
* Remove a single document from the content tree
* @param {AdventureContentTreeNode} node The node to remove
*/
#removeNode(node) {
for ( const child of node.children ) this.#removeNode(child);
for ( const d of node.documents ) this.#removeDocument(d.document);
if ( node.folder ) this.#removeDocument(node.folder);
}
/* -------------------------------------------- */
/**
* Restore a removed node back to the content tree
* @param {AdventureContentTreeNode} node The node to restore
*/
#restoreNode(node) {
for ( const child of node.children ) this.#restoreNode(child);
for ( const d of node.documents ) this.#removedContent[d.document.collectionName].delete(d.id);
return this.#removedContent.folders.delete(node.id);
}
/* -------------------------------------------- */
/**
* Remove a single document from the content tree
* @param {ClientDocument} document The document to remove
*/
#removeDocument(document) {
const cn = document.collectionName;
// If the Document was already removed, re-add it
if ( this.#removedContent[cn].has(document.id) ) {
this.#removedContent[cn].delete(document.id);
}
// If the content was temporarily added, remove it
else if ( this.#addedContent[cn].has(document) ) {
this.#addedContent[cn].delete(document);
}
// Otherwise, mark the content as removed
else this.#removedContent[cn].add(document.id);
}
/* -------------------------------------------- */
/**
* Add an entire folder tree including contained documents and subfolders to the Adventure.
* @param {Folder} folder The folder to add
* @private
*/
#addFolder(folder) {
this.#addDocument(folder);
for ( const doc of folder.contents ) {
this.#addDocument(doc);
}
for ( const sub of folder.getSubfolders() ) {
this.#addFolder(sub);
}
}
/* -------------------------------------------- */
/**
* Add a single document to the Adventure.
* @param {ClientDocument} document The Document to add
* @private
*/
#addDocument(document) {
const cn = document.collectionName;
// If the document was previously removed, restore it
if ( this.#removedContent[cn].has(document.id) ) {
return this.#removedContent[cn].delete(document.id);
}
// Otherwise, add documents which don't yet exist
const existing = this.adventure[cn].find(d => d.id === document.id);
if ( !existing ) this.#addedContent[cn].add(document);
}
}
/**
* An interface for importing an adventure from a compendium pack.
*/
class AdventureImporter extends DocumentSheet {
/**
* An alias for the Adventure document
* @type {Adventure}
*/
adventure = this.object;
/** @override */
get isEditable() {
return game.user.isGM;
}
/* -------------------------------------------- */
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/adventure/importer.html",
id: "adventure-importer",
classes: ["sheet", "adventure", "adventure-importer"],
width: 800,
height: "auto",
submitOnClose: false,
closeOnSubmit: true
});
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
return {
adventure: this.adventure,
contents: this._getContentList(),
imported: !!game.settings.get("core", "adventureImports")?.[this.adventure.uuid]
};
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find('[value="all"]').on("change", this._onToggleImportAll.bind(this));
}
/* -------------------------------------------- */
/**
* Handle toggling the import all checkbox.
* @param {Event} event The change event.
* @protected
*/
_onToggleImportAll(event) {
const target = event.currentTarget;
const section = target.closest(".import-controls");
const checked = target.checked;
section.querySelectorAll("input").forEach(input => {
if ( input === target ) return;
if ( input.value !== "folders" ) input.disabled = checked;
if ( checked ) input.checked = true;
});
}
/* -------------------------------------------- */
/**
* Prepare a list of content types provided by this adventure.
* @returns {{icon: string, label: string, count: number}[]}
* @protected
*/
_getContentList() {
return Object.entries(Adventure.contentFields).reduce((arr, [field, cls]) => {
const count = this.adventure[field].size;
if ( !count ) return arr;
arr.push({
icon: CONFIG[cls.documentName].sidebarIcon,
label: game.i18n.localize(count > 1 ? cls.metadata.labelPlural : cls.metadata.label),
count, field
});
return arr;
}, []);
}
/* -------------------------------------------- */
/** @inheritdoc */
_getHeaderButtons() {
const buttons = super._getHeaderButtons();
buttons.findSplice(b => b.class === "import");
return buttons;
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
// Backwards compatibility. If the AdventureImporter subclass defines _prepareImportData or _importContent
/** @deprecated since v11 */
const prepareImportDefined = foundry.utils.getDefiningClass(this, "_prepareImportData");
const importContentDefined = foundry.utils.getDefiningClass(this, "_importContent");
if ( (prepareImportDefined !== AdventureImporter) || (importContentDefined !== AdventureImporter) ) {
const warning = `The ${this.name} class overrides the AdventureImporter#_prepareImportData or
AdventureImporter#_importContent methods. As such a legacy import workflow will be used, but this workflow is
deprecated. Your importer should now call the new Adventure#import, Adventure#prepareImport,
or Adventure#importContent methods.`;
foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
return this._importLegacy(formData);
}
// Perform the standard Adventure import workflow
return this.adventure.import(formData);
}
/* -------------------------------------------- */
/**
* Mirror Adventure#import but call AdventureImporter#_importContent and AdventureImport#_prepareImportData
* @deprecated since v11
* @ignore
*/
async _importLegacy(formData) {
// Prepare the content for import
const {toCreate, toUpdate, documentCount} = await this._prepareImportData(formData);
// Allow modules to preprocess adventure data or to intercept the import process
const allowed = Hooks.call("preImportAdventure", this.adventure, formData, toCreate, toUpdate);
if ( allowed === false ) {
return console.log(`"${this.adventure.name}" Adventure import was prevented by the "preImportAdventure" hook`);
}
// Warn the user if the import operation will overwrite existing World content
if ( !foundry.utils.isEmpty(toUpdate) ) {
const confirm = await Dialog.confirm({
title: game.i18n.localize("ADVENTURE.ImportOverwriteTitle"),
content: `<h4><strong>${game.i18n.localize("Warning")}:</strong></h4>
<p>${game.i18n.format("ADVENTURE.ImportOverwriteWarning", {name: this.adventure.name})}</p>`
});
if ( !confirm ) return;
}
// Perform the import
const {created, updated} = await this._importContent(toCreate, toUpdate, documentCount);
// Refresh the sidebar display
ui.sidebar.render();
// Allow modules to react to the import process
Hooks.callAll("importAdventure", this.adventure, formData, created, updated);
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
async _prepareImportData(formData) {
foundry.utils.logCompatibilityWarning("AdventureImporter#_prepareImportData is deprecated. "
+ "Please use Adventure#prepareImport instead.", {since: 11, until: 13});
return this.adventure.prepareImport(formData);
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
async _importContent(toCreate, toUpdate, documentCount) {
foundry.utils.logCompatibilityWarning("AdventureImporter#_importContent is deprecated. "
+ "Please use Adventure#importContent instead.", {since: 11, until: 13});
return this.adventure.importContent({ toCreate, toUpdate, documentCount });
}
}
/**
* The Application responsible for displaying a basic sheet for any Document sub-types that do not have a sheet
* registered.
* @extends {DocumentSheet}
*/
class BaseSheet extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/sheets/base-sheet.html",
classes: ["sheet", "base-sheet"],
width: 450,
height: "auto",
resizable: true,
submitOnChange: true,
closeOnSubmit: false
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
const context = await super.getData(options);
context.hasName = "name" in this.object;
context.hasImage = "img" in this.object;
context.hasDescription = "description" in this.object;
if ( context.hasDescription ) {
context.descriptionHTML = await TextEditor.enrichHTML(this.object.description, {
async: true,
secrets: this.object.isOwner,
relativeTo: this.object
});
}
return context;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
await super._render(force, options);
await this._waitForImages();
this.setPosition();
}
/* -------------------------------------------- */
/** @inheritdoc */
async activateEditor(name, options={}, initialContent="") {
options.relativeLinks = true;
options.plugins = {
menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
compact: true,
destroyOnSave: false,
onSave: () => this.saveEditor(name, {remove: false})
})
};
return super.activateEditor(name, options, initialContent);
}
}
/**
* A DocumentSheet application responsible for displaying and editing a single embedded Card document.
* @extends {DocumentSheet}
* @param {Card} object The {@link Card} object being configured.
* @param {DocumentSheetOptions} [options] Application configuration options.
*/
class CardConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "card-config"],
template: "templates/cards/card-config.html",
width: 480,
height: "auto",
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "details"}]
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
return foundry.utils.mergeObject(super.getData(options), {
data: this.document.toObject(), // Source data, not derived
types: CONFIG.Card.typeLabels
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find(".face-control").click(this._onFaceControl.bind(this));
}
/* -------------------------------------------- */
/**
* Handle card face control actions which modify single cards on the sheet.
* @param {PointerEvent} event The originating click event
* @returns {Promise} A Promise which resolves once the handler has completed
* @protected
*/
async _onFaceControl(event) {
const button = event.currentTarget;
const face = button.closest(".face");
const faces = this.object.toObject().faces;
// Save any pending change to the form
await this._onSubmit(event, {preventClose: true, preventRender: true});
// Handle the control action
switch ( button.dataset.action ) {
case "addFace":
faces.push({});
return this.object.update({faces});
case "deleteFace":
return Dialog.confirm({
title: game.i18n.localize("CARD.FaceDelete"),
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("CARD.FaceDeleteWarning")}</p>`,
yes: () => {
const i = Number(face.dataset.face);
faces.splice(i, 1);
return this.object.update({faces});
}
});
}
}
}
/**
* A DocumentSheet application responsible for displaying and editing a single Cards stack.
*/
class CardsConfig extends DocumentSheet {
/**
* The CardsConfig sheet is constructed by providing a Cards document and sheet-level options.
* @param {Cards} object The {@link Cards} object being configured.
* @param {DocumentSheetOptions} [options] Application configuration options.
*/
constructor(object, options) {
super(object, options);
this.options.classes.push(object.type);
}
/**
* The allowed sorting methods which can be used for this sheet
* @enum {string}
*/
static SORT_TYPES = {
STANDARD: "standard",
SHUFFLED: "shuffled"
};
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "cards-config"],
template: "templates/cards/cards-deck.html",
width: 620,
height: "auto",
closeOnSubmit: false,
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
dragDrop: [{dragSelector: "ol.cards li.card", dropSelector: "ol.cards"}],
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "cards"}],
scrollY: ["ol.cards"],
sort: this.SORT_TYPES.SHUFFLED
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
// Sort cards
const sortFn = {
standard: this.object.sortStandard,
shuffled: this.object.sortShuffled
}[options?.sort || "standard"];
const cards = this.object.cards.contents.sort((a, b) => sortFn.call(this.object, a, b));
// Return rendering context
return foundry.utils.mergeObject(super.getData(options), {
cards: cards,
types: CONFIG.Cards.typeLabels,
inCompendium: !!this.object.pack
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
// Card Actions
html.find(".card-control").click(this._onCardControl.bind(this));
// Intersection Observer
const cards = html.find("ol.cards");
const entries = cards.find("li.card");
const observer = new IntersectionObserver(this._onLazyLoadImage.bind(this), {root: cards[0]});
entries.each((i, li) => observer.observe(li));
}
/* -------------------------------------------- */
/**
* Handle card control actions which modify single cards on the sheet.
* @param {PointerEvent} event The originating click event
* @returns {Promise} A Promise which resolves once the handler has completed
* @protected
*/
async _onCardControl(event) {
const button = event.currentTarget;
const li = button.closest(".card");
const card = li ? this.object.cards.get(li.dataset.cardId) : null;
const cls = getDocumentClass("Card");
// Save any pending change to the form
await this._onSubmit(event, {preventClose: true, preventRender: true});
// Handle the control action
switch ( button.dataset.action ) {
case "create":
return cls.createDialog({ faces: [{}], face: 0 }, {parent: this.object, pack: this.object.pack});
case "edit":
return card.sheet.render(true);
case "delete":
return card.deleteDialog();
case "deal":
return this.object.dealDialog();
case "draw":
return this.object.drawDialog();
case "pass":
return this.object.passDialog();
case "play":
return this.object.playDialog(card);
case "reset":
return this.object.resetDialog();
case "shuffle":
this.options.sort = this.constructor.SORT_TYPES.SHUFFLED;
return this.object.shuffle();
case "toggleSort":
this.options.sort = {standard: "shuffled", shuffled: "standard"}[this.options.sort];
return this.render();
case "nextFace":
return card.update({face: card.face === null ? 0 : card.face+1});
case "prevFace":
return card.update({face: card.face === 0 ? null : card.face-1});
}
}
/* -------------------------------------------- */
/**
* Handle lazy-loading card face images.
* See {@link SidebarTab#_onLazyLoadImage}
* @param {IntersectionObserverEntry[]} entries The entries which are now in the observer frame
* @param {IntersectionObserver} observer The intersection observer instance
* @protected
*/
_onLazyLoadImage(entries, observer) {
return ui.cards._onLazyLoadImage.call(this, entries, observer);
}
/* -------------------------------------------- */
/** @inheritdoc */
_canDragStart(selector) {
return this.isEditable;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragStart(event) {
const li = event.currentTarget;
const card = this.object.cards.get(li.dataset.cardId);
if ( !card ) return;
// Set data transfer
event.dataTransfer.setData("text/plain", JSON.stringify(card.toDragData()));
}
/* -------------------------------------------- */
/** @inheritdoc */
_canDragDrop(selector) {
return this.isEditable;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDrop(event) {
const data = TextEditor.getDragEventData(event);
if ( data.type !== "Card" ) return;
const card = await Card.implementation.fromDropData(data);
if ( card.parent.id === this.object.id ) return this._onSortCard(event, card);
try {
return await card.pass(this.object);
} catch(err) {
Hooks.onError("CardsConfig#_onDrop", err, {log: "error", notify: "error"});
}
}
/* -------------------------------------------- */
/**
* Handle sorting a Card relative to other siblings within this document
* @param {Event} event The drag drop event
* @param {Card} card The card being dragged
* @private
*/
_onSortCard(event, card) {
// Identify a specific card as the drop target
let target = null;
const li = event.target.closest("[data-card-id]");
if ( li ) target = this.object.cards.get(li.dataset.cardId) ?? null;
// Identify the set of siblings
const siblings = this.object.cards.filter(c => c.id !== card.id);
// Perform an integer-based sort
const updateData = SortingHelpers.performIntegerSort(card, {target, siblings}).map(u => {
return {_id: u.target.id, sort: u.update.sort};
});
return this.object.updateEmbeddedDocuments("Card", updateData);
}
}
/**
* A subclass of CardsConfig which provides a sheet representation for Cards documents with the "hand" type.
*/
class CardsHand extends CardsConfig {
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/cards/cards-hand.html"
});
}
}
/**
* A subclass of CardsConfig which provides a sheet representation for Cards documents with the "pile" type.
*/
class CardsPile extends CardsConfig {
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/cards/cards-pile.html"
});
}
}
/**
* The Application responsible for configuring the CombatTracker and its contents.
* @extends {FormApplication}
*/
class CombatTrackerConfig extends FormApplication {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "combat-config",
title: game.i18n.localize("COMBAT.Settings"),
classes: ["sheet", "combat-sheet"],
template: "templates/sheets/combat-config.html",
width: 420
});
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const attributes = TokenDocument.implementation.getTrackedAttributes();
attributes.bar.forEach(a => a.push("value"));
const combatThemeSetting = game.settings.settings.get("core.combatTheme");
return {
settings: game.settings.get("core", Combat.CONFIG_SETTING),
attributeChoices: TokenDocument.implementation.getTrackedAttributeChoices(attributes),
combatTheme: combatThemeSetting,
selectedTheme: game.settings.get("core", "combatTheme"),
user: game.user
};
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
game.settings.set("core", "combatTheme", formData.combatTheme);
if ( !game.user.isGM ) return;
return game.settings.set("core", Combat.CONFIG_SETTING, {
resource: formData.resource,
skipDefeated: formData.skipDefeated
});
}
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find(".audio-preview").click(this.#onAudioPreview.bind(this));
}
/* -------------------------------------------- */
#audioPreviewState = 0;
/**
* Handle previewing a sound file for a Combat Tracker setting
* @param {Event} event The initial button click event
* @private
*/
#onAudioPreview(event) {
const themeName = this.form.combatTheme.value;
const theme = CONFIG.Combat.sounds[themeName];
if ( !theme || theme === "none" ) return;
const announcements = CONST.COMBAT_ANNOUNCEMENTS;
const announcement = announcements[this.#audioPreviewState++ % announcements.length];
const sounds = theme[announcement];
if ( !sounds ) return;
const src = sounds[Math.floor(Math.random() * sounds.length)];
const volume = game.settings.get("core", "globalInterfaceVolume");
game.audio.play(src, {volume});
}
/* -------------------------------------------- */
/** @override */
async _onChangeInput(event) {
if ( event.currentTarget.name === "combatTheme" ) this.#audioPreviewState = 0;
return super._onChangeInput(event);
}
}
/**
* The Application responsible for configuring a single Combatant document within a parent Combat.
* @extends {DocumentSheet}
*/
class CombatantConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "combatant-config",
title: game.i18n.localize("COMBAT.CombatantConfig"),
classes: ["sheet", "combat-sheet"],
template: "templates/sheets/combatant-config.html",
width: 420
});
}
/* -------------------------------------------- */
/** @override */
get title() {
return game.i18n.localize(this.object.id ? "COMBAT.CombatantUpdate" : "COMBAT.CombatantCreate");
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
if ( this.object.id ) return this.object.update(formData);
else {
const cls = getDocumentClass("Combatant");
return cls.create(formData, {parent: game.combat});
}
}
}
/**
* An Application responsible for allowing GMs to configure the default sheets that are used for the Documents in their
* world.
*/
class DefaultSheetsConfig extends PackageConfiguration {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("SETTINGS.DefaultSheetsL"),
id: "default-sheets-config",
categoryTemplate: "templates/sidebar/apps/default-sheets-config.html",
submitButton: true
});
}
/* -------------------------------------------- */
/** @inheritdoc */
_prepareCategoryData() {
let total = 0;
const categories = [];
for ( const cls of Object.values(foundry.documents) ) {
const documentName = cls.documentName;
if ( !cls.hasTypeData ) continue;
const subTypes = game.documentTypes[documentName].filter(t => t !== CONST.BASE_DOCUMENT_TYPE);
if ( !subTypes.length ) continue;
const title = game.i18n.localize(cls.metadata.labelPlural);
categories.push({
title,
id: documentName,
count: subTypes.length,
subTypes: subTypes.map(t => {
const typeLabel = CONFIG[documentName].typeLabels?.[t];
const name = typeLabel ? game.i18n.localize(typeLabel) : t;
const {defaultClasses, defaultClass} = DocumentSheetConfig.getSheetClassesForSubType(documentName, t);
return {type: t, name, defaultClasses, defaultClass};
})
});
total += subTypes.length;
}
return {categories, total};
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
const settings = Object.entries(formData).reduce((obj, [name, sheetId]) => {
const [documentName, ...rest] = name.split(".");
const subType = rest.join(".");
const cfg = CONFIG[documentName].sheetClasses?.[subType]?.[sheetId];
// Do not create an entry in the settings object if the class is already the default.
if ( cfg?.default ) return obj;
const entry = obj[documentName] ??= {};
entry[subType] = sheetId;
return obj;
}, {});
return game.settings.set("core", "sheetClasses", settings);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onResetDefaults(event) {
event.preventDefault();
await game.settings.set("core", "sheetClasses", {});
return SettingsConfig.reloadConfirm({world: true});
}
}
/**
* The Application responsible for configuring a single ActiveEffect document within a parent Actor or Item.
* @extends {DocumentSheet}
*
* @param {ActiveEffect} object The target active effect being configured
* @param {DocumentSheetOptions} [options] Additional options which modify this application instance
*/
class ActiveEffectConfig extends DocumentSheet {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "active-effect-sheet"],
template: "templates/sheets/active-effect-config.html",
width: 580,
height: "auto",
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "details"}]
});
}
/* ----------------------------------------- */
/** @override */
async getData(options={}) {
const context = await super.getData(options);
context.descriptionHTML = await TextEditor.enrichHTML(this.object.description,
{async: true, secrets: this.object.isOwner});
const legacyTransfer = CONFIG.ActiveEffect.legacyTransferral;
const labels = {
transfer: {
name: game.i18n.localize(`EFFECT.Transfer${legacyTransfer ? "Legacy" : ""}`),
hint: game.i18n.localize(`EFFECT.TransferHint${legacyTransfer ? "Legacy" : ""}`)
}
};
const data = {
labels,
effect: this.object, // Backwards compatibility
data: this.object,
isActorEffect: this.object.parent.documentName === "Actor",
isItemEffect: this.object.parent.documentName === "Item",
submitText: "EFFECT.Submit",
modes: Object.entries(CONST.ACTIVE_EFFECT_MODES).reduce((obj, e) => {
obj[e[1]] = game.i18n.localize(`EFFECT.MODE_${e[0]}`);
return obj;
}, {})
};
return foundry.utils.mergeObject(context, data);
}
/* ----------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find(".effect-control").click(this._onEffectControl.bind(this));
}
/* ----------------------------------------- */
/**
* Provide centralized handling of mouse clicks on control buttons.
* Delegate responsibility out to action-specific handlers depending on the button action.
* @param {MouseEvent} event The originating click event
* @private
*/
_onEffectControl(event) {
event.preventDefault();
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "add":
return this._addEffectChange();
case "delete":
button.closest(".effect-change").remove();
return this.submit({preventClose: true}).then(() => this.render());
}
}
/* ----------------------------------------- */
/**
* Handle adding a new change to the changes array.
* @private
*/
async _addEffectChange() {
const idx = this.document.changes.length;
return this.submit({preventClose: true, updateData: {
[`changes.${idx}`]: {key: "", mode: CONST.ACTIVE_EFFECT_MODES.ADD, value: ""}
}});
}
/* ----------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData={}) {
const fd = new FormDataExtended(this.form, {editors: this.editors});
let data = foundry.utils.expandObject(fd.object);
if ( updateData ) foundry.utils.mergeObject(data, updateData);
data.changes = Array.from(Object.values(data.changes || {}));
return data;
}
}
/**
* The Application responsible for configuring a single Folder document.
* @extends {DocumentSheet}
* @param {Folder} object The {@link Folder} object to configure.
* @param {DocumentSheetOptions} [options] Application configuration options.
*/
class FolderConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "folder-edit"],
template: "templates/sidebar/folder-edit.html",
width: 360
});
}
/* -------------------------------------------- */
/** @override */
get id() {
return this.object.id ? super.id : "folder-create";
}
/* -------------------------------------------- */
/** @override */
get title() {
if ( this.object.id ) return `${game.i18n.localize("FOLDER.Update")}: ${this.object.name}`;
return game.i18n.localize("FOLDER.Create");
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
if ( !this.options.submitOnClose ) this.options.resolve?.(null);
return super.close(options);
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const folder = this.document.toObject();
const label = game.i18n.localize(Folder.implementation.metadata.label);
return {
folder: folder,
name: folder._id ? folder.name : "",
newName: game.i18n.format("DOCUMENT.New", {type: label}),
safeColor: folder.color ?? "#000000",
sortingModes: {a: "FOLDER.SortAlphabetical", m: "FOLDER.SortManual"},
submitText: game.i18n.localize(folder._id ? "FOLDER.Update" : "FOLDER.Create")
};
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
let doc = this.object;
if ( !formData.name?.trim() ) formData.name = Folder.implementation.defaultName();
if ( this.object.id ) await this.object.update(formData);
else {
this.object.updateSource(formData);
doc = await Folder.create(this.object, { pack: this.object.pack });
}
this.options.resolve?.(doc);
return doc;
}
}
/**
* @typedef {object} NewFontDefinition
* @property {string} [family] The font family.
* @property {number} [weight=400] The font weight.
* @property {string} [style="normal"] The font style.
* @property {string} [src=""] The font file.
* @property {string} [preview] The text to preview the font.
*/
/**
* A class responsible for configuring custom fonts for the world.
* @extends {FormApplication}
*/
class FontConfig extends FormApplication {
/**
* An application for configuring custom world fonts.
* @param {NewFontDefinition} [object] The default settings for new font definition creation.
* @param {object} [options] Additional options to configure behaviour.
*/
constructor(object={}, options={}) {
foundry.utils.mergeObject(object, {
family: "",
weight: 400,
style: "normal",
src: "",
preview: game.i18n.localize("FONTS.FontPreview"),
type: FontConfig.FONT_TYPES.FILE
});
super(object, options);
}
/* -------------------------------------------- */
/**
* Whether fonts have been modified since opening the application.
* @type {boolean}
*/
#fontsModified = false;
/* -------------------------------------------- */
/**
* The currently selected font.
* @type {{family: string, index: number}|null}
*/
#selected = null;
/* -------------------------------------------- */
/**
* Whether the given font is currently selected.
* @param {{family: string, index: number}} selection The font selection information.
* @returns {boolean}
*/
#isSelected({family, index}) {
if ( !this.#selected ) return false;
return (family === this.#selected.family) && (index === this.#selected.index);
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("SETTINGS.FontConfigL"),
id: "font-config",
template: "templates/sidebar/apps/font-config.html",
popOut: true,
width: 600,
height: "auto",
closeOnSubmit: false,
submitOnChange: true
});
}
/* -------------------------------------------- */
/**
* Whether a font is distributed to connected clients or found on their OS.
* @enum {string}
*/
static FONT_TYPES = {
FILE: "file",
SYSTEM: "system"
};
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const definitions = game.settings.get("core", this.constructor.SETTING);
const fonts = Object.entries(definitions).flatMap(([family, definition]) => {
return this._getDataForDefinition(family, definition);
});
let selected;
if ( (this.#selected === null) && fonts.length ) {
fonts[0].selected = true;
this.#selected = {family: fonts[0].family, index: fonts[0].index};
}
if ( fonts.length ) selected = definitions[this.#selected.family].fonts[this.#selected.index];
return {
fonts, selected,
font: this.object,
family: this.#selected?.family,
weights: Object.entries(CONST.FONT_WEIGHTS).map(([k, v]) => ({value: v, label: `${k} ${v}`}))
};
}
/* -------------------------------------------- */
/**
* Template data for a given font definition.
* @param {string} family The font family.
* @param {FontFamilyDefinition} definition The font family definition.
* @returns {object[]}
* @protected
*/
_getDataForDefinition(family, definition) {
const fonts = definition.fonts.length ? definition.fonts : [{}];
return fonts.map((f, i) => {
const data = {family, index: i};
if ( this.#isSelected(data) ) data.selected = true;
data.font = this.constructor._formatFont(family, f);
return data;
});
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("[contenteditable]").on("blur", this._onSubmit.bind(this));
html.find(".control").on("click", this._onClickControl.bind(this));
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
foundry.utils.mergeObject(this.object, formData);
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
await super.close(options);
if ( this.#fontsModified ) return SettingsConfig.reloadConfirm({world: true});
}
/* -------------------------------------------- */
/**
* Handle application controls.
* @param {MouseEvent} event The click event.
* @protected
*/
_onClickControl(event) {
switch ( event.currentTarget.dataset.action ) {
case "add": return this._onAddFont();
case "delete": return this._onDeleteFont(event);
case "select": return this._onSelectFont(event);
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onChangeInput(event) {
this._updateFontFields();
return super._onChangeInput(event);
}
/* -------------------------------------------- */
/**
* Update available font fields based on the font type selected.
* @protected
*/
_updateFontFields() {
const type = this.form.elements.type.value;
const isSystemFont = type === this.constructor.FONT_TYPES.SYSTEM;
["weight", "style", "src"].forEach(name => {
const input = this.form.elements[name];
if ( input ) input.closest(".form-group")?.classList.toggle("hidden", isSystemFont);
});
this.setPosition();
}
/* -------------------------------------------- */
/**
* Add a new custom font definition.
* @protected
*/
async _onAddFont() {
const {family, src, weight, style, type} = this._getSubmitData();
const definitions = game.settings.get("core", this.constructor.SETTING);
definitions[family] ??= {editor: true, fonts: []};
const definition = definitions[family];
const count = type === this.constructor.FONT_TYPES.FILE ? definition.fonts.push({urls: [src], weight, style}) : 1;
await game.settings.set("core", this.constructor.SETTING, definitions);
await this.constructor.loadFont(family, definition);
this.#selected = {family, index: count - 1};
this.#fontsModified = true;
this.render(true);
}
/* -------------------------------------------- */
/**
* Delete a font.
* @param {MouseEvent} event The click event.
* @protected
*/
async _onDeleteFont(event) {
event.preventDefault();
event.stopPropagation();
const target = event.currentTarget.closest("[data-family]");
const {family, index} = target.dataset;
const definitions = game.settings.get("core", this.constructor.SETTING);
const definition = definitions[family];
if ( !definition ) return;
this.#fontsModified = true;
definition.fonts.splice(Number(index), 1);
if ( !definition.fonts.length ) delete definitions[family];
await game.settings.set("core", this.constructor.SETTING, definitions);
if ( this.#isSelected({family, index: Number(index)}) ) this.#selected = null;
this.render(true);
}
/* -------------------------------------------- */
/**
* Select a font to preview.
* @param {MouseEvent} event The click event.
* @protected
*/
_onSelectFont(event) {
const {family, index} = event.currentTarget.dataset;
this.#selected = {family, index: Number(index)};
this.render(true);
}
/* -------------------------------------------- */
/* Font Management Methods */
/* -------------------------------------------- */
/**
* Define the setting key where this world's font information will be stored.
* @type {string}
*/
static SETTING = "fonts";
/* -------------------------------------------- */
/**
* A list of fonts that were correctly loaded and are available for use.
* @type {Set<string>}
* @private
*/
static #available = new Set();
/* -------------------------------------------- */
/**
* Get the list of fonts that successfully loaded.
* @returns {string[]}
*/
static getAvailableFonts() {
return Array.from(this.#available);
}
/* -------------------------------------------- */
/**
* Get the list of fonts formatted for display with selectOptions.
* @returns {Object<string>}
*/
static getAvailableFontChoices() {
return this.getAvailableFonts().reduce((obj, f) => {
obj[f] = f;
return obj;
}, {});
}
/* -------------------------------------------- */
/**
* Load a font definition.
* @param {string} family The font family name (case-sensitive).
* @param {FontFamilyDefinition} definition The font family definition.
* @returns {Promise<boolean>} Returns true if the font was successfully loaded.
*/
static async loadFont(family, definition) {
const font = `1rem "${family}"`;
try {
for ( const font of definition.fonts ) {
const fontFace = this._createFontFace(family, font);
await fontFace.load();
document.fonts.add(fontFace);
}
await document.fonts.load(font);
} catch(err) {
console.warn(`Font family "${family}" failed to load: `, err);
return false;
}
if ( !document.fonts.check(font) ) {
console.warn(`Font family "${family}" failed to load.`);
return false;
}
if ( definition.editor ) this.#available.add(family);
return true;
}
/* -------------------------------------------- */
/**
* Ensure that fonts have loaded and are ready for use.
* Enforce a maximum timeout in milliseconds.
* Proceed after that point even if fonts are not yet available.
* @param {number} [ms=4500] The maximum time to spend loading fonts before proceeding.
* @returns {Promise<void>}
* @internal
*/
static async _loadFonts(ms=4500) {
const allFonts = this._collectDefinitions();
const promises = [];
for ( const definitions of allFonts ) {
for ( const [family, definition] of Object.entries(definitions) ) {
promises.push(this.loadFont(family, definition));
}
}
const timeout = new Promise(resolve => setTimeout(resolve, ms));
const ready = Promise.all(promises).then(() => document.fonts.ready);
return Promise.race([ready, timeout]).then(() => console.log(`${vtt} | Fonts loaded and ready.`));
}
/* -------------------------------------------- */
/**
* Collect all the font definitions and combine them.
* @returns {Object<FontFamilyDefinition>[]}
* @protected
*/
static _collectDefinitions() {
/**
* @deprecated since v10.
*/
const legacyFamilies = CONFIG._fontFamilies.reduce((obj, f) => {
obj[f] = {editor: true, fonts: []};
return obj;
}, {});
return [CONFIG.fontDefinitions, game.settings.get("core", this.SETTING), legacyFamilies];
}
/* -------------------------------------------- */
/**
* Create FontFace object from a FontDefinition.
* @param {string} family The font family name.
* @param {FontDefinition} font The font definition.
* @returns {FontFace}
* @protected
*/
static _createFontFace(family, font) {
const urls = font.urls.map(url => `url("${url}")`).join(", ");
return new FontFace(family, urls, font);
}
/* -------------------------------------------- */
/**
* Format a font definition for display.
* @param {string} family The font family.
* @param {FontDefinition} definition The font definition.
* @returns {string} The formatted definition.
* @private
*/
static _formatFont(family, definition) {
if ( foundry.utils.isEmpty(definition) ) return family;
const {weight, style} = definition;
const byWeight = Object.fromEntries(Object.entries(CONST.FONT_WEIGHTS).map(([k, v]) => [v, k]));
return `
${family},
<span style="font-weight: ${weight}">${byWeight[weight]} ${weight}</span>,
<span style="font-style: ${style}">${style.toLowerCase()}</span>
`;
}
}
/**
* A tool for fine-tuning the grid in a Scene
* @param {Scene} scene The scene whose grid is being configured.
* @param {SceneConfig} sheet The Scene Configuration sheet that spawned this dialog.
* @param {FormApplicationOptions} [options] Application configuration options.
*/
class GridConfig extends FormApplication {
constructor(scene, sheet, ...args) {
super(scene, ...args);
/**
* Track the Scene Configuration sheet reference
* @type {SceneConfig}
*/
this.sheet = sheet;
}
/**
* The counter-factual dimensions being evaluated
* @type {Object}
*/
#dimensions = {};
/**
* A copy of the Scene source which can be restored when the configuration is closed.
* @type {object}
*/
#original;
/**
* A reference to the bound key handler function
* @type {Function}
* @private
*/
#keyHandler;
/**
* A reference to the bound mousewheel handler function
* @type {Function}
* @private
*/
#wheelHandler;
/**
* Saved visibility for some layers
* @type {object}
*/
#layersOriginalVisibility;
/**
* If we should redraw when closing this window
* @type {boolean}
*/
#redrawOnClose = false;
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "grid-config",
template: "templates/scene/grid-config.html",
title: game.i18n.localize("SCENES.GridConfigTool"),
width: 480,
height: "auto",
closeOnSubmit: true
});
}
/* -------------------------------------------- */
/** @override */
async _render(force, options) {
if ( !this.rendered ) this.#original = this.object.toObject();
await super._render(force, options);
if ( !this.object.background.src ) {
ui.notifications.warn("WARNING.GridConfigNoBG", {localize: true});
}
this.#layersOriginalVisibility = {};
for ( const layer of canvas.layers ) {
this.#layersOriginalVisibility[layer.name] = layer.visible;
layer.visible = ["GridLayer", "TilesLayer"].includes(layer.name);
}
this._refresh({
background: true,
grid: {color: 0xFF0000, alpha: 1.0}
});
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const tex = canvas.primary.background.texture;
return {
gridTypes: SceneConfig._getGridTypes(),
scale: tex ? this.object.width / tex.width : 1,
scene: this.object
};
}
/* -------------------------------------------- */
/** @override */
_getSubmitData(updateData = {}) {
const formData = super._getSubmitData(updateData);
const bg = canvas.primary.background;
const tex = bg ? bg.texture : {width: this.object.width, height: this.object.height};
formData.width = tex.width * formData.scale;
formData.height = tex.height * formData.scale;
return formData;
}
/* -------------------------------------------- */
/** @override */
async close(options={}) {
document.removeEventListener("keydown", this.#keyHandler);
document.removeEventListener("wheel", this.#wheelHandler);
this.#keyHandler = this.#wheelHandler = undefined;
await this.sheet.maximize();
// Restore layers original visibility
for ( const layer of canvas.layers ) {
layer.visible = this.#layersOriginalVisibility[layer.name];
}
if ( !options.force ) await this._reset();
return super.close(options);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
this.#keyHandler ||= this._onKeyDown.bind(this);
document.addEventListener("keydown", this.#keyHandler);
this.#wheelHandler ||= this._onWheel.bind(this);
document.addEventListener("wheel", this.#wheelHandler, {passive: false});
html.find('button[name="reset"]').click(this._reset.bind(this));
}
/* -------------------------------------------- */
/**
* Handle keyboard events.
* @param {KeyboardEvent} event The original keydown event
* @private
*/
_onKeyDown(event) {
const key = event.code;
const up = ["KeyW", "ArrowUp"];
const down = ["KeyS", "ArrowDown"];
const left = ["KeyA", "ArrowLeft"];
const right = ["KeyD", "ArrowRight"];
const moveKeys = up.concat(down).concat(left).concat(right);
if ( !moveKeys.includes(key) ) return;
// Increase the Scene scale on shift + up or down
if ( event.shiftKey ) {
event.preventDefault();
event.stopPropagation();
let delta = up.includes(key) ? 1 : (down.includes(key) ? -1 : 0);
this._scaleBackgroundSize(delta);
}
// Resize grid size on ALT
else if ( event.altKey ) {
event.preventDefault();
event.stopPropagation();
let delta = up.includes(key) ? 1 : (down.includes(key) ? -1 : 0);
this._scaleGridSize(delta);
}
// Shift grid position
else if ( !game.keyboard.hasFocus ) {
event.preventDefault();
event.stopPropagation();
if ( up.includes(key) ) this._shiftBackground({deltaY: -1});
else if ( down.includes(key) ) this._shiftBackground({deltaY: 1});
else if ( left.includes(key) ) this._shiftBackground({deltaX: -1});
else if ( right.includes(key) ) this._shiftBackground({deltaX: 1});
}
}
/* -------------------------------------------- */
/**
* Handle mousewheel events.
* @param {WheelEvent} event The original wheel event
* @private
*/
_onWheel(event) {
if ( event.deltaY === 0 ) return;
const normalizedDelta = -Math.sign(event.deltaY);
const activeElement = document.activeElement;
const noShiftAndAlt = !(event.shiftKey || event.altKey);
const focus = game.keyboard.hasFocus && document.hasFocus;
// Increase/Decrease the Scene scale
if ( event.shiftKey || (!event.altKey && focus && activeElement.name === "scale") ) {
event.preventDefault();
event.stopImmediatePropagation();
this._scaleBackgroundSize(normalizedDelta);
}
// Increase/Decrease the Grid scale
else if ( event.altKey || (focus && activeElement.name === "grid.size") ) {
event.preventDefault();
event.stopImmediatePropagation();
this._scaleGridSize(normalizedDelta);
}
// If no shift or alt key are pressed
else if ( noShiftAndAlt && focus ) {
// Increase/Decrease the background x offset
if ( activeElement.name === "background.offsetX" ) {
event.preventDefault();
event.stopImmediatePropagation();
this._shiftBackground({deltaX: normalizedDelta});
}
// Increase/Decrease the background y offset
else if ( activeElement.name === "background.offsetY" ) {
event.preventDefault();
event.stopImmediatePropagation();
this._shiftBackground({deltaY: normalizedDelta});
}
}
}
/* -------------------------------------------- */
/** @override */
async _onChangeInput(event) {
event.preventDefault();
const formData = this._getSubmitData();
const {type, size} = this.object.grid;
this.object.updateSource(formData);
if ( (this.object.grid.type !== type) || (this.object.grid.size !== size) ) {
this.#redrawOnClose = true;
canvas.grid.grid.destroy(true);
await canvas.grid._draw({
type: this.object.grid.type,
dimensions: this.object.getDimensions()
});
}
this._refresh({
background: true,
grid: foundry.utils.mergeObject(this.object.grid, {color: 0xFF0000, alpha: 1.0})
});
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
this.object.updateSource(this.#original);
formData.width = Math.round(this.#dimensions.sceneWidth);
formData.height = Math.round(this.#dimensions.sceneHeight);
const delta = foundry.utils.diffObject(foundry.utils.flattenObject(this.object), formData);
if ( ["width", "height", "padding", "background.offsetX", "background.offsetY", "grid.size", "grid.type"].some(k => k in delta) ) {
const confirm = await Dialog.confirm({
title: game.i18n.localize("SCENES.DimensionChangeTitle"),
content: `<p>${game.i18n.localize("SCENES.DimensionChangeWarning")}</p>`
});
// Update only if the dialog is confirmed
if ( confirm ) return await this.object.update(formData, {fromSheet: true});
}
// We need to reset if the dialog was not confirmed OR if we don't need to update
return await this._reset();
}
/* -------------------------------------------- */
/* Previewing and Updating Functions */
/* -------------------------------------------- */
/**
* Temporarily refresh the display of the BackgroundLayer and GridLayer for the new pending dimensions
* @param {object} options Options which define how the refresh is performed
* @param {boolean} [options.background] Refresh the background display?
* @param {object} [options.grid] Refresh the grid display?
* @private
*/
_refresh({background=false, grid}) {
const bg = canvas.primary.background;
const fg = canvas.primary.foreground;
const d = this.#dimensions = this.object.getDimensions();
// Update the background and foreground sizing
if ( background && bg ) {
bg.position.set(d.sceneX, d.sceneY);
bg.width = d.sceneWidth;
bg.height = d.sceneHeight;
grid ||= {};
}
if ( background && fg ) {
fg.position.set(d.sceneX, d.sceneY);
fg.width = d.sceneWidth;
fg.height = d.sceneHeight;
}
// Update the grid layer
if ( grid ) {
const {type, color, alpha} = {...this.object.grid, ...grid};
canvas.grid.grid.draw({dimensions: d, type, color: Color.from(color).valueOf(), alpha});
canvas.stage.hitArea = d.rect;
}
}
/* -------------------------------------------- */
/**
* Reset the scene back to its original settings
* @private
*/
async _reset() {
this.object.updateSource(this.#original);
if ( this.#redrawOnClose ) {
this.#redrawOnClose = false;
await canvas.draw();
}
return this._refresh({background: true, grid: this.object.grid});
}
/* -------------------------------------------- */
/**
* Scale the background size relative to the grid size
* @param {number} delta The directional change in background size
* @private
*/
_scaleBackgroundSize(delta) {
const scale = Math.round((parseFloat(this.form.scale.value) + delta * 0.001) * 1000) / 1000;
this.form.scale.value = Math.clamped(scale, 0.25, 10.0);
this.form.scale.dispatchEvent(new Event("change", {bubbles: true}));
}
/* -------------------------------------------- */
/**
* Scale the grid size relative to the background image.
* When scaling the grid size in this way, constrain the allowed values between 50px and 300px.
* @param {number} delta The grid size in pixels
* @private
*/
_scaleGridSize(delta) {
const gridSize = this.form.elements["grid.size"];
gridSize.value = Math.clamped(gridSize.valueAsNumber + delta, 50, 300);
gridSize.dispatchEvent(new Event("change", {bubbles: true}));
}
/* -------------------------------------------- */
/**
* Shift the background image relative to the grid layer
* @param {object} position The position configuration to preview
* @param {number} position.deltaX The number of pixels to shift in the x-direction
* @param {number} position.deltaY The number of pixels to shift in the y-direction
* @private
*/
_shiftBackground({deltaX=0, deltaY=0}={}) {
const ox = this.form["background.offsetX"];
ox.value = parseInt(this.form["background.offsetX"].value) + deltaX;
this.form["background.offsetY"].value = parseInt(this.form["background.offsetY"].value) + deltaY;
ox.dispatchEvent(new Event("change", {bubbles: true}));
}
}
/**
* @typedef {FormApplicationOptions} ImagePopoutOptions
* @property {string} [caption] Caption text to display below the image.
* @property {string|null} [uuid=null] The UUID of some related {@link Document}.
* @property {boolean} [showTitle] Force showing or hiding the title.
*/
/**
* An Image Popout Application which features a single image in a lightbox style frame.
* Furthermore, this application allows for sharing the display of an image with other connected players.
* @param {string} src The image URL.
* @param {ImagePopoutOptions} [options] Application configuration options.
*
* @example Creating an Image Popout
* ```js
* // Construct the Application instance
* const ip = new ImagePopout("path/to/image.jpg", {
* title: "My Featured Image",
* uuid: game.actors.getName("My Hero").uuid
* });
*
* // Display the image popout
* ip.render(true);
*
* // Share the image with other connected players
* ip.share();
* ```
*/
class ImagePopout extends FormApplication {
/**
* A cached reference to the related Document.
* @type {ClientDocument}
*/
#related;
/* -------------------------------------------- */
/**
* Whether the application should display video content.
* @type {boolean}
*/
get isVideo() {
return VideoHelper.hasVideoExtension(this.object);
}
/* -------------------------------------------- */
/**
* @override
* @returns {ImagePopoutOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/apps/image-popout.html",
classes: ["image-popout", "dark"],
resizable: true,
caption: undefined,
uuid: null
});
}
/* -------------------------------------------- */
/** @override */
get title() {
return this.isTitleVisible() ? super.title : "";
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
return {
image: this.object,
options: this.options,
title: this.title,
caption: this.options.caption,
showTitle: this.isTitleVisible(),
isVideo: this.isVideo
};
}
/* -------------------------------------------- */
/**
* Test whether the title of the image popout should be visible to the user
* @returns {boolean}
*/
isTitleVisible() {
return this.options.showTitle ?? this.#related?.testUserPermission(game.user, "LIMITED") ?? true;
}
/* -------------------------------------------- */
/**
* Provide a reference to the Document referenced by this popout, if one exists
* @returns {Promise<ClientDocument>}
*/
async getRelatedObject() {
if ( this.options.uuid && !this.#related ) this.#related = await fromUuid(this.options.uuid);
return this.#related;
}
/* -------------------------------------------- */
/** @override */
async _render(...args) {
await this.getRelatedObject();
this.position = await this.constructor.getPosition(this.object);
return super._render(...args);
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
// For some reason, unless we do this, videos will not autoplay the first time the popup is opened in a session,
// even if the user has made a gesture.
if ( this.isVideo ) html.find("video")[0]?.play();
}
/* -------------------------------------------- */
/** @override */
_getHeaderButtons() {
const buttons = super._getHeaderButtons();
if ( game.user.isGM ) {
buttons.unshift({
label: "JOURNAL.ActionShow",
class: "share-image",
icon: "fas fa-eye",
onclick: () => this.shareImage()
});
}
return buttons;
}
/* -------------------------------------------- */
/* Helper Methods
/* -------------------------------------------- */
/**
* Determine the correct position and dimensions for the displayed image
* @param {string} img The image URL.
* @returns {Object} The positioning object which should be used for rendering
*/
static async getPosition(img) {
if ( !img ) return { width: 480, height: 480 };
let w;
let h;
try {
[w, h] = this.isVideo ? await this.getVideoSize(img) : await this.getImageSize(img);
} catch(err) {
return { width: 480, height: 480 };
}
const position = {};
// Compare the image aspect ratio to the screen aspect ratio
const sr = window.innerWidth / window.innerHeight;
const ar = w / h;
// The image is constrained by the screen width, display at max width
if ( ar > sr ) {
position.width = Math.min(w * 2, window.innerWidth - 80);
position.height = position.width / ar;
}
// The image is constrained by the screen height, display at max height
else {
position.height = Math.min(h * 2, window.innerHeight - 120);
position.width = position.height * ar;
}
return position;
}
/* -------------------------------------------- */
/**
* Determine the Image dimensions given a certain path
* @param {string} path The image source.
* @returns {Promise<[number, number]>}
*/
static getImageSize(path) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = function() {
resolve([this.width, this.height]);
};
img.onerror = reject;
img.src = path;
});
}
/* -------------------------------------------- */
/**
* Determine the dimensions of the given video file.
* @param {string} src The URL to the video.
* @returns {Promise<[number, number]>}
*/
static getVideoSize(src) {
return new Promise((resolve, reject) => {
const video = document.createElement("video");
video.onloadedmetadata = () => {
video.onloadedmetadata = null;
resolve([video.videoWidth, video.videoHeight]);
};
video.onerror = reject;
video.src = src;
});
}
/* -------------------------------------------- */
/**
* @typedef {object} ShareImageConfig
* @property {string} image The image URL to share.
* @property {string} title The image title.
* @property {string} [uuid] The UUID of a Document related to the image, used to determine permission to see
* the image title.
* @property {boolean} [showTitle] If this is provided, the permissions of the related Document will be ignored and
* the title will be shown based on this parameter.
* @property {string[]} [users] A list of user IDs to show the image to.
*/
/**
* Share the displayed image with other connected Users
* @param {ShareImageConfig} [options]
*/
shareImage(options={}) {
options = foundry.utils.mergeObject(this.options, options, { inplace: false });
game.socket.emit("shareImage", {
image: this.object,
title: options.title,
caption: options.caption,
uuid: options.uuid,
showTitle: options.showTitle,
users: Array.isArray(options.users) ? options.users : undefined
});
ui.notifications.info(game.i18n.format("JOURNAL.ActionShowSuccess", {
mode: "image",
title: options.title,
which: "all"
}));
}
/* -------------------------------------------- */
/**
* Handle a received request to display an image.
* @param {ShareImageConfig} config The image configuration data.
* @returns {ImagePopout}
* @internal
*/
static _handleShareImage({image, title, caption, uuid, showTitle}={}) {
const ip = new ImagePopout(image, {title, caption, uuid, showTitle});
ip.render(true);
return ip;
}
}
/**
* The Application responsible for displaying and editing a single Item document.
* @param {Item} item The Item instance being displayed within the sheet.
* @param {DocumentSheetOptions} [options] Additional application configuration options.
*/
class ItemSheet extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/sheets/item-sheet.html",
width: 500,
closeOnSubmit: false,
submitOnClose: true,
submitOnChange: true,
resizable: true,
baseApplication: "ItemSheet",
id: "item",
secrets: [{parentSelector: ".editor"}]
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return this.item.name;
}
/* -------------------------------------------- */
/**
* A convenience reference to the Item document
* @type {Item}
*/
get item() {
return this.object;
}
/* -------------------------------------------- */
/**
* The Actor instance which owns this item. This may be null if the item is unowned.
* @type {Actor}
*/
get actor() {
return this.item.actor;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const data = super.getData(options);
data.item = data.document;
return data;
}
}
/**
* The Application responsible for displaying and editing a single JournalEntryPage document.
* @extends {DocumentSheet}
* @param {JournalEntryPage} object The JournalEntryPage instance which is being edited.
* @param {DocumentSheetOptions} [options] Application options.
*/
class JournalPageSheet extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "journal-sheet", "journal-entry-page"],
viewClasses: [],
width: 600,
height: 680,
resizable: true,
closeOnSubmit: false,
submitOnClose: true,
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
includeTOC: true
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get template() {
return `templates/journal/page-${this.document.type}-${this.isEditable ? "edit" : "view"}.html`;
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return this.object.permission ? this.object.name : "";
}
/* -------------------------------------------- */
/**
* The table of contents for this JournalTextPageSheet.
* @type {Object<JournalEntryPageHeading>}
*/
toc = {};
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
return foundry.utils.mergeObject(super.getData(options), {
headingLevels: Object.fromEntries(Array.fromRange(3, 1).map(level => {
return [level, game.i18n.format("JOURNALENTRYPAGE.Level", {level})];
}))
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async _renderInner(...args) {
await loadTemplates({
journalEntryPageHeader: "templates/journal/parts/page-header.html",
journalEntryPageFooter: "templates/journal/parts/page-footer.html"
});
const html = await super._renderInner(...args);
if ( this.options.includeTOC ) this.toc = JournalEntryPage.implementation.buildTOC(html.get());
return html;
}
/* -------------------------------------------- */
/* Text Secrets Management */
/* -------------------------------------------- */
/** @inheritdoc */
_getSecretContent(secret) {
return this.object.text.content;
}
/* -------------------------------------------- */
/** @inheritdoc */
_updateSecret(secret, content) {
return this.object.update({"text.content": content});
}
/* -------------------------------------------- */
/* Text Editor Integration */
/* -------------------------------------------- */
/** @inheritdoc */
async activateEditor(name, options={}, initialContent="") {
options.fitToSize = true;
options.relativeLinks = true;
const editor = await super.activateEditor(name, options, initialContent);
this.form.querySelector('[role="application"]')?.style.removeProperty("height");
return editor;
}
/* -------------------------------------------- */
/**
* Update the parent sheet if it is open when the server autosaves the contents of this editor.
* @param {string} html The updated editor contents.
*/
onAutosave(html) {
this.object.parent?.sheet?.render(false);
}
/* -------------------------------------------- */
/**
* Update the UI appropriately when receiving new steps from another client.
*/
onNewSteps() {
this.form.querySelectorAll('[data-action="save-html"]').forEach(el => el.disabled = true);
}
}
/**
* The Application responsible for displaying and editing a single JournalEntryPage text document.
* @extends {JournalPageSheet}
*/
class JournalTextPageSheet extends JournalPageSheet {
/**
* Bi-directional HTML <-> Markdown converter.
* @type {showdown.Converter}
* @protected
*/
static _converter = (() => {
Object.entries(CONST.SHOWDOWN_OPTIONS).forEach(([k, v]) => showdown.setOption(k, v));
return new showdown.Converter();
})();
/* -------------------------------------------- */
/**
* Declare the format that we edit text content in for this sheet so we can perform conversions as necessary.
* @type {number}
*/
static get format() {
return CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML;
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.classes.push("text");
options.secrets.push({parentSelector: "section"});
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
const data = super.getData(options);
this._convertFormats(data);
data.editor = {
engine: "prosemirror",
collaborate: true,
content: await TextEditor.enrichHTML(data.document.text.content, {
relativeTo: this.object,
secrets: this.object.isOwner,
async: true
})
};
return data;
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
Object.values(this.editors).forEach(ed => {
if ( ed.instance ) ed.instance.destroy();
});
return super.close(options);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
if ( !this.#canRender(options.resync) ) return this.maximize().then(() => this.bringToTop());
return super._render(force, options);
}
/* -------------------------------------------- */
/**
* Suppress re-rendering the sheet in cases where an active editor has unsaved work.
* In such cases we rely upon collaborative editing to save changes and re-render.
* @param {boolean} [resync] Was the application instructed to re-sync?
* @returns {boolean} Should a render operation be allowed?
*/
#canRender(resync) {
if ( resync || (this._state !== Application.RENDER_STATES.RENDERED) || !this.isEditable ) return true;
return !this.isEditorDirty();
}
/* -------------------------------------------- */
/**
* Determine if any editors are dirty.
* @returns {boolean}
*/
isEditorDirty() {
for ( const editor of Object.values(this.editors) ) {
if ( editor.active && editor.instance?.isDirty() ) return true;
}
return false;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
if ( (this.constructor.format === CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML) && this.isEditorDirty() ) {
// Clear any stored markdown so it can be re-converted.
formData["text.markdown"] = "";
formData["text.format"] = CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML;
}
return super._updateObject(event, formData);
}
/* -------------------------------------------- */
/**
* Lazily convert text formats if we detect the document being saved in a different format.
* @param {object} renderData Render data.
* @protected
*/
_convertFormats(renderData) {
const formats = CONST.JOURNAL_ENTRY_PAGE_FORMATS;
const text = this.object.text;
if ( (this.constructor.format === formats.MARKDOWN) && text.content?.length && !text.markdown?.length ) {
// We've opened an HTML document in a markdown editor, so we need to convert the HTML to markdown for editing.
renderData.data.text.markdown = this.constructor._converter.makeMarkdown(text.content.trim());
}
}
}
/* -------------------------------------------- */
/**
* The Application responsible for displaying and editing a single JournalEntryPage image document.
* @extends {JournalPageSheet}
*/
class JournalImagePageSheet extends JournalPageSheet {
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.classes.push("image");
options.height = "auto";
return options;
}
}
/* -------------------------------------------- */
/**
* The Application responsible for displaying and editing a single JournalEntryPage video document.
* @extends {JournalPageSheet}
*/
class JournalVideoPageSheet extends JournalPageSheet {
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.classes.push("video");
options.height = "auto";
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
return foundry.utils.mergeObject(super.getData(options), {
flexRatio: !this.object.video.width && !this.object.video.height,
isYouTube: game.video.isYouTubeURL(this.object.src),
timestamp: this._timestampToTimeComponents(this.object.video.timestamp),
yt: {
id: `youtube-${foundry.utils.randomID()}`,
url: game.video.getYouTubeEmbedURL(this.object.src, this._getYouTubeVars())
}
});
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
if ( this.isEditable ) return;
// The below listeners are only for when the video page is being viewed, not edited.
const iframe = html.find("iframe")[0];
if ( iframe ) game.video.getYouTubePlayer(iframe.id, {
events: {
onStateChange: event => {
if ( event.data === YT.PlayerState.PLAYING ) event.target.setVolume(this.object.video.volume * 100);
}
}
}).then(player => {
if ( this.object.video.timestamp ) player.seekTo(this.object.video.timestamp, true);
});
const video = html.parent().find("video")[0];
if ( video ) {
video.addEventListener("loadedmetadata", () => {
video.volume = this.object.video.volume;
if ( this.object.video.timestamp ) video.currentTime = this.object.video.timestamp;
});
}
}
/* -------------------------------------------- */
/**
* Get the YouTube player parameters depending on whether the sheet is being viewed or edited.
* @returns {object}
* @protected
*/
_getYouTubeVars() {
const vars = {playsinline: 1, modestbranding: 1};
if ( !this.isEditable ) {
vars.controls = this.object.video.controls ? 1 : 0;
vars.autoplay = this.object.video.autoplay ? 1 : 0;
vars.loop = this.object.video.loop ? 1 : 0;
if ( this.object.video.timestamp ) vars.start = this.object.video.timestamp;
}
return vars;
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData={}) {
const data = super._getSubmitData(updateData);
data["video.timestamp"] = this._timeComponentsToTimestamp(foundry.utils.expandObject(data).timestamp);
["h", "m", "s"].forEach(c => delete data[`timestamp.${c}`]);
return data;
}
/* -------------------------------------------- */
/**
* Convert time components to a timestamp in seconds.
* @param {{[h]: number, [m]: number, [s]: number}} components The time components.
* @returns {number} The timestamp, in seconds.
* @protected
*/
_timeComponentsToTimestamp({h=0, m=0, s=0}={}) {
return (h * 3600) + (m * 60) + s;
}
/* -------------------------------------------- */
/**
* Convert a timestamp in seconds into separate time components.
* @param {number} timestamp The timestamp, in seconds.
* @returns {{[h]: number, [m]: number, [s]: number}} The individual time components.
* @protected
*/
_timestampToTimeComponents(timestamp) {
if ( !timestamp ) return {};
const components = {};
const h = Math.floor(timestamp / 3600);
if ( h ) components.h = h;
const m = Math.floor((timestamp % 3600) / 60);
if ( m ) components.m = m;
components.s = timestamp - (h * 3600) - (m * 60);
return components;
}
}
/* -------------------------------------------- */
/**
* The Application responsible for displaying and editing a single JournalEntryPage PDF document.
* @extends {JournalPageSheet}
*/
class JournalPDFPageSheet extends JournalPageSheet {
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.classes.push("pdf");
options.height = "auto";
return options;
}
/**
* Maintain a cache of PDF sizes to avoid making HEAD requests every render.
* @type {Object<number>}
* @protected
*/
static _sizes = {};
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("> button").on("click", this._onLoadPDF.bind(this));
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
return foundry.utils.mergeObject(super.getData(options), {
params: this._getViewerParams()
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async _renderInner(...args) {
const html = await super._renderInner(...args);
const pdfLoader = html.closest(".load-pdf")[0];
if ( this.isEditable || !pdfLoader ) return html;
let size = this.constructor._sizes[this.object.src];
if ( size === undefined ) {
const res = await fetch(this.object.src, {method: "HEAD"}).catch(() => {});
this.constructor._sizes[this.object.src] = size = Number(res?.headers.get("content-length"));
}
if ( !isNaN(size) ) {
const mb = (size / 1024 / 1024).toFixed(2);
const span = document.createElement("span");
span.classList.add("hint");
span.textContent = ` (${mb} MB)`;
pdfLoader.querySelector("button").appendChild(span);
}
return html;
}
/* -------------------------------------------- */
/**
* Handle a request to load a PDF.
* @param {MouseEvent} event The triggering event.
* @protected
*/
_onLoadPDF(event) {
const target = event.currentTarget.parentElement;
const frame = document.createElement("iframe");
frame.src = `scripts/pdfjs/web/viewer.html?${this._getViewerParams()}`;
target.replaceWith(frame);
}
/* -------------------------------------------- */
/**
* Retrieve parameters to pass to the PDF viewer.
* @returns {URLSearchParams}
* @protected
*/
_getViewerParams() {
const params = new URLSearchParams();
if ( this.object.src ) {
const src = URL.parseSafe(this.object.src) ? this.object.src : foundry.utils.getRoute(this.object.src);
params.append("file", src);
}
return params;
}
}
/**
* A subclass of {@link JournalTextPageSheet} that implements a markdown editor for editing the text content.
* @extends {JournalTextPageSheet}
*/
class MarkdownJournalPageSheet extends JournalTextPageSheet {
/**
* Store the dirty flag for this editor.
* @type {boolean}
* @protected
*/
_isDirty = false;
/* -------------------------------------------- */
/** @inheritdoc */
static get format() {
return CONST.JOURNAL_ENTRY_PAGE_FORMATS.MARKDOWN;
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.dragDrop = [{dropSelector: "textarea"}];
options.classes.push("markdown");
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
get template() {
if ( this.isEditable ) return "templates/journal/page-markdown-edit.html";
return super.template;
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
const data = await super.getData(options);
data.markdownFormat = CONST.JOURNAL_ENTRY_PAGE_FORMATS.MARKDOWN;
return data;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("textarea").on("keypress paste", () => this._isDirty = true);
}
/* -------------------------------------------- */
/** @inheritdoc */
isEditorDirty() {
return this._isDirty;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
// Do not persist the markdown conversion if the contents have not been edited.
if ( !this.isEditorDirty() ) {
delete formData["text.markdown"];
delete formData["text.format"];
}
return super._updateObject(event, formData);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDrop(event) {
event.preventDefault();
const eventData = TextEditor.getDragEventData(event);
return this._onDropContentLink(eventData);
}
/* -------------------------------------------- */
/**
* Handle dropping a content link onto the editor.
* @param {object} eventData The parsed event data.
* @protected
*/
async _onDropContentLink(eventData) {
const link = await TextEditor.getContentLink(eventData, {relativeTo: this.object});
if ( !link ) return;
const editor = this.form.elements["text.markdown"];
const content = editor.value;
editor.value = content.substring(0, editor.selectionStart) + link + content.substring(editor.selectionStart);
this._isDirty = true;
}
}
/**
* A subclass of {@link JournalTextPageSheet} that implements a TinyMCE editor.
* @extends {JournalTextPageSheet}
*/
class JournalTextTinyMCESheet extends JournalTextPageSheet {
/** @inheritdoc */
async getData(options={}) {
const data = await super.getData(options);
data.editor.engine = "tinymce";
data.editor.collaborate = false;
return data;
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options = {}) {
return JournalPageSheet.prototype.close.call(this, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
return JournalPageSheet.prototype._render.call(this, force, options);
}
}
/**
* @typedef {DocumentSheetOptions} JournalSheetOptions
* @property {string|null} [sheetMode] The current display mode of the journal. Either 'text' or 'image'.
*/
/**
* The Application responsible for displaying and editing a single JournalEntry document.
* @extends {DocumentSheet}
* @param {JournalEntry} object The JournalEntry instance which is being edited
* @param {JournalSheetOptions} [options] Application options
*/
class JournalSheet extends DocumentSheet {
/**
* @override
* @returns {JournalSheetOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "journal-sheet", "journal-entry"],
template: "templates/journal/sheet.html",
width: 960,
height: 800,
resizable: true,
submitOnChange: true,
submitOnClose: true,
closeOnSubmit: false,
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE,
scrollY: [".scrollable"],
filters: [{inputSelector: 'input[name="search"]', contentSelector: ".directory-list"}],
dragDrop: [{dragSelector: ".directory-item, .heading-link", dropSelector: ".directory-list"}],
pageIndex: undefined,
pageId: undefined
});
}
/* -------------------------------------------- */
/**
* The cached list of processed page entries.
* This array is populated in the getData method.
* @type {object[]}
* @protected
*/
_pages;
/**
* Track which page IDs are currently displayed due to a search filter
* @type {Set<string>}
* @private
*/
#filteredPages = new Set();
/**
* The pages that are currently scrolled into view and marked as 'active' in the sidebar.
* @type {HTMLElement[]}
* @private
*/
#pagesInView = [];
/**
* The index of the currently viewed page.
* @type {number}
* @private
*/
#pageIndex = 0;
/**
* Has the player been granted temporary ownership of this journal entry or its pages?
* @type {boolean}
* @private
*/
#tempOwnership = false;
/**
* A mapping of page IDs to {@link JournalPageSheet} instances used for rendering the pages inside the journal entry.
* @type {Object<JournalPageSheet>}
*/
#sheets = {};
/**
* Store a flag to restore ToC positions after a render.
* @type {boolean}
*/
#restoreTOCPositions = false;
/**
* Store transient sidebar state so it can be restored after context menus are closed.
* @type {{position: number, active: boolean, collapsed: boolean}}
*/
#sidebarState = {collapsed: false};
/**
* Store a reference to the currently active IntersectionObserver.
* @type {IntersectionObserver}
*/
#observer;
/**
* Store a special set of heading intersections so that we can quickly compute the top-most heading in the viewport.
* @type {Map<HTMLHeadingElement, IntersectionObserverEntry>}
*/
#headingIntersections = new Map();
/**
* Store the journal entry's current view mode.
* @type {number|null}
*/
#mode = null;
/* -------------------------------------------- */
/**
* Get the journal entry's current view mode.
* @see {@link JournalSheet.VIEW_MODES}
* @returns {number}
*/
get mode() {
return this.#mode ?? this.document.getFlag("core", "viewMode") ?? this.constructor.VIEW_MODES.SINGLE;
}
/* -------------------------------------------- */
/**
* The current search mode for this journal
* @type {string}
*/
get searchMode() {
return this.document.getFlag("core", "searchMode") || CONST.DIRECTORY_SEARCH_MODES.NAME;
}
/**
* Toggle the search mode for this journal between "name" and "full" text search
*/
toggleSearchMode() {
const updatedSearchMode = this.document.getFlag("core", "searchMode") === CONST.DIRECTORY_SEARCH_MODES.NAME ?
CONST.DIRECTORY_SEARCH_MODES.FULL : CONST.DIRECTORY_SEARCH_MODES.NAME;
this.document.setFlag("core", "searchMode", updatedSearchMode);
}
/* -------------------------------------------- */
/**
* The pages that are currently scrolled into view and marked as 'active' in the sidebar.
* @type {HTMLElement[]}
*/
get pagesInView() {
return this.#pagesInView;
}
/* -------------------------------------------- */
/**
* The index of the currently viewed page.
* @type {number}
*/
get pageIndex() {
return this.#pageIndex;
}
/* -------------------------------------------- */
/**
* The currently active IntersectionObserver.
* @type {IntersectionObserver}
*/
get observer() {
return this.#observer;
}
/* -------------------------------------------- */
/**
* Is the table-of-contents sidebar currently collapsed?
* @type {boolean}
*/
get sidebarCollapsed() {
return this.#sidebarState.collapsed;
}
/* -------------------------------------------- */
/**
* Available view modes for journal entries.
* @enum {number}
*/
static VIEW_MODES = {
SINGLE: 1,
MULTIPLE: 2
};
/* -------------------------------------------- */
/**
* The minimum amount of content that must be visible before the next page is marked as in view. Cannot be less than
* 25% without also modifying the IntersectionObserver threshold.
* @type {number}
*/
static INTERSECTION_RATIO = .25;
/* -------------------------------------------- */
/**
* Icons for page ownership.
* @enum {string}
*/
static OWNERSHIP_ICONS = {
[CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE]: "fa-solid fa-eye-slash",
[CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER]: "fa-solid fa-eye",
[CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER]: "fa-solid fa-feather-pointed"
};
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
const folder = game.folders.get(this.object.folder?.id);
const name = `${folder ? `${folder.name}: ` : ""}${this.object.name}`;
return this.object.permission ? name : "";
}
/* -------------------------------------------- */
/** @inheritdoc */
_getHeaderButtons() {
const buttons = super._getHeaderButtons();
// Share Entry
if ( game.user.isGM ) {
buttons.unshift({
label: "JOURNAL.ActionShow",
class: "share-image",
icon: "fas fa-eye",
onclick: ev => this._onShowPlayers(ev)
});
}
return buttons;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
context.mode = this.mode;
context.toc = this._pages = this._getPageData();
this._getCurrentPage(options);
context.viewMode = {};
// Viewing single page
if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) {
context.pages = [context.toc[this.pageIndex]];
context.viewMode = {label: "JOURNAL.ViewMultiple", icon: "fa-solid fa-note", cls: "single-page"};
}
// Viewing multiple pages
else {
context.pages = context.toc;
context.viewMode = {label: "JOURNAL.ViewSingle", icon: "fa-solid fa-notes", cls: "multi-page"};
}
// Sidebar collapsed mode
context.sidebarClass = this.sidebarCollapsed ? "collapsed" : "";
context.collapseMode = this.sidebarCollapsed
? {label: "JOURNAL.ViewExpand", icon: "fa-solid fa-caret-left"}
: {label: "JOURNAL.ViewCollapse", icon: "fa-solid fa-caret-right"};
// Search mode
context.searchIcon = this.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME ? "fa-search" :
"fa-file-magnifying-glass";
context.searchTooltip = this.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME ? "SIDEBAR.SearchModeName" :
"SIDEBAR.SearchModeFull";
return context;
}
/* -------------------------------------------- */
/**
* Prepare pages for display.
* @returns {JournalEntryPage[]} The sorted list of pages.
* @protected
*/
_getPageData() {
const hasFilterQuery = !!this._searchFilters[0].query;
return this.object.pages.contents.sort((a, b) => a.sort - b.sort).reduce((arr, page) => {
if ( !this.isPageVisible(page) ) return arr;
const p = page.toObject();
const sheet = this.getPageSheet(page.id);
// Page CSS classes
const cssClasses = [p.type, `level${p.title.level}`];
if ( hasFilterQuery && !this.#filteredPages.has(page.id) ) cssClasses.push("hidden");
p.tocClass = p.cssClass = cssClasses.join(" ");
cssClasses.push(...(sheet.options.viewClasses || []));
p.viewClass = cssClasses.join(" ");
// Other page data
p.editable = page.isOwner;
if ( page.parent.pack ) p.editable &&= !game.packs.get(page.parent.pack)?.locked;
p.number = arr.length;
p.icon = this.constructor.OWNERSHIP_ICONS[page.ownership.default];
const levels = Object.entries(CONST.DOCUMENT_OWNERSHIP_LEVELS);
const [ownership] = levels.find(([, level]) => level === page.ownership.default);
p.ownershipCls = ownership.toLowerCase();
arr.push(p);
return arr;
}, []);
}
/* -------------------------------------------- */
/**
* Identify which page of the journal sheet should be currently rendered.
* This can be controlled by options passed into the render method or by a subclass override.
* @param {object} options Sheet rendering options
* @param {number} [options.pageIndex] A numbered index of page to render
* @param {string} [options.pageId] The ID of a page to render
* @returns {number} The currently displayed page index
* @protected
*/
_getCurrentPage({pageIndex, pageId}={}) {
let newPageIndex;
if ( typeof pageIndex === "number" ) newPageIndex = pageIndex;
if ( pageId ) newPageIndex = this._pages.findIndex(p => p._id === pageId);
if ( (newPageIndex != null) && (newPageIndex !== this.pageIndex) ) {
if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) this.#callCloseHooks(this.pageIndex);
this.#pageIndex = newPageIndex;
}
this.options.pageIndex = this.options.pageId = undefined;
return this.#pageIndex = Math.clamped(this.pageIndex, 0, this._pages.length - 1);
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.on("click", "img:not(.nopopout)", this._onClickImage.bind(this));
html.find("button[data-action], a[data-action]").click(this._onAction.bind(this));
this.#pagesInView = [];
this.#observer = new IntersectionObserver((entries, observer) => {
this._onPageScroll(entries, observer);
this._activatePagesInView();
this._updateButtonState();
}, {
root: html.find(".journal-entry-pages .scrollable")[0],
threshold: [0, .25, .5, .75, 1]
});
html.find(".journal-entry-page").each((i, el) => this.#observer.observe(el));
this._contextMenu(html);
}
/* -------------------------------------------- */
/**
* Activate listeners after page content has been injected.
* @protected
*/
_activatePageListeners() {
const html = this.element;
html.find(".editor-edit").click(this._onEditPage.bind(this));
html.find(".page-heading").click(this._onClickPageLink.bind(this));
}
/* -------------------------------------------- */
/**
* @inheritdoc
* @param {number} [options.mode] Render the sheet in a given view mode, see {@link JournalSheet.VIEW_MODES}.
* @param {string} [options.pageId] Render the sheet with the page with the given ID in view.
* @param {number} [options.pageIndex] Render the sheet with the page at the given index in view.
* @param {string} [options.anchor] Render the sheet with the given anchor for the given page in view.
* @param {boolean} [options.tempOwnership] Whether the journal entry or one of its pages is being shown to players
* who might otherwise not have permission to view it.
* @param {boolean} [options.collapsed] Render the sheet with the TOC sidebar collapsed?
*/
async _render(force, options={}) {
// Temporary override of ownership
if ( "tempOwnership" in options ) this.#tempOwnership = options.tempOwnership;
// Override the view mode
const modeChange = ("mode" in options) && (options.mode !== this.mode);
if ( modeChange ) {
if ( this.mode === this.constructor.VIEW_MODES.MULTIPLE ) this.#callCloseHooks();
this.#mode = options.mode;
}
if ( "collapsed" in options ) this.#sidebarState.collapsed = options.collapsed;
// Render the application
await super._render(force, options);
await this._renderPageViews();
this._activatePageListeners();
// Re-sync the TOC scroll position to the new view
const pageChange = ("pageIndex" in options) || ("pageId" in options);
if ( modeChange || pageChange ) {
const pageId = this._pages[this.pageIndex]?._id;
if ( this.mode === this.constructor.VIEW_MODES.MULTIPLE ) this.goToPage(pageId, options.anchor);
else if ( options.anchor ) {
this.getPageSheet(pageId)?.toc[options.anchor]?.element?.scrollIntoView();
this.#restoreTOCPositions = true;
}
}
else this._restoreScrollPositions(this.element);
}
/* -------------------------------------------- */
/**
* Update child views inside the main sheet.
* @returns {Promise<void>}
* @protected
*/
async _renderPageViews() {
for ( const pageNode of this.element[0].querySelectorAll(".journal-entry-page") ) {
const id = pageNode.dataset.pageId;
if ( !id ) continue;
const edit = pageNode.querySelector(":scope > .edit-container");
const sheet = this.getPageSheet(id);
const data = await sheet.getData();
const view = await sheet._renderInner(data);
pageNode.replaceChildren(...view.get());
if ( edit ) pageNode.appendChild(edit);
sheet._activateCoreListeners(view.parent());
sheet.activateListeners(view);
await this._renderHeadings(pageNode, sheet.toc);
for ( const cls of sheet.constructor._getInheritanceChain() ) {
Hooks.callAll(`render${cls.name}`, sheet, view, data);
}
}
this._observeHeadings();
}
/* -------------------------------------------- */
/**
* Call close hooks for individual pages.
* @param {number} [pageIndex] Calls the hook for this page only, otherwise calls for all pages.
*/
#callCloseHooks(pageIndex) {
if ( !this._pages?.length || (pageIndex < 0) ) return;
const pages = pageIndex != null ? [this._pages[pageIndex]] : this._pages;
for ( const page of pages ) {
const sheet = this.getPageSheet(page._id);
for ( const cls of sheet.constructor._getInheritanceChain() ) {
Hooks.callAll(`close${cls.name}`, sheet, sheet.element);
}
}
}
/* -------------------------------------------- */
/**
* Add headings to the table of contents for the given page node.
* @param {HTMLElement} pageNode The HTML node of the page's rendered contents.
* @param {Object<JournalEntryPageHeading>} toc The page's table of contents.
* @protected
*/
async _renderHeadings(pageNode, toc) {
const pageId = pageNode.dataset.pageId;
const page = this.object.pages.get(pageId);
const tocNode = this.element[0].querySelector(`.directory-item[data-page-id="${pageId}"]`);
if ( !tocNode || !toc ) return;
const headings = Object.values(toc);
if ( page.title.show ) headings.shift();
const minLevel = Math.min(...headings.map(node => node.level));
tocNode.querySelector(":scope > ol")?.remove();
const tocHTML = await renderTemplate("templates/journal/journal-page-toc.html", {
headings: headings.reduce((arr, {text, level, slug, element}) => {
if ( element ) element.dataset.anchor = slug;
if ( level < minLevel + 2 ) arr.push({text, slug, level: level - minLevel + 2});
return arr;
}, [])
});
tocNode.innerHTML += tocHTML;
tocNode.querySelectorAll(".heading-link").forEach(el =>
el.addEventListener("click", this._onClickPageLink.bind(this)));
this._dragDrop.forEach(d => d.bind(tocNode));
}
/* -------------------------------------------- */
/**
* Create an intersection observer to maintain a list of headings that are in view. This is much more performant than
* calling getBoundingClientRect on all headings whenever we want to determine this list.
* @protected
*/
_observeHeadings() {
const element = this.element[0];
this.#headingIntersections = new Map();
const headingObserver = new IntersectionObserver(entries => entries.forEach(entry => {
if ( entry.isIntersecting ) this.#headingIntersections.set(entry.target, entry);
else this.#headingIntersections.delete(entry.target);
}), {
root: element.querySelector(".journal-entry-pages .scrollable"),
threshold: 1
});
const headings = Array.fromRange(6, 1).map(n => `h${n}`).join(",");
element.querySelectorAll(`.journal-entry-page :is(${headings})`).forEach(el => headingObserver.observe(el));
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
// Reset any temporarily-granted ownership.
if ( this.#tempOwnership ) {
this.object.ownership = foundry.utils.deepClone(this.object._source.ownership);
this.object.pages.forEach(p => p.ownership = foundry.utils.deepClone(p._source.ownership));
this.#tempOwnership = false;
}
return super.close(options);
}
/* -------------------------------------------- */
/**
* Handle clicking the previous and next page buttons.
* @param {JQuery.TriggeredEvent} event The button click event.
* @protected
*/
_onAction(event) {
event.preventDefault();
const button = event.currentTarget;
const action = button.dataset.action;
switch (action) {
case "previous":
return this.previousPage();
case "next":
return this.nextPage();
case "createPage":
return this.createPage();
case "toggleView":
const modes = this.constructor.VIEW_MODES;
const mode = this.mode === modes.SINGLE ? modes.MULTIPLE : modes.SINGLE;
this.#mode = mode;
return this.render(true, {mode});
case "toggleCollapse":
return this.toggleSidebar(event);
case "toggleSearch":
this.toggleSearchMode();
return this.render();
}
}
/* -------------------------------------------- */
/**
* Prompt the user with a Dialog for creation of a new JournalEntryPage
*/
createPage() {
const bounds = this.element[0].getBoundingClientRect();
const options = {parent: this.object, width: 320, top: bounds.bottom - 200, left: bounds.left + 10};
const sort = (this._pages.at(-1)?.sort ?? 0) + CONST.SORT_INTEGER_DENSITY;
return JournalEntryPage.implementation.createDialog({sort}, options);
}
/* -------------------------------------------- */
/**
* Turn to the previous page.
*/
previousPage() {
if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) return this.render(true, {pageIndex: this.pageIndex - 1});
this.pagesInView[0]?.previousElementSibling?.scrollIntoView();
}
/* -------------------------------------------- */
/**
* Turn to the next page.
*/
nextPage() {
if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) return this.render(true, {pageIndex: this.pageIndex + 1});
if ( this.pagesInView.length ) this.pagesInView.at(-1).nextElementSibling?.scrollIntoView();
else this.element[0].querySelector(".journal-entry-page")?.scrollIntoView();
}
/* -------------------------------------------- */
/**
* Turn to a specific page.
* @param {string} pageId The ID of the page to turn to.
* @param {string} [anchor] Optionally an anchor slug to focus within that page.
*/
goToPage(pageId, anchor) {
if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) {
const currentPageId = this._pages[this.pageIndex]?._id;
if ( currentPageId !== pageId ) return this.render(true, {pageId, anchor});
}
const page = this.element[0].querySelector(`.journal-entry-page[data-page-id="${pageId}"]`);
if ( anchor ) {
const element = this.getPageSheet(pageId)?.toc[anchor]?.element;
if ( element ) {
element.scrollIntoView();
return;
}
}
page?.scrollIntoView();
}
/* -------------------------------------------- */
/**
* Retrieve the sheet instance for rendering this page inline.
* @param {string} pageId The ID of the page.
* @returns {JournalPageSheet}
*/
getPageSheet(pageId) {
const page = this.object.pages.get(pageId);
const sheetClass = page._getSheetClass();
let sheet = this.#sheets[pageId];
if ( sheet?.constructor !== sheetClass ) {
sheet = new sheetClass(page, {editable: false});
this.#sheets[pageId] = sheet;
}
return sheet;
}
/* -------------------------------------------- */
/**
* Determine whether a page is visible to the current user.
* @param {JournalEntryPage} page The page.
* @returns {boolean}
*/
isPageVisible(page) {
return this.getPageSheet(page.id)._canUserView(game.user);
}
/* -------------------------------------------- */
/**
* Toggle the collapsed or expanded state of the Journal Entry table-of-contents sidebar.
*/
toggleSidebar() {
const app = this.element[0];
const sidebar = app.querySelector(".sidebar");
const button = sidebar.querySelector(".collapse-toggle");
this.#sidebarState.collapsed = !this.sidebarCollapsed;
// Disable application interaction temporarily
app.style.pointerEvents = "none";
// Configure CSS transitions for the application window
app.classList.add("collapsing");
app.addEventListener("transitionend", () => {
app.style.pointerEvents = "";
app.classList.remove("collapsing");
}, {once: true});
// Learn the configure sidebar widths
const style = getComputedStyle(sidebar);
const expandedWidth = Number(style.getPropertyValue("--sidebar-width-expanded").trim().replace("px", ""));
const collapsedWidth = Number(style.getPropertyValue("--sidebar-width-collapsed").trim().replace("px", ""));
// Change application position
const delta = expandedWidth - collapsedWidth;
this.setPosition({
left: this.position.left + (this.sidebarCollapsed ? delta : -delta),
width: this.position.width + (this.sidebarCollapsed ? -delta : delta)
});
// Toggle display of the sidebar
sidebar.classList.toggle("collapsed", this.sidebarCollapsed);
// Update icons and labels
button.dataset.tooltip = this.sidebarCollapsed ? "JOURNAL.ViewExpand" : "JOURNAL.ViewCollapse";
const i = button.children[0];
i.setAttribute("class", `fa-solid ${this.sidebarCollapsed ? "fa-caret-left" : "fa-caret-right"}`);
game.tooltip.deactivate();
}
/* -------------------------------------------- */
/**
* Update the disabled state of the previous and next page buttons.
* @protected
*/
_updateButtonState() {
if ( !this.element?.length ) return;
const previous = this.element[0].querySelector('[data-action="previous"]');
const next = this.element[0].querySelector('[data-action="next"]');
if ( !next || !previous ) return;
if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) {
previous.disabled = this.pageIndex < 1;
next.disabled = this.pageIndex >= (this._pages.length - 1);
} else {
previous.disabled = !this.pagesInView[0]?.previousElementSibling;
next.disabled = this.pagesInView.length && !this.pagesInView.at(-1).nextElementSibling;
}
}
/* -------------------------------------------- */
/**
* Edit one of this JournalEntry's JournalEntryPages.
* @param {JQuery.TriggeredEvent} event The originating page edit event.
* @protected
*/
_onEditPage(event) {
event.preventDefault();
const button = event.currentTarget;
const pageId = button.closest("[data-page-id]").dataset.pageId;
const page = this.object.pages.get(pageId);
return page?.sheet.render(true);
}
/* -------------------------------------------- */
/**
* Handle clicking an entry in the sidebar to scroll that heading into view.
* @param {JQuery.TriggeredEvent} event The originating click event.
* @protected
*/
_onClickPageLink(event) {
const target = event.currentTarget;
const pageId = target.closest("[data-page-id]").dataset.pageId;
const anchor = target.closest("[data-anchor]")?.dataset.anchor;
this.goToPage(pageId, anchor);
}
/* -------------------------------------------- */
/**
* Handle clicking an image to pop it out for fullscreen view.
* @param {MouseEvent} event The click event.
* @protected
*/
_onClickImage(event) {
const target = event.currentTarget;
const imagePage = target.closest(".journal-entry-page.image");
const page = this.object.pages.get(imagePage?.dataset.pageId);
const title = page?.name ?? target.title;
const ip = new ImagePopout(target.getAttribute("src"), {title, caption: page?.image.caption});
if ( page ) ip.shareImage = () => Journal.showDialog(page);
ip.render(true);
}
/* -------------------------------------------- */
/**
* Handle new pages scrolling into view.
* @param {IntersectionObserverEntry[]} entries An Array of elements that have scrolled into or out of view.
* @param {IntersectionObserver} observer The IntersectionObserver that invoked this callback.
* @protected
*/
_onPageScroll(entries, observer) {
if ( !entries.length ) return;
// This has been triggered by an old IntersectionObserver from the previous render and is no longer relevant.
if ( observer !== this.observer ) return;
// Case 1 - We are in single page mode.
if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) {
const entry = entries[0]; // There can be only one entry in single page mode.
if ( entry.isIntersecting ) this.#pagesInView = [entry.target];
return;
}
const minRatio = this.constructor.INTERSECTION_RATIO;
const intersecting = entries
.filter(entry => entry.isIntersecting && (entry.intersectionRatio >= minRatio))
.sort((a, b) => a.intersectionRect.y - b.intersectionRect.y);
// Special case where the page is so large that any portion of visible content is less than 25% of the whole page.
if ( !intersecting.length ) {
const isIntersecting = entries.find(entry => entry.isIntersecting);
if ( isIntersecting ) intersecting.push(isIntersecting);
}
// Case 2 - We are in multiple page mode and this is the first render.
if ( !this.pagesInView.length ) {
this.#pagesInView = intersecting.map(entry => entry.target);
return;
}
// Case 3 - The user is scrolling normally through pages in multiple page mode.
const byTarget = new Map(entries.map(entry => [entry.target, entry]));
const inView = [...this.pagesInView];
// Remove pages that have scrolled out of view.
for ( const el of this.pagesInView ) {
const entry = byTarget.get(el);
if ( entry && (entry.intersectionRatio < minRatio) ) inView.findSplice(p => p === el);
}
// Add pages that have scrolled into view.
for ( const entry of intersecting ) {
if ( !inView.includes(entry.target) ) inView.push(entry.target);
}
this.#pagesInView = inView.sort((a, b) => {
const pageA = this.object.pages.get(a.dataset.pageId);
const pageB = this.object.pages.get(b.dataset.pageId);
return pageA.sort - pageB.sort;
});
}
/* -------------------------------------------- */
/**
* Highlights the currently viewed page in the sidebar.
* @protected
*/
_activatePagesInView() {
// Update the pageIndex to the first page in view for when the mode is switched to single view.
if ( this.pagesInView.length ) {
const pageId = this.pagesInView[0].dataset.pageId;
this.#pageIndex = this._pages.findIndex(p => p._id === pageId);
}
let activeChanged = false;
const pageIds = new Set(this.pagesInView.map(p => p.dataset.pageId));
this.element.find(".directory-item").each((i, el) => {
activeChanged ||= (el.classList.contains("active") !== pageIds.has(el.dataset.pageId));
el.classList.toggle("active", pageIds.has(el.dataset.pageId));
});
if ( activeChanged ) this._synchronizeSidebar();
}
/* -------------------------------------------- */
/**
* If the set of active pages has changed, various elements in the sidebar will expand and collapse. For particularly
* long ToCs, this can leave the scroll position of the sidebar in a seemingly random state. We try to do our best to
* sync the sidebar scroll position with the current journal viewport.
* @protected
*/
_synchronizeSidebar() {
const entries = Array.from(this.#headingIntersections.values()).sort((a, b) => {
return a.intersectionRect.y - b.intersectionRect.y;
});
for ( const entry of entries ) {
const pageId = entry.target.closest("[data-page-id]")?.dataset.pageId;
const anchor = entry.target.dataset.anchor;
let toc = this.element[0].querySelector(`.directory-item[data-page-id="${pageId}"]`);
if ( anchor ) toc = toc.querySelector(`li[data-anchor="${anchor}"]`);
if ( toc ) {
toc.scrollIntoView();
break;
}
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_contextMenu(html) {
ContextMenu.create(this, html, ".directory-item", this._getEntryContextOptions(), {
onOpen: this._onContextMenuOpen.bind(this),
onClose: this._onContextMenuClose.bind(this)
});
}
/* -------------------------------------------- */
/**
* Handle opening the context menu.
* @param {HTMLElement} target The element the context menu has been triggered for.
* @protected
*/
_onContextMenuOpen(target) {
this.#sidebarState = {
position: this.element.find(".directory-list.scrollable").scrollTop(),
active: target.classList.contains("active")
};
target.classList.remove("active");
}
/* -------------------------------------------- */
/**
* Handle closing the context menu.
* @param {HTMLElement} target The element the context menu has been triggered for.
* @protected
*/
_onContextMenuClose(target) {
if ( this.#sidebarState.active ) target.classList.add("active");
this.element.find(".directory-list.scrollable").scrollTop(this.#sidebarState.position);
}
/* -------------------------------------------- */
/**
* Get the set of ContextMenu options which should be used for JournalEntryPages in the sidebar.
* @returns {ContextMenuEntry[]} The Array of context options passed to the ContextMenu instance.
* @protected
*/
_getEntryContextOptions() {
const getPage = li => this.object.pages.get(li.data("page-id"));
return [{
name: "SIDEBAR.Edit",
icon: '<i class="fas fa-edit"></i>',
condition: li => this.isEditable && getPage(li)?.canUserModify(game.user, "update"),
callback: li => getPage(li)?.sheet.render(true)
}, {
name: "SIDEBAR.Delete",
icon: '<i class="fas fa-trash"></i>',
condition: li => this.isEditable && getPage(li)?.canUserModify(game.user, "delete"),
callback: li => {
const bounds = li[0].getBoundingClientRect();
return getPage(li)?.deleteDialog({top: bounds.top, left: bounds.right});
}
}, {
name: "SIDEBAR.Duplicate",
icon: '<i class="far fa-copy"></i>',
condition: this.isEditable,
callback: li => {
const page = getPage(li);
return page.clone({name: game.i18n.format("DOCUMENT.CopyOf", {name: page.name})}, {save: true});
}
}, {
name: "OWNERSHIP.Configure",
icon: '<i class="fas fa-lock"></i>',
condition: () => game.user.isGM,
callback: li => {
const page = getPage(li);
const bounds = li[0].getBoundingClientRect();
new DocumentOwnershipConfig(page, {top: bounds.top, left: bounds.right}).render(true);
}
}, {
name: "JOURNAL.ActionShow",
icon: '<i class="fas fa-eye"></i>',
condition: li => getPage(li)?.isOwner,
callback: li => {
const page = getPage(li);
if ( page ) return Journal.showDialog(page);
}
}, {
name: "SIDEBAR.JumpPin",
icon: '<i class="fa-solid fa-crosshairs"></i>',
condition: li => {
const page = getPage(li);
return !!page?.sceneNote;
},
callback: li => {
const page = getPage(li);
if ( page?.sceneNote ) return canvas.notes.panToNote(page.sceneNote);
}
}];
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
// Remove <form> tags which will break the display of the sheet.
if ( formData.content ) formData.content = formData.content.replace(/<\s*\/?\s*form(\s+[^>]*)?>/g, "");
return super._updateObject(event, formData);
}
/* -------------------------------------------- */
/**
* Handle requests to show the referenced Journal Entry to other Users
* Save the form before triggering the show request, in case content has changed
* @param {Event} event The triggering click event
*/
async _onShowPlayers(event) {
event.preventDefault();
await this.submit();
return Journal.showDialog(this.object);
}
/* -------------------------------------------- */
/** @inheritdoc */
_canDragStart(selector) {
return this.object.testUserPermission(game.user, "OBSERVER");
}
/* -------------------------------------------- */
/** @inheritdoc */
_canDragDrop(selector) {
return this.isEditable;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragStart(event) {
if ( ui.context ) ui.context.close({animate: false});
const target = event.currentTarget;
const pageId = target.closest("[data-page-id]").dataset.pageId;
const anchor = target.closest("[data-anchor]")?.dataset.anchor;
const page = this.object.pages.get(pageId);
const dragData = {
...page.toDragData(),
anchor: { slug: anchor, name: target.innerText }
};
event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDrop(event) {
// Retrieve the dropped Journal Entry Page
const data = TextEditor.getDragEventData(event);
if ( data.type !== "JournalEntryPage" ) return;
const page = await JournalEntryPage.implementation.fromDropData(data);
if ( !page ) return;
// Determine the target that was dropped
const target = event.target.closest("[data-page-id]");
const sortTarget = target ? this.object.pages.get(target?.dataset.pageId) : null;
// Prevent dropping a page on itself.
if ( page === sortTarget ) return;
// Case 1 - Sort Pages
if ( page.parent === this.document ) return page.sortRelative({
sortKey: "sort",
target: sortTarget,
siblings: this.object.pages.filter(p => p.id !== page.id)
});
// Case 2 - Create Pages
const pageData = page.toObject();
if ( this.object.pages.has(page.id) ) delete pageData._id;
pageData.sort = sortTarget ? sortTarget.sort : this.object.pages.reduce((max, p) => p.sort > max ? p.sort : max, 0);
return this.document.createEmbeddedDocuments("JournalEntryPage", [pageData], {keepId: true});
}
/* -------------------------------------------- */
/** @inheritdoc */
_onSearchFilter(event, query, rgx, html) {
this.#filteredPages.clear();
const nameOnlySearch = (this.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME);
// Match Pages
let results = [];
if ( !nameOnlySearch ) results = this.object.pages.search({query: query});
for ( const el of html.querySelectorAll(".directory-item") ) {
const page = this.object.pages.get(el.dataset.pageId);
let match = !query;
if ( !match && nameOnlySearch ) match = rgx.test(SearchFilter.cleanQuery(page.name));
else if ( !match ) match = !!results.find(r => r._id === page._id);
if ( match ) this.#filteredPages.add(page._id);
el.classList.toggle("hidden", !match);
}
// Restore TOC Positions
if ( this.#restoreTOCPositions && this._scrollPositions ) {
this.#restoreTOCPositions = false;
const position = this._scrollPositions[this.options.scrollY[0]]?.[0];
const toc = this.element[0].querySelector(".pages-list .scrollable");
if ( position && toc ) toc.scrollTop = position;
}
}
}
/**
* A Macro configuration sheet
* @extends {DocumentSheet}
*
* @param {Macro} object The Macro Document which is being configured
* @param {DocumentSheetOptions} [options] Application configuration options.
*/
class MacroConfig extends DocumentSheet {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "macro-sheet"],
template: "templates/sheets/macro-config.html",
width: 560,
height: 480,
resizable: true
});
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const data = super.getData();
data.macroTypes = game.documentTypes.Macro.reduce((obj, t) => {
if ( t === CONST.BASE_DOCUMENT_TYPE ) return obj;
if ( (t === "script") && !game.user.can("MACRO_SCRIPT") ) return obj;
obj[t] = game.i18n.localize(CONFIG.Macro.typeLabels[t]);
return obj;
}, {});
data.macroScopes = CONST.MACRO_SCOPES;
return data;
}
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find("button.execute").click(this._onExecute.bind(this));
}
/* -------------------------------------------- */
/** @inheritdoc */
_disableFields(form) {
super._disableFields(form);
if ( this.object.canExecute ) form.querySelector("button.execute").disabled = false;
}
/* -------------------------------------------- */
/**
* Save and execute the macro using the button on the configuration sheet
* @param {MouseEvent} event The originating click event
* @return {Promise<void>}
* @private
*/
async _onExecute(event) {
event.preventDefault();
await this._onSubmit(event, {preventClose: true}); // Submit pending changes
this.object.execute(); // Execute the macro
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
if ( !this.object.id ) {
return Macro.create(formData);
} else {
return super._updateObject(event, formData);
}
}
}
/**
* The Application responsible for configuring a single MeasuredTemplate document within a parent Scene.
* @param {MeasuredTemplate} object The {@link MeasuredTemplate} being configured.
* @param {DocumentSheetOptions} [options] Application configuration options.
*/
class MeasuredTemplateConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "template-config",
classes: ["sheet", "template-sheet"],
title: "TEMPLATE.MeasuredConfig",
template: "templates/scene/template-config.html",
width: 400
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData() {
return foundry.utils.mergeObject(super.getData(), {
templateTypes: CONFIG.MeasuredTemplate.types,
gridUnits: canvas.scene.grid.units || game.i18n.localize("GridUnits"),
submitText: `TEMPLATE.Submit${this.options.preview ? "Create" : "Update"}`
});
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
if ( this.object.id ) {
formData.id = this.object.id;
return this.object.update(formData);
}
return this.object.constructor.create(formData);
}
}
/**
* A generic application for configuring permissions for various Document types
* @extends {DocumentSheet}
*/
class DocumentOwnershipConfig extends DocumentSheet {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "permission",
template: "templates/apps/ownership.html",
width: 400
});
}
/* -------------------------------------------- */
/** @override */
get title() {
return `${game.i18n.localize("OWNERSHIP.Title")}: ${this.document.name}`;
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const isFolder = this.document instanceof Folder;
const isEmbedded = this.document.isEmbedded;
const ownership = this.document.ownership;
if ( !ownership && !isFolder ) {
throw new Error(`The ${this.document.documentName} document does not contain ownership data`);
}
// User permission levels
const playerLevels = Object.entries(CONST.DOCUMENT_META_OWNERSHIP_LEVELS).map(([name, level]) => {
return {level, label: game.i18n.localize(`OWNERSHIP.${name}`)};
});
if ( !isFolder ) playerLevels.pop();
for ( let [name, level] of Object.entries(CONST.DOCUMENT_OWNERSHIP_LEVELS) ) {
if ( (level < 0) && !isEmbedded ) continue;
playerLevels.push({level, label: game.i18n.localize(`OWNERSHIP.${name}`)});
}
// Default permission levels
const defaultLevels = foundry.utils.deepClone(playerLevels);
defaultLevels.shift();
// Player users
const users = game.users.map(user => {
return {
user,
level: isFolder ? CONST.DOCUMENT_META_OWNERSHIP_LEVELS.NOCHANGE : ownership[user.id],
isAuthor: this.document.author === user
};
});
// Construct and return the data object
return {
currentDefault: ownership?.default ?? playerLevels[0],
instructions: game.i18n.localize(isFolder ? "OWNERSHIP.HintFolder" : "OWNERSHIP.HintDocument"),
defaultLevels,
playerLevels,
isFolder,
users
};
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
event.preventDefault();
if ( !game.user.isGM ) throw new Error("You do not have the ability to configure permissions.");
// Collect new ownership levels from the form data
const metaLevels = CONST.DOCUMENT_META_OWNERSHIP_LEVELS;
const isFolder = this.document instanceof Folder;
const omit = isFolder ? metaLevels.NOCHANGE : metaLevels.DEFAULT;
const ownershipLevels = {};
for ( let [user, level] of Object.entries(formData) ) {
if ( level === omit ) {
delete ownershipLevels[user];
continue;
}
ownershipLevels[user] = level;
}
// Update all documents in a Folder
if ( this.document instanceof Folder ) {
const cls = getDocumentClass(this.document.type);
const updates = this.document.contents.map(d => {
const ownership = foundry.utils.deepClone(d.ownership);
for ( let [k, v] of Object.entries(ownershipLevels) ) {
if ( v === metaLevels.DEFAULT ) delete ownership[k];
else ownership[k] = v;
}
return {_id: d.id, ownership};
});
return cls.updateDocuments(updates, {diff: false, recursive: false, noHook: true});
}
// Update a single Document
return this.document.update({ownership: ownershipLevels}, {diff: false, recursive: false, noHook: true});
}
}
/**
* @deprecated since v10
* @ignore
*/
class PermissionControl extends DocumentOwnershipConfig {
constructor(...args) {
super(...args);
foundry.utils.logCompatibilityWarning("You are constructing the PermissionControl class which has been renamed " +
"to DocumentOwnershipConfig", {since: 10, until: 12});
}
}
/**
* The Application responsible for configuring a single Playlist document.
* @extends {DocumentSheet}
* @param {Playlist} object The {@link Playlist} to configure.
* @param {DocumentSheetOptions} [options] Application configuration options.
*/
class PlaylistConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.id = "playlist-config";
options.template = "templates/playlist/playlist-config.html";
options.width = 360;
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return `${game.i18n.localize("PLAYLIST.Edit")}: ${this.object.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const data = super.getData(options);
data.modes = Object.entries(CONST.PLAYLIST_MODES).reduce((obj, e) => {
const [name, value] = e;
obj[value] = game.i18n.localize(`PLAYLIST.Mode${name.titleCase()}`);
return obj;
}, {});
data.sorting = Object.entries(CONST.PLAYLIST_SORT_MODES).reduce((obj, [name, value]) => {
obj[value] = game.i18n.localize(`PLAYLIST.Sort${name.titleCase()}`);
return obj;
}, {});
return data;
}
/* -------------------------------------------- */
/** @inheritdoc */
_getFilePickerOptions(event) {
const options = super._getFilePickerOptions(event);
options.allowUpload = false;
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onSelectFile(selection, filePicker) {
if ( filePicker.button.dataset.target !== "importPath" ) return;
const contents = await FilePicker.browse(filePicker.activeSource, filePicker.result.target, {
extensions: Object.keys(CONST.AUDIO_FILE_EXTENSIONS).map(ext => `.${ext.toLowerCase()}`),
bucket: filePicker.result.bucket
});
const playlist = this.object;
const currentSources = new Set(playlist.sounds.map(s => s.path));
const toCreate = contents.files.reduce((arr, src) => {
if ( !AudioHelper.hasAudioExtension(src) || currentSources.has(src) ) return arr;
const soundData = { name: AudioHelper.getDefaultSoundName(src), path: src };
arr.push(soundData);
return arr;
}, []);
if ( toCreate.length ) {
ui.playlists._expanded.add(playlist.id);
return playlist.createEmbeddedDocuments("PlaylistSound", toCreate);
} else {
const warning = game.i18n.format("PLAYLIST.BulkImportWarning", {path: filePicker.target});
return ui.notifications.warn(warning);
}
}
}
/**
* The Application responsible for configuring a single PlaylistSound document within a parent Playlist.
* @extends {DocumentSheet}
*
* @param {PlaylistSound} sound The PlaylistSound document being configured
* @param {DocumentSheetOptions} [options] Additional application rendering options
*/
class PlaylistSoundConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "track-config",
template: "templates/playlist/sound-config.html",
width: 360
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
if ( !this.object.id ) return `${game.i18n.localize("PLAYLIST.SoundCreate")}: ${this.object.parent.name}`;
return `${game.i18n.localize("PLAYLIST.SoundEdit")}: ${this.object.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
if ( !this.document.id ) context.data.name = "";
context.lvolume = AudioHelper.volumeToInput(this.document.volume);
return context;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find('input[name="path"]').change(this._onSourceChange.bind(this));
return html;
}
/* -------------------------------------------- */
/**
* Auto-populate the track name using the provided filename, if a name is not already set
* @param {Event} event
* @private
*/
_onSourceChange(event) {
event.preventDefault();
const field = event.target;
const form = field.form;
if ( !form.name.value ) {
form.name.value = AudioHelper.getDefaultSoundName(field.value);
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
formData["volume"] = AudioHelper.inputToVolume(formData["lvolume"]);
if (this.object.id) return this.object.update(formData);
return this.object.constructor.create(formData, {parent: this.object.parent});
}
}
/**
* The Application responsible for displaying and editing a single RollTable document.
* @param {RollTable} table The RollTable document being configured
* @param {DocumentSheetOptions} [options] Additional application configuration options
*/
class RollTableConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "roll-table-config"],
template: "templates/sheets/roll-table-config.html",
width: 720,
height: "auto",
closeOnSubmit: false,
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
scrollY: ["table.table-results"],
dragDrop: [{dragSelector: null, dropSelector: null}]
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return `${game.i18n.localize("TABLE.SheetTitle")}: ${this.document.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
const context = super.getData(options);
context.descriptionHTML = await TextEditor.enrichHTML(this.object.description, {async: true, secrets: this.object.isOwner});
const results = this.document.results.map(result => {
result = result.toObject(false);
result.isText = result.type === CONST.TABLE_RESULT_TYPES.TEXT;
result.isDocument = result.type === CONST.TABLE_RESULT_TYPES.DOCUMENT;
result.isCompendium = result.type === CONST.TABLE_RESULT_TYPES.COMPENDIUM;
result.img = result.img || CONFIG.RollTable.resultIcon;
result.text = TextEditor.decodeHTML(result.text);
return result;
});
results.sort((a, b) => a.range[0] - b.range[0]);
// Merge data and return;
return foundry.utils.mergeObject(context, {
results: results,
resultTypes: Object.entries(CONST.TABLE_RESULT_TYPES).reduce((obj, v) => {
obj[v[1]] = v[0].titleCase();
return obj;
}, {}),
documentTypes: CONST.COMPENDIUM_DOCUMENT_TYPES,
compendiumPacks: Array.from(game.packs.keys())
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
// Roll the Table
const button = html.find("button.roll");
button.click(this._onRollTable.bind(this));
button[0].disabled = false;
// The below options require an editable sheet
if (!this.isEditable) return;
// Reset the Table
html.find("button.reset").click(this._onResetTable.bind(this));
// Save the sheet on checkbox change
html.find('input[type="checkbox"]').change(this._onSubmit.bind(this));
// Create a new Result
html.find("a.create-result").click(this._onCreateResult.bind(this));
// Delete a Result
html.find("a.delete-result").click(this._onDeleteResult.bind(this));
// Lock or Unlock a Result
html.find("a.lock-result").click(this._onLockResult.bind(this));
// Modify Result Type
html.find(".result-type select").change(this._onChangeResultType.bind(this));
// Re-normalize Table Entries
html.find(".normalize-results").click(this._onNormalizeResults.bind(this));
}
/* -------------------------------------------- */
/**
* Handle creating a TableResult in the RollTable document
* @param {MouseEvent} event The originating mouse event
* @param {object} [resultData] An optional object of result data to use
* @returns {Promise}
* @private
*/
async _onCreateResult(event, resultData={}) {
event.preventDefault();
// Save any pending changes
await this._onSubmit(event);
// Get existing results
const results = Array.from(this.document.results.values());
let last = results[results.length - 1];
// Get weight and range data
let weight = last ? (last.weight || 1) : 1;
let totalWeight = results.reduce((t, r) => t + r.weight, 0) || 1;
let minRoll = results.length ? Math.min(...results.map(r => r.range[0])) : 0;
let maxRoll = results.length ? Math.max(...results.map(r => r.range[1])) : 0;
// Determine new starting range
const spread = maxRoll - minRoll + 1;
const perW = Math.round(spread / totalWeight);
const range = [maxRoll + 1, maxRoll + Math.max(1, weight * perW)];
// Create the new Result
resultData = foundry.utils.mergeObject({
type: last ? last.type : CONST.TABLE_RESULT_TYPES.TEXT,
documentCollection: last ? last.documentCollection : null,
weight: weight,
range: range,
drawn: false
}, resultData);
return this.document.createEmbeddedDocuments("TableResult", [resultData]);
}
/* -------------------------------------------- */
/**
* Submit the entire form when a table result type is changed, in case there are other active changes
* @param {Event} event
* @private
*/
_onChangeResultType(event) {
event.preventDefault();
const rt = CONST.TABLE_RESULT_TYPES;
const select = event.target;
const value = parseInt(select.value);
const resultKey = select.name.replace(".type", "");
let documentCollection = "";
if ( value === rt.DOCUMENT ) documentCollection = "Actor";
else if ( value === rt.COMPENDIUM ) documentCollection = game.packs.keys().next().value;
const updateData = {[resultKey]: {documentCollection, documentId: null}};
return this._onSubmit(event, {updateData});
}
/* -------------------------------------------- */
/**
* Handle deleting a TableResult from the RollTable document
* @param {MouseEvent} event The originating click event
* @returns {Promise<TableResult>} The deleted TableResult document
* @private
*/
async _onDeleteResult(event) {
event.preventDefault();
await this._onSubmit(event);
const li = event.currentTarget.closest(".table-result");
const result = this.object.results.get(li.dataset.resultId);
return result.delete();
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDrop(event) {
const data = TextEditor.getDragEventData(event);
const allowed = Hooks.call("dropRollTableSheetData", this.document, this, data);
if ( allowed === false ) return;
// Get the dropped document
if ( !CONST.DOCUMENT_TYPES.includes(data.type) ) return;
const cls = getDocumentClass(data.type);
const document = await cls.fromDropData(data);
if ( !document || document.isEmbedded ) return;
// Delegate to the onCreate handler
const isCompendium = !!document.compendium;
return this._onCreateResult(event, {
type: isCompendium ? CONST.TABLE_RESULT_TYPES.COMPENDIUM : CONST.TABLE_RESULT_TYPES.DOCUMENT,
documentCollection: isCompendium ? document.pack : document.documentName,
text: document.name,
documentId: document.id,
img: document.img || null
});
}
/* -------------------------------------------- */
/**
* Handle changing the actor profile image by opening a FilePicker
* @param {Event} event
* @private
*/
_onEditImage(event) {
const img = event.currentTarget;
const isHeader = img.dataset.edit === "img";
let current = this.document.img;
if ( !isHeader ) {
const li = img.closest(".table-result");
const result = this.document.results.get(li.dataset.resultId);
if (result.type !== CONST.TABLE_RESULT_TYPES.TEXT) return;
current = result.img;
}
const fp = new FilePicker({
type: "image",
current: current,
callback: path => {
img.src = path;
return this._onSubmit(event);
},
top: this.position.top + 40,
left: this.position.left + 10
});
return fp.browse();
}
/* -------------------------------------------- */
/**
* Handle a button click to re-normalize dice result ranges across all RollTable results
* @param {Event} event
* @private
*/
async _onNormalizeResults(event) {
event.preventDefault();
if ( !this.rendered || this._submitting) return false;
// Save any pending changes
await this._onSubmit(event);
// Normalize the RollTable
return this.document.normalize();
}
/* -------------------------------------------- */
/**
* Handle toggling the drawn status of the result in the table
* @param {Event} event
* @private
*/
_onLockResult(event) {
event.preventDefault();
const tableResult = event.currentTarget.closest(".table-result");
const result = this.document.results.get(tableResult.dataset.resultId);
return result.update({drawn: !result.drawn});
}
/* -------------------------------------------- */
/**
* Reset the Table to it's original composition with all options unlocked
* @param {Event} event
* @private
*/
_onResetTable(event) {
event.preventDefault();
return this.document.resetResults();
}
/* -------------------------------------------- */
/**
* Handle drawing a result from the RollTable
* @param {Event} event
* @private
*/
async _onRollTable(event) {
event.preventDefault();
await this.submit({preventClose: true, preventRender: true});
event.currentTarget.disabled = true;
let tableRoll = await this.document.roll();
const draws = this.document.getResultsForRoll(tableRoll.roll.total);
if ( draws.length ) {
if (game.settings.get("core", "animateRollTable")) await this._animateRoll(draws);
await this.document.draw(tableRoll);
}
event.currentTarget.disabled = false;
}
/* -------------------------------------------- */
/**
* Configure the update object workflow for the Roll Table configuration sheet
* Additional logic is needed here to reconstruct the results array from the editable fields on the sheet
* @param {Event} event The form submission event
* @param {Object} formData The validated FormData translated into an Object for submission
* @returns {Promise}
* @private
*/
async _updateObject(event, formData) {
// Expand the data to update the results array
const expanded = foundry.utils.expandObject(formData);
expanded.results = expanded.hasOwnProperty("results") ? Object.values(expanded.results) : [];
for (let r of expanded.results) {
r.range = [r.rangeL, r.rangeH];
switch (r.type) {
// Document results
case CONST.TABLE_RESULT_TYPES.DOCUMENT:
const collection = game.collections.get(r.documentCollection);
if (!collection) continue;
// Get the original document, if the name still matches - take no action
const original = r.documentId ? collection.get(r.documentId) : null;
if (original && (original.name === r.text)) continue;
// Otherwise, find the document by ID or name (ID preferred)
const doc = collection.find(e => (e.id === r.text) || (e.name === r.text)) || null;
r.documentId = doc?.id ?? null;
r.text = doc?.name ?? null;
r.img = doc?.img ?? null;
r.img = doc?.thumb || doc?.img || null;
break;
// Compendium results
case CONST.TABLE_RESULT_TYPES.COMPENDIUM:
const pack = game.packs.get(r.documentCollection);
if (pack) {
// Get the original entry, if the name still matches - take no action
const original = pack.index.get(r.documentId) || null;
if (original && (original.name === r.text)) continue;
// Otherwise, find the document by ID or name (ID preferred)
const doc = pack.index.find(i => (i._id === r.text) || (i.name === r.text)) || null;
r.documentId = doc?._id || null;
r.text = doc?.name || null;
r.img = doc?.thumb || doc?.img || null;
}
break;
// Plain text results
default:
r.type = 0;
r.documentCollection = null;
r.documentId = null;
}
}
// Update the object
return this.document.update(expanded, {diff: false, recursive: false});
}
/* -------------------------------------------- */
/**
* Display a roulette style animation when a Roll Table result is drawn from the sheet
* @param {TableResult[]} results An Array of drawn table results to highlight
* @returns {Promise} A Promise which resolves once the animation is complete
* @protected
*/
async _animateRoll(results) {
// Get the list of results and their indices
const tableResults = this.element[0].querySelector(".table-results > tbody");
const drawnIds = new Set(results.map(r => r.id));
const drawnItems = Array.from(tableResults.children).filter(item => drawnIds.has(item.dataset.resultId));
// Set the animation timing
const nResults = this.object.results.size;
const maxTime = 2000;
let animTime = 50;
let animOffset = Math.round(tableResults.offsetHeight / (tableResults.children[0].offsetHeight * 2));
const nLoops = Math.min(Math.ceil(maxTime/(animTime * nResults)), 4);
if ( nLoops === 1 ) animTime = maxTime / nResults;
// Animate the roulette
await this._animateRoulette(tableResults, drawnIds, nLoops, animTime, animOffset);
// Flash the results
const flashes = drawnItems.map(li => this._flashResult(li));
return Promise.all(flashes);
}
/* -------------------------------------------- */
/**
* Animate a "roulette" through the table until arriving at the final loop and a drawn result
* @param {HTMLOListElement} ol The list element being iterated
* @param {Set<string>} drawnIds The result IDs which have already been drawn
* @param {number} nLoops The number of times to loop through the animation
* @param {number} animTime The desired animation time in milliseconds
* @param {number} animOffset The desired pixel offset of the result within the list
* @returns {Promise} A Promise that resolves once the animation is complete
* @protected
*/
async _animateRoulette(ol, drawnIds, nLoops, animTime, animOffset) {
let loop = 0;
let idx = 0;
let item = null;
return new Promise(resolve => {
let animId = setInterval(() => {
if (idx === 0) loop++;
if (item) item.classList.remove("roulette");
// Scroll to the next item
item = ol.children[idx];
ol.scrollTop = (idx - animOffset) * item.offsetHeight;
// If we are on the final loop
if ( (loop === nLoops) && drawnIds.has(item.dataset.resultId) ) {
clearInterval(animId);
return resolve();
}
// Continue the roulette and cycle the index
item.classList.add("roulette");
idx = idx < ol.children.length - 1 ? idx + 1 : 0;
}, animTime);
});
}
/* -------------------------------------------- */
/**
* Display a flashing animation on the selected result to emphasize the draw
* @param {HTMLElement} item The HTML &lt;li> item of the winning result
* @returns {Promise} A Promise that resolves once the animation is complete
* @protected
*/
async _flashResult(item) {
return new Promise(resolve => {
let count = 0;
let animId = setInterval(() => {
if (count % 2) item.classList.remove("roulette");
else item.classList.add("roulette");
if (count === 7) {
clearInterval(animId);
resolve();
}
count++;
}, 50);
});
}
}
/**
* The Application responsible for configuring a single Scene document.
* @extends {DocumentSheet}
* @param {Scene} object The Scene Document which is being configured
* @param {DocumentSheetOptions} [options] Application configuration options.
*/
class SceneConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "scene-config",
classes: ["sheet", "scene-sheet"],
template: "templates/scene/config.html",
width: 560,
height: "auto",
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "basic"}]
});
}
/* -------------------------------------------- */
/**
* Indicates if width / height should change together to maintain aspect ratio
* @type {boolean}
*/
linkedDimensions = true;
/* -------------------------------------------- */
/** @override */
get title() {
return `${game.i18n.localize("SCENES.ConfigTitle")}: ${this.object.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
this._resetScenePreview();
return super.close(options);
}
/* -------------------------------------------- */
/** @inheritdoc */
render(force, options={}) {
if ( options.renderContext && (options.renderContext !== "updateScene" ) ) return this;
return super.render(force, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
// Selectable types
context.gridTypes = this.constructor._getGridTypes();
context.weatherTypes = this._getWeatherTypes();
// Referenced documents
context.playlists = this._getDocuments(game.playlists);
context.sounds = this._getDocuments(this.object.playlist?.sounds ?? []);
context.journals = this._getDocuments(game.journal);
context.pages = this.object.journal?.pages.contents.sort((a, b) => a.sort - b.sort) ?? [];
// Global illumination threshold
context.hasGlobalThreshold = context.data.globalLightThreshold !== null;
context.data.globalLightThreshold = context.data.globalLightThreshold ?? 0;
return context;
}
/* -------------------------------------------- */
/**
* Get an enumeration of the available grid types which can be applied to this Scene
* @returns {object}
* @internal
*/
static _getGridTypes() {
const labels = {
GRIDLESS: "SCENES.GridGridless",
SQUARE: "SCENES.GridSquare",
HEXODDR: "SCENES.GridHexOddR",
HEXEVENR: "SCENES.GridHexEvenR",
HEXODDQ: "SCENES.GridHexOddQ",
HEXEVENQ: "SCENES.GridHexEvenQ"
};
return Object.keys(CONST.GRID_TYPES).reduce((obj, t) => {
obj[CONST.GRID_TYPES[t]] = labels[t];
return obj;
}, {});
}
/* -------------------------------------------- */
/**
* Get the available weather effect types which can be applied to this Scene
* @returns {object}
* @private
*/
_getWeatherTypes() {
const types = {};
for ( let [k, v] of Object.entries(CONFIG.weatherEffects) ) {
types[k] = game.i18n.localize(v.label);
}
return types;
}
/* -------------------------------------------- */
/**
* Get the alphabetized Documents which can be chosen as a configuration for the Scene
* @param {WorldCollection} collection
* @returns {object[]}
* @private
*/
_getDocuments(collection) {
const documents = collection.map(doc => {
return {id: doc.id, name: doc.name};
});
documents.sort((a, b) => a.name.localeCompare(b.name));
return documents;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("button.capture-position").click(this._onCapturePosition.bind(this));
html.find("button.grid-config").click(this._onGridConfig.bind(this));
html.find("button.dimension-link").click(this._onLinkDimensions.bind(this));
html.find("select[name='playlist']").change(this._onChangePlaylist.bind(this));
html.find('select[name="journal"]').change(this._onChangeJournal.bind(this));
}
/* -------------------------------------------- */
/**
* Capture the current Scene position and zoom level as the initial view in the Scene config
* @param {Event} event The originating click event
* @private
*/
_onCapturePosition(event) {
event.preventDefault();
if ( !canvas.ready ) return;
const btn = event.currentTarget;
const form = btn.form;
form["initial.x"].value = parseInt(canvas.stage.pivot.x);
form["initial.y"].value = parseInt(canvas.stage.pivot.y);
form["initial.scale"].value = canvas.stage.scale.x;
ui.notifications.info("Captured canvas position as initial view in the Scene configuration form.");
}
/* -------------------------------------------- */
/**
* Handle click events to open the grid configuration application
* @param {Event} event The originating click event
* @private
*/
async _onGridConfig(event) {
event.preventDefault();
if ( !this.object.isView ) await this.object.view();
new GridConfig(this.object, this).render(true);
return this.minimize();
}
/* -------------------------------------------- */
/**
* Handle click events to link or unlink the scene dimensions
* @param {Event} event
* @returns {Promise<void>}
* @private
*/
async _onLinkDimensions(event) {
event.preventDefault();
this.linkedDimensions = !this.linkedDimensions;
this.element.find("button.dimension-link > i").toggleClass("fa-link-simple", this.linkedDimensions);
this.element.find("button.dimension-link > i").toggleClass("fa-link-simple-slash", !this.linkedDimensions);
this.element.find("button.resize").attr("disabled", !this.linkedDimensions);
// Update Tooltip
const tooltip = game.i18n.localize(this.linkedDimensions ? "SCENES.DimensionLinked" : "SCENES.DimensionUnlinked");
this.element.find("button.dimension-link").attr("data-tooltip", tooltip);
game.tooltip.activate(this.element.find("button.dimension-link")[0], { text: tooltip });
}
/* -------------------------------------------- */
/** @override */
async _onChangeInput(event) {
this._previewScene(event.target.name);
if ( event.target.name === "width" || event.target.name === "height" ) this._onChangeDimensions(event);
return super._onChangeInput(event);
}
/* -------------------------------------------- */
/** @override */
_onChangeColorPicker(event) {
super._onChangeColorPicker(event);
this._previewScene(event.target.dataset.edit);
}
/* -------------------------------------------- */
/** @override */
_onChangeRange(event) {
super._onChangeRange(event);
this._previewScene(event.target.name);
}
/* -------------------------------------------- */
/**
* Live update the scene as certain properties are changed.
* @param {string} changed The changed property.
* @private
*/
_previewScene(changed) {
if ( !this.object.isView || !canvas.ready ) return;
if ( ["grid.color", "grid.alpha"].includes(changed) ) canvas.grid.grid.draw({
color: this.form["grid.color"].value.replace("#", "0x"),
alpha: Number(this.form["grid.alpha"].value)
});
if ( ["darkness", "backgroundColor", "fogExploredColor", "fogUnexploredColor"].includes(changed) ) {
canvas.colorManager.initialize({
backgroundColor: this.form.backgroundColor.value,
darknessLevel: Number(this.form.darkness.value),
fogExploredColor: this.form.fogExploredColor.value,
fogUnexploredColor: this.form.fogUnexploredColor.value
});
}
}
/* -------------------------------------------- */
/**
* Reset the previewed darkness level, background color, grid alpha, and grid color back to their true values.
* @private
*/
_resetScenePreview() {
if ( !this.object.isView || !canvas.ready ) return;
const scene = canvas.scene;
let gridChanged = (this.form["grid.color"].value !== scene.grid.color)
|| (this.form["grid.alpha"].value !== scene.grid.alpha);
scene.reset();
canvas.colorManager.initialize();
if ( gridChanged ) canvas.grid.grid.draw();
}
/* -------------------------------------------- */
/**
* Handle updating the select menu of PlaylistSound options when the Playlist is changed
* @param {Event} event The initiating select change event
* @private
*/
_onChangePlaylist(event) {
event.preventDefault();
const playlist = game.playlists.get(event.target.value);
const sounds = this._getDocuments(playlist?.sounds || []);
const options = ['<option value=""></option>'].concat(sounds.map(s => {
return `<option value="${s.id}">${s.name}</option>`;
}));
const select = this.form.querySelector("select[name=\"playlistSound\"]");
select.innerHTML = options.join("");
}
/* -------------------------------------------- */
/**
* Handle updating the select menu of JournalEntryPage options when the JournalEntry is changed.
* @param {Event} event The initiating select change event.
* @protected
*/
_onChangeJournal(event) {
event.preventDefault();
const entry = game.journal.get(event.currentTarget.value);
const pages = entry?.pages.contents.sort((a, b) => a.sort - b.sort) ?? [];
const options = pages.map(page => {
const selected = (entry.id === this.object.journal?.id) && (page.id === this.object.journalEntryPage);
return `<option value="${page.id}"${selected ? " selected" : ""}>${page.name}</option>`;
});
this.form.elements.journalEntryPage.innerHTML = `<option></option>${options}`;
}
/* -------------------------------------------- */
/**
* Handle updating the select menu of JournalEntryPage options when the JournalEntry is changed.
* @param event
* @private
*/
_onChangeDimensions(event) {
event.preventDefault();
if ( !this.linkedDimensions ) return;
const name = event.currentTarget.name;
const value = Number(event.currentTarget.value);
const oldValue = name === "width" ? this.object.width : this.object.height;
const scale = value / oldValue;
const otherInput = this.form.elements[name === "width" ? "height" : "width"];
otherInput.value = otherInput.value * scale;
// If new value is not a round number, display an error and revert
if ( !Number.isInteger(parseFloat(otherInput.value)) ) {
ui.notifications.error(game.i18n.localize("SCENES.InvalidDimension"));
this.form.elements[name].value = oldValue;
otherInput.value = name === "width" ? this.object.height : this.object.width;
return;
}
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
const scene = this.document;
// Toggle global illumination threshold
if ( formData.hasGlobalThreshold === false ) formData.globalLightThreshold = null;
delete formData.hasGlobalThreshold;
// SceneData.texture.src is nullable in the schema, causing an empty string to be initialised to null. We need to
// match that logic here to ensure that comparisons to the existing scene image are accurate.
if ( formData["background.src"] === "" ) formData["background.src"] = null;
if ( formData.foreground === "" ) formData.foreground = null;
if ( formData.fogOverlay === "" ) formData.fogOverlay = null;
// The same for fog colors
if ( formData.fogUnexploredColor === "" ) formData.fogUnexploredColor = null;
if ( formData.fogExploredColor === "" ) formData.fogExploredColor = null;
// Determine what type of change has occurred
const hasDefaultDims = (scene.background.src === null) && (scene.width === 4000) && (scene.height === 3000);
const hasImage = formData["background.src"] || scene.background.src;
const changedBackground =
(formData["background.src"] !== undefined) && (formData["background.src"] !== scene.background.src);
const clearedDims = (formData.width === null) || (formData.height === null);
const needsThumb = changedBackground || !scene.thumb;
const needsDims = formData["background.src"] && (clearedDims || hasDefaultDims);
const createThumbnail = hasImage && (needsThumb || needsDims);
// Update thumbnail and image dimensions
if ( createThumbnail && game.settings.get("core", "noCanvas") ) {
ui.notifications.warn("SCENES.GenerateThumbNoCanvas", {localize: true});
formData.thumb = null;
} else if ( createThumbnail ) {
let td = {};
try {
td = await scene.createThumbnail({img: formData["background.src"] ?? scene.background.src});
} catch(err) {
Hooks.onError("SceneConfig#_updateObject", err, {
msg: "Thumbnail generation for Scene failed",
notify: "error",
log: "error",
scene: scene.id
});
}
if ( needsThumb ) formData.thumb = td.thumb || null;
if ( needsDims ) {
formData.width = td.width;
formData.height = td.height;
}
}
// Warn the user if Scene dimensions are changing
const delta = foundry.utils.diffObject(scene._source, foundry.utils.expandObject(formData));
const changes = foundry.utils.flattenObject(delta);
const textureChange = ["scaleX", "scaleY", "rotation"].map(k => `background.${k}`);
if ( ["grid.size", ...textureChange].some(k => k in changes) ) {
const confirm = await Dialog.confirm({
title: game.i18n.localize("SCENES.DimensionChangeTitle"),
content: `<p>${game.i18n.localize("SCENES.DimensionChangeWarning")}</p>`
});
if ( !confirm ) return;
}
// If the canvas size has changed in a nonuniform way, ask the user if they want to reposition
let autoReposition = false;
if ( (scene.background?.src || scene.foreground?.src) && (["width", "height", "padding", "background", "grid.size"].some(x => x in changes)) ) {
autoReposition = true;
// If aspect ratio changes, prompt to replace all tokens with new dimensions and warn about distortions
let showPrompt = false;
if ( "width" in changes && "height" in changes ) {
const currentScale = this.object.width / this.object.height;
const newScale = formData.width / formData.height;
if ( currentScale !== newScale ) {
showPrompt = true;
}
}
else if ( "width" in changes || "height" in changes ) {
showPrompt = true;
}
if ( showPrompt ) {
const confirm = await Dialog.confirm({
title: game.i18n.localize("SCENES.DistortedDimensionsTitle"),
content: game.i18n.localize("SCENES.DistortedDimensionsWarning"),
defaultYes: false
});
if ( !confirm ) autoReposition = false;
}
}
// Perform the update
return scene.update(formData, {autoReposition});
}
}
/**
* Document Sheet Configuration Application
*/
class DocumentSheetConfig extends FormApplication {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["form", "sheet-config"],
template: "templates/sheets/sheet-config.html",
width: 400
});
}
/**
* An array of pending sheet assignments which are submitted before other elements of the framework are ready.
* @type {object[]}
* @private
*/
static #pending = [];
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
const name = this.object.name ?? game.i18n.localize(this.object.constructor.metadata.label);
return `${name}: Sheet Configuration`;
}
/* -------------------------------------------- */
/** @inheritDoc */
getData(options={}) {
const {sheetClasses, defaultClasses, defaultClass} = this.constructor.getSheetClassesForSubType(
this.object.documentName,
this.object.type || CONST.BASE_DOCUMENT_TYPE
);
// Return data
return {
isGM: game.user.isGM,
object: this.object.toObject(),
options: this.options,
sheetClass: this.object.getFlag("core", "sheetClass") ?? "",
blankLabel: game.i18n.localize("SHEETS.DefaultSheet"),
sheetClasses, defaultClass, defaultClasses
};
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
event.preventDefault();
const original = this.getData({});
const defaultSheetChanged = formData.defaultClass !== original.defaultClass;
const documentSheetChanged = formData.sheetClass !== original.sheetClass;
// Update world settings
if ( game.user.isGM && defaultSheetChanged ) {
const setting = game.settings.get("core", "sheetClasses") || {};
const type = this.object.type || CONST.BASE_DOCUMENT_TYPE;
foundry.utils.mergeObject(setting, {[`${this.object.documentName}.${type}`]: formData.defaultClass});
await game.settings.set("core", "sheetClasses", setting);
// Trigger a sheet change manually if it wouldn't be triggered by the normal ClientDocument#_onUpdate workflow.
if ( !documentSheetChanged ) return this.object._onSheetChange({ sheetOpen: true });
}
// Update the document-specific override
if ( documentSheetChanged ) return this.object.setFlag("core", "sheetClass", formData.sheetClass);
}
/* -------------------------------------------- */
/**
* Marshal information on the available sheet classes for a given document type and sub-type, and format it for
* display.
* @param {string} documentName The Document type.
* @param {string} subType The Document sub-type.
* @returns {{sheetClasses: object, defaultClasses: object, defaultClass: string}}
*/
static getSheetClassesForSubType(documentName, subType) {
const config = CONFIG[documentName];
const defaultClasses = {};
let defaultClass = null;
const sheetClasses = Object.values(config.sheetClasses[subType]).reduce((obj, cfg) => {
if ( cfg.canConfigure ) obj[cfg.id] = cfg.label;
if ( cfg.default && !defaultClass ) defaultClass = cfg.id;
if ( cfg.canConfigure && cfg.canBeDefault ) defaultClasses[cfg.id] = cfg.label;
return obj;
}, {});
return {sheetClasses, defaultClasses, defaultClass};
}
/* -------------------------------------------- */
/* Configuration Methods
/* -------------------------------------------- */
/**
* Initialize the configured Sheet preferences for Documents which support dynamic Sheet assignment
* Create the configuration structure for supported documents
* Process any pending sheet registrations
* Update the default values from settings data
*/
static initializeSheets() {
for ( let cls of Object.values(foundry.documents) ) {
const types = this._getDocumentTypes(cls);
CONFIG[cls.documentName].sheetClasses = types.reduce((obj, type) => {
obj[type] = {};
return obj;
}, {});
}
// Register any pending sheets
this.#pending.forEach(p => {
if ( p.action === "register" ) this.#registerSheet(p);
else if ( p.action === "unregister" ) this.#unregisterSheet(p);
});
this.#pending = [];
// Update default sheet preferences
const defaults = game.settings.get("core", "sheetClasses");
this.updateDefaultSheets(defaults);
}
/* -------------------------------------------- */
static _getDocumentTypes(cls, types=[]) {
if ( types.length ) return types;
return game.documentTypes[cls.documentName];
}
/* -------------------------------------------- */
/**
* Register a sheet class as a candidate which can be used to display documents of a given type
* @param {typeof ClientDocument} documentClass The Document class for which to register a new Sheet option
* @param {string} scope Provide a unique namespace scope for this sheet
* @param {typeof DocumentSheet} sheetClass A defined Application class used to render the sheet
* @param {object} [config] Additional options used for sheet registration
* @param {string|Function} [config.label] A human-readable label for the sheet name, which will be localized
* @param {string[]} [config.types] An array of document types for which this sheet should be used
* @param {boolean} [config.makeDefault=false] Whether to make this sheet the default for provided types
* @param {boolean} [config.canBeDefault=true] Whether this sheet is available to be selected as a default sheet for
* all Documents of that type.
* @param {boolean} [config.canConfigure=true] Whether this sheet appears in the sheet configuration UI for users.
*/
static registerSheet(documentClass, scope, sheetClass, {
label, types, makeDefault=false, canBeDefault=true, canConfigure=true
}={}) {
const id = `${scope}.${sheetClass.name}`;
const config = {documentClass, id, label, sheetClass, types, makeDefault, canBeDefault, canConfigure};
if ( game.ready ) this.#registerSheet(config);
else {
config.action = "register";
this.#pending.push(config);
}
}
/**
* Perform the sheet registration.
* @param {object} config Configuration for how the sheet should be registered
* @param {typeof ClientDocument} config.documentClass The Document class being registered
* @param {string} config.id The sheet ID being registered
* @param {string} config.label The human-readable sheet label
* @param {typeof DocumentSheet} config.sheetClass The sheet class definition being registered
* @param {object[]} config.types An array of types for which this sheet is added
* @param {boolean} config.makeDefault Make this sheet the default for provided types?
* @param {boolean} config.canBeDefault Whether this sheet is available to be selected as a default
* sheet for all Documents of that type.
* @param {boolean} config.canConfigure Whether the sheet appears in the sheet configuration UI for
* users.
*/
static #registerSheet({documentClass, id, label, sheetClass, types, makeDefault, canBeDefault, canConfigure}={}) {
types = this._getDocumentTypes(documentClass, types);
const classes = CONFIG[documentClass.documentName]?.sheetClasses;
const defaults = game.ready ? game.settings.get("core", "sheetClasses") : {};
if ( typeof classes !== "object" ) return;
for ( const t of types ) {
classes[t] ||= {};
const existingDefault = defaults[documentClass.documentName]?.[t];
const isDefault = existingDefault ? (existingDefault === id) : makeDefault;
if ( isDefault ) Object.values(classes[t]).forEach(s => s.default = false);
if ( label instanceof Function ) label = label();
else if ( label ) label = game.i18n.localize(label);
else label = id;
classes[t][id] = {
id, label, canBeDefault, canConfigure,
cls: sheetClass,
default: isDefault
};
}
}
/* -------------------------------------------- */
/**
* Unregister a sheet class, removing it from the list of available Applications to use for a Document type
* @param {typeof ClientDocument} documentClass The Document class for which to register a new Sheet option
* @param {string} scope Provide a unique namespace scope for this sheet
* @param {typeof DocumentSheet} sheetClass A defined DocumentSheet subclass used to render the sheet
* @param {object} [config]
* @param {object[]} [config.types] An Array of types for which this sheet should be removed
*/
static unregisterSheet(documentClass, scope, sheetClass, {types}={}) {
const id = `${scope}.${sheetClass.name}`;
const config = {documentClass, id, types};
if ( game.ready ) this.#unregisterSheet(config);
else {
config.action = "unregister";
this.#pending.push(config);
}
}
/**
* Perform the sheet de-registration.
* @param {object} config Configuration for how the sheet should be un-registered
* @param {typeof ClientDocument} config.documentClass The Document class being unregistered
* @param {string} config.id The sheet ID being unregistered
* @param {object[]} config.types An array of types for which this sheet is removed
*/
static #unregisterSheet({documentClass, id, types}={}) {
types = this._getDocumentTypes(documentClass, types);
const classes = CONFIG[documentClass.documentName]?.sheetClasses;
if ( typeof classes !== "object" ) return;
for ( let t of types ) {
delete classes[t][id];
}
}
/* -------------------------------------------- */
/**
* Update the current default Sheets using a new core world setting.
* @param {object} setting
*/
static updateDefaultSheets(setting={}) {
if ( !Object.keys(setting).length ) return;
for ( let cls of Object.values(foundry.documents) ) {
const documentName = cls.documentName;
const cfg = CONFIG[documentName];
const classes = cfg.sheetClasses;
const collection = cfg.collection?.instance ?? [];
const defaults = setting[documentName];
if ( !defaults ) continue;
// Update default preference for registered sheets
for ( let [type, sheetId] of Object.entries(defaults) ) {
const sheets = Object.values(classes[type] || {});
let requested = sheets.find(s => s.id === sheetId);
if ( requested ) sheets.forEach(s => s.default = s.id === sheetId);
}
// Close and de-register any existing sheets
for ( let document of collection ) {
for ( const [id, app] of Object.entries(document.apps) ) {
app.close();
delete document.apps[id];
}
document._sheet = null;
}
}
}
/* -------------------------------------------- */
/**
* Initialize default sheet configurations for all document types.
* @private
*/
static _registerDefaultSheets() {
const defaultSheets = {
// Documents
Actor: ActorSheet,
Adventure: AdventureImporter,
Folder: FolderConfig,
Item: ItemSheet,
JournalEntry: JournalSheet,
Macro: MacroConfig,
Playlist: PlaylistConfig,
RollTable: RollTableConfig,
Scene: SceneConfig,
User: UserConfig,
// Embedded Documents
ActiveEffect: ActiveEffectConfig,
AmbientLight: AmbientLightConfig,
AmbientSound: AmbientSoundConfig,
Card: CardConfig,
Combatant: CombatantConfig,
Drawing: DrawingConfig,
MeasuredTemplate: MeasuredTemplateConfig,
Note: NoteConfig,
PlaylistSound: PlaylistSoundConfig,
Tile: TileConfig,
Token: TokenConfig,
Wall: WallConfig
};
Object.values(foundry.documents).forEach(base => {
const type = base.documentName;
const cfg = CONFIG[type];
cfg.sheetClasses = {};
const defaultSheet = defaultSheets[type];
if ( !defaultSheet ) return;
DocumentSheetConfig.registerSheet(cfg.documentClass, "core", defaultSheet, {
makeDefault: true,
label: () => game.i18n.format("SHEETS.DefaultDocumentSheet", {document: game.i18n.localize(`DOCUMENT.${type}`)})
});
});
DocumentSheetConfig.registerSheet(Cards, "core", CardsConfig, {
label: "CARDS.CardsDeck",
types: ["deck"],
makeDefault: true
});
DocumentSheetConfig.registerSheet(Cards, "core", CardsHand, {
label: "CARDS.CardsHand",
types: ["hand"],
makeDefault: true
});
DocumentSheetConfig.registerSheet(Cards, "core", CardsPile, {
label: "CARDS.CardsPile",
types: ["pile"],
makeDefault: true
});
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalTextTinyMCESheet, {
types: ["text"],
label: () => game.i18n.localize("EDITOR.TinyMCE")
});
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalImagePageSheet, {
types: ["image"],
makeDefault: true,
label: () =>
game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {page: game.i18n.localize("JOURNALENTRYPAGE.TypeImage")})
});
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalVideoPageSheet, {
types: ["video"],
makeDefault: true,
label: () =>
game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {page: game.i18n.localize("JOURNALENTRYPAGE.TypeVideo")})
});
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalPDFPageSheet, {
types: ["pdf"],
makeDefault: true,
label: () =>
game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {page: game.i18n.localize("JOURNALENTRYPAGE.TypePDF")})
});
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalTextPageSheet, {
types: ["text"],
makeDefault: true,
label: () => {
return game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {
page: game.i18n.localize("JOURNALENTRYPAGE.TypeText")
});
}
});
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", MarkdownJournalPageSheet, {
types: ["text"],
label: () => game.i18n.localize("EDITOR.Markdown")
});
}
}
/**
* The Application responsible for configuring a single User document.
* @extends {DocumentSheet}
*
* @param {User} user The User document being configured.
* @param {DocumentSheetOptions} [options] Additional rendering options which modify the behavior of the form.
*/
class UserConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "user-config"],
template: "templates/user/user-config.html",
width: 400,
height: "auto"
})
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return `${game.i18n.localize("PLAYERS.ConfigTitle")}: ${this.object.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const controlled = game.users.reduce((arr, u) => {
if ( u.character ) arr.push(u.character);
return arr;
}, []);
const actors = game.actors.filter(a => a.testUserPermission(this.object, "OBSERVER") && !controlled.includes(a.id));
return {
user: this.object,
actors: actors,
options: this.options
};
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
// When a character is clicked, record it's ID in the hidden input
let input = html.find('[name="character"]');
html.find('.actor').click(ev => {
// Record the selected actor
let li = ev.currentTarget;
let actorId = li.getAttribute("data-actor-id");
input.val(actorId);
// Add context to the selection
for ( let a of html[0].getElementsByClassName("actor") ) {
a.classList.remove("context");
}
li.classList.add("context");
});
// Release the currently selected character
html.find('button[name="release"]').click(ev => {
canvas.tokens?.releaseAll();
this.object.update({character: null}).then(() => this.render(false));
});
// Support Image updates
html.find('img[data-edit="avatar"]').click(ev => this._onEditAvatar(ev));
}
/* -------------------------------------------- */
/**
* Handle changing the user avatar image by opening a FilePicker
* @private
*/
_onEditAvatar(event) {
event.preventDefault();
const fp = new FilePicker({
type: "image",
current: this.object.avatar,
callback: path => {
event.currentTarget.src = path;
return this._onSubmit(event, {preventClose: true});
},
top: this.position.top + 40,
left: this.position.left + 10
});
return fp.browse();
}
}
/**
* @typedef {Option} ChatBubbleOptions
* @property {string[]} [cssClasses] An optional array of CSS classes to apply to the resulting bubble
* @property {boolean} [pan=true] Pan to the token speaker for this bubble, if allowed by the client
* @property {boolean} [requireVisible=false] Require that the token be visible in order for the bubble to be rendered
*/
/**
* The Chat Bubble Class
* This application displays a temporary message sent from a particular Token in the active Scene.
* The message is displayed on the HUD layer just above the Token.
*/
class ChatBubbles {
constructor() {
this.template = "templates/hud/chat-bubble.html";
/**
* Track active Chat Bubbles
* @type {object}
*/
this.bubbles = {};
/**
* Track which Token was most recently panned to highlight
* Use this to avoid repeat panning
* @type {Token}
* @private
*/
this._panned = null;
}
/* -------------------------------------------- */
/**
* A reference to the chat bubbles HTML container in which rendered bubbles should live
* @returns {jQuery}
*/
get container() {
return $("#chat-bubbles");
}
/* -------------------------------------------- */
/**
* Create a chat bubble message for a certain token which is synchronized for display across all connected clients.
* @param {TokenDocument} token The speaking Token Document
* @param {string} message The spoken message text
* @param {ChatBubbleOptions} [options] Options which affect the bubble appearance
* @returns {Promise<jQuery|null>} A promise which resolves with the created bubble HTML, or null
*/
async broadcast(token, message, options={}) {
if ( token instanceof Token ) token = token.document;
if ( !(token instanceof TokenDocument) || !message ) {
throw new Error("You must provide a Token instance and a message string");
}
game.socket.emit("chatBubble", {
sceneId: token.parent.id,
tokenId: token.id,
message,
options
});
return this.say(token.object, message, options);
}
/* -------------------------------------------- */
/**
* Speak a message as a particular Token, displaying it as a chat bubble
* @param {Token} token The speaking Token
* @param {string} message The spoken message text
* @param {ChatBubbleOptions} [options] Options which affect the bubble appearance
* @returns {Promise<jQuery|null>} A Promise which resolves to the created bubble HTML element, or null
*/
async say(token, message, {cssClasses, requireVisible = false, pan = true} = {}) {
// Ensure that a bubble is allowed for this token
if ( !token || !message ) return null;
let allowBubbles = game.settings.get("core", "chatBubbles");
if ( !allowBubbles ) return null;
if ( requireVisible && !token.visible ) return null;
// Clear any existing bubble for the speaker
await this._clearBubble(token);
// Create the HTML and call the chatBubble hook
const actor = ChatMessage.implementation.getSpeakerActor({scene: token.scene.id, token: token.id});
message = await TextEditor.enrichHTML(message, { async: true, rollData: actor?.getRollData() });
let html = $(await this._renderHTML({
token, message, cssClasses
}));
const allowed = Hooks.call("chatBubble", token, html, message, {cssClasses, pan});
if ( allowed === false ) return null;
// Set initial dimensions
let dimensions = this._getMessageDimensions(message);
this._setPosition(token, html, dimensions);
// Append to DOM
this.container.append(html);
// Optionally pan to the speaker
const panToSpeaker = game.settings.get("core", "chatBubblesPan") && pan && (this._panned !== token);
const promises = [];
if ( panToSpeaker ) {
const scale = Math.max(1, canvas.stage.scale.x);
promises.push(canvas.animatePan({x: token.document.x, y: token.document.y, scale, duration: 1000}));
this._panned = token;
}
// Get animation duration and settings
const duration = this._getDuration(html);
const scroll = dimensions.unconstrained - dimensions.height;
// Animate the bubble
promises.push(new Promise(resolve => {
html.fadeIn(250, () => {
if ( scroll > 0 ) {
html.find(".bubble-content").animate({top: -1 * scroll}, duration - 1000, "linear", resolve);
}
setTimeout(() => html.fadeOut(250, () => html.remove()), duration);
});
}));
// Return the chat bubble HTML after all animations have completed
await Promise.all(promises);
return html;
}
/* -------------------------------------------- */
/**
* Activate Socket event listeners which apply to the ChatBubbles UI.
* @param {Socket} socket The active web socket connection
* @internal
*/
static _activateSocketListeners(socket) {
socket.on("chatBubble", ({sceneId, tokenId, message, options}) => {
if ( !canvas.ready ) return;
const scene = game.scenes.get(sceneId);
if ( !scene?.isView ) return;
const token = scene.tokens.get(tokenId);
if ( !token ) return;
return canvas.hud.bubbles.say(token.object, message, options);
});
}
/* -------------------------------------------- */
/**
* Clear any existing chat bubble for a certain Token
* @param {Token} token
* @private
*/
async _clearBubble(token) {
let existing = $(`.chat-bubble[data-token-id="${token.id}"]`);
if ( !existing.length ) return;
return new Promise(resolve => {
existing.fadeOut(100, () => {
existing.remove();
resolve();
});
});
}
/* -------------------------------------------- */
/**
* Render the HTML template for the chat bubble
* @param {object} data Template data
* @returns {Promise<string>} The rendered HTML
* @private
*/
async _renderHTML(data) {
return renderTemplate(this.template, data);
}
/* -------------------------------------------- */
/**
* Before displaying the chat message, determine it's constrained and unconstrained dimensions
* @param {string} message The message content
* @returns {object} The rendered message dimensions
* @private
*/
_getMessageDimensions(message) {
let div = $(`<div class="chat-bubble" style="visibility:hidden">${message}</div>`);
$("body").append(div);
let dims = {
width: div[0].clientWidth + 8,
height: div[0].clientHeight
};
div.css({maxHeight: "none"});
dims.unconstrained = div[0].clientHeight;
div.remove();
return dims;
}
/* -------------------------------------------- */
/**
* Assign styling parameters to the chat bubble, toggling either a left or right display (randomly)
* @param {Token} token The speaking Token
* @param {JQuery} html Chat bubble content
* @param {Rectangle} dimensions Positioning data
* @private
*/
_setPosition(token, html, dimensions) {
let cls = Math.random() > 0.5 ? "left" : "right";
html.addClass(cls);
const pos = {
height: dimensions.height,
width: dimensions.width,
top: token.y - dimensions.height - 8
};
if ( cls === "right" ) pos.left = token.x - (dimensions.width - token.w);
else pos.left = token.x;
html.css(pos);
}
/* -------------------------------------------- */
/**
* Determine the length of time for which to display a chat bubble.
* Research suggests that average reading speed is 200 words per minute.
* Since these are short-form messages, we multiply reading speed by 1.5.
* Clamp the result between 1 second (minimum) and 20 seconds (maximum)
* @param {jQuery} html The HTML message
* @returns {number} The number of milliseconds for which to display the message
*/
_getDuration(html) {
const words = html.text().split(/\s+/).reduce((n, w) => n + Number(!!w.trim().length), 0);
const ms = (words * 60 * 1000) / 300;
return Math.clamped(1000, ms, 20000);
}
}
/**
* Render the HUD container
* @type {Application}
*/
class HeadsUpDisplay extends Application {
constructor(...args) {
super(...args);
/**
* Token HUD
* @type {TokenHUD}
*/
this.token = new TokenHUD();
/**
* Tile HUD
* @type {TileHUD}
*/
this.tile = new TileHUD();
/**
* Drawing HUD
* @type {DrawingHUD}
*/
this.drawing = new DrawingHUD();
/**
* Chat Bubbles
* @type {ChatBubbles}
*/
this.bubbles = new ChatBubbles();
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.id = "hud";
options.template = "templates/hud/hud.html";
options.popOut = false;
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
if ( !canvas.ready ) return {};
return {
width: canvas.dimensions.width,
height: canvas.dimensions.height
};
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
await super._render(force, options);
this.align();
}
/* -------------------------------------------- */
/**
* Align the position of the HUD layer to the current position of the canvas
*/
align() {
const hud = this.element[0];
const {x, y} = canvas.primary.getGlobalPosition();
const scale = canvas.stage.scale.x;
hud.style.left = `${x}px`;
hud.style.top = `${y}px`;
hud.style.transform = `scale(${scale})`;
}
}
/**
* @typedef {Object} SceneControlTool
* @property {string} name
* @property {string} title
* @property {string} icon
* @property {boolean} visible
* @property {boolean} toggle
* @property {boolean} active
* @property {boolean} button
* @property {Function} onClick
* @property {ToolclipConfiguration} toolclip Configuration for rendering the tool's toolclip.
*/
/**
* @typedef {Object} SceneControl
* @property {string} name
* @property {string} title
* @property {string} layer
* @property {string} icon
* @property {boolean} visible
* @property {SceneControlTool[]} tools
* @property {string} activeTool
*/
/**
* @typedef {object} ToolclipConfiguration
* @property {string} src The filename of the toolclip video.
* @property {string} heading The heading string.
* @property {ToolclipConfigurationItem[]} items The items in the toolclip body.
*/
/**
* @typedef {object} ToolclipConfigurationItem
* @property {string} [paragraph] A plain paragraph of content for this item.
* @property {string} [heading] A heading for the item.
* @property {string} [content] Content for the item.
* @property {string} [reference] If the item is a single key reference, use this instead of content.
*/
/**
* Scene controls navigation menu
* @extends {Application}
*/
class SceneControls extends Application {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
width: 100,
id: "controls",
template: "templates/hud/controls.html",
popOut: false
});
}
/* -------------------------------------------- */
/**
* The Array of Scene Control buttons which are currently rendered
* @type {SceneControl[]}
*/
controls = this._getControlButtons();
/* -------------------------------------------- */
/**
* The currently active control set
* @type {string}
*/
get activeControl() {
return this.#control;
}
#control = "token";
/* -------------------------------------------- */
/**
* The currently active tool in the control palette
* @type {string}
*/
get activeTool() {
return this.#tools[this.#control];
}
/**
* Track which tool is active within each control set
* @type {Object<string, string>}
*/
#tools = {};
/* -------------------------------------------- */
/**
* Return the active control set
* @type {SceneControl|null}
*/
get control() {
if ( !this.controls ) return null;
return this.controls.find(c => c.name === this.#control) || null;
}
/* -------------------------------------------- */
/**
* Return the actively controlled tool
* @type {SceneControlTool|null}
*/
get tool() {
const control = this.control;
if ( !control ) return null;
return this.#tools[control.name] || null;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Initialize the Scene Controls by obtaining the set of control buttons and rendering the HTML
* @param {object} options Options which modify how the controls UI is initialized
* @param {string} [options.control] An optional control set to set as active
* @param {string} [options.layer] An optional layer name to target as the active control
* @param {string} [options.tool] A specific named tool to set as active for the palette
*/
initialize({control, layer, tool} = {}) {
// Determine the control set to activate
let controlSet = control ? this.controls.find(c => c.name === control) : null;
if ( !controlSet && layer ) controlSet = this.controls.find(c => c.layer === layer);
if ( !controlSet ) controlSet = this.control;
// Determine the tool to activate
tool ||= this.#tools[controlSet.name] || controlSet.activeTool;
// Activate the new control scheme
this.#control = controlSet?.name || null;
this.#tools[this.#control] = tool || null;
this.controls = this._getControlButtons();
// Render the UI
this.render(true);
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
const showToolclips = game.settings.get("core", "showToolclips");
const canvasActive = !!canvas.scene;
const controls = [];
for ( const c of this.controls ) {
if ( c.visible === false ) continue;
const control = foundry.utils.deepClone(c);
control.isActive = canvasActive && (this.#control === control.name);
control.css = control.isActive ? "active" : "";
control.tools = [];
for ( const t of c.tools ) {
if ( t.visible === false ) continue;
const tool = foundry.utils.deepClone(t);
tool.isActive = canvasActive && ((this.#tools[control.name] === tool.name) || (tool.toggle && tool.active));
tool.css = [
tool.toggle ? "toggle" : null,
tool.isActive ? "active" : null
].filterJoin(" ");
tool.tooltip = showToolclips && tool.toolclip
? await renderTemplate("templates/hud/toolclip.html", tool.toolclip)
: tool.title;
control.tools.push(tool);
}
if ( control.tools.length ) controls.push(control);
}
// Return data for rendering
return {
controls,
active: canvasActive,
cssClass: canvasActive ? "" : "disabled"
};
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
html.find(".scene-control").click(this._onClickLayer.bind(this));
html.find(".control-tool").click(this._onClickTool.bind(this));
canvas.notes?.hintMapNotes();
}
/* -------------------------------------------- */
/**
* Handle click events on a Control set
* @param {Event} event A click event on a tool control
* @private
*/
_onClickLayer(event) {
event.preventDefault();
if ( !canvas.ready ) return;
const li = event.currentTarget;
const controlName = li.dataset.control;
if ( this.#control === controlName ) return;
this.#control = controlName;
const control = this.controls.find(c => c.name === controlName);
if ( control ) canvas[control.layer].activate();
}
/* -------------------------------------------- */
/**
* Handle click events on Tool controls
* @param {Event} event A click event on a tool control
* @private
*/
_onClickTool(event) {
event.preventDefault();
if ( !canvas.ready ) return;
const li = event.currentTarget;
const control = this.control;
const toolName = li.dataset.tool;
const tool = control.tools.find(t => t.name === toolName);
// Handle Toggles
if ( tool.toggle ) {
tool.active = !tool.active;
if ( tool.onClick instanceof Function ) tool.onClick(tool.active);
}
// Handle Buttons
else if ( tool.button ) {
if ( tool.onClick instanceof Function ) tool.onClick();
}
// Handle Tools
else {
this.#tools[control.name] = toolName;
if ( tool.onClick instanceof Function ) tool.onClick();
}
// Render the controls
this.render();
}
/* -------------------------------------------- */
/**
* Get the set of Control sets and tools that are rendered as the Scene Controls.
* These controls may be extended using the "getSceneControlButtons" Hook.
* @returns {SceneControl[]}
* @private
*/
_getControlButtons() {
const controls = [];
const isGM = game.user.isGM;
const clip = game.settings.get("core", "showToolclips") ? "Clip" : "";
const commonControls = {
create: { heading: "CONTROLS.CommonCreate", reference: "CONTROLS.ClickDrag" },
move: { heading: "CONTROLS.CommonMove", reference: "CONTROLS.Drag" },
edit: { heading: "CONTROLS.CommonEdit", reference: "CONTROLS.DoubleClick" },
editAlt: { heading: "CONTROLS.CommonEdit", reference: "CONTROLS.DoubleRightClick" },
sheet: { heading: "CONTROLS.CommonOpenSheet", reference: "CONTROLS.DoubleClick" },
hide: { heading: "CONTROLS.CommonHide", reference: "CONTROLS.RightClick" },
delete: { heading: "CONTROLS.CommonDelete", reference: "CONTROLS.Delete" },
rotate: { heading: "CONTROLS.CommonRotate", content: "CONTROLS.ShiftOrCtrlScroll" },
select: { heading: "CONTROLS.CommonSelect", reference: "CONTROLS.Click" },
selectAlt: { heading: "CONTROLS.CommonSelect", content: "CONTROLS.ClickOrClickDrag" },
selectMultiple: { heading: "CONTROLS.CommonSelectMultiple", reference: "CONTROLS.ShiftClick" },
hud: { heading: "CONTROLS.CommonToggleHUD", reference: "CONTROLS.RightClick" },
draw: { heading: "CONTROLS.CommonDraw", reference: "CONTROLS.ClickDrag" },
place: { heading: "CONTROLS.CommonPlace", reference: "CONTROLS.ClickDrag" },
chain: { heading: "CONTROLS.CommonChain", content: "CONTROLS.ChainCtrlClick" },
movePoint: { heading: "CONTROLS.CommonMovePoint", reference: "CONTROLS.ClickDrag" },
openClose: { heading: "CONTROLS.CommonOpenClose", reference: "CONTROLS.Click" },
openCloseSilently: { heading: "CONTROLS.CommonOpenCloseSilently", reference: "CONTROLS.AltClick" },
lock: { heading: "CONTROLS.CommonLock", reference: "CONTROLS.RightClick" },
lockSilently: { heading: "CONTROLS.CommonLockSilently", reference: "CONTROLS.AltRightClick" },
onOff: { heading: "CONTROLS.CommonOnOff", reference: "CONTROLS.RightClick" }
};
const buildItems = (...items) => items.map(item => commonControls[item]);
// Token Controls
controls.push({
name: "token",
title: "CONTROLS.GroupToken",
layer: "tokens",
icon: "fas fa-user-alt",
tools: [
{
name: "select",
title: "CONTROLS.BasicSelect",
icon: "fas fa-expand",
toolclip: {
src: "toolclips/tools/token-select.webm",
heading: "CONTROLS.BasicSelect",
items: [
{ paragraph: "CONTROLS.BasicSelectP" },
...buildItems("selectAlt", "selectMultiple", "move", "rotate", "hud", "sheet"),
...(game.user.isGM ? buildItems("editAlt", "delete") : []),
{ heading: "CONTROLS.BasicMeasureStart", reference: "CONTROLS.CtrlClickDrag" },
{ heading: "CONTROLS.BasicMeasureWaypoints", reference: "CONTROLS.CtrlClick" },
{ heading: "CONTROLS.BasicMeasureFollow", reference: "CONTROLS.Spacebar" }
]
}
},
{
name: "target",
title: "CONTROLS.TargetSelect",
icon: "fas fa-bullseye",
toolclip: {
src: "toolclips/tools/token-target.webm",
heading: "CONTROLS.TargetSelect",
items: [
{ paragraph: "CONTROLS.TargetSelectP" },
...buildItems("selectAlt", "selectMultiple")
]
}
},
{
name: "ruler",
title: "CONTROLS.BasicMeasure",
icon: "fas fa-ruler",
toolclip: {
src: "toolclips/tools/token-measure.webm",
heading: "CONTROLS.BasicMeasure",
items: [
{ heading: "CONTROLS.BasicMeasureStart", reference: "CONTROLS.ClickDrag" },
{ heading: "CONTROLS.BasicMeasureWaypoints", reference: "CONTROLS.CtrlClick" },
{ heading: "CONTROLS.BasicMeasureFollow", reference: "CONTROLS.Spacebar" }
]
}
}
],
activeTool: "select"
});
// Measurement Layer Tools
controls.push({
name: "measure",
title: "CONTROLS.GroupMeasure",
layer: "templates",
icon: "fas fa-ruler-combined",
visible: game.user.can("TEMPLATE_CREATE"),
tools: [
{
name: "circle",
title: "CONTROLS.MeasureCircle",
icon: "fa-regular fa-circle",
toolclip: {
src: "toolclips/tools/measure-circle.webm",
heading: "CONTROLS.MeasureCircle",
items: buildItems("create", "move", "edit", "hide", "delete")
}
},
{
name: "cone",
title: "CONTROLS.MeasureCone",
icon: "fa-solid fa-angle-left",
toolclip: {
src: "toolclips/tools/measure-cone.webm",
heading: "CONTROLS.MeasureCone",
items: buildItems("create", "move", "edit", "hide", "delete", "rotate")
}
},
{
name: "rect",
title: "CONTROLS.MeasureRect",
icon: "fa-regular fa-square",
toolclip: {
src: "toolclips/tools/measure-rect.webm",
heading: "CONTROLS.MeasureRect",
items: buildItems("create", "move", "edit", "hide", "delete", "rotate")
}
},
{
name: "ray",
title: "CONTROLS.MeasureRay",
icon: "fa-solid fa-arrows-alt-v",
toolclip: {
src: "toolclips/tools/measure-ray.webm",
heading: "CONTROLS.MeasureRay",
items: buildItems("create", "move", "edit", "hide", "delete", "rotate")
}
},
{
name: "clear",
title: "CONTROLS.MeasureClear",
icon: "fa-solid fa-trash",
visible: isGM,
onClick: () => canvas.templates.deleteAll(),
button: true
}
],
activeTool: "circle"
});
// Tiles Layer
controls.push({
name: "tiles",
title: "CONTROLS.GroupTile",
layer: "tiles",
icon: "fa-solid fa-cubes",
visible: isGM,
tools: [
{
name: "select",
title: "CONTROLS.TileSelect",
icon: "fa-solid fa-expand",
toolclip: {
src: "toolclips/tools/tile-select.webm",
heading: "CONTROLS.TileSelect",
items: buildItems("selectAlt", "selectMultiple", "move", "rotate", "hud", "edit", "delete")
}
},
{
name: "tile",
title: "CONTROLS.TilePlace",
icon: "fa-solid fa-cube",
toolclip: {
src: "toolclips/tools/tile-place.webm",
heading: "CONTROLS.TilePlace",
items: buildItems("create", "move", "rotate", "hud", "edit", "delete")
}
},
{
name: "browse",
title: "CONTROLS.TileBrowser",
icon: "fa-solid fa-folder",
button: true,
onClick: () => {
new FilePicker({
type: "imagevideo",
displayMode: "tiles",
tileSize: true
}).render(true);
},
toolclip: {
src: "toolclips/tools/tile-browser.webm",
heading: "CONTROLS.TileBrowser",
items: buildItems("place", "move", "rotate", "hud", "edit", "delete")
}
},
{
name: "foreground",
title: "CONTROLS.TileForeground",
icon: "fa-solid fa-home",
toggle: true,
active: false,
onClick: active => {
this.control.foreground = active;
canvas.tiles._activateSubLayer(active);
canvas.perception.update({refreshLighting: true, refreshTiles: true});
}
}
],
activeTool: "select"
});
// Drawing Tools
controls.push({
name: "drawings",
title: "CONTROLS.GroupDrawing",
layer: "drawings",
icon: "fa-solid fa-pencil-alt",
visible: game.user.can("DRAWING_CREATE"),
tools: [
{
name: "select",
title: "CONTROLS.DrawingSelect",
icon: "fa-solid fa-expand",
toolclip: {
src: "toolclips/tools/drawing-select.webm",
heading: "CONTROLS.DrawingSelect",
items: buildItems("selectAlt", "selectMultiple", "move", "hud", "edit", "delete", "rotate")
}
},
{
name: "rect",
title: "CONTROLS.DrawingRect",
icon: "fa-solid fa-square",
toolclip: {
src: "toolclips/tools/drawing-rect.webm",
heading: "CONTROLS.DrawingRect",
items: buildItems("draw", "move", "hud", "edit", "delete", "rotate")
}
},
{
name: "ellipse",
title: "CONTROLS.DrawingEllipse",
icon: "fa-solid fa-circle",
toolclip: {
src: "toolclips/tools/drawing-ellipse.webm",
heading: "CONTROLS.DrawingEllipse",
items: buildItems("draw", "move", "hud", "edit", "delete", "rotate")
}
},
{
name: "polygon",
title: "CONTROLS.DrawingPoly",
icon: "fa-solid fa-draw-polygon",
toolclip: {
src: "toolclips/tools/drawing-polygon.webm",
heading: "CONTROLS.DrawingPoly",
items: [
{ heading: "CONTROLS.CommonDraw", content: "CONTROLS.DrawingPolyP" },
...buildItems("move", "hud", "edit", "delete", "rotate")
]
}
},
{
name: "freehand",
title: "CONTROLS.DrawingFree",
icon: "fa-solid fa-signature",
toolclip: {
src: "toolclips/tools/drawing-free.webm",
heading: "CONTROLS.DrawingFree",
items: buildItems("draw", "move", "hud", "edit", "delete", "rotate")
}
},
{
name: "text",
title: "CONTROLS.DrawingText",
icon: "fa-solid fa-font",
onClick: () => {
const controlled = canvas.drawings.controlled;
if ( controlled.length === 1 ) controlled[0].enableTextEditing();
},
toolclip: {
src: "toolclips/tools/drawing-text.webm",
heading: "CONTROLS.DrawingText",
items: buildItems("draw", "move", "hud", "edit", "delete", "rotate")
}
},
{
name: "configure",
title: "CONTROLS.DrawingConfig",
icon: "fa-solid fa-cog",
onClick: () => canvas.drawings.configureDefault(),
button: true
},
{
name: "clear",
title: "CONTROLS.DrawingClear",
icon: "fa-solid fa-trash",
visible: isGM,
onClick: () => canvas.drawings.deleteAll(),
button: true
}
],
activeTool: "select"
});
// Walls Layer Tools
controls.push({
name: "walls",
title: "CONTROLS.GroupWall",
layer: "walls",
icon: "fa-solid fa-block-brick",
visible: isGM,
tools: [
{
name: "select",
title: "CONTROLS.WallSelect",
icon: "fa-solid fa-expand",
toolclip: {
src: "toolclips/tools/wall-select.webm",
heading: "CONTROLS.WallSelect",
items: [
...buildItems("selectAlt", "selectMultiple", "move"),
{ heading: "CONTROLS.CommonMoveWithoutSnapping", reference: "CONTROLS.ShiftDrag" },
{ heading: "CONTROLS.CommonEdit", content: "CONTROLS.WallSelectEdit" },
...buildItems("delete")
]
}
},
{
name: "walls",
title: "CONTROLS.WallDraw",
icon: "fa-solid fa-bars",
toolclip: {
src: "toolclips/tools/wall-basic.webm",
heading: "CONTROLS.WallBasic",
items: [
{ heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallBasicBlocks" },
...buildItems("place", "chain", "movePoint", "edit", "delete")
]
}
},
{
name: "terrain",
title: "CONTROLS.WallTerrain",
icon: "fa-solid fa-mountain",
toolclip: {
src: "toolclips/tools/wall-terrain.webm",
heading: "CONTROLS.WallTerrain",
items: [
{ heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallTerrainBlocks" },
...buildItems("place", "chain", "movePoint", "edit", "delete")
]
}
},
{
name: "invisible",
title: "CONTROLS.WallInvisible",
icon: "fa-solid fa-eye-slash",
toolclip: {
src: "toolclips/tools/wall-invisible.webm",
heading: "CONTROLS.WallInvisible",
items: [
{ heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallInvisibleBlocks" },
...buildItems("place", "chain", "movePoint", "edit", "delete")
]
}
},
{
name: "ethereal",
title: "CONTROLS.WallEthereal",
icon: "fa-solid fa-mask",
toolclip: {
src: "toolclips/tools/wall-ethereal.webm",
heading: "CONTROLS.WallEthereal",
items: [
{ heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallEtherealBlocks" },
...buildItems("place", "chain", "movePoint", "edit", "delete")
]
}
},
{
name: "doors",
title: "CONTROLS.WallDoors",
icon: "fa-solid fa-door-open",
toolclip: {
src: "toolclips/tools/wall-door.webm",
heading: "CONTROLS.WallDoors",
items: [
{ heading: "CONTROLS.CommonBlocks", content: "CONTROLS.DoorBlocks" },
...buildItems("openClose", "openCloseSilently", "lock", "lockSilently", "place", "chain", "movePoint", "edit")
]
}
},
{
name: "secret",
title: "CONTROLS.WallSecret",
icon: "fa-solid fa-user-secret",
toolclip: {
src: "toolclips/tools/wall-secret-door.webm",
heading: "CONTROLS.WallSecret",
items: [
{ heading: "CONTROLS.WallSecretHidden", content: "CONTROLS.WallSecretHiddenP" },
{ heading: "CONTROLS.CommonBlocks", content: "CONTROLS.DoorBlocks" },
...buildItems("openClose", "openCloseSilently", "lock", "lockSilently", "place", "chain", "movePoint", "edit")
]
}
},
{
name: "window",
title: "CONTROLS.WallWindow",
icon: "fa-solid fa-window-frame",
toolclip: {
src: "toolclips/tools/wall-window.webm",
heading: "CONTROLS.WallWindow",
items: [
{ heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallWindowBlocks" },
...buildItems("place", "chain", "movePoint", "edit", "delete")
]
}
},
{
name: "clone",
title: "CONTROLS.WallClone",
icon: "fa-regular fa-clone"
},
{
name: "snap",
title: "CONTROLS.WallSnap",
icon: "fa-solid fa-plus",
toggle: true,
active: canvas.walls?._forceSnap || false,
onClick: toggled => canvas.walls._forceSnap = toggled,
toolclip: {
src: "toolclips/tools/wall-snap.webm",
heading: "CONTROLS.WallSnap",
items: [{ heading: "CONTROLS.WallSnapH", content: "CONTROLS.WallSnapP" }]
}
},
{
name: "close-doors",
title: "CONTROLS.WallCloseDoors",
icon: "fa-regular fa-door-closed",
onClick: () => {
let updates = canvas.walls.placeables.reduce((arr, w) => {
if ( w.isDoor && (w.document.ds === CONST.WALL_DOOR_STATES.OPEN) ) {
arr.push({_id: w.id, ds: CONST.WALL_DOOR_STATES.CLOSED});
}
return arr;
}, []);
if ( !updates.length ) return;
canvas.scene.updateEmbeddedDocuments("Wall", updates, {sound: false});
ui.notifications.info(game.i18n.format("CONTROLS.WallDoorsClosed", {number: updates.length}));
}
},
{
name: "clear",
title: "CONTROLS.WallClear",
icon: "fa-solid fa-trash",
onClick: () => canvas.walls.deleteAll(),
button: true
}
],
activeTool: "walls"
});
// Lighting Layer Tools
controls.push({
name: "lighting",
title: "CONTROLS.GroupLighting",
layer: "lighting",
icon: "fa-regular fa-lightbulb",
visible: isGM,
tools: [
{
name: "light",
title: "CONTROLS.LightDraw",
icon: "fa-solid fa-lightbulb",
toolclip: {
src: "toolclips/tools/light-draw.webm",
heading: "CONTROLS.LightDraw",
items: buildItems("create", "edit", "rotate", "onOff")
}
},
{
name: "day",
title: "CONTROLS.LightDay",
icon: "fa-solid fa-sun",
onClick: () => canvas.scene.update({darkness: 0.0}, {animateDarkness: 10000}),
button: true,
toolclip: {
src: "toolclips/tools/light-day.webm",
heading: "CONTROLS.LightDay",
items: [
{ heading: "CONTROLS.MakeDayH", content: "CONTROLS.MakeDayP" },
{ heading: "CONTROLS.AutoLightToggleH", content: "CONTROLS.AutoLightToggleP" }
]
}
},
{
name: "night",
title: "CONTROLS.LightNight",
icon: "fa-solid fa-moon",
onClick: () => canvas.scene.update({darkness: 1.0}, {animateDarkness: 10000}),
button: true,
toolclip: {
src: "toolclips/tools/light-night.webm",
heading: "CONTROLS.LightNight",
items: [
{ heading: "CONTROLS.MakeNightH", content: "CONTROLS.MakeNightP" },
{ heading: "CONTROLS.AutoLightToggleH", content: "CONTROLS.AutoLightToggleP" }
]
}
},
{
name: "reset",
title: "CONTROLS.LightReset",
icon: "fa-solid fa-cloud",
onClick: () => {
new Dialog({
title: game.i18n.localize("CONTROLS.FOWResetTitle"),
content: `<p>${game.i18n.localize("CONTROLS.FOWResetDesc")}</p>`,
buttons: {
yes: {
icon: '<i class="fa-solid fa-check"></i>',
label: "Yes",
callback: () => canvas.fog.reset()
},
no: {
icon: '<i class="fa-solid fa-times"></i>',
label: "No"
}
}
}).render(true);
},
button: true,
toolclip: {
src: "toolclips/tools/light-reset.webm",
heading: "CONTROLS.LightReset",
items: [{ paragraph: "CONTROLS.LightResetP" }]
}
},
{
name: "clear",
title: "CONTROLS.LightClear",
icon: "fa-solid fa-trash",
onClick: () => canvas.lighting.deleteAll(),
button: true
}
],
activeTool: "light"
});
// Sounds Layer Tools
controls.push({
name: "sounds",
title: "CONTROLS.GroupSound",
layer: "sounds",
icon: "fa-solid fa-music",
visible: isGM,
tools: [
{
name: "sound",
title: "CONTROLS.SoundDraw",
icon: "fa-solid fa-volume-up",
toolclip: {
src: "toolclips/tools/sound-draw.webm",
heading: "CONTROLS.SoundDraw",
items: buildItems("create", "edit", "rotate", "onOff")
}
},
{
name: "preview",
title: `CONTROLS.SoundPreview${clip}`,
icon: "fa-solid fa-headphones",
toggle: true,
active: canvas.sounds?.livePreview ?? false,
onClick: toggled => {
canvas.sounds.livePreview = toggled;
canvas.sounds.refresh();
},
toolclip: {
src: "toolclips/tools/sound-preview.webm",
heading: "CONTROLS.SoundPreview",
items: [{ paragraph: "CONTROLS.SoundPreviewP" }]
}
},
{
name: "clear",
title: "CONTROLS.SoundClear",
icon: "fa-solid fa-trash",
onClick: () => canvas.sounds.deleteAll(),
button: true
}
],
activeTool: "sound"
});
// Notes Layer Tools
controls.push({
name: "notes",
title: "CONTROLS.GroupNotes",
layer: "notes",
icon: "fa-solid fa-bookmark",
tools: [
{
name: "select",
title: "CONTROLS.NoteSelect",
icon: "fa-solid fa-expand"
},
{
name: "journal",
title: "NOTE.Create",
visible: game.user.hasPermission("NOTE_CREATE"),
icon: CONFIG.JournalEntry.sidebarIcon
},
{
name: "toggle",
title: "CONTROLS.NoteToggle",
icon: "fa-solid fa-map-pin",
toggle: true,
active: game.settings.get("core", NotesLayer.TOGGLE_SETTING),
onClick: toggled => game.settings.set("core", NotesLayer.TOGGLE_SETTING, toggled)
},
{
name: "clear",
title: "CONTROLS.NoteClear",
icon: "fa-solid fa-trash",
visible: isGM,
onClick: () => canvas.notes.deleteAll(),
button: true
}
],
activeTool: "select"
});
// Pass the Scene Controls to a hook function to allow overrides or changes
Hooks.callAll("getSceneControlButtons", controls);
return controls;
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
get isRuler() {
return this.activeTool === "ruler";
}
}
/**
* The global action bar displayed at the bottom of the game view.
* The Hotbar is a UI element at the bottom of the screen which contains Macros as interactive buttons.
* The Hotbar supports 5 pages of global macros which can be dragged and dropped to organize as you wish.
*
* Left-clicking a Macro button triggers its effect.
* Right-clicking the button displays a context menu of Macro options.
* The number keys 1 through 0 activate numbered hotbar slots.
* Pressing the delete key while hovering over a Macro will remove it from the bar.
*
* @see {@link Macros}
* @see {@link Macro}
*/
class Hotbar extends Application {
constructor(options) {
super(options);
game.macros.apps.push(this);
/**
* The currently viewed macro page
* @type {number}
*/
this.page = 1;
/**
* The currently displayed set of macros
* @type {Macro[]}
*/
this.macros = [];
/**
* Track collapsed state
* @type {boolean}
*/
this._collapsed = false;
/**
* Track which hotbar slot is the current hover target, if any
* @type {number|null}
*/
this._hover = null;
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
id: "hotbar",
template: "templates/hud/hotbar.html",
popOut: false,
dragDrop: [{ dragSelector: ".macro-icon", dropSelector: "#macro-list" }]
});
}
/* -------------------------------------------- */
/**
* Whether the hotbar is locked.
* @returns {boolean}
*/
get locked() {
return game.settings.get("core", "hotbarLock");
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
this.macros = this._getMacrosByPage(this.page);
return {
page: this.page,
macros: this.macros,
barClass: this._collapsed ? "collapsed" : "",
locked: this.locked
};
}
/* -------------------------------------------- */
/**
* Get the Array of Macro (or null) values that should be displayed on a numbered page of the bar
* @param {number} page
* @returns {Macro[]}
* @private
*/
_getMacrosByPage(page) {
const macros = game.user.getHotbarMacros(page);
for ( let [i, slot] of macros.entries() ) {
slot.key = i<9 ? i+1 : 0;
slot.icon = slot.macro ? slot.macro.img : null;
slot.cssClass = slot.macro ? "active" : "inactive";
slot.tooltip = slot.macro ? slot.macro.name : null;
}
return macros;
}
/* -------------------------------------------- */
/**
* Collapse the Hotbar, minimizing its display.
* @returns {Promise} A promise which resolves once the collapse animation completes
*/
async collapse() {
if ( this._collapsed ) return true;
const toggle = this.element.find("#bar-toggle");
const icon = toggle.children("i");
const bar = this.element.find("#action-bar");
return new Promise(resolve => {
bar.slideUp(200, () => {
bar.addClass("collapsed");
icon.removeClass("fa-caret-down").addClass("fa-caret-up");
this._collapsed = true;
resolve(true);
});
});
}
/* -------------------------------------------- */
/**
* Expand the Hotbar, displaying it normally.
* @returns {Promise} A promise which resolves once the expand animation completes
*/
async expand() {
if ( !this._collapsed ) return true;
const toggle = this.element.find("#bar-toggle");
const icon = toggle.children("i");
const bar = this.element.find("#action-bar");
return new Promise(resolve => {
bar.slideDown(200, () => {
bar.css("display", "");
bar.removeClass("collapsed");
icon.removeClass("fa-caret-up").addClass("fa-caret-down");
this._collapsed = false;
resolve(true);
});
});
}
/* -------------------------------------------- */
/**
* Change to a specific numbered page from 1 to 5
* @param {number} page The page number to change to.
*/
changePage(page) {
this.page = Math.clamped(page ?? 1, 1, 5);
this.render();
}
/* -------------------------------------------- */
/**
* Change the page of the hotbar by cycling up (positive) or down (negative)
* @param {number} direction The direction to cycle
*/
cyclePage(direction) {
direction = Number.isNumeric(direction) ? Math.sign(direction) : 1;
if ( direction > 0 ) {
this.page = this.page < 5 ? this.page+1 : 1;
} else {
this.page = this.page > 1 ? this.page-1 : 5;
}
this.render();
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
// Macro actions
html.find("#bar-toggle").click(this._onToggleBar.bind(this));
html.find("#macro-directory").click(ev => ui.macros.renderPopout(true));
html.find(".macro").click(this._onClickMacro.bind(this));
html.find(".page-control").click(this._onClickPageControl.bind(this));
// Activate context menu
this._contextMenu(html);
}
/* -------------------------------------------- */
/** @inheritdoc */
_contextMenu(html) {
ContextMenu.create(this, html, ".macro", this._getEntryContextOptions());
}
/* -------------------------------------------- */
/**
* Get the Macro entry context options
* @returns {object[]} The Macro entry context options
* @private
*/
_getEntryContextOptions() {
return [
{
name: "MACRO.Edit",
icon: '<i class="fas fa-edit"></i>',
condition: li => {
const macro = game.macros.get(li.data("macro-id"));
return macro ? macro.isOwner : false;
},
callback: li => {
const macro = game.macros.get(li.data("macro-id"));
macro.sheet.render(true);
}
},
{
name: "MACRO.Remove",
icon: '<i class="fas fa-times"></i>',
condition: li => !!li.data("macro-id"),
callback: li => game.user.assignHotbarMacro(null, Number(li.data("slot")))
},
{
name: "MACRO.Delete",
icon: '<i class="fas fa-trash"></i>',
condition: li => {
const macro = game.macros.get(li.data("macro-id"));
return macro ? macro.isOwner : false;
},
callback: li => {
const macro = game.macros.get(li.data("macro-id"));
return Dialog.confirm({
title: `${game.i18n.localize("MACRO.Delete")} ${macro.name}`,
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("MACRO.DeleteWarning")}</p>`,
yes: macro.delete.bind(macro)
});
}
}
];
}
/* -------------------------------------------- */
/**
* Handle left-click events to
* @param {MouseEvent} event The originating click event
* @protected
*/
async _onClickMacro(event) {
event.preventDefault();
const li = event.currentTarget;
// Case 1 - create a new Macro
if ( li.classList.contains("inactive") ) {
const macro = await Macro.create({name: "New Macro", type: "chat", scope: "global"});
await game.user.assignHotbarMacro(macro, Number(li.dataset.slot));
macro.sheet.render(true);
}
// Case 2 - trigger a Macro
else {
const macro = game.macros.get(li.dataset.macroId);
return macro.execute();
}
}
/* -------------------------------------------- */
/**
* Handle pagination controls
* @param {Event} event The originating click event
* @private
*/
_onClickPageControl(event) {
const action = event.currentTarget.dataset.action;
switch ( action ) {
case "page-up":
this.cyclePage(1);
break;
case "page-down":
this.cyclePage(-1);
break;
case "lock":
this._toggleHotbarLock();
break;
}
}
/* -------------------------------------------- */
/** @override */
_canDragStart(selector) {
return !this.locked;
}
/* -------------------------------------------- */
/** @override */
_onDragStart(event) {
const li = event.currentTarget.closest(".macro");
const macro = game.macros.get(li.dataset.macroId);
if ( !macro ) return false;
const dragData = foundry.utils.mergeObject(macro.toDragData(), {slot: li.dataset.slot});
event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
}
/* -------------------------------------------- */
/** @override */
_canDragDrop(selector) {
return true;
}
/* -------------------------------------------- */
/** @override */
async _onDrop(event) {
event.preventDefault();
const li = event.target.closest(".macro");
const slot = Number(li.dataset.slot);
const data = TextEditor.getDragEventData(event);
if ( Hooks.call("hotbarDrop", this, data, slot) === false ) return;
// Forbid overwriting macros if the hotbar is locked.
const existingMacro = game.macros.get(game.user.hotbar[slot]);
if ( existingMacro && this.locked ) return ui.notifications.warn("MACRO.CannotOverwrite", { localize: true });
// Get the dropped document
const cls = getDocumentClass(data.type);
const doc = await cls?.fromDropData(data);
if ( !doc ) return;
// Get the Macro to add to the bar
let macro;
if ( data.type === "Macro" ) macro = game.macros.has(doc.id) ? doc : await cls.create(doc.toObject());
else if ( data.type === "RollTable" ) macro = await this._createRollTableRollMacro(doc);
else macro = await this._createDocumentSheetToggle(doc);
// Assign the macro to the hotbar
if ( !macro ) return;
return game.user.assignHotbarMacro(macro, slot, {fromSlot: data.slot});
}
/* -------------------------------------------- */
/**
* Create a Macro which rolls a RollTable when executed
* @param {Document} table The RollTable document
* @returns {Promise<Macro>} A created Macro document to add to the bar
* @private
*/
async _createRollTableRollMacro(table) {
const command = `const table = await fromUuid("${table.uuid}");\nawait table.draw({roll: true, displayChat: true});`;
return Macro.implementation.create({
name: `${game.i18n.localize("TABLE.Roll")} ${table.name}`,
type: "script",
img: table.img,
command
});
}
/* -------------------------------------------- */
/**
* Create a Macro document which can be used to toggle display of a Journal Entry.
* @param {Document} doc A Document which should be toggled
* @returns {Promise<Macro>} A created Macro document to add to the bar
* @protected
*/
async _createDocumentSheetToggle(doc) {
const name = doc.name || `${game.i18n.localize(doc.constructor.metadata.label)} ${doc.id}`;
return Macro.implementation.create({
name: `${game.i18n.localize("Display")} ${name}`,
type: CONST.MACRO_TYPES.SCRIPT,
img: "icons/svg/book.svg",
command: `await Hotbar.toggleDocumentSheet("${doc.uuid}");`
});
}
/* -------------------------------------------- */
/**
* Handle click events to toggle display of the macro bar
* @param {Event} event
* @private
*/
_onToggleBar(event) {
event.preventDefault();
if ( this._collapsed ) return this.expand();
else return this.collapse();
}
/* -------------------------------------------- */
/**
* Toggle the hotbar's lock state.
* @returns {Promise<Hotbar>}
* @protected
*/
async _toggleHotbarLock() {
await game.settings.set("core", "hotbarLock", !this.locked);
return this.render();
}
/* -------------------------------------------- */
/**
* Handle toggling a document sheet.
* @param {string} uuid The Document UUID to display
* @returns {Promise<void>|Application|*}
*/
static async toggleDocumentSheet(uuid) {
const doc = await fromUuid(uuid);
if ( !doc ) {
return ui.notifications.warn(game.i18n.format("WARNING.ObjectDoesNotExist", {
name: game.i18n.localize("Document"),
identifier: uuid
}));
}
const sheet = doc.sheet;
return sheet.rendered ? sheet.close() : sheet.render(true);
}
}
/**
* An abstract base class for displaying a heads-up-display interface bound to a Placeable Object on the canvas
* @type {Application}
* @abstract
* @interface
* @param {PlaceableObject} object The {@link PlaceableObject} this HUD is bound to.
* @param {ApplicationOptions} [options] Application configuration options.
*/
class BasePlaceableHUD extends Application {
/**
* Reference a PlaceableObject this HUD is currently bound to
* @type {PlaceableObject}
*/
object = undefined;
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["placeable-hud"],
popOut: false
});
}
/* -------------------------------------------- */
/**
* Convenience access for the canvas layer which this HUD modifies
* @type {PlaceablesLayer}
*/
get layer() {
return this.object?.layer;
}
/* -------------------------------------------- */
/* Methods
/* -------------------------------------------- */
/**
* Bind the HUD to a new PlaceableObject and display it
* @param {PlaceableObject} object A PlaceableObject instance to which the HUD should be bound
*/
bind(object) {
const states = this.constructor.RENDER_STATES;
if ( [states.CLOSING, states.RENDERING].includes(this._state) ) return;
if ( this.object ) this.clear();
// Record the new object
if ( !(object instanceof PlaceableObject) || (object.scene !== canvas.scene) ) {
throw new Error("You may only bind a HUD instance to a PlaceableObject in the currently viewed Scene.");
}
this.object = object;
// Render the HUD
this.render(true);
this.element.hide().fadeIn(200);
}
/* -------------------------------------------- */
/**
* Clear the HUD by fading out it's active HTML and recording the new display state
*/
clear() {
let states = this.constructor.RENDER_STATES;
if ( this._state <= states.NONE ) return;
this._state = states.CLOSING;
// Unbind
this.object = null;
this.element.hide();
this._element = null;
this._state = states.NONE;
}
/* -------------------------------------------- */
/** @override */
async _render(...args) {
await super._render(...args);
this.setPosition();
}
/* -------------------------------------------- */
/** @override */
getData(options = {}) {
const data = this.object.document.toObject();
return foundry.utils.mergeObject(data, {
id: this.id,
classes: this.options.classes.join(" "),
appId: this.appId,
isGM: game.user.isGM,
icons: CONFIG.controlIcons
});
}
/* -------------------------------------------- */
/** @override */
setPosition({left, top, width, height, scale} = {}) {
const position = {
width: width || this.object.width,
height: height || this.object.height,
left: left ?? this.object.x,
top: top ?? this.object.y
};
this.element.css(position);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
html.find(".control-icon").click(this._onClickControl.bind(this));
}
/* -------------------------------------------- */
/**
* Handle mouse clicks to control a HUD control button
* @param {PointerEvent} event The originating click event
* @protected
*/
_onClickControl(event) {
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "visibility":
return this._onToggleVisibility(event);
case "locked":
return this._onToggleLocked(event);
case "sort-up":
return this._onSort(event, true);
case "sort-down":
return this._onSort(event, false);
}
}
/* -------------------------------------------- */
/**
* Toggle the visible state of all controlled objects in the Layer
* @param {PointerEvent} event The originating click event
* @private
*/
async _onToggleVisibility(event) {
event.preventDefault();
// Toggle the visible state
const isHidden = this.object.document.hidden;
const updates = this.layer.controlled.map(o => {
return {_id: o.id, hidden: !isHidden};
});
// Update all objects
return canvas.scene.updateEmbeddedDocuments(this.object.document.documentName, updates);
}
/* -------------------------------------------- */
/**
* Toggle locked state of all controlled objects in the Layer
* @param {PointerEvent} event The originating click event
* @private
*/
async _onToggleLocked(event) {
event.preventDefault();
// Toggle the visible state
const isLocked = this.object.document.locked;
const updates = this.layer.controlled.map(o => {
return {_id: o.id, locked: !isLocked};
});
// Update all objects
event.currentTarget.classList.toggle("active", !isLocked);
return canvas.scene.updateEmbeddedDocuments(this.object.document.documentName, updates);
}
/* -------------------------------------------- */
/**
* Handle sorting the z-order of the object
* @param {boolean} up Move the object upwards in the vertical stack?
* @param {PointerEvent} event The originating mouse click event
* @returns {Promise}
* @protected
*/
async _onSort(event, up) {
event.preventDefault();
const siblings = this.layer.placeables;
const controlled = this.layer.controlled.filter(o => !o.document.locked);
// Determine target sort index
let z = 0;
if ( up ) {
controlled.sort((a, b) => a.document.z - b.document.z);
z = siblings.length ? Math.max(...siblings.map(o => o.document.z)) + 1 : 1;
} else {
controlled.sort((a, b) => b.document.z - a.document.z);
z = siblings.length ? Math.min(...siblings.map(o => o.document.z)) - 1 : -1;
}
// Update all controlled objects
const updates = controlled.map((o, i) => {
let d = up ? i : i * -1;
return {_id: o.id, z: z + d};
});
return canvas.scene.updateEmbeddedDocuments(this.object.document.documentName, updates);
}
}
/**
* The main menu application which is toggled via the ESC key.
* @extends {Application}
*/
class MainMenu extends Application {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "menu",
template: "templates/hud/menu.html",
popOut: false
});
}
/* ----------------------------------------- */
/**
* The structure of menu items
* @returns {Object<{label: string, icon: string, enabled: boolean, onClick: Function}>}
*/
get items() {
return {
reload: {
label: "MENU.Reload",
icon: '<i class="fas fa-redo"></i>',
enabled: true,
onClick: () => window.location.reload()
},
logout: {
label: "MENU.Logout",
icon: '<i class="fas fa-user"></i>',
enabled: true,
onClick: () => game.logOut()
},
players: {
label: "MENU.Players",
icon: '<i class="fas fa-users"></i>',
enabled: game.user.isGM && !game.data.demoMode,
onClick: () => window.location.href = "./players"
},
world: {
label: "GAME.ReturnSetup",
icon: '<i class="fas fa-globe"></i>',
enabled: game.user.hasRole("GAMEMASTER") && !game.data.demoMode,
onClick: () => {
this.close();
game.shutDown();
}
}
};
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
return {
items: this.items
};
}
/* ----------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
for ( let [k, v] of Object.entries(this.items) ) {
html.find(`.menu-${k}`).click(v.onClick);
}
}
/* ----------------------------------------- */
/**
* Toggle display of the menu (or render it in the first place)
*/
toggle() {
let menu = this.element;
if ( !this.rendered ) this.render(true);
else menu.slideToggle(150);
}
}
/**
* The UI element which displays the Scene documents which are currently enabled for quick navigation.
*/
class SceneNavigation extends Application {
constructor(options) {
super(options);
game.scenes.apps.push(this);
/**
* Navigation collapsed state
* @type {boolean}
*/
this._collapsed = false;
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "navigation",
template: "templates/hud/navigation.html",
popOut: false,
dragDrop: [{dragSelector: ".scene"}]
});
}
/* -------------------------------------------- */
/**
* Return an Array of Scenes which are displayed in the Navigation bar
* @returns {Scene[]}
*/
get scenes() {
const scenes = game.scenes.filter(s => {
return (s.navigation && s.visible) || s.active || s.isView;
});
scenes.sort((a, b) => a.navOrder - b.navOrder);
return scenes;
}
/* -------------------------------------------- */
/* Application Rendering
/* -------------------------------------------- */
/** @inheritdoc */
render(force, context = {}) {
let {renderContext, renderData} = context;
if ( renderContext ) {
const events = ["createScene", "updateScene", "deleteScene"];
if ( !events.includes(renderContext) ) return this;
const updateKeys = ["name", "ownership", "ownership.default", "active", "navigation", "navName", "navOrder"];
if ( renderContext === "updateScene" && !updateKeys.some(k => renderData.hasOwnProperty(k)) ) return this;
}
return super.render(force, context);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
await super._render(force, options);
const loading = document.getElementById("loading");
const nav = this.element[0];
loading.style.top = `${nav.offsetTop + nav.offsetHeight}px`;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const scenes = this.scenes.map(scene => {
return {
id: scene.id,
active: scene.active,
name: TextEditor.truncateText(scene.navName || scene.name, {maxLength: 32}),
tooltip: scene.navName && game.user.isGM ? scene.name : null,
users: game.users.reduce((arr, u) => {
if ( u.active && ( u.viewedScene === scene.id) ) arr.push({letter: u.name[0], color: u.color});
return arr;
}, []),
visible: game.user.isGM || scene.isOwner || scene.active,
css: [
scene.isView ? "view" : null,
scene.active ? "active" : null,
scene.ownership.default === 0 ? "gm" : null
].filterJoin(" ")
};
});
return {collapsed: this._collapsed, scenes: scenes};
}
/* -------------------------------------------- */
/**
* A hook event that fires when the SceneNavigation menu is expanded or collapsed.
* @function collapseSceneNavigation
* @memberof hookEvents
* @param {SceneNavigation} sceneNavigation The SceneNavigation application
* @param {boolean} collapsed Whether the SceneNavigation is now collapsed or not
*/
/* -------------------------------------------- */
/**
* Expand the SceneNavigation menu, sliding it down if it is currently collapsed
*/
expand() {
if ( !this._collapsed ) return true;
const nav = this.element;
const icon = nav.find("#nav-toggle i.fas");
const ul = nav.children("#scene-list");
return new Promise(resolve => {
ul.slideDown(200, () => {
nav.removeClass("collapsed");
icon.removeClass("fa-caret-down").addClass("fa-caret-up");
this._collapsed = false;
Hooks.callAll("collapseSceneNavigation", this, this._collapsed);
return resolve(true);
});
});
}
/* -------------------------------------------- */
/**
* Collapse the SceneNavigation menu, sliding it up if it is currently expanded
* @returns {Promise<boolean>}
*/
async collapse() {
if ( this._collapsed ) return true;
const nav = this.element;
const icon = nav.find("#nav-toggle i.fas");
const ul = nav.children("#scene-list");
return new Promise(resolve => {
ul.slideUp(200, () => {
nav.addClass("collapsed");
icon.removeClass("fa-caret-up").addClass("fa-caret-down");
this._collapsed = true;
Hooks.callAll("collapseSceneNavigation", this, this._collapsed);
return resolve(true);
});
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
// Click event listener
const scenes = html.find(".scene");
scenes.click(this._onClickScene.bind(this));
html.find("#nav-toggle").click(this._onToggleNav.bind(this));
// Activate Context Menu
const contextOptions = this._getContextMenuOptions();
Hooks.call("getSceneNavigationContext", html, contextOptions);
if ( contextOptions ) new ContextMenu(html, ".scene", contextOptions);
}
/* -------------------------------------------- */
/**
* Get the set of ContextMenu options which should be applied for Scenes in the menu
* @returns {object[]} The Array of context options passed to the ContextMenu instance
* @private
*/
_getContextMenuOptions() {
return [
{
name: "SCENES.Activate",
icon: '<i class="fas fa-bullseye"></i>',
condition: li => game.user.isGM && !game.scenes.get(li.data("sceneId")).active,
callback: li => {
let scene = game.scenes.get(li.data("sceneId"));
scene.activate();
}
},
{
name: "SCENES.Configure",
icon: '<i class="fas fa-cogs"></i>',
condition: game.user.isGM,
callback: li => {
let scene = game.scenes.get(li.data("sceneId"));
scene.sheet.render(true);
}
},
{
name: "SCENES.Notes",
icon: '<i class="fas fa-scroll"></i>',
condition: li => {
if ( !game.user.isGM ) return false;
const scene = game.scenes.get(li.data("sceneId"));
return !!scene.journal;
},
callback: li => {
const scene = game.scenes.get(li.data("sceneId"));
const entry = scene.journal;
if ( entry ) {
const sheet = entry.sheet;
const options = {};
if ( scene.journalEntryPage ) options.pageId = scene.journalEntryPage;
sheet.render(true, options);
}
}
},
{
name: "SCENES.Preload",
icon: '<i class="fas fa-download"></i>',
condition: game.user.isGM,
callback: li => {
let sceneId = li.attr("data-scene-id");
game.scenes.preload(sceneId, true);
}
},
{
name: "SCENES.ToggleNav",
icon: '<i class="fas fa-compass"></i>',
condition: li => {
const scene = game.scenes.get(li.data("sceneId"));
return game.user.isGM && (!scene.active);
},
callback: li => {
const scene = game.scenes.get(li.data("sceneId"));
scene.update({navigation: !scene.navigation});
}
}
];
}
/* -------------------------------------------- */
/**
* Handle left-click events on the scenes in the navigation menu
* @param {PointerEvent} event
* @private
*/
_onClickScene(event) {
event.preventDefault();
let sceneId = event.currentTarget.dataset.sceneId;
game.scenes.get(sceneId).view();
}
/* -------------------------------------------- */
/** @override */
_onDragStart(event) {
const sceneId = event.currentTarget.dataset.sceneId;
const scene = game.scenes.get(sceneId);
event.dataTransfer.setData("text/plain", JSON.stringify(scene.toDragData()));
}
/* -------------------------------------------- */
/** @override */
async _onDrop(event) {
const data = TextEditor.getDragEventData(event);
if ( data.type !== "Scene" ) return;
// Identify the document, the drop target, and the set of siblings
const scene = await Scene.implementation.fromDropData(data);
const dropTarget = event.target.closest(".scene") || null;
const sibling = dropTarget ? game.scenes.get(dropTarget.dataset.sceneId) : null;
if ( sibling && (sibling.id === scene.id) ) return;
const siblings = this.scenes.filter(s => s.id !== scene.id);
// Update the navigation sorting for each Scene
return scene.sortRelative({
target: sibling,
siblings: siblings,
sortKey: "navOrder"
});
}
/* -------------------------------------------- */
/**
* Handle navigation menu toggle click events
* @param {Event} event
* @private
*/
_onToggleNav(event) {
event.preventDefault();
if ( this._collapsed ) return this.expand();
else return this.collapse();
}
/* -------------------------------------------- */
/**
* Display progress of some major operation like loading Scene textures.
* @param {object} options Options for how the progress bar is displayed
* @param {string} options.label A text label to display
* @param {number} options.pct A percentage of progress between 0 and 100
*/
static displayProgressBar({label, pct} = {}) {
const loader = document.getElementById("loading");
pct = Math.clamped(pct, 0, 100);
loader.querySelector("#context").textContent = label;
loader.querySelector("#loading-bar").style.width = `${pct}%`;
loader.querySelector("#progress").textContent = `${pct}%`;
loader.style.display = "block";
if ( (pct === 100) && !loader.hidden ) $(loader).fadeOut(2000);
}
}
/**
* Pause notification in the HUD
* @extends {Application}
*/
class Pause extends Application {
static get defaultOptions() {
const options = super.defaultOptions;
options.id = "pause";
options.template = "templates/hud/pause.html";
options.popOut = false;
return options;
}
/** @override */
getData(options={}) {
return { paused: game.paused };
}
}
/**
* The UI element which displays the list of Users who are currently playing within the active World.
* @extends {Application}
*/
class PlayerList extends Application {
constructor(options) {
super(options);
game.users.apps.push(this);
/**
* An internal toggle for whether to show offline players or hide them
* @type {boolean}
* @private
*/
this._showOffline = false;
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "players",
template: "templates/user/players.html",
popOut: false
});
}
/* -------------------------------------------- */
/* Application Rendering */
/* -------------------------------------------- */
/**
* Whether the players list is in a configuration where it is hidden.
* @returns {boolean}
*/
get isHidden() {
if ( game.webrtc.mode === AVSettings.AV_MODES.DISABLED ) return false;
const { client, verticalDock } = game.webrtc.settings;
return verticalDock && client.hidePlayerList && !client.hideDock && !ui.webrtc.hidden;
}
/* -------------------------------------------- */
/** @override */
render(force, context={}) {
this._positionInDOM();
const { renderContext, renderData } = context;
if ( renderContext ) {
const events = ["createUser", "updateUser", "deleteUser"];
if ( !events.includes(renderContext) ) return this;
const updateKeys = ["name", "ownership", "ownership.default", "active", "navigation"];
if ( renderContext === "updateUser" && !updateKeys.some(k => renderData.hasOwnProperty(k)) ) return this;
}
return super.render(force, context);
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
// Process user data by adding extra characteristics
const users = game.users.filter(u => this._showOffline || u.active).map(user => {
const u = user.toObject(false);
u.active = user.active;
u.isGM = user.isGM;
u.isSelf = user.isSelf;
u.charname = user.character?.name.split(" ")[0] || "";
u.color = u.active ? u.color : "#333333";
u.border = u.active ? user.border : "#000000";
u.displayName = this._getDisplayName(u);
return u;
}).sort((a, b) => {
if ( (b.role >= CONST.USER_ROLES.ASSISTANT) && (b.role > a.role) ) return 1;
return a.name.localeCompare(b.name);
});
// Return the data for rendering
return {
users,
hide: this.isHidden,
showOffline: this._showOffline
};
}
/* -------------------------------------------- */
/**
* Prepare a displayed name string for the User which includes their name, pronouns, character, or GM tag.
* @returns {string}
* @protected
*/
_getDisplayName(user) {
const displayNamePart = [user.name];
if ( user.pronouns ) displayNamePart.push(`(${user.pronouns})`);
if ( user.isGM ) displayNamePart.push(`[${game.i18n.localize("USER.GM")}]`);
else if ( user.charname ) displayNamePart.push(`[${user.charname}]`);
return displayNamePart.join(" ");
}
/* -------------------------------------------- */
/**
* Position this Application in the main DOM appropriately.
* @protected
*/
_positionInDOM() {
document.body.classList.toggle("players-hidden", this.isHidden);
if ( (game.webrtc.mode === AVSettings.AV_MODES.DISABLED) || this.isHidden || !this.element.length ) return;
const element = this.element[0];
const cameraViews = ui.webrtc.element[0];
const uiTop = document.getElementById("ui-top");
const uiLeft = document.getElementById("ui-left");
const { client, verticalDock } = game.webrtc.settings;
const inDock = verticalDock && !client.hideDock && !ui.webrtc.hidden;
if ( inDock && !cameraViews?.contains(element) ) {
cameraViews.appendChild(element);
uiTop.classList.remove("offset");
} else if ( !inDock && !uiLeft.contains(element) ) {
uiLeft.appendChild(element);
uiTop.classList.add("offset");
}
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
// Toggle online/offline
html.find("h3").click(this._onToggleOfflinePlayers.bind(this));
// Context menu
const contextOptions = this._getUserContextOptions();
Hooks.call("getUserContextOptions", html, contextOptions);
new ContextMenu(html, ".player", contextOptions);
}
/* -------------------------------------------- */
/**
* Return the default context options available for the Players application
* @returns {object[]}
* @private
*/
_getUserContextOptions() {
return [
{
name: game.i18n.localize("PLAYERS.ConfigTitle"),
icon: '<i class="fas fa-male"></i>',
condition: li => game.user.isGM || (li[0].dataset.userId === game.user.id),
callback: li => {
const user = game.users.get(li[0].dataset.userId);
user?.sheet.render(true);
}
},
{
name: game.i18n.localize("PLAYERS.ViewAvatar"),
icon: '<i class="fas fa-image"></i>',
condition: li => {
const user = game.users.get(li[0].dataset.userId);
return user.avatar !== CONST.DEFAULT_TOKEN;
},
callback: li => {
let user = game.users.get(li.data("user-id"));
new ImagePopout(user.avatar, {
title: user.name,
uuid: user.uuid
}).render(true);
}
},
{
name: game.i18n.localize("PLAYERS.PullToScene"),
icon: '<i class="fas fa-directions"></i>',
condition: li => game.user.isGM && (li[0].dataset.userId !== game.user.id),
callback: li => game.socket.emit("pullToScene", canvas.scene.id, li.data("user-id"))
},
{
name: game.i18n.localize("PLAYERS.Kick"),
icon: '<i class="fas fa-door-open"></i>',
condition: li => {
const user = game.users.get(li[0].dataset.userId);
return game.user.isGM && user.active && !user.isSelf;
},
callback: li => {
const user = game.users.get(li[0].dataset.userId);
return this.#kickUser(user);
}
},
{
name: game.i18n.localize("PLAYERS.Ban"),
icon: '<i class="fas fa-ban"></i>',
condition: li => {
const user = game.users.get(li[0].dataset.userId);
return game.user.isGM && !user.isSelf && (user.role !== CONST.USER_ROLES.NONE);
},
callback: li => {
const user = game.users.get(li[0].dataset.userId);
return this.#banUser(user);
}
},
{
name: game.i18n.localize("PLAYERS.UnBan"),
icon: '<i class="fas fa-ban"></i>',
condition: li => {
const user = game.users.get(li[0].dataset.userId);
return game.user.isGM && !user.isSelf && (user.role === CONST.USER_ROLES.NONE);
},
callback: li => {
const user = game.users.get(li[0].dataset.userId);
return this.#unbanUser(user);
}
},
{
name: game.i18n.localize("WEBRTC.TooltipShowUser"),
icon: '<i class="fas fa-eye"></i>',
condition: li => {
const userId = li.data("userId");
return game.webrtc.settings.client.users[userId]?.blocked;
},
callback: async li => {
const userId = li.data("userId");
await game.webrtc.settings.set("client", `users.${userId}.blocked`, false);
ui.webrtc.render();
}
}
];
}
/* -------------------------------------------- */
/**
* Toggle display of the Players hud setting for whether to display offline players
* @param {Event} event The originating click event
* @private
*/
_onToggleOfflinePlayers(event) {
event.preventDefault();
this._showOffline = !this._showOffline;
this.render();
}
/* -------------------------------------------- */
/**
* Temporarily remove a User from the World by banning and then un-banning them.
* @param {User} user The User to kick
* @returns {Promise<void>}
*/
async #kickUser(user) {
const role = user.role;
await user.update({role: CONST.USER_ROLES.NONE});
await user.update({role}, {diff: false});
ui.notifications.info(`${user.name} has been <strong>kicked</strong> from the World.`);
}
/* -------------------------------------------- */
/**
* Ban a User by changing their role to "NONE".
* @param {User} user The User to ban
* @returns {Promise<void>}
*/
async #banUser(user) {
if ( user.role === CONST.USER_ROLES.NONE ) return;
await user.update({role: CONST.USER_ROLES.NONE});
ui.notifications.info(`${user.name} has been <strong>banned</strong> from the World.`);
}
/* -------------------------------------------- */
/**
* Unban a User by changing their role to "PLAYER".
* @param {User} user The User to unban
* @returns {Promise<void>}
*/
async #unbanUser(user) {
if ( user.role !== CONST.USER_ROLES.NONE ) return;
await user.update({role: CONST.USER_ROLES.PLAYER});
ui.notifications.info(`${user.name} has been <strong>unbanned</strong> from the World.`);
}
}
/**
* Audio/Video Conferencing Configuration Sheet
* @extends {FormApplication}
*
* @param {AVMaster} object The {@link AVMaster} instance being configured.
* @param {FormApplicationOptions} [options] Application configuration options.
*/
class AVConfig extends FormApplication {
constructor(object, options) {
super(object || game.webrtc, options);
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
title: game.i18n.localize("WEBRTC.Title"),
id: "av-config",
template: "templates/sidebar/apps/av-config.html",
popOut: true,
width: 480,
height: "auto",
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "general"}]
});
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const settings = this.object.settings;
const videoSources = await this.object.client.getVideoSources();
const audioSources = await this.object.client.getAudioSources();
const audioSinks = await this.object.client.getAudioSinks();
// If the currently chosen device is unavailable, display a separate option for 'unavailable device (use default)'
const { videoSrc, audioSrc, audioSink } = settings.client;
const videoSrcUnavailable = this._isSourceUnavailable(videoSources, videoSrc);
const audioSrcUnavailable = this._isSourceUnavailable(audioSources, audioSrc);
const audioSinkUnavailable = this._isSourceUnavailable(audioSinks, audioSink);
const isSSL = window.location.protocol === "https:";
// Audio/Video modes
const modes = {
[AVSettings.AV_MODES.DISABLED]: "WEBRTC.ModeDisabled",
[AVSettings.AV_MODES.AUDIO]: "WEBRTC.ModeAudioOnly",
[AVSettings.AV_MODES.VIDEO]: "WEBRTC.ModeVideoOnly",
[AVSettings.AV_MODES.AUDIO_VIDEO]: "WEBRTC.ModeAudioVideo"
};
// Voice Broadcast modes
const voiceModes = Object.values(AVSettings.VOICE_MODES).reduce((obj, m) => {
obj[m] = game.i18n.localize(`WEBRTC.VoiceMode${m.titleCase()}`);
return obj;
}, {});
// Nameplate settings.
const nameplates = {
[AVSettings.NAMEPLATE_MODES.OFF]: "WEBRTC.NameplatesOff",
[AVSettings.NAMEPLATE_MODES.PLAYER_ONLY]: "WEBRTC.NameplatesPlayer",
[AVSettings.NAMEPLATE_MODES.CHAR_ONLY]: "WEBRTC.NameplatesCharacter",
[AVSettings.NAMEPLATE_MODES.BOTH]: "WEBRTC.NameplatesBoth"
};
const dockPositions = Object.fromEntries(Object.values(AVSettings.DOCK_POSITIONS).map(p => {
return [p, game.i18n.localize(`WEBRTC.DockPosition${p.titleCase()}`)];
}));
// Return data to the template
return {
user: game.user,
modes,
voiceModes,
serverTypes: {FVTT: "WEBRTC.FVTTSignalingServer", custom: "WEBRTC.CustomSignalingServer"},
turnTypes: {server: "WEBRTC.TURNServerProvisioned", custom: "WEBRTC.CustomTURNServer"},
settings,
canSelectMode: game.user.isGM && isSSL,
noSSL: !isSSL,
videoSources,
audioSources,
audioSinks: foundry.utils.isEmpty(audioSinks) ? false : audioSinks,
videoSrcUnavailable,
audioSrcUnavailable,
audioSinkUnavailable,
audioDisabled: audioSrc === "disabled",
videoDisabled: videoSrc === "disabled",
nameplates,
nameplateSetting: settings.client.nameplates ?? AVSettings.NAMEPLATE_MODES.BOTH,
dockPositions
};
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
// Options below are GM only
if ( !game.user.isGM ) return;
html.find('select[name="world.turn.type"]').change(this._onTurnTypeChanged.bind(this));
// Activate or de-activate the custom server and turn configuration sections based on current settings
const settings = this.object.settings;
this._setConfigSectionEnabled(".webrtc-custom-turn-config", settings.world.turn.type === "custom");
}
/* -------------------------------------------- */
/**
* Set a section's input to enabled or disabled
* @param {string} selector Selector for the section to enable or disable
* @param {boolean} enabled Whether to enable or disable this section
* @private
*/
_setConfigSectionEnabled(selector, enabled = true) {
let section = this.element.find(selector);
if (section) {
section.css("opacity", enabled ? 1.0 : 0.5);
section.find("input").prop("disabled", !enabled);
}
}
/* -------------------------------------------- */
/**
* Determine whether a given video or audio source, or audio sink has become
* unavailable since the last time it was set.
* @param {object} sources The available devices
* @param {string} source The selected device
* @private
*/
_isSourceUnavailable(sources, source) {
const specialValues = ["default", "disabled"];
return source && (!specialValues.includes(source)) && !Object.keys(sources).includes(source);
}
/* -------------------------------------------- */
/**
* Callback when the turn server type changes
* Will enable or disable the turn section based on whether the user selected a custom turn or not
* @param {Event} event The event that triggered the turn server type change
* @private
*/
_onTurnTypeChanged(event) {
event.preventDefault();
const choice = event.currentTarget.value;
this._setConfigSectionEnabled(".webrtc-custom-turn-config", choice === "custom")
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
const settings = game.webrtc.settings;
settings.client.videoSrc = settings.client.videoSrc || null;
settings.client.audioSrc = settings.client.audioSrc || null;
const update = expandObject(formData);
// Update world settings
if ( game.user.isGM ) {
if ( settings.world.mode !== update.world.mode ) SettingsConfig.reloadConfirm({world: true});
const world = mergeObject(settings.world, update.world);
await game.settings.set("core", "rtcWorldSettings", world);
}
// Update client settings
const client = mergeObject(settings.client, update.client);
await game.settings.set("core", "rtcClientSettings", client);
}
}
/**
* Abstraction of the Application interface to be used with the Draggable class as a substitute for the app
* This class will represent one popout feed window and handle its positioning and draggability
* @param {CameraViews} view The CameraViews application that this popout belongs to
* @param {string} userId ID of the user this popout belongs to
* @param {jQuery} element The div element of this specific popout window
*/
class CameraPopoutAppWrapper {
constructor(view, userId, element) {
this.view = view;
this.element = element;
this.userId = userId;
// "Fake" some application attributes
this.popOut = true;
this.options = {};
// Get the saved position
let setting = game.webrtc.settings.getUser(userId);
this.setPosition(setting);
new Draggable(this, element.find(".camera-view"), element.find(".video-container")[0], true);
}
/* -------------------------------------------- */
/**
* Get the current position of this popout window
*/
get position() {
return foundry.utils.mergeObject(this.element.position(), {
width: this.element.outerWidth(),
height: this.element.outerHeight(),
scale: 1
});
}
/* -------------------------------------------- */
/** @override */
setPosition(options={}) {
const position = Application.prototype.setPosition.call(this, options);
// Let the HTML renderer figure out the height based on width.
this.element[0].style.height = "";
if ( !foundry.utils.isEmpty(position) ) {
const current = game.webrtc.settings.client.users[this.userId] || {};
const update = foundry.utils.mergeObject(current, position);
game.webrtc.settings.set("client", `users.${this.userId}`, update);
}
return position;
}
/* -------------------------------------------- */
_onResize(event) {}
/* -------------------------------------------- */
/** @override */
bringToTop() {
let parent = this.element.parent();
let children = parent.children();
let lastElement = children[children.length - 1];
if (lastElement !== this.element[0]) {
game.webrtc.settings.set("client", `users.${this.userId}.z`, ++this.view.maxZ);
parent.append(this.element);
}
}
}
/**
* The Camera UI View that displays all the camera feeds as individual video elements.
* @type {Application}
*
* @param {WebRTC} webrtc The WebRTC Implementation to display
* @param {ApplicationOptions} [options] Application configuration options.
*/
class CameraViews extends Application {
constructor(options={}) {
if ( !("width" in options) ) options.width = game.webrtc?.settings.client.dockWidth || 240;
super(options);
if ( game.webrtc?.settings.client.dockPosition === AVSettings.DOCK_POSITIONS.RIGHT ) {
this.options.resizable.rtl = true;
}
}
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "camera-views",
template: "templates/hud/camera-views.html",
popOut: false,
width: 240,
resizable: {selector: ".camera-view-width-control", resizeY: false}
});
}
/* -------------------------------------------- */
/**
* A reference to the master AV orchestrator instance
* @type {AVMaster}
*/
get webrtc() {
return game.webrtc;
}
/* -------------------------------------------- */
/**
* If all camera views are popped out, hide the dock.
* @type {boolean}
*/
get hidden() {
return this.webrtc.client.getConnectedUsers().reduce((hidden, u) => {
const settings = this.webrtc.settings.users[u];
return hidden && (settings.blocked || settings.popout);
}, true);
}
/* -------------------------------------------- */
/* Public API */
/* -------------------------------------------- */
/**
* Obtain a reference to the div.camera-view which is used to portray a given Foundry User.
* @param {string} userId The ID of the User document
* @return {HTMLElement|null}
*/
getUserCameraView(userId) {
return this.element.find(`.camera-view[data-user=${userId}]`)[0] || null;
}
/* -------------------------------------------- */
/**
* Obtain a reference to the video.user-camera which displays the video channel for a requested Foundry User.
* If the user is not broadcasting video this will return null.
* @param {string} userId The ID of the User document
* @return {HTMLVideoElement|null}
*/
getUserVideoElement(userId) {
return this.element.find(`.camera-view[data-user=${userId}] video.user-camera`)[0] || null;
}
/* -------------------------------------------- */
/**
* Sets whether a user is currently speaking or not
*
* @param {string} userId The ID of the user
* @param {boolean} speaking Whether the user is speaking
*/
setUserIsSpeaking(userId, speaking) {
const view = this.getUserCameraView(userId);
if ( view ) view.classList.toggle("speaking", speaking);
}
/* -------------------------------------------- */
/* Application Rendering */
/* -------------------------------------------- */
/**
* Extend the render logic to first check whether a render is necessary based on the context
* If a specific context was provided, make sure an update to the navigation is necessary before rendering
*/
render(force, context={}) {
const { renderContext, renderData } = context;
if ( this.webrtc.mode === AVSettings.AV_MODES.DISABLED ) return this;
if ( renderContext ) {
if ( renderContext !== "updateUser" ) return this;
const updateKeys = ["name", "permissions", "role", "active", "color", "sort", "character", "avatar"];
if ( !updateKeys.some(k => renderData.hasOwnProperty(k)) ) return this;
}
return super.render(force, context);
}
/* -------------------------------------------- */
/** @override */
async _render(force = false, options = {}) {
await super._render(force, options);
this.webrtc.onRender();
}
/* -------------------------------------------- */
/** @inheritdoc */
setPosition({left, top, width, scale} = {}) {
const position = super.setPosition({left, top, width, height: "auto", scale});
if ( foundry.utils.isEmpty(position) ) return position;
const clientSettings = game.webrtc.settings.client;
if ( game.webrtc.settings.verticalDock ) {
clientSettings.dockWidth = width;
game.webrtc.settings.set("client", "dockWidth", width);
}
return position;
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const settings = this.webrtc.settings;
const userSettings = settings.users;
// Get the sorted array of connected users
const connectedIds = this.webrtc.client.getConnectedUsers();
const users = connectedIds.reduce((users, u) => {
const data = this._getDataForUser(u, userSettings[u]);
if ( data && !userSettings[u].blocked ) users.push(data);
return users;
}, []);
users.sort(this.constructor._sortUsers);
// Maximum Z of all user popout windows
this.maxZ = Math.max(...users.map(u => userSettings[u.user.id].z));
// Define a dynamic class for the camera dock container which affects its rendered style
const dockClass = [`camera-position-${settings.client.dockPosition}`];
if ( !users.some(u => !u.settings.popout) ) dockClass.push("webrtc-dock-empty");
if ( settings.client.hideDock ) dockClass.push("webrtc-dock-minimized");
if ( this.hidden ) dockClass.push("hidden");
// Alter the body class depending on whether the players list is hidden
const playersVisible = !settings.client.hidePlayerList || settings.client.hideDock;
document.body.classList.toggle("players-hidden", playersVisible);
const nameplateModes = AVSettings.NAMEPLATE_MODES;
const nameplateSetting = settings.client.nameplates ?? nameplateModes.BOTH;
const nameplates = {
cssClass: [
nameplateSetting === nameplateModes.OFF ? "hidden" : "",
[nameplateModes.PLAYER_ONLY, nameplateModes.CHAR_ONLY].includes(nameplateSetting) ? "noanimate" : ""
].filterJoin(" "),
playerName: [nameplateModes.BOTH, nameplateModes.PLAYER_ONLY].includes(nameplateSetting),
charname: [nameplateModes.BOTH, nameplateModes.CHAR_ONLY].includes(nameplateSetting)
};
// Return data for rendering
return {
self: game.user,
muteAll: settings.muteAll,
borderColors: settings.client.borderColors,
dockClass: dockClass.join(" "),
hidden: this.hidden,
users, nameplates
};
}
/* -------------------------------------------- */
/**
* Prepare rendering data for a single user
* @private
*/
_getDataForUser(userId, settings) {
const user = game.users.get(userId);
if ( !user || !user.active ) return null;
const charname = user.character ? user.character.name.split(" ")[0] : "";
// CSS classes for the frame
const frameClass = settings.popout ? "camera-box-popout" : "camera-box-dock";
const audioClass = this.webrtc.canUserShareAudio(userId) ? null : "no-audio";
const videoClass = this.webrtc.canUserShareVideo(userId) ? null : "no-video";
// Return structured User data
return {
user, settings,
local: user.isSelf,
charname: user.isGM ? game.i18n.localize("GM") : charname,
volume: AudioHelper.volumeToInput(settings.volume),
cameraViewClass: [frameClass, videoClass, audioClass].filterJoin(" ")
};
}
/* -------------------------------------------- */
/**
* A custom sorting function that orders/arranges the user display frames
* @return {number}
* @private
*/
static _sortUsers(a, b) {
const as = a.settings;
const bs = b.settings;
if (as.popout && bs.popout) return as.z - bs.z; // Sort popouts by z-index
if (as.popout) return -1; // Show popout feeds first
if (bs.popout) return 1;
if (a.user.isSelf) return -1; // Show local feed first
if (b.user.isSelf) return 1;
if (a.hasVideo && !b.hasVideo) return -1; // Show remote users with a camera before those without
if (b.hasVideo && !a.hasVideo) return 1;
return a.user.sort - b.user.sort; // Sort according to user order
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
// Display controls when hovering over the video container
let cvh = this._onCameraViewHover.bind(this);
html.find(".camera-view").hover(cvh, cvh);
// Handle clicks on AV control buttons
html.find(".av-control").click(this._onClickControl.bind(this));
// Handle volume changes
html.find(".webrtc-volume-slider").change(this._onVolumeChange.bind(this));
// Handle user controls.
this._refreshView(html.find(".user-controls")[0]?.dataset.user);
// Hide Global permission icons depending on the A/V mode
const mode = this.webrtc.mode;
if ( mode === AVSettings.AV_MODES.VIDEO ) html.find('[data-action="toggle-audio"]').hide();
if ( mode === AVSettings.AV_MODES.AUDIO ) html.find('[data-action="toggle-video"]').hide();
// Make each popout window draggable
for ( let popout of this.element.find(".app.camera-view-popout") ) {
let box = popout.querySelector(".camera-view");
new CameraPopoutAppWrapper(this, box.dataset.user, $(popout));
}
// Listen to the video's srcObjectSet event to set the display mode of the user.
for ( let video of this.element.find("video") ) {
const view = video.closest(".camera-view");
this._refreshView(view.dataset.user);
video.addEventListener("webrtcVideoSet", ev => {
const view = video.closest(".camera-view");
if ( view.dataset.user !== ev.detail ) return;
this._refreshView(view.dataset.user);
});
}
}
/* -------------------------------------------- */
/**
* On hover in a camera container, show/hide the controls.
* @event {Event} event The original mouseover or mouseout hover event
* @private
*/
_onCameraViewHover(event) {
this._toggleControlVisibility(event.currentTarget, event.type === "mouseenter", null);
}
/* -------------------------------------------- */
/**
* On clicking on a toggle, disable/enable the audio or video stream.
* @event {MouseEvent} event The originating click event
* @private
*/
async _onClickControl(event) {
event.preventDefault();
// Reference relevant data
const button = event.currentTarget;
const action = button.dataset.action;
const userId = button.closest(".camera-view, .user-controls")?.dataset.user;
const user = game.users.get(userId);
const settings = this.webrtc.settings;
const userSettings = settings.getUser(user.id);
// Handle different actions
switch ( action ) {
// Globally block video
case "block-video":
if ( !game.user.isGM ) return;
await user.update({"permissions.BROADCAST_VIDEO": !userSettings.canBroadcastVideo});
return this._refreshView(userId);
// Globally block audio
case "block-audio":
if ( !game.user.isGM ) return;
await user.update({"permissions.BROADCAST_AUDIO": !userSettings.canBroadcastAudio});
return this._refreshView(userId);
// Hide the user
case "hide-user":
if ( user.isSelf ) return;
await settings.set("client", `users.${user.id}.blocked`, !userSettings.blocked);
return this.render();
// Toggle video display
case "toggle-video":
if ( !user.isSelf ) return;
if ( userSettings.hidden && !userSettings.canBroadcastVideo ) {
return ui.notifications.warn("WEBRTC.WarningCannotEnableVideo", {localize: true});
}
await settings.set("client", `users.${user.id}.hidden`, !userSettings.hidden);
return this._refreshView(userId);
// Toggle audio output
case "toggle-audio":
if ( !user.isSelf ) return;
if ( userSettings.muted && !userSettings.canBroadcastAudio ) {
return ui.notifications.warn("WEBRTC.WarningCannotEnableAudio", {localize: true});
}
await settings.set("client", `users.${user.id}.muted`, !userSettings.muted);
return this._refreshView(userId);
// Toggle mute all peers
case "mute-peers":
if ( !user.isSelf ) return;
await settings.set("client", "muteAll", !settings.client.muteAll);
return this._refreshView(userId);
// Disable sending and receiving video
case "disable-video":
if ( !user.isSelf ) return;
await settings.set("client", "disableVideo", !settings.client.disableVideo);
return this._refreshView(userId);
// Configure settings
case "configure":
return this.webrtc.config.render(true);
// Toggle popout
case "toggle-popout":
await settings.set("client", `users.${user.id}.popout`, !userSettings.popout);
return this.render();
// Hide players
case "toggle-players":
await settings.set("client", "hidePlayerList", !settings.client.hidePlayerList);
return this.render();
// Minimize the dock
case "toggle-dock":
await settings.set("client", "hideDock", !settings.client.hideDock);
return this.render();
}
}
/* -------------------------------------------- */
/**
* Change volume control for a stream
* @param {Event} event The originating change event from interaction with the range input
* @private
*/
_onVolumeChange(event) {
const input = event.currentTarget;
const box = input.closest(".camera-view");
const userId = box.dataset.user;
let volume = AudioHelper.inputToVolume(input.value);
box.getElementsByTagName("video")[0].volume = volume;
this.webrtc.settings.set("client", `users.${userId}.volume`, volume);
}
/* -------------------------------------------- */
/* Internal Helpers */
/* -------------------------------------------- */
/**
* Dynamically refresh the state of a single camera view
* @param {string} userId The ID of the user whose view we want to refresh.
* @protected
*/
_refreshView(userId) {
const view = this.element[0].querySelector(`.camera-view[data-user="${userId}"]`);
const isSelf = game.user.id === userId;
const clientSettings = game.webrtc.settings.client;
const userSettings = game.webrtc.settings.getUser(userId);
const minimized = clientSettings.hideDock;
const isVertical = game.webrtc.settings.verticalDock;
// Identify permissions
const cbv = game.webrtc.canUserBroadcastVideo(userId);
const csv = game.webrtc.canUserShareVideo(userId);
const cba = game.webrtc.canUserBroadcastAudio(userId);
const csa = game.webrtc.canUserShareAudio(userId);
// Refresh video display
const video = view.querySelector("video.user-camera");
const avatar = view.querySelector("img.user-avatar");
if ( video && avatar ) {
const showVideo = csv && (isSelf || !clientSettings.disableVideo) && (!minimized || userSettings.popout);
video.style.visibility = showVideo ? "visible" : "hidden";
video.style.display = showVideo ? "block" : "none";
avatar.style.display = showVideo ? "none" : "unset";
}
// Hidden and muted status icons
view.querySelector(".status-hidden")?.classList.toggle("hidden", csv);
view.querySelector(".status-muted")?.classList.toggle("hidden", csa);
// Volume bar and video output volume
if ( video ) {
video.volume = userSettings.volume;
video.muted = isSelf || clientSettings.muteAll; // Mute your own video
}
const volBar = this.element[0].querySelector(`[data-user="${userId}"] .volume-bar`);
if ( volBar ) {
const displayBar = (userId !== game.user.id) && cba;
volBar.style.display = displayBar ? "block" : "none";
volBar.disabled = !displayBar;
}
// Control toggle states
const actions = {
"block-video": {state: !cbv, display: game.user.isGM && !isSelf},
"block-audio": {state: !cba, display: game.user.isGM && !isSelf},
"hide-user": {state: !userSettings.blocked, display: !isSelf},
"toggle-video": {state: !csv, display: isSelf && !minimized},
"toggle-audio": {state: !csa, display: isSelf},
"mute-peers": {state: clientSettings.muteAll, display: isSelf},
"disable-video": {state: clientSettings.disableVideo, display: isSelf && !minimized},
"toggle-players": {state: !clientSettings.hidePlayerList, display: isSelf && !minimized && isVertical},
"toggle-dock": {state: !clientSettings.hideDock, display: isSelf}
};
const toggles = this.element[0].querySelectorAll(`[data-user="${userId}"] .av-control.toggle`);
for ( let button of toggles ) {
const action = button.dataset.action;
if ( !(action in actions) ) continue;
const state = actions[action].state;
const displayed = actions[action].display;
button.style.display = displayed ? "block" : "none";
button.enabled = displayed;
button.children[0].classList.remove(this._getToggleIcon(action, !state));
button.children[0].classList.add(this._getToggleIcon(action, state));
button.dataset.tooltip = this._getToggleTooltip(action, state);
}
}
/* -------------------------------------------- */
/**
* Render changes needed to the PlayerList ui.
* Show/Hide players depending on option.
* @private
*/
_setPlayerListVisibility() {
const hidePlayerList = this.webrtc.settings.client.hidePlayerList;
const players = document.getElementById("players");
const top = document.getElementById("ui-top");
if ( players ) players.classList.toggle("hidden", hidePlayerList);
if ( top ) top.classList.toggle("offset", !hidePlayerList);
}
/* -------------------------------------------- */
/**
* Get the icon class that should be used for various action buttons with different toggled states.
* The returned icon should represent the visual status of the NEXT state (not the CURRENT state).
*
* @param {string} action The named av-control button action
* @param {boolean} state The CURRENT action state.
* @returns {string} The icon that represents the NEXT action state.
* @protected
*/
_getToggleIcon(action, state) {
const clientSettings = game.webrtc.settings.client;
const dockPositions = AVSettings.DOCK_POSITIONS;
const dockIcons = {
[dockPositions.TOP]: {collapse: "down", expand: "up"},
[dockPositions.RIGHT]: {collapse: "left", expand: "right"},
[dockPositions.BOTTOM]: {collapse: "up", expand: "down"},
[dockPositions.LEFT]: {collapse: "right", expand: "left"}
}[clientSettings.dockPosition];
const actionMapping = {
"block-video": ["fa-video", "fa-video-slash"], // True means "blocked"
"block-audio": ["fa-microphone", "fa-microphone-slash"], // True means "blocked"
"hide-user": ["fa-eye", "fa-eye-slash"],
"toggle-video": ["fa-camera-web", "fa-camera-web-slash"], // True means "enabled"
"toggle-audio": ["fa-microphone", "fa-microphone-slash"], // True means "enabled"
"mute-peers": ["fa-volume-up", "fa-volume-mute"], // True means "muted"
"disable-video": ["fa-video", "fa-video-slash"],
"toggle-players": ["fa-caret-square-right", "fa-caret-square-left"], // True means "displayed"
"toggle-dock": [`fa-caret-square-${dockIcons.collapse}`, `fa-caret-square-${dockIcons.expand}`]
};
const icons = actionMapping[action];
return icons ? icons[state ? 1: 0] : null;
}
/* -------------------------------------------- */
/**
* Get the text title that should be used for various action buttons with different toggled states.
* The returned title should represent the tooltip of the NEXT state (not the CURRENT state).
*
* @param {string} action The named av-control button action
* @param {boolean} state The CURRENT action state.
* @returns {string} The icon that represents the NEXT action state.
* @protected
*/
_getToggleTooltip(action, state) {
const actionMapping = {
"block-video": ["BlockUserVideo", "AllowUserVideo"], // True means "blocked"
"block-audio": ["BlockUserAudio", "AllowUserAudio"], // True means "blocked"
"hide-user": ["ShowUser", "HideUser"],
"toggle-video": ["DisableMyVideo", "EnableMyVideo"], // True means "enabled"
"toggle-audio": ["DisableMyAudio", "EnableMyAudio"], // True means "enabled"
"mute-peers": ["MutePeers", "UnmutePeers"], // True means "muted"
"disable-video": ["DisableAllVideo", "EnableVideo"],
"toggle-players": ["ShowPlayers", "HidePlayers"], // True means "displayed"
"toggle-dock": ["ExpandDock", "MinimizeDock"]
};
const labels = actionMapping[action];
return game.i18n.localize(`WEBRTC.Tooltip${labels ? labels[state ? 1 : 0] : ""}`);
}
/* -------------------------------------------- */
/**
* Show or hide UI control elements
* This replaces the use of jquery.show/hide as it simply adds a class which has display:none
* which allows us to have elements with display:flex which can be hidden then shown without
* breaking their display style.
* This will show/hide the toggle buttons, volume controls and overlay sidebars
* @param {jQuery} container The container for which to show/hide control elements
* @param {boolean} show Whether to show or hide the controls
* @param {string} selector Override selector to specify which controls to show or hide
* @private
*/
_toggleControlVisibility(container, show, selector) {
selector = selector || `.control-bar`;
container.querySelectorAll(selector).forEach(c => c.classList.toggle("hidden", !show));
}
}
/**
* An abstract base class designed to standardize the behavior for a multi-select UI component.
* Multi-select components return an array of values as part of form submission.
* Different implementations may provide different experiences around how inputs are presented to the user.
* @abstract
* @internal
* @category - Custom HTML Elements
* @fires change
*/
class AbstractMultiSelectElement extends HTMLElement {
constructor() {
super();
this._initializeOptions();
}
/**
* The "change" event is emitted when the values of the multi-select element are changed.
* @param {Event} event A "change" event passed to event listeners.
* @event change
*/
static onChange;
/**
* Predefined <option> and <optgroup> elements which were defined in the original HTML.
* @type {(HTMLOptionElement|HTMLOptgroupElement)[]}
* @protected
*/
_options;
/**
* An object which maps option values to displayed labels.
* @type {Object<string, string>}
* @protected
*/
_choices = {};
/**
* An array of identifiers which have been chosen.
* @type {Set<string>}
* @protected
*/
_chosen = new Set();
/**
* The form this custom element belongs to, if any.
* @type {HTMLFormElement|null}
*/
form = null;
/**
* The bound form data handler method
* @type {Function|null}
*/
#formDataHandler = null;
/* -------------------------------------------- */
/**
* Preserve existing <option> and <optgroup> elements which are defined in the original HTML.
* @protected
*/
_initializeOptions() {
this._options = [...this.children];
for ( const option of this.querySelectorAll("option") ) {
if ( !option.value ) continue; // Skip predefined options which are already blank
this._choices[option.value] = option.innerText;
if ( option.selected ) {
this._chosen.add(option.value);
option.selected = false;
}
}
}
/* -------------------------------------------- */
/**
* The name of the multi-select input element.
* @type {string}
*/
get name() {
return this.getAttribute("name");
}
set name(value) {
if ( !value || (typeof value !== "string") ) {
throw new Error("The name attribute of the multi-select element must be a non-empty string");
}
this.setAttribute("name", value);
}
/* -------------------------------------------- */
/**
* The values of the multi-select input are expressed as an array of strings.
* @type {string[]}
*/
get value() {
return Array.from(this._chosen);
}
set value(values) {
if ( !Array.isArray(values) ) {
throw new Error("The value assigned to a multi-select element must be an array.");
}
if ( values.some(v => !(v in this._choices)) ) {
throw new Error("The values assigned to a multi-select element must all be valid options.");
}
this._chosen.clear();
for ( const v of values ) this._chosen.add(v);
this.dispatchEvent(new Event("change"));
}
/* -------------------------------------------- */
/**
* Activate the custom element when it is attached to the DOM.
* @inheritDoc
*/
connectedCallback() {
this.replaceChildren();
const elements = this._buildElements();
this._refresh();
this.append(...elements);
this._activateListeners();
}
/* -------------------------------------------- */
/**
* Deactivate the custom element when it is detached from the DOM.
* @inheritDoc
*/
disconnectedCallback() {
if ( this.form ) {
delete this.form[this.name];
delete this.form.elements[this.name];
this.form.removeEventListener("formdata", this.#formDataHandler);
}
this.form = this.#formDataHandler = null;
}
/* -------------------------------------------- */
/**
* Mark a choice as selected.
* @param {string} value The value to add to the chosen set
*/
select(value) {
const exists = this._chosen.has(value);
if ( !exists ) {
if ( !(value in this._choices) ) {
throw new Error(`"${value}" is not an option allowed by this multi-select element`);
}
this._chosen.add(value);
this.dispatchEvent(new Event("change"));
this._refresh();
}
}
/* -------------------------------------------- */
/**
* Mark a choice as un-selected.
* @param {string} value The value to delete from the chosen set
*/
unselect(value) {
const exists = this._chosen.has(value);
if ( exists ) {
this._chosen.delete(value);
this.dispatchEvent(new Event("change"));
this._refresh();
}
}
/* -------------------------------------------- */
/**
* Create the HTML elements that should be included in this custom element.
* Elements are returned as an array of ordered children.
* @returns {HTMLElement[]}
* @protected
*/
_buildElements() {
return [];
}
/* -------------------------------------------- */
/**
* Refresh the active state of the custom element by reflecting changes to the _chosen set.
* @protected
*/
_refresh() {}
/* -------------------------------------------- */
/**
* Activate event listeners which add dynamic behavior to the custom element.
* @protected
*/
_activateListeners() {
this.form = this.closest("form");
if ( this.form ) {
this.form[this.name] = this.form.elements[this.name] = this;
this.#formDataHandler = this.#onFormData.bind(this);
this.form.addEventListener("formdata", this.#formDataHandler);
}
}
/* -------------------------------------------- */
/**
* Add the value of the custom element to processed FormData.
* @param {FormDataEvent} event
*/
#onFormData(event) {
for ( const value of this._chosen ) {
event.formData.append(this.name, value);
}
}
}
/* -------------------------------------------- */
/**
* Provide a multi-select workflow using a select element as the input mechanism.
* @internal
* @category - Custom HTML Elements
*
* @example Multi-Select HTML Markup
* ```html
* <multi-select name="select-many-things">
* <optgroup label="Basic Options">
* <option value="foo">Foo</option>
* <option value="bar">Bar</option>
* <option value="baz">Baz</option>
* </optgroup>
* <optgroup label="Advanced Options">
* <option value="fizz">Fizz</option>
* <option value="buzz">Buzz</option>
* </optgroup>
* </multi-select>
* ```
*/
class HTMLMultiSelectElement extends AbstractMultiSelectElement {
/**
* A select element used to choose options.
* @type {HTMLSelectElement}
*/
#select;
/**
* A display element which lists the chosen options.
* @type {HTMLDivElement}
*/
#tags;
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Create select element
this.#select = document.createElement("select");
this.#select.insertAdjacentHTML("afterbegin", '<option value=""></option>');
this.#select.append(...this._options);
// Create a div element for display
this.#tags = document.createElement("div");
this.#tags.classList.add("tags", "chosen");
return [this.#tags, this.#select];
}
/* -------------------------------------------- */
/** @override */
_refresh() {
// Update the displayed tags
this.#tags.innerHTML = Array.from(this._chosen).map(id => {
return `<span class="tag" data-value="${id}">${this._choices[id]} <i class="fa-solid fa-times"></i></span>`;
}).join("");
// Disable selected options
for ( const option of this.#select.querySelectorAll("option") ) {
option.disabled = this._chosen.has(option.value);
}
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
super._activateListeners();
this.#select.addEventListener("change", this.#onChangeSelect.bind(this));
this.#tags.addEventListener("click", this.#onClickTag.bind(this));
}
/* -------------------------------------------- */
/**
* Handle changes to the Select input, marking the selected option as a chosen value.
* @param {Event} event The change event on the select element
*/
#onChangeSelect(event) {
event.preventDefault();
event.stopImmediatePropagation();
const select = event.currentTarget;
if ( !select.value ) return; // Ignore selection of the blank value
this.select(select.value);
select.value = "";
}
/* -------------------------------------------- */
/**
* Handle click events on a tagged value, removing it from the chosen set.
* @param {PointerEvent} event The originating click event on a chosen tag
*/
#onClickTag(event) {
event.preventDefault();
const tag = event.target.closest(".tag");
this.unselect(tag.dataset.value);
}
}
/* -------------------------------------------- */
/**
* Provide a multi-select workflow as a grid of input checkbox elements.
* @internal
* @category - Custom HTML Elements
*
* @example Multi-Checkbox HTML Markup
* ```html
* <multi-checkbox name="check-many-boxes">
* <optgroup label="Basic Options">
* <option value="foo">Foo</option>
* <option value="bar">Bar</option>
* <option value="baz">Baz</option>
* </optgroup>
* <optgroup label="Advanced Options">
* <option value="fizz">Fizz</option>
* <option value="buzz">Buzz</option>
* </optgroup>
* </multi-checkbox>
* ```
*/
class HTMLMultiCheckboxElement extends AbstractMultiSelectElement {
/**
* The checkbox elements used to select inputs
* @type {HTMLInputElement[]}
*/
#checkboxes;
/* -------------------------------------------- */
/** @override */
_buildElements() {
this.#checkboxes = [];
const children = [];
for ( const option of this._options ) {
if ( option instanceof HTMLOptGroupElement ) children.push(this.#buildGroup(option));
else children.push(this.#buildOption(option));
}
return children;
}
/* -------------------------------------------- */
/**
* Translate an input <optgroup> element into a <fieldset> of checkboxes.
* @param {HTMLOptGroupElement} optgroup The originally configured optgroup
* @returns {HTMLFieldSetElement} The created fieldset grouping
*/
#buildGroup(optgroup) {
// Create fieldset group
const group = document.createElement("fieldset");
group.classList.add("checkbox-group");
const legend = document.createElement("legend");
legend.innerText = optgroup.label;
group.append(legend);
// Add child options
for ( const option of optgroup.children ) {
if ( option instanceof HTMLOptionElement ) {
group.append(this.#buildOption(option));
}
}
return group;
}
/* -------------------------------------------- */
/**
* Build an input <option> element into a <label class="checkbox"> element.
* @param {HTMLOptionElement} option The originally configured option
* @returns {HTMLLabelElement} The created labeled checkbox element
*/
#buildOption(option) {
const label = document.createElement("label");
label.classList.add("checkbox");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = option.value;
checkbox.checked = this._chosen.has(option.value);
label.append(checkbox, option.innerText);
this.#checkboxes.push(checkbox);
return label;
}
/* -------------------------------------------- */
/** @override */
_refresh() {
for ( const checkbox of this.#checkboxes ) {
checkbox.checked = this._chosen.has(checkbox.value);
}
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
super._activateListeners();
for ( const checkbox of this.#checkboxes ) {
checkbox.addEventListener("change", this.#onChangeCheckbox.bind(this));
}
}
/* -------------------------------------------- */
/**
* Handle changes to a checkbox input, marking the selected option as a chosen value.
* @param {Event} event The change event on the checkbox input element
*/
#onChangeCheckbox(event) {
event.preventDefault();
event.stopImmediatePropagation();
const checkbox = event.currentTarget;
if ( checkbox.checked ) this.select(checkbox.value);
else this.unselect(checkbox.value);
}
}
// Register Custom Elements
window.customElements.define("multi-select", HTMLMultiSelectElement);
window.customElements.define("multi-checkbox", HTMLMultiCheckboxElement);
/**
* A Dialog subclass which allows the user to configure export options for a Folder
* @extends {Dialog}
*/
class FolderExport extends Dialog {
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find('select[name="pack"]').change(this._onPackChange.bind(this));
}
/* -------------------------------------------- */
/**
* Handle changing the selected pack by updating the dropdown of folders available.
* @param {Event} event The input change event
*/
_onPackChange(event) {
const select = this.element.find('select[name="folder"]')[0];
const pack = game.packs.get(event.target.value);
if ( !pack ) {
select.disabled = true;
return;
}
const folders = pack._formatFolderSelectOptions();
select.disabled = folders.length === 0;
select.innerHTML = HandlebarsHelpers.selectOptions(folders, {hash: {
blank: "",
nameAttr: "id",
labelAttr: "name"
}});
}
}
/**
* @typedef {FormApplicationOptions} DrawingConfigOptions
* @property {boolean} [configureDefault=false] Configure the default drawing settings, instead of a specific Drawing
*/
/**
* The Application responsible for configuring a single Drawing document within a parent Scene.
* @extends {DocumentSheet}
*
* @param {Drawing} drawing The Drawing object being configured
* @param {DrawingConfigOptions} options Additional application rendering options
*/
class DrawingConfig extends DocumentSheet {
/**
* @override
* @returns {DrawingConfigOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "drawing-config",
template: "templates/scene/drawing-config.html",
width: 480,
height: "auto",
configureDefault: false,
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "position"}]
});
}
/* -------------------------------------------- */
/** @override */
get title() {
if ( this.options.configureDefault ) return game.i18n.localize("DRAWING.ConfigDefaultTitle");
return super.title;
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
// Submit text
let submit;
if ( this.options.configureDefault ) submit = "DRAWING.SubmitDefault";
else submit = this.document.id ? "DRAWING.SubmitUpdate" : "DRAWING.SubmitCreate";
// Rendering context
return {
author: this.document.author?.name || "",
isDefault: this.options.configureDefault,
fillTypes: Object.entries(CONST.DRAWING_FILL_TYPES).reduce((obj, v) => {
obj[v[1]] = `DRAWING.FillType${v[0].titleCase()}`;
return obj;
}, {}),
scaledBezierFactor: this.document.bezierFactor * 2,
fontFamilies: FontConfig.getAvailableFontChoices(),
object: this.document.toObject(),
options: this.options,
submitText: submit
};
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
if ( !this.object.isOwner ) throw new Error("You do not have the ability to configure this Drawing object.");
// Un-scale the bezier factor
formData.bezierFactor /= 2;
// Configure the default Drawing settings
if ( this.options.configureDefault ) {
formData = foundry.utils.expandObject(formData);
const defaults = DrawingDocument.cleanData(formData, {partial: true});
return game.settings.set("core", DrawingsLayer.DEFAULT_CONFIG_SETTING, defaults);
}
// Rescale dimensions if needed
const shape = this.object.shape;
const w = formData["shape.width"];
const h = formData["shape.height"];
if ( shape && ((w !== shape.width) || (h !== shape.height)) ) {
const dx = w - shape.width;
const dy = h - shape.height;
formData = foundry.utils.expandObject(formData);
foundry.utils.mergeObject(formData, Drawing.rescaleDimensions(this.object, dx, dy));
}
// Create or update a Drawing
if ( this.object.id ) return this.object.update(formData);
return this.object.constructor.create(formData);
}
/* -------------------------------------------- */
/** @override */
async close(options) {
await super.close(options);
if ( this.preview ) {
this.preview.removeChildren();
this.preview = null;
}
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find('button[name="reset"]').click(this._onResetDefaults.bind(this));
}
/* -------------------------------------------- */
/**
* Reset the user Drawing configuration settings to their default values
* @param {PointerEvent} event The originating mouse-click event
* @protected
*/
_onResetDefaults(event) {
event.preventDefault();
this.object = DrawingDocument.fromSource({});
this.render();
}
}
/**
* An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Drawing objects.
* @extends {BasePlaceableHUD}
* @param {Drawing} object The {@link Drawing} this HUD is bound to.
* @param {ApplicationOptions} [options] Application configuration options.
*/
class DrawingHUD extends BasePlaceableHUD {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "drawing-hud",
template: "templates/hud/drawing-hud.html"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const d = this.object.document;
return foundry.utils.mergeObject(super.getData(options), {
lockedClass: d.locked ? "active" : "",
visibilityClass: d.hidden ? "active" : ""
});
}
/* -------------------------------------------- */
/** @inheritdoc */
setPosition(options) {
let {x, y, width, height} = this.object.hitArea;
const c = 70;
const p = 10;
const position = {
width: width + (c * 2) + (p * 2),
height: height + (p * 2),
left: x + this.object.x - c - p,
top: y + this.object.y - p
};
this.element.css(position);
}
}
/**
* The Application responsible for configuring a single AmbientLight document within a parent Scene.
* @param {AmbientLight} light The AmbientLight object for which settings are being configured
* @param {DocumentSheetOptions} [options] Additional application configuration options
*/
class AmbientLightConfig extends DocumentSheet {
/**
* Maintain a copy of the original to show a real-time preview of changes.
* @type {AmbientLightDocument}
*/
preview;
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "ambient-light-config",
classes: ["sheet", "ambient-light-config"],
title: "LIGHT.ConfigTitle",
template: "templates/scene/ambient-light-config.html",
width: 480,
height: "auto",
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "basic"}]
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
const states = Application.RENDER_STATES;
if ( force && [states.CLOSED, states.NONE].includes(this._state) && this.document.object ) {
if ( !this.preview ) {
const clone = this.document.object.clone();
this.preview = clone.document;
}
await this.preview.object.draw();
this.document.object.renderable = false;
this.preview.object.layer.preview.addChild(this.preview.object);
this._previewChanges();
}
return super._render(force, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
delete context.document; // Replaced below
return foundry.utils.mergeObject(context, {
data: this.preview.toObject(false),
document: this.preview,
isAdvanced: this._tabs[0].active === "advanced",
colorationTechniques: AdaptiveLightingShader.SHADER_TECHNIQUES,
lightAnimations: CONFIG.Canvas.lightAnimations,
gridUnits: canvas.scene.grid.units || game.i18n.localize("GridUnits"),
submitText: game.i18n.localize(this.options.preview ? "LIGHT.Create" : "LIGHT.Update")
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
const states = Application.RENDER_STATES;
if ( options.force || [states.RENDERED, states.ERROR].includes(this._state) ) {
this._resetPreview();
}
await super.close(options);
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
html.find('button[type="reset"]').click(this._onResetForm.bind(this));
return super.activateListeners(html);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onChangeInput(event) {
await super._onChangeInput(event);
const previewData = this._getSubmitData();
this._previewChanges(previewData);
}
/* -------------------------------------------- */
/**
* Reset the values of advanced attributes to their default state.
* @param {PointerEvent} event The originating click event
* @private
*/
_onResetForm(event) {
event.preventDefault();
const defaults = AmbientLightDocument.cleanData();
const keys = ["walls", "vision", "config"];
const configKeys = ["coloration", "contrast", "attenuation", "luminosity", "saturation", "shadows"];
for ( const k in defaults ) {
if ( !keys.includes(k) ) delete defaults[k];
}
for ( const k in defaults.config ) {
if ( !configKeys.includes(k) ) delete defaults.config[k];
}
this._previewChanges(defaults);
this.render();
}
/* -------------------------------------------- */
/**
* Preview changes to the AmbientLight document as if they were true document updates.
* @param {object} [change] A change to preview.
* @protected
*/
_previewChanges(change) {
if ( !this.preview ) return;
if ( change ) this.preview.updateSource(change);
if ( this.preview.object?.destroyed === false ) {
this.preview.object.renderFlags.set({refresh: true});
this.preview.object.updateSource();
}
}
/* -------------------------------------------- */
/**
* Restore the true data for the AmbientLight document when the form is submitted or closed.
* @protected
*/
_resetPreview() {
if ( !this.preview ) return;
if ( this.preview.object?.destroyed === false ) {
this.preview.object.destroy({children: true});
}
this.preview = null;
if ( this.document.object?.destroyed === false ) {
this.document.object.renderable = true;
this.document.object.renderFlags.set({refresh: true});
this.document.object.updateSource();
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_onChangeTab(event, tabs, active) {
super._onChangeTab(event, tabs, active);
this.element.find('button[type="reset"]').toggleClass("hidden", active !== "advanced");
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData={}) {
const formData = super._getSubmitData(updateData);
if ( formData["config.color"] === "" ) formData["config.color"] = null;
return formData;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
this._resetPreview();
if ( this.object.id ) return this.object.update(formData);
return this.object.constructor.create(formData, {parent: canvas.scene});
}
}
/**
* The Application responsible for configuring a single Note document within a parent Scene.
* @param {NoteDocument} note The Note object for which settings are being configured
* @param {DocumentSheetOptions} [options] Additional Application configuration options
*/
class NoteConfig extends DocumentSheet {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("NOTE.ConfigTitle"),
template: "templates/scene/note-config.html",
width: 480
});
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const data = super.getData(options);
if ( !this.object.id ) data.data.global = !canvas.scene.tokenVision;
const entry = game.journal.get(this.object.entryId);
const pages = entry?.pages.contents.sort((a, b) => a.sort - b.sort);
const icons = Object.entries(CONFIG.JournalEntry.noteIcons).map(([label, src]) => {
return {label, src};
}).sort((a, b) => a.label.localeCompare(b.label));
icons.unshift({label: game.i18n.localize("NOTE.Custom"), src: ""});
const customIcon = !Object.values(CONFIG.JournalEntry.noteIcons).includes(this.document.texture.src);
const icon = {
selected: customIcon ? "" : this.document.texture.src,
custom: customIcon ? this.document.texture.src : ""
};
return foundry.utils.mergeObject(data, {
icon, icons,
label: this.object.label,
entry: entry || {},
pages: pages || [],
entries: game.journal.filter(e => e.isOwner).sort((a, b) => a.name.localeCompare(b.name)),
fontFamilies: FontConfig.getAvailableFontChoices(),
textAnchors: Object.entries(CONST.TEXT_ANCHOR_POINTS).reduce((obj, e) => {
obj[e[1]] = game.i18n.localize(`JOURNAL.Anchor${e[0].titleCase()}`);
return obj;
}, {}),
submitText: game.i18n.localize(this.id ? "NOTE.Update" : "NOTE.Create")
});
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
this._updateCustomIcon();
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onChangeInput(event) {
this._updateCustomIcon();
if ( event.currentTarget.name === "entryId" ) this._updatePageList();
return super._onChangeInput(event);
}
/* -------------------------------------------- */
/**
* Update disabled state of the custom icon field.
* @protected
*/
_updateCustomIcon() {
const selected = this.form?.querySelector('[name="icon.selected"]');
const custom = this.form?.querySelector('[name="icon.custom"]');
if ( custom ) {
custom.disabled = selected.value.length;
this.form.querySelector('[data-target="icon.custom"]').disabled = selected.value.length;
}
}
/* -------------------------------------------- */
/**
* Update the list of pages.
* @protected
*/
_updatePageList() {
const entryId = this.form.elements.entryId?.value;
const pages = game.journal.get(entryId)?.pages.contents.sort((a, b) => a.sort - b.sort) ?? [];
const options = pages.map(page => {
const selected = (entryId === this.object.entryId) && (page.id === this.object.pageId);
return `<option value="${page.id}"${selected ? " selected" : ""}>${page.name}</option>`;
});
this.form.elements.pageId.innerHTML = `<option></option>${options}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData={}) {
const data = super._getSubmitData(updateData);
data["texture.src"] = data["icon.selected"] || data["icon.custom"];
delete data["icon.selected"];
delete data["icon.custom"];
return data;
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
if ( this.object.id ) return this.object.update(formData);
else return this.object.constructor.create(formData, {parent: canvas.scene});
}
/* -------------------------------------------- */
/** @override */
async close(options) {
if ( !this.object.id ) canvas.notes.clearPreviewContainer();
return super.close(options);
}
}
/**
* The Application responsible for configuring a single AmbientSound document within a parent Scene.
* @extends {DocumentSheet}
*
* @param {AmbientSound} sound The sound object being configured
* @param {DocumentSheetOptions} [options] Additional application rendering options
*/
class AmbientSoundConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: "SOUND.ConfigTitle",
template: "templates/scene/sound-config.html",
width: 480
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
if ( this.object.id ) return super.title;
else return game.i18n.localize("SOUND.Create");
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const data = super.getData(options);
data.submitText = game.i18n.localize(this.object.id ? "SOUND.Update" : "SOUND.Create");
data.gridUnits = canvas.scene.grid.units;
return data;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
if ( this.object.id ) return this.object.update(formData);
return this.object.constructor.create(formData, {parent: canvas.scene});
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options) {
if ( !this.object.id ) canvas.sounds.clearPreviewContainer();
await super.close(options);
}
}
/**
* The Application responsible for configuring a single Tile document within a parent Scene.
* @param {Tile} tile The Tile object being configured
* @param {DocumentSheetOptions} [options] Additional application rendering options
*/
class TileConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "tile-config",
title: game.i18n.localize("TILE.ConfigTitle"),
template: "templates/scene/tile-config.html",
width: 420,
height: "auto",
submitOnChange: true,
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "basic"}]
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
// If the config was closed without saving, reset the initial display of the Tile
if ( !options.force ) {
this.document.reset();
if ( this.document.object?.destroyed === false ) {
this.document.object.refresh();
}
}
// Remove the preview tile and close
const layer = this.object.layer;
layer.clearPreviewContainer();
return super.close(options);
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const data = super.getData(options);
data.submitText = game.i18n.localize(this.object.id ? "TILE.SubmitUpdate" : "TILE.SubmitCreate");
data.occlusionModes = Object.entries(CONST.OCCLUSION_MODES).reduce((obj, e) => {
obj[e[1]] = game.i18n.localize(`TILE.OcclusionMode${e[0].titleCase()}`);
return obj;
}, {});
return data;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onChangeInput(event) {
// Handle form element updates
const el = event.target;
if ( (el.type === "color") && el.dataset.edit ) this._onChangeColorPicker(event);
else if ( el.type === "range" ) this._onChangeRange(event);
// Update preview object
const fdo = new FormDataExtended(this.form).object;
// To allow a preview without glitches
fdo.width = Math.abs(fdo.width);
fdo.height = Math.abs(fdo.height);
// Handle tint exception
let tint = fdo["texture.tint"];
if ( !foundry.data.validators.isColorString(tint) ) fdo["texture.tint"] = null;
// Update preview object
foundry.utils.mergeObject(this.document, foundry.utils.expandObject(fdo));
this.document.object.refresh();
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
if ( this.document.id ) return this.document.update(formData);
else return this.document.constructor.create(formData, {
parent: this.document.parent,
pack: this.document.pack
});
}
}
/**
* An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Tile objects.
* @extends {BasePlaceableHUD}
*/
class TileHUD extends BasePlaceableHUD {
/**
* @inheritdoc
* @type {Tile}
*/
object = undefined;
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "tile-hud",
template: "templates/hud/tile-hud.html"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const d = this.object.document;
const isVideo = this.object.isVideo;
const src = this.object.sourceElement;
const isPlaying = isVideo && !src.paused && !src.ended;
return foundry.utils.mergeObject(super.getData(options), {
isVideo: isVideo,
lockedClass: d.locked ? "active" : "",
visibilityClass: d.hidden ? "active" : "",
overheadClass: d.overhead ? "active" : "",
underfootClass: !d.overhead ? "active" : "",
videoIcon: isPlaying ? "fas fa-pause" : "fas fa-play",
videoTitle: game.i18n.localize(isPlaying ? "HUD.TilePause" : "HUD.TilePlay")
});
}
/* -------------------------------------------- */
/** @inheritdoc */
setPosition(options) {
let {x, y, width, height} = this.object.hitArea;
const c = 70;
const p = -10;
const position = {
width: width + (c * 2) + (p * 2),
height: height + (p * 2),
left: x + this.object.x - c - p,
top: y + this.object.y - p
};
this.element.css(position);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickControl(event) {
super._onClickControl(event);
if ( event.defaultPrevented ) return;
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "overhead":
return this._onToggleOverhead(event, true);
case "underfoot":
return this._onToggleOverhead(event, false);
case "video":
return this._onControlVideo(event);
}
}
/* -------------------------------------------- */
/**
* Handle toggling the overhead state of the Tile.
* @param {PointerEvent} event The triggering click event
* @param {boolean} overhead Should the Tile be overhead?
* @private
*/
async _onToggleOverhead(event, overhead) {
await canvas.scene.updateEmbeddedDocuments("Tile", this.layer.controlled.map(o => {
return {_id: o.id, overhead: overhead};
}));
return this.render();
}
/* -------------------------------------------- */
/**
* Control video playback by toggling play or paused state for a video Tile.
* @param {object} event
* @private
*/
_onControlVideo(event) {
const src = this.object.sourceElement;
const icon = event.currentTarget.children[0];
const isPlaying = !src.paused && !src.ended;
// Intercepting state change if the source is not looping and not playing
if ( !src.loop && !isPlaying ) {
const self = this;
src.onpause = () => {
if ( self.object?.sourceElement ) {
icon.classList.replace("fa-pause", "fa-play");
self.render();
}
src.onpause = null;
};
}
return this.object.document.update({"video.autoplay": false}, {
diff: false,
playVideo: !isPlaying,
offset: src.ended ? 0 : null
});
}
}
/**
* The Application responsible for configuring a single Token document within a parent Scene.
* @param {TokenDocument|Actor} object The {@link TokenDocument} being configured or an {@link Actor} for whom
* to configure the {@link PrototypeToken}
* @param {FormApplicationOptions} [options] Application configuration options.
*/
class TokenConfig extends DocumentSheet {
constructor(object, options) {
super(object, options);
/**
* The placed Token object in the Scene
* @type {TokenDocument}
*/
this.token = this.object;
/**
* A reference to the Actor which the token depicts
* @type {Actor}
*/
this.actor = this.object.actor;
// Configure options
if ( this.isPrototype ) this.options.sheetConfig = false;
}
/**
* Maintain a copy of the original to show a real-time preview of changes.
* @type {TokenDocument|PrototypeToken}
*/
preview;
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "token-sheet"],
template: "templates/scene/token-config.html",
width: 480,
height: "auto",
tabs: [
{navSelector: '.tabs[data-group="main"]', contentSelector: "form", initial: "character"},
{navSelector: '.tabs[data-group="light"]', contentSelector: '.tab[data-tab="light"]', initial: "basic"},
{navSelector: '.tabs[data-group="vision"]', contentSelector: '.tab[data-tab="vision"]', initial: "basic"}
],
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER,
sheetConfig: true
});
}
/* -------------------------------------------- */
/**
* A convenience accessor to test whether we are configuring the prototype Token for an Actor.
* @type {boolean}
*/
get isPrototype() {
return this.object instanceof foundry.data.PrototypeToken;
}
/* -------------------------------------------- */
/** @inheritDoc */
get id() {
if ( this.isPrototype ) return `${this.constructor.name}-${this.actor.uuid}`;
else return super.id;
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
if ( this.isPrototype ) return `${game.i18n.localize("TOKEN.TitlePrototype")}: ${this.actor.name}`;
return `${game.i18n.localize("TOKEN.Title")}: ${this.token.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
render(force=false, options={}) {
if ( this.isPrototype ) this.object.actor.apps[this.appId] = this;
return super.render(force, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options={}) {
await this._handleTokenPreview(force, options);
return super._render(force, options);
}
/* -------------------------------------------- */
/**
* Handle preview with a token.
* @param {boolean} force
* @param {object} options
* @returns {Promise<void>}
* @protected
*/
async _handleTokenPreview(force, options={}) {
const states = Application.RENDER_STATES;
if ( force && [states.CLOSED, states.NONE].includes(this._state) ) {
if ( this.isPrototype ) {
this.preview = this.object.clone();
return;
}
if ( !this.document.object ) {
this.preview = null;
return;
}
if ( !this.preview ) {
const clone = this.document.object.clone();
Object.defineProperty(clone, "sourceId", {
get() { return `${this.document.documentName}.${this.document.id}`; },
configurable: true,
enumerable: false
});
this.preview = clone.document;
clone.control({releaseOthers: true});
}
await this.preview.object.draw();
this.document.object.renderable = false;
this.preview.object.layer.preview.addChild(this.preview.object);
this._previewChanges();
}
}
/* -------------------------------------------- */
/** @inheritDoc */
_canUserView(user) {
const canView = super._canUserView(user);
return canView && game.user.can("TOKEN_CONFIGURE");
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
const alternateImages = await this._getAlternateTokenImages();
const attributeSource = this.actor?.system instanceof foundry.abstract.DataModel
? this.actor?.type
: this.actor?.system;
const attributes = TokenDocument.implementation.getTrackedAttributes(attributeSource);
const canBrowseFiles = game.user.hasPermission("FILES_BROWSE");
const gridUnits = (this.isPrototype || !canvas.ready) ? game.system.gridUnits : canvas.scene.grid.units;
// Prepare Token data
const doc = this.preview ?? this.document;
const token = doc.toObject();
const basicDetection = token.detectionModes.find(m => m.id === DetectionMode.BASIC_MODE_ID) ? null
: doc.detectionModes.find(m => m.id === DetectionMode.BASIC_MODE_ID);
// Return rendering context
return {
cssClasses: [this.isPrototype ? "prototype" : null].filter(c => !!c).join(" "),
isPrototype: this.isPrototype,
hasAlternates: !foundry.utils.isEmpty(alternateImages),
alternateImages: alternateImages,
object: token,
options: this.options,
gridUnits: gridUnits || game.i18n.localize("GridUnits"),
barAttributes: TokenDocument.implementation.getTrackedAttributeChoices(attributes),
bar1: doc.getBarAttribute?.("bar1"),
bar2: doc.getBarAttribute?.("bar2"),
colorationTechniques: AdaptiveLightingShader.SHADER_TECHNIQUES,
visionModes: Object.values(CONFIG.Canvas.visionModes).filter(f => f.tokenConfig),
detectionModes: Object.values(CONFIG.Canvas.detectionModes).filter(f => f.tokenConfig),
basicDetection,
displayModes: Object.entries(CONST.TOKEN_DISPLAY_MODES).reduce((obj, e) => {
obj[e[1]] = game.i18n.localize(`TOKEN.DISPLAY_${e[0]}`);
return obj;
}, {}),
actors: game.actors.reduce((actors, a) => {
if ( !a.isOwner ) return actors;
actors.push({_id: a.id, name: a.name});
return actors;
}, []).sort((a, b) => a.name.localeCompare(b.name)),
dispositions: Object.entries(CONST.TOKEN_DISPOSITIONS).reduce((obj, e) => {
obj[e[1]] = game.i18n.localize(`TOKEN.DISPOSITION.${e[0]}`);
return obj;
}, {}),
lightAnimations: Object.entries(CONFIG.Canvas.lightAnimations).reduce((obj, e) => {
obj[e[0]] = game.i18n.localize(e[1].label);
return obj;
}, {"": game.i18n.localize("None")}),
isGM: game.user.isGM,
randomImgEnabled: this.isPrototype && (canBrowseFiles || doc.randomImg),
scale: Math.abs(doc.texture.scaleX),
mirrorX: doc.texture.scaleX < 0,
mirrorY: doc.texture.scaleY < 0
};
}
/* --------------------------------------------- */
/** @inheritdoc */
async _renderInner(...args) {
await loadTemplates([
"templates/scene/parts/token-lighting.html",
"templates/scene/parts/token-vision.html",
"templates/scene/parts/token-resources.html"
]);
return super._renderInner(...args);
}
/* -------------------------------------------- */
/**
* Get an Object of image paths and filenames to display in the Token sheet
* @returns {Promise<object>}
* @private
*/
async _getAlternateTokenImages() {
if ( !this.actor?.prototypeToken.randomImg ) return {};
const alternates = await this.actor.getTokenImages();
return alternates.reduce((obj, img) => {
obj[img] = img.split("/").pop();
return obj;
}, {});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find(".action-button").click(this._onClickActionButton.bind(this));
html.find(".bar-attribute").change(this._onBarChange.bind(this));
html.find(".alternate-images").change(ev => ev.target.form["texture.src"].value = ev.target.value);
html.find("button.assign-token").click(this._onAssignToken.bind(this));
this._disableEditImage();
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
const states = Application.RENDER_STATES;
if ( options.force || [states.RENDERED, states.ERROR].includes(this._state) ) {
this._resetPreview();
}
await super.close(options);
if ( this.isPrototype ) delete this.object.actor.apps?.[this.appId];
}
/* -------------------------------------------- */
/** @inheritDoc */
_getSubmitData(updateData={}) {
const formData = super._getSubmitData(updateData);
// Mirror token scale
if ( "scale" in formData ) {
formData["texture.scaleX"] = formData.scale * (formData.mirrorX ? -1 : 1);
formData["texture.scaleY"] = formData.scale * (formData.mirrorY ? -1 : 1);
}
["scale", "mirrorX", "mirrorY"].forEach(k => delete formData[k]);
// Clear detection modes array
if ( !("detectionModes.0.id" in formData) ) formData.detectionModes = [];
// Treat "None" as null for bar attributes
formData["bar1.attribute"] ||= null;
formData["bar2.attribute"] ||= null;
return formData;
}
/* -------------------------------------------- */
/** @inheritDoc */
async _onChangeInput(event) {
await super._onChangeInput(event);
// Disable image editing for wildcards
this._disableEditImage();
// Pre-populate vision mode defaults
const element = event.target;
if ( element.name === "sight.visionMode" ) {
const visionDefaults = CONFIG.Canvas.visionModes[element.value]?.vision?.defaults || {};
const update = fieldName => {
const field = this.form.querySelector(`[name="sight.${fieldName}"]`);
if ( fieldName in visionDefaults ) {
field.valueAsNumber = visionDefaults[fieldName];
field.nextElementSibling.innerText = visionDefaults[fieldName];
}
};
for ( const fieldName of ["attenuation", "brightness", "saturation", "contrast"] ) update(fieldName);
}
// Preview token changes
const previewData = this._getSubmitData();
this._previewChanges(previewData);
}
/* -------------------------------------------- */
/**
* Mimic changes to the Token document as if they were true document updates.
* @param {object} [change] The change to preview.
* @protected
*/
_previewChanges(change) {
if ( !this.preview ) return;
if ( change ) {
change = {...change};
delete change.actorId;
delete change.actorLink;
this.preview.updateSource(change);
}
if ( !this.isPrototype && (this.preview.object?.destroyed === false) ) {
this.preview.object.renderFlags.set({refresh: true});
this.preview.object.updateSource();
canvas.perception.update({initializeVision: true});
}
}
/* -------------------------------------------- */
/**
* Reset the temporary preview of the Token when the form is submitted or closed.
* @protected
*/
_resetPreview() {
if ( !this.preview ) return;
if ( this.isPrototype ) return this.preview = null;
if ( this.preview.object?.destroyed === false ) {
this.preview.object.destroy({children: true});
}
this.preview.baseActor?._unregisterDependentToken(this.preview);
this.preview = null;
if ( this.document.object?.destroyed === false ) {
this.document.object.renderable = true;
this.document.object.renderFlags.set({refresh: true});
this.document.object.control();
this.document.object.updateSource();
}
canvas.perception.update({initializeVision: true});
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
this._resetPreview();
return this.token.update(formData);
}
/* -------------------------------------------- */
/**
* Handle Token assignment requests to update the default prototype Token
* @param {MouseEvent} event The left-click event on the assign token button
* @private
*/
async _onAssignToken(event) {
event.preventDefault();
// Get controlled Token data
let tokens = canvas.ready ? canvas.tokens.controlled : [];
if ( tokens.length !== 1 ) {
ui.notifications.warn("TOKEN.AssignWarn", {localize: true});
return;
}
const token = tokens.pop().document.toObject();
token.tokenId = token.x = token.y = null;
token.randomImg = this.form.elements.randomImg.checked;
if ( token.randomImg ) delete token.texture.src;
// Update the prototype token for the actor using the existing Token instance
await this.actor.update({prototypeToken: token}, {diff: false, recursive: false, noHook: true});
ui.notifications.info(game.i18n.format("TOKEN.AssignSuccess", {name: this.actor.name}));
}
/* -------------------------------------------- */
/**
* Handle changing the attribute bar in the drop-down selector to update the default current and max value
* @param {Event} event The select input change event
* @private
*/
async _onBarChange(event) {
const form = event.target.form;
const doc = this.preview ?? this.document;
const attr = doc.getBarAttribute("", {alternative: event.target.value});
const bar = event.target.name.split(".").shift();
form.querySelector(`input.${bar}-value`).value = attr !== null ? attr.value : "";
form.querySelector(`input.${bar}-max`).value = ((attr !== null) && (attr.type === "bar")) ? attr.max : "";
}
/* -------------------------------------------- */
/**
* Handle click events on a token configuration sheet action button
* @param {PointerEvent} event The originating click event
* @protected
*/
_onClickActionButton(event) {
event.preventDefault();
const button = event.currentTarget;
const action = button.dataset.action;
game.tooltip.deactivate();
// Get pending changes to modes
const modes = Object.values(foundry.utils.expandObject(this._getSubmitData())?.detectionModes || {});
// Manipulate the array
switch ( action ) {
case "addDetectionMode":
this._onAddDetectionMode(modes);
break;
case "removeDetectionMode":
const idx = button.closest(".detection-mode").dataset.index;
this._onRemoveDetectionMode(Number(idx), modes);
break;
}
this._previewChanges({detectionModes: modes});
this.render();
}
/* -------------------------------------------- */
/**
* Handle adding a detection mode.
* @param {object[]} modes The existing detection modes.
* @protected
*/
_onAddDetectionMode(modes) {
modes.push({id: "", range: 0, enabled: true});
}
/* -------------------------------------------- */
/**
* Handle removing a detection mode.
* @param {number} index The index of the detection mode to remove.
* @param {object[]} modes The existing detection modes.
* @protected
*/
_onRemoveDetectionMode(index, modes) {
modes.splice(index, 1);
}
/* -------------------------------------------- */
/**
* Disable the user's ability to edit the token image field if wildcard images are enabled and that user does not have
* file browser permissions.
* @private
*/
_disableEditImage() {
const img = this.form.querySelector('[name="texture.src"]');
const randomImg = this.form.querySelector('[name="randomImg"]');
if ( randomImg ) img.disabled = !game.user.hasPermission("FILES_BROWSE") && randomImg.checked;
}
}
/**
* A sheet that alters the values of the default Token configuration used when new Token documents are created.
* @extends {TokenConfig}
*/
class DefaultTokenConfig extends TokenConfig {
constructor(object, options) {
const setting = game.settings.get("core", DefaultTokenConfig.SETTING);
const cls = getDocumentClass("Token");
object = new cls({name: "Default Token", ...setting}, {actor: null, strict: false});
super(object, options);
}
/**
* The named world setting that stores the default Token configuration
* @type {string}
*/
static SETTING = "defaultToken";
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/scene/default-token-config.html",
sheetConfig: false
});
}
/* --------------------------------------------- */
/** @inheritdoc */
get id() {
return "default-token-config";
}
/* --------------------------------------------- */
/** @inheritdoc */
get title() {
return game.i18n.localize("SETTINGS.DefaultTokenN");
}
/* -------------------------------------------- */
/** @override */
get isEditable() {
return game.user.can("SETTINGS_MODIFY");
}
/* -------------------------------------------- */
/** @override */
_canUserView(user) {
return user.can("SETTINGS_MODIFY");
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const context = await super.getData(options);
return Object.assign(context, {
object: this.token.toObject(false),
isDefault: true,
barAttributes: TokenDocument.implementation.getTrackedAttributeChoices(),
bar1: this.token.bar1,
bar2: this.token.bar2
});
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData = {}) {
const formData = foundry.utils.expandObject(super._getSubmitData(updateData));
formData.light.color = formData.light.color || undefined;
formData.bar1.attribute = formData.bar1.attribute || null;
formData.bar2.attribute = formData.bar2.attribute || null;
return formData;
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
// Validate the default data
try {
this.object.updateSource(formData);
formData = foundry.utils.filterObject(this.token.toObject(), formData);
} catch(err) {
Hooks.onError("DefaultTokenConfig#_updateObject", err, {notify: "error"});
}
// Diff the form data against normal defaults
const defaults = foundry.documents.BaseToken.cleanData();
const delta = foundry.utils.diffObject(defaults, formData);
return game.settings.set("core", DefaultTokenConfig.SETTING, delta);
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find('button[data-action="reset"]').click(this.reset.bind(this));
}
/* -------------------------------------------- */
/**
* Reset the form to default values
* @returns {Promise<void>}
*/
async reset() {
const cls = getDocumentClass("Token");
this.object = new cls({}, {actor: null, strict: false});
this.token = this.object;
this.render();
}
/* --------------------------------------------- */
/** @inheritdoc */
async _onBarChange() {}
/* -------------------------------------------- */
/** @inheritdoc */
_onAddDetectionMode(modes) {
super._onAddDetectionMode(modes);
this.document.updateSource({ detectionModes: modes });
}
/* -------------------------------------------- */
/** @inheritdoc */
_onRemoveDetectionMode(index, modes) {
super._onRemoveDetectionMode(index, modes);
this.document.updateSource({ detectionModes: modes });
}
}
/**
* An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Token objects.
* This interface provides controls for visibility, attribute bars, elevation, status effects, and more.
* @type {BasePlaceableHUD}
*/
class TokenHUD extends BasePlaceableHUD {
/**
* Track whether the status effects control palette is currently expanded or hidden
* @type {boolean}
* @private
*/
_statusEffects = false;
/**
* Track whether a control icon is hovered or not
* @type {boolean}
*/
#hoverControlIcon = false;
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "token-hud",
template: "templates/hud/token-hud.html"
});
}
/* -------------------------------------------- */
/** @override */
bind(object) {
this._statusEffects = false;
return super.bind(object);
}
/* -------------------------------------------- */
/**
* Refresh the currently active state of all status effect icons in the Token HUD selector.
*/
refreshStatusIcons() {
const effects = this.element.find(".status-effects")[0];
const statuses = this._getStatusEffectChoices();
for ( let img of effects.children ) {
const status = statuses[img.getAttribute("src")] || {};
img.classList.toggle("overlay", !!status.isOverlay);
img.classList.toggle("active", !!status.isActive);
}
}
/* -------------------------------------------- */
/** @override */
setPosition(_position) {
const td = this.object.document;
const ratio = canvas.dimensions.size / 100;
const position = {
width: td.width * 100,
height: td.height * 100,
left: this.object.x,
top: this.object.y
};
if ( ratio !== 1 ) position.transform = `scale(${ratio})`;
this.element.css(position);
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
let data = super.getData(options);
const bar1 = this.object.document.getBarAttribute("bar1");
const bar2 = this.object.document.getBarAttribute("bar2");
data = foundry.utils.mergeObject(data, {
canConfigure: game.user.can("TOKEN_CONFIGURE"),
canToggleCombat: ui.combat !== null,
displayBar1: bar1 && (bar1.type !== "none"),
bar1Data: bar1,
displayBar2: bar2 && (bar2.type !== "none"),
bar2Data: bar2,
visibilityClass: data.hidden ? "active" : "",
effectsClass: this._statusEffects ? "active" : "",
combatClass: this.object.inCombat ? "active" : "",
targetClass: this.object.targeted.has(game.user) ? "active" : ""
});
data.statusEffects = this._getStatusEffectChoices(data);
return data;
}
/* -------------------------------------------- */
/**
* Get an array of icon paths which represent valid status effect choices
* @private
*/
_getStatusEffectChoices() {
const token = this.object;
const doc = token.document;
// Get statuses which are active for the token actor
const actor = token.actor || null;
const statuses = actor ? actor.effects.reduce((obj, effect) => {
for ( const id of effect.statuses ) {
obj[id] = {id, overlay: !!effect.getFlag("core", "overlay")};
}
return obj;
}, {}) : {};
// Prepare the list of effects from the configured defaults and any additional effects present on the Token
const tokenEffects = foundry.utils.deepClone(doc.effects) || [];
if ( doc.overlayEffect ) tokenEffects.push(doc.overlayEffect);
return CONFIG.statusEffects.concat(tokenEffects).reduce((obj, e) => {
const src = e.icon ?? e;
if ( src in obj ) return obj;
const status = statuses[e.id] || {};
const isActive = !!status.id || doc.effects.includes(src);
const isOverlay = !!status.overlay || doc.overlayEffect === src;
/** @deprecated since v11 */
const label = e.name ?? e.label;
obj[src] = {
id: e.id ?? "",
title: label ? game.i18n.localize(label) : null,
src,
isActive,
isOverlay,
cssClass: [
isActive ? "active" : null,
isOverlay ? "overlay" : null
].filterJoin(" ")
};
return obj;
}, {});
}
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
// Attribute Bars
html.find(".attribute input")
.click(this._onAttributeClick)
.keydown(this._onAttributeKeydown.bind(this))
.focusout(this._onAttributeUpdate.bind(this));
// Control icons hover detection
html.find(".control-icon")
.mouseleave(() => this.#hoverControlIcon = false)
.mouseenter(() => this.#hoverControlIcon = true);
// Status Effects Controls
this._toggleStatusEffects(this._statusEffects);
html.find(".status-effects")
.on("click", ".effect-control", this._onToggleEffect.bind(this))
.on("contextmenu", ".effect-control", event => this._onToggleEffect(event, {overlay: true}));
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickControl(event) {
super._onClickControl(event);
if ( event.defaultPrevented ) return;
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "config":
return this._onTokenConfig(event);
case "combat":
return this._onToggleCombat(event);
case "target":
return this._onToggleTarget(event);
case "effects":
return this._onToggleStatusEffects(event);
}
}
/* -------------------------------------------- */
/**
* Handle initial click to focus an attribute update field
* @private
*/
_onAttributeClick(event) {
event.currentTarget.select();
}
/* -------------------------------------------- */
/**
* Force field handling on an Enter keypress even if the value of the field did not change.
* This is important to suppose use cases with negative number values.
* @param {KeyboardEvent} event The originating keydown event
* @private
*/
_onAttributeKeydown(event) {
if ( (event.code === "Enter") || (event.code === "NumpadEnter") ) event.currentTarget.blur();
}
/* -------------------------------------------- */
/**
* Handle attribute bar update
* @private
*/
_onAttributeUpdate(event) {
event.preventDefault();
if ( !this.object ) return;
// Acquire string input
const input = event.currentTarget;
let strVal = input.value.trim();
let isDelta = strVal.startsWith("+") || strVal.startsWith("-");
if (strVal.startsWith("=")) strVal = strVal.slice(1);
let value = Number(strVal);
// For attribute bar values, update the associated Actor
const bar = input.dataset.bar;
const actor = this.object?.actor;
if ( bar && actor ) {
const attr = this.object.document.getBarAttribute(bar);
if ( isDelta || (attr.attribute !== value) ) {
actor.modifyTokenAttribute(attr.attribute, value, isDelta, attr.type === "bar");
}
}
// Otherwise update the Token directly
else {
const current = foundry.utils.getProperty(this.object.document, input.name);
this.object.document.update({[input.name]: isDelta ? current + value : value});
}
// Clear the HUD
if ( !this.#hoverControlIcon ) this.clear();
}
/* -------------------------------------------- */
/**
* Toggle Token combat state
* @private
*/
async _onToggleCombat(event) {
event.preventDefault();
return this.object.toggleCombat();
}
/* -------------------------------------------- */
/**
* Handle Token configuration button click
* @private
*/
_onTokenConfig(event) {
event.preventDefault();
this.object.sheet.render(true);
}
/* -------------------------------------------- */
/**
* Handle left-click events to toggle the displayed state of the status effect selection palette
* @param {MouseEvent }event
* @private
*/
_onToggleStatusEffects(event) {
event.preventDefault();
this._toggleStatusEffects(!this._statusEffects);
}
/* -------------------------------------------- */
/**
* Assign css selectors for the active state of the status effects selection palette
* @param {boolean} active Should the effects menu be active?
* @private
*/
_toggleStatusEffects(active) {
this._statusEffects = active;
const button = this.element.find('.control-icon[data-action="effects"]')[0];
button.classList.toggle("active", active);
const palette = button.querySelector(".status-effects");
palette.classList.toggle("active", active);
}
/* -------------------------------------------- */
/**
* Handle toggling a token status effect icon
* @param {PointerEvent} event The click event to toggle the effect
* @param {object} [options] Options which modify the toggle
* @param {boolean} [options.overlay] Toggle the overlay effect?
* @private
*/
_onToggleEffect(event, {overlay=false}={}) {
event.preventDefault();
event.stopPropagation();
let img = event.currentTarget;
const effect = ( img.dataset.statusId && this.object.actor ) ?
CONFIG.statusEffects.find(e => e.id === img.dataset.statusId) :
img.getAttribute("src");
return this.object.toggleEffect(effect, {overlay});
}
/* -------------------------------------------- */
/**
* Handle toggling the target state for this Token
* @param {PointerEvent} event The click event to toggle the target
* @private
*/
_onToggleTarget(event) {
event.preventDefault();
const btn = event.currentTarget;
const token = this.object;
const targeted = !token.isTargeted;
token.setTarget(targeted, {releaseOthers: false});
btn.classList.toggle("active", targeted);
}
}
/**
* The Application responsible for configuring a single Wall document within a parent Scene.
* @param {Wall} object The Wall object for which settings are being configured
* @param {FormApplicationOptions} [options] Additional options which configure the rendering of the configuration
* sheet.
*/
class WallConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.classes.push("wall-config");
options.template = "templates/scene/wall-config.html";
options.width = 400;
options.height = "auto";
return options;
}
/**
* An array of Wall ids that should all be edited when changes to this config form are submitted
* @type {string[]}
*/
editTargets = [];
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
if ( this.editTargets.length > 1 ) return game.i18n.localize("WALLS.TitleMany");
return super.title;
}
/* -------------------------------------------- */
/** @inheritdoc */
render(force, options) {
if ( options?.walls instanceof Array ) {
this.editTargets = options.walls.map(w => w.id);
}
return super.render(force, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
context.source = this.document.toObject();
context.p0 = {x: this.object.c[0], y: this.object.c[1]};
context.p1 = {x: this.object.c[2], y: this.object.c[3]};
context.gridUnits = canvas.scene.grid.units || game.i18n.localize("GridUnits");
context.moveTypes = Object.keys(CONST.WALL_MOVEMENT_TYPES).reduce((obj, key) => {
let k = CONST.WALL_MOVEMENT_TYPES[key];
obj[k] = game.i18n.localize(`WALLS.SenseTypes.${key}`);
return obj;
}, {});
context.senseTypes = Object.keys(CONST.WALL_SENSE_TYPES).reduce((obj, key) => {
let k = CONST.WALL_SENSE_TYPES[key];
obj[k] = game.i18n.localize(`WALLS.SenseTypes.${key}`);
return obj;
}, {});
context.dirTypes = Object.keys(CONST.WALL_DIRECTIONS).reduce((obj, key) => {
let k = CONST.WALL_DIRECTIONS[key];
obj[k] = game.i18n.localize(`WALLS.Directions.${key}`);
return obj;
}, {});
context.doorTypes = Object.keys(CONST.WALL_DOOR_TYPES).reduce((obj, key) => {
let k = CONST.WALL_DOOR_TYPES[key];
obj[k] = game.i18n.localize(`WALLS.DoorTypes.${key}`);
return obj;
}, {});
context.doorStates = Object.keys(CONST.WALL_DOOR_STATES).reduce((obj, key) => {
let k = CONST.WALL_DOOR_STATES[key];
obj[k] = game.i18n.localize(`WALLS.DoorStates.${key}`);
return obj;
}, {});
context.doorSounds = CONFIG.Wall.doorSounds;
context.isDoor = this.object.isDoor;
return context;
}
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
html.find(".audio-preview").click(this.#onAudioPreview.bind(this));
this.#enableDoorOptions(this.document.door > CONST.WALL_DOOR_TYPES.NONE);
this.#toggleThresholdInputVisibility();
return super.activateListeners(html);
}
/* -------------------------------------------- */
#audioPreviewState = 0;
/**
* Handle previewing a sound file for a Wall setting
* @param {Event} event The initial button click event
*/
#onAudioPreview(event) {
const doorSoundName = this.form.doorSound.value;
const doorSound = CONFIG.Wall.doorSounds[doorSoundName];
if ( !doorSound ) return;
const interactions = CONST.WALL_DOOR_INTERACTIONS;
const interaction = interactions[this.#audioPreviewState++ % interactions.length];
let sounds = doorSound[interaction];
if ( !sounds ) return;
if ( !Array.isArray(sounds) ) sounds = [sounds];
const src = sounds[Math.floor(Math.random() * sounds.length)];
const volume = game.settings.get("core", "globalInterfaceVolume");
game.audio.play(src, {volume});
}
/* -------------------------------------------- */
/** @inheritDoc */
async _onChangeInput(event) {
if ( event.currentTarget.name === "door" ) {
this.#enableDoorOptions(Number(event.currentTarget.value) > CONST.WALL_DOOR_TYPES.NONE);
}
else if ( event.currentTarget.name === "doorSound" ) {
this.#audioPreviewState = 0;
}
else if ( ["light", "sight", "sound"].includes(event.currentTarget.name) ) {
this.#toggleThresholdInputVisibility();
}
return super._onChangeInput(event);
}
/* -------------------------------------------- */
/**
* Toggle the disabled attribute of the door state select.
* @param {boolean} isDoor
*/
#enableDoorOptions(isDoor) {
const doorOptions = this.form.querySelector(".door-options");
doorOptions.disabled = !isDoor;
doorOptions.classList.toggle("hidden", !isDoor);
this.setPosition({height: "auto"});
}
/* -------------------------------------------- */
/**
* Toggle visibility of proximity input fields.
*/
#toggleThresholdInputVisibility() {
const form = this.form;
const showTypes = [CONST.WALL_SENSE_TYPES.PROXIMITY, CONST.WALL_SENSE_TYPES.DISTANCE];
for ( const sense of ["light", "sight", "sound"] ) {
const select = form[sense];
const input = select.parentElement.querySelector(".proximity");
input.classList.toggle("hidden", !showTypes.includes(Number(select.value)));
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData={}) {
const thresholdTypes = [CONST.WALL_SENSE_TYPES.PROXIMITY, CONST.WALL_SENSE_TYPES.DISTANCE];
const formData = super._getSubmitData(updateData);
for ( const sense of ["light", "sight", "sound"] ) {
if ( !thresholdTypes.includes(formData[sense]) ) formData[`threshold.${sense}`] = null;
}
return formData;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
// Update multiple walls
if ( this.editTargets.length > 1 ) {
const updateData = canvas.scene.walls.reduce((arr, w) => {
if ( this.editTargets.includes(w.id) ) {
arr.push(foundry.utils.mergeObject(w.toJSON(), formData));
}
return arr;
}, []);
return canvas.scene.updateEmbeddedDocuments("Wall", updateData, {sound: false});
}
// Update single wall
if ( !this.object.id ) return;
return this.object.update(formData, {sound: false});
}
}
/**
* A simple application which supports popping a ChatMessage out to a separate UI window.
* @extends {Application}
* @param {ChatMessage} object The {@link ChatMessage} object that is being popped out.
* @param {ApplicationOptions} [options] Application configuration options.
*/
class ChatPopout extends Application {
constructor(message, options) {
super(options);
/**
* The displayed Chat Message document
* @type {ChatMessage}
*/
this.message = message;
// Register the application
this.message.apps[this.appId] = this;
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
width: 300,
height: "auto",
classes: ["chat-popout"]
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get id() {
return `chat-popout-${this.message.id}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
let title = this.message.flavor ?? this.message.speaker.alias;
return TextEditor.previewHTML(title, 32);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _renderInner(data, options) {
const html = await this.message.getHTML();
html.find(".message-delete").remove();
return html;
}
}
/**
* The Application responsible for displaying and editing the client and world settings for this world.
* This form renders the settings defined via the game.settings.register API which have config = true
*/
class SettingsConfig extends PackageConfiguration {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("SETTINGS.Title"),
id: "client-settings",
categoryTemplate: "templates/sidebar/apps/settings-config-category.html",
submitButton: true
});
}
/* -------------------------------------------- */
/** @inheritDoc */
_prepareCategoryData() {
const gs = game.settings;
const canConfigure = game.user.can("SETTINGS_MODIFY");
let categories = new Map();
let total = 0;
const getCategory = category => {
let cat = categories.get(category.id);
if ( !cat ) {
cat = {
id: category.id,
title: category.title,
menus: [],
settings: [],
count: 0
};
categories.set(category.id, cat);
}
return cat;
};
// Classify all menus
for ( let menu of gs.menus.values() ) {
if ( menu.restricted && !canConfigure ) continue;
const category = getCategory(this._categorizeEntry(menu.namespace));
category.menus.push(menu);
total++;
}
// Classify all settings
for ( let setting of gs.settings.values() ) {
if ( !setting.config || (!canConfigure && (setting.scope !== "client")) ) continue;
// Update setting data
const s = foundry.utils.deepClone(setting);
s.id = `${s.namespace}.${s.key}`;
s.name = game.i18n.localize(s.name);
s.hint = game.i18n.localize(s.hint);
s.value = game.settings.get(s.namespace, s.key);
s.type = setting.type instanceof Function ? setting.type.name : "String";
s.isCheckbox = setting.type === Boolean;
s.isSelect = s.choices !== undefined;
s.isRange = (setting.type === Number) && s.range;
s.isNumber = setting.type === Number;
s.filePickerType = s.filePicker === true ? "any" : s.filePicker;
const category = getCategory(this._categorizeEntry(setting.namespace));
category.settings.push(s);
total++;
}
// Sort categories by priority and assign Counts
for ( let category of categories.values() ) {
category.count = category.menus.length + category.settings.length;
}
categories = Array.from(categories.values()).sort(this._sortCategories.bind(this));
return {categories, total, user: game.user, canConfigure};
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find(".submenu button").click(this._onClickSubmenu.bind(this));
html.find('[name="core.fontSize"]').change(this._previewFontScaling.bind(this));
}
/* -------------------------------------------- */
/**
* Handle activating the button to configure User Role permissions
* @param {Event} event The initial button click event
* @private
*/
_onClickSubmenu(event) {
event.preventDefault();
const menu = game.settings.menus.get(event.currentTarget.dataset.key);
if ( !menu ) return ui.notifications.error("No submenu found for the provided key");
const app = new menu.type();
return app.render(true);
}
/* -------------------------------------------- */
/**
* Preview font scaling as the setting is changed.
* @param {Event} event The triggering event.
* @private
*/
_previewFontScaling(event) {
const scale = Number(event.currentTarget.value);
game.scaleFonts(scale);
this.setPosition();
}
/* --------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
game.scaleFonts();
return super.close(options);
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
let requiresClientReload = false;
let requiresWorldReload = false;
for ( let [k, v] of Object.entries(foundry.utils.flattenObject(formData)) ) {
let s = game.settings.settings.get(k);
let current = game.settings.get(s.namespace, s.key);
if ( v === current ) continue;
requiresClientReload ||= (s.scope === "client") && s.requiresReload;
requiresWorldReload ||= (s.scope === "world") && s.requiresReload;
await game.settings.set(s.namespace, s.key, v);
}
if ( requiresClientReload || requiresWorldReload ) this.constructor.reloadConfirm({world: requiresWorldReload});
}
/* -------------------------------------------- */
/**
* Handle button click to reset default settings
* @param {Event} event The initial button click event
* @private
*/
_onResetDefaults(event) {
event.preventDefault();
const form = this.element.find("form")[0];
for ( let [k, v] of game.settings.settings.entries() ) {
if ( !v.config ) continue;
const input = form[k];
if ( !input ) continue;
if ( input.type === "checkbox" ) input.checked = v.default;
else input.value = v.default;
$(input).change();
}
ui.notifications.info("SETTINGS.ResetInfo", {localize: true});
}
/* -------------------------------------------- */
/**
* Confirm if the user wishes to reload the application.
* @param {object} [options] Additional options to configure the prompt.
* @param {boolean} [options.world=false] Whether to reload all connected clients as well.
* @returns {Promise<void>}
*/
static async reloadConfirm({world=false}={}) {
const reload = await Dialog.confirm({
title: game.i18n.localize("SETTINGS.ReloadPromptTitle"),
content: `<p>${game.i18n.localize("SETTINGS.ReloadPromptBody")}</p>`
});
if ( !reload ) return;
if ( world && game.user.isGM ) game.socket.emit("reload");
foundry.utils.debouncedReload();
}
}
/**
* An interface for displaying the content of a CompendiumCollection.
* @param {CompendiumCollection} collection The {@link CompendiumCollection} object represented by this interface.
* @param {ApplicationOptions} [options] Application configuration options.
*/
class Compendium extends DocumentDirectory {
constructor(...args) {
if ( args[0] instanceof Collection ) {
foundry.utils.logCompatibilityWarning("Compendium constructor should now be passed a CompendiumCollection "
+ "instance via {collection: compendiumCollection}", {
since: 11,
until: 13
});
args[1] ||= {};
args[1].collection = args.shift();
}
super(...args);
}
/* -------------------------------------------- */
/** @override */
get entryType() {
return this.metadata.type;
}
/* -------------------------------------------- */
/** @override */
static entryPartial = "templates/sidebar/partials/compendium-index-partial.html";
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/apps/compendium.html",
width: 350,
height: window.innerHeight - 100,
top: 70,
left: 120,
popOut: true
});
}
/* -------------------------------------------- */
/** @inheritDoc */
get id() {
return `compendium-${this.collection.collection}`;
}
/* ----------------------------------------- */
/** @inheritdoc */
get title() {
return [this.collection.title, this.collection.locked ? "[Locked]" : null].filterJoin(" ");
}
/* -------------------------------------------- */
/** @inheritdoc */
get tabName() {
return "Compendium";
}
/* -------------------------------------------- */
/** @override */
get canCreateEntry() {
const cls = getDocumentClass(this.collection.documentName);
const isOwner = this.collection.testUserPermission(game.user, "OWNER");
return !this.collection.locked && isOwner && cls.canUserCreate(game.user);
}
/* -------------------------------------------- */
/** @override */
get canCreateFolder() {
return this.canCreateEntry;
}
/* ----------------------------------------- */
/**
* A convenience redirection back to the metadata object of the associated CompendiumCollection
* @returns {object}
*/
get metadata() {
return this.collection.metadata;
}
/* -------------------------------------------- */
/** @override */
initialize() {
this.collection.initializeTree();
}
/* ----------------------------------------- */
/* Rendering */
/* ----------------------------------------- */
/** @inheritDoc */
render(force, options) {
if ( !this.collection.visible ) {
if ( force ) ui.notifications.warn("COMPENDIUM.CannotViewWarning", {localize: true});
return this;
}
return super.render(force, options);
}
/* ----------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
const context = await super.getData(options);
return foundry.utils.mergeObject(context, {
collection: this.collection,
index: this.collection.index,
name: game.i18n.localize(this.metadata.label),
footerButtons: []
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
_entryAlreadyExists(document) {
return (document.pack === this.collection.collection) && this.collection.index.has(document.id);
}
/* -------------------------------------------- */
/** @override */
async _createDroppedEntry(document, folderId) {
document = document.clone({folder: folderId || null}, {keepId: true});
return this.collection.importDocument(document);
}
/* -------------------------------------------- */
/** @override */
_getEntryDragData(entryId) {
return {
type: this.collection.documentName,
uuid: this.collection.getUuid(entryId)
};
}
/* -------------------------------------------- */
/** @override */
_onCreateEntry(event) {
// If this is an Adventure, use the Adventure Exporter application
if ( this.collection.documentName === "Adventure" ) {
const adventure = new Adventure({name: "New Adventure"}, {pack: this.collection.collection});
return new AdventureExporter(adventure).render(true);
}
return super._onCreateEntry(event);
}
/* -------------------------------------------- */
/**
* Handle clicks on a footer button
* @param {PointerEvent} event The originating pointer event
* @private
*/
_onClickFooterButton(event) {
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "createAdventure":
const adventure = new Adventure({name: "New Adventure"}, {pack: this.collection.collection});
return new AdventureExporter(adventure).render(true);
}
}
/* -------------------------------------------- */
/** @override */
_getDocumentDragData(documentId) {
return {
type: this.collection.documentName,
uuid: this.collection.getUuid(documentId)
};
}
/* -------------------------------------------- */
/** @override */
_getFolderDragData(folderId) {
const folder = this.collection.folders.get(folderId);
if ( !folder ) return null;
return {
type: "Folder",
uuid: folder.uuid
};
}
/* -------------------------------------------- */
/** @override */
_getFolderContextOptions() {
const toRemove = ["OWNERSHIP.Configure", "FOLDER.Export"];
return super._getFolderContextOptions().filter(o => !toRemove.includes(o.name));
}
/* -------------------------------------------- */
/** @override */
_getEntryContextOptions() {
const isAdventure = this.collection.documentName === "Adventure";
return [
{
name: "COMPENDIUM.ImportEntry",
icon: '<i class="fas fa-download"></i>',
condition: () => !isAdventure && this.collection.documentClass.canUserCreate(game.user),
callback: li => {
const collection = game.collections.get(this.collection.documentName);
const id = li.data("document-id");
return collection.importFromCompendium(this.collection, id, {}, {renderSheet: true});
}
},
{
name: "ADVENTURE.ExportEdit",
icon: '<i class="fa-solid fa-edit"></i>',
condition: () => isAdventure && game.user.isGM && !this.collection.locked,
callback: async li => {
const id = li.data("document-id");
const document = await this.collection.getDocument(id);
return new AdventureExporter(document.clone({}, {keepId: true})).render(true);
}
},
{
name: "SCENES.GenerateThumb",
icon: '<i class="fas fa-image"></i>',
condition: () => !this.collection.locked && (this.collection.documentName === "Scene"),
callback: async li => {
const scene = await this.collection.getDocument(li.data("document-id"));
scene.createThumbnail().then(data => {
scene.update({thumb: data.thumb}, {diff: false});
ui.notifications.info(game.i18n.format("SCENES.GenerateThumbSuccess", {name: scene.name}));
}).catch(err => ui.notifications.error(err.message));
}
},
{
name: "COMPENDIUM.DeleteEntry",
icon: '<i class="fas fa-trash"></i>',
condition: () => game.user.isGM && !this.collection.locked,
callback: async li => {
const id = li.data("document-id");
const document = await this.collection.getDocument(id);
return Dialog.confirm({
title: `${game.i18n.localize("COMPENDIUM.DeleteEntry")} ${document.name}`,
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("COMPENDIUM.DeleteEntryWarning")}</p>`,
yes: () => document.delete()
});
}
}
];
}
}
/**
* Game Invitation Links Reference
* @extends {Application}
*/
class InvitationLinks extends Application {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "invitation-links",
template: "templates/sidebar/apps/invitation-links.html",
title: game.i18n.localize("INVITATIONS.Title"),
width: 400
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
let addresses = game.data.addresses;
// Check for IPv6 detection, and don't display connectivity info if so
if ( addresses.remote === undefined ) return addresses;
// Otherwise, handle remote connection test
if ( addresses.remoteIsAccessible == null ) {
addresses.remoteClass = "unknown-connection";
addresses.remoteTitle = game.i18n.localize("INVITATIONS.UnknownConnection");
addresses.failedCheck = true;
} else if ( addresses.remoteIsAccessible ) {
addresses.remoteClass = "connection";
addresses.remoteTitle = game.i18n.localize("INVITATIONS.OpenConnection");
addresses.canConnect = true;
} else {
addresses.remoteClass = "no-connection";
addresses.remoteTitle = game.i18n.localize("INVITATIONS.ClosedConnection");
addresses.canConnect = false;
}
return addresses;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find(".invite-link").click(ev => {
ev.preventDefault();
ev.target.select();
game.clipboard.copyPlainText(ev.currentTarget.value);
ui.notifications.info("INVITATIONS.Copied", {localize: true});
});
html.find(".refresh").click(ev => {
ev.preventDefault();
const icon = ev.currentTarget;
icon.className = "fas fa-sync fa-pulse";
let me = this;
setTimeout(function(){
game.socket.emit("refreshAddresses", addresses => {
game.data.addresses = addresses;
me.render(true);
});
}, 250)
});
html.find(".show-hide").click(ev => {
ev.preventDefault();
const icon = ev.currentTarget;
const showLink = icon.classList.contains("show-link");
if ( showLink ) {
icon.classList.replace("fa-eye", "fa-eye-slash");
icon.classList.replace("show-link", "hide-link");
}
else {
icon.classList.replace("fa-eye-slash", "fa-eye");
icon.classList.replace("hide-link", "show-link");
}
icon.closest("form").querySelector('#remote-link').type = showLink ? "text" : "password";
});
}
}
/**
* Allows for viewing and editing of Keybinding Actions
*/
class KeybindingsConfig extends PackageConfiguration {
/**
* Categories present in the app. Within each category is an array of package data
* @type {{categories: object[], total: number}}
* @protected
*/
#cachedData;
/**
* A Map of pending Edits. The Keys are bindingIds
* @type {Map<string, KeybindingActionBinding[]>}
* @private
*/
#pendingEdits = new Map();
/* -------------------------------------------- */
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("SETTINGS.Keybindings"),
id: "keybindings",
categoryTemplate: "templates/sidebar/apps/keybindings-config-category.html",
scrollY: [".scrollable"]
});
}
/* -------------------------------------------- */
/** @inheritDoc */
static get categoryOrder() {
const categories = super.categoryOrder;
categories.splice(2, 0, "core-mouse");
return categories;
}
/* -------------------------------------------- */
/** @inheritDoc */
_categorizeEntry(namespace) {
const category = super._categorizeEntry(namespace);
if ( namespace === "core" ) category.title = game.i18n.localize("KEYBINDINGS.CoreKeybindings");
return category;
}
/* -------------------------------------------- */
/** @inheritDoc */
_prepareCategoryData() {
if ( this.#cachedData ) return this.#cachedData;
// Classify all Actions
let categories = new Map();
let totalActions = 0;
const ctrlString = KeyboardManager.CONTROL_KEY_STRING;
for ( let [actionId, action] of game.keybindings.actions ) {
if ( action.restricted && !game.user.isGM ) continue;
totalActions++;
// Determine what category the action belongs to
let category = this._categorizeEntry(action.namespace);
// Carry over bindings for future rendering
const actionData = foundry.utils.deepClone(action);
actionData.category = category.title;
actionData.id = actionId;
actionData.name = game.i18n.localize(action.name);
actionData.hint = game.i18n.localize(action.hint);
actionData.cssClass = action.restricted ? "gm" : "";
actionData.notes = [
action.restricted ? game.i18n.localize("KEYBINDINGS.Restricted") : "",
action.reservedModifiers.length > 0 ? game.i18n.format("KEYBINDINGS.ReservedModifiers", {
modifiers: action.reservedModifiers.map(m => m === "Control" ? ctrlString : m.titleCase()).join(", ")
}) : "",
game.i18n.localize(action.hint)
].filterJoin("<br>");
actionData.uneditable = action.uneditable;
// Prepare binding-level data
actionData.bindings = (game.keybindings.bindings.get(actionId) ?? []).map((b, i) => {
const uneditable = action.uneditable.includes(b);
const binding = foundry.utils.deepClone(b);
binding.id = `${actionId}.binding.${i}`;
binding.display = KeybindingsConfig._humanizeBinding(binding);
binding.cssClasses = uneditable ? "uneditable" : "";
binding.isEditable = !uneditable;
binding.isFirst = i === 0;
const conflicts = this._detectConflictingActions(actionId, action, binding);
binding.conflicts = game.i18n.format("KEYBINDINGS.Conflict", {
conflicts: conflicts.map(action => game.i18n.localize(action.name)).join(", ")
});
binding.hasConflicts = conflicts.length > 0;
return binding;
});
actionData.noBindings = actionData.bindings.length === 0;
// Register a category the first time it is seen, otherwise add to it
if ( !categories.has(category.id) ) {
categories.set(category.id, {
id: category.id,
title: category.title,
actions: [actionData],
count: 0
});
} else categories.get(category.id).actions.push(actionData);
}
// Add Mouse Controls
totalActions += this._addMouseControlsReference(categories);
// Sort Actions by priority and assign Counts
for ( let category of categories.values() ) {
category.actions = category.actions.sort(ClientKeybindings._compareActions);
category.count = category.actions.length;
}
categories = Array.from(categories.values()).sort(this._sortCategories.bind(this));
return this.#cachedData = {categories, total: totalActions};
}
/* -------------------------------------------- */
/**
* Add faux-keybind actions that represent the possible Mouse Controls
* @param {Map} categories The current Map of Categories to add to
* @returns {number} The number of Actions added
* @private
*/
_addMouseControlsReference(categories) {
let coreMouseCategory = game.i18n.localize("KEYBINDINGS.CoreMouse");
const defineMouseAction = (id, name, keys, gmOnly=false) => {
return {
category: coreMouseCategory,
id: id,
name: game.i18n.localize(name),
notes: gmOnly ? game.i18n.localize("KEYBINDINGS.Restricted") : "",
bindings: [
{
display: keys.map(k => game.i18n.localize(k)).join(" + "),
cssClasses: "uneditable",
isEditable: false,
hasConflicts: false,
isFirst: false
}
]
};
};
const actions = [
["canvas-select", "CONTROLS.CanvasSelect", ["CONTROLS.LeftClick"]],
["canvas-select-many", "CONTROLS.CanvasSelectMany", ["Shift", "CONTROLS.LeftClick"]],
["canvas-drag", "CONTROLS.CanvasLeftDrag", ["CONTROLS.LeftClick", "CONTROLS.Drag"]],
["canvas-select-cancel", "CONTROLS.CanvasSelectCancel", ["CONTROLS.RightClick"]],
["canvas-pan-mouse", "CONTROLS.CanvasPan", ["CONTROLS.RightClick", "CONTROLS.Drag"]],
["canvas-zoom", "CONTROLS.CanvasSelectCancel", ["CONTROLS.MouseWheel"]],
["ruler-measure", "CONTROLS.RulerMeasure", [KeyboardManager.CONTROL_KEY_STRING, "CONTROLS.LeftDrag"]],
["ruler-measure-waypoint", "CONTROLS.RulerWaypoint", [KeyboardManager.CONTROL_KEY_STRING, "CONTROLS.LeftClick"]],
["object-sheet", "CONTROLS.ObjectSheet", [`${game.i18n.localize("CONTROLS.Double")} ${game.i18n.localize("CONTROLS.LeftClick")}`]],
["object-hud", "CONTROLS.ObjectHUD", ["CONTROLS.RightClick"]],
["object-config", "CONTROLS.ObjectConfig", [`${game.i18n.localize("CONTROLS.Double")} ${game.i18n.localize("CONTROLS.RightClick")}`]],
["object-drag", "CONTROLS.ObjectDrag", ["CONTROLS.LeftClick", "CONTROLS.Drag"]],
["object-no-snap", "CONTROLS.ObjectNoSnap", ["CONTROLS.Drag", "Shift", "CONTROLS.Drop"]],
["object-drag-cancel", "CONTROLS.ObjectDragCancel", [`${game.i18n.localize("CONTROLS.RightClick")} ${game.i18n.localize("CONTROLS.During")} ${game.i18n.localize("CONTROLS.Drag")}`]],
["object-rotate-slow", "CONTROLS.ObjectRotateSlow", [KeyboardManager.CONTROL_KEY_STRING, "CONTROLS.MouseWheel"]],
["object-rotate-fast", "CONTROLS.ObjectRotateFast", ["Shift", "CONTROLS.MouseWheel"]],
["place-hidden-token", "CONTROLS.TokenPlaceHidden", ["Alt", "CONTROLS.Drop"], true],
["token-target-mouse", "CONTROLS.TokenTarget", [`${game.i18n.localize("CONTROLS.Double")} ${game.i18n.localize("CONTROLS.RightClick")}`]],
["canvas-ping", "CONTROLS.CanvasPing", ["CONTROLS.LongPress"]],
["canvas-ping-alert", "CONTROLS.CanvasPingAlert", ["Alt", "CONTROLS.LongPress"]],
["canvas-ping-pull", "CONTROLS.CanvasPingPull", ["Shift", "CONTROLS.LongPress"], true],
["tooltip-lock", "CONTROLS.TooltipLock", ["CONTROLS.MiddleClick"]],
["tooltip-dismiss", "CONTROLS.TooltipDismiss", ["CONTROLS.RightClick"]]
];
let coreMouseCategoryData = {
id: "core-mouse",
title: coreMouseCategory,
actions: actions.map(a => defineMouseAction(...a)),
count: 0
};
coreMouseCategoryData.count = coreMouseCategoryData.actions.length;
categories.set("core-mouse", coreMouseCategoryData);
return coreMouseCategoryData.count;
}
/* -------------------------------------------- */
/**
* Given an Binding and its parent Action, detects other Actions that might conflict with that binding
* @param {string} actionId The namespaced Action ID the Binding belongs to
* @param {KeybindingActionConfig} action The Action config
* @param {KeybindingActionBinding} binding The Binding
* @returns {KeybindingAction[]}
* @private
*/
_detectConflictingActions(actionId, action, binding) {
// Uneditable Core bindings are never wrong, they can never conflict with something
if ( actionId.startsWith("core.") && action.uneditable.includes(binding) ) return [];
// Build fake context
/** @type KeyboardEventContext */
const context = KeyboardManager.getKeyboardEventContext({
code: binding.key,
shiftKey: binding.modifiers.includes(KeyboardManager.MODIFIER_KEYS.SHIFT),
ctrlKey: binding.modifiers.includes(KeyboardManager.MODIFIER_KEYS.CONTROL),
altKey: binding.modifiers.includes(KeyboardManager.MODIFIER_KEYS.ALT),
repeat: false
});
// Return matching keybinding actions (excluding this one)
let matching = KeyboardManager._getMatchingActions(context);
return matching.filter(a => a.action !== actionId);
}
/* -------------------------------------------- */
/**
* Transforms a Binding into a human-readable string representation
* @param {KeybindingActionBinding} binding The Binding
* @returns {string} A human readable string
* @private
*/
static _humanizeBinding(binding) {
const stringParts = binding.modifiers.reduce((parts, part) => {
if ( KeyboardManager.MODIFIER_CODES[part]?.includes(binding.key) ) return parts;
parts.unshift(KeyboardManager.getKeycodeDisplayString(part));
return parts;
}, [KeyboardManager.getKeycodeDisplayString(binding.key)]);
return stringParts.join(" + ");
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
const actionBindings = html.find(".action-bindings");
actionBindings.on("dblclick", ".editable-binding", this._onDoubleClickKey.bind(this));
actionBindings.on("click", ".control", this._onClickBindingControl.bind(this));
actionBindings.on("keydown", ".binding-input", this._onKeydownBindingInput.bind(this));
}
/* -------------------------------------------- */
/** @override */
async _onResetDefaults(event) {
return Dialog.confirm({
title: game.i18n.localize("KEYBINDINGS.ResetTitle"),
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("KEYBINDINGS.ResetWarning")}</p>`,
yes: async () => {
await game.keybindings.resetDefaults();
this.#cachedData = undefined;
this.#pendingEdits.clear();
this.render();
ui.notifications.info("KEYBINDINGS.ResetSuccess", {localize: true});
},
no: () => {},
defaultYes: false
});
}
/* -------------------------------------------- */
/**
* Handle Control clicks
* @param {MouseEvent} event
* @private
*/
_onClickBindingControl(event) {
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "add":
this._onClickAdd(event); break;
case "delete":
this._onClickDelete(event); break;
case "edit":
return this._onClickEditableBinding(event);
case "save":
return this._onClickSaveBinding(event);
}
}
/* -------------------------------------------- */
/**
* Handle left-click events to show / hide a certain category
* @param {MouseEvent} event
* @private
*/
async _onClickAdd(event) {
const {actionId, namespace, action} = this._getParentAction(event);
const {bindingHtml, bindingId} = this._getParentBinding(event);
const bindings = game.keybindings.bindings.get(actionId);
const newBindingId = `${namespace}.${action}.binding.${bindings.length}`;
const toInsert =
`<li class="binding flexrow inserted" data-binding-id="${newBindingId}">
<div class="editable-binding">
<div class="form-fields binding-fields">
<input type="text" class="binding-input" name="${newBindingId}" id="${newBindingId}" placeholder="Control + 1">
<i class="far fa-keyboard binding-input-icon"></i>
</div>
</div>
<div class="binding-controls flexrow">
<a class="control save-edit" title="${game.i18n.localize("KEYBINDINGS.SaveBinding")}" data-action="save"><i class="fas fa-save"></i></a>
<a class="control" title="${game.i18n.localize("KEYBINDINGS.DeleteBinding")}" data-action="delete"><i class="fas fa-trash-alt"></i></a>
</div>
</li>`;
bindingHtml.closest(".action-bindings").insertAdjacentHTML("beforeend", toInsert);
document.getElementById(newBindingId).focus();
// If this is an empty binding, delete it
if ( bindingId === "empty" ) {
bindingHtml.remove();
}
}
/* -------------------------------------------- */
/**
* Handle left-click events to show / hide a certain category
* @param {MouseEvent} event
* @private
*/
async _onClickDelete(event) {
const {namespace, action} = this._getParentAction(event);
const {bindingId} = this._getParentBinding(event);
const bindingIndex = Number.parseInt(bindingId.split(".")[3]);
this._addPendingEdit(namespace, action, bindingIndex, {index: bindingIndex, key: null});
await this._savePendingEdits();
}
/* -------------------------------------------- */
/**
* Inserts a Binding into the Pending Edits object, creating a new Map entry as needed
* @param {string} namespace
* @param {string} action
* @param {number} bindingIndex
* @param {KeybindingActionBinding} binding
* @private
*/
_addPendingEdit(namespace, action, bindingIndex, binding) {
// Save pending edits
const pendingEditKey = `${namespace}.${action}`;
if ( this.#pendingEdits.has(pendingEditKey) ) {
// Filter out any existing pending edits for this Binding so we don't add each Key in "Shift + A"
let currentBindings = this.#pendingEdits.get(pendingEditKey).filter(x => x.index !== bindingIndex);
currentBindings.push(binding);
this.#pendingEdits.set(pendingEditKey, currentBindings);
} else {
this.#pendingEdits.set(pendingEditKey, [binding]);
}
}
/* -------------------------------------------- */
/**
* Toggle visibility of the Edit / Save UI
* @param {MouseEvent} event
* @private
*/
_onClickEditableBinding(event) {
const target = event.currentTarget;
const bindingRow = target.closest("li.binding");
target.classList.toggle("hidden");
bindingRow.querySelector(".save-edit").classList.toggle("hidden");
for ( let binding of bindingRow.querySelectorAll(".editable-binding") ) {
binding.classList.toggle("hidden");
binding.getElementsByClassName("binding-input")[0]?.focus();
}
}
/* -------------------------------------------- */
/**
* Toggle visibility of the Edit UI
* @param {MouseEvent} event
* @private
*/
_onDoubleClickKey(event) {
const target = event.currentTarget;
// If this is an inserted binding, don't try to swap to a non-edit mode
if ( target.parentNode.parentNode.classList.contains("inserted") ) return;
for ( let child of target.parentNode.getElementsByClassName("editable-binding") ) {
child.classList.toggle("hidden");
child.getElementsByClassName("binding-input")[0]?.focus();
}
const bindingRow = target.closest(".binding");
for ( let child of bindingRow.getElementsByClassName("save-edit") ) {
child.classList.toggle("hidden");
}
}
/* -------------------------------------------- */
/**
* Save the new Binding value and update the display of the UI
* @param {MouseEvent} event
* @private
*/
async _onClickSaveBinding(event) {
await this._savePendingEdits();
}
/* -------------------------------------------- */
/**
* Given a clicked Action element, finds the parent Action
* @param {MouseEvent|KeyboardEvent} event
* @returns {{namespace: string, action: string, actionHtml: *}}
* @private
*/
_getParentAction(event) {
const actionHtml = event.currentTarget.closest(".action");
const actionId = actionHtml.dataset.actionId;
let [namespace, ...action] = actionId.split(".");
action = action.join(".");
return {actionId, actionHtml, namespace, action};
}
/* -------------------------------------------- */
/**
* Given a Clicked binding control element, finds the parent Binding
* @param {MouseEvent|KeyboardEvent} event
* @returns {{bindingHtml: *, bindingId: string}}
* @private
*/
_getParentBinding(event) {
const bindingHtml = event.currentTarget.closest(".binding");
const bindingId = bindingHtml.dataset.bindingId;
return {bindingHtml, bindingId};
}
/* -------------------------------------------- */
/**
* Iterates over all Pending edits, merging them in with unedited Bindings and then saving and resetting the UI
* @returns {Promise<void>}
* @private
*/
async _savePendingEdits() {
for ( let [id, pendingBindings] of this.#pendingEdits ) {
let [namespace, ...action] = id.split(".");
action = action.join(".");
const bindingsData = game.keybindings.bindings.get(id);
const actionData = game.keybindings.actions.get(id);
// Identify the set of bindings which should be saved
const toSet = [];
for ( const [index, binding] of bindingsData.entries() ) {
if ( actionData.uneditable.includes(binding) ) continue;
const {key, modifiers} = binding;
toSet[index] = {key, modifiers};
}
for ( const binding of pendingBindings ) {
const {index, key, modifiers} = binding;
toSet[index] = {key, modifiers};
}
// Try to save the binding, reporting any errors
try {
await game.keybindings.set(namespace, action, toSet.filter(b => !!b?.key));
}
catch(e) {
ui.notifications.error(e);
}
}
// Reset and rerender
this.#cachedData = undefined;
this.#pendingEdits.clear();
this.render();
}
/* -------------------------------------------- */
/**
* Processes input from the keyboard to form a list of pending Binding edits
* @param {KeyboardEvent} event The keyboard event
* @private
*/
_onKeydownBindingInput(event) {
const context = KeyboardManager.getKeyboardEventContext(event);
// Stop propagation
event.preventDefault();
event.stopPropagation();
const {bindingHtml, bindingId} = this._getParentBinding(event);
const {namespace, action} = this._getParentAction(event);
// Build pending Binding
const bindingIdParts = bindingId.split(".");
const bindingIndex = Number.parseInt(bindingIdParts[bindingIdParts.length - 1]);
const {MODIFIER_KEYS, MODIFIER_CODES} = KeyboardManager;
/** @typedef {KeybindingActionBinding} **/
let binding = {
index: bindingIndex,
key: context.key,
modifiers: []
};
if ( context.isAlt && !MODIFIER_CODES[MODIFIER_KEYS.ALT].includes(context.key) ) {
binding.modifiers.push(MODIFIER_KEYS.ALT);
}
if ( context.isShift && !MODIFIER_CODES[MODIFIER_KEYS.SHIFT].includes(context.key) ) {
binding.modifiers.push(MODIFIER_KEYS.SHIFT);
}
if ( context.isControl && !MODIFIER_CODES[MODIFIER_KEYS.CONTROL].includes(context.key) ) {
binding.modifiers.push(MODIFIER_KEYS.CONTROL);
}
// Save pending edits
this._addPendingEdit(namespace, action, bindingIndex, binding);
// Predetect potential conflicts
const conflicts = this._detectConflictingActions(`${namespace}.${action}`, game.keybindings.actions.get(`${namespace}.${action}`), binding);
const conflictString = game.i18n.format("KEYBINDINGS.Conflict", {
conflicts: conflicts.map(action => game.i18n.localize(action.name)).join(", ")
});
// Remove existing conflicts and add a new one
for ( const conflict of bindingHtml.getElementsByClassName("conflicts") ) {
conflict.remove();
}
if ( conflicts.length > 0 ) {
const conflictHtml = `<div class="control conflicts" title="${conflictString}"><i class="fas fa-exclamation-triangle"></i></div>`;
bindingHtml.getElementsByClassName("binding-controls")[0].insertAdjacentHTML("afterbegin", conflictHtml);
}
// Set value
event.currentTarget.value = this.constructor._humanizeBinding(binding);
}
}
/**
* The Module Management Application.
* This application provides a view of which modules are available to be used and allows for configuration of the
* set of modules which are active within the World.
*/
class ModuleManagement extends FormApplication {
constructor(...args) {
super(...args);
this._filter = this.isEditable ? "all" : "active";
this._expanded = true;
}
/**
* The named game setting which persists module configuration.
* @type {string}
*/
static CONFIG_SETTING = "moduleConfiguration";
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("MODMANAGE.Title"),
id: "module-management",
template: "templates/sidebar/apps/module-management.html",
popOut: true,
width: 680,
height: "auto",
scrollY: [".package-list"],
closeOnSubmit: false,
filters: [{inputSelector: 'input[name="search"]', contentSelector: ".package-list"}]
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get isEditable() {
return game.user.can("SETTINGS_MODIFY");
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const editable = this.isEditable;
const counts = {all: game.modules.size, active: 0, inactive: 0};
// Prepare modules
const modules = game.modules.reduce((arr, module) => {
const isActive = module.active;
if ( isActive ) counts.active++;
else if ( !editable ) return arr;
else counts.inactive++;
const mod = module.toObject();
mod.active = isActive;
mod.css = isActive ? " active" : "";
mod.hasPacks = mod.packs.length > 0;
mod.hasScripts = mod.scripts.length > 0;
mod.hasStyles = mod.styles.length > 0;
mod.systemOnly = mod.relationships?.systems.find(s => s.id === game.system.id);
mod.systemTag = game.system.id;
mod.authors = mod.authors.map(a => {
if ( a.url ) return `<a href="${a.url}" target="_blank">${a.name}</a>`;
return a.name;
}).join(", ");
mod.tooltip = null; // No tooltip by default
const requiredModules = Array.from(game.world.relationships.requires)
.concat(Array.from(game.system.relationships.requires));
mod.required = !!requiredModules.find(r => r.id === mod.id);
if ( mod.required ) mod.tooltip = game.i18n.localize("MODMANAGE.RequiredModule");
// String formatting labels
const authorsLabel = game.i18n.localize(`Author${module.authors.size > 1 ? "Pl" : ""}`);
mod.labels = {authors: authorsLabel};
mod.badge = module.getVersionBadge();
// Document counts.
const subTypeCounts = game.issues.getSubTypeCountsFor(mod);
if ( subTypeCounts ) mod.documents = this._formatDocumentSummary(subTypeCounts, isActive);
// If the current System is not one of the supported ones, don't return
if ( mod.relationships?.systems.size > 0 && !mod.systemOnly ) return arr;
mod.enableable = true;
this._evaluateDependencies(mod);
this._evaluateSystemCompatibility(mod);
mod.disabled = mod.required || !mod.enableable;
return arr.concat([mod]);
}, []).sort((a, b) => a.title.localeCompare(b.title));
// Filters
const filters = editable ? ["all", "active", "inactive"].map(f => ({
id: f,
label: game.i18n.localize(`MODMANAGE.Filter${f.titleCase()}`),
count: counts[f] || 0
})) : [];
// Return data for rendering
return { editable, filters, modules, expanded: this._expanded };
}
/* -------------------------------------------- */
/**
* Given a module, determines if it meets minimum and maximum compatibility requirements of its dependencies.
* If not, it is marked as being unable to be activated.
* If the package does not meet verified requirements, it is marked with a warning instead.
* @param {object} module The module.
* @protected
*/
_evaluateDependencies(module) {
for ( const required of module.relationships.requires ) {
if ( required.type !== "module" ) continue;
// Verify the required package is installed
const pkg = game.modules.get(required.id);
if ( !pkg ) {
module.enableable = false;
required.class = "error";
required.message = game.i18n.localize("SETUP.DependencyNotInstalled");
continue;
}
// Test required package compatibility
const c = required.compatibility;
if ( !c ) continue;
const dependencyVersion = pkg.version;
if ( c.minimum && foundry.utils.isNewerVersion(c.minimum, dependencyVersion) ) {
module.enableable = false;
required.class = "error";
required.message = game.i18n.format("SETUP.CompatibilityRequireUpdate",
{ version: required.compatibility.minimum});
continue;
}
if ( c.maximum && foundry.utils.isNewerVersion(dependencyVersion, c.maximum) ) {
module.enableable = false;
required.class = "error";
required.message = game.i18n.format("SETUP.CompatibilityRequireDowngrade",
{ version: required.compatibility.maximum});
continue;
}
if ( c.verified && !foundry.utils.isNewerVersion(dependencyVersion, c.verified) ) {
required.class = "warning";
required.message = game.i18n.format("SETUP.CompatibilityRiskWithVersion",
{version: required.compatibility.verified});
}
}
// Record that a module may not be able to be enabled
if ( !module.enableable ) module.tooltip = game.i18n.localize("MODMANAGE.DependencyIssues");
}
/* -------------------------------------------- */
/**
* Given a module, determine if it meets the minimum and maximum system compatibility requirements.
* @param {object} module The module.
* @protected
*/
_evaluateSystemCompatibility(module) {
if ( !module.relationships.systems?.length ) return;
const supportedSystem = module.relationships.systems.find(s => s.id === game.system.id);
const {minimum, maximum} = supportedSystem?.compatibility ?? {};
const {version} = game.system;
if ( !minimum && !maximum ) return;
if ( minimum && foundry.utils.isNewerVersion(minimum, version) ) {
module.enableable = false;
module.tooltip = game.i18n.format("MODMANAGE.SystemCompatibilityIssueMinimum", {minimum, version});
}
if ( maximum && foundry.utils.isNewerVersion(version, maximum) ) {
module.enableable = false;
module.tooltip = game.i18n.format("MODMANAGE.SystemCompatibilityIssueMaximum", {maximum, version});
}
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find('button[name="deactivate"]').click(this._onDeactivateAll.bind(this));
html.find(".filter").click(this._onFilterList.bind(this));
html.find("button.expand").click(this._onExpandCollapse.bind(this));
html.find('input[type="checkbox"]').change(this._onChangeCheckbox.bind(this));
// Allow users to filter modules even if they don't have permission to edit them.
html.find('input[name="search"]').attr("disabled", false);
html.find("button.expand").attr("disabled", false);
// Activate the appropriate filter.
html.find(`a[data-filter="${this._filter}"]`).addClass("active");
// Initialize
this._onExpandCollapse();
}
/* -------------------------------------------- */
/** @inheritdoc */
async _renderInner(...args) {
await loadTemplates(["templates/setup/parts/package-tags.hbs"]);
return super._renderInner(...args);
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData={}) {
const formData = super._getSubmitData(updateData);
delete formData.search;
return formData;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
const settings = game.settings.get("core", this.constructor.CONFIG_SETTING);
const requiresReload = !foundry.utils.isEmpty(foundry.utils.diffObject(settings, formData));
const setting = foundry.utils.mergeObject(settings, formData);
// Ensure all relationships are satisfied
for ( let [k, v] of Object.entries(setting) ) {
if ( v === false ) continue;
const mod = game.modules.get(k);
if ( !mod ) {
delete setting[k];
continue;
}
if ( !mod.relationships?.requires?.length ) continue;
const missing = mod.relationships.requires.reduce((arr, d) => {
if ( d.type && (d.type !== "module") ) return arr;
if ( !setting[d.id] ) arr.push(d.id);
return arr;
}, []);
if ( missing.length ) {
const warning = game.i18n.format("MODMANAGE.DepMissing", {module: k, missing: missing.join(", ")});
this.options.closeOnSubmit = false;
return ui.notifications.warn(warning);
}
}
// Apply the setting
if ( requiresReload ) SettingsConfig.reloadConfirm({world: true});
return game.settings.set("core", this.constructor.CONFIG_SETTING, setting);
}
/* -------------------------------------------- */
/**
* Handle changes to a module checkbox to prompt for whether to enable dependencies.
* @param {Event} event The change event.
* @protected
*/
async _onChangeCheckbox(event) {
const input = event.target;
const module = game.modules.get(input.name);
const enabling = input.checked;
// If disabling Packages, warn that subdocuments will be unavailable
if ( !enabling ) {
const confirm = await this._confirmDocumentsUnavailable(module);
if ( !confirm ) {
input.checked = true;
return;
}
}
// Check for impacted dependencies
const impactedDependencies = new Map();
for ( const pkg of module.relationships.requires ) {
if ( impactedDependencies.has(pkg.id) ) continue;
this.#checkImpactedDependency(impactedDependencies, pkg, enabling, input, true);
}
for ( const pkg of module.relationships.recommends ) {
if ( impactedDependencies.has(pkg.id) ) continue;
this.#checkImpactedDependency(impactedDependencies, pkg, enabling, input, false);
}
// Check for upstream packages
const upstreamPackages = enabling ? new Set() : this.#checkUpstreamPackages(module);
if ( !impactedDependencies.size && !upstreamPackages.size ) return;
const dependencies = Array.from(impactedDependencies.values());
const { requiredDependencies, optionalDependencies } = dependencies.reduce((obj, dep) => {
if ( dep.required ) obj.requiredDependencies.push(dep);
else obj.optionalDependencies.push(dep);
return obj;
}, { requiredDependencies: [], optionalDependencies: [] });
const html = await renderTemplate("templates/setup/impacted-dependencies.html", {
enabling,
dependencies,
requiredDependencies,
optionalDependencies,
upstreamPackages: Array.from(upstreamPackages.values())
});
function deactivateUpstreamPackages() {
for ( let d of upstreamPackages ) {
const dep = input.form[d.id];
if ( dep ) {
dep.checked = false;
}
}
}
// If there is a choice to be made, verify choices with the user via Dialog.confirm.
// Otherwise, just notify them with Dialog.prompt
const dialogConfigData = {
title: `${module.title} ${game.i18n.localize("MODMANAGE.Dependencies")}`,
content: html
};
// If there are no dependencies, just inform the user about the upstream packages
if ( !impactedDependencies.size ) {
dialogConfigData.callback = deactivateUpstreamPackages;
dialogConfigData.rejectClose = false;
return Dialog.prompt(dialogConfigData);
}
// Otherwise, prompt the user to confirm the changes to impacted dependencies
return Dialog.confirm(foundry.utils.mergeObject(dialogConfigData, {
yes: (html) => {
const inputs = Array.from(html[0].querySelectorAll("input"));
for ( let d of dependencies ) {
const dep = input.form[d.id];
if ( dep ) dep.checked = inputs.find(i => i.name === d.id)?.checked ? input.checked : dep.checked;
}
deactivateUpstreamPackages();
},
no: () => {
input.checked = false;
deactivateUpstreamPackages();
},
}));
}
/* -------------------------------------------- */
/**
* @typedef {Object} ImpactedDependency
* @property {string} id The dependency ID
* @property {string} title The dependency title
* @property {string} reason The reason the dependency is related to this package
* @property {boolean} required Whether the dependency is required
* @property {string} note A note to display to the user
*/
/**
* Check whether a dependency is impacted by another package being enabled / disabled.
* @param {Map<string,ImpactedDependency>} impactedDependencies The map of impacted dependencies.
* @param {Package} pkg The dependency to check.
* @param {boolean} enabling Whether the dependency is being enabled or disabled.
* @param {HTMLInputElement} input The checkbox input for the dependency.
* @param {boolean} required Whether the dependency is required.
*/
#checkImpactedDependency(impactedDependencies, pkg, enabling,
input, required=true) {
if ( pkg.type !== "module" ) return null;
/** @type {ImpactedDependency} */
const impactedDependency = {
id: pkg.id,
reason: pkg.reason
};
if ( enabling ) {
impactedDependency.required = required;
impactedDependency.note = required ? game.i18n.localize("SETUP.RequiredPackageNote") :
game.i18n.localize("SETUP.RecommendedPackageNote")
}
const pack = game.modules.get(pkg.id);
if ( !pack ) {
if ( required ) ui.notifications.error(game.i18n.format("MODMANAGE.DepNotInstalled",
{missing: pkg.id}));
return null;
}
impactedDependency.title = pack.title;
if ( pack.active === input.checked ) return null;
if ( !enabling ) {
// Check if other modules depend on this dependency, and if so, remove it from the to-disable list.
const allPackages = Array.from(game.modules).concat([game.system, game.world]);
const requiredByOtherPackages = !!allPackages.find(a => {
if ( (a.type === "module") && !a.active ) return false;
if ( a.id === input.name ) return false;
if ( !a.relationships?.length ) return false;
const dependencies = Array.from(a.relationships.requires).concat(Array.from(a.relationships.recommends));
return dependencies.find(d => d.id === pkg.id);
});
if ( requiredByOtherPackages ) return null;
}
// Add the dependency to the list of impacted dependencies
impactedDependencies.set(pkg.id, impactedDependency);
// Recursively check downstream dependencies
const fullPackage = game.modules.get(pkg.id);
for ( const p of fullPackage.relationships.requires ) {
if ( impactedDependencies.has(p.id) ) continue;
this.#checkImpactedDependency(impactedDependencies, p, enabling, input, true);
}
for ( const p of fullPackage.relationships.recommends ) {
if ( impactedDependencies.has(p.id) ) continue;
this.#checkImpactedDependency(impactedDependencies, p, enabling, input, false);
}
}
/* -------------------------------------------- */
/**
* Recursively check if any upstream packages would become disabled if the module were disabled.
* @param {Package} pkg The dependency to check.
* @param {Set<Package>} upstreamPackages The current Set of detected upstream packages.
* @returns {Set<Package>} A Set of upstream packages.
*/
#checkUpstreamPackages(pkg, upstreamPackages=null) {
if ( !upstreamPackages ) upstreamPackages = new Set();
const packagesThatRequireThis = game.modules.filter(m => m.active && m.relationships.requires.some(r => r.id === pkg.id));
for ( let p of packagesThatRequireThis ) {
if ( upstreamPackages.has(p) ) continue;
upstreamPackages.add(p);
this.#checkUpstreamPackages(p, upstreamPackages);
}
return upstreamPackages;
}
/* -------------------------------------------- */
/**
* Indicate if any Documents would become unavailable if the module were disabled, and confirm if the user wishes to
* proceed.
* @param {Module} module The module being disabled.
* @returns {Promise<boolean>} A Promise which resolves to true if disabling should continue.
* @protected
*/
async _confirmDocumentsUnavailable(module) {
const counts = game.issues.getSubTypeCountsFor(module.id);
if ( !counts ) return true;
const confirm = await Dialog.confirm({
title: game.i18n.localize("MODMANAGE.UnavailableDocuments"),
content: `
<p>${game.i18n.localize("MODMANAGE.UnavailableDocumentsConfirm")}</p>
<hr>
<p>${this._formatDocumentSummary(counts)}</p>
`,
yes: () => true,
no: () => false
});
return !!confirm;
}
/* -------------------------------------------- */
/**
* Handle a button-click to deactivate all modules
* @private
*/
_onDeactivateAll(event) {
event.preventDefault();
for ( let input of this.element[0].querySelectorAll('input[type="checkbox"]') ) {
if ( !input.disabled ) input.checked = false;
}
}
/* -------------------------------------------- */
/**
* Handle expanding or collapsing the display of descriptive elements
* @private
*/
_onExpandCollapse(event) {
event?.preventDefault();
this._expanded = !this._expanded;
this.form.querySelectorAll(".package-description").forEach(pack =>
pack.classList.toggle("hidden", !this._expanded)
);
const icon = this.form.querySelector("i.fa");
icon.classList.toggle("fa-angle-double-down", this._expanded);
icon.classList.toggle("fa-angle-double-up", !this._expanded);
icon.parentElement.title = this._expanded ?
game.i18n.localize("Collapse") : game.i18n.localize("Expand");
}
/* -------------------------------------------- */
/**
* Handle switching the module list filter.
* @private
*/
_onFilterList(event) {
event.preventDefault();
this._filter = event.target.dataset.filter;
// Toggle the activity state of all filters.
this.form.querySelectorAll("a[data-filter]").forEach(a =>
a.classList.toggle("active", a.dataset.filter === this._filter));
// Iterate over modules and toggle their hidden states based on the chosen filter.
const settings = game.settings.get("core", this.constructor.CONFIG_SETTING);
const list = this.form.querySelector("#module-list");
for ( const li of list.children ) {
const name = li.dataset.moduleId;
const isActive = settings[name] === true;
const hidden = ((this._filter === "active") && !isActive) || ((this._filter === "inactive") && isActive);
li.classList.toggle("hidden", hidden);
}
// Re-apply any search filter query.
const searchFilter = this._searchFilters[0];
searchFilter.filter(null, searchFilter._input.value);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onSearchFilter(event, query, rgx, html) {
const settings = game.settings.get("core", this.constructor.CONFIG_SETTING);
for ( let li of html.children ) {
const name = li.dataset.moduleId;
const isActive = settings[name] === true;
if ( (this._filter === "active") && !isActive ) continue;
if ( (this._filter === "inactive") && isActive ) continue;
if ( !query ) {
li.classList.remove("hidden");
continue;
}
const title = (li.querySelector(".package-title")?.textContent || "").trim();
const author = (li.querySelector(".author")?.textContent || "").trim();
const match = rgx.test(SearchFilter.cleanQuery(name)) ||
rgx.test(SearchFilter.cleanQuery(title)) ||
rgx.test(SearchFilter.cleanQuery(author));
li.classList.toggle("hidden", !match);
}
}
/* -------------------------------------------- */
/**
* Format a document count collection for display.
* @param {ModuleSubTypeCounts} counts An object of sub-type counts.
* @param {boolean} isActive Whether the module is active.
* @protected
*/
_formatDocumentSummary(counts, isActive) {
return Object.entries(counts).map(([documentName, types]) => {
let total = 0;
const typesList = game.i18n.getListFormatter().format(Object.entries(types).map(([subType, count]) => {
total += count;
const label = game.i18n.localize(CONFIG[documentName].typeLabels?.[subType] ?? subType);
return `<strong>${count}</strong> ${label}`;
}));
const cls = getDocumentClass(documentName);
const label = total === 1 ? cls.metadata.label : cls.metadata.labelPlural;
if ( isActive ) return `${typesList} ${game.i18n.localize(label)}`;
return `<strong>${total}</strong> ${game.i18n.localize(label)}`;
}).join(" &bull; ");
}
}
/**
* An application for configuring the permissions which are available to each User role.
*/
class PermissionConfig extends FormApplication {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("PERMISSION.Title"),
id: "permissions-config",
template: "templates/sidebar/apps/permission-config.html",
width: 660,
height: "auto",
scrollY: [".permissions-list"],
closeOnSubmit: true
});
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const current = await game.settings.get("core", "permissions");
return {
roles: Object.keys(CONST.USER_ROLES).reduce((obj, r) => {
if ( r === "NONE" ) return obj;
obj[r] = `USER.Role${r.titleCase()}`;
return obj;
}, {}),
permissions: this._getPermissions(current)
};
}
/* -------------------------------------------- */
/**
* Prepare the permissions object used to render the configuration template
* @param {object} current The current permission configuration
* @returns {object[]} Permission data for sheet rendering
* @private
*/
_getPermissions(current) {
const r = CONST.USER_ROLES;
const rgm = r.GAMEMASTER;
// Get permissions
const perms = Object.entries(CONST.USER_PERMISSIONS).reduce((arr, e) => {
const perm = foundry.utils.deepClone(e[1]);
perm.id = e[0];
perm.label = game.i18n.localize(perm.label);
perm.hint = game.i18n.localize(perm.hint);
arr.push(perm);
return arr;
}, []);
perms.sort((a, b) => a.label.localeCompare(b.label));
// Configure permission roles
for ( let p of perms ) {
const roles = current[p.id] || Array.fromRange(rgm + 1).slice(p.defaultRole);
p.roles = Object.values(r).reduce((arr, role) => {
if ( role === r.NONE ) return arr;
arr.push({
name: `${p.id}.${role}`,
value: roles.includes(role),
disabled: (role === rgm) && (!p.disableGM)
});
return arr;
}, []);
}
return perms;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find("button[name='reset']").click(this._onResetDefaults.bind(this));
}
/* -------------------------------------------- */
/**
* Handle button click to reset default settings
* @param {Event} event The initial button click event
* @private
*/
async _onResetDefaults(event) {
event.preventDefault();
// Collect default permissions.
const defaults = Object.entries(CONST.USER_PERMISSIONS).reduce((obj, [id, perm]) => {
obj[id] = Array.fromRange(CONST.USER_ROLES.GAMEMASTER + 1).slice(perm.defaultRole);
return obj;
}, {});
await game.settings.set("core", "permissions", defaults);
ui.notifications.info("SETTINGS.PermissionReset", {localize: true});
return this.render();
}
/* -------------------------------------------- */
/** @override */
async _onSubmit(event, options) {
event.target.querySelectorAll("input[disabled]").forEach(i => i.disabled = false);
return super._onSubmit(event, options);
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
const permissions = foundry.utils.expandObject(formData);
for ( let [k, v] of Object.entries(permissions) ) {
if ( !(k in CONST.USER_PERMISSIONS ) ) {
delete permissions[k];
continue;
}
permissions[k] = Object.entries(v).reduce((arr, r) => {
if ( r[1] === true ) arr.push(parseInt(r[0]));
return arr;
}, []);
}
await game.settings.set("core", "permissions", permissions);
ui.notifications.info("SETTINGS.PermissionUpdate", {localize: true});
}
}
/**
* Support Info and Report
* @type {Application}
*/
class SupportDetails extends Application {
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.title = "SUPPORT.Title";
options.id = "support-details";
options.template = "templates/sidebar/apps/support-details.html";
options.width = 780;
options.height = 680;
options.resizable = true;
options.classes = ["sheet"];
options.tabs = [{navSelector: ".tabs", contentSelector: "article", initial: "support"}];
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
// Build report data
context.report = SupportDetails.generateSupportReport();
// Build document issues data.
context.documentIssues = this._getDocumentValidationErrors();
// Build module issues data.
context.moduleIssues = this._getModuleIssues();
// Build client issues data.
context.clientIssues = Object.values(game.issues.usabilityIssues).map(({message, severity, params}) => {
return {severity, message: params ? game.i18n.format(message, params) : game.i18n.localize(message)};
});
return context;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("button[data-action]").on("click", this._onClickAction.bind(this));
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force=false, options={}) {
await super._render(force, options);
if ( options.tab ) this._tabs[0].activate(options.tab);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _renderInner(data) {
await loadTemplates({supportDetailsReport: "templates/sidebar/apps/parts/support-details-report.html"});
return super._renderInner(data);
}
/* -------------------------------------------- */
/**
* Handle a button click action.
* @param {MouseEvent} event The click event.
* @protected
*/
_onClickAction(event) {
const action = event.currentTarget.dataset.action;
switch ( action ) {
case "copy":
this._copyReport();
break;
}
}
/* -------------------------------------------- */
/**
* Copy the support details report to clipboard.
* @protected
*/
_copyReport() {
const report = document.getElementById("support-report");
game.clipboard.copyPlainText(report.innerText);
ui.notifications.info("SUPPORT.ReportCopied", {localize: true});
}
/* -------------------------------------------- */
/**
* Marshal information on Documents that failed validation and format it for display.
* @returns {object[]}
* @protected
*/
_getDocumentValidationErrors() {
const context = [];
for ( const [documentName, documents] of Object.entries(game.issues.validationFailures) ) {
const cls = getDocumentClass(documentName);
const label = game.i18n.localize(cls.metadata.labelPlural);
context.push({
label,
documents: Object.entries(documents).map(([id, {name, error}]) => {
return {name: name ?? id, validationError: error.asHTML()};
})
});
}
return context;
}
/* -------------------------------------------- */
/**
* Marshal package-related warnings and errors and format it for display.
* @returns {object[]}
* @protected
*/
_getModuleIssues() {
const errors = {label: game.i18n.localize("Errors"), issues: []};
const warnings = {label: game.i18n.localize("Warnings"), issues: []};
for ( const [moduleId, {error, warning}] of Object.entries(game.issues.packageCompatibilityIssues) ) {
const label = game.modules.get(moduleId)?.title ?? moduleId;
if ( error.length ) errors.issues.push({label, issues: error.map(message => ({severity: "error", message}))});
if ( warning.length ) warnings.issues.push({
label,
issues: warning.map(message => ({severity: "warning", message}))
});
}
const context = [];
if ( errors.issues.length ) context.push(errors);
if ( warnings.issues.length ) context.push(warnings);
return context;
}
/* -------------------------------------------- */
/**
* A bundle of metrics for Support
* @typedef {Object} SupportReportData
* @property {number} coreVersion
* @property {string} systemVersion
* @property {number} activeModuleCount
* @property {string} os
* @property {string} client
* @property {string} gpu
* @property {number|string} maxTextureSize
* @property {string} sceneDimensions
* @property {number} grid
* @property {float} padding
* @property {number} walls
* @property {number} lights
* @property {number} sounds
* @property {number} tiles
* @property {number} tokens
* @property {number} actors
* @property {number} items
* @property {number} journals
* @property {number} tables
* @property {number} playlists
* @property {number} packs
* @property {number} messages
*/
/**
* Collects a number of metrics that is useful for Support
* @returns {SupportReportData}
*/
static generateSupportReport() {
// Create a WebGL Context if necessary
let tempCanvas;
let gl = canvas.app?.renderer?.gl;
if ( !gl ) {
const tempCanvas = document.createElement("canvas");
if ( tempCanvas.getContext ) {
gl = tempCanvas.getContext("webgl2") || tempCanvas.getContext("webgl") || tempCanvas.getContext("experimental-webgl");
}
}
const rendererInfo = this.getWebGLRendererInfo(gl) ?? "Unknown Renderer";
// Build report data
const viewedScene = game.scenes.get(game.user.viewedScene);
/** @type {SupportReportData} **/
const report = {
coreVersion: `${game.release.display}, ${game.release.version}`,
systemVersion: `${game.system.id}, ${game.system.version}`,
activeModuleCount: Array.from(game.modules.values()).filter(x => x.active).length,
performanceMode: game.settings.get("core", "performanceMode"),
os: navigator.oscpu ?? "Unknown",
client: navigator.userAgent,
gpu: rendererInfo,
maxTextureSize: gl && gl.getParameter ? gl.getParameter(gl.MAX_TEXTURE_SIZE) : "Could not detect",
hasViewedScene: viewedScene,
packs: game.packs.size,
};
// Attach Document Collection counts
const reportCollections = [ "actors", "items", "journal", "tables", "playlists", "messages" ];
for ( let c of reportCollections ) {
const collection = game[c];
report[c] = `${collection.size}${collection.invalidDocumentIds.size > 0 ?
` (${collection.invalidDocumentIds.size} ${game.i18n.localize("Invalid")})` : ""}`;
}
if ( viewedScene ) {
report.sceneDimensions = `${viewedScene.dimensions.width} x ${viewedScene.dimensions.height}`;
report.grid = viewedScene.grid.size;
report.padding = viewedScene.padding;
report.walls = viewedScene.walls.size;
report.lights = viewedScene.lights.size;
report.sounds = viewedScene.sounds.size;
report.tiles = viewedScene.tiles.size;
report.tokens = viewedScene.tokens.size;
}
// Clean up temporary canvas
if ( tempCanvas ) tempCanvas.remove();
return report;
}
/* -------------------------------------------- */
/**
* Get a WebGL renderer information string
* @param {WebGLRenderingContext} gl The rendering context
* @returns {string} The unmasked renderer string
*/
static getWebGLRendererInfo(gl) {
if ( navigator.userAgent.match(/Firefox\/([0-9]+)\./) ) {
return gl.getParameter(gl.RENDERER);
} else {
return gl.getParameter(gl.getExtension("WEBGL_debug_renderer_info").UNMASKED_RENDERER_WEBGL);
}
}
}
/**
* A management app for configuring which Tours are available or have been completed.
*/
class ToursManagement extends PackageConfiguration {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "tours-management",
title: game.i18n.localize("SETTINGS.Tours"),
categoryTemplate: "templates/sidebar/apps/tours-management-category.html"
});
}
/* -------------------------------------------- */
/** @override */
_prepareCategoryData() {
// Classify all Actions
let categories = new Map();
let total = 0;
for ( let tour of game.tours.values() ) {
if ( !tour.config.display || (tour.config.restricted && !game.user.isGM) ) continue;
total++;
// Determine what category the action belongs to
let category = this._categorizeEntry(tour.namespace);
// Convert Tour to render data
const tourData = {};
tourData.category = category.title;
tourData.id = `${tour.namespace}.${tour.id}`;
tourData.title = game.i18n.localize(tour.title);
tourData.description = game.i18n.localize(tour.description);
tourData.cssClass = tour.config.restricted ? "gm" : "";
tourData.notes = [
tour.config.restricted ? game.i18n.localize("KEYBINDINGS.Restricted") : "",
tour.description
].filterJoin("<br>");
switch ( tour.status ) {
case Tour.STATUS.UNSTARTED: {
tourData.status = game.i18n.localize("TOURS.NotStarted");
tourData.canBePlayed = tour.canStart;
tourData.canBeReset = false;
tourData.startOrResume = game.i18n.localize("TOURS.Start");
break;
}
case Tour.STATUS.IN_PROGRESS: {
tourData.status = game.i18n.format("TOURS.InProgress", {
current: tour.stepIndex + 1,
total: tour.steps.length ?? 0
});
tourData.canBePlayed = tour.canStart;
tourData.canBeReset = true;
tourData.startOrResume = game.i18n.localize(`TOURS.${tour.config.canBeResumed ? "Resume" : "Restart"}`);
break;
}
case Tour.STATUS.COMPLETED: {
tourData.status = game.i18n.localize("TOURS.Completed");
tourData.canBeReset = true;
tourData.cssClass += " completed";
break;
}
}
// Register a category the first time it is seen, otherwise add to it
if ( !categories.has(category.id) ) {
categories.set(category.id, {
id: category.id,
title: category.title,
tours: [tourData],
count: 0
});
} else categories.get(category.id).tours.push(tourData);
}
// Sort Actions by priority and assign Counts
for ( let category of categories.values() ) {
category.count = category.tours.length;
}
categories = Array.from(categories.values()).sort(this._sortCategories.bind(this));
return {categories, total};
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find(".controls").on("click", ".control", this._onClickControl.bind(this));
}
/* -------------------------------------------- */
/** @override */
async _onResetDefaults(event) {
return Dialog.confirm({
title: game.i18n.localize("TOURS.ResetTitle"),
content: `<p>${game.i18n.localize("TOURS.ResetWarning")}</p>`,
yes: async () => {
await Promise.all(game.tours.contents.map(tour => tour.reset()));
ui.notifications.info("TOURS.ResetSuccess", {localize: true});
this.render(true);
},
no: () => {},
defaultYes: false
});
}
/* -------------------------------------------- */
/**
* Handle Control clicks
* @param {MouseEvent} event
* @private
*/
_onClickControl(event) {
const button = event.currentTarget;
const div = button.closest(".tour");
const tour = game.tours.get(div.dataset.tour);
switch ( button.dataset.action ) {
case "play":
this.close();
return tour.start();
case "reset": return tour.reset();
}
}
}
/**
* @typedef {FormApplicationOptions} WorldConfigOptions
* @property {boolean} [create=false] Whether the world is being created or updated.
*/
/**
* The World Management setup application
* @param {World} object The world being configured.
* @param {WorldConfigOptions} [options] Application configuration options.
*/
class WorldConfig extends FormApplication {
/**
* @override
* @returns {WorldConfigOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "world-config",
template: "templates/setup/world-config.hbs",
width: 620,
height: "auto",
create: false
});
}
/**
* A semantic alias for the World object which is being configured by this form.
* @type {World}
*/
get world() {
return this.object;
}
/**
* The website knowledge base URL.
* @type {string}
* @private
*/
static #WORLD_KB_URL = "https://foundryvtt.com/article/game-worlds/";
/* -------------------------------------------- */
/** @override */
get title() {
return this.options.create ? game.i18n.localize("WORLD.TitleCreate")
: `${game.i18n.localize("WORLD.TitleEdit")}: ${this.world.title}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find('[name="title"]').on("input", this.#onTitleChange.bind(this));
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const ac = CONST.PACKAGE_AVAILABILITY_CODES;
const nextDate = new Date(this.world.nextSession || undefined);
const context = {
world: this.world,
isCreate: this.options.create,
submitText: game.i18n.localize(this.options.create ? "WORLD.TitleCreate" : "WORLD.SubmitEdit"),
nextDate: nextDate.isValid() ? nextDate.toDateInputString() : "",
nextTime: nextDate.isValid() ? nextDate.toTimeInputString() : "",
worldKbUrl: WorldConfig.#WORLD_KB_URL,
inWorld: !!game.world,
themes: CONST.WORLD_JOIN_THEMES
};
context.showEditFields = !context.isCreate && !context.inWorld;
if ( game.systems ) {
context.systems = game.systems.filter(system => {
if ( this.world.system === system.id ) return true;
return ( system.availability <= ac.UNVERIFIED_GENERATION );
}).sort((a, b) => a.title.localeCompare(b.title));
}
return context;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
_getSubmitData(updateData={}) {
const data = super._getSubmitData(updateData);
// Augment submission actions
if ( this.options.create ) {
data.action = "createWorld";
if ( !data.id.length ) data.id = data.title.slugify({strict: true});
}
else {
data.id = this.world.id;
if ( !data.resetKeys ) delete data.resetKeys;
if ( !data.safeMode ) delete data.safeMode;
}
// Handle next session schedule fields
if ( data.nextSession.some(t => !!t) ) {
const now = new Date();
const dateStr = `${data.nextSession[0] || now.toDateString()} ${data.nextSession[1] || now.toTimeString()}`;
const date = new Date(dateStr);
data.nextSession = isNaN(Number(date)) ? null : date.toISOString();
}
else data.nextSession = null;
if ( data.joinTheme === CONST.WORLD_JOIN_THEMES.default ) delete data.joinTheme;
return data;
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
formData = foundry.utils.expandObject(formData);
const form = event.target || this.form;
form.disable = true;
// Validate the submission data
try {
this.world.validate({changes: formData, clean: true});
formData.action = this.options.create ? "createWorld" : "editWorld";
} catch(err) {
ui.notifications.error(err.message.replace("\n", ". "));
throw err;
}
// Dispatch the POST request
let response;
try {
response = await foundry.utils.fetchJsonWithTimeout(foundry.utils.getRoute("setup"), {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(formData)
});
form.disabled = false;
// Display error messages
if (response.error) return ui.notifications.error(response.error);
}
catch(e) {
return ui.notifications.error(e);
}
// Handle successful creation
if ( formData.action === "createWorld" ) {
const world = new this.world.constructor(response);
game.worlds.set(world.id, world);
}
else this.world.updateSource(response);
if ( ui.setup ) ui.setup.refresh(); // TODO old V10
if ( ui.setupPackages ) ui.setupPackages.render(); // New v11
}
/* -------------------------------------------- */
/**
* Update the world name placeholder when the title is changed.
* @param {Event} event The input change event
* @private
*/
#onTitleChange(event) {
let slug = this.form.elements.title.value.slugify({strict: true});
if ( !slug.length ) slug = "world-name";
this.form.elements.id?.setAttribute("placeholder", slug);
}
/* -------------------------------------------- */
/** @inheritDoc */
async activateEditor(name, options={}, initialContent="") {
const toolbar = CONFIG.TinyMCE.toolbar.split(" ").filter(t => t !== "save").join(" ");
foundry.utils.mergeObject(options, {toolbar});
return super.activateEditor(name, options, initialContent);
}
}
/**
* The sidebar directory which organizes and displays world-level Actor documents.
*/
class ActorDirectory extends DocumentDirectory {
constructor(...args) {
super(...args);
this._dragDrop[0].permissions.dragstart = () => game.user.can("TOKEN_CREATE");
this._dragDrop[0].permissions.drop = () => game.user.can("ACTOR_CREATE");
}
/* -------------------------------------------- */
/** @override */
static documentName = "Actor";
/* -------------------------------------------- */
/** @override */
_canDragStart(selector) {
return game.user.can("TOKEN_CREATE");
}
/* -------------------------------------------- */
/** @override */
_onDragStart(event) {
const li = event.currentTarget.closest(".directory-item");
let actor = null;
if ( li.dataset.documentId ) {
actor = game.actors.get(li.dataset.documentId);
if ( !actor || !actor.visible ) return false;
}
// Parent directory drag start handling
super._onDragStart(event);
// Create the drag preview for the Token
if ( actor && canvas.ready ) {
const img = li.querySelector("img");
const pt = actor.prototypeToken;
const w = pt.width * canvas.dimensions.size * Math.abs(pt.texture.scaleX) * canvas.stage.scale.x;
const h = pt.height * canvas.dimensions.size * Math.abs(pt.texture.scaleY) * canvas.stage.scale.y;
const preview = DragDrop.createDragImage(img, w, h);
event.dataTransfer.setDragImage(preview, w / 2, h / 2);
}
}
/* -------------------------------------------- */
/** @override */
_canDragDrop(selector) {
return game.user.can("ACTOR_CREATE");
}
/* -------------------------------------------- */
/** @override */
_getEntryContextOptions() {
const options = super._getEntryContextOptions();
return [
{
name: "SIDEBAR.CharArt",
icon: '<i class="fas fa-image"></i>',
condition: li => {
const actor = game.actors.get(li.data("documentId"));
return actor.img !== CONST.DEFAULT_TOKEN;
},
callback: li => {
const actor = game.actors.get(li.data("documentId"));
new ImagePopout(actor.img, {
title: actor.name,
uuid: actor.uuid
}).render(true);
}
},
{
name: "SIDEBAR.TokenArt",
icon: '<i class="fas fa-image"></i>',
condition: li => {
const actor = game.actors.get(li.data("documentId"));
if ( actor.prototypeToken.randomImg ) return false;
return ![null, undefined, CONST.DEFAULT_TOKEN].includes(actor.prototypeToken.texture.src);
},
callback: li => {
const actor = game.actors.get(li.data("documentId"));
new ImagePopout(actor.prototypeToken.texture.src, {
title: actor.name,
uuid: actor.uuid
}).render(true);
}
}
].concat(options);
}
}
/**
* The sidebar directory which organizes and displays world-level Cards documents.
* @extends {DocumentDirectory}
*/
class CardsDirectory extends DocumentDirectory {
/** @override */
static documentName = "Cards";
/** @inheritDoc */
_getEntryContextOptions() {
const options = super._getEntryContextOptions();
const duplicate = options.find(o => o.name === "SIDEBAR.Duplicate");
duplicate.condition = li => {
if ( !game.user.isGM ) return false;
const cards = this.constructor.collection.get(li.data("documentId"));
return cards.canClone;
};
return options;
}
}
/**
* @typedef {ApplicationOptions} ChatLogOptions
* @property {boolean} [stream] Is this chat log being rendered as part of the stream view?
*/
/**
* The sidebar directory which organizes and displays world-level ChatMessage documents.
* @extends {SidebarTab}
* @see {Sidebar}
* @param {ChatLogOptions} [options] Application configuration options.
*/
class ChatLog extends SidebarTab {
constructor(options) {
super(options);
/**
* Track any pending text which the user has submitted in the chat log textarea
* @type {string}
* @private
*/
this._pendingText = "";
/**
* Track the history of the past 5 sent messages which can be accessed using the arrow keys
* @type {object[]}
* @private
*/
this._sentMessages = [];
/**
* Track which remembered message is being currently displayed to cycle properly
* @type {number}
* @private
*/
this._sentMessageIndex = -1;
/**
* Track the time when the last message was sent to avoid flooding notifications
* @type {number}
* @private
*/
this._lastMessageTime = 0;
/**
* Track the id of the last message displayed in the log
* @type {string|null}
* @private
*/
this._lastId = null;
/**
* Track the last received message which included the user as a whisper recipient.
* @type {ChatMessage|null}
* @private
*/
this._lastWhisper = null;
/**
* A reference to the chat text entry bound key method
* @type {Function|null}
* @private
*/
this._onChatKeyDownBinding = null;
// Update timestamps every 15 seconds
setInterval(this.updateTimestamps.bind(this), 1000 * 15);
}
/**
* A flag for whether the chat log is currently scrolled to the bottom
* @type {boolean}
*/
#isAtBottom = true;
/**
* A cache of the Jump to Bottom element
*/
#jumpToBottomElement;
/* -------------------------------------------- */
/**
* Returns if the chat log is currently scrolled to the bottom
* @returns {boolean}
*/
get isAtBottom() {
return this.#isAtBottom;
}
/* -------------------------------------------- */
/**
* @override
* @returns {ChatLogOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "chat",
template: "templates/sidebar/chat-log.html",
title: game.i18n.localize("CHAT.Title"),
stream: false,
scrollY: ["#chat-log"]
});
}
/* -------------------------------------------- */
/**
* An enumeration of regular expression patterns used to match chat messages.
* @enum {RegExp}
*/
static MESSAGE_PATTERNS = (() => {
const dice = "([^#]+)(?:#(.*))?"; // Dice expression with appended flavor text
const any = "([^]*)"; // Any character, including new lines
return {
roll: new RegExp(`^(\\/r(?:oll)? )${dice}$`, "i"), // Regular rolls: /r or /roll
gmroll: new RegExp(`^(\\/gmr(?:oll)? )${dice}$`, "i"), // GM rolls: /gmr or /gmroll
blindroll: new RegExp(`^(\\/b(?:lind)?r(?:oll)? )${dice}$`, "i"), // Blind rolls: /br or /blindroll
selfroll: new RegExp(`^(\\/s(?:elf)?r(?:oll)? )${dice}$`, "i"), // Self rolls: /sr or /selfroll
publicroll: new RegExp(`^(\\/p(?:ublic)?r(?:oll)? )${dice}$`, "i"), // Public rolls: /pr or /publicroll
ic: new RegExp(`^(/ic )${any}`, "i"),
ooc: new RegExp(`^(/ooc )${any}`, "i"),
emote: new RegExp(`^(/(?:em(?:ote)?|me) )${any}`, "i"),
whisper: new RegExp(/^(\/w(?:hisper)?\s)(\[(?:[^\]]+)\]|(?:[^\s]+))\s*([^]*)/, "i"),
reply: new RegExp(`^(/reply )${any}`, "i"),
gm: new RegExp(`^(/gm )${any}`, "i"),
players: new RegExp(`^(/players )${any}`, "i"),
macro: new RegExp(`^(\\/m(?:acro)? )${any}`, "i"),
invalid: /^(\/[^\s]+)/ // Any other message starting with a slash command is invalid
};
})();
/* -------------------------------------------- */
/**
* The set of commands that can be processed over multiple lines.
* @type {Set<string>}
*/
static MULTILINE_COMMANDS = new Set(["roll", "gmroll", "blindroll", "selfroll", "publicroll"]);
/* -------------------------------------------- */
/**
* A reference to the Messages collection that the chat log displays
* @type {Messages}
*/
get collection() {
return game.messages;
}
/* -------------------------------------------- */
/* Application Rendering */
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const context = await super.getData(options);
return foundry.utils.mergeObject(context, {
rollMode: game.settings.get("core", "rollMode"),
rollModes: CONFIG.Dice.rollModes,
isStream: !!this.options.stream
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
if ( this.rendered ) return; // Never re-render the Chat Log itself, only its contents
await super._render(force, options);
return this.scrollBottom({waitImages: true});
}
/* -------------------------------------------- */
/** @inheritdoc */
async _renderInner(data) {
const html = await super._renderInner(data);
await this._renderBatch(html, CONFIG.ChatMessage.batchSize);
return html;
}
/* -------------------------------------------- */
/**
* Render a batch of additional messages, prepending them to the top of the log
* @param {jQuery} html The rendered jQuery HTML object
* @param {number} size The batch size to include
* @returns {Promise<void>}
* @private
*/
async _renderBatch(html, size) {
const messages = game.messages.contents;
const log = html.find("#chat-log, #chat-log-popout");
// Get the index of the last rendered message
let lastIdx = messages.findIndex(m => m.id === this._lastId);
lastIdx = lastIdx !== -1 ? lastIdx : messages.length;
// Get the next batch to render
let targetIdx = Math.max(lastIdx - size, 0);
let m = null;
if ( lastIdx !== 0 ) {
let html = [];
for ( let i=targetIdx; i<lastIdx; i++) {
m = messages[i];
if (!m.visible) continue;
m.logged = true;
try {
html.push(await m.getHTML());
} catch(err) {
err.message = `Chat message ${m.id} failed to render: ${err})`;
console.error(err);
}
}
// Prepend the HTML
log.prepend(html);
this._lastId = messages[targetIdx].id;
}
}
/* -------------------------------------------- */
/* Chat Sidebar Methods */
/* -------------------------------------------- */
/**
* Delete a single message from the chat log
* @param {string} messageId The ChatMessage document to remove from the log
* @param {boolean} [deleteAll] Is this part of a flush operation to delete all messages?
*/
deleteMessage(messageId, {deleteAll=false}={}) {
// Get the chat message being removed from the log
const message = game.messages.get(messageId, {strict: false});
if ( message ) message.logged = false;
// Get the current HTML element for the message
let li = this.element.find(`.message[data-message-id="${messageId}"]`);
if ( !li.length ) return;
// Update the last index
if ( deleteAll ) {
this._lastId = null;
} else if ( messageId === this._lastId ) {
const next = li[0].nextElementSibling;
this._lastId = next ? next.dataset.messageId : null;
}
// Remove the deleted message
li.slideUp(100, () => li.remove());
// Delete from popout tab
if ( this._popout ) this._popout.deleteMessage(messageId, {deleteAll});
if ( this.popOut ) this.setPosition();
}
/* -------------------------------------------- */
/**
* Trigger a notification that alerts the user visually and audibly that a new chat log message has been posted
* @param {ChatMessage} message The message generating a notification
*/
notify(message) {
this._lastMessageTime = Date.now();
if ( !this.rendered ) return;
// Display the chat notification icon and remove it 3 seconds later
let icon = $("#chat-notification");
if ( icon.is(":hidden") ) icon.fadeIn(100);
setTimeout(() => {
if ( (Date.now() - this._lastMessageTime > 3000) && icon.is(":visible") ) icon.fadeOut(100);
}, 3001);
// Play a notification sound effect
if ( message.sound ) AudioHelper.play({src: message.sound});
}
/* -------------------------------------------- */
/**
* Parse a chat string to identify the chat command (if any) which was used
* @param {string} message The message to match
* @returns {string[]} The identified command and regex match
*/
static parse(message) {
for ( const [rule, rgx] of Object.entries(this.MESSAGE_PATTERNS) ) {
// For multi-line matches, the first line must match
if ( this.MULTILINE_COMMANDS.has(rule) ) {
const lines = message.split("\n");
if ( rgx.test(lines[0]) ) return [rule, lines.map(l => l.match(rgx))];
}
// For single-line matches, match directly
else {
const match = message.match(rgx);
if ( match ) return [rule, match];
}
}
return ["none", [message, "", message]];
}
/* -------------------------------------------- */
/**
* Post a single chat message to the log
* @param {ChatMessage} message A ChatMessage document instance to post to the log
* @param {object} [options={}] Additional options for how the message is posted to the log
* @param {string} [options.before] An existing message ID to append the message before, by default the new message is
* appended to the end of the log.
* @param {boolean} [options.notify] Trigger a notification which shows the log as having a new unread message.
* @returns {Promise<void>} A Promise which resolves once the message is posted
*/
async postOne(message, {before, notify=false}={}) {
if ( !message.visible ) return;
message.logged = true;
// Track internal flags
if ( !this._lastId ) this._lastId = message.id; // Ensure that new messages don't result in batched scrolling
if ( (message.whisper || []).includes(game.user.id) && !message.isRoll ) {
this._lastWhisper = message;
}
// Render the message to the log
const html = await message.getHTML();
const log = this.element.find("#chat-log");
// Append the message after some other one
const existing = before ? this.element.find(`.message[data-message-id="${before}"]`) : [];
if ( existing.length ) existing.before(html);
// Otherwise, append the message to the bottom of the log
else {
log.append(html);
if ( this.isAtBottom || (message.user._id === game.user._id) ) this.scrollBottom({waitImages: true});
}
// Post notification
if ( notify ) this.notify(message);
// Update popout tab
if ( this._popout ) await this._popout.postOne(message, {before, notify: false});
if ( this.popOut ) this.setPosition();
}
/* -------------------------------------------- */
/**
* Scroll the chat log to the bottom
* @param {object} [options]
* @param {boolean} [options.popout=false] If a popout exists, scroll it to the bottom too.
* @param {boolean} [options.waitImages=false] Wait for any images embedded in the chat log to load first
* before scrolling?
* @param {ScrollIntoViewOptions} [options.scrollOptions] Options to configure scrolling behaviour.
*/
async scrollBottom({popout=false, waitImages=false, scrollOptions={}}={}) {
if ( !this.rendered ) return;
if ( waitImages ) await this._waitForImages();
const log = this.element[0].querySelector("#chat-log");
log.lastElementChild?.scrollIntoView(scrollOptions);
if ( popout ) this._popout?.scrollBottom({waitImages, scrollOptions});
}
/* -------------------------------------------- */
/**
* Update the content of a previously posted message after its data has been replaced
* @param {ChatMessage} message The ChatMessage instance to update
* @param {boolean} notify Trigger a notification which shows the log as having a new unread message
*/
async updateMessage(message, notify=false) {
let li = this.element.find(`.message[data-message-id="${message.id}"]`);
if ( li.length ) {
const html = await message.getHTML();
li.replaceWith(html);
}
// Add a newly visible message to the log
else {
const messages = game.messages.contents;
const messageIndex = messages.findIndex(m => m === message);
let nextMessage;
for ( let i = messageIndex + 1; i < messages.length; i++ ) {
if ( messages[i].visible ) {
nextMessage = messages[i];
break;
}
}
await this.postOne(message, {before: nextMessage?.id, notify: false});
}
// Post notification of update
if ( notify ) this.notify(message);
// Update popout tab
if ( this._popout ) await this._popout.updateMessage(message, false);
if ( this.popOut ) this.setPosition();
}
/* -------------------------------------------- */
/**
* Update the displayed timestamps for every displayed message in the chat log.
* Timestamps are displayed in a humanized "timesince" format.
*/
updateTimestamps() {
const messages = this.element.find("#chat-log .message");
for ( let li of messages ) {
const message = game.messages.get(li.dataset.messageId);
if ( !message?.timestamp ) return;
const stamp = li.querySelector(".message-timestamp");
stamp.textContent = foundry.utils.timeSince(message.timestamp);
}
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
// Load new messages on scroll
html.find("#chat-log").scroll(this._onScrollLog.bind(this));
// Chat message entry
this._onChatKeyDownBinding = this._onChatKeyDown.bind(this);
html.find("#chat-message").keydown(this._onChatKeyDownBinding);
// Expand dice roll tooltips
html.on("click", ".dice-roll", this._onDiceRollClick.bind(this));
// Modify Roll Type
html.find('select[name="rollMode"]').change(this._onChangeRollMode.bind(this));
// Single Message Delete
html.on("click", "a.message-delete", this._onDeleteMessage.bind(this));
// Flush log
html.find("a.chat-flush").click(this._onFlushLog.bind(this));
// Export log
html.find("a.export-log").click(this._onExportLog.bind(this));
// Jump to Bottom
html.find(".jump-to-bottom > a").click(() => this.scrollBottom());
// Content Link Dragging
html[0].addEventListener("drop", ChatLog._onDropTextAreaData);
// Chat Entry context menu
this._contextMenu(html);
}
/* -------------------------------------------- */
/**
* Handle dropping of transferred data onto the chat editor
* @param {DragEvent} event The originating drop event which triggered the data transfer
* @private
*/
static async _onDropTextAreaData(event) {
event.preventDefault();
const textarea = event.target;
// Drop cross-linked content
const eventData = TextEditor.getDragEventData(event);
const link = await TextEditor.getContentLink(eventData);
if ( link ) textarea.value += link;
// Record pending text
this._pendingText = textarea.value;
}
/* -------------------------------------------- */
/**
* Prepare the data object of chat message data depending on the type of message being posted
* @param {string} message The original string of the message content
* @returns {Promise<Object|void>} The prepared chat data object, or void if we were executing a macro instead
*/
async processMessage(message) {
message = message.trim();
if ( !message ) return;
const cls = ChatMessage.implementation;
// Set up basic chat data
const chatData = {
user: game.user.id,
speaker: cls.getSpeaker()
};
if ( Hooks.call("chatMessage", this, message, chatData) === false ) return;
// Parse the message to determine the matching handler
let [command, match] = this.constructor.parse(message);
// Special handlers for no command
if ( command === "invalid" ) throw new Error(game.i18n.format("CHAT.InvalidCommand", {command: match[1]}));
else if ( command === "none" ) command = chatData.speaker.token ? "ic" : "ooc";
// Process message data based on the identified command type
const createOptions = {};
switch (command) {
case "roll": case "gmroll": case "blindroll": case "selfroll": case "publicroll":
await this._processDiceCommand(command, match, chatData, createOptions);
break;
case "whisper": case "reply": case "gm": case "players":
this._processWhisperCommand(command, match, chatData, createOptions);
break;
case "ic": case "emote": case "ooc":
this._processChatCommand(command, match, chatData, createOptions);
break;
case "macro":
this._processMacroCommand(command, match);
return;
}
// Create the message using provided data and options
return cls.create(chatData, createOptions);
}
/* -------------------------------------------- */
/**
* Process messages which are posted using a dice-roll command
* @param {string} command The chat command type
* @param {RegExpMatchArray[]} matches Multi-line matched roll expressions
* @param {Object} chatData The initial chat data
* @param {Object} createOptions Options used to create the message
* @private
*/
async _processDiceCommand(command, matches, chatData, createOptions) {
const actor = ChatMessage.getSpeakerActor(chatData.speaker) || game.user.character;
const rollData = actor ? actor.getRollData() : {};
const rolls = [];
for ( const match of matches ) {
if ( !match ) continue;
const [formula, flavor] = match.slice(2, 4);
if ( flavor && !chatData.flavor ) chatData.flavor = flavor;
const roll = Roll.create(formula, rollData);
await roll.evaluate({async: true});
rolls.push(roll);
}
chatData.type = CONST.CHAT_MESSAGE_TYPES.ROLL;
chatData.rolls = rolls;
chatData.sound = CONFIG.sounds.dice;
chatData.content = rolls.reduce((t, r) => t + r.total, 0);
createOptions.rollMode = command;
}
/* -------------------------------------------- */
/**
* Process messages which are posted using a chat whisper command
* @param {string} command The chat command type
* @param {RegExpMatchArray} match The matched RegExp expressions
* @param {Object} chatData The initial chat data
* @param {Object} createOptions Options used to create the message
* @private
*/
_processWhisperCommand(command, match, chatData, createOptions) {
// Prepare whisper data
chatData.type = CONST.CHAT_MESSAGE_TYPES.WHISPER;
delete chatData.speaker;
// Determine the recipient users
let users = [];
let message= "";
switch(command) {
case "whisper":
message = match[3];
const names = match[2].replace(/[\[\]]/g, "").split(",").map(n => n.trim());
users = names.reduce((arr, n) => arr.concat(ChatMessage.getWhisperRecipients(n)), []);
break;
case "reply":
message = match[2];
const w = this._lastWhisper;
if ( w ) {
const group = new Set(w.data.whisper);
group.add(w.data.user);
group.delete(game.user.id);
users = Array.from(group).map(id => game.users.get(id));
}
break;
case "gm":
message = match[2];
users = ChatMessage.getWhisperRecipients("gm");
break;
case "players":
message = match[2];
users = ChatMessage.getWhisperRecipients("players");
break;
}
// Add line break elements
message = message.replace(/\n/g, "<br>");
// Ensure we have valid whisper targets
if ( !users.length ) throw new Error(game.i18n.localize("ERROR.NoTargetUsersForWhisper"));
if ( users.some(u => !u.isGM) && !game.user.can("MESSAGE_WHISPER") ) {
throw new Error(game.i18n.localize("ERROR.CantWhisper"));
}
// Update chat data
chatData.whisper = users.map(u => u.id);
chatData.content = message;
chatData.sound = CONFIG.sounds.notification;
}
/* -------------------------------------------- */
/**
* Process messages which are posted using a chat whisper command
* @param {string} command The chat command type
* @param {RegExpMatchArray} match The matched RegExp expressions
* @param {Object} chatData The initial chat data
* @param {Object} createOptions Options used to create the message
* @private
*/
_processChatCommand(command, match, chatData, createOptions) {
if ( ["ic", "emote"].includes(command) && !(chatData.speaker.actor || chatData.speaker.token) ) {
throw new Error("You cannot chat in-character without an identified speaker");
}
chatData.content = match[2].replace(/\n/g, "<br>");
// Augment chat data
if ( command === "ic" ) {
chatData.type = CONST.CHAT_MESSAGE_TYPES.IC;
createOptions.chatBubble = true;
} else if ( command === "emote" ) {
chatData.type = CONST.CHAT_MESSAGE_TYPES.EMOTE;
chatData.content = `${chatData.speaker.alias} ${chatData.content}`;
createOptions.chatBubble = true;
}
else {
chatData.type = CONST.CHAT_MESSAGE_TYPES.OOC;
delete chatData.speaker;
}
}
/* -------------------------------------------- */
/**
* Process messages which execute a macro.
* @param {string} command The chat command typed.
* @param {RegExpMatchArray} match The RegExp matches.
* @private
*/
_processMacroCommand(command, match) {
// Parse the macro command with the form /macro {macroName} [param1=val1] [param2=val2] ...
let [macroName, ...params] = match[2].split(" ");
let expandName = true;
const scope = {};
for ( const p of params ) {
const kv = p.split("=");
if ( kv.length === 2 ) {
scope[kv[0]] = kv[1];
expandName = false;
}
else if ( expandName ) macroName += ` ${p}`; // Macro names may contain spaces
}
macroName = macroName.trimEnd(); // Eliminate trailing spaces
// Get the target macro by number or by name
let macro;
if ( Number.isNumeric(macroName) ) {
const macroID = game.user.hotbar[macroName];
macro = game.macros.get(macroID);
}
if ( !macro ) macro = game.macros.getName(macroName);
if ( !macro ) throw new Error(`Requested Macro "${macroName}" was not found as a named macro or hotbar position`);
// Execute the Macro with provided scope
return macro.execute(scope);
}
/* -------------------------------------------- */
/**
* Add a sent message to an array of remembered messages to be re-sent if the user pages up with the up arrow key
* @param {string} message The message text being remembered
* @private
*/
_remember(message) {
if ( this._sentMessages.length === 5 ) this._sentMessages.splice(4, 1);
this._sentMessages.unshift(message);
this._sentMessageIndex = -1;
}
/* -------------------------------------------- */
/**
* Recall a previously sent message by incrementing up (1) or down (-1) through the sent messages array
* @param {number} direction The direction to recall, positive for older, negative for more recent
* @return {string} The recalled message, or an empty string
* @private
*/
_recall(direction) {
if ( this._sentMessages.length > 0 ) {
let idx = this._sentMessageIndex + direction;
this._sentMessageIndex = Math.clamped(idx, -1, this._sentMessages.length-1);
}
return this._sentMessages[this._sentMessageIndex] || "";
}
/* -------------------------------------------- */
/** @inheritdoc */
_contextMenu(html) {
ContextMenu.create(this, html, ".message", this._getEntryContextOptions());
}
/* -------------------------------------------- */
/**
* Get the ChatLog entry context options
* @return {object[]} The ChatLog entry context options
* @private
*/
_getEntryContextOptions() {
return [
{
name: "CHAT.PopoutMessage",
icon: '<i class="fas fa-external-link-alt fa-rotate-180"></i>',
condition: li => {
const message = game.messages.get(li.data("messageId"));
return message.getFlag("core", "canPopout") === true;
},
callback: li => {
const message = game.messages.get(li.data("messageId"));
new ChatPopout(message).render(true);
}
},
{
name: "CHAT.RevealMessage",
icon: '<i class="fas fa-eye"></i>',
condition: li => {
const message = game.messages.get(li.data("messageId"));
const isLimited = message.whisper.length || message.blind;
return isLimited && (game.user.isGM || message.isAuthor) && message.isContentVisible;
},
callback: li => {
const message = game.messages.get(li.data("messageId"));
return message.update({whisper: [], blind: false});
}
},
{
name: "CHAT.ConcealMessage",
icon: '<i class="fas fa-eye-slash"></i>',
condition: li => {
const message = game.messages.get(li.data("messageId"));
const isLimited = message.whisper.length || message.blind;
return !isLimited && (game.user.isGM || message.isAuthor) && message.isContentVisible;
},
callback: li => {
const message = game.messages.get(li.data("messageId"));
return message.update({whisper: ChatMessage.getWhisperRecipients("gm").map(u => u.id), blind: false});
}
},
{
name: "SIDEBAR.Delete",
icon: '<i class="fas fa-trash"></i>',
condition: li => {
const message = game.messages.get(li.data("messageId"));
return message.canUserModify(game.user, "delete");
},
callback: li => {
const message = game.messages.get(li.data("messageId"));
return message.delete();
}
}
];
}
/* -------------------------------------------- */
/**
* Handle keydown events in the chat entry textarea
* @param {KeyboardEvent} event
* @private
*/
_onChatKeyDown(event) {
const code = event.code;
const textarea = event.currentTarget;
if ( event.originalEvent.isComposing ) return; // Ignore IME composition
// UP/DOWN ARROW -> Recall Previous Messages
const isArrow = ["ArrowUp", "ArrowDown"].includes(code);
if ( isArrow ) {
if ( this._pendingText ) return;
event.preventDefault();
textarea.value = this._recall(code === "ArrowUp" ? 1 : -1);
return;
}
// ENTER -> Send Message
const isEnter = ( (code === "Enter") || (code === "NumpadEnter") ) && !event.shiftKey;
if ( isEnter ) {
event.preventDefault();
const message = textarea.value;
if (!message) return;
this._pendingText = "";
// Prepare chat message data and handle result
return this.processMessage(message).then(() => {
textarea.value = "";
this._remember(message);
}).catch(error => {
ui.notifications.error(error);
throw error;
});
}
// BACKSPACE -> Remove pending text
if ( event.key === "Backspace" ) {
this._pendingText = this._pendingText.slice(0, -1);
return
}
// Otherwise, record that there is pending text
this._pendingText = textarea.value + (event.key.length === 1 ? event.key : "");
}
/* -------------------------------------------- */
/**
* Handle setting the preferred roll mode
* @param {Event} event
* @private
*/
_onChangeRollMode(event) {
event.preventDefault();
game.settings.set("core", "rollMode", event.target.value);
}
/* -------------------------------------------- */
/**
* Handle single message deletion workflow
* @param {Event} event
* @private
*/
_onDeleteMessage(event) {
event.preventDefault();
const li = event.currentTarget.closest(".message");
const messageId = li.dataset.messageId;
const message = game.messages.get(messageId);
return message ? message.delete() : this.deleteMessage(messageId);
}
/* -------------------------------------------- */
/**
* Handle clicking of dice tooltip buttons
* @param {Event} event
* @private
*/
_onDiceRollClick(event) {
event.preventDefault();
// Toggle the message flag
let roll = event.currentTarget;
const message = game.messages.get(roll.closest(".message").dataset.messageId);
message._rollExpanded = !message._rollExpanded;
// Expand or collapse tooltips
const tooltips = roll.querySelectorAll(".dice-tooltip");
for ( let tip of tooltips ) {
if ( message._rollExpanded ) $(tip).slideDown(200);
else $(tip).slideUp(200);
tip.classList.toggle("expanded", message._rollExpanded);
}
}
/* -------------------------------------------- */
/**
* Handle click events to export the chat log
* @param {Event} event
* @private
*/
_onExportLog(event) {
event.preventDefault();
game.messages.export();
}
/* -------------------------------------------- */
/**
* Handle click events to flush the chat log
* @param {Event} event
* @private
*/
_onFlushLog(event) {
event.preventDefault();
game.messages.flush(this.#jumpToBottomElement);
}
/* -------------------------------------------- */
/**
* Handle scroll events within the chat log container
* @param {UIEvent} event The initial scroll event
* @private
*/
_onScrollLog(event) {
if ( !this.rendered ) return;
const log = event.target;
const pct = log.scrollTop / (log.scrollHeight - log.clientHeight);
if ( !this.#jumpToBottomElement ) this.#jumpToBottomElement = this.element.find(".jump-to-bottom")[0];
this.#isAtBottom = pct > 0.99;
this.#jumpToBottomElement.classList.toggle("hidden", this.#isAtBottom);
if ( pct < 0.01 ) return this._renderBatch(this.element, CONFIG.ChatMessage.batchSize);
}
/* -------------------------------------------- */
/**
* Update roll mode select dropdowns when the setting is changed
* @param {string} mode The new roll mode setting
*/
static _setRollMode(mode) {
for ( let select of $(".roll-type-select") ) {
for ( let option of select.options ) {
option.selected = option.value === mode;
}
}
}
}
/**
* The sidebar directory which organizes and displays world-level Combat documents.
*/
class CombatTracker extends SidebarTab {
constructor(options) {
super(options);
if ( !this.popOut ) game.combats.apps.push(this);
/**
* Record a reference to the currently highlighted Token
* @type {Token|null}
* @private
*/
this._highlighted = null;
/**
* Record the currently tracked Combat encounter
* @type {Combat|null}
*/
this.viewed = null;
// Initialize the starting encounter
this.initialize({render: false});
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "combat",
template: "templates/sidebar/combat-tracker.html",
title: "COMBAT.SidebarTitle",
scrollY: [".directory-list"]
});
}
/* -------------------------------------------- */
/**
* Return an array of Combat encounters which occur within the current Scene.
* @type {Combat[]}
*/
get combats() {
return game.combats.combats;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
createPopout() {
const pop = super.createPopout();
pop.initialize({combat: this.viewed, render: true});
return pop;
}
/* -------------------------------------------- */
/**
* Initialize the combat tracker to display a specific combat encounter.
* If no encounter is provided, the tracker will be initialized with the first encounter in the viewed scene.
* @param {object} [options] Additional options to configure behavior.
* @param {Combat|null} [options.combat=null] The combat encounter to initialize
* @param {boolean} [options.render=true] Whether to re-render the sidebar after initialization
*/
initialize({combat=null, render=true}={}) {
// Retrieve a default encounter if none was provided
if ( combat === null ) {
const combats = this.combats;
combat = combats.length ? combats.find(c => c.active) || combats[0] : null;
combat?.updateCombatantActors();
}
// Prepare turn order
if ( combat && !combat.turns ) combat.turns = combat.setupTurns();
// Set flags
this.viewed = combat;
this._highlighted = null;
// Also initialize the popout
if ( this._popout ) {
this._popout.viewed = combat;
this._popout._highlighted = null;
}
// Render the tracker
if ( render ) this.render();
}
/* -------------------------------------------- */
/**
* Scroll the combat log container to ensure the current Combatant turn is centered vertically
*/
scrollToTurn() {
const combat = this.viewed;
if ( !combat || (combat.turn === null) ) return;
let active = this.element.find(".active")[0];
if ( !active ) return;
let container = active.parentElement;
const nViewable = Math.floor(container.offsetHeight / active.offsetHeight);
container.scrollTop = (combat.turn * active.offsetHeight) - ((nViewable/2) * active.offsetHeight);
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
let context = await super.getData(options);
// Get the combat encounters possible for the viewed Scene
const combat = this.viewed;
const hasCombat = combat !== null;
const combats = this.combats;
const currentIdx = combats.findIndex(c => c === combat);
const previousId = currentIdx > 0 ? combats[currentIdx-1].id : null;
const nextId = currentIdx < combats.length - 1 ? combats[currentIdx+1].id : null;
const settings = game.settings.get("core", Combat.CONFIG_SETTING);
// Prepare rendering data
context = foundry.utils.mergeObject(context, {
combats: combats,
currentIndex: currentIdx + 1,
combatCount: combats.length,
hasCombat: hasCombat,
combat,
turns: [],
previousId,
nextId,
started: this.started,
control: false,
settings,
linked: combat?.scene !== null,
labels: {}
});
context.labels.scope = game.i18n.localize(`COMBAT.${context.linked ? "Linked" : "Unlinked"}`);
if ( !hasCombat ) return context;
// Format information about each combatant in the encounter
let hasDecimals = false;
const turns = [];
for ( let [i, combatant] of combat.turns.entries() ) {
if ( !combatant.visible ) continue;
// Prepare turn data
const resource = combatant.permission >= CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER ? combatant.resource : null;
const turn = {
id: combatant.id,
name: combatant.name,
img: await this._getCombatantThumbnail(combatant),
active: i === combat.turn,
owner: combatant.isOwner,
defeated: combatant.isDefeated,
hidden: combatant.hidden,
initiative: combatant.initiative,
hasRolled: combatant.initiative !== null,
hasResource: resource !== null,
resource: resource,
canPing: (combatant.sceneId === canvas.scene?.id) && game.user.hasPermission("PING_CANVAS")
};
if ( (turn.initiative !== null) && !Number.isInteger(turn.initiative) ) hasDecimals = true;
turn.css = [
turn.active ? "active" : "",
turn.hidden ? "hidden" : "",
turn.defeated ? "defeated" : ""
].join(" ").trim();
// Actor and Token status effects
turn.effects = new Set();
if ( combatant.token ) {
combatant.token.effects.forEach(e => turn.effects.add(e));
if ( combatant.token.overlayEffect ) turn.effects.add(combatant.token.overlayEffect);
}
if ( combatant.actor ) {
for ( const effect of combatant.actor.temporaryEffects ) {
if ( effect.statuses.has(CONFIG.specialStatusEffects.DEFEATED) ) turn.defeated = true;
else if ( effect.icon ) turn.effects.add(effect.icon);
}
}
turns.push(turn);
}
// Format initiative numeric precision
const precision = CONFIG.Combat.initiative.decimals;
turns.forEach(t => {
if ( t.initiative !== null ) t.initiative = t.initiative.toFixed(hasDecimals ? precision : 0);
});
// Merge update data for rendering
return foundry.utils.mergeObject(context, {
round: combat.round,
turn: combat.turn,
turns: turns,
control: combat.combatant?.players?.includes(game.user)
});
}
/* -------------------------------------------- */
/**
* Retrieve a source image for a combatant.
* @param {Combatant} combatant The combatant queried for image.
* @returns {Promise<string>} The source image attributed for this combatant.
* @protected
*/
async _getCombatantThumbnail(combatant) {
if ( combatant._videoSrc && !combatant.img ) {
if ( combatant._thumb ) return combatant._thumb;
return combatant._thumb = await game.video.createThumbnail(combatant._videoSrc, {width: 100, height: 100});
}
return combatant.img ?? CONST.DEFAULT_TOKEN;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
const tracker = html.find("#combat-tracker");
const combatants = tracker.find(".combatant");
// Create new Combat encounter
html.find(".combat-create").click(ev => this._onCombatCreate(ev));
// Display Combat settings
html.find(".combat-settings").click(ev => {
ev.preventDefault();
new CombatTrackerConfig().render(true);
});
// Cycle the current Combat encounter
html.find(".combat-cycle").click(ev => this._onCombatCycle(ev));
// Combat control
html.find(".combat-control").click(ev => this._onCombatControl(ev));
// Combatant control
html.find(".combatant-control").click(ev => this._onCombatantControl(ev));
// Hover on Combatant
combatants.hover(this._onCombatantHoverIn.bind(this), this._onCombatantHoverOut.bind(this));
// Click on Combatant
combatants.click(this._onCombatantMouseDown.bind(this));
// Context on right-click
if ( game.user.isGM ) this._contextMenu(html);
// Intersection Observer for Combatant avatars
const observer = new IntersectionObserver(this._onLazyLoadImage.bind(this), {root: tracker[0]});
combatants.each((i, li) => observer.observe(li));
}
/* -------------------------------------------- */
/**
* Handle new Combat creation request
* @param {Event} event
* @private
*/
async _onCombatCreate(event) {
event.preventDefault();
let scene = game.scenes.current;
const cls = getDocumentClass("Combat");
const combat = await cls.create({scene: scene?.id});
await combat.activate({render: false});
}
/* -------------------------------------------- */
/**
* Handle a Combat cycle request
* @param {Event} event
* @private
*/
async _onCombatCycle(event) {
event.preventDefault();
const btn = event.currentTarget;
const combat = game.combats.get(btn.dataset.documentId);
if ( !combat ) return;
await combat.activate({render: false});
}
/* -------------------------------------------- */
/**
* Handle click events on Combat control buttons
* @private
* @param {Event} event The originating mousedown event
*/
async _onCombatControl(event) {
event.preventDefault();
const combat = this.viewed;
const ctrl = event.currentTarget;
if ( ctrl.getAttribute("disabled") ) return;
else ctrl.setAttribute("disabled", true);
const fn = combat[ctrl.dataset.control];
if ( fn ) await fn.bind(combat)();
ctrl.removeAttribute("disabled");
}
/* -------------------------------------------- */
/**
* Handle a Combatant control toggle
* @private
* @param {Event} event The originating mousedown event
*/
async _onCombatantControl(event) {
event.preventDefault();
event.stopPropagation();
const btn = event.currentTarget;
const li = btn.closest(".combatant");
const combat = this.viewed;
const c = combat.combatants.get(li.dataset.combatantId);
// Switch control action
switch (btn.dataset.control) {
// Toggle combatant visibility
case "toggleHidden":
return c.update({hidden: !c.hidden});
// Toggle combatant defeated flag
case "toggleDefeated":
return this._onToggleDefeatedStatus(c);
// Roll combatant initiative
case "rollInitiative":
return combat.rollInitiative([c.id]);
// Actively ping the Combatant
case "pingCombatant":
return this._onPingCombatant(c);
}
}
/* -------------------------------------------- */
/**
* Handle toggling the defeated status effect on a combatant Token
* @param {Combatant} combatant The combatant data being modified
* @returns {Promise} A Promise that resolves after all operations are complete
* @private
*/
async _onToggleDefeatedStatus(combatant) {
const isDefeated = !combatant.isDefeated;
await combatant.update({defeated: isDefeated});
const token = combatant.token;
if ( !token ) return;
// Push the defeated status to the token
const status = CONFIG.statusEffects.find(e => e.id === CONFIG.specialStatusEffects.DEFEATED);
if ( !status && !token.object ) return;
const effect = token.actor && status ? status : CONFIG.controlIcons.defeated;
if ( token.object ) await token.object.toggleEffect(effect, {overlay: true, active: isDefeated});
else await token.toggleActiveEffect(effect, {overlay: true, active: isDefeated});
}
/* -------------------------------------------- */
/**
* Handle pinging a combatant Token
* @param {Combatant} combatant The combatant data
* @returns {Promise}
* @protected
*/
async _onPingCombatant(combatant) {
if ( !canvas.ready || (combatant.sceneId !== canvas.scene.id) ) return;
if ( !combatant.token.object.visible ) return ui.notifications.warn(game.i18n.localize("COMBAT.PingInvisibleToken"));
await canvas.ping(combatant.token.object.center);
}
/* -------------------------------------------- */
/**
* Handle mouse-down event on a combatant name in the tracker
* @param {Event} event The originating mousedown event
* @returns {Promise} A Promise that resolves once the pan is complete
* @private
*/
async _onCombatantMouseDown(event) {
event.preventDefault();
const li = event.currentTarget;
const combatant = this.viewed.combatants.get(li.dataset.combatantId);
const token = combatant.token;
if ( !combatant.actor?.testUserPermission(game.user, "OBSERVER") ) return;
const now = Date.now();
// Handle double-left click to open sheet
const dt = now - this._clickTime;
this._clickTime = now;
if ( dt <= 250 ) return combatant.actor?.sheet.render(true);
// Control and pan to Token object
if ( token?.object ) {
token.object?.control({releaseOthers: true});
return canvas.animatePan(token.object.center);
}
}
/* -------------------------------------------- */
/**
* Handle mouse-hover events on a combatant in the tracker
* @private
*/
_onCombatantHoverIn(event) {
event.preventDefault();
if ( !canvas.ready ) return;
const li = event.currentTarget;
const combatant = this.viewed.combatants.get(li.dataset.combatantId);
const token = combatant.token?.object;
if ( token?.isVisible ) {
if ( !token.controlled ) token._onHoverIn(event, {hoverOutOthers: true});
this._highlighted = token;
}
}
/* -------------------------------------------- */
/**
* Handle mouse-unhover events for a combatant in the tracker
* @private
*/
_onCombatantHoverOut(event) {
event.preventDefault();
if ( this._highlighted ) this._highlighted._onHoverOut(event);
this._highlighted = null;
}
/* -------------------------------------------- */
/**
* Highlight a hovered combatant in the tracker.
* @param {Combatant} combatant The Combatant
* @param {boolean} hover Whether they are being hovered in or out.
*/
hoverCombatant(combatant, hover) {
const trackers = [this.element[0]];
if ( this._popout ) trackers.push(this._popout.element[0]);
for ( const tracker of trackers ) {
const li = tracker.querySelector(`.combatant[data-combatant-id="${combatant.id}"]`);
if ( !li ) continue;
if ( hover ) li.classList.add("hover");
else li.classList.remove("hover");
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_contextMenu(html) {
ContextMenu.create(this, html, ".directory-item", this._getEntryContextOptions());
}
/* -------------------------------------------- */
/**
* Get the Combatant entry context options
* @returns {object[]} The Combatant entry context options
* @private
*/
_getEntryContextOptions() {
return [
{
name: "COMBAT.CombatantUpdate",
icon: '<i class="fas fa-edit"></i>',
callback: this._onConfigureCombatant.bind(this)
},
{
name: "COMBAT.CombatantClear",
icon: '<i class="fas fa-undo"></i>',
condition: li => {
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
return Number.isNumeric(combatant?.initiative);
},
callback: li => {
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
if ( combatant ) return combatant.update({initiative: null});
}
},
{
name: "COMBAT.CombatantReroll",
icon: '<i class="fas fa-dice-d20"></i>',
callback: li => {
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
if ( combatant ) return this.viewed.rollInitiative([combatant.id]);
}
},
{
name: "COMBAT.CombatantRemove",
icon: '<i class="fas fa-trash"></i>',
callback: li => {
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
if ( combatant ) return combatant.delete();
}
}
];
}
/* -------------------------------------------- */
/**
* Display a dialog which prompts the user to enter a new initiative value for a Combatant
* @param {jQuery} li
* @private
*/
_onConfigureCombatant(li) {
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
new CombatantConfig(combatant, {
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720,
width: 400
}).render(true);
}
}
/**
* A compendium of knowledge arcane and mystical!
* Renders the sidebar directory of compendium packs
* @extends {SidebarTab}
* @mixes {DirectoryApplication}
*/
class CompendiumDirectory extends DirectoryApplicationMixin(SidebarTab) {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "compendium",
template: "templates/sidebar/compendium-directory.html",
title: "COMPENDIUM.SidebarTitle",
contextMenuSelector: ".directory-item.compendium",
entryClickSelector: ".compendium"
});
}
/**
* A reference to the currently active compendium types. If empty, all types are shown.
* @type {string[]}
*/
#activeFilters = [];
get activeFilters() {
return this.#activeFilters;
}
/* -------------------------------------------- */
/** @override */
entryType = "Compendium";
/* -------------------------------------------- */
/** @override */
static entryPartial = "templates/sidebar/partials/pack-partial.html";
/* -------------------------------------------- */
/** @override */
_entryAlreadyExists(entry) {
return this.collection.has(entry.collection);
}
/* -------------------------------------------- */
/** @override */
_getEntryDragData(entryId) {
const pack = this.collection.get(entryId);
return {
type: "Compendium",
id: pack.collection
};
}
/* -------------------------------------------- */
/** @override */
_entryIsSelf(entry, otherEntry) {
return entry.metadata.id === otherEntry.metadata.id;
}
/* -------------------------------------------- */
/** @override */
async _sortRelative(entry, sortData) {
// We build up a single update object for all compendiums to prevent multiple re-renders
const packConfig = game.settings.get("core", "compendiumConfiguration");
const targetFolderId = sortData.updateData.folder;
if ( targetFolderId ) {
packConfig[entry.collection] = foundry.utils.mergeObject(packConfig[entry.collection] || {}, {
folder: targetFolderId
});
}
// Update sorting
const sorting = SortingHelpers.performIntegerSort(entry, sortData);
for ( const s of sorting ) {
const pack = s.target;
const existingConfig = packConfig[pack.collection] || {};
existingConfig.sort = s.update.sort;
}
await game.settings.set("core", "compendiumConfiguration", packConfig);
}
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find(".filter").click(this._displayFilterCompendiumMenu.bind(this));
}
/* -------------------------------------------- */
/**
* Display a menu of compendium types to filter by
* @param {PointerEvent} event The originating pointer event
* @returns {Promise<void>}
* @protected
*/
async _displayFilterCompendiumMenu(event) {
// If there is a current dropdown menu, remove it
const dropdown = document.getElementsByClassName("dropdown-menu")[0];
if ( dropdown ) {
dropdown.remove();
return;
}
const button = event.currentTarget;
// Display a menu of compendium types to filter by
const choices = CONST.COMPENDIUM_DOCUMENT_TYPES.map(t => {
const config = CONFIG[t];
return {
name: game.i18n.localize(config.documentClass.metadata.label),
icon: config.sidebarIcon,
type: t,
callback: (event) => this._onToggleCompendiumFilterType(event, t)
};
});
// If there are active filters, add a "Clear Filters" option
if ( this.#activeFilters.length ) {
choices.unshift({
name: game.i18n.localize("COMPENDIUM.ClearFilters"),
icon: "fas fa-times",
type: null,
callback: (event) => this._onToggleCompendiumFilterType(event, null)
});
}
// Create a vertical list of buttons contained in a div
const menu = document.createElement("div");
menu.classList.add("dropdown-menu");
const list = document.createElement("div");
list.classList.add("dropdown-list", "flexcol");
menu.appendChild(list);
for ( let c of choices ) {
const dropdownItem = document.createElement("a");
dropdownItem.classList.add("dropdown-item");
if ( this.#activeFilters.includes(c.type) ) dropdownItem.classList.add("active");
dropdownItem.innerHTML = `<i class="${c.icon}"></i> ${c.name}`;
dropdownItem.addEventListener("click", c.callback);
list.appendChild(dropdownItem);
}
// Position the menu
const pos = {
top: button.offsetTop + 10,
left: button.offsetLeft + 10
};
menu.style.top = `${pos.top}px`;
menu.style.left = `${pos.left}px`;
button.parentElement.appendChild(menu);
}
/* -------------------------------------------- */
/**
* Handle toggling a compendium type filter
* @param {PointerEvent} event The originating pointer event
* @param {string|null} type The compendium type to filter by. If null, clear all filters.
* @protected
*/
_onToggleCompendiumFilterType(event, type) {
if ( type === null ) this.#activeFilters = [];
else this.#activeFilters = this.#activeFilters.includes(type) ?
this.#activeFilters.filter(t => t !== type) : this.#activeFilters.concat(type);
this.render();
}
/* -------------------------------------------- */
/**
* The collection of Compendium Packs which are displayed in this Directory
* @returns {CompendiumPacks<string, CompendiumCollection>}
*/
get collection() {
return game.packs;
}
/* -------------------------------------------- */
/**
* Get the dropped Entry from the drop data
* @param {object} data The data being dropped
* @returns {Promise<object>} The dropped Entry
* @protected
*/
async _getDroppedEntryFromData(data) {
return game.packs.get(data.id);
}
/* -------------------------------------------- */
/** @override */
async _createDroppedEntry(document, folder) {
throw new Error("The _createDroppedEntry shouldn't be called for CompendiumDirectory");
}
/* -------------------------------------------- */
/** @override */
_getEntryName(entry) {
return entry.metadata.label;
}
/* -------------------------------------------- */
/** @override */
_getEntryId(entry) {
return entry.metadata.id;
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
let context = await super.getData(options);
// For each document, assign a default image if one is not already present, and calculate the style string
const packageTypeIcons = {
"world": World.icon,
"system": System.icon,
"module": Module.icon
};
const packContext = {};
for ( const pack of this.collection ) {
packContext[pack.collection] = {
locked: pack.locked,
customOwnership: "ownership" in pack.config,
collection: pack.collection,
name: pack.metadata.packageName,
label: pack.metadata.label,
icon: CONFIG[pack.metadata.type].sidebarIcon,
hidden: this.#activeFilters?.length ? !this.#activeFilters.includes(pack.metadata.type) : false,
banner: pack.banner,
sourceIcon: packageTypeIcons[pack.metadata.packageType]
};
}
// Return data to the sidebar
context = foundry.utils.mergeObject(context, {
folderIcon: CONFIG.Folder.sidebarIcon,
label: game.i18n.localize("PACKAGE.TagCompendium"),
labelPlural: game.i18n.localize("SIDEBAR.TabCompendium"),
sidebarIcon: "fas fa-atlas",
filtersActive: !!this.#activeFilters.length
});
context.packContext = packContext;
return context;
}
/* -------------------------------------------- */
/** @override */
async render(force=false, options={}) {
game.packs.initializeTree();
return super.render(force, options);
}
/* -------------------------------------------- */
/** @override */
_getEntryContextOptions() {
if ( !game.user.isGM ) return [];
return [
{
name: "OWNERSHIP.Configure",
icon: '<i class="fa-solid fa-user-lock"></i>',
callback: li => {
const pack = game.packs.get(li.data("pack"));
return pack.configureOwnershipDialog();
}
},
{
name: "FOLDER.Clear",
icon: '<i class="fas fa-folder"></i>',
condition: header => {
const li = header.closest(".directory-item");
const entry = this.collection.get(li.data("entryId"));
return !!entry.folder;
},
callback: header => {
const li = header.closest(".directory-item");
const entry = this.collection.get(li.data("entryId"));
entry.setFolder(null);
}
},
{
name: "COMPENDIUM.ToggleLocked",
icon: '<i class="fas fa-lock"></i>',
callback: li => {
let pack = game.packs.get(li.data("pack"));
const isUnlock = pack.locked;
if ( isUnlock && (pack.metadata.packageType !== "world")) {
return Dialog.confirm({
title: `${game.i18n.localize("COMPENDIUM.ToggleLocked")}: ${pack.title}`,
content: `<p><strong>${game.i18n.localize("Warning")}:</strong> ${game.i18n.localize("COMPENDIUM.ToggleLockedWarning")}</p>`,
yes: () => pack.configure({locked: !pack.locked}),
options: {
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720,
width: 400
}
});
}
else return pack.configure({locked: !pack.locked});
}
},
{
name: "COMPENDIUM.Duplicate",
icon: '<i class="fas fa-copy"></i>',
callback: li => {
let pack = game.packs.get(li.data("pack"));
const html = `<form>
<div class="form-group">
<label>${game.i18n.localize("COMPENDIUM.DuplicateTitle")}</label>
<input type="text" name="label" value="${game.i18n.format("DOCUMENT.CopyOf", {name: pack.title})}"/>
<p class="notes">${game.i18n.localize("COMPENDIUM.DuplicateHint")}</p>
</div>
</form>`;
return Dialog.confirm({
title: `${game.i18n.localize("COMPENDIUM.Duplicate")}: ${pack.title}`,
content: html,
yes: html => {
const label = html.querySelector('input[name="label"]').value;
return pack.duplicateCompendium({label});
},
options: {
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720,
width: 400,
jQuery: false
}
});
}
},
{
name: "COMPENDIUM.ImportAll",
icon: '<i class="fas fa-download"></i>',
condition: li => game.packs.get(li.data("pack"))?.documentName !== "Adventure",
callback: li => {
let pack = game.packs.get(li.data("pack"));
return pack.importDialog({
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720,
width: 400
});
}
},
{
name: "COMPENDIUM.Delete",
icon: '<i class="fas fa-trash"></i>',
condition: li => {
let pack = game.packs.get(li.data("pack"));
return pack.metadata.packageType === "world";
},
callback: li => {
let pack = game.packs.get(li.data("pack"));
return this._onDeleteCompendium(pack);
}
}
];
}
/* -------------------------------------------- */
/** @override */
async _onClickEntryName(event) {
event.preventDefault();
const element = event.currentTarget;
const packId = element.closest("[data-pack]").dataset.pack;
const pack = game.packs.get(packId);
pack.render(true);
}
/* -------------------------------------------- */
/** @override */
async _onCreateEntry(event) {
event.preventDefault();
event.stopPropagation();
const li = event.currentTarget.closest(".directory-item");
const targetFolderId = li ? li.dataset.folderId : null;
const types = CONST.COMPENDIUM_DOCUMENT_TYPES.map(documentName => {
return { value: documentName, label: game.i18n.localize(getDocumentClass(documentName).metadata.label) };
});
game.i18n.sortObjects(types, "label");
const folders = this.collection._formatFolderSelectOptions();
const html = await renderTemplate("templates/sidebar/compendium-create.html",
{types, folders, folder: targetFolderId, hasFolders: folders.length >= 1});
return Dialog.prompt({
title: game.i18n.localize("COMPENDIUM.Create"),
content: html,
label: game.i18n.localize("COMPENDIUM.Create"),
callback: async html => {
const form = html.querySelector("#compendium-create");
const fd = new FormDataExtended(form);
const metadata = fd.object;
let targetFolderId = metadata.folder;
if ( metadata.folder ) delete metadata.folder;
if ( !metadata.label ) {
let defaultName = game.i18n.format("DOCUMENT.New", {type: game.i18n.localize("PACKAGE.TagCompendium")});
const count = game.packs.size;
if ( count > 0 ) defaultName += ` (${count + 1})`;
metadata.label = defaultName;
}
const pack = await CompendiumCollection.createCompendium(metadata);
if ( targetFolderId ) await pack.setFolder(targetFolderId);
},
rejectClose: false,
options: { jQuery: false }
});
}
/* -------------------------------------------- */
/**
* Handle a Compendium Pack deletion request
* @param {object} pack The pack object requested for deletion
* @private
*/
_onDeleteCompendium(pack) {
return Dialog.confirm({
title: `${game.i18n.localize("COMPENDIUM.Delete")}: ${pack.title}`,
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("COMPENDIUM.DeleteWarning")}</p>`,
yes: () => pack.deleteCompendium(),
defaultYes: false
});
}
}
/**
* The sidebar directory which organizes and displays world-level Item documents.
*/
class ItemDirectory extends DocumentDirectory {
/** @override */
static documentName = "Item";
/* -------------------------------------------- */
/** @override */
_canDragDrop(selector) {
return game.user.can("ITEM_CREATE");
}
/* -------------------------------------------- */
/** @override */
_getEntryContextOptions() {
const options = super._getEntryContextOptions();
return [
{
name: "ITEM.ViewArt",
icon: '<i class="fas fa-image"></i>',
condition: li => {
const item = game.items.get(li.data("documentId"));
return item.img !== CONST.DEFAULT_TOKEN;
},
callback: li => {
const item = game.items.get(li.data("documentId"));
new ImagePopout(item.img, {
title: item.name,
uuid: item.uuid
}).render(true);
}
}
].concat(options);
}
}
/**
* The sidebar directory which organizes and displays world-level JournalEntry documents.
* @extends {DocumentDirectory}
*/
class JournalDirectory extends DocumentDirectory {
/** @override */
static documentName = "JournalEntry";
/* -------------------------------------------- */
/** @override */
_getEntryContextOptions() {
const options = super._getEntryContextOptions();
return options.concat([
{
name: "SIDEBAR.JumpPin",
icon: '<i class="fas fa-crosshairs"></i>',
condition: li => {
const entry = game.journal.get(li.data("document-id"));
return !!entry.sceneNote;
},
callback: li => {
const entry = game.journal.get(li.data("document-id"));
return entry.panToNote();
}
}
]);
}
}
/**
* The directory, not displayed in the sidebar, which organizes and displays world-level Macro documents.
* @extends {DocumentDirectory}
*
* @see {@link Macros} The WorldCollection of Macro Documents
* @see {@link Macro} The Macro Document
* @see {@link MacroConfig} The Macro Configuration Sheet
*/
class MacroDirectory extends DocumentDirectory {
constructor(options={}) {
options.popOut = true;
super(options);
delete ui.sidebar.tabs["macros"];
game.macros.apps.push(this);
}
/** @override */
static documentName = "Macro";
}
/**
* The sidebar directory which organizes and displays world-level Playlist documents.
* @extends {DocumentDirectory}
*/
class PlaylistDirectory extends DocumentDirectory {
constructor(options) {
super(options);
/**
* Track the playlist IDs which are currently expanded in their display
* @type {Set<string>}
*/
this._expanded = this._createExpandedSet();
/**
* Are the global volume controls currently expanded?
* @type {boolean}
* @private
*/
this._volumeExpanded = true;
/**
* Cache the set of Playlist documents that are displayed as playing when the directory is rendered
* @type {Playlist[]}
*/
this._playingPlaylists = [];
/**
* Cache the set of PlaylistSound documents that are displayed as playing when the directory is rendered
* @type {PlaylistSound[]}
*/
this._playingSounds = [];
// Update timestamps every second
setInterval(this._updateTimestamps.bind(this), 1000);
// Playlist 'currently playing' pinned location.
game.settings.register("core", "playlist.playingLocation", {
scope: "client",
config: false,
default: "top",
type: String,
onChange: () => ui.playlists.render()
});
}
/** @override */
static documentName = "Playlist";
/** @override */
static entryPartial = "templates/sidebar/partials/playlist-partial.html";
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
const options = super.defaultOptions;
options.template = "templates/sidebar/playlists-directory.html";
options.dragDrop[0].dragSelector = ".folder, .playlist-name, .sound-name";
options.renderUpdateKeys = ["name", "playing", "mode", "sounds", "sort", "sorting", "folder"];
options.contextMenuSelector = ".document .playlist-header";
return options;
}
/* -------------------------------------------- */
/**
* Initialize the set of Playlists which should be displayed in an expanded form
* @returns {Set<string>}
* @private
*/
_createExpandedSet() {
const expanded = new Set();
for ( let playlist of this.documents ) {
if ( playlist.playing ) expanded.add(playlist.id);
}
return expanded;
}
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* Return an Array of the Playlist documents which are currently playing
* @type {Playlist[]}
*/
get playing() {
return this._playingPlaylists;
}
/**
* Whether the 'currently playing' element is pinned to the top or bottom of the display.
* @type {string}
* @private
*/
get _playingLocation() {
return game.settings.get("core", "playlist.playingLocation");
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
this._playingPlaylists = [];
this._playingSounds = [];
this._playingSoundsData = [];
this._prepareTreeData(this.collection.tree);
const data = await super.getData(options);
const currentAtTop = this._playingLocation === "top";
return foundry.utils.mergeObject(data, {
playingSounds: this._playingSoundsData,
showPlaying: this._playingSoundsData.length > 0,
playlistModifier: AudioHelper.volumeToInput(game.settings.get("core", "globalPlaylistVolume")),
playlistTooltip: PlaylistDirectory.volumeToTooltip(game.settings.get("core", "globalPlaylistVolume")),
ambientModifier: AudioHelper.volumeToInput(game.settings.get("core", "globalAmbientVolume")),
ambientTooltip: PlaylistDirectory.volumeToTooltip(game.settings.get("core", "globalAmbientVolume")),
interfaceModifier: AudioHelper.volumeToInput(game.settings.get("core", "globalInterfaceVolume")),
interfaceTooltip: PlaylistDirectory.volumeToTooltip(game.settings.get("core", "globalInterfaceVolume")),
volumeExpanded: this._volumeExpanded,
currentlyPlaying: {
class: `location-${currentAtTop ? "top" : "bottom"}`,
location: {top: currentAtTop, bottom: !currentAtTop},
pin: {label: `PLAYLIST.PinTo${currentAtTop ? "Bottom" : "Top"}`, caret: currentAtTop ? "down" : "up"}
}
});
}
/* -------------------------------------------- */
/**
* Converts a volume level to a human-friendly % value
* @param {number} volume Value between [0, 1] of the volume level
* @returns {string}
*/
static volumeToTooltip(volume) {
return game.i18n.format("PLAYLIST.VolumeTooltip", { volume: Math.round(AudioHelper.volumeToInput(volume) * 100) });
}
/* -------------------------------------------- */
/**
* Augment the tree directory structure with playlist-level data objects for rendering
* @param {object} node The tree leaf node being prepared
* @private
*/
_prepareTreeData(node) {
node.entries = node.entries.map(p => this._preparePlaylistData(p));
for ( const child of node.children ) this._prepareTreeData(child);
}
/* -------------------------------------------- */
/**
* Create an object of rendering data for each Playlist document being displayed
* @param {Playlist} playlist The playlist to display
* @returns {object} The data for rendering
* @private
*/
_preparePlaylistData(playlist) {
const isGM = game.user.isGM;
if ( playlist.playing ) this._playingPlaylists.push(playlist);
// Playlist configuration
const p = playlist.toObject(false);
p.modeTooltip = this._getModeTooltip(p.mode);
p.modeIcon = this._getModeIcon(p.mode);
p.disabled = p.mode === CONST.PLAYLIST_MODES.DISABLED;
p.expanded = this._expanded.has(p._id);
p.css = [p.expanded ? "" : "collapsed", playlist.playing ? "playing" : ""].filterJoin(" ");
p.controlCSS = (isGM && !p.disabled) ? "" : "disabled";
// Playlist sounds
const sounds = [];
for ( const soundId of playlist.playbackOrder ) {
const sound = playlist.sounds.get(soundId);
if ( !isGM && !sound.playing ) continue;
// All sounds
const s = sound.toObject(false);
s.playlistId = playlist.id;
s.css = s.playing ? "playing" : "";
s.controlCSS = isGM ? "" : "disabled";
s.playIcon = this._getPlayIcon(sound);
s.playTitle = s.pausedTime ? "PLAYLIST.SoundResume" : "PLAYLIST.SoundPlay";
// Playing sounds
if ( sound.sound && !sound.sound.failed && (sound.playing || s.pausedTime) ) {
s.isPaused = !sound.playing && s.pausedTime;
s.pauseIcon = this._getPauseIcon(sound);
s.lvolume = AudioHelper.volumeToInput(s.volume);
s.volumeTooltip = this.constructor.volumeToTooltip(s.volume);
s.currentTime = this._formatTimestamp(sound.playing ? sound.sound.currentTime : s.pausedTime);
s.durationTime = this._formatTimestamp(sound.sound.duration);
this._playingSounds.push(sound);
this._playingSoundsData.push(s);
}
sounds.push(s);
}
p.sounds = sounds;
return p;
}
/* -------------------------------------------- */
/**
* Get the icon used to represent the "play/stop" icon for the PlaylistSound
* @param {PlaylistSound} sound The sound being rendered
* @returns {string} The icon that should be used
* @private
*/
_getPlayIcon(sound) {
if ( !sound.playing ) return sound.pausedTime ? "fas fa-play-circle" : "fas fa-play";
else return "fas fa-square";
}
/* -------------------------------------------- */
/**
* Get the icon used to represent the pause/loading icon for the PlaylistSound
* @param {PlaylistSound} sound The sound being rendered
* @returns {string} The icon that should be used
* @private
*/
_getPauseIcon(sound) {
return (sound.playing && !sound.sound?.loaded) ? "fas fa-spinner fa-spin" : "fas fa-pause";
}
/* -------------------------------------------- */
/**
* Given a constant playback mode, provide the FontAwesome icon used to display it
* @param {number} mode
* @returns {string}
* @private
*/
_getModeIcon(mode) {
return {
[CONST.PLAYLIST_MODES.DISABLED]: '<i class="fas fa-ban"></i>',
[CONST.PLAYLIST_MODES.SEQUENTIAL]: '<i class="far fa-arrow-alt-circle-right"></i>',
[CONST.PLAYLIST_MODES.SHUFFLE]: '<i class="fas fa-random"></i>',
[CONST.PLAYLIST_MODES.SIMULTANEOUS]: '<i class="fas fa-compress-arrows-alt"></i>',
}[mode];
}
/* -------------------------------------------- */
/**
* Given a constant playback mode, provide the string tooltip used to describe it
* @param {number} mode
* @returns {string}
* @private
*/
_getModeTooltip(mode) {
return {
[CONST.PLAYLIST_MODES.DISABLED]: game.i18n.localize("PLAYLIST.ModeDisabled"),
[CONST.PLAYLIST_MODES.SEQUENTIAL]: game.i18n.localize("PLAYLIST.ModeSequential"),
[CONST.PLAYLIST_MODES.SHUFFLE]: game.i18n.localize("PLAYLIST.ModeShuffle"),
[CONST.PLAYLIST_MODES.SIMULTANEOUS]: game.i18n.localize("PLAYLIST.ModeSimultaneous")
}[mode];
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
// Volume sliders
html.find(".global-volume-slider").change(this._onGlobalVolume.bind(this));
html.find(".sound-volume").change(this._onSoundVolume.bind(this));
// Collapse/Expand
html.find("#global-volume .playlist-header").click(this._onVolumeCollapse.bind(this));
// Currently playing pinning
html.find("#currently-playing .pin").click(this._onPlayingPin.bind(this));
// All options below require a GM user
if (!game.user.isGM) return;
// Playlist Control Events
html.on("click", "a.sound-control", event => {
event.preventDefault();
const btn = event.currentTarget;
const action = btn.dataset.action;
if (!action || btn.classList.contains("disabled")) return;
// Delegate to Playlist and Sound control handlers
switch (action) {
case "playlist-mode":
return this._onPlaylistToggleMode(event);
case "playlist-play":
case "playlist-stop":
return this._onPlaylistPlay(event, action === "playlist-play");
case "playlist-forward":
case "playlist-backward":
return this._onPlaylistSkip(event, action);
case "sound-create":
return this._onSoundCreate(event);
case "sound-pause":
case "sound-play":
case "sound-stop":
return this._onSoundPlay(event, action);
case "sound-repeat":
return this._onSoundToggleMode(event);
}
});
}
/* -------------------------------------------- */
/**
* Handle global volume change for the playlist sidebar
* @param {MouseEvent} event The initial click event
* @private
*/
_onGlobalVolume(event) {
event.preventDefault();
const slider = event.currentTarget;
const volume = AudioHelper.inputToVolume(slider.value);
const tooltip = PlaylistDirectory.volumeToTooltip(volume);
slider.setAttribute("data-tooltip", tooltip);
game.tooltip.activate(slider, {text: tooltip});
return game.settings.set("core", slider.name, volume);
}
/* -------------------------------------------- */
/** @inheritdoc */
collapseAll() {
super.collapseAll();
const el = this.element[0];
for ( let p of el.querySelectorAll("li.playlist") ) {
this._collapse(p, true);
}
this._expanded.clear();
this._collapse(el.querySelector("#global-volume"), true);
this._volumeExpanded = false;
}
/* -------------------------------------------- */
/** @override */
_onClickEntryName(event) {
const li = event.currentTarget.closest(".playlist");
const playlistId = li.dataset.documentId;
const wasExpanded = this._expanded.has(playlistId);
this._collapse(li, wasExpanded);
if ( wasExpanded ) this._expanded.delete(playlistId);
else this._expanded.add(playlistId);
}
/* -------------------------------------------- */
/**
* Handle global volume control collapse toggle
* @param {MouseEvent} event The initial click event
* @private
*/
_onVolumeCollapse(event) {
event.preventDefault();
const div = event.currentTarget.parentElement;
this._volumeExpanded = !this._volumeExpanded;
this._collapse(div, !this._volumeExpanded);
}
/* -------------------------------------------- */
/**
* Helper method to render the expansion or collapse of playlists
* @private
*/
_collapse(el, collapse, speed = 250) {
const ol = el.querySelector(".playlist-sounds");
const icon = el.querySelector("i.collapse");
if (collapse) { // Collapse the sounds
$(ol).slideUp(speed, () => {
el.classList.add("collapsed");
icon.classList.replace("fa-angle-down", "fa-angle-up");
});
}
else { // Expand the sounds
$(ol).slideDown(speed, () => {
el.classList.remove("collapsed");
icon.classList.replace("fa-angle-up", "fa-angle-down");
});
}
}
/* -------------------------------------------- */
/**
* Handle Playlist playback state changes
* @param {MouseEvent} event The initial click event
* @param {boolean} playing Is the playlist now playing?
* @private
*/
_onPlaylistPlay(event, playing) {
const li = event.currentTarget.closest(".playlist");
const playlist = game.playlists.get(li.dataset.documentId);
if ( playing ) return playlist.playAll();
else return playlist.stopAll();
}
/* -------------------------------------------- */
/**
* Handle advancing the playlist to the next (or previous) sound
* @param {MouseEvent} event The initial click event
* @param {string} action The control action requested
* @private
*/
_onPlaylistSkip(event, action) {
const li = event.currentTarget.closest(".playlist");
const playlist = game.playlists.get(li.dataset.documentId);
return playlist.playNext(undefined, {direction: action === "playlist-forward" ? 1 : -1});
}
/* -------------------------------------------- */
/**
* Handle cycling the playback mode for a Playlist
* @param {MouseEvent} event The initial click event
* @private
*/
_onPlaylistToggleMode(event) {
const li = event.currentTarget.closest(".playlist");
const playlist = game.playlists.get(li.dataset.documentId);
return playlist.cycleMode();
}
/* -------------------------------------------- */
/**
* Handle Playlist track addition request
* @param {MouseEvent} event The initial click event
* @private
*/
_onSoundCreate(event) {
const li = $(event.currentTarget).parents('.playlist');
const playlist = game.playlists.get(li.data("documentId"));
const sound = new PlaylistSound({name: game.i18n.localize("SOUND.New")}, {parent: playlist});
sound.sheet.render(true, {top: li[0].offsetTop, left: window.innerWidth - 670});
}
/* -------------------------------------------- */
/**
* Modify the playback state of a Sound within a Playlist
* @param {MouseEvent} event The initial click event
* @param {string} action The sound control action performed
* @private
*/
_onSoundPlay(event, action) {
const li = event.currentTarget.closest(".sound");
const playlist = game.playlists.get(li.dataset.playlistId);
const sound = playlist.sounds.get(li.dataset.soundId);
switch ( action ) {
case "sound-play":
return playlist.playSound(sound);
case "sound-pause":
return sound.update({playing: false, pausedTime: sound.sound.currentTime});
case "sound-stop":
return playlist.stopSound(sound);
}
}
/* -------------------------------------------- */
/**
* Handle volume adjustments to sounds within a Playlist
* @param {Event} event The initial change event
* @private
*/
_onSoundVolume(event) {
event.preventDefault();
const slider = event.currentTarget;
const li = slider.closest(".sound");
const playlist = game.playlists.get(li.dataset.playlistId);
const playlistSound = playlist.sounds.get(li.dataset.soundId);
// Get the desired target volume
const volume = AudioHelper.inputToVolume(slider.value);
if ( volume === playlistSound.volume ) return;
// Immediately apply a local adjustment
playlistSound.updateSource({volume});
playlistSound.sound?.fade(playlistSound.effectiveVolume, {duration: PlaylistSound.VOLUME_DEBOUNCE_MS});
const tooltip = PlaylistDirectory.volumeToTooltip(volume);
slider.setAttribute("data-tooltip", tooltip);
game.tooltip.activate(slider, {text: tooltip});
// Debounce a change to the database
if ( playlistSound.isOwner ) playlistSound.debounceVolume(volume);
}
/* -------------------------------------------- */
/**
* Handle changes to the sound playback mode
* @param {Event} event The initial click event
* @private
*/
_onSoundToggleMode(event) {
event.preventDefault();
const li = event.currentTarget.closest(".sound");
const playlist = game.playlists.get(li.dataset.playlistId);
const sound = playlist.sounds.get(li.dataset.soundId);
return sound.update({repeat: !sound.repeat});
}
/* -------------------------------------------- */
_onPlayingPin() {
const location = this._playingLocation === "top" ? "bottom" : "top";
return game.settings.set("core", "playlist.playingLocation", location);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onSearchFilter(event, query, rgx, html) {
const isSearch = !!query;
const playlistIds = new Set();
const soundIds = new Set();
const folderIds = new Set();
const nameOnlySearch = (this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME);
// Match documents and folders
if ( isSearch ) {
let results = [];
if ( !nameOnlySearch ) results = this.collection.search({query: query});
// Match Playlists and Sounds
for ( let d of this.documents ) {
let matched = false;
for ( let s of d.sounds ) {
if ( s.playing || rgx.test(SearchFilter.cleanQuery(s.name)) ) {
soundIds.add(s._id);
matched = true;
}
}
if ( matched || d.playing || ( nameOnlySearch && rgx.test(SearchFilter.cleanQuery(d.name) )
|| results.some(r => r._id === d._id)) ) {
playlistIds.add(d._id);
if ( d.folder ) folderIds.add(d.folder._id);
}
}
// Include parent Folders
const folders = this.folders.sort((a, b) => b.depth - a.depth);
for ( let f of folders ) {
if ( folderIds.has(f.id) && f.folder ) folderIds.add(f.folder._id);
}
}
// Toggle each directory item
for ( let el of html.querySelectorAll(".directory-item") ) {
if ( el.classList.contains("global-volume") ) continue;
// Playlists
if ( el.classList.contains("document") ) {
const pid = el.dataset.documentId;
let playlistIsMatch = !isSearch || playlistIds.has(pid);
el.style.display = playlistIsMatch ? "flex" : "none";
// Sounds
const sounds = el.querySelector(".playlist-sounds");
for ( const li of sounds.children ) {
let soundIsMatch = !isSearch || soundIds.has(li.dataset.soundId);
li.style.display = soundIsMatch ? "flex" : "none";
if ( soundIsMatch ) {
playlistIsMatch = true;
}
}
const showExpanded = this._expanded.has(pid) || (isSearch && playlistIsMatch);
el.classList.toggle("collapsed", !showExpanded);
}
// Folders
else if ( el.classList.contains("folder") ) {
const hidden = isSearch && !folderIds.has(el.dataset.folderId);
el.style.display = hidden ? "none" : "flex";
const uuid = el.closest("li.folder").dataset.uuid;
const expanded = (isSearch && folderIds.has(el.dataset.folderId)) ||
(!isSearch && game.folders._expanded[uuid]);
el.classList.toggle("collapsed", !expanded);
}
}
}
/* -------------------------------------------- */
/**
* Update the displayed timestamps for all currently playing audio sources.
* Runs on an interval every 1000ms.
* @private
*/
_updateTimestamps() {
if ( !this._playingSounds.length ) return;
const playing = this.element.find("#currently-playing")[0];
if ( !playing ) return;
for ( let sound of this._playingSounds ) {
const li = playing.querySelector(`.sound[data-sound-id="${sound.id}"]`);
if ( !li ) continue;
// Update current and max playback time
const current = li.querySelector("span.current");
const ct = sound.playing ? sound.sound.currentTime : sound.pausedTime;
if ( current ) current.textContent = this._formatTimestamp(ct);
const max = li.querySelector("span.duration");
if ( max ) max.textContent = this._formatTimestamp(sound.sound.duration);
// Remove the loading spinner
const play = li.querySelector("a.pause i.fas");
if ( play.classList.contains("fa-spinner") ) {
play.classList.remove("fa-spin");
play.classList.replace("fa-spinner", "fa-pause");
}
}
}
/* -------------------------------------------- */
/**
* Format the displayed timestamp given a number of seconds as input
* @param {number} seconds The current playback time in seconds
* @returns {string} The formatted timestamp
* @private
*/
_formatTimestamp(seconds) {
if ( !Number.isFinite(seconds) ) return "∞";
seconds = seconds ?? 0;
let minutes = Math.floor(seconds / 60);
seconds = Math.round(seconds % 60);
return `${minutes}:${seconds.paddedString(2)}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
_contextMenu(html) {
super._contextMenu(html);
/**
* A hook event that fires when the context menu for a Sound in the PlaylistDirectory is constructed.
* @function getPlaylistDirectorySoundContext
* @memberof hookEvents
* @param {jQuery} html The HTML element to which the context options are attached
* @param {ContextMenuEntry[]} entryOptions The context menu entries
*/
ContextMenu.create(this, html, ".playlist .sound", this._getSoundContextOptions(), {hookName: "SoundContext"});
}
/* -------------------------------------------- */
/** @inheritdoc */
_getFolderContextOptions() {
const options = super._getFolderContextOptions();
options.findSplice(o => o.name === "OWNERSHIP.Configure");
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
_getEntryContextOptions() {
const options = super._getEntryContextOptions();
options.findSplice(o => o.name === "OWNERSHIP.Configure");
options.unshift({
name: "PLAYLIST.Edit",
icon: '<i class="fas fa-edit"></i>',
callback: header => {
const li = header.closest(".directory-item");
const playlist = game.playlists.get(li.data("document-id"));
const sheet = playlist.sheet;
sheet.render(true, this.popOut ? {} : {
top: li[0].offsetTop - 24,
left: window.innerWidth - ui.sidebar.position.width - sheet.options.width - 10
});
}
});
return options;
}
/* -------------------------------------------- */
/**
* Get context menu options for individual sound effects
* @returns {Object} The context options for each sound
* @private
*/
_getSoundContextOptions() {
return [
{
name: "PLAYLIST.SoundEdit",
icon: '<i class="fas fa-edit"></i>',
callback: li => {
const playlistId = li.parents(".playlist").data("document-id");
const playlist = game.playlists.get(playlistId);
const sound = playlist.sounds.get(li.data("sound-id"));
const sheet = sound.sheet;
sheet.render(true, this.popOut ? {} : {
top: li[0].offsetTop - 24,
left: window.innerWidth - ui.sidebar.position.width - sheet.options.width - 10
});
}
},
{
name: "PLAYLIST.SoundPreload",
icon: '<i class="fas fa-download"></i>',
callback: li => {
const playlistId = li.parents(".playlist").data("document-id");
const playlist = game.playlists.get(playlistId);
const sound = playlist.sounds.get(li.data("sound-id"));
game.audio.preload(sound.path);
}
},
{
name: "PLAYLIST.SoundDelete",
icon: '<i class="fas fa-trash"></i>',
callback: li => {
const playlistId = li.parents(".playlist").data("document-id");
const playlist = game.playlists.get(playlistId);
const sound = playlist.sounds.get(li.data("sound-id"));
return sound.deleteDialog({
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720
});
}
}
];
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragStart(event) {
const target = event.currentTarget;
if ( target.classList.contains("sound-name") ) {
const sound = target.closest(".sound");
const document = game.playlists.get(sound.dataset.playlistId)?.sounds.get(sound.dataset.soundId);
event.dataTransfer.setData("text/plain", JSON.stringify(document.toDragData()));
}
else super._onDragStart(event);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDrop(event) {
const data = TextEditor.getDragEventData(event);
if ( data.type !== "PlaylistSound" ) return super._onDrop(event);
// Reference the target playlist and sound elements
const target = event.target.closest(".sound, .playlist");
if ( !target ) return false;
const sound = await PlaylistSound.implementation.fromDropData(data);
const playlist = sound.parent;
const otherPlaylistId = target.dataset.documentId || target.dataset.playlistId;
// Copying to another playlist.
if ( otherPlaylistId !== playlist.id ) {
const otherPlaylist = game.playlists.get(otherPlaylistId);
return PlaylistSound.implementation.create(sound.toObject(), {parent: otherPlaylist});
}
// If there's nothing to sort relative to, or the sound was dropped on itself, do nothing.
const targetId = target.dataset.soundId;
if ( !targetId || (targetId === sound.id) ) return false;
sound.sortRelative({
target: playlist.sounds.get(targetId),
siblings: playlist.sounds.filter(s => s.id !== sound.id)
});
}
}
/**
* The sidebar directory which organizes and displays world-level RollTable documents.
* @extends {DocumentDirectory}
*/
class RollTableDirectory extends DocumentDirectory {
/** @override */
static documentName = "RollTable";
/* -------------------------------------------- */
/** @inheritdoc */
_getEntryContextOptions() {
let options = super._getEntryContextOptions();
// Add the "Roll" option
options = [
{
name: "TABLE.Roll",
icon: '<i class="fas fa-dice-d20"></i>',
callback: li => {
const table = game.tables.get(li.data("documentId"));
table.draw({roll: true, displayChat: true});
}
}
].concat(options);
return options;
}
}
/**
* The sidebar directory which organizes and displays world-level Scene documents.
* @extends {DocumentDirectory}
*/
class SceneDirectory extends DocumentDirectory {
/** @override */
static documentName = "Scene";
/** @override */
static entryPartial = "templates/sidebar/scene-partial.html";
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.renderUpdateKeys.push("background");
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
if ( !game.user.isGM ) return;
return super._render(force, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
_getEntryContextOptions() {
let options = super._getEntryContextOptions();
options = [
{
name: "SCENES.View",
icon: '<i class="fas fa-eye"></i>',
condition: li => !canvas.ready || (li.data("documentId") !== canvas.scene.id),
callback: li => {
const scene = game.scenes.get(li.data("documentId"));
scene.view();
}
},
{
name: "SCENES.Activate",
icon: '<i class="fas fa-bullseye"></i>',
condition: li => game.user.isGM && !game.scenes.get(li.data("documentId")).active,
callback: li => {
const scene = game.scenes.get(li.data("documentId"));
scene.activate();
}
},
{
name: "SCENES.Configure",
icon: '<i class="fas fa-cogs"></i>',
callback: li => {
const scene = game.scenes.get(li.data("documentId"));
scene.sheet.render(true);
}
},
{
name: "SCENES.Notes",
icon: '<i class="fas fa-scroll"></i>',
condition: li => {
const scene = game.scenes.get(li.data("documentId"));
return !!scene.journal;
},
callback: li => {
const scene = game.scenes.get(li.data("documentId"));
const entry = scene.journal;
if ( entry ) {
const sheet = entry.sheet;
const options = {};
if ( scene.journalEntryPage ) options.pageId = scene.journalEntryPage;
sheet.render(true, options);
}
}
},
{
name: "SCENES.ToggleNav",
icon: '<i class="fas fa-compass"></i>',
condition: li => {
const scene = game.scenes.get(li.data("documentId"));
return game.user.isGM && ( !scene.active );
},
callback: li => {
const scene = game.scenes.get(li.data("documentId"));
scene.update({navigation: !scene.navigation});
}
},
{
name: "SCENES.GenerateThumb",
icon: '<i class="fas fa-image"></i>',
condition: li => {
const scene = game.scenes.get(li[0].dataset.documentId);
return (scene.background.src || scene.tiles.size) && !game.settings.get("core", "noCanvas");
},
callback: li => {
const scene = game.scenes.get(li[0].dataset.documentId);
scene.createThumbnail().then(data => {
scene.update({thumb: data.thumb}, {diff: false});
ui.notifications.info(game.i18n.format("SCENES.GenerateThumbSuccess", {name: scene.name}));
}).catch(err => ui.notifications.error(err.message));
}
}
].concat(options);
// Remove the ownership entry
options.findSplice(o => o.name === "OWNERSHIP.Configure");
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
_getFolderContextOptions() {
const options = super._getFolderContextOptions();
options.findSplice(o => o.name === "OWNERSHIP.Configure");
return options;
}
}
/**
* The sidebar tab which displays various game settings, help messages, and configuration options.
* The Settings sidebar is the furthest-to-right using a triple-cogs icon.
* @extends {SidebarTab}
*/
class Settings extends SidebarTab {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "settings",
template: "templates/sidebar/settings.html",
title: "Settings"
});
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const context = await super.getData(options);
// Check for core update
let coreUpdate;
if ( game.user.isGM && game.data.coreUpdate.hasUpdate ) {
coreUpdate = game.i18n.format("SETUP.UpdateAvailable", {
type: game.i18n.localize("Software"),
channel: game.data.coreUpdate.channel,
version: game.data.coreUpdate.version
});
}
// Check for system update
let systemUpdate;
if ( game.user.isGM && game.data.systemUpdate.hasUpdate ) {
systemUpdate = game.i18n.format("SETUP.UpdateAvailable", {
type: game.i18n.localize("System"),
channel: game.data.system.title,
version: game.data.systemUpdate.version
});
}
const issues = CONST.DOCUMENT_TYPES.reduce((count, documentName) => {
const collection = CONFIG[documentName].collection.instance;
return count + collection.invalidDocumentIds.size;
}, 0) + Object.values(game.issues.packageCompatibilityIssues).reduce((count, {error}) => {
return count + error.length;
}, 0) + Object.keys(game.issues.usabilityIssues).length;
// Return rendering context
const isDemo = game.data.demoMode;
return foundry.utils.mergeObject(context, {
system: game.system,
release: game.data.release,
versionDisplay: game.release.display,
canConfigure: game.user.can("SETTINGS_MODIFY") && !isDemo,
canEditWorld: game.user.hasRole("GAMEMASTER") && !isDemo,
canManagePlayers: game.user.isGM && !isDemo,
canReturnSetup: game.user.hasRole("GAMEMASTER") && !isDemo,
modules: game.modules.reduce((n, m) => n + (m.active ? 1 : 0), 0),
issues,
isDemo,
coreUpdate,
systemUpdate
});
}
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
html.find("button[data-action]").click(this._onSettingsButton.bind(this));
html.find(".notification-pip.update").click(this._onUpdateNotificationClick.bind(this));
}
/* -------------------------------------------- */
/**
* Delegate different actions for different settings buttons
* @param {MouseEvent} event The originating click event
* @private
*/
_onSettingsButton(event) {
event.preventDefault();
const button = event.currentTarget;
switch (button.dataset.action) {
case "configure":
game.settings.sheet.render(true);
break;
case "modules":
new ModuleManagement().render(true);
break;
case "world":
new WorldConfig(game.world).render(true);
break;
case "players":
return ui.menu.items.players.onClick();
case "setup":
return game.shutDown();
case "support":
new SupportDetails().render(true);
break;
case "controls":
new KeybindingsConfig().render(true);
break;
case "tours":
new ToursManagement().render(true);
break;
case "docs":
new FrameViewer("https://foundryvtt.com/kb", {
title: "SIDEBAR.Documentation"
}).render(true);
break;
case "wiki":
new FrameViewer("https://foundryvtt.wiki/", {
title: "SIDEBAR.Wiki"
}).render(true);
break;
case "invitations":
new InvitationLinks().render(true);
break;
case "logout":
return ui.menu.items.logout.onClick();
}
}
/* -------------------------------------------- */
/**
* Executes with the update notification pip is clicked
* @param {MouseEvent} event The originating click event
* @private
*/
_onUpdateNotificationClick(event) {
event.preventDefault();
const key = event.target.dataset.action === "core-update" ? "CoreUpdateInstructions" : "SystemUpdateInstructions";
ui.notifications.notify(game.i18n.localize(`SETUP.${key}`));
}
}
/* -------------------------------------------- */
/**
* A simple window application which shows the built documentation pages within an iframe
* @type {Application}
*/
class FrameViewer extends Application {
constructor(url, options) {
super(options);
this.url = url;
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
const options = super.defaultOptions;
const h = window.innerHeight * 0.9;
const w = Math.min(window.innerWidth * 0.9, 1200);
options.height = h;
options.width = w;
options.top = (window.innerHeight - h) / 2;
options.left = (window.innerWidth - w) / 2;
options.id = "documentation";
options.template = "templates/apps/documentation.html";
return options;
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
return {
src: this.url
};
}
/* -------------------------------------------- */
/** @override */
async close(options) {
this.element.find("#docs").remove();
return super.close(options);
}
}
/**
* An interface for an Audio/Video client which is extended to provide broadcasting functionality.
* @interface
* @param {AVMaster} master The master orchestration instance
* @param {AVSettings} settings The audio/video settings being used
*/
class AVClient {
constructor(master, settings) {
/**
* The master orchestration instance
* @type {AVMaster}
*/
this.master = master;
/**
* The active audio/video settings being used
* @type {AVSettings}
*/
this.settings = settings;
}
/* -------------------------------------------- */
/**
* Is audio broadcasting push-to-talk enabled?
* @returns {boolean}
*/
get isVoicePTT() {
return this.settings.client.voice.mode === "ptt";
}
/**
* Is audio broadcasting always enabled?
* @returns {boolean}
*/
get isVoiceAlways() {
return this.settings.client.voice.mode === "always";
}
/**
* Is audio broadcasting voice-activation enabled?
* @returns {boolean}
*/
get isVoiceActivated() {
return this.settings.client.voice.mode === "activity";
}
/**
* Is the current user muted?
* @returns {boolean}
*/
get isMuted() {
return this.settings.client.users[game.user.id]?.muted;
}
/* -------------------------------------------- */
/* Connection */
/* -------------------------------------------- */
/**
* One-time initialization actions that should be performed for this client implementation.
* This will be called only once when the Game object is first set-up.
* @returns {Promise<void>}
*/
async initialize() {
throw Error("The initialize() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Connect to any servers or services needed in order to provide audio/video functionality.
* Any parameters needed in order to establish the connection should be drawn from the settings object.
* This function should return a boolean for whether the connection attempt was successful.
* @returns {Promise<boolean>} Was the connection attempt successful?
*/
async connect() {
throw Error("The connect() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Disconnect from any servers or services which are used to provide audio/video functionality.
* This function should return a boolean for whether a valid disconnection occurred.
* @returns {Promise<boolean>} Did a disconnection occur?
*/
async disconnect() {
throw Error("The disconnect() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/* Device Discovery */
/* -------------------------------------------- */
/**
* Provide an Object of available audio sources which can be used by this implementation.
* Each object key should be a device id and the key should be a human-readable label.
* @returns {Promise<{object}>}
*/
async getAudioSinks() {
return this._getSourcesOfType("audiooutput");
}
/* -------------------------------------------- */
/**
* Provide an Object of available audio sources which can be used by this implementation.
* Each object key should be a device id and the key should be a human-readable label.
* @returns {Promise<{object}>}
*/
async getAudioSources() {
return this._getSourcesOfType("audioinput");
}
/* -------------------------------------------- */
/**
* Provide an Object of available video sources which can be used by this implementation.
* Each object key should be a device id and the key should be a human-readable label.
* @returns {Promise<{object}>}
*/
async getVideoSources() {
return this._getSourcesOfType("videoinput");
}
/* -------------------------------------------- */
/**
* Obtain a mapping of available device sources for a given type.
* @param {string} kind The type of device source being requested
* @returns {Promise<{object}>}
* @private
*/
async _getSourcesOfType(kind) {
if ( !("mediaDevices" in navigator) ) return {};
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.reduce((obj, device) => {
if ( device.kind === kind ) {
obj[device.deviceId] = device.label || game.i18n.localize("WEBRTC.UnknownDevice");
}
return obj;
}, {});
}
/* -------------------------------------------- */
/* Track Manipulation */
/* -------------------------------------------- */
/**
* Return an array of Foundry User IDs which are currently connected to A/V.
* The current user should also be included as a connected user in addition to all peers.
* @returns {string[]} The connected User IDs
*/
getConnectedUsers() {
throw Error("The getConnectedUsers() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Provide a MediaStream instance for a given user ID
* @param {string} userId The User id
* @returns {MediaStream|null} The MediaStream for the user, or null if the user does not have one
*/
getMediaStreamForUser(userId) {
throw Error("The getMediaStreamForUser() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Provide a MediaStream for monitoring a given user's voice volume levels.
* @param {string} userId The User ID.
* @returns {MediaStream|null} The MediaStream for the user, or null if the user does not have one.
*/
getLevelsStreamForUser(userId) {
throw new Error("An AVClient subclass must define the getLevelsStreamForUser method");
}
/* -------------------------------------------- */
/**
* Is outbound audio enabled for the current user?
* @returns {boolean}
*/
isAudioEnabled() {
throw Error("The isAudioEnabled() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Is outbound video enabled for the current user?
* @returns {boolean}
*/
isVideoEnabled() {
throw Error("The isVideoEnabled() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Set whether the outbound audio feed for the current game user is enabled.
* This method should be used when the user marks themselves as muted or if the gamemaster globally mutes them.
* @param {boolean} enable Whether the outbound audio track should be enabled (true) or disabled (false)
*/
toggleAudio(enable) {
throw Error("The toggleAudio() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Set whether the outbound audio feed for the current game user is actively broadcasting.
* This can only be true if audio is enabled, but may be false if using push-to-talk or voice activation modes.
* @param {boolean} broadcast Whether outbound audio should be sent to connected peers or not?
*/
toggleBroadcast(broadcast) {
throw Error("The toggleBroadcast() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Set whether the outbound video feed for the current game user is enabled.
* This method should be used when the user marks themselves as hidden or if the gamemaster globally hides them.
* @param {boolean} enable Whether the outbound video track should be enabled (true) or disabled (false)
*/
toggleVideo(enable) {
throw Error("The toggleVideo() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Set the Video Track for a given User ID to a provided VideoElement
* @param {string} userId The User ID to set to the element
* @param {HTMLVideoElement} videoElement The HTMLVideoElement to which the video should be set
*/
async setUserVideo(userId, videoElement) {
throw Error("The setUserVideo() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/* Settings and Configuration */
/* -------------------------------------------- */
/**
* Handle changes to A/V configuration settings.
* @param {object} changed The settings which have changed
*/
onSettingsChanged(changed) {}
/* -------------------------------------------- */
/**
* Replace the local stream for each connected peer with a re-generated MediaStream.
*/
async updateLocalStream() {
throw Error("The updateLocalStream() method must be defined by an AVClient subclass.");
}
}
/**
* The master Audio/Video controller instance.
* This is available as the singleton game.webrtc
*
* @param {AVSettings} settings The Audio/Video settings to use
*/
class AVMaster {
constructor() {
this.settings = new AVSettings();
this.config = new AVConfig(this);
/**
* The Audio/Video client class
* @type {AVClient}
*/
this.client = new CONFIG.WebRTC.clientClass(this, this.settings);
/**
* A flag to track whether the current user is actively broadcasting their microphone.
* @type {boolean}
*/
this.broadcasting = false;
/**
* Flag to determine if we are connected to the signalling server or not.
* This is required for synchronization between connection and reconnection attempts.
* @type {boolean}
*/
this._connected = false;
/**
* The cached connection promise.
* This is required to prevent re-triggering a connection while one is already in progress.
* @type {Promise<boolean>|null}
* @private
*/
this._connecting = null;
/**
* A flag to track whether the A/V system is currently in the process of reconnecting.
* This occurs if the connection is lost or interrupted.
* @type {boolean}
* @private
*/
this._reconnecting = false;
// Other internal flags
this._speakingData = {speaking: false, volumeHistories: []};
this._pttMuteTimeout = 0;
}
/* -------------------------------------------- */
get mode() {
return this.settings.world.mode;
}
/* -------------------------------------------- */
/* Initialization */
/* -------------------------------------------- */
/**
* Connect to the Audio/Video client.
* @return {Promise<boolean>} Was the connection attempt successful?
*/
async connect() {
if ( this._connecting ) return this._connecting;
const connect = async () => {
// Disconnect from any existing session
await this.disconnect();
// Activate the connection
if ( this.mode === AVSettings.AV_MODES.DISABLED ) return false;
// Initialize Client state
await this.client.initialize();
// Connect to the client
const connected = await this.client.connect();
if ( !connected ) return false;
console.log(`${vtt} | Connected to the ${this.client.constructor.name} Audio/Video client.`);
// Initialize local broadcasting
this._initialize();
return this._connected = connected;
};
return this._connecting = connect().finally(() => this._connecting = null);
}
/* -------------------------------------------- */
/**
* Disconnect from the Audio/Video client.
* @return {Promise<boolean>} Whether an existing connection was terminated?
*/
async disconnect() {
if ( !this._connected ) return false;
this._connected = this._reconnecting = false;
await this.client.disconnect();
console.log(`${vtt} | Disconnected from the ${this.client.constructor.name} Audio/Video client.`);
return true;
}
/* -------------------------------------------- */
/**
* Callback actions to take when the user becomes disconnected from the server.
* @return {Promise<void>}
*/
async reestablish() {
if ( !this._connected ) return;
ui.notifications.warn("WEBRTC.ConnectionLostWarning", {localize: true});
await this.disconnect();
// Attempt to reconnect
while ( this._reconnecting ) {
await this.connect();
if ( this._connected ) {
this._reconnecting = true;
break;
}
await new Promise(resolve => setTimeout(resolve, this._reconnectPeriodMS));
}
}
/* -------------------------------------------- */
/**
* Initialize the local broadcast state.
* @private
*/
_initialize() {
const client = this.settings.client;
const voiceMode = client.voice.mode;
// Initialize voice detection
this._initializeUserVoiceDetection(voiceMode);
// Reset the speaking history for the user
this._resetSpeakingHistory(game.user.id);
// Set the initial state of outbound audio and video streams
const isAlways = voiceMode === "always";
this.client.toggleAudio(isAlways && client.audioSrc && this.canUserShareAudio(game.user.id));
this.client.toggleVideo(client.videoSrc && this.canUserShareVideo(game.user.id));
this.broadcast(isAlways);
// Update the display of connected A/V
ui.webrtc.render();
}
/* -------------------------------------------- */
/* Permissions */
/* -------------------------------------------- */
/**
* A user can broadcast audio if the AV mode is compatible and if they are allowed to broadcast.
* @param {string} userId
* @return {boolean}
*/
canUserBroadcastAudio(userId) {
if ( [AVSettings.AV_MODES.DISABLED, AVSettings.AV_MODES.VIDEO].includes(this.mode) ) return false;
const user = this.settings.getUser(userId);
return user && user.canBroadcastAudio;
}
/* -------------------------------------------- */
/**
* A user can share audio if they are allowed to broadcast and if they have not muted themselves or been blocked.
* @param {string} userId
* @return {boolean}
*/
canUserShareAudio(userId) {
if ( [AVSettings.AV_MODES.DISABLED, AVSettings.AV_MODES.VIDEO].includes(this.mode) ) return false;
const user = this.settings.getUser(userId);
return user && user.canBroadcastAudio && !(user.muted || user.blocked);
}
/* -------------------------------------------- */
/**
* A user can broadcast video if the AV mode is compatible and if they are allowed to broadcast.
* @param {string} userId
* @return {boolean}
*/
canUserBroadcastVideo(userId) {
if ( [AVSettings.AV_MODES.DISABLED, AVSettings.AV_MODES.AUDIO].includes(this.mode) ) return false;
const user = this.settings.getUser(userId);
return user && user.canBroadcastVideo;
}
/* -------------------------------------------- */
/**
* A user can share video if they are allowed to broadcast and if they have not hidden themselves or been blocked.
* @param {string} userId
* @return {boolean}
*/
canUserShareVideo(userId) {
if ( [AVSettings.AV_MODES.DISABLED, AVSettings.AV_MODES.AUDIO].includes(this.mode) ) return false;
const user = this.settings.getUser(userId);
return user && user.canBroadcastVideo && !(user.hidden || user.blocked);
}
/* -------------------------------------------- */
/* Broadcasting */
/* -------------------------------------------- */
/**
* Trigger a change in the audio broadcasting state when using a push-to-talk workflow.
* @param {boolean} intent The user's intent to broadcast. Whether an actual broadcast occurs will depend
* on whether or not the user has muted their audio feed.
*/
broadcast(intent) {
this.broadcasting = intent && this.canUserShareAudio(game.user.id);
this.client.toggleBroadcast(this.broadcasting);
const activity = this.settings.activity[game.user.id];
if ( activity.speaking !== this.broadcasting ) game.user.broadcastActivity({av: {speaking: this.broadcasting}});
activity.speaking = this.broadcasting;
return ui.webrtc.setUserIsSpeaking(game.user.id, this.broadcasting);
}
/* -------------------------------------------- */
/**
* Set up audio level listeners to handle voice activation detection workflow.
* @param {string} mode The currently selected voice broadcasting mode
* @private
*/
_initializeUserVoiceDetection(mode) {
// Deactivate prior detection
game.audio.stopLevelReports(game.user.id);
if ( !["always", "activity"].includes(mode) ) return;
// Activate voice level detection for always-on and activity-based broadcasting
const stream = this.client.getLevelsStreamForUser(game.user.id);
const ms = mode === "activity" ? CONFIG.WebRTC.detectSelfVolumeInterval : CONFIG.WebRTC.detectPeerVolumeInterval;
this.activateVoiceDetection(stream, ms);
}
/* -------------------------------------------- */
/**
* Activate voice detection tracking for a userId on a provided MediaStream.
* Currently only a MediaStream is supported because MediaStreamTrack processing is not yet supported cross-browser.
* @param {MediaStream} stream The MediaStream which corresponds to that User
* @param {number} [ms] A number of milliseconds which represents the voice activation volume interval
*/
activateVoiceDetection(stream, ms) {
this.deactivateVoiceDetection();
if ( !stream || !stream.getAudioTracks().some(t => t.enabled) ) return;
ms = ms || CONFIG.WebRTC.detectPeerVolumeInterval;
const handler = this._onAudioLevel.bind(this);
game.audio.startLevelReports(game.userId, stream, handler, ms);
}
/* -------------------------------------------- */
/**
* Actions which the orchestration layer should take when a peer user disconnects from the audio/video service.
*/
deactivateVoiceDetection() {
this._resetSpeakingHistory();
game.audio.stopLevelReports(game.userId);
}
/* -------------------------------------------- */
/**
* Periodic notification of user audio level
*
* This function uses the audio level (in dB) of the audio stream to determine if the user is speaking or not and
* notifies the UI of such changes.
*
* The User is considered speaking if they are above the decibel threshold in any of the history values.
* This marks them as speaking as soon as they have a high enough volume, and marks them as not speaking only after
* they drop below the threshold in all histories (last 4 volumes = for 200 ms).
*
* There can be more optimal ways to do this and which uses whether the user was already considered speaking before
* or not, in order to eliminate short bursts of audio (coughing for example).
*
* @param {number} dbLevel The audio level in decibels of the user within the last 50ms
* @private
*/
_onAudioLevel(dbLevel) {
const voice = this.settings.client.voice;
const speakingData = this._speakingData;
const wasSpeaking = speakingData.speaking;
// Add the current volume to the history of the user and keep the list below the history length config.
if (speakingData.volumeHistories.push(dbLevel) > CONFIG.WebRTC.speakingHistoryLength) {
speakingData.volumeHistories.shift();
}
// Count the number and total decibels of speaking events which exceed an activity threshold
const [count, max, total] = speakingData.volumeHistories.reduce((totals, vol) => {
if ( vol >= voice.activityThreshold ) {
totals[0] += 1;
totals[1] = Math.min(totals[1], vol);
totals[2] += vol;
}
return totals;
}, [0, 0, 0]);
// The user is classified as currently speaking if they exceed a certain threshold of speaking events
const isSpeaking = (count > (wasSpeaking ? 0 : CONFIG.WebRTC.speakingThresholdEvents)) && !this.client.isMuted;
speakingData.speaking = isSpeaking;
// Take further action when a change in the speaking state has occurred
if ( isSpeaking === wasSpeaking ) return;
if ( this.client.isVoiceActivated ) return this.broadcast(isSpeaking); // Declare broadcast intent
}
/* -------------------------------------------- */
/* Push-To-Talk Controls */
/* -------------------------------------------- */
/**
* Resets the speaking history of a user
* If the user was considered speaking, then mark them as not speaking
*/
_resetSpeakingHistory() {
if ( ui.webrtc ) ui.webrtc.setUserIsSpeaking(game.userId, false);
this._speakingData.speaking = false;
this._speakingData.volumeHistories = [];
}
/* -------------------------------------------- */
/**
* Handle activation of a push-to-talk key or button.
* @param {KeyboardEventContext} context The context data of the event
*/
_onPTTStart(context) {
if ( !this._connected ) return false;
const voice = this.settings.client.voice;
// Case 1: Push-to-Talk (begin broadcasting immediately)
if ( voice.mode === "ptt" ) {
if (this._pttMuteTimeout > 0) clearTimeout(this._pttMuteTimeout);
this._pttMuteTimeout = 0;
this.broadcast(true);
}
// Case 2: Push-to-Mute (disable broadcasting on a timeout)
else this._pttMuteTimeout = setTimeout(() => this.broadcast(false), voice.pttDelay);
return true;
}
/* -------------------------------------------- */
/**
* Handle deactivation of a push-to-talk key or button.
* @param {KeyboardEventContext} context The context data of the event
*/
_onPTTEnd(context) {
if ( !this._connected ) return false;
const voice = this.settings.client.voice;
// Case 1: Push-to-Talk (disable broadcasting on a timeout)
if ( voice.mode === "ptt" ) {
this._pttMuteTimeout = setTimeout(() => this.broadcast(false), voice.pttDelay);
}
// Case 2: Push-to-Mute (re-enable broadcasting immediately)
else {
if (this._pttMuteTimeout > 0) clearTimeout(this._pttMuteTimeout);
this._pttMuteTimeout = 0;
this.broadcast(true);
}
return true;
}
/* -------------------------------------------- */
/* User Interface Controls */
/* -------------------------------------------- */
render() {
return ui.webrtc.render();
}
/* -------------------------------------------- */
/**
* Render the audio/video streams to the CameraViews UI.
* Assign each connected user to the correct video frame element.
*/
onRender() {
const users = this.client.getConnectedUsers();
for ( let u of users ) {
const videoElement = ui.webrtc.getUserVideoElement(u);
if ( !videoElement ) continue;
const isSpeaking = this.settings.activity[u]?.speaking || false;
this.client.setUserVideo(u, videoElement);
ui.webrtc.setUserIsSpeaking(u, isSpeaking);
}
// Determine the players list position based on the user's settings.
const dockPositions = AVSettings.DOCK_POSITIONS;
const isAfter = [dockPositions.RIGHT, dockPositions.BOTTOM].includes(this.settings.client.dockPosition);
const iface = document.getElementById("interface");
const cameraViews = ui.webrtc.element[0];
ui.players.render(true);
if ( this.settings.client.hideDock || ui.webrtc.hidden ) {
cameraViews?.style.removeProperty("width");
cameraViews?.style.removeProperty("height");
}
document.body.classList.toggle("av-horizontal-dock", !this.settings.verticalDock);
// Change the dock position based on the user's settings.
if ( cameraViews ) {
if ( isAfter && (iface.nextElementSibling !== cameraViews) ) document.body.insertBefore(iface, cameraViews);
else if ( !isAfter && (cameraViews.nextElementSibling !== iface) ) document.body.insertBefore(cameraViews, iface);
}
}
/* -------------------------------------------- */
/* Events Handlers and Callbacks */
/* -------------------------------------------- */
/**
* Respond to changes which occur to AV Settings.
* Changes are handled in descending order of impact.
* @param {object} changed The object of changed AV settings
*/
onSettingsChanged(changed) {
const keys = Object.keys(flattenObject(changed));
// Change the server configuration (full AV re-connection)
if ( keys.includes("world.turn") ) return this.connect();
// Change audio and video visibility at a user level
const sharing = getProperty(changed, `client.users.${game.userId}`) || {};
if ( "hidden" in sharing ) this.client.toggleVideo(this.canUserShareVideo(game.userId));
if ( "muted" in sharing ) this.client.toggleAudio(this.canUserShareAudio(game.userId));
// Restore stored dock width when switching to a vertical dock position.
const isVertical =
[AVSettings.DOCK_POSITIONS.LEFT, AVSettings.DOCK_POSITIONS.RIGHT].includes(changed.client?.dockPosition);
const dockWidth = changed.client?.dockWidth ?? this.settings.client.dockWidth ?? 240;
if ( isVertical ) ui.webrtc.position.width = dockWidth;
// Switch resize direction if docked to the right.
if ( keys.includes("client.dockPosition") ) {
ui.webrtc.options.resizable.rtl = changed.client.dockPosition === AVSettings.DOCK_POSITIONS.RIGHT;
}
// Requires re-render.
const rerender = ["client.borderColors", "client.dockPosition", "client.nameplates"].some(k => keys.includes(k));
if ( rerender ) ui.webrtc.render(true);
// Call client specific setting handling
this.client.onSettingsChanged(changed);
}
/* -------------------------------------------- */
debug(message) {
if ( this.settings.debug ) console.debug(message);
}
}
/**
* @typedef {object} AVSettingsData
* @property {boolean} [muted] Whether this user has muted themselves.
* @property {boolean} [hidden] Whether this user has hidden their video.
* @property {boolean} [speaking] Whether the user is broadcasting audio.
*/
class AVSettings {
constructor() {
this.initialize();
this._set = debounce((key, value) => game.settings.set("core", key, value), 100);
this._change = debounce(this._onSettingsChanged.bind(this), 100);
this.activity[game.userId] = {};
}
/* -------------------------------------------- */
/**
* WebRTC Mode, Disabled, Audio only, Video only, Audio & Video
* @enum {number}
*/
static AV_MODES = {
DISABLED: 0,
AUDIO: 1,
VIDEO: 2,
AUDIO_VIDEO: 3
};
/* -------------------------------------------- */
/**
* Voice modes: Always-broadcasting, voice-level triggered, push-to-talk.
* @enum {string}
*/
static VOICE_MODES = {
ALWAYS: "always",
ACTIVITY: "activity",
PTT: "ptt"
};
/* -------------------------------------------- */
/**
* Displayed nameplate options: Off entirely, animate between player and character name, player name only, character
* name only.
* @enum {number}
*/
static NAMEPLATE_MODES = {
OFF: 0,
BOTH: 1,
PLAYER_ONLY: 2,
CHAR_ONLY: 3
};
/* -------------------------------------------- */
/**
* AV dock positions.
* @enum {string}
*/
static DOCK_POSITIONS = {
TOP: "top",
RIGHT: "right",
BOTTOM: "bottom",
LEFT: "left"
};
/* -------------------------------------------- */
/**
* Default client AV settings.
* @type {object}
*/
static DEFAULT_CLIENT_SETTINGS = {
videoSrc: "default",
audioSrc: "default",
audioSink: "default",
dockPosition: AVSettings.DOCK_POSITIONS.LEFT,
hidePlayerList: false,
hideDock: false,
muteAll: false,
disableVideo: false,
borderColors: false,
dockWidth: 240,
nameplates: AVSettings.NAMEPLATE_MODES.BOTH,
voice: {
mode: AVSettings.VOICE_MODES.PTT,
pttName: "`",
pttDelay: 100,
activityThreshold: -45
},
users: {}
};
/* -------------------------------------------- */
/**
* Default world-level AV settings.
* @type {object}
*/
static DEFAULT_WORLD_SETTINGS = {
mode: AVSettings.AV_MODES.DISABLED,
turn: {
type: "server",
url: "",
username: "",
password: ""
}
};
/* -------------------------------------------- */
/**
* Default client settings for each connected user.
* @type {object}
*/
static DEFAULT_USER_SETTINGS = {
popout: false,
x: 100,
y: 100,
z: 0,
width: 320,
volume: 1.0,
muted: false,
hidden: false,
blocked: false
};
/* -------------------------------------------- */
/**
* Stores the transient AV activity data received from other users.
* @type {Object<string, AVSettingsData>}
*/
activity = {};
/* -------------------------------------------- */
initialize() {
this.client = game.settings.get("core", "rtcClientSettings");
this.world = game.settings.get("core", "rtcWorldSettings");
this._original = foundry.utils.deepClone({client: this.client, world: this.world});
const {muted, hidden} = this._getUserSettings(game.user);
game.user.broadcastActivity({av: {muted, hidden}});
}
/* -------------------------------------------- */
changed() {
return this._change();
}
/* -------------------------------------------- */
get(scope, setting) {
return getProperty(this[scope], setting);
}
/* -------------------------------------------- */
getUser(userId) {
const user = game.users.get(userId);
if ( !user ) return null;
return this._getUserSettings(user);
}
/* -------------------------------------------- */
set(scope, setting, value) {
setProperty(this[scope], setting, value);
this._set(`rtc${scope.titleCase()}Settings`, this[scope]);
}
/* -------------------------------------------- */
/**
* Return a mapping of AV settings for each game User.
* @type {object}
*/
get users() {
const users = {};
for ( let u of game.users ) {
users[u.id] = this._getUserSettings(u);
}
return users;
}
/* -------------------------------------------- */
/**
* A helper to determine if the dock is configured in a vertical position.
*/
get verticalDock() {
const positions = this.constructor.DOCK_POSITIONS;
return [positions.LEFT, positions.RIGHT].includes(this.client.dockPosition ?? positions.LEFT);
}
/* -------------------------------------------- */
/**
* Prepare a standardized object of user settings data for a single User
* @private
*/
_getUserSettings(user) {
const clientSettings = this.client.users[user.id] || {};
const activity = this.activity[user.id] || {};
const settings = foundry.utils.mergeObject(AVSettings.DEFAULT_USER_SETTINGS, clientSettings, {inplace: false});
settings.canBroadcastAudio = user.can("BROADCAST_AUDIO");
settings.canBroadcastVideo = user.can("BROADCAST_VIDEO");
if ( user.isSelf ) {
settings.muted ||= !game.webrtc?.client.isAudioEnabled();
settings.hidden ||= !game.webrtc?.client.isVideoEnabled();
} else {
// Either we have muted or hidden them, or they have muted or hidden themselves.
settings.muted ||= !!activity.muted;
settings.hidden ||= !!activity.hidden;
}
settings.speaking = activity.speaking;
return settings;
}
/* -------------------------------------------- */
/**
* Handle setting changes to either rctClientSettings or rtcWorldSettings.
* @private
*/
_onSettingsChanged() {
const original = this._original;
this.initialize();
const changed = foundry.utils.diffObject(original, this._original);
game.webrtc.onSettingsChanged(changed);
Hooks.callAll("rtcSettingsChanged", this, changed);
}
/* -------------------------------------------- */
/**
* Handle another connected user changing their AV settings.
* @param {string} userId
* @param {AVSettingsData} settings
*/
handleUserActivity(userId, settings) {
const current = this.activity[userId] || {};
this.activity[userId] = foundry.utils.mergeObject(current, settings, {inplace: false});
if ( !ui.webrtc ) return;
const hiddenChanged = ("hidden" in settings) && (current.hidden !== settings.hidden);
const mutedChanged = ("muted" in settings) && (current.muted !== settings.muted);
if ( (hiddenChanged || mutedChanged) && ui.webrtc.getUserVideoElement(userId) ) ui.webrtc._refreshView(userId);
if ( "speaking" in settings ) ui.webrtc.setUserIsSpeaking(userId, settings.speaking);
}
}
/**
* An implementation of the AVClient which uses the simple-peer library and the Foundry socket server for signaling.
* Credit to bekit#4213 for identifying simple-peer as a viable technology and providing a POC implementation.
* @extends {AVClient}
*/
class SimplePeerAVClient extends AVClient {
/**
* The local Stream which captures input video and audio
* @type {MediaStream}
*/
localStream = null;
/**
* The dedicated audio stream used to measure volume levels for voice activity detection.
* @type {MediaStream}
*/
levelsStream = null;
/**
* A mapping of connected peers
* @type {Map}
*/
peers = new Map();
/**
* A mapping of connected remote streams
* @type {Map}
*/
remoteStreams = new Map();
/**
* Has the client been successfully initialized?
* @type {boolean}
* @private
*/
_initialized = false;
/**
* Is outbound broadcast of local audio enabled?
* @type {boolean}
*/
audioBroadcastEnabled = false;
/**
* The polling interval ID for connected users that might have unexpectedly dropped out of our peer network.
* @type {number|null}
*/
_connectionPoll = null;
/* -------------------------------------------- */
/* Required AVClient Methods */
/* -------------------------------------------- */
/** @override */
async connect() {
await this._connect();
clearInterval(this._connectionPoll);
this._connectionPoll = setInterval(this._connect.bind(this), CONFIG.WebRTC.connectedUserPollIntervalS * 1000);
return true;
}
/* -------------------------------------------- */
/**
* Try to establish a peer connection with each user connected to the server.
* @private
*/
_connect() {
const promises = [];
for ( let user of game.users ) {
if ( user.isSelf || !user.active ) continue;
promises.push(this.initializePeerStream(user.id));
}
return Promise.all(promises);
}
/* -------------------------------------------- */
/** @override */
async disconnect() {
clearInterval(this._connectionPoll);
this._connectionPoll = null;
await this.disconnectAll();
return true;
}
/* -------------------------------------------- */
/** @override */
async initialize() {
if ( this._initialized ) return;
console.debug(`Initializing SimplePeer client connection`);
// Initialize the local stream
await this.initializeLocalStream();
// Set up socket listeners
this.activateSocketListeners();
// Register callback to close peer connections when the window is closed
window.addEventListener("beforeunload", ev => this.disconnectAll());
// Flag the client as initialized
this._initialized = true;
}
/* -------------------------------------------- */
/** @override */
getConnectedUsers() {
return [...Array.from(this.peers.keys()), game.userId];
}
/* -------------------------------------------- */
/** @override */
getMediaStreamForUser(userId) {
return userId === game.user.id ? this.localStream : this.remoteStreams.get(userId);
}
/* -------------------------------------------- */
/** @override */
getLevelsStreamForUser(userId) {
return userId === game.userId ? this.levelsStream : this.getMediaStreamForUser(userId);
}
/* -------------------------------------------- */
/** @override */
isAudioEnabled() {
return !!this.localStream?.getAudioTracks().length;
}
/* -------------------------------------------- */
/** @override */
isVideoEnabled() {
return !!this.localStream?.getVideoTracks().length;
}
/* -------------------------------------------- */
/** @override */
toggleAudio(enabled) {
const stream = this.localStream;
if ( !stream ) return;
// If "always on" broadcasting is not enabled, don't proceed
if ( !this.audioBroadcastEnabled || this.isVoicePTT ) return;
// Enable active broadcasting
return this.toggleBroadcast(enabled);
}
/* -------------------------------------------- */
/** @override */
toggleBroadcast(enabled) {
const stream = this.localStream;
if ( !stream ) return;
console.debug(`[SimplePeer] Toggling broadcast of outbound audio: ${enabled}`);
this.audioBroadcastEnabled = enabled;
for ( let t of stream.getAudioTracks() ) {
t.enabled = enabled;
}
}
/* -------------------------------------------- */
/** @override */
toggleVideo(enabled) {
const stream = this.localStream;
if ( !stream ) return;
console.debug(`[SimplePeer] Toggling broadcast of outbound video: ${enabled}`);
for (const track of stream.getVideoTracks()) {
track.enabled = enabled;
}
}
/* -------------------------------------------- */
/** @override */
async setUserVideo(userId, videoElement) {
const stream = this.getMediaStreamForUser(userId);
// Set the stream as the video element source
if ("srcObject" in videoElement) videoElement.srcObject = stream;
else videoElement.src = window.URL.createObjectURL(stream); // for older browsers
// Forward volume to the configured audio sink
if ( videoElement.sinkId === undefined ) {
return console.warn(`[SimplePeer] Your web browser does not support output audio sink selection`);
}
const requestedSink = this.settings.get("client", "audioSink");
await videoElement.setSinkId(requestedSink).catch(err => {
console.warn(`[SimplePeer] An error occurred when requesting the output audio device: ${requestedSink}`);
})
}
/* -------------------------------------------- */
/* Local Stream Management */
/* -------------------------------------------- */
/**
* Initialize a local media stream for the current user
* @returns {Promise<MediaStream>}
*/
async initializeLocalStream() {
console.debug(`[SimplePeer] Initializing local media stream for current User`);
// If there is already an existing local media stream, terminate it
if ( this.localStream ) this.localStream.getTracks().forEach(t => t.stop());
this.localStream = null;
if ( this.levelsStream ) this.levelsStream.getTracks().forEach(t => t.stop());
this.levelsStream = null;
// Determine whether the user can send audio
const audioSrc = this.settings.get("client", "audioSrc");
const canBroadcastAudio = this.master.canUserBroadcastAudio(game.user.id);
const audioParams = (audioSrc && (audioSrc !== "disabled") && canBroadcastAudio) ? {
deviceId: { ideal: audioSrc }
} : false;
// Configure whether the user can send video
const videoSrc = this.settings.get("client", "videoSrc");
const canBroadcastVideo = this.master.canUserBroadcastVideo(game.user.id);
const videoParams = (videoSrc && (videoSrc !== "disabled") && canBroadcastVideo) ? {
deviceId: { ideal: videoSrc },
width: { ideal: 320 },
height: { ideal: 240 }
} : false;
// FIXME: Firefox does not allow you to request a specific device, you can only use whatever the browser allows
// https://bugzilla.mozilla.org/show_bug.cgi?id=1443294#c7
if ( navigator.userAgent.match(/Firefox/) ) {
delete videoParams["deviceId"];
}
if ( !videoParams && !audioParams ) return null;
let stream = await this._createMediaStream({video: videoParams, audio: audioParams});
if ( (videoParams && audioParams) && (stream instanceof Error) ) {
// Even if the game is set to both audio and video, the user may not have one of those devices, or they might have
// blocked access to one of them. In those cases we do not want to prevent A/V loading entirely, so we must try
// each of them separately to see what is available.
if ( audioParams ) stream = await this._createMediaStream({video: false, audio: audioParams});
if ( (stream instanceof Error) && videoParams ) {
stream = await this._createMediaStream({video: videoParams, audio: false});
}
}
if ( stream instanceof Error ) {
const error = new Error(`[SimplePeer] Unable to acquire user media stream: ${stream.message}`);
error.stack = stream.stack;
console.error(error);
return null;
}
this.localStream = stream;
this.levelsStream = stream.clone();
this.levelsStream.getVideoTracks().forEach(t => this.levelsStream.removeTrack(t));
return stream;
}
/* -------------------------------------------- */
/**
* Attempt to create local media streams.
* @param {{video: object, audio: object}} params Parameters for the getUserMedia request.
* @returns {Promise<MediaStream|Error>} The created MediaStream or an error.
* @private
*/
async _createMediaStream(params) {
try {
return await navigator.mediaDevices.getUserMedia(params);
} catch(err) {
return err;
}
}
/* -------------------------------------------- */
/* Peer Stream Management */
/* -------------------------------------------- */
/**
* Listen for Audio/Video updates on the av socket to broker connections between peers
*/
activateSocketListeners() {
game.socket.on("av", (request, userId) => {
if ( request.userId !== game.user.id ) return; // The request is not for us, this shouldn't happen
switch ( request.action ) {
case "peer-signal":
if ( request.activity ) this.master.settings.handleUserActivity(userId, request.activity);
return this.receiveSignal(userId, request.data);
case "peer-close":
return this.disconnectPeer(userId);
}
});
}
/* -------------------------------------------- */
/**
* Initialize a stream connection with a new peer
* @param {string} userId The Foundry user ID for which the peer stream should be established
* @returns {Promise<SimplePeer>} A Promise which resolves once the peer stream is initialized
*/
async initializePeerStream(userId) {
const peer = this.peers.get(userId);
if ( peer?.connected || peer?._connecting ) return peer;
return this.connectPeer(userId, true);
}
/* -------------------------------------------- */
/**
* Receive a request to establish a peer signal with some other User id
* @param {string} userId The Foundry user ID who is requesting to establish a connection
* @param {object} data The connection details provided by SimplePeer
*/
receiveSignal(userId, data) {
console.debug(`[SimplePeer] Receiving signal from User [${userId}] to establish initial connection`);
let peer = this.peers.get(userId);
if ( !peer ) peer = this.connectPeer(userId, false);
peer.signal(data);
}
/* -------------------------------------------- */
/**
* Connect to a peer directly, either as the initiator or as the receiver
* @param {string} userId The Foundry user ID with whom we are connecting
* @param {boolean} isInitiator Is the current user initiating the connection, or responding to it?
* @returns {SimplePeer} The constructed and configured SimplePeer instance
*/
connectPeer(userId, isInitiator=false) {
// Create the SimplePeer instance for this connection
const peer = this._createPeerConnection(userId, isInitiator);
this.peers.set(userId, peer);
// Signal to request that a remote user establish a connection with us
peer.on("signal", data => {
console.debug(`[SimplePeer] Sending signal to User [${userId}] to establish initial connection`);
game.socket.emit("av", {
action: "peer-signal",
userId: userId,
data: data,
activity: this.master.settings.getUser(game.userId)
}, {recipients: [userId]});
});
// Receive a stream provided by a peer
peer.on("stream", stream => {
console.debug(`[SimplePeer] Received media stream from User [${userId}]`);
this.remoteStreams.set(userId, stream);
this.master.render();
});
// Close a connection with a current peer
peer.on("close", () => {
console.debug(`[SimplePeer] Closed connection with remote User [${userId}]`);
return this.disconnectPeer(userId);
});
// Handle errors
peer.on("error", err => {
if ( err.code !== "ERR_DATA_CHANNEL" ) {
const error = new Error(`[SimplePeer] An unexpected error occurred with User [${userId}]: ${err.message}`);
error.stack = err.stack;
console.error(error);
}
if ( peer.connected ) return this.disconnectPeer(userId);
});
this.master.render();
return peer;
}
/* -------------------------------------------- */
/**
* Create the SimplePeer instance for the desired peer connection.
* Modules may implement more advanced connection strategies by overriding this method.
* @param {string} userId The Foundry user ID with whom we are connecting
* @param {boolean} isInitiator Is the current user initiating the connection, or responding to it?
* @private
*/
_createPeerConnection(userId, isInitiator) {
const options = {
initiator: isInitiator,
stream: this.localStream
};
this._setupCustomTURN(options);
return new SimplePeer(options);
}
/* -------------------------------------------- */
/**
* Setup the custom TURN relay to be used in subsequent calls if there is one configured.
* TURN credentials are mandatory in WebRTC.
* @param {object} options The SimplePeer configuration object.
* @private
*/
_setupCustomTURN(options) {
const { url, type, username, password } = this.settings.world.turn;
if ( (type !== "custom") || !url || !username || !password ) return;
const iceServer = { username, urls: url, credential: password };
options.config = { iceServers: [iceServer] };
}
/* -------------------------------------------- */
/**
* Disconnect from a peer by stopping current stream tracks and destroying the SimplePeer instance
* @param {string} userId The Foundry user ID from whom we are disconnecting
* @returns {Promise<void>} A Promise which resolves once the disconnection is complete
*/
async disconnectPeer(userId) {
// Stop audio and video tracks from the remote stream
const remoteStream = this.remoteStreams.get(userId);
if ( remoteStream ) {
this.remoteStreams.delete(userId);
for ( let track of remoteStream.getTracks() ) {
await track.stop();
}
}
// Remove the peer
const peer = this.peers.get(userId);
if ( peer ) {
this.peers.delete(userId);
await peer.destroy();
}
// Re-render the UI on disconnection
this.master.render();
}
/* -------------------------------------------- */
/**
* Disconnect from all current peer streams
* @returns {Promise<Array>} A Promise which resolves once all peers have been disconnected
*/
async disconnectAll() {
const promises = [];
for ( let userId of this.peers.keys() ) {
promises.push(this.disconnectPeer(userId));
}
return Promise.all(promises);
}
/* -------------------------------------------- */
/* Settings and Configuration */
/* -------------------------------------------- */
/** @override */
async onSettingsChanged(changed) {
const keys = new Set(Object.keys(foundry.utils.flattenObject(changed)));
// Change audio or video sources
const sourceChange = ["client.videoSrc", "client.audioSrc"].some(k => keys.has(k));
if ( sourceChange ) await this.updateLocalStream();
// Change voice broadcasting mode
const modeChange = ["client.voice.mode", `client.users.${game.user.id}.muted`].some(k => keys.has(k));
if ( modeChange ) {
const isAlways = this.settings.client.voice.mode === "always";
this.toggleAudio(isAlways && this.master.canUserShareAudio(game.user.id));
this.master.broadcast(isAlways);
this.master._initializeUserVoiceDetection(changed.client.voice?.mode);
ui.webrtc.setUserIsSpeaking(game.user.id, this.master.broadcasting);
}
// Re-render the AV camera view
const renderChange = ["client.audioSink", "client.muteAll", "client.disableVideo"].some(k => keys.has(k));
if ( sourceChange || renderChange ) this.master.render();
}
/* -------------------------------------------- */
/** @inheritdoc */
async updateLocalStream() {
const oldStream = this.localStream;
await this.initializeLocalStream();
for ( let peer of this.peers.values() ) {
if ( oldStream ) peer.removeStream(oldStream);
if ( this.localStream ) peer.addStream(this.localStream);
}
// FIXME: This is a cheat, should be handled elsewhere
this.master._initializeUserVoiceDetection(this.settings.client.voice.mode);
}
}
/**
* Runtime configuration settings for Foundry VTT which exposes a large number of variables which determine how
* aspects of the software behaves.
*
* Unlike the CONST analog which is frozen and immutable, the CONFIG object may be updated during the course of a
* session or modified by system and module developers to adjust how the application behaves.
*
* @type {object}
*/
const CONFIG = globalThis.CONFIG = {
/**
* Configure debugging flags to display additional information
*/
debug: {
combat: false,
dice: false,
documents: false,
fog: {
extractor: false,
manager: false
},
hooks: false,
av: false,
avclient: false,
mouseInteraction: false,
time: false,
keybindings: false,
polygons: false,
gamepad: false
},
/**
* Configure the verbosity of compatibility warnings generated throughout the software.
* The compatibility mode defines the logging level of any displayed warnings.
* The includePatterns and excludePatterns arrays provide a set of regular expressions which can either only
* include or specifically exclude certain file paths or warning messages.
* Exclusion rules take precedence over inclusion rules.
*
* @see {@link CONST.COMPATIBILITY_MODES}
* @type {{mode: number, includePatterns: RegExp[], excludePatterns: RegExp[]}}
*
* @example Include Specific Errors
* ```js
* const includeRgx = new RegExp("/systems/dnd5e/module/documents/active-effect.mjs");
* CONFIG.compatibility.includePatterns.push(includeRgx);
* ```
*
* @example Exclude Specific Errors
* ```js
* const excludeRgx = new RegExp("/systems/dnd5e/");
* CONFIG.compatibility.excludePatterns.push(excludeRgx);
* ```
*
* @example Both Include and Exclude
* ```js
* const includeRgx = new RegExp("/systems/dnd5e/module/actor/");
* const excludeRgx = new RegExp("/systems/dnd5e/module/actor/sheets/base.js");
* CONFIG.compatibility.includePatterns.push(includeRgx);
* CONFIG.compatibility.excludePatterns.push(excludeRgx);
* ```
*
* @example Targeting more than filenames
* ```js
* const includeRgx = new RegExp("applyActiveEffects");
* CONFIG.compatibility.includePatterns.push(includeRgx);
* ```
*/
compatibility: {
mode: CONST.COMPATIBILITY_MODES.WARNING,
includePatterns: [],
excludePatterns: []
},
/**
* Configure the DatabaseBackend used to perform Document operations
* @type {ClientDatabaseBackend}
*/
DatabaseBackend: new ClientDatabaseBackend(),
/**
* Configuration for the Actor document
*/
Actor: {
documentClass: Actor,
collection: Actors,
compendiumIndexFields: [],
compendiumBanner: "ui/banners/actor-banner.webp",
sidebarIcon: "fas fa-user",
dataModels: {},
typeLabels: {},
typeIcons: {},
trackableAttributes: {}
},
/**
* Configuration for the Adventure document.
* Currently for internal use only.
* @private
*/
Adventure: {
documentClass: Adventure,
compendiumIndexFields: [],
compendiumBanner: "ui/banners/adventure-banner.webp",
sidebarIcon: "fa-solid fa-treasure-chest"
},
/**
* Configuration for the Cards primary Document type
*/
Cards: {
collection: CardStacks,
compendiumIndexFields: [],
compendiumBanner: "ui/banners/cards-banner.webp",
documentClass: Cards,
sidebarIcon: "fa-solid fa-cards",
dataModels: {},
presets: {
pokerDark: {
type: "deck",
label: "CARDS.DeckPresetPokerDark",
src: "cards/poker-deck-dark.json"
},
pokerLight: {
type: "deck",
label: "CARDS.DeckPresetPokerLight",
src: "cards/poker-deck-light.json"
}
},
typeLabels: {},
typeIcons: {
deck: "fas fa-cards",
hand: "fa-duotone fa-cards",
pile: "fa-duotone fa-layer-group"
}
},
/**
* Configuration for the ChatMessage document
*/
ChatMessage: {
documentClass: ChatMessage,
collection: Messages,
template: "templates/sidebar/chat-message.html",
sidebarIcon: "fas fa-comments",
batchSize: 100
},
/**
* Configuration for the Combat document
*/
Combat: {
documentClass: Combat,
collection: CombatEncounters,
sidebarIcon: "fas fa-swords",
initiative: {
formula: null,
decimals: 2
},
sounds: {
epic: {
label: "COMBAT.Sounds.Epic",
startEncounter: ["sounds/combat/epic-start-3hit.ogg", "sounds/combat/epic-start-horn.ogg"],
nextUp: ["sounds/combat/epic-next-horn.ogg"],
yourTurn: ["sounds/combat/epic-turn-1hit.ogg", "sounds/combat/epic-turn-2hit.ogg"]
},
mc: {
label: "COMBAT.Sounds.MC",
startEncounter: ["sounds/combat/mc-start-battle.ogg", "sounds/combat/mc-start-begin.ogg", "sounds/combat/mc-start-fight.ogg", "sounds/combat/mc-start-fight2.ogg"],
nextUp: ["sounds/combat/mc-next-itwillbe.ogg", "sounds/combat/mc-next-makeready.ogg", "sounds/combat/mc-next-youare.ogg"],
yourTurn: ["sounds/combat/mc-turn-itisyour.ogg", "sounds/combat/mc-turn-itsyour.ogg"]
}
}
},
/**
* Configuration for dice rolling behaviors in the Foundry Virtual Tabletop client.
* @type {object}
*/
Dice: {
/**
* The Dice types which are supported.
* @type {Array<typeof DiceTerm>}
*/
types: [Die, FateDie],
rollModes: Object.entries(CONST.DICE_ROLL_MODES).reduce((obj, e) => {
let [k, v] = e;
obj[v] = `CHAT.Roll${k.titleCase()}`;
return obj;
}, {}),
/**
* Configured Roll class definitions
* @type {Array<typeof Roll>}
*/
rolls: [Roll],
/**
* Configured DiceTerm class definitions
* @type {Object<typeof RollTerm>}
*/
termTypes: {DiceTerm, MathTerm, NumericTerm, OperatorTerm, ParentheticalTerm, PoolTerm, StringTerm},
/**
* Configured roll terms and the classes they map to.
* @enum {typeof DiceTerm}
*/
terms: {
c: Coin,
d: Die,
f: FateDie
},
/**
* A function used to provide random uniform values.
* @type {function():number}
*/
randomUniform: MersenneTwister.random
},
/**
* Configuration for the FogExploration document
*/
FogExploration: {
documentClass: FogExploration,
collection: FogExplorations
},
/**
* Configuration for the Folder document
*/
Folder: {
documentClass: Folder,
collection: Folders,
sidebarIcon: "fas fa-folder"
},
/**
* Configuration for Item document
*/
Item: {
documentClass: Item,
collection: Items,
compendiumIndexFields: [],
compendiumBanner: "ui/banners/item-banner.webp",
sidebarIcon: "fas fa-suitcase",
dataModels: {},
typeLabels: {},
typeIcons: {}
},
/**
* Configuration for the JournalEntry document
*/
JournalEntry: {
documentClass: JournalEntry,
collection: Journal,
compendiumIndexFields: [],
compendiumBanner: "ui/banners/journalentry-banner.webp",
noteIcons: {
Anchor: "icons/svg/anchor.svg",
Barrel: "icons/svg/barrel.svg",
Book: "icons/svg/book.svg",
Bridge: "icons/svg/bridge.svg",
Cave: "icons/svg/cave.svg",
Castle: "icons/svg/castle.svg",
Chest: "icons/svg/chest.svg",
City: "icons/svg/city.svg",
Coins: "icons/svg/coins.svg",
Fire: "icons/svg/fire.svg",
"Hanging Sign": "icons/svg/hanging-sign.svg",
House: "icons/svg/house.svg",
Mountain: "icons/svg/mountain.svg",
"Oak Tree": "icons/svg/oak.svg",
Obelisk: "icons/svg/obelisk.svg",
Pawprint: "icons/svg/pawprint.svg",
Ruins: "icons/svg/ruins.svg",
Skull: "icons/svg/skull.svg",
Statue: "icons/svg/statue.svg",
Sword: "icons/svg/sword.svg",
Tankard: "icons/svg/tankard.svg",
Temple: "icons/svg/temple.svg",
Tower: "icons/svg/tower.svg",
Trap: "icons/svg/trap.svg",
Village: "icons/svg/village.svg",
Waterfall: "icons/svg/waterfall.svg",
Windmill: "icons/svg/windmill.svg"
},
sidebarIcon: "fas fa-book-open"
},
/**
* Configuration for the Macro document
*/
Macro: {
documentClass: Macro,
collection: Macros,
compendiumIndexFields: [],
compendiumBanner: "ui/banners/macro-banner.webp",
sidebarIcon: "fas fa-code"
},
/**
* Configuration for the Playlist document
*/
Playlist: {
documentClass: Playlist,
collection: Playlists,
compendiumIndexFields: [],
compendiumBanner: "ui/banners/playlist-banner.webp",
sidebarIcon: "fas fa-music",
autoPreloadSeconds: 20
},
/**
* Configuration for RollTable random draws
*/
RollTable: {
documentClass: RollTable,
collection: RollTables,
compendiumIndexFields: ["formula"],
compendiumBanner: "ui/banners/rolltable-banner.webp",
sidebarIcon: "fas fa-th-list",
resultIcon: "icons/svg/d20-black.svg",
resultTemplate: "templates/dice/table-result.html"
},
/**
* Configuration for the Scene document
*/
Scene: {
documentClass: Scene,
collection: Scenes,
compendiumIndexFields: [],
compendiumBanner: "ui/banners/scene-banner.webp",
sidebarIcon: "fas fa-map"
},
Setting: {
documentClass: Setting,
collection: WorldSettings
},
/**
* Configuration for the User document
*/
User: {
documentClass: User,
collection: Users
},
/* -------------------------------------------- */
/* Canvas */
/* -------------------------------------------- */
/**
* Configuration settings for the Canvas and its contained layers and objects
* @type {object}
*/
Canvas: {
blurStrength: 8,
darknessColor: 0x242448,
daylightColor: 0xEEEEEE,
brightestColor: 0xFFFFFF,
darknessLightPenalty: 0.25,
videoPremultiplyRgx: /Edg|Firefox|Electron/,
dispositionColors: {
HOSTILE: 0xE72124,
NEUTRAL: 0xF1D836,
FRIENDLY: 0x43DFDF,
INACTIVE: 0x555555,
PARTY: 0x33BC4E,
CONTROLLED: 0xFF9829,
SECRET: 0xA612D4
},
exploredColor: 0x000000,
unexploredColor: 0x000000,
groups: {
hidden: {
groupClass: HiddenCanvasGroup,
parent: "stage"
},
rendered: {
groupClass: RenderedCanvasGroup,
parent: "stage"
},
environment: {
groupClass: EnvironmentCanvasGroup,
parent: "rendered"
},
primary: {
groupClass: PrimaryCanvasGroup,
parent: "environment"
},
effects: {
groupClass: EffectsCanvasGroup,
parent: "environment"
},
interface: {
groupClass: InterfaceCanvasGroup,
parent: "rendered"
},
overlay: {
groupClass: OverlayCanvasGroup,
parent: "stage"
}
},
layers: {
weather: {
layerClass: WeatherEffects,
group: "primary"
},
grid: {
layerClass: GridLayer,
group: "interface"
},
drawings: {
layerClass: DrawingsLayer,
group: "interface"
},
templates: {
layerClass: TemplateLayer,
group: "interface"
},
tiles: {
layerClass: TilesLayer,
group: "interface"
},
walls: {
layerClass: WallsLayer,
group: "interface"
},
tokens: {
layerClass: TokenLayer,
group: "interface"
},
sounds: {
layerClass: SoundsLayer,
group: "interface"
},
lighting: {
layerClass: LightingLayer,
group: "interface"
},
notes: {
layerClass: NotesLayer,
group: "interface"
},
controls: {
layerClass: ControlsLayer,
group: "interface"
}
},
lightLevels: {
dark: 0,
halfdark: 0.5,
dim: 0.25,
bright: 1.0
},
fogManager: FogManager,
colorManager: CanvasColorManager,
/**
* @enum {typeof PointSourcePolygon}
*/
polygonBackends: {
sight: ClockwiseSweepPolygon,
light: ClockwiseSweepPolygon,
sound: ClockwiseSweepPolygon,
move: ClockwiseSweepPolygon
},
visibilityFilter: VisibilityFilter,
rulerClass: Ruler,
globalLightConfig: {
luminosity: 0
},
dragSpeedModifier: 0.8,
maxZoom: 3.0,
objectBorderThickness: 4,
lightAnimations: {
flame: {
label: "LIGHT.AnimationFlame",
animation: LightSource.prototype.animateFlickering,
illuminationShader: FlameIlluminationShader,
colorationShader: FlameColorationShader
},
torch: {
label: "LIGHT.AnimationTorch",
animation: LightSource.prototype.animateTorch,
illuminationShader: TorchIlluminationShader,
colorationShader: TorchColorationShader
},
revolving: {
label: "LIGHT.AnimationRevolving",
animation: LightSource.prototype.animateTime,
colorationShader: RevolvingColorationShader
},
siren: {
label: "LIGHT.AnimationSiren",
animation: LightSource.prototype.animateTorch,
illuminationShader: SirenIlluminationShader,
colorationShader: SirenColorationShader
},
pulse: {
label: "LIGHT.AnimationPulse",
animation: LightSource.prototype.animatePulse,
illuminationShader: PulseIlluminationShader,
colorationShader: PulseColorationShader
},
chroma: {
label: "LIGHT.AnimationChroma",
animation: LightSource.prototype.animateTime,
colorationShader: ChromaColorationShader
},
wave: {
label: "LIGHT.AnimationWave",
animation: LightSource.prototype.animateTime,
illuminationShader: WaveIlluminationShader,
colorationShader: WaveColorationShader
},
fog: {
label: "LIGHT.AnimationFog",
animation: LightSource.prototype.animateTime,
colorationShader: FogColorationShader
},
sunburst: {
label: "LIGHT.AnimationSunburst",
animation: LightSource.prototype.animateTime,
illuminationShader: SunburstIlluminationShader,
colorationShader: SunburstColorationShader
},
dome: {
label: "LIGHT.AnimationLightDome",
animation: LightSource.prototype.animateTime,
colorationShader: LightDomeColorationShader
},
emanation: {
label: "LIGHT.AnimationEmanation",
animation: LightSource.prototype.animateTime,
colorationShader: EmanationColorationShader
},
hexa: {
label: "LIGHT.AnimationHexaDome",
animation: LightSource.prototype.animateTime,
colorationShader: HexaDomeColorationShader
},
ghost: {
label: "LIGHT.AnimationGhostLight",
animation: LightSource.prototype.animateTime,
illuminationShader: GhostLightIlluminationShader,
colorationShader: GhostLightColorationShader
},
energy: {
label: "LIGHT.AnimationEnergyField",
animation: LightSource.prototype.animateTime,
colorationShader: EnergyFieldColorationShader
},
roiling: {
label: "LIGHT.AnimationRoilingMass",
animation: LightSource.prototype.animateTime,
illuminationShader: RoilingIlluminationShader
},
hole: {
label: "LIGHT.AnimationBlackHole",
animation: LightSource.prototype.animateTime,
illuminationShader: BlackHoleIlluminationShader
},
vortex: {
label: "LIGHT.AnimationVortex",
animation: LightSource.prototype.animateTime,
illuminationShader: VortexIlluminationShader,
colorationShader: VortexColorationShader
},
witchwave: {
label: "LIGHT.AnimationBewitchingWave",
animation: LightSource.prototype.animateTime,
illuminationShader: BewitchingWaveIlluminationShader,
colorationShader: BewitchingWaveColorationShader
},
rainbowswirl: {
label: "LIGHT.AnimationSwirlingRainbow",
animation: LightSource.prototype.animateTime,
colorationShader: SwirlingRainbowColorationShader
},
radialrainbow: {
label: "LIGHT.AnimationRadialRainbow",
animation: LightSource.prototype.animateTime,
colorationShader: RadialRainbowColorationShader
},
fairy: {
label: "LIGHT.AnimationFairyLight",
animation: LightSource.prototype.animateTime,
illuminationShader: FairyLightIlluminationShader,
colorationShader: FairyLightColorationShader
},
grid: {
label: "LIGHT.AnimationForceGrid",
animation: LightSource.prototype.animateTime,
colorationShader: ForceGridColorationShader
},
starlight: {
label: "LIGHT.AnimationStarLight",
animation: LightSource.prototype.animateTime,
colorationShader: StarLightColorationShader
},
smokepatch: {
label: "LIGHT.AnimationSmokePatch",
animation: LightSource.prototype.animateTime,
illuminationShader: SmokePatchIlluminationShader,
colorationShader: SmokePatchColorationShader
}
},
pings: {
types: {
PULSE: "pulse",
ALERT: "alert",
PULL: "chevron",
ARROW: "arrow"
},
styles: {
alert: {
class: AlertPing,
color: "#ff0000",
size: 1.5,
duration: 900
},
arrow: {
class: ArrowPing,
size: 1,
duration: 900
},
chevron: {
class: ChevronPing,
size: 1,
duration: 2000
},
pulse: {
class: PulsePing,
size: 1.5,
duration: 900
}
},
pullSpeed: 700
},
targeting: {
size: .15
},
/* -------------------------------------------- */
/**
* The set of VisionMode definitions which are available to be used for Token vision.
* @type {Object<VisionMode>}
*/
visionModes: {
// Default (Basic) Vision
basic: new VisionMode({
id: "basic",
label: "VISION.ModeBasicVision",
vision: {
defaults: { attenuation: 0, contrast: 0, saturation: 0, brightness: 0 },
preferred: true // Takes priority over other vision modes
}
}),
// Darkvision
darkvision: new VisionMode({
id: "darkvision",
label: "VISION.ModeDarkvision",
canvas: {
shader: ColorAdjustmentsSamplerShader,
uniforms: { contrast: 0, saturation: -1.0, brightness: 0 }
},
lighting: {
levels: {
[VisionMode.LIGHTING_LEVELS.DIM]: VisionMode.LIGHTING_LEVELS.BRIGHT
},
background: { visibility: VisionMode.LIGHTING_VISIBILITY.REQUIRED }
},
vision: {
darkness: { adaptive: false },
defaults: { attenuation: 0, contrast: 0, saturation: -1.0, brightness: 0 }
}
}),
// Darkvision
monochromatic: new VisionMode({
id: "monochromatic",
label: "VISION.ModeMonochromatic",
canvas: {
shader: ColorAdjustmentsSamplerShader,
uniforms: { contrast: 0, saturation: -1.0, brightness: 0 }
},
lighting: {
background: {
postProcessingModes: ["SATURATION"],
uniforms: { saturation: -1.0, tint: [1, 1, 1] }
},
illumination: {
postProcessingModes: ["SATURATION"],
uniforms: { saturation: -1.0, tint: [1, 1, 1] }
},
coloration: {
postProcessingModes: ["SATURATION"],
uniforms: { saturation: -1.0, tint: [1, 1, 1] }
}
},
vision: {
darkness: { adaptive: false },
defaults: { attenuation: 0, contrast: 0, saturation: -1, brightness: 0 }
}
}),
// Blindness
blindness: new VisionMode({
id: "blindness",
label: "VISION.ModeBlindness",
tokenConfig: false,
canvas: {
shader: ColorAdjustmentsSamplerShader,
uniforms: { contrast: -0.75, saturation: -1, exposure: -0.3 }
},
lighting: {
background: { visibility: VisionMode.LIGHTING_VISIBILITY.DISABLED },
illumination: { visibility: VisionMode.LIGHTING_VISIBILITY.DISABLED },
coloration: { visibility: VisionMode.LIGHTING_VISIBILITY.DISABLED }
},
vision: {
darkness: { adaptive: false },
defaults: { attenuation: 0, contrast: -0.5, saturation: -1, brightness: -1 }
}
}),
// Tremorsense
tremorsense: new VisionMode({
id: "tremorsense",
label: "VISION.ModeTremorsense",
canvas: {
shader: ColorAdjustmentsSamplerShader,
uniforms: { contrast: 0, saturation: -0.8, exposure: -0.65 }
},
lighting: {
background: { visibility: VisionMode.LIGHTING_VISIBILITY.DISABLED },
illumination: { visibility: VisionMode.LIGHTING_VISIBILITY.DISABLED },
coloration: { visibility: VisionMode.LIGHTING_VISIBILITY.DISABLED }
},
vision: {
darkness: { adaptive: false },
defaults: { attenuation: 0, contrast: 0.2, saturation: -0.3, brightness: 1 },
background: { shader: WaveBackgroundVisionShader },
coloration: { shader: WaveColorationVisionShader }
}
}, {animated: true}),
// Light Amplification
lightAmplification: new VisionMode({
id: "lightAmplification",
label: "VISION.ModeLightAmplification",
canvas: {
shader: AmplificationSamplerShader,
uniforms: { saturation: -0.5, tint: [0.38, 0.8, 0.38] }
},
lighting: {
background: {
visibility: VisionMode.LIGHTING_VISIBILITY.REQUIRED,
postProcessingModes: ["SATURATION", "EXPOSURE"],
uniforms: { saturation: -0.5, exposure: 1.5, tint: [0.38, 0.8, 0.38] }
},
illumination: {
postProcessingModes: ["SATURATION"],
uniforms: { saturation: -0.5 }
},
coloration: {
postProcessingModes: ["SATURATION", "EXPOSURE"],
uniforms: { saturation: -0.5, exposure: 1.5, tint: [0.38, 0.8, 0.38] }
},
levels: {
[VisionMode.LIGHTING_LEVELS.DIM]: VisionMode.LIGHTING_LEVELS.BRIGHT,
[VisionMode.LIGHTING_LEVELS.BRIGHT]: VisionMode.LIGHTING_LEVELS.BRIGHTEST
}
},
vision: {
darkness: { adaptive: false },
defaults: { attenuation: 0, contrast: 0, saturation: -0.5, brightness: 1 },
background: { shader: AmplificationBackgroundVisionShader }
}
})
},
/* -------------------------------------------- */
/**
* The set of DetectionMode definitions which are available to be used for visibility detection.
* @type {Object<DetectionMode>}
*/
detectionModes: {
basicSight: new DetectionModeBasicSight({
id: "basicSight",
label: "DETECTION.BasicSight",
type: DetectionMode.DETECTION_TYPES.SIGHT
}),
seeInvisibility: new DetectionModeInvisibility({
id: "seeInvisibility",
label: "DETECTION.SeeInvisibility",
type: DetectionMode.DETECTION_TYPES.SIGHT
}),
senseInvisibility: new DetectionModeInvisibility({
id: "senseInvisibility",
label: "DETECTION.SenseInvisibility",
walls: false,
angle: false,
type: DetectionMode.DETECTION_TYPES.OTHER
}),
feelTremor: new DetectionModeTremor({
id: "feelTremor",
label: "DETECTION.FeelTremor",
walls: false,
angle: false,
type: DetectionMode.DETECTION_TYPES.MOVE
}),
seeAll: new DetectionModeAll({
id: "seeAll",
label: "DETECTION.SeeAll",
type: DetectionMode.DETECTION_TYPES.SIGHT
}),
senseAll: new DetectionModeAll({
id: "senseAll",
label: "DETECTION.SenseAll",
walls: false,
angle: false,
type: DetectionMode.DETECTION_TYPES.OTHER
})
}
},
/* -------------------------------------------- */
/**
* Configure the default Token text style so that it may be reused and overridden by modules
* @type {PIXI.TextStyle}
*/
canvasTextStyle: new PIXI.TextStyle({
fontFamily: "Signika",
fontSize: 36,
fill: "#FFFFFF",
stroke: "#111111",
strokeThickness: 1,
dropShadow: true,
dropShadowColor: "#000000",
dropShadowBlur: 2,
dropShadowAngle: 0,
dropShadowDistance: 0,
align: "center",
wordWrap: false,
padding: 1
}),
/**
* Available Weather Effects implementations
* @typedef {Object} WeatherAmbienceConfiguration
* @param {string} id
* @param {string} label
* @param {{enabled: boolean, blendMode: PIXI.BLEND_MODES}} filter
* @param {WeatherEffectConfiguration[]} effects
*
* @typedef {Object} WeatherEffectConfiguration
* @param {string} id
* @param {typeof ParticleEffect|WeatherShaderEffect} effectClass
* @param {PIXI.BLEND_MODES} blendMode
* @param {object} config
*/
weatherEffects: {
leaves: {
id: "leaves",
label: "WEATHER.AutumnLeaves",
effects: [{
id: "leavesParticles",
effectClass: AutumnLeavesWeatherEffect
}]
},
rain: {
id: "rain",
label: "WEATHER.Rain",
filter: {
enabled: false
},
effects: [{
id: "rainShader",
effectClass: WeatherShaderEffect,
shaderClass: RainShader,
blendMode: PIXI.BLEND_MODES.SCREEN,
config: {
opacity: 0.25,
tint: [0.7, 0.9, 1.0],
intensity: 1,
strength: 1,
rotation: 0.2618,
speed: 0.2,
}
}]
},
rainStorm: {
id: "rainStorm",
label: "WEATHER.RainStorm",
filter: {
enabled: false
},
effects: [{
id: "fogShader",
effectClass: WeatherShaderEffect,
shaderClass: FogShader,
blendMode: PIXI.BLEND_MODES.SCREEN,
performanceLevel: 2,
config: {
slope: 1.5,
intensity: 0.050,
speed: -55.0,
scale: 25,
}
},
{
id: "rainShader",
effectClass: WeatherShaderEffect,
shaderClass: RainShader,
blendMode: PIXI.BLEND_MODES.SCREEN,
config: {
opacity: 0.45,
tint: [0.7, 0.9, 1.0],
intensity: 1.5,
strength: 1.5,
rotation: 0.5236,
speed: 0.30,
}
}]
},
fog: {
id: "fog",
label: "WEATHER.Fog",
filter: {
enabled: false
},
effects: [{
id: "fogShader",
effectClass: WeatherShaderEffect,
shaderClass: FogShader,
blendMode: PIXI.BLEND_MODES.SCREEN,
config: {
slope: 0.45,
intensity: 0.4,
speed: 0.4,
}
}]
},
snow: {
id: "snow",
label: "WEATHER.Snow",
filter: {
enabled: false
},
effects: [{
id: "snowShader",
effectClass: WeatherShaderEffect,
shaderClass: SnowShader,
blendMode: PIXI.BLEND_MODES.SCREEN,
config: {
tint: [0.85, 0.95, 1],
direction: 0.5,
speed: 2,
scale: 2.5,
}
}]
},
blizzard: {
id: "blizzard",
label: "WEATHER.Blizzard",
filter: {
enabled: false
},
effects: [{
id: "snowShader",
effectClass: WeatherShaderEffect,
shaderClass: SnowShader,
blendMode: PIXI.BLEND_MODES.SCREEN,
config: {
tint: [0.95, 1, 1],
direction: 0.80,
speed: 8,
scale: 2.5,
}
},
{
id: "fogShader",
effectClass: WeatherShaderEffect,
shaderClass: FogShader,
blendMode: PIXI.BLEND_MODES.SCREEN,
performanceLevel: 2,
config: {
slope: 1.0,
intensity: 0.15,
speed: -4.0,
}
}]
}
},
/**
* The control icons used for rendering common HUD operations
* @type {object}
*/
controlIcons: {
combat: "icons/svg/combat.svg",
visibility: "icons/svg/cowled.svg",
effects: "icons/svg/aura.svg",
lock: "icons/svg/padlock.svg",
up: "icons/svg/up.svg",
down: "icons/svg/down.svg",
defeated: "icons/svg/skull.svg",
light: "icons/svg/light.svg",
lightOff: "icons/svg/light-off.svg",
template: "icons/svg/explosion.svg",
sound: "icons/svg/sound.svg",
soundOff: "icons/svg/sound-off.svg",
doorClosed: "icons/svg/door-closed-outline.svg",
doorOpen: "icons/svg/door-open-outline.svg",
doorSecret: "icons/svg/door-secret-outline.svg",
doorLocked: "icons/svg/door-locked-outline.svg",
wallDirection: "icons/svg/wall-direction.svg"
},
/**
* @typedef {FontFaceDescriptors} FontDefinition
* @property {string} urls An array of remote URLs the font files exist at.
*/
/**
* @typedef {object} FontFamilyDefinition
* @property {boolean} editor Whether the font is available in the rich text editor. This will also enable it
* for notes and drawings.
* @property {FontDefinition[]} fonts Individual font face definitions for this font family. If this is empty, the
* font family may only be loaded from the client's OS-installed fonts.
*/
/**
* A collection of fonts to load either from the user's local system, or remotely.
* @type {Object<FontFamilyDefinition>}
*/
fontDefinitions: {
Arial: {editor: true, fonts: []},
Amiri: {
editor: true,
fonts: [
{urls: ["fonts/amiri/amiri-regular.woff2"]},
{urls: ["fonts/amiri/amiri-bold.woff2"], weight: 700}
]
},
"Bruno Ace": {editor: true, fonts: [
{urls: ["fonts/bruno-ace/bruno-ace.woff2"]}
]},
Courier: {editor: true, fonts: []},
"Courier New": {editor: true, fonts: []},
"Modesto Condensed": {
editor: true,
fonts: [
{urls: ["fonts/modesto-condensed/modesto-condensed.woff2"]},
{urls: ["fonts/modesto-condensed/modesto-condensed-bold.woff2"], weight: 700}
]
},
Signika: {
editor: true,
fonts: [
{urls: ["fonts/signika/signika-regular.woff2"]},
{urls: ["fonts/signika/signika-bold.woff2"], weight: 700}
]
},
Times: {editor: true, fonts: []},
"Times New Roman": {editor: true, fonts: []}
},
/**
* @deprecated since v10.
*/
_fontFamilies: [],
/**
* The default font family used for text labels on the PIXI Canvas
* @type {string}
*/
defaultFontFamily: "Signika",
/**
* An array of status effects which can be applied to a TokenDocument.
* Each effect can either be a string for an icon path, or an object representing an Active Effect data.
* @type {Array<string|ActiveEffectData>}
*/
statusEffects: [
{
id: "dead",
name: "EFFECT.StatusDead",
icon: "icons/svg/skull.svg"
},
{
id: "unconscious",
name: "EFFECT.StatusUnconscious",
icon: "icons/svg/unconscious.svg"
},
{
id: "sleep",
name: "EFFECT.StatusAsleep",
icon: "icons/svg/sleep.svg"
},
{
id: "stun",
name: "EFFECT.StatusStunned",
icon: "icons/svg/daze.svg"
},
{
id: "prone",
name: "EFFECT.StatusProne",
icon: "icons/svg/falling.svg"
},
{
id: "restrain",
name: "EFFECT.StatusRestrained",
icon: "icons/svg/net.svg"
},
{
id: "paralysis",
name: "EFFECT.StatusParalysis",
icon: "icons/svg/paralysis.svg"
},
{
id: "fly",
name: "EFFECT.StatusFlying",
icon: "icons/svg/wing.svg"
},
{
id: "blind",
name: "EFFECT.StatusBlind",
icon: "icons/svg/blind.svg"
},
{
id: "deaf",
name: "EFFECT.StatusDeaf",
icon: "icons/svg/deaf.svg"
},
{
id: "silence",
name: "EFFECT.StatusSilenced",
icon: "icons/svg/silenced.svg"
},
{
id: "fear",
name: "EFFECT.StatusFear",
icon: "icons/svg/terror.svg"
},
{
id: "burning",
name: "EFFECT.StatusBurning",
icon: "icons/svg/fire.svg"
},
{
id: "frozen",
name: "EFFECT.StatusFrozen",
icon: "icons/svg/frozen.svg"
},
{
id: "shock",
name: "EFFECT.StatusShocked",
icon: "icons/svg/lightning.svg"
},
{
id: "corrode",
name: "EFFECT.StatusCorrode",
icon: "icons/svg/acid.svg"
},
{
id: "bleeding",
name: "EFFECT.StatusBleeding",
icon: "icons/svg/blood.svg"
},
{
id: "disease",
name: "EFFECT.StatusDisease",
icon: "icons/svg/biohazard.svg"
},
{
id: "poison",
name: "EFFECT.StatusPoison",
icon: "icons/svg/poison.svg"
},
{
id: "curse",
name: "EFFECT.StatusCursed",
icon: "icons/svg/sun.svg"
},
{
id: "regen",
name: "EFFECT.StatusRegen",
icon: "icons/svg/regen.svg"
},
{
id: "degen",
name: "EFFECT.StatusDegen",
icon: "icons/svg/degen.svg"
},
{
id: "upgrade",
name: "EFFECT.StatusUpgrade",
icon: "icons/svg/upgrade.svg"
},
{
id: "downgrade",
name: "EFFECT.StatusDowngrade",
icon: "icons/svg/downgrade.svg"
},
{
id: "invisible",
name: "EFFECT.StatusInvisible",
icon: "icons/svg/invisible.svg"
},
{
id: "target",
name: "EFFECT.StatusTarget",
icon: "icons/svg/target.svg"
},
{
id: "eye",
name: "EFFECT.StatusMarked",
icon: "icons/svg/eye.svg"
},
{
id: "bless",
name: "EFFECT.StatusBlessed",
icon: "icons/svg/angel.svg"
},
{
id: "fireShield",
name: "EFFECT.StatusFireShield",
icon: "icons/svg/fire-shield.svg"
},
{
id: "coldShield",
name: "EFFECT.StatusIceShield",
icon: "icons/svg/ice-shield.svg"
},
{
id: "magicShield",
name: "EFFECT.StatusMagicShield",
icon: "icons/svg/mage-shield.svg"
},
{
id: "holyShield",
name: "EFFECT.StatusHolyShield",
icon: "icons/svg/holy-shield.svg"
}
].map(s => {
/** @deprecated since v11 */
return Object.defineProperty(s, "label", {
get() { return this.name; },
set(value) { this.name = value; },
enumerable: false,
configurable: true
});
}),
/**
* A mapping of status effect IDs which provide some additional mechanical integration.
* @enum {string}
*/
specialStatusEffects: {
DEFEATED: "dead",
INVISIBLE: "invisible",
BLIND: "blind"
},
/**
* A mapping of core audio effects used which can be replaced by systems or mods
* @type {object}
*/
sounds: {
dice: "sounds/dice.wav",
lock: "sounds/lock.wav",
notification: "sounds/notify.wav",
combat: "sounds/drums.wav"
},
/**
* Define the set of supported languages for localization
* @type {{string, string}}
*/
supportedLanguages: {
en: "English"
},
/**
* Localization constants.
* @type {object}
*/
i18n: {
/**
* In operations involving the document index, search prefixes must have at least this many characters to avoid too
* large a search space. Languages that have hundreds or thousands of characters will typically have very shallow
* search trees, so it should be safe to lower this number in those cases.
*/
searchMinimumCharacterLength: 4
},
/**
* Configuration for time tracking
* @type {{turnTime: number}}
*/
time: {
turnTime: 0,
roundTime: 0
},
/* -------------------------------------------- */
/* Embedded Documents */
/* -------------------------------------------- */
/**
* Configuration for the ActiveEffect embedded document type
*/
ActiveEffect: {
documentClass: ActiveEffect,
/**
* If true, Active Effects on Items will be copied to the Actor when the Item is created on the Actor if the
* Active Effect's transfer property is true, and will be deleted when that Item is deleted from the Actor.
* If false, Active Effects are never copied to the Actor, but will still apply to the Actor from within the Item
* if the transfer property on the Active Effect is true.
* @deprecated since v11
*/
legacyTransferral: true
},
/**
* Configuration for the ActorDelta embedded document type.
*/
ActorDelta: {
documentClass: ActorDelta
},
/**
* Configuration for the Card embedded Document type
*/
Card: {
documentClass: Card,
dataModels: {}
},
/**
* Configuration for the TableResult embedded document type
*/
TableResult: {
documentClass: TableResult
},
/**
* Configuration for the JournalEntryPage embedded document type.
*/
JournalEntryPage: {
documentClass: JournalEntryPage,
dataModels: {},
typeLabels: {},
typeIcons: {
image: "fas fa-file-image",
pdf: "fas fa-file-pdf",
text: "fas fa-file-lines",
video: "fas fa-file-video"
},
defaultType: "text",
sidebarIcon: "fas fa-book-open"
},
/**
* Configuration for the PlaylistSound embedded document type
*/
PlaylistSound: {
documentClass: PlaylistSound,
sidebarIcon: "fas fa-music"
},
/**
* Configuration for the AmbientLight embedded document type and its representation on the game Canvas
* @enum {Function}
*/
AmbientLight: {
documentClass: AmbientLightDocument,
objectClass: AmbientLight,
layerClass: LightingLayer
},
/**
* Configuration for the AmbientSound embedded document type and its representation on the game Canvas
* @enum {Function}
*/
AmbientSound: {
documentClass: AmbientSoundDocument,
objectClass: AmbientSound,
layerClass: SoundsLayer
},
/**
* Configuration for the Combatant embedded document type within a Combat document
* @enum {Function}
*/
Combatant: {
documentClass: Combatant
},
/**
* Configuration for the Drawing embedded document type and its representation on the game Canvas
* @enum {Function}
*/
Drawing: {
documentClass: DrawingDocument,
objectClass: Drawing,
layerClass: DrawingsLayer
},
/**
* Configuration for the MeasuredTemplate embedded document type and its representation on the game Canvas
* @enum {Function}
*/
MeasuredTemplate: {
defaults: {
angle: 53.13,
width: 1
},
types: {
circle: "Circle",
cone: "Cone",
rect: "Rectangle",
ray: "Ray"
},
documentClass: MeasuredTemplateDocument,
objectClass: MeasuredTemplate,
layerClass: TemplateLayer
},
/**
* Configuration for the Note embedded document type and its representation on the game Canvas
* @enum {Function}
*/
Note: {
documentClass: NoteDocument,
objectClass: Note,
layerClass: NotesLayer
},
/**
* Configuration for the Tile embedded document type and its representation on the game Canvas
* @enum {Function}
*/
Tile: {
documentClass: TileDocument,
objectClass: Tile,
layerClass: TilesLayer
},
/**
* Configuration for the Token embedded document type and its representation on the game Canvas
* @enum {Function}
*/
Token: {
documentClass: TokenDocument,
objectClass: Token,
layerClass: TokenLayer,
prototypeSheetClass: TokenConfig,
adjectivesPrefix: "TOKEN.Adjectives"
},
/**
* @typedef {Object} WallDoorSound
* @property {string} label A localization string label
* @property {string} close A sound path when the door is closed
* @property {string} lock A sound path when the door becomes locked
* @property {string} open A sound path when opening the door
* @property {string} test A sound path when attempting to open a locked door
* @property {string} unlock A sound path when the door becomes unlocked
*/
/**
* Configuration for the Wall embedded document type and its representation on the game Canvas
* @property {typeof ClientDocument} documentClass
* @property {typeof PlaceableObject} objectClass
* @property {typeof CanvasLayer} layerClass
* @property {number} thresholdAttenuationMultiplier
* @property {WallDoorSound[]} doorSounds
*/
Wall: {
documentClass: WallDocument,
objectClass: Wall,
layerClass: WallsLayer,
thresholdAttenuationMultiplier: 1,
doorSounds: {
futuristicFast: {
label: "WALLS.DoorSound.FuturisticFast",
close: "sounds/doors/futuristic/close-fast.ogg",
lock: "sounds/doors/futuristic/lock.ogg",
open: "sounds/doors/futuristic/open-fast.ogg",
test: "sounds/doors/futuristic/test.ogg",
unlock: "sounds/doors/futuristic/unlock.ogg"
},
futuristicHydraulic: {
label: "WALLS.DoorSound.FuturisticHydraulic",
close: "sounds/doors/futuristic/close-hydraulic.ogg",
lock: "sounds/doors/futuristic/lock.ogg",
open: "sounds/doors/futuristic/open-hydraulic.ogg",
test: "sounds/doors/futuristic/test.ogg",
unlock: "sounds/doors/futuristic/unlock.ogg"
},
futuristicForcefield: {
label: "WALLS.DoorSound.FuturisticForcefield",
close: "sounds/doors/futuristic/close-forcefield.ogg",
lock: "sounds/doors/futuristic/lock.ogg",
open: "sounds/doors/futuristic/open-forcefield.ogg",
test: "sounds/doors/futuristic/test-forcefield.ogg",
unlock: "sounds/doors/futuristic/unlock.ogg"
},
industrial: {
label: "WALLS.DoorSound.Industrial",
close: "sounds/doors/industrial/close.ogg",
lock: "sounds/doors/industrial/lock.ogg",
open: "sounds/doors/industrial/open.ogg",
test: "sounds/doors/industrial/test.ogg",
unlock: "sounds/doors/industrial/unlock.ogg"
},
industrialCreaky: {
label: "WALLS.DoorSound.IndustrialCreaky",
close: "sounds/doors/industrial/close-creaky.ogg",
lock: "sounds/doors/industrial/lock.ogg",
open: "sounds/doors/industrial/open-creaky.ogg",
test: "sounds/doors/industrial/test.ogg",
unlock: "sounds/doors/industrial/unlock.ogg"
},
jail: {
label: "WALLS.DoorSound.Jail",
close: "sounds/doors/jail/close.ogg",
lock: "sounds/doors/jail/lock.ogg",
open: "sounds/doors/jail/open.ogg",
test: "sounds/doors/jail/test.ogg",
unlock: "sounds/doors/jail/unlock.ogg"
},
metal: {
label: "WALLS.DoorSound.Metal",
close: "sounds/doors/metal/close.ogg",
lock: "sounds/doors/metal/lock.ogg",
open: "sounds/doors/metal/open.ogg",
test: "sounds/doors/metal/test.ogg",
unlock: "sounds/doors/metal/unlock.ogg"
},
slidingMetal: {
label: "WALLS.DoorSound.SlidingMetal",
close: "sounds/doors/shutter/close.ogg",
lock: "sounds/doors/shutter/lock.ogg",
open: "sounds/doors/shutter/open.ogg",
test: "sounds/doors/shutter/test.ogg",
unlock: "sounds/doors/shutter/unlock.ogg"
},
slidingModern: {
label: "WALLS.DoorSound.SlidingModern",
close: "sounds/doors/sliding/close.ogg",
lock: "sounds/doors/sliding/lock.ogg",
open: "sounds/doors/sliding/open.ogg",
test: "sounds/doors/sliding/test.ogg",
unlock: "sounds/doors/sliding/unlock.ogg"
},
slidingWood: {
label: "WALLS.DoorSound.SlidingWood",
close: "sounds/doors/sliding/close-wood.ogg",
lock: "sounds/doors/sliding/lock.ogg",
open: "sounds/doors/sliding/open-wood.ogg",
test: "sounds/doors/sliding/test.ogg",
unlock: "sounds/doors/sliding/unlock.ogg"
},
stoneBasic: {
label: "WALLS.DoorSound.StoneBasic",
close: "sounds/doors/stone/close.ogg",
lock: "sounds/doors/stone/lock.ogg",
open: "sounds/doors/stone/open.ogg",
test: "sounds/doors/stone/test.ogg",
unlock: "sounds/doors/stone/unlock.ogg"
},
stoneRocky: {
label: "WALLS.DoorSound.StoneRocky",
close: "sounds/doors/stone/close-rocky.ogg",
lock: "sounds/doors/stone/lock.ogg",
open: "sounds/doors/stone/open-rocky.ogg",
test: "sounds/doors/stone/test.ogg",
unlock: "sounds/doors/stone/unlock.ogg"
},
stoneSandy: {
label: "WALLS.DoorSound.StoneSandy",
close: "sounds/doors/stone/close-sandy.ogg",
lock: "sounds/doors/stone/lock.ogg",
open: "sounds/doors/stone/open-sandy.ogg",
test: "sounds/doors/stone/test.ogg",
unlock: "sounds/doors/stone/unlock.ogg"
},
woodBasic: {
label: "WALLS.DoorSound.WoodBasic",
close: "sounds/doors/wood/close.ogg",
lock: "sounds/doors/wood/lock.ogg",
open: "sounds/doors/wood/open.ogg",
test: "sounds/doors/wood/test.ogg",
unlock: "sounds/doors/wood/unlock.ogg"
},
woodCreaky: {
label: "WALLS.DoorSound.WoodCreaky",
close: "sounds/doors/wood/close-creaky.ogg",
lock: "sounds/doors/wood/lock.ogg",
open: "sounds/doors/wood/open-creaky.ogg",
test: "sounds/doors/wood/test.ogg",
unlock: "sounds/doors/wood/unlock.ogg"
},
woodHeavy: {
label: "WALLS.DoorSound.WoodHeavy",
close: "sounds/doors/wood/close-heavy.ogg",
lock: "sounds/doors/wood/lock.ogg",
open: "sounds/doors/wood/open-heavy.ogg",
test: "sounds/doors/wood/test.ogg",
unlock: "sounds/doors/wood/unlock.ogg"
}
}
},
/* -------------------------------------------- */
/* Integrations */
/* -------------------------------------------- */
/**
* Default configuration options for TinyMCE editors
* @type {object}
*/
TinyMCE: {
branding: false,
menubar: false,
statusbar: false,
content_css: ["/css/mce.css"],
plugins: "lists image table code save link",
toolbar: "styles bullist numlist image table hr link removeformat code save",
save_enablewhendirty: true,
table_default_styles: {},
style_formats: [
{
title: "Custom",
items: [
{
title: "Secret",
block: "section",
classes: "secret",
wrapper: true
}
]
}
],
style_formats_merge: true
},
/**
* @callback TextEditorEnricher
* @param {RegExpMatchArray} match The regular expression match result
* @param {EnrichmentOptions} [options] Options provided to customize text enrichment
* @returns {Promise<HTMLElement|null>} An HTML element to insert in place of the matched text or null to
* indicate that no replacement should be made.
*/
/**
* @typedef {object} TextEditorEnricherConfig
* @property {RegExp} pattern The string pattern to match. Must be flagged as global.
* @property {TextEditorEnricher} enricher The function that will be called on each match. It is expected that this
* returns an HTML element to be inserted into the final enriched content.
*/
/**
* Rich text editing configuration.
* @type {object}
*/
TextEditor: {
/**
* A collection of custom enrichers that can be applied to text content, allowing for the matching and handling of
* custom patterns.
* @type {TextEditorEnricherConfig[]}
*/
enrichers: []
},
/**
* Configuration for the WebRTC implementation class
* @type {object}
*/
WebRTC: {
clientClass: SimplePeerAVClient,
detectPeerVolumeInterval: 50,
detectSelfVolumeInterval: 20,
emitVolumeInterval: 25,
speakingThresholdEvents: 2,
speakingHistoryLength: 10,
connectedUserPollIntervalS: 8
},
/* -------------------------------------------- */
/* Interface */
/* -------------------------------------------- */
/**
* Configure the Application classes used to render various core UI elements in the application.
* The order of this object is relevant, as certain classes need to be constructed and referenced before others.
* @type {Object<Application>}
*/
ui: {
menu: MainMenu,
sidebar: Sidebar,
pause: Pause,
nav: SceneNavigation,
notifications: Notifications,
actors: ActorDirectory,
cards: CardsDirectory,
chat: ChatLog,
combat: CombatTracker,
compendium: CompendiumDirectory,
controls: SceneControls,
hotbar: Hotbar,
items: ItemDirectory,
journal: JournalDirectory,
macros: MacroDirectory,
players: PlayerList,
playlists: PlaylistDirectory,
scenes: SceneDirectory,
settings: Settings,
tables: RollTableDirectory,
webrtc: CameraViews
}
};
/**
* @deprecated since v10
*/
CONFIG._fontFamilies = Object.keys(CONFIG.fontDefinitions);
Object.defineProperty(CONFIG, "fontFamilies", {
get() {
foundry.utils.logCompatibilityWarning(
"CONFIG.fontFamilies is deprecated. Please use CONFIG.fontDefinitions instead.", {since: 10, until: 12});
return CONFIG._fontFamilies;
}
});
/**
* @deprecated since v11
*/
["Actor", "Item", "JournalEntryPage", "Cards", "Card"].forEach(doc => {
const warning = `You are accessing CONFIG.${doc}.systemDataModels which is deprecated. `
+ `Please use CONFIG.${doc}.dataModels instead.`;
Object.defineProperty(CONFIG[doc], "systemDataModels", {
enumerable: false,
get() {
foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
return CONFIG[doc].dataModels;
},
set(models) {
foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
CONFIG[doc].dataModels = models;
}
});
});
/**
* @deprecated since v11
*/
Object.defineProperty(CONFIG.Canvas, "losBackend", {
get() {
const warning = "You are accessing CONFIG.Canvas.losbackend, which is deprecated."
+ " Use CONFIG.Canvas.polygonBackends.sight instead.";
foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
return CONFIG.Canvas.polygonBackends.sight;
},
set(cls) {
const warning = "You are setting CONFIG.Canvas.losbackend, which is deprecated."
+ " Use CONFIG.Canvas.polygonBackends[type] instead.";
foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
for ( const k of Object.keys(CONFIG.Canvas.polygonBackends) ) CONFIG.Canvas.polygonBackends[k] = cls;
}
});
// Helper classes
globalThis.Hooks = Hooks;
globalThis.TextEditor = TextEditor;
globalThis.SortingHelpers = SortingHelpers;
// Default Document sheet registrations
DocumentSheetConfig._registerDefaultSheets();
console.groupCollapsed(`${vtt} | Before DOMContentLoaded`);
/**
* Once the Window has loaded, created and initialize the Game object
*/
window.addEventListener("DOMContentLoaded", async function() {
console.groupEnd();
// Log ASCII welcome message
console.log(CONST.ASCII);
// Get the current URL
const url = new URL(window.location.href);
const view = url.pathname.split("/").pop();
// Establish a session
const cookies = Game.getCookies();
const sessionId = cookies.session ?? null;
if ( !sessionId ) return window.location.href = foundry.utils.getRoute("join");
console.log(`${vtt} | Reestablishing existing session ${sessionId}`);
// Initialize the asset loader
const routePrefix = globalThis.ROUTE_PREFIX?.replace(/(^[/]+)|([/]+$)/g, "");
const basePath = routePrefix ? `${window.location.origin}/${routePrefix}` : window.location.origin;
await PIXI.Assets.init({basePath, preferences: {defaultAutoPlay: false}});
// Create the master Game controller
if ( CONST.SETUP_VIEWS.includes(view) ) game = globalThis.game = await Setup.create(view, sessionId);
else if ( CONST.GAME_VIEWS.includes(view) ) game = globalThis.game = await Game.create(view, sessionId);
return globalThis.game.initialize();
}, {once: true, passive: true});
/**
* A helper class to provide common functionality for working with the Web Audio API.
* https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API
* A singleton instance of this class is available as game#audio.
* @see Game#audio
*/
class AudioHelper {
constructor() {
if ( game.audio instanceof this.constructor ) {
throw new Error("You may not re-initialize the singleton AudioHelper. Use game.audio instead.");
}
/**
* The primary Audio Context used to play client-facing sounds.
* The context is undefined until the user's first gesture is observed.
* @type {AudioContext}
*/
this.context = undefined;
/**
* The set of AudioBuffer objects which are cached for different audio paths
* @type {Map<string,{buffer: AudioBuffer, lastAccessed: number, playing: boolean, size: number}>}
*/
this.buffers = new Map();
/**
* The set of singleton Sound instances which are cached for different audio paths
* @type {Map<string,Sound>}
*/
this.sounds = new Map();
/**
* Get a map of the Sound objects which are currently playing.
* @type {Map<number,Sound>}
*/
this.playing = new Map();
/**
* A user gesture must be registered before audio can be played.
* This Array contains the Sound instances which are requested for playback prior to a gesture.
* Once a gesture is observed, we begin playing all elements of this Array.
* @type {Function[]}
* @see Sound
*/
this.pending = [];
/**
* A flag for whether video playback is currently locked by awaiting a user gesture
* @type {boolean}
*/
this.locked = true;
/**
* Audio Context singleton used for analysing audio levels of each stream
* Only created if necessary to listen to audio streams.
*
* @type {AudioContext}
* @private
*/
this._audioContext = null;
/**
* Map of all streams that we listen to for determining the decibel levels.
* Used for analyzing audio levels of each stream.
* Format of the object stored is :
* {id:
* {
* stream: MediaStream,
* analyser: AudioAnalyser,
* interval: Number,
* callback: Function
* }
* }
*
* @type {Object}
* @private
*/
this._analyserStreams = {};
/**
* Interval ID as returned by setInterval for analysing the volume of streams
* When set to 0, means no timer is set.
* @type {number}
* @private
*/
this._analyserInterval = 0;
/**
* Fast Fourier Transform Array.
* Used for analysing the decibel level of streams. The array is allocated only once
* then filled by the analyser repeatedly. We only generate it when we need to listen to
* a stream's level, so we initialize it to null.
* @type {Float32Array}
* @private
*/
this._fftArray = null;
/**
* A Promise which resolves once the game audio API is unlocked and ready to use.
* @type {Promise<AudioContext>}
*/
this.unlock = this.awaitFirstGesture();
}
/**
* The Native interval for the AudioHelper to analyse audio levels from streams
* Any interval passed to startLevelReports() would need to be a multiple of this value.
* @type {number}
*/
static levelAnalyserNativeInterval = 50;
/**
* The cache size threshold after which audio buffers will be expired from the cache to make more room.
* 1 gigabyte, by default.
*/
static THRESHOLD_CACHE_SIZE_BYTES = Math.pow(1024, 3);
/**
* An internal tracker for the total size of the buffers cache.
* @type {number}
* @private
*/
#cacheSize = 0;
/* -------------------------------------------- */
/**
* Register client-level settings for global volume overrides
*/
static registerSettings() {
// Playlist Volume
game.settings.register("core", "globalPlaylistVolume", {
name: "Global Playlist Volume",
hint: "Define a global playlist volume modifier",
scope: "client",
config: false,
default: 1.0,
type: Number,
onChange: v => {
for ( let p of game.playlists ) {
for ( let s of p.sounds ) {
if ( s.playing ) s.sync();
}
}
game.audio._onChangeGlobalVolume("globalPlaylistVolume", v);
}
});
// Ambient Volume
game.settings.register("core", "globalAmbientVolume", {
name: "Global Ambient Volume",
hint: "Define a global ambient volume modifier",
scope: "client",
config: false,
default: 1.0,
type: Number,
onChange: v => {
if ( canvas.ready ) {
canvas.sounds.refresh({fade: 0});
for ( const mesh of canvas.primary.videoMeshes ) {
if ( mesh.object instanceof Tile ) mesh.sourceElement.volume = mesh.object.volume;
else mesh.sourceElement.volume = v;
}
}
game.audio._onChangeGlobalVolume("globalAmbientVolume", v);
}
});
// Interface Volume
game.settings.register("core", "globalInterfaceVolume", {
name: "Global Interface Volume",
hint: "Define a global interface volume modifier",
scope: "client",
config: false,
default: 0.5,
type: Number,
onChange: v => game.audio._onChangeGlobalVolume("globalInterfaceVolume", v)
});
}
/* -------------------------------------------- */
/**
* Create a Sound instance for a given audio source URL
* @param {object} options Audio creation options
* @param {string} options.src The source URL for the audio file
* @param {boolean} [options.singleton=true] Reuse an existing Sound for this source?
* @param {boolean} [options.preload=false] Begin loading the audio immediately?
* @param {boolean} [options.autoplay=false] Begin playing the audio as soon as it is ready?
* @param {object} [options.autoplayOptions={}] Additional options passed to the play method if autoplay is true
* @returns {Sound}
*/
create({src, singleton=true, preload=false, autoplay=false, autoplayOptions={}} = {}) {
let sound;
if ( singleton ) {
if ( this.sounds.has(src) ) sound = this.sounds.get(src);
else {
sound = new Sound(src);
this.sounds.set(src, sound);
}
} else {
sound = new Sound(src);
}
if ( preload ) sound.load({autoplay, autoplayOptions});
return sound;
}
/* -------------------------------------------- */
/**
* Test whether a source file has a supported audio extension type
* @param {string} src A requested audio source path
* @returns {boolean} Does the filename end with a valid audio extension?
*/
static hasAudioExtension(src) {
let rgx = new RegExp(`(\\.${Object.keys(CONST.AUDIO_FILE_EXTENSIONS).join("|\\.")})(\\?.*)?`, "i");
return rgx.test(src);
}
/* -------------------------------------------- */
/**
* Given an input file path, determine a default name for the sound based on the filename
* @param {string} src An input file path
* @returns {string} A default sound name for the path
*/
static getDefaultSoundName(src) {
const parts = src.split("/").pop().split(".");
parts.pop();
let name = decodeURIComponent(parts.join("."));
return name.replace(/[-_.]/g, " ").titleCase();
}
/* -------------------------------------------- */
/**
* Play a single Sound by providing its source.
* @param {string} src The file path to the audio source being played
* @param {object} [options] Additional options passed to Sound#play
* @returns {Promise<Sound>} The created Sound which is now playing
*/
async play(src, options) {
const sound = new Sound(src);
await sound.load();
sound.play(options);
return sound;
}
/* -------------------------------------------- */
/**
* Register an event listener to await the first mousemove gesture and begin playback once observed.
* @returns {Promise<AudioContext>} The unlocked audio context
*/
async awaitFirstGesture() {
if ( !this.locked ) return this.context;
await new Promise(resolve => {
for ( let eventName of ["contextmenu", "auxclick", "pointerdown", "pointerup", "keydown"] ) {
document.addEventListener(eventName, event => this._onFirstGesture(event, resolve), {once: true});
}
});
return this.context;
}
/* -------------------------------------------- */
/**
* Request that other connected clients begin preloading a certain sound path.
* @param {string} src The source file path requested for preload
* @returns {Promise<Sound>} A Promise which resolves once the preload is complete
*/
preload(src) {
if ( !src || !AudioHelper.hasAudioExtension(src) ) {
throw new Error(`Invalid audio source path ${src} provided for preload request`);
}
game.socket.emit("preloadAudio", src);
return this.constructor.preloadSound(src);
}
/* -------------------------------------------- */
/* Buffer Caching */
/* -------------------------------------------- */
/**
* Retrieve an AudioBuffer from the buffers cache, if it is available
* @param {string} src The buffer audio source path
* @returns {AudioBuffer} The AudioBuffer instance if cached, otherwise undefined
*/
getCache(src) {
const cache = this.buffers.get(src);
if ( cache ) {
cache.lastAccessed = Date.now();
return cache.buffer;
}
}
/* -------------------------------------------- */
/**
* Update the last accessed time and playing status of a cached buffer.
* @param {string} src The buffer audio source path
* @param {boolean} playing Is the buffer currently playing?
*/
updateCache(src, playing=false) {
const buffer = this.buffers.get(src);
if ( !buffer ) return;
buffer.playing = playing;
buffer.lastAccessed = Date.now();
}
/* -------------------------------------------- */
/**
* Insert an AudioBuffer into the buffers cache.
* See https://padenot.github.io/web-audio-perf/#memory-profiling
* @param {string} src The buffer audio source path
* @param {AudioBuffer} buffer The AudioBuffer instance
*/
setCache(src, buffer) {
const existing = this.buffers.get(src);
if ( existing ) return existing.lastAccessed = Date.now();
const size = buffer.length * buffer.numberOfChannels * 4;
this.buffers.set(src, {buffer, lastAccessed: Date.now(), playing: false, size});
this.#cacheSize += size;
this.#expireCache();
}
/* -------------------------------------------- */
/**
* Expire buffers from the cache when the total cache size exceeds a specified threshold.
* Buffers which were least recently accessed are removed first, provided they are not currently playing.
* @private
*/
#expireCache() {
if ( this.#cacheSize < this.constructor.THRESHOLD_CACHE_SIZE_BYTES ) return;
const entries = Array.from(this.buffers.entries());
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed); // Oldest to newest
for ( const [key, entry] of entries ) {
if ( entry.playing ) continue; // Don't expire buffers which are currently playing
console.debug(`Expiring AudioBuffer for ${key}`);
this.buffers.delete(key);
this.#cacheSize -= entry.size;
if ( this.#cacheSize < this.constructor.THRESHOLD_CACHE_SIZE_BYTES ) break;
}
}
/* -------------------------------------------- */
/* Socket Listeners and Handlers */
/* -------------------------------------------- */
/**
* Open socket listeners which transact ChatMessage data
* @param socket
*/
static _activateSocketListeners(socket) {
socket.on("playAudio", audioData => this.play(audioData, false));
socket.on("preloadAudio", src => this.preloadSound(src));
}
/* -------------------------------------------- */
/**
* Play a one-off sound effect which is not part of a Playlist
*
* @param {Object} data An object configuring the audio data to play
* @param {string} data.src The audio source file path, either a public URL or a local path relative to the public directory
* @param {number} data.volume The volume level at which to play the audio, between 0 and 1.
* @param {boolean} data.autoplay Begin playback of the audio effect immediately once it is loaded.
* @param {boolean} data.loop Loop the audio effect and continue playing it until it is manually stopped.
* @param {object|boolean} socketOptions Options which only apply when emitting playback over websocket.
* As a boolean, emits (true) or does not emit (false) playback to all other clients
* As an object, can configure which recipients should receive the event.
* @param {string[]} [socketOptions.recipients] An array of user IDs to push audio playback to. All users by default.
*
* @returns {Sound} A Sound instance which controls audio playback.
*
* @example Play the sound of a locked door for all players
* ```js
* AudioHelper.play({src: "sounds/lock.wav", volume: 0.8, loop: false}, true);
* ```
*/
static play(data, socketOptions) {
const audioData = foundry.utils.mergeObject({
src: null,
volume: 1.0,
loop: false
}, data, {insertKeys: true});
// Push the sound to other clients
const push = socketOptions && (socketOptions !== false);
if ( push ) {
socketOptions = foundry.utils.getType(socketOptions) === "Object" ? socketOptions : {};
if ( "recipients" in socketOptions && !Array.isArray(socketOptions.recipients)) {
throw new Error("Socket recipients must be an array of User IDs");
}
game.socket.emit("playAudio", audioData, socketOptions);
}
// Backwards compatibility, if autoplay was passed as false take no further action
if ( audioData.autoplay === false ) return;
// Play the sound locally
return game.audio.play(audioData.src, {
volume: (audioData.volume ?? 1) * game.settings.get("core", "globalInterfaceVolume"),
loop: audioData.loop
});
}
/* -------------------------------------------- */
/**
* Begin loading the sound for a provided source URL adding its
* @param {string} src The audio source path to preload
* @returns {Promise<Sound>} The created and loaded Sound ready for playback
*/
static async preloadSound(src) {
const sound = game.audio.create({
src: src,
preload: true,
singleton: true
});
return sound.load();
}
/* -------------------------------------------- */
/**
* Returns the volume value based on a range input volume control's position.
* This is using an exponential approximation of the logarithmic nature of audio level perception
* @param {number|string} value Value between [0, 1] of the range input
* @param {number} [order=1.5] The exponent of the curve
* @returns {number}
*/
static inputToVolume(value, order=1.5) {
return Math.pow(parseFloat(value), order);
}
/* -------------------------------------------- */
/**
* Counterpart to inputToVolume()
* Returns the input range value based on a volume
* @param {number} volume Value between [0, 1] of the volume level
* @param {number} [order=1.5] The exponent of the curve
* @returns {number}
*/
static volumeToInput(volume, order=1.5) {
return Math.pow(volume, 1 / order);
}
/* -------------------------------------------- */
/* Audio Stream Analysis */
/* -------------------------------------------- */
/**
* Returns a singleton AudioContext if one can be created.
* An audio context may not be available due to limited resources or browser compatibility
* in which case null will be returned
*
* @returns {AudioContext} A singleton AudioContext or null if one is not available
*/
getAudioContext() {
if ( this._audioContext ) return this._audioContext;
try {
// Use one Audio Context for all the analysers.
return new (AudioContext || webkitAudioContext)();
} catch(err) {
console.log("Could not create AudioContext. Will not be able to analyse stream volumes.");
}
return null;
}
/* -------------------------------------------- */
/**
* Registers a stream for periodic reports of audio levels.
* Once added, the callback will be called with the maximum decibel level of
* the audio tracks in that stream since the last time the event was fired.
* The interval needs to be a multiple of AudioHelper.levelAnalyserNativeInterval which defaults at 50ms
*
* @param {string} id An id to assign to this report. Can be used to stop reports
* @param {MediaStream} stream The MediaStream instance to report activity on.
* @param {Function} callback The callback function to call with the decibel level. `callback(dbLevel)`
* @param {number} interval (optional) The interval at which to produce reports.
* @param {number} smoothing (optional) The smoothingTimeConstant to set on the audio analyser. Refer to AudioAnalyser API docs.
* @returns {boolean} Returns whether or not listening to the stream was successful
*/
startLevelReports(id, stream, callback, interval = 50, smoothing = 0.1) {
if ( !stream || !id ) return;
let audioContext = this.getAudioContext();
if (audioContext === null) return false;
// Clean up any existing report with the same ID
this.stopLevelReports(id);
// Make sure this stream has audio tracks, otherwise we can't connect the analyser to it
if (stream.getAudioTracks().length === 0) return false;
// Create the analyser
let analyser = audioContext.createAnalyser();
analyser.fftSize = 512;
analyser.smoothingTimeConstant = smoothing;
// Connect the analyser to the MediaStreamSource
audioContext.createMediaStreamSource(stream).connect(analyser);
this._analyserStreams[id] = {
stream,
analyser,
interval,
callback,
// Used as a counter of 50ms increments in case the interval is more than 50
_lastEmit: 0
};
// Ensure the analyser timer is started as we have at least one valid stream to listen to
this._ensureAnalyserTimer();
return true;
}
/* -------------------------------------------- */
/**
* Stop sending audio level reports
* This stops listening to a stream and stops sending reports.
* If we aren't listening to any more streams, cancel the global analyser timer.
* @param {string} id The id of the reports that passed to startLevelReports.
*/
stopLevelReports(id) {
delete this._analyserStreams[id];
if ( foundry.utils.isEmpty(this._analyserStreams) ) this._cancelAnalyserTimer();
}
/* -------------------------------------------- */
/**
* Ensures the global analyser timer is started
*
* We create only one timer that runs every 50ms and only create it if needed, this is meant to optimize things
* and avoid having multiple timers running if we want to analyse multiple streams at the same time.
* I don't know if it actually helps much with performance but it's expected that limiting the number of timers
* running at the same time is good practice and with JS itself, there's a potential for a timer congestion
* phenomenon if too many are created.
* @private
*/
_ensureAnalyserTimer() {
if (this._analyserInterval === 0) {
this._analyserInterval = setInterval(this._emitVolumes.bind(this), AudioHelper.levelAnalyserNativeInterval);
}
}
/* -------------------------------------------- */
/**
* Cancel the global analyser timer
* If the timer is running and has become unnecessary, stops it.
* @private
*/
_cancelAnalyserTimer() {
if (this._analyserInterval !== 0) {
clearInterval(this._analyserInterval);
this._analyserInterval = 0;
}
}
/* -------------------------------------------- */
/**
* Capture audio level for all speakers and emit a webrtcVolumes custom event with all the volume levels
* detected since the last emit.
* The event's detail is in the form of {userId: decibelLevel}
* @private
*/
_emitVolumes() {
for (let id in this._analyserStreams) {
const analyserStream = this._analyserStreams[id];
if (++analyserStream._lastEmit < analyserStream.interval / AudioHelper.levelAnalyserNativeInterval) continue;
// Create the Fast Fourier Transform Array only once. Assume all analysers use the same fftSize
if (this._fftArray === null) this._fftArray = new Float32Array(analyserStream.analyser.frequencyBinCount);
// Fill the array
analyserStream.analyser.getFloatFrequencyData(this._fftArray);
let maxDecibel = Math.max(...this._fftArray);
analyserStream.callback(maxDecibel, this._fftArray);
analyserStream._lastEmit = 0;
}
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/**
* Handle the first observed user gesture
* @param {Event} event The mouse-move event which enables playback
* @param {Function} resolve The Promise resolution function
* @private
*/
_onFirstGesture(event, resolve) {
if ( this.locked === false ) return resolve();
this.context = new AudioContext();
this.locked = false;
if ( this.pending.length ) {
console.log(`${vtt} | Activating pending audio playback with user gesture.`);
this.pending.forEach(fn => fn());
this.pending = [];
}
return resolve();
}
/* -------------------------------------------- */
/**
* Additional standard callback events that occur whenever a global volume slider is adjusted
* @param {string} key The setting key
* @param {number} volume The new volume level
* @private
*/
_onChangeGlobalVolume(key, volume) {
Hooks.callAll(`${key}Changed`, volume);
}
}
/**
* An AudioSourceNode container which handles the strategy of node type to use for playback.
* Used by the Sound interface which controls playback.
* This class is for internal use only and should not be used by external callers.
*/
class AudioContainer {
constructor(src) {
/**
* The audio source path
* @type {string}
*/
this.src = src;
}
/**
* The Audio Node used to control this sound
* @type {AudioBufferSourceNode|MediaElementAudioSourceNode}
*/
sourceNode = undefined;
/**
* The GainNode used to control volume
* @type {GainNode}
*/
gainNode = undefined;
/**
* Is this container using an AudioBuffer?
* @type {boolean}
*/
isBuffer = false;
/**
* Whether we have attempted to load the audio node or not, and whether it failed.
* @see {LOAD_STATES}
* @type {number}
*/
loadState = AudioContainer.LOAD_STATES.NONE;
/**
* Is the audio source currently playing?
* @type {boolean}
*/
playing = false;
/**
* Should the audio source loop?
* @type {boolean}
* @private
*/
_loop = false;
get loop() {
return this._loop;
}
set loop(looping) {
this._loop = looping;
if ( !this.sourceNode ) return;
if ( this.isBuffer ) this.sourceNode.loop = looping;
}
/**
* The maximum duration, in seconds, for which an AudioBuffer will be used.
* Otherwise, a streaming media element will be used.
* @type {number}
*/
static MAX_BUFFER_DURATION = 10 * 60; // 10 Minutes
/**
* The sequence of container loading states.
* @enum {number}
*/
static LOAD_STATES = {
FAILED: -1,
NONE: 0,
LOADING: 1,
LOADED: 2
};
/**
* Has the audio file been loaded either fully or for streaming.
* @type {boolean}
*/
get loaded() {
return this.loadState === AudioContainer.LOAD_STATES.LOADED;
}
/**
* Did the audio file fail to load.
* @type {boolean}
*/
get failed() {
return this.loadState === AudioContainer.LOAD_STATES.FAILED;
}
/* -------------------------------------------- */
/* Container Attributes */
/* -------------------------------------------- */
/**
* A reference to the AudioBuffer if the sourceNode is a AudioBufferSourceNode.
* @returns {AudioBuffer}
*/
get buffer() {
return this.sourceNode.buffer;
}
/* -------------------------------------------- */
/**
* The game audio context used throughout the application.
* @returns {AudioContext}
*/
get context() {
return game.audio.context;
}
/* -------------------------------------------- */
/**
* The total duration of the audio source in seconds
* @type {number}
*/
get duration() {
if ( !this.loaded || this.failed ) return undefined;
if ( this.isBuffer ) return this.buffer.duration;
else return this.element.duration;
}
/* -------------------------------------------- */
/**
* A reference to the HTMLMediaElement, if the sourceNode is a MediaElementAudioSourceNode.
* @returns {HTMLMediaElement}
*/
get element() {
return this.sourceNode.mediaElement;
}
/* -------------------------------------------- */
/* Constructor Methods */
/* -------------------------------------------- */
/**
* Load the source node required for playback of this audio source
* @returns {Promise<void>}
*/
async load() {
this.loadState = AudioContainer.LOAD_STATES.LOADING;
this.sourceNode = await this._createNode();
if ( !this.sourceNode ) {
this.loadState = AudioContainer.LOAD_STATES.FAILED;
return;
}
this.gainNode = this.context.createGain();
this.sourceNode.connect(this.gainNode);
this.gainNode.connect(this.context.destination);
this.loadState = AudioContainer.LOAD_STATES.LOADED;
}
/* -------------------------------------------- */
/**
* Create the initial audio node used for playback.
* Determine the node type to use based on cached state and sound duration.
* @returns {AudioBufferSourceNode|MediaElementAudioSourceNode}
* @private
*/
async _createNode() {
// If an audio buffer is cached, use an AudioBufferSourceNode
const cached = game.audio.getCache(this.src);
if ( cached ) return this._createAudioBufferSourceNode(cached);
// Otherwise, check the element duration using HTML5 audio
let element;
try {
element = await this._createAudioElement();
} catch(err) {
console.error(`${vtt} | Failed to load audio node:`, err);
return;
}
const isShort = element.duration && (element.duration <= this.constructor.MAX_BUFFER_DURATION);
// For short sounds create and cache the audio buffer and use an AudioBufferSourceNode
if ( isShort ) {
const buffer = await this.createAudioBuffer(this.src);
console.debug(`${vtt} | Constructing audio buffer source node - ${this.src}`);
return this._createAudioBufferSourceNode(buffer);
}
// For long or streamed sounds, use a MediaElementAudioSourceNode
console.debug(`${vtt} | Constructing audio element source node - ${this.src}`);
return this._createMediaElementAudioSourceNode(element);
}
/* -------------------------------------------- */
/**
* Create an Audio source node using a buffered array.
* @param {string} src The source URL from which to create the buffer
* @returns {Promise<AudioBuffer>} The created and decoded buffer
*/
async createAudioBuffer(src) {
console.debug(`${vtt} | Loading audio buffer - ${src}`);
const response = await foundry.utils.fetchWithTimeout(src);
const arrayBuffer = await response.arrayBuffer();
return this.context.decodeAudioData(arrayBuffer);
}
/* -------------------------------------------- */
/**
* Create a AudioBufferSourceNode using a provided AudioBuffer
* @private
*/
_createAudioBufferSourceNode(buffer) {
this.isBuffer = true;
game.audio.setCache(this.src, buffer);
return new AudioBufferSourceNode(this.context, {buffer});
}
/* -------------------------------------------- */
/**
* Create an HTML5 Audio element which has loaded the metadata for the provided source.
* @returns {Promise<HTMLAudioElement>}
* @private
*/
async _createAudioElement() {
console.debug(`${vtt} | Loading audio element - ${this.src}`);
return new Promise((resolve, reject) => {
const element = new Audio();
element.autoplay = false;
element.crossOrigin = "anonymous";
element.onloadedmetadata = () => resolve(element);
element.onload = () => resolve(element);
element.onerror = reject;
element.src = this.src;
});
}
/* -------------------------------------------- */
/**
* Create a MediaElementAudioSourceNode using a provided HTMLAudioElement
* @private
*/
_createMediaElementAudioSourceNode(element) {
this.isBuffer = false;
return new MediaElementAudioSourceNode(this.context, {mediaElement: element});
}
/* -------------------------------------------- */
/* Playback Methods */
/* -------------------------------------------- */
/**
* Begin playback for the source node.
* @param {number} [offset] The desired start time
* @param {Function} [onended] A callback function for when playback concludes naturally
*/
play(offset=0, onended=undefined) {
if ( this.isBuffer ) {
this.sourceNode.onended = () => this._onEnd(onended);
this.sourceNode.start(0, offset);
game.audio.updateCache(this.src, true);
}
else {
this.element.currentTime = offset;
this.element.onended = () => this._onEnd(onended);
this.element.play();
}
this.playing = true;
}
/* -------------------------------------------- */
/**
* Terminate playback for the source node.
*/
stop() {
this.playing = false;
if ( this.isBuffer ) {
this.sourceNode.onended = undefined;
this.sourceNode.stop(0);
game.audio.updateCache(this.src, false);
}
this._unloadMediaNode();
}
/* -------------------------------------------- */
/**
* Perform cleanup actions when the sound has finished playing. For
* MediaElementAudioSourceNodes, this also means optionally restarting if
* the sound is supposed to loop.
* @param {Function} onended A callback provided by the owner of the container that gets fired when the sound ends.
* @private
*/
_onEnd(onended) {
if ( !this.isBuffer && this._loop ) return this.play(0, onended);
onended();
this.stop();
}
/* -------------------------------------------- */
/**
* Unload the MediaElementAudioSourceNode to terminate any ongoing
* connections.
* @private
*/
_unloadMediaNode() {
console.debug(`${vtt} | Unloading audio element - ${this.src}`);
const element = this.element;
// Deconstruct the audio pipeline
this.sourceNode.disconnect(this.gainNode);
this.gainNode.disconnect(this.context.destination);
this.loadState = AudioContainer.LOAD_STATES.NONE;
this.sourceNode = this.gainNode = undefined;
// Unload media streams
if ( !this.isBuffer ) {
element.onended = undefined;
element.pause();
element.src = "";
element.remove();
}
}
}
/**
* The Sound class is used to control the playback of audio sources using the Web Audio API.
*/
class Sound {
constructor(src, {container}={}) {
/**
* The numeric identifier for accessing this node
* @type {number}
*/
this.id = ++Sound._nodeId;
/**
* The audio source path
* @type {string}
*/
this.src = src;
/**
* The AudioContainer which controls playback
* @type {AudioContainer}
*/
this.container = container || new AudioContainer(this.src);
}
/* -------------------------------------------- */
/**
* The time in seconds at which playback was started
* @type {number}
*/
startTime = undefined;
/**
* The time in seconds at which playback was paused
* @type {number}
*/
pausedTime = undefined;
/**
* Registered event callbacks
* @type {{stop: {}, start: {}, end: {}, pause: {}, load: {}}}
*/
events = {
end: {},
pause: {},
start: {},
stop: {},
load: {}
};
/**
* The registered event handler id for this Sound.
* Incremented each time a callback is registered.
* @type {number}
* @private
*/
_eventHandlerId = 1;
/**
* If this Sound source is currently in the process of loading, this attribute contains a Promise that will resolve
* when the loading process completes.
* @type {Promise}
*/
loading = undefined;
/**
* A collection of scheduled events recorded as window timeout IDs
* @type {Set<number>}
* @private
*/
_scheduledEvents = new Set();
/**
* A global audio node ID used to quickly reference a specific audio node
* @type {number}
* @private
*/
static _nodeId = 0;
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* A convenience reference to the sound context used by the application
* @returns {AudioContext}
*/
get context() {
return game.audio.context;
}
/**
* A reference to the audio source node being used by the AudioContainer
* @returns {AudioBufferSourceNode|MediaElementAudioSourceNode}
*/
get node() {
return this.container.sourceNode;
}
/**
* A reference to the GainNode parameter which controls volume
* @type {AudioParam}
*/
get gain() {
return this.container.gainNode?.gain;
}
/**
* The current playback time of the sound
* @returns {number}
*/
get currentTime() {
if ( !this.playing ) return undefined;
if ( this.pausedTime ) return this.pausedTime;
let time = this.context.currentTime - this.startTime;
if ( Number.isFinite(this.duration) ) time %= this.duration;
return time;
}
/**
* The total sound duration, in seconds
* @type {number}
*/
get duration() {
return this.container.duration;
}
/**
* Is the contained audio node loaded and ready for playback?
* @type {boolean}
*/
get loaded() {
return this.container.loaded;
}
/**
* Did the contained audio node fail to load?
* @type {boolean}
*/
get failed() {
return this.container.failed;
}
/**
* Is the audio source currently playing?
* @type {boolean}
*/
get playing() {
return this.container.playing;
}
/**
* Is the Sound current looping?
* @type {boolean}
*/
get loop() {
return this.container.loop;
}
set loop(looping) {
this.container.loop = looping;
}
/**
* The volume at which the Sound is playing
* @returns {number}
*/
get volume() {
return this.gain?.value;
}
set volume(value) {
if ( !this.node || !Number.isNumeric(value) ) return;
const ct = this.context.currentTime;
this.gain.cancelScheduledValues(ct);
this.gain.setValueAtTime(this.gain.value = value, ct); // Important - immediately "schedule" the current value
}
/* -------------------------------------------- */
/* Control Methods */
/* -------------------------------------------- */
/**
* Fade the volume for this sound between its current level and a desired target volume
* @param {number} volume The desired target volume level between 0 and 1
* @param {object} [options={}] Additional options that configure the fade operation
* @param {number} [options.duration=1000] The duration of the fade effect in milliseconds
* @param {number} [options.from] A volume level to start from, the current volume by default
* @param {string} [options.type=linear] The type of fade easing, "linear" or "exponential"
* @returns {Promise<void>} A Promise that resolves after the requested fade duration
*/
async fade(volume, {duration=1000, from, type="linear"}={}) {
if ( !this.gain ) return;
const ramp = this.gain[`${type}RampToValueAtTime`];
if ( !ramp ) throw new Error(`Invalid fade type ${type} requested`);
const ct = this.context.currentTime;
// Schedule the fade
this.gain.cancelScheduledValues(ct); // Cancel any existing transition
this.gain.setValueAtTime(from ?? this.gain.value, ct); // Important - immediately "schedule" the current value
ramp.call(this.gain, volume, ct + (duration / 1000));
return new Promise(resolve => window.setTimeout(resolve, duration));
}
/* -------------------------------------------- */
/**
* Load the audio source, creating an AudioBuffer.
* Audio loading is idempotent, it can be requested multiple times but only the first load request will be honored.
* @param {object} [options={}] Additional options which affect resource loading
* @param {boolean} [options.autoplay=false] Automatically begin playback of the audio source once loaded
* @param {object} [options.autoplayOptions] Additional options passed to the play method when loading is complete
* @returns {Promise<Sound>} The Sound once its source audio buffer is loaded
*/
async load({autoplay=false, autoplayOptions={}}={}) {
// Delay audio loading until after an observed user gesture
if ( game.audio.locked ) {
console.log(`${vtt} | Delaying load of sound ${this.src} until after first user gesture`);
await new Promise(resolve => game.audio.pending.push(resolve));
}
// Currently loading
if ( this.loading instanceof Promise ) await this.loading;
// If loading is required, cache the promise for idempotency
if ( !this.container || this.container.loadState === AudioContainer.LOAD_STATES.NONE ) {
this.loading = this.container.load();
await this.loading;
this.loading = undefined;
}
// Trigger automatic playback actions
if ( autoplay ) this.play(autoplayOptions);
return this;
}
/* -------------------------------------------- */
/**
* Begin playback for the sound node
* @param {object} [options={}] Options which configure playback
* @param {boolean} [options.loop=false] Whether to loop the audio automatically
* @param {number} [options.offset] A specific offset in seconds at which to begin playback
* @param {number} [options.volume] The desired volume at which to begin playback
* @param {number} [options.fade=0] Fade volume changes over a desired duration in milliseconds
*/
play({loop=false, offset, volume, fade=0}={}) {
if ( this.failed ) {
this._onEnd();
return;
}
if ( !this.loaded ) {
return console.warn(`You cannot play Sound ${this.src} before it has loaded`);
}
// If we are still awaiting the first user interaction, add this playback to a pending queue
if ( game.audio.locked ) {
console.log(`${vtt} | Delaying playback of sound ${this.src} until after first user gesture`);
return game.audio.pending.push(() => this.play({loop, offset, volume}));
}
// Adjust volume and looping
const adjust = () => {
this.loop = loop;
if ( (volume !== undefined) && (volume !== this.volume) ) {
if ( fade ) return this.fade(volume, {duration: fade});
else this.volume = volume;
}
};
// If the sound is already playing, and a specific offset is not provided, do nothing
if ( this.playing ) {
if ( offset === undefined ) return adjust();
this.stop();
}
// Configure playback
offset = offset ?? this.pausedTime ?? 0;
if ( Number.isFinite(this.duration) ) offset %= this.duration;
this.startTime = this.context.currentTime - offset;
this.pausedTime = undefined;
// Start playback
this.volume = 0; // Start volume at 0
this.container.play(offset, this._onEnd.bind(this));
adjust(); // Adjust to the desired volume
this._onStart();
}
/* -------------------------------------------- */
/**
* Pause playback, remembering the playback position in order to resume later.
*/
pause() {
this.pausedTime = this.currentTime;
this.startTime = undefined;
this.container.stop();
this._onPause();
}
/* -------------------------------------------- */
/**
* Stop playback, fully resetting the Sound to a non-playing state.
*/
stop() {
if ( this.playing === false ) return;
this.pausedTime = undefined;
this.startTime = undefined;
this.container.stop();
this._onStop();
}
/* -------------------------------------------- */
/**
* Schedule a function to occur at the next occurrence of a specific playbackTime for this Sound.
* @param {Function} fn A function that will be called with this Sound as its single argument
* @param {number} playbackTime The desired playback time at which the function should be called
* @returns {Promise<null>} A Promise which resolves once the scheduled function has been called
*
* @example Schedule audio playback changes
* ```js
* sound.schedule(() => console.log("Do something exactly 30 seconds into the track"), 30);
* sound.schedule(() => console.log("Do something next time the track loops back to the beginning"), 0);
* sound.schedule(() => console.log("Do something 5 seconds before the end of the track"), sound.duration - 5);
* ```
*/
schedule(fn, playbackTime) {
const now = this.currentTime;
playbackTime = Math.clamped(playbackTime, 0, this.duration);
if ( (playbackTime < now) && Number.isFinite(duration) ) playbackTime += this.duration;
const deltaMS = Math.max(0, (playbackTime - now) * 1000);
return new Promise(resolve => {
const timeoutId = setTimeout(() => {
this._scheduledEvents.delete(timeoutId);
fn(this);
return resolve();
}, deltaMS);
this._scheduledEvents.add(timeoutId);
});
}
/* -------------------------------------------- */
/* Event Emitter */
/* -------------------------------------------- */
/**
* Trigger registered callback functions for a specific event name.
* @param {string} eventName The event name being emitted
*/
emit(eventName) {
const events = this.events[eventName]
if ( !events ) return;
for ( let [fnId, callback] of Object.entries(events) ) {
callback.fn(this);
if ( callback.once ) delete events[fnId];
}
}
/* -------------------------------------------- */
/**
* Deactivate an event handler which was previously registered for a specific event
* @param {string} eventName The event name being deactivated
* @param {number|Function} fn The callback ID or callback function being un-registered
*/
off(eventName, fn) {
const events = this.events[eventName];
if ( !events ) return;
if ( Number.isNumeric(fn) ) delete events[fn];
for ( let [id, f] of Object.entries(events) ) {
if ( f === fn ) {
delete events[id];
break;
}
}
}
/* -------------------------------------------- */
/**
* Register an event handler to take actions for a certain Sound event.
* @param {string} eventName The event name being deactivated
* @param {Function} fn The callback function to trigger when the event occurs
* @param {object} [options={}] Additional options that affect callback registration
* @param {boolean} [options.once=false] Trigger the callback once only and automatically un-register it
*/
on(eventName, fn, {once=false}={}) {
return this._registerForEvent(eventName, {fn, once});
}
/* -------------------------------------------- */
/**
* Register a new callback function for a certain event. For internal use only.
* @private
*/
_registerForEvent(eventName, callback) {
const events = this.events[eventName];
if ( !events ) return;
const fnId = this._eventHandlerId++;
events[fnId] = callback;
return fnId;
}
/* -------------------------------------------- */
/**
* Cancel all pending scheduled events.
* @private
*/
_clearEvents() {
for ( let timeoutId of this._scheduledEvents ) {
window.clearTimeout(timeoutId);
}
this._scheduledEvents.clear();
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/**
* Called when playback concludes naturally
* @protected
*/
_onEnd() {
this._clearEvents();
game.audio.playing.delete(this.id);
this.emit("end");
}
/**
* Called when the audio buffer is first loaded
* @protected
*/
_onLoad() {
this.emit("load");
}
/**
* Called when playback is paused
* @protected
*/
_onPause() {
this._clearEvents();
this.emit("pause");
}
/**
* Called when the sound begins playing
* @protected
*/
_onStart() {
game.audio.playing.set(this.id, this);
this.emit("start");
}
/**
* Called when playback is stopped (prior to naturally reaching the end)
* @protected
*/
_onStop() {
this._clearEvents();
game.audio.playing.delete(this.id);
this.emit("stop");
}
}
/**
* A tour for demonstrating an aspect of Canvas functionality.
* Automatically activates a certain canvas layer or tool depending on the needs of the step.
*/
class CanvasTour extends Tour {
/** @override */
async start() {
game.togglePause(false);
await super.start();
}
/* -------------------------------------------- */
/** @override */
get canStart() {
return !!canvas.scene;
}
/* -------------------------------------------- */
/** @override */
async _preStep() {
await super._preStep();
this.#activateTool();
}
/* -------------------------------------------- */
/**
* Activate a canvas layer and control for each step
*/
#activateTool() {
if ( "layer" in this.currentStep && canvas.scene ) {
const layer = canvas[this.currentStep.layer];
if ( layer.active ) ui.controls.initialize({tool: this.currentStep.tool});
else layer.activate({tool: this.currentStep.tool});
}
}
}
/**
* @typedef {TourConfig} SetupTourConfig
* @property {boolean} [closeWindows=true] Whether to close all open windows before beginning the tour.
*/
/**
* A Tour subclass that handles controlling the UI state of the Setup screen
*/
class SetupTour extends Tour {
/**
* Stores a currently open Application for future steps
* @type {Application}
*/
focusedApp;
/* -------------------------------------------- */
/** @override */
get canStart() {
return game.view === "setup";
}
/* -------------------------------------------- */
/** @override */
get steps() {
return this.config.steps; // A user is always "GM" for Setup Tours
}
/* -------------------------------------------- */
/** @override */
async _preStep() {
await super._preStep();
// Close currently open applications
if ( (this.stepIndex === 0) && (this.config.closeWindows !== false) ) {
for ( const app of Object.values(ui.windows) ) {
app.close();
}
}
// Configure specific steps
switch ( this.id ) {
case "installingASystem": return this._installingASystem();
case "creatingAWorld": return this._creatingAWorld();
}
}
/* -------------------------------------------- */
/**
* Handle Step setup for the Installing a System Tour
* @returns {Promise<void>}
* @private
*/
async _installingASystem() {
// Activate Systems tab and warm cache
if ( this.currentStep.id === "systemsTab" ) {
ui.setupPackages.activateTab("systems");
// noinspection ES6MissingAwait
Setup.warmPackages({type: "system"});
}
// Render the InstallPackage app with a filter
else if ( this.currentStep.id === "searching" ) {
await Setup.browsePackages("system", {search: "Simple Worldbuilding"});
}
}
/* -------------------------------------------- */
/**
* Handle Step setup for the Creating a World Tour
* @returns {Promise<void>}
* @private
*/
async _creatingAWorld() {
// Activate the World tab
if ( this.currentStep.id === "worldTab" ) {
ui.setupPackages.activateTab("world");
}
else if ( this.currentStep.id === "worldTitle" ) {
let world = new World({
name: "my-first-world",
title: "My First World",
system: Array.from(game.systems)[0].id,
coreVersion: game.release.version,
description: game.i18n.localize("SETUP.NueWorldDescription")
});
const options = {
create: true
};
// Render the World configuration application
this.focusedApp = new WorldConfig(world, options);
await this.focusedApp._render(true);
}
else if ( this.currentStep.id === "launching" ) {
await this.focusedApp.submit();
}
}
}
/**
* A Tour subclass for the Sidebar Tour
*/
class SidebarTour extends Tour {
/** @override */
async start() {
game.togglePause(false);
await super.start();
}
/* -------------------------------------------- */
/** @override */
async _preStep() {
await super._preStep();
// Configure specific steps
if ( (this.id === "sidebar") || (this.id === "welcome") ) {
await this._updateSidebarTab();
}
}
/* -------------------------------------------- */
async _updateSidebarTab() {
if ( this.currentStep.sidebarTab ) {
ui.sidebar.activateTab(this.currentStep.sidebarTab);
}
}
}