/** * A Tile is an implementation of PlaceableObject which represents a static piece of artwork or prop within the Scene. * Tiles are drawn inside the {@link TilesLayer} container. * @category - Canvas * * @see {@link TileDocument} * @see {@link TilesLayer} */ class Tile extends PlaceableObject { /* -------------------------------------------- */ /* Attributes */ /* -------------------------------------------- */ /** @inheritdoc */ static embeddedName = "Tile"; /** @override */ static RENDER_FLAGS = { redraw: {propagate: ["refresh"]}, refresh: {propagate: ["refreshState", "refreshTransform", "refreshMesh", "refreshElevation", "refreshVideo"], alias: true}, refreshState: {propagate: ["refreshPerception"]}, refreshTransform: {propagate: ["refreshPosition", "refreshRotation", "refreshSize"], alias: true}, refreshPosition: {propagate: ["refreshPerception"]}, refreshRotation: {propagate: ["refreshPerception", "refreshFrame"]}, refreshSize: {propagate: ["refreshPosition", "refreshFrame"]}, refreshMesh: {}, refreshFrame: {}, refreshElevation: {propagate: ["refreshPerception"]}, refreshPerception: {}, refreshVideo: {}, /** @deprecated since v12 */ refreshShape: { propagate: ["refreshTransform", "refreshMesh", "refreshElevation"], deprecated: {since: 12, until: 14, alias: true} } }; /** * The Tile border frame * @type {PIXI.Container} */ frame; /** * The primary tile image texture * @type {PIXI.Texture} */ texture; /** * A Tile background which is displayed if no valid image texture is present * @type {PIXI.Graphics} */ bg; /** * A reference to the SpriteMesh which displays this Tile in the PrimaryCanvasGroup. * @type {PrimarySpriteMesh} */ mesh; /** * A flag to capture whether this Tile has an unlinked video texture * @type {boolean} */ #unlinkedVideo = false; /** * Video options passed by the HUD * @type {object} */ #hudVideoOptions = { playVideo: undefined, offset: undefined }; /* -------------------------------------------- */ /** * Get the native aspect ratio of the base texture for the Tile sprite * @type {number} */ get aspectRatio() { if ( !this.texture ) return 1; let tex = this.texture.baseTexture; return (tex.width / tex.height); } /* -------------------------------------------- */ /** @override */ get bounds() { let {x, y, width, height, texture, rotation} = this.document; // Adjust top left coordinate and dimensions according to scale if ( texture.scaleX !== 1 ) { const w0 = width; width *= Math.abs(texture.scaleX); x += (w0 - width) / 2; } if ( texture.scaleY !== 1 ) { const h0 = height; height *= Math.abs(texture.scaleY); y += (h0 - height) / 2; } // If the tile is rotated, return recomputed bounds according to rotation if ( rotation !== 0 ) return PIXI.Rectangle.fromRotation(x, y, width, height, Math.toRadians(rotation)).normalize(); // Normal case return new PIXI.Rectangle(x, y, width, height).normalize(); } /* -------------------------------------------- */ /** * The HTML source element for the primary Tile texture * @type {HTMLImageElement|HTMLVideoElement} */ get sourceElement() { return this.texture?.baseTexture.resource.source; } /* -------------------------------------------- */ /** * Does this Tile depict an animated video texture? * @type {boolean} */ get isVideo() { const source = this.sourceElement; return source?.tagName === "VIDEO"; } /* -------------------------------------------- */ /** * Is this Tile currently visible on the Canvas? * @type {boolean} */ get isVisible() { return !this.document.hidden || game.user.isGM; } /* -------------------------------------------- */ /** * Is this tile occluded? * @returns {boolean} */ get occluded() { return this.mesh?.occluded ?? false; } /* -------------------------------------------- */ /** * Is the tile video playing? * @type {boolean} */ get playing() { return this.isVideo && !this.sourceElement.paused; } /* -------------------------------------------- */ /** * The effective volume at which this Tile should be playing, including the global ambient volume modifier * @type {number} */ get volume() { return this.document.video.volume * game.settings.get("core", "globalAmbientVolume"); } /* -------------------------------------------- */ /* Interactivity */ /* -------------------------------------------- */ /** @override */ _overlapsSelection(rectangle) { if ( !this.frame ) return false; const localRectangle = new PIXI.Rectangle( rectangle.x - this.document.x, rectangle.y - this.document.y, rectangle.width, rectangle.height ); return localRectangle.overlaps(this.frame.bounds); } /* -------------------------------------------- */ /* Rendering */ /* -------------------------------------------- */ /** * Create a preview tile with a background texture instead of an image * @param {object} data Initial data with which to create the preview Tile * @returns {PlaceableObject} */ static createPreview(data) { data.width = data.height = 1; data.elevation = data.elevation ?? (ui.controls.control.foreground ? canvas.scene.foregroundElevation : 0); data.sort = Math.max(canvas.tiles.getMaxSort() + 1, 0); // Create a pending TileDocument const cls = getDocumentClass("Tile"); const doc = new cls(data, {parent: canvas.scene}); // Render the preview Tile object const tile = doc.object; tile.control({releaseOthers: false}); tile.draw().then(() => { // Swap the z-order of the tile and the frame tile.removeChild(tile.frame); tile.addChild(tile.frame); }); return tile; } /* -------------------------------------------- */ /** @override */ async _draw(options={}) { // Load Tile texture let texture; if ( this._original ) texture = this._original.texture?.clone(); else if ( this.document.texture.src ) { texture = await loadTexture(this.document.texture.src, {fallback: "icons/svg/hazard.svg"}); } // Manage video playback and clone texture for unlinked video 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); if ( (this.document.getFlag("core", "randomizeVideo") !== false) && Number.isFinite(video.duration) ) { video.currentTime = Math.random() * video.duration; } } if ( !video ) this.#hudVideoOptions.playVideo = undefined; this.#hudVideoOptions.offset = undefined; this.texture = texture; // Draw the Token mesh if ( this.texture ) { this.mesh = canvas.primary.addTile(this); this.bg = undefined; } // Draw a placeholder background else { canvas.primary.removeTile(this); this.texture = this.mesh = null; this.bg = this.addChild(new PIXI.Graphics()); this.bg.eventMode = "none"; } // Control Border this.frame = this.addChild(this.#drawFrame()); // Interactivity this.cursor = this.document.isOwner ? "pointer" : null; } /* -------------------------------------------- */ /** * Create elements for the Tile border and handles * @returns {PIXI.Container} */ #drawFrame() { const frame = new PIXI.Container(); frame.eventMode = "passive"; frame.bounds = new PIXI.Rectangle(); frame.interaction = frame.addChild(new PIXI.Container()); frame.interaction.hitArea = frame.bounds; frame.interaction.eventMode = "auto"; frame.border = frame.addChild(new PIXI.Graphics()); frame.border.eventMode = "none"; frame.handle = frame.addChild(new ResizeHandle([1, 1])); frame.handle.eventMode = "static"; return frame; } /* -------------------------------------------- */ /** @inheritdoc */ clear(options) { if ( this.#unlinkedVideo ) this.texture?.baseTexture?.destroy(); // Base texture destroyed for non preview video this.#unlinkedVideo = false; super.clear(options); } /* -------------------------------------------- */ /** @inheritdoc */ _destroy(options) { canvas.primary.removeTile(this); if ( this.texture ) { if ( this.#unlinkedVideo ) this.texture?.baseTexture?.destroy(); // Base texture destroyed for non preview video this.texture = undefined; this.#unlinkedVideo = false; } } /* -------------------------------------------- */ /* Incremental Refresh */ /* -------------------------------------------- */ /** @override */ _applyRenderFlags(flags) { if ( flags.refreshState ) this._refreshState(); if ( flags.refreshPosition ) this._refreshPosition(); if ( flags.refreshRotation ) this._refreshRotation(); if ( flags.refreshSize ) this._refreshSize(); if ( flags.refreshMesh ) this._refreshMesh(); if ( flags.refreshFrame ) this._refreshFrame(); if ( flags.refreshElevation ) this._refreshElevation(); if ( flags.refreshPerception ) this.#refreshPerception(); if ( flags.refreshVideo ) this._refreshVideo(); } /* -------------------------------------------- */ /** * Refresh the position. * @protected */ _refreshPosition() { const {x, y, width, height} = this.document; if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent(); this.position.set(x, y); if ( !this.mesh ) { this.bg.position.set(width / 2, height / 2); this.bg.pivot.set(width / 2, height / 2); return; } this.mesh.position.set(x + (width / 2), y + (height / 2)); } /* -------------------------------------------- */ /** * Refresh the rotation. * @protected */ _refreshRotation() { const rotation = this.document.rotation; if ( !this.mesh ) return this.bg.angle = rotation; this.mesh.angle = rotation; } /* -------------------------------------------- */ /** * Refresh the size. * @protected */ _refreshSize() { const {width, height, texture: {fit, scaleX, scaleY}} = this.document; if ( !this.mesh ) return this.bg.clear().beginFill(0xFFFFFF, 0.5).drawRect(0, 0, width, height).endFill(); this.mesh.resize(width, height, {fit, scaleX, scaleY}); } /* -------------------------------------------- */ /** * Refresh the displayed state of the Tile. * Updated when the tile interaction state changes, when it is hidden, or when its elevation changes. * @protected */ _refreshState() { const {hidden, locked, elevation, sort} = this.document; this.visible = this.isVisible; this.alpha = this._getTargetAlpha(); if ( this.bg ) this.bg.visible = this.layer.active; const colors = CONFIG.Canvas.dispositionColors; this.frame.border.tint = this.controlled ? (locked ? colors.HOSTILE : colors.CONTROLLED) : colors.INACTIVE; this.frame.border.visible = this.controlled || this.hover || this.layer.highlightObjects; this.frame.handle.visible = this.controlled && !locked; const foreground = this.layer.active && !!ui.controls.control.foreground; const overhead = elevation >= this.document.parent.foregroundElevation; const oldEventMode = this.eventMode; this.eventMode = overhead === foreground ? "static" : "none"; if ( this.eventMode !== oldEventMode ) MouseInteractionManager.emulateMoveEvent(); const zIndex = this.zIndex = this.controlled ? 2 : this.hover ? 1 : 0; if ( !this.mesh ) return; this.mesh.visible = this.visible; this.mesh.sort = sort; this.mesh.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.TILES; this.mesh.zIndex = zIndex; this.mesh.alpha = this.alpha * (hidden ? 0.5 : 1); this.mesh.hidden = hidden; this.mesh.restrictsLight = this.document.restrictions.light; this.mesh.restrictsWeather = this.document.restrictions.weather; } /* -------------------------------------------- */ /** * Refresh the appearance of the tile. * @protected */ _refreshMesh() { if ( !this.mesh ) return; const {width, height, alpha, occlusion, texture} = this.document; const {anchorX, anchorY, fit, scaleX, scaleY, tint, alphaThreshold} = texture; this.mesh.anchor.set(anchorX, anchorY); this.mesh.resize(width, height, {fit, scaleX, scaleY}); this.mesh.unoccludedAlpha = alpha; this.mesh.occludedAlpha = occlusion.alpha; this.mesh.occlusionMode = occlusion.mode; this.mesh.hoverFade = this.mesh.isOccludable; this.mesh.tint = tint; this.mesh.textureAlphaThreshold = alphaThreshold; } /* -------------------------------------------- */ /** * Refresh the elevation. * @protected */ _refreshElevation() { if ( !this.mesh ) return; this.mesh.elevation = this.document.elevation; } /* -------------------------------------------- */ /** * Refresh the tiles. */ #refreshPerception() { if ( !this.mesh ) return; canvas.perception.update({refreshOcclusionStates: true}); } /* -------------------------------------------- */ /** * Refresh the border frame that encloses the Tile. * @protected */ _refreshFrame() { const thickness = CONFIG.Canvas.objectBorderThickness; // Update the frame bounds const {width, height, rotation} = this.document; const bounds = this.frame.bounds; bounds.x = 0; bounds.y = 0; bounds.width = width; bounds.height = height; bounds.rotate(Math.toRadians(rotation)); const minSize = thickness * 0.25; if ( bounds.width < minSize ) { bounds.x -= ((minSize - bounds.width) / 2); bounds.width = minSize; } if ( bounds.height < minSize ) { bounds.y -= ((minSize - bounds.height) / 2); bounds.height = minSize; } MouseInteractionManager.emulateMoveEvent(); // Draw the border const border = this.frame.border; border.clear(); border.lineStyle({width: thickness, color: 0x000000, join: PIXI.LINE_JOIN.ROUND, alignment: 0.75}) .drawShape(bounds); border.lineStyle({width: thickness / 2, color: 0xFFFFFF, join: PIXI.LINE_JOIN.ROUND, alignment: 1}) .drawShape(bounds); // Draw the handle this.frame.handle.refresh(bounds); } /* -------------------------------------------- */ /** * Refresh changes to the video playback state. * @protected */ _refreshVideo() { if ( !this.texture || !this.#unlinkedVideo ) return; const video = game.video.getVideoSource(this.texture); if ( !video ) return; const playOptions = {...this.document.video, volume: this.volume}; playOptions.playing = (this.#hudVideoOptions.playVideo ?? playOptions.autoplay); playOptions.offset = this.#hudVideoOptions.offset; this.#hudVideoOptions.offset = undefined; game.video.play(video, playOptions); // Refresh HUD if necessary if ( this.hasActiveHUD ) this.layer.hud.render(); } /* -------------------------------------------- */ /* Document Event Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { super._onUpdate(changed, options, userId); const restrictionsChanged = ("restrictions" in changed) && !foundry.utils.isEmpty(changed.restrictions); // Refresh the Drawing this.renderFlags.set({ redraw: ("texture" in changed) && ("src" in changed.texture), refreshState: ("sort" in changed) || ("hidden" in changed) || ("locked" in changed) || restrictionsChanged, refreshPosition: ("x" in changed) || ("y" in changed), refreshRotation: "rotation" in changed, refreshSize: ("width" in changed) || ("height" in changed), refreshMesh: ("alpha" in changed) || ("occlusion" in changed) || ("texture" in changed), refreshElevation: "elevation" in changed, refreshPerception: ("occlusion" in changed) && ("mode" in changed.occlusion), refreshVideo: ("video" in changed) || ("playVideo" in options) || ("offset" in options) }); // Set the video options if ( "playVideo" in options ) this.#hudVideoOptions.playVideo = options.playVideo; if ( "offset" in options ) this.#hudVideoOptions.offset = options.offset; } /* -------------------------------------------- */ /* Interactivity */ /* -------------------------------------------- */ /** @inheritdoc */ activateListeners() { super.activateListeners(); this.frame.handle.off("pointerover").off("pointerout") .on("pointerover", this._onHandleHoverIn.bind(this)) .on("pointerout", this._onHandleHoverOut.bind(this)); } /* -------------------------------------------- */ /** @inheritdoc */ _onClickLeft(event) { if ( event.target === this.frame.handle ) { event.interactionData.dragHandle = true; event.stopPropagation(); return; } return super._onClickLeft(event); } /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftStart(event) { if ( event.interactionData.dragHandle ) return this._onHandleDragStart(event); return super._onDragLeftStart(event); } /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftMove(event) { if ( event.interactionData.dragHandle ) return this._onHandleDragMove(event); super._onDragLeftMove(event); } /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftDrop(event) { if ( event.interactionData.dragHandle ) return this._onHandleDragDrop(event); return super._onDragLeftDrop(event); } /* -------------------------------------------- */ /* Resize Handling */ /* -------------------------------------------- */ /** @inheritdoc */ _onDragLeftCancel(event) { if ( event.interactionData.dragHandle ) return this._onHandleDragCancel(event); return super._onDragLeftCancel(event); } /* -------------------------------------------- */ /** * Handle mouse-over event on a control handle * @param {PIXI.FederatedEvent} event The mouseover event * @protected */ _onHandleHoverIn(event) { const handle = event.target; handle?.scale.set(1.5, 1.5); } /* -------------------------------------------- */ /** * Handle mouse-out event on a control handle * @param {PIXI.FederatedEvent} event The mouseout event * @protected */ _onHandleHoverOut(event) { const handle = event.target; handle?.scale.set(1.0, 1.0); } /* -------------------------------------------- */ /** * Handle the beginning of a drag event on a resize handle. * @param {PIXI.FederatedEvent} event The mousedown event * @protected */ _onHandleDragStart(event) { const handle = this.frame.handle; const aw = this.document.width; const ah = this.document.height; const x0 = this.document.x + (handle.offset[0] * aw); const y0 = this.document.y + (handle.offset[1] * ah); event.interactionData.origin = {x: x0, y: y0, width: aw, height: ah}; } /* -------------------------------------------- */ /** * Handle mousemove while dragging a tile scale handler * @param {PIXI.FederatedEvent} event The mousemove event * @protected */ _onHandleDragMove(event) { canvas._onDragCanvasPan(event); const interaction = event.interactionData; if ( !event.shiftKey ) interaction.destination = this.layer.getSnappedPoint(interaction.destination); const d = this.#getResizedDimensions(event); this.document.x = d.x; this.document.y = d.y; this.document.width = d.width; this.document.height = d.height; this.document.rotation = 0; // Mirror horizontally or vertically this.document.texture.scaleX = d.sx; this.document.texture.scaleY = d.sy; this.renderFlags.set({refreshTransform: true}); } /* -------------------------------------------- */ /** * Handle mouseup after dragging a tile scale handler * @param {PIXI.FederatedEvent} event The mouseup event * @protected */ _onHandleDragDrop(event) { const interaction = event.interactionData; interaction.resetDocument = false; if ( !event.shiftKey ) interaction.destination = this.layer.getSnappedPoint(interaction.destination); const d = this.#getResizedDimensions(event); this.document.update({ x: d.x, y: d.y, width: d.width, height: d.height, "texture.scaleX": d.sx, "texture.scaleY": d.sy }).then(() => this.renderFlags.set({refreshTransform: true})); } /* -------------------------------------------- */ /** * Get resized Tile dimensions * @param {PIXI.FederatedEvent} event * @returns {{x: number, y: number, width: number, height: number, sx: number, sy: number}} */ #getResizedDimensions(event) { const o = this.document._source; const {origin, destination} = event.interactionData; // Identify the new width and height as positive dimensions const dx = destination.x - origin.x; const dy = destination.y - origin.y; let w = Math.abs(o.width) + dx; let h = Math.abs(o.height) + dy; // Constrain the aspect ratio using the ALT key if ( event.altKey && this.texture?.valid ) { const ar = this.texture.width / this.texture.height; if ( Math.abs(w) > Math.abs(h) ) h = w / ar; else w = h * ar; } const {x, y, width, height} = new PIXI.Rectangle(o.x, o.y, w, h).normalize(); // Comparing destination coord and source coord to apply mirroring and append to nr const sx = (Math.sign(destination.x - o.x) || 1) * o.texture.scaleX; const sy = (Math.sign(destination.y - o.y) || 1) * o.texture.scaleY; return {x, y, width, height, sx, sy}; } /* -------------------------------------------- */ /** * Handle cancellation of a drag event for one of the resizing handles * @param {PIXI.FederatedEvent} event The mouseup event * @protected */ _onHandleDragCancel(event) { if ( event.interactionData.resetDocument !== false ) { this.document.reset(); this.renderFlags.set({refreshTransform: true}); } } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get isRoof() { const msg = "Tile#isRoof has been deprecated without replacement."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return this.document.roof; } /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ testOcclusion(...args) { const msg = "Tile#testOcclusion has been deprecated in favor of PrimaryCanvasObject#testOcclusion"; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return this.mesh?.testOcclusion(...args) ?? false; } /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ containsPixel(...args) { const msg = "Tile#containsPixel has been deprecated in favor of PrimaryCanvasObject#containsPixel" foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return this.mesh?.containsPixel(...args) ?? false; } /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ getPixelAlpha(...args) { const msg = "Tile#getPixelAlpha has been deprecated in favor of PrimaryCanvasObject#getPixelAlpha" foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return this.mesh?.getPixelAlpha(...args) ?? null; } /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ _getAlphaBounds() { const msg = "Tile#_getAlphaBounds has been deprecated"; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return this.mesh?._getAlphaBounds(); } }