Files

774 lines
24 KiB
JavaScript
Raw Permalink Normal View History

2025-01-04 00:34:03 +01:00
/**
* 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();
}
}