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,141 @@
/**
* @typedef {FormApplicationOptions} DrawingConfigOptions
* @property {boolean} [configureDefault=false] Configure the default drawing settings, instead of a specific Drawing
*/
/**
* The Application responsible for configuring a single Drawing document within a parent Scene.
* @extends {DocumentSheet}
*
* @param {Drawing} drawing The Drawing object being configured
* @param {DrawingConfigOptions} options Additional application rendering options
*/
class DrawingConfig extends DocumentSheet {
/**
* @override
* @returns {DrawingConfigOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "drawing-config",
template: "templates/scene/drawing-config.html",
width: 480,
height: "auto",
configureDefault: false,
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "position"}]
});
}
/* -------------------------------------------- */
/** @override */
get title() {
if ( this.options.configureDefault ) return game.i18n.localize("DRAWING.ConfigDefaultTitle");
return super.title;
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
// Submit text
let submit;
if ( this.options.configureDefault ) submit = "DRAWING.SubmitDefault";
else submit = this.document.id ? "DRAWING.SubmitUpdate" : "DRAWING.SubmitCreate";
// Rendering context
return {
author: this.document.author?.name || "",
isDefault: this.options.configureDefault,
fillTypes: Object.entries(CONST.DRAWING_FILL_TYPES).reduce((obj, v) => {
obj[v[1]] = `DRAWING.FillType${v[0].titleCase()}`;
return obj;
}, {}),
scaledBezierFactor: this.document.bezierFactor * 2,
fontFamilies: FontConfig.getAvailableFontChoices(),
drawingRoles: {
object: "DRAWING.Object",
information: "DRAWING.Information"
},
currentRole: this.document.interface ? "information" : "object",
object: this.document.toObject(),
options: this.options,
gridUnits: this.document.parent?.grid.units || canvas.scene.grid.units || game.i18n.localize("GridUnits"),
userColor: game.user.color,
submitText: submit
};
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
if ( !this.object.isOwner ) throw new Error("You do not have the ability to configure this Drawing object.");
// Un-scale the bezier factor
formData.bezierFactor /= 2;
// Configure the default Drawing settings
if ( this.options.configureDefault ) {
formData = foundry.utils.expandObject(formData);
const defaults = DrawingDocument.cleanData(formData, {partial: true});
return game.settings.set("core", DrawingsLayer.DEFAULT_CONFIG_SETTING, defaults);
}
// Assign location
formData.interface = (formData.drawingRole === "information");
delete formData.drawingRole;
// Rescale dimensions if needed
const shape = this.object.shape;
const w = formData["shape.width"];
const h = formData["shape.height"];
if ( shape && ((w !== shape.width) || (h !== shape.height)) ) {
const dx = w - shape.width;
const dy = h - shape.height;
formData = foundry.utils.expandObject(formData);
formData.shape.width = shape.width;
formData.shape.height = shape.height;
foundry.utils.mergeObject(formData, Drawing.rescaleDimensions(formData, dx, dy));
}
// Create or update a Drawing
if ( this.object.id ) return this.object.update(formData);
return this.object.constructor.create(formData);
}
/* -------------------------------------------- */
/** @override */
async close(options) {
await super.close(options);
if ( this.preview ) {
this.preview.removeChildren();
this.preview = null;
}
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find('button[name="reset"]').click(this._onResetDefaults.bind(this));
}
/* -------------------------------------------- */
/**
* Reset the user Drawing configuration settings to their default values
* @param {PointerEvent} event The originating mouse-click event
* @protected
*/
_onResetDefaults(event) {
event.preventDefault();
this.object = DrawingDocument.fromSource({});
this.render();
}
}

View File

@@ -0,0 +1,42 @@
/**
* An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Drawing objects.
* The DrawingHUD implementation can be configured and replaced via {@link CONFIG.Drawing.hudClass}.
* @extends {BasePlaceableHUD<Drawing, DrawingDocument, DrawingsLayer>}
*/
class DrawingHUD extends BasePlaceableHUD {
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "drawing-hud",
template: "templates/hud/drawing-hud.html"
});
}
/* -------------------------------------------- */
/** @inheritDoc */
getData(options={}) {
const {locked, hidden} = this.object.document;
return foundry.utils.mergeObject(super.getData(options), {
lockedClass: locked ? "active" : "",
visibilityClass: hidden ? "active" : ""
});
}
/* -------------------------------------------- */
/** @override */
setPosition(options) {
let {x, y, width, height} = this.object.frame.bounds;
const c = 70;
const p = 10;
const position = {
width: width + (c * 2) + (p * 2),
height: height + (p * 2),
left: x + this.object.x - c - p,
top: y + this.object.y - p
};
this.element.css(position);
}
}

View File

@@ -0,0 +1,120 @@
/**
* The Application responsible for configuring a single Note document within a parent Scene.
* @param {NoteDocument} note The Note object for which settings are being configured
* @param {DocumentSheetOptions} [options] Additional Application configuration options
*/
class NoteConfig extends DocumentSheet {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("NOTE.ConfigTitle"),
template: "templates/scene/note-config.html",
width: 480
});
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const data = super.getData(options);
if ( !this.object.id ) data.data.global = !canvas.scene.tokenVision;
const entry = game.journal.get(this.object.entryId);
const pages = entry?.pages.contents.sort((a, b) => a.sort - b.sort);
const icons = Object.entries(CONFIG.JournalEntry.noteIcons).map(([label, src]) => {
return {label, src};
}).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
icons.unshift({label: game.i18n.localize("NOTE.Custom"), src: ""});
const customIcon = !Object.values(CONFIG.JournalEntry.noteIcons).includes(this.document.texture.src);
const icon = {
selected: customIcon ? "" : this.document.texture.src,
custom: customIcon ? this.document.texture.src : ""
};
return foundry.utils.mergeObject(data, {
icon, icons,
label: this.object.label,
entry: entry || {},
pages: pages || [],
entries: game.journal.filter(e => e.isOwner).sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang)),
fontFamilies: FontConfig.getAvailableFontChoices(),
textAnchors: Object.entries(CONST.TEXT_ANCHOR_POINTS).reduce((obj, e) => {
obj[e[1]] = game.i18n.localize(`JOURNAL.Anchor${e[0].titleCase()}`);
return obj;
}, {}),
gridUnits: this.document.parent.grid.units || game.i18n.localize("GridUnits"),
submitText: game.i18n.localize(this.id ? "NOTE.Update" : "NOTE.Create")
});
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
this._updateCustomIcon();
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onChangeInput(event) {
this._updateCustomIcon();
if ( event.currentTarget.name === "entryId" ) this._updatePageList();
return super._onChangeInput(event);
}
/* -------------------------------------------- */
/**
* Update disabled state of the custom icon field.
* @protected
*/
_updateCustomIcon() {
const selected = this.form["icon.selected"];
this.form["icon.custom"].disabled = selected.value.length;
}
/* -------------------------------------------- */
/**
* Update the list of pages.
* @protected
*/
_updatePageList() {
const entryId = this.form.elements.entryId?.value;
const pages = game.journal.get(entryId)?.pages.contents.sort((a, b) => a.sort - b.sort) ?? [];
const options = pages.map(page => {
const selected = (entryId === this.object.entryId) && (page.id === this.object.pageId);
return `<option value="${page.id}"${selected ? " selected" : ""}>${page.name}</option>`;
});
this.form.elements.pageId.innerHTML = `<option></option>${options}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData={}) {
const data = super._getSubmitData(updateData);
data["texture.src"] = data["icon.selected"] || data["icon.custom"];
delete data["icon.selected"];
delete data["icon.custom"];
return data;
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
if ( this.object.id ) return this.object.update(formData);
else return this.object.constructor.create(formData, {parent: canvas.scene});
}
/* -------------------------------------------- */
/** @override */
async close(options) {
if ( !this.object.id ) canvas.notes.clearPreviewContainer();
return super.close(options);
}
}

View File

@@ -0,0 +1,91 @@
/**
* The Application responsible for configuring a single Tile document within a parent Scene.
* @param {Tile} tile The Tile object being configured
* @param {DocumentSheetOptions} [options] Additional application rendering options
*/
class TileConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "tile-config",
title: game.i18n.localize("TILE.ConfigTitle"),
template: "templates/scene/tile-config.html",
width: 420,
height: "auto",
submitOnChange: true,
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "basic"}]
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
// If the config was closed without saving, reset the initial display of the Tile
if ( !options.force ) {
this.document.reset();
if ( this.document.object?.destroyed === false ) {
this.document.object.refresh();
}
}
// Remove the preview tile and close
const layer = this.object.layer;
layer.clearPreviewContainer();
return super.close(options);
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const data = super.getData(options);
data.submitText = game.i18n.localize(this.object.id ? "TILE.SubmitUpdate" : "TILE.SubmitCreate");
data.occlusionModes = Object.entries(CONST.OCCLUSION_MODES).reduce((obj, e) => {
obj[e[1]] = game.i18n.localize(`TILE.OcclusionMode${e[0].titleCase()}`);
return obj;
}, {});
data.gridUnits = this.document.parent.grid.units || game.i18n.localize("GridUnits");
return data;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onChangeInput(event) {
// Handle form element updates
const el = event.target;
if ( (el.type === "color") && el.dataset.edit ) this._onChangeColorPicker(event);
else if ( el.type === "range" ) this._onChangeRange(event);
// Update preview object
const fdo = new FormDataExtended(this.form).object;
// To allow a preview without glitches
fdo.width = Math.abs(fdo.width);
fdo.height = Math.abs(fdo.height);
// Handle tint exception
let tint = fdo["texture.tint"];
if ( !foundry.data.validators.isColorString(tint) ) fdo["texture.tint"] = "#ffffff";
fdo["texture.tint"] = Color.from(fdo["texture.tint"]);
// Update preview object
foundry.utils.mergeObject(this.document, foundry.utils.expandObject(fdo));
this.document.object.refresh();
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
if ( this.document.id ) return this.document.update(formData);
else return this.document.constructor.create(formData, {
parent: this.document.parent,
pack: this.document.pack
});
}
}

View File

@@ -0,0 +1,93 @@
/**
* An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Tile objects.
* The TileHUD implementation can be configured and replaced via {@link CONFIG.Tile.hudClass}.
* @extends {BasePlaceableHUD<Tile, TileDocument, TilesLayer>}
*/
class TileHUD extends BasePlaceableHUD {
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "tile-hud",
template: "templates/hud/tile-hud.html"
});
}
/* -------------------------------------------- */
/** @inheritDoc */
getData(options={}) {
const {locked, hidden} = this.document;
const {isVideo, sourceElement} = this.object;
const isPlaying = isVideo && !sourceElement.paused && !sourceElement.ended;
return foundry.utils.mergeObject(super.getData(options), {
isVideo: isVideo,
lockedClass: locked ? "active" : "",
visibilityClass: hidden ? "active" : "",
videoIcon: isPlaying ? "fas fa-pause" : "fas fa-play",
videoTitle: game.i18n.localize(isPlaying ? "HUD.TilePause" : "HUD.TilePlay")
});
}
/* -------------------------------------------- */
/** @override */
setPosition(options) {
let {x, y, width, height} = this.object.frame.bounds;
const c = 70;
const p = 10;
const position = {
width: width + (c * 2) + (p * 2),
height: height + (p * 2),
left: x + this.object.x - c - p,
top: y + this.object.y - p
};
this.element.css(position);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
_onClickControl(event) {
super._onClickControl(event);
if ( event.defaultPrevented ) return;
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "video":
return this.#onControlVideo(event);
}
}
/* -------------------------------------------- */
/**
* Control video playback by toggling play or paused state for a video Tile.
* @param {PointerEvent} event
*/
#onControlVideo(event) {
const src = this.object.sourceElement;
const icon = event.currentTarget.children[0];
const isPlaying = !src.paused && !src.ended;
// Intercepting state change if the source is not looping and not playing
if ( !src.loop && !isPlaying ) {
const self = this;
src.onpause = () => {
if ( self.object?.sourceElement ) {
icon.classList.replace("fa-pause", "fa-play");
self.render();
}
src.onpause = null;
};
}
// Update the video playing state
return this.object.document.update({"video.autoplay": false}, {
diff: false,
playVideo: !isPlaying,
offset: src.ended ? 0 : null
});
}
}

View File

@@ -0,0 +1,660 @@
/**
* The Application responsible for configuring a single Token document within a parent Scene.
* @param {TokenDocument|Actor} object The {@link TokenDocument} being configured or an {@link Actor} for whom
* to configure the {@link PrototypeToken}
* @param {FormApplicationOptions} [options] Application configuration options.
*/
class TokenConfig extends DocumentSheet {
constructor(object, options) {
super(object, options);
/**
* The placed Token object in the Scene
* @type {TokenDocument}
*/
this.token = this.object;
/**
* A reference to the Actor which the token depicts
* @type {Actor}
*/
this.actor = this.object.actor;
// Configure options
if ( this.isPrototype ) this.options.sheetConfig = false;
}
/**
* Maintain a copy of the original to show a real-time preview of changes.
* @type {TokenDocument|PrototypeToken}
*/
preview;
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "token-sheet"],
template: "templates/scene/token-config.html",
width: 480,
height: "auto",
tabs: [
{navSelector: '.tabs[data-group="main"]', contentSelector: "form", initial: "character"},
{navSelector: '.tabs[data-group="light"]', contentSelector: '.tab[data-tab="light"]', initial: "basic"},
{navSelector: '.tabs[data-group="vision"]', contentSelector: '.tab[data-tab="vision"]', initial: "basic"}
],
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER,
sheetConfig: true
});
}
/* -------------------------------------------- */
/**
* A convenience accessor to test whether we are configuring the prototype Token for an Actor.
* @type {boolean}
*/
get isPrototype() {
return this.object instanceof foundry.data.PrototypeToken;
}
/* -------------------------------------------- */
/** @inheritDoc */
get id() {
if ( this.isPrototype ) return `${this.constructor.name}-${this.actor.uuid}`;
else return super.id;
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
if ( this.isPrototype ) return `${game.i18n.localize("TOKEN.TitlePrototype")}: ${this.actor.name}`;
return `${game.i18n.localize("TOKEN.Title")}: ${this.token.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
render(force=false, options={}) {
if ( this.isPrototype ) this.object.actor.apps[this.appId] = this;
return super.render(force, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options={}) {
await this._handleTokenPreview(force, options);
return super._render(force, options);
}
/* -------------------------------------------- */
/**
* Handle preview with a token.
* @param {boolean} force
* @param {object} options
* @returns {Promise<void>}
* @protected
*/
async _handleTokenPreview(force, options={}) {
const states = Application.RENDER_STATES;
if ( force && [states.CLOSED, states.NONE].includes(this._state) ) {
if ( this.isPrototype ) {
this.preview = this.object.clone();
return;
}
if ( !this.document.object ) {
this.preview = null;
return;
}
if ( !this.preview ) {
const clone = this.document.object.clone({}, {keepId: true});
this.preview = clone.document;
clone.control({releaseOthers: true});
}
await this.preview.object.draw();
this.document.object.renderable = false;
this.document.object.initializeSources({deleted: true});
this.preview.object.layer.preview.addChild(this.preview.object);
this._previewChanges();
}
}
/* -------------------------------------------- */
/** @inheritDoc */
_canUserView(user) {
const canView = super._canUserView(user);
return canView && game.user.can("TOKEN_CONFIGURE");
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
const alternateImages = await this._getAlternateTokenImages();
const usesTrackableAttributes = !foundry.utils.isEmpty(CONFIG.Actor.trackableAttributes);
const attributeSource = (this.actor?.system instanceof foundry.abstract.DataModel) && usesTrackableAttributes
? this.actor?.type
: this.actor?.system;
const attributes = TokenDocument.implementation.getTrackedAttributes(attributeSource);
const canBrowseFiles = game.user.hasPermission("FILES_BROWSE");
// Prepare Token data
const doc = this.preview ?? this.document;
const source = doc.toObject();
const sourceDetectionModes = new Set(source.detectionModes.map(m => m.id));
const preparedDetectionModes = doc.detectionModes.filter(m => !sourceDetectionModes.has(m.id));
// Return rendering context
return {
fields: this.document.schema.fields, // Important to use the true document schema,
lightFields: this.document.schema.fields.light.fields,
cssClasses: [this.isPrototype ? "prototype" : null].filter(c => !!c).join(" "),
isPrototype: this.isPrototype,
hasAlternates: !foundry.utils.isEmpty(alternateImages),
alternateImages: alternateImages,
object: source,
options: this.options,
gridUnits: (this.isPrototype ? "" : this.document.parent?.grid.units) || game.i18n.localize("GridUnits"),
barAttributes: TokenDocument.implementation.getTrackedAttributeChoices(attributes),
bar1: doc.getBarAttribute?.("bar1"),
bar2: doc.getBarAttribute?.("bar2"),
colorationTechniques: AdaptiveLightingShader.SHADER_TECHNIQUES,
visionModes: Object.values(CONFIG.Canvas.visionModes).filter(f => f.tokenConfig),
detectionModes: Object.values(CONFIG.Canvas.detectionModes).filter(f => f.tokenConfig),
preparedDetectionModes,
displayModes: Object.entries(CONST.TOKEN_DISPLAY_MODES).reduce((obj, e) => {
obj[e[1]] = game.i18n.localize(`TOKEN.DISPLAY_${e[0]}`);
return obj;
}, {}),
hexagonalShapes: Object.entries(CONST.TOKEN_HEXAGONAL_SHAPES).reduce((obj, [k, v]) => {
obj[v] = game.i18n.localize(`TOKEN.HEXAGONAL_SHAPE_${k}`);
return obj;
}, {}),
showHexagonalShapes: this.isPrototype || !doc.parent || doc.parent.grid.isHexagonal,
actors: game.actors.reduce((actors, a) => {
if ( !a.isOwner ) return actors;
actors.push({_id: a.id, name: a.name});
return actors;
}, []).sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang)),
dispositions: Object.entries(CONST.TOKEN_DISPOSITIONS).reduce((obj, e) => {
obj[e[1]] = game.i18n.localize(`TOKEN.DISPOSITION.${e[0]}`);
return obj;
}, {}),
lightAnimations: CONFIG.Canvas.lightAnimations,
isGM: game.user.isGM,
randomImgEnabled: this.isPrototype && (canBrowseFiles || doc.randomImg),
scale: Math.abs(doc.texture.scaleX),
mirrorX: doc.texture.scaleX < 0,
mirrorY: doc.texture.scaleY < 0,
textureFitModes: CONST.TEXTURE_DATA_FIT_MODES.reduce((obj, fit) => {
obj[fit] = game.i18n.localize(`TEXTURE_DATA.FIT.${fit}`);
return obj;
}, {}),
ringEffectsInput: this.#ringEffectsInput.bind(this)
};
}
/* --------------------------------------------- */
/** @inheritdoc */
async _renderInner(...args) {
await loadTemplates([
"templates/scene/parts/token-lighting.hbs",
"templates/scene/parts/token-vision.html",
"templates/scene/parts/token-resources.html"
]);
return super._renderInner(...args);
}
/* -------------------------------------------- */
/**
* Render the Token ring effects input using a multi-checkbox element.
* @param {NumberField} field The ring effects field
* @param {FormInputConfig} inputConfig Form input configuration
* @returns {HTMLMultiCheckboxElement}
*/
#ringEffectsInput(field, inputConfig) {
const options = [];
const value = [];
for ( const [effectName, effectValue] of Object.entries(CONFIG.Token.ring.ringClass.effects) ) {
const localization = CONFIG.Token.ring.effects[effectName];
if ( (effectName === "DISABLED") || (effectName === "ENABLED") || !localization ) continue;
options.push({value: effectName, label: game.i18n.localize(localization)});
if ( (inputConfig.value & effectValue) !== 0 ) value.push(effectName);
}
Object.assign(inputConfig, {name: field.fieldPath, options, value, type: "checkboxes"});
return foundry.applications.fields.createMultiSelectInput(inputConfig);
}
/* -------------------------------------------- */
/**
* Get an Object of image paths and filenames to display in the Token sheet
* @returns {Promise<object>}
* @private
*/
async _getAlternateTokenImages() {
if ( !this.actor?.prototypeToken.randomImg ) return {};
const alternates = await this.actor.getTokenImages();
return alternates.reduce((obj, img) => {
obj[img] = img.split("/").pop();
return obj;
}, {});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find(".action-button").click(this._onClickActionButton.bind(this));
html.find(".bar-attribute").change(this._onBarChange.bind(this));
html.find(".alternate-images").change(ev => ev.target.form["texture.src"].value = ev.target.value);
html.find("button.assign-token").click(this._onAssignToken.bind(this));
this._disableEditImage();
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
const states = Application.RENDER_STATES;
if ( options.force || [states.RENDERED, states.ERROR].includes(this._state) ) {
this._resetPreview();
}
await super.close(options);
if ( this.isPrototype ) delete this.object.actor.apps?.[this.appId];
}
/* -------------------------------------------- */
/** @inheritDoc */
_getSubmitData(updateData={}) {
const formData = foundry.utils.expandObject(super._getSubmitData(updateData));
// Prototype Token unpacking
if ( this.document instanceof foundry.data.PrototypeToken ) {
Object.assign(formData, formData.prototypeToken);
delete formData.prototypeToken;
}
// Mirror token scale
if ( "scale" in formData ) {
formData.texture.scaleX = formData.scale * (formData.mirrorX ? -1 : 1);
formData.texture.scaleY = formData.scale * (formData.mirrorY ? -1 : 1);
}
["scale", "mirrorX", "mirrorY"].forEach(k => delete formData[k]);
// Token Ring Effects
if ( Array.isArray(formData.ring?.effects) ) {
const TRE = CONFIG.Token.ring.ringClass.effects;
let effects = formData.ring.enabled ? TRE.ENABLED : TRE.DISABLED;
for ( const effectName of formData.ring.effects ) {
const v = TRE[effectName] ?? 0;
effects |= v;
}
formData.ring.effects = effects;
}
// Clear detection modes array
formData.detectionModes ??= [];
// Treat "None" as null for bar attributes
formData.bar1.attribute ||= null;
formData.bar2.attribute ||= null;
return foundry.utils.flattenObject(formData);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _onChangeInput(event) {
await super._onChangeInput(event);
// Disable image editing for wildcards
this._disableEditImage();
// Pre-populate vision mode defaults
const element = event.target;
if ( element.name === "sight.visionMode" ) {
const visionDefaults = CONFIG.Canvas.visionModes[element.value]?.vision?.defaults || {};
const update = fieldName => {
const field = this.form.querySelector(`[name="sight.${fieldName}"]`);
if ( fieldName in visionDefaults ) {
const value = visionDefaults[fieldName];
if ( value === undefined ) return;
if ( field.type === "checkbox" ) {
field.checked = value;
} else if ( field.type === "range" ) {
field.value = value;
const rangeValue = field.parentNode.querySelector(".range-value");
if ( rangeValue ) rangeValue.innerText = value;
} else if ( field.classList.contains("color") ) {
field.value = value;
const colorInput = field.parentNode.querySelector('input[type="color"]');
if ( colorInput ) colorInput.value = value;
} else {
field.value = value;
}
}
};
for ( const fieldName of ["color", "attenuation", "brightness", "saturation", "contrast"] ) update(fieldName);
}
// Preview token changes
const previewData = this._getSubmitData();
this._previewChanges(previewData);
}
/* -------------------------------------------- */
/**
* Mimic changes to the Token document as if they were true document updates.
* @param {object} [change] The change to preview.
* @protected
*/
_previewChanges(change) {
if ( !this.preview ) return;
if ( change ) {
change = {...change};
delete change.actorId;
delete change.actorLink;
this.preview.updateSource(change);
}
if ( !this.isPrototype && (this.preview.object?.destroyed === false) ) {
this.preview.object.initializeSources();
this.preview.object.renderFlags.set({refresh: true});
}
}
/* -------------------------------------------- */
/**
* Reset the temporary preview of the Token when the form is submitted or closed.
* @protected
*/
_resetPreview() {
if ( !this.preview ) return;
if ( this.isPrototype ) return this.preview = null;
if ( this.preview.object?.destroyed === false ) {
this.preview.object.destroy({children: true});
}
this.preview.baseActor?._unregisterDependentToken(this.preview);
this.preview = null;
if ( this.document.object?.destroyed === false ) {
this.document.object.renderable = true;
this.document.object.initializeSources();
this.document.object.control();
this.document.object.renderFlags.set({refresh: true});
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
this._resetPreview();
return this.token.update(formData);
}
/* -------------------------------------------- */
/**
* Handle Token assignment requests to update the default prototype Token
* @param {MouseEvent} event The left-click event on the assign token button
* @private
*/
async _onAssignToken(event) {
event.preventDefault();
// Get controlled Token data
let tokens = canvas.ready ? canvas.tokens.controlled : [];
if ( tokens.length !== 1 ) {
ui.notifications.warn("TOKEN.AssignWarn", {localize: true});
return;
}
const token = tokens.pop().document.toObject();
token.tokenId = token.x = token.y = null;
token.randomImg = this.form.elements.randomImg.checked;
if ( token.randomImg ) delete token.texture.src;
// Update the prototype token for the actor using the existing Token instance
await this.actor.update({prototypeToken: token}, {diff: false, recursive: false, noHook: true});
ui.notifications.info(game.i18n.format("TOKEN.AssignSuccess", {name: this.actor.name}));
}
/* -------------------------------------------- */
/**
* Handle changing the attribute bar in the drop-down selector to update the default current and max value
* @param {Event} event The select input change event
* @private
*/
async _onBarChange(event) {
const form = event.target.form;
const doc = this.preview ?? this.document;
const attr = doc.getBarAttribute("", {alternative: event.target.value});
const bar = event.target.name.split(".").shift();
form.querySelector(`input.${bar}-value`).value = attr !== null ? attr.value : "";
form.querySelector(`input.${bar}-max`).value = ((attr !== null) && (attr.type === "bar")) ? attr.max : "";
}
/* -------------------------------------------- */
/**
* Handle click events on a token configuration sheet action button
* @param {PointerEvent} event The originating click event
* @protected
*/
_onClickActionButton(event) {
event.preventDefault();
const button = event.currentTarget;
const action = button.dataset.action;
game.tooltip.deactivate();
// Get pending changes to modes
const modes = Object.values(foundry.utils.expandObject(this._getSubmitData())?.detectionModes || {});
// Manipulate the array
switch ( action ) {
case "addDetectionMode":
this._onAddDetectionMode(modes);
break;
case "removeDetectionMode":
const idx = button.closest(".detection-mode").dataset.index;
this._onRemoveDetectionMode(Number(idx), modes);
break;
}
this._previewChanges({detectionModes: modes});
this.render();
}
/* -------------------------------------------- */
/**
* Handle adding a detection mode.
* @param {object[]} modes The existing detection modes.
* @protected
*/
_onAddDetectionMode(modes) {
modes.push({id: "", range: 0, enabled: true});
}
/* -------------------------------------------- */
/**
* Handle removing a detection mode.
* @param {number} index The index of the detection mode to remove.
* @param {object[]} modes The existing detection modes.
* @protected
*/
_onRemoveDetectionMode(index, modes) {
modes.splice(index, 1);
}
/* -------------------------------------------- */
/**
* Disable the user's ability to edit the token image field if wildcard images are enabled and that user does not have
* file browser permissions.
* @private
*/
_disableEditImage() {
const img = this.form.querySelector('[name="texture.src"]');
const randomImg = this.form.querySelector('[name="randomImg"]');
if ( randomImg ) img.disabled = !game.user.hasPermission("FILES_BROWSE") && randomImg.checked;
}
}
/**
* A sheet that alters the values of the default Token configuration used when new Token documents are created.
* @extends {TokenConfig}
*/
class DefaultTokenConfig extends TokenConfig {
constructor(object, options) {
const setting = game.settings.get("core", DefaultTokenConfig.SETTING);
const cls = getDocumentClass("Token");
object = new cls({name: "Default Token", ...setting}, {strict: false});
super(object, options);
}
/**
* The named world setting that stores the default Token configuration
* @type {string}
*/
static SETTING = "defaultToken";
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/scene/default-token-config.html",
sheetConfig: false
});
}
/* --------------------------------------------- */
/** @inheritdoc */
get id() {
return "default-token-config";
}
/* --------------------------------------------- */
/** @inheritdoc */
get title() {
return game.i18n.localize("SETTINGS.DefaultTokenN");
}
/* -------------------------------------------- */
/** @override */
get isEditable() {
return game.user.can("SETTINGS_MODIFY");
}
/* -------------------------------------------- */
/** @override */
_canUserView(user) {
return user.can("SETTINGS_MODIFY");
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const context = await super.getData(options);
return Object.assign(context, {
object: this.token.toObject(false),
isDefault: true,
barAttributes: TokenDocument.implementation.getTrackedAttributeChoices(),
bar1: this.token.bar1,
bar2: this.token.bar2
});
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData = {}) {
const formData = foundry.utils.expandObject(super._getSubmitData(updateData));
formData.light.color = formData.light.color || undefined;
formData.bar1.attribute = formData.bar1.attribute || null;
formData.bar2.attribute = formData.bar2.attribute || null;
return formData;
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
// Validate the default data
try {
this.object.updateSource(formData);
formData = foundry.utils.filterObject(this.token.toObject(), formData);
} catch(err) {
Hooks.onError("DefaultTokenConfig#_updateObject", err, {notify: "error"});
}
// Diff the form data against normal defaults
const defaults = foundry.documents.BaseToken.cleanData();
const delta = foundry.utils.diffObject(defaults, formData);
await game.settings.set("core", DefaultTokenConfig.SETTING, delta);
return SettingsConfig.reloadConfirm({ world: true });
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find('button[data-action="reset"]').click(this.reset.bind(this));
}
/* -------------------------------------------- */
/**
* Reset the form to default values
* @returns {Promise<void>}
*/
async reset() {
const cls = getDocumentClass("Token");
this.object = new cls({}, {strict: false});
this.token = this.object;
this.render();
}
/* --------------------------------------------- */
/** @inheritdoc */
async _onBarChange() {}
/* -------------------------------------------- */
/** @inheritdoc */
_onAddDetectionMode(modes) {
super._onAddDetectionMode(modes);
this.document.updateSource({ detectionModes: modes });
}
/* -------------------------------------------- */
/** @inheritdoc */
_onRemoveDetectionMode(index, modes) {
super._onRemoveDetectionMode(index, modes);
this.document.updateSource({ detectionModes: modes });
}
}

View File

@@ -0,0 +1,258 @@
/**
* An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Token objects.
* This interface provides controls for visibility, attribute bars, elevation, status effects, and more.
* The TokenHUD implementation can be configured and replaced via {@link CONFIG.Token.hudClass}.
* @extends {BasePlaceableHUD<Token, TokenDocument, TokenLayer>}
*/
class TokenHUD extends BasePlaceableHUD {
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "token-hud",
template: "templates/hud/token-hud.html"
});
}
/* -------------------------------------------- */
/**
* Track whether the status effects control palette is currently expanded or hidden
* @type {boolean}
*/
#statusTrayActive = false;
/* -------------------------------------------- */
/**
* Convenience reference to the Actor modified by this TokenHUD.
* @type {Actor}
*/
get actor() {
return this.document?.actor;
}
/* -------------------------------------------- */
/** @override */
bind(object) {
this.#statusTrayActive = false;
return super.bind(object);
}
/* -------------------------------------------- */
/** @override */
setPosition(_position) {
const b = this.object.bounds;
const {width, height} = this.document;
const ratio = canvas.dimensions.size / 100;
const position = {width: width * 100, height: height * 100, left: b.left, top: b.top};
if ( ratio !== 1 ) position.transform = `scale(${ratio})`;
this.element.css(position);
this.element[0].classList.toggle("large", height >= 2);
}
/* -------------------------------------------- */
/** @inheritDoc */
getData(options={}) {
let data = super.getData(options);
const bar1 = this.document.getBarAttribute("bar1");
const bar2 = this.document.getBarAttribute("bar2");
data = foundry.utils.mergeObject(data, {
canConfigure: game.user.can("TOKEN_CONFIGURE"),
canToggleCombat: ui.combat !== null,
displayBar1: bar1 && (bar1.type !== "none"),
bar1Data: bar1,
displayBar2: bar2 && (bar2.type !== "none"),
bar2Data: bar2,
visibilityClass: data.hidden ? "active" : "",
effectsClass: this.#statusTrayActive ? "active" : "",
combatClass: this.object.inCombat ? "active" : "",
targetClass: this.object.targeted.has(game.user) ? "active" : ""
});
data.statusEffects = this._getStatusEffectChoices();
return data;
}
/* -------------------------------------------- */
/**
* Get an array of icon paths which represent valid status effect choices.
* @protected
*/
_getStatusEffectChoices() {
// Include all HUD-enabled status effects
const choices = {};
for ( const status of CONFIG.statusEffects ) {
if ( (status.hud === false) || ((foundry.utils.getType(status.hud) === "Object")
&& (status.hud.actorTypes?.includes(this.document.actor.type) === false)) ) {
continue;
}
choices[status.id] = {
_id: status._id,
id: status.id,
title: game.i18n.localize(status.name ?? /** @deprecated since v12 */ status.label),
src: status.img ?? /** @deprecated since v12 */ status.icon,
isActive: false,
isOverlay: false
};
}
// Update the status of effects which are active for the token actor
const activeEffects = this.actor?.effects || [];
for ( const effect of activeEffects ) {
for ( const statusId of effect.statuses ) {
const status = choices[statusId];
if ( !status ) continue;
if ( status._id ) {
if ( status._id !== effect.id ) continue;
} else {
if ( effect.statuses.size !== 1 ) continue;
}
status.isActive = true;
if ( effect.getFlag("core", "overlay") ) status.isOverlay = true;
break;
}
}
// Flag status CSS class
for ( const status of Object.values(choices) ) {
status.cssClass = [
status.isActive ? "active" : null,
status.isOverlay ? "overlay" : null
].filterJoin(" ");
}
return choices;
}
/* -------------------------------------------- */
/**
* Toggle the expanded state of the status effects selection tray.
* @param {boolean} [active] Force the status tray to be active or inactive
*/
toggleStatusTray(active) {
active ??= !this.#statusTrayActive;
this.#statusTrayActive = active;
const button = this.element.find('.control-icon[data-action="effects"]')[0];
button.classList.toggle("active", active);
const palette = this.element[0].querySelector(".status-effects");
palette.classList.toggle("active", active);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
this.toggleStatusTray(this.#statusTrayActive);
const effectsTray = html.find(".status-effects");
effectsTray.on("click", ".effect-control", this.#onToggleEffect.bind(this));
effectsTray.on("contextmenu", ".effect-control", event => this.#onToggleEffect(event, {overlay: true}));
}
/* -------------------------------------------- */
/** @inheritDoc */
_onClickControl(event) {
super._onClickControl(event);
if ( event.defaultPrevented ) return;
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "config":
return this.#onTokenConfig(event);
case "combat":
return this.#onToggleCombat(event);
case "target":
return this.#onToggleTarget(event);
case "effects":
return this.#onToggleStatusEffects(event);
}
}
/* -------------------------------------------- */
/** @inheritDoc */
async _updateAttribute(name, input) {
const attr = this.document.getBarAttribute(name);
if ( !attr ) return super._updateAttribute(name, input);
const {value, delta, isDelta, isBar} = this._parseAttributeInput(name, attr, input);
await this.actor?.modifyTokenAttribute(attr.attribute, isDelta ? delta : value, isDelta, isBar);
}
/* -------------------------------------------- */
/**
* Toggle the combat state of all controlled Tokens.
* @param {PointerEvent} event
*/
async #onToggleCombat(event) {
event.preventDefault();
const tokens = canvas.tokens.controlled.map(t => t.document);
if ( !this.object.controlled ) tokens.push(this.document);
try {
if ( this.document.inCombat ) await TokenDocument.implementation.deleteCombatants(tokens);
else await TokenDocument.implementation.createCombatants(tokens);
} catch(err) {
ui.notifications.warn(err.message);
}
}
/* -------------------------------------------- */
/**
* Handle Token configuration button click.
* @param {PointerEvent} event
*/
#onTokenConfig(event) {
event.preventDefault();
this.object.sheet.render(true);
}
/* -------------------------------------------- */
/**
* Handle left-click events to toggle the displayed state of the status effect selection palette
* @param {PointerEvent} event
*/
#onToggleStatusEffects(event) {
event.preventDefault();
this.toggleStatusTray(!this.#statusTrayActive);
}
/* -------------------------------------------- */
/**
* Handle toggling a token status effect icon
* @param {PointerEvent} event The click event to toggle the effect
* @param {object} [options] Options which modify the toggle
* @param {boolean} [options.overlay] Toggle the overlay effect?
*/
#onToggleEffect(event, {overlay=false}={}) {
event.preventDefault();
event.stopPropagation();
if ( !this.actor ) return ui.notifications.warn("HUD.WarningEffectNoActor", {localize: true});
const statusId = event.currentTarget.dataset.statusId;
this.actor.toggleStatusEffect(statusId, {overlay});
}
/* -------------------------------------------- */
/**
* Handle toggling the target state for this Token
* @param {PointerEvent} event The click event to toggle the target
*/
#onToggleTarget(event) {
event.preventDefault();
const btn = event.currentTarget;
const token = this.object;
const targeted = !token.isTargeted;
token.setTarget(targeted, {releaseOthers: false});
btn.classList.toggle("active", targeted);
}
}

View File

@@ -0,0 +1,189 @@
/**
* The Application responsible for configuring a single Wall document within a parent Scene.
* @param {Wall} object The Wall object for which settings are being configured
* @param {FormApplicationOptions} [options] Additional options which configure the rendering of the configuration
* sheet.
*/
class WallConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.classes.push("wall-config");
options.template = "templates/scene/wall-config.html";
options.width = 400;
options.height = "auto";
return options;
}
/**
* An array of Wall ids that should all be edited when changes to this config form are submitted
* @type {string[]}
*/
editTargets = [];
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
if ( this.editTargets.length > 1 ) return game.i18n.localize("WALLS.TitleMany");
return super.title;
}
/* -------------------------------------------- */
/** @inheritdoc */
render(force, options) {
if ( options?.walls instanceof Array ) {
this.editTargets = options.walls.map(w => w.id);
}
return super.render(force, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
context.source = this.document.toObject();
context.p0 = {x: this.object.c[0], y: this.object.c[1]};
context.p1 = {x: this.object.c[2], y: this.object.c[3]};
context.gridUnits = this.document.parent.grid.units || game.i18n.localize("GridUnits");
context.moveTypes = Object.keys(CONST.WALL_MOVEMENT_TYPES).reduce((obj, key) => {
let k = CONST.WALL_MOVEMENT_TYPES[key];
obj[k] = game.i18n.localize(`WALLS.SenseTypes.${key}`);
return obj;
}, {});
context.senseTypes = Object.keys(CONST.WALL_SENSE_TYPES).reduce((obj, key) => {
let k = CONST.WALL_SENSE_TYPES[key];
obj[k] = game.i18n.localize(`WALLS.SenseTypes.${key}`);
return obj;
}, {});
context.dirTypes = Object.keys(CONST.WALL_DIRECTIONS).reduce((obj, key) => {
let k = CONST.WALL_DIRECTIONS[key];
obj[k] = game.i18n.localize(`WALLS.Directions.${key}`);
return obj;
}, {});
context.doorTypes = Object.keys(CONST.WALL_DOOR_TYPES).reduce((obj, key) => {
let k = CONST.WALL_DOOR_TYPES[key];
obj[k] = game.i18n.localize(`WALLS.DoorTypes.${key}`);
return obj;
}, {});
context.doorStates = Object.keys(CONST.WALL_DOOR_STATES).reduce((obj, key) => {
let k = CONST.WALL_DOOR_STATES[key];
obj[k] = game.i18n.localize(`WALLS.DoorStates.${key}`);
return obj;
}, {});
context.doorSounds = CONFIG.Wall.doorSounds;
context.isDoor = this.object.isDoor;
return context;
}
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
html.find(".audio-preview").click(this.#onAudioPreview.bind(this));
this.#enableDoorOptions(this.document.door > CONST.WALL_DOOR_TYPES.NONE);
this.#toggleThresholdInputVisibility();
return super.activateListeners(html);
}
/* -------------------------------------------- */
#audioPreviewState = 0;
/**
* Handle previewing a sound file for a Wall setting
* @param {Event} event The initial button click event
*/
#onAudioPreview(event) {
const doorSoundName = this.form.doorSound.value;
const doorSound = CONFIG.Wall.doorSounds[doorSoundName];
if ( !doorSound ) return;
const interactions = CONST.WALL_DOOR_INTERACTIONS;
const interaction = interactions[this.#audioPreviewState++ % interactions.length];
let sounds = doorSound[interaction];
if ( !sounds ) return;
if ( !Array.isArray(sounds) ) sounds = [sounds];
const src = sounds[Math.floor(Math.random() * sounds.length)];
game.audio.play(src, {context: game.audio.interface});
}
/* -------------------------------------------- */
/** @inheritDoc */
async _onChangeInput(event) {
if ( event.currentTarget.name === "door" ) {
this.#enableDoorOptions(Number(event.currentTarget.value) > CONST.WALL_DOOR_TYPES.NONE);
}
else if ( event.currentTarget.name === "doorSound" ) {
this.#audioPreviewState = 0;
}
else if ( ["light", "sight", "sound"].includes(event.currentTarget.name) ) {
this.#toggleThresholdInputVisibility();
}
return super._onChangeInput(event);
}
/* -------------------------------------------- */
/**
* Toggle the disabled attribute of the door state select.
* @param {boolean} isDoor
*/
#enableDoorOptions(isDoor) {
const doorOptions = this.form.querySelector(".door-options");
doorOptions.disabled = !isDoor;
doorOptions.classList.toggle("hidden", !isDoor);
this.setPosition({height: "auto"});
}
/* -------------------------------------------- */
/**
* Toggle visibility of proximity input fields.
*/
#toggleThresholdInputVisibility() {
const form = this.form;
const showTypes = [CONST.WALL_SENSE_TYPES.PROXIMITY, CONST.WALL_SENSE_TYPES.DISTANCE];
for ( const sense of ["light", "sight", "sound"] ) {
const select = form[sense];
const input = select.parentElement.querySelector(".proximity");
input.classList.toggle("hidden", !showTypes.includes(Number(select.value)));
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData={}) {
const thresholdTypes = [CONST.WALL_SENSE_TYPES.PROXIMITY, CONST.WALL_SENSE_TYPES.DISTANCE];
const formData = super._getSubmitData(updateData);
for ( const sense of ["light", "sight", "sound"] ) {
if ( !thresholdTypes.includes(formData[sense]) ) formData[`threshold.${sense}`] = null;
}
return formData;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
// Update multiple walls
if ( this.editTargets.length > 1 ) {
const updateData = canvas.scene.walls.reduce((arr, w) => {
if ( this.editTargets.includes(w.id) ) {
arr.push(foundry.utils.mergeObject(w.toJSON(), formData));
}
return arr;
}, []);
return canvas.scene.updateEmbeddedDocuments("Wall", updateData, {sound: false});
}
// Update single wall
if ( !this.object.id ) return;
return this.object.update(formData, {sound: false});
}
}