This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View File

@@ -0,0 +1,211 @@
{
"env": {
"browser": true,
"es2022": true,
"node": true,
"jquery": true
},
"parserOptions": {
"requireConfigFile": false
},
"plugins": [
"jsdoc"
],
"rules": {
"array-bracket-spacing": ["warn", "never"],
"array-callback-return": "warn",
"arrow-spacing": "warn",
"comma-dangle": ["warn", "never"],
"comma-style": "warn",
"computed-property-spacing": "warn",
"constructor-super": "error",
"default-param-last": "warn",
"dot-location": ["warn", "property"],
"eol-last": ["error", "always"],
"eqeqeq": ["warn", "smart"],
"func-call-spacing": "warn",
"func-names": ["warn", "never"],
"getter-return": "warn",
"lines-between-class-members": "warn",
"new-parens": ["warn", "always"],
"no-alert": "warn",
"no-array-constructor": "warn",
"no-class-assign": "warn",
"no-compare-neg-zero": "warn",
"no-cond-assign": "warn",
"no-const-assign": "error",
"no-constant-condition": "warn",
"no-constructor-return": "warn",
"no-delete-var": "warn",
"no-dupe-args": "warn",
"no-dupe-class-members": "warn",
"no-dupe-keys": "warn",
"no-duplicate-case": "warn",
"no-duplicate-imports": ["warn", {"includeExports": true}],
"no-empty": ["warn", {"allowEmptyCatch": true}],
"no-empty-character-class": "warn",
"no-empty-pattern": "warn",
"no-func-assign": "warn",
"no-global-assign": "warn",
"no-implicit-coercion": ["warn", {"allow": ["!!"]}],
"no-implied-eval": "warn",
"no-import-assign": "warn",
"no-invalid-regexp": "warn",
"no-irregular-whitespace": "warn",
"no-iterator": "warn",
"no-lone-blocks": "warn",
"no-lonely-if": "off",
"no-loop-func": "warn",
"no-misleading-character-class": "warn",
"no-mixed-operators": "warn",
"no-multi-str": "warn",
"no-multiple-empty-lines": "warn",
"no-new-func": "warn",
"no-new-object": "warn",
"no-new-symbol": "warn",
"no-new-wrappers": "warn",
"no-nonoctal-decimal-escape": "warn",
"no-obj-calls": "warn",
"no-octal": "warn",
"no-octal-escape": "warn",
"no-promise-executor-return": "warn",
"no-proto": "warn",
"no-regex-spaces": "warn",
"no-script-url": "warn",
"no-self-assign": "warn",
"no-self-compare": "warn",
"no-setter-return": "warn",
"no-sequences": "warn",
"no-template-curly-in-string": "warn",
"no-this-before-super": "error",
"no-unexpected-multiline": "warn",
"no-unmodified-loop-condition": "warn",
"no-unneeded-ternary": "warn",
"no-unreachable": "warn",
"no-unreachable-loop": "warn",
"no-unsafe-negation": ["warn", {"enforceForOrderingRelations": true}],
"no-unsafe-optional-chaining": ["warn", {"disallowArithmeticOperators": true}],
"no-unused-expressions": "warn",
"no-useless-backreference": "warn",
"no-useless-call": "warn",
"no-useless-catch": "warn",
"no-useless-computed-key": ["warn", {"enforceForClassMembers": true}],
"no-useless-concat": "warn",
"no-useless-constructor": "warn",
"no-useless-rename": "warn",
"no-useless-return": "warn",
"no-var": "warn",
"no-void": "warn",
"no-whitespace-before-property": "warn",
"prefer-numeric-literals": "warn",
"prefer-object-spread": "warn",
"prefer-regex-literals": "warn",
"prefer-spread": "warn",
"rest-spread-spacing": ["warn", "never"],
"semi-spacing": "warn",
"semi-style": ["warn", "last"],
"space-unary-ops": ["warn", {"words": true, "nonwords": false}],
"switch-colon-spacing": "warn",
"symbol-description": "warn",
"template-curly-spacing": ["warn", "never"],
"unicode-bom": ["warn", "never"],
"use-isnan": ["warn", {"enforceForSwitchCase": true, "enforceForIndexOf": true}],
"valid-typeof": ["warn", {"requireStringLiterals": true}],
"wrap-iife": ["warn", "inside"],
"arrow-parens": ["warn", "as-needed", {"requireForBlockBody": false}],
"capitalized-comments": ["warn", "always", {
"ignoreConsecutiveComments": true,
"ignorePattern": "noinspection"
}],
"comma-spacing": "warn",
"dot-notation": "warn",
"indent": ["warn", 2, {"SwitchCase": 1}],
"key-spacing": "warn",
"keyword-spacing": ["warn", {"overrides": {"catch": {"before": true, "after": false}}}],
"max-len": ["warn", {
"code": 120,
"ignoreTrailingComments": true,
"ignoreUrls": true,
"ignoreStrings": true,
"ignoreTemplateLiterals": true
}],
"no-extra-boolean-cast": ["warn", {"enforceForLogicalOperands": true}],
"no-extra-semi": "warn",
"no-multi-spaces": ["warn", {"ignoreEOLComments": true}],
"no-tabs": "warn",
"no-throw-literal": "error",
"no-trailing-spaces": "warn",
"no-useless-escape": "warn",
"nonblock-statement-body-position": ["warn", "beside"],
"one-var": ["warn", "never"],
"operator-linebreak": ["warn", "before", {
"overrides": {"=": "after", "+=": "after", "-=": "after"}
}],
"prefer-template": "warn",
"quote-props": ["warn", "as-needed", {"keywords": false}],
"quotes": ["warn", "double", {"avoidEscape": true, "allowTemplateLiterals": false}],
"semi": "warn",
"space-before-blocks": ["warn", "always"],
"space-before-function-paren": ["warn", {
"anonymous": "never",
"named": "never",
"asyncArrow": "always"
}],
"spaced-comment": "warn",
"jsdoc/check-access": "warn",
"jsdoc/check-alignment": "warn",
"jsdoc/check-examples": "off",
"jsdoc/check-indentation": "off",
"jsdoc/check-line-alignment": "off",
"jsdoc/check-param-names": "warn",
"jsdoc/check-property-names": "warn",
"jsdoc/check-syntax": "off",
"jsdoc/check-tag-names": ["warn", { "definedTags": ["category"] }],
"jsdoc/check-types": "warn",
"jsdoc/check-values": "warn",
"jsdoc/empty-tags": "warn",
"jsdoc/implements-on-classes": "warn",
"jsdoc/match-description": "off",
"jsdoc/newline-after-description": "off",
"jsdoc/no-bad-blocks": "warn",
"jsdoc/no-defaults": "off",
"jsdoc/no-types": "off",
"jsdoc/no-undefined-types": "off",
"jsdoc/require-description": "warn",
"jsdoc/require-description-complete-sentence": "off",
"jsdoc/require-example": "off",
"jsdoc/require-file-overview": "off",
"jsdoc/require-hyphen-before-param-description": ["warn", "never"],
"jsdoc/require-jsdoc": "warn",
"jsdoc/require-param": "warn",
"jsdoc/require-param-description": "off",
"jsdoc/require-param-name": "warn",
"jsdoc/require-param-type": "warn",
"jsdoc/require-property": "warn",
"jsdoc/require-property-description": "off",
"jsdoc/require-property-name": "warn",
"jsdoc/require-property-type": "warn",
"jsdoc/require-returns": "off",
"jsdoc/require-returns-check": "warn",
"jsdoc/require-returns-description": "off",
"jsdoc/require-returns-type": "warn",
"jsdoc/require-throws": "off",
"jsdoc/require-yields": "warn",
"jsdoc/require-yields-check": "warn",
"jsdoc/valid-types": "off"
},
"settings": {
"jsdoc": {
"preferredTypes": {
".<>": "<>",
"object": "Object",
"Object": "object"
},
"mode": "typescript",
"tagNamePreference": {
"augments": "extends"
}
}
}
}

View File

@@ -0,0 +1,30 @@
/** @module applications */
export * as types from "./_types.mjs";
export * as api from "./api/_module.mjs";
export * as dice from "./dice/_module.mjs";
export * as elements from "./elements/_module.mjs";
export * as fields from "./forms/fields.mjs";
export * as apps from "./apps/_module.mjs";
export * as sheets from "./sheets/_module.mjs";
export * as ui from "./ui/_module.mjs";
/**
* A registry of currently rendered ApplicationV2 instances.
* @type {Map<number, ApplicationV2>}
*/
export const instances = new Map();
/**
* Parse an HTML string, returning a processed HTMLElement or HTMLCollection.
* A single HTMLElement is returned if the provided string contains only a single top-level element.
* An HTMLCollection is returned if the provided string contains multiple top-level elements.
* @param {string} htmlString
* @returns {HTMLCollection|HTMLElement}
*/
export function parseHTML(htmlString) {
const div = document.createElement("div");
div.innerHTML = htmlString;
const children = div.children;
return children.length > 1 ? children : children[0];
}

View File

@@ -0,0 +1,146 @@
/**
* @typedef {Object} ApplicationConfiguration
* @property {string} id An HTML element identifier used for this Application instance
* @property {string} uniqueId An string discriminator substituted for {id} in the default
* HTML element identifier for the class
* @property {string[]} classes An array of CSS classes to apply to the Application
* @property {string} tag The HTMLElement tag type used for the outer Application frame
* @property {ApplicationWindowConfiguration} window Configuration of the window behaviors for this Application
* @property {Record<string, ApplicationClickAction|{handler: ApplicationClickAction, buttons: number[]}>} actions
* Click actions supported by the Application and their event handler
* functions. A handler function can be defined directly which only
* responds to left-click events. Otherwise, an object can be declared
* containing both a handler function and an array of buttons which are
* matched against the PointerEvent#button property.
* @property {ApplicationFormConfiguration} [form] Configuration used if the application top-level element is a form or
* dialog
* @property {Partial<ApplicationPosition>} position Default positioning data for the application
*/
/**
* @typedef {Object} ApplicationPosition
* @property {number} top Window offset pixels from top
* @property {number} left Window offset pixels from left
* @property {number|"auto"} width Un-scaled pixels in width or "auto"
* @property {number|"auto"} height Un-scaled pixels in height or "auto"
* @property {number} scale A numeric scaling factor applied to application dimensions
* @property {number} zIndex A z-index of the application relative to siblings
*/
/**
* @typedef {Object} ApplicationWindowConfiguration
* @property {boolean} [frame=true] Is this Application rendered inside a window frame?
* @property {boolean} [positioned=true] Can this Application be positioned via JavaScript or only by CSS
* @property {string} [title] The window title. Displayed only if the application is framed
* @property {string|false} [icon] An optional Font Awesome icon class displayed left of the window title
* @property {ApplicationHeaderControlsEntry[]} [controls] An array of window control entries
* @property {boolean} [minimizable=true] Can the window app be minimized by double-clicking on the title
* @property {boolean} [resizable=false] Is this window resizable?
* @property {string} [contentTag="section"] A specific tag name to use for the .window-content element
* @property {string[]} [contentClasses] Additional CSS classes to apply to the .window-content element
*/
/**
* @typedef {Object} ApplicationFormConfiguration
* @property {ApplicationFormSubmission} handler
* @property {boolean} submitOnChange
* @property {boolean} closeOnSubmit
*/
/**
* @typedef {Object} ApplicationHeaderControlsEntry
* @property {string} icon A font-awesome icon class which denotes the control button
* @property {string} label The text label for the control button. This label will be automatically
* localized when the button is rendered
* @property {string} action The action name triggered by clicking the control button
* @property {boolean} [visible] Is the control button visible for the current client?
* @property {string|number} [ownership] A key or value in CONST.DOCUMENT_OWNERSHIP_LEVELS that restricts
* visibility of this option for the current user. This option only
* applies to DocumentSheetV2 instances.
*/
/**
* @typedef {Object} ApplicationConstructorParams
* @property {ApplicationPosition} position
*/
/**
* @typedef {Object} ApplicationRenderOptions
* @property {boolean} [force=false] Force application rendering. If true, an application which does not
* yet exist in the DOM is added. If false, only applications which
* already exist are rendered.
* @property {ApplicationPosition} [position] A specific position at which to render the Application
* @property {ApplicationWindowRenderOptions} [window] Updates to the Application window frame
* @property {string[]} [parts] Some Application classes, for example the HandlebarsApplication,
* support re-rendering a subset of application parts instead of the full
* Application HTML.
* @property {boolean} [isFirstRender] Is this render the first one for the application? This property is
* populated automatically.
*/
/**
* @typedef {Object} ApplicationWindowRenderOptions
* @property {string} title Update the window title with a new value?
* @property {string|false} icon Update the window icon with a new value?
* @property {boolean} controls Re-render the window controls menu?
*/
/**
* @typedef {Object} ApplicationRenderContext Context data provided to the renderer
*/
/**
* @typedef {Object} ApplicationClosingOptions
* @property {boolean} animate Whether to animate the close, or perform it instantaneously
* @property {boolean} closeKey Whether the application was closed via keypress.
*/
/**
* @callback ApplicationClickAction An on-click action supported by the Application. Run in the context of
* a {@link HandlebarsApplication}.
* @param {PointerEvent} event The originating click event
* @param {HTMLElement} target The capturing HTML element which defines the [data-action]
* @returns {Promise<void>}
*/
/**
* @callback ApplicationFormSubmission A form submission handler method. Run in the context of a
* {@link HandlebarsApplication}.
* @param {SubmitEvent|Event} event The originating form submission or input change event
* @param {HTMLFormElement} form The form element that was submitted
* @param {FormDataExtended} formData Processed data for the submitted form
* @returns {Promise<void>}
*/
/**
* @typedef {Object} ApplicationTab
* @property {string} id
* @property {string} group
* @property {string} icon
* @property {string} label
* @property {boolean} active
* @property {string} cssClass
*/
/**
* @typedef {Object} FormNode
* @property {boolean} fieldset
* @property {string} [legend]
* @property {FormNode[]} [fields]
* @property {DataField} [field]
* @property {any} [value]
*/
/**
* @typedef {Object} FormFooterButton
* @property {string} type
* @property {string} [name]
* @property {string} [icon]
* @property {string} [label]
* @property {string} [action]
* @property {string} [cssClass]
* @property {boolean} [disabled=false]
*/

View File

@@ -0,0 +1,4 @@
export {default as ApplicationV2} from "./application.mjs";
export {default as DialogV2} from "./dialog.mjs";
export {default as DocumentSheetV2} from "./document-sheet.mjs";
export {default as HandlebarsApplicationMixin} from "./handlebars-application.mjs";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,342 @@
import ApplicationV2 from "./application.mjs";
import {mergeObject} from "../../../common/utils/helpers.mjs";
/**
* @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration
*/
/**
* @typedef {Object} DialogV2Button
* @property {string} action The button action identifier.
* @property {string} label The button label. Will be localized.
* @property {string} [icon] FontAwesome icon classes.
* @property {string} [class] CSS classes to apply to the button.
* @property {boolean} [default] Whether this button represents the default action to take if the user
* submits the form without pressing a button, i.e. with an Enter
* keypress.
* @property {DialogV2ButtonCallback} [callback] A function to invoke when the button is clicked. The value returned
* from this function will be used as the dialog's submitted value.
* Otherwise, the button's identifier is used.
*/
/**
* @callback DialogV2ButtonCallback
* @param {PointerEvent|SubmitEvent} event The button click event, or a form submission event if the dialog was
* submitted via keyboard.
* @param {HTMLButtonElement} button If the form was submitted via keyboard, this will be the default
* button, otherwise the button that was clicked.
* @param {HTMLDialogElement} dialog The dialog element.
* @returns {Promise<any>}
*/
/**
* @typedef {Object} DialogV2Configuration
* @property {boolean} [modal] Modal dialogs prevent interaction with the rest of the UI until they
* are dismissed or submitted.
* @property {DialogV2Button[]} buttons Button configuration.
* @property {string} [content] The dialog content.
* @property {DialogV2SubmitCallback} [submit] A function to invoke when the dialog is submitted. This will not be
* called if the dialog is dismissed.
*/
/**
* @callback DialogV2RenderCallback
* @param {Event} event The render event.
* @param {HTMLDialogElement} dialog The dialog element.
*/
/**
* @callback DialogV2CloseCallback
* @param {Event} event The close event.
* @param {DialogV2} dialog The dialog instance.
*/
/**
* @callback DialogV2SubmitCallback
* @param {any} result Either the identifier of the button that was clicked to submit the
* dialog, or the result returned by that button's callback.
* @returns {Promise<void>}
*/
/**
* @typedef {object} DialogV2WaitOptions
* @property {DialogV2RenderCallback} [render] A synchronous function to invoke whenever the dialog is rendered.
* @property {DialogV2CloseCallback} [close] A synchronous function to invoke when the dialog is closed under any
* circumstances.
* @property {boolean} [rejectClose=true] Throw a Promise rejection if the dialog is dismissed.
*/
/**
* A lightweight Application that renders a dialog containing a form with arbitrary content, and some buttons.
* @extends {ApplicationV2<ApplicationConfiguration & DialogV2Configuration>}
*
* @example Prompt the user to confirm an action.
* ```js
* const proceed = await foundry.applications.api.DialogV2.confirm({
* content: "Are you sure?",
* rejectClose: false,
* modal: true
* });
* if ( proceed ) console.log("Proceed.");
* else console.log("Do not proceed.");
* ```
*
* @example Prompt the user for some input.
* ```js
* let guess;
* try {
* guess = await foundry.applications.api.DialogV2.prompt({
* window: { title: "Guess a number between 1 and 10" },
* content: '<input name="guess" type="number" min="1" max="10" step="1" autofocus>',
* ok: {
* label: "Submit Guess",
* callback: (event, button, dialog) => button.form.elements.guess.valueAsNumber
* }
* });
* } catch {
* console.log("User did not make a guess.");
* return;
* }
* const n = Math.ceil(CONFIG.Dice.randomUniform() * 10);
* if ( n === guess ) console.log("User guessed correctly.");
* else console.log("User guessed incorrectly.");
* ```
*
* @example A custom dialog.
* ```js
* new foundry.applications.api.DialogV2({
* window: { title: "Choose an option" },
* content: `
* <label><input type="radio" name="choice" value="one" checked> Option 1</label>
* <label><input type="radio" name="choice" value="two"> Option 2</label>
* <label><input type="radio" name="choice" value="three"> Options 3</label>
* `,
* buttons: [{
* action: "choice",
* label: "Make Choice",
* default: true,
* callback: (event, button, dialog) => button.form.elements.choice.value
* }, {
* action: "all",
* label: "Take All"
* }],
* submit: result => {
* if ( result === "all" ) console.log("User picked all options.");
* else console.log(`User picked option: ${result}`);
* }
* }).render({ force: true });
* ```
*/
export default class DialogV2 extends ApplicationV2 {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
id: "dialog-{id}",
classes: ["dialog"],
tag: "dialog",
form: {
closeOnSubmit: true
},
window: {
frame: true,
positioned: true,
minimizable: false
}
};
/* -------------------------------------------- */
/** @inheritDoc */
_initializeApplicationOptions(options) {
options = super._initializeApplicationOptions(options);
if ( !options.buttons?.length ) throw new Error("You must define at least one entry in options.buttons");
options.buttons = options.buttons.reduce((obj, button) => {
options.actions[button.action] = this.constructor._onClickButton;
obj[button.action] = button;
return obj;
}, {});
return options;
}
/* -------------------------------------------- */
/** @override */
async _renderHTML(_context, _options) {
const form = document.createElement("form");
form.className = "dialog-form standard-form";
form.autocomplete = "off";
form.innerHTML = `
${this.options.content ? `<div class="dialog-content standard-form">${this.options.content}</div>` : ""}
<footer class="form-footer">${this._renderButtons()}</footer>
`;
form.addEventListener("submit", event => this._onSubmit(event.submitter, event));
return form;
}
/* -------------------------------------------- */
/**
* Render configured buttons.
* @returns {string}
* @protected
*/
_renderButtons() {
return Object.values(this.options.buttons).map(button => {
const { action, label, icon, default: isDefault, class: cls="" } = button;
return `
<button type="${isDefault ? "submit" : "button"}" data-action="${action}" class="${cls}"
${isDefault ? "autofocus" : ""}>
${icon ? `<i class="${icon}"></i>` : ""}
<span>${game.i18n.localize(label)}</span>
</button>
`;
}).join("");
}
/* -------------------------------------------- */
/**
* Handle submitting the dialog.
* @param {HTMLButtonElement} target The button that was clicked or the default button.
* @param {PointerEvent|SubmitEvent} event The triggering event.
* @returns {Promise<DialogV2>}
* @protected
*/
async _onSubmit(target, event) {
event.preventDefault();
const button = this.options.buttons[target?.dataset.action];
const result = (await button?.callback?.(event, target, this.element)) ?? button?.action;
await this.options.submit?.(result);
return this.options.form.closeOnSubmit ? this.close() : this;
}
/* -------------------------------------------- */
/** @override */
_onFirstRender(_context, _options) {
if ( this.options.modal ) this.element.showModal();
else this.element.show();
}
/* -------------------------------------------- */
/** @inheritDoc */
_attachFrameListeners() {
super._attachFrameListeners();
this.element.addEventListener("keydown", this._onKeyDown.bind(this));
}
/* -------------------------------------------- */
/** @override */
_replaceHTML(result, content, _options) {
content.replaceChildren(result);
}
/* -------------------------------------------- */
/**
* Handle keypresses within the dialog.
* @param {KeyboardEvent} event The triggering event.
* @protected
*/
_onKeyDown(event) {
// Capture Escape keypresses for dialogs to ensure that close is called properly.
if ( event.key === "Escape" ) {
event.preventDefault(); // Prevent default browser dialog dismiss behavior.
event.stopPropagation();
this.close();
}
}
/* -------------------------------------------- */
/**
* @this {DialogV2}
* @param {PointerEvent} event The originating click event.
* @param {HTMLButtonElement} target The button element that was clicked.
* @protected
*/
static _onClickButton(event, target) {
this._onSubmit(target, event);
}
/* -------------------------------------------- */
/* Factory Methods */
/* -------------------------------------------- */
/**
* A utility helper to generate a dialog with yes and no buttons.
* @param {Partial<ApplicationConfiguration & DialogV2Configuration & DialogV2WaitOptions>} [options]
* @param {DialogV2Button} [options.yes] Options to overwrite the default yes button configuration.
* @param {DialogV2Button} [options.no] Options to overwrite the default no button configuration.
* @returns {Promise<any>} Resolves to true if the yes button was pressed, or false if the no button
* was pressed. If additional buttons were provided, the Promise resolves to
* the identifier of the one that was pressed, or the value returned by its
* callback. If the dialog was dismissed, and rejectClose is false, the
* Promise resolves to null.
*/
static async confirm({ yes={}, no={}, ...options }={}) {
options.buttons ??= [];
options.buttons.unshift(mergeObject({
action: "yes", label: "Yes", icon: "fas fa-check", callback: () => true
}, yes), mergeObject({
action: "no", label: "No", icon: "fas fa-xmark", default: true, callback: () => false
}, no));
return this.wait(options);
}
/* -------------------------------------------- */
/**
* A utility helper to generate a dialog with a single confirmation button.
* @param {Partial<ApplicationConfiguration & DialogV2Configuration & DialogV2WaitOptions>} [options]
* @param {Partial<DialogV2Button>} [options.ok] Options to overwrite the default confirmation button configuration.
* @returns {Promise<any>} Resolves to the identifier of the button used to submit the dialog,
* or the value returned by that button's callback. If the dialog was
* dismissed, and rejectClose is false, the Promise resolves to null.
*/
static async prompt({ ok={}, ...options }={}) {
options.buttons ??= [];
options.buttons.unshift(mergeObject({
action: "ok", label: "Confirm", icon: "fas fa-check", default: true
}, ok));
return this.wait(options);
}
/* -------------------------------------------- */
/**
* Spawn a dialog and wait for it to be dismissed or submitted.
* @param {Partial<ApplicationConfiguration & DialogV2Configuration>} [options]
* @param {DialogV2RenderCallback} [options.render] A function to invoke whenever the dialog is rendered.
* @param {DialogV2CloseCallback} [options.close] A function to invoke when the dialog is closed under any
* circumstances.
* @param {boolean} [options.rejectClose=true] Throw a Promise rejection if the dialog is dismissed.
* @returns {Promise<any>} Resolves to the identifier of the button used to submit the
* dialog, or the value returned by that button's callback. If the
* dialog was dismissed, and rejectClose is false, the Promise
* resolves to null.
*/
static async wait({ rejectClose=true, close, render, ...options }={}) {
return new Promise((resolve, reject) => {
// Wrap submission handler with Promise resolution.
const originalSubmit = options.submit;
options.submit = async result => {
await originalSubmit?.(result);
resolve(result);
};
const dialog = new this(options);
dialog.addEventListener("close", event => {
if ( close instanceof Function ) close(event, dialog);
if ( rejectClose ) reject(new Error("Dialog was dismissed without pressing a button."));
else resolve(null);
}, { once: true });
if ( render instanceof Function ) {
dialog.addEventListener("render", event => render(event, dialog.element));
}
dialog.render({ force: true });
});
}
}

View File

@@ -0,0 +1,296 @@
import ApplicationV2 from "./application.mjs";
import {DOCUMENT_OWNERSHIP_LEVELS} from "../../../common/constants.mjs";
/**
* @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration
* @typedef {import("../_types.mjs").ApplicationRenderOptions} ApplicationRenderOptions
*/
/**
* @typedef {Object} DocumentSheetConfiguration
* @property {Document} document The Document instance associated with this sheet
* @property {number} viewPermission A permission level in CONST.DOCUMENT_OWNERSHIP_LEVELS
* @property {number} editPermission A permission level in CONST.DOCUMENT_OWNERSHIP_LEVELS
* @property {boolean} sheetConfig Allow sheet configuration as a header button
*/
/**
* @typedef {Object} DocumentSheetRenderOptions
* @property {string} renderContext A string with the format "{operation}{documentName}" providing context
* @property {object} renderData Data describing the document modification that occurred
*/
/**
* The Application class is responsible for rendering an HTMLElement into the Foundry Virtual Tabletop user interface.
* @extends {ApplicationV2<
* ApplicationConfiguration & DocumentSheetConfiguration,
* ApplicationRenderOptions & DocumentSheetRenderOptions
* >}
* @alias DocumentSheetV2
*/
export default class DocumentSheetV2 extends ApplicationV2 {
constructor(options={}) {
super(options);
this.#document = options.document;
}
/** @inheritDoc */
static DEFAULT_OPTIONS = {
id: "{id}",
classes: ["sheet"],
tag: "form", // Document sheets are forms by default
document: null,
viewPermission: DOCUMENT_OWNERSHIP_LEVELS.LIMITED,
editPermission: DOCUMENT_OWNERSHIP_LEVELS.OWNER,
sheetConfig: true,
actions: {
configureSheet: DocumentSheetV2.#onConfigureSheet,
copyUuid: {handler: DocumentSheetV2.#onCopyUuid, buttons: [0, 2]}
},
form: {
handler: this.#onSubmitDocumentForm,
submitOnChange: false,
closeOnSubmit: false
}
};
/* -------------------------------------------- */
/**
* The Document instance associated with the application
* @type {ClientDocument}
*/
get document() {
return this.#document;
}
#document;
/* -------------------------------------------- */
/** @override */
get title() {
const {constructor: cls, id, name, type} = this.document;
const prefix = cls.hasTypeData ? CONFIG[cls.documentName].typeLabels[type] : cls.metadata.label
return `${game.i18n.localize(prefix)}: ${name ?? id}`;
}
/* -------------------------------------------- */
/**
* Is this Document sheet visible to the current User?
* This is governed by the viewPermission threshold configured for the class.
* @type {boolean}
*/
get isVisible() {
return this.document.testUserPermission(game.user, this.options.viewPermission);
}
/* -------------------------------------------- */
/**
* Is this Document sheet editable by the current User?
* This is governed by the editPermission threshold configured for the class.
* @type {boolean}
*/
get isEditable() {
if ( this.document.pack ) {
const pack = game.packs.get(this.document.pack);
if ( pack.locked ) return false;
}
return this.document.testUserPermission(game.user, this.options.editPermission);
}
/* -------------------------------------------- */
/** @inheritDoc */
_initializeApplicationOptions(options) {
options = super._initializeApplicationOptions(options);
options.uniqueId = `${this.constructor.name}-${options.document.uuid}`;
return options;
}
/* -------------------------------------------- */
/** @inheritDoc */
*_headerControlButtons() {
for ( const control of this._getHeaderControls() ) {
if ( control.visible === false ) continue;
if ( ("ownership" in control) && !this.document.testUserPermission(game.user, control.ownership) ) continue;
yield control;
}
}
/* -------------------------------------------- */
/** @inheritDoc */
async _renderFrame(options) {
const frame = await super._renderFrame(options);
// Add form options
if ( this.options.tag === "form" ) frame.autocomplete = "off";
// Add document ID copy
const copyLabel = game.i18n.localize("SHEETS.CopyUuid");
const copyId = `<button type="button" class="header-control fa-solid fa-passport" data-action="copyUuid"
data-tooltip="${copyLabel}" aria-label="${copyLabel}"></button>`;
this.window.close.insertAdjacentHTML("beforebegin", copyId);
// Add sheet configuration button
if ( this.options.sheetConfig && this.isEditable && !this.document.getFlag("core", "sheetLock") ) {
const label = game.i18n.localize("SHEETS.ConfigureSheet");
const sheetConfig = `<button type="button" class="header-control fa-solid fa-cog" data-action="configureSheet"
data-tooltip="${label}" aria-label="${label}"></button>`;
this.window.close.insertAdjacentHTML("beforebegin", sheetConfig);
}
return frame;
}
/* -------------------------------------------- */
/* Application Life-Cycle Events */
/* -------------------------------------------- */
/** @override */
_canRender(_options) {
if ( !this.isVisible ) throw new Error(game.i18n.format("SHEETS.DocumentSheetPrivate", {
type: game.i18n.localize(this.document.constructor.metadata.label)
}));
}
/* -------------------------------------------- */
/** @inheritDoc */
_onFirstRender(context, options) {
super._onFirstRender(context, options);
this.document.apps[this.id] = this;
}
/* -------------------------------------------- */
/** @override */
_onClose(_options) {
delete this.document.apps[this.id];
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Handle click events to configure the sheet used for this document.
* @param {PointerEvent} event
* @this {DocumentSheetV2}
*/
static #onConfigureSheet(event) {
event.stopPropagation(); // Don't trigger other events
if ( event.detail > 1 ) return; // Ignore repeated clicks
new DocumentSheetConfig(this.document, {
top: this.position.top + 40,
left: this.position.left + ((this.position.width - DocumentSheet.defaultOptions.width) / 2)
}).render(true);
}
/* -------------------------------------------- */
/**
* Handle click events to copy the UUID of this document to clipboard.
* @param {PointerEvent} event
* @this {DocumentSheetV2}
*/
static #onCopyUuid(event) {
event.preventDefault(); // Don't open context menu
event.stopPropagation(); // Don't trigger other events
if ( event.detail > 1 ) return; // Ignore repeated clicks
const id = event.button === 2 ? this.document.id : this.document.uuid;
const type = event.button === 2 ? "id" : "uuid";
const label = game.i18n.localize(this.document.constructor.metadata.label);
game.clipboard.copyPlainText(id);
ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type, id}));
}
/* -------------------------------------------- */
/* Form Submission */
/* -------------------------------------------- */
/**
* Process form submission for the sheet
* @this {DocumentSheetV2} The handler is called with the application as its bound scope
* @param {SubmitEvent} event The originating form submission event
* @param {HTMLFormElement} form The form element that was submitted
* @param {FormDataExtended} formData Processed data for the submitted form
* @returns {Promise<void>}
*/
static async #onSubmitDocumentForm(event, form, formData) {
const submitData = this._prepareSubmitData(event, form, formData);
await this._processSubmitData(event, form, submitData);
}
/* -------------------------------------------- */
/**
* Prepare data used to update the Item upon form submission.
* This data is cleaned and validated before being returned for further processing.
* @param {SubmitEvent} event The originating form submission event
* @param {HTMLFormElement} form The form element that was submitted
* @param {FormDataExtended} formData Processed data for the submitted form
* @returns {object} Prepared submission data as an object
* @throws {Error} Subclasses may throw validation errors here to prevent form submission
* @protected
*/
_prepareSubmitData(event, form, formData) {
const submitData = this._processFormData(event, form, formData);
const addType = this.document.constructor.hasTypeData && !("type" in submitData);
if ( addType ) submitData.type = this.document.type;
this.document.validate({changes: submitData, clean: true, fallback: false});
if ( addType ) delete submitData.type;
return submitData;
}
/* -------------------------------------------- */
/**
* Customize how form data is extracted into an expanded object.
* @param {SubmitEvent} event The originating form submission event
* @param {HTMLFormElement} form The form element that was submitted
* @param {FormDataExtended} formData Processed data for the submitted form
* @returns {object} An expanded object of processed form data
* @throws {Error} Subclasses may throw validation errors here to prevent form submission
*/
_processFormData(event, form, formData) {
return foundry.utils.expandObject(formData.object);
}
/* -------------------------------------------- */
/**
* Submit a document update based on the processed form data.
* @param {SubmitEvent} event The originating form submission event
* @param {HTMLFormElement} form The form element that was submitted
* @param {object} submitData Processed and validated form data to be used for a document update
* @returns {Promise<void>}
* @protected
*/
async _processSubmitData(event, form, submitData) {
await this.document.update(submitData);
}
/* -------------------------------------------- */
/**
* Programmatically submit a DocumentSheetV2 instance, providing additional data to be merged with form data.
* @param {object} options
* @param {object} [options.updateData] Additional data merged with processed form data
* @returns {Promise<void>}
*/
async submit({updateData}={}) {
const formConfig = this.options.form;
if ( !formConfig?.handler ) throw new Error(`The ${this.constructor.name} DocumentSheetV2 does not support a`
+ ` single top-level form element.`);
const form = this.element;
const event = new Event("submit");
const formData = new FormDataExtended(form);
const submitData = this._prepareSubmitData(event, form, formData);
foundry.utils.mergeObject(submitData, updateData, {inplace: true});
await this._processSubmitData(event, form, submitData);
}
}

View File

@@ -0,0 +1,243 @@
/**
* @typedef {import("../types.mjs").Constructor} Constructor
* @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration
* @typedef {import("./types.mjs").ApplicationFormSubmission} ApplicationFormSubmission
*/
/**
* @typedef {Object} HandlebarsRenderOptions
* @property {string[]} parts An array of named template parts to render
*/
/**
* @typedef {Object} HandlebarsTemplatePart
* @property {string} template The template entry-point for the part
* @property {string} [id] A CSS id to assign to the top-level element of the rendered part.
* This id string is automatically prefixed by the application id.
* @property {string[]} [classes] An array of CSS classes to apply to the top-level element of the
* rendered part.
* @property {string[]} [templates] An array of templates that are required to render the part.
* If omitted, only the entry-point is inferred as required.
* @property {string[]} [scrollable] An array of selectors within this part whose scroll positions should
* be persisted during a re-render operation. A blank string is used
* to denote that the root level of the part is scrollable.
* @property {Record<string, ApplicationFormConfiguration>} [forms] A registry of forms selectors and submission handlers.
*/
/**
* Augment an Application class with [Handlebars](https://handlebarsjs.com) template rendering behavior.
* @param {Constructor} BaseApplication
*/
export default function HandlebarsApplicationMixin(BaseApplication) {
/**
* The mixed application class augmented with [Handlebars](https://handlebarsjs.com) template rendering behavior.
* @extends {ApplicationV2<ApplicationConfiguration, HandlebarsRenderOptions>}
*/
class HandlebarsApplication extends BaseApplication {
/**
* Configure a registry of template parts which are supported for this application for partial rendering.
* @type {Record<string, HandlebarsTemplatePart>}
*/
static PARTS = {}
/**
* A record of all rendered template parts.
* @returns {Record<string, HTMLElement>}
*/
get parts() {
return this.#parts;
}
#parts = {};
/* -------------------------------------------- */
/** @inheritDoc */
_configureRenderOptions(options) {
super._configureRenderOptions(options);
options.parts ??= Object.keys(this.constructor.PARTS);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _preFirstRender(context, options) {
await super._preFirstRender(context, options);
const allTemplates = new Set();
for ( const part of Object.values(this.constructor.PARTS) ) {
const partTemplates = part.templates ?? [part.template];
for ( const template of partTemplates ) allTemplates.add(template);
}
await loadTemplates(Array.from(allTemplates));
}
/* -------------------------------------------- */
/**
* Render each configured application part using Handlebars templates.
* @param {ApplicationRenderContext} context Context data for the render operation
* @param {HandlebarsRenderOptions} options Options which configure application rendering behavior
* @returns {Promise<Record<string, HTMLElement>>} A single rendered HTMLElement for each requested part
* @protected
* @override
*/
async _renderHTML(context, options) {
const rendered = {}
for ( const partId of options.parts ) {
const part = this.constructor.PARTS[partId];
if ( !part ) {
ui.notifications.warn(`Part "${partId}" is not a supported template part for ${this.constructor.name}`);
continue;
}
const partContext = await this._preparePartContext(partId, context, options);
try {
const htmlString = await renderTemplate(part.template, partContext);
rendered[partId] = this.#parsePartHTML(partId, part, htmlString);
} catch(err) {
throw new Error(`Failed to render template part "${partId}":\n${err.message}`, {cause: err});
}
}
return rendered;
}
/* -------------------------------------------- */
/**
* Prepare context that is specific to only a single rendered part.
*
* It is recommended to augment or mutate the shared context so that downstream methods like _onRender have
* visibility into the data that was used for rendering. It is acceptable to return a different context object
* rather than mutating the shared context at the expense of this transparency.
*
* @param {string} partId The part being rendered
* @param {ApplicationRenderContext} context Shared context provided by _prepareContext
* @param {HandlebarsRenderOptions} options Options which configure application rendering behavior
* @returns {Promise<ApplicationRenderContext>} Context data for a specific part
* @protected
*/
async _preparePartContext(partId, context, options) {
context.partId = `${this.id}-${partId}`;
return context;
}
/* -------------------------------------------- */
/**
* Parse the returned HTML string from template rendering into a uniquely identified HTMLElement for insertion.
* @param {string} partId The id of the part being rendered
* @param {HandlebarsTemplatePart} part Configuration of the part being parsed
* @param {string} htmlString The string rendered for the part
* @returns {HTMLElement} The parsed HTMLElement for the part
*/
#parsePartHTML(partId, part, htmlString) {
const t = document.createElement("template");
t.innerHTML = htmlString;
if ( (t.content.children.length !== 1) ) {
throw new Error(`Template part "${partId}" must render a single HTML element.`);
}
const e = t.content.firstElementChild;
e.dataset.applicationPart = partId;
if ( part.id ) e.setAttribute("id", `${this.id}-${part.id}`);
if ( part.classes ) e.classList.add(...part.classes);
return e;
}
/* -------------------------------------------- */
/**
* Replace the HTML of the application with the result provided by Handlebars rendering.
* @param {Record<string, HTMLElement>} result The result from Handlebars template rendering
* @param {HTMLElement} content The content element into which the rendered result must be inserted
* @param {HandlebarsRenderOptions} options Options which configure application rendering behavior
* @protected
* @override
*/
_replaceHTML(result, content, options) {
for ( const [partId, htmlElement] of Object.entries(result) ) {
const priorElement = content.querySelector(`[data-application-part="${partId}"]`);
const state = {};
if ( priorElement ) {
this._preSyncPartState(partId, htmlElement, priorElement, state);
priorElement.replaceWith(htmlElement);
this._syncPartState(partId, htmlElement, priorElement, state);
}
else content.appendChild(htmlElement);
this._attachPartListeners(partId, htmlElement, options);
this.#parts[partId] = htmlElement;
}
}
/* -------------------------------------------- */
/**
* Prepare data used to synchronize the state of a template part.
* @param {string} partId The id of the part being rendered
* @param {HTMLElement} newElement The new rendered HTML element for the part
* @param {HTMLElement} priorElement The prior rendered HTML element for the part
* @param {object} state A state object which is used to synchronize after replacement
* @protected
*/
_preSyncPartState(partId, newElement, priorElement, state) {
const part = this.constructor.PARTS[partId];
// Focused element or field
const focus = priorElement.querySelector(":focus");
if ( focus?.id ) state.focus = `#${focus.id}`;
else if ( focus?.name ) state.focus = `${focus.tagName}[name="${focus.name}"]`;
else state.focus = undefined;
// Scroll positions
state.scrollPositions = [];
for ( const selector of (part.scrollable || []) ) {
const el0 = selector === "" ? priorElement : priorElement.querySelector(selector);
if ( el0 ) {
const el1 = selector === "" ? newElement : newElement.querySelector(selector);
if ( el1 ) state.scrollPositions.push([el1, el0.scrollTop, el0.scrollLeft]);
}
}
}
/* -------------------------------------------- */
/**
* Synchronize the state of a template part after it has been rendered and replaced in the DOM.
* @param {string} partId The id of the part being rendered
* @param {HTMLElement} newElement The new rendered HTML element for the part
* @param {HTMLElement} priorElement The prior rendered HTML element for the part
* @param {object} state A state object which is used to synchronize after replacement
* @protected
*/
_syncPartState(partId, newElement, priorElement, state) {
if ( state.focus ) {
const newFocus = newElement.querySelector(state.focus);
if ( newFocus ) newFocus.focus();
}
for ( const [el, scrollTop, scrollLeft] of state.scrollPositions ) Object.assign(el, {scrollTop, scrollLeft});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Attach event listeners to rendered template parts.
* @param {string} partId The id of the part being rendered
* @param {HTMLElement} htmlElement The rendered HTML element for the part
* @param {ApplicationRenderOptions} options Rendering options passed to the render method
* @protected
*/
_attachPartListeners(partId, htmlElement, options) {
const part = this.constructor.PARTS[partId];
// Attach form submission handlers
if ( part.forms ) {
for ( const [selector, formConfig] of Object.entries(part.forms) ) {
const form = htmlElement.matches(selector) ? htmlElement : htmlElement.querySelector(selector);
form.addEventListener("submit", this._onSubmitForm.bind(this, formConfig));
form.addEventListener("change", this._onChangeForm.bind(this, formConfig));
}
}
}
}
return HandlebarsApplication;
}

View File

@@ -0,0 +1,2 @@
export {default as CompendiumArtConfig} from "./compendium-art-config.mjs";
export {default as PermissionConfig} from "./permission-config.mjs";

View File

@@ -0,0 +1,105 @@
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
import ApplicationV2 from "../api/application.mjs";
/**
* An application for configuring compendium art priorities.
* @extends ApplicationV2
* @mixes HandlebarsApplication
* @alias CompendiumArtConfig
*/
export default class CompendiumArtConfig extends HandlebarsApplicationMixin(ApplicationV2) {
/** @override */
static DEFAULT_OPTIONS = {
id: "compendium-art-config",
tag: "form",
window: {
contentClasses: ["standard-form"],
icon: "fas fa-palette",
title: "COMPENDIUM.ART.SETTING.Title"
},
position: {
width: 600,
height: "auto"
},
form: {
closeOnSubmit: true,
handler: CompendiumArtConfig.#onSubmit
},
actions: {
priority: CompendiumArtConfig.#onAdjustPriority
}
};
/** @override */
static PARTS = {
priorities: {
id: "priorities",
template: "templates/apps/compendium-art-config.hbs"
},
footer: {
template: "templates/generic/form-footer.hbs"
}
};
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @override */
async _prepareContext(_options={}) {
return {
config: game.compendiumArt.getPackages(),
buttons: [{ type: "submit", icon: "fas fa-save", label: "SETUP.SaveConfiguration" }]
};
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/**
* Adjust the priority of a package.
* @this {ApplicationV2}
* @param {MouseEvent} _event The click event.
* @param {HTMLButtonElement} target The button that was clicked.
*/
static async #onAdjustPriority(_event, target) {
const row = target.closest("[data-package-id]");
const { packageId } = row.dataset;
const configs = [];
for ( const element of this.element.elements ) {
const [id, key] = element.name.split(".");
if ( key === "priority" ) configs.push({ packageId: id, priority: Number(element.value) });
}
const idx = configs.findIndex(config => config.packageId === packageId);
if ( idx < 0 ) return;
const sortBefore = "increase" in target.dataset;
if ( sortBefore && (idx === 0) ) return;
if ( !sortBefore && (idx >= configs.length - 1) ) return;
const config = configs[idx];
const sortTarget = configs[sortBefore ? idx - 1 : idx + 1];
configs.splice(idx, 1);
const updates = SortingHelpers.performIntegerSort(config, {
sortBefore, target: sortTarget, siblings: configs, sortKey: "priority"
});
updates.forEach(({ target, update }) => {
this.element.elements[`${target.packageId}.priority`].value = update.priority;
});
if ( sortBefore ) row.previousElementSibling.insertAdjacentElement("beforebegin", row);
else row.nextElementSibling.insertAdjacentElement("afterend", row);
}
/* -------------------------------------------- */
/**
* Save the compendium art configuration.
* @this {ApplicationV2}
* @param {SubmitEvent} _event The form submission event.
* @param {HTMLFormElement} _form The form element that was submitted.
* @param {FormDataExtended} formData Processed data for the submitted form.
*/
static async #onSubmit(_event, _form, formData) {
await game.settings.set("core", game.compendiumArt.SETTING, foundry.utils.expandObject(formData.object));
return SettingsConfig.reloadConfirm({ world: true });
}
}

View File

@@ -0,0 +1,152 @@
import ApplicationV2 from "../api/application.mjs";
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
/**
* An application for configuring the permissions which are available to each User role.
* @extends ApplicationV2
* @mixes HandlebarsApplication
* @alias PermissionConfig
*/
export default class PermissionConfig extends HandlebarsApplicationMixin(ApplicationV2) {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
id: "permissions-config",
tag: "form",
window: {
contentClasses: ["standard-form"],
icon: "fa-solid fa-shield-keyhole",
title: "PERMISSION.Title",
},
position: {
width: 660,
height: "auto"
},
form: {
closeOnSubmit: true,
handler: PermissionConfig.#onSubmit
},
actions: {
reset: PermissionConfig.#onReset
}
};
/** @override */
static PARTS = {
permissions: {
id: "permissions",
template: "templates/apps/permission-config.hbs",
scrollable: [".permissions-list"]
},
footer: {
template: "templates/generic/form-footer.hbs"
}
};
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @override */
async _prepareContext(_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.#preparePermissions(current),
buttons: [
{type: "reset", action: "reset", icon: "fa-solid fa-sync", label: "PERMISSION.Reset"},
{type: "submit", icon: "fa-solid fa-save", label: "PERMISSION.Submit"}
]
};
}
/* -------------------------------------------- */
/**
* Prepare the permissions object used to render the configuration template
* @param {object} current The current permission configuration
* @returns {object[]} Permission data for sheet rendering
*/
#preparePermissions(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, game.i18n.lang));
// 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),
readonly: (role === rgm) && (!p.disableGM) ? "readonly" : ""
});
return arr;
}, []);
}
return perms;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Handle submission
* @this {DocumentSheetV2} The handler is called with the application as its bound scope
* @param {SubmitEvent} event The originating form submission event
* @param {HTMLFormElement} form The form element that was submitted
* @param {FormDataExtended} formData Processed data for the submitted form
* @returns {Promise<void>}
*/
static async #onSubmit(event, form, formData) {
const permissions = foundry.utils.expandObject(formData.object);
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});
}
/* -------------------------------------------- */
/**
* Handle click actions to reset all permissions back to their initial state.
* @this {PermissionConfig}
* @param {PointerEvent} event
* @returns {Promise<void>}
*/
static async #onReset(event) {
event.preventDefault();
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});
await this.render();
}
}

View File

@@ -0,0 +1 @@
export {default as RollResolver} from "./roll-resolver.mjs";

View File

@@ -0,0 +1,321 @@
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
import ApplicationV2 from "../api/application.mjs";
/**
* @typedef {object} DiceTermFulfillmentDescriptor
* @property {string} id A unique identifier for the term.
* @property {DiceTerm} term The term.
* @property {string} method The fulfillment method.
* @property {boolean} [isNew] Was the term newly-added to this resolver?
*/
/**
* An application responsible for handling unfulfilled dice terms in a roll.
* @extends {ApplicationV2<ApplicationConfiguration, ApplicationRenderOptions>}
* @mixes HandlebarsApplication
* @alias RollResolver
*/
export default class RollResolver extends HandlebarsApplicationMixin(ApplicationV2) {
constructor(roll, options={}) {
super(options);
this.#roll = roll;
}
/** @inheritDoc */
static DEFAULT_OPTIONS = {
id: "roll-resolver-{id}",
tag: "form",
classes: ["roll-resolver"],
window: {
title: "DICE.RollResolution",
},
position: {
width: 500,
height: "auto"
},
form: {
submitOnChange: false,
closeOnSubmit: false,
handler: this._fulfillRoll
}
};
/** @override */
static PARTS = {
form: {
id: "form",
template: "templates/dice/roll-resolver.hbs"
}
};
/**
* A collection of fulfillable dice terms.
* @type {Map<string, DiceTermFulfillmentDescriptor>}
*/
get fulfillable() {
return this.#fulfillable;
}
#fulfillable = new Map();
/**
* A function to call when the first pass of fulfillment is complete.
* @type {function}
*/
#resolve;
/**
* The roll being resolved.
* @type {Roll}
*/
get roll() {
return this.#roll;
}
#roll;
/* -------------------------------------------- */
/**
* Identify any terms in this Roll that should be fulfilled externally, and prompt the user to do so.
* @returns {Promise<void>} Returns a Promise that resolves when the first pass of fulfillment is complete.
*/
async awaitFulfillment() {
const fulfillable = await this.#identifyFulfillableTerms(this.roll.terms);
if ( !fulfillable.length ) return;
Roll.defaultImplementation.RESOLVERS.set(this.roll, this);
this.render(true);
return new Promise(resolve => this.#resolve = resolve);
}
/* -------------------------------------------- */
/**
* Register a fulfilled die roll.
* @param {string} method The method used for fulfillment.
* @param {string} denomination The denomination of the fulfilled die.
* @param {number} result The rolled number.
* @returns {boolean} Whether the result was consumed.
*/
registerResult(method, denomination, result) {
const query = `label[data-denomination="${denomination}"][data-method="${method}"] > input:not(:disabled)`;
const term = Array.from(this.element.querySelectorAll(query)).find(input => input.value === "");
if ( !term ) {
ui.notifications.warn(`${denomination} roll was not needed by the resolver.`);
return false;
}
term.value = `${result}`;
const submitTerm = term.closest(".form-fields")?.querySelector("button");
if ( submitTerm ) submitTerm.dispatchEvent(new MouseEvent("click"));
else this._checkDone();
return true;
}
/* -------------------------------------------- */
/** @inheritDoc */
async close(options={}) {
if ( this.rendered ) await this.constructor._fulfillRoll.call(this, null, null, new FormDataExtended(this.element));
Roll.defaultImplementation.RESOLVERS.delete(this.roll);
this.#resolve?.();
return super.close(options);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _prepareContext(_options) {
const context = {
formula: this.roll.formula,
groups: {}
};
for ( const fulfillable of this.fulfillable.values() ) {
const { id, term, method, isNew } = fulfillable;
fulfillable.isNew = false;
const config = CONFIG.Dice.fulfillment.methods[method];
const group = context.groups[id] = {
results: [],
label: term.expression,
icon: config.icon ?? '<i class="fas fa-bluetooth"></i>',
tooltip: game.i18n.localize(config.label)
};
const { denomination, faces } = term;
const icon = CONFIG.Dice.fulfillment.dice[denomination]?.icon;
for ( let i = 0; i < Math.max(term.number ?? 1, term.results.length); i++ ) {
const result = term.results[i];
const { result: value, exploded, rerolled } = result ?? {};
group.results.push({
denomination, faces, id, method, icon, exploded, rerolled, isNew,
value: value ?? "",
readonly: method !== "manual",
disabled: !!result
});
}
}
return context;
}
/* -------------------------------------------- */
/** @inheritDoc */
async _onSubmitForm(formConfig, event) {
this._toggleSubmission(false);
this.element.querySelectorAll("input").forEach(input => {
if ( !isNaN(input.valueAsNumber) ) return;
const { term } = this.fulfillable.get(input.name);
input.value = `${term.randomFace()}`;
});
await super._onSubmitForm(formConfig, event);
this.element?.querySelectorAll("input").forEach(input => input.disabled = true);
this.#resolve();
}
/* -------------------------------------------- */
/**
* Handle prompting for a single extra result from a term.
* @param {DiceTerm} term The term.
* @param {string} method The method used to obtain the result.
* @param {object} [options]
* @returns {Promise<number|void>}
*/
async resolveResult(term, method, { reroll=false, explode=false }={}) {
const group = this.element.querySelector(`fieldset[data-term-id="${term._id}"]`);
if ( !group ) {
console.warn("Attempted to resolve a single result for an unregistered DiceTerm.");
return;
}
const fields = document.createElement("div");
fields.classList.add("form-fields");
fields.innerHTML = `
<label class="icon die-input new-addition" data-denomination="${term.denomination}" data-method="${method}">
<input type="number" min="1" max="${term.faces}" step="1" name="${term._id}"
${method === "manual" ? "" : "readonly"} placeholder="${game.i18n.localize(term.denomination)}">
${reroll ? '<i class="fas fa-arrow-rotate-right"></i>' : ""}
${explode ? '<i class="fas fa-burst"></i>' : ""}
${CONFIG.Dice.fulfillment.dice[term.denomination]?.icon ?? ""}
</label>
<button type="button" class="submit-result" data-tooltip="DICE.SubmitRoll"
aria-label="${game.i18n.localize("DICE.SubmitRoll")}">
<i class="fas fa-arrow-right"></i>
</button>
`;
group.appendChild(fields);
this.setPosition({ height: "auto" });
return new Promise(resolve => {
const button = fields.querySelector("button");
const input = fields.querySelector("input");
button.addEventListener("click", () => {
if ( !input.validity.valid ) {
input.form.reportValidity();
return;
}
let value = input.valueAsNumber;
if ( !value ) value = term.randomFace();
input.value = `${value}`;
input.disabled = true;
button.remove();
resolve(value);
});
});
}
/* -------------------------------------------- */
/**
* Update the Roll instance with the fulfilled results.
* @this {RollResolver}
* @param {SubmitEvent} event The originating form submission event.
* @param {HTMLFormElement} form The form element that was submitted.
* @param {FormDataExtended} formData Processed data for the submitted form.
* @returns {Promise<void>}
* @protected
*/
static async _fulfillRoll(event, form, formData) {
// Update the DiceTerms with the fulfilled values.
for ( let [id, results] of Object.entries(formData.object) ) {
const { term } = this.fulfillable.get(id);
if ( !Array.isArray(results) ) results = [results];
for ( const result of results ) {
const roll = { result: undefined, active: true };
// A null value indicates the user wishes to skip external fulfillment and fall back to the digital roll.
if ( result === null ) roll.result = term.randomFace();
else roll.result = result;
term.results.push(roll);
}
}
}
/* -------------------------------------------- */
/**
* Identify any of the given terms which should be fulfilled externally.
* @param {RollTerm[]} terms The terms.
* @param {object} [options]
* @param {boolean} [options.isNew=false] Whether this term is a new addition to the already-rendered RollResolver.
* @returns {Promise<DiceTerm[]>}
*/
async #identifyFulfillableTerms(terms, { isNew=false }={}) {
const config = game.settings.get("core", "diceConfiguration");
const fulfillable = Roll.defaultImplementation.identifyFulfillableTerms(terms);
fulfillable.forEach(term => {
if ( term._id ) return;
const method = config[term.denomination] || CONFIG.Dice.fulfillment.defaultMethod;
const id = foundry.utils.randomID();
term._id = id;
term.method = method;
this.fulfillable.set(id, { id, term, method, isNew });
});
return fulfillable;
}
/* -------------------------------------------- */
/**
* Add a new term to the resolver.
* @param {DiceTerm} term The term.
* @returns {Promise<void>} Returns a Promise that resolves when the term's results have been externally fulfilled.
*/
async addTerm(term) {
if ( !(term instanceof foundry.dice.terms.DiceTerm) ) {
throw new Error("Only DiceTerm instances may be added to the RollResolver.");
}
const fulfillable = await this.#identifyFulfillableTerms([term], { isNew: true });
if ( !fulfillable.length ) return;
this.render({ force: true, position: { height: "auto" } });
return new Promise(resolve => this.#resolve = resolve);
}
/* -------------------------------------------- */
/**
* Check if all rolls have been fulfilled.
* @protected
*/
_checkDone() {
// If the form has already in the submission state, we don't need to re-submit.
const submitter = this.element.querySelector('button[type="submit"]');
if ( submitter.disabled ) return;
// If there are any manual inputs, or if there are any empty inputs, then fulfillment is not done.
if ( this.element.querySelector("input:not([readonly], :disabled)") ) return;
for ( const input of this.element.querySelectorAll("input[readonly]:not(:disabled)") ) {
if ( input.value === "" ) return;
}
this.element.requestSubmit(submitter);
}
/* -------------------------------------------- */
/**
* Toggle the state of the submit button.
* @param {boolean} enabled Whether the button is enabled.
* @protected
*/
_toggleSubmission(enabled) {
const submit = this.element.querySelector('button[type="submit"]');
const icon = submit.querySelector("i");
icon.className = `fas ${enabled ? "fa-check" : "fa-spinner fa-pulse"}`;
submit.disabled = !enabled;
}
}

View File

@@ -0,0 +1,34 @@
/**
* Custom HTMLElement implementations for use in template rendering.
* @module elements
*/
import HTMLDocumentTagsElement from "./document-tags.mjs";
import HTMLFilePickerElement from "./file-picker.mjs";
import HTMLHueSelectorSlider from "./hue-slider.mjs";
import {HTMLMultiSelectElement, HTMLMultiCheckboxElement} from "./multi-select.mjs";
import HTMLStringTagsElement from "./string-tags.mjs";
import HTMLColorPickerElement from "./color-picker.mjs";
import HTMLRangePickerElement from "./range-picker.mjs";
import HTMLProseMirrorElement from "./prosemirror-editor.mjs";
export {default as AbstractFormInputElement} from "./form-element.mjs";
export {default as HTMLColorPickerElement} from "./color-picker.mjs";
export {default as HTMLDocumentTagsElement} from "./document-tags.mjs";
export {default as HTMLFilePickerElement} from "./file-picker.mjs";
export {default as HTMLHueSelectorSlider} from "./hue-slider.mjs"
export {default as HTMLRangePickerElement} from "./range-picker.mjs"
export {default as HTMLStringTagsElement} from "./string-tags.mjs"
export {default as HTMLProseMirrorElement} from "./prosemirror-editor.mjs";
export * from "./multi-select.mjs";
// Define custom elements
window.customElements.define(HTMLColorPickerElement.tagName, HTMLColorPickerElement);
window.customElements.define(HTMLDocumentTagsElement.tagName, HTMLDocumentTagsElement);
window.customElements.define(HTMLFilePickerElement.tagName, HTMLFilePickerElement);
window.customElements.define(HTMLHueSelectorSlider.tagName, HTMLHueSelectorSlider);
window.customElements.define(HTMLMultiSelectElement.tagName, HTMLMultiSelectElement);
window.customElements.define(HTMLMultiCheckboxElement.tagName, HTMLMultiCheckboxElement);
window.customElements.define(HTMLRangePickerElement.tagName, HTMLRangePickerElement);
window.customElements.define(HTMLStringTagsElement.tagName, HTMLStringTagsElement);
window.customElements.define(HTMLProseMirrorElement.tagName, HTMLProseMirrorElement);

View File

@@ -0,0 +1,103 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* A custom HTMLElement used to select a color using a linked pair of input fields.
* @extends {AbstractFormInputElement<string>}
*/
export default class HTMLColorPickerElement extends AbstractFormInputElement {
constructor() {
super();
this._setValue(this.getAttribute("value")); // Initialize existing color value
}
/** @override */
static tagName = "color-picker";
/* -------------------------------------------- */
/**
* The button element to add a new document.
* @type {HTMLInputElement}
*/
#colorSelector;
/**
* The input element to define a Document UUID.
* @type {HTMLInputElement}
*/
#colorString;
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Create string input element
this.#colorString = this._primaryInput = document.createElement("input");
this.#colorString.type = "text";
this.#colorString.placeholder = this.getAttribute("placeholder") || "";
this._applyInputAttributes(this.#colorString);
// Create color selector element
this.#colorSelector = document.createElement("input");
this.#colorSelector.type = "color";
this._applyInputAttributes(this.#colorSelector);
return [this.#colorString, this.#colorSelector];
}
/* -------------------------------------------- */
/** @override */
_refresh() {
if ( !this.#colorString ) return; // Not yet connected
this.#colorString.value = this._value;
this.#colorSelector.value = this._value || this.#colorString.placeholder || "#000000";
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
const onChange = this.#onChangeInput.bind(this);
this.#colorString.addEventListener("change", onChange);
this.#colorSelector.addEventListener("change", onChange);
}
/* -------------------------------------------- */
/**
* Handle changes to one of the inputs of the color picker element.
* @param {InputEvent} event The originating input change event
*/
#onChangeInput(event) {
event.stopPropagation();
this.value = event.currentTarget.value;
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#colorString.toggleAttribute("disabled", disabled);
this.#colorSelector.toggleAttribute("disabled", disabled);
}
/* -------------------------------------------- */
/**
* Create a HTMLColorPickerElement using provided configuration data.
* @param {FormInputConfig} config
* @returns {HTMLColorPickerElement}
*/
static create(config) {
const picker = document.createElement(HTMLColorPickerElement.tagName);
picker.name = config.name;
picker.setAttribute("value", config.value ?? "");
foundry.applications.fields.setInputAttributes(picker, config);
return picker;
}
}

View File

@@ -0,0 +1,344 @@
import AbstractFormInputElement from "./form-element.mjs";
import HTMLStringTagsElement from "./string-tags.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* @typedef {Object} DocumentTagsInputConfig
* @property {string} [type] A specific document type in CONST.ALL_DOCUMENT_TYPES
* @property {boolean} [single] Only allow referencing a single document. In this case the submitted form value will
* be a single UUID string rather than an array
* @property {number} [max] Only allow attaching a maximum number of documents
*/
/**
* A custom HTMLElement used to render a set of associated Documents referenced by UUID.
* @extends {AbstractFormInputElement<string|string[]|null>}
*/
export default class HTMLDocumentTagsElement extends AbstractFormInputElement {
constructor() {
super();
this._initializeTags();
}
/** @override */
static tagName = "document-tags";
/* -------------------------------------------- */
/**
* @override
* @type {Record<string, string>}
* @protected
*/
_value = {};
/**
* The button element to add a new document.
* @type {HTMLButtonElement}
*/
#button;
/**
* The input element to define a Document UUID.
* @type {HTMLInputElement}
*/
#input;
/**
* The list of tagged documents.
* @type {HTMLDivElement}
*/
#tags;
/* -------------------------------------------- */
/**
* Restrict this element to documents of a particular type.
* @type {string|null}
*/
get type() {
return this.getAttribute("type");
}
set type(value) {
if ( !value ) return this.removeAttribute("type");
if ( !CONST.ALL_DOCUMENT_TYPES.includes(value) ) {
throw new Error(`"${value}" is not a valid Document type in CONST.ALL_DOCUMENT_TYPES`);
}
this.setAttribute("type", value);
}
/* -------------------------------------------- */
/**
* Restrict to only allow referencing a single Document instead of an array of documents.
* @type {boolean}
*/
get single() {
return this.hasAttribute("single");
}
set single(value) {
this.toggleAttribute("single", value === true);
}
/* -------------------------------------------- */
/**
* Allow a maximum number of documents to be tagged to the element.
* @type {number}
*/
get max() {
const max = parseInt(this.getAttribute("max"));
return isNaN(max) ? Infinity : max;
}
set max(value) {
if ( Number.isInteger(value) && (value > 0) ) this.setAttribute("max", String(value));
else this.removeAttribute("max");
}
/* -------------------------------------------- */
/**
* Initialize innerText or an initial value attribute of the element as a serialized JSON array.
* @protected
*/
_initializeTags() {
const initial = this.getAttribute("value") || this.innerText || "";
const tags = initial ? initial.split(",") : [];
for ( const t of tags ) {
try {
this.#add(t);
} catch(err) {
this._value[t] = `${t} [INVALID]`; // Display invalid UUIDs as a raw string
}
}
this.innerText = "";
this.removeAttribute("value");
}
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Create tags list
this.#tags = document.createElement("div");
this.#tags.className = "tags input-element-tags";
// Create input element
this.#input = this._primaryInput = document.createElement("input");
this.#input.type = "text";
this.#input.placeholder = game.i18n.format("HTMLDocumentTagsElement.PLACEHOLDER", {
type: game.i18n.localize(this.type ? getDocumentClass(this.type).metadata.label : "DOCUMENT.Document")});
// Create button
this.#button = document.createElement("button");
this.#button.type = "button"
this.#button.className = "icon fa-solid fa-file-plus";
this.#button.dataset.tooltip = game.i18n.localize("ELEMENTS.DOCUMENT_TAGS.Add");
this.#button.setAttribute("aria-label", this.#button.dataset.tooltip);
return [this.#tags, this.#input, this.#button];
}
/* -------------------------------------------- */
/** @override */
_refresh() {
if ( !this.#tags ) return; // Not yet connected
const tags = Object.entries(this._value).map(([k, v]) => this.constructor.renderTag(k, v, this.editable));
this.#tags.replaceChildren(...tags);
}
/* -------------------------------------------- */
/**
* Create an HTML string fragment for a single document tag.
* @param {string} uuid The document UUID
* @param {string} name The document name
* @param {boolean} [editable=true] Is the tag editable?
* @returns {HTMLDivElement}
*/
static renderTag(uuid, name, editable=true) {
const div = HTMLStringTagsElement.renderTag(uuid, TextEditor.truncateText(name, {maxLength: 32}), editable);
div.classList.add("document-tag");
div.querySelector("span").dataset.tooltip = uuid;
if ( editable ) {
const t = game.i18n.localize("ELEMENTS.DOCUMENT_TAGS.Remove");
const a = div.querySelector("a");
a.dataset.tooltip = t;
a.ariaLabel = t;
}
return div;
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
this.#button.addEventListener("click", () => this.#tryAdd(this.#input.value));
this.#tags.addEventListener("click", this.#onClickTag.bind(this));
this.#input.addEventListener("keydown", this.#onKeydown.bind(this));
this.addEventListener("drop", this.#onDrop.bind(this));
}
/* -------------------------------------------- */
/**
* Remove a single coefficient by clicking on its tag.
* @param {PointerEvent} event
*/
#onClickTag(event) {
if ( !event.target.classList.contains("remove") ) return;
const tag = event.target.closest(".tag");
delete this._value[tag.dataset.key];
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this._refresh();
}
/* -------------------------------------------- */
/**
* Add a new document tag by pressing the ENTER key in the UUID input field.
* @param {KeyboardEvent} event
*/
#onKeydown(event) {
if ( event.key !== "Enter" ) return;
event.preventDefault();
event.stopPropagation();
this.#tryAdd(this.#input.value);
}
/* -------------------------------------------- */
/**
* Handle data dropped onto the form element.
* @param {DragEvent} event
*/
#onDrop(event) {
event.preventDefault();
const dropData = TextEditor.getDragEventData(event);
if ( dropData.uuid ) this.#tryAdd(dropData.uuid);
}
/* -------------------------------------------- */
/**
* Add a Document to the tagged set using the value of the input field.
* @param {string} uuid The UUID to attempt to add
*/
#tryAdd(uuid) {
try {
this.#add(uuid);
this._refresh();
} catch(err) {
ui.notifications.error(err.message);
}
this.#input.value = "";
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this.#input.focus();
}
/* -------------------------------------------- */
/**
* Validate that the tagged document is allowed to be added to this field.
* Subclasses may impose more strict validation as to which types of documents are allowed.
* @param {foundry.abstract.Document|object} document A candidate document or compendium index entry to tag
* @throws {Error} An error if the candidate document is not allowed
*/
_validateDocument(document) {
const {type, max} = this;
if ( type && (document.documentName !== type) ) throw new Error(`Incorrect document type "${document.documentName}"`
+ ` provided to document tag field which requires "${type}" documents.`);
const n = Object.keys(this._value).length;
if ( n >= max ) throw new Error(`You may only attach at most ${max} Documents to the "${this.name}" field`);
}
/* -------------------------------------------- */
/**
* Add a new UUID to the tagged set, throwing an error if the UUID is not valid.
* @param {string} uuid The UUID to add
* @throws {Error} If the UUID is not valid
*/
#add(uuid) {
// Require the UUID to exist
let record;
const {id} = foundry.utils.parseUuid(uuid);
if ( id ) record = fromUuidSync(uuid);
else if ( this.type ) {
const collection = game.collections.get(this.type);
record = collection.get(uuid);
}
if ( !record ) throw new Error(`Invalid document UUID "${uuid}" provided to document tag field.`);
// Require a certain type of document
this._validateDocument(record);
// Replace singleton
if ( this.single ) {
for ( const k of Object.keys(this._value) ) delete this._value[k];
}
// Record the document
this._value[uuid] = record.name;
}
/* -------------------------------------------- */
/* Form Handling */
/* -------------------------------------------- */
/** @override */
_getValue() {
const uuids = Object.keys(this._value);
if ( this.single ) return uuids[0] ?? null;
else return uuids;
}
/** @override */
_setValue(value) {
this._value = {};
if ( !value ) return;
if ( typeof value === "string" ) value = [value];
for ( const uuid of value ) this.#add(uuid);
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#input?.toggleAttribute("disabled", disabled);
this.#button?.toggleAttribute("disabled", disabled);
}
/* -------------------------------------------- */
/**
* Create a HTMLDocumentTagsElement using provided configuration data.
* @param {FormInputConfig & DocumentTagsInputConfig} config
* @returns {HTMLDocumentTagsElement}
*/
static create(config) {
const tags = /** @type {HTMLDocumentTagsElement} */ document.createElement(HTMLDocumentTagsElement.tagName);
tags.name = config.name;
// Coerce value to an array
let values;
if ( config.value instanceof Set ) values = Array.from(config.value);
else if ( !Array.isArray(config.value) ) values = [config.value];
else values = config.value;
tags.setAttribute("value", values);
tags.type = config.type;
tags.max = config.max;
tags.single = config.single;
foundry.applications.fields.setInputAttributes(tags, config);
return tags;
}
}

View File

@@ -0,0 +1,158 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* @typedef {Object} FilePickerInputConfig
* @property {FilePickerOptions.type} [type]
* @property {string} [placeholder]
* @property {boolean} [noupload]
*/
/**
* A custom HTML element responsible for rendering a file input field and associated FilePicker button.
* @extends {AbstractFormInputElement<string>}
*/
export default class HTMLFilePickerElement extends AbstractFormInputElement {
/** @override */
static tagName = "file-picker";
/**
* The file path selected.
* @type {HTMLInputElement}
*/
input;
/**
* A button to open the file picker interface.
* @type {HTMLButtonElement}
*/
button;
/**
* A reference to the FilePicker application instance originated by this element.
* @type {FilePicker}
*/
picker;
/* -------------------------------------------- */
/**
* A type of file which can be selected in this field.
* @see {@link FilePicker.FILE_TYPES}
* @type {FilePickerOptions.type}
*/
get type() {
return this.getAttribute("type") ?? "any";
}
set type(value) {
if ( !FilePicker.FILE_TYPES.includes(value) ) throw new Error(`Invalid type "${value}" provided which must be a `
+ "value in FilePicker.TYPES");
this.setAttribute("type", value);
}
/* -------------------------------------------- */
/**
* Prevent uploading new files as part of this element's FilePicker dialog.
* @type {boolean}
*/
get noupload() {
return this.hasAttribute("noupload");
}
set noupload(value) {
this.toggleAttribute("noupload", value === true);
}
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Initialize existing value
this._value ??= this.getAttribute("value") || this.innerText || "";
this.removeAttribute("value");
// Create an input field
const elements = [];
this.input = this._primaryInput = document.createElement("input");
this.input.className = "image";
this.input.type = "text";
this.input.placeholder = this.getAttribute("placeholder") ?? "path/to/file.ext";
elements.push(this.input);
// Disallow browsing for some users
if ( game.world && !game.user.can("FILES_BROWSE") ) return elements;
// Create a FilePicker button
this.button = document.createElement("button");
this.button.className = "fa-solid fa-file-import fa-fw";
this.button.type = "button";
this.button.dataset.tooltip = game.i18n.localize("FILES.BrowseTooltip");
this.button.setAttribute("aria-label", this.button.dataset.tooltip);
this.button.tabIndex = -1;
elements.push(this.button);
return elements;
}
/* -------------------------------------------- */
/** @override */
_refresh() {
this.input.value = this._value;
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.input.disabled = disabled;
if ( this.button ) this.button.disabled = disabled;
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
this.input.addEventListener("input", () => this._value = this.input.value);
this.button?.addEventListener("click", this.#onClickButton.bind(this));
}
/* -------------------------------------------- */
/**
* Handle clicks on the button element to render the FilePicker UI.
* @param {PointerEvent} event The initiating click event
*/
#onClickButton(event) {
event.preventDefault();
this.picker = new FilePicker({
type: this.type,
current: this.value,
allowUpload: !this.noupload,
callback: src => this.value = src
});
return this.picker.browse();
}
/* -------------------------------------------- */
/**
* Create a HTMLFilePickerElement using provided configuration data.
* @param {FormInputConfig<string> & FilePickerInputConfig} config
*/
static create(config) {
const picker = document.createElement(this.tagName);
picker.name = config.name;
picker.setAttribute("value", config.value || "");
picker.type = config.type;
picker.noupload = config.noupload;
foundry.applications.fields.setInputAttributes(picker, config);
return picker;
}
}

View File

@@ -0,0 +1,214 @@
/**
* An abstract custom HTMLElement designed for use with form inputs.
* @abstract
* @template {any} FormInputValueType
*
* @fires {Event} input An "input" event when the value of the input changes
* @fires {Event} change A "change" event when the value of the element changes
*/
export default class AbstractFormInputElement extends HTMLElement {
constructor() {
super();
this._internals = this.attachInternals();
}
/**
* The HTML tag name used by this element.
* @type {string}
*/
static tagName;
/**
* Declare that this custom element provides form element functionality.
* @type {boolean}
*/
static formAssociated = true;
/**
* Attached ElementInternals which provides form handling functionality.
* @type {ElementInternals}
* @protected
*/
_internals;
/**
* The primary input (if any). Used to determine what element should receive focus when an associated label is clicked
* on.
* @type {HTMLElement}
* @protected
*/
_primaryInput;
/**
* The form this element belongs to.
* @type {HTMLFormElement}
*/
get form() {
return this._internals.form;
}
/* -------------------------------------------- */
/* Element Properties */
/* -------------------------------------------- */
/**
* The input element name.
* @type {string}
*/
get name() {
return this.getAttribute("name");
}
set name(value) {
this.setAttribute("name", value);
}
/* -------------------------------------------- */
/**
* The value of the input element.
* @type {FormInputValueType}
*/
get value() {
return this._getValue();
}
set value(value) {
this._setValue(value);
this.dispatchEvent(new Event("input", {bubbles: true, cancelable: true}));
this.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
this._refresh();
}
/**
* The underlying value of the element.
* @type {FormInputValueType}
* @protected
*/
_value;
/* -------------------------------------------- */
/**
* Return the value of the input element which should be submitted to the form.
* @returns {FormInputValueType}
* @protected
*/
_getValue() {
return this._value;
}
/* -------------------------------------------- */
/**
* Translate user-provided input value into the format that should be stored.
* @param {FormInputValueType} value A new value to assign to the element
* @throws {Error} An error if the provided value is invalid
* @protected
*/
_setValue(value) {
this._value = value;
}
/* -------------------------------------------- */
/**
* Is this element disabled?
* @type {boolean}
*/
get disabled() {
return this.hasAttribute("disabled");
}
set disabled(value) {
this.toggleAttribute("disabled", value);
this._toggleDisabled(!this.editable);
}
/* -------------------------------------------- */
/**
* Is this field editable? The field can be neither disabled nor readonly.
* @type {boolean}
*/
get editable() {
return !(this.hasAttribute("disabled") || this.hasAttribute("readonly"));
}
/* -------------------------------------------- */
/**
* Special behaviors that the subclass should implement when toggling the disabled state of the input.
* @param {boolean} disabled The new disabled state
* @protected
*/
_toggleDisabled(disabled) {}
/* -------------------------------------------- */
/* Element Lifecycle */
/* -------------------------------------------- */
/**
* Initialize the custom element, constructing its HTML.
*/
connectedCallback() {
const elements = this._buildElements();
this.replaceChildren(...elements);
this._refresh();
this._toggleDisabled(!this.editable);
this.addEventListener("click", this._onClick.bind(this));
this._activateListeners();
}
/* -------------------------------------------- */
/**
* 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.
* @protected
*/
_refresh() {}
/* -------------------------------------------- */
/**
* Apply key attributes on the containing custom HTML element to input elements contained within it.
* @internal
*/
_applyInputAttributes(input) {
input.toggleAttribute("required", this.hasAttribute("required"));
input.toggleAttribute("disabled", this.hasAttribute("disabled"));
input.toggleAttribute("readonly", this.hasAttribute("readonly"));
}
/* -------------------------------------------- */
/**
* Activate event listeners which add dynamic behavior to the custom element.
* @protected
*/
_activateListeners() {}
/* -------------------------------------------- */
/**
* Special handling when the custom element is clicked. This should be implemented to transfer focus to an
* appropriate internal element.
* @param {PointerEvent} event
* @protected
*/
_onClick(event) {
if ( event.target === this ) this._primaryInput?.focus?.();
}
}

View File

@@ -0,0 +1,88 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* A class designed to standardize the behavior for a hue selector UI component.
* @extends {AbstractFormInputElement<number>}
*/
export default class HTMLHueSelectorSlider extends AbstractFormInputElement {
/** @override */
static tagName = "hue-slider";
/**
* The color range associated with this element.
* @type {HTMLInputElement|null}
*/
#input;
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Initialize existing value
this._setValue(this.getAttribute("value"));
// Build elements
this.#input = this._primaryInput = document.createElement("input");
this.#input.className = "color-range";
this.#input.type = "range";
this.#input.min = "0";
this.#input.max = "360";
this.#input.step = "1";
this.#input.disabled = this.disabled;
this.#input.value = this._value * 360;
return [this.#input];
}
/* -------------------------------------------- */
/**
* Refresh the active state of the custom element.
* @protected
*/
_refresh() {
this.#input.style.setProperty("--color-thumb", Color.fromHSL([this._value, 1, 0.5]).css);
}
/* -------------------------------------------- */
/**
* Activate event listeners which add dynamic behavior to the custom element.
* @protected
*/
_activateListeners() {
this.#input.oninput = this.#onInputColorRange.bind(this);
}
/* -------------------------------------------- */
/**
* Update the thumb and the value.
* @param {FormDataEvent} event
*/
#onInputColorRange(event) {
event.preventDefault();
event.stopImmediatePropagation();
this.value = this.#input.value / 360;
}
/* -------------------------------------------- */
/* Form Handling
/* -------------------------------------------- */
/** @override */
_setValue(value) {
value = Number(value);
if ( !value.between(0, 1) ) throw new Error("The value of a hue-slider must be on the range [0,1]");
this._value = value;
this.setAttribute("value", String(value));
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#input.disabled = disabled;
}
}

View File

@@ -0,0 +1,361 @@
import AbstractFormInputElement from "./form-element.mjs";
import HTMLStringTagsElement from "./string-tags.mjs";
/**
* 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.
* @extends {AbstractFormInputElement<Set<string>>}
*/
export class AbstractMultiSelectElement extends AbstractFormInputElement {
constructor() {
super();
this._value = new Set();
this._initialize();
}
/**
* 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 {Record<string, string>}
* @protected
*/
_choices = {};
/* -------------------------------------------- */
/**
* Preserve existing <option> and <optgroup> elements which are defined in the original HTML.
* @protected
*/
_initialize() {
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._value.add(option.value);
option.selected = false;
}
}
}
/* -------------------------------------------- */
/**
* Mark a choice as selected.
* @param {string} value The value to add to the chosen set
*/
select(value) {
const exists = this._value.has(value);
if ( !exists ) {
if ( !(value in this._choices) ) {
throw new Error(`"${value}" is not an option allowed by this multi-select element`);
}
this._value.add(value);
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this._refresh();
}
}
/* -------------------------------------------- */
/**
* Mark a choice as un-selected.
* @param {string} value The value to delete from the chosen set
*/
unselect(value) {
const exists = this._value.has(value);
if ( exists ) {
this._value.delete(value);
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this._refresh();
}
}
/* -------------------------------------------- */
/* Form Handling */
/* -------------------------------------------- */
/** @override */
_getValue() {
return Array.from(this._value);
}
/** @override */
_setValue(value) {
if ( !Array.isArray(value) ) {
throw new Error("The value assigned to a multi-select element must be an array.");
}
if ( value.some(v => !(v in this._choices)) ) {
throw new Error("The values assigned to a multi-select element must all be valid options.");
}
this._value.clear();
for ( const v of value ) this._value.add(v);
}
}
/* -------------------------------------------- */
/**
* Provide a multi-select workflow using a select element as the input mechanism.
*
* @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>
* ```
*/
export class HTMLMultiSelectElement extends AbstractMultiSelectElement {
/** @override */
static tagName = "multi-select";
/**
* 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 = this._primaryInput = document.createElement("select");
this.#select.insertAdjacentHTML("afterbegin", '<option value=""></option>');
this.#select.append(...this._options);
this.#select.disabled = !this.editable;
// Create a div element for display
this.#tags = document.createElement("div");
this.#tags.className = "tags input-element-tags";
return [this.#tags, this.#select];
}
/* -------------------------------------------- */
/** @override */
_refresh() {
// Update the displayed tags
const tags = Array.from(this._value).map(id => {
return HTMLStringTagsElement.renderTag(id, this._choices[id], this.editable);
});
this.#tags.replaceChildren(...tags);
// Disable selected options
for ( const option of this.#select.querySelectorAll("option") ) {
option.disabled = this._value.has(option.value);
}
}
/* -------------------------------------------- */
/** @override */
_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();
if ( !event.target.classList.contains("remove") ) return;
if ( !this.editable ) return;
const tag = event.target.closest(".tag");
this.unselect(tag.dataset.key);
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#select.toggleAttribute("disabled", disabled);
}
/* -------------------------------------------- */
/**
* Create a HTMLMultiSelectElement using provided configuration data.
* @param {FormInputConfig<string[]> & Omit<SelectInputConfig, "blank">} config
* @returns {HTMLMultiSelectElement}
*/
static create(config) {
return foundry.applications.fields.createMultiSelectInput(config);
}
}
/* -------------------------------------------- */
/**
* Provide a multi-select workflow as a grid of input checkbox 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>
* ```
*/
export class HTMLMultiCheckboxElement extends AbstractMultiSelectElement {
/** @override */
static tagName = "multi-checkbox";
/**
* 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._value.has(option.value);
checkbox.disabled = this.disabled;
label.append(checkbox, option.innerText);
this.#checkboxes.push(checkbox);
return label;
}
/* -------------------------------------------- */
/** @override */
_refresh() {
for ( const checkbox of this.#checkboxes ) {
checkbox.checked = this._value.has(checkbox.value);
}
}
/* -------------------------------------------- */
/** @override */
_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);
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
for ( const checkbox of this.#checkboxes ) {
checkbox.disabled = disabled;
}
}
}

View File

@@ -0,0 +1,248 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* @typedef {Object} ProseMirrorInputConfig
* @property {boolean} toggled Is this editor toggled (true) or always active (false)
* @property {string} [enriched] If the editor is toggled, provide the enrichedHTML which is displayed while
* the editor is not active.
* @property {boolean} collaborate Does this editor instance support collaborative editing?
* @property {boolean} compact Should the editor be presented in compact mode?
* @property {string} documentUUID A Document UUID. Required for collaborative editing
*/
/**
* A custom HTML element responsible displaying a ProseMirror rich text editor.
* @extends {AbstractFormInputElement<string>}
*/
export default class HTMLProseMirrorElement extends AbstractFormInputElement {
constructor() {
super();
// Initialize raw content
this._setValue(this.getAttribute("value") || "");
this.removeAttribute("value");
// Initialize enriched content
this.#toggled = this.hasAttribute("toggled");
this.#enriched = this.innerHTML;
}
/** @override */
static tagName = "prose-mirror";
/**
* Is the editor in active edit mode?
* @type {boolean}
*/
#active = false;
/**
* The ProseMirror editor instance.
* @type {ProseMirrorEditor}
*/
#editor;
/**
* Current editor contents
* @type {HTMLDivElement}
*/
#content;
/**
* Does this editor function via a toggle button? Or is it always active?
* @type {boolean}
*/
#toggled;
/**
* Enriched content which is optionally used if the editor is toggled.
* @type {string}
*/
#enriched;
/**
* An optional edit button which activates edit mode for the editor
* @type {HTMLButtonElement|null}
*/
#button = null;
/* -------------------------------------------- */
/**
* Actions to take when the custom element is removed from the document.
*/
disconnectedCallback() {
this.#editor?.destroy();
}
/* -------------------------------------------- */
/** @override */
_buildElements() {
this.classList.add("editor", "prosemirror", "inactive");
const elements = [];
this.#content = document.createElement("div");
this.#content.className = "editor-content";
elements.push(this.#content);
if ( this.#toggled ) {
this.#button = document.createElement("button");
this.#button.type = "button";
this.#button.className = "icon toggle";
this.#button.innerHTML = `<i class="fa-solid fa-edit"></i>`;
elements.push(this.#button);
}
return elements;
}
/* -------------------------------------------- */
/** @override */
_refresh() {
if ( this.#active ) return; // It is not safe to replace the content while the editor is active
if ( this.#toggled ) this.#content.innerHTML = this.#enriched ?? this._value;
else this.#content.innerHTML = this._value;
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
if ( this.#toggled ) this.#button.addEventListener("click", this.#onClickButton.bind(this));
else this.#activateEditor();
}
/* -------------------------------------------- */
/** @override */
_getValue() {
if ( this.#active ) return ProseMirror.dom.serializeString(this.#editor.view.state.doc.content);
return this._value;
}
/* -------------------------------------------- */
/**
* Activate the ProseMirror editor.
* @returns {Promise<void>}
*/
async #activateEditor() {
// If the editor was toggled, replace with raw editable content
if ( this.#toggled ) this.#content.innerHTML = this._value;
// Create the TextEditor instance
const document = await fromUuid(this.dataset.documentUuid ?? this.dataset.documentUUID);
this.#editor = await TextEditor.create({
engine: "prosemirror",
plugins: this._configurePlugins(),
fieldName: this.name,
collaborate: this.hasAttribute("collaborate"),
target: this.#content,
document
}, this._getValue());
// Toggle active state
this.#active = true;
if ( this.#button ) this.#button.disabled = true;
this.classList.add("active");
this.classList.remove("inactive");
}
/* -------------------------------------------- */
/**
* Configure ProseMirror editor plugins.
* @returns {Record<string, ProseMirror.Plugin>}
* @protected
*/
_configurePlugins() {
return {
menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
compact: this.hasAttribute("compact"),
destroyOnSave: this.#toggled,
onSave: this.#save.bind(this)
}),
keyMaps: ProseMirror.ProseMirrorKeyMaps.build(ProseMirror.defaultSchema, {
onSave: this.#save.bind(this)
})
};
}
/* -------------------------------------------- */
/**
* Handle clicking the editor activation button.
* @param {PointerEvent} event The triggering event.
*/
#onClickButton(event) {
event.preventDefault();
this.#activateEditor();
}
/* -------------------------------------------- */
/**
* Handle saving the editor content.
* Store new parsed HTML into the _value attribute of the element.
* If the editor is toggled, also deactivate editing mode.
*/
#save() {
const value = ProseMirror.dom.serializeString(this.#editor.view.state.doc.content);
if ( value !== this._value ) {
this._setValue(value);
this.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
}
// Deactivate a toggled editor
if ( this.#toggled ) {
this.#button.disabled = this.disabled;
this.#active = false;
this.#editor.destroy();
this.classList.remove("active");
this.classList.add("inactive");
this.replaceChildren(this.#button, this.#content);
this._refresh();
this.dispatchEvent(new Event("close", {bubbles: true, cancelable: true}));
}
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
if ( this.#toggled ) this.#button.disabled = disabled;
}
/* -------------------------------------------- */
/**
* Create a HTMLProseMirrorElement using provided configuration data.
* @param {FormInputConfig & ProseMirrorInputConfig} config
* @returns {HTMLProseMirrorElement}
*/
static create(config) {
const editor = document.createElement(HTMLProseMirrorElement.tagName);
editor.name = config.name;
// Configure editor properties
editor.toggleAttribute("collaborate", config.collaborate ?? false);
editor.toggleAttribute("compact", config.compact ?? false);
editor.toggleAttribute("toggled", config.toggled ?? false);
if ( "documentUUID" in config ) Object.assign(editor.dataset, {
documentUuid: config.documentUUID,
documentUUID: config.documentUUID
});
if ( Number.isNumeric(config.height) ) editor.style.height = `${config.height}px`;
// Un-enriched content gets temporarily assigned to the value property of the element
editor.setAttribute("value", config.value);
// Enriched content gets temporarily assigned as the innerHTML of the element
if ( config.toggled && config.enriched ) editor.innerHTML = config.enriched;
return editor;
}
}

View File

@@ -0,0 +1,166 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* @typedef {Object} RangePickerInputConfig
* @property {number} min
* @property {number} max
* @property {number} [step]
*/
/**
* A custom HTML element responsible selecting a value on a range slider with a linked number input field.
* @extends {AbstractFormInputElement<number>}
*/
export default class HTMLRangePickerElement extends AbstractFormInputElement {
constructor() {
super();
this.#min = Number(this.getAttribute("min")) ?? 0;
this.#max = Number(this.getAttribute("max")) ?? 1;
this.#step = Number(this.getAttribute("step")) || undefined;
this._setValue(Number(this.getAttribute("value"))); // Initialize existing value
}
/** @override */
static tagName = "range-picker";
/**
* The range input.
* @type {HTMLInputElement}
*/
#rangeInput;
/**
* The number input.
* @type {HTMLInputElement}
*/
#numberInput;
/**
* The minimum allowed value for the range.
* @type {number}
*/
#min;
/**
* The maximum allowed value for the range.
* @type {number}
*/
#max;
/**
* A required step size for the range.
* @type {number}
*/
#step;
/* -------------------------------------------- */
/**
* The value of the input element.
* @type {number}
*/
get valueAsNumber() {
return this._getValue();
}
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Create range input element
const r = this.#rangeInput = document.createElement("input");
r.type = "range";
r.min = String(this.#min);
r.max = String(this.#max);
r.step = String(this.#step ?? 0.1);
this._applyInputAttributes(r);
// Create the number input element
const n = this.#numberInput = this._primaryInput = document.createElement("input");
n.type = "number";
n.min = String(this.#min);
n.max = String(this.#max);
n.step = this.#step ?? "any";
this._applyInputAttributes(n);
return [this.#rangeInput, this.#numberInput];
}
/* -------------------------------------------- */
/** @override */
_setValue(value) {
value = Math.clamp(value, this.#min, this.#max);
if ( this.#step ) value = value.toNearest(this.#step);
this._value = value;
}
/* -------------------------------------------- */
/** @override */
_refresh() {
if ( !this.#rangeInput ) return; // Not yet connected
this.#rangeInput.valueAsNumber = this.#numberInput.valueAsNumber = this._value;
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
const onChange = this.#onChangeInput.bind(this);
this.#rangeInput.addEventListener("input", this.#onDragSlider.bind(this));
this.#rangeInput.addEventListener("change", onChange);
this.#numberInput.addEventListener("change", onChange);
}
/* -------------------------------------------- */
/**
* Update display of the number input as the range slider is actively changed.
* @param {InputEvent} event The originating input event
*/
#onDragSlider(event) {
event.preventDefault();
this.#numberInput.valueAsNumber = this.#rangeInput.valueAsNumber;
}
/* -------------------------------------------- */
/**
* Handle changes to one of the inputs of the range picker element.
* @param {InputEvent} event The originating input change event
*/
#onChangeInput(event) {
event.stopPropagation();
this.value = event.currentTarget.valueAsNumber;
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#rangeInput.toggleAttribute("disabled", disabled);
this.#numberInput.toggleAttribute("disabled", disabled);
}
/* -------------------------------------------- */
/**
* Create a HTMLRangePickerElement using provided configuration data.
* @param {FormInputConfig & RangePickerInputConfig} config
* @returns {HTMLRangePickerElement}
*/
static create(config) {
const picker = document.createElement(HTMLRangePickerElement.tagName);
picker.name = config.name;
for ( const attr of ["value", "min", "max", "step"] ) {
if ( attr in config ) picker.setAttribute(attr, config[attr]);
}
foundry.applications.fields.setInputAttributes(picker, config);
return picker;
}
}

View File

@@ -0,0 +1,275 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* @typedef {Object} StringTagsInputConfig
* @property {boolean} slug Automatically slugify provided strings?
*/
/**
* A custom HTML element which allows for arbitrary assignment of a set of string tags.
* This element may be used directly or subclassed to impose additional validation or functionality.
* @extends {AbstractFormInputElement<Set<string>>}
*/
export default class HTMLStringTagsElement extends AbstractFormInputElement {
constructor() {
super();
this.#slug = this.hasAttribute("slug");
this._value = new Set();
this._initializeTags();
}
/** @override */
static tagName = "string-tags";
static icons = {
add: "fa-solid fa-tag",
remove: "fa-solid fa-times"
}
static labels = {
add: "ELEMENTS.TAGS.Add",
remove: "ELEMENTS.TAGS.Remove",
placeholder: ""
}
/**
* The button element to add a new tag.
* @type {HTMLButtonElement}
*/
#button;
/**
* The input element to enter a new tag.
* @type {HTMLInputElement}
*/
#input;
/**
* The tags list of assigned tags.
* @type {HTMLDivElement}
*/
#tags;
/**
* Automatically slugify all strings provided to the element?
* @type {boolean}
*/
#slug;
/* -------------------------------------------- */
/**
* Initialize innerText or an initial value attribute of the element as a comma-separated list of currently assigned
* string tags.
* @protected
*/
_initializeTags() {
const initial = this.getAttribute("value") || this.innerText || "";
const tags = initial ? initial.split(",") : [];
for ( let tag of tags ) {
tag = tag.trim();
if ( tag ) {
if ( this.#slug ) tag = tag.slugify({strict: true});
try {
this._validateTag(tag);
} catch ( err ) {
console.warn(err.message);
continue;
}
this._value.add(tag);
}
}
this.innerText = "";
this.removeAttribute("value");
}
/* -------------------------------------------- */
/**
* Subclasses may impose more strict validation on what tags are allowed.
* @param {string} tag A candidate tag
* @throws {Error} An error if the candidate tag is not allowed
* @protected
*/
_validateTag(tag) {
if ( !tag ) throw new Error(game.i18n.localize("ELEMENTS.TAGS.ErrorBlank"));
}
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Create tags list
const tags = document.createElement("div");
tags.className = "tags input-element-tags";
this.#tags = tags;
// Create input element
const input = document.createElement("input");
input.type = "text";
input.placeholder = game.i18n.localize(this.constructor.labels.placeholder);
this.#input = this._primaryInput = input;
// Create button
const button = document.createElement("button");
button.type = "button"
button.className = `icon ${this.constructor.icons.add}`;
button.dataset.tooltip = this.constructor.labels.add;
button.ariaLabel = game.i18n.localize(this.constructor.labels.add);
this.#button = button;
return [this.#tags, this.#input, this.#button];
}
/* -------------------------------------------- */
/** @override */
_refresh() {
const tags = this.value.map(tag => this.constructor.renderTag(tag, tag, this.editable));
this.#tags.replaceChildren(...tags);
}
/* -------------------------------------------- */
/**
* Render the tagged string as an HTML element.
* @param {string} tag The raw tag value
* @param {string} [label] An optional tag label
* @param {boolean} [editable=true] Is the tag editable?
* @returns {HTMLDivElement} A rendered HTML element for the tag
*/
static renderTag(tag, label, editable=true) {
const div = document.createElement("div");
div.className = "tag";
div.dataset.key = tag;
const span = document.createElement("span");
span.textContent = label ?? tag;
div.append(span);
if ( editable ) {
const t = game.i18n.localize(this.labels.remove);
const a = `<a class="remove ${this.icons.remove}" data-tooltip="${t}" aria-label="${t}"></a>`;
div.insertAdjacentHTML("beforeend", a);
}
return div;
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
this.#button.addEventListener("click", this.#addTag.bind(this));
this.#tags.addEventListener("click", this.#onClickTag.bind(this));
this.#input.addEventListener("keydown", this.#onKeydown.bind(this));
}
/* -------------------------------------------- */
/**
* Remove a tag from the set when its removal button is clicked.
* @param {PointerEvent} event
*/
#onClickTag(event) {
if ( !event.target.classList.contains("remove") ) return;
const tag = event.target.closest(".tag");
this._value.delete(tag.dataset.key);
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this._refresh();
}
/* -------------------------------------------- */
/**
* Add a tag to the set when the ENTER key is pressed in the text input.
* @param {KeyboardEvent} event
*/
#onKeydown(event) {
if ( event.key !== "Enter" ) return;
event.preventDefault();
event.stopPropagation();
this.#addTag();
}
/* -------------------------------------------- */
/**
* Add a new tag to the set upon user input.
*/
#addTag() {
let tag = this.#input.value.trim();
if ( this.#slug ) tag = tag.slugify({strict: true});
// Validate the proposed code
try {
this._validateTag(tag);
} catch(err) {
ui.notifications.error(err.message);
this.#input.value = "";
return;
}
// Ensure uniqueness
if ( this._value.has(tag) ) {
const message = game.i18n.format("ELEMENTS.TAGS.ErrorNonUnique", {tag});
ui.notifications.error(message);
this.#input.value = "";
return;
}
// Add hex
this._value.add(tag);
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this.#input.value = "";
this._refresh();
}
/* -------------------------------------------- */
/* Form Handling */
/* -------------------------------------------- */
/** @override */
_getValue() {
return Array.from(this._value);
}
/* -------------------------------------------- */
/** @override */
_setValue(value) {
this._value.clear();
const toAdd = [];
for ( let v of value ) {
if ( this.#slug ) v = v.slugify({strict: true});
this._validateTag(v);
toAdd.push(v);
}
for ( const v of toAdd ) this._value.add(v);
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#input.toggleAttribute("disabled", disabled);
this.#button.toggleAttribute("disabled", disabled);
}
/* -------------------------------------------- */
/**
* Create a HTMLStringTagsElement using provided configuration data.
* @param {FormInputConfig & StringTagsInputConfig} config
*/
static create(config) {
const tags = document.createElement(this.tagName);
tags.name = config.name;
const value = Array.from(config.value || []).join(",");
tags.toggleAttribute("slug", !!config.slug)
tags.setAttribute("value", value);
foundry.applications.fields.setInputAttributes(tags, config);
return tags;
}
}

View File

@@ -0,0 +1,434 @@
/**
* @callback CustomFormGroup
* @param {DataField} field
* @param {FormGroupConfig} groupConfig
* @param {FormInputConfig} inputConfig
* @returns {HTMLDivElement}
*/
/**
* @callback CustomFormInput
* @param {DataField} field
* @param {FormInputConfig} config
* @returns {HTMLElement|HTMLCollection}
*/
/**
* @typedef {Object} FormGroupConfig
* @property {string} label A text label to apply to the form group
* @property {string} [units] An optional units string which is appended to the label
* @property {HTMLElement|HTMLCollection} input An HTML element or collection of elements which provide the inputs
* for the group
* @property {string} [hint] Hint text displayed as part of the form group
* @property {string} [rootId] Some parent CSS id within which field names are unique. If provided,
* this root ID is used to automatically assign "id" attributes to input
* elements and "for" attributes to corresponding labels
* @property {string[]} [classes] An array of CSS classes applied to the form group element
* @property {boolean} [stacked=false] Is the "stacked" class applied to the form group
* @property {boolean} [localize=false] Should labels or other elements within this form group be
* automatically localized?
* @property {CustomFormGroup} [widget] A custom form group widget function which replaces the default
* group HTML generation
*/
/**
* @template FormInputValue
* @typedef {Object} FormInputConfig
* @property {string} name The name of the form element
* @property {FormInputValue} [value] The current value of the form element
* @property {boolean} [required=false] Is the field required?
* @property {boolean} [disabled=false] Is the field disabled?
* @property {boolean} [readonly=false] Is the field readonly?
* @property {boolean} [autofocus=false] Is the field autofocused?
* @property {boolean} [localize=false] Localize values of this field?
* @property {Record<string,string>} [dataset] Additional dataset attributes to assign to the input
* @property {string} [placeholder] A placeholder value, if supported by the element type
* @property {CustomFormInput} [input]
*/
/**
* Create a standardized form field group.
* @param {FormGroupConfig} config
* @returns {HTMLDivElement}
*/
export function createFormGroup(config) {
let {classes, hint, label, input, rootId, stacked, localize, units} = config;
classes ||= [];
if ( stacked ) classes.unshift("stacked");
classes.unshift("form-group");
// Assign identifiers to each input
input = input instanceof HTMLCollection ? input : [input];
let labelFor;
if ( rootId ) {
for ( const [i, el] of input.entries() ) {
const id = [rootId, el.name, input.length > 1 ? i : ""].filterJoin("-");
labelFor ||= id;
el.setAttribute("id", id);
}
}
// Create the group element
const group = document.createElement("div");
group.className = classes.join(" ");
// Label element
const lbl = document.createElement("label");
lbl.innerText = localize ? game.i18n.localize(label) : label;
if ( labelFor ) lbl.setAttribute("for", labelFor);
if ( units ) lbl.insertAdjacentHTML("beforeend", ` <span class="units">(${game.i18n.localize(units)})</span>`);
group.prepend(lbl);
// Form fields and inputs
const fields = document.createElement("div");
fields.className = "form-fields";
fields.append(...input);
group.append(fields);
// Hint element
if ( hint ) {
const h = document.createElement("p");
h.className = "hint";
h.innerText = localize ? game.i18n.localize(hint) : hint;
group.append(h);
}
return group;
}
/* ---------------------------------------- */
/**
* Create an `<input type="checkbox">` element for a BooleanField.
* @param {FormInputConfig<boolean>} config
* @returns {HTMLInputElement}
*/
export function createCheckboxInput(config) {
const input = document.createElement("input");
input.type = "checkbox";
input.name = config.name;
if ( config.value ) input.setAttribute("checked", "");
setInputAttributes(input, config);
return input;
}
/* ---------------------------------------- */
/**
* @typedef {Object} EditorInputConfig
* @property {string} [engine="prosemirror"]
* @property {number} [height]
* @property {boolean} [editable=true]
* @property {boolean} [button=false]
* @property {boolean} [collaborate=false]
*/
/**
* Create a `<div class="editor">` element for a StringField.
* @param {FormInputConfig<string> & EditorInputConfig} config
* @returns {HTMLDivElement}
*/
export function createEditorInput(config) {
const {engine="prosemirror", editable=true, button=false, collaborate=false, height} = config;
const editor = document.createElement("div");
editor.className = "editor";
if ( height !== undefined ) editor.style.height = `${height}px`;
// Dataset attributes
let dataset = { engine, collaborate };
if ( editable ) dataset.edit = config.name;
dataset = Object.entries(dataset).map(([k, v]) => `data-${k}="${v}"`).join(" ");
// Editor HTML
let editorHTML = "";
if ( button && editable ) editorHTML += '<a class="editor-edit"><i class="fa-solid fa-edit"></i></a>';
editorHTML += `<div class="editor-content" ${dataset}>${config.value ?? ""}</div>`;
editor.innerHTML = editorHTML;
return editor;
}
/* ---------------------------------------- */
/**
* Create a `<multi-select>` element for a StringField.
* @param {FormInputConfig<string[]> & Omit<SelectInputConfig, "blank">} config
* @returns {HTMLSelectElement}
*/
export function createMultiSelectInput(config) {
const tagName = config.type === "checkboxes" ? "multi-checkbox" : "multi-select";
const select = document.createElement(tagName);
select.name = config.name;
setInputAttributes(select, config);
const groups = prepareSelectOptionGroups(config);
for ( const g of groups ) {
let parent = select;
if ( g.group ) parent = _appendOptgroup(g.group, select);
for ( const o of g.options ) _appendOption(o, parent);
}
return select;
}
/* ---------------------------------------- */
/**
* @typedef {Object} NumberInputConfig
* @property {number} min
* @property {number} max
* @property {number|"any"} step
* @property {"range"|"number"} [type]
*/
/**
* Create an `<input type="number">` element for a NumberField.
* @param {FormInputConfig<number> & NumberInputConfig} config
* @returns {HTMLInputElement}
*/
export function createNumberInput(config) {
const input = document.createElement("input");
input.type = "number";
if ( config.name ) input.name = config.name;
// Assign value
let step = typeof config.step === "number" ? config.step : "any";
let value = config.value;
if ( Number.isNumeric(value) ) {
if ( typeof config.step === "number" ) value = value.toNearest(config.step);
input.setAttribute("value", String(value));
}
else input.setAttribute("value", "");
// Min, max, and step size
if ( typeof config.min === "number" ) input.setAttribute("min", String(config.min));
if ( typeof config.max === "number" ) input.setAttribute("max", String(config.max));
input.setAttribute("step", String(step));
setInputAttributes(input, config);
return input;
}
/* ---------------------------------------- */
/**
* @typedef {Object} FormSelectOption
* @property {string} [value]
* @property {string} [label]
* @property {string} [group]
* @property {boolean} [disabled]
* @property {boolean} [selected]
* @property {boolean} [rule]
*/
/**
* @typedef {Object} SelectInputConfig
* @property {FormSelectOption[]} options
* @property {string[]} [groups] An option to control the order and display of optgroup elements. The order of
* strings defines the displayed order of optgroup elements.
* A blank string may be used to define the position of ungrouped options.
* If not defined, the order of groups corresponds to the order of options.
* @property {string} [blank]
* @property {string} [valueAttr] An alternative value key of the object passed to the options array
* @property {string} [labelAttr] An alternative label key of the object passed to the options array
* @property {boolean} [localize=false] Localize value labels
* @property {boolean} [sort=false] Sort options alphabetically by label within groups
* @property {"single"|"multi"|"checkboxes"} [type] Customize the type of select that is created
*/
/**
* Create a `<select>` element for a StringField.
* @param {FormInputConfig<string> & SelectInputConfig} config
* @returns {HTMLSelectElement}
*/
export function createSelectInput(config) {
const select = document.createElement("select");
select.name = config.name;
setInputAttributes(select, config);
const groups = prepareSelectOptionGroups(config);
for ( const g of groups ) {
let parent = select;
if ( g.group ) parent = _appendOptgroup(g.group, select);
for ( const o of g.options ) _appendOption(o, parent);
}
return select;
}
/* ---------------------------------------- */
/**
* @typedef {Object} TextAreaInputConfig
* @property {number} rows
*/
/**
* Create a `<textarea>` element for a StringField.
* @param {FormInputConfig<string> & TextAreaInputConfig} config
* @returns {HTMLTextAreaElement}
*/
export function createTextareaInput(config) {
const textarea = document.createElement("textarea");
textarea.name = config.name;
textarea.textContent = config.value ?? "";
if ( config.rows ) textarea.setAttribute("rows", String(config.rows));
setInputAttributes(textarea, config);
return textarea;
}
/* ---------------------------------------- */
/**
* Create an `<input type="text">` element for a StringField.
* @param {FormInputConfig<string>} config
* @returns {HTMLInputElement}
*/
export function createTextInput(config) {
const input = document.createElement("input");
input.type = "text";
input.name = config.name;
input.setAttribute("value", config.value ?? "");
setInputAttributes(input, config);
return input;
}
/* ---------------------------------------- */
/* Helper Methods */
/* ---------------------------------------- */
/**
* Structure a provided array of select options into a standardized format for rendering optgroup and option elements.
* @param {FormInputConfig & SelectInputConfig} config
* @returns {{group: string, options: FormSelectOption[]}[]}
*
* @example
* const options = [
* {value: "bar", label: "Bar", selected: true, group: "Good Options"},
* {value: "foo", label: "Foo", disabled: true, group: "Bad Options"},
* {value: "baz", label: "Baz", group: "Good Options"}
* ];
* const groups = ["Good Options", "Bad Options", "Unused Options"];
* const optgroups = foundry.applications.fields.prepareSelectOptionGroups({options, groups, blank: true, sort: true});
*/
export function prepareSelectOptionGroups(config) {
// Coerce values to string array
let values = [];
if ( (config.value === undefined) || (config.value === null) ) values = [];
else if ( typeof config.value === "object" ) {
for ( const v of config.value ) values.push(String(v));
}
else values = [String(config.value)];
const isSelected = value => values.includes(value);
// Organize options into groups
let hasBlank = false;
const groups = {};
for ( const option of (config.options || []) ) {
let {group, value, label, disabled, rule} = option;
// Value
if ( config.valueAttr ) value = option[config.valueAttr];
if ( value !== undefined ) {
value = String(value);
if ( value === "" ) hasBlank = true;
}
// Label
if ( config.labelAttr ) label = option[config.labelAttr];
label ??= value;
if ( label !== undefined ) {
if ( typeof label !== "string" ) label = label.toString();
if ( config.localize ) label = game.i18n.localize(label);
}
const selected = option.selected || isSelected(value);
disabled = !!disabled;
// Add to group
group ||= "";
groups[group] ||= [];
groups[group].push({type: "option", value, label, selected, disabled, rule})
}
// Add groups into an explicitly desired order
const result = [];
if ( config.groups instanceof Array ) {
for ( let group of config.groups ) {
const options = groups[group] ?? [];
delete groups[group];
if ( config.localize ) group = game.i18n.localize(group);
result.push({group, options});
}
}
// Add remaining groups
for ( let [groupName, options] of Object.entries(groups) ) {
if ( groupName && config.localize ) groupName = game.i18n.localize(groupName);
result.push({group: groupName, options});
}
// Sort options
if ( config.sort ) {
for ( const group of result ) group.options.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
}
// A blank option always comes first
if ( (typeof config.blank === "string") && !hasBlank ) result.unshift({group: "", options: [{
value: "",
label: config.localize ? game.i18n.localize(config.blank) : config.blank,
selected: isSelected("")
}]});
return result;
}
/* ---------------------------------------- */
/**
* Create and append an option element to a parent select or optgroup.
* @param {FormSelectOption} option
* @param {HTMLSelectElement|HTMLOptGroupElement} parent
* @internal
*/
function _appendOption(option, parent) {
const { value, label, selected, disabled, rule } = option;
if ( (value !== undefined) && (label !== undefined) ) {
const o = document.createElement("option");
o.value = value;
o.innerText = label;
if ( selected ) o.toggleAttribute("selected", true);
if ( disabled ) o.toggleAttribute("disabled", true);
parent.appendChild(o);
}
if ( rule ) parent.insertAdjacentHTML("beforeend", "<hr>");
}
/* ---------------------------------------- */
/**
* Create and append an optgroup element to a parent select.
* @param {string} label
* @param {HTMLSelectElement} parent
* @returns {HTMLOptGroupElement}
* @internal
*/
function _appendOptgroup(label, parent) {
const g = document.createElement("optgroup");
g.label = label;
parent.appendChild(g);
return g;
}
/* ---------------------------------------- */
/**
* Apply standard attributes to all input elements.
* @param {HTMLElement} input The element being configured
* @param {FormInputConfig<*>} config Configuration for the element
*/
export function setInputAttributes(input, config) {
input.toggleAttribute("required", config.required === true);
input.toggleAttribute("disabled", config.disabled === true);
input.toggleAttribute("readonly", config.readonly === true);
input.toggleAttribute("autofocus", config.autofocus === true);
if ( config.placeholder ) input.setAttribute("placeholder", config.placeholder);
if ( "dataset" in config ) {
for ( const [k, v] of Object.entries(config.dataset) ) {
input.dataset[k] = v;
}
}
}

View File

@@ -0,0 +1,7 @@
export {default as ActorSheetV2} from "./actor-sheet.mjs";
export {default as AmbientSoundConfig} from "./ambient-sound-config.mjs";
export {default as AmbientLightConfig} from "./ambient-light-config.mjs";
export {default as ItemSheetV2} from "./item-sheet.mjs";
export {default as RegionBehaviorConfig} from "./region-behavior-config.mjs";
export {default as RegionConfig} from "./region-config.mjs";
export {default as UserConfig} from "./user-config.mjs";

View File

@@ -0,0 +1,130 @@
import DocumentSheetV2 from "../api/document-sheet.mjs";
/**
* A base class for providing Actor Sheet behavior using ApplicationV2.
*/
export default class ActorSheetV2 extends DocumentSheetV2 {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
position: {
width: 600
},
window: {
controls: [
{
action: "configurePrototypeToken",
icon: "fa-solid fa-user-circle",
label: "TOKEN.TitlePrototype",
ownership: "OWNER"
},
{
action: "showPortraitArtwork",
icon: "fa-solid fa-image",
label: "SIDEBAR.CharArt",
ownership: "OWNER"
},
{
action: "showTokenArtwork",
icon: "fa-solid fa-image",
label: "SIDEBAR.TokenArt",
ownership: "OWNER"
}
]
},
actions: {
configurePrototypeToken: ActorSheetV2.#onConfigurePrototypeToken,
showPortraitArtwork: ActorSheetV2.#onShowPortraitArtwork,
showTokenArtwork: ActorSheetV2.#onShowTokenArtwork,
}
};
/**
* The Actor document managed by this sheet.
* @type {ClientDocument}
*/
get actor() {
return this.document;
}
/* -------------------------------------------- */
/**
* If this sheet manages the ActorDelta of an unlinked Token, reference that Token document.
* @type {TokenDocument|null}
*/
get token() {
return this.document.token || null;
}
/* -------------------------------------------- */
/** @override */
_getHeaderControls() {
const controls = this.options.window.controls;
// Portrait image
const img = this.actor.img;
if ( img === CONST.DEFAULT_TOKEN ) controls.findSplice(c => c.action === "showPortraitArtwork");
// Token image
const pt = this.actor.prototypeToken;
const tex = pt.texture.src;
if ( pt.randomImg || [null, undefined, CONST.DEFAULT_TOKEN].includes(tex) ) {
controls.findSplice(c => c.action === "showTokenArtwork");
}
return controls;
}
/* -------------------------------------------- */
async _renderHTML(context, options) {
return `<p>TESTING</p>`;
}
_replaceHTML(result, content, options) {
content.insertAdjacentHTML("beforeend", result);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Handle header control button clicks to render the Prototype Token configuration sheet.
* @this {ActorSheetV2}
* @param {PointerEvent} event
*/
static #onConfigurePrototypeToken(event) {
event.preventDefault();
const renderOptions = {
left: Math.max(this.position.left - 560 - 10, 10),
top: this.position.top
};
new CONFIG.Token.prototypeSheetClass(this.actor.prototypeToken, renderOptions).render(true);
}
/* -------------------------------------------- */
/**
* Handle header control button clicks to display actor portrait artwork.
* @this {ActorSheetV2}
* @param {PointerEvent} event
*/
static #onShowPortraitArtwork(event) {
const {img, name, uuid} = this.actor;
new ImagePopout(img, {title: name, uuid: uuid}).render(true);
}
/* -------------------------------------------- */
/**
* Handle header control button clicks to display actor portrait artwork.
* @this {ActorSheetV2}
* @param {PointerEvent} event
*/
static #onShowTokenArtwork(event) {
const {prototypeToken, name, uuid} = this.actor;
new ImagePopout(prototypeToken.texture.src, {title: name, uuid: uuid}).render(true);
}
}

View File

@@ -0,0 +1,259 @@
import DocumentSheetV2 from "../api/document-sheet.mjs";
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
/**
* The AmbientLight configuration application.
* @extends DocumentSheetV2
* @mixes HandlebarsApplication
* @alias AmbientLightConfig
*/
export default class AmbientLightConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
classes: ["ambient-light-config"],
window: {
contentClasses: ["standard-form"]
},
position: {
width: 560,
height: "auto"
},
form: {
handler: this.#onSubmit,
closeOnSubmit: true
},
actions:{
reset: this.#onReset
}
};
/** @override */
static PARTS = {
tabs: {
template: "templates/generic/tab-navigation.hbs"
},
basic: {
template: "templates/scene/parts/light-basic.hbs"
},
animation: {
template: "templates/scene/parts/light-animation.hbs"
},
advanced: {
template: "templates/scene/parts/light-advanced.hbs"
},
footer: {
template: "templates/generic/form-footer.hbs"
}
}
/**
* Maintain a copy of the original to show a real-time preview of changes.
* @type {AmbientLightDocument}
*/
preview;
/** @override */
tabGroups = {
sheet: "basic"
}
/* -------------------------------------------- */
/* Application Rendering */
/* -------------------------------------------- */
/** @inheritDoc */
async _preRender(context, options) {
await super._preRender(context, options);
if ( this.preview?.rendered ) {
await this.preview.object.draw();
this.document.object.initializeLightSource({deleted: true});
this.preview.object.layer.preview.addChild(this.preview.object);
this._previewChanges();
}
}
/* -------------------------------------------- */
/** @inheritDoc */
_onRender(context, options) {
super._onRender(context, options);
this.#toggleReset();
}
/* -------------------------------------------- */
/** @override */
_onClose(options) {
super._onClose(options);
if ( this.preview ) this._resetPreview();
if ( this.document.rendered ) this.document.object.initializeLightSource();
}
/* -------------------------------------------- */
/** @override */
async _prepareContext(options) {
// Create the preview on first render
if ( options.isFirstRender && this.document.object ) {
const clone = this.document.object.clone();
this.preview = clone.document;
}
// Prepare context
const document = this.preview ?? this.document;
const isDarkness = document.config.negative;
return {
light: document,
source: document.toObject(),
fields: document.schema.fields,
colorationTechniques: AdaptiveLightingShader.SHADER_TECHNIQUES,
gridUnits: document.parent.grid.units || game.i18n.localize("GridUnits"),
isDarkness,
lightAnimations: isDarkness ? CONFIG.Canvas.darknessAnimations : CONFIG.Canvas.lightAnimations,
tabs: this.#getTabs(),
buttons: [
{
type: "reset",
action: "reset",
icon: "fa-solid fa-undo",
label: "AMBIENT_LIGHT.ACTIONS.RESET"
},
{
type: "submit",
icon: "fa-solid fa-save",
label: this.document.id ? "AMBIENT_LIGHT.ACTIONS.UPDATE" : "AMBIENT_LIGHT.ACTIONS.CREATE"
}
]
}
}
/* -------------------------------------------- */
/**
* Prepare an array of form header tabs.
* @returns {Record<string, Partial<ApplicationTab>>}
*/
#getTabs() {
const tabs = {
basic: {id: "basic", group: "sheet", icon: "fa-solid fa-lightbulb", label: "AMBIENT_LIGHT.SECTIONS.BASIC"},
animation: {id: "animation", group: "sheet", icon: "fa-solid fa-play", label: "AMBIENT_LIGHT.SECTIONS.ANIMATION"},
advanced: {id: "advanced", group: "sheet", icon: "fa-solid fa-cogs", label: "AMBIENT_LIGHT.SECTIONS.ADVANCED"}
}
for ( const v of Object.values(tabs) ) {
v.active = this.tabGroups[v.group] === v.id;
v.cssClass = v.active ? "active" : "";
}
return tabs;
}
/* -------------------------------------------- */
/**
* Toggle visibility of the reset button which is only visible on the advanced tab.
*/
#toggleReset() {
const reset = this.element.querySelector("button[data-action=reset]");
reset.classList.toggle("hidden", this.tabGroups.sheet !== "advanced");
}
/* -------------------------------------------- */
/** @inheritDoc */
changeTab(...args) {
super.changeTab(...args);
this.#toggleReset();
}
/* -------------------------------------------- */
/* Real-Time Preview */
/* -------------------------------------------- */
/** @inheritDoc */
_onChangeForm(formConfig, event) {
super._onChangeForm(formConfig, event);
const formData = new FormDataExtended(this.element);
this._previewChanges(formData.object);
// Special handling for darkness state change
if ( event.target.name === "config.negative") this.render({parts: ["animation", "advanced"]});
}
/* -------------------------------------------- */
/**
* 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?.rendered ) {
this.preview.object.renderFlags.set({refresh: true});
this.preview.object.initializeLightSource();
}
}
/* -------------------------------------------- */
/**
* Restore the true data for the AmbientLight document when the form is submitted or closed.
* @protected
*/
_resetPreview() {
if ( !this.preview ) return;
if ( this.preview.rendered ) {
this.preview.object.destroy({children: true});
}
this.preview = null;
if ( this.document.rendered ) {
const object = this.document.object;
object.renderable = true;
object.initializeLightSource();
object.renderFlags.set({refresh: true});
}
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Process form submission for the sheet.
* @param {SubmitEvent} event The originating form submission event
* @param {HTMLFormElement} form The form element that was submitted
* @param {FormDataExtended} formData Processed data for the submitted form
* @this {AmbientLightConfig}
* @returns {Promise<void>}
*/
static async #onSubmit(event, form, formData) {
const submitData = this._prepareSubmitData(event, form, formData);
if ( this.document.id ) await this.document.update(submitData);
else await this.document.constructor.create(submitData, {parent: canvas.scene});
}
/* -------------------------------------------- */
/**
* Process reset button click
* @param {PointerEvent} event The originating button click
* @this {AmbientLightConfig}
* @returns {Promise<void>}
*/
static async #onReset(event) {
event.preventDefault();
const defaults = AmbientLightDocument.cleanData();
const keys = ["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);
await this.render();
}
}

View File

@@ -0,0 +1,114 @@
import DocumentSheetV2 from "../api/document-sheet.mjs";
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
/**
* The AmbientSound configuration application.
* @extends DocumentSheetV2
* @mixes HandlebarsApplication
* @alias AmbientSoundConfig
*/
export default class AmbientSoundConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
classes: ["ambient-sound-config"],
window: {
contentClasses: ["standard-form"]
},
position: {
width: 560,
height: "auto"
},
form: {
handler: this.#onSubmit,
closeOnSubmit: true
}
};
/** @override */
static PARTS = {
body: {
template: "templates/scene/ambient-sound-config.hbs"
},
footer: {
template: "templates/generic/form-footer.hbs"
}
}
/* -------------------------------------------- */
/** @inheritDoc */
get title() {
if ( !this.document.id ) return game.i18n.localize("AMBIENT_SOUND.ACTIONS.CREATE");
return super.title;
}
/* -------------------------------------------- */
/** @override */
async _prepareContext(_options) {
return {
sound: this.document,
source: this.document.toObject(),
fields: this.document.schema.fields,
gridUnits: this.document.parent.grid.units || game.i18n.localize("GridUnits"),
soundEffects: CONFIG.soundEffects,
buttons: [{
type: "submit",
icon: "fa-solid fa-save",
label: game.i18n.localize(this.document.id ? "AMBIENT_SOUND.ACTIONS.UPDATE" : "AMBIENT_SOUND.ACTIONS.CREATE")
}]
}
}
/* -------------------------------------------- */
/** @inheritDoc */
_onRender(context, options) {
this.#toggleDisabledFields();
return super._onRender(context, options);
}
/* -------------------------------------------- */
/** @override */
_onClose(_options) {
if ( !this.document.id ) canvas.sounds.clearPreviewContainer();
}
/* -------------------------------------------- */
/** @inheritDoc */
_onChangeForm(formConfig, event) {
this.#toggleDisabledFields();
return super._onChangeForm(formConfig, event);
}
/* -------------------------------------------- */
/**
* Special logic to toggle the disabled state of form fields depending on the values of other fields.
*/
#toggleDisabledFields() {
const form = this.element;
form["effects.base.intensity"].disabled = !form["effects.base.type"].value;
form["effects.muffled.type"].disabled = form.walls.checked;
form["effects.muffled.intensity"].disabled = form.walls.checked || !form["effects.muffled.type"].value;
}
/* -------------------------------------------- */
/**
* Process form submission for the sheet.
* @param {SubmitEvent} event The originating form submission event
* @param {HTMLFormElement} form The form element that was submitted
* @param {FormDataExtended} formData Processed data for the submitted form
* @this {AmbientSoundConfig}
* @returns {Promise<void>}
*/
static async #onSubmit(event, form, formData) {
const submitData = this._prepareSubmitData(event, form, formData);
if ( this.document.id ) await this.document.update(submitData);
else await this.document.constructor.create(submitData, {parent: canvas.scene});
}
}

View File

@@ -0,0 +1,30 @@
import DocumentSheetV2 from "../api/document-sheet.mjs";
/**
* A base class for providing Item Sheet behavior using ApplicationV2.
*/
export default class ItemSheetV2 extends DocumentSheetV2 {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
position: {
width: 480
}
};
/**
* The Item document managed by this sheet.
* @type {ClientDocument}
*/
get item() {
return this.document;
}
/**
* The Actor instance which owns this Item, if any.
* @type {Actor|null}
*/
get actor() {
return this.document.actor;
}
}

View File

@@ -0,0 +1,140 @@
import DocumentSheetV2 from "../api/document-sheet.mjs";
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
/**
* @typedef {import("../_types.mjs").FormNode} FormNode
* @typedef {import("../_types.mjs").FormFooterButton} FormFooterButton
*/
/**
* The Scene Region configuration application.
* @extends DocumentSheetV2
* @mixes HandlebarsApplication
* @alias RegionBehaviorConfig
*/
export default class RegionBehaviorConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
constructor(options) {
super(options);
this.options.window.icon = CONFIG.RegionBehavior.typeIcons[this.document.type];
}
/** @inheritDoc */
static DEFAULT_OPTIONS = {
classes: ["region-behavior-config"],
window: {
contentClasses: ["standard-form"],
icon: undefined // Defined in constructor
},
position: {
width: 480,
height: "auto"
},
form: {
closeOnSubmit: true
}
};
/** @override */
static PARTS = {
form: {
template: "templates/generic/form-fields.hbs"
},
footer: {
template: "templates/generic/form-footer.hbs"
}
}
/* -------------------------------------------- */
/* Context Preparation */
/* -------------------------------------------- */
/** @override */
async _prepareContext(_options) {
const doc = this.document;
return {
region: doc,
source: doc._source,
fields: this._getFields(),
buttons: this._getButtons()
}
}
/* -------------------------------------------- */
/**
* Prepare form field structure for rendering.
* @returns {FormNode[]}
*/
_getFields() {
const doc = this.document;
const source = doc._source;
const fields = doc.schema.fields;
const {events, ...systemFields} = CONFIG.RegionBehavior.dataModels[doc.type]?.schema.fields;
const fieldsets = [];
// Identity
fieldsets.push({
fieldset: true,
legend: "BEHAVIOR.SECTIONS.identity",
fields: [
{field: fields.name, value: source.name}
]
});
// Status
fieldsets.push({
fieldset: true,
legend: "BEHAVIOR.SECTIONS.status",
fields: [
{field: fields.disabled, value: source.disabled}
]
});
// Subscribed events
if ( events ) {
fieldsets.push({
fieldset: true,
legend: "BEHAVIOR.TYPES.base.SECTIONS.events",
fields: [
{field: events, value: source.system.events}
]
});
}
// Other system fields
const sf = {fieldset: true, legend: CONFIG.RegionBehavior.typeLabels[doc.type], fields: []};
this.#addSystemFields(sf, systemFields, source);
if ( sf.fields.length ) fieldsets.push(sf);
return fieldsets;
}
/* -------------------------------------------- */
/**
* Recursively add system model fields to the fieldset.
*/
#addSystemFields(fieldset, schema, source, _path="system") {
for ( const field of Object.values(schema) ) {
const path = `${_path}.${field.name}`;
if ( field instanceof foundry.data.fields.SchemaField ) {
this.#addSystemFields(fieldset, field.fields, source, path);
}
else if ( field.constructor.hasFormSupport ) {
fieldset.fields.push({field, value: foundry.utils.getProperty(source, path)});
}
}
}
/* -------------------------------------------- */
/**
* Get footer buttons for this behavior config sheet.
* @returns {FormFooterButton[]}
* @protected
*/
_getButtons() {
return [
{type: "submit", icon: "fa-solid fa-save", label: "BEHAVIOR.ACTIONS.update"}
]
}
}

View File

@@ -0,0 +1,383 @@
import DocumentSheetV2 from "../api/document-sheet.mjs";
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
import {DOCUMENT_OWNERSHIP_LEVELS} from "../../../common/constants.mjs";
/**
* @typedef {import("../_types.mjs").ApplicationTab} ApplicationTab
* @typedef {import("../_types.mjs").FormFooterButton} FormFooterButton
*/
/**
* The Scene Region configuration application.
* @extends DocumentSheetV2
* @mixes HandlebarsApplication
* @alias RegionConfig
*/
export default class RegionConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
classes: ["region-config"],
window: {
contentClasses: ["standard-form"],
icon: "fa-regular fa-game-board"
},
position: {
width: 480,
height: "auto"
},
form: {
closeOnSubmit: true
},
viewPermission: DOCUMENT_OWNERSHIP_LEVELS.OWNER,
actions: {
shapeCreateFromWalls: RegionConfig.#onShapeCreateFromWalls,
shapeToggleHole: RegionConfig.#onShapeToggleHole,
shapeMoveUp: RegionConfig.#onShapeMoveUp,
shapeMoveDown: RegionConfig.#onShapeMoveDown,
shapeRemove: RegionConfig.#onShapeRemove,
behaviorCreate: RegionConfig.#onBehaviorCreate,
behaviorDelete: RegionConfig.#onBehaviorDelete,
behaviorEdit: RegionConfig.#onBehaviorEdit,
behaviorToggle: RegionConfig.#onBehaviorToggle
}
};
/** @override */
static PARTS = {
tabs: {
template: "templates/generic/tab-navigation.hbs"
},
identity: {
template: "templates/scene/parts/region-identity.hbs"
},
shapes: {
template: "templates/scene/parts/region-shapes.hbs",
scrollable: [".scrollable"]
},
behaviors: {
template: "templates/scene/parts/region-behaviors.hbs",
scrollable: [".scrollable"]
},
footer: {
template: "templates/generic/form-footer.hbs"
}
}
/** @override */
tabGroups = {
sheet: "identity"
}
/* -------------------------------------------- */
/* Context Preparation */
/* -------------------------------------------- */
/** @override */
async _prepareContext(_options) {
const doc = this.document;
return {
region: doc,
source: doc.toObject(),
fields: doc.schema.fields,
tabs: this.#getTabs(),
}
}
/* -------------------------------------------- */
/** @override */
async _preparePartContext(partId, context) {
const doc = this.document;
switch ( partId ) {
case "footer":
context.buttons = this.#getFooterButtons();
break;
case "behaviors":
context.tab = context.tabs.behaviors;
context.behaviors = doc.behaviors.map(b => ({
id: b.id,
name: b.name,
typeLabel: game.i18n.localize(CONFIG.RegionBehavior.typeLabels[b.type]),
typeIcon: CONFIG.RegionBehavior.typeIcons[b.type] || "fa-regular fa-notdef",
disabled: b.disabled
})).sort((a, b) => (a.disabled - b.disabled) || a.name.localeCompare(b.name, game.i18n.lang));
break;
case "identity":
context.tab = context.tabs.identity;
break;
case "shapes":
context.tab = context.tabs.shapes;
break;
}
return context;
}
/* -------------------------------------------- */
/** @inheritDoc */
_onRender(context, options) {
super._onRender(context, options);
this.element.querySelectorAll(".region-shape").forEach(e => {
e.addEventListener("mouseover", this.#onShapeHoverIn.bind(this));
e.addEventListener("mouseout", this.#onShapeHoverOut.bind(this));
});
this.document.object?.renderFlags.set({refreshState: true});
}
/* -------------------------------------------- */
/** @inheritDoc */
_onClose(options) {
super._onClose(options);
this.document.object?.renderFlags.set({refreshState: true});
}
/* -------------------------------------------- */
/**
* Prepare an array of form header tabs.
* @returns {Record<string, Partial<ApplicationTab>>}
*/
#getTabs() {
const tabs = {
identity: {id: "identity", group: "sheet", icon: "fa-solid fa-tag", label: "REGION.SECTIONS.identity"},
shapes: {id: "shapes", group: "sheet", icon: "fa-solid fa-shapes", label: "REGION.SECTIONS.shapes"},
behaviors: {id: "behaviors", group: "sheet", icon: "fa-solid fa-child-reaching", label: "REGION.SECTIONS.behaviors"}
}
for ( const v of Object.values(tabs) ) {
v.active = this.tabGroups[v.group] === v.id;
v.cssClass = v.active ? "active" : "";
}
return tabs;
}
/* -------------------------------------------- */
/**
* Prepare an array of form footer buttons.
* @returns {Partial<FormFooterButton>[]}
*/
#getFooterButtons() {
return [
{type: "submit", icon: "fa-solid fa-save", label: "REGION.ACTIONS.update"}
]
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Handle mouse-hover events on a shape.
*/
#onShapeHoverIn(event) {
event.preventDefault();
if ( !this.document.parent.isView ) return;
const index = this.#getControlShapeIndex(event);
canvas.regions._highlightShape(this.document.shapes[index]);
}
/* -------------------------------------------- */
/**
* Handle mouse-unhover events for shape.
*/
#onShapeHoverOut(event) {
event.preventDefault();
if ( !this.document.parent.isView ) return;
canvas.regions._highlightShape(null);
}
/* -------------------------------------------- */
/**
* Handle button clicks to move the shape up.
* @param {PointerEvent} event
* @this {RegionConfig}
*/
static async #onShapeMoveUp(event) {
if ( this.document.shapes.length <= 1 ) return;
const index = this.#getControlShapeIndex(event);
if ( index === 0 ) return;
const shapes = [...this.document.shapes];
[shapes[index - 1], shapes[index]] = [shapes[index], shapes[index - 1]];
await this.document.update({shapes});
}
/* -------------------------------------------- */
/**
* Handle button clicks to move the shape down.
* @param {PointerEvent} event
* @this {RegionConfig}
*/
static async #onShapeMoveDown(event) {
if ( this.document.shapes.length <= 1 ) return;
const index = this.#getControlShapeIndex(event);
if ( index === this.document.shapes.length - 1 ) return;
const shapes = [...this.document.shapes];
[shapes[index], shapes[index + 1]] = [shapes[index + 1], shapes[index]];
await this.document.update({shapes});
}
/* -------------------------------------------- */
/**
* Handle button clicks to create shapes from the controlled walls.
* @param {PointerEvent} event
* @this {RegionConfig}
*/
static async #onShapeCreateFromWalls(event) {
event.preventDefault(); // Don't open context menu
event.stopPropagation(); // Don't trigger other events
if ( !canvas.ready || (event.detail > 1) ) return; // Ignore repeated clicks
// If no walls are controlled, inform the user they need to control walls
if ( !canvas.walls.controlled.length ) {
if ( canvas.walls.active ) {
ui.notifications.error("REGION.NOTIFICATIONS.NoControlledWalls", {localize: true});
}
else {
canvas.walls.activate({tool: "select"});
ui.notifications.info("REGION.NOTIFICATIONS.ControlWalls", {localize: true});
}
return;
}
// Create the shape
const polygons = canvas.walls.identifyInteriorArea(canvas.walls.controlled);
if ( polygons.length === 0 ) {
ui.notifications.error("REGION.NOTIFICATIONS.EmptyEnclosedArea", {localize: true});
return;
}
const shapes = polygons.map(p => new foundry.data.PolygonShapeData({points: p.points}));
// Merge the new shape with form submission data
const form = this.element;
const formData = new FormDataExtended(form);
const submitData = this._prepareSubmitData(event, form, formData);
submitData.shapes = [...this.document._source.shapes, ...shapes];
// Update the region
await this.document.update(submitData);
}
/* -------------------------------------------- */
/**
* Handle button clicks to toggle the hold field of a shape.
* @param {PointerEvent} event
* @this {RegionConfig}
*/
static async #onShapeToggleHole(event) {
const index = this.#getControlShapeIndex(event);
const shapes = this.document.shapes.map(s => s.toObject());
shapes[index].hole = !shapes[index].hole;
await this.document.update({shapes});
}
/* -------------------------------------------- */
/**
* Handle button clicks to remove a shape.
* @param {PointerEvent} event
* @this {RegionConfig}
*/
static async #onShapeRemove(event) {
const index = this.#getControlShapeIndex(event);
let shapes = this.document.shapes;
return foundry.applications.api.DialogV2.confirm({
window: {
title: game.i18n.localize("REGION.ACTIONS.shapeRemove")
},
content: `<p>${game.i18n.localize("AreYouSure")}</p>`,
rejectClose: false,
yes: {
callback: () => {
// Test that there haven't been any changes to the shapes since the dialog the button was clicked
if ( this.document.shapes !== shapes ) return false;
shapes = [...shapes];
shapes.splice(index, 1);
this.document.update({shapes});
return true;
}
}
});
}
/* -------------------------------------------- */
/**
* Get the shape index from a control button click.
* @param {PointerEvent} event The button-click event
* @returns {number} The shape index
*/
#getControlShapeIndex(event) {
const button = event.target;
const li = button.closest(".region-shape");
return Number(li.dataset.shapeIndex);
}
/* -------------------------------------------- */
/**
* Handle button clicks to create a new behavior.
* @this {RegionConfig}
*/
static async #onBehaviorCreate(_event) {
await RegionBehavior.implementation.createDialog({}, {parent: this.document});
}
/* -------------------------------------------- */
/**
* Handle button clicks to delete a behavior.
* @param {PointerEvent} event
* @this {RegionConfig}
*/
static async #onBehaviorDelete(event) {
const behavior = this.#getControlBehavior(event);
await behavior.deleteDialog();
}
/* -------------------------------------------- */
/**
* Handle button clicks to edit a behavior.
* @param {PointerEvent} event
* @this {RegionConfig}
*/
static async #onBehaviorEdit(event) {
const target = event.target;
if ( target.closest(".region-element-name") && (event.detail !== 2) ) return; // Double-click on name
const behavior = this.#getControlBehavior(event);
await behavior.sheet.render(true);
}
/* -------------------------------------------- */
/**
* Handle button clicks to toggle a behavior.
* @param {PointerEvent} event
* @this {RegionConfig}
*/
static async #onBehaviorToggle(event) {
const behavior = this.#getControlBehavior(event);
await behavior.update({disabled: !behavior.disabled});
}
/* -------------------------------------------- */
/**
* Get the RegionBehavior document from a control button click.
* @param {PointerEvent} event The button-click event
* @returns {RegionBehavior} The region behavior document
*/
#getControlBehavior(event) {
const button = event.target;
const li = button.closest(".region-behavior");
return this.document.behaviors.get(li.dataset.behaviorId);
}
}

View File

@@ -0,0 +1,118 @@
import DocumentSheetV2 from "../api/document-sheet.mjs";
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
/**
* The User configuration application.
* @extends DocumentSheetV2
* @mixes HandlebarsApplication
* @alias UserConfig
*/
export default class UserConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
classes: ["user-config"],
position: {
width: 480,
height: "auto"
},
actions: {
releaseCharacter: UserConfig.#onReleaseCharacter
},
form: {
closeOnSubmit: true
}
};
/** @override */
static PARTS = {
form: {
id: "form",
template: "templates/sheets/user-config.hbs"
}
}
/* -------------------------------------------- */
/** @inheritDoc */
get title() {
return `${game.i18n.localize("PLAYERS.ConfigTitle")}: ${this.document.name}`;
}
/* -------------------------------------------- */
/** @override */
async _prepareContext(_options) {
return {
user: this.document,
source: this.document.toObject(),
fields: this.document.schema.fields,
characterWidget: this.#characterChoiceWidget.bind(this)
}
}
/* -------------------------------------------- */
/**
* Render the Character field as a choice between observed Actors.
* @returns {HTMLDivElement}
*/
#characterChoiceWidget(field, _groupConfig, inputConfig) {
// Create the form field
const fg = document.createElement("div");
fg.className = "form-group stacked character";
const ff = fg.appendChild(document.createElement("div"));
ff.className = "form-fields";
fg.insertAdjacentHTML("beforeend", `<p class="hint">${field.hint}</p>`);
// Actor select
const others = game.users.reduce((s, u) => {
if ( u.character && !u.isSelf ) s.add(u.character.id);
return s;
}, new Set());
const options = [];
const ownerGroup = game.i18n.localize("OWNERSHIP.OWNER");
const observerGroup = game.i18n.localize("OWNERSHIP.OBSERVER");
for ( const actor of game.actors ) {
if ( !actor.testUserPermission(this.document, "OBSERVER") ) continue;
const a = {value: actor.id, label: actor.name, disabled: others.has(actor.id)};
options.push({group: actor.isOwner ? ownerGroup : observerGroup, ...a});
}
const input = foundry.applications.fields.createSelectInput({...inputConfig,
name: field.fieldPath,
options,
blank: "",
sort: true
});
ff.appendChild(input);
// Player character
const c = this.document.character;
if ( c ) {
ff.insertAdjacentHTML("afterbegin", `<img class="avatar" src="${c.img}" alt="${c.name}">`);
const release = `<button type="button" class="icon fa-solid fa-ban" data-action="releaseCharacter"
data-tooltip="USER.SHEET.BUTTONS.RELEASE"></button>`
ff.insertAdjacentHTML("beforeend", release);
}
return fg;
}
/* -------------------------------------------- */
/**
* Handle button clicks to release the currently selected character.
* @param {PointerEvent} event
*/
static #onReleaseCharacter(event) {
event.preventDefault();
const button = event.target;
const fields = button.parentElement;
fields.querySelector("select[name=character]").value = "";
fields.querySelector("img.avatar").remove();
button.remove();
this.setPosition({height: "auto"});
}
}

View File

@@ -0,0 +1 @@
export {default as RegionLegend} from "./region-legend.mjs";

View File

@@ -0,0 +1,389 @@
import ApplicationV2 from "../api/application.mjs";
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
/**
* Scene Region Legend.
* @extends ApplicationV2
* @mixes HandlebarsApplication
* @alias RegionLegend
*/
export default class RegionLegend extends HandlebarsApplicationMixin(ApplicationV2) {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
id: "region-legend",
tag: "aside",
position: {
width: 320,
height: "auto"
},
window: {
title: "REGION.LEGEND.title",
icon: "fa-regular fa-game-board",
minimizable: false
},
actions: {
config: RegionLegend.#onConfig,
control: RegionLegend.#onControl,
create: RegionLegend.#onCreate,
delete: RegionLegend.#onDelete,
lock: RegionLegend.#onLock
},
};
/** @override */
static PARTS = {
list: {
id: "list",
template: "templates/scene/region-legend.hbs",
scrollable: ["ol.region-list"]
}
}
/* -------------------------------------------- */
/**
* The currently filtered Regions.
* @type {{bottom: number, top: number}}
*/
#visibleRegions = new Set();
/* -------------------------------------------- */
/**
* The currently viewed elevation range.
* @type {{bottom: number, top: number}}
*/
elevation = {bottom: -Infinity, top: Infinity};
/* -------------------------------------------- */
/** @type {SearchFilter} */
#searchFilter = new SearchFilter({
inputSelector: 'input[name="search"]',
contentSelector: ".region-list",
callback: this.#onSearchFilter.bind(this)
});
/* -------------------------------------------- */
/**
* Record a reference to the currently highlighted Region.
* @type {Region|null}
*/
#hoveredRegion = null;
/* -------------------------------------------- */
/** @override */
_configureRenderOptions(options) {
super._configureRenderOptions(options);
if ( options.isFirstRender ) {
options.position.left ??= ui.nav?.element[0].getBoundingClientRect().left;
options.position.top ??= ui.controls?.element[0].getBoundingClientRect().top;
}
}
/* -------------------------------------------- */
/** @override */
_canRender(options) {
const rc = options.renderContext;
if ( rc && !["createregions", "updateregions", "deleteregions"].includes(rc) ) return false;
}
/* -------------------------------------------- */
/** @inheritDoc */
async _renderFrame(options) {
const frame = await super._renderFrame(options);
this.window.close.remove(); // Prevent closing
return frame;
}
/* -------------------------------------------- */
/** @inheritDoc */
async close(options={}) {
if ( !options.closeKey ) return super.close(options);
return this;
}
/* -------------------------------------------- */
/** @inheritDoc */
_onFirstRender(context, options) {
super._onFirstRender(context, options);
canvas.scene.apps[this.id] = this;
}
/* -------------------------------------------- */
/** @inheritDoc */
_onRender(context, options) {
super._onRender(context, options);
this.#searchFilter.bind(this.element);
for ( const li of this.element.querySelectorAll(".region") ) {
li.addEventListener("mouseover", this.#onRegionHoverIn.bind(this));
li.addEventListener("mouseout", this.#onRegionHoverOut.bind(this));
}
this.element.querySelector(`input[name="elevationBottom"]`)
.addEventListener("change", this.#onElevationBottomChange.bind(this));
this.element.querySelector(`input[name="elevationTop"]`)
.addEventListener("change", this.#onElevationTopChange.bind(this));
this.#updateVisibleRegions();
}
/* -------------------------------------------- */
/** @override */
_onClose(options) {
super._onClose(options);
this.#visibleRegions.clear();
this.elevation.bottom = -Infinity;
this.elevation.top = Infinity;
delete canvas.scene.apps[this.id];
}
/* -------------------------------------------- */
/** @override */
async _prepareContext(_options) {
const regions = canvas.scene.regions.map(r => this.#prepareRegion(r));
regions.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));
return {
regions,
elevation: {
bottom: Number.isFinite(this.elevation.bottom) ? this.elevation.bottom : "",
top: Number.isFinite(this.elevation.top) ? this.elevation.top : "",
}
};
}
/*-------------------------------------------- */
/**
* Prepare each Region for rendering in the legend.
* @param {Region} region
* @returns {object}
*/
#prepareRegion(region) {
const hasElv = (region.elevation.bottom !== null) || (region.elevation.top !== null);
return {
id: region.id,
name: region.name,
color: region.color.css,
elevation: region.elevation,
elevationLabel: hasElv ? `[${region.elevation.bottom ?? "&infin;"}, ${region.elevation.top ?? "&infin;"}]` : "",
empty: !region.shapes.length,
locked: region.locked,
controlled: region.object?.controlled,
hover: region.object?.hover,
buttons: [
{
action: "config",
icon: "fa-cogs",
tooltip: game.i18n.localize("REGION.LEGEND.config"),
disabled: ""
},
{
action: "lock",
icon: region.locked ? "fa-lock" : "fa-unlock",
tooltip: game.i18n.localize(region.locked ? "REGION.LEGEND.unlock" : "REGION.LEGEND.lock"),
disabled: ""
},
{
action: "delete",
icon: "fa-trash",
tooltip: game.i18n.localize("REGION.LEGEND.delete"),
disabled: region.locked ? "disabled" : ""
}
]
}
}
/* -------------------------------------------- */
/**
* Update the region list and hide regions that are not visible.
*/
#updateVisibleRegions() {
this.#visibleRegions.clear();
for ( const li of this.element.querySelectorAll(".region-list > .region") ) {
const id = li.dataset.regionId;
const region = canvas.scene.regions.get(id);
const hidden = !((this.#searchFilter.rgx?.test(SearchFilter.cleanQuery(region.name)) !== false)
&& (Math.max(region.object.bottom, this.elevation.bottom) <= Math.min(region.object.top, this.elevation.top)));
if ( !hidden ) this.#visibleRegions.add(region);
li.classList.toggle("hidden", hidden);
}
this.setPosition({height: "auto"});
for ( const region of canvas.regions.placeables ) region.renderFlags.set({refreshState: true});
}
/* -------------------------------------------- */
/**
* Filter regions.
* @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
*/
#onSearchFilter(event, query, rgx, html) {
if ( !this.rendered ) return;
this.#updateVisibleRegions();
}
/* -------------------------------------------- */
/**
* Handle change events of the elevation range (bottom) input.
* @param {KeyboardEvent} event
*/
#onElevationBottomChange(event) {
this.elevation.bottom = Number(event.currentTarget.value || -Infinity);
this.#updateVisibleRegions();
}
/* -------------------------------------------- */
/**
* Handle change events of the elevation range (top) input.
* @param {KeyboardEvent} event
*/
#onElevationTopChange(event) {
this.elevation.top = Number(event.currentTarget.value || Infinity);
this.#updateVisibleRegions();
}
/* -------------------------------------------- */
/**
* Is this Region visible in this RegionLegend?
* @param {Region} region The region
* @returns {boolean}
* @internal
*/
_isRegionVisible(region) {
if ( !this.rendered ) return true;
return this.#visibleRegions.has(region.document);
}
/* -------------------------------------------- */
/**
* Handle mouse-in events on a region in the legend.
* @param {PointerEvent} event
*/
#onRegionHoverIn(event) {
event.preventDefault();
if ( !canvas.ready ) return;
const li = event.currentTarget.closest(".region");
const region = canvas.regions.get(li.dataset.regionId);
region._onHoverIn(event, {hoverOutOthers: true, updateLegend: false});
this.#hoveredRegion = region;
li.classList.add("hovered");
}
/* -------------------------------------------- */
/**
* Handle mouse-out events for a region in the legend.
* @param {PointerEvent} event
*/
#onRegionHoverOut(event) {
event.preventDefault();
const li = event.currentTarget.closest(".region");
this.#hoveredRegion?._onHoverOut(event, {updateLegend: false});
this.#hoveredRegion = null;
li.classList.remove("hovered");
}
/* -------------------------------------------- */
/**
* Highlight a hovered region in the legend.
* @param {Region} region The Region
* @param {boolean} hover Whether they are being hovered in or out.
* @internal
*/
_hoverRegion(region, hover) {
if ( !this.rendered ) return;
const li = this.element.querySelector(`.region[data-region-id="${region.id}"]`);
if ( !li ) return;
if ( hover ) li.classList.add("hovered");
else li.classList.remove("hovered");
}
/* -------------------------------------------- */
/**
* Handle clicks to configure a Region.
* @param {PointerEvent} event
*/
static #onConfig(event) {
const regionId = event.target.closest(".region").dataset.regionId;
const region = canvas.scene.regions.get(regionId);
region.sheet.render({force: true});
}
/* -------------------------------------------- */
/**
* Handle clicks to assume control over a Region.
* @param {PointerEvent} event
*/
static #onControl(event) {
const regionId = event.target.closest(".region").dataset.regionId;
const region = canvas.scene.regions.get(regionId);
// Double-click = toggle sheet
if ( event.detail === 2 ) {
region.object.control({releaseOthers: true});
region.sheet.render({force: true});
}
// Single-click = toggle control
else if ( event.detail === 1 ) {
if ( region.object.controlled ) region.object.release();
else region.object.control({releaseOthers: true});
}
}
/* -------------------------------------------- */
/**
* Handle button clicks to create a new Region.
* @param {PointerEvent} event
*/
static async #onCreate(event) {
await canvas.scene.createEmbeddedDocuments("Region", [{
name: RegionDocument.implementation.defaultName({parent: canvas.scene})
}]);
}
/* -------------------------------------------- */
/**
* Handle clicks to delete a Region.
* @param {PointerEvent} event
*/
static async #onDelete(event) {
const regionId = event.target.closest(".region").dataset.regionId;
const region = canvas.scene.regions.get(regionId);
await region.deleteDialog();
}
/* -------------------------------------------- */
/**
* Handle clicks to toggle the locked state of a Region.
* @param {PointerEvent} event
*/
static async #onLock(event) {
const regionId = event.target.closest(".region").dataset.regionId;
const region = canvas.scene.regions.get(regionId);
await region.update({locked: !region.locked});
}
}

View File

@@ -0,0 +1,7 @@
export * as types from "./_types.mjs";
export {default as AudioBufferCache} from "./cache.mjs";
export {default as AudioHelper} from "./helper.mjs";
export {default as AudioTimeout} from "./timeout.mjs";
export {default as Sound} from "./sound.mjs";
export {default as BiquadFilterEffect} from "./biquad.mjs";
export {default as ConvolverEffect} from "./convolver.mjs";

View File

@@ -0,0 +1,41 @@
/**
* @typedef {Object} AudioBufferCacheEntry
* @property {string} src
* @property {AudioBuffer} buffer
* @property {number} size
* @property {boolean} [locked]
* @property {AudioBufferCacheEntry} [next]
* @property {AudioBufferCacheEntry} [previous]
*/
/**
* @typedef {Object} SoundCreationOptions
* @property {string} src The source URL for the audio file
* @property {AudioContext} [context] A specific AudioContext to attach the sound to
* @property {boolean} [singleton=true] Reuse an existing Sound for this source?
* @property {boolean} [preload=false] Begin loading the audio immediately?
* @property {boolean} [autoplay=false] Begin playing the audio as soon as it is ready?
* @property {SoundPlaybackOptions} [autoplayOptions={}] Options passed to the play method if autoplay is true
*/
/**
* @typedef {Object} SoundPlaybackOptions
* @property {number} [delay=0] A delay in seconds by which to delay playback
* @property {number} [duration] A limited duration in seconds for which to play
* @property {number} [fade=0] A duration in milliseconds over which to fade in playback
* @property {boolean} [loop=false] Should sound playback loop?
* @property {number} [loopStart=0] Seconds of the AudioBuffer when looped playback should start.
* Only works for AudioBufferSourceNode.
* @property {number} [loopEnd] Seconds of the Audio buffer when looped playback should restart.
* Only works for AudioBufferSourceNode.
* @property {number} [offset=0] An offset in seconds at which to start playback
* @property {Function|null} [onended] A callback function attached to the source node
* @property {number} [volume=1.0] The volume at which to play the sound
*/
/**
* @callback SoundScheduleCallback
* @param {Sound} sound The Sound instance being scheduled
* @returns {any} A return value of the callback is returned as the resolved value of the
* Sound#schedule promise
*/

View File

@@ -0,0 +1,74 @@
/**
* A sound effect which applies a biquad filter.
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/BiquadFilterNode}
* @alias foundry.audio.BiquadFilterEffect
*/
export default class BiquadFilterEffect extends BiquadFilterNode {
/**
* A ConvolverEffect is constructed by passing the following parameters.
* @param {AudioContext} context The audio context required by the BiquadFilterNode
* @param {object} [options] Additional options which modify the BiquadFilterEffect behavior
* @param {BiquadFilterType} [options.type=lowpass] The filter type to apply
* @param {number} [options.intensity=5] The initial intensity of the effect
*/
constructor(context, {type="lowpass", intensity=5, ...options}={}) {
if ( !BiquadFilterEffect.#ALLOWED_TYPES.includes(type) ) {
throw new Error(`Invalid BiquadFilterEffect type "${type}" provided`);
}
super(context, options);
this.#type = this.type = type;
this.#intensity = intensity;
this.update();
}
/**
* The allowed filter types supported by this effect class.
*/
static #ALLOWED_TYPES = ["lowpass", "highpass", "bandpass", "lowshelf", "highshelf", "peaking", "notch"];
/**
* The original configured type of the effect.
* @type {BiquadFilterType}
*/
#type;
/* -------------------------------------------- */
/**
* Adjust the intensity of the effect on a scale of 0 to 10.
* @type {number}
*/
get intensity() {
return this.#intensity;
}
set intensity(intensity) {
this.update({intensity});
}
#intensity;
/* -------------------------------------------- */
/**
* Update the state of the effect node given the active flag and numeric intensity.
* @param {object} options Options which are updated
* @param {number} [options.intensity] A new effect intensity
* @param {BiquadFilterType} [options.type] A new filter type
*/
update({intensity, type} = {}) {
if ( Number.isFinite(intensity) ) this.#intensity = Math.clamp(intensity, 1, 10);
if ( BiquadFilterEffect.#ALLOWED_TYPES.includes(type) ) this.#type = type;
this.type = this.#type;
switch ( this.#type ) {
case "lowpass":
this.frequency.value = 1100 - (100 * this.#intensity); // More intensity cuts at a lower frequency
break;
case "highpass":
this.frequency.value = 100 * this.#intensity; // More intensity cuts at higher frequency
break;
default:
throw new Error(`BiquadFilterEffect type "${this.#type}" not yet configured`);
}
}
}

View File

@@ -0,0 +1,190 @@
/** @typedef {import("./_types.mjs").AudioBufferCacheEntry} AudioBufferCacheEntry
/**
* A specialized cache used for audio buffers.
* This is an LRU cache which expires buffers from the cache once the maximum cache size is exceeded.
* @extends {Map<string, AudioBufferCacheEntry>}
*/
export default class AudioBufferCache extends Map {
/**
* Construct an AudioBufferCache providing a maximum disk size beyond which entries are expired.
* @param {number} [cacheSize] The maximum cache size in bytes. 1GB by default.
*/
constructor(cacheSize=Math.pow(1024, 3)) {
super();
this.#maxSize = cacheSize;
}
/**
* The maximum cache size in bytes.
* @type {number}
*/
#maxSize;
/**
* The current memory utilization in bytes.
* @type {number}
*/
#memorySize = 0;
/**
* The head of the doubly-linked list.
* @type {AudioBufferCacheEntry}
*/
#head;
/**
* The tail of the doubly-linked list
* @type {AudioBufferCacheEntry}
*/
#tail;
/**
* A string representation of the current cache utilization.
* @type {{current: number, max: number, pct: number, currentString: string, maxString: string, pctString: string}}
*/
get usage() {
return {
current: this.#memorySize,
max: this.#maxSize,
pct: this.#memorySize / this.#maxSize,
currentString: foundry.utils.formatFileSize(this.#memorySize),
maxString: foundry.utils.formatFileSize(this.#maxSize),
pctString: `${(this.#memorySize * 100 / this.#maxSize).toFixed(2)}%`
};
}
/* -------------------------------------------- */
/* Cache Methods */
/* -------------------------------------------- */
/**
* Retrieve an AudioBuffer from the cache.
* @param {string} src The audio buffer source path
* @returns {AudioBuffer} The cached audio buffer, or undefined
*/
getBuffer(src) {
const node = super.get(src);
let buffer;
if ( node ) {
buffer = node.buffer;
if ( this.#head !== node ) this.#shift(node);
}
return buffer;
}
/* -------------------------------------------- */
/**
* Insert an AudioBuffer into the buffers cache.
* @param {string} src The audio buffer source path
* @param {AudioBuffer} buffer The audio buffer to insert
* @returns {AudioBufferCache}
*/
setBuffer(src, buffer) {
if ( !(buffer instanceof AudioBuffer) ) {
throw new Error("The AudioBufferCache is only used to store AudioBuffer instances");
}
let node = super.get(src);
if ( node ) this.#remove(node);
node = {src, buffer, size: buffer.length * buffer.numberOfChannels * 4, next: this.#head};
super.set(src, node);
this.#insert(node);
game.audio.debug(`Cached audio buffer "${src}" | ${this}`);
this.#expire();
return this;
}
/* -------------------------------------------- */
/**
* Delete an entry from the cache.
* @param {string} src The audio buffer source path
* @returns {boolean} Was the buffer deleted from the cache?
*/
delete(src) {
const node = super.get(src);
if ( node ) this.#remove(node);
return super.delete(src);
}
/* -------------------------------------------- */
/**
* Lock a buffer, preventing it from being expired even if it is least-recently-used.
* @param {string} src The audio buffer source path
* @param {boolean} [locked=true] Lock the buffer, preventing its expiration?
*/
lock(src, locked=true) {
const node = super.get(src);
if ( !node ) return;
node.locked = locked;
}
/* -------------------------------------------- */
/**
* Insert a new node into the cache, updating the linked list and cache size.
* @param {AudioBufferCacheEntry} node The node to insert
*/
#insert(node) {
if ( this.#head ) {
this.#head.previous = node;
this.#head = node;
}
else this.#head = this.#tail = node;
this.#memorySize += node.size;
}
/* -------------------------------------------- */
/**
* Remove a node from the cache, updating the linked list and cache size.
* @param {AudioBufferCacheEntry} node The node to remove
*/
#remove(node) {
if ( node.previous ) node.previous.next = node.next;
else this.#head = node.next;
if ( node.next ) node.next.previous = node.previous;
else this.#tail = node.previous;
this.#memorySize -= node.size;
}
/* -------------------------------------------- */
/**
* Shift an accessed node to the head of the linked list.
* @param {AudioBufferCacheEntry} node The node to shift
*/
#shift(node) {
node.previous = undefined;
node.next = this.#head;
this.#head.previous = node;
this.#head = node;
}
/* -------------------------------------------- */
/**
* Recursively expire entries from the cache in least-recently used order.
* Skip expiration of any entries which are locked.
* @param {AudioBufferCacheEntry} [node] A node from which to start expiring. Otherwise, starts from the tail.
*/
#expire(node) {
if ( this.#memorySize < this.#maxSize ) return;
node ||= this.#tail;
if ( !node.locked ) {
this.#remove(node);
game.audio.debug(`Expired audio buffer ${node.src} | ${this}`);
}
if ( node.previous ) this.#expire(node.previous);
}
/* -------------------------------------------- */
/** @override */
toString() {
const {currentString, maxString, pctString} = this.usage;
return `AudioBufferCache: ${currentString} / ${maxString} (${pctString})`;
}
}

View File

@@ -0,0 +1,121 @@
import Sound from "./sound.mjs";
/**
* A sound effect which applies a convolver filter.
* The convolver effect splits the input sound into two separate paths:
* 1. A "dry" node which is the original sound
* 2. A "wet" node which contains the result of the convolution
* This effect mixes between the dry and wet channels based on the intensity of the reverb effect.
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ConvolverNode}
* @alias foundry.audio.ConvolverFilterEffect
*/
export default class ConvolverEffect extends ConvolverNode {
/**
* A ConvolverEffect is constructed by passing the following parameters.
* @param {AudioContext} context The audio context required by the ConvolverNode
* @param {object} [options] Additional options which modify the ConvolverEffect behavior
* @param {string} [options.impulseResponsePath] The file path to the impulse response buffer to use
* @param {number} [options.intensity] The initial intensity of the effect
*/
constructor(context, {impulseResponsePath="sounds/impulse-responses/ir-full.wav", intensity=5, ...options}={}) {
super(context, options);
this.#impulseResponsePath = impulseResponsePath;
this.#intensity = intensity;
this.#dryGain = context.createGain();
this.#wetGain = context.createGain();
this.update();
}
/**
* The identifier of the impulse response buffer currently used.
* The default impulse response function was generated using https://aldel.com/reverbgen/.
* @type {string}
*/
#impulseResponsePath;
/**
* A GainNode which mixes base, non-convolved, audio playback into the final result.
* @type {GainNode}
*/
#dryGain;
/**
* A GainNode which mixes convolved audio playback into the final result.
* @type {GainNode}
*/
#wetGain;
/**
* Flag whether the impulse response buffer has been loaded to prevent duplicate load requests.
* @type {boolean}
*/
#loaded = false;
/* -------------------------------------------- */
/**
* Adjust the intensity of the effect on a scale of 0 to 10.
* @type {number}
*/
get intensity() {
return this.#intensity;
}
set intensity(value) {
this.update({intensity: value});
}
#intensity;
/* -------------------------------------------- */
/**
* Update the state of the effect node given the active flag and numeric intensity.
* @param {object} options Options which are updated
* @param {number} [options.intensity] A new effect intensity
*/
update({intensity} = {}) {
if ( Number.isFinite(intensity) ) this.#intensity = Math.clamp(intensity, 1, 10);
// Load an impulse response buffer
if ( !this.#loaded ) {
const irSound = new Sound(this.#impulseResponsePath, {context: this.context});
this.#loaded = true;
irSound.load().then(s => this.buffer = s.buffer);
}
// Set mix of wet and dry gain based on reverb intensity
this.#wetGain.gain.value = 0.2 + Math.sqrt(this.#intensity / 10); // [0.2, 1.2]
this.#dryGain.gain.value = Math.sqrt((11 - this.#intensity) / 10);
}
/* -------------------------------------------- */
/** @override */
disconnect(...args) {
this.#wetGain.disconnect();
this.#dryGain.disconnect();
return super.disconnect(...args);
}
/* -------------------------------------------- */
/** @override */
connect(destinationNode, ...args) {
super.connect(this.#wetGain, ...args);
this.#dryGain.connect(destinationNode);
this.#wetGain.connect(destinationNode);
return destinationNode;
}
/* -------------------------------------------- */
/**
* Additional side effects performed when some other AudioNode connects to this one.
* This behavior is not supported by the base WebAudioAPI but is needed here for more complex effects.
* @param {AudioNode} sourceNode An upstream source node that is connecting to this one
*/
onConnectFrom(sourceNode) {
sourceNode.connect(this.#dryGain);
}
}

View File

@@ -0,0 +1,635 @@
import AudioBufferCache from "./cache.mjs";
import Sound from "./sound.mjs";
/**
* @typedef {import("./_types.mjs").SoundCreationOptions} SoundCreationOptions
*/
/**
* 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
* @alias game.audio
*/
export default class AudioHelper {
constructor() {
if ( game.audio instanceof this.constructor ) {
throw new Error("You may not re-initialize the singleton AudioHelper. Use game.audio instead.");
}
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);
/**
* Audio Context singleton used for analysing audio levels of each stream
* Only created if necessary to listen to audio streams.
* @type {AudioContext}
*/
static #analyzerContext;
/**
* The set of singleton Sound instances which are shared across multiple uses of the same sound path.
* @type {Map<string,WeakRef<Sound>>}
*/
sounds = new Map();
/**
* Get a map of the Sound objects which are currently playing.
* @type {Map<number,Sound>}
*/
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
*/
pending = [];
/**
* A Promise which resolves once the game audio API is unlocked and ready to use.
* @type {Promise<void>}
*/
unlock;
/**
* A flag for whether video playback is currently locked by awaiting a user gesture
* @type {boolean}
*/
locked = true;
/**
* A singleton audio context used for playback of music.
* @type {AudioContext}
*/
music;
/**
* A singleton audio context used for playback of environmental audio.
* @type {AudioContext}
*/
environment;
/**
* A singleton audio context used for playback of interface sounds and effects.
* @type {AudioContext}
*/
interface;
/**
* For backwards compatibility, AudioHelper#context refers to the context used for music playback.
* @type {AudioContext}
*/
get context() {
return this.music;
}
/**
* Interval ID as returned by setInterval for analysing the volume of streams
* When set to 0, means no timer is set.
* @type {number}
*/
#analyserInterval;
/**
* A singleton cache used for audio buffers.
* @type {AudioBufferCache}
*/
buffers = new AudioBufferCache(AudioHelper.THRESHOLD_CACHE_SIZE_BYTES);
/**
* Map of all streams that we listen to for determining the decibel levels.
* Used for analyzing audio levels of each stream.
* @type {Record<string, {stream: MediaStream, analyser: AnalyserNode, interval: number, callback: Function}>}
*/
#analyserStreams = {};
/**
* 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}
*/
#fftArray = null;
/* -------------------------------------------- */
/**
* Create a Sound instance for a given audio source URL
* @param {SoundCreationOptions} options Sound creation options
* @returns {Sound}
*/
create({src, context, singleton=true, preload=false, autoplay=false, autoplayOptions={}}) {
let sound;
// Share singleton sounds across multiple use cases
if ( singleton ) {
const ref = this.sounds.get(src);
sound = ref?.deref();
if ( !sound ) {
sound = new Sound(src, {context});
this.sounds.set(src, new WeakRef(sound));
}
}
// Create an independent sound instance
else sound = new Sound(src, {context});
// Preload or autoplay
if ( preload && !sound.loaded ) sound.load({autoplay, autoplayOptions});
else if ( autoplay ) sound.play(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 which configure playback
* @param {AudioContext} [options.context] A specific AudioContext within which to play
* @returns {Promise<Sound>} The created Sound which is now playing
*/
async play(src, {context, ...options}={}) {
const sound = new Sound(src, {context});
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<void>} The unlocked audio context
*/
async awaitFirstGesture() {
if ( !this.locked ) return;
await new Promise(resolve => {
for ( let eventName of ["contextmenu", "auxclick", "pointerdown", "pointerup", "keydown"] ) {
document.addEventListener(eventName, event => this._onFirstGesture(event, resolve), {once: true});
}
});
}
/* -------------------------------------------- */
/**
* 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);
}
/* -------------------------------------------- */
/* Settings and Volume Controls */
/* -------------------------------------------- */
/**
* Register client-level settings for global volume controls.
*/
static registerSettings() {
// Playlist Volume
game.settings.register("core", "globalPlaylistVolume", {
name: "Global Playlist Volume",
hint: "Define a global playlist volume modifier",
scope: "client",
config: false,
type: new foundry.data.fields.AlphaField({required: true, initial: 0.5}),
onChange: AudioHelper.#onChangeMusicVolume
});
// Ambient Volume
game.settings.register("core", "globalAmbientVolume", {
name: "Global Ambient Volume",
hint: "Define a global ambient volume modifier",
scope: "client",
config: false,
type: new foundry.data.fields.AlphaField({required: true, initial: 0.5}),
onChange: AudioHelper.#onChangeEnvironmentVolume
});
// Interface Volume
game.settings.register("core", "globalInterfaceVolume", {
name: "Global Interface Volume",
hint: "Define a global interface volume modifier",
scope: "client",
config: false,
type: new foundry.data.fields.AlphaField({required: true, initial: 0.5}),
onChange: AudioHelper.#onChangeInterfaceVolume
});
}
/* -------------------------------------------- */
/**
* Handle changes to the global music volume slider.
* @param {number} volume
*/
static #onChangeMusicVolume(volume) {
volume = Math.clamp(volume, 0, 1);
const ctx = game.audio.music;
if ( !ctx ) return;
ctx.gainNode.gain.setValueAtTime(volume, ctx.currentTime);
ui.playlists?.render();
Hooks.callAll("globalPlaylistVolumeChanged", volume);
}
/* -------------------------------------------- */
/**
* Handle changes to the global environment volume slider.
* @param {number} volume
*/
static #onChangeEnvironmentVolume(volume) {
volume = Math.clamp(volume, 0, 1);
const ctx = game.audio.environment;
if ( !ctx ) return;
ctx.gainNode.gain.setValueAtTime(volume, ctx.currentTime);
if ( canvas.ready ) {
for ( const mesh of canvas.primary.videoMeshes ) {
mesh.sourceElement.volume = mesh.object instanceof Tile ? mesh.object.volume : volume;
}
}
ui.playlists?.render();
Hooks.callAll("globalAmbientVolumeChanged", volume);
}
/* -------------------------------------------- */
/**
* Handle changes to the global interface volume slider.
* @param {number} volume
*/
static #onChangeInterfaceVolume(volume) {
volume = Math.clamp(volume, 0, 1);
const ctx = game.audio.interface;
if ( !ctx ) return;
ctx.gainNode.gain.setValueAtTime(volume, ctx.currentTime);
ui.playlists?.render();
Hooks.callAll("globalInterfaceVolumeChanged", volume);
}
/* -------------------------------------------- */
/* 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("playAudioPosition", args => canvas.sounds.playAtPosition(...args));
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 {string} [data.channel] An audio channel in CONST.AUDIO_CHANNELS where the sound should play
* @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,
channel: "interface"
}, 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.0,
loop: audioData.loop,
context: game.audio[audioData.channel]
});
}
/* -------------------------------------------- */
/**
* 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: false, singleton: true});
await sound.load();
return sound;
}
/* -------------------------------------------- */
/**
* 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) {
if ( typeof value === "string" ) value = parseFloat(value);
return Math.pow(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
*/
getAnalyzerContext() {
if ( !AudioHelper.#analyzerContext ) AudioHelper.#analyzerContext = new AudioContext();
return AudioHelper.#analyzerContext;
}
/* -------------------------------------------- */
/**
* 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] The interval at which to produce reports.
* @param {number} [smoothing] The smoothingTimeConstant to set on the audio analyser.
* @returns {boolean} Returns whether listening to the stream was successful
*/
startLevelReports(id, stream, callback, interval=50, smoothing=0.1) {
if ( !stream || !id ) return false;
let audioContext = this.getAnalyzerContext();
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, _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.
*/
#ensureAnalyserTimer() {
if ( !this.#analyserInterval ) {
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.
*/
#cancelAnalyserTimer() {
if ( this.#analyserInterval ) {
clearInterval(this.#analyserInterval);
this.#analyserInterval = undefined;
}
}
/* -------------------------------------------- */
/**
* 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}
*/
#emitVolumes() {
for ( const stream of Object.values(this.#analyserStreams) ) {
if ( ++stream._lastEmit < (stream.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(stream.analyser.frequencyBinCount);
// Fill the array
stream.analyser.getFloatFrequencyData(this.#fftArray);
const maxDecibel = Math.max(...this.#fftArray);
stream.callback(maxDecibel, this.#fftArray);
stream._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 ) return resolve();
// Create audio contexts
this.music = AudioHelper.#createContext("globalPlaylistVolume");
this.environment = AudioHelper.#createContext("globalAmbientVolume");
this.interface = AudioHelper.#createContext("globalInterfaceVolume");
// Unlock and evaluate pending playbacks
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();
}
/* -------------------------------------------- */
/**
* Create an AudioContext with an attached GainNode for master volume control.
* @returns {AudioContext}
*/
static #createContext(volumeSetting) {
const ctx = new AudioContext();
ctx.gainNode = ctx.createGain();
ctx.gainNode.connect(ctx.destination);
const volume = game.settings.get("core", volumeSetting);
ctx.gainNode.gain.setValueAtTime(volume, ctx.currentTime)
return ctx;
}
/* -------------------------------------------- */
/**
* Log a debugging message if the audio debugging flag is enabled.
* @param {string} message The message to log
*/
debug(message) {
if ( CONFIG.debug.audio ) console.debug(`${vtt} | ${message}`);
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
getCache(src) {
foundry.utils.logCompatibilityWarning("AudioHelper#getCache is deprecated in favor of AudioHelper#buffers#get");
return this.buffers.getBuffer(src, {since: 12, until: 14});
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
updateCache(src, playing=false) {
foundry.utils.logCompatibilityWarning("AudioHelper#updateCache is deprecated without replacement");
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
setCache(src, buffer) {
foundry.utils.logCompatibilityWarning("AudioHelper#setCache is deprecated in favor of AudioHelper#buffers#set");
this.buffers.setBuffer(src, buffer);
}
}

View File

@@ -0,0 +1,990 @@
import AudioTimeout from "./timeout.mjs";
import EventEmitterMixin from "../../common/utils/event-emitter.mjs";
/**
* @typedef {import("./_types.mjs").SoundPlaybackOptions} SoundPlaybackOptions
* @typedef {import("./_types.mjs").SoundScheduleCallback} SoundScheduleCallback
*/
/**
* A container around an AudioNode which manages sound playback in Foundry Virtual Tabletop.
* Each Sound is either an AudioBufferSourceNode (for short sources) or a MediaElementAudioSourceNode (for long ones).
* This class provides an interface around both types which allows standardized control over playback.
* @alias foundry.audio.Sound
* @see {EventEmitterMixin}
*/
export default class Sound extends EventEmitterMixin(Object) {
/**
* Construct a Sound by providing the source URL and other options.
* @param {string} src The audio source path, either a relative path or a remote URL
* @param {object} [options] Additional options which configure the Sound
* @param {AudioContext} [options.context] A non-default audio context within which the sound should play
* @param {boolean} [options.forceBuffer] Force use of an AudioBufferSourceNode even if the audio duration is long
*/
constructor(src, {context, forceBuffer=false}={}) {
super();
Object.defineProperties(this, {
id: {value: Sound.#nodeId++, writable: false, enumerable: true, configurable: false},
src: {value: src, writable: false, enumerable: true, configurable: false}
});
this.#context = context || game.audio.music;
this.#forceBuffer = forceBuffer;
}
/**
* The sequence of container loading states.
* @enum {Readonly<number>}
*/
static STATES = Object.freeze({
FAILED: -1,
NONE: 0,
LOADING: 1,
LOADED: 2,
STARTING: 3,
PLAYING: 4,
PAUSED: 5,
STOPPING: 6,
STOPPED: 7
});
/**
* The maximum duration, in seconds, for which an AudioBufferSourceNode will be used.
* Otherwise, a MediaElementAudioSourceNode will be used.
* @type {number}
*/
static MAX_BUFFER_DURATION = 10 * 60; // 10 Minutes
/**
* An incrementing counter used to assign each Sound a unique id.
* @type {number}
*/
static #nodeId = 0;
/** @override */
static emittedEvents = ["load", "play", "pause", "end", "stop"];
/**
* A unique integer identifier for this sound.
* @type {number}
*/
id;
/**
* The audio source path.
* Either a relative path served by the running Foundry VTT game server or a remote URL.
* @type {string}
*/
src;
/**
* The audio context within which this Sound is played.
* @type {AudioContext}
*/
get context() {
return this.#context;
}
#context;
/**
* When this Sound uses an AudioBuffer, this is an AudioBufferSourceNode.
* @type {AudioBufferSourceNode}
*/
#bufferNode;
/**
* When this Sound uses an HTML Audio stream, this is a MediaElementAudioSourceNode.
* @type {MediaElementAudioSourceNode}
*/
#mediaNode;
/**
* The AudioSourceNode used to control sound playback.
* @type {AudioBufferSourceNode|MediaElementAudioSourceNode}
*/
get sourceNode() {
return this.#bufferNode || this.#mediaNode;
}
/**
* The GainNode used to control volume for this sound.
* @type {GainNode}
*/
gainNode;
/**
* An AudioBuffer instance, if this Sound uses an AudioBufferSourceNode for playback.
* @type {AudioBuffer|null}
*/
buffer = null;
/**
* An HTMLAudioElement, if this Sound uses a MediaElementAudioSourceNode for playback.
* @type {HTMLAudioElement|null}
*/
element = null;
/**
* Playback configuration options specified at the time that Sound#play is called.
* @type {SoundPlaybackOptions}
*/
#playback = {
delay: 0,
duration: undefined,
fade: 0,
loop: false,
loopStart: 0,
loopEnd: undefined,
offset: 0,
onended: null,
volume: 1.0
};
/**
* Force usage of an AudioBufferSourceNode regardless of audio duration?
* @type {boolean}
*/
#forceBuffer = false;
/**
* The life-cycle state of the sound.
* @see {Sound.STATES}
* @type {number}
* @protected
*/
_state = Sound.STATES.NONE;
/**
* Has the audio file been loaded either fully or for streaming.
* @type {boolean}
*/
get loaded() {
if ( this._state < Sound.STATES.LOADED ) return false;
return !!(this.buffer || this.element);
}
/**
* Did the audio file fail to load.
* @type {boolean}
*/
get failed() {
return this._state === Sound.STATES.FAILED;
}
/**
* Is this sound currently playing?
* @type {boolean}
*/
get playing() {
return (this._state === Sound.STATES.STARTING) || (this._state === Sound.STATES.PLAYING);
}
/**
* Does this Sound use an AudioBufferSourceNode?
* Otherwise, the Sound uses a streamed MediaElementAudioSourceNode.
* @type {boolean}
*/
get isBuffer() {
return !!this.buffer && (this.sourceNode instanceof AudioBufferSourceNode);
}
/**
* A convenience reference to the GainNode gain audio parameter.
* @type {AudioParam}
*/
get gain() {
return this.gainNode?.gain;
}
/**
* The AudioNode destination which is the output target for the Sound.
* @type {AudioNode}
*/
destination;
/**
* Record the pipeline of nodes currently used by this Sound.
* @type {AudioNode[]}
*/
#pipeline = [];
/**
* A pipeline of AudioNode instances to be applied to Sound playback.
* @type {AudioNode[]}
*/
effects = [];
/**
* The currently playing volume of the sound.
* Undefined until playback has started for the first time.
* @type {number}
*/
get volume() {
return this.gain?.value;
}
set volume(value) {
if ( !this.gainNode || !Number.isFinite(value) ) return;
const ct = this.#context.currentTime;
this.gain.cancelScheduledValues(ct);
this.gain.value = value;
this.gain.setValueAtTime(value, ct); // Immediately schedule the new value
}
/**
* The time in seconds at which playback was started.
* @type {number}
*/
startTime;
/**
* The time in seconds at which playback was paused.
* @type {number}
*/
pausedTime;
/**
* The total duration of the audio source in seconds.
* @type {number}
*/
get duration() {
if ( this._state < Sound.STATES.LOADED ) return undefined;
if ( this.buffer ) {
const {loop, loopStart, loopEnd} = this.#playback;
if ( loop && Number.isFinite(loopStart) && Number.isFinite(loopEnd) ) return loopEnd - loopStart;
return this.buffer.duration;
}
return this.element?.duration;
}
/**
* The current playback time of the sound.
* @type {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;
}
/**
* Is the sound looping?
* @type {boolean}
*/
get loop() {
return this.#playback.loop;
}
set loop(value) {
const loop = this.#playback.loop = Boolean(value);
if ( this.#bufferNode ) this.#bufferNode.loop = loop;
else if ( this.element ) this.element.loop = loop;
}
/**
* A set of scheduled events orchestrated using the Sound#schedule function.
* @type {Set<AudioTimeout>}
*/
#scheduledEvents = new Set();
/**
* An operation in progress on the sound which must be queued.
* @type {Promise}
*/
#operation;
/**
* A delay timeout before the sound starts or stops.
* @type {AudioTimeout}
*/
#delay;
/**
* An internal reference to some object which is managing this Sound instance.
* @type {Object|null}
* @internal
*/
_manager = null;
/* -------------------------------------------- */
/* Life-Cycle Methods */
/* -------------------------------------------- */
/**
* Load the audio source and prepare it for playback, either using an AudioBuffer or a streamed HTMLAudioElement.
* @param {object} [options={}] Additional options which affect resource loading
* @param {boolean} [options.autoplay=false] Automatically begin playback of the sound once loaded
* @param {SoundPlaybackOptions} [options.autoplayOptions] Playback options passed to Sound#play, if autoplay
* @returns {Promise<Sound>} A Promise which resolves to the Sound once it is loaded
*/
async load({autoplay=false, autoplayOptions={}}={}) {
const {STATES} = Sound;
// Await audio unlock
if ( game.audio.locked ) {
game.audio.debug(`Delaying load of sound "${this.src}" until after first user gesture`);
await game.audio.unlock;
}
// Wait for another ongoing operation
if ( this.#operation ) {
await this.#operation;
return this.load({autoplay, autoplayOptions});
}
// Queue loading
if ( !this.loaded ) {
this._state = STATES.LOADING;
this.#context ||= game.audio.music;
try {
this.#operation = this._load();
await this.#operation;
this._state = STATES.LOADED;
this.dispatchEvent(new Event("load"));
} catch(err) {
console.error(err);
this._state = STATES.FAILED;
}
finally {
this.#operation = undefined;
}
}
// Autoplay after load
if ( autoplay && !this.failed && !this.playing ) {
// noinspection ES6MissingAwait
this.play(autoplayOptions);
}
return this;
}
/* -------------------------------------------- */
/**
* An inner method which handles loading so that it can be de-duplicated under a single shared Promise resolution.
* This method is factored out to allow for subclasses to override loading behavior.
* @returns {Promise<void>} A Promise which resolves once the sound is loaded
* @throws {Error} An error if loading failed for any reason
* @protected
*/
async _load() {
// Attempt to load a cached AudioBuffer
this.buffer = game.audio.buffers.getBuffer(this.src) || null;
this.element = null;
// Otherwise, load the audio as an HTML5 audio element to learn its playback duration
if ( !this.buffer ) {
const element = await this.#createAudioElement();
const isShort = (element?.duration || Infinity) <= Sound.MAX_BUFFER_DURATION;
// For short sounds create and cache the audio buffer and use an AudioBufferSourceNode
if ( isShort || this.#forceBuffer ) {
this.buffer = await this.#createAudioBuffer();
game.audio.buffers.setBuffer(this.src, this.buffer);
Sound.#unloadAudioElement(element);
}
else this.element = element;
}
}
/* -------------------------------------------- */
/**
* Begin playback for the Sound.
* This method is asynchronous because playback may not start until after an initially provided delay.
* The Promise resolves *before* the fade-in of any configured volume transition.
* @param {SoundPlaybackOptions} [options] Options which configure the beginning of sound playback
* @returns {Promise<Sound>} A Promise which resolves once playback has started (excluding fade)
*/
async play(options={}) {
// Signal our intention to start immediately
const {STATES} = Sound;
if ( ![STATES.LOADED, STATES.PAUSED, STATES.STOPPED].includes(this._state) ) return this;
this._state = STATES.STARTING;
// Wait for another ongoing operation
if ( this.#operation ) {
await this.#operation;
return this.play(options);
}
// Configure options
if ( typeof options === "number" ) {
options = {offset: options};
if ( arguments[1] instanceof Function ) options.onended = arguments[1];
foundry.utils.logCompatibilityWarning("Sound#play now takes an object of playback options instead of "
+ "positional arguments.", {since: 12, until: 14});
}
// Queue playback
try {
this.#operation = this.#queuePlay(options);
await this.#operation;
this._state = STATES.PLAYING;
} finally {
this.#operation = undefined;
}
return this;
}
/* -------------------------------------------- */
/**
* An inner method that is wrapped in an enqueued promise. See {@link Sound#play}.
*/
async #queuePlay(options={}) {
// Configure playback
this.#configurePlayback(options);
const {delay, fade, offset, volume} = this.#playback;
// Create the audio pipeline including gainNode and sourceNode used for playback
this._createNodes();
this._connectPipeline();
// Delay playback start
if ( delay ) {
await this.wait(delay * 1000);
if ( this._state !== Sound.STATES.STARTING ) return; // We may no longer be starting if the delay was cancelled
}
// Begin playback
this._play();
// Record state change
this.startTime = this.#context.currentTime - offset;
this.pausedTime = undefined;
// Set initial volume
this.volume = fade ? 0 : volume;
if ( fade ) this.fade(volume, {duration: fade});
this.#onStart();
}
/* -------------------------------------------- */
/**
* Begin playback for the configured pipeline and playback options.
* This method is factored out so that subclass implementations of Sound can implement alternative behavior.
* @protected
*/
_play() {
const {loop, loopStart, loopEnd, offset, duration} = this.#playback;
if ( this.buffer ) {
this.#bufferNode.loop = loop;
if ( loop && Number.isFinite(loopStart) && Number.isFinite(loopEnd) ) {
this.#bufferNode.loopStart = loopStart;
this.#bufferNode.loopEnd = loopEnd;
}
this.#bufferNode.onended = this.#onEnd.bind(this);
this.#bufferNode.start(0, offset, duration);
}
else if ( this.element ) {
this.element.loop = loop;
this.element.currentTime = offset;
this.element.onended = this.#onEnd.bind(this);
this.element.play();
}
game.audio.debug(`Beginning playback of Sound "${this.src}"`);
}
/* -------------------------------------------- */
/**
* Pause playback of the Sound.
* For AudioBufferSourceNode this stops playback after recording the current time.
* Calling Sound#play will resume playback from the pausedTime unless some other offset is passed.
* For a MediaElementAudioSourceNode this simply calls the HTMLAudioElement#pause method directly.
*/
pause() {
const {STATES} = Sound;
if ( this._state !== STATES.PLAYING ) {
throw new Error("You may only call Sound#pause for a Sound which is PLAYING");
}
this._pause();
this.pausedTime = this.currentTime;
this._state = STATES.PAUSED;
this.#onPause();
}
/* -------------------------------------------- */
/**
* Pause playback of the Sound.
* This method is factored out so that subclass implementations of Sound can implement alternative behavior.
* @protected
*/
_pause() {
if ( this.isBuffer ) {
this.#bufferNode.onended = undefined;
this.#bufferNode.stop(0);
}
else this.element.pause();
game.audio.debug(`Pausing playback of Sound "${this.src}"`);
}
/* -------------------------------------------- */
/**
* Stop playback for the Sound.
* This method is asynchronous because playback may not stop until after an initially provided delay.
* The Promise resolves *after* the fade-out of any configured volume transition.
* @param {SoundPlaybackOptions} [options] Options which configure the stopping of sound playback
* @returns {Promise<Sound>} A Promise which resolves once playback is fully stopped (including fade)
*/
async stop(options={}) {
// Signal our intention to stop immediately
if ( !this.playing ) return this;
this._state = Sound.STATES.STOPPING;
this.#delay?.cancel();
// Wait for another operation to conclude
if ( this.#operation ) {
await this.#operation;
return this.stop(options);
}
// Queue stop
try {
this.#operation = this.#queueStop(options);
await this.#operation;
this._state = Sound.STATES.STOPPED;
} finally {
this.#operation = undefined;
}
return this;
}
/* -------------------------------------------- */
/**
* An inner method that is wrapped in an enqueued promise. See {@link Sound#stop}.
*/
async #queueStop(options) {
// Immediately disconnect the onended callback
if ( this.#bufferNode ) this.#bufferNode.onended = undefined;
if ( this.#mediaNode ) this.element.onended = undefined;
// Configure playback settings
this.#configurePlayback(options);
const {delay, fade, volume} = this.#playback;
// Fade out
if ( fade ) await this.fade(volume, {duration: fade});
else this.volume = volume;
// Stop playback
if ( delay ) {
await this.wait(delay * 1000);
if ( this._state !== Sound.STATES.STOPPING ) return; // We may no longer be stopping if the delay was cancelled
}
this._stop();
// Disconnect the audio pipeline
this._disconnectPipeline();
// Record state changes
this.#bufferNode = this.#mediaNode = undefined;
this.startTime = this.pausedTime = undefined;
this.#onStop();
}
/* -------------------------------------------- */
/**
* Stop playback of the Sound.
* This method is factored out so that subclass implementations of Sound can implement alternative behavior.
* @protected
*/
_stop() {
this.gain.cancelScheduledValues(this.context.currentTime);
if ( this.buffer && this.sourceNode && (this._state === Sound.STATES.PLAYING) ) this.#bufferNode.stop(0);
else if ( this.element ) {
Sound.#unloadAudioElement(this.element);
this.element = null;
}
game.audio.debug(`Stopping playback of Sound "${this.src}"`);
}
/* -------------------------------------------- */
/**
* 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`);
// Cancel any other ongoing transitions
const startTime = this.#context.currentTime;
this.gain.cancelScheduledValues(startTime);
// Immediately schedule the starting volume
from ??= this.gain.value;
this.gain.setValueAtTime(from, startTime);
// Ramp to target volume
ramp.call(this.gain, volume, startTime + (duration / 1000));
// Wait for the transition
if ( volume !== from ) await this.wait(duration);
}
/* -------------------------------------------- */
/**
* Wait a certain scheduled duration within this sound's own AudioContext.
* @param {number} duration The duration to wait in milliseconds
* @returns {Promise<void>} A promise which resolves after the waited duration
*/
async wait(duration) {
this.#delay = new AudioTimeout(duration, {context: this.#context});
await this.#delay.complete;
this.#delay = undefined;
}
/* -------------------------------------------- */
/**
* Schedule a function to occur at the next occurrence of a specific playbackTime for this Sound.
* @param {SoundScheduleCallback} 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<any>} A Promise which resolves to the returned value of the provided function once
* it has been evaluated.
*
* @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);
* ```
*/
async schedule(fn, playbackTime) {
// Determine the amount of time until the next occurrence of playbackTime
const {currentTime, duration} = this;
playbackTime = Math.clamp(playbackTime, 0, duration);
if ( this.#playback.loop && Number.isFinite(duration) ) {
while ( playbackTime < currentTime ) playbackTime += duration;
}
const deltaMS = Math.max(0, (playbackTime - currentTime) * 1000);
// Wait for an AudioTimeout completion then invoke the scheduled function
const timeout = new AudioTimeout(deltaMS, {context: this.#context});
this.#scheduledEvents.add(timeout);
try {
await timeout.complete;
return fn(this);
}
catch {}
finally {
this.#scheduledEvents.delete(timeout);
}
}
/* -------------------------------------------- */
/**
* Update the array of effects applied to a Sound instance.
* Optionally a new array of effects can be assigned. If no effects are passed, the current effects are re-applied.
* @param {AudioNode[]} [effects] An array of AudioNode effects to apply
*/
applyEffects(effects) {
if ( Array.isArray(effects) ) this.effects = effects;
this._disconnectPipeline();
this._connectPipeline();
game.audio.debug(`Applied effects to Sound "${this.src}": ${this.effects.map(e => e.constructor.name)}`);
}
/* -------------------------------------------- */
/* Playback Events */
/* -------------------------------------------- */
/**
* Additional workflows when playback of the Sound begins.
*/
#onStart() {
game.audio.playing.set(this.id, this); // Track playing sounds
this.dispatchEvent(new Event("play"));
}
/* -------------------------------------------- */
/**
* Additional workflows when playback of the Sound is paused.
*/
#onPause() {
this.#cancelScheduledEvents();
this.dispatchEvent(new Event("pause"));
}
/* -------------------------------------------- */
/**
* Additional workflows when playback of the Sound concludes.
* This is called by the AudioNode#onended callback.
*/
async #onEnd() {
await this.stop();
this.#playback.onended?.(this);
this.dispatchEvent(new Event("end"));
}
/* -------------------------------------------- */
/**
* Additional workflows when playback of the Sound is stopped, either manually or by concluding its playback.
*/
#onStop() {
game.audio.playing.delete(this.id);
this.#cancelScheduledEvents();
this.dispatchEvent(new Event("stop"));
}
/* -------------------------------------------- */
/* Helper Methods */
/* -------------------------------------------- */
/**
* Create an HTML5 Audio element which has loaded the metadata for the provided source.
* @returns {Promise<HTMLAudioElement>} A created HTML Audio element
* @throws {Error} An error if audio element creation failed
*/
async #createAudioElement() {
game.audio.debug(`Loading audio element "${this.src}"`);
return new Promise((resolve, reject) => {
const element = new Audio();
element.autoplay = false;
element.crossOrigin = "anonymous";
element.preload = "metadata";
element.onloadedmetadata = () => resolve(element);
element.onerror = () => reject(`Failed to load audio element "${this.src}"`);
element.src = this.src;
});
}
/* -------------------------------------------- */
/**
* Ensure to safely unload a media stream
* @param {HTMLAudioElement} element The audio element to unload
*/
static #unloadAudioElement(element) {
element.onended = undefined;
element.pause();
element.src = "";
element.remove();
}
/* -------------------------------------------- */
/**
* Load an audio file and decode it to create an AudioBuffer.
* @returns {Promise<AudioBuffer>} A created AudioBuffer
* @throws {Error} An error if buffer creation failed
*/
async #createAudioBuffer() {
game.audio.debug(`Loading audio buffer "${this.src}"`);
try {
const response = await foundry.utils.fetchWithTimeout(this.src);
const arrayBuffer = await response.arrayBuffer();
return this.#context.decodeAudioData(arrayBuffer);
} catch(err) {
err.message = `Failed to load audio buffer "${this.src}"`;
throw err;
}
}
/* -------------------------------------------- */
/**
* Create any AudioNode instances required for playback of this Sound.
* @protected
*/
_createNodes() {
this.gainNode ||= this.#context.createGain();
this.destination ||= (this.#context.gainNode ?? this.#context.destination); // Prefer a context gain if present
const {buffer, element: mediaElement} = this;
if ( buffer ) this.#bufferNode = new AudioBufferSourceNode(this.#context, {buffer});
else if ( mediaElement ) this.#mediaNode = new MediaElementAudioSourceNode(this.#context, {mediaElement});
}
/* -------------------------------------------- */
/**
* Create the audio pipeline used to play this Sound.
* The GainNode is reused each time to link volume changes across multiple playbacks.
* The AudioSourceNode is re-created every time that Sound#play is called.
* @protected
*/
_connectPipeline() {
if ( !this.sourceNode ) return;
this.#pipeline.length = 0;
// Start with the sourceNode
let node = this.sourceNode;
this.#pipeline.push(node);
// Connect effect nodes
for ( const effect of this.effects ) {
node.connect(effect);
effect.onConnectFrom?.(node); // Special behavior to inform the effect node it has been connected
node = effect;
this.#pipeline.push(effect);
}
// End with the gainNode
node.connect(this.gainNode);
this.#pipeline.push(this.gainNode);
this.gainNode.connect(this.destination);
}
/* -------------------------------------------- */
/**
* Disconnect the audio pipeline once playback is stopped.
* Walk backwards along the Sound##pipeline from the Sound#destination, disconnecting each node.
* @protected
*/
_disconnectPipeline() {
for ( let i=this.#pipeline.length-1; i>=0; i-- ) {
const node = this.#pipeline[i];
node.disconnect();
}
}
/* -------------------------------------------- */
/**
* Configure playback parameters for the Sound.
* @param {SoundPlaybackOptions} Provided playback options
*/
#configurePlayback({delay, duration, fade, loop, loopStart, loopEnd, offset, onended, volume}={}) {
// Some playback options only update if they are explicitly passed
this.#playback.loop = loop ?? this.#playback.loop;
this.#playback.loopStart = loopStart ?? this.#playback.loopStart;
this.#playback.loopEnd = loopEnd ?? this.#playback.loopEnd;
this.#playback.volume = volume ?? this.#playback.volume;
this.#playback.onended = onended !== undefined ? onended : this.#playback.onended;
// Determine playback offset and duration timing
const loopTime = (this.#playback.loopEnd ?? Infinity) - this.#playback.loopStart;
// Starting offset
offset ??= this.#playback.loopStart;
if ( Number.isFinite(this.pausedTime) ) offset += this.pausedTime;
// Loop forever
if ( this.#playback.loop ) duration ??= undefined;
// Play once
else if ( Number.isFinite(loopTime) ) {
offset = Math.clamp(offset, this.#playback.loopStart, this.#playback.loopEnd);
duration ??= loopTime;
duration = Math.min(duration, loopTime);
}
// Some playback options reset unless they are explicitly passed
this.#playback.delay = delay ?? 0;
this.#playback.offset = offset;
this.#playback.duration = duration;
this.#playback.fade = fade ?? 0;
}
/* -------------------------------------------- */
/**
* Cancel any scheduled events which have not yet occurred.
*/
#cancelScheduledEvents() {
for ( const timeout of this.#scheduledEvents ) timeout.cancel();
this.#scheduledEvents.clear();
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
static get LOAD_STATES() {
foundry.utils.logCompatibilityWarning("AudioContainer.LOAD_STATES is deprecated in favor of Sound.STATES",
{since: 12, until: 14});
return this.STATES;
}
/**
* @deprecated since v12
* @ignore
*/
get loadState() {
foundry.utils.logCompatibilityWarning("AudioContainer#loadState is deprecated in favor of Sound#_state",
{since: 12, until: 14});
return this._state;
}
/**
* @deprecated since v12
* @ignore
*/
get container() {
foundry.utils.logCompatibilityWarning("Sound#container is deprecated without replacement because the Sound and "
+ "AudioContainer classes are now merged", {since: 12, until: 14});
return this;
}
/**
* @deprecated since v12
* @ignore
*/
get node() {
foundry.utils.logCompatibilityWarning("Sound#node is renamed Sound#sourceNode", {since: 12, until: 14});
return this.sourceNode;
}
/**
* @deprecated since v12
* @ignore
*/
on(eventName, fn, {once=false}={}) {
foundry.utils.logCompatibilityWarning("Sound#on is deprecated in favor of Sound#addEventListener",
{since: 12, until: 14});
return this.addEventListener(eventName, fn, {once});
}
/**
* @deprecated since v12
* @ignore
*/
off(eventName, fn) {
foundry.utils.logCompatibilityWarning("Sound#off is deprecated in favor of Sound#removeEventListener",
{since: 12, until: 14});
return this.removeEventListener(eventName, fn);
}
/**
* @deprecated since v12
* @ignore
*/
emit(eventName) {
foundry.utils.logCompatibilityWarning("Sound#emit is deprecated in favor of Sound#dispatchEvent",
{since: 12, until: 14});
const event = new Event(eventName, {cancelable: true});
return this.dispatchEvent(event);
}
}

View File

@@ -0,0 +1,147 @@
/**
* @typedef {Object} AudioTimeoutOptions
* @property {AudioContext} [context]
* @property {function(): any} [callback]
*/
/**
* A special error class used for cancellation.
*/
class AudioTimeoutCancellation extends Error {}
/**
* A framework for scheduled audio events with more precise and synchronized timing than using window.setTimeout.
* This approach creates an empty audio buffer of the desired duration played using the shared game audio context.
* The onended event of the AudioBufferSourceNode provides a very precise way to synchronize audio events.
* For audio timing, this is preferable because it avoids numerous issues with window.setTimeout.
*
* @example Using a callback function
* ```js
* function playForDuration(sound, duration) {
* sound.play();
* const wait = new AudioTimeout(duration, {callback: () => sound.stop()})
* }
* ```
*
* @example Using an awaited Promise
* ```js
* async function playForDuration(sound, duration) {
* sound.play();
* const timeout = new AudioTimeout(delay);
* await timeout.complete;
* sound.stop();
* }
* ```
*
* @example Using the wait helper
* ```js
* async function playForDuration(sound, duration) {
* sound.play();
* await AudioTimeout.wait(duration);
* sound.stop();
* }
* ```
*/
export default class AudioTimeout {
/**
* Create an AudioTimeout by providing a delay and callback.
* @param {number} delayMS A desired delay timing in milliseconds
* @param {AudioTimeoutOptions} [options] Additional options which modify timeout behavior
*/
constructor(delayMS, {callback, context}={}) {
if ( !(typeof delayMS === "number") ) throw new Error("Numeric timeout duration must be provided");
this.#callback = callback;
this.complete = new Promise((resolve, reject) => {
this.#resolve = resolve;
this.#reject = reject;
// Immediately evaluated
if ( delayMS <= 0 ) return this.end();
// Create and play a blank AudioBuffer of the desired delay duration
context ||= game.audio.music;
const seconds = delayMS / 1000;
const sampleRate = context.sampleRate;
const buffer = new AudioBuffer({length: seconds * sampleRate, sampleRate});
this.#sourceNode = new AudioBufferSourceNode(context, {buffer});
this.#sourceNode.onended = this.end.bind(this);
this.#sourceNode.start();
})
// The promise may get cancelled
.catch(err => {
if ( err instanceof AudioTimeoutCancellation ) return;
throw err;
});
}
/**
* Is the timeout complete?
* This can be used to await the completion of the AudioTimeout if necessary.
* The Promise resolves to the returned value of the provided callback function.
* @type {Promise<*>}
*/
complete;
/**
* The resolution function for the wrapping Promise.
* @type {Function}
*/
#resolve;
/**
* The rejection function for the wrapping Promise.
* @type {Function}
*/
#reject;
/**
* A scheduled callback function
* @type {Function}
*/
#callback;
/**
* The source node used to maintain the timeout
* @type {AudioBufferSourceNode}
*/
#sourceNode;
/* -------------------------------------------- */
/**
* Cancel an AudioTimeout by ending it early, rejecting its completion promise, and skipping any callback function.
*/
cancel() {
if ( !this.#reject ) return;
const reject = this.#reject;
this.#resolve = this.#reject = undefined;
reject(new AudioTimeoutCancellation("AudioTimeout cancelled"));
this.#sourceNode.onended = null;
this.#sourceNode.stop();
}
/* -------------------------------------------- */
/**
* End the timeout, either on schedule or prematurely. Executing any callback function
*/
end() {
const resolve = this.#resolve;
this.#resolve = this.#reject = undefined;
resolve(this.#callback?.());
}
/* -------------------------------------------- */
/**
* Schedule a task according to some audio timeout.
* @param {number} delayMS A desired delay timing in milliseconds
* @param {AudioTimeoutOptions} [options] Additional options which modify timeout behavior
* @returns {Promise<void|any>} A promise which resolves as a returned value of the callback or void
*/
static async wait(delayMS, options) {
const timeout = new this(delayMS, options);
return timeout.complete;
}
}

View File

@@ -0,0 +1,6 @@
export {default as SceneManager} from "./scene-manager.mjs";
export {default as SMAAFilter} from "./smaa/smaa.mjs";
export * as edges from "./edges/_module.mjs";
export * as regions from "./regions/_module.mjs";
export * as sources from "./sources/_module.mjs";
export * as tokens from "./tokens/_module.mjs";

View File

@@ -0,0 +1,4 @@
export {default as CanvasEdges} from "./edges.mjs";
export {default as CollisionResult} from "./collision.mjs";
export {default as Edge} from "./edge.mjs";
export {default as PolygonVertex} from "./vertex.mjs";

View File

@@ -0,0 +1,94 @@
/**
* A specialized object that contains the result of a collision in the context of the ClockwiseSweepPolygon.
* This class is not designed or intended for use outside of that context.
* @alias CollisionResult
*/
export default class CollisionResult {
constructor({target, collisions=[], cwEdges, ccwEdges, isBehind, isLimited, wasLimited}={}) {
this.target = target;
this.collisions = collisions;
this.cwEdges = cwEdges || new Set();
this.ccwEdges = ccwEdges || new Set();
this.isBehind = isBehind;
this.isLimited = isLimited;
this.wasLimited = wasLimited;
}
/**
* The vertex that was the target of this result
* @type {PolygonVertex}
*/
target;
/**
* The array of collision points which apply to this result
* @type {PolygonVertex[]}
*/
collisions;
/**
* The set of edges connected to the target vertex that continue clockwise
* @type {EdgeSet}
*/
cwEdges;
/**
* The set of edges connected to the target vertex that continue counter-clockwise
* @type {EdgeSet}
*/
ccwEdges;
/**
* Is the target vertex for this result behind some closer active edge?
* @type {boolean}
*/
isBehind;
/**
* Does the target vertex for this result impose a limited collision?
* @type {boolean}
*/
isLimited;
/**
* Has the set of collisions for this result encountered a limited edge?
* @type {boolean}
*/
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?
* @type {boolean}
*/
blockedCCWPrev = false;
}

View File

@@ -0,0 +1,285 @@
/**
* @typedef {import("../../../common/types.mjs").Point} Point
*/
/**
* @typedef {"wall"|"darkness"|"innerBounds"|"outerBounds"} EdgeTypes
*/
/**
* A data structure used to represent potential edges used by the ClockwiseSweepPolygon.
* Edges are not polygon-specific, meaning they can be reused across many polygon instances.
*/
export default class Edge {
/**
* Construct an Edge by providing the following information.
* @param {Point} a The first endpoint of the edge
* @param {Point} b The second endpoint of the edge
* @param {object} [options] Additional options which describe the edge
* @param {string} [options.id] A string used to uniquely identify this edge
* @param {PlaceableObject} [options.object] A PlaceableObject that is responsible for this edge, if any
* @param {EdgeTypes} [options.type] The type of edge
* @param {WALL_SENSE_TYPES} [options.light] How this edge restricts light
* @param {WALL_SENSE_TYPES} [options.move] How this edge restricts movement
* @param {WALL_SENSE_TYPES} [options.sight] How this edge restricts sight
* @param {WALL_SENSE_TYPES} [options.sound] How this edge restricts sound
* @param {WALL_DIRECTIONS} [options.direction=0] A direction of effect for the edge
* @param {WallThresholdData} [options.threshold] Configuration of threshold data for this edge
*/
constructor(a, b, {id, object, direction, type, light, move, sight, sound, threshold}={}) {
this.a = new PIXI.Point(a.x, a.y);
this.b = new PIXI.Point(b.x, b.y);
this.id = id ?? object?.id ?? undefined;
this.object = object;
this.type = type || "wall";
this.direction = direction ?? CONST.WALL_DIRECTIONS.BOTH;
this.light = light ?? CONST.WALL_SENSE_TYPES.NONE;
this.move = move ?? CONST.WALL_SENSE_TYPES.NONE;
this.sight = sight ?? CONST.WALL_SENSE_TYPES.NONE;
this.sound = sound ?? CONST.WALL_SENSE_TYPES.NONE;
this.threshold = threshold;
// Record the edge orientation arranged from top-left to bottom-right
const isSE = b.x === a.x ? b.y > a.y : b.x > a.x;
if ( isSE ) {
this.nw = a;
this.se = b;
}
else {
this.nw = b;
this.se = a;
}
this.bounds = new PIXI.Rectangle(this.nw.x, this.nw.y, this.se.x - this.nw.x, this.se.y - this.nw.y);
}
/* -------------------------------------------- */
/**
* The first endpoint of the edge.
* @type {PIXI.Point}
*/
a;
/**
* The second endpoint of the edge.
* @type {PIXI.Point}
*/
b;
/**
* The endpoint of the edge which is oriented towards the top-left.
*/
nw;
/**
* The endpoint of the edge which is oriented towards the bottom-right.
*/
se;
/**
* The rectangular bounds of the edge. Used by the quadtree.
* @type {PIXI.Rectangle}
*/
bounds;
/**
* The direction of effect for the edge.
* @type {WALL_DIRECTIONS}
*/
direction;
/**
* A string used to uniquely identify this edge.
* @type {string}
*/
id;
/**
* How this edge restricts light.
* @type {WALL_SENSE_TYPES}
*/
light;
/**
* How this edge restricts movement.
* @type {WALL_SENSE_TYPES}
*/
move;
/**
* How this edge restricts sight.
* @type {WALL_SENSE_TYPES}
*/
sight;
/**
* How this edge restricts sound.
* @type {WALL_SENSE_TYPES}
*/
sound;
/**
* Specialized threshold data for this edge.
* @type {WallThresholdData}
*/
threshold;
/**
* Record other edges which this one intersects with.
* @type {{edge: Edge, intersection: LineIntersection}[]}
*/
intersections = [];
/**
* A PolygonVertex instance.
* Used as part of ClockwiseSweepPolygon computation.
* @type {PolygonVertex}
*/
vertexA;
/**
* A PolygonVertex instance.
* Used as part of ClockwiseSweepPolygon computation.
* @type {PolygonVertex}
*/
vertexB;
/* -------------------------------------------- */
/**
* Is this edge limited for a particular type?
* @returns {boolean}
*/
isLimited(type) {
return this[type] === CONST.WALL_SENSE_TYPES.LIMITED;
}
/* -------------------------------------------- */
/**
* Create a copy of the Edge which can be safely mutated.
* @returns {Edge}
*/
clone() {
const clone = new this.constructor(this.a, this.b, this);
clone.intersections = [...this.intersections];
clone.vertexA = this.vertexA;
clone.vertexB = this.vertexB;
return clone;
}
/* -------------------------------------------- */
/**
* Get an intersection point between this Edge and another.
* @param {Edge} other
* @returns {LineIntersection|void}
*/
getIntersection(other) {
if ( this === other ) return;
const {a: a0, b: b0} = this;
const {a: a1, b: b1} = other;
// Ignore edges which share an endpoint
if ( a0.equals(a1) || a0.equals(b1) || b0.equals(a1) || b0.equals(b1) ) return;
// Initial fast CCW test for intersection
if ( !foundry.utils.lineSegmentIntersects(a0, b0, a1, b1) ) return;
// Slower computation of intersection point
const i = foundry.utils.lineLineIntersection(a0, b0, a1, b1, {t1: true});
if ( !i ) return; // Eliminates co-linear lines, theoretically should not be necessary but just in case
return i;
}
/* -------------------------------------------- */
/**
* Test whether to apply a proximity threshold to this edge.
* If the proximity threshold is met, this edge excluded from 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 edge has a threshold greater than 0 for the source type,
* and the source type is within that distance.
*/
applyThreshold(sourceType, sourceOrigin, externalRadius=0) {
const d = this.threshold?.[sourceType];
const t = this[sourceType];
if ( !d || (t < CONST.WALL_SENSE_TYPES.PROXIMITY) ) return false; // Threshold behavior does not apply
const proximity = t === CONST.WALL_SENSE_TYPES.PROXIMITY;
const pt = foundry.utils.closestPointToSegment(sourceOrigin, this.a, this.b);
const sourceDistance = Math.hypot(pt.x - sourceOrigin.x, pt.y - sourceOrigin.y);
return proximity ? Math.max(sourceDistance - externalRadius, 0) < d : (sourceDistance + externalRadius) > d;
}
/* -------------------------------------------- */
/**
* Determine the orientation of this Edge 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 Edge
*/
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;
}
/* -------------------------------------------- */
/* Intersection Management */
/* -------------------------------------------- */
/**
* Identify intersections between a provided iterable of edges.
* @param {Iterable<Edge>} edges An iterable of edges
*/
static identifyEdgeIntersections(edges) {
// Sort edges by their north-west x value, breaking ties with the south-east x value
const sorted = [];
for ( const edge of edges ) {
edge.intersections.length = 0; // Clear prior intersections
sorted.push(edge);
}
sorted.sort((e1, e2) => (e1.nw.x - e2.nw.x) || (e1.se.x - e2.se.x));
// Iterate over all known edges, identifying intersections
const ln = sorted.length;
for ( let i=0; i<ln; i++ ) {
const e1 = sorted[i];
for ( let j=i+1; j<ln; j++ ) {
const e2 = sorted[j];
if ( e2.nw.x > e1.se.x ) break; // Segment e2 is entirely right of segment e1
e1.recordIntersections(e2);
}
}
}
/* -------------------------------------------- */
/**
* Record the intersections between two edges.
* @param {Edge} other Another edge to test and record
*/
recordIntersections(other) {
if ( other === this ) return;
const i = this.getIntersection(other);
if ( !i ) return;
this.intersections.push({edge: other, intersection: i});
other.intersections.push({edge: this, intersection: {x: i.x, y: i.y, t0: i.t1, t1: i.t0}});
}
/* -------------------------------------------- */
/**
* Remove intersections of this edge with all other edges.
*/
removeIntersections() {
for ( const {edge: other} of this.intersections ) {
other.intersections.findSplice(e => e.edge === this);
}
this.intersections.length = 0;
}
}

View File

@@ -0,0 +1,84 @@
import Edge from "./edge.mjs";
/**
* A special class of Map which defines all the edges used to restrict perception in a Scene.
* @extends {Map<string, Edge>}
*/
export default class CanvasEdges extends Map {
/**
* Edge instances which represent the outer boundaries of the game canvas.
* @type {Edge[]}
*/
#outerBounds = [];
/**
* Edge instances which represent the inner boundaries of the scene rectangle.
* @type {Edge[]}
*/
#innerBounds = [];
/* -------------------------------------------- */
/**
* Initialize all active edges for the Scene. This workflow occurs once only when the Canvas is first initialized.
* Edges are created from the following sources:
* 1. Wall documents
* 2. Canvas boundaries (inner and outer bounds)
* 3. Darkness sources
* 4. Programmatically defined in the "initializeEdges" hook
*/
initialize() {
this.clear();
// Wall Documents
for ( /** @type {Wall} */ const wall of canvas.walls.placeables ) wall.initializeEdge();
// Canvas Boundaries
this.#defineBoundaries();
// Darkness Sources
for ( const source of canvas.effects.darknessSources ) {
for ( const edge of source.edges ) this.set(edge.id, edge);
}
// Programmatic Edges
Hooks.callAll("initializeEdges");
}
/* -------------------------------------------- */
/**
* Incrementally refresh Edges by computing intersections between all registered edges.
*/
refresh() {
Edge.identifyEdgeIntersections(canvas.edges.values());
}
/* -------------------------------------------- */
/**
* Define Edge instances for outer and inner canvas bounds rectangles.
*/
#defineBoundaries() {
const d = canvas.dimensions;
const define = (type, r) => {
const top = new Edge({x: r.x, y: r.y}, {x: r.right, y: r.y}, {id: `${type}Top`, type});
const right = new Edge({x: r.right, y: r.y}, {x: r.right, y: r.bottom}, {id: `${type}Right`, type});
const bottom = new Edge({x: r.right, y: r.bottom}, {x: r.x, y: r.bottom}, {id: `${type}Bottom`, type});
const left = new Edge({x: r.x, y: r.bottom}, {x: r.x, y: r.y}, {id: `${type}Left`, type});
return [top, right, bottom, left];
};
// Outer canvas bounds
this.#outerBounds = define("outerBounds", d.rect);
for ( const b of this.#outerBounds ) this.set(b.id, b);
// Inner canvas bounds (if there is padding)
if ( d.rect.x === d.sceneRect.x ) this.#innerBounds = this.#outerBounds;
else {
this.#innerBounds = define("innerBounds", d.sceneRect);
for ( const b of this.#innerBounds ) this.set(b.id, b);
}
}
}

View File

@@ -0,0 +1,187 @@
/**
* A specialized point data structure used to represent vertices in the context of the ClockwiseSweepPolygon.
* This class is not designed or intended for use outside of that context.
* @alias PolygonVertex
*/
export default class PolygonVertex {
constructor(x, y, {distance, index}={}) {
this.x = Math.round(x);
this.y = Math.round(y);
this.key = PolygonVertex.getKey(this.x, this.y);
this._distance = distance;
this._d2 = undefined;
this._index = index;
}
/**
* 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
* @returns {number} The key used to identify the vertex
*/
static getKey(x, y) {
return (this.#MAX_TEXTURE_SIZE * x) + y;
}
/**
* The set of edges which connect to this vertex.
* This set is initially empty and populated later after vertices are de-duplicated.
* @type {EdgeSet}
*/
edges = new Set();
/**
* The subset of edges which continue clockwise from this vertex.
* @type {EdgeSet}
*/
cwEdges = new Set();
/**
* The subset of edges which continue counter-clockwise from this vertex.
* @type {EdgeSet}
*/
ccwEdges = new Set();
/**
* The set of vertices collinear to this vertex
* @type {Set<PolygonVertex>}
*/
collinearVertices = new Set();
/**
* 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;
/**
* Does this vertex result from an internal collision?
* @type {boolean}
*/
isInternal = false;
/**
* The maximum restriction imposed by this vertex.
* @type {number}
*/
restriction = 0;
/**
* Record whether this PolygonVertex has been visited in the sweep
* @type {boolean}
* @internal
*/
_visited = false;
/* -------------------------------------------- */
/**
* Is this vertex limited in type?
* @returns {boolean}
*/
get isLimited() {
return this.restriction === CONST.WALL_SENSE_TYPES.LIMITED;
}
/* -------------------------------------------- */
/**
* Associate an edge with this vertex.
* @param {Edge} edge The edge being attached
* @param {number} orientation The orientation of the edge with respect to the origin
* @param {string} type The restriction type of polygon being created
*/
attachEdge(edge, orientation, type) {
this.edges.add(edge);
this.restriction = Math.max(this.restriction ?? 0, edge[type]);
if ( orientation <= 0 ) this.cwEdges.add(edge);
if ( orientation >= 0 ) this.ccwEdges.add(edge);
this.#updateFlags(type);
}
/* -------------------------------------------- */
/**
* Update flags for whether this vertex is limiting or blocking in certain direction.
* @param {string} type
*/
#updateFlags(type) {
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(type);
return {isLimiting, isBlocking: !isLimiting};
}
};
// Flag endpoint
this.isEndpoint = this.edges.some(edge => {
return (edge.vertexA || edge.a).equals(this) || (edge.vertexB || 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);
}
}

View File

@@ -0,0 +1,4 @@
export {default as RegionGeometry} from "./geometry.mjs";
export {default as RegionMesh} from "./mesh.mjs";
export {default as RegionPolygonTree} from "./polygon-tree.mjs";
export {default as RegionShape} from "./shape.mjs";

View File

@@ -0,0 +1,65 @@
/**
* The geometry of a {@link Region}.
* - Vertex Attribute: `aVertexPosition` (`vec2`)
* - Draw Mode: `PIXI.DRAW_MODES.TRIANGLES`
*/
export default class RegionGeometry extends PIXI.Geometry {
/**
* Create a RegionGeometry.
* @param {Region} region The Region to create the RegionGeometry from.
* @internal
*/
constructor(region) {
super();
this.#region = region;
this.addAttribute("aVertexPosition", new PIXI.Buffer(new Float32Array(), true, false), 2);
this.addIndex(new PIXI.Buffer(new Uint16Array(), true, true));
}
/* -------------------------------------------- */
/**
* The Region this geometry belongs to.
* @type {Region}
*/
get region() {
return this.#region;
}
#region;
/* -------------------------------------------- */
/**
* Do the buffers need to be updated?
* @type {boolean}
*/
#invalidBuffers = true;
/* -------------------------------------------- */
/**
* Update the buffers.
* @internal
*/
_clearBuffers() {
this.buffers[0].update(new Float32Array());
this.indexBuffer.update(new Uint16Array());
this.#invalidBuffers = true;
}
/* -------------------------------------------- */
/**
* Update the buffers.
* @internal
*/
_updateBuffers() {
if ( !this.#invalidBuffers ) return;
const triangulation = this.region.triangulation;
this.buffers[0].update(triangulation.vertices);
this.indexBuffer.update(triangulation.indices);
this.#invalidBuffers = false;
}
}

View File

@@ -0,0 +1,213 @@
/**
* A mesh of a {@link Region}.
* @extends {PIXI.Container}
*/
export default class RegionMesh extends PIXI.Container {
/**
* Create a RegionMesh.
* @param {Region} region The Region to create the RegionMesh from.
* @param {AbstractBaseShader} [shaderClass] The shader class to use.
*/
constructor(region, shaderClass=RegionShader) {
super();
this.#region = region;
this.region.geometry.refCount++;
if ( !AbstractBaseShader.isPrototypeOf(shaderClass) ) {
throw new Error("RegionMesh shader class must inherit from AbstractBaseShader.");
}
this.#shader = shaderClass.create();
}
/* ---------------------------------------- */
/**
* Shared point instance.
* @type {PIXI.Point}
*/
static #SHARED_POINT = new PIXI.Point();
/* ---------------------------------------- */
/**
* The Region of this RegionMesh.
* @type {RegionMesh}
*/
get region() {
return this.#region;
}
#region;
/* ---------------------------------------- */
/**
* The shader bound to this RegionMesh.
* @type {AbstractBaseShader}
*/
get shader() {
return this.#shader;
}
#shader;
/* ---------------------------------------- */
/**
* The blend mode assigned to this RegionMesh.
* @type {PIXI.BLEND_MODES}
*/
get blendMode() {
return this.#state.blendMode;
}
set blendMode(value) {
if ( this.#state.blendMode === value ) return;
this.#state.blendMode = value;
this._tintAlphaDirty = true;
}
#state = PIXI.State.for2d();
/* ---------------------------------------- */
/**
* The tint applied to the mesh. This is a hex value.
*
* A value of 0xFFFFFF will remove any tint effect.
* @type {number}
* @defaultValue 0xFFFFFF
*/
get tint() {
return this._tintColor.value;
}
set tint(tint) {
const currentTint = this._tintColor.value;
this._tintColor.setValue(tint);
if ( currentTint === this._tintColor.value ) return;
this._tintAlphaDirty = true;
}
/* ---------------------------------------- */
/**
* The tint applied to the mesh. This is a hex value. A value of 0xFFFFFF will remove any tint effect.
* @type {PIXI.Color}
* @protected
*/
_tintColor = new PIXI.Color(0xFFFFFF);
/* ---------------------------------------- */
/**
* Cached tint value for the shader uniforms.
* @type {[red: number, green: number, blue: number, alpha: number]}
* @protected
* @internal
*/
_cachedTint = [1, 1, 1, 1];
/* ---------------------------------------- */
/**
* Used to track a tint or alpha change to execute a recomputation of _cachedTint.
* @type {boolean}
* @protected
*/
_tintAlphaDirty = true;
/* ---------------------------------------- */
/**
* Initialize shader based on the shader class type.
* @param {type AbstractBaseShader} shaderClass The shader class, which must inherit from {@link AbstractBaseShader}.
*/
setShaderClass(shaderClass) {
if ( !AbstractBaseShader.isPrototypeOf(shaderClass) ) {
throw new Error("RegionMesh shader class must inherit from AbstractBaseShader.");
}
if ( this.#shader.constructor === shaderClass ) return;
// Create shader program
this.#shader = shaderClass.create();
}
/* ---------------------------------------- */
/** @override */
updateTransform() {
super.updateTransform();
// We set tintAlphaDirty to true if the worldAlpha has changed
// It is needed to recompute the _cachedTint vec4 which is a combination of tint and alpha
if ( this.#worldAlpha !== this.worldAlpha ) {
this.#worldAlpha = this.worldAlpha;
this._tintAlphaDirty = true;
}
}
#worldAlpha;
/* ---------------------------------------- */
/** @override */
_render(renderer) {
if ( this._tintAlphaDirty ) {
const premultiply = PIXI.utils.premultiplyBlendMode[1][this.blendMode] === this.blendMode;
PIXI.Color.shared.setValue(this._tintColor)
.premultiply(this.worldAlpha, premultiply)
.toArray(this._cachedTint);
this._tintAlphaDirty = false;
}
this.#shader._preRender(this, renderer);
this.#shader.uniforms.translationMatrix = this.transform.worldTransform.toArray(true);
// Flush batch renderer
renderer.batch.flush();
// Set state
renderer.state.set(this.#state);
// Bind shader and geometry
renderer.shader.bind(this.#shader);
const geometry = this.region.geometry;
geometry._updateBuffers();
renderer.geometry.bind(geometry, this.#shader);
// Draw the geometry
renderer.geometry.draw(PIXI.DRAW_MODES.TRIANGLES);
}
/* ---------------------------------------- */
/** @override */
_calculateBounds() {
const {left, top, right, bottom} = this.region.bounds;
this._bounds.addFrame(this.transform, left, top, right, bottom);
}
/* ---------------------------------------- */
/**
* Tests if a point is inside this RegionMesh.
* @param {PIXI.IPointData} point
* @returns {boolean}
*/
containsPoint(point) {
return this.region.polygonTree.testPoint(this.worldTransform.applyInverse(point, RegionMesh.#SHARED_POINT));
}
/* ---------------------------------------- */
/** @override */
destroy(options) {
super.destroy(options);
const geometry = this.region.geometry;
geometry.refCount--;
if ( geometry.refCount === 0 ) geometry.dispose();
this.#shader = null;
this.#state = null;
}
}

View File

@@ -0,0 +1,298 @@
/**
* The node of a {@link RegionPolygonTree}.
*/
class RegionPolygonTreeNode {
/**
* Create a RegionPolygonTreeNode.
* @param {RegionPolygonTreeNode|null} parent The parent node.
* @internal
*/
constructor(parent) {
this.#parent = parent;
this.#children = [];
this.#depth = parent ? parent.depth + 1 : 0;
this.#isHole = this.#depth % 2 === 0;
if ( parent ) parent.#children.push(this);
else {
this.#polygon = null;
this.#clipperPath = null;
}
}
/* -------------------------------------------- */
/**
* Create a node from the Clipper path and add it to the children of the parent.
* @param {ClipperLib.IntPoint[]} clipperPath The clipper path of this node.
* @param {RegionPolygonTreeNode|null} parent The parent node or `null` if root.
* @internal
*/
static _fromClipperPath(clipperPath, parent) {
const node = new RegionPolygonTreeNode(parent);
if ( parent ) node.#clipperPath = clipperPath;
return node;
}
/* -------------------------------------------- */
/**
* The parent of this node or `null` if this is the root node.
* @type {RegionPolygonTreeNode|null}
*/
get parent() {
return this.#parent;
}
#parent;
/* -------------------------------------------- */
/**
* The children of this node.
* @type {ReadonlyArray<RegionPolygonTreeNode>}
*/
get children() {
return this.#children;
}
#children;
/* -------------------------------------------- */
/**
* The depth of this node.
* The depth of the root node is 0.
* @type {number}
*/
get depth() {
return this.#depth;
}
#depth;
/* -------------------------------------------- */
/**
* Is this a hole?
* The root node is a hole.
* @type {boolean}
*/
get isHole() {
return this.#isHole;
}
#isHole;
/* -------------------------------------------- */
/**
* The Clipper path of this node.
* It is empty in case of the root node.
* @type {ReadonlyArray<ClipperLib.IntPoint>|null}
*/
get clipperPath() {
return this.#clipperPath;
}
#clipperPath;
/* -------------------------------------------- */
/**
* The polygon of this node.
* It is `null` in case of the root node.
* @type {PIXI.Polygon|null}
*/
get polygon() {
let polygon = this.#polygon;
if ( polygon === undefined ) polygon = this.#polygon = this.#createPolygon();
return polygon;
}
#polygon;
/* -------------------------------------------- */
/**
* The points of the polygon ([x0, y0, x1, y1, ...]).
* They are `null` in case of the root node.
* @type {ReadonlyArray<number>|null}
*/
get points() {
const polygon = this.polygon;
if ( !polygon ) return null;
return polygon.points;
}
/* -------------------------------------------- */
/**
* The bounds of the polygon.
* They are `null` in case of the root node.
* @type {PIXI.Rectangle|null}
*/
get bounds() {
let bounds = this.#bounds;
if ( bounds === undefined ) bounds = this.#bounds = this.polygon?.getBounds() ?? null;
return bounds;
}
#bounds;
/* -------------------------------------------- */
/**
* Iterate over recursively over the children in depth-first order.
* @yields {RegionPolygonTreeNode}
*/
*[Symbol.iterator]() {
for ( const child of this.children ) {
yield child;
yield *child;
}
}
/* -------------------------------------------- */
/**
* Test whether given point is contained within this node.
* @param {Point} point The point.
* @returns {boolean}
*/
testPoint(point) {
return this.#testPoint(point) === 2;
}
/* -------------------------------------------- */
/**
* Test point containment.
* @param {Point} point The point.
* @returns {0|1|2} - 0: not contained within the polygon of this node.
* - 1: contained within the polygon of this node but also contained
* inside the polygon of a sub-node that is a hole.
* - 2: contained within the polygon of this node and not contained
* inside any polygon of a sub-node that is a hole.
*/
#testPoint(point) {
const {x, y} = point;
if ( this.parent ) {
if ( !this.bounds.contains(x, y) || !this.polygon.contains(x, y) ) return 0;
}
const children = this.children;
for ( let i = 0, n = children.length; i < n; i++ ) {
const result = children[i].#testPoint(point);
if ( result !== 0 ) return result;
}
return this.isHole ? 1 : 2;
}
/* -------------------------------------------- */
/**
* Test circle containment/intersection with this node.
* @param {Point} center The center point of the circle.
* @param {number} radius The radius of the circle.
* @returns {-1|0|1} - -1: the circle is in the exterior and does not intersect the boundary.
* - 0: the circle is intersects the boundary.
* - 1: the circle is in the interior and does not intersect the boundary.
*/
testCircle(center, radius) {
switch ( this.#testCircle(center, radius) ) {
case 2: return 1;
case 3: return 0;
default: return -1;
}
}
/* -------------------------------------------- */
/**
* Test circle containment/intersection with this node.
* @param {Point} center The center point of the circle.
* @param {number} radius The radius of the circle.
* @returns {0|1|2|3} - 0: does not intersect the boundary or interior of this node.
* - 1: contained within the polygon of this node but also contained
* inside the polygon of a sub-node that is a hole.
* - 2: contained within the polygon of this node and not contained
* inside any polygon of a sub-node that is a hole.
* - 3: intersects the boundary of this node or any sub-node.
*/
#testCircle(center, radius) {
if ( this.parent ) {
const {x, y} = center;
// Test whether the circle intersects the bounds of this node
const {left, right, top, bottom} = this.bounds;
if ( (x < left - radius) || (x > right + radius) || (y < top - radius) || (y > bottom + radius) ) return 0;
// Test whether the circle intersects any edge of the polygon of this node
const intersects = foundry.utils.pathCircleIntersects(this.points, true, center, radius);
if ( intersects ) return 3;
// Test whether the circle is completely outside of the polygon
const inside = this.polygon.contains(x, y);
if ( !inside ) return 0;
}
// Test the children of this node now that we know that the circle is
// completely inside of the polygon of this node
const children = this.children;
for ( let i = 0, n = children.length; i < n; i++ ) {
const result = children[i].#testCircle(center, radius);
if ( result !== 0 ) return result;
}
return this.isHole ? 1 : 2;
}
/* -------------------------------------------- */
/**
* Create the polygon of this node.
* @returns {PIXI.Polygon|null}
*/
#createPolygon() {
if ( !this.parent ) return null;
const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
const polygon = PIXI.Polygon.fromClipperPoints(this.clipperPath, {scalingFactor});
polygon._isPositive = !this.isHole;
return polygon;
}
}
/* -------------------------------------------- */
/**
* The polygon tree of a {@link Region}.
*/
export default class RegionPolygonTree extends RegionPolygonTreeNode {
/**
* Create a RegionPolygonTree.
* @internal
*/
constructor() {
super(null);
}
/* -------------------------------------------- */
/**
* Create the tree from a Clipper polygon tree.
* @param {ClipperLib.PolyTree} clipperPolyTree
* @internal
*/
static _fromClipperPolyTree(clipperPolyTree) {
const visit = (clipperPolyNode, parent) => {
const clipperPath = clipperPolyNode.Contour();
const node = RegionPolygonTreeNode._fromClipperPath(clipperPath, parent);
clipperPolyNode.Childs().forEach(child => visit(child, node));
return node;
};
const tree = new RegionPolygonTree();
clipperPolyTree.Childs().forEach(child => visit(child, tree));
return tree;
}
}

View File

@@ -0,0 +1,371 @@
import {CircleShapeData, EllipseShapeData, PolygonShapeData, RectangleShapeData} from "../../data/_module.mjs";
/**
* A shape of a {@link Region}.
* @template {data.BaseShapeData} T
* @abstract
*/
export default class RegionShape {
/**
* Create a RegionShape.
* @param {T} data The shape data.
* @internal
*/
constructor(data) {
this.#data = data;
}
/* -------------------------------------------- */
/**
* Create the RegionShape from the shape data.
* @template {data.BaseShapeData} T
* @param {T} data The shape data.
* @returns {RegionShape<T>}
*/
static create(data) {
switch ( data.type ) {
case "circle": return new RegionCircle(data);
case "ellipse": return new RegionEllipse(data);
case "polygon": return new RegionPolygon(data);
case "rectangle": return new RegionRectangle(data);
default: throw new Error("Invalid shape type");
}
}
/* -------------------------------------------- */
/**
* The data of this shape.
* It is owned by the shape and must not be modified.
* @type {T}
*/
get data() {
return this.#data;
}
#data;
/* -------------------------------------------- */
/**
* Is this a hole?
* @type {boolean}
*/
get isHole() {
return this.data.hole;
}
/* -------------------------------------------- */
/**
* The Clipper paths of this shape.
* The winding numbers are 1 or 0.
* @type {ReadonlyArray<ReadonlyArray<ClipperLib.IntPoint>>}
*/
get clipperPaths() {
return this.#clipperPaths ??= ClipperLib.Clipper.PolyTreeToPaths(this.clipperPolyTree);
}
#clipperPaths;
/* -------------------------------------------- */
/**
* The Clipper polygon tree of this shape.
* @type {ClipperLib.PolyTree}
*/
get clipperPolyTree() {
let clipperPolyTree = this.#clipperPolyTree;
if ( !clipperPolyTree ) {
clipperPolyTree = this._createClipperPolyTree();
if ( Array.isArray(clipperPolyTree) ) {
const clipperPolyNode = new ClipperLib.PolyNode();
clipperPolyNode.m_polygon = clipperPolyTree;
clipperPolyTree = new ClipperLib.PolyTree();
clipperPolyTree.AddChild(clipperPolyNode);
clipperPolyTree.m_AllPolys.push(clipperPolyNode);
}
this.#clipperPolyTree = clipperPolyTree;
}
return clipperPolyTree;
}
#clipperPolyTree;
/* -------------------------------------------- */
/**
* Create the Clipper polygon tree of this shape.
* This function may return a single positively-orientated and non-selfintersecting Clipper path instead of a tree,
* which is automatically converted to a Clipper polygon tree.
* This function is called only once. It is not called if the shape is empty.
* @returns {ClipperLib.PolyTree|ClipperLib.IntPoint[]}
* @protected
* @abstract
*/
_createClipperPolyTree() {
throw new Error("A subclass of the RegionShape must implement the _createClipperPolyTree method.");
}
/* -------------------------------------------- */
/**
* Draw shape into the graphics.
* @param {PIXI.Graphics} graphics The graphics to draw the shape into.
* @protected
* @internal
*/
_drawShape(graphics) {
throw new Error("A subclass of the RegionShape must implement the _drawShape method.");
}
}
/* -------------------------------------------- */
/**
* A circle of a {@link Region}.
* @extends {RegionShape<data.CircleShapeData>}
*
* @param {data.CircleShapeData} data The shape data.
*/
class RegionCircle extends RegionShape {
constructor(data) {
if ( !(data instanceof CircleShapeData) ) throw new Error("Invalid shape data");
super(data);
}
/* -------------------------------------------- */
/**
* The vertex density epsilon used to create a polygon approximation of the circle.
* @type {number}
*/
static #VERTEX_DENSITY_EPSILON = 1;
/* -------------------------------------------- */
/** @override */
_createClipperPolyTree() {
const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
const data = this.data;
const x = data.x * scalingFactor;
const y = data.y * scalingFactor;
const radius = data.radius * scalingFactor;
const epsilon = RegionCircle.#VERTEX_DENSITY_EPSILON * scalingFactor;
const density = PIXI.Circle.approximateVertexDensity(radius, epsilon);
const path = new Array(density);
for ( let i = 0; i < density; i++ ) {
const angle = 2 * Math.PI * (i / density);
path[i] = new ClipperLib.IntPoint(
Math.round(x + (Math.cos(angle) * radius)),
Math.round(y + (Math.sin(angle) * radius))
);
}
return path;
}
/* -------------------------------------------- */
/** @override */
_drawShape(graphics) {
const {x, y, radius} = this.data;
graphics.drawCircle(x, y, radius);
}
}
/* -------------------------------------------- */
/**
* An ellipse of a {@link Region}.
* @extends {RegionShape<data.EllipseShapeData>}
*
* @param {data.EllipseShapeData} data The shape data.
*/
class RegionEllipse extends RegionShape {
constructor(data) {
if ( !(data instanceof EllipseShapeData) ) throw new Error("Invalid shape data");
super(data);
}
/* -------------------------------------------- */
/**
* The vertex density epsilon used to create a polygon approximation of the circle.
* @type {number}
*/
static #VERTEX_DENSITY_EPSILON = 1;
/* -------------------------------------------- */
/** @override */
_createClipperPolyTree() {
const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
const data = this.data;
const x = data.x * scalingFactor;
const y = data.y * scalingFactor;
const radiusX = data.radiusX * scalingFactor;
const radiusY = data.radiusY * scalingFactor;
const epsilon = RegionEllipse.#VERTEX_DENSITY_EPSILON * scalingFactor;
const density = PIXI.Circle.approximateVertexDensity((radiusX + radiusY) / 2, epsilon);
const rotation = Math.toRadians(data.rotation);
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
const path = new Array(density);
for ( let i = 0; i < density; i++ ) {
const angle = 2 * Math.PI * (i / density);
const dx = Math.cos(angle) * radiusX;
const dy = Math.sin(angle) * radiusY;
path[i] = new ClipperLib.IntPoint(
Math.round(x + ((cos * dx) - (sin * dy))),
Math.round(y + ((sin * dx) + (cos * dy)))
);
}
return path;
}
/* -------------------------------------------- */
/** @override */
_drawShape(graphics) {
const {x, y, radiusX, radiusY, rotation} = this.data;
if ( rotation === 0 ) {
graphics.drawEllipse(x, y, radiusX, radiusY);
} else {
graphics.setMatrix(new PIXI.Matrix()
.translate(-x, -x)
.rotate(Math.toRadians(rotation))
.translate(x, y));
graphics.drawEllipse(x, y, radiusX, radiusY);
graphics.setMatrix(null);
}
}
}
/* -------------------------------------------- */
/**
* A polygon of a {@link Region}.
* @extends {RegionShape<data.PolygonShapeData>}
*
* @param {data.PolygonShapeData} data The shape data.
*/
class RegionPolygon extends RegionShape {
constructor(data) {
if ( !(data instanceof PolygonShapeData) ) throw new Error("Invalid shape data");
super(data);
}
/* -------------------------------------------- */
/** @override */
_createClipperPolyTree() {
const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
const points = this.data.points;
const path = new Array(points.length / 2);
for ( let i = 0, j = 0; i < path.length; i++ ) {
path[i] = new ClipperLib.IntPoint(
Math.round(points[j++] * scalingFactor),
Math.round(points[j++] * scalingFactor)
);
}
if ( !ClipperLib.Clipper.Orientation(path) ) path.reverse();
return path;
}
/* -------------------------------------------- */
/** @override */
_drawShape(graphics) {
graphics.drawPolygon(this.data.points);
}
}
/* -------------------------------------------- */
/**
* A rectangle of a {@link Region}.
* @extends {RegionShape<data.RectangleShapeData>}
*
* @param {data.RectangleShapeData} data The shape data.
*/
class RegionRectangle extends RegionShape {
constructor(data) {
if ( !(data instanceof RectangleShapeData) ) throw new Error("Invalid shape data");
super(data);
}
/* -------------------------------------------- */
/** @override */
_createClipperPolyTree() {
let p0;
let p1;
let p2;
let p3;
const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
const {x, y, width, height, rotation} = this.data;
let x0 = x * scalingFactor;
let y0 = y * scalingFactor;
let x1 = (x + width) * scalingFactor;
let y1 = (y + height) * scalingFactor;
// The basic non-rotated case
if ( rotation === 0 ) {
x0 = Math.round(x0);
y0 = Math.round(y0);
x1 = Math.round(x1);
y1 = Math.round(y1);
p0 = new ClipperLib.IntPoint(x0, y0);
p1 = new ClipperLib.IntPoint(x1, y0);
p2 = new ClipperLib.IntPoint(x1, y1);
p3 = new ClipperLib.IntPoint(x0, y1);
}
// The more complex rotated case
else {
const tx = (x0 + x1) / 2;
const ty = (y0 + y1) / 2;
x0 -= tx;
y0 -= ty;
x1 -= tx;
y1 -= ty;
const angle = Math.toRadians(rotation);
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const x00 = Math.round((cos * x0) - (sin * y0) + tx);
const y00 = Math.round((sin * x0) + (cos * y0) + ty);
const x10 = Math.round((cos * x1) - (sin * y0) + tx);
const y10 = Math.round((sin * x1) + (cos * y0) + ty);
const x11 = Math.round((cos * x1) - (sin * y1) + tx);
const y11 = Math.round((sin * x1) + (cos * y1) + ty);
const x01 = Math.round((cos * x0) - (sin * y1) + tx);
const y01 = Math.round((sin * x0) + (cos * y1) + ty);
p0 = new ClipperLib.IntPoint(x00, y00);
p1 = new ClipperLib.IntPoint(x10, y10);
p2 = new ClipperLib.IntPoint(x11, y11);
p3 = new ClipperLib.IntPoint(x01, y01);
}
return [p0, p1, p2, p3];
}
/* -------------------------------------------- */
/** @override */
_drawShape(graphics) {
const {x, y, width, height, rotation} = this.data;
if ( rotation === 0 ) {
graphics.drawRect(x, y, width, height);
} else {
const centerX = x + (width / 2);
const centerY = y + (height / 2);
graphics.setMatrix(new PIXI.Matrix()
.translate(-centerX, -centerY)
.rotate(Math.toRadians(rotation))
.translate(centerX, centerY));
graphics.drawRect(x, y, width, height);
graphics.setMatrix(null);
}
}
}

View File

@@ -0,0 +1,128 @@
/**
* A framework for imbuing special scripted behaviors into a single specific Scene.
* Managed scenes are registered in CONFIG.Canvas.managedScenes.
*
* The SceneManager instance is called at various points in the Scene rendering life-cycle.
*
* This also provides a framework for registering additional hook events which are required only for the life-cycle of
* the managed Scene.
*
* @example Registering a custom SceneManager
* ```js
* // Define a custom SceneManager subclass
* class MyCustomSceneManager extends SceneManager {
* async _onInit() {
* console.log(`Initializing managed Scene "${this.scene.name}"`);
* }
*
* async _onDraw() {
* console.log(`Drawing managed Scene "${this.scene.name}"`);
* }
*
* async _onReady() {
* console.log(`Readying managed Scene "${this.scene.name}"`);
* }
*
* async _onTearDown() {
* console.log(`Deconstructing managed Scene "${this.scene.name}"`);
* }
*
* _registerHooks() {
* this.registerHook("updateToken", this.#onUpdateToken.bind(this));
* }
*
* #onUpdateToken(document, updateData, options, userId) {
* console.log("Updating a token within the managed Scene");
* }
* }
*
* // Register MyCustomSceneManager to be used for a specific Scene
* CONFIG.Canvas.sceneManagers = {
* [sceneId]: MyCustomSceneManager
* }
* ```
*/
export default class SceneManager {
/**
* The SceneManager is constructed by passing a reference to the active Scene document.
* @param {Scene} scene
*/
constructor(scene) {
this.#scene = scene;
}
/**
* The managed Scene.
* @type {Scene}
*/
get scene() {
return this.#scene;
}
#scene;
/* -------------------------------------------- */
/* Scene Life-Cycle Methods */
/* -------------------------------------------- */
/**
* Additional behaviors to perform when the Canvas is first initialized for the Scene.
* @returns {Promise<void>}
* @internal
*/
async _onInit() {}
/**
* Additional behaviors to perform after core groups and layers are drawn to the canvas.
* @returns {Promise<void>}
* @internal
*/
async _onDraw() {}
/**
* Additional behaviors to perform after the Canvas is fully initialized for the Scene.
* @returns {Promise<void>}
* @internal
*/
async _onReady() {}
/**
* Additional behaviors to perform when the Scene is deactivated.
* @returns {Promise<void>}
* @internal
*/
async _onTearDown() {}
/* -------------------------------------------- */
/* Scene Hooks */
/* -------------------------------------------- */
/**
* Registered hook functions used within this specific Scene that are automatically deactivated.
* @type {Record<string, number>}
*/
#hooks = {};
/**
* Register additional hook functions are only used while this Scene is active and is automatically deactivated.
* Hooks should be registered in this function by calling this._registerHook(hookName, handler)
* @internal
*/
_registerHooks() {}
/**
* Register additional hook functions are only used while this Scene is active and is automatically deactivated.
* @param {string} hookName
* @param {Function} handler
*/
registerHook(hookName, handler) {
this.#hooks[hookName] = Hooks.on(hookName, handler);
}
/**
* Deactivate Hook functions that were added specifically for this Scene.
* @internal
*/
_deactivateHooks() {
for ( const [hookName, hookId] of Object.entries(this.#hooks) ) Hooks.off(hookName, hookId);
}
}

View File

@@ -0,0 +1,25 @@
Copyright (C) 2013 Jorge Jimenez (jorge@iryoku.com)
Copyright (C) 2013 Jose I. Echevarria (joseignacioechevarria@gmail.com)
Copyright (C) 2013 Belen Masia (bmasia@unizar.es)
Copyright (C) 2013 Fernando Navarro (fernandn@microsoft.com)
Copyright (C) 2013 Diego Gutierrez (diegog@unizar.es)
Copyright (C) 2018 Damien Seguin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,109 @@
/**
* The neighborhood blending filter for {@link foundry.canvas.SMAAFilter}.
*/
export default class SMAANeighborhoodBlendingFilter extends PIXI.Filter {
constructor() {
super(VERTEX_SOURCE, FRAGMENT_SOURCE);
}
}
/* -------------------------------------------- */
/**
* The vertex shader source of {@link SMAANeighborhoodBlendingFilter}.
* @type {string}
*/
const VERTEX_SOURCE = `\
#define mad(a, b, c) (a * b + c)
attribute vec2 aVertexPosition;
uniform mat3 projectionMatrix;
uniform vec4 inputSize;
uniform vec4 inputPixel;
uniform vec4 outputFrame;
#define resolution (inputPixel.xy)
#define SMAA_RT_METRICS (inputPixel.zwxy)
varying vec2 vTexCoord0;
varying vec4 vOffset;
void main() {
vTexCoord0 = aVertexPosition * (outputFrame.zw * inputSize.zw);
vOffset = mad(SMAA_RT_METRICS.xyxy, vec4(1.0, 0.0, 0.0, 1.0), vTexCoord0.xyxy);
vec3 position = vec3(aVertexPosition * max(outputFrame.zw, vec2(0.0)) + outputFrame.xy, 1.0);
gl_Position = vec4((projectionMatrix * position).xy, 0.0, 1.0);
}
`;
/* -------------------------------------------- */
/**
* The fragment shader source of {@link SMAANeighborhoodBlendingFilter}.
* @type {string}
*/
const FRAGMENT_SOURCE = `\
precision highp float;
#define mad(a, b, c) (a * b + c)
uniform sampler2D blendTex;
uniform sampler2D uSampler; // colorTex
uniform vec4 inputPixel;
#define colorTex uSampler
#define resolution (inputPixel.xy)
#define SMAA_RT_METRICS (inputPixel.zwxy)
varying vec2 vTexCoord0;
varying vec4 vOffset;
/**
* Conditional move:
*/
void SMAAMovc(bvec2 cond, inout vec2 variable, vec2 value) {
if (cond.x) variable.x = value.x;
if (cond.y) variable.y = value.y;
}
void SMAAMovc(bvec4 cond, inout vec4 variable, vec4 value) {
SMAAMovc(cond.xy, variable.xy, value.xy);
SMAAMovc(cond.zw, variable.zw, value.zw);
}
void main() {
vec4 color;
// Fetch the blending weights for current pixel:
vec4 a;
a.x = texture2D(blendTex, vOffset.xy).a; // Right
a.y = texture2D(blendTex, vOffset.zw).g; // Top
a.wz = texture2D(blendTex, vTexCoord0).xz; // Bottom / Left
// Is there any blending weight with a value greater than 0.0?
if (dot(a, vec4(1.0, 1.0, 1.0, 1.0)) <= 1e-5) {
color = texture2D(colorTex, vTexCoord0); // LinearSampler
} else {
bool h = max(a.x, a.z) > max(a.y, a.w); // max(horizontal) > max(vertical)
// Calculate the blending offsets:
vec4 blendingOffset = vec4(0.0, a.y, 0.0, a.w);
vec2 blendingWeight = a.yw;
SMAAMovc(bvec4(h, h, h, h), blendingOffset, vec4(a.x, 0.0, a.z, 0.0));
SMAAMovc(bvec2(h, h), blendingWeight, a.xz);
blendingWeight /= dot(blendingWeight, vec2(1.0, 1.0));
// Calculate the texture coordinates:
vec4 blendingCoord = mad(blendingOffset, vec4(SMAA_RT_METRICS.xy, -SMAA_RT_METRICS.xy), vTexCoord0.xyxy);
// We exploit bilinear filtering to mix current pixel with the chosen
// neighbor:
color = blendingWeight.x * texture2D(colorTex, blendingCoord.xy); // LinearSampler
color += blendingWeight.y * texture2D(colorTex, blendingCoord.zw); // LinearSampler
}
gl_FragColor = color;
}
`;

View File

@@ -0,0 +1,129 @@
/**
* The edge detection filter for {@link foundry.canvas.SMAAFilter}.
*/
export default class SMAAEdgeDetectionFilter extends PIXI.Filter {
/**
* @param {SMAAFilterConfig} config
*/
constructor(config) {
super(VERTEX_SOURCE, generateFragmentSource(config));
}
}
/* -------------------------------------------- */
/**
* The vertex shader source of {@link SMAAEdgeDetectionFilter}.
* @type {string}
*/
const VERTEX_SOURCE = `\
#define mad(a, b, c) (a * b + c)
attribute vec2 aVertexPosition;
uniform mat3 projectionMatrix;
uniform vec4 inputSize;
uniform vec4 inputPixel;
uniform vec4 outputFrame;
#define resolution (inputPixel.xy)
#define SMAA_RT_METRICS (inputPixel.zwxy)
varying vec2 vTexCoord0;
varying vec4 vOffset[3];
void main() {
vTexCoord0 = aVertexPosition * (outputFrame.zw * inputSize.zw);
vOffset[0] = mad(SMAA_RT_METRICS.xyxy, vec4(-1.0, 0.0, 0.0, -1.0), vTexCoord0.xyxy);
vOffset[1] = mad(SMAA_RT_METRICS.xyxy, vec4( 1.0, 0.0, 0.0, 1.0), vTexCoord0.xyxy);
vOffset[2] = mad(SMAA_RT_METRICS.xyxy, vec4(-2.0, 0.0, 0.0, -2.0), vTexCoord0.xyxy);
vec3 position = vec3(aVertexPosition * max(outputFrame.zw, vec2(0.0)) + outputFrame.xy, 1.0);
gl_Position = vec4((projectionMatrix * position).xy, 0.0, 1.0);
}
`;
/* -------------------------------------------- */
/**
* The fragment shader source of {@link SMAAEdgeDetectionFilter}.
* @param {SMAAFilterConfig} config
* @returns {string}
*/
function generateFragmentSource(config) {
return `\
precision highp float;
/**
* Color Edge Detection
*
* IMPORTANT NOTICE: color edge detection requires gamma-corrected colors, and
* thus 'colorTex' should be a non-sRGB texture.
*/
#define SMAA_THRESHOLD ${config.threshold.toFixed(8)}
#define SMAA_LOCAL_CONTRAST_ADAPTATION_FACTOR ${config.localContrastAdaptionFactor.toFixed(8)}
uniform sampler2D uSampler; // colorTex
#define colorTex uSampler
varying vec2 vTexCoord0;
varying vec4 vOffset[3];
void main() {
// Calculate the threshold:
vec2 threshold = vec2(SMAA_THRESHOLD);
// Calculate color deltas:
vec4 delta;
vec3 c = texture2D(colorTex, vTexCoord0).rgb;
vec3 cLeft = texture2D(colorTex, vOffset[0].xy).rgb;
vec3 t = abs(c - cLeft);
delta.x = max(max(t.r, t.g), t.b);
vec3 cTop = texture2D(colorTex, vOffset[0].zw).rgb;
t = abs(c - cTop);
delta.y = max(max(t.r, t.g), t.b);
// We do the usual threshold:
vec2 edges = step(threshold, delta.xy);
// Then discard if there is no edge:
if (dot(edges, vec2(1.0, 1.0)) == 0.0)
discard;
// Calculate right and bottom deltas:
vec3 cRight = texture2D(colorTex, vOffset[1].xy).rgb;
t = abs(c - cRight);
delta.z = max(max(t.r, t.g), t.b);
vec3 cBottom = texture2D(colorTex, vOffset[1].zw).rgb;
t = abs(c - cBottom);
delta.w = max(max(t.r, t.g), t.b);
// Calculate the maximum delta in the direct neighborhood:
vec2 maxDelta = max(delta.xy, delta.zw);
// Calculate left-left and top-top deltas:
vec3 cLeftLeft = texture2D(colorTex, vOffset[2].xy).rgb;
t = abs(c - cLeftLeft);
delta.z = max(max(t.r, t.g), t.b);
vec3 cTopTop = texture2D(colorTex, vOffset[2].zw).rgb;
t = abs(c - cTopTop);
delta.w = max(max(t.r, t.g), t.b);
// Calculate the final maximum delta:
maxDelta = max(maxDelta.xy, delta.zw);
float finalDelta = max(maxDelta.x, maxDelta.y);
// Local contrast adaptation:
edges.xy *= step(finalDelta, SMAA_LOCAL_CONTRAST_ADAPTATION_FACTOR * delta.xy);
gl_FragColor = vec4(edges, 0.0, 1.0);
}
`;
}

View File

@@ -0,0 +1,116 @@
import {default as SMAAEdgeDetectionFilter} from "./edges.mjs";
import {default as SMAABlendingWeightCalculationFilter} from "./weights.mjs";
import {default as SMAANeighborhoodBlendingFilter} from "./blend.mjs";
/**
* @typedef {object} SMAAFilterConfig
* @property {number} threshold Specifies the threshold or sensitivity to edges. Lowering this value you will be able to detect more edges at the expense of performance. Range: [0, 0.5]. 0.1 is a reasonable value, and allows to catch most visible edges. 0.05 is a rather overkill value, that allows to catch 'em all.
* @property {number} localContrastAdaptionFactor If there is an neighbor edge that has SMAA_LOCAL_CONTRAST_FACTOR times bigger contrast than current edge, current edge will be discarded.
* This allows to eliminate spurious crossing edges, and is based on the fact that, if there is too much contrast in a direction, that will hide perceptually contrast in the other neighbors.
* @property {number} maxSearchSteps Specifies the maximum steps performed in the horizontal/vertical pattern searches, at each side of the pixel. In number of pixels, it's actually the double. So the maximum line length perfectly handled by, for example 16, is 64 (by perfectly, we meant that longer lines won't look as good, but still antialiased. Range: [0, 112].
* @property {number} maxSearchStepsDiag Specifies the maximum steps performed in the diagonal pattern searches, at each side of the pixel. In this case we jump one pixel at time, instead of two. Range: [0, 20].
* @property {number} cornerRounding Specifies how much sharp corners will be rounded. Range: [0, 100].
* @property {boolean} disableDiagDetection Is diagonal detection disabled?
* @property {boolean} disableCornerDetection Is corner detection disabled?
*/
export default class SMAAFilter extends PIXI.Filter {
/**
* @param {Partial<SMAAFilterConfig>} [config] The config (defaults: {@link SMAAFilter.PRESETS.DEFAULT})
*/
constructor({threshold=0.1, localContrastAdaptionFactor=2.0, maxSearchSteps=16, maxSearchStepsDiag=8, cornerRounding=25, disableDiagDetection=false, disableCornerDetection=false}={}) {
super();
const config = {threshold, localContrastAdaptionFactor, maxSearchSteps, maxSearchStepsDiag, cornerRounding, disableDiagDetection, disableCornerDetection};
this.#edgesFilter = new SMAAEdgeDetectionFilter(config);
this.#weightsFilter = new SMAABlendingWeightCalculationFilter(config);
this.#blendFilter = new SMAANeighborhoodBlendingFilter();
}
/* -------------------------------------------- */
/**
* The presets.
* @enum {SMAAFilterConfig}
*/
static get PRESETS() {
return SMAAFilter.#PRESETS;
}
static #PRESETS = {
LOW: {
threshold: 0.15,
localContrastAdaptionFactor: 2.0,
maxSearchSteps: 4,
maxSearchStepsDiag: 0,
cornerRounding: 0,
disableDiagDetection: true,
disableCornerDetection: true
},
MEDIUM: {
threshold: 0.1,
localContrastAdaptionFactor: 2.0,
maxSearchSteps: 8,
maxSearchStepsDiag: 0,
cornerRounding: 0,
disableDiagDetection: true,
disableCornerDetection: true
},
HIGH: {
threshold: 0.1,
localContrastAdaptionFactor: 2.0,
maxSearchSteps: 16,
maxSearchStepsDiag: 8,
cornerRounding: 25,
disableDiagDetection: false,
disableCornerDetection: false
},
ULTRA: {
threshold: 0.05,
localContrastAdaptionFactor: 2.0,
maxSearchSteps: 32,
maxSearchStepsDiag: 16,
cornerRounding: 25,
disableDiagDetection: false,
disableCornerDetection: false
}
};
/* -------------------------------------------- */
/**
* The edge detection filter.
* @type {SMAAEdgeDetectionFilter}
*/
#edgesFilter;
/* -------------------------------------------- */
/**
* The blending weight calculation filter.
* @type {SMAABlendingWeightCalculationFilter}
*/
#weightsFilter;
/* -------------------------------------------- */
/**
* The neighborhood blending filter.
* @type {SMAANeighborhoodBlendingFilter}
*/
#blendFilter;
/* -------------------------------------------- */
/** @override */
apply(filterManager, input, output, clearMode, currentState) {
const edgesTex = filterManager.getFilterTexture();
const blendTex = filterManager.getFilterTexture();
this.#edgesFilter.apply(filterManager, input, edgesTex, PIXI.CLEAR_MODES.CLEAR, currentState);
this.#weightsFilter.apply(filterManager, edgesTex, blendTex, PIXI.CLEAR_MODES.CLEAR, currentState);
this.#blendFilter.uniforms.blendTex = blendTex;
this.#blendFilter.apply(filterManager, input, output, clearMode, currentState);
filterManager.returnFilterTexture(edgesTex);
filterManager.returnFilterTexture(blendTex);
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
export {default as BaseEffectSource} from "./base-effect-source.mjs";
export {default as BaseLightSource} from "./base-light-source.mjs";
export {default as GlobalLightSource} from "./global-light-source.mjs";
export {default as PointDarknessSource} from "./point-darkness-source.mjs";
export {default as PointEffectSourceMixin} from "./point-effect-source.mjs";
export {default as PointLightSource} from "./point-light-source.mjs";
export {default as PointMovementSource} from "./point-movement-source.mjs";
export {default as PointSoundSource} from "./point-sound-source.mjs";
export {default as PointVisionSource} from "./point-vision-source.mjs";
export {default as RenderedEffectSource} from "./rendered-effect-source.mjs";

View File

@@ -0,0 +1,370 @@
/**
* @typedef {Object} BasseEffectSourceOptions
* @property {PlaceableObject} [options.object] An optional PlaceableObject which is responsible for this source
* @property {string} [options.sourceId] A unique ID for this source. This will be set automatically if an
* object is provided, otherwise is required.
*/
/**
* @typedef {Object} BaseEffectSourceData
* @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 {boolean} disabled Whether or not the source is disabled
*/
/**
* TODO - Re-document after ESM refactor.
* 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
* ```
*
* @template {BaseEffectSourceData} SourceData
* @template {PIXI.Polygon} SourceShape
* @abstract
*/
export default class BaseEffectSource {
/**
* An effect source is constructed by providing configuration options.
* @param {BasseEffectSourceOptions} [options] Options which modify the base effect source instance
*/
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;
this.sourceId = this.object.sourceId;
}
else {
this.object = options.object ?? null;
this.sourceId = options.sourceId;
}
}
/**
* The type of source represented by this data structure.
* Each subclass must implement this attribute.
* @type {string}
*/
static sourceType;
/**
* The target collection into the effects canvas group.
* @type {string}
* @abstract
*/
static effectsCollection;
/**
* Effect source default data.
* @type {SourceData}
*/
static defaultData = {
x: 0,
y: 0,
elevation: 0,
disabled: false
}
/* -------------------------------------------- */
/* Source Data */
/* -------------------------------------------- */
/**
* Some other object which is responsible for this source.
* @type {object|null}
*/
object;
/**
* The source id linked to this effect source.
* @type {Readonly<string>}
*/
sourceId;
/**
* The data of this source.
* @type {SourceData}
*/
data = foundry.utils.deepClone(this.constructor.defaultData);
/**
* The geometric shape of the effect source which is generated later.
* @type {SourceShape}
*/
shape;
/**
* A collection of boolean flags which control rendering and refresh behavior for the source.
* @type {Record<string, boolean|number>}
* @protected
*/
_flags = {};
/**
* 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;
}
/* -------------------------------------------- */
/* Source State */
/* -------------------------------------------- */
/**
* The EffectsCanvasGroup collection linked to this effect source.
* @type {Collection<string, BaseEffectSource>}
*/
get effectsCollection() {
return canvas.effects[this.constructor.effectsCollection];
}
/**
* Returns the update ID associated with this source.
* The update ID is increased whenever the shape of the source changes.
* @type {number}
*/
get updateId() {
return this.#updateId;
}
#updateId = 0;
/**
* Is this source currently active?
* A source is active if it is attached to an effect collection and is not disabled or suppressed.
* @type {boolean}
*/
get active() {
return this.#attached && !this.data.disabled && !this.suppressed;
}
/**
* Is this source attached to an effect collection?
* @type {boolean}
*/
get attached() {
return this.#attached;
}
#attached = false;
/* -------------------------------------------- */
/* Source Suppression Management */
/* -------------------------------------------- */
/**
* Is this source temporarily suppressed?
* @type {boolean}
*/
get suppressed() {
return Object.values(this.suppression).includes(true);
};
/**
* Records of suppression strings with a boolean value.
* If any of this record is true, the source is suppressed.
* @type {Record<string, boolean>}
*/
suppression = {};
/* -------------------------------------------- */
/* Source Initialization */
/* -------------------------------------------- */
/**
* Initialize and configure the source using provided data.
* @param {Partial<SourceData>} data Provided data for configuration
* @param {object} options Additional options which modify source initialization
* @param {object} [options.behaviors] An object containing optional behaviors to apply.
* @param {boolean} [options.reset=false] Should source data be reset to default values before applying changes?
* @returns {BaseEffectSource} The initialized source
*/
initialize(data={}, {reset=false}={}) {
// Reset the source back to default data
if ( reset ) data = Object.assign(foundry.utils.deepClone(this.constructor.defaultData), data);
// Update data for the source
let changes = {};
if ( !foundry.utils.isEmpty(data) ) {
const prior = foundry.utils.deepClone(this.data) || {};
for ( const key in data ) {
if ( !(key in this.data) ) continue;
this.data[key] = data[key] ?? this.constructor.defaultData[key];
}
this._initialize(data);
changes = foundry.utils.flattenObject(foundry.utils.diffObject(prior, this.data));
}
// Update shapes for the source
try {
this._createShapes();
this.#updateId++;
}
catch (err) {
console.error(err);
this.remove();
}
// Configure attached and non disabled sources
if ( this.#attached && !this.data.disabled ) this._configure(changes);
return this;
}
/* -------------------------------------------- */
/**
* Subclass specific data initialization steps.
* @param {Partial<SourceData>} data Provided data for configuration
* @abstract
*/
_initialize(data) {}
/* -------------------------------------------- */
/**
* Create the polygon shape (or shapes) for this source using configured data.
* @protected
* @abstract
*/
_createShapes() {}
/* -------------------------------------------- */
/**
* Subclass specific configuration steps. Occurs after data initialization and shape computation.
* Only called if the source is attached and not disabled.
* @param {Partial<SourceData>} changes Changes to the source data which were applied
* @protected
*/
_configure(changes) {}
/* -------------------------------------------- */
/* Source Refresh */
/* -------------------------------------------- */
/**
* Refresh the state and uniforms of the source.
* Only active sources are refreshed.
*/
refresh() {
if ( !this.active ) return;
this._refresh();
}
/* -------------------------------------------- */
/**
* Subclass-specific refresh steps.
* @protected
* @abstract
*/
_refresh() {}
/* -------------------------------------------- */
/* Source Destruction */
/* -------------------------------------------- */
/**
* Steps that must be performed when the source is destroyed.
*/
destroy() {
this.remove();
this._destroy();
}
/* -------------------------------------------- */
/**
* Subclass specific destruction steps.
* @protected
*/
_destroy() {}
/* -------------------------------------------- */
/* Source Management */
/* -------------------------------------------- */
/**
* Add this BaseEffectSource instance to the active collection.
*/
add() {
if ( !this.sourceId ) throw new Error("A BaseEffectSource cannot be added to the active collection unless it has"
+ " a sourceId assigned.");
this.effectsCollection.set(this.sourceId, this);
const wasConfigured = this.#attached && !this.data.disabled;
this.#attached = true;
if ( !wasConfigured && !this.data.disabled ) this._configure({});
}
/* -------------------------------------------- */
/**
* Remove this BaseEffectSource instance from the active collection.
*/
remove() {
if ( !this.effectsCollection.has(this.sourceId) ) return;
this.effectsCollection.delete(this.sourceId);
this.#attached = false;
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get sourceType() {
const msg = "BaseEffectSource#sourceType is deprecated. Use BaseEffectSource.sourceType instead.";
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
return this.constructor.sourceType;
}
/**
* @deprecated since v12
* @ignore
*/
_createShape() {
const msg = "BaseEffectSource#_createShape is deprecated in favor of BaseEffectSource#_createShapes.";
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
return this._createShapes();
}
/**
* @deprecated since v12
* @ignore
*/
get disabled() {
foundry.utils.logCompatibilityWarning("BaseEffectSource#disabled is deprecated in favor of " +
"BaseEffectSource#data#disabled or BaseEffectSource#active depending on your use case.", { since: 11, until: 13});
return this.data.disabled;
}
}

View File

@@ -0,0 +1,306 @@
import RenderedEffectSource from "./rendered-effect-source.mjs";
import {LIGHTING_LEVELS} from "../../../common/constants.mjs";
/**
* @typedef {import("./base-effect-source.mjs").BaseEffectSourceData} BaseEffectSourceData
* @typedef {import("./rendered-effect-source-source.mjs").RenderedEffectSourceData} RenderedEffectSourceData
*/
/**
* @typedef {Object} LightSourceData
* @property {number} alpha An opacity for the emitted light, if any
* @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
* @property {number} priority Strength of this source to beat or not negative/positive sources
*/
/**
* A specialized subclass of BaseEffectSource which deals with the rendering of light or darkness.
* @extends {RenderedEffectSource<BaseEffectSourceData & RenderedEffectSourceData & LightSourceData>}
* @abstract
*/
export default class BaseLightSource extends RenderedEffectSource {
/** @override */
static sourceType = "light";
/** @override */
static _initializeShaderKeys = ["animation.type", "walls"];
/** @override */
static _refreshUniformsKeys = ["dim", "bright", "attenuation", "alpha", "coloration", "color", "contrast",
"saturation", "shadows", "luminosity"];
/**
* The corresponding lighting levels for dim light.
* @type {number}
* @protected
*/
static _dimLightingLevel = LIGHTING_LEVELS.DIM;
/**
* The corresponding lighting levels for bright light.
* @type {string}
* @protected
*/
static _brightLightingLevel = LIGHTING_LEVELS.BRIGHT;
/**
* The corresponding animation config.
* @type {LightSourceAnimationConfig}
* @protected
*/
static get ANIMATIONS() {
return CONFIG.Canvas.lightAnimations;
}
/** @inheritDoc */
static defaultData = {
...super.defaultData,
priority: 0,
alpha: 0.5,
bright: 0,
coloration: 1,
contrast: 0,
dim: 0,
attenuation: 0.5,
luminosity: 0.5,
saturation: 0,
shadows: 0,
vision: false
}
/* -------------------------------------------- */
/* Light Source Attributes */
/* -------------------------------------------- */
/**
* A ratio of dim:bright as part of the source radius
* @type {number}
*/
ratio = 1;
/* -------------------------------------------- */
/* Light Source Initialization */
/* -------------------------------------------- */
/** @override */
_initialize(data) {
super._initialize(data);
const animationConfig = foundry.utils.deepClone(this.constructor.ANIMATIONS[this.data.animation.type] || {});
this.animation = Object.assign(this.data.animation, animationConfig);
}
/* -------------------------------------------- */
/* Shader Management */
/* -------------------------------------------- */
/** @inheritDoc */
_updateColorationUniforms() {
super._updateColorationUniforms();
const u = this.layers.coloration.shader?.uniforms;
if ( !u ) return;
// 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;
}
/* -------------------------------------------- */
/** @inheritDoc */
_updateIlluminationUniforms() {
super._updateIlluminationUniforms();
const u = this.layers.illumination.shader?.uniforms;
if ( !u ) return;
u.useSampler = false;
// Flag uniforms as updated
const i = this.layers.illumination;
i.reset = i.suppressed = false;
}
/* -------------------------------------------- */
/** @inheritDoc */
_updateBackgroundUniforms() {
super._updateBackgroundUniforms();
const u = this.layers.background.shader?.uniforms;
if ( !u ) return;
canvas.colors.background.applyRGB(u.colorBackground);
u.backgroundAlpha = this.data.alpha;
u.useSampler = true;
// Flag uniforms as updated
this.layers.background.reset = false;
}
/* -------------------------------------------- */
/** @override */
_updateCommonUniforms(shader) {
const u = shader.uniforms;
const c = canvas.colors;
// Passing common environment values
u.computeIllumination = true;
u.darknessLevel = canvas.environment.darknessLevel;
c.ambientBrightest.applyRGB(u.ambientBrightest);
c.ambientDarkness.applyRGB(u.ambientDarkness);
c.ambientDaylight.applyRGB(u.ambientDaylight);
u.weights[0] = canvas.environment.weights.dark;
u.weights[1] = canvas.environment.weights.halfdark;
u.weights[2] = canvas.environment.weights.dim;
u.weights[3] = canvas.environment.weights.bright;
u.dimLevelCorrection = this.constructor.getCorrectedLevel(this.constructor._dimLightingLevel);
u.brightLevelCorrection = this.constructor.getCorrectedLevel(this.constructor._brightLightingLevel);
// Passing advanced color correction values
u.luminosity = this.data.luminosity;
u.exposure = this.data.luminosity * 2.0 - 1.0;
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.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.elevation = this.data.elevation;
u.color = this.colorRGB ?? shader.constructor.defaultUniforms.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;
if ( !u.darknessLevelTexture ) u.darknessLevelTexture = canvas.effects.illumination.renderTexture;
}
/* -------------------------------------------- */
/* 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 0 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 0 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 0 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);
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get isDarkness() {
const msg = "BaseLightSource#isDarkness is now obsolete. Use DarknessSource instead.";
foundry.utils.logCompatibilityWarning(msg, { since: 12, until: 14});
return false;
}
}

View File

@@ -0,0 +1,80 @@
import BaseLightSource from "./base-light-source.mjs";
/**
* A specialized subclass of the BaseLightSource which is used to render global light source linked to the scene.
*/
export default class GlobalLightSource extends BaseLightSource {
/** @inheritDoc */
static sourceType = "GlobalLight";
/** @override */
static effectsCollection = "lightSources";
/** @inheritDoc */
static defaultData = {
...super.defaultData,
rotation: 0,
angle: 360,
attenuation: 0,
priority: -Infinity,
vision: false,
walls: false,
elevation: Infinity,
darkness: {min: 0, max: 0}
}
/**
* Name of this global light source.
* @type {string}
* @defaultValue GlobalLightSource.sourceType
*/
name = this.constructor.sourceType;
/**
* A custom polygon placeholder.
* @type {PIXI.Polygon|number[]|null}
*/
customPolygon = null;
/* -------------------------------------------- */
/* Global Light Source Initialization */
/* -------------------------------------------- */
/** @override */
_createShapes() {
this.shape = this.customPolygon ?? canvas.dimensions.sceneRect.toPolygon();
}
/* -------------------------------------------- */
/** @override */
_initializeSoftEdges() {
this._flags.renderSoftEdges = false;
}
/* -------------------------------------------- */
/** @override */
_updateGeometry() {
const offset = this._flags.renderSoftEdges ? this.constructor.EDGE_OFFSET : 0;
const pm = new PolygonMesher(this.shape, {offset});
this._geometry = pm.triangulate(this._geometry);
const bounds = this.shape instanceof PointSourcePolygon ? this.shape.bounds : this.shape.getBounds();
if ( this._geometry.bounds ) this._geometry.bounds.copyFrom(bounds);
else this._geometry.bounds = bounds;
}
/* -------------------------------------------- */
/** @override */
_updateCommonUniforms(shader) {
super._updateCommonUniforms(shader);
const {min, max} = this.data.darkness;
const u = shader.uniforms;
u.globalLight = true;
u.globalLightThresholds[0] = min;
u.globalLightThresholds[1] = max;
}
}

View File

@@ -0,0 +1,253 @@
import BaseLightSource from "./base-light-source.mjs";
import PointEffectSourceMixin from "./point-effect-source.mjs";
import {LIGHTING_LEVELS} from "../../../common/constants.mjs";
/**
* A specialized subclass of the BaseLightSource which renders a source of darkness as a point-based effect.
* @extends {BaseLightSource}
* @mixes {PointEffectSource}
*/
export default class PointDarknessSource extends PointEffectSourceMixin(BaseLightSource) {
/** @override */
static effectsCollection = "darknessSources";
/** @override */
static _dimLightingLevel = LIGHTING_LEVELS.HALFDARK;
/** @override */
static _brightLightingLevel = LIGHTING_LEVELS.DARKNESS;
/** @override */
static get ANIMATIONS() {
return CONFIG.Canvas.darknessAnimations;
}
/** @override */
static get _layers() {
return {
darkness: {
defaultShader: AdaptiveDarknessShader,
blendMode: "MAX_COLOR"
}
};
}
/**
* The optional geometric shape is solely utilized for visual representation regarding darkness sources.
* Used only when an additional radius is added for visuals.
* @protected
* @type {SourceShape}
*/
_visualShape;
/**
* Padding applied on the darkness source shape for visual appearance only.
* Note: for now, padding is increased radius. It might evolve in a future release.
* @type {number}
* @protected
*/
_padding = (CONFIG.Canvas.darknessSourcePaddingMultiplier ?? 0) * canvas.grid.size;
/**
* The Edge instances added by this darkness source.
* @type {Edge[]}
*/
edges = [];
/**
* The normalized border distance.
* @type {number}
*/
#borderDistance = 0;
/* -------------------------------------------- */
/* Darkness Source Properties */
/* -------------------------------------------- */
/**
* A convenience accessor to the darkness layer mesh.
* @type {PointSourceMesh}
*/
get darkness() {
return this.layers.darkness.mesh;
}
/* -------------------------------------------- */
/* Source Initialization and Management */
/* -------------------------------------------- */
/** @inheritDoc */
_initialize(data) {
super._initialize(data);
this.data.radius = this.data.bright = this.data.dim = Math.max(this.data.dim ?? 0, this.data.bright ?? 0);
this.#borderDistance = this.radius / (this.radius + this._padding);
}
/* -------------------------------------------- */
/** @inheritDoc */
_createShapes() {
this.#deleteEdges();
const origin = {x: this.data.x, y: this.data.y};
const config = this._getPolygonConfiguration();
const polygonClass = CONFIG.Canvas.polygonBackends[this.constructor.sourceType];
// Create shapes based on padding
if ( this.radius < config.radius ) {
this._visualShape = polygonClass.create(origin, config);
this.shape = this.#createShapeFromVisualShape(this.radius);
}
else {
this._visualShape = null;
this.shape = polygonClass.create(origin, config);
}
}
/* -------------------------------------------- */
/** @inheritDoc */
_configure(changes) {
super._configure(changes);
this.#createEdges();
}
/* -------------------------------------------- */
/** @inheritDoc */
_getPolygonConfiguration() {
return Object.assign(super._getPolygonConfiguration(), {
useThreshold: true,
includeDarkness: false,
radius: (this.data.disabled || this.suppressed) ? 0 : this.radius + this._padding,
});
}
/* -------------------------------------------- */
_drawMesh(layerId) {
const mesh = super._drawMesh(layerId);
if ( mesh ) mesh.scale.set(this.radius + this._padding);
return mesh;
}
/* -------------------------------------------- */
/** @override */
_updateGeometry() {
const {x, y} = this.data;
const radius = this.radius + this._padding;
const offset = this._flags.renderSoftEdges ? this.constructor.EDGE_OFFSET : 0;
const shape = this._visualShape ?? this.shape;
const pm = new PolygonMesher(shape, {x, y, radius, normalize: true, offset});
this._geometry = pm.triangulate(this._geometry);
const bounds = new PIXI.Rectangle(0, 0, 0, 0);
if ( radius > 0 ) {
const b = shape instanceof PointSourcePolygon ? shape.bounds : 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;
}
/* -------------------------------------------- */
/**
* Create a radius constrained polygon from the visual shape polygon.
* If the visual shape is not created, no polygon is created.
* @param {number} radius The radius to constraint to.
* @returns {PointSourcePolygon} The new polygon or null if no visual shape is present.
*/
#createShapeFromVisualShape(radius) {
if ( !this._visualShape ) return null;
const {x, y} = this.data;
const circle = new PIXI.Circle(x, y, radius);
const density = PIXI.Circle.approximateVertexDensity(radius);
return this._visualShape.applyConstraint(circle, {density, scalingFactor: 100});
}
/* -------------------------------------------- */
/**
* Create the Edge instances that correspond to this darkness source.
*/
#createEdges() {
if ( !this.active || this.isPreview ) return;
const cls = foundry.canvas.edges.Edge;
const block = CONST.WALL_SENSE_TYPES.NORMAL;
const direction = CONST.WALL_DIRECTIONS.LEFT;
const points = [...this.shape.points];
let p0 = {x: points[0], y: points[1]};
points.push(p0.x, p0.y);
let p1;
for ( let i=2; i<points.length; i+=2 ) {
p1 = {x: points[i], y: points[i+1]};
const id = `${this.sourceId}.${i/2}`;
const edge = new cls(p0, p1, {type: "darkness", id, object: this.object, direction, light: block, sight: block});
this.edges.push(edge);
canvas.edges.set(edge.id, edge);
p0 = p1;
}
}
/* -------------------------------------------- */
/**
* Remove edges from the active Edges collection.
*/
#deleteEdges() {
for ( const edge of this.edges ) canvas.edges.delete(edge.id);
this.edges.length = 0;
}
/* -------------------------------------------- */
/* Shader Management */
/* -------------------------------------------- */
/**
* Update the uniforms of the shader on the darkness layer.
*/
_updateDarknessUniforms() {
const u = this.layers.darkness.shader?.uniforms;
if ( !u ) return;
u.color = this.colorRGB ?? this.layers.darkness.shader.constructor.defaultUniforms.color;
u.enableVisionMasking = canvas.effects.visionSources.some(s => s.active) || !game.user.isGM;
u.borderDistance = this.#borderDistance;
u.colorationAlpha = this.data.alpha * 2;
// 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;
if ( !u.visionTexture ) u.visionTexture = canvas.masks.vision.renderTexture;
// Flag uniforms as updated
this.layers.darkness.reset = false;
}
/* -------------------------------------------- */
/** @inheritDoc */
_destroy() {
this.#deleteEdges();
super._destroy();
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get isDarkness() {
const msg = "BaseLightSource#isDarkness is now obsolete. Use DarknessSource instead.";
foundry.utils.logCompatibilityWarning(msg, { since: 12, until: 14});
return true;
}
}

View File

@@ -0,0 +1,165 @@
/**
* @typedef {import("./base-effect-source.mjs").BaseEffectSourceData} BaseEffectSourceData
*/
/**
* @typedef {Object} PointEffectSourceData
* @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
*/
/**
* TODO - documentation required about what a PointEffectSource is.
* @param BaseSource
* @returns {{new(): PointEffectSource, prototype: PointEffectSource}}
* @mixin
*/
export default function PointEffectSourceMixin(BaseSource) {
/**
* @extends {BaseEffectSource<BaseEffectSourceData & PointEffectSourceData, PointSourcePolygon>}
* @abstract
*/
return class PointEffectSource extends BaseSource {
/** @inheritDoc */
static defaultData = {
...super.defaultData,
radius: 0,
externalRadius: 0,
rotation: 0,
angle: 360,
walls: true
}
/* -------------------------------------------- */
/**
* A convenience reference to the radius of the source.
* @type {number}
*/
get radius() {
return this.data.radius ?? 0;
}
/* -------------------------------------------- */
/** @inheritDoc */
_initialize(data) {
super._initialize(data);
if ( this.data.radius > 0 ) this.data.radius = Math.max(this.data.radius, this.data.externalRadius);
}
/* -------------------------------------------- */
/* Point Source Geometry Methods */
/* -------------------------------------------- */
/** @inheritDoc */
_initializeSoftEdges() {
super._initializeSoftEdges();
const isCircle = (this.shape instanceof PointSourcePolygon) && this.shape.isCompleteCircle();
this._flags.renderSoftEdges &&= !isCircle;
}
/* -------------------------------------------- */
/**
* 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.disabled || this.suppressed) ? 0 : this.radius,
externalRadius: this.data.externalRadius,
angle: this.data.angle,
rotation: this.data.rotation,
source: this
};
}
/* -------------------------------------------- */
/** @inheritDoc */
_createShapes() {
const origin = {x: this.data.x, y: this.data.y};
const config = this._getPolygonConfiguration();
const polygonClass = CONFIG.Canvas.polygonBackends[this.constructor.sourceType];
this.shape = polygonClass.create(origin, config);
}
/* -------------------------------------------- */
/* Rendering methods */
/* -------------------------------------------- */
/** @override */
_drawMesh(layerId) {
const mesh = super._drawMesh(layerId);
if ( mesh ) mesh.scale.set(this.radius);
return mesh;
}
/** @override */
_updateGeometry() {
const {x, y} = this.data;
const radius = this.radius;
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);
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;
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
set radius(radius) {
const msg = "The setter PointEffectSource#radius is deprecated."
+ " The radius should not be set anywhere except in PointEffectSource#_initialize.";
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
this.data.radius = radius;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get los() {
const msg = "PointEffectSource#los is deprecated in favor of PointEffectSource#shape.";
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
return this.shape;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
set los(shape) {
const msg = "PointEffectSource#los is deprecated in favor of PointEffectSource#shape.";
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
this.shape = shape;
}
}
}

View File

@@ -0,0 +1,89 @@
import BaseLightSource from "./base-light-source.mjs";
import PointEffectSourceMixin from "./point-effect-source.mjs";
/**
* A specialized subclass of the BaseLightSource which renders a source of light as a point-based effect.
* @extends {BaseLightSource}
* @mixes {PointEffectSourceMixin}
*/
export default class PointLightSource extends PointEffectSourceMixin(BaseLightSource) {
/** @override */
static effectsCollection = "lightSources";
/* -------------------------------------------- */
/* Source Suppression Management */
/* -------------------------------------------- */
/**
* Update darkness suppression according to darkness sources collection.
*/
#updateDarknessSuppression() {
this.suppression.darkness = canvas.effects.testInsideDarkness({x: this.x, y: this.y}, this.elevation);
}
/* -------------------------------------------- */
/* Light Source Initialization */
/* -------------------------------------------- */
/** @inheritDoc */
_initialize(data) {
super._initialize(data);
Object.assign(this.data, {
radius: Math.max(this.data.dim ?? 0, this.data.bright ?? 0)
});
}
/* -------------------------------------------- */
/** @inheritDoc */
_createShapes() {
this.#updateDarknessSuppression();
super._createShapes();
}
/* -------------------------------------------- */
/** @inheritDoc */
_configure(changes) {
this.ratio = Math.clamp(Math.abs(this.data.bright) / this.data.radius, 0, 1);
super._configure(changes);
}
/* -------------------------------------------- */
/** @inheritDoc */
_getPolygonConfiguration() {
return Object.assign(super._getPolygonConfiguration(), {useThreshold: true, includeDarkness: true});
}
/* -------------------------------------------- */
/* 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 => this.shape.contains(test.point.x, test.point.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;
}
}

View File

@@ -0,0 +1,13 @@
import BaseEffectSource from "./base-effect-source.mjs";
import PointEffectSourceMixin from "./point-effect-source.mjs";
/**
* A specialized subclass of the BaseEffectSource which describes a movement-based source.
* @extends {BaseEffectSource}
* @mixes {PointEffectSource}
*/
export default class PointMovementSource extends PointEffectSourceMixin(BaseEffectSource) {
/** @override */
static sourceType = "move";
}

View File

@@ -0,0 +1,46 @@
import BaseEffectSource from "./base-effect-source.mjs";
import PointEffectSourceMixin from "./point-effect-source.mjs";
/**
* A specialized subclass of the BaseEffectSource which describes a point-based source of sound.
* @extends {BaseEffectSource}
* @mixes {PointEffectSource}
*/
export default class PointSoundSource extends PointEffectSourceMixin(BaseEffectSource) {
/** @override */
static sourceType = "sound";
/** @override */
get effectsCollection() {
return canvas.sounds.sources;
}
/* -------------------------------------------- */
/** @inheritDoc */
_getPolygonConfiguration() {
return Object.assign(super._getPolygonConfiguration(), {useThreshold: true});
}
/* -------------------------------------------- */
/**
* Get the effective volume at which an AmbientSound source should be played for a certain listener.
* @param {Point} listener
* @param {object} [options]
* @param {boolean} [options.easing]
* @returns {number}
*/
getVolumeMultiplier(listener, {easing=true}={}) {
if ( !listener ) return 0; // No listener = 0
const {x, y, radius} = this.data;
const distance = Math.hypot(listener.x - x, listener.y - y);
if ( distance === 0 ) return 1;
if ( distance > radius ) return 0; // Distance outside of radius = 0
if ( !this.shape?.contains(listener.x, listener.y) ) return 0; // Point outside of shape = 0
if ( !easing ) return 1; // No easing = 1
const dv = Math.clamp(distance, 0, radius) / radius;
return (Math.cos(Math.PI * dv) + 1) * 0.5; // Cosine easing [0, 1]
}
}

View File

@@ -0,0 +1,445 @@
import RenderedEffectSource from "./rendered-effect-source.mjs";
import PointEffectSourceMixin from "./point-effect-source.mjs";
import {LIGHTING_LEVELS} from "../../../common/constants.mjs";
/**
* @typedef {import("./base-effect-source.mjs").BaseEffectSourceData} BaseEffectSourceData
* @typedef {import("./rendered-effect-source-source.mjs").RenderedEffectSourceData} RenderedEffectSourceData
*/
/**
* @typedef {Object} 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 {number} lightRadius The range of light perception.
* @property {boolean} blinded Is this vision source blinded?
*/
/**
* A specialized subclass of RenderedEffectSource which represents a source of point-based vision.
* @extends {RenderedEffectSource<BaseEffectSourceData & RenderedEffectSourceData & VisionSourceData, PointSourcePolygon>}
*/
export default class PointVisionSource extends PointEffectSourceMixin(RenderedEffectSource) {
/** @inheritdoc */
static sourceType = "sight";
/** @override */
static _initializeShaderKeys = ["visionMode", "blinded"];
/** @override */
static _refreshUniformsKeys = ["radius", "color", "attenuation", "brightness", "contrast", "saturation", "visionMode"];
/**
* The corresponding lighting levels for dim light.
* @type {number}
* @protected
*/
static _dimLightingLevel = LIGHTING_LEVELS.DIM;
/**
* The corresponding lighting levels for bright light.
* @type {string}
* @protected
*/
static _brightLightingLevel = LIGHTING_LEVELS.BRIGHT;
/** @inheritdoc */
static EDGE_OFFSET = -2;
/** @override */
static effectsCollection = "visionSources";
/** @inheritDoc */
static defaultData = {
...super.defaultData,
contrast: 0,
attenuation: 0.5,
saturation: 0,
brightness: 0,
visionMode: "basic",
lightRadius: null
}
/** @override */
static get _layers() {
return foundry.utils.mergeObject(super._layers, {
background: {
defaultShader: BackgroundVisionShader
},
coloration: {
defaultShader: ColorationVisionShader
},
illumination: {
defaultShader: IlluminationVisionShader
}
});
}
/* -------------------------------------------- */
/* Vision Source Attributes */
/* -------------------------------------------- */
/**
* 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;
/**
* The polygon of light perception.
* @type {PointSourcePolygon}
*/
light;
/* -------------------------------------------- */
/**
* 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;
}
/* -------------------------------------------- */
/**
* Is the rendered source animated?
* @type {boolean}
*/
get isAnimated() {
return this.active && this.data.animation && this.visionMode?.animated;
}
/* -------------------------------------------- */
/**
* Light perception radius of this vision source, taking into account if the source is blinded.
* @type {number}
*/
get lightRadius() {
return this.#hasBlindedVisionMode ? 0 : (this.data.lightRadius ?? 0);
}
/* -------------------------------------------- */
/** @override */
get radius() {
return (this.#hasBlindedVisionMode ? this.data.externalRadius : this.data.radius) ?? 0;
}
/* -------------------------------------------- */
/* Point Vision Source Blinded Management */
/* -------------------------------------------- */
/**
* Is this source temporarily blinded?
* @type {boolean}
*/
get isBlinded() {
return (this.data.radius === 0) && ((this.data.lightRadius === 0) || !this.visionMode?.perceivesLight)
|| Object.values(this.blinded).includes(true);
};
/**
* Records of blinding strings with a boolean value.
* By default, if any of this record is true, the source is blinded.
* @type {Record<string, boolean>}
*/
blinded = {};
/**
* Data overrides that could happen with blindness vision mode.
* @type {object}
*/
visionModeOverrides = {};
/* -------------------------------------------- */
/**
* Update darkness blinding according to darkness sources collection.
*/
#updateBlindedState() {
this.blinded.darkness = canvas.effects.testInsideDarkness({x: this.x, y: this.y}, this.elevation);
}
/* -------------------------------------------- */
/**
* To know if blindness vision mode is configured for this source.
* Note: Convenient method used to avoid calling this.blinded which is costly.
* @returns {boolean}
*/
get #hasBlindedVisionMode() {
return this.visionMode === CONFIG.Canvas.visionModes.blindness;
}
/* -------------------------------------------- */
/* Vision Source Initialization */
/* -------------------------------------------- */
/** @inheritDoc */
_initialize(data) {
super._initialize(data);
this.data.lightRadius ??= canvas.dimensions.maxR;
if ( this.data.lightRadius > 0 ) this.data.lightRadius = Math.max(this.data.lightRadius, this.data.externalRadius);
if ( this.data.radius > 0 ) this.data.radius = Math.max(this.data.radius, this.data.externalRadius);
if ( !(this.data.visionMode in CONFIG.Canvas.visionModes) ) this.data.visionMode = "basic";
}
/* -------------------------------------------- */
/** @inheritDoc */
_createShapes() {
this._updateVisionMode();
super._createShapes();
this.los = this.shape;
this.light = this._createLightPolygon();
this.shape = this._createRestrictedPolygon();
}
/* -------------------------------------------- */
/**
* Responsible for assigning the Vision Mode and calling the activation and deactivation handlers.
* @protected
*/
_updateVisionMode() {
const previousVM = this.visionMode;
this.visionMode = CONFIG.Canvas.visionModes[this.data.visionMode];
// Check blinding conditions
this.#updateBlindedState();
// Apply vision mode according to conditions
if ( this.isBlinded ) this.visionMode = CONFIG.Canvas.visionModes.blindness;
// Process vision mode overrides for blindness
const defaults = this.visionMode.vision.defaults;
const data = this.data;
const applyOverride = prop => this.#hasBlindedVisionMode && (defaults[prop] !== undefined) ? defaults[prop] : data[prop];
const blindedColor = applyOverride("color");
this.visionModeOverrides.colorRGB = blindedColor !== null ? Color.from(blindedColor).rgb : null;
this.visionModeOverrides.brightness = applyOverride("brightness");
this.visionModeOverrides.contrast = applyOverride("contrast");
this.visionModeOverrides.saturation = applyOverride("saturation");
this.visionModeOverrides.attenuation = applyOverride("attenuation");
// Process deactivation and activation handlers
if ( this.visionMode !== previousVM ) previousVM?.deactivate(this);
}
/* -------------------------------------------- */
/** @inheritDoc */
_configure(changes) {
this.visionMode.activate(this);
super._configure(changes);
this.animation.animation = this.visionMode.animate;
}
/* -------------------------------------------- */
/** @override */
_configureLayer(layer, layerId) {
const vmUniforms = this.visionMode.vision[layerId].uniforms;
layer.vmUniforms = Object.entries(vmUniforms);
}
/* -------------------------------------------- */
/** @inheritDoc */
_getPolygonConfiguration() {
return Object.assign(super._getPolygonConfiguration(), {
radius: this.data.disabled || this.suppressed ? 0 : (this.blinded.darkness
? this.data.externalRadius : canvas.dimensions.maxR),
useThreshold: true,
includeDarkness: true
});
}
/* -------------------------------------------- */
/**
* Creates the polygon that represents light perception.
* If the light perception radius is unconstrained, no new polygon instance is created;
* instead the LOS polygon of this vision source is returned.
* @returns {PointSourcePolygon} The new polygon or `this.los`.
* @protected
*/
_createLightPolygon() {
return this.#createConstrainedPolygon(this.lightRadius);
}
/* -------------------------------------------- */
/**
* Create a restricted FOV polygon by limiting the radius of the unrestricted LOS polygon.
* If the vision radius is unconstrained, no new polygon instance is created;
* instead the LOS polygon of this vision source is returned.
* @returns {PointSourcePolygon} The new polygon or `this.los`.
* @protected
*/
_createRestrictedPolygon() {
return this.#createConstrainedPolygon(this.radius || this.data.externalRadius);
}
/* -------------------------------------------- */
/**
* Create a constrained polygon by limiting the radius of the unrestricted LOS polygon.
* If the radius is unconstrained, no new polygon instance is created;
* instead the LOS polygon of this vision source is returned.
* @param {number} radius The radius to constraint to.
* @returns {PointSourcePolygon} The new polygon or `this.los`.
*/
#createConstrainedPolygon(radius) {
if ( radius >= this.los.config.radius ) return this.los;
const {x, y} = this.data;
const circle = new PIXI.Circle(x, 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;
const shaders = {};
for ( const layer in this.layers ) {
shaders[layer] = vm[`${layer.toLowerCase()}`]?.shader || this.layers[layer].defaultShader;
}
return shaders;
}
/* -------------------------------------------- */
/** @inheritDoc */
_updateColorationUniforms() {
super._updateColorationUniforms();
const shader = this.layers.coloration.shader;
if ( !shader ) return;
const u = shader?.uniforms;
const d = shader.constructor.defaultUniforms;
u.colorEffect = this.visionModeOverrides.colorRGB ?? d.colorEffect;
u.useSampler = true;
const vmUniforms = this.layers.coloration.vmUniforms;
if ( vmUniforms.length ) this._updateVisionModeUniforms(shader, vmUniforms);
}
/* -------------------------------------------- */
/** @inheritDoc */
_updateIlluminationUniforms() {
super._updateIlluminationUniforms();
const shader = this.layers.illumination.shader;
if ( !shader ) return;
shader.uniforms.useSampler = false; // We don't need to use the background sampler into vision illumination
const vmUniforms = this.layers.illumination.vmUniforms;
if ( vmUniforms.length ) this._updateVisionModeUniforms(shader, vmUniforms);
}
/* -------------------------------------------- */
/** @inheritDoc */
_updateBackgroundUniforms() {
super._updateBackgroundUniforms();
const shader = this.layers.background.shader;
if ( !shader ) return;
const u = shader.uniforms;
u.technique = 0;
u.contrast = this.visionModeOverrides.contrast;
u.useSampler = true;
const vmUniforms = this.layers.background.vmUniforms;
if ( vmUniforms.length ) this._updateVisionModeUniforms(shader, vmUniforms);
}
/* -------------------------------------------- */
/** @inheritDoc */
_updateCommonUniforms(shader) {
const u = shader.uniforms;
const d = shader.constructor.defaultUniforms;
const c = canvas.colors;
// Passing common environment values
u.computeIllumination = true;
u.darknessLevel = canvas.environment.darknessLevel;
c.ambientBrightest.applyRGB(u.ambientBrightest);
c.ambientDarkness.applyRGB(u.ambientDarkness);
c.ambientDaylight.applyRGB(u.ambientDaylight);
u.weights[0] = canvas.environment.weights.dark;
u.weights[1] = canvas.environment.weights.halfdark;
u.weights[2] = canvas.environment.weights.dim;
u.weights[3] = canvas.environment.weights.bright;
u.dimLevelCorrection = this.constructor._dimLightingLevel;
u.brightLevelCorrection = this.constructor._brightLightingLevel;
// Vision values
const attenuation = this.visionModeOverrides.attenuation;
u.attenuation = Math.max(attenuation, 0.0125);
const brightness = this.visionModeOverrides.brightness;
u.brightness = (brightness + 1) / 2;
u.saturation = this.visionModeOverrides.saturation;
u.linkedToDarknessLevel = this.visionMode.vision.darkness.adaptive;
// Other values
u.elevation = this.data.elevation;
u.screenDimensions = canvas.screenDimensions;
u.colorTint = this.visionModeOverrides.colorRGB ?? d.colorTint;
// Textures
if ( !u.depthTexture ) u.depthTexture = canvas.masks.depth.renderTexture;
if ( !u.primaryTexture ) u.primaryTexture = canvas.primary.renderTexture;
if ( !u.darknessLevelTexture ) u.darknessLevelTexture = canvas.effects.illumination.renderTexture;
}
/* -------------------------------------------- */
/**
* Update layer uniforms according to vision mode uniforms, if any.
* @param {AdaptiveVisionShader} shader The shader being updated.
* @param {Array} vmUniforms The targeted layer.
* @protected
*/
_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;
}
}
}

View File

@@ -0,0 +1,625 @@
import BaseEffectSource from "./base-effect-source.mjs";
/**
* @typedef {import("./base-effect-source.mjs").BaseEffectSourceData} BaseEffectSourceData
*/
/**
* @typedef {Object} RenderedEffectSourceData
* @property {object} animation An animation configuration for the source
* @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} RenderedEffectSourceAnimationConfig
* @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 {AdaptiveDarknessShader} [darknessShader] A custom darkness shader used by this animation
* @property {number} [seed] The animation seed
* @property {number} [time] The animation time
*/
/**
* @typedef {Object} RenderedEffectLayerConfig
* @property {AdaptiveLightingShader} defaultShader The default shader used by this layer
* @property {PIXI.BLEND_MODES} blendMode The blend mode used by this layer
*/
/**
* 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.
* @extends {BaseEffectSource<BaseEffectSourceData & RenderedEffectSourceData>}
* @abstract
*/
export default class RenderedEffectSource extends BaseEffectSource {
/**
* Keys of the data object which require shaders to be re-initialized.
* @type {string[]}
* @protected
*/
static _initializeShaderKeys = ["animation.type"];
/**
* Keys of the data object which require uniforms to be refreshed.
* @type {string[]}
* @protected
*/
static _refreshUniformsKeys = [];
/**
* Layers handled by this rendered source.
* @type {Record<string, RenderedEffectLayerConfig>}
* @protected
*/
static get _layers() {
return {
background: {
defaultShader: AdaptiveBackgroundShader,
blendMode: "MAX_COLOR"
},
coloration: {
defaultShader: AdaptiveColorationShader,
blendMode: "SCREEN"
},
illumination: {
defaultShader: AdaptiveIlluminationShader,
blendMode: "MAX_COLOR"
}
};
}
/**
* The offset in pixels applied to create soft edges.
* @type {number}
*/
static EDGE_OFFSET = -8;
/** @inheritDoc */
static defaultData = {
...super.defaultData,
animation: {},
seed: null,
preview: false,
color: null
}
/* -------------------------------------------- */
/* Rendered Source Attributes */
/* -------------------------------------------- */
/**
* The animation configuration applied to this source
* @type {RenderedEffectSourceAnimationConfig}
*/
animation = {};
/**
* @typedef {Object} RenderedEffectSourceLayer
* @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: RenderedEffectSourceLayer,
* coloration: RenderedEffectSourceLayer,
* illumination: RenderedEffectSourceLayer
* }}
*/
layers = Object.entries(this.constructor._layers).reduce((obj, [layer, config]) => {
obj[layer] = {active: true, reset: true, suppressed: false,
mesh: undefined, shader: undefined, defaultShader: config.defaultShader,
vmUniforms: undefined, blendMode: config.blendMode};
return obj;
}, {});
/**
* Array of update uniforms functions.
* @type {Function[]}
*/
#updateUniformsFunctions = (() => {
const initializedFunctions = [];
for ( const layer in this.layers ) {
const fn = this[`_update${layer.titleCase()}Uniforms`];
if ( fn ) initializedFunctions.push(fn);
}
return initializedFunctions;
})();
/**
* The color of the source as an RGB vector.
* @type {[number, number, number]|null}
*/
colorRGB = null;
/**
* PIXI Geometry generated to draw meshes.
* @type {PIXI.Geometry|null}
* @protected
*/
_geometry = null;
/* -------------------------------------------- */
/* Source State */
/* -------------------------------------------- */
/**
* Is the rendered source animated?
* @type {boolean}
*/
get isAnimated() {
return this.active && this.data.animation?.type;
}
/**
* Has the rendered source at least one active layer?
* @type {boolean}
*/
get hasActiveLayer() {
return this.#hasActiveLayer;
}
#hasActiveLayer = false;
/**
* Is this RenderedEffectSource a temporary preview?
* @returns {boolean}
*/
get isPreview() {
return !!this.data.preview;
}
/* -------------------------------------------- */
/* 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;
}
/* -------------------------------------------- */
/* Rendered Source Initialization */
/* -------------------------------------------- */
/** @inheritDoc */
_initialize(data) {
super._initialize(data);
const color = Color.from(this.data.color ?? null);
this.data.color = color.valid ? color.valueOf() : null;
const seed = this.data.seed ?? this.animation.seed ?? Math.floor(Math.random() * 100000);
this.animation = this.data.animation = {seed, ...this.data.animation};
// Initialize the color attributes
const hasColor = this._flags.hasColor = (this.data.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;
}
this._initializeSoftEdges();
}
/* -------------------------------------------- */
/**
* Decide whether to render soft edges with a blur.
* @protected
*/
_initializeSoftEdges() {
this._flags.renderSoftEdges = canvas.performance.lightSoftEdges && !this.isPreview;
}
/* -------------------------------------------- */
/** @override */
_configure(changes) {
// To know if we need a first time initialization of the shaders
const initializeShaders = !this._geometry;
// Initialize meshes using the computed shape
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;
}
}
// Update the visible state the layers
this.#updateVisibleLayers();
}
/* -------------------------------------------- */
/**
* Configure which shaders are used for each rendered layer.
* @returns {{
* background: AdaptiveLightingShader,
* coloration: AdaptiveLightingShader,
* illumination: AdaptiveLightingShader
* }}
* @private
*/
_configureShaders() {
const a = this.animation;
const shaders = {};
for ( const layer in this.layers ) {
shaders[layer] = a[`${layer.toLowerCase()}Shader`] || this.layers[layer].defaultShader;
}
return shaders;
}
/* -------------------------------------------- */
/**
* 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 = RenderedEffectSource.#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 geometry and the meshes.
*/
#initializeMeshes() {
this._updateGeometry();
if ( !this._flags.initializedMeshes ) this.#createMeshes();
}
/* -------------------------------------------- */
/**
* Create meshes for each layer of the RenderedEffectSource that is drawn to the canvas.
*/
#createMeshes() {
if ( !this._geometry ) return;
const shaders = this._configureShaders();
for ( const [l, layer] of Object.entries(this.layers) ) {
layer.mesh = this.#createMesh(shaders[l]);
layer.mesh.blendMode = PIXI.BLEND_MODES[layer.blendMode];
layer.shader = layer.mesh.shader;
}
this._flags.initializedMeshes = true;
}
/* -------------------------------------------- */
/**
* 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.
* @protected
* @abstract
*/
_updateGeometry() {}
/* -------------------------------------------- */
/* 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 = {};
for ( const layerId of Object.keys(this.layers) ) {
meshes[layerId] = this._drawMesh(layerId);
}
return meshes;
}
/* -------------------------------------------- */
/**
* Create a Mesh for a certain rendered layer of this source.
* @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
* @protected
*/
_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 ) {
mesh.visible = false;
return null;
}
// Update the mesh
const {x, y} = this.data;
mesh.position.set(x, y);
mesh.visible = mesh.renderable = true;
return layer.mesh;
}
/* -------------------------------------------- */
/* Rendered Source Refresh */
/* -------------------------------------------- */
/** @override */
_refresh() {
this.#updateUniforms();
this.#updateVisibleLayers();
}
/* -------------------------------------------- */
/**
* Update uniforms for all rendered layers.
*/
#updateUniforms() {
for ( const updateUniformsFunction of this.#updateUniformsFunctions ) updateUniformsFunction.call(this);
}
/* -------------------------------------------- */
/**
* Update the visible state of the component channels of this RenderedEffectSource.
* @returns {boolean} Is there an active layer?
*/
#updateVisibleLayers() {
const active = this.active;
let hasActiveLayer = false;
for ( const layer of Object.values(this.layers) ) {
layer.active = active && (layer.shader?.isRequired !== false);
if ( layer.active ) hasActiveLayer = true;
}
this.#hasActiveLayer = hasActiveLayer;
}
/* -------------------------------------------- */
/**
* Update shader uniforms used by every rendered layer.
* @param {AbstractBaseShader} shader
* @protected
*/
_updateCommonUniforms(shader) {}
/* -------------------------------------------- */
/**
* Update shader uniforms used for the background layer.
* @protected
*/
_updateBackgroundUniforms() {
const shader = this.layers.background.shader;
if ( !shader ) return;
this._updateCommonUniforms(shader);
}
/* -------------------------------------------- */
/**
* Update shader uniforms used for the coloration layer.
* @protected
*/
_updateColorationUniforms() {
const shader = this.layers.coloration.shader;
if ( !shader ) return;
this._updateCommonUniforms(shader);
}
/* -------------------------------------------- */
/**
* Update shader uniforms used for the illumination layer.
* @protected
*/
_updateIlluminationUniforms() {
const shader = this.layers.illumination.shader;
if ( !shader ) return;
this._updateCommonUniforms(shader);
}
/* -------------------------------------------- */
/* 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 0 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;
}
}
/* -------------------------------------------- */
/* Static Helper Methods */
/* -------------------------------------------- */
/**
* Get corrected level according to level and active vision mode data.
* @param {VisionMode.LIGHTING_LEVELS} level
* @returns {number} The corrected level.
*/
static getCorrectedLevel(level) {
// Retrieving the lighting mode and the corrected level, if any
const lightingOptions = canvas.visibility.visionModeData?.activeLightingOptions;
return (lightingOptions?.levels?.[level]) ?? level;
}
/* -------------------------------------------- */
/**
* Get corrected color according to level, dim color, bright color and background color.
* @param {VisionMode.LIGHTING_LEVELS} level
* @param {Color} colorDim
* @param {Color} colorBright
* @param {Color} [colorBackground]
* @returns {Color}
*/
static getCorrectedColor(level, colorDim, colorBright, colorBackground) {
colorBackground ??= canvas.colors.background;
// Returning the corrected color according to the lighting options
const levels = VisionMode.LIGHTING_LEVELS;
switch ( this.getCorrectedLevel(level) ) {
case levels.HALFDARK:
case levels.DIM: return colorDim;
case levels.BRIGHT:
case levels.DARKNESS: return colorBright;
case levels.BRIGHTEST: return canvas.colors.ambientBrightest;
case levels.UNLIT: return colorBackground;
default: return colorDim;
}
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get preview() {
const msg = "The RenderedEffectSource#preview is deprecated. Use RenderedEffectSource#isPreview instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this.isPreview;
}
/**
* @deprecated since v11
* @ignore
*/
set preview(preview) {
const msg = "The RenderedEffectSource#preview is deprecated. "
+ "Set RenderedEffectSource#preview as part of RenderedEffectSource#initialize instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
this.data.preview = preview;
}
}

View File

@@ -0,0 +1,3 @@
export {default as TokenRing} from "./ring.mjs";
export {default as TokenRingConfig} from "./ring-config.mjs";
export {default as DynamicRingData} from "./ring-data.mjs"

View File

@@ -0,0 +1,379 @@
import DynamicRingData from "./ring-data.mjs";
/**
* The start and end radii of the token ring color band.
* @typedef {Object} RingColorBand
* @property {number} startRadius The starting normalized radius of the token ring color band.
* @property {number} endRadius The ending normalized radius of the token ring color band.
*/
/**
* Dynamic ring id.
* @typedef {string} DynamicRingId
*/
/**
* Token Ring configuration Singleton Class.
*
* @example Add a new custom ring configuration. Allow only ring pulse, ring gradient and background wave effects.
* const customConfig = new foundry.canvas.tokens.DynamicRingData({
* id: "myCustomRingId",
* label: "Custom Ring",
* effects: {
* RING_PULSE: "TOKEN.RING.EFFECTS.RING_PULSE",
* RING_GRADIENT: "TOKEN.RING.EFFECTS.RING_GRADIENT",
* BACKGROUND_WAVE: "TOKEN.RING.EFFECTS.BACKGROUND_WAVE"
* },
* spritesheet: "canvas/tokens/myCustomRings.json",
* framework: {
* shaderClass: MyCustomTokenRingSamplerShader,
* ringClass: TokenRing
* }
* });
* CONFIG.Token.ring.addConfig(customConfig.id, customConfig);
*
* @example Get a specific ring configuration
* const config = CONFIG.Token.ring.getConfig("myCustomRingId");
* console.log(config.spritesheet); // Output: canvas/tokens/myCustomRings.json
*
* @example Use a specific ring configuration
* const success = CONFIG.Token.ring.useConfig("myCustomRingId");
* console.log(success); // Output: true
*
* @example Get the labels of all configurations
* const configLabels = CONFIG.Token.ring.configLabels;
* console.log(configLabels);
* // Output:
* // {
* // "coreSteel": "Foundry VTT Steel Ring",
* // "coreBronze": "Foundry VTT Bronze Ring",
* // "myCustomRingId" : "My Super Power Ring"
* // }
*
* @example Get the IDs of all configurations
* const configIDs = CONFIG.Token.ring.configIDs;
* console.log(configIDs); // Output: ["coreSteel", "coreBronze", "myCustomRingId"]
*
* @example Create a hook to add a custom token ring configuration. This ring configuration will appear in the settings.
* Hooks.on("initializeDynamicTokenRingConfig", ringConfig => {
* const mySuperPowerRings = new foundry.canvas.tokens.DynamicRingData({
* id: "myCustomRingId",
* label: "My Super Power Rings",
* effects: {
* RING_PULSE: "TOKEN.RING.EFFECTS.RING_PULSE",
* RING_GRADIENT: "TOKEN.RING.EFFECTS.RING_GRADIENT",
* BACKGROUND_WAVE: "TOKEN.RING.EFFECTS.BACKGROUND_WAVE"
* },
* spritesheet: "canvas/tokens/mySuperPowerRings.json"
* });
* ringConfig.addConfig("mySuperPowerRings", mySuperPowerRings);
* });
*
* @example Activate color bands debugging visuals to ease configuration
* CONFIG.Token.ring.debugColorBands = true;
*/
export default class TokenRingConfig {
constructor() {
if ( TokenRingConfig.#instance ) {
throw new Error("An instance of TokenRingConfig has already been created. " +
"Use `CONFIG.Token.ring` to access it.");
}
TokenRingConfig.#instance = this;
}
/**
* The token ring config instance.
* @type {TokenRingConfig}
*/
static #instance;
/**
* To know if the ring config is initialized.
* @type {boolean}
*/
static #initialized = false;
/**
* To know if a Token Ring registration is possible.
* @type {boolean}
*/
static #closedRegistration = true;
/**
* Core token rings used in Foundry VTT.
* Each key is a string identifier for a ring, and the value is an object containing the ring's data.
* This object is frozen to prevent any modifications.
* @type {Readonly<Record<DynamicRingId, RingData>>}
*/
static CORE_TOKEN_RINGS = Object.freeze({
coreSteel: {
id: "coreSteel",
label: "TOKEN.RING.SETTINGS.coreSteel",
spritesheet: "canvas/tokens/rings-steel.json"
},
coreBronze: {
id: "coreBronze",
label: "TOKEN.RING.SETTINGS.coreBronze",
spritesheet: "canvas/tokens/rings-bronze.json"
}
});
/**
* Core token rings fit modes used in Foundry VTT.
* @type {Readonly<object>}
*/
static CORE_TOKEN_RINGS_FIT_MODES = Object.freeze({
subject: {
id: "subject",
label: "TOKEN.RING.SETTINGS.FIT_MODES.subject"
},
grid: {
id: "grid",
label: "TOKEN.RING.SETTINGS.FIT_MODES.grid"
}
});
/* -------------------------------------------- */
/**
* Register the token ring config and initialize it
*/
static initialize() {
// If token config is initialized
if ( this.#initialized ) {
throw new Error("The token configuration class can be initialized only once!")
}
// Open the registration window for the token rings
this.#closedRegistration = false;
// Add default rings
for ( const id in this.CORE_TOKEN_RINGS ) {
const config = new DynamicRingData(this.CORE_TOKEN_RINGS[id]);
CONFIG.Token.ring.addConfig(config.id, config);
}
// Call an explicit hook for token ring configuration
Hooks.callAll("initializeDynamicTokenRingConfig", CONFIG.Token.ring);
// Initialize token rings configuration
if ( !CONFIG.Token.ring.useConfig(game.settings.get("core", "dynamicTokenRing")) ) {
CONFIG.Token.ring.useConfig(this.CORE_TOKEN_RINGS.coreSteel.id);
}
// Close the registration window for the token rings
this.#closedRegistration = true;
this.#initialized = true;
}
/* -------------------------------------------- */
/**
* Register game settings used by the Token Ring
*/
static registerSettings() {
game.settings.register("core", "dynamicTokenRing", {
name: "TOKEN.RING.SETTINGS.label",
hint: "TOKEN.RING.SETTINGS.hint",
scope: "world",
config: true,
type: new foundry.data.fields.StringField({required: true, blank: false,
initial: this.CORE_TOKEN_RINGS.coreSteel.id,
choices: () => CONFIG.Token.ring.configLabels
}),
requiresReload: true
});
game.settings.register("core", "dynamicTokenRingFitMode", {
name: "TOKEN.RING.SETTINGS.FIT_MODES.label",
hint: "TOKEN.RING.SETTINGS.FIT_MODES.hint",
scope: "world",
config: true,
type: new foundry.data.fields.StringField({
required: true,
blank: false,
initial: this.CORE_TOKEN_RINGS_FIT_MODES.subject.id,
choices: Object.fromEntries(Object.entries(this.CORE_TOKEN_RINGS_FIT_MODES).map(([key, mode]) => [key, mode.label]))
}),
requiresReload: true
});
}
/* -------------------------------------------- */
/**
* Ring configurations.
* @type {Map<string, DynamicRingData>}
*/
#configs = new Map();
/**
* The current ring configuration.
* @type {DynamicRingData}
*/
#currentConfig;
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* A mapping of token subject paths where modules or systems have configured subject images.
* @type {Record<string, string>}
*/
subjectPaths = {};
/**
* All color bands visual debug flag.
* @type {boolean}
*/
debugColorBands = false;
/**
* Get the current ring class.
* @type {typeof TokenRing} The current ring class.
*/
get ringClass() {
return this.#currentConfig.framework.ringClass;
}
set ringClass(value) {
this.#currentConfig.framework.ringClass = value;
}
/**
* Get the current effects.
* @type {Record<string, string>} The current effects.
*/
get effects() {
return this.#currentConfig.effects;
}
/**
* Get the current spritesheet.
* @type {string} The current spritesheet path.
*/
get spritesheet() {
return this.#currentConfig.spritesheet;
}
/**
* Get the current shader class.
* @type {typeof PrimaryBaseSamplerShader} The current shader class.
*/
get shaderClass() {
return this.#currentConfig.framework.shaderClass;
}
set shaderClass(value) {
this.#currentConfig.framework.shaderClass = value;
}
/**
* Get the current localized label.
* @returns {string}
*/
get label() {
return this.#currentConfig.label;
}
/**
* Get the current id.
* @returns {string}
*/
get id() {
return this.#currentConfig.id;
}
/* -------------------------------------------- */
/* Management */
/* -------------------------------------------- */
/**
* Is a custom fit mode active?
* @returns {boolean}
*/
get isGridFitMode() {
return game.settings.get("core","dynamicTokenRingFitMode")
=== this.constructor.CORE_TOKEN_RINGS_FIT_MODES.grid.id;
}
/* -------------------------------------------- */
/**
* Add a new ring configuration.
* @param {string} id The id of the ring configuration.
* @param {RingConfig} config The configuration object for the ring.
*/
addConfig(id, config) {
if ( this.constructor.#closedRegistration ) {
throw new Error("Dynamic Rings registration window is closed. You must register a dynamic token ring configuration during" +
" the `registerDynamicTokenRing` hook.");
}
this.#configs.set(id, config);
}
/* -------------------------------------------- */
/**
* Get a ring configuration.
* @param {string} id The id of the ring configuration.
* @returns {RingConfig} The ring configuration object.
*/
getConfig(id) {
return this.#configs.get(id);
}
/* -------------------------------------------- */
/**
* Use a ring configuration.
* @param {string} id The id of the ring configuration to use.
* @returns {boolean} True if the configuration was successfully set, false otherwise.
*/
useConfig(id) {
if ( this.#configs.has(id) ) {
this.#currentConfig = this.#configs.get(id);
return true;
}
return false;
}
/* -------------------------------------------- */
/**
* Get the IDs of all configurations.
* @returns {string[]} The names of all configurations.
*/
get configIDs() {
return Array.from(this.#configs.keys());
}
/* -------------------------------------------- */
/**
* Get the labels of all configurations.
* @returns {Record<string, string>} An object with configuration names as keys and localized labels as values.
*/
get configLabels() {
const labels = {};
for ( const [name, config] of this.#configs.entries() ) {
labels[name] = config.label;
}
return labels;
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get configNames() {
const msg = "TokenRingConfig#configNames is deprecated and replaced by TokenRingConfig#configIDs";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return this.configIDs;
}
}

View File

@@ -0,0 +1,81 @@
import TokenRing from "./ring.mjs";
import DataModel from "../../../common/abstract/data.mjs";
import {DataField} from "../../../common/data/fields.mjs";
/**
* @typedef {Object} RingData
* @property {string} id The id of this Token Ring configuration.
* @property {string} label The label of this Token Ring configuration.
* @property {string} spritesheet The spritesheet path which provides token ring frames for various sized creatures.
* @property {Record<string, string>} [effects] Registered special effects which can be applied to a token ring.
* @property {Object} framework
* @property {typeof TokenRing} [framework.ringClass=TokenRing] The manager class responsible for rendering token rings.
* @property {typeof PrimaryBaseSamplerShader} [framework.shaderClass=TokenRingSamplerShader] The shader class used to render the TokenRing.
*/
/**
* A special subclass of DataField used to reference a class definition.
*/
class ClassReferenceField extends DataField {
constructor(options) {
super(options);
this.#baseClass = options.baseClass;
}
/**
* The base class linked to this data field.
* @type {typeof Function}
*/
#baseClass;
/** @inheritdoc */
static get _defaults() {
const defaults = super._defaults;
defaults.required = true;
return defaults;
}
/** @override */
_cast(value) {
if ( !foundry.utils.isSubclass(value, this.#baseClass) ) {
throw new Error(`The value provided to a ClassReferenceField must be a ${this.#baseClass.name} subclass.`);
}
return value;
}
/** @override */
getInitialValue(data) {
return this.initial;
}
}
/* -------------------------------------------- */
/**
* Dynamic Ring configuration data model.
* @extends {foundry.abstract.DataModel}
* @implements {RingData}
*/
export default class DynamicRingData extends DataModel {
/** @inheritDoc */
static defineSchema() {
const fields = foundry.data.fields;
// Return model schema
return {
id: new fields.StringField({blank: true}),
label: new fields.StringField({blank: false}),
spritesheet: new fields.FilePathField({categories: ["TEXT"], required: true}),
effects: new fields.ObjectField({initial: {
RING_PULSE: "TOKEN.RING.EFFECTS.RING_PULSE",
RING_GRADIENT: "TOKEN.RING.EFFECTS.RING_GRADIENT",
BKG_WAVE: "TOKEN.RING.EFFECTS.BKG_WAVE",
INVISIBILITY: "TOKEN.RING.EFFECTS.INVISIBILITY"
}}),
framework: new fields.SchemaField({
ringClass: new ClassReferenceField({initial: TokenRing, baseClass: TokenRing}),
shaderClass: new ClassReferenceField({initial: TokenRingSamplerShader, baseClass: PrimaryBaseSamplerShader})
})
};
}
}

View File

@@ -0,0 +1,508 @@
/**
* Dynamic Token Ring Manager.
*/
export default class TokenRing {
/**
* A TokenRing is constructed by providing a reference to a Token object.
* @param {Token} token
*/
constructor(token) {
this.#token = new WeakRef(token);
}
/* -------------------------------------------- */
/* Rings System */
/* -------------------------------------------- */
/**
* The start and end radii of the token ring color band.
* @typedef {Object} RingColorBand
* @property {number} startRadius The starting normalized radius of the token ring color band.
* @property {number} endRadius The ending normalized radius of the token ring color band.
*/
/* -------------------------------------------- */
/**
* The effects which could be applied to a token ring (using bitwise operations).
* @type {Readonly<Record<string, number>>}
*/
static effects = Object.freeze({
DISABLED: 0x00,
ENABLED: 0x01,
RING_PULSE: 0x02,
RING_GRADIENT: 0x04,
BKG_WAVE: 0x08,
INVISIBILITY: 0x10 // or spectral pulse effect
});
/* -------------------------------------------- */
/**
* Is the token rings framework enabled? Will be `null` if the system hasn't initialized yet.
* @type {boolean|null}
*/
static get initialized() {
return this.#initialized;
}
static #initialized = null;
/* -------------------------------------------- */
/**
* Token Rings sprite sheet base texture.
* @type {PIXI.BaseTexture}
*/
static baseTexture;
/**
* Rings and background textures UVs and center offset.
* @type {Record<string, {UVs: Float32Array, center: {x: number, y: number}}>}
*/
static texturesData;
/**
* The token ring shader class definition.
* @type {typeof TokenRingSamplerShader}
*/
static tokenRingSamplerShader;
/**
* Ring data with their ring name, background name and their grid dimension target.
* @type {{ringName: string, bkgName: string, colorBand: RingColorBand, gridTarget: number,
* defaultRingColorLittleEndian: number|null, defaultBackgroundColorLittleEndian: number|null,
* subjectScaleAdjustment: number}[]}
*/
static #ringData;
/**
* Default ring thickness in normalized space.
* @type {number}
*/
static #defaultRingThickness = 0.1269848;
/**
* Default ring subject thickness in normalized space.
* @type {number}
*/
static #defaultSubjectThickness = 0.6666666;
/* -------------------------------------------- */
/**
* Initialize the Token Rings system, registering the batch plugin and patching PrimaryCanvasGroup#addToken.
*/
static initialize() {
if ( TokenRing.#initialized ) return;
TokenRing.#initialized = true;
// Register batch plugin
this.tokenRingSamplerShader = CONFIG.Token.ring.shaderClass;
this.tokenRingSamplerShader.registerPlugin();
}
/* -------------------------------------------- */
/**
* Create texture UVs for each asset into the token rings sprite sheet.
*/
static createAssetsUVs() {
const spritesheet = TextureLoader.loader.getCache(CONFIG.Token.ring.spritesheet);
if ( !spritesheet ) throw new Error("TokenRing UV generation failed because no spritesheet was loaded!");
this.baseTexture = spritesheet.baseTexture;
this.texturesData = {};
this.#ringData = [];
const {
defaultColorBand={startRadius: 0.59, endRadius: 0.7225},
defaultRingColor: drc,
defaultBackgroundColor: dbc
} = spritesheet.data.config ?? {};
const defaultRingColor = Color.from(drc);
const defaultBackgroundColor = Color.from(dbc);
const validDefaultRingColor = defaultRingColor.valid ? defaultRingColor.littleEndian : null;
const validDefaultBackgroundColor = defaultBackgroundColor.valid ? defaultBackgroundColor.littleEndian : null;
const frames = Object.keys(spritesheet.data.frames || {});
for ( const asset of frames ) {
const assetTexture = PIXI.Assets.cache.get(asset);
if ( !assetTexture ) continue;
// Extracting texture UVs
const frame = assetTexture.frame;
const textureUvs = new PIXI.TextureUvs();
textureUvs.set(frame, assetTexture.baseTexture, assetTexture.rotate);
this.texturesData[asset] = {
UVs: textureUvs.uvsFloat32,
center: {
x: frame.center.x / assetTexture.baseTexture.width,
y: frame.center.y / assetTexture.baseTexture.height
}
};
// Skip background assets
if ( asset.includes("-bkg") ) continue;
// Extracting and determining final colors
const { ringColor: rc, backgroundColor: bc, colorBand, gridTarget, ringThickness=this.#defaultRingThickness }
= spritesheet.data.frames[asset] || {};
const ringColor = Color.from(rc);
const backgroundColor = Color.from(bc);
const finalRingColor = ringColor.valid ? ringColor.littleEndian : validDefaultRingColor;
const finalBackgroundColor = backgroundColor.valid ? backgroundColor.littleEndian : validDefaultBackgroundColor;
const subjectScaleAdjustment = 1 / (ringThickness + this.#defaultSubjectThickness);
this.#ringData.push({
ringName: asset,
bkgName: `${asset}-bkg`,
colorBand: foundry.utils.deepClone(colorBand ?? defaultColorBand),
gridTarget: gridTarget ?? 1,
defaultRingColorLittleEndian: finalRingColor,
defaultBackgroundColorLittleEndian: finalBackgroundColor,
subjectScaleAdjustment
});
}
// Sorting the rings data array
this.#ringData.sort((a, b) => a.gridTarget - b.gridTarget);
}
/* -------------------------------------------- */
/**
* Get the UVs array for a given texture name and scale correction.
* @param {string} name Name of the texture we want to get UVs.
* @param {number} [scaleCorrection=1] The scale correction applied to UVs.
* @returns {Float32Array}
*/
static getTextureUVs(name, scaleCorrection=1) {
if ( scaleCorrection === 1 ) return this.texturesData[name].UVs;
const tUVs = this.texturesData[name].UVs;
const c = this.texturesData[name].center;
const UVs = new Float32Array(8);
for ( let i=0; i<8; i+=2 ) {
UVs[i] = ((tUVs[i] - c.x) * scaleCorrection) + c.x;
UVs[i+1] = ((tUVs[i+1] - c.y) * scaleCorrection) + c.y;
}
return UVs;
}
/* -------------------------------------------- */
/**
* Get ring and background names for a given size.
* @param {number} size The size to match (grid size dimension)
* @returns {{bkgName: string, ringName: string, colorBand: RingColorBand}}
*/
static getRingDataBySize(size) {
if ( !Number.isFinite(size) || !this.#ringData.length ) {
return {
ringName: undefined,
bkgName: undefined,
colorBand: undefined,
defaultRingColorLittleEndian: null,
defaultBackgroundColorLittleEndian: null,
subjectScaleAdjustment: null
};
}
const rings = this.#ringData.map(r => [Math.abs(r.gridTarget - size), r]);
// Sort rings on proximity to target size
rings.sort((a, b) => a[0] - b[0]);
// Choose the closest ring, access the second element of the first array which is the ring data object
const closestRing = rings[0][1];
return {
ringName: closestRing.ringName,
bkgName: closestRing.bkgName,
colorBand: closestRing.colorBand,
defaultRingColorLittleEndian: closestRing.defaultRingColorLittleEndian,
defaultBackgroundColorLittleEndian: closestRing.defaultBackgroundColorLittleEndian,
subjectScaleAdjustment: closestRing.subjectScaleAdjustment
};
}
/* -------------------------------------------- */
/* Attributes */
/* -------------------------------------------- */
/** @type {string} */
ringName;
/** @type {string} */
bkgName;
/** @type {Float32Array} */
ringUVs;
/** @type {Float32Array} */
bkgUVs;
/** @type {number} */
ringColorLittleEndian = 0xFFFFFF; // Little endian format => BBGGRR
/** @type {number} */
bkgColorLittleEndian = 0xFFFFFF; // Little endian format => BBGGRR
/** @type {number|null} */
defaultRingColorLittleEndian = null;
/** @type {number|null} */
defaultBackgroundColorLittleEndian = null;
/** @type {number} */
effects = 0;
/** @type {number} */
scaleCorrection = 1;
/** @type {number} */
scaleAdjustmentX = 1;
/** @type {number} */
scaleAdjustmentY = 1;
/** @type {number} */
subjectScaleAdjustment = 1;
/** @type {number} */
textureScaleAdjustment = 1;
/** @type {RingColorBand} */
colorBand;
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* Reference to the token that should be animated.
* @type {Token|void}
*/
get token() {
return this.#token.deref();
}
/**
* Weak reference to the token being animated.
* @type {WeakRef<Token>}
*/
#token;
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* Configure the sprite mesh.
* @param {PrimarySpriteMesh} [mesh] The mesh to which TokenRing functionality is configured.
*/
configure(mesh) {
this.#configureTexture(mesh);
this.configureSize();
this.configureVisuals();
}
/* -------------------------------------------- */
/**
* Clear configuration pertaining to token ring from the mesh.
*/
clear() {
this.ringName = undefined;
this.bkgName = undefined;
this.ringUVs = undefined;
this.bkgUVs = undefined;
this.colorBand = undefined;
this.ringColorLittleEndian = 0xFFFFFF;
this.bkgColorLittleEndian = 0xFFFFFF;
this.defaultRingColorLittleEndian = null;
this.defaultBackgroundColorLittleEndian = null;
this.scaleCorrection = 1;
this.scaleAdjustmentX = 1;
this.scaleAdjustmentY = 1;
this.subjectScaleAdjustment = 1;
this.textureScaleAdjustment = 1;
const mesh = this.token.mesh;
if ( mesh ) mesh.padding = 0;
}
/* -------------------------------------------- */
/**
* Configure token ring size.
*/
configureSize() {
const mesh = this.token.mesh;
// Ring size
const size = Math.min(this.token.document.width ?? 1, this.token.document.height ?? 1);
Object.assign(this, this.constructor.getRingDataBySize(size));
// Subject scale
const scale = this.token.document.ring.subject.scale ?? this.scaleCorrection ?? 1;
this.scaleCorrection = scale;
this.ringUVs = this.constructor.getTextureUVs(this.ringName, scale);
this.bkgUVs = this.constructor.getTextureUVs(this.bkgName, scale);
// Determine the longer and shorter sides of the image
const {width: w, height: h} = this.token.mesh.texture ?? this.token.texture;
let longSide = Math.max(w, h);
let shortSide = Math.min(w, h);
// Calculate the necessary padding
let padding = (longSide - shortSide) / 2;
// Determine padding for x and y sides
let paddingX = (w < h) ? padding : 0;
let paddingY = (w > h) ? padding : 0;
// Apply mesh padding
mesh.paddingX = paddingX;
mesh.paddingY = paddingY;
// Apply adjustments
const adjustment = shortSide / longSide;
this.scaleAdjustmentX = paddingX ? adjustment : 1.0;
this.scaleAdjustmentY = paddingY ? adjustment : 1.0;
// Apply texture scale adjustment for token without a subject texture and in grid fit mode
const inferred = (this.token.document.texture.src !== this.token.document._inferRingSubjectTexture());
if ( CONFIG.Token.ring.isGridFitMode && !inferred && !this.token.document._source.ring.subject.texture ) {
this.textureScaleAdjustment = this.subjectScaleAdjustment;
}
else this.textureScaleAdjustment = 1;
}
/* -------------------------------------------- */
/**
* Configure the token ring visuals properties.
*/
configureVisuals() {
const ring = this.token.document.ring;
// Configure colors
const colors = foundry.utils.mergeObject(ring.colors, this.token.getRingColors(), {inplace: false});
const resolveColor = (color, defaultColor) => {
const resolvedColor = Color.from(color ?? 0xFFFFFF).littleEndian;
return ((resolvedColor === 0xFFFFFF) && (defaultColor !== null)) ? defaultColor : resolvedColor;
};
this.ringColorLittleEndian = resolveColor(colors?.ring, this.defaultRingColorLittleEndian);
this.bkgColorLittleEndian = resolveColor(colors?.background, this.defaultBackgroundColorLittleEndian)
// Configure effects
const effectsToApply = this.token.getRingEffects();
this.effects = ((ring.effects >= this.constructor.effects.DISABLED)
? ring.effects : this.constructor.effects.ENABLED)
| effectsToApply.reduce((acc, e) => acc |= e, 0x0);
// Mask with enabled effects for the current token ring configuration
let mask = this.effects & CONFIG.Token.ring.ringClass.effects.ENABLED;
for ( const key in CONFIG.Token.ring.effects ) {
const v = CONFIG.Token.ring.ringClass.effects[key];
if ( v !== undefined ) {
mask |= v;
}
}
this.effects &= mask;
}
/* -------------------------------------------- */
/**
* Configure dynamic token ring subject texture.
* @param {PrimarySpriteMesh} mesh The mesh being configured
*/
#configureTexture(mesh) {
const src = this.token.document.ring.subject.texture;
if ( PIXI.Assets.cache.has(src) ) {
const subjectTexture = getTexture(src);
if ( subjectTexture?.valid ) mesh.texture = subjectTexture;
}
}
/* -------------------------------------------- */
/* Animations */
/* -------------------------------------------- */
/**
* Flash the ring briefly with a certain color.
* @param {Color} color Color to flash.
* @param {CanvasAnimationOptions} animationOptions Options to customize the animation.
* @returns {Promise<boolean|void>}
*/
async flashColor(color, animationOptions={}) {
if ( Number.isNaN(color) ) return;
const defaultColorFallback = this.token.ring.defaultRingColorLittleEndian ?? 0xFFFFFF;
const configuredColor = Color.from(foundry.utils.mergeObject(
this.token.document.ring.colors,
this.token.getRingColors(),
{inplace: false}
).ring);
const originalColor = configuredColor.valid ? configuredColor.littleEndian : defaultColorFallback;
return await CanvasAnimation.animate([{
attribute: "ringColorLittleEndian",
parent: this,
from: originalColor,
to: new Color(color.littleEndian),
color: true
}], foundry.utils.mergeObject({
duration: 1600,
priority: PIXI.UPDATE_PRIORITY.HIGH,
easing: this.constructor.createSpikeEasing(.15)
}, animationOptions));
}
/* -------------------------------------------- */
/**
* Create an easing function that spikes in the center. Ideal duration is around 1600ms.
* @param {number} [spikePct=0.5] Position on [0,1] where the spike occurs.
* @returns {Function(number): number}
*/
static createSpikeEasing(spikePct=0.5) {
const scaleStart = 1 / spikePct;
const scaleEnd = 1 / (1 - spikePct);
return pt => {
if ( pt < spikePct ) return CanvasAnimation.easeInCircle(pt * scaleStart);
else return 1 - CanvasAnimation.easeOutCircle(((pt - spikePct) * scaleEnd));
};
}
/* -------------------------------------------- */
/**
* Easing function that produces two peaks before returning to the original value. Ideal duration is around 500ms.
* @param {number} pt The proportional animation timing on [0,1].
* @returns {number} The eased animation progress on [0,1].
*/
static easeTwoPeaks(pt) {
return (Math.sin((4 * Math.PI * pt) - (Math.PI / 2)) + 1) / 2;
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* To avoid breaking dnd5e.
* @deprecated since v12
* @ignore
*/
configureMesh() {}
/**
* To avoid breaking dnd5e.
* @deprecated since v12
* @ignore
*/
configureNames() {}
}

View File

@@ -0,0 +1,224 @@
/**
* The Foundry Virtual Tabletop client-side ESModule API.
* @module foundry
*/
/* ----------------------------------------- */
/* Imports for JavaScript Usage */
/* ----------------------------------------- */
// Import Commons Modules
import * as primitives from "../common/primitives/module.mjs";
import * as CONST from "../common/constants.mjs";
import * as abstract from "../common/abstract/module.mjs";
import * as documents from "../common/documents/_module.mjs";
import * as packages from "../common/packages/module.mjs";
import * as utils from "../common/utils/module.mjs";
import * as config from "../common/config.mjs";
import * as prosemirror from "../common/prosemirror/_module.mjs"
import * as grid from "../common/grid/_module.mjs";
// Import Client Modules
import * as applications from "./applications/_module.mjs";
import * as audio from "./audio/_module.mjs";
import * as canvas from "./canvas/_module.mjs";
import * as helpers from "./helpers/_module.mjs";
import * as data from "./data/_module.mjs";
import * as dice from "./dice/_module.mjs";
import {AmbientLightConfig} from "./applications/sheets/_module.mjs";
/* ----------------------------------------- */
/* Exports for ESModule and Typedoc Usage */
/* ----------------------------------------- */
/**
* Constant definitions used throughout the Foundry Virtual Tabletop framework.
*/
export * as CONST from "../common/constants.mjs";
/**
* Abstract class definitions for fundamental concepts used throughout the Foundry Virtual Tabletop framework.
*/
export * as abstract from "../common/abstract/module.mjs";
/**
* Application configuration options
*/
export * as config from "../common/config.mjs";
/**
* Document definitions used throughout the Foundry Virtual Tabletop framework.
*/
export * as documents from "../common/documents/_module.mjs";
/**
* Package data definitions, validations, and schema.
*/
export * as packages from "../common/packages/module.mjs";
/**
* Utility functions providing helpful functionality.
*/
export * as utils from "../common/utils/module.mjs";
/**
* A library for providing rich text editing using ProseMirror within the Foundry Virtual Tabletop game client.
*/
export * as prosemirror from "../common/prosemirror/_module.mjs";
/**
* Grid classes.
*/
export * as grid from "../common/grid/_module.mjs";
/**
* A library for rendering and managing HTML user interface elements within the Foundry Virtual Tabletop game client.
*/
export * as applications from "./applications/_module.mjs";
/**
* A library for controlling audio playback within the Foundry Virtual Tabletop game client.
*/
export * as audio from "./audio/_module.mjs";
/**
* A submodule defining concepts related to canvas rendering.
*/
export * as canvas from "./canvas/_module.mjs";
/**
* A submodule containing core helper classes.
*/
export * as helpers from "./helpers/_module.mjs";
/**
* A module which defines data architecture components.
*/
export * as data from "./data/_module.mjs";
/**
* A module for parsing and executing dice roll syntax.
*/
export * as dice from "./dice/_module.mjs";
/**
* Shared importable types.
*/
export * as types from "../common/types.mjs";
/* ----------------------------------------- */
/* Client-Side Globals */
/* ----------------------------------------- */
// Global foundry namespace
globalThis.foundry = {
CONST, // Commons
abstract,
utils,
documents,
packages,
config,
prosemirror,
grid,
applications, // Client
audio,
canvas,
helpers,
data,
dice
};
globalThis.CONST = CONST;
// Specifically expose some global classes
Object.assign(globalThis, {
Color: utils.Color,
Collection: utils.Collection,
ProseMirror: prosemirror,
Roll: dice.Roll
});
// Immutable constants
for ( const c of Object.values(CONST) ) {
Object.freeze(c);
}
/* ----------------------------------------- */
/* Backwards Compatibility */
/* ----------------------------------------- */
/** @deprecated since v12 */
addBackwardsCompatibilityReferences({
AudioHelper: "audio.AudioHelper",
AmbientSoundConfig: "applications.sheets.AmbientSoundConfig",
AmbientLightConfig: "applications.sheets.AmbientLightConfig",
Sound: "audio.Sound",
RollTerm: "dice.terms.RollTerm",
MersenneTwister: "dice.MersenneTwister",
DiceTerm: "dice.terms.DiceTerm",
MathTerm: "dice.terms.FunctionTerm",
NumericTerm: "dice.terms.NumericTerm",
OperatorTerm: "dice.terms.OperatorTerm",
ParentheticalTerm: "dice.terms.ParentheticalTerm",
PoolTerm: "dice.terms.PoolTerm",
StringTerm: "dice.terms.StringTerm",
Coin: "dice.terms.Coin",
Die: "dice.terms.Die",
FateDie: "dice.terms.FateDie",
twist: "dice.MersenneTwister",
LightSource: "canvas.sources.PointLightSource",
DarknessSource: "canvas.sources.PointDarknessSource",
GlobalLightSource: "canvas.sources.GlobalLightSource",
VisionSource: "canvas.sources.PointVisionSource",
SoundSource: "canvas.sources.PointSoundSource",
MovementSource: "canvas.sources.PointMovementSource",
PermissionConfig: "applications.apps.PermissionConfig",
BaseGrid: "grid.GridlessGrid",
SquareGrid: "grid.SquareGrid",
HexagonalGrid: "grid.HexagonalGrid",
GridHex: "grid.GridHex",
UserConfig: "applications.sheets.UserConfig",
WordTree: "utils.WordTree"
}, {since: 12, until: 14});
/** @deprecated since v12 */
for ( let [k, v] of Object.entries(utils) ) {
if ( !(k in globalThis) ) {
Object.defineProperty(globalThis, k, {
get() {
foundry.utils.logCompatibilityWarning(`You are accessing globalThis.${k} which must now be accessed via `
+ `foundry.utils.${k}`, {since: 12, until: 14, once: true});
return v;
}
});
}
}
/**
* Add Foundry Virtual Tabletop ESModule exports to the global scope for backwards compatibility
* @param {object} mapping A mapping of class name to ESModule export path
* @param {object} [options] Options which modify compatible references
* @param {number} [options.since] Deprecated since generation
* @param {number} [options.until] Backwards compatibility provided until generation
*/
function addBackwardsCompatibilityReferences(mapping, {since, until}={}) {
const properties = Object.fromEntries(Object.entries(mapping).map(([name, path]) => {
return [name, {
get() {
foundry.utils.logCompatibilityWarning(`You are accessing the global "${name}" which is now namespaced under `
+ `foundry.${path}`, {since, until, once: true});
return foundry.utils.getProperty(globalThis.foundry, path);
}
}]
}));
Object.defineProperties(globalThis, properties);
}
/* ----------------------------------------- */
/* Dispatch Ready Event */
/* ----------------------------------------- */
if ( globalThis.window ) {
console.log(`${CONST.vtt} | Foundry Virtual Tabletop ESModule loaded`);
const ready = new Event("FoundryFrameworkLoaded");
globalThis.dispatchEvent(ready);
}

View File

@@ -0,0 +1,4 @@
/** @module foundry.data */
export * from "../../common/data/module.mjs";
export * as regionBehaviors from "./region-behaviors/_module.mjs";
export {default as ClientDatabaseBackend} from "./client-backend.mjs";

View File

@@ -0,0 +1,607 @@
import DatabaseBackend from "../../common/abstract/backend.mjs";
/**
* @typedef {import("../../common/abstract/_types.mjs").DatabaseAction} DatabaseAction
* @typedef {import("../../common/abstract/_types.mjs").DatabaseOperation} DatabaseOperation
* @typedef {import("../../common/abstract/_types.mjs").DatabaseGetOperation} DatabaseGetOperation
* @typedef {import("../../common/abstract/_types.mjs").DatabaseCreateOperation} DatabaseCreateOperation
* @typedef {import("../../common/abstract/_types.mjs").DatabaseUpdateOperation} DatabaseUpdateOperation
* @typedef {import("../../common/abstract/_types.mjs").DatabaseDeleteOperation} DatabaseDeleteOperation
* @typedef {import("../../common/abstract/_types.mjs").DocumentSocketRequest} DocumentSocketRequest
*/
/**
* The client-side database backend implementation which handles Document modification operations.
* @alias foundry.data.ClientDatabaseBackend
*/
export default class ClientDatabaseBackend extends DatabaseBackend {
/* -------------------------------------------- */
/* Get Operations */
/* -------------------------------------------- */
/**
* @override
* @ignore
*/
async _getDocuments(documentClass, operation, user) {
const request = ClientDatabaseBackend.#buildRequest(documentClass, "get", operation);
const response = await ClientDatabaseBackend.#dispatchRequest(request);
if ( operation.index ) return response.result;
return response.result.map(data => documentClass.fromSource(data, {pack: operation.pack}));
}
/* -------------------------------------------- */
/* Create Operations */
/* -------------------------------------------- */
/**
* @override
* @ignore
*/
async _createDocuments(documentClass, operation, user) {
user ||= game.user;
await ClientDatabaseBackend.#preCreateDocumentArray(documentClass, operation, user);
if ( !operation.data.length ) return [];
/** @deprecated since v12 */
// Legacy support for temporary creation option
if ( "temporary" in operation ) {
foundry.utils.logCompatibilityWarning("It is no longer supported to create temporary documents using the " +
"Document.createDocuments API. Use the new Document() constructor instead.", {since: 12, until: 14});
if ( operation.temporary ) return operation.data;
}
const request = ClientDatabaseBackend.#buildRequest(documentClass, "create", operation);
const response = await ClientDatabaseBackend.#dispatchRequest(request);
return this.#handleCreateDocuments(response);
}
/* -------------------------------------------- */
/**
* Perform a standardized pre-creation workflow for all Document types.
* This workflow mutates the operation data array.
* @param {typeof ClientDocument} documentClass
* @param {DatabaseCreateOperation} operation
* @param {User} user
*/
static async #preCreateDocumentArray(documentClass, operation, user) {
const {data, noHook, pack, parent, ...options} = operation;
const type = documentClass.documentName;
const toCreate = [];
const documents = [];
for ( let d of data ) {
// Clean input data
d = ( d instanceof foundry.abstract.DataModel ) ? d.toObject() : foundry.utils.expandObject(d);
d = documentClass.migrateData(d);
const createData = foundry.utils.deepClone(d); // Copy for later passing original input data to preCreate
// Create pending document
let doc;
try {
doc = new documentClass(createData, {parent, pack});
} catch(err) {
Hooks.onError("ClientDatabaseBackend##preCreateDocumentArray", err, {id: d._id, log: "error", notify: "error"});
continue;
}
// Call per-document workflows
let documentAllowed = await doc._preCreate(d, options, user) ?? true;
documentAllowed &&= (noHook || Hooks.call(`preCreate${type}`, doc, d, options, user.id));
if ( documentAllowed === false ) {
console.debug(`${vtt} | ${type} creation prevented by _preCreate`);
continue;
}
documents.push(doc);
toCreate.push(d);
}
operation.data = toCreate;
if ( !documents.length ) return;
// Call final pre-operation workflow
Object.assign(operation, options); // Hooks may have changed options
const operationAllowed = await documentClass._preCreateOperation(documents, operation, user);
if ( operationAllowed === false ) {
console.debug(`${vtt} | ${type} creation operation prevented by _preCreateOperation`);
operation.data = [];
}
else operation.data = documents;
}
/* -------------------------------------------- */
/**
* Handle a SocketResponse from the server when one or multiple documents were created.
* @param {foundry.abstract.DocumentSocketResponse} response A document modification socket response
* @returns {Promise<ClientDocument[]>} An Array of created Document instances
*/
async #handleCreateDocuments(response) {
const {type, operation, result, userId} = response;
const documentClass = getDocumentClass(type);
const parent = /** @type {ClientDocument|null} */ operation.parent = await this._getParent(operation);
const collection = ClientDatabaseBackend.#getCollection(documentClass, operation);
const user = game.users.get(userId);
const {pack, parentUuid, syntheticActorUpdate, ...options} = operation;
operation.data = response.result; // Record created data objects back to the operation
// Initial descendant document events
const preArgs = [result, options, userId];
parent?._dispatchDescendantDocumentEvents("preCreate", collection.name, preArgs);
// Create documents and prepare post-creation callback functions
const callbacks = result.map(data => {
const doc = collection.createDocument(data, {parent, pack});
collection.set(doc.id, doc, options);
return () => {
doc._onCreate(data, options, userId);
Hooks.callAll(`create${type}`, doc, options, userId);
return doc;
}
});
parent?.reset();
let documents = callbacks.map(fn => fn());
// Call post-operation workflows
const postArgs = [documents, result, options, userId];
parent?._dispatchDescendantDocumentEvents("onCreate", collection.name, postArgs);
await documentClass._onCreateOperation(documents, operation, user);
collection._onModifyContents("create", documents, result, operation, user);
// Log and return result
if ( CONFIG.debug.documents ) this._logOperation("Created", type, documents, {level: "info", parent, pack});
if ( syntheticActorUpdate ) documents = ClientDatabaseBackend.#adjustActorDeltaResponse(documents);
return documents;
}
/* -------------------------------------------- */
/* Update Operations */
/* -------------------------------------------- */
/**
* @override
* @ignore
*/
async _updateDocuments(documentClass, operation, user) {
user ||= game.user;
await ClientDatabaseBackend.#preUpdateDocumentArray(documentClass, operation, user);
if ( !operation.updates.length ) return [];
const request = ClientDatabaseBackend.#buildRequest(documentClass, "update", operation);
const response = await ClientDatabaseBackend.#dispatchRequest(request);
return this.#handleUpdateDocuments(response);
}
/* -------------------------------------------- */
/**
* Perform a standardized pre-update workflow for all Document types.
* This workflow mutates the operation updates array.
* @param {typeof ClientDocument} documentClass
* @param {DatabaseUpdateOperation} operation
* @param {User} user
*/
static async #preUpdateDocumentArray(documentClass, operation, user) {
const collection = ClientDatabaseBackend.#getCollection(documentClass, operation);
const type = documentClass.documentName;
const {updates, restoreDelta, noHook, pack, parent, ...options} = operation;
// Ensure all Documents which are update targets have been loaded
await ClientDatabaseBackend.#loadCompendiumDocuments(collection, updates);
// Iterate over requested changes
const toUpdate = [];
const documents = [];
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 target document and the request changes
let changes;
if ( update instanceof foundry.abstract.DataModel ) changes = update.toObject();
else changes = foundry.utils.expandObject(update);
const doc = collection.get(update._id, {strict: true, invalid: true});
// Migrate provided changes, including document sub-type
const addType = ("type" in doc) && !("type" in changes);
if ( addType ) changes.type = doc.type;
changes = documentClass.migrateData(changes);
// Perform pre-update operations
let documentAllowed = await doc._preUpdate(changes, options, user) ?? true;
documentAllowed &&= (noHook || Hooks.call(`preUpdate${type}`, doc, changes, options, user.id));
if ( documentAllowed === false ) {
console.debug(`${vtt} | ${type} update prevented during pre-update`);
continue;
}
// Attempt updating the document to validate the changes
let diff = {};
try {
diff = doc.updateSource(changes, {dryRun: true, fallback: false, 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 = documentClass.shimData(diff); // Re-apply shims for backwards compatibility in _preUpdate hooks
}
else if ( addType ) delete changes.type;
documents.push(doc);
toUpdate.push(changes);
}
operation.updates = toUpdate;
if ( !toUpdate.length ) return;
// Call final pre-operation workflow
Object.assign(operation, options); // Hooks may have changed options
const operationAllowed = await documentClass._preUpdateOperation(documents, operation, user);
if ( operationAllowed === false ) {
console.debug(`${vtt} | ${type} creation operation prevented by _preUpdateOperation`);
operation.updates = [];
}
}
/* -------------------------------------------- */
/**
* Handle a SocketResponse from the server when one or multiple documents were updated.
* @param {foundry.abstract.DocumentSocketResponse} response A document modification socket response
* @returns {Promise<ClientDocument[]>} An Array of updated Document instances
*/
async #handleUpdateDocuments(response) {
const {type, operation, result, userId} = response;
const documentClass = getDocumentClass(type);
const parent = /** @type {ClientDocument|null} */ operation.parent = await this._getParent(operation);
const collection = ClientDatabaseBackend.#getCollection(documentClass, operation);
const user = game.users.get(userId);
const {pack, parentUuid, syntheticActorUpdate, ...options} = operation;
operation.updates = response.result; // Record update data objects back to the operation
// Ensure all Documents which are update targets have been loaded.
await ClientDatabaseBackend.#loadCompendiumDocuments(collection, operation.updates);
// Pre-operation actions
const preArgs = [result, options, userId];
parent?._dispatchDescendantDocumentEvents("preUpdate", collection.name, preArgs);
// Perform updates and create a callback function for each document
const callbacks = [];
const changes = [];
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(() => {
change = documentClass.shimData(change);
doc._onUpdate(change, options, userId);
Hooks.callAll(`update${type}`, doc, change, options, userId);
changes.push(change);
return doc;
});
}
parent?.reset();
let documents = callbacks.map(fn => fn());
operation.updates = changes;
// Post-operation actions
const postArgs = [documents, changes, options, userId];
parent?._dispatchDescendantDocumentEvents("onUpdate", collection.name, postArgs);
await documentClass._onUpdateOperation(documents, operation, user);
collection._onModifyContents("update", documents, changes, operation, user);
// Log and return result
if ( CONFIG.debug.documents ) this._logOperation("Updated", type, documents, {level: "debug", parent, pack});
if ( syntheticActorUpdate ) documents = ClientDatabaseBackend.#adjustActorDeltaResponse(documents);
return documents;
}
/* -------------------------------------------- */
/* Delete Operations */
/* -------------------------------------------- */
/**
* @override
* @ignore
*/
async _deleteDocuments(documentClass, operation, user) {
user ||= game.user;
await ClientDatabaseBackend.#preDeleteDocumentArray(documentClass, operation, user);
if ( !operation.ids.length ) return operation.ids;
const request = ClientDatabaseBackend.#buildRequest(documentClass, "delete", operation);
const response = await ClientDatabaseBackend.#dispatchRequest(request);
return this.#handleDeleteDocuments(response);
}
/* -------------------------------------------- */
/**
* Perform a standardized pre-delete workflow for all Document types.
* This workflow mutates the operation ids array.
* @param {typeof ClientDocument} documentClass
* @param {DatabaseDeleteOperation} operation
* @param {User} user
*/
static async #preDeleteDocumentArray(documentClass, operation, user) {
let {ids, deleteAll, noHook, pack, parent, ...options} = operation;
const collection = ClientDatabaseBackend.#getCollection(documentClass, operation);
const type = documentClass.documentName;
// Ensure all Documents which are deletion targets have been loaded
if ( deleteAll ) ids = Array.from(collection.index?.keys() ?? collection.keys());
await ClientDatabaseBackend.#loadCompendiumDocuments(collection, ids);
// Iterate over ids requested for deletion
const toDelete = [];
const documents = [];
for ( const id of ids ) {
const doc = collection.get(id, {strict: true, invalid: true});
let documentAllowed = await doc._preDelete(options, user) ?? true;
documentAllowed &&= (noHook || Hooks.call(`preDelete${type}`, doc, options, user.id));
if ( documentAllowed === false ) {
console.debug(`${vtt} | ${type} deletion prevented during pre-delete`);
continue;
}
toDelete.push(id);
documents.push(doc);
}
operation.ids = toDelete;
if ( !toDelete.length ) return;
// Call final pre-operation workflow
Object.assign(operation, options); // Hooks may have changed options
const operationAllowed = await documentClass._preDeleteOperation(documents, operation, user);
if ( operationAllowed === false ) {
console.debug(`${vtt} | ${type} creation operation prevented by _preDeleteOperation`);
operation.ids = [];
}
}
/* -------------------------------------------- */
/**
* Handle a SocketResponse from the server where Documents are deleted.
* @param {foundry.abstract.DocumentSocketResponse} response A document modification socket response
* @returns {Promise<ClientDocument[]>} An Array of deleted Document instances
*/
async #handleDeleteDocuments(response) {
const {type, operation, result, userId} = response;
const documentClass = getDocumentClass(type);
const parent = /** @type {ClientDocument|null} */ operation.parent = await this._getParent(operation);
const collection = ClientDatabaseBackend.#getCollection(documentClass, operation);
const user = game.users.get(userId);
const {deleteAll, pack, parentUuid, syntheticActorUpdate, ...options} = operation;
operation.ids = response.result; // Record deleted document ids back to the operation
await ClientDatabaseBackend.#loadCompendiumDocuments(collection, operation.ids);
// Pre-operation actions
const preArgs = [result, options, userId];
parent?._dispatchDescendantDocumentEvents("preDelete", collection.name, preArgs);
// Perform deletions and create a callback function for each document
const callbacks = [];
const ids = [];
for ( const id of result ) {
const doc = collection.get(id, {strict: false});
if ( !doc ) continue;
collection.delete(id);
callbacks.push(() => {
doc._onDelete(options, userId);
Hooks.callAll(`delete${type}`, doc, options, userId);
ids.push(id);
return doc;
});
}
parent?.reset();
let documents = callbacks.map(fn => fn());
operation.ids = ids;
// Post-operation actions
const postArgs = [documents, ids, options, userId];
parent?._dispatchDescendantDocumentEvents("onDelete", collection.name, postArgs);
await documentClass._onDeleteOperation(documents, operation, user);
collection._onModifyContents("delete", documents, ids, operation, user);
// Log and return result
if ( CONFIG.debug.documents ) this._logOperation("Deleted", type, documents, {level: "info", parent, pack});
if ( syntheticActorUpdate ) documents = ClientDatabaseBackend.#adjustActorDeltaResponse(documents);
return documents;
}
/* -------------------------------------------- */
/* Socket Workflows */
/* -------------------------------------------- */
/**
* Activate the Socket event listeners used to receive responses from events which modify database documents
* @param {Socket} socket The active game socket
* @internal
* @ignore
*/
activateSocketListeners(socket) {
socket.on("modifyDocument", this.#onModifyDocument.bind(this));
}
/* -------------------------------------------- */
/**
* Handle a socket response broadcast back from the server.
* @param {foundry.abstract.DocumentSocketResponse} response A document modification socket response
*/
#onModifyDocument(response) {
switch ( response.action ) {
case "create":
this.#handleCreateDocuments(response);
break;
case "update":
this.#handleUpdateDocuments(response);
break;
case "delete":
this.#handleDeleteDocuments(response);
break;
default:
throw new Error(`Invalid Document modification action ${response.action} provided`);
}
}
/* -------------------------------------------- */
/* 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());
}
/* -------------------------------------------- */
/** @override */
_log(level, message) {
globalThis.logger[level](`${vtt} | ${message}`);
}
/* -------------------------------------------- */
/**
* Obtain the document collection for a given Document class and database operation.
* @param {typeof foundry.abstract.Document} documentClass The Document class being operated upon
* @param {object} operation The database operation being performed
* @param {ClientDocument|null} operation.parent A parent Document, if applicable
* @param {string|null} operation.pack A compendium pack identifier, if applicable
* @returns {DocumentCollection|CompendiumCollection} The relevant collection instance for this request
*/
static #getCollection(documentClass, {parent, pack}) {
const documentName = documentClass.documentName;
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);
}
/* -------------------------------------------- */
/**
* Structure a database operation as a web socket request.
* @param {typeof foundry.abstract.Document} documentClass
* @param {DatabaseAction} action
* @param {DatabaseOperation} operation
* @returns {DocumentSocketRequest}
*/
static #buildRequest(documentClass, action, operation) {
const request = {type: documentClass.documentName, action, operation};
if ( operation.parent ) { // Don't send full parent data
operation.parentUuid = operation.parent.uuid;
ClientDatabaseBackend.#adjustActorDeltaRequest(documentClass, request);
delete operation.parent;
}
return request;
}
/* -------------------------------------------- */
/**
* Dispatch a document modification socket request to the server.
* @param {DocumentSocketRequest} request
* @returns {foundry.abstract.DocumentSocketResponse}
*/
static async #dispatchRequest(request) {
const responseData = await SocketInterface.dispatch("modifyDocument", request);
return new foundry.abstract.DocumentSocketResponse(responseData);
}
/* -------------------------------------------- */
/**
* Ensure the given list of documents is loaded into the compendium collection so that they can be retrieved by
* subsequent operations.
* @param {Collection} collection The candidate collection.
* @param {object[]|string[]} documents An array of update deltas, or IDs, depending on the operation.
*/
static async #loadCompendiumDocuments(collection, documents) {
// Ensure all Documents which are update targets have been loaded
if ( collection instanceof CompendiumCollection ) {
const ids = documents.reduce((arr, doc) => {
const id = doc._id ?? doc;
if ( id && !collection.has(id) ) arr.push(id);
return arr;
}, []);
await collection.getDocuments({_id__in: ids});
}
}
/* -------------------------------------------- */
/* Token and ActorDelta Special Case */
/* -------------------------------------------- */
/**
* Augment a database operation with alterations needed to support ActorDelta and TokenDocuments.
* @param {typeof foundry.abstract.Document} documentClass The document class being operated upon
* @param {DocumentSocketRequest} request The document modification socket request
*/
static #adjustActorDeltaRequest(documentClass, request) {
const operation = request.operation;
const parent = operation.parent;
// Translate updates to a token actor to the token's ActorDelta instead.
if ( foundry.utils.isSubclass(documentClass, Actor) && (parent instanceof TokenDocument) ) {
request.type = "ActorDelta";
if ( "updates" in operation ) operation.updates[0]._id = parent.delta.id;
operation.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(parent.uuid);
operation.parentUuid = [token.delta.uuid, embedded.slice(4).join(".")].filterJoin(".");
}
}
/* -------------------------------------------- */
/**
* Retrieve a Document's Token ancestor, if it exists.
* @param {Document|null} 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 ClientDatabaseBackend.#getTokenAncestor(parent.parent);
}
/* -------------------------------------------- */
/**
* Build a CRUD response.
* @param {ActorDelta[]} documents An array of ActorDelta documents modified by a database workflow
* @returns {foundry.abstract.Document[]} The modified ActorDelta documents mapped to their synthetic Actor
*/
static #adjustActorDeltaResponse(documents) {
return documents.map(delta => delta.syntheticActor);
}
}

View File

@@ -0,0 +1,10 @@
/** @module foundry.data.regionBehaviors */
export {default as RegionBehaviorType} from "./base.mjs";
export {default as AdjustDarknessLevelRegionBehaviorType} from "./adjust-darkness-level.mjs";
export {default as DisplayScrollingTextRegionBehaviorType} from "./display-scrolling-text.mjs";
export {default as ExecuteMacroRegionBehaviorType} from "./execute-macro.mjs";
export {default as ExecuteScriptRegionBehaviorType} from "./execute-script.mjs";
export {default as PauseGameRegionBehaviorType} from "./pause-game.mjs";
export {default as SuppressWeatherRegionBehaviorType} from "./suppress-weather.mjs";
export {default as TeleportTokenRegionBehaviorType} from "./teleport-token.mjs";
export {default as ToggleBehaviorRegionBehaviorType} from "./toggle-behavior.mjs";

View File

@@ -0,0 +1,136 @@
import RegionBehaviorType from "./base.mjs";
import {REGION_EVENTS} from "../../../common/constants.mjs";
import RegionMesh from "../../canvas/regions/mesh.mjs";
import * as fields from "../../../common/data/fields.mjs";
/**
* The data model for a behavior that allows to suppress weather effects within the Region
*/
export default class AdjustDarknessLevelRegionBehaviorType extends RegionBehaviorType {
/** @override */
static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.adjustDarknessLevel", "BEHAVIOR.TYPES.base"];
/* ---------------------------------------- */
/**
* Darkness level behavior modes.
* @enum {number}
*/
static get MODES() {
return AdjustDarknessLevelRegionBehaviorType.#MODES;
}
static #MODES = Object.freeze({
/**
* Override the darkness level with the modifier.
*/
OVERRIDE: 0,
/**
* Brighten the darkness level: `darknessLevel * (1 - modifier)`
*/
BRIGHTEN: 1,
/**
* Darken the darkness level: `1 - (1 - darknessLevel) * (1 - modifier)`.
*/
DARKEN: 2
});
/* ---------------------------------------- */
/** @override */
static defineSchema() {
return {
mode: new fields.NumberField({required: true, blank: false, choices: Object.fromEntries(Object.entries(this.MODES)
.map(([key, value]) => [value, `BEHAVIOR.TYPES.adjustDarknessLevel.MODES.${key}.label`])),
initial: this.MODES.OVERRIDE, validationError: "must be a value in AdjustDarknessLevelRegionBehaviorType.MODES"}),
modifier: new fields.AlphaField({initial: 0, step: 0.01})
};
}
/* ---------------------------------------- */
/**
* Called when the status of the weather behavior is changed.
* @param {RegionEvent} event
* @this {AdjustDarknessLevelRegionBehaviorType}
*/
static async #onBehaviorStatus(event) {
// Create mesh
if ( event.data.viewed === true ) {
// Create darkness level mesh
const dlMesh = new RegionMesh(this.region.object, AdjustDarknessLevelRegionShader);
if ( canvas.performance.mode > CONST.CANVAS_PERFORMANCE_MODES.LOW ) {
dlMesh._blurFilter = canvas.createBlurFilter(8, 2);
dlMesh.filters = [dlMesh._blurFilter];
}
// Create illumination mesh
const illMesh = new RegionMesh(this.region.object, IlluminationDarknessLevelRegionShader);
// Common properties
illMesh.name = dlMesh.name = this.behavior.uuid;
illMesh.shader.mode = dlMesh.shader.mode = this.mode;
illMesh.shader.modifier = dlMesh.shader.modifier = this.modifier;
// Adding the mesh to their respective containers
canvas.effects.illumination.darknessLevelMeshes.addChild(dlMesh);
canvas.visibility.vision.light.global.meshes.addChild(illMesh);
// Invalidate darkness level container and refresh vision if global light is enabled
canvas.effects.illumination.invalidateDarknessLevelContainer(true);
canvas.perception.update({refreshLighting: true, refreshVision: canvas.environment.globalLightSource.active});
}
// Destroy mesh
else if ( event.data.viewed === false ) {
const dlMesh = canvas.effects.illumination.darknessLevelMeshes.getChildByName(this.behavior.uuid);
if ( dlMesh._blurFilter ) canvas.blurFilters.delete(dlMesh._blurFilter);
dlMesh.destroy();
const ilMesh = canvas.visibility.vision.light.global.meshes.getChildByName(this.behavior.uuid);
ilMesh.destroy();
canvas.effects.illumination.invalidateDarknessLevelContainer(true);
canvas.perception.update({refreshLighting: true, refreshVision: canvas.environment.globalLightSource.active});
}
}
/* ---------------------------------------- */
/**
* Called when the boundary of an event has changed.
* @param {RegionEvent} event
* @this {AdjustDarknessLevelRegionBehaviorType}
*/
static async #onRegionBoundary(event) {
if ( !this.behavior.viewed ) return;
canvas.effects.illumination.invalidateDarknessLevelContainer(true);
canvas.perception.update({refreshLighting: true, refreshVision: canvas.environment.globalLightSource.active});
}
/* ---------------------------------------- */
/** @override */
static events = {
[REGION_EVENTS.BEHAVIOR_STATUS]: this.#onBehaviorStatus,
[REGION_EVENTS.REGION_BOUNDARY]: this.#onRegionBoundary
};
/* ---------------------------------------- */
/** @inheritDoc */
_onUpdate(changed, options, userId) {
super._onUpdate(changed, options, userId);
if ( !("system" in changed) || !this.behavior.viewed ) return;
const dlMesh = canvas.effects.illumination.darknessLevelMeshes.getChildByName(this.behavior.uuid);
dlMesh.shader.mode = this.mode;
dlMesh.shader.modifier = this.modifier;
const ilMesh = canvas.visibility.vision.light.global.meshes.getChildByName(this.behavior.uuid);
ilMesh.shader.mode = this.mode;
ilMesh.shader.modifier = this.modifier;
canvas.effects.illumination.invalidateDarknessLevelContainer(true);
canvas.perception.update({refreshLighting: true, refreshVision: canvas.environment.globalLightSource.active});
}
}

View File

@@ -0,0 +1,101 @@
import TypeDataModel from "../../../common/abstract/type-data.mjs";
import * as fields from "../../../common/data/fields.mjs";
/**
* The data model for a behavior that receives Region events.
* @extends TypeDataModel
* @memberof data.behaviors
* @abstract
*
* @property {Set<string>} events The Region events that are handled by the behavior.
*/
export default class RegionBehaviorType extends TypeDataModel {
/**
* Create the events field.
* @param {object} options Options which configure how the events field is declared
* @param {string[]} [options.events] The event names to restrict to.
* @param {string[]} [options.initial] The initial set of events that should be default for the field
* @returns {fields.SetField}
* @protected
*/
static _createEventsField({events, initial}={}) {
const setFieldOptions = {
label: "BEHAVIOR.TYPES.base.FIELDS.events.label",
hint: "BEHAVIOR.TYPES.base.FIELDS.events.hint"
};
if ( initial ) setFieldOptions.initial = initial;
return new fields.SetField(new fields.StringField({
required: true,
choices: Object.values(CONST.REGION_EVENTS).reduce((obj, e) => {
if ( events && !events.includes(e) ) return obj;
obj[e] = `REGION.EVENTS.${e}.label`;
return obj;
}, {})
}), setFieldOptions);
}
/* ---------------------------------------- */
/**
* @callback EventBehaviorStaticHandler Run in the context of a {@link RegionBehaviorType}.
* @param {RegionEvent} event
* @returns {Promise<void>}
*/
/**
* A RegionBehaviorType may register to always receive certain events by providing a record of handler functions.
* These handlers are called with the behavior instance as its bound scope.
* @type {Record<string, EventBehaviorStaticHandler>}
*/
static events = {};
/* ---------------------------------------- */
/**
* The events that are handled by the behavior.
* @type {Set<string>}
*/
events = this.events ?? new Set();
/* ---------------------------------------- */
/**
* A convenience reference to the RegionBehavior which contains this behavior sub-type.
* @type {RegionBehavior|null}
*/
get behavior() {
return this.parent;
}
/* ---------------------------------------- */
/**
* A convenience reference to the RegionDocument which contains this behavior sub-type.
* @type {RegionDocument|null}
*/
get region() {
return this.behavior?.region ?? null;
}
/* ---------------------------------------- */
/**
* A convenience reference to the Scene which contains this behavior sub-type.
* @type {Scene|null}
*/
get scene() {
return this.behavior?.scene ?? null;
}
/* ---------------------------------------- */
/**
* Handle the Region event.
* @param {RegionEvent} event The Region event
* @returns {Promise<void>}
* @protected
* @internal
*/
async _handleRegionEvent(event) {}
}

View File

@@ -0,0 +1,125 @@
import RegionBehaviorType from "./base.mjs";
import {REGION_EVENTS} from "../../../common/constants.mjs";
import * as fields from "../../../common/data/fields.mjs";
/**
* The data model for a behavior that displays scrolling text above a token when one of the subscribed events occurs.
*
* @property {boolean} once Disable the behavior after it triggers once
* @property {string} text The text to display
* @property {string} color Optional color setting for the text
* @property {number} visibility Which users the scrolling text will display for
(see {@link DisplayScrollingTextRegionBehaviorType.VISIBILITY_MODES})
*/
export default class DisplayScrollingTextRegionBehaviorType extends RegionBehaviorType {
/** @override */
static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.displayScrollingText", "BEHAVIOR.TYPES.base"];
/* ---------------------------------------- */
/**
* Text visibility behavior modes.
* @enum {number}
*/
static get VISIBILITY_MODES() {
return DisplayScrollingTextRegionBehaviorType.#VISIBILITY_MODES;
}
static #VISIBILITY_MODES = Object.freeze({
/**
* Display only for gamemaster users
*/
GAMEMASTER: 0,
/**
* Display only for users with observer permissions on the triggering token (and for the GM)
*/
OBSERVER: 1,
/**
* Display for all users
*/
ANYONE: 2,
});
/* ---------------------------------------- */
/** @override */
static defineSchema() {
return {
events: this._createEventsField({events: [
REGION_EVENTS.TOKEN_ENTER,
REGION_EVENTS.TOKEN_EXIT,
REGION_EVENTS.TOKEN_MOVE,
REGION_EVENTS.TOKEN_MOVE_IN,
REGION_EVENTS.TOKEN_MOVE_OUT,
REGION_EVENTS.TOKEN_TURN_START,
REGION_EVENTS.TOKEN_TURN_END,
REGION_EVENTS.TOKEN_ROUND_START,
REGION_EVENTS.TOKEN_ROUND_END
]}),
text: new fields.StringField({required: true}),
color: new fields.ColorField({required: true, nullable: false, initial: "#ffffff"}),
visibility: new fields.NumberField({
required: true,
choices: Object.entries(this.VISIBILITY_MODES).reduce((obj, [key, value]) => {
obj[value] = `BEHAVIOR.TYPES.displayScrollingText.VISIBILITY_MODES.${key}.label`;
return obj;
}, {}),
initial: this.VISIBILITY_MODES.ANYONE,
validationError: "must be a value in DisplayScrollingTextRegionBehaviorType.VISIBILITY_MODES"}),
once: new fields.BooleanField()
};
}
/* ---------------------------------------- */
/**
* Display the scrolling text to the current User?
* @param {RegionEvent} event The Region event.
* @returns {boolean} Display the scrolling text to the current User?
*/
#canView(event) {
if ( !this.parent.scene.isView ) return false;
if ( game.user.isGM ) return true;
if ( event.data.token.isSecret ) return false;
const token = event.data.token.object;
if ( !token || !token.visible ) return false;
const M = DisplayScrollingTextRegionBehaviorType.VISIBILITY_MODES;
if ( this.visibility === M.ANYONE ) return true;
if ( this.visibility === M.OBSERVER ) return event.data.token.testUserPermission(game.user, "OBSERVER");
return false;
}
/* ---------------------------------------- */
/** @override */
async _handleRegionEvent(event) {
if ( this.once && game.users.activeGM?.isSelf ) {
// noinspection ES6MissingAwait
this.parent.update({disabled: true});
}
if ( !this.text ) return;
const canView = this.#canView(event);
if ( !canView ) return;
const token = event.data.token.object;
const animation = CanvasAnimation.getAnimation(token.animationName);
if ( animation ) await animation.promise;
await canvas.interface.createScrollingText(
token.center,
this.text,
{
distance: 2 * token.h,
fontSize: 28,
fill: this.color,
stroke: 0x000000,
strokeThickness: 4
}
);
}
}

View File

@@ -0,0 +1,61 @@
import RegionBehaviorType from "./base.mjs";
import * as fields from "../../../common/data/fields.mjs";
/**
* The data model for a behavior that executes a Macro.
*
* @property {string} uuid The Macro UUID.
*/
export default class ExecuteMacroRegionBehaviorType extends RegionBehaviorType {
/** @override */
static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.executeMacro", "BEHAVIOR.TYPES.base"];
/* ---------------------------------------- */
/** @override */
static defineSchema() {
return {
events: this._createEventsField(),
uuid: new fields.DocumentUUIDField({type: "Macro"}),
everyone: new fields.BooleanField()
};
}
/* ---------------------------------------- */
/** @override */
async _handleRegionEvent(event) {
if ( !this.uuid ) return;
const macro = await fromUuid(this.uuid);
if ( !(macro instanceof Macro) ) {
console.error(`${this.uuid} does not exist`);
return;
}
if ( !this.#shouldExecute(macro, event.user) ) return;
const {scene, region, behavior} = this;
const token = event.data.token;
const speaker = token
? {scene: token.parent?.id ?? null, actor: token.actor?.id ?? null, token: token.id, alias: token.name}
: {scene: scene.id, actor: null, token: null, alias: region.name};
await macro.execute({speaker, actor: token?.actor, token: token?.object, scene, region, behavior, event});
}
/* ---------------------------------------- */
/**
* Should the client execute the macro?
* @param {Macro} macro The macro.
* @param {User} user The user that triggered the event.
* @returns {boolean} Should the client execute the macro?
*/
#shouldExecute(macro, user) {
if ( this.everyone ) return true;
if ( macro.canUserExecute(user) ) return user.isSelf;
const eligibleUsers = game.users.filter(u => u.active && macro.canUserExecute(u));
if ( eligibleUsers.length === 0 ) return false;
eligibleUsers.sort((a, b) => (b.role - a.role) || a.id.compare(b.id));
const designatedUser = eligibleUsers[0];
return designatedUser.isSelf;
}
}

View File

@@ -0,0 +1,38 @@
import RegionBehaviorType from "./base.mjs";
import * as fields from "../../../common/data/fields.mjs";
import {AsyncFunction} from "../../../common/utils/module.mjs";
/**
* The data model for a behavior that executes a script.
*
* @property {string} source The source code of the script.
*/
export default class ExecuteScriptRegionBehaviorType extends RegionBehaviorType {
/** @override */
static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.executeScript", "BEHAVIOR.TYPES.base"];
/* ---------------------------------------- */
/** @override */
static defineSchema() {
return {
events: this._createEventsField(),
source: new fields.JavaScriptField({async: true, gmOnly: true})
};
}
/* ---------------------------------------- */
/** @override */
async _handleRegionEvent(event) {
try {
// eslint-disable-next-line no-new-func
const fn = new AsyncFunction("scene", "region", "behavior", "event", `{${this.source}\n}`);
await fn.call(globalThis, this.scene, this.region, this.behavior, event);
} catch(err) {
console.error(err);
}
}
}

View File

@@ -0,0 +1,65 @@
import RegionBehaviorType from "./base.mjs";
import {REGION_EVENTS} from "../../../common/constants.mjs";
import * as fields from "../../../common/data/fields.mjs";
/**
* The data model for a behavior that pauses the game when a player-controlled Token enters the Region.
*
* @property {boolean} once Disable the behavior once a player-controlled Token enters the region?
*/
export default class PauseGameRegionBehaviorType extends RegionBehaviorType {
/** @override */
static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.pauseGame", "BEHAVIOR.TYPES.base"];
/* ---------------------------------------- */
/** @override */
static defineSchema() {
return {
once: new fields.BooleanField()
};
}
/* ---------------------------------------- */
/**
* Pause the game if a player-controlled Token moves into the Region.
* @param {RegionEvent} event
* @this {PauseGameRegionBehaviorType}
*/
static async #onTokenMoveIn(event) {
if ( event.data.forced || event.user.isGM || !game.users.activeGM?.isSelf ) return;
game.togglePause(true, true);
if ( this.once ) {
// noinspection ES6MissingAwait
this.parent.update({disabled: true});
}
}
/* ---------------------------------------- */
/**
* Stop movement after a player-controlled Token enters the Region.
* @param {RegionEvent} event
* @this {PauseGameRegionBehaviorType}
*/
static async #onTokenPreMove(event) {
if ( event.user.isGM ) return;
for ( const segment of event.data.segments ) {
if ( segment.type === Region.MOVEMENT_SEGMENT_TYPES.ENTER ) {
event.data.destination = segment.to;
break;
}
}
}
/* ---------------------------------------- */
/** @override */
static events = {
[REGION_EVENTS.TOKEN_MOVE_IN]: this.#onTokenMoveIn,
[REGION_EVENTS.TOKEN_PRE_MOVE]: this.#onTokenPreMove
};
}

View File

@@ -0,0 +1,50 @@
import RegionBehaviorType from "./base.mjs";
import RegionMesh from "../../canvas/regions/mesh.mjs";
import {REGION_EVENTS} from "../../../common/constants.mjs";
/**
* The data model for a behavior that allows to suppress weather effects within the Region
*/
export default class SuppressWeatherRegionBehaviorType extends RegionBehaviorType {
/** @override */
static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.suppressWeather", "BEHAVIOR.TYPES.base"];
/* ---------------------------------------- */
/** @override */
static defineSchema() {
return {};
}
/* ---------------------------------------- */
/**
* Called when the status of the weather behavior is changed.
* @param {RegionEvent} event
* @this {SuppressWeatherRegionBehaviorType}
*/
static async #onBehaviorStatus(event) {
// Create mesh
if ( event.data.viewed === true ) {
const mesh = new RegionMesh(this.region.object);
mesh.name = this.behavior.uuid;
mesh.blendMode = PIXI.BLEND_MODES.ERASE;
canvas.weather.suppression.addChild(mesh);
}
// Destroy mesh
else if ( event.data.viewed === false ) {
const mesh = canvas.weather.suppression.getChildByName(this.behavior.uuid);
mesh.destroy();
}
}
/* ---------------------------------------- */
/** @override */
static events = {
[REGION_EVENTS.BEHAVIOR_STATUS]: this.#onBehaviorStatus
};
}

View File

@@ -0,0 +1,355 @@
import RegionBehaviorType from "./base.mjs";
import {REGION_EVENTS} from "../../../common/constants.mjs";
import * as fields from "../../../common/data/fields.mjs";
import DialogV2 from "../../applications/api/dialog.mjs";
/**
* The data model for a behavior that teleports Token that enter the Region to a preset destination Region.
*
* @property {RegionDocument} destination The destination Region the Token is teleported to.
*/
export default class TeleportTokenRegionBehaviorType extends RegionBehaviorType {
/** @override */
static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.teleportToken", "BEHAVIOR.TYPES.base"];
/* ---------------------------------------- */
/** @override */
static defineSchema() {
return {
destination: new fields.DocumentUUIDField({type: "Region"}),
choice: new fields.BooleanField()
};
}
/* ---------------------------------------- */
/**
* Teleport the Token if it moves into the Region.
* @param {RegionEvent} event
* @this {TeleportTokenRegionBehaviorType}
*/
static async #onTokenMoveIn(event) {
if ( !this.destination || event.data.forced ) return;
const destination = fromUuidSync(this.destination);
if ( !(destination instanceof RegionDocument) ) {
console.error(`${this.destination} does not exist`);
return;
}
const token = event.data.token;
const user = event.user;
if ( !TeleportTokenRegionBehaviorType.#shouldTeleport(token, destination, user) ) return false;
if ( token.object ) {
const animation = CanvasAnimation.getAnimation(token.object.animationName);
if ( animation ) await animation.promise;
}
if ( this.choice ) {
let confirmed;
if ( user.isSelf ) confirmed = await TeleportTokenRegionBehaviorType.#confirmDialog(token, destination);
else {
confirmed = await new Promise(resolve => {
game.socket.emit("confirmTeleportToken", {
behaviorUuid: this.parent.uuid,
tokenUuid: token.uuid,
userId: user.id
}, resolve);
});
}
if ( !confirmed ) return;
}
await TeleportTokenRegionBehaviorType.#teleportToken(token, destination, user);
}
/* ---------------------------------------- */
/**
* Stop movement after a Token enters the Region.
* @param {RegionEvent} event
* @this {TeleportTokenRegionBehaviorType}
*/
static async #onTokenPreMove(event) {
if ( !this.destination ) return;
for ( const segment of event.data.segments ) {
if ( segment.type === Region.MOVEMENT_SEGMENT_TYPES.ENTER ) {
event.data.destination = segment.to;
break;
}
}
}
/* ---------------------------------------- */
/** @override */
static events = {
[REGION_EVENTS.TOKEN_MOVE_IN]: this.#onTokenMoveIn,
[REGION_EVENTS.TOKEN_PRE_MOVE]: this.#onTokenPreMove
};
/* ---------------------------------------- */
/**
* Should the current user teleport the token?
* @param {TokenDocument} token The token that is teleported.
* @param {RegionDocument} destination The destination region.
* @param {User} user The user that moved the token.
* @returns {boolean} Should the current user teleport the token?
*/
static #shouldTeleport(token, destination, user) {
const userCanTeleport = (token.parent === destination.parent) || (user.can("TOKEN_CREATE") && user.can("TOKEN_DELETE"));
if ( userCanTeleport ) return user.isSelf;
const eligibleGMs = game.users.filter(u => u.active && u.isGM && u.can("TOKEN_CREATE") && u.can("TOKEN_DELETE"));
if ( eligibleGMs.length === 0 ) return false;
eligibleGMs.sort((a, b) => (b.role - a.role) || a.id.compare(b.id));
const designatedGM = eligibleGMs[0];
return designatedGM.isSelf;
}
/* ---------------------------------------- */
/**
* Teleport the Token to the destination Region, which is in Scene that is not viewed.
* @param {TokenDocument} originToken The token that is teleported.
* @param {RegionDocument} destinationRegion The destination region.
* @param {User} user The user that moved the token.
*/
static async #teleportToken(originToken, destinationRegion, user) {
const destinationScene = destinationRegion.parent;
const destinationRegionObject = destinationRegion.object ?? new CONFIG.Region.objectClass(destinationRegion);
const originScene = originToken.parent;
let destinationToken;
if ( originScene === destinationScene ) destinationToken = originToken;
else {
const originTokenData = originToken.toObject();
delete originTokenData._id;
destinationToken = TokenDocument.implementation.fromSource(originTokenData, {parent: destinationScene});
}
const destinationTokenObject = destinationToken.object ?? new CONFIG.Token.objectClass(destinationToken);
// Reset destination token so that it isn't in an animated state
if ( destinationTokenObject.animationContexts.size !== 0 ) destinationToken.reset();
// Get the destination position
let destination;
try {
destination = TeleportTokenRegionBehaviorType.#getDestination(destinationRegionObject, destinationTokenObject);
} finally {
if ( !destinationRegion.object ) destinationRegionObject.destroy({children: true});
if ( !destinationToken.id || !destinationToken.object ) destinationTokenObject.destroy({children: true});
}
// If the origin and destination scene are the same
if ( originToken === destinationToken ) {
await originToken.update(destination, {teleport: true, forced: true});
return;
}
// Otherwise teleport the token to the different scene
destinationToken.updateSource(destination);
// Create the new token
const destinationTokenData = destinationToken.toObject();
if ( destinationScene.tokens.has(originToken.id) ) delete destinationTokenData._id;
else destinationTokenData._id = originToken.id;
destinationToken = await TokenDocument.implementation.create(destinationToken,
{parent: destinationScene, keepId: true});
// Update all combatants of the token
for ( const combat of game.combats ) {
const toUpdate = [];
for ( const combatant of combat.combatants ) {
if ( (combatant.sceneId === originScene.id) && (combatant.tokenId === originToken.id) ) {
toUpdate.push({_id: combatant.id, sceneId: destinationScene.id, tokenId: destinationToken.id});
}
}
if ( toUpdate.length ) await combat.updateEmbeddedDocuments("Combatant", toUpdate);
}
// Delete the old token
await originToken.delete();
// View destination scene / Pull the user to the destination scene only if the user is currently viewing the origin scene
if ( user.isSelf ) {
if ( originScene.isView ) await destinationScene.view();
} else {
if ( originScene.id === user.viewedScene ) await game.socket.emit("pullToScene", destinationScene.id, user.id);
}
}
/* ---------------------------------------- */
/**
* Get a destination for the Token within the Region that places the token and its center point inside it.
* @param {Region} region The region that is the destination of the teleportation.
* @param {Token} token The token that is teleported.
* @returns {{x: number, y: number, elevation: number}} The destination.
*/
static #getDestination(region, token) {
const scene = region.document.parent;
const grid = scene.grid;
// Not all regions are valid teleportation destinations
if ( region.polygons.length === 0 ) throw new Error(`${region.document.uuid} is empty`);
// Clamp the elevation of the token the elevation range of the destination region
const elevation = Math.clamp(token.document.elevation, region.bottom, region.top);
// Now we look for a random position within the destination region for the token
let position;
const pivot = token.getCenterPoint({x: 0, y: 0});
// Find a random snapped position in square/hexagonal grids that place the token within the destination region
if ( !grid.isGridless ) {
// Identify token positions that place the token and its center point within the region
const positions = [];
const [i0, j0, i1, j1] = grid.getOffsetRange(new PIXI.Rectangle(
0, 0, scene.dimensions.width, scene.dimensions.height).fit(region.bounds).pad(1));
for ( let i = i0; i < i1; i++ ) {
for ( let j = j0; j < j1; j++ ) {
// Drop the token with its center point on the grid space center and snap the token position
const center = grid.getCenterPoint({i, j});
// The grid space center must be inside the region to be a valid drop target
if ( !region.polygonTree.testPoint(center) ) continue;
const position = token.getSnappedPosition({x: center.x - pivot.x, y: center.y - pivot.y});
position.x = Math.round(position.x);
position.y = Math.round(position.y);
position.elevation = elevation;
// The center point of the token must be inside the region
if ( !region.polygonTree.testPoint(token.getCenterPoint(position)) ) continue;
// The token itself must be inside the region
if ( !token.testInsideRegion(region, position) ) continue;
positions.push(position);
}
}
// Pick a random position
if ( positions.length !== 0 ) position = positions[Math.floor(positions.length * Math.random())];
}
// If we found a snapped position, we're done. Otherwise, search for an unsnapped position.
if ( position ) return position;
// Calculate the areas of each triangle of the triangulation
const {vertices, indices} = region.triangulation;
const areas = [];
let totalArea = 0;
for ( let k = 0; k < indices.length; k += 3 ) {
const i0 = indices[k] * 2;
const i1 = indices[k + 1] * 2;
const i2 = indices[k + 2] * 2;
const x0 = vertices[i0];
const y0 = vertices[i0 + 1];
const x1 = vertices[i1];
const y1 = vertices[i1 + 1];
const x2 = vertices[i2];
const y2 = vertices[i2 + 1];
const area = Math.abs(((x1 - x0) * (y2 - y0)) - ((x2 - x0) * (y1 - y0))) / 2;
totalArea += area;
areas.push(area);
}
// Try to find a position that places the token inside the region
for ( let n = 0; n < 10; n++ ) {
position = undefined;
// Choose a triangle randomly weighted by area
let j;
let a = totalArea * Math.random();
for ( j = 0; j < areas.length - 1; j++ ) {
a -= areas[j];
if ( a < 0 ) break;
}
const k = 3 * j;
const i0 = indices[k] * 2;
const i1 = indices[k + 1] * 2;
const i2 = indices[k + 2] * 2;
const x0 = vertices[i0];
const y0 = vertices[i0 + 1];
const x1 = vertices[i1];
const y1 = vertices[i1 + 1];
const x2 = vertices[i2];
const y2 = vertices[i2 + 1];
// Select a random point within the triangle
const r1 = Math.sqrt(Math.random());
const r2 = Math.random();
const s = r1 * (1 - r2);
const t = r1 * r2;
const x = Math.round(x0 + ((x1 - x0) * s) + ((x2 - x0) * t) - pivot.x);
const y = Math.round(y0 + ((y1 - y0) * s) + ((y2 - y0) * t) - pivot.y);
position = {x, y, elevation};
// The center point of the token must be inside the region
if ( !region.polygonTree.testPoint(token.getCenterPoint(position)) ) continue;
// The token itself must be inside the region
if ( !token.testInsideRegion(region, position) ) continue;
}
// If we still didn't find a position that places the token within the destination region,
// the region is not a valid destination for teleporation or we didn't have luck finding one in 10 tries.
if ( !position ) throw new Error(`${region.document.uuid} cannot accomodate ${token.document.uuid}`);
return position;
}
/* -------------------------------------------- */
/**
* Activate the Socket event listeners.
* @param {Socket} socket The active game socket
* @internal
*/
static _activateSocketListeners(socket) {
socket.on("confirmTeleportToken", this.#onSocketEvent.bind(this));
}
/* -------------------------------------------- */
/**
* Handle the socket event that handles teleporation confirmation.
* @param {object} data The socket data.
* @param {string} data.tokenUuid The UUID of the Token that is teleported.
* @param {string} data.destinationUuid The UUID of the Region that is the destination of the teleportation.
* @param {Function} ack The acknowledgement function to return the result of the confirmation to the server.
*/
static async #onSocketEvent({behaviorUuid, tokenUuid}, ack) {
let confirmed = false;
try {
const behavior = await fromUuid(behaviorUuid);
if ( !behavior || (behavior.type !== "teleportToken") || !behavior.system.destination ) return;
const destination = await fromUuid(behavior.system.destination);
if ( !destination ) return;
const token = await fromUuid(tokenUuid);
if ( !token ) return;
confirmed = await TeleportTokenRegionBehaviorType.#confirmDialog(token, destination);
} finally {
ack(confirmed);
}
}
/* -------------------------------------------- */
/**
* Display a dialog to confirm the teleportation?
* @param {TokenDocument} token The token that is teleported.
* @param {RegionDocument} destination The destination region.
* @returns {Promise<boolean>} The result of the dialog.
*/
static async #confirmDialog(token, destination) {
return DialogV2.confirm({
window: {title: game.i18n.localize(CONFIG.RegionBehavior.typeLabels.teleportToken)},
content: `<p>${game.i18n.format(game.user.isGM ? "BEHAVIOR.TYPES.teleportToken.ConfirmGM"
: "BEHAVIOR.TYPES.teleportToken.Confirm", {token: token.name, region: destination.name,
scene: destination.parent.name})}</p>`,
rejectClose: false
});
}
}

View File

@@ -0,0 +1,62 @@
import RegionBehaviorType from "./base.mjs";
import {REGION_EVENTS} from "../../../common/constants.mjs";
import * as fields from "../../../common/data/fields.mjs";
/**
* The data model for a behavior that toggles Region Behaviors when one of the subscribed events occurs.
*
* @property {Set<string>} enable The Region Behavior UUIDs that are enabled.
* @property {Set<string>} disable The Region Behavior UUIDs that are disabled.
*/
export default class ToggleBehaviorRegionBehaviorType extends RegionBehaviorType {
/** @override */
static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.toggleBehavior", "BEHAVIOR.TYPES.base"];
/* ---------------------------------------- */
/** @override */
static defineSchema() {
return {
events: this._createEventsField({events: [
REGION_EVENTS.TOKEN_ENTER,
REGION_EVENTS.TOKEN_EXIT,
REGION_EVENTS.TOKEN_MOVE,
REGION_EVENTS.TOKEN_MOVE_IN,
REGION_EVENTS.TOKEN_MOVE_OUT,
REGION_EVENTS.TOKEN_TURN_START,
REGION_EVENTS.TOKEN_TURN_END,
REGION_EVENTS.TOKEN_ROUND_START,
REGION_EVENTS.TOKEN_ROUND_END
]}),
enable: new fields.SetField(new fields.DocumentUUIDField({type: "RegionBehavior"})),
disable: new fields.SetField(new fields.DocumentUUIDField({type: "RegionBehavior"}))
};
}
/* -------------------------------------------- */
/** @override */
static validateJoint(data) {
if ( new Set(data.enable).intersection(new Set(data.disable)).size !== 0 ) {
throw new Error("A RegionBehavior cannot be both enabled and disabled");
}
}
/* ---------------------------------------- */
/** @override */
async _handleRegionEvent(event) {
if ( !game.users.activeGM?.isSelf ) return;
const toggle = async (uuid, disabled) => {
const behavior = await fromUuid(uuid);
if ( !(behavior instanceof RegionBehavior) ) {
console.error(`${uuid} does not exist`);
return;
}
await behavior.update({disabled});
}
await Promise.allSettled(this.disable.map(uuid => toggle(uuid, true)));
await Promise.allSettled(this.enable.map(uuid => toggle(uuid, false)));
}
}

View File

@@ -0,0 +1,9 @@
/** @module dice */
export * as types from "./_types.mjs";
export * as terms from "./terms/_module.mjs";
export {default as Roll} from "./roll.mjs";
export {default as RollGrammar} from "./grammar.pegjs";
export {default as RollParser} from "./parser.mjs";
export {default as MersenneTwister} from "./twister.mjs";

View File

@@ -0,0 +1,75 @@
/**
* @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?
*/
/* -------------------------------------------- */
/* Roll Parsing Types */
/* -------------------------------------------- */
/**
* @typedef {object} RollParseNode
* @property {string} class The class name for this node.
* @property {string} formula The original matched text for this node.
*/
/**
* @typedef {RollParseNode} RollParseTreeNode
* @property {string} operator The binary operator.
* @property {[RollParseNode, RollParseNode]} operands The two operands.
*/
/**
* @typedef {RollParseNode} FlavorRollParseNode
* @property {object} options
* @property {string} options.flavor Flavor text associated with the node.
*/
/**
* @typedef {FlavorRollParseNode} ModifiersRollParseNode
* @property {string} modifiers The matched modifiers string.
*/
/**
* @typedef {FlavorRollParseNode} NumericRollParseNode
* @property {number} number The number.
*/
/**
* @typedef {FlavorRollParseNode} FunctionRollParseNode
* @property {string} fn The function name.
* @property {RollParseNode[]} terms The arguments to the function.
*/
/**
* @typedef {ModifiersRollParseNode} PoolRollParseNode
* @property {RollParseNode[]} terms The pool terms.
*/
/**
* @typedef {FlavorRollParseNode} ParentheticalRollParseNode
* @property {string} term The inner parenthetical term.
*/
/**
* @typedef {FlavorRollParseNode} StringParseNode
* @property {string} term The unclassified string term.
*/
/**
* @typedef {ModifiersRollParseNode} DiceRollParseNode
* @property {number|ParentheticalRollParseNode} number The number of dice.
* @property {string|number|ParentheticalRollParseNode} faces The number of faces or a string denomination like "c" or
* "f".
*/
/**
* @typedef {null|number|string|RollParseNode|RollParseArg[]} RollParseArg
*/

View File

@@ -0,0 +1,91 @@
{
/*
This is a per-parser initialization block. It runs whenever the parser is invoked. Any variables declared here are
available in any javascript blocks in the rest of the grammar.
In addition to the parser generated by peggy, we allow for certain parts of the process to be delegated to client
code. A class implementing this 'parser' API may be passed-in here as an option when the peggy parser is invoked,
otherwise we use the one configured at CONFIG.Dice.parser.
*/
const Parser = options.parser ?? CONFIG.Dice.parser;
const parser = new Parser(input);
}
Expression = _ leading:(_ @Additive)* _ head:Term tail:(_ @Operators _ @Term)* _ {
/*
The grammar rules are matched in order of precedence starting at the top of the file, so the rules that match the
largest portions of a string should generally go at the top, with matches for smaller substrings going at the bottom.
Here we have a rule that matches the overall roll formula. If a given formula does not match this rule, it means that
it is an invalid formula and will throw a parsing error.
Prefixing a pattern with 'foo:' is a way to give a name to the sub-match in the associated javascript code. We use
this fairly heavily since we want to forward these sub-matches onto the 'parser'. We can think of them like named
capture groups.
Prefixing a pattern with '@' is called 'plucking', and is used to identify sub-matches that should be assigned to the
overall capture name (like 'foo:'), ignoring any that are not 'plucked'.
For example 'tail:(_ @Operators _ @Term)*' matches operators followed by terms, with any amount of whitespace
in-between, however only the operator and term matches are assigned to the 'tail' variable, the whitespace is ignored.
The 'head:A tail:(Delimiter B)*' pattern is a way of representing a string of things separated by a delimiter,
like 'A + B + C' for formulas, or 'A, B, C' for Pool and Math terms.
In each of these cases we follow the same pattern: We match a term, then we call 'parser.on*', and that method is
responsible for taking the raw matched sub-strings and returning a 'parse node', i.e. a plain javascript object that
contains all the information we need to instantiate a real `RollTerm`.
*/
return parser._onExpression(head, tail, leading, text(), error);
}
Term = FunctionTerm / DiceTerm / NumericTerm / PoolTerm / Parenthetical / StringTerm
FunctionTerm = fn:FunctionName "(" _ head:Expression? _ tail:(_ "," _ @Expression)* _ ")" flavor:Flavor? {
return parser._onFunctionTerm(fn, head, tail, flavor, text());
}
DiceTerm = number:(Parenthetical / Constant)? [dD] faces:([a-z]i / Parenthetical / Constant) modifiers:Modifiers? flavor:Flavor? {
return parser._onDiceTerm(number, faces, modifiers, flavor, text());
}
NumericTerm = number:Constant flavor:Flavor? !StringTerm { return parser._onNumericTerm(number, flavor); }
PoolTerm = "{" _ head:Expression tail:("," _ @Expression)* "}" modifiers:Modifiers? flavor:Flavor? {
return parser._onPoolTerm(head, tail, modifiers, flavor, text());
}
Parenthetical = "(" _ term:Expression _ ")" flavor:Flavor? { return parser._onParenthetical(term, flavor, text()); }
StringTerm = term:(ReplacedData / PlainString) flavor:Flavor? { return parser._onStringTerm(term, flavor); }
ReplacedData = $("$" $[^$]+ "$")
PlainString = $[^ (){}[\]$,+\-*%/]+
FunctionName = $([a-z$_]i [a-z$_0-9]i*)
Modifiers = $([^ (){}[\]$+\-*/,]+)
Constant = _ [0-9]+ ("." [0-9]+)? { return Number(text()); }
Operators = MultiplicativeFirst / AdditiveOnly
MultiplicativeFirst = head:Multiplicative tail:(_ @Additive)* { return [head, ...tail]; }
AdditiveOnly = head:Additive tail:(_ @Additive)* { return [null, head, ...tail]; }
Multiplicative = "*" / "/" / "%"
Additive = "+" / "-"
Flavor = "[" @$[^[\]]+ "]"
_ "whitespace" = [ ]*

View File

@@ -0,0 +1,370 @@
import { getType } from "../../common/utils/helpers.mjs";
import OperatorTerm from "./terms/operator.mjs";
/**
* @typedef {import("../_types.mjs").RollParseNode} RollParseNode
* @typedef {import("../_types.mjs").RollParseTreeNode} RollParseTreeNode
* @typedef {import("../_types.mjs").NumericRollParseNode} NumericRollParseNode
* @typedef {import("../_types.mjs").FunctionRollParseNode} FunctionRollParseNode
* @typedef {import("../_types.mjs").PoolRollParseNode} PoolRollParseNode
* @typedef {import("../_types.mjs").ParentheticalRollParseNode} ParentheticalRollParseNode
* @typedef {import("../_types.mjs").DiceRollParseNode} DiceRollParseNode
* @typedef {import("../_types.mjs").RollParseArg} RollParseArg
*/
/**
* A class for transforming events from the Peggy grammar lexer into various formats.
*/
export default class RollParser {
/**
* @param {string} formula The full formula.
*/
constructor(formula) {
this.formula = formula;
}
/**
* The full formula.
* @type {string}
*/
formula;
/* -------------------------------------------- */
/* Parse Events */
/* -------------------------------------------- */
/**
* Handle a base roll expression.
* @param {RollParseNode} head The first operand.
* @param {[string[], RollParseNode][]} tail Zero or more subsequent (operators, operand) tuples.
* @param {string} [leading] A leading operator.
* @param {string} formula The original matched text.
* @param {function} error The peggy error callback to invoke on a parse error.
* @returns {RollParseTreeNode}
* @internal
* @protected
*/
_onExpression(head, tail, leading, formula, error) {
if ( CONFIG.debug.rollParsing ) console.debug(this.constructor.formatDebug("onExpression", head, tail));
if ( leading.length ) leading = this._collapseOperators(leading);
if ( leading === "-" ) head = this._wrapNegativeTerm(head);
// We take the list of (operator, operand) tuples and arrange them into a left-skewed binary tree.
return tail.reduce((acc, [operators, operand]) => {
let operator;
let [multiplicative, ...additive] = operators;
if ( additive.length ) additive = this._collapseOperators(additive);
if ( multiplicative ) {
operator = multiplicative;
if ( additive === "-" ) operand = this._wrapNegativeTerm(operand);
}
else operator = additive;
if ( typeof operator !== "string" ) error(`Failed to parse ${formula}. Unexpected operator.`);
const operands = [acc, operand];
return { class: "Node", formula: `${acc.formula} ${operator} ${operand.formula}`, operands, operator };
}, head);
}
/* -------------------------------------------- */
/**
* Handle a dice term.
* @param {NumericRollParseNode|ParentheticalRollParseNode|null} number The number of dice.
* @param {string|NumericRollParseNode|ParentheticalRollParseNode|null} faces The number of die faces or a string
* denomination like "c" or "f".
* @param {string|null} modifiers The matched modifiers string.
* @param {string|null} flavor Associated flavor text.
* @param {string} formula The original matched text.
* @returns {DiceRollParseNode}
* @internal
* @protected
*/
_onDiceTerm(number, faces, modifiers, flavor, formula) {
if ( CONFIG.debug.rollParsing ) {
console.debug(this.constructor.formatDebug("onDiceTerm", number, faces, modifiers, flavor, formula));
}
return { class: "DiceTerm", formula, modifiers, number, faces, evaluated: false, options: { flavor } };
}
/* -------------------------------------------- */
/**
* Handle a numeric term.
* @param {number} number The number.
* @param {string} flavor Associated flavor text.
* @returns {NumericRollParseNode}
* @internal
* @protected
*/
_onNumericTerm(number, flavor) {
if ( CONFIG.debug.rollParsing ) console.debug(this.constructor.formatDebug("onNumericTerm", number, flavor));
return {
class: "NumericTerm", number,
formula: `${number}${flavor ? `[${flavor}]` : ""}`,
evaluated: false,
options: { flavor }
};
}
/* -------------------------------------------- */
/**
* Handle a math term.
* @param {string} fn The Math function.
* @param {RollParseNode} head The first term.
* @param {RollParseNode[]} tail Zero or more additional terms.
* @param {string} flavor Associated flavor text.
* @param {string} formula The original matched text.
* @returns {FunctionRollParseNode}
* @internal
* @protected
*/
_onFunctionTerm(fn, head, tail, flavor, formula) {
if ( CONFIG.debug.rollParsing ) {
console.debug(this.constructor.formatDebug("onFunctionTerm", fn, head, tail, flavor, formula));
}
const terms = [];
if ( head ) terms.push(head, ...tail);
return { class: "FunctionTerm", fn, terms, formula, evaluated: false, options: { flavor } };
}
/* -------------------------------------------- */
/**
* Handle a pool term.
* @param {RollParseNode} head The first term.
* @param {RollParseNode[]} tail Zero or more additional terms.
* @param {string|null} modifiers The matched modifiers string.
* @param {string|null} flavor Associated flavor text.
* @param {string} formula The original matched text.
* @returns {PoolRollParseNode}
* @internal
* @protected
*/
_onPoolTerm(head, tail, modifiers, flavor, formula) {
if ( CONFIG.debug.rollParsing ) {
console.debug(this.constructor.formatDebug("onPoolTerm", head, tail, modifiers, flavor, formula));
}
const terms = [];
if ( head ) terms.push(head, ...tail);
return { class: "PoolTerm", terms, formula, modifiers, evaluated: false, options: { flavor } };
}
/* -------------------------------------------- */
/**
* Handle a parenthetical.
* @param {RollParseNode} term The inner term.
* @param {string|null} flavor Associated flavor text.
* @param {string} formula The original matched text.
* @returns {ParentheticalRollParseNode}
* @internal
* @protected
*/
_onParenthetical(term, flavor, formula) {
if ( CONFIG.debug.rollParsing ) {
console.debug(this.constructor.formatDebug("onParenthetical", term, flavor, formula));
}
return { class: "ParentheticalTerm", term, formula, evaluated: false, options: { flavor } };
}
/* -------------------------------------------- */
/**
* Handle some string that failed to be classified.
* @param {string} term The term.
* @param {string|null} [flavor] Associated flavor text.
* @returns {StringParseNode}
* @protected
*/
_onStringTerm(term, flavor) {
return { class: "StringTerm", term, evaluated: false, options: { flavor } };
}
/* -------------------------------------------- */
/**
* Collapse multiple additive operators into a single one.
* @param {string[]} operators A sequence of additive operators.
* @returns {string}
* @protected
*/
_collapseOperators(operators) {
let head = operators.pop();
for ( const operator of operators ) {
if ( operator === "-" ) head = head === "+" ? "-" : "+";
}
return head;
}
/* -------------------------------------------- */
/**
* Wrap a term with a leading minus.
* @param {RollParseNode} term The term to wrap.
* @returns {RollParseNode}
* @protected
*/
_wrapNegativeTerm(term) {
// Special case when we have a numeric term, otherwise we wrap it in a parenthetical.
if ( term.class === "NumericTerm" ) {
term.number *= -1;
term.formula = `-${term.formula}`;
return term;
}
return foundry.dice.RollGrammar.parse(`(${term.formula} * -1)`, { parser: this.constructor });
}
/* -------------------------------------------- */
/* Tree Manipulation */
/* -------------------------------------------- */
/**
* Flatten a tree structure (either a parse tree or AST) into an array with operators in infix notation.
* @param {RollParseNode} root The root of the tree.
* @returns {RollParseNode[]}
*/
static flattenTree(root) {
const list = [];
/**
* Flatten the given node.
* @param {RollParseNode} node The node.
*/
function flattenNode(node) {
if ( node.class !== "Node" ) {
list.push(node);
return;
}
const [left, right] = node.operands;
flattenNode(left);
list.push({ class: "OperatorTerm", operator: node.operator, evaluated: false });
flattenNode(right);
}
flattenNode(root);
return list;
}
/* -------------------------------------------- */
/**
* Use the Shunting Yard algorithm to convert a parse tree or list of terms into an AST with correct operator
* precedence.
* See https://en.wikipedia.org/wiki/Shunting_yard_algorithm for a description of the algorithm in detail.
* @param {RollParseNode|RollTerm[]} root The root of the parse tree or a list of terms.
* @returns {RollParseNode} The root of the AST.
*/
static toAST(root) {
// Flatten the parse tree to an array representing the original formula in infix notation.
const list = Array.isArray(root) ? root : this.flattenTree(root);
const operators = [];
const output = [];
/**
* Pop operators from the operator stack and push them onto the output stack until we reach an operator with lower
* or equal precedence and left-associativity.
* @param {RollParseNode} op The target operator to push.
*/
function pushOperator(op) {
let peek = operators.at(-1);
// We assume all our operators are left-associative, so we only check if the precedence is lower or equal here.
while ( peek && ((OperatorTerm.PRECEDENCE[peek.operator] ?? 0) >= (OperatorTerm.PRECEDENCE[op.operator] ?? 0)) ) {
output.push(operators.pop());
peek = operators.at(-1);
}
operators.push(op);
}
for ( const node of list ) {
// If this is an operator, push it onto the operators stack.
if ( this.isOperatorTerm(node) ) {
pushOperator(node);
continue;
}
// Recursively reorganize inner terms to AST sub-trees.
if ( node.class === "ParentheticalTerm" ) node.term = this.toAST(node.term);
else if ( (node.class === "FunctionTerm") || (node.class === "PoolTerm") ) {
node.terms = node.terms.map(term => this.toAST(term));
}
// Push the node onto the output stack.
output.push(node);
}
// Pop remaining operators off the operator stack and onto the output stack.
while ( operators.length ) output.push(operators.pop());
// The output now contains the formula in postfix notation, with correct operator precedence applied. We recombine
// it into a tree by matching each postfix operator with two operands.
const ast = [];
for ( const node of output ) {
if ( !this.isOperatorTerm(node) ) {
ast.push(node);
continue;
}
const right = ast.pop();
const left = ast.pop();
ast.push({ class: "Node", operator: node.operator, operands: [left, right] });
}
// The postfix array has been recombined into an array of one element, which is the root of the new AST.
return ast.pop();
}
/* -------------------------------------------- */
/**
* Determine if a given node is an operator term.
* @param {RollParseNode|RollTerm} node
*/
static isOperatorTerm(node) {
return (node instanceof OperatorTerm) || (node.class === "OperatorTerm");
}
/* -------------------------------------------- */
/* Debug Formatting */
/* -------------------------------------------- */
/**
* Format a list argument.
* @param {RollParseArg[]} list The list to format.
* @returns {string}
*/
static formatList(list) {
if ( !list ) return "[]";
return `[${list.map(RollParser.formatArg).join(", ")}]`;
}
/* -------------------------------------------- */
/**
* Format a parser argument.
* @param {RollParseArg} arg The argument.
* @returns {string}
*/
static formatArg(arg) {
switch ( getType(arg) ) {
case "null": return "null";
case "number": return `${arg}`;
case "string": return `"${arg}"`;
case "Object": return arg.class;
case "Array": return RollParser.formatList(arg);
}
}
/* -------------------------------------------- */
/**
* Format arguments for debugging.
* @param {string} method The method name.
* @param {...RollParseArg} args The arguments.
* @returns {string}
*/
static formatDebug(method, ...args) {
return `${method}(${args.map(RollParser.formatArg).join(", ")})`;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
export {default as Coin} from "./coin.mjs";
export {default as DiceTerm} from "./dice.mjs";
export {default as Die} from "./die.mjs"
export {default as FateDie} from "./fate.mjs";
export {default as FunctionTerm} from "./function.mjs";
export {default as NumericTerm} from "./numeric.mjs";
export {default as OperatorTerm} from "./operator.mjs";
export {default as ParentheticalTerm} from "./parenthetical.mjs";
export {default as PoolTerm} from "./pool.mjs";
export {default as RollTerm} from "./term.mjs";
export {default as StringTerm} from "./string.mjs";

View File

@@ -0,0 +1,89 @@
import DiceTerm from "./dice.mjs";
/**
* A type of DiceTerm used to represent flipping a two-sided coin.
* @implements {DiceTerm}
*/
export default class Coin extends DiceTerm {
constructor(termData) {
termData.faces = 2;
super(termData);
}
/** @inheritdoc */
static DENOMINATION = "c";
/** @inheritdoc */
static MODIFIERS = {
"c": "call"
};
/* -------------------------------------------- */
/** @inheritdoc */
async roll({minimize=false, maximize=false, ...options}={}) {
const roll = {result: undefined, active: true};
if ( minimize ) roll.result = 0;
else if ( maximize ) roll.result = 1;
else roll.result = await this._roll(options);
if ( roll.result === undefined ) roll.result = this.randomFace();
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
]
}
/* -------------------------------------------- */
/** @override */
mapRandomFace(randomUniform) {
return Math.round(randomUniform);
}
/* -------------------------------------------- */
/* 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;
}
}
}

View File

@@ -0,0 +1,705 @@
import RollTerm from "./term.mjs";
/**
* @typedef {import("../_types.mjs").DiceTermResult} DiceTermResult
*/
/**
* An abstract base class for any type of RollTerm which involves randomized input from dice, coins, or other devices.
* @extends RollTerm
*/
export default class DiceTerm extends RollTerm {
/**
* @param {object} termData Data used to create the Dice Term, including the following:
* @param {number|Roll} [termData.number=1] The number of dice of this term to roll, before modifiers are applied, or
* a Roll instance that will be evaluated to a number.
* @param {number|Roll} termData.faces The number of faces on each die of this type, or a Roll instance that
* will be evaluated to a number.
* @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
*/
constructor({number=1, faces=6, method, modifiers=[], results=[], options={}}) {
super({options});
this._number = number;
this._faces = faces;
this.method = method;
this.modifiers = modifiers;
this.results = results;
// If results were explicitly passed, the term has already been evaluated
if ( results.length ) this._evaluated = true;
}
/* -------------------------------------------- */
/**
* The resolution method used to resolve this DiceTerm.
* @type {string}
*/
get method() {
return this.#method;
}
set method(method) {
if ( this.#method || !(method in CONFIG.Dice.fulfillment.methods) ) return;
this.#method = method;
}
#method;
/**
* An Array of dice term modifiers which are applied
* @type {string[]}
*/
modifiers;
/**
* The array of dice term results which have been rolled
* @type {DiceTermResult[]}
*/
results;
/**
* 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 {Record<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]+)${this.MODIFIERS_REGEXP_STRING}?${this.FLAVOR_REGEXP_STRING}?$`);
/** @inheritdoc */
static SERIALIZE_ATTRIBUTES = ["number", "faces", "modifiers", "results", "method"];
/* -------------------------------------------- */
/* Dice Term Attributes */
/* -------------------------------------------- */
/**
* The number of dice of this term to roll. Returns undefined if the number is a complex term that has not yet been
* evaluated.
* @type {number|void}
*/
get number() {
if ( typeof this._number === "number" ) return this._number;
else if ( this._number?._evaluated ) return this._number.total;
}
/**
* The number of dice of this term to roll, before modifiers are applied, or a Roll instance that will be evaluated to
* a number.
* @type {number|Roll}
* @protected
*/
_number;
set number(value) {
this._number = value;
}
/* -------------------------------------------- */
/**
* The number of faces on the die. Returns undefined if the faces are represented as a complex term that has not yet
* been evaluated.
* @type {number|void}
*/
get faces() {
if ( typeof this._faces === "number" ) return this._faces;
else if ( this._faces?._evaluated ) return this._faces.total;
}
/**
* The number of faces on the die, or a Roll instance that will be evaluated to a number.
* @type {number|Roll}
* @protected
*/
_faces;
set faces(value) {
this._faces = value;
}
/* -------------------------------------------- */
/** @inheritdoc */
get expression() {
const x = this.constructor.DENOMINATION === "d" ? this._faces : this.constructor.DENOMINATION;
return `${this._number}d${x}${this.modifiers.join("")}`;
}
/* -------------------------------------------- */
/**
* The denomination of this DiceTerm instance.
* @type {string}
*/
get denomination() {
return this.constructor.DENOMINATION;
}
/* -------------------------------------------- */
/**
* An array of additional DiceTerm instances involved in resolving this DiceTerm.
* @type {DiceTerm[]}
*/
get dice() {
const dice = [];
if ( this._number instanceof Roll ) dice.push(...this._number.dice);
if ( this._faces instanceof Roll ) dice.push(...this._faces.dice);
return dice;
}
/* -------------------------------------------- */
/** @inheritdoc */
get total() {
if ( !this._evaluated ) return undefined;
let total = this.results.reduce((t, r) => {
if ( !r.active ) return t;
if ( r.count !== undefined ) return t + r.count;
else return t + r.result;
}, 0);
if ( this.number < 0 ) total *= -1;
return total;
}
/* -------------------------------------------- */
/**
* 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.
* @returns {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 ) {
if ( this._number instanceof Roll ) this._number = Roll.create(`(${this._number} * ${multiply})`);
else this._number = Math.round(this.number * multiply);
}
if ( add ) {
if ( this._number instanceof Roll ) this._number = Roll.create(`(${this._number} + ${add})`);
else this._number += add;
}
return this;
}
/* -------------------------------------------- */
/** @inheritDoc */
_evaluate(options={}) {
if ( RollTerm.isDeterministic(this, options) ) return this._evaluateSync(options);
return this._evaluateAsync(options);
}
/* -------------------------------------------- */
/**
* Evaluate this dice term asynchronously.
* @param {object} [options] Options forwarded to inner Roll evaluation.
* @returns {Promise<DiceTerm>}
* @protected
*/
async _evaluateAsync(options={}) {
for ( const roll of [this._faces, this._number] ) {
if ( !(roll instanceof Roll) ) continue;
if ( this._root ) roll._root = this._root;
await roll.evaluate(options);
}
if ( Math.abs(this.number) > 999 ) {
throw new Error("You may not evaluate a DiceTerm with more than 999 requested results");
}
// If this term was an intermediate term, it has not yet been added to the resolver, so we add it here.
if ( this.resolver && !this._id ) await this.resolver.addTerm(this);
for ( let n = this.results.length; n < Math.abs(this.number); n++ ) await this.roll(options);
await this._evaluateModifiers();
return this;
}
/* -------------------------------------------- */
/**
* Evaluate deterministic values of this term synchronously.
* @param {object} [options]
* @param {boolean} [options.maximize] Force the result to be maximized.
* @param {boolean} [options.minimize] Force the result to be minimized.
* @param {boolean} [options.strict] Throw an error if attempting to evaluate a die term in a way that cannot be
* done synchronously.
* @returns {DiceTerm}
* @protected
*/
_evaluateSync(options={}) {
if ( this._faces instanceof Roll ) this._faces.evaluateSync(options);
if ( this._number instanceof Roll ) this._number.evaluateSync(options);
if ( Math.abs(this.number) > 999 ) {
throw new Error("You may not evaluate a DiceTerm with more than 999 requested results");
}
for ( let n = this.results.length; n < Math.abs(this.number); n++ ) {
const roll = { active: true };
if ( options.minimize ) roll.result = Math.min(1, this.faces);
else if ( options.maximize ) roll.result = this.faces;
else if ( options.strict ) throw new Error("Cannot synchronously evaluate a non-deterministic term.");
else continue;
this.results.push(roll);
}
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.
* @returns {Promise<DiceTermResult>} The produced result
*/
async roll({minimize=false, maximize=false, ...options}={}) {
const roll = {result: undefined, active: true};
roll.result = await this._roll(options);
if ( minimize ) roll.result = Math.min(1, this.faces);
else if ( maximize ) roll.result = this.faces;
else if ( roll.result === undefined ) roll.result = this.randomFace();
this.results.push(roll);
return roll;
}
/* -------------------------------------------- */
/**
* Generate a roll result value for this DiceTerm based on its fulfillment method.
* @param {object} [options] Options forwarded to the fulfillment method handler.
* @returns {Promise<number|void>} Returns a Promise that resolves to the fulfilled number, or undefined if it could
* not be fulfilled.
* @protected
*/
async _roll(options={}) {
return this.#invokeFulfillmentHandler(options);
}
/* -------------------------------------------- */
/**
* Invoke the configured fulfillment handler for this term to produce a result value.
* @param {object} [options] Options forwarded to the fulfillment method handler.
* @returns {Promise<number|void>} Returns a Promise that resolves to the fulfilled number, or undefined if it could
* not be fulfilled.
*/
async #invokeFulfillmentHandler(options={}) {
const config = game.settings.get("core", "diceConfiguration");
const method = config[this.denomination] || CONFIG.Dice.fulfillment.defaultMethod;
if ( (method === "manual") && !game.user.hasPermission("MANUAL_ROLLS") ) return;
const { handler, interactive } = CONFIG.Dice.fulfillment.methods[method] ?? {};
if ( interactive && this.resolver ) return this.resolver.resolveResult(this, method, options);
return handler?.(this, options);
}
/* -------------------------------------------- */
/**
* Maps a randomly-generated value in the interval [0, 1) to a face value on the die.
* @param {number} randomUniform A value to map. Must be in the interval [0, 1).
* @returns {number} The face value.
*/
mapRandomFace(randomUniform) {
return Math.ceil((1 - randomUniform) * this.faces);
}
/* -------------------------------------------- */
/**
* Generate a random face value for this die using the configured PRNG.
* @returns {number}
*/
randomFace() {
return this.mapRandomFace(CONFIG.Dice.randomUniform());
}
/* -------------------------------------------- */
/**
* Return a string used as the label for each rolled result
* @param {DiceTermResult} result The rolled result
* @returns {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
* @returns {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
* @returns {object} The data object used to render the default tooltip template for this DiceTerm
*/
getTooltipData() {
const { total, faces, flavor } = this;
const method = CONFIG.Dice.fulfillment.methods[this.method];
const icon = method?.interactive ? (method.icon ?? '<i class="fas fa-bluetooth"></i>') : null;
return {
total, faces, flavor, icon,
method: method?.label,
formula: this.expression,
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.
* @internal
*/
async _evaluateModifiers() {
const cls = this.constructor;
const requested = foundry.utils.deepClone(this.modifiers);
this.modifiers = [];
// Sort modifiers from longest to shortest to ensure that the matching algorithm greedily matches the longest
// prefixes first.
const allModifiers = Object.keys(cls.MODIFIERS).sort((a, b) => b.length - a.length);
// Iterate over requested modifiers
for ( const m of requested ) {
let command = m.match(/[A-z]+/)[0].toLowerCase();
// Matched command
if ( command in cls.MODIFIERS ) {
await this._evaluateModifier(command, m);
continue;
}
// Unmatched compound command
while ( command ) {
let matched = false;
for ( const modifier of allModifiers ) {
if ( command.startsWith(modifier) ) {
matched = true;
await this._evaluateModifier(modifier, modifier);
command = command.replace(modifier, "");
break;
}
}
if ( !matched ) command = "";
}
}
}
/* -------------------------------------------- */
/**
* Asynchronously 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
* @internal
*/
async _evaluateModifier(command, modifier) {
let fn = this.constructor.MODIFIERS[command];
if ( typeof fn === "string" ) fn = this[fn];
if ( fn instanceof Function ) {
const result = await 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
* @returns {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?
* @returns {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.clamp(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"
* @returns {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
* @returns {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, foundry.dice.terms.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(this.MODIFIER_REGEXP)).map(m => m[0]);
// Construct a term of the appropriate denomination
return new cls({number, faces, modifiers, options: {flavor}});
}
/* -------------------------------------------- */
/** @override */
static fromParseNode(node) {
let { number, faces } = node;
let denomination = "d";
if ( number === null ) number = 1;
if ( number.class ) {
number = Roll.defaultImplementation.fromTerms(Roll.defaultImplementation.instantiateAST(number));
}
if ( typeof faces === "string" ) denomination = faces.toLowerCase();
else if ( faces.class ) {
faces = Roll.defaultImplementation.fromTerms(Roll.defaultImplementation.instantiateAST(faces));
}
const modifiers = Array.from((node.modifiers || "").matchAll(this.MODIFIER_REGEXP)).map(([m]) => m);
const cls = CONFIG.Dice.terms[denomination];
const data = { ...node, number, modifiers, class: cls.name };
if ( denomination === "d" ) data.faces = faces;
return this.fromData(data);
}
/* -------------------------------------------- */
/* Serialization & Loading */
/* -------------------------------------------- */
/** @inheritDoc */
toJSON() {
const data = super.toJSON();
if ( this._number instanceof Roll ) data._number = this._number.toJSON();
if ( this._faces instanceof Roll ) data._faces = this._faces.toJSON();
return data;
}
/* -------------------------------------------- */
/** @inheritDoc */
static _fromData(data) {
if ( data._number ) data.number = Roll.fromData(data._number);
if ( data._faces ) data.faces = Roll.fromData(data._faces);
return super._fromData(data);
}
}

View File

@@ -0,0 +1,421 @@
import DiceTerm from "./dice.mjs";
/**
* 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();
* ```
*/
export default class Die extends DiceTerm {
/** @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;
}
/* -------------------------------------------- */
/** @inheritDoc */
get denomination() {
return `d${this.faces}`;
}
/* -------------------------------------------- */
/* 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 {Promise<false|void>} False if the modifier was unmatched
*/
async 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;
const initial = this.results.length;
while ( checked < this.results.length ) {
const 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;
await this.roll({ reroll: true });
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}
*/
async 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?
* @returns {Promise<false|void>} False if the modifier was unmatched.
*/
async 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 ) {
const 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;
await this.roll({ explode: true });
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}
*/
async 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;
}
}
}
}

View File

@@ -0,0 +1,62 @@
import DiceTerm from "./dice.mjs";
import Die from "./die.mjs";
/**
* A type of DiceTerm used to represent a three-sided Fate/Fudge die.
* Mathematically behaves like 1d3-2
* @extends {DiceTerm}
*/
export default class FateDie extends DiceTerm {
constructor(termData) {
termData.faces = 3;
super(termData);
}
/** @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 */
async roll({minimize=false, maximize=false, ...options}={}) {
const roll = {result: undefined, active: true};
if ( minimize ) roll.result = -1;
else if ( maximize ) roll.result = 1;
else roll.result = await this._roll(options);
if ( roll.result === undefined ) roll.result = this.randomFace();
if ( roll.result === -1 ) roll.failure = true;
if ( roll.result === 1 ) roll.success = true;
this.results.push(roll);
return roll;
}
/* -------------------------------------------- */
/** @override */
mapRandomFace(randomUniform) {
return Math.ceil((randomUniform * this.faces) - 2);
}
/* -------------------------------------------- */
/** @inheritdoc */
getResultLabel(result) {
return {
"-1": "-",
"0": "&nbsp;",
"1": "+"
}[result.result];
}
}

View File

@@ -0,0 +1,177 @@
import RollTerm from "./term.mjs";
import DiceTerm from "./dice.mjs";
/**
* A type of RollTerm used to apply a function.
* @extends {RollTerm}
*/
export default class FunctionTerm extends RollTerm {
constructor({fn, terms=[], rolls=[], result, options}={}) {
super({options});
this.fn = fn;
this.terms = terms;
this.rolls = (rolls.length === terms.length) ? rolls : this.terms.map(t => Roll.create(t));
this.result = result;
if ( result !== undefined ) this._evaluated = true;
}
/**
* The name of the configured function, or one in the Math environment, which should be applied to the term
* @type {string}
*/
fn;
/**
* An array of string argument terms for the function
* @type {string[]}
*/
terms;
/**
* The cached Roll instances for each function argument
* @type {Roll[]}
*/
rolls = [];
/**
* The cached result of evaluating the method arguments
* @type {string|number}
*/
result;
/** @inheritdoc */
isIntermediate = true;
/** @inheritdoc */
static SERIALIZE_ATTRIBUTES = ["fn", "terms", "rolls", "result"];
/* -------------------------------------------- */
/* Function Term Attributes */
/* -------------------------------------------- */
/**
* An array of evaluated DiceTerm instances that should be bubbled up to the parent Roll
* @type {DiceTerm[]}
*/
get dice() {
return this.rolls.flatMap(r => r.dice);
}
/** @inheritdoc */
get total() {
return this.result;
}
/** @inheritdoc */
get expression() {
return `${this.fn}(${this.terms.join(",")})`;
}
/**
* The function this term represents.
* @returns {RollFunction}
*/
get function() {
return CONFIG.Dice.functions[this.fn] ?? Math[this.fn];
}
/** @inheritdoc */
get isDeterministic() {
if ( this.function?.constructor.name === "AsyncFunction" ) return false;
return this.terms.every(t => Roll.create(t).isDeterministic);
}
/* -------------------------------------------- */
/* Math Term Methods */
/* -------------------------------------------- */
/** @inheritdoc */
_evaluate(options={}) {
if ( RollTerm.isDeterministic(this, options) ) return this._evaluateSync(options);
return this._evaluateAsync(options);
}
/* -------------------------------------------- */
/**
* Evaluate this function when it contains any non-deterministic sub-terms.
* @param {object} [options]
* @returns {Promise<RollTerm>}
* @protected
*/
async _evaluateAsync(options={}) {
const args = await Promise.all(this.rolls.map(async roll => {
if ( this._root ) roll._root = this._root;
await roll.evaluate({ ...options, allowStrings: true });
roll.propagateFlavor(this.flavor);
return this.#parseArgument(roll);
}));
this.result = await this.function(...args);
if ( !options.allowStrings ) this.result = Number(this.result);
return this;
}
/* -------------------------------------------- */
/**
* Evaluate this function when it contains only deterministic sub-terms.
* @param {object} [options]
* @returns {RollTerm}
* @protected
*/
_evaluateSync(options={}) {
const args = [];
for ( const roll of this.rolls ) {
roll.evaluateSync({ ...options, allowStrings: true });
roll.propagateFlavor(this.flavor);
args.push(this.#parseArgument(roll));
}
this.result = this.function(...args);
if ( !options.allowStrings ) this.result = Number(this.result);
return this;
}
/* -------------------------------------------- */
/**
* Parse a function argument from its evaluated Roll instance.
* @param {Roll} roll The evaluated Roll instance that wraps the argument.
* @returns {string|number}
*/
#parseArgument(roll) {
const { product } = roll;
if ( typeof product !== "string" ) return product;
const [, value] = product.match(/^\$([^$]+)\$$/) || [];
return value ? JSON.parse(value) : product;
}
/* -------------------------------------------- */
/* Saving and Loading */
/* -------------------------------------------- */
/** @inheritDoc */
static _fromData(data) {
data.rolls = (data.rolls || []).map(r => r instanceof Roll ? r : Roll.fromData(r));
return super._fromData(data);
}
/* -------------------------------------------- */
/** @inheritDoc */
toJSON() {
const data = super.toJSON();
data.rolls = data.rolls.map(r => r.toJSON());
return data;
}
/* -------------------------------------------- */
/** @override */
static fromParseNode(node) {
const rolls = node.terms.map(t => {
return Roll.defaultImplementation.fromTerms(Roll.defaultImplementation.instantiateAST(t));
});
const modifiers = Array.from((node.modifiers || "").matchAll(DiceTerm.MODIFIER_REGEXP)).map(([m]) => m);
return this.fromData({ ...node, rolls, modifiers, terms: rolls.map(r => r.formula) });
}
}

View File

@@ -0,0 +1,59 @@
import RollTerm from "./term.mjs";
/**
* A type of RollTerm used to represent static numbers.
* @extends {RollTerm}
*/
export default class NumericTerm extends RollTerm {
constructor({number, options}={}) {
super({options});
this.number = Number(number);
}
/**
* The term's numeric value.
* @type {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
* @returns {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
* @returns {NumericTerm} The constructed term
*/
static fromMatch(match) {
const [number, flavor] = match.slice(1);
return new this({number, options: {flavor}});
}
}

View File

@@ -0,0 +1,58 @@
import RollTerm from "./term.mjs";
/**
* A type of RollTerm used to denote and perform an arithmetic operation.
* @extends {RollTerm}
*/
export default class OperatorTerm extends RollTerm {
constructor({operator, options}={}) {
super({options});
this.operator = operator;
}
/**
* The term's operator value.
* @type {string}
*/
operator;
/**
* An object of operators with their precedence values.
* @type {Readonly<Record<string, number>>}
*/
static PRECEDENCE = Object.freeze({
"+": 10,
"-": 10,
"*": 20,
"/": 20,
"%": 20
});
/**
* An array of operators which represent arithmetic operations
* @type {string[]}
*/
static OPERATORS = Object.keys(this.PRECEDENCE);
/** @inheritdoc */
static REGEXP = new RegExp(this.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} `;
}
}

Some files were not shown because too many files have changed in this diff Show More