Initial
This commit is contained in:
141
resources/app/client/apps/placeables/drawing-config.js
Normal file
141
resources/app/client/apps/placeables/drawing-config.js
Normal 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();
|
||||
}
|
||||
}
|
||||
42
resources/app/client/apps/placeables/drawing-hud.js
Normal file
42
resources/app/client/apps/placeables/drawing-hud.js
Normal 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);
|
||||
}
|
||||
}
|
||||
120
resources/app/client/apps/placeables/note-config.js
Normal file
120
resources/app/client/apps/placeables/note-config.js
Normal 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);
|
||||
}
|
||||
}
|
||||
91
resources/app/client/apps/placeables/tile-config.js
Normal file
91
resources/app/client/apps/placeables/tile-config.js
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
93
resources/app/client/apps/placeables/tile-hud.js
Normal file
93
resources/app/client/apps/placeables/tile-hud.js
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
660
resources/app/client/apps/placeables/token-config.js
Normal file
660
resources/app/client/apps/placeables/token-config.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
258
resources/app/client/apps/placeables/token-hud.js
Normal file
258
resources/app/client/apps/placeables/token-hud.js
Normal 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);
|
||||
}
|
||||
}
|
||||
189
resources/app/client/apps/placeables/wall-config.js
Normal file
189
resources/app/client/apps/placeables/wall-config.js
Normal 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});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user