Initial
This commit is contained in:
10
resources/app/client-esm/canvas/sources/_module.mjs
Normal file
10
resources/app/client-esm/canvas/sources/_module.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
export {default as BaseEffectSource} from "./base-effect-source.mjs";
|
||||
export {default as BaseLightSource} from "./base-light-source.mjs";
|
||||
export {default as GlobalLightSource} from "./global-light-source.mjs";
|
||||
export {default as PointDarknessSource} from "./point-darkness-source.mjs";
|
||||
export {default as PointEffectSourceMixin} from "./point-effect-source.mjs";
|
||||
export {default as PointLightSource} from "./point-light-source.mjs";
|
||||
export {default as PointMovementSource} from "./point-movement-source.mjs";
|
||||
export {default as PointSoundSource} from "./point-sound-source.mjs";
|
||||
export {default as PointVisionSource} from "./point-vision-source.mjs";
|
||||
export {default as RenderedEffectSource} from "./rendered-effect-source.mjs";
|
||||
370
resources/app/client-esm/canvas/sources/base-effect-source.mjs
Normal file
370
resources/app/client-esm/canvas/sources/base-effect-source.mjs
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* @typedef {Object} BasseEffectSourceOptions
|
||||
* @property {PlaceableObject} [options.object] An optional PlaceableObject which is responsible for this source
|
||||
* @property {string} [options.sourceId] A unique ID for this source. This will be set automatically if an
|
||||
* object is provided, otherwise is required.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BaseEffectSourceData
|
||||
* @property {number} x The x-coordinate of the source location
|
||||
* @property {number} y The y-coordinate of the source location
|
||||
* @property {number} elevation The elevation of the point source
|
||||
* @property {boolean} disabled Whether or not the source is disabled
|
||||
*/
|
||||
|
||||
/**
|
||||
* TODO - Re-document after ESM refactor.
|
||||
* An abstract base class which defines a framework for effect sources which originate radially from a specific point.
|
||||
* This abstraction is used by the LightSource, VisionSource, SoundSource, and MovementSource subclasses.
|
||||
*
|
||||
* @example A standard PointSource lifecycle:
|
||||
* ```js
|
||||
* const source = new PointSource({object}); // Create the point source
|
||||
* source.initialize(data); // Configure the point source with new data
|
||||
* source.refresh(); // Refresh the point source
|
||||
* source.destroy(); // Destroy the point source
|
||||
* ```
|
||||
*
|
||||
* @template {BaseEffectSourceData} SourceData
|
||||
* @template {PIXI.Polygon} SourceShape
|
||||
* @abstract
|
||||
*/
|
||||
export default class BaseEffectSource {
|
||||
/**
|
||||
* An effect source is constructed by providing configuration options.
|
||||
* @param {BasseEffectSourceOptions} [options] Options which modify the base effect source instance
|
||||
*/
|
||||
constructor(options={}) {
|
||||
if ( options instanceof PlaceableObject ) {
|
||||
const warning = "The constructor PointSource(PlaceableObject) is deprecated. "
|
||||
+ "Use new PointSource({ object }) instead.";
|
||||
foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
|
||||
this.object = options;
|
||||
this.sourceId = this.object.sourceId;
|
||||
}
|
||||
else {
|
||||
this.object = options.object ?? null;
|
||||
this.sourceId = options.sourceId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of source represented by this data structure.
|
||||
* Each subclass must implement this attribute.
|
||||
* @type {string}
|
||||
*/
|
||||
static sourceType;
|
||||
|
||||
/**
|
||||
* The target collection into the effects canvas group.
|
||||
* @type {string}
|
||||
* @abstract
|
||||
*/
|
||||
static effectsCollection;
|
||||
|
||||
/**
|
||||
* Effect source default data.
|
||||
* @type {SourceData}
|
||||
*/
|
||||
static defaultData = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
elevation: 0,
|
||||
disabled: false
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Source Data */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Some other object which is responsible for this source.
|
||||
* @type {object|null}
|
||||
*/
|
||||
object;
|
||||
|
||||
/**
|
||||
* The source id linked to this effect source.
|
||||
* @type {Readonly<string>}
|
||||
*/
|
||||
sourceId;
|
||||
|
||||
/**
|
||||
* The data of this source.
|
||||
* @type {SourceData}
|
||||
*/
|
||||
data = foundry.utils.deepClone(this.constructor.defaultData);
|
||||
|
||||
/**
|
||||
* The geometric shape of the effect source which is generated later.
|
||||
* @type {SourceShape}
|
||||
*/
|
||||
shape;
|
||||
|
||||
/**
|
||||
* A collection of boolean flags which control rendering and refresh behavior for the source.
|
||||
* @type {Record<string, boolean|number>}
|
||||
* @protected
|
||||
*/
|
||||
_flags = {};
|
||||
|
||||
/**
|
||||
* The x-coordinate of the point source origin.
|
||||
* @type {number}
|
||||
*/
|
||||
get x() {
|
||||
return this.data.x;
|
||||
}
|
||||
|
||||
/**
|
||||
* The y-coordinate of the point source origin.
|
||||
* @type {number}
|
||||
*/
|
||||
get y() {
|
||||
return this.data.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* The elevation bound to this source.
|
||||
* @type {number}
|
||||
*/
|
||||
get elevation() {
|
||||
return this.data.elevation;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Source State */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The EffectsCanvasGroup collection linked to this effect source.
|
||||
* @type {Collection<string, BaseEffectSource>}
|
||||
*/
|
||||
get effectsCollection() {
|
||||
return canvas.effects[this.constructor.effectsCollection];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the update ID associated with this source.
|
||||
* The update ID is increased whenever the shape of the source changes.
|
||||
* @type {number}
|
||||
*/
|
||||
get updateId() {
|
||||
return this.#updateId;
|
||||
}
|
||||
|
||||
#updateId = 0;
|
||||
|
||||
/**
|
||||
* Is this source currently active?
|
||||
* A source is active if it is attached to an effect collection and is not disabled or suppressed.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get active() {
|
||||
return this.#attached && !this.data.disabled && !this.suppressed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this source attached to an effect collection?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get attached() {
|
||||
return this.#attached;
|
||||
}
|
||||
|
||||
#attached = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Source Suppression Management */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this source temporarily suppressed?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get suppressed() {
|
||||
return Object.values(this.suppression).includes(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Records of suppression strings with a boolean value.
|
||||
* If any of this record is true, the source is suppressed.
|
||||
* @type {Record<string, boolean>}
|
||||
*/
|
||||
suppression = {};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Source Initialization */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize and configure the source using provided data.
|
||||
* @param {Partial<SourceData>} data Provided data for configuration
|
||||
* @param {object} options Additional options which modify source initialization
|
||||
* @param {object} [options.behaviors] An object containing optional behaviors to apply.
|
||||
* @param {boolean} [options.reset=false] Should source data be reset to default values before applying changes?
|
||||
* @returns {BaseEffectSource} The initialized source
|
||||
*/
|
||||
initialize(data={}, {reset=false}={}) {
|
||||
// Reset the source back to default data
|
||||
if ( reset ) data = Object.assign(foundry.utils.deepClone(this.constructor.defaultData), data);
|
||||
|
||||
// Update data for the source
|
||||
let changes = {};
|
||||
if ( !foundry.utils.isEmpty(data) ) {
|
||||
const prior = foundry.utils.deepClone(this.data) || {};
|
||||
for ( const key in data ) {
|
||||
if ( !(key in this.data) ) continue;
|
||||
this.data[key] = data[key] ?? this.constructor.defaultData[key];
|
||||
}
|
||||
this._initialize(data);
|
||||
changes = foundry.utils.flattenObject(foundry.utils.diffObject(prior, this.data));
|
||||
}
|
||||
|
||||
// Update shapes for the source
|
||||
try {
|
||||
this._createShapes();
|
||||
this.#updateId++;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
this.remove();
|
||||
}
|
||||
|
||||
// Configure attached and non disabled sources
|
||||
if ( this.#attached && !this.data.disabled ) this._configure(changes);
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Subclass specific data initialization steps.
|
||||
* @param {Partial<SourceData>} data Provided data for configuration
|
||||
* @abstract
|
||||
*/
|
||||
_initialize(data) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create the polygon shape (or shapes) for this source using configured data.
|
||||
* @protected
|
||||
* @abstract
|
||||
*/
|
||||
_createShapes() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Subclass specific configuration steps. Occurs after data initialization and shape computation.
|
||||
* Only called if the source is attached and not disabled.
|
||||
* @param {Partial<SourceData>} changes Changes to the source data which were applied
|
||||
* @protected
|
||||
*/
|
||||
_configure(changes) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Source Refresh */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Refresh the state and uniforms of the source.
|
||||
* Only active sources are refreshed.
|
||||
*/
|
||||
refresh() {
|
||||
if ( !this.active ) return;
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Subclass-specific refresh steps.
|
||||
* @protected
|
||||
* @abstract
|
||||
*/
|
||||
_refresh() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Source Destruction */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Steps that must be performed when the source is destroyed.
|
||||
*/
|
||||
destroy() {
|
||||
this.remove();
|
||||
this._destroy();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Subclass specific destruction steps.
|
||||
* @protected
|
||||
*/
|
||||
_destroy() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Source Management */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add this BaseEffectSource instance to the active collection.
|
||||
*/
|
||||
add() {
|
||||
if ( !this.sourceId ) throw new Error("A BaseEffectSource cannot be added to the active collection unless it has"
|
||||
+ " a sourceId assigned.");
|
||||
this.effectsCollection.set(this.sourceId, this);
|
||||
const wasConfigured = this.#attached && !this.data.disabled;
|
||||
this.#attached = true;
|
||||
if ( !wasConfigured && !this.data.disabled ) this._configure({});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove this BaseEffectSource instance from the active collection.
|
||||
*/
|
||||
remove() {
|
||||
if ( !this.effectsCollection.has(this.sourceId) ) return;
|
||||
this.effectsCollection.delete(this.sourceId);
|
||||
this.#attached = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
get sourceType() {
|
||||
const msg = "BaseEffectSource#sourceType is deprecated. Use BaseEffectSource.sourceType instead.";
|
||||
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
|
||||
return this.constructor.sourceType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
_createShape() {
|
||||
const msg = "BaseEffectSource#_createShape is deprecated in favor of BaseEffectSource#_createShapes.";
|
||||
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
|
||||
return this._createShapes();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get disabled() {
|
||||
foundry.utils.logCompatibilityWarning("BaseEffectSource#disabled is deprecated in favor of " +
|
||||
"BaseEffectSource#data#disabled or BaseEffectSource#active depending on your use case.", { since: 11, until: 13});
|
||||
return this.data.disabled;
|
||||
}
|
||||
}
|
||||
306
resources/app/client-esm/canvas/sources/base-light-source.mjs
Normal file
306
resources/app/client-esm/canvas/sources/base-light-source.mjs
Normal file
@@ -0,0 +1,306 @@
|
||||
import RenderedEffectSource from "./rendered-effect-source.mjs";
|
||||
import {LIGHTING_LEVELS} from "../../../common/constants.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./base-effect-source.mjs").BaseEffectSourceData} BaseEffectSourceData
|
||||
* @typedef {import("./rendered-effect-source-source.mjs").RenderedEffectSourceData} RenderedEffectSourceData
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} LightSourceData
|
||||
* @property {number} alpha An opacity for the emitted light, if any
|
||||
* @property {number} bright The allowed radius of bright vision or illumination
|
||||
* @property {number} coloration The coloration technique applied in the shader
|
||||
* @property {number} contrast The amount of contrast this light applies to the background texture
|
||||
* @property {number} dim The allowed radius of dim vision or illumination
|
||||
* @property {number} attenuation Strength of the attenuation between bright, dim, and dark
|
||||
* @property {number} luminosity The luminosity applied in the shader
|
||||
* @property {number} saturation The amount of color saturation this light applies to the background texture
|
||||
* @property {number} shadows The depth of shadows this light applies to the background texture
|
||||
* @property {boolean} vision Whether or not this source provides a source of vision
|
||||
* @property {number} priority Strength of this source to beat or not negative/positive sources
|
||||
*/
|
||||
|
||||
/**
|
||||
* A specialized subclass of BaseEffectSource which deals with the rendering of light or darkness.
|
||||
* @extends {RenderedEffectSource<BaseEffectSourceData & RenderedEffectSourceData & LightSourceData>}
|
||||
* @abstract
|
||||
*/
|
||||
export default class BaseLightSource extends RenderedEffectSource {
|
||||
/** @override */
|
||||
static sourceType = "light";
|
||||
|
||||
/** @override */
|
||||
static _initializeShaderKeys = ["animation.type", "walls"];
|
||||
|
||||
/** @override */
|
||||
static _refreshUniformsKeys = ["dim", "bright", "attenuation", "alpha", "coloration", "color", "contrast",
|
||||
"saturation", "shadows", "luminosity"];
|
||||
|
||||
/**
|
||||
* The corresponding lighting levels for dim light.
|
||||
* @type {number}
|
||||
* @protected
|
||||
*/
|
||||
static _dimLightingLevel = LIGHTING_LEVELS.DIM;
|
||||
|
||||
/**
|
||||
* The corresponding lighting levels for bright light.
|
||||
* @type {string}
|
||||
* @protected
|
||||
*/
|
||||
static _brightLightingLevel = LIGHTING_LEVELS.BRIGHT;
|
||||
|
||||
/**
|
||||
* The corresponding animation config.
|
||||
* @type {LightSourceAnimationConfig}
|
||||
* @protected
|
||||
*/
|
||||
static get ANIMATIONS() {
|
||||
return CONFIG.Canvas.lightAnimations;
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
static defaultData = {
|
||||
...super.defaultData,
|
||||
priority: 0,
|
||||
alpha: 0.5,
|
||||
bright: 0,
|
||||
coloration: 1,
|
||||
contrast: 0,
|
||||
dim: 0,
|
||||
attenuation: 0.5,
|
||||
luminosity: 0.5,
|
||||
saturation: 0,
|
||||
shadows: 0,
|
||||
vision: false
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Light Source Attributes */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A ratio of dim:bright as part of the source radius
|
||||
* @type {number}
|
||||
*/
|
||||
ratio = 1;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Light Source Initialization */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_initialize(data) {
|
||||
super._initialize(data);
|
||||
const animationConfig = foundry.utils.deepClone(this.constructor.ANIMATIONS[this.data.animation.type] || {});
|
||||
this.animation = Object.assign(this.data.animation, animationConfig);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Shader Management */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_updateColorationUniforms() {
|
||||
super._updateColorationUniforms();
|
||||
const u = this.layers.coloration.shader?.uniforms;
|
||||
if ( !u ) return;
|
||||
|
||||
// Adapting color intensity to the coloration technique
|
||||
switch ( this.data.coloration ) {
|
||||
case 0: // Legacy
|
||||
// Default 0.25 -> Legacy technique needs quite low intensity default to avoid washing background
|
||||
u.colorationAlpha = Math.pow(this.data.alpha, 2);
|
||||
break;
|
||||
case 4: // Color burn
|
||||
case 5: // Internal burn
|
||||
case 6: // External burn
|
||||
case 9: // Invert absorption
|
||||
// Default 0.5 -> These techniques are better at low color intensity
|
||||
u.colorationAlpha = this.data.alpha;
|
||||
break;
|
||||
default:
|
||||
// Default 1 -> The remaining techniques use adaptive lighting,
|
||||
// which produces interesting results in the [0, 2] range.
|
||||
u.colorationAlpha = this.data.alpha * 2;
|
||||
}
|
||||
|
||||
u.useSampler = this.data.coloration > 0; // Not needed for legacy coloration (technique id 0)
|
||||
|
||||
// Flag uniforms as updated
|
||||
this.layers.coloration.reset = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_updateIlluminationUniforms() {
|
||||
super._updateIlluminationUniforms();
|
||||
const u = this.layers.illumination.shader?.uniforms;
|
||||
if ( !u ) return;
|
||||
u.useSampler = false;
|
||||
|
||||
// Flag uniforms as updated
|
||||
const i = this.layers.illumination;
|
||||
i.reset = i.suppressed = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_updateBackgroundUniforms() {
|
||||
super._updateBackgroundUniforms();
|
||||
const u = this.layers.background.shader?.uniforms;
|
||||
if ( !u ) return;
|
||||
|
||||
canvas.colors.background.applyRGB(u.colorBackground);
|
||||
u.backgroundAlpha = this.data.alpha;
|
||||
u.useSampler = true;
|
||||
|
||||
// Flag uniforms as updated
|
||||
this.layers.background.reset = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_updateCommonUniforms(shader) {
|
||||
const u = shader.uniforms;
|
||||
const c = canvas.colors;
|
||||
|
||||
// Passing common environment values
|
||||
u.computeIllumination = true;
|
||||
u.darknessLevel = canvas.environment.darknessLevel;
|
||||
c.ambientBrightest.applyRGB(u.ambientBrightest);
|
||||
c.ambientDarkness.applyRGB(u.ambientDarkness);
|
||||
c.ambientDaylight.applyRGB(u.ambientDaylight);
|
||||
u.weights[0] = canvas.environment.weights.dark;
|
||||
u.weights[1] = canvas.environment.weights.halfdark;
|
||||
u.weights[2] = canvas.environment.weights.dim;
|
||||
u.weights[3] = canvas.environment.weights.bright;
|
||||
u.dimLevelCorrection = this.constructor.getCorrectedLevel(this.constructor._dimLightingLevel);
|
||||
u.brightLevelCorrection = this.constructor.getCorrectedLevel(this.constructor._brightLightingLevel);
|
||||
|
||||
// Passing advanced color correction values
|
||||
u.luminosity = this.data.luminosity;
|
||||
u.exposure = this.data.luminosity * 2.0 - 1.0;
|
||||
u.contrast = (this.data.contrast < 0 ? this.data.contrast * 0.5 : this.data.contrast);
|
||||
u.saturation = this.data.saturation;
|
||||
u.shadows = this.data.shadows;
|
||||
u.hasColor = this._flags.hasColor;
|
||||
u.ratio = this.ratio;
|
||||
u.technique = this.data.coloration;
|
||||
// Graph: https://www.desmos.com/calculator/e7z0i7hrck
|
||||
// mapping [0,1] attenuation user value to [0,1] attenuation shader value
|
||||
if ( this.cachedAttenuation !== this.data.attenuation ) {
|
||||
this.computedAttenuation = (Math.cos(Math.PI * Math.pow(this.data.attenuation, 1.5)) - 1) / -2;
|
||||
this.cachedAttenuation = this.data.attenuation;
|
||||
}
|
||||
u.attenuation = this.computedAttenuation;
|
||||
u.elevation = this.data.elevation;
|
||||
u.color = this.colorRGB ?? shader.constructor.defaultUniforms.color;
|
||||
|
||||
// Passing screenDimensions to use screen size render textures
|
||||
u.screenDimensions = canvas.screenDimensions;
|
||||
if ( !u.depthTexture ) u.depthTexture = canvas.masks.depth.renderTexture;
|
||||
if ( !u.primaryTexture ) u.primaryTexture = canvas.primary.renderTexture;
|
||||
if ( !u.darknessLevelTexture ) u.darknessLevelTexture = canvas.effects.illumination.renderTexture;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Animation Functions */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An animation with flickering ratio and light intensity.
|
||||
* @param {number} dt Delta time
|
||||
* @param {object} [options={}] Additional options which modify the flame animation
|
||||
* @param {number} [options.speed=5] The animation speed, from 0 to 10
|
||||
* @param {number} [options.intensity=5] The animation intensity, from 1 to 10
|
||||
* @param {boolean} [options.reverse=false] Reverse the animation direction
|
||||
*/
|
||||
animateTorch(dt, {speed=5, intensity=5, reverse=false} = {}) {
|
||||
this.animateFlickering(dt, {speed, intensity, reverse, amplification: intensity / 5});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An animation with flickering ratio and light intensity
|
||||
* @param {number} dt Delta time
|
||||
* @param {object} [options={}] Additional options which modify the flame animation
|
||||
* @param {number} [options.speed=5] The animation speed, from 0 to 10
|
||||
* @param {number} [options.intensity=5] The animation intensity, from 1 to 10
|
||||
* @param {number} [options.amplification=1] Noise amplification (>1) or dampening (<1)
|
||||
* @param {boolean} [options.reverse=false] Reverse the animation direction
|
||||
*/
|
||||
animateFlickering(dt, {speed=5, intensity=5, reverse=false, amplification=1} = {}) {
|
||||
this.animateTime(dt, {speed, intensity, reverse});
|
||||
|
||||
// Create the noise object for the first frame
|
||||
const amplitude = amplification * 0.45;
|
||||
if ( !this._noise ) this._noise = new SmoothNoise({amplitude: amplitude, scale: 3, maxReferences: 2048});
|
||||
|
||||
// Update amplitude
|
||||
if ( this._noise.amplitude !== amplitude ) this._noise.amplitude = amplitude;
|
||||
|
||||
// Create noise from animation time. Range [0.0, 0.45]
|
||||
let n = this._noise.generate(this.animation.time);
|
||||
|
||||
// Update brightnessPulse and ratio with some noise in it
|
||||
const co = this.layers.coloration.shader;
|
||||
const il = this.layers.illumination.shader;
|
||||
co.uniforms.brightnessPulse = il.uniforms.brightnessPulse = 0.55 + n; // Range [0.55, 1.0 <* amplification>]
|
||||
co.uniforms.ratio = il.uniforms.ratio = (this.ratio * 0.9) + (n * 0.222);// Range [ratio * 0.9, ratio * ~1.0 <* amplification>]
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A basic "pulse" animation which expands and contracts.
|
||||
* @param {number} dt Delta time
|
||||
* @param {object} [options={}] Additional options which modify the pulse animation
|
||||
* @param {number} [options.speed=5] The animation speed, from 0 to 10
|
||||
* @param {number} [options.intensity=5] The animation intensity, from 1 to 10
|
||||
* @param {boolean} [options.reverse=false] Reverse the animation direction
|
||||
*/
|
||||
animatePulse(dt, {speed=5, intensity=5, reverse=false}={}) {
|
||||
|
||||
// Determine the animation timing
|
||||
let t = canvas.app.ticker.lastTime;
|
||||
if ( reverse ) t *= -1;
|
||||
this.animation.time = ((speed * t)/5000) + this.animation.seed;
|
||||
|
||||
// Define parameters
|
||||
const i = (10 - intensity) * 0.1;
|
||||
const w = 0.5 * (Math.cos(this.animation.time * 2.5) + 1);
|
||||
const wave = (a, b, w) => ((a - b) * w) + b;
|
||||
|
||||
// Pulse coloration
|
||||
const co = this.layers.coloration.shader;
|
||||
co.uniforms.intensity = intensity;
|
||||
co.uniforms.time = this.animation.time;
|
||||
co.uniforms.pulse = wave(1.2, i, w);
|
||||
|
||||
// Pulse illumination
|
||||
const il = this.layers.illumination.shader;
|
||||
il.uniforms.intensity = intensity;
|
||||
il.uniforms.time = this.animation.time;
|
||||
il.uniforms.ratio = wave(this.ratio, this.ratio * i, w);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get isDarkness() {
|
||||
const msg = "BaseLightSource#isDarkness is now obsolete. Use DarknessSource instead.";
|
||||
foundry.utils.logCompatibilityWarning(msg, { since: 12, until: 14});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import BaseLightSource from "./base-light-source.mjs";
|
||||
|
||||
/**
|
||||
* A specialized subclass of the BaseLightSource which is used to render global light source linked to the scene.
|
||||
*/
|
||||
export default class GlobalLightSource extends BaseLightSource {
|
||||
|
||||
/** @inheritDoc */
|
||||
static sourceType = "GlobalLight";
|
||||
|
||||
/** @override */
|
||||
static effectsCollection = "lightSources";
|
||||
|
||||
/** @inheritDoc */
|
||||
static defaultData = {
|
||||
...super.defaultData,
|
||||
rotation: 0,
|
||||
angle: 360,
|
||||
attenuation: 0,
|
||||
priority: -Infinity,
|
||||
vision: false,
|
||||
walls: false,
|
||||
elevation: Infinity,
|
||||
darkness: {min: 0, max: 0}
|
||||
}
|
||||
|
||||
/**
|
||||
* Name of this global light source.
|
||||
* @type {string}
|
||||
* @defaultValue GlobalLightSource.sourceType
|
||||
*/
|
||||
name = this.constructor.sourceType;
|
||||
|
||||
/**
|
||||
* A custom polygon placeholder.
|
||||
* @type {PIXI.Polygon|number[]|null}
|
||||
*/
|
||||
customPolygon = null;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Global Light Source Initialization */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_createShapes() {
|
||||
this.shape = this.customPolygon ?? canvas.dimensions.sceneRect.toPolygon();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_initializeSoftEdges() {
|
||||
this._flags.renderSoftEdges = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_updateGeometry() {
|
||||
const offset = this._flags.renderSoftEdges ? this.constructor.EDGE_OFFSET : 0;
|
||||
const pm = new PolygonMesher(this.shape, {offset});
|
||||
this._geometry = pm.triangulate(this._geometry);
|
||||
const bounds = this.shape instanceof PointSourcePolygon ? this.shape.bounds : this.shape.getBounds();
|
||||
if ( this._geometry.bounds ) this._geometry.bounds.copyFrom(bounds);
|
||||
else this._geometry.bounds = bounds;
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_updateCommonUniforms(shader) {
|
||||
super._updateCommonUniforms(shader);
|
||||
const {min, max} = this.data.darkness;
|
||||
const u = shader.uniforms;
|
||||
u.globalLight = true;
|
||||
u.globalLightThresholds[0] = min;
|
||||
u.globalLightThresholds[1] = max;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import BaseLightSource from "./base-light-source.mjs";
|
||||
import PointEffectSourceMixin from "./point-effect-source.mjs";
|
||||
import {LIGHTING_LEVELS} from "../../../common/constants.mjs";
|
||||
|
||||
/**
|
||||
* A specialized subclass of the BaseLightSource which renders a source of darkness as a point-based effect.
|
||||
* @extends {BaseLightSource}
|
||||
* @mixes {PointEffectSource}
|
||||
*/
|
||||
export default class PointDarknessSource extends PointEffectSourceMixin(BaseLightSource) {
|
||||
|
||||
/** @override */
|
||||
static effectsCollection = "darknessSources";
|
||||
|
||||
/** @override */
|
||||
static _dimLightingLevel = LIGHTING_LEVELS.HALFDARK;
|
||||
|
||||
/** @override */
|
||||
static _brightLightingLevel = LIGHTING_LEVELS.DARKNESS;
|
||||
|
||||
/** @override */
|
||||
static get ANIMATIONS() {
|
||||
return CONFIG.Canvas.darknessAnimations;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static get _layers() {
|
||||
return {
|
||||
darkness: {
|
||||
defaultShader: AdaptiveDarknessShader,
|
||||
blendMode: "MAX_COLOR"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional geometric shape is solely utilized for visual representation regarding darkness sources.
|
||||
* Used only when an additional radius is added for visuals.
|
||||
* @protected
|
||||
* @type {SourceShape}
|
||||
*/
|
||||
_visualShape;
|
||||
|
||||
/**
|
||||
* Padding applied on the darkness source shape for visual appearance only.
|
||||
* Note: for now, padding is increased radius. It might evolve in a future release.
|
||||
* @type {number}
|
||||
* @protected
|
||||
*/
|
||||
_padding = (CONFIG.Canvas.darknessSourcePaddingMultiplier ?? 0) * canvas.grid.size;
|
||||
|
||||
/**
|
||||
* The Edge instances added by this darkness source.
|
||||
* @type {Edge[]}
|
||||
*/
|
||||
edges = [];
|
||||
|
||||
/**
|
||||
* The normalized border distance.
|
||||
* @type {number}
|
||||
*/
|
||||
#borderDistance = 0;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Darkness Source Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience accessor to the darkness layer mesh.
|
||||
* @type {PointSourceMesh}
|
||||
*/
|
||||
get darkness() {
|
||||
return this.layers.darkness.mesh;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Source Initialization and Management */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_initialize(data) {
|
||||
super._initialize(data);
|
||||
this.data.radius = this.data.bright = this.data.dim = Math.max(this.data.dim ?? 0, this.data.bright ?? 0);
|
||||
this.#borderDistance = this.radius / (this.radius + this._padding);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
|
||||
/** @inheritDoc */
|
||||
_createShapes() {
|
||||
this.#deleteEdges();
|
||||
const origin = {x: this.data.x, y: this.data.y};
|
||||
const config = this._getPolygonConfiguration();
|
||||
const polygonClass = CONFIG.Canvas.polygonBackends[this.constructor.sourceType];
|
||||
|
||||
// Create shapes based on padding
|
||||
if ( this.radius < config.radius ) {
|
||||
this._visualShape = polygonClass.create(origin, config);
|
||||
this.shape = this.#createShapeFromVisualShape(this.radius);
|
||||
}
|
||||
else {
|
||||
this._visualShape = null;
|
||||
this.shape = polygonClass.create(origin, config);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_configure(changes) {
|
||||
super._configure(changes);
|
||||
this.#createEdges();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_getPolygonConfiguration() {
|
||||
return Object.assign(super._getPolygonConfiguration(), {
|
||||
useThreshold: true,
|
||||
includeDarkness: false,
|
||||
radius: (this.data.disabled || this.suppressed) ? 0 : this.radius + this._padding,
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
_drawMesh(layerId) {
|
||||
const mesh = super._drawMesh(layerId);
|
||||
if ( mesh ) mesh.scale.set(this.radius + this._padding);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_updateGeometry() {
|
||||
const {x, y} = this.data;
|
||||
const radius = this.radius + this._padding;
|
||||
const offset = this._flags.renderSoftEdges ? this.constructor.EDGE_OFFSET : 0;
|
||||
const shape = this._visualShape ?? this.shape;
|
||||
const pm = new PolygonMesher(shape, {x, y, radius, normalize: true, offset});
|
||||
this._geometry = pm.triangulate(this._geometry);
|
||||
const bounds = new PIXI.Rectangle(0, 0, 0, 0);
|
||||
if ( radius > 0 ) {
|
||||
const b = shape instanceof PointSourcePolygon ? shape.bounds : shape.getBounds();
|
||||
bounds.x = (b.x - x) / radius;
|
||||
bounds.y = (b.y - y) / radius;
|
||||
bounds.width = b.width / radius;
|
||||
bounds.height = b.height / radius;
|
||||
}
|
||||
if ( this._geometry.bounds ) this._geometry.bounds.copyFrom(bounds);
|
||||
else this._geometry.bounds = bounds;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a radius constrained polygon from the visual shape polygon.
|
||||
* If the visual shape is not created, no polygon is created.
|
||||
* @param {number} radius The radius to constraint to.
|
||||
* @returns {PointSourcePolygon} The new polygon or null if no visual shape is present.
|
||||
*/
|
||||
#createShapeFromVisualShape(radius) {
|
||||
if ( !this._visualShape ) return null;
|
||||
const {x, y} = this.data;
|
||||
const circle = new PIXI.Circle(x, y, radius);
|
||||
const density = PIXI.Circle.approximateVertexDensity(radius);
|
||||
return this._visualShape.applyConstraint(circle, {density, scalingFactor: 100});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create the Edge instances that correspond to this darkness source.
|
||||
*/
|
||||
#createEdges() {
|
||||
if ( !this.active || this.isPreview ) return;
|
||||
const cls = foundry.canvas.edges.Edge;
|
||||
const block = CONST.WALL_SENSE_TYPES.NORMAL;
|
||||
const direction = CONST.WALL_DIRECTIONS.LEFT;
|
||||
const points = [...this.shape.points];
|
||||
let p0 = {x: points[0], y: points[1]};
|
||||
points.push(p0.x, p0.y);
|
||||
let p1;
|
||||
for ( let i=2; i<points.length; i+=2 ) {
|
||||
p1 = {x: points[i], y: points[i+1]};
|
||||
const id = `${this.sourceId}.${i/2}`;
|
||||
const edge = new cls(p0, p1, {type: "darkness", id, object: this.object, direction, light: block, sight: block});
|
||||
this.edges.push(edge);
|
||||
canvas.edges.set(edge.id, edge);
|
||||
p0 = p1;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove edges from the active Edges collection.
|
||||
*/
|
||||
#deleteEdges() {
|
||||
for ( const edge of this.edges ) canvas.edges.delete(edge.id);
|
||||
this.edges.length = 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Shader Management */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the uniforms of the shader on the darkness layer.
|
||||
*/
|
||||
_updateDarknessUniforms() {
|
||||
const u = this.layers.darkness.shader?.uniforms;
|
||||
if ( !u ) return;
|
||||
u.color = this.colorRGB ?? this.layers.darkness.shader.constructor.defaultUniforms.color;
|
||||
u.enableVisionMasking = canvas.effects.visionSources.some(s => s.active) || !game.user.isGM;
|
||||
u.borderDistance = this.#borderDistance;
|
||||
u.colorationAlpha = this.data.alpha * 2;
|
||||
|
||||
// Passing screenDimensions to use screen size render textures
|
||||
u.screenDimensions = canvas.screenDimensions;
|
||||
if ( !u.depthTexture ) u.depthTexture = canvas.masks.depth.renderTexture;
|
||||
if ( !u.primaryTexture ) u.primaryTexture = canvas.primary.renderTexture;
|
||||
if ( !u.visionTexture ) u.visionTexture = canvas.masks.vision.renderTexture;
|
||||
|
||||
// Flag uniforms as updated
|
||||
this.layers.darkness.reset = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_destroy() {
|
||||
this.#deleteEdges();
|
||||
super._destroy();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get isDarkness() {
|
||||
const msg = "BaseLightSource#isDarkness is now obsolete. Use DarknessSource instead.";
|
||||
foundry.utils.logCompatibilityWarning(msg, { since: 12, until: 14});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
165
resources/app/client-esm/canvas/sources/point-effect-source.mjs
Normal file
165
resources/app/client-esm/canvas/sources/point-effect-source.mjs
Normal file
@@ -0,0 +1,165 @@
|
||||
|
||||
/**
|
||||
* @typedef {import("./base-effect-source.mjs").BaseEffectSourceData} BaseEffectSourceData
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PointEffectSourceData
|
||||
* @property {number} radius The radius of the source
|
||||
* @property {number} externalRadius A secondary radius used for limited angles
|
||||
* @property {number} rotation The angle of rotation for this point source
|
||||
* @property {number} angle The angle of emission for this point source
|
||||
* @property {boolean} walls Whether or not the source is constrained by walls
|
||||
*/
|
||||
|
||||
/**
|
||||
* TODO - documentation required about what a PointEffectSource is.
|
||||
* @param BaseSource
|
||||
* @returns {{new(): PointEffectSource, prototype: PointEffectSource}}
|
||||
* @mixin
|
||||
*/
|
||||
export default function PointEffectSourceMixin(BaseSource) {
|
||||
/**
|
||||
* @extends {BaseEffectSource<BaseEffectSourceData & PointEffectSourceData, PointSourcePolygon>}
|
||||
* @abstract
|
||||
*/
|
||||
return class PointEffectSource extends BaseSource {
|
||||
|
||||
/** @inheritDoc */
|
||||
static defaultData = {
|
||||
...super.defaultData,
|
||||
radius: 0,
|
||||
externalRadius: 0,
|
||||
rotation: 0,
|
||||
angle: 360,
|
||||
walls: true
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience reference to the radius of the source.
|
||||
* @type {number}
|
||||
*/
|
||||
get radius() {
|
||||
return this.data.radius ?? 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_initialize(data) {
|
||||
super._initialize(data);
|
||||
if ( this.data.radius > 0 ) this.data.radius = Math.max(this.data.radius, this.data.externalRadius);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Point Source Geometry Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_initializeSoftEdges() {
|
||||
super._initializeSoftEdges();
|
||||
const isCircle = (this.shape instanceof PointSourcePolygon) && this.shape.isCompleteCircle();
|
||||
this._flags.renderSoftEdges &&= !isCircle;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Configure the parameters of the polygon that is generated for this source.
|
||||
* @returns {PointSourcePolygonConfig}
|
||||
* @protected
|
||||
*/
|
||||
_getPolygonConfiguration() {
|
||||
return {
|
||||
type: this.data.walls ? this.constructor.sourceType : "universal",
|
||||
radius: (this.data.disabled || this.suppressed) ? 0 : this.radius,
|
||||
externalRadius: this.data.externalRadius,
|
||||
angle: this.data.angle,
|
||||
rotation: this.data.rotation,
|
||||
source: this
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_createShapes() {
|
||||
const origin = {x: this.data.x, y: this.data.y};
|
||||
const config = this._getPolygonConfiguration();
|
||||
const polygonClass = CONFIG.Canvas.polygonBackends[this.constructor.sourceType];
|
||||
this.shape = polygonClass.create(origin, config);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendering methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_drawMesh(layerId) {
|
||||
const mesh = super._drawMesh(layerId);
|
||||
if ( mesh ) mesh.scale.set(this.radius);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_updateGeometry() {
|
||||
const {x, y} = this.data;
|
||||
const radius = this.radius;
|
||||
const offset = this._flags.renderSoftEdges ? this.constructor.EDGE_OFFSET : 0;
|
||||
const pm = new PolygonMesher(this.shape, {x, y, radius, normalize: true, offset});
|
||||
this._geometry = pm.triangulate(this._geometry);
|
||||
const bounds = new PIXI.Rectangle(0, 0, 0, 0);
|
||||
if ( radius > 0 ) {
|
||||
const b = this.shape instanceof PointSourcePolygon ? this.shape.bounds : this.shape.getBounds();
|
||||
bounds.x = (b.x - x) / radius;
|
||||
bounds.y = (b.y - y) / radius;
|
||||
bounds.width = b.width / radius;
|
||||
bounds.height = b.height / radius;
|
||||
}
|
||||
if ( this._geometry.bounds ) this._geometry.bounds.copyFrom(bounds);
|
||||
else this._geometry.bounds = bounds;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
set radius(radius) {
|
||||
const msg = "The setter PointEffectSource#radius is deprecated."
|
||||
+ " The radius should not be set anywhere except in PointEffectSource#_initialize.";
|
||||
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
|
||||
this.data.radius = radius;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
get los() {
|
||||
const msg = "PointEffectSource#los is deprecated in favor of PointEffectSource#shape.";
|
||||
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
|
||||
return this.shape;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
set los(shape) {
|
||||
const msg = "PointEffectSource#los is deprecated in favor of PointEffectSource#shape.";
|
||||
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
|
||||
this.shape = shape;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import BaseLightSource from "./base-light-source.mjs";
|
||||
import PointEffectSourceMixin from "./point-effect-source.mjs";
|
||||
|
||||
/**
|
||||
* A specialized subclass of the BaseLightSource which renders a source of light as a point-based effect.
|
||||
* @extends {BaseLightSource}
|
||||
* @mixes {PointEffectSourceMixin}
|
||||
*/
|
||||
export default class PointLightSource extends PointEffectSourceMixin(BaseLightSource) {
|
||||
|
||||
/** @override */
|
||||
static effectsCollection = "lightSources";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Source Suppression Management */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update darkness suppression according to darkness sources collection.
|
||||
*/
|
||||
#updateDarknessSuppression() {
|
||||
this.suppression.darkness = canvas.effects.testInsideDarkness({x: this.x, y: this.y}, this.elevation);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Light Source Initialization */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_initialize(data) {
|
||||
super._initialize(data);
|
||||
Object.assign(this.data, {
|
||||
radius: Math.max(this.data.dim ?? 0, this.data.bright ?? 0)
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_createShapes() {
|
||||
this.#updateDarknessSuppression();
|
||||
super._createShapes();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_configure(changes) {
|
||||
this.ratio = Math.clamp(Math.abs(this.data.bright) / this.data.radius, 0, 1);
|
||||
super._configure(changes);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_getPolygonConfiguration() {
|
||||
return Object.assign(super._getPolygonConfiguration(), {useThreshold: true, includeDarkness: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Visibility Testing */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether this LightSource provides visibility to see a certain target object.
|
||||
* @param {object} config The visibility test configuration
|
||||
* @param {CanvasVisibilityTest[]} config.tests The sequence of tests to perform
|
||||
* @param {PlaceableObject} config.object The target object being tested
|
||||
* @returns {boolean} Is the target object visible to this source?
|
||||
*/
|
||||
testVisibility({tests, object}={}) {
|
||||
if ( !(this.data.vision && this._canDetectObject(object)) ) return false;
|
||||
return tests.some(test => this.shape.contains(test.point.x, test.point.y));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Can this LightSource theoretically detect a certain object based on its properties?
|
||||
* This check should not consider the relative positions of either object, only their state.
|
||||
* @param {PlaceableObject} target The target object being tested
|
||||
* @returns {boolean} Can the target object theoretically be detected by this vision source?
|
||||
*/
|
||||
_canDetectObject(target) {
|
||||
const tgt = target?.document;
|
||||
const isInvisible = ((tgt instanceof TokenDocument) && tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE));
|
||||
return !isInvisible;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import BaseEffectSource from "./base-effect-source.mjs";
|
||||
import PointEffectSourceMixin from "./point-effect-source.mjs";
|
||||
|
||||
/**
|
||||
* A specialized subclass of the BaseEffectSource which describes a movement-based source.
|
||||
* @extends {BaseEffectSource}
|
||||
* @mixes {PointEffectSource}
|
||||
*/
|
||||
export default class PointMovementSource extends PointEffectSourceMixin(BaseEffectSource) {
|
||||
|
||||
/** @override */
|
||||
static sourceType = "move";
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import BaseEffectSource from "./base-effect-source.mjs";
|
||||
import PointEffectSourceMixin from "./point-effect-source.mjs";
|
||||
|
||||
/**
|
||||
* A specialized subclass of the BaseEffectSource which describes a point-based source of sound.
|
||||
* @extends {BaseEffectSource}
|
||||
* @mixes {PointEffectSource}
|
||||
*/
|
||||
export default class PointSoundSource extends PointEffectSourceMixin(BaseEffectSource) {
|
||||
|
||||
/** @override */
|
||||
static sourceType = "sound";
|
||||
|
||||
/** @override */
|
||||
get effectsCollection() {
|
||||
return canvas.sounds.sources;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_getPolygonConfiguration() {
|
||||
return Object.assign(super._getPolygonConfiguration(), {useThreshold: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the effective volume at which an AmbientSound source should be played for a certain listener.
|
||||
* @param {Point} listener
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.easing]
|
||||
* @returns {number}
|
||||
*/
|
||||
getVolumeMultiplier(listener, {easing=true}={}) {
|
||||
if ( !listener ) return 0; // No listener = 0
|
||||
const {x, y, radius} = this.data;
|
||||
const distance = Math.hypot(listener.x - x, listener.y - y);
|
||||
if ( distance === 0 ) return 1;
|
||||
if ( distance > radius ) return 0; // Distance outside of radius = 0
|
||||
if ( !this.shape?.contains(listener.x, listener.y) ) return 0; // Point outside of shape = 0
|
||||
if ( !easing ) return 1; // No easing = 1
|
||||
const dv = Math.clamp(distance, 0, radius) / radius;
|
||||
return (Math.cos(Math.PI * dv) + 1) * 0.5; // Cosine easing [0, 1]
|
||||
}
|
||||
}
|
||||
445
resources/app/client-esm/canvas/sources/point-vision-source.mjs
Normal file
445
resources/app/client-esm/canvas/sources/point-vision-source.mjs
Normal file
@@ -0,0 +1,445 @@
|
||||
import RenderedEffectSource from "./rendered-effect-source.mjs";
|
||||
import PointEffectSourceMixin from "./point-effect-source.mjs";
|
||||
import {LIGHTING_LEVELS} from "../../../common/constants.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./base-effect-source.mjs").BaseEffectSourceData} BaseEffectSourceData
|
||||
* @typedef {import("./rendered-effect-source-source.mjs").RenderedEffectSourceData} RenderedEffectSourceData
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} VisionSourceData
|
||||
* @property {number} contrast The amount of contrast
|
||||
* @property {number} attenuation Strength of the attenuation between bright, dim, and dark
|
||||
* @property {number} saturation The amount of color saturation
|
||||
* @property {number} brightness The vision brightness.
|
||||
* @property {string} visionMode The vision mode.
|
||||
* @property {number} lightRadius The range of light perception.
|
||||
* @property {boolean} blinded Is this vision source blinded?
|
||||
*/
|
||||
|
||||
/**
|
||||
* A specialized subclass of RenderedEffectSource which represents a source of point-based vision.
|
||||
* @extends {RenderedEffectSource<BaseEffectSourceData & RenderedEffectSourceData & VisionSourceData, PointSourcePolygon>}
|
||||
*/
|
||||
export default class PointVisionSource extends PointEffectSourceMixin(RenderedEffectSource) {
|
||||
|
||||
/** @inheritdoc */
|
||||
static sourceType = "sight";
|
||||
|
||||
/** @override */
|
||||
static _initializeShaderKeys = ["visionMode", "blinded"];
|
||||
|
||||
/** @override */
|
||||
static _refreshUniformsKeys = ["radius", "color", "attenuation", "brightness", "contrast", "saturation", "visionMode"];
|
||||
|
||||
/**
|
||||
* The corresponding lighting levels for dim light.
|
||||
* @type {number}
|
||||
* @protected
|
||||
*/
|
||||
static _dimLightingLevel = LIGHTING_LEVELS.DIM;
|
||||
|
||||
/**
|
||||
* The corresponding lighting levels for bright light.
|
||||
* @type {string}
|
||||
* @protected
|
||||
*/
|
||||
static _brightLightingLevel = LIGHTING_LEVELS.BRIGHT;
|
||||
|
||||
/** @inheritdoc */
|
||||
static EDGE_OFFSET = -2;
|
||||
|
||||
/** @override */
|
||||
static effectsCollection = "visionSources";
|
||||
|
||||
/** @inheritDoc */
|
||||
static defaultData = {
|
||||
...super.defaultData,
|
||||
contrast: 0,
|
||||
attenuation: 0.5,
|
||||
saturation: 0,
|
||||
brightness: 0,
|
||||
visionMode: "basic",
|
||||
lightRadius: null
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static get _layers() {
|
||||
return foundry.utils.mergeObject(super._layers, {
|
||||
background: {
|
||||
defaultShader: BackgroundVisionShader
|
||||
},
|
||||
coloration: {
|
||||
defaultShader: ColorationVisionShader
|
||||
},
|
||||
illumination: {
|
||||
defaultShader: IlluminationVisionShader
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Vision Source Attributes */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The vision mode linked to this VisionSource
|
||||
* @type {VisionMode|null}
|
||||
*/
|
||||
visionMode = null;
|
||||
|
||||
/**
|
||||
* The vision mode activation flag for handlers
|
||||
* @type {boolean}
|
||||
* @internal
|
||||
*/
|
||||
_visionModeActivated = false;
|
||||
|
||||
/**
|
||||
* The unconstrained LOS polygon.
|
||||
* @type {PointSourcePolygon}
|
||||
*/
|
||||
los;
|
||||
|
||||
/**
|
||||
* The polygon of light perception.
|
||||
* @type {PointSourcePolygon}
|
||||
*/
|
||||
light;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An alias for the shape of the vision source.
|
||||
* @type {PointSourcePolygon|PIXI.Polygon}
|
||||
*/
|
||||
get fov() {
|
||||
return this.shape;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* If this vision source background is rendered into the lighting container.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get preferred() {
|
||||
return this.visionMode?.vision.preferred;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is the rendered source animated?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isAnimated() {
|
||||
return this.active && this.data.animation && this.visionMode?.animated;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Light perception radius of this vision source, taking into account if the source is blinded.
|
||||
* @type {number}
|
||||
*/
|
||||
get lightRadius() {
|
||||
return this.#hasBlindedVisionMode ? 0 : (this.data.lightRadius ?? 0);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get radius() {
|
||||
return (this.#hasBlindedVisionMode ? this.data.externalRadius : this.data.radius) ?? 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Point Vision Source Blinded Management */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this source temporarily blinded?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isBlinded() {
|
||||
return (this.data.radius === 0) && ((this.data.lightRadius === 0) || !this.visionMode?.perceivesLight)
|
||||
|| Object.values(this.blinded).includes(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Records of blinding strings with a boolean value.
|
||||
* By default, if any of this record is true, the source is blinded.
|
||||
* @type {Record<string, boolean>}
|
||||
*/
|
||||
blinded = {};
|
||||
|
||||
/**
|
||||
* Data overrides that could happen with blindness vision mode.
|
||||
* @type {object}
|
||||
*/
|
||||
visionModeOverrides = {};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update darkness blinding according to darkness sources collection.
|
||||
*/
|
||||
#updateBlindedState() {
|
||||
this.blinded.darkness = canvas.effects.testInsideDarkness({x: this.x, y: this.y}, this.elevation);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* To know if blindness vision mode is configured for this source.
|
||||
* Note: Convenient method used to avoid calling this.blinded which is costly.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get #hasBlindedVisionMode() {
|
||||
return this.visionMode === CONFIG.Canvas.visionModes.blindness;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Vision Source Initialization */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_initialize(data) {
|
||||
super._initialize(data);
|
||||
this.data.lightRadius ??= canvas.dimensions.maxR;
|
||||
if ( this.data.lightRadius > 0 ) this.data.lightRadius = Math.max(this.data.lightRadius, this.data.externalRadius);
|
||||
if ( this.data.radius > 0 ) this.data.radius = Math.max(this.data.radius, this.data.externalRadius);
|
||||
if ( !(this.data.visionMode in CONFIG.Canvas.visionModes) ) this.data.visionMode = "basic";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_createShapes() {
|
||||
this._updateVisionMode();
|
||||
super._createShapes();
|
||||
this.los = this.shape;
|
||||
this.light = this._createLightPolygon();
|
||||
this.shape = this._createRestrictedPolygon();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Responsible for assigning the Vision Mode and calling the activation and deactivation handlers.
|
||||
* @protected
|
||||
*/
|
||||
_updateVisionMode() {
|
||||
const previousVM = this.visionMode;
|
||||
this.visionMode = CONFIG.Canvas.visionModes[this.data.visionMode];
|
||||
|
||||
// Check blinding conditions
|
||||
this.#updateBlindedState();
|
||||
|
||||
// Apply vision mode according to conditions
|
||||
if ( this.isBlinded ) this.visionMode = CONFIG.Canvas.visionModes.blindness;
|
||||
|
||||
// Process vision mode overrides for blindness
|
||||
const defaults = this.visionMode.vision.defaults;
|
||||
const data = this.data;
|
||||
const applyOverride = prop => this.#hasBlindedVisionMode && (defaults[prop] !== undefined) ? defaults[prop] : data[prop];
|
||||
const blindedColor = applyOverride("color");
|
||||
this.visionModeOverrides.colorRGB = blindedColor !== null ? Color.from(blindedColor).rgb : null;
|
||||
this.visionModeOverrides.brightness = applyOverride("brightness");
|
||||
this.visionModeOverrides.contrast = applyOverride("contrast");
|
||||
this.visionModeOverrides.saturation = applyOverride("saturation");
|
||||
this.visionModeOverrides.attenuation = applyOverride("attenuation");
|
||||
|
||||
// Process deactivation and activation handlers
|
||||
if ( this.visionMode !== previousVM ) previousVM?.deactivate(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_configure(changes) {
|
||||
this.visionMode.activate(this);
|
||||
super._configure(changes);
|
||||
this.animation.animation = this.visionMode.animate;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_configureLayer(layer, layerId) {
|
||||
const vmUniforms = this.visionMode.vision[layerId].uniforms;
|
||||
layer.vmUniforms = Object.entries(vmUniforms);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_getPolygonConfiguration() {
|
||||
return Object.assign(super._getPolygonConfiguration(), {
|
||||
radius: this.data.disabled || this.suppressed ? 0 : (this.blinded.darkness
|
||||
? this.data.externalRadius : canvas.dimensions.maxR),
|
||||
useThreshold: true,
|
||||
includeDarkness: true
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Creates the polygon that represents light perception.
|
||||
* If the light perception radius is unconstrained, no new polygon instance is created;
|
||||
* instead the LOS polygon of this vision source is returned.
|
||||
* @returns {PointSourcePolygon} The new polygon or `this.los`.
|
||||
* @protected
|
||||
*/
|
||||
_createLightPolygon() {
|
||||
return this.#createConstrainedPolygon(this.lightRadius);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a restricted FOV polygon by limiting the radius of the unrestricted LOS polygon.
|
||||
* If the vision radius is unconstrained, no new polygon instance is created;
|
||||
* instead the LOS polygon of this vision source is returned.
|
||||
* @returns {PointSourcePolygon} The new polygon or `this.los`.
|
||||
* @protected
|
||||
*/
|
||||
_createRestrictedPolygon() {
|
||||
return this.#createConstrainedPolygon(this.radius || this.data.externalRadius);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a constrained polygon by limiting the radius of the unrestricted LOS polygon.
|
||||
* If the radius is unconstrained, no new polygon instance is created;
|
||||
* instead the LOS polygon of this vision source is returned.
|
||||
* @param {number} radius The radius to constraint to.
|
||||
* @returns {PointSourcePolygon} The new polygon or `this.los`.
|
||||
*/
|
||||
#createConstrainedPolygon(radius) {
|
||||
if ( radius >= this.los.config.radius ) return this.los;
|
||||
const {x, y} = this.data;
|
||||
const circle = new PIXI.Circle(x, y, radius);
|
||||
const density = PIXI.Circle.approximateVertexDensity(radius);
|
||||
return this.los.applyConstraint(circle, {density, scalingFactor: 100});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Shader Management */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_configureShaders() {
|
||||
const vm = this.visionMode.vision;
|
||||
const shaders = {};
|
||||
for ( const layer in this.layers ) {
|
||||
shaders[layer] = vm[`${layer.toLowerCase()}`]?.shader || this.layers[layer].defaultShader;
|
||||
}
|
||||
return shaders;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_updateColorationUniforms() {
|
||||
super._updateColorationUniforms();
|
||||
const shader = this.layers.coloration.shader;
|
||||
if ( !shader ) return;
|
||||
const u = shader?.uniforms;
|
||||
const d = shader.constructor.defaultUniforms;
|
||||
u.colorEffect = this.visionModeOverrides.colorRGB ?? d.colorEffect;
|
||||
u.useSampler = true;
|
||||
const vmUniforms = this.layers.coloration.vmUniforms;
|
||||
if ( vmUniforms.length ) this._updateVisionModeUniforms(shader, vmUniforms);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_updateIlluminationUniforms() {
|
||||
super._updateIlluminationUniforms();
|
||||
const shader = this.layers.illumination.shader;
|
||||
if ( !shader ) return;
|
||||
shader.uniforms.useSampler = false; // We don't need to use the background sampler into vision illumination
|
||||
const vmUniforms = this.layers.illumination.vmUniforms;
|
||||
if ( vmUniforms.length ) this._updateVisionModeUniforms(shader, vmUniforms);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_updateBackgroundUniforms() {
|
||||
super._updateBackgroundUniforms();
|
||||
const shader = this.layers.background.shader;
|
||||
if ( !shader ) return;
|
||||
const u = shader.uniforms;
|
||||
u.technique = 0;
|
||||
u.contrast = this.visionModeOverrides.contrast;
|
||||
u.useSampler = true;
|
||||
const vmUniforms = this.layers.background.vmUniforms;
|
||||
if ( vmUniforms.length ) this._updateVisionModeUniforms(shader, vmUniforms);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_updateCommonUniforms(shader) {
|
||||
const u = shader.uniforms;
|
||||
const d = shader.constructor.defaultUniforms;
|
||||
const c = canvas.colors;
|
||||
|
||||
// Passing common environment values
|
||||
u.computeIllumination = true;
|
||||
u.darknessLevel = canvas.environment.darknessLevel;
|
||||
c.ambientBrightest.applyRGB(u.ambientBrightest);
|
||||
c.ambientDarkness.applyRGB(u.ambientDarkness);
|
||||
c.ambientDaylight.applyRGB(u.ambientDaylight);
|
||||
u.weights[0] = canvas.environment.weights.dark;
|
||||
u.weights[1] = canvas.environment.weights.halfdark;
|
||||
u.weights[2] = canvas.environment.weights.dim;
|
||||
u.weights[3] = canvas.environment.weights.bright;
|
||||
u.dimLevelCorrection = this.constructor._dimLightingLevel;
|
||||
u.brightLevelCorrection = this.constructor._brightLightingLevel;
|
||||
|
||||
// Vision values
|
||||
const attenuation = this.visionModeOverrides.attenuation;
|
||||
u.attenuation = Math.max(attenuation, 0.0125);
|
||||
const brightness = this.visionModeOverrides.brightness;
|
||||
u.brightness = (brightness + 1) / 2;
|
||||
u.saturation = this.visionModeOverrides.saturation;
|
||||
u.linkedToDarknessLevel = this.visionMode.vision.darkness.adaptive;
|
||||
|
||||
// Other values
|
||||
u.elevation = this.data.elevation;
|
||||
u.screenDimensions = canvas.screenDimensions;
|
||||
u.colorTint = this.visionModeOverrides.colorRGB ?? d.colorTint;
|
||||
|
||||
// Textures
|
||||
if ( !u.depthTexture ) u.depthTexture = canvas.masks.depth.renderTexture;
|
||||
if ( !u.primaryTexture ) u.primaryTexture = canvas.primary.renderTexture;
|
||||
if ( !u.darknessLevelTexture ) u.darknessLevelTexture = canvas.effects.illumination.renderTexture;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update layer uniforms according to vision mode uniforms, if any.
|
||||
* @param {AdaptiveVisionShader} shader The shader being updated.
|
||||
* @param {Array} vmUniforms The targeted layer.
|
||||
* @protected
|
||||
*/
|
||||
_updateVisionModeUniforms(shader, vmUniforms) {
|
||||
const shaderUniforms = shader.uniforms;
|
||||
for ( const [uniform, value] of vmUniforms ) {
|
||||
if ( Array.isArray(value) ) {
|
||||
const u = (shaderUniforms[uniform] ??= []);
|
||||
for ( const i in value ) u[i] = value[i];
|
||||
}
|
||||
else shaderUniforms[uniform] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
import BaseEffectSource from "./base-effect-source.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./base-effect-source.mjs").BaseEffectSourceData} BaseEffectSourceData
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RenderedEffectSourceData
|
||||
* @property {object} animation An animation configuration for the source
|
||||
* @property {number|null} color A color applied to the rendered effect
|
||||
* @property {number|null} seed An integer seed to synchronize (or de-synchronize) animations
|
||||
* @property {boolean} preview Is this source a temporary preview?
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RenderedEffectSourceAnimationConfig
|
||||
* @property {string} [label] The human-readable (localized) label for the animation
|
||||
* @property {Function} [animation] The animation function that runs every frame
|
||||
* @property {AdaptiveIlluminationShader} [illuminationShader] A custom illumination shader used by this animation
|
||||
* @property {AdaptiveColorationShader} [colorationShader] A custom coloration shader used by this animation
|
||||
* @property {AdaptiveBackgroundShader} [backgroundShader] A custom background shader used by this animation
|
||||
* @property {AdaptiveDarknessShader} [darknessShader] A custom darkness shader used by this animation
|
||||
* @property {number} [seed] The animation seed
|
||||
* @property {number} [time] The animation time
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RenderedEffectLayerConfig
|
||||
* @property {AdaptiveLightingShader} defaultShader The default shader used by this layer
|
||||
* @property {PIXI.BLEND_MODES} blendMode The blend mode used by this layer
|
||||
*/
|
||||
|
||||
/**
|
||||
* An abstract class which extends the base PointSource to provide common functionality for rendering.
|
||||
* This class is extended by both the LightSource and VisionSource subclasses.
|
||||
* @extends {BaseEffectSource<BaseEffectSourceData & RenderedEffectSourceData>}
|
||||
* @abstract
|
||||
*/
|
||||
export default class RenderedEffectSource extends BaseEffectSource {
|
||||
|
||||
/**
|
||||
* Keys of the data object which require shaders to be re-initialized.
|
||||
* @type {string[]}
|
||||
* @protected
|
||||
*/
|
||||
static _initializeShaderKeys = ["animation.type"];
|
||||
|
||||
/**
|
||||
* Keys of the data object which require uniforms to be refreshed.
|
||||
* @type {string[]}
|
||||
* @protected
|
||||
*/
|
||||
static _refreshUniformsKeys = [];
|
||||
|
||||
/**
|
||||
* Layers handled by this rendered source.
|
||||
* @type {Record<string, RenderedEffectLayerConfig>}
|
||||
* @protected
|
||||
*/
|
||||
static get _layers() {
|
||||
return {
|
||||
background: {
|
||||
defaultShader: AdaptiveBackgroundShader,
|
||||
blendMode: "MAX_COLOR"
|
||||
},
|
||||
coloration: {
|
||||
defaultShader: AdaptiveColorationShader,
|
||||
blendMode: "SCREEN"
|
||||
},
|
||||
illumination: {
|
||||
defaultShader: AdaptiveIlluminationShader,
|
||||
blendMode: "MAX_COLOR"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The offset in pixels applied to create soft edges.
|
||||
* @type {number}
|
||||
*/
|
||||
static EDGE_OFFSET = -8;
|
||||
|
||||
/** @inheritDoc */
|
||||
static defaultData = {
|
||||
...super.defaultData,
|
||||
animation: {},
|
||||
seed: null,
|
||||
preview: false,
|
||||
color: null
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendered Source Attributes */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The animation configuration applied to this source
|
||||
* @type {RenderedEffectSourceAnimationConfig}
|
||||
*/
|
||||
animation = {};
|
||||
|
||||
/**
|
||||
* @typedef {Object} RenderedEffectSourceLayer
|
||||
* @property {boolean} active Is this layer actively rendered?
|
||||
* @property {boolean} reset Do uniforms need to be reset?
|
||||
* @property {boolean} suppressed Is this layer temporarily suppressed?
|
||||
* @property {PointSourceMesh} mesh The rendered mesh for this layer
|
||||
* @property {AdaptiveLightingShader} shader The shader instance used for the layer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Track the status of rendering layers
|
||||
* @type {{
|
||||
* background: RenderedEffectSourceLayer,
|
||||
* coloration: RenderedEffectSourceLayer,
|
||||
* illumination: RenderedEffectSourceLayer
|
||||
* }}
|
||||
*/
|
||||
layers = Object.entries(this.constructor._layers).reduce((obj, [layer, config]) => {
|
||||
obj[layer] = {active: true, reset: true, suppressed: false,
|
||||
mesh: undefined, shader: undefined, defaultShader: config.defaultShader,
|
||||
vmUniforms: undefined, blendMode: config.blendMode};
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
/**
|
||||
* Array of update uniforms functions.
|
||||
* @type {Function[]}
|
||||
*/
|
||||
#updateUniformsFunctions = (() => {
|
||||
const initializedFunctions = [];
|
||||
for ( const layer in this.layers ) {
|
||||
const fn = this[`_update${layer.titleCase()}Uniforms`];
|
||||
if ( fn ) initializedFunctions.push(fn);
|
||||
}
|
||||
return initializedFunctions;
|
||||
})();
|
||||
|
||||
/**
|
||||
* The color of the source as an RGB vector.
|
||||
* @type {[number, number, number]|null}
|
||||
*/
|
||||
colorRGB = null;
|
||||
|
||||
/**
|
||||
* PIXI Geometry generated to draw meshes.
|
||||
* @type {PIXI.Geometry|null}
|
||||
* @protected
|
||||
*/
|
||||
_geometry = null;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Source State */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is the rendered source animated?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isAnimated() {
|
||||
return this.active && this.data.animation?.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has the rendered source at least one active layer?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get hasActiveLayer() {
|
||||
return this.#hasActiveLayer;
|
||||
}
|
||||
|
||||
#hasActiveLayer = false;
|
||||
|
||||
/**
|
||||
* Is this RenderedEffectSource a temporary preview?
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get isPreview() {
|
||||
return !!this.data.preview;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendered Source Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience accessor to the background layer mesh.
|
||||
* @type {PointSourceMesh}
|
||||
*/
|
||||
get background() {
|
||||
return this.layers.background.mesh;
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenience accessor to the coloration layer mesh.
|
||||
* @type {PointSourceMesh}
|
||||
*/
|
||||
get coloration() {
|
||||
return this.layers.coloration.mesh;
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenience accessor to the illumination layer mesh.
|
||||
* @type {PointSourceMesh}
|
||||
*/
|
||||
get illumination() {
|
||||
return this.layers.illumination.mesh;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendered Source Initialization */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_initialize(data) {
|
||||
super._initialize(data);
|
||||
const color = Color.from(this.data.color ?? null);
|
||||
this.data.color = color.valid ? color.valueOf() : null;
|
||||
const seed = this.data.seed ?? this.animation.seed ?? Math.floor(Math.random() * 100000);
|
||||
this.animation = this.data.animation = {seed, ...this.data.animation};
|
||||
|
||||
// Initialize the color attributes
|
||||
const hasColor = this._flags.hasColor = (this.data.color !== null);
|
||||
if ( hasColor ) Color.applyRGB(color, this.colorRGB ??= [0, 0, 0]);
|
||||
else this.colorRGB = null;
|
||||
|
||||
// We need to update the hasColor uniform attribute immediately
|
||||
for ( const layer of Object.values(this.layers) ) {
|
||||
if ( layer.shader ) layer.shader.uniforms.hasColor = hasColor;
|
||||
}
|
||||
this._initializeSoftEdges();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Decide whether to render soft edges with a blur.
|
||||
* @protected
|
||||
*/
|
||||
_initializeSoftEdges() {
|
||||
this._flags.renderSoftEdges = canvas.performance.lightSoftEdges && !this.isPreview;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_configure(changes) {
|
||||
// To know if we need a first time initialization of the shaders
|
||||
const initializeShaders = !this._geometry;
|
||||
|
||||
// Initialize meshes using the computed shape
|
||||
this.#initializeMeshes();
|
||||
|
||||
// Initialize shaders
|
||||
if ( initializeShaders || this.constructor._initializeShaderKeys.some(k => k in changes) ) {
|
||||
this.#initializeShaders();
|
||||
}
|
||||
|
||||
// Refresh uniforms
|
||||
else if ( this.constructor._refreshUniformsKeys.some(k => k in changes) ) {
|
||||
for ( const config of Object.values(this.layers) ) {
|
||||
config.reset = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the visible state the layers
|
||||
this.#updateVisibleLayers();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Configure which shaders are used for each rendered layer.
|
||||
* @returns {{
|
||||
* background: AdaptiveLightingShader,
|
||||
* coloration: AdaptiveLightingShader,
|
||||
* illumination: AdaptiveLightingShader
|
||||
* }}
|
||||
* @private
|
||||
*/
|
||||
_configureShaders() {
|
||||
const a = this.animation;
|
||||
const shaders = {};
|
||||
for ( const layer in this.layers ) {
|
||||
shaders[layer] = a[`${layer.toLowerCase()}Shader`] || this.layers[layer].defaultShader;
|
||||
}
|
||||
return shaders;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Specific configuration for a layer.
|
||||
* @param {object} layer
|
||||
* @param {string} layerId
|
||||
* @protected
|
||||
*/
|
||||
_configureLayer(layer, layerId) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the shaders used for this source, swapping to a different shader if the animation has changed.
|
||||
*/
|
||||
#initializeShaders() {
|
||||
const shaders = this._configureShaders();
|
||||
for ( const [layerId, layer] of Object.entries(this.layers) ) {
|
||||
layer.shader = RenderedEffectSource.#createShader(shaders[layerId], layer.mesh);
|
||||
this._configureLayer(layer, layerId);
|
||||
}
|
||||
this.#updateUniforms();
|
||||
Hooks.callAll(`initialize${this.constructor.name}Shaders`, this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a new shader using a provider shader class
|
||||
* @param {typeof AdaptiveLightingShader} cls The shader class to create
|
||||
* @param {PointSourceMesh} container The container which requires a new shader
|
||||
* @returns {AdaptiveLightingShader} The shader instance used
|
||||
*/
|
||||
static #createShader(cls, container) {
|
||||
const current = container.shader;
|
||||
if ( current?.constructor === cls ) return current;
|
||||
const shader = cls.create({
|
||||
primaryTexture: canvas.primary.renderTexture
|
||||
});
|
||||
shader.container = container;
|
||||
container.shader = shader;
|
||||
container.uniforms = shader.uniforms;
|
||||
if ( current ) current.destroy();
|
||||
return shader;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the geometry and the meshes.
|
||||
*/
|
||||
#initializeMeshes() {
|
||||
this._updateGeometry();
|
||||
if ( !this._flags.initializedMeshes ) this.#createMeshes();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create meshes for each layer of the RenderedEffectSource that is drawn to the canvas.
|
||||
*/
|
||||
#createMeshes() {
|
||||
if ( !this._geometry ) return;
|
||||
const shaders = this._configureShaders();
|
||||
for ( const [l, layer] of Object.entries(this.layers) ) {
|
||||
layer.mesh = this.#createMesh(shaders[l]);
|
||||
layer.mesh.blendMode = PIXI.BLEND_MODES[layer.blendMode];
|
||||
layer.shader = layer.mesh.shader;
|
||||
}
|
||||
this._flags.initializedMeshes = true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a new Mesh for this source using a provided shader class
|
||||
* @param {typeof AdaptiveLightingShader} shaderCls The shader class used for this mesh
|
||||
* @returns {PointSourceMesh} The created Mesh
|
||||
*/
|
||||
#createMesh(shaderCls) {
|
||||
const state = new PIXI.State();
|
||||
const mesh = new PointSourceMesh(this._geometry, shaderCls.create(), state);
|
||||
mesh.drawMode = PIXI.DRAW_MODES.TRIANGLES;
|
||||
mesh.uniforms = mesh.shader.uniforms;
|
||||
mesh.cullable = true;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create the geometry for the source shape that is used in shaders and compute its bounds for culling purpose.
|
||||
* Triangulate the form and create buffers.
|
||||
* @protected
|
||||
* @abstract
|
||||
*/
|
||||
_updateGeometry() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendered Source Canvas Rendering */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render the containers used to represent this light source within the LightingLayer
|
||||
* @returns {{background: PIXI.Mesh, coloration: PIXI.Mesh, illumination: PIXI.Mesh}}
|
||||
*/
|
||||
drawMeshes() {
|
||||
const meshes = {};
|
||||
for ( const layerId of Object.keys(this.layers) ) {
|
||||
meshes[layerId] = this._drawMesh(layerId);
|
||||
}
|
||||
return meshes;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a Mesh for a certain rendered layer of this source.
|
||||
* @param {string} layerId The layer key in layers to draw
|
||||
* @returns {PIXI.Mesh|null} The drawn mesh for this layer, or null if no mesh is required
|
||||
* @protected
|
||||
*/
|
||||
_drawMesh(layerId) {
|
||||
const layer = this.layers[layerId];
|
||||
const mesh = layer.mesh;
|
||||
|
||||
if ( layer.reset ) {
|
||||
const fn = this[`_update${layerId.titleCase()}Uniforms`];
|
||||
fn.call(this);
|
||||
}
|
||||
if ( !layer.active ) {
|
||||
mesh.visible = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update the mesh
|
||||
const {x, y} = this.data;
|
||||
mesh.position.set(x, y);
|
||||
mesh.visible = mesh.renderable = true;
|
||||
return layer.mesh;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendered Source Refresh */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_refresh() {
|
||||
this.#updateUniforms();
|
||||
this.#updateVisibleLayers();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update uniforms for all rendered layers.
|
||||
*/
|
||||
#updateUniforms() {
|
||||
for ( const updateUniformsFunction of this.#updateUniformsFunctions ) updateUniformsFunction.call(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the visible state of the component channels of this RenderedEffectSource.
|
||||
* @returns {boolean} Is there an active layer?
|
||||
*/
|
||||
#updateVisibleLayers() {
|
||||
const active = this.active;
|
||||
let hasActiveLayer = false;
|
||||
for ( const layer of Object.values(this.layers) ) {
|
||||
layer.active = active && (layer.shader?.isRequired !== false);
|
||||
if ( layer.active ) hasActiveLayer = true;
|
||||
}
|
||||
this.#hasActiveLayer = hasActiveLayer;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update shader uniforms used by every rendered layer.
|
||||
* @param {AbstractBaseShader} shader
|
||||
* @protected
|
||||
*/
|
||||
_updateCommonUniforms(shader) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update shader uniforms used for the background layer.
|
||||
* @protected
|
||||
*/
|
||||
_updateBackgroundUniforms() {
|
||||
const shader = this.layers.background.shader;
|
||||
if ( !shader ) return;
|
||||
this._updateCommonUniforms(shader);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update shader uniforms used for the coloration layer.
|
||||
* @protected
|
||||
*/
|
||||
_updateColorationUniforms() {
|
||||
const shader = this.layers.coloration.shader;
|
||||
if ( !shader ) return;
|
||||
this._updateCommonUniforms(shader);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update shader uniforms used for the illumination layer.
|
||||
* @protected
|
||||
*/
|
||||
_updateIlluminationUniforms() {
|
||||
const shader = this.layers.illumination.shader;
|
||||
if ( !shader ) return;
|
||||
this._updateCommonUniforms(shader);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendered Source Destruction */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_destroy() {
|
||||
for ( const layer of Object.values(this.layers) ) layer.mesh?.destroy();
|
||||
this._geometry?.destroy();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Animation Functions */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Animate the PointSource, if an animation is enabled and if it currently has rendered containers.
|
||||
* @param {number} dt Delta time.
|
||||
*/
|
||||
animate(dt) {
|
||||
if ( !this.isAnimated ) return;
|
||||
const {animation, ...options} = this.animation;
|
||||
return animation?.call(this, dt, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Generic time-based animation used for Rendered Point Sources.
|
||||
* @param {number} dt Delta time.
|
||||
* @param {object} [options] Options which affect the time animation
|
||||
* @param {number} [options.speed=5] The animation speed, from 0 to 10
|
||||
* @param {number} [options.intensity=5] The animation intensity, from 1 to 10
|
||||
* @param {boolean} [options.reverse=false] Reverse the animation direction
|
||||
*/
|
||||
animateTime(dt, {speed=5, intensity=5, reverse=false}={}) {
|
||||
|
||||
// Determine the animation timing
|
||||
let t = canvas.app.ticker.lastTime;
|
||||
if ( reverse ) t *= -1;
|
||||
this.animation.time = ( (speed * t) / 5000 ) + this.animation.seed;
|
||||
|
||||
// Update uniforms
|
||||
for ( const layer of Object.values(this.layers) ) {
|
||||
const u = layer.mesh.uniforms;
|
||||
u.time = this.animation.time;
|
||||
u.intensity = intensity;
|
||||
}
|
||||
}
|
||||
/* -------------------------------------------- */
|
||||
/* Static Helper Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get corrected level according to level and active vision mode data.
|
||||
* @param {VisionMode.LIGHTING_LEVELS} level
|
||||
* @returns {number} The corrected level.
|
||||
*/
|
||||
static getCorrectedLevel(level) {
|
||||
// Retrieving the lighting mode and the corrected level, if any
|
||||
const lightingOptions = canvas.visibility.visionModeData?.activeLightingOptions;
|
||||
return (lightingOptions?.levels?.[level]) ?? level;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get corrected color according to level, dim color, bright color and background color.
|
||||
* @param {VisionMode.LIGHTING_LEVELS} level
|
||||
* @param {Color} colorDim
|
||||
* @param {Color} colorBright
|
||||
* @param {Color} [colorBackground]
|
||||
* @returns {Color}
|
||||
*/
|
||||
static getCorrectedColor(level, colorDim, colorBright, colorBackground) {
|
||||
colorBackground ??= canvas.colors.background;
|
||||
|
||||
// Returning the corrected color according to the lighting options
|
||||
const levels = VisionMode.LIGHTING_LEVELS;
|
||||
switch ( this.getCorrectedLevel(level) ) {
|
||||
case levels.HALFDARK:
|
||||
case levels.DIM: return colorDim;
|
||||
case levels.BRIGHT:
|
||||
case levels.DARKNESS: return colorBright;
|
||||
case levels.BRIGHTEST: return canvas.colors.ambientBrightest;
|
||||
case levels.UNLIT: return colorBackground;
|
||||
default: return colorDim;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
get preview() {
|
||||
const msg = "The RenderedEffectSource#preview is deprecated. Use RenderedEffectSource#isPreview instead.";
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
|
||||
return this.isPreview;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
set preview(preview) {
|
||||
const msg = "The RenderedEffectSource#preview is deprecated. "
|
||||
+ "Set RenderedEffectSource#preview as part of RenderedEffectSource#initialize instead.";
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
|
||||
this.data.preview = preview;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user