Files
2025-01-04 00:34:03 +01:00

456 lines
15 KiB
JavaScript

/**
* The Tokens Container.
* @category - Canvas
*/
class TokenLayer extends PlaceablesLayer {
/**
* The current index position in the tab cycle
* @type {number|null}
* @private
*/
_tabIndex = null;
/* -------------------------------------------- */
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
name: "tokens",
controllableObjects: true,
rotatableObjects: true,
zIndex: 200
});
}
/** @inheritdoc */
static documentName = "Token";
/* -------------------------------------------- */
/**
* The set of tokens that trigger occlusion (a union of {@link CONST.TOKEN_OCCLUSION_MODES}).
* @type {number}
*/
set occlusionMode(value) {
this.#occlusionMode = value;
canvas.perception.update({refreshOcclusion: true});
}
get occlusionMode() {
return this.#occlusionMode;
}
#occlusionMode;
/* -------------------------------------------- */
/** @inheritdoc */
get hookName() {
return TokenLayer.name;
}
/* -------------------------------------------- */
/* Properties
/* -------------------------------------------- */
/**
* Token objects on this layer utilize the TokenHUD
*/
get hud() {
return canvas.hud.token;
}
/**
* An Array of tokens which belong to actors which are owned
* @type {Token[]}
*/
get ownedTokens() {
return this.placeables.filter(t => t.actor && t.actor.isOwner);
}
/* -------------------------------------------- */
/* Methods
/* -------------------------------------------- */
/** @override */
getSnappedPoint(point) {
const M = CONST.GRID_SNAPPING_MODES;
return canvas.grid.getSnappedPoint(point, {mode: M.TOP_LEFT_CORNER, resolution: 1});
}
/* -------------------------------------------- */
/** @inheritDoc */
async _draw(options) {
await super._draw(options);
this.objects.visible = true;
// Reset the Tokens layer occlusion mode for the Scene
const M = CONST.TOKEN_OCCLUSION_MODES;
this.#occlusionMode = game.user.isGM ? M.CONTROLLED | M.HOVERED | M.HIGHLIGHTED : M.OWNED;
canvas.app.ticker.add(this._animateTargets, this);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _tearDown(options) {
this.concludeAnimation();
return super._tearDown(options);
}
/* -------------------------------------------- */
/** @inheritDoc */
_activate() {
super._activate();
if ( canvas.controls ) canvas.controls.doors.visible = true;
this._tabIndex = null;
}
/* -------------------------------------------- */
/** @inheritDoc */
_deactivate() {
super._deactivate();
this.objects.visible = true;
if ( canvas.controls ) canvas.controls.doors.visible = false;
}
/* -------------------------------------------- */
/** @override */
_pasteObject(copy, offset, {hidden=false, snap=true}={}) {
const {x, y} = copy.document;
let position = {x: x + offset.x, y: y + offset.y};
if ( snap ) position = copy.getSnappedPosition(position);
const d = canvas.dimensions;
position.x = Math.clamp(position.x, 0, d.width - 1);
position.y = Math.clamp(position.y, 0, d.height - 1);
const data = copy.document.toObject();
delete data._id;
data.x = position.x;
data.y = position.y;
data.hidden ||= hidden;
return data;
}
/* -------------------------------------------- */
/** @inheritDoc */
_getMovableObjects(ids, includeLocked) {
const ruler = canvas.controls.ruler;
if ( ruler.state === Ruler.STATES.MEASURING ) return [];
const tokens = super._getMovableObjects(ids, includeLocked);
if ( ruler.token ) tokens.findSplice(token => token === ruler.token);
return tokens;
}
/* -------------------------------------------- */
/**
* Target all Token instances which fall within a coordinate rectangle.
*
* @param {object} rectangle The selection rectangle.
* @param {number} rectangle.x The top-left x-coordinate of the selection rectangle
* @param {number} rectangle.y The top-left y-coordinate of the selection rectangle
* @param {number} rectangle.width The width of the selection rectangle
* @param {number} rectangle.height The height of the selection rectangle
* @param {object} [options] Additional options to configure targeting behaviour.
* @param {boolean} [options.releaseOthers=true] Whether or not to release other targeted tokens
* @returns {number} The number of Token instances which were targeted.
*/
targetObjects({x, y, width, height}, {releaseOthers=true}={}) {
const user = game.user;
// Get the set of targeted tokens
const targets = new Set();
const rectangle = new PIXI.Rectangle(x, y, width, height);
for ( const token of this.placeables ) {
if ( !token.visible || token.document.isSecret ) continue;
if ( token._overlapsSelection(rectangle) ) targets.add(token);
}
// Maybe release other targets
if ( releaseOthers ) {
for ( const token of user.targets ) {
if ( targets.has(token) ) continue;
token.setTarget(false, {releaseOthers: false, groupSelection: true});
}
}
// Acquire targets for tokens which are not yet targeted
for ( const token of targets ) {
if ( user.targets.has(token) ) continue;
token.setTarget(true, {releaseOthers: false, groupSelection: true});
}
// Broadcast the target change
user.broadcastActivity({targets: user.targets.ids});
// Return the number of targeted tokens
return user.targets.size;
}
/* -------------------------------------------- */
/**
* Cycle the controlled token by rotating through the list of Owned Tokens that are available within the Scene
* Tokens are currently sorted in order of their TokenID
*
* @param {boolean} forwards Which direction to cycle. A truthy value cycles forward, while a false value
* cycles backwards.
* @param {boolean} reset Restart the cycle order back at the beginning?
* @returns {Token|null} The Token object which was cycled to, or null
*/
cycleTokens(forwards, reset) {
let next = null;
if ( reset ) this._tabIndex = null;
const order = this._getCycleOrder();
// If we are not tab cycling, try and jump to the currently controlled or impersonated token
if ( this._tabIndex === null ) {
this._tabIndex = 0;
// Determine the ideal starting point based on controlled tokens or the primary character
let current = this.controlled.length ? order.find(t => this.controlled.includes(t)) : null;
if ( !current && game.user.character ) {
const actorTokens = game.user.character.getActiveTokens();
current = actorTokens.length ? order.find(t => actorTokens.includes(t)) : null;
}
current = current || order[this._tabIndex] || null;
// Either start cycling, or cancel
if ( !current ) return null;
next = current;
}
// Otherwise, cycle forwards or backwards
else {
if ( forwards ) this._tabIndex = this._tabIndex < (order.length - 1) ? this._tabIndex + 1 : 0;
else this._tabIndex = this._tabIndex > 0 ? this._tabIndex - 1 : order.length - 1;
next = order[this._tabIndex];
if ( !next ) return null;
}
// Pan to the token and control it (if possible)
canvas.animatePan({x: next.center.x, y: next.center.y, duration: 250});
next.control();
return next;
}
/* -------------------------------------------- */
/**
* Get the tab cycle order for tokens by sorting observable tokens based on their distance from top-left.
* @returns {Token[]}
* @private
*/
_getCycleOrder() {
const observable = this.placeables.filter(token => {
if ( game.user.isGM ) return true;
if ( !token.actor?.testUserPermission(game.user, "OBSERVER") ) return false;
return !token.document.hidden;
});
observable.sort((a, b) => Math.hypot(a.x, a.y) - Math.hypot(b.x, b.y));
return observable;
}
/* -------------------------------------------- */
/**
* Immediately conclude the animation of any/all tokens
*/
concludeAnimation() {
this.placeables.forEach(t => t.stopAnimation());
canvas.app.ticker.remove(this._animateTargets, this);
}
/* -------------------------------------------- */
/**
* Animate targeting arrows on targeted tokens.
* @private
*/
_animateTargets() {
if ( !game.user.targets.size ) return;
if ( this._t === undefined ) this._t = 0;
else this._t += canvas.app.ticker.elapsedMS;
const duration = 2000;
const pause = duration * .6;
const fade = (duration - pause) * .25;
const minM = .5; // Minimum margin is half the size of the arrow.
const maxM = 1; // Maximum margin is the full size of the arrow.
// The animation starts with the arrows halfway across the token bounds, then move fully inside the bounds.
const rm = maxM - minM;
const t = this._t % duration;
let dt = Math.max(0, t - pause) / (duration - pause);
dt = CanvasAnimation.easeOutCircle(dt);
const m = t < pause ? minM : minM + (rm * dt);
const ta = Math.max(0, t - duration + fade);
const a = 1 - (ta / fade);
for ( const t of game.user.targets ) {
t._refreshTarget({
margin: m,
alpha: a,
color: CONFIG.Canvas.targeting.color,
size: CONFIG.Canvas.targeting.size
});
}
}
/* -------------------------------------------- */
/**
* Provide an array of Tokens which are eligible subjects for tile occlusion.
* By default, only tokens which are currently controlled or owned by a player are included as subjects.
* @returns {Token[]}
* @protected
* @internal
*/
_getOccludableTokens() {
const M = CONST.TOKEN_OCCLUSION_MODES;
const mode = this.occlusionMode;
if ( (mode & M.VISIBLE) || ((mode & M.HIGHLIGHTED) && this.highlightObjects) ) {
return this.placeables.filter(t => t.visible);
}
const tokens = new Set();
if ( (mode & M.HOVERED) && this.hover ) tokens.add(this.hover);
if ( mode & M.CONTROLLED ) this.controlled.forEach(t => tokens.add(t));
if ( mode & M.OWNED ) this.ownedTokens.filter(t => !t.document.hidden).forEach(t => tokens.add(t));
return Array.from(tokens);
}
/* -------------------------------------------- */
/** @inheritdoc */
storeHistory(type, data) {
super.storeHistory(type, type === "update" ? data.map(d => {
// Clean actorData and delta updates from the history so changes to those fields are not undone.
d = foundry.utils.deepClone(d);
delete d.actorData;
delete d.delta;
delete d._regions;
return d;
}) : data);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Handle dropping of Actor data onto the Scene canvas
* @private
*/
async _onDropActorData(event, data) {
// Ensure the user has permission to drop the actor and create a Token
if ( !game.user.can("TOKEN_CREATE") ) {
return ui.notifications.warn("You do not have permission to create new Tokens!");
}
// Acquire dropped data and import the actor
let actor = await Actor.implementation.fromDropData(data);
if ( !actor.isOwner ) {
return ui.notifications.warn(`You do not have permission to create a new Token for the ${actor.name} Actor.`);
}
if ( actor.compendium ) {
const actorData = game.actors.fromCompendium(actor);
actor = await Actor.implementation.create(actorData, {fromCompendium: true});
}
// Prepare the Token document
const td = await actor.getTokenDocument({
hidden: game.user.isGM && event.altKey,
sort: Math.max(this.getMaxSort() + 1, 0)
}, {parent: canvas.scene});
// Set the position of the Token such that its center point is the drop position before snapping
const t = this.createObject(td);
let position = t.getCenterPoint({x: 0, y: 0});
position.x = data.x - position.x;
position.y = data.y - position.y;
if ( !event.shiftKey ) position = t.getSnappedPosition(position);
t.destroy({children: true});
td.updateSource(position);
// Validate the final position
if ( !canvas.dimensions.rect.contains(td.x, td.y) ) return false;
// Submit the Token creation request and activate the Tokens layer (if not already active)
this.activate();
return td.constructor.create(td, {parent: canvas.scene});
}
/* -------------------------------------------- */
/** @inheritDoc */
_onClickLeft(event) {
let tool = game.activeTool;
// If Control is being held, we always want the Tool to be Ruler
if ( game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL) ) tool = "ruler";
switch ( tool ) {
// Clear targets if Left Click Release is set
case "target":
if ( game.settings.get("core", "leftClickRelease") ) {
game.user.updateTokenTargets([]);
game.user.broadcastActivity({targets: []});
}
break;
// Place Ruler waypoints
case "ruler":
return canvas.controls.ruler._onClickLeft(event);
}
// If we don't explicitly return from handling the tool, use the default behavior
super._onClickLeft(event);
}
/* -------------------------------------------- */
/** @override */
_onMouseWheel(event) {
// Prevent wheel rotation during dragging
if ( this.preview.children.length ) return;
// Determine the incremental angle of rotation from event data
const snap = canvas.grid.isHexagonal ? (event.shiftKey ? 60 : 30) : (event.shiftKey ? 45 : 15);
const delta = snap * Math.sign(event.delta);
return this.rotateMany({delta, snap});
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get gridPrecision() {
// eslint-disable-next-line no-unused-expressions
super.gridPrecision;
return 1; // Snap tokens to top-left
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
async toggleCombat(state=true, combat=null, {token=null}={}) {
foundry.utils.logCompatibilityWarning("TokenLayer#toggleCombat is deprecated in favor of"
+ " TokenDocument.implementation.createCombatants and TokenDocument.implementation.deleteCombatants", {since: 12, until: 14});
const tokens = this.controlled.map(t => t.document);
if ( token && !token.controlled && (token.inCombat !== state) ) tokens.push(token.document);
if ( state ) return TokenDocument.implementation.createCombatants(tokens, {combat});
else return TokenDocument.implementation.deleteCombatants(tokens, {combat});
}
}