375 lines
13 KiB
JavaScript
375 lines
13 KiB
JavaScript
/**
|
|
* A CanvasLayer for displaying visual effects like weather, transitions, flashes, or more.
|
|
*/
|
|
class WeatherEffects extends FullCanvasObjectMixin(CanvasLayer) {
|
|
constructor() {
|
|
super();
|
|
this.#initializeFilters();
|
|
this.mask = canvas.masks.scene;
|
|
this.sortableChildren = true;
|
|
this.eventMode = "none";
|
|
}
|
|
|
|
/**
|
|
* The container in which effects are added.
|
|
* @type {PIXI.Container}
|
|
*/
|
|
weatherEffects;
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* The container in which suppression meshed are added.
|
|
* @type {PIXI.Container}
|
|
*/
|
|
suppression;
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Initialize the inverse occlusion and the void filters.
|
|
*/
|
|
#initializeFilters() {
|
|
this.#suppressionFilter = VoidFilter.create();
|
|
this.occlusionFilter = WeatherOcclusionMaskFilter.create({
|
|
occlusionTexture: canvas.masks.depth.renderTexture
|
|
});
|
|
this.#suppressionFilter.enabled = this.occlusionFilter.enabled = false;
|
|
// FIXME: this does not produce correct results for weather effects that are configured
|
|
// with the occlusion filter disabled and use a different blend mode than SCREEN
|
|
this.#suppressionFilter.blendMode = PIXI.BLEND_MODES.SCREEN;
|
|
this.occlusionFilter.elevation = this.#elevation;
|
|
this.filterArea = canvas.app.renderer.screen;
|
|
this.filters = [this.occlusionFilter, this.#suppressionFilter];
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
static get layerOptions() {
|
|
return foundry.utils.mergeObject(super.layerOptions, {name: "effects"});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Array of weather effects linked to this weather container.
|
|
* @type {Map<string,(ParticleEffect|WeatherShaderEffect)[]>}
|
|
*/
|
|
effects = new Map();
|
|
|
|
/**
|
|
* @typedef {Object} WeatherTerrainMaskConfiguration
|
|
* @property {boolean} enabled Enable or disable this mask.
|
|
* @property {number[]} channelWeights An RGBA array of channel weights applied to the mask texture.
|
|
* @property {boolean} [reverse=false] If the mask should be reversed.
|
|
* @property {PIXI.Texture|PIXI.RenderTexture} texture A texture which defines the mask region.
|
|
*/
|
|
|
|
/**
|
|
* A default configuration of the terrain mask that is automatically applied to any shader-based weather effects.
|
|
* This configuration is automatically passed to WeatherShaderEffect#configureTerrainMask upon construction.
|
|
* @type {WeatherTerrainMaskConfiguration}
|
|
*/
|
|
terrainMaskConfig;
|
|
|
|
/**
|
|
* @typedef {Object} WeatherOcclusionMaskConfiguration
|
|
* @property {boolean} enabled Enable or disable this mask.
|
|
* @property {number[]} channelWeights An RGBA array of channel weights applied to the mask texture.
|
|
* @property {boolean} [reverse=false] If the mask should be reversed.
|
|
* @property {PIXI.Texture|PIXI.RenderTexture} texture A texture which defines the mask region.
|
|
*/
|
|
|
|
/**
|
|
* A default configuration of the terrain mask that is automatically applied to any shader-based weather effects.
|
|
* This configuration is automatically passed to WeatherShaderEffect#configureTerrainMask upon construction.
|
|
* @type {WeatherOcclusionMaskConfiguration}
|
|
*/
|
|
occlusionMaskConfig;
|
|
|
|
/**
|
|
* The inverse occlusion mask filter bound to this container.
|
|
* @type {WeatherOcclusionMaskFilter}
|
|
*/
|
|
occlusionFilter;
|
|
|
|
/**
|
|
* The filter that is needed for suppression if the occlusion filter isn't enabled.
|
|
* @type {VoidFilter}
|
|
*/
|
|
#suppressionFilter;
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* The elevation of this object.
|
|
* @type {number}
|
|
* @default Infinity
|
|
*/
|
|
get elevation() {
|
|
return this.#elevation;
|
|
}
|
|
|
|
set elevation(value) {
|
|
if ( (typeof value !== "number") || Number.isNaN(value) ) {
|
|
throw new Error("WeatherEffects#elevation must be a numeric value.");
|
|
}
|
|
if ( value === this.#elevation ) return;
|
|
this.#elevation = value;
|
|
if ( this.parent ) this.parent.sortDirty = true;
|
|
}
|
|
|
|
#elevation = Infinity;
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* A key which resolves ties amongst objects at the same elevation of different layers.
|
|
* @type {number}
|
|
* @default PrimaryCanvasGroup.SORT_LAYERS.WEATHER
|
|
*/
|
|
get sortLayer() {
|
|
return this.#sortLayer;
|
|
}
|
|
|
|
set sortLayer(value) {
|
|
if ( (typeof value !== "number") || Number.isNaN(value) ) {
|
|
throw new Error("WeatherEffects#sortLayer must be a numeric value.");
|
|
}
|
|
if ( value === this.#sortLayer ) return;
|
|
this.#sortLayer = value;
|
|
if ( this.parent ) this.parent.sortDirty = true;
|
|
}
|
|
|
|
#sortLayer = PrimaryCanvasGroup.SORT_LAYERS.WEATHER;
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* A key which resolves ties amongst objects at the same elevation within the same layer.
|
|
* @type {number}
|
|
* @default 0
|
|
*/
|
|
get sort() {
|
|
return this.#sort;
|
|
}
|
|
|
|
set sort(value) {
|
|
if ( (typeof value !== "number") || Number.isNaN(value) ) {
|
|
throw new Error("WeatherEffects#sort must be a numeric value.");
|
|
}
|
|
if ( value === this.#sort ) return;
|
|
this.#sort = value;
|
|
if ( this.parent ) this.parent.sortDirty = true;
|
|
}
|
|
|
|
#sort = 0;
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* A key which resolves ties amongst objects at the same elevation within the same layer and same sort.
|
|
* @type {number}
|
|
* @default 0
|
|
*/
|
|
get zIndex() {
|
|
return this._zIndex;
|
|
}
|
|
|
|
set zIndex(value) {
|
|
if ( (typeof value !== "number") || Number.isNaN(value) ) {
|
|
throw new Error("WeatherEffects#zIndex must be a numeric value.");
|
|
}
|
|
if ( value === this._zIndex ) return;
|
|
this._zIndex = value;
|
|
if ( this.parent ) this.parent.sortDirty = true;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Weather Effect Rendering */
|
|
/* -------------------------------------------- */
|
|
|
|
/** @override */
|
|
async _draw(options) {
|
|
const effect = CONFIG.weatherEffects[canvas.scene.weather];
|
|
this.weatherEffects = this.addChild(new PIXI.Container());
|
|
this.suppression = this.addChild(new PIXI.Container());
|
|
for ( const event of ["childAdded", "childRemoved"] ) {
|
|
this.suppression.on(event, () => {
|
|
this.#suppressionFilter.enabled = !this.occlusionFilter.enabled && !!this.suppression.children.length;
|
|
});
|
|
}
|
|
this.initializeEffects(effect);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritDoc */
|
|
async _tearDown(options) {
|
|
this.clearEffects();
|
|
return super._tearDown(options);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Weather Effect Management */
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Initialize the weather container from a weather config object.
|
|
* @param {object} [weatherEffectsConfig] Weather config object (or null/undefined to clear the container).
|
|
*/
|
|
initializeEffects(weatherEffectsConfig) {
|
|
this.#destroyEffects();
|
|
Hooks.callAll("initializeWeatherEffects", this, weatherEffectsConfig);
|
|
this.#constructEffects(weatherEffectsConfig);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Clear the weather container.
|
|
*/
|
|
clearEffects() {
|
|
this.initializeEffects(null);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Destroy all effects associated with this weather container.
|
|
*/
|
|
#destroyEffects() {
|
|
if ( this.effects.size === 0 ) return;
|
|
for ( const effect of this.effects.values() ) effect.destroy();
|
|
this.effects.clear();
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Construct effects according to the weather effects config object.
|
|
* @param {object} [weatherEffectsConfig] Weather config object (or null/undefined to clear the container).
|
|
*/
|
|
#constructEffects(weatherEffectsConfig) {
|
|
if ( !weatherEffectsConfig ) {
|
|
this.#suppressionFilter.enabled = this.occlusionFilter.enabled = false;
|
|
return;
|
|
}
|
|
const effects = weatherEffectsConfig.effects;
|
|
let zIndex = 0;
|
|
|
|
// Enable a layer-wide occlusion filter unless it is explicitly disabled by the effect configuration
|
|
const useOcclusionFilter = weatherEffectsConfig.filter?.enabled !== false;
|
|
if ( useOcclusionFilter ) {
|
|
WeatherEffects.configureOcclusionMask(this.occlusionFilter, this.occlusionMaskConfig || {enabled: true});
|
|
if ( this.terrainMaskConfig ) WeatherEffects.configureTerrainMask(this.occlusionFilter, this.terrainMaskConfig);
|
|
this.occlusionFilter.blendMode = weatherEffectsConfig.filter?.blendMode ?? PIXI.BLEND_MODES.NORMAL;
|
|
this.occlusionFilter.enabled = true;
|
|
this.#suppressionFilter.enabled = false;
|
|
}
|
|
else {
|
|
this.#suppressionFilter.enabled = !!this.suppression.children.length;
|
|
}
|
|
|
|
// Create each effect
|
|
for ( const effect of effects ) {
|
|
const requiredPerformanceLevel = Number.isNumeric(effect.performanceLevel) ? effect.performanceLevel : 0;
|
|
if ( canvas.performance.mode < requiredPerformanceLevel ) {
|
|
console.debug(`Skipping weather effect ${effect.id}. The client performance level ${canvas.performance.mode}`
|
|
+ ` is less than the required performance mode ${requiredPerformanceLevel} for the effect`);
|
|
continue;
|
|
}
|
|
|
|
// Construct the effect container
|
|
let ec;
|
|
try {
|
|
ec = new effect.effectClass(effect.config, effect.shaderClass);
|
|
} catch(err) {
|
|
err.message = `Failed to construct weather effect: ${err.message}`;
|
|
console.error(err);
|
|
continue;
|
|
}
|
|
|
|
// Configure effect container
|
|
ec.zIndex = effect.zIndex ?? zIndex++;
|
|
ec.blendMode = effect.blendMode ?? PIXI.BLEND_MODES.NORMAL;
|
|
|
|
// Apply effect-level occlusion and terrain masking only if we are not using a layer-wide filter
|
|
if ( effect.shaderClass && !useOcclusionFilter ) {
|
|
WeatherEffects.configureOcclusionMask(ec.shader, this.occlusionMaskConfig || {enabled: true});
|
|
if ( this.terrainMaskConfig ) WeatherEffects.configureTerrainMask(ec.shader, this.terrainMaskConfig);
|
|
}
|
|
|
|
// Add to the layer, register the effect, and begin play
|
|
this.weatherEffects.addChild(ec);
|
|
this.effects.set(effect.id, ec);
|
|
ec.play();
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Set the occlusion uniforms for this weather shader.
|
|
* @param {PIXI.Shader} context The shader context
|
|
* @param {WeatherOcclusionMaskConfiguration} config Occlusion masking options
|
|
* @protected
|
|
*/
|
|
static configureOcclusionMask(context, {enabled=false, channelWeights=[0, 0, 1, 0], reverse=false, texture}={}) {
|
|
if ( !(context instanceof PIXI.Shader) ) return;
|
|
const uniforms = context.uniforms;
|
|
if ( texture !== undefined ) uniforms.occlusionTexture = texture;
|
|
else uniforms.occlusionTexture ??= canvas.masks.depth.renderTexture;
|
|
uniforms.useOcclusion = enabled;
|
|
uniforms.occlusionWeights = channelWeights;
|
|
uniforms.reverseOcclusion = reverse;
|
|
if ( enabled && !uniforms.occlusionTexture ) {
|
|
console.warn(`The occlusion configuration for the weather shader ${context.constructor.name} is enabled but`
|
|
+ " does not have a valid texture");
|
|
uniforms.useOcclusion = false;
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Set the terrain uniforms for this weather shader.
|
|
* @param {PIXI.Shader} context The shader context
|
|
* @param {WeatherTerrainMaskConfiguration} config Terrain masking options
|
|
* @protected
|
|
*/
|
|
static configureTerrainMask(context, {enabled=false, channelWeights=[1, 0, 0, 0], reverse=false, texture}={}) {
|
|
if ( !(context instanceof PIXI.Shader) ) return;
|
|
const uniforms = context.uniforms;
|
|
if ( texture !== undefined ) {
|
|
uniforms.terrainTexture = texture;
|
|
const terrainMatrix = new PIXI.TextureMatrix(texture);
|
|
terrainMatrix.update();
|
|
uniforms.terrainUvMatrix.copyFrom(terrainMatrix.mapCoord);
|
|
}
|
|
uniforms.useTerrain = enabled;
|
|
uniforms.terrainWeights = channelWeights;
|
|
uniforms.reverseTerrain = reverse;
|
|
if ( enabled && !uniforms.terrainTexture ) {
|
|
console.warn(`The terrain configuration for the weather shader ${context.constructor.name} is enabled but`
|
|
+ " does not have a valid texture");
|
|
uniforms.useTerrain = false;
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Deprecations and Compatibility */
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* @deprecated since v11
|
|
* @ignore
|
|
*/
|
|
get weather() {
|
|
const msg = "The WeatherContainer at canvas.weather.weather is deprecated and combined with the layer itself.";
|
|
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
|
|
return this;
|
|
}
|
|
}
|