/** * 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} */ 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|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} */ #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|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} */ 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} 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} */ 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} */ 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} */ 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} */ 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 = ` ${this.fps.average.toFixed(2)}`; 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} 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} */ 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} */ pendingRenderFlags; /** * Cached references to bound ticker functions which can be removed later. * @type {Record} */ #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} 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; } }