Files

2237 lines
71 KiB
JavaScript
Raw Permalink Normal View History

2025-01-04 00:34:03 +01:00
/**
* The virtual tabletop environment is implemented using a WebGL powered HTML 5 canvas using the powerful PIXI.js
* library. The canvas is comprised by an ordered sequence of layers which define rendering groups and collections of
* objects that are drawn on the canvas itself.
*
* ### Hook Events
* {@link hookEvents.canvasConfig}
* {@link hookEvents.canvasInit}
* {@link hookEvents.canvasReady}
* {@link hookEvents.canvasPan}
* {@link hookEvents.canvasTearDown}
*
* @category - Canvas
*
* @example Canvas State
* ```js
* canvas.ready; // Is the canvas ready for use?
* canvas.scene; // The currently viewed Scene document.
* canvas.dimensions; // The dimensions of the current Scene.
* ```
* @example Canvas Methods
* ```js
* canvas.draw(); // Completely re-draw the game canvas (this is usually unnecessary).
* canvas.pan(x, y, zoom); // Pan the canvas to new coordinates and scale.
* canvas.recenter(); // Re-center the canvas on the currently controlled Token.
* ```
*/
class Canvas {
constructor() {
Object.defineProperty(this, "edges", {value: new foundry.canvas.edges.CanvasEdges()});
Object.defineProperty(this, "fog", {value: new CONFIG.Canvas.fogManager()});
Object.defineProperty(this, "perception", {value: new PerceptionManager()});
}
/**
* A set of blur filter instances which are modified by the zoom level and the "soft shadows" setting
* @type {Set<PIXI.filters>}
*/
blurFilters = new Set();
/**
* A reference to the MouseInteractionManager that is currently controlling pointer-based interaction, or null.
* @type {MouseInteractionManager|null}
*/
currentMouseManager = null;
/**
* Configure options passed to the texture loaded for the Scene.
* This object can be configured during the canvasInit hook before textures have been loaded.
* @type {{expireCache: boolean, additionalSources: string[]}}
*/
loadTexturesOptions;
/**
* Configure options used by the visibility framework for special effects
* This object can be configured during the canvasInit hook before visibility is initialized.
* @type {{persistentVision: boolean}}
*/
visibilityOptions;
/**
* Configure options passed to initialize blur for the Scene and override normal behavior.
* This object can be configured during the canvasInit hook before blur is initialized.
* @type {{enabled: boolean, blurClass: Class, strength: number, passes: number, kernels: number}}
*/
blurOptions;
/**
* Configure the Textures to apply to the Scene.
* Textures registered here will be automatically loaded as part of the TextureLoader.loadSceneTextures workflow.
* Textures which need to be loaded should be configured during the "canvasInit" hook.
* @type {{[background]: string, [foreground]: string, [fogOverlay]: string}}
*/
sceneTextures = {};
/**
* Record framerate performance data.
* @type {{average: number, values: number[], element: HTMLElement, render: number}}
*/
fps = {
average: 0,
values: [],
render: 0,
element: document.getElementById("fps")
};
/**
* The singleton interaction manager instance which handles mouse interaction on the Canvas.
* @type {MouseInteractionManager}
*/
mouseInteractionManager;
/**
* @typedef {Object} CanvasPerformanceSettings
* @property {number} mode The performance mode in CONST.CANVAS_PERFORMANCE_MODES
* @property {string} mipmap Whether to use mipmaps, "ON" or "OFF"
* @property {boolean} msaa Whether to apply MSAA at the overall canvas level
* @property {boolean} smaa Whether to apply SMAA at the overall canvas level
* @property {number} fps Maximum framerate which should be the render target
* @property {boolean} tokenAnimation Whether to display token movement animation
* @property {boolean} lightAnimation Whether to display light source animation
* @property {boolean} lightSoftEdges Whether to render soft edges for light sources
*/
/**
* Configured performance settings which affect the behavior of the Canvas and its renderer.
* @type {CanvasPerformanceSettings}
*/
performance;
/**
* @typedef {Object} CanvasSupportedComponents
* @property {boolean} webGL2 Is WebGL2 supported?
* @property {boolean} readPixelsRED Is reading pixels in RED format supported?
* @property {boolean} offscreenCanvas Is the OffscreenCanvas supported?
*/
/**
* A list of supported webGL capabilities and limitations.
* @type {CanvasSupportedComponents}
*/
supported;
/**
* Is the photosensitive mode enabled?
* @type {boolean}
*/
photosensitiveMode;
/**
* The renderer screen dimensions.
* @type {number[]}
*/
screenDimensions = [0, 0];
/**
* A flag to indicate whether a new Scene is currently being drawn.
* @type {boolean}
*/
loading = false;
/**
* A promise that resolves when the canvas is first initialized and ready.
* @type {Promise<void>|null}
*/
initializing = null;
/* -------------------------------------------- */
/**
* A throttled function that handles mouse moves.
* @type {function()}
*/
#throttleOnMouseMove = foundry.utils.throttle(this.#onMouseMove.bind(this), 100);
/**
* An internal reference to a Promise in-progress to draw the canvas.
* @type {Promise<Canvas>}
*/
#drawing = Promise.resolve(this);
/* -------------------------------------------- */
/* Canvas Groups and Layers */
/* -------------------------------------------- */
/**
* The singleton PIXI.Application instance rendered on the Canvas.
* @type {PIXI.Application}
*/
app;
/**
* The primary stage container of the PIXI.Application.
* @type {PIXI.Container}
*/
stage;
/**
* The rendered canvas group which render the environment canvas group and the interface canvas group.
* @see environment
* @see interface
* @type {RenderedCanvasGroup}
*/
rendered;
/**
* A singleton CanvasEdges instance.
* @type {foundry.canvas.edges.CanvasEdges}
*/
edges;
/**
* The singleton FogManager instance.
* @type {FogManager}
*/
fog;
/**
* A perception manager interface for batching lighting, sight, and sound updates.
* @type {PerceptionManager}
*/
perception;
/**
* The environment canvas group which render the primary canvas group and the effects canvas group.
* @see primary
* @see effects
* @type {EnvironmentCanvasGroup}
*/
environment;
/**
* The primary Canvas group which generally contains tangible physical objects which exist within the Scene.
* This group is a {@link CachedContainer} which is rendered to the Scene as a {@link SpriteMesh}.
* This allows the rendered result of the Primary Canvas Group to be affected by a {@link BaseSamplerShader}.
* @type {PrimaryCanvasGroup}
*/
primary;
/**
* The effects Canvas group which modifies the result of the {@link PrimaryCanvasGroup} by adding special effects.
* This includes lighting, vision, fog of war and related animations.
* @type {EffectsCanvasGroup}
*/
effects;
/**
* The visibility Canvas group which handles the fog of war overlay by consolidating multiple render textures,
* and applying a filter with special effects and blur.
* @type {CanvasVisibility}
*/
visibility;
/**
* The interface Canvas group which is rendered above other groups and contains all interactive elements.
* The various {@link InteractionLayer} instances of the interface group provide different control sets for
* interacting with different types of {@link Document}s which can be represented on the Canvas.
* @type {InterfaceCanvasGroup}
*/
interface;
/**
* The overlay Canvas group which is rendered above other groups and contains elements not bound to stage transform.
* @type {OverlayCanvasGroup}
*/
overlay;
/**
* The singleton HeadsUpDisplay container which overlays HTML rendering on top of this Canvas.
* @type {HeadsUpDisplay}
*/
hud;
/**
* Position of the mouse on stage.
* @type {PIXI.Point}
*/
mousePosition = new PIXI.Point();
/**
* The DragDrop instance which handles interactivity resulting from DragTransfer events.
* @type {DragDrop}
* @private
*/
#dragDrop;
/**
* An object of data which caches data which should be persisted across re-draws of the game canvas.
* @type {{scene: string, layer: string, controlledTokens: string[], targetedTokens: string[]}}
* @private
*/
#reload = {};
/**
* Track the last automatic pan time to throttle
* @type {number}
* @private
*/
_panTime = 0;
/* -------------------------------------------- */
/**
* Force snapping to grid vertices?
* @type {boolean}
*/
forceSnapVertices = false;
/* -------------------------------------------- */
/* Properties and Attributes
/* -------------------------------------------- */
/**
* A flag for whether the game Canvas is fully initialized and ready for additional content to be drawn.
* @type {boolean}
*/
get initialized() {
return this.#initialized;
}
/** @ignore */
#initialized = false;
/* -------------------------------------------- */
/**
* A reference to the currently displayed Scene document, or null if the Canvas is currently blank.
* @type {Scene|null}
*/
get scene() {
return this.#scene;
}
/** @ignore */
#scene = null;
/* -------------------------------------------- */
/**
* A SceneManager instance which adds behaviors to this Scene, or null if there is no manager.
* @type {SceneManager|null}
*/
get manager() {
return this.#manager;
}
#manager = null;
/* -------------------------------------------- */
/**
* @typedef {object} _CanvasDimensions
* @property {PIXI.Rectangle} rect The canvas rectangle.
* @property {PIXI.Rectangle} sceneRect The scene rectangle.
*/
/**
* @typedef {SceneDimensions & _CanvasDimensions} CanvasDimensions
*/
/**
* The current pixel dimensions of the displayed Scene, or null if the Canvas is blank.
* @type {Readonly<CanvasDimensions>|null}
*/
get dimensions() {
return this.#dimensions;
}
#dimensions = null;
/* -------------------------------------------- */
/**
* A reference to the grid of the currently displayed Scene document, or null if the Canvas is currently blank.
* @type {foundry.grid.BaseGrid|null}
*/
get grid() {
return this.scene?.grid ?? null;
}
/* -------------------------------------------- */
/**
* A flag for whether the game Canvas is ready to be used. False if the canvas is not yet drawn, true otherwise.
* @type {boolean}
*/
get ready() {
return this.#ready;
}
/** @ignore */
#ready = false;
/* -------------------------------------------- */
/**
* The colors bound to this scene and handled by the color manager.
* @type {Color}
*/
get colors() {
return this.environment.colors;
}
/* -------------------------------------------- */
/**
* Shortcut to get the masks container from HiddenCanvasGroup.
* @type {PIXI.Container}
*/
get masks() {
return this.hidden.masks;
}
/* -------------------------------------------- */
/**
* The id of the currently displayed Scene.
* @type {string|null}
*/
get id() {
return this.#scene?.id || null;
}
/* -------------------------------------------- */
/**
* A mapping of named CanvasLayer classes which defines the layers which comprise the Scene.
* @type {Record<string, CanvasLayer>}
*/
static get layers() {
return CONFIG.Canvas.layers;
}
/* -------------------------------------------- */
/**
* An Array of all CanvasLayer instances which are active on the Canvas board
* @type {CanvasLayer[]}
*/
get layers() {
const layers = [];
for ( const [k, cfg] of Object.entries(CONFIG.Canvas.layers) ) {
const l = this[cfg.group]?.[k] ?? this[k];
if ( l instanceof CanvasLayer ) layers.push(l);
}
return layers;
}
/* -------------------------------------------- */
/**
* Return a reference to the active Canvas Layer
* @type {CanvasLayer}
*/
get activeLayer() {
for ( const layer of this.layers ) {
if ( layer.active ) return layer;
}
return null;
}
/* -------------------------------------------- */
/**
* The currently displayed darkness level, which may override the saved Scene value.
* @type {number}
*/
get darknessLevel() {
return this.environment.darknessLevel;
}
/* -------------------------------------------- */
/* Initialization */
/* -------------------------------------------- */
/**
* Initialize the Canvas by creating the HTML element and PIXI application.
* This step should only ever be performed once per client session.
* Subsequent requests to reset the canvas should go through Canvas#draw
*/
initialize() {
if ( this.#initialized ) throw new Error("The Canvas is already initialized and cannot be re-initialized");
// If the game canvas is disabled by "no canvas" mode, we don't need to initialize anything
if ( game.settings.get("core", "noCanvas") ) return;
// Verify that WebGL is available
Canvas.#configureWebGL();
// Create the HTML Canvas element
const canvas = Canvas.#createHTMLCanvas();
// Configure canvas settings
const config = Canvas.#configureCanvasSettings();
// Create the PIXI Application
this.#createApplication(canvas, config);
// Configure the desired performance mode
this._configurePerformanceMode();
// Display any performance warnings which suggest that the created Application will not function well
game.issues._detectWebGLIssues();
// Activate drop handling
this.#dragDrop = new DragDrop({ callbacks: { drop: this._onDrop.bind(this) } }).bind(canvas);
// Create heads up display
Object.defineProperty(this, "hud", {value: new HeadsUpDisplay(), writable: false});
// Cache photosensitive mode
Object.defineProperty(this, "photosensitiveMode", {
value: game.settings.get("core", "photosensitiveMode"),
writable: false
});
// Create groups
this.#createGroups("stage", this.stage);
// Update state flags
this.#scene = null;
this.#manager = null;
this.#initialized = true;
this.#ready = false;
}
/* -------------------------------------------- */
/**
* Configure the usage of WebGL for the PIXI.Application that will be created.
* @throws an Error if WebGL is not supported by this browser environment.
*/
static #configureWebGL() {
if ( !PIXI.utils.isWebGLSupported() ) {
const err = new Error(game.i18n.localize("ERROR.NoWebGL"));
ui.notifications.error(err.message, {permanent: true});
throw err;
}
PIXI.settings.PREFER_ENV = PIXI.ENV.WEBGL2;
}
/* -------------------------------------------- */
/**
* Create the Canvas element which will be the render target for the PIXI.Application instance.
* Replace the template element which serves as a placeholder in the initially served HTML response.
* @returns {HTMLCanvasElement}
*/
static #createHTMLCanvas() {
const board = document.getElementById("board");
const canvas = document.createElement("canvas");
canvas.id = "board";
canvas.style.display = "none";
board.replaceWith(canvas);
return canvas;
}
/* -------------------------------------------- */
/**
* Configure the settings used to initialize the PIXI.Application instance.
* @returns {object} Options passed to the PIXI.Application constructor.
*/
static #configureCanvasSettings() {
const config = {
width: window.innerWidth,
height: window.innerHeight,
transparent: false,
resolution: game.settings.get("core", "pixelRatioResolutionScaling") ? window.devicePixelRatio : 1,
autoDensity: true,
antialias: false, // Not needed because we use SmoothGraphics
powerPreference: "high-performance" // Prefer high performance GPU for devices with dual graphics cards
};
Hooks.callAll("canvasConfig", config);
return config;
}
/* -------------------------------------------- */
/**
* Initialize custom pixi plugins.
*/
#initializePlugins() {
BaseSamplerShader.registerPlugin({force: true});
OccludableSamplerShader.registerPlugin();
DepthSamplerShader.registerPlugin();
// Configure TokenRing
CONFIG.Token.ring.ringClass.initialize();
}
/* -------------------------------------------- */
/**
* Create the PIXI.Application and update references to the created app and stage.
* @param {HTMLCanvasElement} canvas The target canvas view element
* @param {object} config Desired PIXI.Application configuration options
*/
#createApplication(canvas, config) {
this.#initializePlugins();
// Create the Application instance
const app = new PIXI.Application({view: canvas, ...config});
Object.defineProperty(this, "app", {value: app, writable: false});
// Reference the Stage
Object.defineProperty(this, "stage", {value: this.app.stage, writable: false});
// Map all the custom blend modes
this.#mapBlendModes();
// Attach specific behaviors to the PIXI runners
this.#attachToRunners();
// Test the support of some GPU features
const supported = this.#testSupport(app.renderer);
Object.defineProperty(this, "supported", {
value: Object.freeze(supported),
writable: false,
enumerable: true
});
// Additional PIXI configuration : Adding the FramebufferSnapshot to the canvas
const snapshot = new FramebufferSnapshot();
Object.defineProperty(this, "snapshot", {value: snapshot, writable: false});
}
/* -------------------------------------------- */
/**
* Attach specific behaviors to the PIXI runners.
* - contextChange => Remap all the blend modes
*/
#attachToRunners() {
const contextChange = {
contextChange: () => {
console.debug(`${vtt} | Recovering from context loss.`);
this.#mapBlendModes();
this.hidden.invalidateMasks();
this.effects.illumination.invalidateDarknessLevelContainer(true);
}
};
this.app.renderer.runners.contextChange.add(contextChange);
}
/* -------------------------------------------- */
/**
* Map custom blend modes and premultiplied blend modes.
*/
#mapBlendModes() {
for ( let [k, v] of Object.entries(BLEND_MODES) ) {
const pos = this.app.renderer.state.blendModes.push(v) - 1;
PIXI.BLEND_MODES[k] = pos;
PIXI.BLEND_MODES[pos] = k;
}
// Fix a PIXI bug with custom blend modes
this.#mapPremultipliedBlendModes();
}
/* -------------------------------------------- */
/**
* Remap premultiplied blend modes/non premultiplied blend modes to fix PIXI bug with custom BM.
*/
#mapPremultipliedBlendModes() {
const pm = [];
const npm = [];
// Create the reference mapping
for ( let i = 0; i < canvas.app.renderer.state.blendModes.length; i++ ) {
pm[i] = i;
npm[i] = i;
}
// Assign exceptions
pm[PIXI.BLEND_MODES.NORMAL_NPM] = PIXI.BLEND_MODES.NORMAL;
pm[PIXI.BLEND_MODES.ADD_NPM] = PIXI.BLEND_MODES.ADD;
pm[PIXI.BLEND_MODES.SCREEN_NPM] = PIXI.BLEND_MODES.SCREEN;
npm[PIXI.BLEND_MODES.NORMAL] = PIXI.BLEND_MODES.NORMAL_NPM;
npm[PIXI.BLEND_MODES.ADD] = PIXI.BLEND_MODES.ADD_NPM;
npm[PIXI.BLEND_MODES.SCREEN] = PIXI.BLEND_MODES.SCREEN_NPM;
// Keep the reference to PIXI.utils.premultiplyBlendMode!
// And recreate the blend modes mapping with the same object.
PIXI.utils.premultiplyBlendMode.splice(0, PIXI.utils.premultiplyBlendMode.length);
PIXI.utils.premultiplyBlendMode.push(npm);
PIXI.utils.premultiplyBlendMode.push(pm);
}
/* -------------------------------------------- */
/**
* Initialize the group containers of the game Canvas.
* @param {string} parentName
* @param {PIXI.DisplayObject} parent
*/
#createGroups(parentName, parent) {
for ( const [name, config] of Object.entries(CONFIG.Canvas.groups) ) {
if ( config.parent !== parentName ) continue;
const group = new config.groupClass();
Object.defineProperty(this, name, {value: group, writable: false}); // Reference on the Canvas
Object.defineProperty(parent, name, {value: group, writable: false}); // Reference on the parent
parent.addChild(group);
this.#createGroups(name, group); // Recursive
}
}
/* -------------------------------------------- */
/**
* TODO: Add a quality parameter
* Compute the blur parameters according to grid size and performance mode.
* @param options Blur options.
* @private
*/
_initializeBlur(options={}) {
// Discard shared filters
this.blurFilters.clear();
// Compute base values from grid size
const gridSize = this.scene.grid.size;
const blurStrength = gridSize / 25;
const blurFactor = gridSize / 100;
// Lower stress for MEDIUM performance mode
const level =
Math.max(0, this.performance.mode - (this.performance.mode < CONST.CANVAS_PERFORMANCE_MODES.HIGH ? 1 : 0));
const maxKernels = Math.max(5 + (level * 2), 5);
const maxPass = 2 + (level * 2);
// Compute blur parameters
this.blur = new Proxy(Object.seal({
enabled: options.enabled ?? this.performance.mode > CONST.CANVAS_PERFORMANCE_MODES.MED,
blurClass: options.blurClass ?? AlphaBlurFilter,
blurPassClass: options.blurPassClass ?? AlphaBlurFilterPass,
strength: options.strength ?? blurStrength,
passes: options.passes ?? Math.clamp(level + Math.floor(blurFactor), 2, maxPass),
kernels: options.kernels
?? Math.clamp((2 * Math.ceil((1 + (2 * level) + Math.floor(blurFactor)) / 2)) - 1, 5, maxKernels)
}), {
set(obj, prop, value) {
if ( prop !== "strength" ) throw new Error(`canvas.blur.${prop} is immutable`);
const v = Reflect.set(obj, prop, value);
canvas.updateBlur();
return v;
}
});
// Immediately update blur
this.updateBlur();
}
/* -------------------------------------------- */
/**
* Configure performance settings for hte canvas application based on the selected performance mode.
* @returns {CanvasPerformanceSettings}
* @internal
*/
_configurePerformanceMode() {
const modes = CONST.CANVAS_PERFORMANCE_MODES;
// Get client settings
let mode = game.settings.get("core", "performanceMode");
const fps = game.settings.get("core", "maxFPS");
const mip = game.settings.get("core", "mipmap");
// Deprecation shim for textures
const gl = this.app.renderer.context.gl;
const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
// Configure default performance mode if one is not set
if ( mode === null ) {
if ( maxTextureSize <= Math.pow(2, 12) ) mode = CONST.CANVAS_PERFORMANCE_MODES.LOW;
else if ( maxTextureSize <= Math.pow(2, 13) ) mode = CONST.CANVAS_PERFORMANCE_MODES.MED;
else mode = CONST.CANVAS_PERFORMANCE_MODES.HIGH;
game.settings.storage.get("client").setItem("core.performanceMode", String(mode));
}
// Construct performance settings object
const settings = {
mode: mode,
mipmap: mip ? "ON" : "OFF",
msaa: false,
smaa: false,
fps: Math.clamp(fps, 0, 60),
tokenAnimation: true,
lightAnimation: true,
lightSoftEdges: false
};
// Low settings
if ( mode >= modes.LOW ) {
settings.tokenAnimation = false;
settings.lightAnimation = false;
}
// Medium settings
if ( mode >= modes.MED ) {
settings.lightSoftEdges = true;
settings.smaa = true;
}
// Max settings
if ( mode === modes.MAX ) {
if ( settings.fps === 60 ) settings.fps = 0;
}
// Configure performance settings
PIXI.BaseTexture.defaultOptions.mipmap = PIXI.MIPMAP_MODES[settings.mipmap];
// Use the resolution and multisample of the current render target for filters by default
PIXI.Filter.defaultResolution = null;
PIXI.Filter.defaultMultisample = null;
this.app.ticker.maxFPS = PIXI.Ticker.shared.maxFPS = PIXI.Ticker.system.maxFPS = settings.fps;
return this.performance = settings;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* Draw the game canvas.
* @param {Scene} [scene] A specific Scene document to render on the Canvas
* @returns {Promise<Canvas>} A Promise which resolves once the Canvas is fully drawn
*/
async draw(scene) {
this.#drawing = this.#drawing.finally(this.#draw.bind(this, scene));
await this.#drawing;
return this;
}
/* -------------------------------------------- */
/**
* Draw the game canvas.
* This method is wrapped by a promise that enqueues multiple draw requests.
* @param {Scene} [scene] A specific Scene document to render on the Canvas
* @returns {Promise<void>}
*/
async #draw(scene) {
// If the canvas had not yet been initialized, we have done something out of order
if ( !this.#initialized ) {
throw new Error("You may not call Canvas#draw before Canvas#initialize");
}
// Identify the Scene which should be drawn
if ( scene === undefined ) scene = game.scenes.current;
if ( !((scene instanceof Scene) || (scene === null)) ) {
throw new Error("You must provide a Scene Document to draw the Canvas.");
}
// Assign status flags
const wasReady = this.#ready;
this.#ready = false;
this.stage.visible = false;
this.loading = true;
// Tear down any existing scene
if ( wasReady ) {
try {
await this.tearDown();
} catch(err) {
err.message = `Encountered an error while tearing down the previous scene: ${err.message}`;
logger.error(err);
}
}
// Record Scene changes
if ( this.#scene && (scene !== this.#scene) ) {
this.#scene._view = false;
if ( game.user.viewedScene === this.#scene.id ) game.user.viewedScene = null;
}
this.#scene = scene;
// Draw a blank canvas
if ( this.#scene === null ) return this.#drawBlank();
// Configure Scene dimensions
const {rect, sceneRect, ...sceneDimensions} = scene.getDimensions();
this.#dimensions = Object.assign(sceneDimensions, {
rect: new PIXI.Rectangle(rect.x, rect.y, rect.width, rect.height),
sceneRect: new PIXI.Rectangle(sceneRect.x, sceneRect.y, sceneRect.width, sceneRect.height)
});
canvas.app.view.style.display = "block";
document.documentElement.style.setProperty("--gridSize", `${this.dimensions.size}px`);
// Configure a SceneManager instance
this.#manager = Canvas.getSceneManager(this.#scene);
// Initialize the basis transcoder
if ( CONFIG.Canvas.transcoders.basis ) await TextureLoader.initializeBasisTranscoder();
// Call Canvas initialization hooks
this.loadTexturesOptions = {expireCache: true, additionalSources: []};
this.visibilityOptions = {persistentVision: false};
console.log(`${vtt} | Drawing game canvas for scene ${this.#scene.name}`);
await this.#callManagerEvent("_onInit");
await this.#callManagerEvent("_registerHooks");
Hooks.callAll("canvasInit", this);
// Configure attributes of the Stage
this.stage.position.set(window.innerWidth / 2, window.innerHeight / 2);
this.stage.hitArea = {contains: () => true};
this.stage.eventMode = "static";
this.stage.sortableChildren = true;
// Initialize the camera view position (although the canvas is hidden)
this.initializeCanvasPosition();
// Initialize blur parameters
this._initializeBlur(this.blurOptions);
// Load required textures
try {
await TextureLoader.loadSceneTextures(this.#scene, this.loadTexturesOptions);
} catch(err) {
Hooks.onError("Canvas#draw", err, {
msg: `Texture loading failed: ${err.message}`,
log: "error",
notify: "error"
});
this.loading = false;
return;
}
// Configure the SMAA filter
if ( this.performance.smaa ) this.stage.filters = [new foundry.canvas.SMAAFilter()];
// Configure TokenRing
CONFIG.Token.ring.ringClass.createAssetsUVs();
// Activate ticker render workflows
this.#activateTicker();
// Draw canvas groups
await this.#callManagerEvent("_onDraw");
Hooks.callAll("canvasDraw", this);
for ( const name of Object.keys(CONFIG.Canvas.groups) ) {
const group = this[name];
try {
await group.draw();
} catch(err) {
Hooks.onError("Canvas#draw", err, {
msg: `Failed drawing ${name} canvas group: ${err.message}`,
log: "error",
notify: "error"
});
this.loading = false;
return;
}
}
// Mask primary and effects layers by the overall canvas
const cr = canvas.dimensions.rect;
this.masks.canvas.clear().beginFill(0xFFFFFF, 1.0).drawRect(cr.x, cr.y, cr.width, cr.height).endFill();
this.primary.sprite.mask = this.primary.mask = this.effects.mask = this.interface.grid.mask =
this.interface.templates.mask = this.masks.canvas;
// Compute the scene scissor mask
const sr = canvas.dimensions.sceneRect;
this.masks.scene.clear().beginFill(0xFFFFFF, 1.0).drawRect(sr.x, sr.y, sr.width, sr.height).endFill();
// Initialize starting conditions
await this.#initialize();
this.#scene._view = true;
this.stage.visible = true;
await this.#callManagerEvent("_onReady");
Hooks.call("canvasReady", this);
// Record that loading was complete and return
this.loading = false;
// Trigger Region status events
await this.#handleRegionBehaviorStatusEvents(true);
MouseInteractionManager.emulateMoveEvent();
}
/* -------------------------------------------- */
/**
* When re-drawing the canvas, first tear down or discontinue some existing processes
* @returns {Promise<void>}
*/
async tearDown() {
this.stage.visible = false;
this.stage.filters = null;
this.sceneTextures = {};
this.blurOptions = undefined;
// Track current data which should be restored on draw
this.#reload = {
scene: this.#scene.id,
layer: this.activeLayer?.options.name,
controlledTokens: this.tokens.controlled.map(t => t.id),
targetedTokens: Array.from(game.user.targets).map(t => t.id)
};
// Deactivate ticker workflows
this.#deactivateTicker();
this.deactivateFPSMeter();
// Deactivate every layer before teardown
for ( let l of this.layers.reverse() ) {
if ( l instanceof InteractionLayer ) l.deactivate();
}
// Trigger Region status events
await this.#handleRegionBehaviorStatusEvents(false);
// Call tear-down hooks
await this.#callManagerEvent("_deactivateHooks");
await this.#callManagerEvent("_onTearDown");
Hooks.callAll("canvasTearDown", this);
// Tear down groups
for ( const name of Object.keys(CONFIG.Canvas.groups).reverse() ) {
const group = this[name];
await group.tearDown();
}
// Tear down every layer
await this.effects.tearDown();
for ( let l of this.layers.reverse() ) {
await l.tearDown();
}
// Clear edges
this.edges.clear();
// Discard shared filters
this.blurFilters.clear();
// Create a new event boundary for the stage
this.app.renderer.events.rootBoundary = new PIXI.EventBoundary(this.stage);
MouseInteractionManager.emulateMoveEvent();
}
/* -------------------------------------------- */
/**
* Handle Region BEHAVIOR_STATUS events that are triggered when the Scene is (un)viewed.
* @param {boolean} viewed Is the scene viewed or not?
*/
async #handleRegionBehaviorStatusEvents(viewed) {
const results = await Promise.allSettled(this.scene.regions.map(region => region._handleEvent({
name: CONST.REGION_EVENTS.BEHAVIOR_STATUS,
data: {viewed},
region,
user: game.user
})));
for ( const result of results ) {
if ( result.status === "rejected" ) console.error(result.reason);
}
}
/* -------------------------------------------- */
/**
* Create a SceneManager instance used for this Scene, if any.
* @param {Scene} scene
* @returns {foundry.canvas.SceneManager|null}
* @internal
*/
static getSceneManager(scene) {
const managerCls = CONFIG.Canvas.managedScenes[scene.id];
return managerCls ? new managerCls(scene) : null;
}
/* -------------------------------------------- */
/**
* A special workflow to perform when rendering a blank Canvas with no active Scene.
*/
#drawBlank() {
console.log(`${vtt} | Skipping game canvas - no active scene.`);
canvas.app.view.style.display = "none";
ui.controls.render();
this.loading = this.#ready = false;
this.#manager = null;
this.#dimensions = null;
MouseInteractionManager.emulateMoveEvent();
}
/* -------------------------------------------- */
/**
* Get the value of a GL parameter
* @param {string} parameter The GL parameter to retrieve
* @returns {*} The GL parameter value
*/
getGLParameter(parameter) {
const gl = this.app.renderer.context.gl;
return gl.getParameter(gl[parameter]);
}
/* -------------------------------------------- */
/**
* Once the canvas is drawn, initialize control, visibility, and audio states
* @returns {Promise<void>}
*/
async #initialize() {
this.#ready = true;
// Clear the set of targeted Tokens for the current user
game.user.targets.clear();
// Render the HUD layer
this.hud.render(true);
// Initialize canvas conditions
this.#initializeCanvasLayer();
this.#initializeTokenControl();
this._onResize();
this.#reload = {};
// Initialize edges and perception
this.edges.initialize();
this.perception.initialize();
// Broadcast user presence in the Scene and request user activity data
game.user.viewedScene = this.#scene.id;
game.user.broadcastActivity({sceneId: this.#scene.id, cursor: null, ruler: null, targets: []});
game.socket.emit("getUserActivity");
// Activate user interaction
this.#addListeners();
// Call PCO sorting
canvas.primary.sortChildren();
}
/* -------------------------------------------- */
/**
* Initialize the starting view of the canvas stage
* If we are re-drawing a scene which was previously rendered, restore the prior view position
* Otherwise set the view to the top-left corner of the scene at standard scale
*/
initializeCanvasPosition() {
// If we are re-drawing a Scene that was already visited, use it's cached view position
let position = this.#scene._viewPosition;
// Use a saved position, or determine the default view based on the scene size
if ( foundry.utils.isEmpty(position) ) {
let {x, y, scale} = this.#scene.initial;
const r = this.dimensions.rect;
x ??= (r.right / 2);
y ??= (r.bottom / 2);
scale ??= Math.clamp(Math.min(window.innerHeight / r.height, window.innerWidth / r.width), 0.25, 3);
position = {x, y, scale};
}
// Pan to the initial view
this.pan(position);
}
/* -------------------------------------------- */
/**
* Initialize a CanvasLayer in the activation state
*/
#initializeCanvasLayer() {
const layer = this[this.#reload.layer] ?? this.tokens;
layer.activate();
}
/* -------------------------------------------- */
/**
* Initialize a token or set of tokens which should be controlled.
* Restore controlled and targeted tokens from before the re-draw.
*/
#initializeTokenControl() {
let panToken = null;
let controlledTokens = [];
let targetedTokens = [];
// Initial tokens based on reload data
let isReload = this.#reload.scene === this.#scene.id;
if ( isReload ) {
controlledTokens = this.#reload.controlledTokens.map(id => canvas.tokens.get(id));
targetedTokens = this.#reload.targetedTokens.map(id => canvas.tokens.get(id));
}
// Initialize tokens based on player character
else if ( !game.user.isGM ) {
controlledTokens = game.user.character?.getActiveTokens() || [];
if (!controlledTokens.length) {
controlledTokens = canvas.tokens.placeables.filter(t => t.actor?.testUserPermission(game.user, "OWNER"));
}
if (!controlledTokens.length) {
const observed = canvas.tokens.placeables.filter(t => t.actor?.testUserPermission(game.user, "OBSERVER"));
panToken = observed.shift() || null;
}
}
// Initialize Token Control
for ( let token of controlledTokens ) {
if ( !panToken ) panToken = token;
token?.control({releaseOthers: false});
}
// Display a warning if the player has no vision tokens in a visibility-restricted scene
if ( !game.user.isGM && this.#scene.tokenVision && !canvas.effects.visionSources.size ) {
ui.notifications.warn("TOKEN.WarningNoVision", {localize: true});
}
// Initialize Token targets
for ( const token of targetedTokens ) {
token?.setTarget(true, {releaseOthers: false, groupSelection: true});
}
// Pan camera to controlled token
if ( panToken && !isReload ) this.pan({x: panToken.center.x, y: panToken.center.y, duration: 250});
}
/* -------------------------------------------- */
/**
* Safely call a function of the SceneManager instance, catching and logging any errors.
* @param {string} fnName The name of the manager function to invoke
* @returns {Promise<void>}
*/
async #callManagerEvent(fnName) {
if ( !this.#manager ) return;
const fn = this.#manager[fnName];
try {
if ( !(fn instanceof Function) ) {
console.error(`Invalid SceneManager function name "${fnName}"`);
return;
}
await fn.call(this.#manager);
} catch(err) {
err.message = `${this.#manager.constructor.name}#${fnName} failed with error: ${err.message}`;
console.error(err);
}
}
/* -------------------------------------------- */
/**
* Given an embedded object name, get the canvas layer for that object
* @param {string} embeddedName
* @returns {PlaceablesLayer|null}
*/
getLayerByEmbeddedName(embeddedName) {
return {
AmbientLight: this.lighting,
AmbientSound: this.sounds,
Drawing: this.drawings,
MeasuredTemplate: this.templates,
Note: this.notes,
Region: this.regions,
Tile: this.tiles,
Token: this.tokens,
Wall: this.walls
}[embeddedName] || null;
}
/* -------------------------------------------- */
/**
* Get the InteractionLayer of the canvas which manages Documents of a certain collection within the Scene.
* @param {string} collectionName The collection name
* @returns {PlaceablesLayer} The canvas layer
*/
getCollectionLayer(collectionName) {
return {
drawings: this.drawings,
lights: this.lighting,
notes: this.notes,
regions: this.regions,
sounds: this.sounds,
templates: this.templates,
tiles: this.tiles,
tokens: this.tokens,
walls: this.walls
}[collectionName];
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Activate framerate tracking by adding an HTML element to the display and refreshing it every frame.
*/
activateFPSMeter() {
this.deactivateFPSMeter();
if ( !this.#ready ) return;
this.fps.element.style.display = "block";
this.app.ticker.add(this.#measureFPS, this, PIXI.UPDATE_PRIORITY.LOW);
}
/* -------------------------------------------- */
/**
* Deactivate framerate tracking by canceling ticker updates and removing the HTML element.
*/
deactivateFPSMeter() {
this.app.ticker.remove(this.#measureFPS, this);
this.fps.element.style.display = "none";
}
/* -------------------------------------------- */
/**
* Measure average framerate per second over the past 30 frames
*/
#measureFPS() {
const lastTime = this.app.ticker.lastTime;
// Push fps values every frame
this.fps.values.push(1000 / this.app.ticker.elapsedMS);
if ( this.fps.values.length > 60 ) this.fps.values.shift();
// Do some computations and rendering occasionally
if ( (lastTime - this.fps.render) < 250 ) return;
if ( !this.fps.element ) return;
// Compute average fps
const total = this.fps.values.reduce((fps, total) => total + fps, 0);
this.fps.average = (total / this.fps.values.length);
// Render it
this.fps.element.innerHTML = `<label>FPS:</label> <span>${this.fps.average.toFixed(2)}</span>`;
this.fps.render = lastTime;
}
/* -------------------------------------------- */
/**
* @typedef {Object} CanvasViewPosition
* @property {number|null} x The x-coordinate which becomes stage.pivot.x
* @property {number|null} y The y-coordinate which becomes stage.pivot.y
* @property {number|null} scale The zoom level up to CONFIG.Canvas.maxZoom which becomes stage.scale.x and y
*/
/**
* Pan the canvas to a certain {x,y} coordinate and a certain zoom level
* @param {CanvasViewPosition} position The canvas position to pan to
*/
pan({x=null, y=null, scale=null}={}) {
// Constrain the resulting canvas view
const constrained = this._constrainView({x, y, scale});
const scaleChange = constrained.scale !== this.stage.scale.x;
// Set the pivot point
this.stage.pivot.set(constrained.x, constrained.y);
// Set the zoom level
if ( scaleChange ) {
this.stage.scale.set(constrained.scale, constrained.scale);
this.updateBlur();
}
// Update the scene tracked position
this.scene._viewPosition = constrained;
// Call hooks
Hooks.callAll("canvasPan", this, constrained);
// Update controls
this.controls._onCanvasPan();
// Align the HUD
this.hud.align();
// Invalidate cached containers
this.hidden.invalidateMasks();
this.effects.illumination.invalidateDarknessLevelContainer();
// Emulate mouse event to update the hover states
MouseInteractionManager.emulateMoveEvent();
}
/* -------------------------------------------- */
/**
* Animate panning the canvas to a certain destination coordinate and zoom scale
* Customize the animation speed with additional options
* Returns a Promise which is resolved once the animation has completed
*
* @param {CanvasViewPosition} view The desired view parameters
* @param {number} [view.duration=250] The total duration of the animation in milliseconds; used if speed is not set
* @param {number} [view.speed] The speed of animation in pixels per second; overrides duration if set
* @param {Function} [view.easing] An easing function passed to CanvasAnimation animate
* @returns {Promise} A Promise which resolves once the animation has been completed
*/
async animatePan({x, y, scale, duration=250, speed, easing}={}) {
// Determine the animation duration to reach the target
if ( speed ) {
let ray = new Ray(this.stage.pivot, {x, y});
duration = Math.round(ray.distance * 1000 / speed);
}
// Constrain the resulting dimensions and construct animation attributes
const position = {...this.scene._viewPosition};
const constrained = this._constrainView({x, y, scale});
// Trigger the animation function
return CanvasAnimation.animate([
{parent: position, attribute: "x", to: constrained.x},
{parent: position, attribute: "y", to: constrained.y},
{parent: position, attribute: "scale", to: constrained.scale}
], {
name: "canvas.animatePan",
duration: duration,
easing: easing ?? CanvasAnimation.easeInOutCosine,
ontick: () => this.pan(position)
});
}
/* -------------------------------------------- */
/**
* Recenter the canvas with a pan animation that ends in the center of the canvas rectangle.
* @param {CanvasViewPosition} initial A desired initial position from which to begin the animation
* @returns {Promise<void>} A Promise which resolves once the animation has been completed
*/
async recenter(initial) {
if ( initial ) this.pan(initial);
const r = this.dimensions.sceneRect;
return this.animatePan({
x: r.x + (window.innerWidth / 2),
y: r.y + (window.innerHeight / 2),
duration: 250
});
}
/* -------------------------------------------- */
/**
* Highlight objects on any layers which are visible
* @param {boolean} active
*/
highlightObjects(active) {
if ( !this.#ready ) return;
for ( let layer of this.layers ) {
if ( !layer.objects || !layer.interactiveChildren ) continue;
layer.highlightObjects = active;
for ( let o of layer.placeables ) {
o.renderFlags.set({refreshState: true});
}
}
if ( canvas.tokens.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.HIGHLIGHTED ) {
canvas.perception.update({refreshOcclusion: true});
}
/** @see hookEvents.highlightObjects */
Hooks.callAll("highlightObjects", active);
}
/* -------------------------------------------- */
/**
* Displays a Ping both locally and on other connected client, following these rules:
* 1) Displays on the current canvas Scene
* 2) If ALT is held, becomes an ALERT ping
* 3) Else if the user is GM and SHIFT is held, becomes a PULL ping
* 4) Else is a PULSE ping
* @param {Point} origin Point to display Ping at
* @param {PingOptions} [options] Additional options to configure how the ping is drawn.
* @returns {Promise<boolean>}
*/
async ping(origin, options) {
// Don't allow pinging outside of the canvas bounds
if ( !this.dimensions.rect.contains(origin.x, origin.y) ) return false;
// Configure the ping to be dispatched
const types = CONFIG.Canvas.pings.types;
const isPull = game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.SHIFT);
const isAlert = game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.ALT);
let style = types.PULSE;
if ( isPull ) style = types.PULL;
else if ( isAlert ) style = types.ALERT;
let ping = {scene: this.scene?.id, pull: isPull, style, zoom: canvas.stage.scale.x};
ping = foundry.utils.mergeObject(ping, options);
// Broadcast the ping to other connected clients
/** @type ActivityData */
const activity = {cursor: origin, ping};
game.user.broadcastActivity(activity);
// Display the ping locally
return this.controls.handlePing(game.user, origin, ping);
}
/* -------------------------------------------- */
/**
* Get the constrained zoom scale parameter which is allowed by the maxZoom parameter
* @param {CanvasViewPosition} position The unconstrained position
* @returns {CanvasViewPosition} The constrained position
* @internal
*/
_constrainView({x, y, scale}) {
if ( !Number.isNumeric(x) ) x = this.stage.pivot.x;
if ( !Number.isNumeric(y) ) y = this.stage.pivot.y;
if ( !Number.isNumeric(scale) ) scale = this.stage.scale.x;
const d = canvas.dimensions;
// Constrain the scale to the maximum zoom level
const maxScale = CONFIG.Canvas.maxZoom;
const minScale = 1 / Math.max(d.width / window.innerWidth, d.height / window.innerHeight, maxScale);
scale = Math.clamp(scale, minScale, maxScale);
// Constrain the pivot point using the new scale
const padX = 0.4 * (window.innerWidth / scale);
const padY = 0.4 * (window.innerHeight / scale);
x = Math.clamp(x, -padX, d.width + padX);
y = Math.clamp(y, -padY, d.height + padY);
// Return the constrained view dimensions
return {x, y, scale};
}
/* -------------------------------------------- */
/**
* Create a BlurFilter instance and register it to the array for updates when the zoom level changes.
* @param {number} blurStrength The desired blur strength to use for this filter
* @param {number} blurQuality The desired quality to use for this filter
* @returns {PIXI.BlurFilter}
*/
createBlurFilter(blurStrength, blurQuality=CONFIG.Canvas.blurQuality) {
const configuredStrength = blurStrength ?? this.blur.strength ?? CONFIG.Canvas.blurStrength;
const f = new PIXI.BlurFilter(configuredStrength, blurQuality);
f._configuredStrength = configuredStrength;
this.addBlurFilter(f);
return f;
}
/* -------------------------------------------- */
/**
* Add a filter to the blur filter list. The filter must have the blur property
* @param {PIXI.BlurFilter} filter The Filter instance to add
* @returns {PIXI.BlurFilter} The added filter for method chaining
*/
addBlurFilter(filter) {
if ( filter.blur === undefined ) return;
filter.blur = (filter._configuredStrength ?? this.blur.strength ?? CONFIG.Canvas.blurStrength) * this.stage.scale.x;
this.blurFilters.add(filter); // Save initial blur of the filter in the set
return filter;
}
/* -------------------------------------------- */
/**
* Update the blur strength depending on the scale of the canvas stage.
* This number is zero if "soft shadows" are disabled
* @param {number} [strength] Optional blur strength to apply
* @private
*/
updateBlur(strength) {
for ( const filter of this.blurFilters ) {
filter.blur = (strength ?? filter._configuredStrength ?? this.blur.strength ?? CONFIG.Canvas.blurStrength)
* this.stage.scale.x;
}
}
/* -------------------------------------------- */
/**
* Convert canvas coordinates to the client's viewport.
* @param {Point} origin The canvas coordinates.
* @returns {Point} The corresponding coordinates relative to the client's viewport.
*/
clientCoordinatesFromCanvas(origin) {
const point = {x: origin.x, y: origin.y};
return this.stage.worldTransform.apply(point, point);
}
/* -------------------------------------------- */
/**
* Convert client viewport coordinates to canvas coordinates.
* @param {Point} origin The client coordinates.
* @returns {Point} The corresponding canvas coordinates.
*/
canvasCoordinatesFromClient(origin) {
const point = {x: origin.x, y: origin.y};
return this.stage.worldTransform.applyInverse(point, point);
}
/* -------------------------------------------- */
/**
* Determine whether given canvas coordinates are off-screen.
* @param {Point} position The canvas coordinates.
* @returns {boolean} Is the coordinate outside the screen bounds?
*/
isOffscreen(position) {
const { clientWidth, clientHeight } = document.documentElement;
const { x, y } = this.clientCoordinatesFromCanvas(position);
return (x < 0) || (y < 0) || (x >= clientWidth) || (y >= clientHeight);
}
/* -------------------------------------------- */
/**
* Remove all children of the display object and call one cleaning method:
* clean first, then tearDown, and destroy if no cleaning method is found.
* @param {PIXI.DisplayObject} displayObject The display object to clean.
* @param {boolean} destroy If textures should be destroyed.
*/
static clearContainer(displayObject, destroy=true) {
const children = displayObject.removeChildren();
for ( const child of children ) {
if ( child.clear ) child.clear(destroy);
else if ( child.tearDown ) child.tearDown();
else child.destroy(destroy);
}
}
/* -------------------------------------------- */
/**
* Get a texture with the required configuration and clear color.
* @param {object} options
* @param {number[]} [options.clearColor] The clear color to use for this texture. Transparent by default.
* @param {object} [options.textureConfiguration] The render texture configuration.
* @returns {PIXI.RenderTexture}
*/
static getRenderTexture({clearColor, textureConfiguration}={}) {
const texture = PIXI.RenderTexture.create(textureConfiguration);
if ( clearColor ) texture.baseTexture.clearColor = clearColor;
return texture;
}
/* -------------------------------------------- */
/* Event Handlers
/* -------------------------------------------- */
/**
* Attach event listeners to the game canvas to handle click and interaction events
*/
#addListeners() {
// Remove all existing listeners
this.stage.removeAllListeners();
// Define callback functions for mouse interaction events
const callbacks = {
clickLeft: this.#onClickLeft.bind(this),
clickLeft2: this.#onClickLeft2.bind(this),
clickRight: this.#onClickRight.bind(this),
clickRight2: this.#onClickRight2.bind(this),
dragLeftStart: this.#onDragLeftStart.bind(this),
dragLeftMove: this.#onDragLeftMove.bind(this),
dragLeftDrop: this.#onDragLeftDrop.bind(this),
dragLeftCancel: this.#onDragLeftCancel.bind(this),
dragRightStart: this._onDragRightStart.bind(this),
dragRightMove: this._onDragRightMove.bind(this),
dragRightDrop: this._onDragRightDrop.bind(this),
dragRightCancel: this._onDragRightCancel.bind(this),
longPress: this.#onLongPress.bind(this)
};
// Create and activate the interaction manager
const permissions = {
clickRight2: false,
dragLeftStart: this.#canDragLeftStart.bind(this)
};
const mgr = new MouseInteractionManager(this.stage, this.stage, permissions, callbacks);
this.mouseInteractionManager = mgr.activate();
// Debug average FPS
if ( game.settings.get("core", "fpsMeter") ) this.activateFPSMeter();
this.dt = 0;
// Add a listener for cursor movement
this.stage.on("pointermove", event => {
event.getLocalPosition(this.stage, this.mousePosition);
this.#throttleOnMouseMove();
});
}
/* -------------------------------------------- */
/**
* Handle mouse movement on the game canvas.
*/
#onMouseMove() {
this.controls._onMouseMove();
this.sounds._onMouseMove();
this.primary._onMouseMove();
}
/* -------------------------------------------- */
/**
* Handle left mouse-click events occurring on the Canvas.
* @see {MouseInteractionManager##handleClickLeft}
* @param {PIXI.FederatedEvent} event
*/
#onClickLeft(event) {
const layer = this.activeLayer;
if ( layer instanceof InteractionLayer ) return layer._onClickLeft(event);
}
/* -------------------------------------------- */
/**
* Handle double left-click events occurring on the Canvas.
* @see {MouseInteractionManager##handleClickLeft2}
* @param {PIXI.FederatedEvent} event
*/
#onClickLeft2(event) {
const layer = this.activeLayer;
if ( layer instanceof InteractionLayer ) return layer._onClickLeft2(event);
}
/* -------------------------------------------- */
/**
* Handle long press events occurring on the Canvas.
* @see {MouseInteractionManager##handleLongPress}
* @param {PIXI.FederatedEvent} event The triggering canvas interaction event.
* @param {PIXI.Point} origin The local canvas coordinates of the mousepress.
*/
#onLongPress(event, origin) {
canvas.controls._onLongPress(event, origin);
}
/* -------------------------------------------- */
/**
* Does the User have permission to left-click drag on the Canvas?
* @param {User} user The User performing the action.
* @param {PIXI.FederatedEvent} event The event object.
* @returns {boolean}
*/
#canDragLeftStart(user, event) {
const layer = this.activeLayer;
if ( (layer instanceof TokenLayer) && CONFIG.Canvas.rulerClass.canMeasure ) return !this.controls.ruler.active;
if ( ["select", "target"].includes(game.activeTool) ) return true;
if ( layer instanceof InteractionLayer ) return layer._canDragLeftStart(user, event);
return false;
}
/* -------------------------------------------- */
/**
* Handle the beginning of a left-mouse drag workflow on the Canvas stage or its active Layer.
* @see {MouseInteractionManager##handleDragStart}
* @param {PIXI.FederatedEvent} event
*/
#onDragLeftStart(event) {
const layer = this.activeLayer;
// Begin ruler measurement
if ( (layer instanceof TokenLayer) && CONFIG.Canvas.rulerClass.canMeasure ) {
event.interactionData.ruler = true;
return this.controls.ruler._onDragStart(event);
}
// Activate select rectangle
const isSelect = ["select", "target"].includes(game.activeTool);
if ( isSelect ) {
// The event object appears to be reused, so delete any coords from a previous selection.
delete event.interactionData.coords;
canvas.controls.select.active = true;
return;
}
// Dispatch the event to the active layer
if ( layer instanceof InteractionLayer ) return layer._onDragLeftStart(event);
}
/* -------------------------------------------- */
/**
* Handle mouse movement events occurring on the Canvas.
* @see {MouseInteractionManager##handleDragMove}
* @param {PIXI.FederatedEvent} event
*/
#onDragLeftMove(event) {
const layer = this.activeLayer;
// Pan the canvas if the drag event approaches the edge
this._onDragCanvasPan(event);
// Continue a Ruler measurement
if ( event.interactionData.ruler ) return this.controls.ruler._onMouseMove(event);
// Continue a select event
const isSelect = ["select", "target"].includes(game.activeTool);
if ( isSelect && canvas.controls.select.active ) return this.#onDragSelect(event);
// Dispatch the event to the active layer
if ( layer instanceof InteractionLayer ) return layer._onDragLeftMove(event);
}
/* -------------------------------------------- */
/**
* Handle the conclusion of a left-mouse drag workflow when the mouse button is released.
* @see {MouseInteractionManager##handleDragDrop}
* @param {PIXI.FederatedEvent} event
* @internal
*/
#onDragLeftDrop(event) {
// Extract event data
const coords = event.interactionData.coords;
const tool = game.activeTool;
const layer = canvas.activeLayer;
// Conclude a measurement event if we aren't holding the CTRL key
if ( event.interactionData.ruler ) return canvas.controls.ruler._onMouseUp(event);
// Conclude a select event
const isSelect = ["select", "target"].includes(tool);
const targetKeyDown = game.keyboard.isCoreActionKeyActive("target");
if ( isSelect && canvas.controls.select.active && (layer instanceof PlaceablesLayer) ) {
canvas.controls.select.clear();
canvas.controls.select.active = false;
const releaseOthers = !event.shiftKey;
if ( !coords ) return;
if ( tool === "select" && !targetKeyDown ) return layer.selectObjects(coords, {releaseOthers});
else if ( tool === "target" || targetKeyDown ) return layer.targetObjects(coords, {releaseOthers});
}
// Dispatch the event to the active layer
if ( layer instanceof InteractionLayer ) return layer._onDragLeftDrop(event);
}
/* -------------------------------------------- */
/**
* Handle the cancellation of a left-mouse drag workflow
* @see {MouseInteractionManager##handleDragCancel}
* @param {PointerEvent} event
* @internal
*/
#onDragLeftCancel(event) {
const layer = canvas.activeLayer;
const tool = game.activeTool;
// Don't cancel ruler measurement unless the token was moved by the ruler
if ( event.interactionData.ruler ) {
const ruler = canvas.controls.ruler;
return !ruler.active || (ruler.state === Ruler.STATES.MOVING);
}
// Clear selection
const isSelect = ["select", "target"].includes(tool);
if ( isSelect ) {
canvas.controls.select.clear();
return;
}
// Dispatch the event to the active layer
if ( layer instanceof InteractionLayer ) return layer._onDragLeftCancel(event);
}
/* -------------------------------------------- */
/**
* Handle right mouse-click events occurring on the Canvas.
* @see {MouseInteractionManager##handleClickRight}
* @param {PIXI.FederatedEvent} event
*/
#onClickRight(event) {
const ruler = canvas.controls.ruler;
if ( ruler.state === Ruler.STATES.MEASURING ) return ruler._onClickRight(event);
// Dispatch to the active layer
const layer = this.activeLayer;
if ( layer instanceof InteractionLayer ) return layer._onClickRight(event);
}
/* -------------------------------------------- */
/**
* Handle double right-click events occurring on the Canvas.
* @see {MouseInteractionManager##handleClickRight}
* @param {PIXI.FederatedEvent} event
*/
#onClickRight2(event) {
const layer = this.activeLayer;
if ( layer instanceof InteractionLayer ) return layer._onClickRight2(event);
}
/* -------------------------------------------- */
/**
* Handle right-mouse start drag events occurring on the Canvas.
* @see {MouseInteractionManager##handleDragStart}
* @param {PIXI.FederatedEvent} event
* @internal
*/
_onDragRightStart(event) {}
/* -------------------------------------------- */
/**
* Handle right-mouse drag events occurring on the Canvas.
* @see {MouseInteractionManager##handleDragMove}
* @param {PIXI.FederatedEvent} event
* @internal
*/
_onDragRightMove(event) {
// Extract event data
const {origin, destination} = event.interactionData;
const dx = destination.x - origin.x;
const dy = destination.y - origin.y;
// Pan the canvas
this.pan({
x: canvas.stage.pivot.x - (dx * CONFIG.Canvas.dragSpeedModifier),
y: canvas.stage.pivot.y - (dy * CONFIG.Canvas.dragSpeedModifier)
});
// Reset Token tab cycling
this.tokens._tabIndex = null;
}
/* -------------------------------------------- */
/**
* Handle the conclusion of a right-mouse drag workflow the Canvas stage.
* @see {MouseInteractionManager##handleDragDrop}
* @param {PIXI.FederatedEvent} event
* @internal
*/
_onDragRightDrop(event) {}
/* -------------------------------------------- */
/**
* Handle the cancellation of a right-mouse drag workflow the Canvas stage.
* @see {MouseInteractionManager##handleDragCancel}
* @param {PIXI.FederatedEvent} event
* @internal
*/
_onDragRightCancel(event) {}
/* -------------------------------------------- */
/**
* Determine selection coordinate rectangle during a mouse-drag workflow
* @param {PIXI.FederatedEvent} event
*/
#onDragSelect(event) {
// Extract event data
const {origin, destination} = event.interactionData;
// Determine rectangle coordinates
let coords = {
x: Math.min(origin.x, destination.x),
y: Math.min(origin.y, destination.y),
width: Math.abs(destination.x - origin.x),
height: Math.abs(destination.y - origin.y)
};
// Draw the select rectangle
canvas.controls.drawSelect(coords);
event.interactionData.coords = coords;
}
/* -------------------------------------------- */
/**
* Pan the canvas view when the cursor position gets close to the edge of the frame
* @param {MouseEvent} event The originating mouse movement event
*/
_onDragCanvasPan(event) {
// Throttle panning by 200ms
const now = Date.now();
if ( now - (this._panTime || 0) <= 200 ) return;
this._panTime = now;
// Shift by 3 grid spaces at a time
const {x, y} = event;
const pad = 50;
const shift = (this.dimensions.size * 3) / this.stage.scale.x;
// Shift horizontally
let dx = 0;
if ( x < pad ) dx = -shift;
else if ( x > window.innerWidth - pad ) dx = shift;
// Shift vertically
let dy = 0;
if ( y < pad ) dy = -shift;
else if ( y > window.innerHeight - pad ) dy = shift;
// Enact panning
if ( dx || dy ) return this.animatePan({x: this.stage.pivot.x + dx, y: this.stage.pivot.y + dy, duration: 200});
}
/* -------------------------------------------- */
/* Other Event Handlers */
/* -------------------------------------------- */
/**
* Handle window resizing with the dimensions of the window viewport change
* @param {Event} event The Window resize event
* @private
*/
_onResize(event=null) {
if ( !this.#ready ) return false;
// Resize the renderer to the current screen dimensions
this.app.renderer.resize(window.innerWidth, window.innerHeight);
// Record the dimensions that were resized to (may be rounded, etc..)
const w = this.screenDimensions[0] = this.app.renderer.screen.width;
const h = this.screenDimensions[1] = this.app.renderer.screen.height;
// Update the canvas position
this.stage.position.set(w/2, h/2);
this.pan(this.stage.pivot);
}
/* -------------------------------------------- */
/**
* Handle mousewheel events which adjust the scale of the canvas
* @param {WheelEvent} event The mousewheel event that zooms the canvas
* @private
*/
_onMouseWheel(event) {
let dz = ( event.delta < 0 ) ? 1.05 : 0.95;
this.pan({scale: dz * canvas.stage.scale.x});
}
/* -------------------------------------------- */
/**
* Event handler for the drop portion of a drag-and-drop event.
* @param {DragEvent} event The drag event being dropped onto the canvas
* @private
*/
_onDrop(event) {
event.preventDefault();
const data = TextEditor.getDragEventData(event);
if ( !data.type ) return;
// Acquire the cursor position transformed to Canvas coordinates
const {x, y} = this.canvasCoordinatesFromClient({x: event.clientX, y: event.clientY});
data.x = x;
data.y = y;
/**
* A hook event that fires when some useful data is dropped onto the
* Canvas.
* @function dropCanvasData
* @memberof hookEvents
* @param {Canvas} canvas The Canvas
* @param {object} data The data that has been dropped onto the Canvas
*/
const allowed = Hooks.call("dropCanvasData", this, data);
if ( allowed === false ) return;
// Handle different data types
switch ( data.type ) {
case "Actor":
return canvas.tokens._onDropActorData(event, data);
case "JournalEntry": case "JournalEntryPage":
return canvas.notes._onDropData(event, data);
case "Macro":
return game.user.assignHotbarMacro(null, Number(data.slot));
case "PlaylistSound":
return canvas.sounds._onDropData(event, data);
case "Tile":
return canvas.tiles._onDropData(event, data);
}
}
/* -------------------------------------------- */
/* Pre-Rendering Workflow */
/* -------------------------------------------- */
/**
* Track objects which have pending render flags.
* @enum {Set<RenderFlagObject>}
*/
pendingRenderFlags;
/**
* Cached references to bound ticker functions which can be removed later.
* @type {Record<string, Function>}
*/
#tickerFunctions = {};
/* -------------------------------------------- */
/**
* Activate ticker functions which should be called as part of the render loop.
* This occurs as part of setup for a newly viewed Scene.
*/
#activateTicker() {
const p = PIXI.UPDATE_PRIORITY;
// Define custom ticker priorities
Object.assign(p, {
OBJECTS: p.HIGH - 2,
PRIMARY: p.NORMAL + 3,
PERCEPTION: p.NORMAL + 2
});
// Create pending queues
Object.defineProperty(this, "pendingRenderFlags", {
value: {
OBJECTS: new Set(),
PERCEPTION: new Set()
},
configurable: true,
writable: false
});
// Apply PlaceableObject RenderFlags
this.#tickerFunctions.OBJECTS = this.#applyRenderFlags.bind(this, this.pendingRenderFlags.OBJECTS);
this.app.ticker.add(this.#tickerFunctions.OBJECTS, undefined, p.OBJECTS);
// Update the primary group
this.#tickerFunctions.PRIMARY = this.primary.update.bind(this.primary);
this.app.ticker.add(this.#tickerFunctions.PRIMARY, undefined, p.PRIMARY);
// Update Perception
this.#tickerFunctions.PERCEPTION = this.#applyRenderFlags.bind(this, this.pendingRenderFlags.PERCEPTION);
this.app.ticker.add(this.#tickerFunctions.PERCEPTION, undefined, p.PERCEPTION);
}
/* -------------------------------------------- */
/**
* Deactivate ticker functions which were previously registered.
* This occurs during tear-down of a previously viewed Scene.
*/
#deactivateTicker() {
for ( const queue of Object.values(this.pendingRenderFlags) ) queue.clear();
for ( const [k, fn] of Object.entries(this.#tickerFunctions) ) {
canvas.app.ticker.remove(fn);
delete this.#tickerFunctions[k];
}
}
/* -------------------------------------------- */
/**
* Apply pending render flags which should be handled at a certain ticker priority.
* @param {Set<RenderFlagObject>} queue The queue of objects to handle
*/
#applyRenderFlags(queue) {
if ( !queue.size ) return;
const objects = Array.from(queue);
queue.clear();
for ( const object of objects ) object.applyRenderFlags();
}
/* -------------------------------------------- */
/**
* Test support for some GPU capabilities and update the supported property.
* @param {PIXI.Renderer} renderer
*/
#testSupport(renderer) {
const supported = {};
const gl = renderer?.gl;
if ( !(gl instanceof WebGL2RenderingContext) ) {
supported.webGL2 = false;
return supported;
}
supported.webGL2 = true;
let renderTexture;
// Test support for reading pixels in RED/UNSIGNED_BYTE format
renderTexture = PIXI.RenderTexture.create({
width: 1,
height: 1,
format: PIXI.FORMATS.RED,
type: PIXI.TYPES.UNSIGNED_BYTE,
resolution: 1,
multisample: PIXI.MSAA_QUALITY.NONE
});
renderer.renderTexture.bind(renderTexture);
const format = gl.getParameter(gl.IMPLEMENTATION_COLOR_READ_FORMAT);
const type = gl.getParameter(gl.IMPLEMENTATION_COLOR_READ_TYPE);
supported.readPixelsRED = (format === gl.RED) && (type === gl.UNSIGNED_BYTE);
renderer.renderTexture.bind();
renderTexture?.destroy(true);
// Test support for OffscreenCanvas
try {
supported.offscreenCanvas =
(typeof OffscreenCanvas !== "undefined") && (!!new OffscreenCanvas(10, 10).getContext("2d"));
} catch(e) {
supported.offscreenCanvas = false;
}
return supported;
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
addPendingOperation(name, fn, scope, args) {
const msg = "Canvas#addPendingOperation is deprecated without replacement in v11. The callback that you have "
+ "passed as a pending operation has been executed immediately. We recommend switching your code to use a "
+ "debounce operation or RenderFlags to de-duplicate overlapping requests.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
fn.call(scope, ...args);
}
/**
* @deprecated since v11
* @ignore
*/
triggerPendingOperations() {
const msg = "Canvas#triggerPendingOperations is deprecated without replacement in v11 and performs no action.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
}
/**
* @deprecated since v11
* @ignore
*/
get pendingOperations() {
const msg = "Canvas#pendingOperations is deprecated without replacement in v11.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return [];
}
/**
* @deprecated since v12
* @ignore
*/
get colorManager() {
const msg = "Canvas#colorManager is deprecated and replaced by Canvas#environment";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return this.environment;
}
}