/** * A Token is an implementation of PlaceableObject which represents an Actor within a viewed Scene on the game canvas. * @category - Canvas * @see {TokenDocument} * @see {TokenLayer} */ class Token extends PlaceableObject { constructor(document) { super(document); this.#initialize(); } /** @inheritdoc */ static embeddedName = "Token"; /** @override */ static RENDER_FLAGS = { redraw: {propagate: ["refresh"]}, redrawEffects: {}, refresh: {propagate: ["refreshState", "refreshTransform", "refreshMesh", "refreshNameplate", "refreshElevation", "refreshRingVisuals"], alias: true}, refreshState: {propagate: ["refreshVisibility", "refreshTarget"]}, refreshVisibility: {}, refreshTransform: {propagate: ["refreshPosition", "refreshRotation", "refreshSize"], alias: true}, refreshPosition: {}, refreshRotation: {}, refreshSize: {propagate: ["refreshPosition", "refreshShape", "refreshBars", "refreshEffects", "refreshNameplate", "refreshTarget", "refreshTooltip"]}, refreshElevation: {propagate: ["refreshTooltip"]}, refreshMesh: {propagate: ["refreshShader"]}, refreshShader: {}, refreshShape: {propagate: ["refreshVisibility", "refreshPosition", "refreshBorder", "refreshEffects"]}, refreshBorder: {}, refreshBars: {}, refreshEffects: {}, refreshNameplate: {}, refreshTarget: {}, refreshTooltip: {}, refreshRingVisuals: {}, /** @deprecated since v12 Stable 4 */ recoverFromPreview: {deprecated: {since: 12, until: 14}} }; /** * Used in {@link Token#_renderDetectionFilter}. * @type {[detectionFilter: PIXI.Filter|null]} */ static #DETECTION_FILTER_ARRAY = [null]; /** * The shape of this token. * @type {PIXI.Rectangle|PIXI.Polygon|PIXI.Circle} */ shape; /** * Defines the filter to use for detection. * @param {PIXI.Filter|null} filter */ detectionFilter = null; /** * A Graphics instance which renders the border frame for this Token inside the GridLayer. * @type {PIXI.Graphics} */ border; /** * The effects icons of temporary ActiveEffects that are applied to the Actor of this Token. * @type {PIXI.Container} */ effects; /** * The attribute bars of this Token. * @type {PIXI.Container} */ bars; /** * The tooltip text of this Token, which contains its elevation. * @type {PreciseText} */ tooltip; /** * The target marker, which indicates that this Token is targeted by this User or others. * @type {PIXI.Graphics} */ target; /** * The nameplate of this Token, which displays its name. * @type {PreciseText} */ nameplate; /** * Track the set of User documents which are currently targeting this Token * @type {Set} */ targeted = new Set([]); /** * A reference to the SpriteMesh which displays this Token in the PrimaryCanvasGroup. * @type {PrimarySpriteMesh} */ mesh; /** * Renders the mesh of this Token with ERASE blending in the Token. * @type {PIXI.DisplayObject} */ voidMesh; /** * Renders the mesh of with the detection filter. * @type {PIXI.DisplayObject} */ detectionFilterMesh; /** * The texture of this Token, which is used by its mesh. * @type {PIXI.Texture} */ texture; /** * A reference to the VisionSource object which defines this vision source area of effect. * This is undefined if the Token does not provide an active source of vision. * @type {PointVisionSource} */ vision; /** * A reference to the LightSource object which defines this light source area of effect. * This is undefined if the Token does not provide an active source of light. * @type {PointLightSource} */ light; /** * An Object which records the Token's prior velocity dx and dy. * This can be used to determine which direction a Token was previously moving. * @type {{dx: number, dy: number, ox: number, oy: number}} */ #priorMovement; /** * The Token central coordinate, adjusted for its most recent movement vector. * @type {Point} */ #adjustedCenter; /** * @typedef {Point} TokenPosition * @property {number} rotation The token's last valid rotation. */ /** * The Token's most recent valid position and rotation. * @type {TokenPosition} */ #validPosition; /** * A flag to capture whether this Token has an unlinked video texture. * @type {boolean} */ #unlinkedVideo = false; /** * @typedef {object} TokenAnimationData * @property {number} x The x position in pixels * @property {number} y The y position in pixels * @property {number} width The width in grid spaces * @property {number} height The height in grid spaces * @property {number} alpha The alpha value * @property {number} rotation The rotation in degrees * @property {object} texture The texture data * @property {string} texture.src The texture file path * @property {number} texture.anchorX The texture anchor X * @property {number} texture.anchorY The texture anchor Y * @property {number} texture.scaleX The texture scale X * @property {number} texture.scaleY The texture scale Y * @property {Color} texture.tint The texture tint * @property {object} ring The ring data * @property {object} ring.subject The ring subject data * @property {string} ring.subject.texture The ring subject texture * @property {number} ring.subject.scale The ring subject scale */ /** * The current animation data of this Token. * @type {TokenAnimationData} */ #animationData; /** * The prior animation data of this Token. * @type {TokenAnimationData} */ #priorAnimationData; /** * A map of effects id and their filters applied on this token placeable. * @type {Map} */ #filterEffects = new Map(); /** * @typedef {object} TokenAnimationContext * @property {string|symbol} name The name of the animation * @property {Partial} to The final animation state * @property {number} duration The duration of the animation * @property {number} time The current time of the animation * @property {((context: TokenAnimationContext) => Promise)[]} preAnimate * Asynchronous functions that are executed before the animation starts * @property {((context: TokenAnimationContext) => void)[]} postAnimate * Synchronous functions that are executed after the animation ended. * They may be executed before the preAnimate functions have finished if the animation is terminated. * @property {((context: TokenAnimationContext) => void)[]} onAnimate * Synchronous functions that are executed each frame after `ontick` and before {@link Token#_onAnimationUpdate}. * @property {Promise} [promise] * The promise of the animation, which resolves to true if the animation * completed, to false if it was terminated, and rejects if an error occurred. * Undefined in the first frame (at time 0) of the animation. */ /** * The current animations of this Token. * @type {Map} */ get animationContexts() { return this.#animationContexts; } #animationContexts = new Map(); /** * A TokenRing instance which is used if this Token applies a dynamic ring. * This property is null if the Token does not use a dynamic ring. * @type {foundry.canvas.tokens.TokenRing|null} */ get ring() { return this.#ring; } #ring; /** * A convenience boolean to test whether the Token is using a dynamic ring. * @type {boolean} */ get hasDynamicRing() { return this.ring instanceof foundry.canvas.tokens.TokenRing; } /* -------------------------------------------- */ /* Initialization */ /* -------------------------------------------- */ /** * Establish an initial velocity of the token based on its direction of facing. * Assume the Token made some prior movement towards the direction that it is currently facing. */ #initialize() { // Initialize prior movement const {x, y, rotation} = this.document; const r = Ray.fromAngle(x, y, Math.toRadians(rotation + 90), canvas.dimensions.size); // Initialize valid position this.#validPosition = {x, y, rotation}; this.#priorMovement = {dx: r.dx, dy: r.dy, ox: Math.sign(r.dx), oy: Math.sign(r.dy)}; this.#adjustedCenter = this.getMovementAdjustedPoint(this.center); // Initialize animation data this.#animationData = this._getAnimationData(); this.#priorAnimationData = foundry.utils.deepClone(this.#animationData); } /* -------------------------------------------- */ /** * Initialize a TokenRing instance for this Token, if a dynamic ring is enabled. */ #initializeRing() { // Construct a TokenRing instance if ( this.document.ring.enabled ) { if ( !this.hasDynamicRing ) { const cls = CONFIG.Token.ring.ringClass; if ( !foundry.utils.isSubclass(cls, foundry.canvas.tokens.TokenRing) ) { throw new Error("The configured CONFIG.Token.ring.ringClass is not a TokenRing subclass."); } this.#ring = new cls(this); } this.#ring.configure(this.mesh); return; } // Deactivate a prior TokenRing instance if ( this.hasDynamicRing ) this.#ring.clear(); this.#ring = null; } /* -------------------------------------------- */ /* Permission Attributes /* -------------------------------------------- */ /** * A convenient reference to the Actor object associated with the Token embedded document. * @returns {Actor|null} */ get actor() { return this.document.actor; } /* -------------------------------------------- */ /** * A boolean flag for whether the current game User has observer permission for the Token * @type {boolean} */ get observer() { return game.user.isGM || !!this.actor?.testUserPermission(game.user, "OBSERVER"); } /* -------------------------------------------- */ /** * Convenience access to the token's nameplate string * @type {string} */ get name() { return this.document.name; } /* -------------------------------------------- */ /* Rendering Attributes /* -------------------------------------------- */ /** @override */ get bounds() { const {x, y} = this.document; const {width, height} = this.getSize(); return new PIXI.Rectangle(x, y, width, height); } /* -------------------------------------------- */ /** * Translate the token's grid width into a pixel width based on the canvas size * @type {number} */ get w() { return this.getSize().width; } /* -------------------------------------------- */ /** * Translate the token's grid height into a pixel height based on the canvas size * @type {number} */ get h() { return this.getSize().height; } /* -------------------------------------------- */ /** * The Token's current central position * @type {PIXI.Point} */ get center() { const {x, y} = this.getCenterPoint(); return new PIXI.Point(x, y); } /* -------------------------------------------- */ /** * The Token's central position, adjusted in each direction by one or zero pixels to offset it relative to walls. * @type {Point} */ getMovementAdjustedPoint(point, {offsetX, offsetY}={}) { const x = Math.round(point.x); const y = Math.round(point.y); const r = new PIXI.Rectangle(x, y, 0, 0); // Verify whether the current position overlaps an edge const edges = []; for ( const edge of canvas.edges.values() ) { if ( !edge.move ) continue; // Non-blocking movement if ( r.overlaps(edge.bounds) && (foundry.utils.orient2dFast(edge.a, edge.b, {x, y}) === 0) ) edges.push(edge); } if ( edges.length ) { const {ox, oy} = this.#priorMovement; return {x: x - (offsetX ?? ox), y: y - (offsetY ?? oy)}; } return {x, y}; } /* -------------------------------------------- */ /** * The HTML source element for the primary Tile texture * @type {HTMLImageElement|HTMLVideoElement} */ get sourceElement() { return this.texture?.baseTexture.resource.source; } /* -------------------------------------------- */ /** @override */ get sourceId() { let id = `${this.document.documentName}.${this.document.id}`; if ( this.isPreview ) id += ".preview"; return id; } /* -------------------------------------------- */ /** * Does this Tile depict an animated video texture? * @type {boolean} */ get isVideo() { const source = this.sourceElement; return source?.tagName === "VIDEO"; } /* -------------------------------------------- */ /* State Attributes /* -------------------------------------------- */ /** * An indicator for whether or not this token is currently involved in the active combat encounter. * @type {boolean} */ get inCombat() { return this.document.inCombat; } /* -------------------------------------------- */ /** * Return a reference to a Combatant that represents this Token, if one is present in the current encounter. * @type {Combatant|null} */ get combatant() { return this.document.combatant; } /* -------------------------------------------- */ /** * An indicator for whether the Token is currently targeted by the active game User * @type {boolean} */ get isTargeted() { return this.targeted.has(game.user); } /* -------------------------------------------- */ /** * Return a reference to the detection modes array. * @type {[object]} */ get detectionModes() { return this.document.detectionModes; } /* -------------------------------------------- */ /** * Determine whether the Token is visible to the calling user's perspective. * Hidden Tokens are only displayed to GM Users. * Non-hidden Tokens are always visible if Token Vision is not required. * Controlled tokens are always visible. * All Tokens are visible to a GM user if no Token is controlled. * * @see {CanvasVisibility#testVisibility} * @type {boolean} */ get isVisible() { // Clear the detection filter this.detectionFilter = null; // Only GM users can see hidden tokens const gm = game.user.isGM; if ( this.document.hidden && !gm ) return false; // Some tokens are always visible if ( !canvas.visibility.tokenVision ) return true; if ( this.controlled ) return true; // Otherwise, test visibility against current sight polygons if ( this.vision?.active ) return true; const {width, height} = this.getSize(); const tolerance = Math.min(width, height) / 4; return canvas.visibility.testVisibility(this.center, {tolerance, object: this}); } /* -------------------------------------------- */ /** * The animation name used for Token movement * @type {string} */ get animationName() { return `${this.objectId}.animate`; } /* -------------------------------------------- */ /* Lighting and Vision Attributes /* -------------------------------------------- */ /** * Test whether the Token has sight (or blindness) at any radius * @type {boolean} */ get hasSight() { return this.document.sight.enabled; } /* -------------------------------------------- */ /** * Does this Token actively emit light given its properties and the current darkness level of the Scene? * @returns {boolean} * @protected */ _isLightSource() { const {hidden, light} = this.document; if ( hidden ) return false; if ( !(light.dim || light.bright) ) return false; const darkness = canvas.darknessLevel; if ( !darkness.between(light.darkness.min, light.darkness.max)) return false; return !this.document.hasStatusEffect(CONFIG.specialStatusEffects.BURROW); } /* -------------------------------------------- */ /** * Does this Ambient Light actively emit darkness given * its properties and the current darkness level of the Scene? * @type {boolean} */ get emitsDarkness() { return this.document.light.negative && this._isLightSource(); } /* -------------------------------------------- */ /** * Does this Ambient Light actively emit light given * its properties and the current darkness level of the Scene? * @type {boolean} */ get emitsLight() { return !this.document.light.negative && this._isLightSource(); } /* -------------------------------------------- */ /** * Test whether the Token uses a limited angle of vision or light emission. * @type {boolean} */ get hasLimitedSourceAngle() { const doc = this.document; return (this.hasSight && (doc.sight.angle !== 360)) || (this._isLightSource() && (doc.light.angle !== 360)); } /* -------------------------------------------- */ /** * Translate the token's dim light distance in units into a radius in pixels. * @type {number} */ get dimRadius() { return this.getLightRadius(this.document.light.dim); } /* -------------------------------------------- */ /** * Translate the token's bright light distance in units into a radius in pixels. * @type {number} */ get brightRadius() { return this.getLightRadius(this.document.light.bright); } /* -------------------------------------------- */ /** * The maximum radius in pixels of the light field * @type {number} */ get radius() { return Math.max(Math.abs(this.dimRadius), Math.abs(this.brightRadius)); } /* -------------------------------------------- */ /** * The range of this token's light perception in pixels. * @type {number} */ get lightPerceptionRange() { const mode = this.document.detectionModes.find(m => m.id === "lightPerception"); return mode?.enabled ? this.getLightRadius(mode.range ?? Infinity) : 0; } /* -------------------------------------------- */ /** * Translate the token's vision range in units into a radius in pixels. * @type {number} */ get sightRange() { return this.getLightRadius(this.document.sight.range ?? Infinity); } /* -------------------------------------------- */ /** * Translate the token's maximum vision range that takes into account lights. * @type {number} */ get optimalSightRange() { let lightRadius = 0; const mode = this.document.detectionModes.find(m => m.id === "lightPerception"); if ( mode?.enabled ) { lightRadius = Math.max(this.document.light.bright, this.document.light.dim); lightRadius = Math.min(lightRadius, mode.range ?? Infinity); } return this.getLightRadius(Math.max(this.document.sight.range ?? Infinity, lightRadius)); } /* -------------------------------------------- */ /** * Update the light and vision source objects associated with this Token. * @param {object} [options={}] Options which configure how perception sources are updated * @param {boolean} [options.deleted=false] Indicate that this light and vision source has been deleted */ initializeSources({deleted=false}={}) { this.#adjustedCenter = this.getMovementAdjustedPoint(this.center); this.initializeLightSource({deleted}); this.initializeVisionSource({deleted}); } /* -------------------------------------------- */ /** * Update an emitted light source associated with this Token. * @param {object} [options={}] * @param {boolean} [options.deleted] Indicate that this light source has been deleted. */ initializeLightSource({deleted=false}={}) { const sourceId = this.sourceId; const wasLight = canvas.effects.lightSources.has(sourceId); const wasDarkness = canvas.effects.darknessSources.has(sourceId); const isDarkness = this.document.light.negative; const perceptionFlags = { refreshEdges: wasDarkness || isDarkness, initializeVision: wasDarkness || isDarkness, initializeLighting: wasDarkness || isDarkness, refreshLighting: true, refreshVision: true }; // Remove the light source from the active collection if ( deleted || !this._isLightSource() ) { if ( !this.light ) return; if ( this.light.active ) canvas.perception.update(perceptionFlags); this.#destroyLightSource(); return; } // Re-create the source if it switches darkness state if ( (wasLight && isDarkness) || (wasDarkness && !isDarkness) ) this.#destroyLightSource(); // Create a light source if necessary this.light ??= this.#createLightSource(); // Re-initialize source data and add to the active collection this.light.initialize(this._getLightSourceData()); this.light.add(); canvas.perception.update(perceptionFlags); } /* -------------------------------------------- */ /** * Get the light source data. * @returns {LightSourceData} * @protected */ _getLightSourceData() { const {x, y} = this.#adjustedCenter; const {elevation, rotation} = this.document; const d = canvas.dimensions; const lightDoc = this.document.light; return foundry.utils.mergeObject(lightDoc.toObject(false), { x, y, elevation, rotation, dim: Math.clamp(this.getLightRadius(lightDoc.dim), 0, d.maxR), bright: Math.clamp(this.getLightRadius(lightDoc.bright), 0, d.maxR), externalRadius: this.externalRadius, seed: this.document.getFlag("core", "animationSeed"), preview: this.isPreview, disabled: !this._isLightSource() }); } /* -------------------------------------------- */ /** * Update the VisionSource instance associated with this Token. * @param {object} [options] Options which affect how the vision source is updated * @param {boolean} [options.deleted] Indicate that this vision source has been deleted. */ initializeVisionSource({deleted=false}={}) { // Remove a deleted vision source from the active collection if ( deleted || !this._isVisionSource() ) { if ( !this.vision ) return; if ( this.vision.active ) canvas.perception.update({ initializeVisionModes: true, refreshVision: true, refreshLighting: true }); this.#destroyVisionSource(); return; } // Create a vision source if necessary const wasVision = !!this.vision; this.vision ??= this.#createVisionSource(); // Re-initialize source data const previousActive = this.vision.active; const previousVisionMode = this.vision.visionMode; const blindedStates = this._getVisionBlindedStates(); for ( const state in blindedStates ) this.vision.blinded[state] = blindedStates[state]; this.vision.initialize(this._getVisionSourceData()); this.vision.add(); canvas.perception.update({ initializeVisionModes: !wasVision || (this.vision.active !== previousActive) || (this.vision.visionMode !== previousVisionMode), refreshVision: true, refreshLighting: true }); } /* -------------------------------------------- */ /** * Returns a record of blinding state. * @returns {Record} * @protected */ _getVisionBlindedStates() { return { blind: this.document.hasStatusEffect(CONFIG.specialStatusEffects.BLIND), burrow: this.document.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) }; } /* -------------------------------------------- */ /** * Get the vision source data. * @returns {VisionSourceData} * @protected */ _getVisionSourceData() { const d = canvas.dimensions; const {x, y} = this.#adjustedCenter; const {elevation, rotation} = this.document; const sight = this.document.sight; return { x, y, elevation, rotation, radius: Math.clamp(this.sightRange, 0, d.maxR), lightRadius: Math.clamp(this.lightPerceptionRange, 0, d.maxR), externalRadius: this.externalRadius, angle: sight.angle, contrast: sight.contrast, saturation: sight.saturation, brightness: sight.brightness, attenuation: sight.attenuation, visionMode: sight.visionMode, color: sight.color, preview: this.isPreview, disabled: false }; } /* -------------------------------------------- */ /** * Test whether this Token is a viable vision source for the current User. * @returns {boolean} * @protected */ _isVisionSource() { if ( !canvas.visibility.tokenVision || !this.hasSight ) return false; // Only display hidden tokens for the GM const isGM = game.user.isGM; if ( this.document.hidden && !isGM ) return false; // Always display controlled tokens which have vision if ( this.controlled ) return true; // Otherwise, vision is ignored for GM users if ( isGM ) return false; // If a non-GM user controls no other tokens with sight, display sight const canObserve = this.actor?.testUserPermission(game.user, "OBSERVER") ?? false; if ( !canObserve ) return false; return !this.layer.controlled.some(t => !t.document.hidden && t.hasSight); } /* -------------------------------------------- */ /* Rendering */ /* -------------------------------------------- */ /** * Render the bound mesh detection filter. * Note: this method does not verify that the detection filter exists. * @param {PIXI.Renderer} renderer * @protected */ _renderDetectionFilter(renderer) { if ( !this.mesh ) return; Token.#DETECTION_FILTER_ARRAY[0] = this.detectionFilter; // Rendering the mesh const originalFilters = this.mesh.filters; const originalTint = this.mesh.tint; const originalAlpha = this.mesh.worldAlpha; this.mesh.filters = Token.#DETECTION_FILTER_ARRAY; this.mesh.tint = 0xFFFFFF; this.mesh.worldAlpha = 1; this.mesh.pluginName = BaseSamplerShader.classPluginName; this.mesh.render(renderer); this.mesh.filters = originalFilters; this.mesh.tint = originalTint; this.mesh.worldAlpha = originalAlpha; this.mesh.pluginName = null; Token.#DETECTION_FILTER_ARRAY[0] = null; } /* -------------------------------------------- */ /** @override */ clear() { if ( this.mesh ) { this.mesh.texture = PIXI.Texture.EMPTY; this.mesh.visible = false; } if ( this.#unlinkedVideo ) this.texture?.baseTexture?.destroy(); // Destroy base texture if the token has an unlinked video this.#unlinkedVideo = false; if ( this.hasActiveHUD ) this.layer.hud.clear(); } /* -------------------------------------------- */ /** @inheritdoc */ _destroy(options) { this._removeAllFilterEffects(); this.stopAnimation(); // Cancel movement animations canvas.primary.removeToken(this); // Remove the TokenMesh from the PrimaryCanvasGroup this.#destroyLightSource(); // Destroy the LightSource this.#destroyVisionSource(); // Destroy the VisionSource if ( this.#unlinkedVideo ) this.texture?.baseTexture?.destroy(); // Destroy base texture if the token has an unlinked video this.removeChildren().forEach(c => c.destroy({children: true})); this.texture = undefined; this.#unlinkedVideo = false; } /* -------------------------------------------- */ /** @override */ async _draw(options) { this.#cleanData(); // Load token texture let texture; if ( this._original ) texture = this._original.texture?.clone(); else texture = await loadTexture(this.document.texture.src, {fallback: CONST.DEFAULT_TOKEN}); // Cache token ring subject texture if needed const ring = this.document.ring; if ( ring.enabled && ring.subject.texture ) await loadTexture(ring.subject.texture); // Manage video playback let video = game.video.getVideoSource(texture); this.#unlinkedVideo = !!video && !this._original; if ( this.#unlinkedVideo ) { texture = await game.video.cloneTexture(video); video = game.video.getVideoSource(texture); const playOptions = {volume: 0}; if ( (this.document.getFlag("core", "randomizeVideo") !== false) && Number.isFinite(video.duration) ) { playOptions.offset = Math.random() * video.duration; } game.video.play(video, playOptions); } this.texture = texture; // Draw the TokenMesh in the PrimaryCanvasGroup this.mesh = canvas.primary.addToken(this); // Initialize token ring this.#initializeRing(); // Draw the border this.border ||= this.addChild(new PIXI.Graphics()); // Draw the void of the TokenMesh if ( !this.voidMesh ) { this.voidMesh = this.addChild(new PIXI.Container()); this.voidMesh.updateTransform = () => {}; this.voidMesh.render = renderer => this.mesh?._renderVoid(renderer); } // Draw the detection filter of the TokenMesh if ( !this.detectionFilterMesh ) { this.detectionFilterMesh = this.addChild(new PIXI.Container()); this.detectionFilterMesh.updateTransform = () => {}; this.detectionFilterMesh.render = renderer => { if ( this.detectionFilter ) this._renderDetectionFilter(renderer); }; } // Draw Token interface components this.bars ||= this.addChild(this.#drawAttributeBars()); this.tooltip ||= this.addChild(this.#drawTooltip()); this.effects ||= this.addChild(new PIXI.Container()); this.target ||= this.addChild(new PIXI.Graphics()); this.nameplate ||= this.addChild(this.#drawNameplate()); // Add filter effects this._updateSpecialStatusFilterEffects(); // Draw elements await this._drawEffects(); // Initialize sources if ( !this.isPreview ) this.initializeSources(); } /* -------------------------------------------- */ /** * Create a point light source according to token options. * @returns {PointDarknessSource|PointLightSource} */ #createLightSource() { const lightSourceClass = this.document.light.negative ? CONFIG.Canvas.darknessSourceClass : CONFIG.Canvas.lightSourceClass; return new lightSourceClass({sourceId: this.sourceId, object: this}); } /* -------------------------------------------- */ /** * Destroy the PointLightSource or PointDarknessSource instance associated with this Token. */ #destroyLightSource() { this.light?.destroy(); this.light = undefined; } /* -------------------------------------------- */ /** * Create a point vision source for the Token. * @returns {PointVisionSource} */ #createVisionSource() { return new CONFIG.Canvas.visionSourceClass({sourceId: this.sourceId, object: this}); } /* -------------------------------------------- */ /** * Destroy the PointVisionSource instance associated with this Token. */ #destroyVisionSource() { this.vision?.visionMode?.deactivate(this.vision); this.vision?.destroy(); this.vision = undefined; } /* -------------------------------------------- */ /** * Apply initial sanitizations to the provided input data to ensure that a Token has valid required attributes. * Constrain the Token position to remain within the Canvas rectangle. */ #cleanData() { const d = this.scene.dimensions; const {x: cx, y: cy} = this.getCenterPoint({x: 0, y: 0}); this.document.x = Math.clamp(this.document.x, -cx, d.width - cx); this.document.y = Math.clamp(this.document.y, -cy, d.height - cy); } /* -------------------------------------------- */ /** * Draw resource bars for the Token * @returns {PIXI.Container} */ #drawAttributeBars() { const bars = new PIXI.Container(); bars.bar1 = bars.addChild(new PIXI.Graphics()); bars.bar2 = bars.addChild(new PIXI.Graphics()); return bars; } /* -------------------------------------------- */ /* Incremental Refresh */ /* -------------------------------------------- */ /** @override */ _applyRenderFlags(flags) { if ( flags.refreshState ) this._refreshState(); if ( flags.refreshVisibility ) this._refreshVisibility(); if ( flags.refreshPosition ) this._refreshPosition(); if ( flags.refreshRotation ) this._refreshRotation(); if ( flags.refreshSize ) this._refreshSize(); if ( flags.refreshElevation ) this._refreshElevation(); if ( flags.refreshMesh ) this._refreshMesh(); if ( flags.refreshShader ) this._refreshShader(); if ( flags.refreshShape ) this._refreshShape(); if ( flags.refreshBorder ) this._refreshBorder(); if ( flags.refreshBars ) this.drawBars(); if ( flags.refreshNameplate ) this._refreshNameplate(); if ( flags.refreshTarget ) this._refreshTarget(); if ( flags.refreshTooltip ) this._refreshTooltip(); if ( flags.recoverFromPreview ) this._recoverFromPreview(); if ( flags.refreshRingVisuals ) this._refreshRingVisuals(); if ( flags.redrawEffects ) this.drawEffects(); if ( flags.refreshEffects ) this._refreshEffects(); } /* -------------------------------------------- */ /** * Refresh the token ring visuals if necessary. * @protected */ _refreshRingVisuals() { if ( this.hasDynamicRing ) this.ring.configureVisuals(); } /* -------------------------------------------- */ /** * Refresh the visibility. * @protected */ _refreshVisibility() { const wasVisible = this.visible; this.visible = this.isVisible; if ( this.visible !== wasVisible ) MouseInteractionManager.emulateMoveEvent(); this.mesh.visible = this.visible && this.renderable; if ( this.layer.occlusionMode === CONST.TOKEN_OCCLUSION_MODES.VISIBLE ) { canvas.perception.update({refreshOcclusion: true}); } } /* -------------------------------------------- */ /** * Refresh aspects of the user interaction state. * For example the border, nameplate, or bars may be shown on Hover or on Control. * @protected */ _refreshState() { this.alpha = this._getTargetAlpha(); this.border.tint = this.#getBorderColor(); const isSecret = this.document.isSecret; const isHover = this.hover || this.layer.highlightObjects; this.removeChild(this.voidMesh); this.addChildAt(this.voidMesh, this.getChildIndex(this.border) + (isHover ? 0 : 1)); this.border.visible = !isSecret && (this.controlled || isHover); this.nameplate.visible = !isSecret && this._canViewMode(this.document.displayName); this.bars.visible = !isSecret && (this.actor && this._canViewMode(this.document.displayBars)); this.tooltip.visible = !isSecret; this.effects.visible = !isSecret; this.target.visible = !isSecret; this.cursor = !isSecret ? "pointer" : null; this.zIndex = this.mesh.zIndex = this.controlled ? 2 : this.hover ? 1 : 0; this.mesh.sort = this.document.sort; this.mesh.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.TOKENS; this.mesh.alpha = this.alpha * this.document.alpha; this.mesh.hidden = this.document.hidden; } /* -------------------------------------------- */ /** * Refresh the size. * @protected */ _refreshSize() { const {width, height} = this.getSize(); const {fit, scaleX, scaleY} = this.document.texture; let adjustedScaleX = scaleX; let adjustedScaleY = scaleY; if ( this.hasDynamicRing && CONFIG.Token.ring.isGridFitMode ) { adjustedScaleX *= this.ring.subjectScaleAdjustment; adjustedScaleY *= this.ring.subjectScaleAdjustment; } this.mesh.resize(width, height, {fit, scaleX: adjustedScaleX, scaleY: adjustedScaleY}); this.nameplate.position.set(width / 2, height + 2); this.tooltip.position.set(width / 2, -2); if ( this.hasDynamicRing ) this.ring.configureSize(); } /* -------------------------------------------- */ /** * Refresh the shape. * @protected */ _refreshShape() { this.shape = this.getShape(); this.hitArea = this.shape; MouseInteractionManager.emulateMoveEvent(); } /* -------------------------------------------- */ /** * Refresh the rotation. * @protected */ _refreshRotation() { this.mesh.angle = this.document.lockRotation ? 0 : this.document.rotation; } /* -------------------------------------------- */ /** * Refresh the position. * @protected */ _refreshPosition() { const {x, y} = this.document; if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent(); this.position.set(x, y); this.mesh.position = this.center; } /* -------------------------------------------- */ /** * Refresh the elevation * @protected */ _refreshElevation() { this.mesh.elevation = this.document.elevation; } /* -------------------------------------------- */ /** * Refresh the tooltip. * @protected */ _refreshTooltip() { this.tooltip.text = this._getTooltipText(); this.tooltip.style = this._getTextStyle(); } /* -------------------------------------------- */ /** * Refresh the text content, position, and visibility of the Token nameplate. * @protected */ _refreshNameplate() { this.nameplate.text = this.document.name; this.nameplate.style = this._getTextStyle(); } /* -------------------------------------------- */ /** * Refresh the token mesh. * @protected */ _refreshMesh() { const {alpha, texture: {anchorX, anchorY, fit, scaleX, scaleY, tint, alphaThreshold}} = this.document; const {width, height} = this.getSize(); let adjustedScaleX = scaleX; let adjustedScaleY = scaleY; if ( this.hasDynamicRing && CONFIG.Token.ring.isGridFitMode ) { adjustedScaleX *= this.ring.subjectScaleAdjustment; adjustedScaleY *= this.ring.subjectScaleAdjustment; } this.mesh.resize(width, height, {fit, scaleX: adjustedScaleX, scaleY: adjustedScaleY}); this.mesh.anchor.set(anchorX, anchorY); this.mesh.alpha = this.alpha * alpha; this.mesh.tint = tint; this.mesh.textureAlphaThreshold = alphaThreshold; this.mesh.occludedAlpha = 0.5; } /* -------------------------------------------- */ /** * Refresh the token mesh shader. * @protected */ _refreshShader() { if ( this.hasDynamicRing ) this.mesh.setShaderClass(CONFIG.Token.ring.shaderClass); else this.mesh.setShaderClass(PrimaryBaseSamplerShader); } /* -------------------------------------------- */ /** * Refresh the border. * @protected */ _refreshBorder() { const thickness = CONFIG.Canvas.objectBorderThickness; this.border.clear(); this.border.lineStyle({width: thickness, color: 0x000000, alignment: 0.75, join: PIXI.LINE_JOIN.ROUND}); this.border.drawShape(this.shape); this.border.lineStyle({width: thickness / 2, color: 0xFFFFFF, alignment: 1, join: PIXI.LINE_JOIN.ROUND}); this.border.drawShape(this.shape); } /* -------------------------------------------- */ /** * Get the hex color that should be used to render the Token border * @returns {number} The hex color used to depict the border color * @protected */ _getBorderColor() { const colors = CONFIG.Canvas.dispositionColors; if ( this.controlled || (this.isOwner && !game.user.isGM) ) return colors.CONTROLLED; const D = CONST.TOKEN_DISPOSITIONS; switch ( this.document.disposition ) { case D.SECRET: return colors.SECRET; case D.HOSTILE: return colors.HOSTILE; case D.NEUTRAL: return colors.NEUTRAL; case D.FRIENDLY: return this.actor?.hasPlayerOwner ? colors.PARTY : colors.FRIENDLY; default: throw new Error("Invalid disposition"); } } /* -------------------------------------------- */ /** * Get the hex color that should be used to render the Token border * @returns {number} The border color */ #getBorderColor() { let color = this._getBorderColor(); /** @deprecated since v12 */ if ( typeof color !== "number" ) { color = CONFIG.Canvas.dispositionColors.INACTIVE; const msg = "Token#_getBorderColor returning null is deprecated."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); } return color; } /* -------------------------------------------- */ /** * @typedef {object} ReticuleOptions * @property {number} [margin=0] The amount of margin between the targeting arrows and the token's bounding * box, expressed as a fraction of an arrow's size. * @property {number} [alpha=1] The alpha value of the arrows. * @property {number} [size=0.15] The size of the arrows as a proportion of grid size. * @property {number} [color=0xFF6400] The color of the arrows. * @property {object} [border] The arrows' border style configuration. * @property {number} [border.color=0] The border color. * @property {number} [border.width=2] The border width. */ /** * Refresh the target indicators for the Token. * Draw both target arrows for the primary User and indicator pips for other Users targeting the same Token. * @param {ReticuleOptions} [reticule] Additional parameters to configure how the targeting reticule is drawn. * @protected */ _refreshTarget(reticule) { this.target.clear(); // We don't show the target arrows for a secret token disposition and non-GM users if ( !this.targeted.size ) return; // Determine whether the current user has target and any other users const [others, user] = Array.from(this.targeted).partition(u => u === game.user); // For the current user, draw the target arrows if ( user.length ) this._drawTarget(reticule); // For other users, draw offset pips const hw = (this.w / 2) + (others.length % 2 === 0 ? 8 : 0); for ( let [i, u] of others.entries() ) { const offset = Math.floor((i+1) / 2) * 16; const sign = i % 2 === 0 ? 1 : -1; const x = hw + (sign * offset); this.target.beginFill(u.color, 1.0).lineStyle(2, 0x0000000).drawCircle(x, 0, 6); } } /* -------------------------------------------- */ /** * Draw the targeting arrows around this token. * @param {ReticuleOptions} [reticule] Additional parameters to configure how the targeting reticule is drawn. * @protected */ _drawTarget({margin: m=0, alpha=1, size=.15, color, border: {width=2, color: lineColor=0}={}}={}) { const l = canvas.dimensions.size * size; // Side length. const {h, w} = this; const lineStyle = {color: lineColor, alpha, width, cap: PIXI.LINE_CAP.ROUND, join: PIXI.LINE_JOIN.BEVEL}; color ??= this.#getBorderColor(); m *= l * -1; this.target.beginFill(color, alpha).lineStyle(lineStyle) .drawPolygon([-m, -m, -m-l, -m, -m, -m-l]) // Top left .drawPolygon([w+m, -m, w+m+l, -m, w+m, -m-l]) // Top right .drawPolygon([-m, h+m, -m-l, h+m, -m, h+m+l]) // Bottom left .drawPolygon([w+m, h+m, w+m+l, h+m, w+m, h+m+l]); // Bottom right } /* -------------------------------------------- */ /** * Refresh the display of Token attribute bars, rendering its latest resource data. * If the bar attribute is valid (has a value and max), draw the bar. Otherwise hide it. */ drawBars() { if ( !this.actor || (this.document.displayBars === CONST.TOKEN_DISPLAY_MODES.NONE) ) return; ["bar1", "bar2"].forEach((b, i) => { const bar = this.bars[b]; const attr = this.document.getBarAttribute(b); if ( !attr || (attr.type !== "bar") || (attr.max === 0) ) return bar.visible = false; this._drawBar(i, bar, attr); bar.visible = true; }); } /* -------------------------------------------- */ /** * Draw a single resource bar, given provided data * @param {number} number The Bar number * @param {PIXI.Graphics} bar The Bar container * @param {Object} data Resource data for this bar * @protected */ _drawBar(number, bar, data) { const val = Number(data.value); const pct = Math.clamp(val, 0, data.max) / data.max; // Determine sizing const {width, height} = this.getSize(); const bw = width; const bh = Math.max(canvas.dimensions.size / 12, 8) * (this.document.height >= 2 ? 1.6 : 1); const bs = Math.clamp(bh / 8, 1, 2); // Determine the color to use let color; if ( number === 0 ) color = Color.fromRGB([1 - (pct / 2), pct, 0]); else color = Color.fromRGB([0.5 * pct, 0.7 * pct, 0.5 + (pct / 2)]); // Draw the bar bar.clear(); bar.lineStyle(bs, 0x000000, 1.0); bar.beginFill(0x000000, 0.5).drawRoundedRect(0, 0, bw, bh, 3); bar.beginFill(color, 1.0).drawRoundedRect(0, 0, pct * bw, bh, 2); // Set position const posY = number === 0 ? height - bh : 0; bar.position.set(0, posY); return true; } /* -------------------------------------------- */ /** * Draw the token's nameplate as a text object * @returns {PreciseText} The Text object for the Token nameplate */ #drawNameplate() { const nameplate = new PreciseText(this.document.name, this._getTextStyle()); nameplate.anchor.set(0.5, 0); return nameplate; } /* -------------------------------------------- */ /** * Draw a text tooltip for the token which can be used to display Elevation or a resource value * @returns {PreciseText} The text object used to render the tooltip */ #drawTooltip() { const tooltip = new PreciseText(this._getTooltipText(), this._getTextStyle()); tooltip.anchor.set(0.5, 1); return tooltip; } /* -------------------------------------------- */ /** * Return the text which should be displayed in a token's tooltip field * @returns {string} * @protected */ _getTooltipText() { let elevation = this.document.elevation; if ( !Number.isFinite(elevation) || (elevation === 0) ) return ""; let text = String(elevation); if ( elevation > 0 ) text = `+${text}`; const units = canvas.grid.units; if ( units ) text = `${text} ${units}`; return text; } /* -------------------------------------------- */ /** * Get the text style that should be used for this Token's tooltip. * @returns {string} * @protected */ _getTextStyle() { const style = CONFIG.canvasTextStyle.clone(); style.fontSize = 24; if (canvas.dimensions.size >= 200) style.fontSize = 28; else if (canvas.dimensions.size < 50) style.fontSize = 20; style.wordWrapWidth = this.w * 2.5; return style; } /* -------------------------------------------- */ /** * Draw the effect icons for ActiveEffect documents which apply to the Token's Actor. */ async drawEffects() { return this._partialDraw(() => this._drawEffects()); } /* -------------------------------------------- */ /** * Draw the effect icons for ActiveEffect documents which apply to the Token's Actor. * Called by {@link Token#drawEffects}. * @protected */ async _drawEffects() { this.effects.renderable = false; // Clear Effects Container this.effects.removeChildren().forEach(c => c.destroy()); this.effects.bg = this.effects.addChild(new PIXI.Graphics()); this.effects.bg.zIndex = -1; this.effects.overlay = null; // Categorize effects const activeEffects = this.actor?.temporaryEffects || []; const overlayEffect = activeEffects.findLast(e => e.img && e.getFlag("core", "overlay")); // Draw effects const promises = []; for ( const [i, effect] of activeEffects.entries() ) { if ( !effect.img ) continue; const promise = effect === overlayEffect ? this._drawOverlay(effect.img, effect.tint) : this._drawEffect(effect.img, effect.tint); promises.push(promise.then(e => { if ( e ) e.zIndex = i; })); } await Promise.allSettled(promises); this.effects.sortChildren(); this.effects.renderable = true; this.renderFlags.set({refreshEffects: true}); } /* -------------------------------------------- */ /** * Draw a status effect icon * @param {string} src * @param {PIXI.ColorSource|null} tint * @returns {Promise} * @protected */ async _drawEffect(src, tint) { if ( !src ) return; const tex = await loadTexture(src, {fallback: "icons/svg/hazard.svg"}); const icon = new PIXI.Sprite(tex); icon.tint = tint ?? 0xFFFFFF; return this.effects.addChild(icon); } /* -------------------------------------------- */ /** * Draw the overlay effect icon * @param {string} src * @param {number|null} tint * @returns {Promise} * @protected */ async _drawOverlay(src, tint) { const icon = await this._drawEffect(src, tint); if ( icon ) icon.alpha = 0.8; this.effects.overlay = icon ?? null; return icon; } /* -------------------------------------------- */ /** * Refresh the display of status effects, adjusting their position for the token width and height. * @protected */ _refreshEffects() { let i = 0; const size = Math.round(canvas.dimensions.size / 10) * 2; const rows = Math.floor(this.document.height * 5); const bg = this.effects.bg.clear().beginFill(0x000000, 0.40).lineStyle(1.0, 0x000000); for ( const effect of this.effects.children ) { if ( effect === bg ) continue; // Overlay effect if ( effect === this.effects.overlay ) { const {width, height} = this.getSize(); const size = Math.min(width * 0.6, height * 0.6); effect.width = effect.height = size; effect.position = this.getCenterPoint({x: 0, y: 0}); effect.anchor.set(0.5, 0.5); } // Status effect else { effect.width = effect.height = size; effect.x = Math.floor(i / rows) * size; effect.y = (i % rows) * size; bg.drawRoundedRect(effect.x + 1, effect.y + 1, size - 2, size - 2, 2); i++; } } } /* -------------------------------------------- */ /** * Helper method to determine whether a token attribute is viewable under a certain mode * @param {number} mode The mode from CONST.TOKEN_DISPLAY_MODES * @returns {boolean} Is the attribute viewable? * @protected */ _canViewMode(mode) { if ( mode === CONST.TOKEN_DISPLAY_MODES.NONE ) return false; else if ( mode === CONST.TOKEN_DISPLAY_MODES.ALWAYS ) return true; else if ( mode === CONST.TOKEN_DISPLAY_MODES.CONTROL ) return this.controlled; else if ( mode === CONST.TOKEN_DISPLAY_MODES.HOVER ) return this.hover || this.layer.highlightObjects; else if ( mode === CONST.TOKEN_DISPLAY_MODES.OWNER_HOVER ) return this.isOwner && (this.hover || this.layer.highlightObjects); else if ( mode === CONST.TOKEN_DISPLAY_MODES.OWNER ) return this.isOwner; return false; } /* -------------------------------------------- */ /* Token Ring */ /* -------------------------------------------- */ /** * Override ring colors for this particular Token instance. * @returns {{[ring]: Color, [background]: Color}} */ getRingColors() { return {}; } /* -------------------------------------------- */ /** * Apply additional ring effects for this particular Token instance. * Effects are returned as an array of integers in {@link foundry.canvas.tokens.TokenRing.effects}. * @returns {number[]} */ getRingEffects() { return []; } /* -------------------------------------------- */ /* Token Animation */ /* -------------------------------------------- */ /** * Get the animation data for the current state of the document. * @returns {TokenAnimationData} The target animation data object * @protected */ _getAnimationData() { const doc = this.document; const {x, y, width, height, rotation, alpha} = doc; const {src, anchorX, anchorY, scaleX, scaleY, tint} = doc.texture; const texture = {src, anchorX, anchorY, scaleX, scaleY, tint}; const subject = { texture: doc.ring.subject.texture, scale: doc.ring.subject.scale }; return {x, y, width, height, rotation, alpha, texture, ring: {subject}}; } /* -------------------------------------------- */ /** * Animate from the old to the new state of this Token. * @param {Partial} to The animation data to animate to * @param {object} [options] The options that configure the animation behavior. * Passed to {@link Token#_getAnimationDuration}. * @param {number} [options.duration] The duration of the animation in milliseconds * @param {number} [options.movementSpeed=6] A desired token movement speed in grid spaces per second * @param {string} [options.transition] The desired texture transition type * @param {Function|string} [options.easing] The easing function of the animation * @param {string|symbol|null} [options.name] The name of the animation, or null if nameless. * The default is {@link Token#animationName}. * @param {Function} [options.ontick] A on-tick callback * @returns {Promise} A promise which resolves once the animation has finished or stopped */ async animate(to, {duration, easing, movementSpeed, name, ontick, ...options}={}) { /** @deprecated since v12 */ if ( "a0" in options ) { const msg = "Passing a0 to Token#animate is deprecated without replacement."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); } // Get the name and the from and to animation data if ( name === undefined ) name = this.animationName; else name ||= Symbol(this.animationName); const from = this.#animationData; to = foundry.utils.filterObject(to, this.#animationData); let context = this.#animationContexts.get(name); if ( context ) to = foundry.utils.mergeObject(context.to, to, {inplace: false}); // Conclude the current animation CanvasAnimation.terminateAnimation(name); if ( context ) this.#animationContexts.delete(name); // Get the animation duration and create the animation context duration ??= this._getAnimationDuration(from, to, {movementSpeed, ...options}); context = {name, to, duration, time: 0, preAnimate: [], postAnimate: [], onAnimate: []}; // Animate the first frame this.#animateFrame(context); // If the duration of animation is not positive, we can immediately conclude the animation if ( duration <= 0 ) return; // Set the animation context this.#animationContexts.set(name, context); // Prepare the animation data changes const changes = foundry.utils.diffObject(from, to); const attributes = this._prepareAnimation(from, changes, context, options); // Dispatch the animation context.promise = CanvasAnimation.animate(attributes, { name, context: this, duration, easing, priority: PIXI.UPDATE_PRIORITY.OBJECTS + 1, // Before perception updates and Token render flags wait: Promise.allSettled(context.preAnimate.map(fn => fn(context))), ontick: (dt, anim) => { context.time = anim.time; if ( ontick ) ontick(dt, anim, this.#animationData); this.#animateFrame(context); } }); await context.promise.finally(() => { if ( this.#animationContexts.get(name) === context ) this.#animationContexts.delete(name); for ( const fn of context.postAnimate ) fn(context); }); } /* -------------------------------------------- */ /** * Get the duration of the animation. * @param {TokenAnimationData} from The animation data to animate from * @param {Partial} to The animation data to animate to * @param {object} [options] The options that configure the animation behavior * @param {number} [options.movementSpeed=6] A desired token movement speed in grid spaces per second * @returns {number} The duration of the animation in milliseconds * @protected */ _getAnimationDuration(from, to, {movementSpeed=6}={}) { let duration = 0; const dx = from.x - (to.x ?? from.x); const dy = from.y - (to.y ?? from.y); if ( dx || dy ) duration = Math.max(duration, Math.hypot(dx, dy) / canvas.dimensions.size / movementSpeed * 1000); const dr = ((Math.abs(from.rotation - (to.rotation ?? from.rotation)) + 180) % 360) - 180; if ( dr ) duration = Math.max(duration, Math.abs(dr) / (movementSpeed * 60) * 1000); if ( !duration ) duration = 1000; // The default animation duration is 1 second return duration; } /* -------------------------------------------- */ /** * Handle a single frame of a token animation. * @param {TokenAnimationContext} context The animation context */ #animateFrame(context) { if ( context.time >= context.duration ) foundry.utils.mergeObject(this.#animationData, context.to); const changes = foundry.utils.diffObject(this.#priorAnimationData, this.#animationData); foundry.utils.mergeObject(this.#priorAnimationData, this.#animationData); foundry.utils.mergeObject(this.document, this.#animationData, {insertKeys: false}); for ( const fn of context.onAnimate ) fn(context); this._onAnimationUpdate(changes, context); } /* -------------------------------------------- */ /** * Called each animation frame. * @param {Partial} changed The animation data that changed * @param {TokenAnimationContext} context The animation context * @protected */ _onAnimationUpdate(changed, context) { const positionChanged = ("x" in changed) || ("y" in changed); const rotationChanged = ("rotation" in changed); const sizeChanged = ("width" in changed) || ("height" in changed); const textureChanged = "texture" in changed; const ringEnabled = this.document.ring.enabled; const ringChanged = "ring" in changed; const ringSubjectChanged = ringEnabled && ringChanged && ("subject" in changed.ring); const ringSubjectTextureChanged = ringSubjectChanged && ("texture" in changed.ring.subject); const ringSubjectScaleChanged = ringSubjectChanged && ("scale" in changed.ring.subject); this.renderFlags.set({ redraw: (textureChanged && ("src" in changed.texture)) || ringSubjectTextureChanged, refreshVisibility: positionChanged || sizeChanged, refreshPosition: positionChanged, refreshRotation: rotationChanged && !this.document.lockRotation, refreshSize: sizeChanged || ringSubjectScaleChanged, refreshMesh: textureChanged || ("alpha" in changed) }); // Update occlusion and/or sounds and the HUD if necessary if ( positionChanged || sizeChanged ) { canvas.perception.update({refreshSounds: true, refreshOcclusionMask: true, refreshOcclusionStates: true}); if ( this.hasActiveHUD ) this.layer.hud.clear(); } // Update light and sight sources unless Vision Animation is disabled if ( (context.time < context.duration) && !game.settings.get("core", "visionAnimation") ) return; const perspectiveChanged = positionChanged || sizeChanged || (rotationChanged && this.hasLimitedSourceAngle); const visionChanged = perspectiveChanged && this.hasSight; const lightChanged = perspectiveChanged && this._isLightSource(); if ( visionChanged || lightChanged ) this.initializeSources(); } /* -------------------------------------------- */ /** * Terminate the animations of this particular Token, if exists. * @param {object} [options] Additional options. * @param {boolean} [options.reset=true] Reset the TokenDocument? */ stopAnimation({reset=true}={}) { if ( reset ) this.document.reset(); for ( const name of this.#animationContexts.keys() ) CanvasAnimation.terminateAnimation(name); this.#animationContexts.clear(); const to = this._getAnimationData(); const changes = foundry.utils.diffObject(this.#animationData, to); foundry.utils.mergeObject(this.#animationData, to); foundry.utils.mergeObject(this.#priorAnimationData, this.#animationData); if ( foundry.utils.isEmpty(changes) ) return; const context = {name: Symbol(this.animationName), to, duration: 0, time: 0, preAnimate: [], postAnimate: [], onAnimate: []}; this._onAnimationUpdate(changes, context); } /* -------------------------------------------- */ /* Animation Preparation Methods */ /* -------------------------------------------- */ /** * Move the token immediately to the destination if it is teleported. * @param {Partial} to The animation data to animate to */ #handleTeleportAnimation(to) { const changes = {}; if ( "x" in to ) this.#animationData.x = changes.x = to.x; if ( "y" in to ) this.#animationData.y = changes.y = to.y; if ( "elevation" in to ) this.#animationData.elevation = changes.elevation = to.elevation; if ( !foundry.utils.isEmpty(changes) ) { const context = {name: Symbol(this.animationName), to: changes, duration: 0, time: 0, preAnimate: [], postAnimate: [], onAnimate: []}; this._onAnimationUpdate(changes, context); } } /* -------------------------------------------- */ /** * Handle the rotation changes for the animation, ensuring the shortest rotation path. * @param {TokenAnimationData} from The animation data to animate from * @param {Partial} changes The animation data changes */ static #handleRotationChanges(from, changes) { if ( "rotation" in changes ) { let dr = changes.rotation - from.rotation; while ( dr > 180 ) dr -= 360; while ( dr < -180 ) dr += 360; changes.rotation = from.rotation + dr; } } /* -------------------------------------------- */ /** * Update the padding for both the source and target tokens to ensure they are square. * @param {PrimarySpriteMesh} sourceMesh The source mesh * @param {PrimarySpriteMesh} targetMesh The target mesh */ static #updatePadding(sourceMesh, targetMesh) { const calculatePadding = ({width, height}) => ({ x: width > height ? 0 : (height - width) / 2, y: height > width ? 0 : (width - height) / 2 }); const paddingSource = calculatePadding(sourceMesh.texture); sourceMesh.paddingX = paddingSource.x; sourceMesh.paddingY = paddingSource.y; const paddingTarget = calculatePadding(targetMesh.texture); targetMesh.paddingX = paddingTarget.x; targetMesh.paddingY = paddingTarget.y; } /* -------------------------------------------- */ /** * Create a texture transition filter with the given options. * @param {object} options The options that configure the filter * @returns {TextureTransitionFilter} The created filter */ static #createTransitionFilter(options) { const filter = TextureTransitionFilter.create(); filter.enabled = false; filter.type = options.transition ?? "fade"; return filter; } /* -------------------------------------------- */ /** * Prepare the animation data changes: performs special handling required for animating rotation. * @param {TokenAnimationData} from The animation data to animate from * @param {Partial} changes The animation data changes * @param {Omit} context The animation context * @param {object} [options] The options that configure the animation behavior * @param {string} [options.transition="fade"] The desired texture transition type * @returns {CanvasAnimationAttribute[]} The animation attributes * @protected */ _prepareAnimation(from, changes, context, options = {}) { const attributes = []; Token.#handleRotationChanges(from, changes); this.#handleTransitionChanges(changes, context, options, attributes); // Create animation attributes from the changes const recur = (changes, parent) => { for ( const [attribute, to] of Object.entries(changes) ) { const type = foundry.utils.getType(to); if ( type === "Object" ) recur(to, parent[attribute]); else if ( type === "number" || type === "Color" ) attributes.push({attribute, parent, to}); } }; recur(changes, this.#animationData); return attributes; } /* -------------------------------------------- */ /** * Handle the transition changes, creating the necessary filter and preparing the textures. * @param {Partial} changed The animation data that changed * @param {Omit} context The animation context * @param {object} options The options that configure the animation behavior * @param {CanvasAnimationAttribute[]} attributes The array to push animation attributes to */ #handleTransitionChanges(changed, context, options, attributes) { const textureChanged = ("texture" in changed) && ("src" in changed.texture); const ringEnabled = this.document.ring.enabled; const subjectTextureChanged = ringEnabled && ("ring" in changed) && ("subject" in changed.ring) && ("texture" in changed.ring.subject); // If no texture has changed, no need for a transition if ( !(textureChanged || subjectTextureChanged) ) return; const filter = Token.#createTransitionFilter(options); let renderTexture; let targetMesh; let targetToken; if ( this.mesh ) { this.mesh.filters ??= []; this.mesh.filters.unshift(filter); } context.preAnimate.push(async function() { const targetAsset = !ringEnabled ? changed.texture.src : (subjectTextureChanged ? changed.ring.subject.texture : this.document.ring.subject.texture); const targetTexture = await loadTexture(targetAsset, {fallback: CONST.DEFAULT_TOKEN}); targetToken = this.#prepareTargetToken(targetTexture); // Create target primary sprite mesh and assign to the target token targetMesh = new PrimarySpriteMesh({object: targetToken}); targetMesh.texture = targetTexture; targetToken.mesh = targetMesh; // Prepare source and target meshes and shader class if ( ringEnabled ) { targetToken.#ring = new CONFIG.Token.ring.ringClass(targetToken); targetToken.#ring.configure(targetMesh); targetMesh.setShaderClass(CONFIG.Token.ring.shaderClass); } else { Token.#updatePadding(this.mesh, targetMesh); targetMesh.setShaderClass(PrimaryBaseSamplerShader); } // Prepare mesh position for rendering targetMesh.position.set(targetMesh.paddingX, targetMesh.paddingY); // Configure render texture and render the target mesh into it const renderer = canvas.app.renderer; renderTexture = renderer.generateTexture(targetMesh, {resolution: targetMesh.texture.resolution}); // Add animation function if ring effects are enabled if ( targetToken.hasDynamicRing && (this.document.ring.effects > CONFIG.Token.ring.ringClass.effects.ENABLED) ) { context.onAnimate.push(function() { canvas.app.renderer.render(targetMesh, {renderTexture}); }); } // Preparing the transition filter filter.targetTexture = renderTexture; filter.enabled = true; }.bind(this)); context.postAnimate.push(function() { targetMesh?.destroy(); renderTexture?.destroy(true); targetToken?.destroy({children: true}); this.mesh?.filters?.findSplice(f => f === filter); if ( !this.hasDynamicRing && this.mesh ) this.mesh.padding = 0; }.bind(this)); attributes.push({attribute: "progress", parent: filter.uniforms, to: 1}); } /* -------------------------------------------- */ /** * Prepare a target token by cloning the current token and setting its texture. * @param {PIXI.Texture} targetTexture The texture to set on the target token * @returns {Token} The prepared target token * @internal */ #prepareTargetToken(targetTexture) { const cloneDoc = this.document.clone(); const clone = cloneDoc.object; clone.texture = targetTexture; return clone; } /* -------------------------------------------- */ /* Methods /* -------------------------------------------- */ /** * Check for collision when attempting a move to a new position * @param {Point} destination The central destination point of the attempted movement * @param {object} [options={}] Additional options forwarded to PointSourcePolygon.testCollision * @param {Point} [options.origin] The origin to be used instead of the current origin * @param {PointSourcePolygonType} [options.type="move"] The collision type * @param {"any"|"all"|"closest"} [options.mode="any"] The collision mode to test: "any", "all", or "closest" * @returns {boolean|PolygonVertex|PolygonVertex[]|null} The collision result depends on the mode of the test: * * any: returns a boolean for whether any collision occurred * * all: returns a sorted array of PolygonVertex instances * * closest: returns a PolygonVertex instance or null */ checkCollision(destination, {origin, type="move", mode="any"}={}) { // Round origin and destination such that the top-left point (i.e. the Token's position) is integer const {x: cx, y: cy} = this.getCenterPoint({x: 0, y: 0}); if ( origin ) origin = {x: Math.round(origin.x - cx) + cx, y: Math.round(origin.y - cy) + cy}; destination = {x: Math.round(destination.x - cx) + cx, y: Math.round(destination.y - cy) + cy}; // The test origin is the last confirmed valid position of the Token const center = origin || this.getCenterPoint(this.#validPosition); origin = this.getMovementAdjustedPoint(center); // The test destination is the adjusted point based on the proposed movement vector const dx = destination.x - center.x; const dy = destination.y - center.y; const offsetX = dx === 0 ? this.#priorMovement.ox : Math.sign(dx); const offsetY = dy === 0 ? this.#priorMovement.oy : Math.sign(dy); destination = this.getMovementAdjustedPoint(destination, {offsetX, offsetY}); // Reference the correct source object let source; switch ( type ) { case "move": source = this.#getMovementSource(origin); break; case "sight": source = this.vision; break; case "light": source = this.light; break; case "sound": throw new Error("Collision testing for Token sound sources is not supported at this time"); } // Create a movement source passed to the polygon backend return CONFIG.Canvas.polygonBackends[type].testCollision(origin, destination, {type, mode, source}); } /* -------------------------------------------- */ /** * Prepare a PointMovementSource for the document * @param {Point} origin The origin of the source * @returns {foundry.canvas.sources.PointMovementSource} */ #getMovementSource(origin) { const movement = new foundry.canvas.sources.PointMovementSource({object: this}); movement.initialize({x: origin.x, y: origin.y, elevation: this.document.elevation}); return movement; } /* -------------------------------------------- */ /** * Get the width and height of the Token in pixels. * @returns {{width: number, height: number}} The size in pixels */ getSize() { let {width, height} = this.document; const grid = this.scene.grid; if ( grid.isHexagonal ) { if ( grid.columns ) width = (0.75 * Math.floor(width)) + (0.5 * (width % 1)) + 0.25; else height = (0.75 * Math.floor(height)) + (0.5 * (height % 1)) + 0.25; } width *= grid.sizeX; height *= grid.sizeY; return {width, height}; } /* -------------------------------------------- */ /** * Get the shape of this Token. * @returns {PIXI.Rectangle|PIXI.Polygon|PIXI.Circle} */ getShape() { const {width, height, hexagonalShape} = this.document; const grid = this.scene.grid; // Hexagonal shape if ( grid.isHexagonal ) { const shape = Token.#getHexagonalShape(grid.columns, hexagonalShape, width, height); if ( shape ) { const points = []; for ( let i = 0; i < shape.points.length; i += 2 ) { points.push(shape.points[i] * grid.sizeX, shape.points[i + 1] * grid.sizeY); } return new PIXI.Polygon(points); } // No hexagonal shape for this combination of shape type, width, and height. // Fallback to rectangular shape. } // Rectangular shape const size = this.getSize(); return new PIXI.Rectangle(0, 0, size.width, size.height); } /* -------------------------------------------- */ /** * Get the center point for a given position or the current position. * @param {Point} [position] The position to be used instead of the current position * @returns {Point} The center point */ getCenterPoint(position) { const {x, y} = position ?? this.document; const {width, height, hexagonalShape} = this.document; const grid = this.scene.grid; // Hexagonal shape if ( grid.isHexagonal ) { const shape = Token.#getHexagonalShape(grid.columns, hexagonalShape, width, height); if ( shape ) { const center = shape.center; return {x: x + (center.x * grid.sizeX), y: y + (center.y * grid.sizeY)}; } // No hexagonal shape for this combination of shape type, width, and height. // Fallback to the center of the rectangle. } // Rectangular shape const size = this.getSize(); return {x: x + (size.width / 2), y: y + (size.height / 2)}; } /* -------------------------------------------- */ /** @override */ getSnappedPosition(position) { position ??= this.document; const grid = this.scene.grid; if ( grid.isSquare ) return this.#snapToSquareGrid(position); if ( grid.isHexagonal ) return this.#snapToHexagonalGrid(position); return {x: position.x, y: position.y}; } /* -------------------------------------------- */ /** * Get the snapped position for a given position on a square grid. * @param {Point} position The position that is snapped * @returns {Point} The snapped position */ #snapToSquareGrid(position) { const {width, height} = this.document; const grid = this.scene.grid; const M = CONST.GRID_SNAPPING_MODES; // Small tokens snap to any vertex of the subgrid with resolution 4 // where the token is fully contained within the grid space if ( ((width === 0.5) && (height <= 1)) || ((width <= 1) && (height === 0.5)) ) { let x = position.x / grid.size; let y = position.y / grid.size; if ( width === 1 ) x = Math.round(x); else { x = Math.floor(x * 8); const k = ((x % 8) + 8) % 8; if ( k >= 6 ) x = Math.ceil(x / 8); else if ( k === 5 ) x = Math.floor(x / 8) + 0.5; else x = Math.round(x / 2) / 4; } if ( height === 1 ) y = Math.round(y); else { y = Math.floor(y * 8); const k = ((y % 8) + 8) % 8; if ( k >= 6 ) y = Math.ceil(y / 8); else if ( k === 5 ) y = Math.floor(y / 8) + 0.5; else y = Math.round(y / 2) / 4; } x *= grid.size; y *= grid.size; return {x, y}; } const modeX = Number.isInteger(width) ? M.VERTEX : M.VERTEX | M.EDGE_MIDPOINT | M.CENTER; const modeY = Number.isInteger(height) ? M.VERTEX : M.VERTEX | M.EDGE_MIDPOINT | M.CENTER; if ( modeX === modeY ) return grid.getSnappedPoint(position, {mode: modeX}); return { x: grid.getSnappedPoint(position, {mode: modeX}).x, y: grid.getSnappedPoint(position, {mode: modeY}).y }; } /* -------------------------------------------- */ /** * Get the snapped position for a given position on a hexagonal grid. * @param {Point} position The position that is snapped * @returns {Point} The snapped position */ #snapToHexagonalGrid(position) { const {width, height, hexagonalShape} = this.document; const grid = this.scene.grid; const M = CONST.GRID_SNAPPING_MODES; // Hexagonal shape const shape = Token.#getHexagonalShape(grid.columns, hexagonalShape, width, height); if ( shape ) { const {behavior, anchor} = shape.snapping; const offsetX = anchor.x * grid.sizeX; const offsetY = anchor.y * grid.sizeY; position = grid.getSnappedPoint({x: position.x + offsetX, y: position.y + offsetY}, behavior); position.x -= offsetX; position.y -= offsetY; return position; } // Rectagular shape return grid.getSnappedPoint(position, {mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT}); } /* -------------------------------------------- */ /** * Test whether the Token is inside the Region. * This function determines the state of {@link TokenDocument#regions} and {@link RegionDocument#tokens}. * * Implementations of this function are restricted in the following ways: * - If the bounds (given by {@link Token#getSize}) of the Token do not intersect the Region, then the Token is not * contained within the Region. * - If the Token is inside the Region a particular elevation, then the Token is inside the Region at any elevation * within the elevation range of the Region. * * If this function is overridden, then {@link Token#segmentizeRegionMovement} must be overridden too. * @param {Region} region The region. * @param {Point | (Point & {elevation: number}) | {elevation: number}} position * The (x, y) and/or elevation to use instead of the current values. * @returns {boolean} Is the Token inside the Region? */ testInsideRegion(region, position) { return region.testPoint(this.getCenterPoint(position), position?.elevation ?? this.document.elevation); } /* -------------------------------------------- */ /** * Split the Token movement through the waypoints into its segments. * * Implementations of this function are restricted in the following ways: * - The segments must go through the waypoints. * - The *from* position matches the *to* position of the succeeding segment. * - The Token must be contained (w.r.t. {@link Token#testInsideRegion}) within the Region * at the *from* and *to* of MOVE segments. * - The Token must be contained (w.r.t. {@link Token#testInsideRegion}) within the Region * at the *to* position of ENTER segments. * - The Token must be contained (w.r.t. {@link Token#testInsideRegion}) within the Region * at the *from* position of EXIT segments. * - The Token must not be contained (w.r.t. {@link Token#testInsideRegion}) within the Region * at the *from* position of ENTER segments. * - The Token must not be contained (w.r.t. {@link Token#testInsideRegion}) within the Region * at the *to* position of EXIT segments. * @param {Region} region The region. * @param {RegionMovementWaypoint[]} waypoints The waypoints of movement. * @param {object} [options] Additional options * @param {boolean} [options.teleport=false] Is it teleportation? * @returns {RegionMovementSegment[]} The movement split into its segments. */ segmentizeRegionMovement(region, waypoints, {teleport=false}={}) { return region.segmentizeMovement(waypoints, [this.getCenterPoint({x: 0, y: 0})], {teleport}); } /* -------------------------------------------- */ /** * Set this Token as an active target for the current game User. * Note: If the context is set with groupSelection:true, you need to manually broadcast the activity for other users. * @param {boolean} targeted Is the Token now targeted? * @param {object} [context={}] Additional context options * @param {User|null} [context.user=null] Assign the token as a target for a specific User * @param {boolean} [context.releaseOthers=true] Release other active targets for the same player? * @param {boolean} [context.groupSelection=false] Is this target being set as part of a group selection workflow? */ setTarget(targeted=true, {user=null, releaseOthers=true, groupSelection=false}={}) { // Do not allow setting a preview token as a target if ( this.isPreview ) return; // Release other targets user = user || game.user; if ( user.targets.size && releaseOthers ) { user.targets.forEach(t => { if ( t !== this ) t.setTarget(false, {user, releaseOthers: false, groupSelection: true}); }); } // Acquire target const wasTargeted = this.targeted.has(user); if ( targeted ) { this.targeted.add(user); user.targets.add(this); } // Release target else { this.targeted.delete(user); user.targets.delete(this); } // If target status changed if ( wasTargeted !== targeted ) { this.renderFlags.set({refreshTarget: true}); if ( this.hasActiveHUD ) this.layer.hud.render(); } // Broadcast the target change if it was not part of a group selection if ( !groupSelection ) user.broadcastActivity({targets: user.targets.ids}); } /* -------------------------------------------- */ /** * The external radius of the token in pixels. * @type {number} */ get externalRadius() { const {width, height} = this.getSize(); return Math.max(width, height) / 2; } /* -------------------------------------------- */ /** * A generic transformation to turn a certain number of grid units into a radius in canvas pixels. * This function adds additional padding to the light radius equal to the external radius of the token. * This causes light to be measured from the outer token edge, rather than from the center-point. * @param {number} units The radius in grid units * @returns {number} The radius in pixels */ getLightRadius(units) { if ( units === 0 ) return 0; return ((Math.abs(units) * canvas.dimensions.distancePixels) + this.externalRadius) * Math.sign(units); } /* -------------------------------------------- */ /** @inheritDoc */ _getShiftedPosition(dx, dy) { const shifted = super._getShiftedPosition(dx, dy); const collides = this.checkCollision(this.getCenterPoint(shifted)); return collides ? {x: this.document._source.x, y: this.document._source.y} : shifted; } /* -------------------------------------------- */ /** @override */ _updateRotation({angle, delta=0, snap=0}={}) { let degrees = Number.isNumeric(angle) ? angle : this.document.rotation + delta; const isHexRow = [CONST.GRID_TYPES.HEXODDR, CONST.GRID_TYPES.HEXEVENR].includes(canvas.grid.type); if ( isHexRow ) degrees -= 30; if ( snap > 0 ) degrees = degrees.toNearest(snap); if ( isHexRow ) degrees += 30; return Math.normalizeDegrees(degrees); } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ _onCreate(data, options, userId) { super._onCreate(data, options, userId); this.initializeSources(); // Update vision and lighting sources if ( !game.user.isGM && this.isOwner && !this.document.hidden ) this.control({pan: true}); // Assume control canvas.perception.update({refreshOcclusion: true}); } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { super._onUpdate(changed, options, userId); const doc = this.document; // Record movement const positionChanged = ("x" in changed) || ("y" in changed); const rotationChanged = "rotation" in changed; const sizeChanged = ("width" in changed) || ("height" in changed); const elevationChanged = "elevation" in changed; if ( positionChanged || rotationChanged || sizeChanged ) { this.#recordPosition(positionChanged, rotationChanged, sizeChanged); } // Acquire or release Token control const hiddenChanged = "hidden" in changed; if ( hiddenChanged ) { if ( this.controlled && changed.hidden && !game.user.isGM ) this.release(); else if ( (changed.hidden === false) && !canvas.tokens.controlled.length ) this.control({pan: true}); if ( this.isOwner && (this.layer.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.OWNED) ) { canvas.perception.update({refreshOcclusion: true}); } } // Automatically pan the canvas if ( positionChanged && this.controlled && (options.pan !== false) ) this.#panCanvas(); // Process Combat Tracker changes if ( this.inCombat && ("name" in changed) ) game.combat.debounceSetup(); // Texture and Ring changes const textureChanged = "texture" in changed; const ringEnabled = doc.ring.enabled; const ringChanged = "ring" in changed; const ringEnabledChanged = ringChanged && ("enabled" in changed.ring); const ringSubjectChanged = ringEnabled && ringChanged && ("subject" in changed.ring); const ringSubjectTextureChanged = ringSubjectChanged && ("texture" in changed.ring.subject); const ringVisualsChanged = ringEnabled && ringChanged && (("colors" in changed.ring) || ("effects" in changed.ring)); // Handle animatable changes if ( options.animate === false ) this.stopAnimation({reset: false}); else { const to = foundry.utils.filterObject(this._getAnimationData(), changed); // TODO: Can we find a solution that doesn't require special handling for hidden? if ( hiddenChanged ) to.alpha = doc.alpha; // We need to infer subject texture if ring is enabled and texture is changed if ( (ringEnabled || ringEnabledChanged) && !ringSubjectTextureChanged && textureChanged && ("src" in changed.texture) && !doc._source.ring.subject.texture ) { foundry.utils.mergeObject(to, {ring: {subject: {texture: doc.texture.src}}}); } // Don't animate movement if teleport if ( options.teleport === true ) this.#handleTeleportAnimation(to); // Dispatch the animation this.animate(to, options.animation); } // Source and perception updates if ( hiddenChanged || elevationChanged || ("light" in changed) || ("sight" in changed) || ("detectionModes" in changed) ) { this.initializeSources(); } if ( !game.user.isGM && this.controlled && (hiddenChanged || (("sight" in changed) && ("enabled" in changed.sight))) ) { for ( const token of this.layer.placeables ) { if ( (token !== this) && (!token.vision === token._isVisionSource()) ) token.initializeVisionSource(); } } if ( hiddenChanged || elevationChanged ) { canvas.perception.update({refreshVision: true, refreshSounds: true, refreshOcclusion: true}); } if ( "occludable" in changed ) canvas.perception.update({refreshOcclusionMask: true}); // Incremental refresh this.renderFlags.set({ redraw: ringEnabledChanged || ("actorId" in changed) || ("actorLink" in changed), refreshState: hiddenChanged || ("sort" in changed) || ("disposition" in changed) || ("displayBars" in changed) || ("displayName" in changed), refreshRotation: "lockRotation" in changed, refreshElevation: elevationChanged, refreshMesh: textureChanged && ("fit" in changed.texture), refreshShape: "hexagonalShape" in changed, refreshBars: ["displayBars", "bar1", "bar2"].some(k => k in changed), refreshNameplate: ["displayName", "name", "appendNumber", "prependAdjective"].some(k => k in changed), refreshRingVisuals: ringVisualsChanged }); } /* -------------------------------------------- */ /** @inheritDoc */ _onDelete(options, userId) { game.user.targets.delete(this); this.initializeSources({deleted: true}); canvas.perception.update({refreshOcclusion: true}); return super._onDelete(options, userId); } /* -------------------------------------------- */ /** * When Token position or rotation changes, record the movement vector. * Update cached values for both #validPosition and #priorMovement. * @param {boolean} positionChange Did the x/y position change? * @param {boolean} rotationChange Did rotation change? * @param {boolean} sizeChange Did the width or height change? */ #recordPosition(positionChange, rotationChange, sizeChange) { // Update rotation const position = {}; if ( rotationChange ) { position.rotation = this.document.rotation; } // Update movement vector if ( positionChange ) { const origin = {x: this.#animationData.x, y: this.#animationData.y}; position.x = this.document.x; position.y = this.document.y; const ray = new Ray(origin, position); // Offset movement relative to prior vector const prior = this.#priorMovement; const ox = ray.dx === 0 ? prior.ox : Math.sign(ray.dx); const oy = ray.dy === 0 ? prior.oy : Math.sign(ray.dy); this.#priorMovement = {dx: ray.dx, dy: ray.dy, ox, oy}; } // Update valid position foundry.utils.mergeObject(this.#validPosition, position); } /* -------------------------------------------- */ /** * Automatically pan the canvas when a controlled Token moves offscreen. */ #panCanvas() { // Target center point in screen coordinates const c = this.center; const {x: sx, y: sy} = canvas.stage.transform.worldTransform.apply(c); // Screen rectangle minus padding space const pad = 50; const sidebarPad = $("#sidebar").width() + pad; const rect = new PIXI.Rectangle(pad, pad, window.innerWidth - sidebarPad, window.innerHeight - pad); // Pan the canvas if the target center-point falls outside the screen rect if ( !rect.contains(sx, sy) ) canvas.animatePan(this.center); } /* -------------------------------------------- */ /** * Handle changes to Token behavior when a significant status effect is applied * @param {string} statusId The status effect ID being applied, from CONFIG.specialStatusEffects * @param {boolean} active Is the special status effect now active? * @protected * @internal */ _onApplyStatusEffect(statusId, active) { switch ( statusId ) { case CONFIG.specialStatusEffects.BURROW: this.initializeSources(); break; case CONFIG.specialStatusEffects.FLY: case CONFIG.specialStatusEffects.HOVER: canvas.perception.update({refreshVision: true}); break; case CONFIG.specialStatusEffects.INVISIBLE: canvas.perception.update({refreshVision: true}); this._configureFilterEffect(statusId, active); break; case CONFIG.specialStatusEffects.BLIND: this.initializeVisionSource(); break; } // Call hooks Hooks.callAll("applyTokenStatusEffect", this, statusId, active); } /* -------------------------------------------- */ /** * Add/Modify a filter effect on this token. * @param {string} statusId The status effect ID being applied, from CONFIG.specialStatusEffects * @param {boolean} active Is the special status effect now active? * @internal */ _configureFilterEffect(statusId, active) { let filterClass = null; let filterUniforms = {}; // TODO: The filter class should be into CONFIG with specialStatusEffects or conditions. switch ( statusId ) { case CONFIG.specialStatusEffects.INVISIBLE: filterClass = InvisibilityFilter; break; } if ( !filterClass ) return; const target = this.mesh; target.filters ??= []; // Is a filter active for this id? let filter = this.#filterEffects.get(statusId); if ( !filter && active ) { filter = filterClass.create(filterUniforms); // Push the filter and set the filter effects map target.filters.push(filter); this.#filterEffects.set(statusId, filter); } else if ( filter ) { filter.enabled = active; foundry.utils.mergeObject(filter.uniforms, filterUniforms, { insertKeys: false, overwrite: true, enforceTypes: true }); if ( active && !target.filters.find(f => f === filter) ) target.filters.push(filter); } } /* -------------------------------------------- */ /** * Update the filter effects depending on special status effects * TODO: replace this method by something more convenient. * @internal */ _updateSpecialStatusFilterEffects() { const invisible = CONFIG.specialStatusEffects.INVISIBLE; this._configureFilterEffect(invisible, this.document.hasStatusEffect(invisible)); } /* -------------------------------------------- */ /** * Remove all filter effects on this placeable. * @internal */ _removeAllFilterEffects() { const target = this.mesh; if ( target?.filters?.length ) { for ( const filterEffect of this.#filterEffects.values() ) { target.filters.findSplice(f => f === filterEffect); } } this.#filterEffects.clear(); } /* -------------------------------------------- */ /** @inheritdoc */ _onControl({releaseOthers=true, pan=false, ...options}={}) { super._onControl(options); for ( const token of this.layer.placeables ) { if ( !token.vision === token._isVisionSource() ) token.initializeVisionSource(); } _token = this; // Debugging global window variable canvas.perception.update({ refreshVision: true, refreshSounds: true, refreshOcclusion: this.layer.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.CONTROLLED }); // Pan to the controlled Token if ( pan ) canvas.animatePan(this.center); } /* -------------------------------------------- */ /** @inheritdoc */ _onRelease(options) { super._onRelease(options); for ( const token of this.layer.placeables ) { if ( !token.vision === token._isVisionSource() ) token.initializeVisionSource(); } canvas.perception.update({ refreshVision: true, refreshSounds: true, refreshOcclusion: this.layer.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.CONTROLLED }); } /* -------------------------------------------- */ /** @override */ _overlapsSelection(rectangle) { if ( !this.shape ) return false; const shape = this.shape; const isRectangle = shape instanceof PIXI.Rectangle; if ( !isRectangle && !rectangle.intersects(this.bounds) ) return false; const localRectangle = new PIXI.Rectangle( rectangle.x - this.document.x, rectangle.y - this.document.y, rectangle.width, rectangle.height ); if ( isRectangle ) return localRectangle.intersects(shape); const shapePolygon = shape instanceof PIXI.Polygon ? shape : shape.toPolygon(); const intersection = localRectangle.intersectPolygon(shapePolygon, {scalingFactor: 100}); return intersection.points.length !== 0; } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** @override */ _canControl(user, event) { if ( !this.layer.active || this.isPreview ) return false; if ( canvas.controls.ruler.state === Ruler.STATES.MEASURING ) return false; const tool = game.activeTool; if ( (tool === "target") && !this.isPreview ) return true; return super._canControl(user, event); } /* -------------------------------------------- */ /** @override */ _canHUD(user, event) { if ( canvas.controls.ruler.state === Ruler.STATES.MEASURING ) return false; return user.isGM || (this.actor?.testUserPermission(user, "OWNER") ?? false); } /* -------------------------------------------- */ /** @override */ _canConfigure(user, event) { if ( canvas.controls.ruler.state === Ruler.STATES.MEASURING ) return false; return !this.isPreview; } /* -------------------------------------------- */ /** @override */ _canHover(user, event) { return !this.isPreview; } /* -------------------------------------------- */ /** @override */ _canView(user, event) { if ( canvas.controls.ruler.state === Ruler.STATES.MEASURING ) return false; if ( !this.actor ) ui.notifications.warn("TOKEN.WarningNoActor", {localize: true}); return this.actor?.testUserPermission(user, "LIMITED"); } /* -------------------------------------------- */ /** @override */ _canDrag(user, event) { if ( !this.controlled ) return false; if ( !this.layer.active || (game.activeTool !== "select") ) return false; const ruler = canvas.controls.ruler; if ( ruler.state === Ruler.STATES.MEASURING ) return false; if ( ruler.token === this ) return false; if ( CONFIG.Canvas.rulerClass.canMeasure ) return false; return true; } /* -------------------------------------------- */ /** @inheritDoc */ _onHoverIn(event, options) { const combatant = this.combatant; if ( combatant ) ui.combat.hoverCombatant(combatant, true); if ( this.layer.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.HOVERED ) { canvas.perception.update({refreshOcclusion: true}); } return super._onHoverIn(event, options); } /* -------------------------------------------- */ /** @inheritDoc */ _onHoverOut(event) { const combatant = this.combatant; if ( combatant ) ui.combat.hoverCombatant(combatant, false); if ( this.layer.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.HOVERED ) { canvas.perception.update({refreshOcclusion: true}); } return super._onHoverOut(event); } /* -------------------------------------------- */ /** @inheritDoc */ _onClickLeft(event) { const tool = game.activeTool; if ( tool === "target" ) { event.stopPropagation(); if ( this.document.isSecret ) return; return this.setTarget(!this.isTargeted, {releaseOthers: !event.shiftKey}); } super._onClickLeft(event); } /** @override */ _propagateLeftClick(event) { return CONFIG.Canvas.rulerClass.canMeasure; } /* -------------------------------------------- */ /** @override */ _onClickLeft2(event) { if ( !this._propagateLeftClick(event) ) event.stopPropagation(); const sheet = this.actor?.sheet; if ( sheet?.rendered ) { sheet.maximize(); sheet.bringToTop(); } else sheet?.render(true, {token: this.document}); } /* -------------------------------------------- */ /** @override */ _onClickRight2(event) { if ( !this._propagateRightClick(event) ) event.stopPropagation(); if ( this.isOwner && game.user.can("TOKEN_CONFIGURE") ) return super._onClickRight2(event); if ( this.document.isSecret ) return; return this.setTarget(!this.targeted.has(game.user), {releaseOthers: !event.shiftKey}); } /* -------------------------------------------- */ /** @override */ _onDragLeftStart(event) { const currentX = this.#animationData.x; const currentY = this.#animationData.y; this.stopAnimation(); const origin = event.interactionData.origin; origin.x += (this.document.x - currentX); origin.y += (this.document.y - currentY); return super._onDragLeftStart(event); } /* -------------------------------------------- */ /** @override */ _prepareDragLeftDropUpdates(event) { const updates = []; for ( const clone of event.interactionData.clones ) { const {document, _original: original} = clone; const dest = !event.shiftKey ? clone.getSnappedPosition() : {x: document.x, y: document.y}; const target = clone.getCenterPoint(dest); if ( !game.user.isGM ) { let collides = original.checkCollision(target); if ( collides ) { ui.notifications.error("RULER.MovementCollision", {localize: true, console: false}); continue; } } else if ( !canvas.dimensions.rect.contains(target.x, target.y) ) continue; updates.push({_id: original.id, x: dest.x, y: dest.y}); } return updates; } /* -------------------------------------------- */ /** @override */ _onDragLeftMove(event) { const {destination, clones} = event.interactionData; const preview = game.settings.get("core", "tokenDragPreview"); // Pan the canvas if the drag event approaches the edge canvas._onDragCanvasPan(event); // Determine dragged distance const origin = this.getCenterPoint(); const dx = destination.x - origin.x; const dy = destination.y - origin.y; // Update the position of each clone for ( const c of clones ) { const o = c._original; let position = {x: o.document.x + dx, y: o.document.y + dy}; if ( !event.shiftKey ) position = c.getSnappedPosition(position); if ( preview && !game.user.isGM ) { const collision = o.checkCollision(o.getCenterPoint(position)); if ( collision ) continue; } c.document.x = position.x; c.document.y = position.y; c.renderFlags.set({refreshPosition: true}); if ( preview ) c.initializeSources(); } // Update perception immediately if ( preview ) canvas.perception.update({refreshLighting: true, refreshVision: true}); } /* -------------------------------------------- */ /** @override */ _onDragEnd() { this.initializeSources({deleted: true}); this._original?.initializeSources(); super._onDragEnd(); } /* -------------------------------------------- */ /* Hexagonal Shape Helpers */ /* -------------------------------------------- */ /** * A hexagonal shape of a Token. * @typedef {object} TokenHexagonalShape * @property {number[]} points The points in normalized coordinates * @property {Point} center The center of the shape in normalized coordiantes * @property {{behavior: GridSnappingBehavior, anchor: Point}} snapping * The snapping behavior and snapping anchor in normalized coordinates */ /** * The cache of hexagonal shapes. * @type {Map>} */ static #hexagonalShapes = new Map(); /* -------------------------------------------- */ /** * Get the hexagonal shape given the type, width, and height. * @param {boolean} columns Column-based instead of row-based hexagonal grid? * @param {number} type The hexagonal shape (one of {@link CONST.TOKEN_HEXAGONAL_SHAPES}) * @param {number} width The width of the Token (positive) * @param {number} height The height of the Token (positive) * @returns {DeepReadonly|null} The hexagonal shape or null if there is no shape * for the given combination of arguments */ static #getHexagonalShape(columns, type, width, height) { if ( !Number.isInteger(width * 2) || !Number.isInteger(height * 2) ) return null; const key = `${columns ? "C" : "R"},${type},${width},${height}`; let shape = Token.#hexagonalShapes.get(key); if ( shape ) return shape; const T = CONST.TOKEN_HEXAGONAL_SHAPES; const M = CONST.GRID_SNAPPING_MODES; // Hexagon symmetry if ( columns ) { const rowShape = Token.#getHexagonalShape(false, type, height, width); if ( !rowShape ) return null; // Transpose and reverse the points of the shape in row orientation const points = []; for ( let i = rowShape.points.length; i > 0; i -= 2 ) { points.push(rowShape.points[i - 1], rowShape.points[i - 2]); } shape = { points, center: {x: rowShape.center.y, y: rowShape.center.x}, snapping: { behavior: rowShape.snapping.behavior, anchor: {x: rowShape.snapping.anchor.y, y: rowShape.snapping.anchor.x} } }; } // Small hexagon else if ( (width === 0.5) && (height === 0.5) ) { shape = { points: [0.25, 0.0, 0.5, 0.125, 0.5, 0.375, 0.25, 0.5, 0.0, 0.375, 0.0, 0.125], center: {x: 0.25, y: 0.25}, snapping: {behavior: {mode: M.CENTER, resolution: 1}, anchor: {x: 0.25, y: 0.25}} }; } // Normal hexagon else if ( (width === 1) && (height === 1) ) { shape = { points: [0.5, 0.0, 1.0, 0.25, 1, 0.75, 0.5, 1.0, 0.0, 0.75, 0.0, 0.25], center: {x: 0.5, y: 0.5}, snapping: {behavior: {mode: M.TOP_LEFT_CORNER, resolution: 1}, anchor: {x: 0.0, y: 0.0}} }; } // Hexagonal ellipse or trapezoid else if ( type <= T.TRAPEZOID_2 ) { shape = Token.#createHexagonalEllipseOrTrapezoid(type, width, height); } // Hexagonal rectangle else if ( type <= T.RECTANGLE_2 ) { shape = Token.#createHexagonalRectangle(type, width, height); } // Cache the shape if ( shape ) { Object.freeze(shape); Object.freeze(shape.points); Object.freeze(shape.center); Object.freeze(shape.snapping); Object.freeze(shape.snapping.behavior); Object.freeze(shape.snapping.anchor); Token.#hexagonalShapes.set(key, shape); } return shape; } /* -------------------------------------------- */ /** * Create the row-based hexagonal ellipse/trapezoid given the type, width, and height. * @param {number} type The shape type (must be ELLIPSE_1, ELLIPSE_1, TRAPEZOID_1, or TRAPEZOID_2) * @param {number} width The width of the Token (positive) * @param {number} height The height of the Token (positive) * @returns {TokenHexagonalShape|null} The hexagonal shape or null if there is no shape * for the given combination of arguments */ static #createHexagonalEllipseOrTrapezoid(type, width, height) { if ( !Number.isInteger(width) || !Number.isInteger(height) ) return null; const T = CONST.TOKEN_HEXAGONAL_SHAPES; const M = CONST.GRID_SNAPPING_MODES; const points = []; let top; let bottom; switch ( type ) { case T.ELLIPSE_1: if ( height >= 2 * width ) return null; top = Math.floor(height / 2); bottom = Math.floor((height - 1) / 2); break; case T.ELLIPSE_2: if ( height >= 2 * width ) return null; top = Math.floor((height - 1) / 2); bottom = Math.floor(height / 2); break; case T.TRAPEZOID_1: if ( height > width ) return null; top = height - 1; bottom = 0; break; case T.TRAPEZOID_2: if ( height > width ) return null; top = 0; bottom = height - 1; break; } let x = 0.5 * bottom; let y = 0.25; for ( let k = width - bottom; k--; ) { points.push(x, y); x += 0.5; y -= 0.25; points.push(x, y); x += 0.5; y += 0.25; } points.push(x, y); for ( let k = bottom; k--; ) { y += 0.5; points.push(x, y); x += 0.5; y += 0.25; points.push(x, y); } y += 0.5; for ( let k = top; k--; ) { points.push(x, y); x -= 0.5; y += 0.25; points.push(x, y); y += 0.5; } for ( let k = width - top; k--; ) { points.push(x, y); x -= 0.5; y += 0.25; points.push(x, y); x -= 0.5; y -= 0.25; } points.push(x, y); for ( let k = top; k--; ) { y -= 0.5; points.push(x, y); x -= 0.5; y -= 0.25; points.push(x, y); } y -= 0.5; for ( let k = bottom; k--; ) { points.push(x, y); x += 0.5; y -= 0.25; points.push(x, y); y -= 0.5; } return { points, // We use the centroid of the polygon for ellipse and trapzoid shapes center: foundry.utils.polygonCentroid(points), snapping: { behavior: {mode: bottom % 2 ? M.BOTTOM_RIGHT_VERTEX : M.TOP_LEFT_CORNER, resolution: 1}, anchor: {x: 0.0, y: 0.0} } }; } /** * Create the row-based hexagonal rectangle given the type, width, and height. * @param {number} type The shape type (must be RECTANGLE_1 or RECTANGLE_2) * @param {number} width The width of the Token (positive) * @param {number} height The height of the Token (positive) * @returns {TokenHexagonalShape|null} The hexagonal shape or null if there is no shape * for the given combination of arguments */ static #createHexagonalRectangle(type, width, height) { if ( (width < 1) || !Number.isInteger(height) ) return null; if ( (width === 1) && (height > 1) ) return null; if ( !Number.isInteger(width) && (height === 1) ) return null; const T = CONST.TOKEN_HEXAGONAL_SHAPES; const M = CONST.GRID_SNAPPING_MODES; const even = (type === T.RECTANGLE_1) || (height === 1); let x = even ? 0.0 : 0.5; let y = 0.25; const points = [x, y]; while ( x + 1 <= width ) { x += 0.5; y -= 0.25; points.push(x, y); x += 0.5; y += 0.25; points.push(x, y); } if ( x !== width ) { y += 0.5; points.push(x, y); x += 0.5; y += 0.25; points.push(x, y); } while ( y + 1.5 <= 0.75 * height ) { y += 0.5; points.push(x, y); x -= 0.5; y += 0.25; points.push(x, y); y += 0.5; points.push(x, y); x += 0.5; y += 0.25; points.push(x, y); } if ( y + 0.75 < 0.75 * height ) { y += 0.5; points.push(x, y); x -= 0.5; y += 0.25; points.push(x, y); } y += 0.5; points.push(x, y); while ( x - 1 >= 0 ) { x -= 0.5; y += 0.25; points.push(x, y); x -= 0.5; y -= 0.25; points.push(x, y); } if ( x !== 0 ) { y -= 0.5; points.push(x, y); x -= 0.5; y -= 0.25; points.push(x, y); } while ( y - 1.5 > 0 ) { y -= 0.5; points.push(x, y); x += 0.5; y -= 0.25; points.push(x, y); y -= 0.5; points.push(x, y); x -= 0.5; y -= 0.25; points.push(x, y); } if ( y - 0.75 > 0 ) { y -= 0.5; points.push(x, y); x += 0.5; y -= 0.25; points.push(x, y); } return { points, // We use center of the rectangle (and not the centroid of the polygon) for the rectangle shapes center: { x: width / 2, y: ((0.75 * Math.floor(height)) + (0.5 * (height % 1)) + 0.25) / 2 }, snapping: { behavior: {mode: even ? M.TOP_LEFT_CORNER : M.BOTTOM_RIGHT_VERTEX, resolution: 1}, anchor: {x: 0.0, y: 0.0} } }; } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ updatePosition() { const msg = "Token#updatePosition has been deprecated without replacement as it is no longer required."; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); } /** * @deprecated since 11 * @ignore */ refreshHUD({bars=true, border=true, effects=true, elevation=true, nameplate=true}={}) { const msg = "Token#refreshHUD is deprecated in favor of token.renderFlags.set()"; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); this.renderFlags.set({ refreshBars: bars, refreshBorder: border, refreshElevation: elevation, refreshNameplate: nameplate, redrawEffects: effects }); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ updateSource({deleted=false}={}) { const msg = "Token#updateSource has been deprecated in favor of Token#initializeSources"; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); this.initializeSources({deleted}); } /** * @deprecated since v12 * @ignore */ getCenter(x, y) { const msg = "Token#getCenter(x, y) has been deprecated in favor of Token#getCenterPoint(Point)."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.getCenterPoint(x !== undefined ? {x, y} : this.document); } /** * @deprecated since v12 * @ignore */ get owner() { const msg = "Token#owner has been deprecated. Use Token#isOwner instead."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return this.isOwner; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ async toggleCombat(combat) { foundry.utils.logCompatibilityWarning("Token#toggleCombat is deprecated in favor of TokenDocument#toggleCombatant," + " TokenDocument.implementation.createCombatants, and TokenDocument.implementation.deleteCombatants", {since: 12, until: 14}); const tokens = canvas.tokens.controlled.map(t => t.document); if ( !this.controlled ) tokens.push(this.document); if ( this.inCombat ) await TokenDocument.implementation.deleteCombatants(tokens); else await TokenDocument.implementation.createCombatants(tokens); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ async toggleEffect(effect, {active, overlay=false}={}) { foundry.utils.logCompatibilityWarning("Token#toggleEffect is deprecated in favor of Actor#toggleStatusEffect", {since: 12, until: 14}); if ( !this.actor || !effect.id ) return false; return this.actor.toggleStatusEffect(effect.id, {active, overlay}); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ async toggleVisibility() { foundry.utils.logCompatibilityWarning("Token#toggleVisibility is deprecated without replacement in favor of" + " updating the hidden field of the TokenDocument directly.", {since: 12, until: 14}); let isHidden = this.document.hidden; const tokens = this.controlled ? canvas.tokens.controlled : [this]; const updates = tokens.map(t => { return {_id: t.id, hidden: !isHidden};}); return canvas.scene.updateEmbeddedDocuments("Token", updates); } /* -------------------------------------------- */ /** * @deprecated since v12 Stable 4 * @ignore */ _recoverFromPreview() { foundry.utils.logCompatibilityWarning("Token#_recoverFromPreview is deprecated without replacement in favor of" + " recovering from preview directly into TokenConfig#_resetPreview.", {since: 12, until: 14}); this.renderable = true; this.initializeSources(); this.control(); } } /** * A "secret" global to help debug attributes of the currently controlled Token. * This is only for debugging, and may be removed in the future, so it's not safe to use. * @type {Token} * @ignore */ let _token = null;