Files
2025-01-04 00:34:03 +01:00

614 lines
20 KiB
JavaScript

/**
* 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}.
* @extends {CachedContainer}
* @mixes CanvasGroupMixin
* @category - Canvas
*/
class PrimaryCanvasGroup extends CanvasGroupMixin(CachedContainer) {
constructor(sprite) {
sprite ||= new SpriteMesh(undefined, BaseSamplerShader);
super(sprite);
this.eventMode = "none";
this.#createAmbienceFilter();
this.on("childAdded", this.#onChildAdded);
this.on("childRemoved", this.#onChildRemoved);
}
/**
* Sort order to break ties on the group/layer level.
* @enum {number}
*/
static SORT_LAYERS = Object.freeze({
SCENE: 0,
TILES: 500,
DRAWINGS: 600,
TOKENS: 700,
WEATHER: 1000
});
/** @override */
static groupName = "primary";
/** @override */
static textureConfiguration = {
scaleMode: PIXI.SCALE_MODES.NEAREST,
format: PIXI.FORMATS.RGB,
multisample: PIXI.MSAA_QUALITY.NONE
};
/** @override */
clearColor = [0, 0, 0, 0];
/**
* The background color in RGB.
* @type {[red: number, green: number, blue: number]}
* @internal
*/
_backgroundColor;
/**
* Track the set of HTMLVideoElements which are currently playing as part of this group.
* @type {Set<SpriteMesh>}
*/
videoMeshes = new Set();
/**
* Occludable objects above this elevation are faded on hover.
* @type {number}
*/
hoverFadeElevation = 0;
/**
* Allow API users to override the default elevation of the background layer.
* This is a temporary solution until more formal support for scene levels is added in a future release.
* @type {number}
*/
static BACKGROUND_ELEVATION = 0;
/* -------------------------------------------- */
/* Group Attributes */
/* -------------------------------------------- */
/**
* The primary background image configured for the Scene, rendered as a SpriteMesh.
* @type {SpriteMesh}
*/
background;
/**
* The primary foreground image configured for the Scene, rendered as a SpriteMesh.
* @type {SpriteMesh}
*/
foreground;
/**
* A Quadtree which partitions and organizes primary canvas objects.
* @type {CanvasQuadtree}
*/
quadtree = new CanvasQuadtree();
/**
* The collection of PrimaryDrawingContainer objects which are rendered in the Scene.
* @type {Collection<string, PrimaryDrawingContainer>}
*/
drawings = new foundry.utils.Collection();
/**
* The collection of SpriteMesh objects which are rendered in the Scene.
* @type {Collection<string, TokenMesh>}
*/
tokens = new foundry.utils.Collection();
/**
* The collection of SpriteMesh objects which are rendered in the Scene.
* @type {Collection<string, PrimarySpriteMesh|TileSprite>}
*/
tiles = new foundry.utils.Collection();
/**
* The ambience filter which is applying post-processing effects.
* @type {PrimaryCanvasGroupAmbienceFilter}
* @internal
*/
_ambienceFilter;
/**
* The objects that are currently hovered in reverse sort order.
* @type {PrimaryCanvasObjec[]>}
*/
#hoveredObjects = [];
/**
* Trace the tiling sprite error to avoid multiple warning.
* FIXME: Remove when the deprecation period for the tiling sprite error is over.
* @type {boolean}
* @internal
*/
#tilingSpriteError = false;
/* -------------------------------------------- */
/* Group Properties */
/* -------------------------------------------- */
/**
* Return the base HTML image or video element which provides the background texture.
* @type {HTMLImageElement|HTMLVideoElement}
*/
get backgroundSource() {
if ( !this.background.texture.valid || this.background.texture === PIXI.Texture.WHITE ) return null;
return this.background.texture.baseTexture.resource.source;
}
/* -------------------------------------------- */
/**
* Return the base HTML image or video element which provides the foreground texture.
* @type {HTMLImageElement|HTMLVideoElement}
*/
get foregroundSource() {
if ( !this.foreground.texture.valid ) return null;
return this.foreground.texture.baseTexture.resource.source;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* Create the ambience filter bound to the primary group.
*/
#createAmbienceFilter() {
if ( this._ambienceFilter ) this._ambienceFilter.enabled = false;
else {
this.filters ??= [];
const f = this._ambienceFilter = PrimaryCanvasGroupAmbienceFilter.create();
f.enabled = false;
this.filterArea = canvas.app.renderer.screen;
this.filters.push(f);
}
}
/* -------------------------------------------- */
/**
* Refresh the primary mesh.
*/
refreshPrimarySpriteMesh() {
const singleSource = canvas.visibility.visionModeData.source;
const vmOptions = singleSource?.visionMode.canvas;
const isBaseSampler = (this.sprite.shader.constructor === BaseSamplerShader);
if ( !vmOptions && isBaseSampler ) return;
// Update the primary sprite shader class (or reset to BaseSamplerShader)
this.sprite.setShaderClass(vmOptions?.shader ?? BaseSamplerShader);
this.sprite.shader.uniforms.sampler = this.renderTexture;
// Need to update uniforms?
if ( !vmOptions?.uniforms ) return;
vmOptions.uniforms.linkedToDarknessLevel = singleSource?.visionMode.vision.darkness.adaptive;
vmOptions.uniforms.darknessLevel = canvas.environment.darknessLevel;
vmOptions.uniforms.darknessLevelTexture = canvas.effects.illumination.renderTexture;
vmOptions.uniforms.screenDimensions = canvas.screenDimensions;
// Assigning color from source if any
vmOptions.uniforms.tint = singleSource?.visionModeOverrides.colorRGB
?? this.sprite.shader.constructor.defaultUniforms.tint;
// Updating uniforms in the primary sprite shader
for ( const [uniform, value] of Object.entries(vmOptions?.uniforms ?? {}) ) {
if ( uniform in this.sprite.shader.uniforms ) this.sprite.shader.uniforms[uniform] = value;
}
}
/* -------------------------------------------- */
/**
* Update this group. Calculates the canvas transform and bounds of all its children and updates the quadtree.
*/
update() {
if ( this.sortDirty ) this.sortChildren();
const children = this.children;
for ( let i = 0, n = children.length; i < n; i++ ) {
children[i].updateCanvasTransform?.();
}
canvas.masks.depth._update();
if ( !CONFIG.debug.canvas.primary.bounds ) return;
const dbg = canvas.controls.debug.clear().lineStyle(5, 0x30FF00);
for ( const child of this.children ) {
if ( child.canvasBounds ) dbg.drawShape(child.canvasBounds);
}
}
/* -------------------------------------------- */
/** @inheritDoc */
async _draw(options) {
this.#drawBackground();
this.#drawForeground();
this.#drawPadding();
this.hoverFadeElevation = 0;
await super._draw(options);
}
/* -------------------------------------------- */
/** @inheritDoc */
_render(renderer) {
const [r, g, b] = this._backgroundColor;
renderer.framebuffer.clear(r, g, b, 1, PIXI.BUFFER_BITS.COLOR);
super._render(renderer);
}
/* -------------------------------------------- */
/**
* Draw the Scene background image.
*/
#drawBackground() {
const bg = this.background = this.addChild(new PrimarySpriteMesh({name: "background", object: this}));
bg.elevation = this.constructor.BACKGROUND_ELEVATION;
const bgTextureSrc = canvas.sceneTextures.background ?? canvas.scene.background.src;
const bgTexture = bgTextureSrc instanceof PIXI.Texture ? bgTextureSrc : getTexture(bgTextureSrc);
this.#drawSceneMesh(bg, bgTexture);
}
/* -------------------------------------------- */
/**
* Draw the Scene foreground image.
*/
#drawForeground() {
const fg = this.foreground = this.addChild(new PrimarySpriteMesh({name: "foreground", object: this}));
fg.elevation = canvas.scene.foregroundElevation;
const fgTextureSrc = canvas.sceneTextures.foreground ?? canvas.scene.foreground;
const fgTexture = fgTextureSrc instanceof PIXI.Texture ? fgTextureSrc : getTexture(fgTextureSrc);
// Compare dimensions with background texture and draw the mesh
const bg = this.background.texture;
if ( fgTexture && bg && ((fgTexture.width !== bg.width) || (fgTexture.height !== bg.height)) ) {
ui.notifications.warn("WARNING.ForegroundDimensionsMismatch", {localize: true});
}
this.#drawSceneMesh(fg, fgTexture);
}
/* -------------------------------------------- */
/**
* Draw a PrimarySpriteMesh that fills the entire Scene rectangle.
* @param {PrimarySpriteMesh} mesh The target PrimarySpriteMesh
* @param {PIXI.Texture|null} texture The loaded Texture or null
*/
#drawSceneMesh(mesh, texture) {
const d = canvas.dimensions;
mesh.texture = texture ?? PIXI.Texture.EMPTY;
mesh.textureAlphaThreshold = 0.75;
mesh.occludedAlpha = 0.5;
mesh.visible = mesh.texture !== PIXI.Texture.EMPTY;
mesh.position.set(d.sceneX, d.sceneY);
mesh.width = d.sceneWidth;
mesh.height = d.sceneHeight;
mesh.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.SCENE;
mesh.zIndex = -Infinity;
mesh.hoverFade = false;
// Manage video playback
const video = game.video.getVideoSource(mesh);
if ( video ) {
this.videoMeshes.add(mesh);
game.video.play(video, {volume: game.settings.get("core", "globalAmbientVolume")});
}
}
/* -------------------------------------------- */
/**
* Draw the Scene padding.
*/
#drawPadding() {
const d = canvas.dimensions;
const g = this.addChild(new PIXI.LegacyGraphics());
g.beginFill(0x000000, 0.025)
.drawShape(d.rect)
.beginHole()
.drawShape(d.sceneRect)
.endHole()
.endFill();
g.elevation = -Infinity;
g.sort = -Infinity;
}
/* -------------------------------------------- */
/* Tear-Down */
/* -------------------------------------------- */
/** @inheritDoc */
async _tearDown(options) {
// Stop video playback
for ( const mesh of this.videoMeshes ) game.video.stop(mesh.sourceElement);
await super._tearDown(options);
// Clear collections
this.videoMeshes.clear();
this.tokens.clear();
this.tiles.clear();
// Clear the quadtree
this.quadtree.clear();
// Reset the tiling sprite tracker
this.#tilingSpriteError = false;
}
/* -------------------------------------------- */
/* Token Management */
/* -------------------------------------------- */
/**
* Draw the SpriteMesh for a specific Token object.
* @param {Token} token The Token being added
* @returns {PrimarySpriteMesh} The added PrimarySpriteMesh
*/
addToken(token) {
const name = token.objectId;
// Create the token mesh
const mesh = this.tokens.get(name) ?? this.addChild(new PrimarySpriteMesh({name, object: token}));
mesh.texture = token.texture ?? PIXI.Texture.EMPTY;
this.tokens.set(name, mesh);
if ( mesh.isVideo ) this.videoMeshes.add(mesh);
return mesh;
}
/* -------------------------------------------- */
/**
* Remove a TokenMesh from the group.
* @param {Token} token The Token being removed
*/
removeToken(token) {
const name = token.objectId;
const mesh = this.tokens.get(name);
if ( mesh?.destroyed === false ) mesh.destroy({children: true});
this.tokens.delete(name);
this.videoMeshes.delete(mesh);
}
/* -------------------------------------------- */
/* Tile Management */
/* -------------------------------------------- */
/**
* Draw the SpriteMesh for a specific Token object.
* @param {Tile} tile The Tile being added
* @returns {PrimarySpriteMesh} The added PrimarySpriteMesh
*/
addTile(tile) {
/** @deprecated since v12 */
if ( !this.#tilingSpriteError && tile.document.getFlag("core", "isTilingSprite") ) {
this.#tilingSpriteError = true;
ui.notifications.warn("WARNING.TilingSpriteDeprecation", {localize: true, permanent: true});
const msg = "Tiling Sprites are deprecated without replacement.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
}
const name = tile.objectId;
let mesh = this.tiles.get(name) ?? this.addChild(new PrimarySpriteMesh({name, object: tile}));
mesh.texture = tile.texture ?? PIXI.Texture.EMPTY;
this.tiles.set(name, mesh);
if ( mesh.isVideo ) this.videoMeshes.add(mesh);
return mesh;
}
/* -------------------------------------------- */
/**
* Remove a TokenMesh from the group.
* @param {Tile} tile The Tile being removed
*/
removeTile(tile) {
const name = tile.objectId;
const mesh = this.tiles.get(name);
if ( mesh?.destroyed === false ) mesh.destroy({children: true});
this.tiles.delete(name);
this.videoMeshes.delete(mesh);
}
/* -------------------------------------------- */
/* Drawing Management */
/* -------------------------------------------- */
/**
* Add a PrimaryGraphics to the group.
* @param {Drawing} drawing The Drawing being added
* @returns {PrimaryGraphics} The created PrimaryGraphics instance
*/
addDrawing(drawing) {
const name = drawing.objectId;
const shape = this.drawings.get(name) ?? this.addChild(new PrimaryGraphics({name, object: drawing}));
this.drawings.set(name, shape);
return shape;
}
/* -------------------------------------------- */
/**
* Remove a PrimaryGraphics from the group.
* @param {Drawing} drawing The Drawing being removed
*/
removeDrawing(drawing) {
const name = drawing.objectId;
if ( !this.drawings.has(name) ) return;
const shape = this.drawings.get(name);
if ( shape?.destroyed === false ) shape.destroy({children: true});
this.drawings.delete(name);
}
/* -------------------------------------------- */
/**
* Override the default PIXI.Container behavior for how objects in this container are sorted.
* @override
*/
sortChildren() {
const children = this.children;
for ( let i = 0, n = children.length; i < n; i++ ) children[i]._lastSortedIndex = i;
children.sort(PrimaryCanvasGroup.#compareObjects);
this.sortDirty = false;
}
/* -------------------------------------------- */
/**
* The sorting function used to order objects inside the Primary Canvas Group.
* Overrides the default sorting function defined for the PIXI.Container.
* Sort Tokens PCO above other objects except WeatherEffects, then Drawings PCO, all else held equal.
* @param {PrimaryCanvasObject|PIXI.DisplayObject} a An object to display
* @param {PrimaryCanvasObject|PIXI.DisplayObject} b Some other object to display
* @returns {number}
*/
static #compareObjects(a, b) {
return ((a.elevation || 0) - (b.elevation || 0))
|| ((a.sortLayer || 0) - (b.sortLayer || 0))
|| ((a.sort || 0) - (b.sort || 0))
|| (a.zIndex - b.zIndex)
|| (a._lastSortedIndex - b._lastSortedIndex);
}
/* -------------------------------------------- */
/* PIXI Events */
/* -------------------------------------------- */
/**
* Called when a child is added.
* @param {PIXI.DisplayObject} child
*/
#onChildAdded(child) {
if ( child.shouldRenderDepth ) canvas.masks.depth._elevationDirty = true;
}
/* -------------------------------------------- */
/**
* Called when a child is removed.
* @param {PIXI.DisplayObject} child
*/
#onChildRemoved(child) {
if ( child.shouldRenderDepth ) canvas.masks.depth._elevationDirty = true;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Handle mousemove events on the primary group to update the hovered state of its children.
* @internal
*/
_onMouseMove() {
const time = canvas.app.ticker.lastTime;
// Unset the hovered state of the hovered PCOs
for ( const object of this.#hoveredObjects ) {
if ( !object._hoverFadeState.hovered ) continue;
object._hoverFadeState.hovered = false;
object._hoverFadeState.hoveredTime = time;
}
this.#updateHoveredObjects();
// Set the hovered state of the hovered PCOs
for ( const object of this.#hoveredObjects ) {
if ( !object.hoverFade || !(object.elevation > this.hoverFadeElevation) ) break;
object._hoverFadeState.hovered = true;
object._hoverFadeState.hoveredTime = time;
}
}
/* -------------------------------------------- */
/**
* Update the hovered objects. Returns the hovered objects.
*/
#updateHoveredObjects() {
this.#hoveredObjects.length = 0;
// Get all PCOs that contain the mouse position
const position = canvas.mousePosition;
const collisionTest = ({t}) => t.visible && t.renderable
&& t._hoverFadeState && t.containsCanvasPoint(position);
for ( const object of canvas.primary.quadtree.getObjects(
new PIXI.Rectangle(position.x, position.y, 0, 0), {collisionTest}
)) {
this.#hoveredObjects.push(object);
}
// Sort the hovered PCOs in reverse primary order
this.#hoveredObjects.sort((a, b) => PrimaryCanvasGroup.#compareObjects(b, a));
// Discard hit objects below the hovered placeable
const hoveredPlaceable = canvas.activeLayer?.hover;
if ( hoveredPlaceable ) {
let elevation = 0;
let sortLayer = Infinity;
let sort = Infinity;
let zIndex = Infinity;
if ( (hoveredPlaceable instanceof Token) || (hoveredPlaceable instanceof Tile) ) {
const mesh = hoveredPlaceable.mesh;
if ( mesh ) {
elevation = mesh.elevation;
sortLayer = mesh.sortLayer;
sort = mesh.sort;
zIndex = mesh.zIndex;
}
} else if ( hoveredPlaceable instanceof Drawing ) {
const shape = hoveredPlaceable.shape;
if ( shape ) {
elevation = shape.elevation;
sortLayer = shape.sortLayer;
sort = shape.sort;
zIndex = shape.zIndex;
}
} else if ( hoveredPlaceable.document.schema.has("elevation") ) {
elevation = hoveredPlaceable.document.elevation;
}
const threshold = {elevation, sortLayer, sort, zIndex, _lastSortedIndex: Infinity};
while ( this.#hoveredObjects.length
&& PrimaryCanvasGroup.#compareObjects(this.#hoveredObjects.at(-1), threshold) <= 0 ) {
this.#hoveredObjects.pop();
}
}
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
mapElevationToDepth(elevation) {
const msg = "PrimaryCanvasGroup#mapElevationAlpha is deprecated. "
+ "Use canvas.masks.depth.mapElevation(elevation) instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return canvas.masks.depth.mapElevation(elevation);
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
mapElevationAlpha(elevation) {
const msg = "PrimaryCanvasGroup#mapElevationAlpha is deprecated. "
+ "Use canvas.masks.depth.mapElevation(elevation) instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return canvas.masks.depth.mapElevation(elevation);
}
}