Initial
This commit is contained in:
6
resources/app/client-esm/canvas/_module.mjs
Normal file
6
resources/app/client-esm/canvas/_module.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
export {default as SceneManager} from "./scene-manager.mjs";
|
||||
export {default as SMAAFilter} from "./smaa/smaa.mjs";
|
||||
export * as edges from "./edges/_module.mjs";
|
||||
export * as regions from "./regions/_module.mjs";
|
||||
export * as sources from "./sources/_module.mjs";
|
||||
export * as tokens from "./tokens/_module.mjs";
|
||||
4
resources/app/client-esm/canvas/edges/_module.mjs
Normal file
4
resources/app/client-esm/canvas/edges/_module.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
export {default as CanvasEdges} from "./edges.mjs";
|
||||
export {default as CollisionResult} from "./collision.mjs";
|
||||
export {default as Edge} from "./edge.mjs";
|
||||
export {default as PolygonVertex} from "./vertex.mjs";
|
||||
94
resources/app/client-esm/canvas/edges/collision.mjs
Normal file
94
resources/app/client-esm/canvas/edges/collision.mjs
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* A specialized object that contains the result of a collision in the context of the ClockwiseSweepPolygon.
|
||||
* This class is not designed or intended for use outside of that context.
|
||||
* @alias CollisionResult
|
||||
*/
|
||||
export default class CollisionResult {
|
||||
constructor({target, collisions=[], cwEdges, ccwEdges, isBehind, isLimited, wasLimited}={}) {
|
||||
this.target = target;
|
||||
this.collisions = collisions;
|
||||
this.cwEdges = cwEdges || new Set();
|
||||
this.ccwEdges = ccwEdges || new Set();
|
||||
this.isBehind = isBehind;
|
||||
this.isLimited = isLimited;
|
||||
this.wasLimited = wasLimited;
|
||||
}
|
||||
|
||||
/**
|
||||
* The vertex that was the target of this result
|
||||
* @type {PolygonVertex}
|
||||
*/
|
||||
target;
|
||||
|
||||
/**
|
||||
* The array of collision points which apply to this result
|
||||
* @type {PolygonVertex[]}
|
||||
*/
|
||||
collisions;
|
||||
|
||||
/**
|
||||
* The set of edges connected to the target vertex that continue clockwise
|
||||
* @type {EdgeSet}
|
||||
*/
|
||||
cwEdges;
|
||||
|
||||
/**
|
||||
* The set of edges connected to the target vertex that continue counter-clockwise
|
||||
* @type {EdgeSet}
|
||||
*/
|
||||
ccwEdges;
|
||||
|
||||
/**
|
||||
* Is the target vertex for this result behind some closer active edge?
|
||||
* @type {boolean}
|
||||
*/
|
||||
isBehind;
|
||||
|
||||
/**
|
||||
* Does the target vertex for this result impose a limited collision?
|
||||
* @type {boolean}
|
||||
*/
|
||||
isLimited;
|
||||
|
||||
/**
|
||||
* Has the set of collisions for this result encountered a limited edge?
|
||||
* @type {boolean}
|
||||
*/
|
||||
wasLimited;
|
||||
|
||||
/**
|
||||
* Is this result limited in the clockwise direction?
|
||||
* @type {boolean}
|
||||
*/
|
||||
limitedCW = false;
|
||||
|
||||
/**
|
||||
* Is this result limited in the counter-clockwise direction?
|
||||
* @type {boolean}
|
||||
*/
|
||||
limitedCCW = false;
|
||||
|
||||
/**
|
||||
* Is this result blocking in the clockwise direction?
|
||||
* @type {boolean}
|
||||
*/
|
||||
blockedCW = false;
|
||||
|
||||
/**
|
||||
* Is this result blocking in the counter-clockwise direction?
|
||||
* @type {boolean}
|
||||
*/
|
||||
blockedCCW = false;
|
||||
|
||||
/**
|
||||
* Previously blocking in the clockwise direction?
|
||||
* @type {boolean}
|
||||
*/
|
||||
blockedCWPrev = false;
|
||||
|
||||
/**
|
||||
* Previously blocking in the counter-clockwise direction?
|
||||
* @type {boolean}
|
||||
*/
|
||||
blockedCCWPrev = false;
|
||||
}
|
||||
285
resources/app/client-esm/canvas/edges/edge.mjs
Normal file
285
resources/app/client-esm/canvas/edges/edge.mjs
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* @typedef {import("../../../common/types.mjs").Point} Point
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {"wall"|"darkness"|"innerBounds"|"outerBounds"} EdgeTypes
|
||||
*/
|
||||
|
||||
/**
|
||||
* A data structure used to represent potential edges used by the ClockwiseSweepPolygon.
|
||||
* Edges are not polygon-specific, meaning they can be reused across many polygon instances.
|
||||
*/
|
||||
export default class Edge {
|
||||
/**
|
||||
* Construct an Edge by providing the following information.
|
||||
* @param {Point} a The first endpoint of the edge
|
||||
* @param {Point} b The second endpoint of the edge
|
||||
* @param {object} [options] Additional options which describe the edge
|
||||
* @param {string} [options.id] A string used to uniquely identify this edge
|
||||
* @param {PlaceableObject} [options.object] A PlaceableObject that is responsible for this edge, if any
|
||||
* @param {EdgeTypes} [options.type] The type of edge
|
||||
* @param {WALL_SENSE_TYPES} [options.light] How this edge restricts light
|
||||
* @param {WALL_SENSE_TYPES} [options.move] How this edge restricts movement
|
||||
* @param {WALL_SENSE_TYPES} [options.sight] How this edge restricts sight
|
||||
* @param {WALL_SENSE_TYPES} [options.sound] How this edge restricts sound
|
||||
* @param {WALL_DIRECTIONS} [options.direction=0] A direction of effect for the edge
|
||||
* @param {WallThresholdData} [options.threshold] Configuration of threshold data for this edge
|
||||
*/
|
||||
constructor(a, b, {id, object, direction, type, light, move, sight, sound, threshold}={}) {
|
||||
this.a = new PIXI.Point(a.x, a.y);
|
||||
this.b = new PIXI.Point(b.x, b.y);
|
||||
this.id = id ?? object?.id ?? undefined;
|
||||
this.object = object;
|
||||
this.type = type || "wall";
|
||||
this.direction = direction ?? CONST.WALL_DIRECTIONS.BOTH;
|
||||
this.light = light ?? CONST.WALL_SENSE_TYPES.NONE;
|
||||
this.move = move ?? CONST.WALL_SENSE_TYPES.NONE;
|
||||
this.sight = sight ?? CONST.WALL_SENSE_TYPES.NONE;
|
||||
this.sound = sound ?? CONST.WALL_SENSE_TYPES.NONE;
|
||||
this.threshold = threshold;
|
||||
|
||||
// Record the edge orientation arranged from top-left to bottom-right
|
||||
const isSE = b.x === a.x ? b.y > a.y : b.x > a.x;
|
||||
if ( isSE ) {
|
||||
this.nw = a;
|
||||
this.se = b;
|
||||
}
|
||||
else {
|
||||
this.nw = b;
|
||||
this.se = a;
|
||||
}
|
||||
this.bounds = new PIXI.Rectangle(this.nw.x, this.nw.y, this.se.x - this.nw.x, this.se.y - this.nw.y);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The first endpoint of the edge.
|
||||
* @type {PIXI.Point}
|
||||
*/
|
||||
a;
|
||||
|
||||
/**
|
||||
* The second endpoint of the edge.
|
||||
* @type {PIXI.Point}
|
||||
*/
|
||||
b;
|
||||
|
||||
/**
|
||||
* The endpoint of the edge which is oriented towards the top-left.
|
||||
*/
|
||||
nw;
|
||||
|
||||
/**
|
||||
* The endpoint of the edge which is oriented towards the bottom-right.
|
||||
*/
|
||||
se;
|
||||
|
||||
/**
|
||||
* The rectangular bounds of the edge. Used by the quadtree.
|
||||
* @type {PIXI.Rectangle}
|
||||
*/
|
||||
bounds;
|
||||
|
||||
/**
|
||||
* The direction of effect for the edge.
|
||||
* @type {WALL_DIRECTIONS}
|
||||
*/
|
||||
direction;
|
||||
|
||||
/**
|
||||
* A string used to uniquely identify this edge.
|
||||
* @type {string}
|
||||
*/
|
||||
id;
|
||||
|
||||
/**
|
||||
* How this edge restricts light.
|
||||
* @type {WALL_SENSE_TYPES}
|
||||
*/
|
||||
light;
|
||||
|
||||
/**
|
||||
* How this edge restricts movement.
|
||||
* @type {WALL_SENSE_TYPES}
|
||||
*/
|
||||
move;
|
||||
|
||||
/**
|
||||
* How this edge restricts sight.
|
||||
* @type {WALL_SENSE_TYPES}
|
||||
*/
|
||||
sight;
|
||||
|
||||
/**
|
||||
* How this edge restricts sound.
|
||||
* @type {WALL_SENSE_TYPES}
|
||||
*/
|
||||
sound;
|
||||
|
||||
/**
|
||||
* Specialized threshold data for this edge.
|
||||
* @type {WallThresholdData}
|
||||
*/
|
||||
threshold;
|
||||
|
||||
/**
|
||||
* Record other edges which this one intersects with.
|
||||
* @type {{edge: Edge, intersection: LineIntersection}[]}
|
||||
*/
|
||||
intersections = [];
|
||||
|
||||
/**
|
||||
* A PolygonVertex instance.
|
||||
* Used as part of ClockwiseSweepPolygon computation.
|
||||
* @type {PolygonVertex}
|
||||
*/
|
||||
vertexA;
|
||||
|
||||
/**
|
||||
* A PolygonVertex instance.
|
||||
* Used as part of ClockwiseSweepPolygon computation.
|
||||
* @type {PolygonVertex}
|
||||
*/
|
||||
vertexB;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this edge limited for a particular type?
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isLimited(type) {
|
||||
return this[type] === CONST.WALL_SENSE_TYPES.LIMITED;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a copy of the Edge which can be safely mutated.
|
||||
* @returns {Edge}
|
||||
*/
|
||||
clone() {
|
||||
const clone = new this.constructor(this.a, this.b, this);
|
||||
clone.intersections = [...this.intersections];
|
||||
clone.vertexA = this.vertexA;
|
||||
clone.vertexB = this.vertexB;
|
||||
return clone;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an intersection point between this Edge and another.
|
||||
* @param {Edge} other
|
||||
* @returns {LineIntersection|void}
|
||||
*/
|
||||
getIntersection(other) {
|
||||
if ( this === other ) return;
|
||||
const {a: a0, b: b0} = this;
|
||||
const {a: a1, b: b1} = other;
|
||||
|
||||
// Ignore edges which share an endpoint
|
||||
if ( a0.equals(a1) || a0.equals(b1) || b0.equals(a1) || b0.equals(b1) ) return;
|
||||
|
||||
// Initial fast CCW test for intersection
|
||||
if ( !foundry.utils.lineSegmentIntersects(a0, b0, a1, b1) ) return;
|
||||
|
||||
// Slower computation of intersection point
|
||||
const i = foundry.utils.lineLineIntersection(a0, b0, a1, b1, {t1: true});
|
||||
if ( !i ) return; // Eliminates co-linear lines, theoretically should not be necessary but just in case
|
||||
return i;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether to apply a proximity threshold to this edge.
|
||||
* If the proximity threshold is met, this edge excluded from perception calculations.
|
||||
* @param {string} sourceType Sense type for the source
|
||||
* @param {Point} sourceOrigin The origin or position of the source on the canvas
|
||||
* @param {number} [externalRadius=0] The external radius of the source
|
||||
* @returns {boolean} True if the edge has a threshold greater than 0 for the source type,
|
||||
* and the source type is within that distance.
|
||||
*/
|
||||
applyThreshold(sourceType, sourceOrigin, externalRadius=0) {
|
||||
const d = this.threshold?.[sourceType];
|
||||
const t = this[sourceType];
|
||||
if ( !d || (t < CONST.WALL_SENSE_TYPES.PROXIMITY) ) return false; // Threshold behavior does not apply
|
||||
const proximity = t === CONST.WALL_SENSE_TYPES.PROXIMITY;
|
||||
const pt = foundry.utils.closestPointToSegment(sourceOrigin, this.a, this.b);
|
||||
const sourceDistance = Math.hypot(pt.x - sourceOrigin.x, pt.y - sourceOrigin.y);
|
||||
return proximity ? Math.max(sourceDistance - externalRadius, 0) < d : (sourceDistance + externalRadius) > d;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine the orientation of this Edge with respect to a reference point.
|
||||
* @param {Point} point Some reference point, relative to which orientation is determined
|
||||
* @returns {number} An orientation in CONST.WALL_DIRECTIONS which indicates whether the Point is left,
|
||||
* right, or collinear (both) with the Edge
|
||||
*/
|
||||
orientPoint(point) {
|
||||
const orientation = foundry.utils.orient2dFast(this.a, this.b, point);
|
||||
if ( orientation === 0 ) return CONST.WALL_DIRECTIONS.BOTH;
|
||||
return orientation < 0 ? CONST.WALL_DIRECTIONS.LEFT : CONST.WALL_DIRECTIONS.RIGHT;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Intersection Management */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Identify intersections between a provided iterable of edges.
|
||||
* @param {Iterable<Edge>} edges An iterable of edges
|
||||
*/
|
||||
static identifyEdgeIntersections(edges) {
|
||||
|
||||
// Sort edges by their north-west x value, breaking ties with the south-east x value
|
||||
const sorted = [];
|
||||
for ( const edge of edges ) {
|
||||
edge.intersections.length = 0; // Clear prior intersections
|
||||
sorted.push(edge);
|
||||
}
|
||||
sorted.sort((e1, e2) => (e1.nw.x - e2.nw.x) || (e1.se.x - e2.se.x));
|
||||
|
||||
// Iterate over all known edges, identifying intersections
|
||||
const ln = sorted.length;
|
||||
for ( let i=0; i<ln; i++ ) {
|
||||
const e1 = sorted[i];
|
||||
for ( let j=i+1; j<ln; j++ ) {
|
||||
const e2 = sorted[j];
|
||||
if ( e2.nw.x > e1.se.x ) break; // Segment e2 is entirely right of segment e1
|
||||
e1.recordIntersections(e2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Record the intersections between two edges.
|
||||
* @param {Edge} other Another edge to test and record
|
||||
*/
|
||||
recordIntersections(other) {
|
||||
if ( other === this ) return;
|
||||
const i = this.getIntersection(other);
|
||||
if ( !i ) return;
|
||||
this.intersections.push({edge: other, intersection: i});
|
||||
other.intersections.push({edge: this, intersection: {x: i.x, y: i.y, t0: i.t1, t1: i.t0}});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove intersections of this edge with all other edges.
|
||||
*/
|
||||
removeIntersections() {
|
||||
for ( const {edge: other} of this.intersections ) {
|
||||
other.intersections.findSplice(e => e.edge === this);
|
||||
}
|
||||
this.intersections.length = 0;
|
||||
}
|
||||
}
|
||||
84
resources/app/client-esm/canvas/edges/edges.mjs
Normal file
84
resources/app/client-esm/canvas/edges/edges.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
import Edge from "./edge.mjs";
|
||||
|
||||
/**
|
||||
* A special class of Map which defines all the edges used to restrict perception in a Scene.
|
||||
* @extends {Map<string, Edge>}
|
||||
*/
|
||||
export default class CanvasEdges extends Map {
|
||||
|
||||
/**
|
||||
* Edge instances which represent the outer boundaries of the game canvas.
|
||||
* @type {Edge[]}
|
||||
*/
|
||||
#outerBounds = [];
|
||||
|
||||
/**
|
||||
* Edge instances which represent the inner boundaries of the scene rectangle.
|
||||
* @type {Edge[]}
|
||||
*/
|
||||
#innerBounds = [];
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize all active edges for the Scene. This workflow occurs once only when the Canvas is first initialized.
|
||||
* Edges are created from the following sources:
|
||||
* 1. Wall documents
|
||||
* 2. Canvas boundaries (inner and outer bounds)
|
||||
* 3. Darkness sources
|
||||
* 4. Programmatically defined in the "initializeEdges" hook
|
||||
*/
|
||||
initialize() {
|
||||
this.clear();
|
||||
|
||||
// Wall Documents
|
||||
for ( /** @type {Wall} */ const wall of canvas.walls.placeables ) wall.initializeEdge();
|
||||
|
||||
// Canvas Boundaries
|
||||
this.#defineBoundaries();
|
||||
|
||||
// Darkness Sources
|
||||
for ( const source of canvas.effects.darknessSources ) {
|
||||
for ( const edge of source.edges ) this.set(edge.id, edge);
|
||||
}
|
||||
|
||||
// Programmatic Edges
|
||||
Hooks.callAll("initializeEdges");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Incrementally refresh Edges by computing intersections between all registered edges.
|
||||
*/
|
||||
refresh() {
|
||||
Edge.identifyEdgeIntersections(canvas.edges.values());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Define Edge instances for outer and inner canvas bounds rectangles.
|
||||
*/
|
||||
#defineBoundaries() {
|
||||
const d = canvas.dimensions;
|
||||
const define = (type, r) => {
|
||||
const top = new Edge({x: r.x, y: r.y}, {x: r.right, y: r.y}, {id: `${type}Top`, type});
|
||||
const right = new Edge({x: r.right, y: r.y}, {x: r.right, y: r.bottom}, {id: `${type}Right`, type});
|
||||
const bottom = new Edge({x: r.right, y: r.bottom}, {x: r.x, y: r.bottom}, {id: `${type}Bottom`, type});
|
||||
const left = new Edge({x: r.x, y: r.bottom}, {x: r.x, y: r.y}, {id: `${type}Left`, type});
|
||||
return [top, right, bottom, left];
|
||||
};
|
||||
|
||||
// Outer canvas bounds
|
||||
this.#outerBounds = define("outerBounds", d.rect);
|
||||
for ( const b of this.#outerBounds ) this.set(b.id, b);
|
||||
|
||||
// Inner canvas bounds (if there is padding)
|
||||
if ( d.rect.x === d.sceneRect.x ) this.#innerBounds = this.#outerBounds;
|
||||
else {
|
||||
this.#innerBounds = define("innerBounds", d.sceneRect);
|
||||
for ( const b of this.#innerBounds ) this.set(b.id, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
187
resources/app/client-esm/canvas/edges/vertex.mjs
Normal file
187
resources/app/client-esm/canvas/edges/vertex.mjs
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* A specialized point data structure used to represent vertices in the context of the ClockwiseSweepPolygon.
|
||||
* This class is not designed or intended for use outside of that context.
|
||||
* @alias PolygonVertex
|
||||
*/
|
||||
export default class PolygonVertex {
|
||||
constructor(x, y, {distance, index}={}) {
|
||||
this.x = Math.round(x);
|
||||
this.y = Math.round(y);
|
||||
this.key = PolygonVertex.getKey(this.x, this.y);
|
||||
this._distance = distance;
|
||||
this._d2 = undefined;
|
||||
this._index = index;
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective maximum texture size that Foundry VTT "ever" has to worry about.
|
||||
* @type {number}
|
||||
*/
|
||||
static #MAX_TEXTURE_SIZE = Math.pow(2, 16);
|
||||
|
||||
/**
|
||||
* Determine the sort key to use for this vertex, arranging points from north-west to south-east.
|
||||
* @param {number} x The x-coordinate
|
||||
* @param {number} y The y-coordinate
|
||||
* @returns {number} The key used to identify the vertex
|
||||
*/
|
||||
static getKey(x, y) {
|
||||
return (this.#MAX_TEXTURE_SIZE * x) + y;
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of edges which connect to this vertex.
|
||||
* This set is initially empty and populated later after vertices are de-duplicated.
|
||||
* @type {EdgeSet}
|
||||
*/
|
||||
edges = new Set();
|
||||
|
||||
/**
|
||||
* The subset of edges which continue clockwise from this vertex.
|
||||
* @type {EdgeSet}
|
||||
*/
|
||||
cwEdges = new Set();
|
||||
|
||||
/**
|
||||
* The subset of edges which continue counter-clockwise from this vertex.
|
||||
* @type {EdgeSet}
|
||||
*/
|
||||
ccwEdges = new Set();
|
||||
|
||||
/**
|
||||
* The set of vertices collinear to this vertex
|
||||
* @type {Set<PolygonVertex>}
|
||||
*/
|
||||
collinearVertices = new Set();
|
||||
|
||||
/**
|
||||
* Is this vertex an endpoint of one or more edges?
|
||||
* @type {boolean}
|
||||
*/
|
||||
isEndpoint;
|
||||
|
||||
/**
|
||||
* Does this vertex have a single counterclockwise limiting edge?
|
||||
* @type {boolean}
|
||||
*/
|
||||
isLimitingCCW;
|
||||
|
||||
/**
|
||||
* Does this vertex have a single clockwise limiting edge?
|
||||
* @type {boolean}
|
||||
*/
|
||||
isLimitingCW;
|
||||
|
||||
/**
|
||||
* Does this vertex have non-limited edges or 2+ limited edges counterclockwise?
|
||||
* @type {boolean}
|
||||
*/
|
||||
isBlockingCCW;
|
||||
|
||||
/**
|
||||
* Does this vertex have non-limited edges or 2+ limited edges clockwise?
|
||||
* @type {boolean}
|
||||
*/
|
||||
isBlockingCW;
|
||||
|
||||
/**
|
||||
* Does this vertex result from an internal collision?
|
||||
* @type {boolean}
|
||||
*/
|
||||
isInternal = false;
|
||||
|
||||
/**
|
||||
* The maximum restriction imposed by this vertex.
|
||||
* @type {number}
|
||||
*/
|
||||
restriction = 0;
|
||||
|
||||
/**
|
||||
* Record whether this PolygonVertex has been visited in the sweep
|
||||
* @type {boolean}
|
||||
* @internal
|
||||
*/
|
||||
_visited = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this vertex limited in type?
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get isLimited() {
|
||||
return this.restriction === CONST.WALL_SENSE_TYPES.LIMITED;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Associate an edge with this vertex.
|
||||
* @param {Edge} edge The edge being attached
|
||||
* @param {number} orientation The orientation of the edge with respect to the origin
|
||||
* @param {string} type The restriction type of polygon being created
|
||||
*/
|
||||
attachEdge(edge, orientation, type) {
|
||||
this.edges.add(edge);
|
||||
this.restriction = Math.max(this.restriction ?? 0, edge[type]);
|
||||
if ( orientation <= 0 ) this.cwEdges.add(edge);
|
||||
if ( orientation >= 0 ) this.ccwEdges.add(edge);
|
||||
this.#updateFlags(type);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update flags for whether this vertex is limiting or blocking in certain direction.
|
||||
* @param {string} type
|
||||
*/
|
||||
#updateFlags(type) {
|
||||
const classify = edges => {
|
||||
const s = edges.size;
|
||||
if ( s === 0 ) return {isLimiting: false, isBlocking: false};
|
||||
if ( s > 1 ) return {isLimiting: false, isBlocking: true};
|
||||
else {
|
||||
const isLimiting = edges.first().isLimited(type);
|
||||
return {isLimiting, isBlocking: !isLimiting};
|
||||
}
|
||||
};
|
||||
|
||||
// Flag endpoint
|
||||
this.isEndpoint = this.edges.some(edge => {
|
||||
return (edge.vertexA || edge.a).equals(this) || (edge.vertexB || edge.b).equals(this);
|
||||
});
|
||||
|
||||
// Flag CCW edges
|
||||
const ccwFlags = classify(this.ccwEdges);
|
||||
this.isLimitingCCW = ccwFlags.isLimiting;
|
||||
this.isBlockingCCW = ccwFlags.isBlocking;
|
||||
|
||||
// Flag CW edges
|
||||
const cwFlags = classify(this.cwEdges);
|
||||
this.isLimitingCW = cwFlags.isLimiting;
|
||||
this.isBlockingCW = cwFlags.isBlocking;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this vertex the same point as some other vertex?
|
||||
* @param {PolygonVertex} other Some other vertex
|
||||
* @returns {boolean} Are they the same point?
|
||||
*/
|
||||
equals(other) {
|
||||
return this.key === other.key;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Construct a PolygonVertex instance from some other Point structure.
|
||||
* @param {Point} point The point
|
||||
* @param {object} [options] Additional options that apply to this vertex
|
||||
* @returns {PolygonVertex} The constructed vertex
|
||||
*/
|
||||
static fromPoint(point, options) {
|
||||
return new this(point.x, point.y, options);
|
||||
}
|
||||
}
|
||||
4
resources/app/client-esm/canvas/regions/_module.mjs
Normal file
4
resources/app/client-esm/canvas/regions/_module.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
export {default as RegionGeometry} from "./geometry.mjs";
|
||||
export {default as RegionMesh} from "./mesh.mjs";
|
||||
export {default as RegionPolygonTree} from "./polygon-tree.mjs";
|
||||
export {default as RegionShape} from "./shape.mjs";
|
||||
65
resources/app/client-esm/canvas/regions/geometry.mjs
Normal file
65
resources/app/client-esm/canvas/regions/geometry.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The geometry of a {@link Region}.
|
||||
* - Vertex Attribute: `aVertexPosition` (`vec2`)
|
||||
* - Draw Mode: `PIXI.DRAW_MODES.TRIANGLES`
|
||||
*/
|
||||
export default class RegionGeometry extends PIXI.Geometry {
|
||||
|
||||
/**
|
||||
* Create a RegionGeometry.
|
||||
* @param {Region} region The Region to create the RegionGeometry from.
|
||||
* @internal
|
||||
*/
|
||||
constructor(region) {
|
||||
super();
|
||||
this.#region = region;
|
||||
this.addAttribute("aVertexPosition", new PIXI.Buffer(new Float32Array(), true, false), 2);
|
||||
this.addIndex(new PIXI.Buffer(new Uint16Array(), true, true));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The Region this geometry belongs to.
|
||||
* @type {Region}
|
||||
*/
|
||||
get region() {
|
||||
return this.#region;
|
||||
}
|
||||
|
||||
#region;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Do the buffers need to be updated?
|
||||
* @type {boolean}
|
||||
*/
|
||||
#invalidBuffers = true;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the buffers.
|
||||
* @internal
|
||||
*/
|
||||
_clearBuffers() {
|
||||
this.buffers[0].update(new Float32Array());
|
||||
this.indexBuffer.update(new Uint16Array());
|
||||
this.#invalidBuffers = true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the buffers.
|
||||
* @internal
|
||||
*/
|
||||
_updateBuffers() {
|
||||
if ( !this.#invalidBuffers ) return;
|
||||
const triangulation = this.region.triangulation;
|
||||
this.buffers[0].update(triangulation.vertices);
|
||||
this.indexBuffer.update(triangulation.indices);
|
||||
this.#invalidBuffers = false;
|
||||
}
|
||||
}
|
||||
213
resources/app/client-esm/canvas/regions/mesh.mjs
Normal file
213
resources/app/client-esm/canvas/regions/mesh.mjs
Normal file
@@ -0,0 +1,213 @@
|
||||
|
||||
/**
|
||||
* A mesh of a {@link Region}.
|
||||
* @extends {PIXI.Container}
|
||||
*/
|
||||
export default class RegionMesh extends PIXI.Container {
|
||||
|
||||
/**
|
||||
* Create a RegionMesh.
|
||||
* @param {Region} region The Region to create the RegionMesh from.
|
||||
* @param {AbstractBaseShader} [shaderClass] The shader class to use.
|
||||
*/
|
||||
constructor(region, shaderClass=RegionShader) {
|
||||
super();
|
||||
this.#region = region;
|
||||
this.region.geometry.refCount++;
|
||||
if ( !AbstractBaseShader.isPrototypeOf(shaderClass) ) {
|
||||
throw new Error("RegionMesh shader class must inherit from AbstractBaseShader.");
|
||||
}
|
||||
this.#shader = shaderClass.create();
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Shared point instance.
|
||||
* @type {PIXI.Point}
|
||||
*/
|
||||
static #SHARED_POINT = new PIXI.Point();
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* The Region of this RegionMesh.
|
||||
* @type {RegionMesh}
|
||||
*/
|
||||
get region() {
|
||||
return this.#region;
|
||||
}
|
||||
|
||||
#region;
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* The shader bound to this RegionMesh.
|
||||
* @type {AbstractBaseShader}
|
||||
*/
|
||||
get shader() {
|
||||
return this.#shader;
|
||||
}
|
||||
|
||||
#shader;
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* The blend mode assigned to this RegionMesh.
|
||||
* @type {PIXI.BLEND_MODES}
|
||||
*/
|
||||
get blendMode() {
|
||||
return this.#state.blendMode;
|
||||
}
|
||||
|
||||
set blendMode(value) {
|
||||
if ( this.#state.blendMode === value ) return;
|
||||
this.#state.blendMode = value;
|
||||
this._tintAlphaDirty = true;
|
||||
}
|
||||
|
||||
#state = PIXI.State.for2d();
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* The tint applied to the mesh. This is a hex value.
|
||||
*
|
||||
* A value of 0xFFFFFF will remove any tint effect.
|
||||
* @type {number}
|
||||
* @defaultValue 0xFFFFFF
|
||||
*/
|
||||
get tint() {
|
||||
return this._tintColor.value;
|
||||
}
|
||||
|
||||
set tint(tint) {
|
||||
const currentTint = this._tintColor.value;
|
||||
this._tintColor.setValue(tint);
|
||||
if ( currentTint === this._tintColor.value ) return;
|
||||
this._tintAlphaDirty = true;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* The tint applied to the mesh. This is a hex value. A value of 0xFFFFFF will remove any tint effect.
|
||||
* @type {PIXI.Color}
|
||||
* @protected
|
||||
*/
|
||||
_tintColor = new PIXI.Color(0xFFFFFF);
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Cached tint value for the shader uniforms.
|
||||
* @type {[red: number, green: number, blue: number, alpha: number]}
|
||||
* @protected
|
||||
* @internal
|
||||
*/
|
||||
_cachedTint = [1, 1, 1, 1];
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Used to track a tint or alpha change to execute a recomputation of _cachedTint.
|
||||
* @type {boolean}
|
||||
* @protected
|
||||
*/
|
||||
_tintAlphaDirty = true;
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize shader based on the shader class type.
|
||||
* @param {type AbstractBaseShader} shaderClass The shader class, which must inherit from {@link AbstractBaseShader}.
|
||||
*/
|
||||
setShaderClass(shaderClass) {
|
||||
if ( !AbstractBaseShader.isPrototypeOf(shaderClass) ) {
|
||||
throw new Error("RegionMesh shader class must inherit from AbstractBaseShader.");
|
||||
}
|
||||
if ( this.#shader.constructor === shaderClass ) return;
|
||||
|
||||
// Create shader program
|
||||
this.#shader = shaderClass.create();
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
updateTransform() {
|
||||
super.updateTransform();
|
||||
|
||||
// We set tintAlphaDirty to true if the worldAlpha has changed
|
||||
// It is needed to recompute the _cachedTint vec4 which is a combination of tint and alpha
|
||||
if ( this.#worldAlpha !== this.worldAlpha ) {
|
||||
this.#worldAlpha = this.worldAlpha;
|
||||
this._tintAlphaDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
#worldAlpha;
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_render(renderer) {
|
||||
if ( this._tintAlphaDirty ) {
|
||||
const premultiply = PIXI.utils.premultiplyBlendMode[1][this.blendMode] === this.blendMode;
|
||||
PIXI.Color.shared.setValue(this._tintColor)
|
||||
.premultiply(this.worldAlpha, premultiply)
|
||||
.toArray(this._cachedTint);
|
||||
this._tintAlphaDirty = false;
|
||||
}
|
||||
this.#shader._preRender(this, renderer);
|
||||
this.#shader.uniforms.translationMatrix = this.transform.worldTransform.toArray(true);
|
||||
|
||||
// Flush batch renderer
|
||||
renderer.batch.flush();
|
||||
|
||||
// Set state
|
||||
renderer.state.set(this.#state);
|
||||
|
||||
// Bind shader and geometry
|
||||
renderer.shader.bind(this.#shader);
|
||||
const geometry = this.region.geometry;
|
||||
geometry._updateBuffers();
|
||||
renderer.geometry.bind(geometry, this.#shader);
|
||||
|
||||
// Draw the geometry
|
||||
renderer.geometry.draw(PIXI.DRAW_MODES.TRIANGLES);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_calculateBounds() {
|
||||
const {left, top, right, bottom} = this.region.bounds;
|
||||
this._bounds.addFrame(this.transform, left, top, right, bottom);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Tests if a point is inside this RegionMesh.
|
||||
* @param {PIXI.IPointData} point
|
||||
* @returns {boolean}
|
||||
*/
|
||||
containsPoint(point) {
|
||||
return this.region.polygonTree.testPoint(this.worldTransform.applyInverse(point, RegionMesh.#SHARED_POINT));
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
destroy(options) {
|
||||
super.destroy(options);
|
||||
const geometry = this.region.geometry;
|
||||
geometry.refCount--;
|
||||
if ( geometry.refCount === 0 ) geometry.dispose();
|
||||
this.#shader = null;
|
||||
this.#state = null;
|
||||
}
|
||||
}
|
||||
298
resources/app/client-esm/canvas/regions/polygon-tree.mjs
Normal file
298
resources/app/client-esm/canvas/regions/polygon-tree.mjs
Normal file
@@ -0,0 +1,298 @@
|
||||
|
||||
/**
|
||||
* The node of a {@link RegionPolygonTree}.
|
||||
*/
|
||||
class RegionPolygonTreeNode {
|
||||
|
||||
/**
|
||||
* Create a RegionPolygonTreeNode.
|
||||
* @param {RegionPolygonTreeNode|null} parent The parent node.
|
||||
* @internal
|
||||
*/
|
||||
constructor(parent) {
|
||||
this.#parent = parent;
|
||||
this.#children = [];
|
||||
this.#depth = parent ? parent.depth + 1 : 0;
|
||||
this.#isHole = this.#depth % 2 === 0;
|
||||
if ( parent ) parent.#children.push(this);
|
||||
else {
|
||||
this.#polygon = null;
|
||||
this.#clipperPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a node from the Clipper path and add it to the children of the parent.
|
||||
* @param {ClipperLib.IntPoint[]} clipperPath The clipper path of this node.
|
||||
* @param {RegionPolygonTreeNode|null} parent The parent node or `null` if root.
|
||||
* @internal
|
||||
*/
|
||||
static _fromClipperPath(clipperPath, parent) {
|
||||
const node = new RegionPolygonTreeNode(parent);
|
||||
if ( parent ) node.#clipperPath = clipperPath;
|
||||
return node;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The parent of this node or `null` if this is the root node.
|
||||
* @type {RegionPolygonTreeNode|null}
|
||||
*/
|
||||
get parent() {
|
||||
return this.#parent;
|
||||
}
|
||||
|
||||
#parent;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The children of this node.
|
||||
* @type {ReadonlyArray<RegionPolygonTreeNode>}
|
||||
*/
|
||||
get children() {
|
||||
return this.#children;
|
||||
}
|
||||
|
||||
#children;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The depth of this node.
|
||||
* The depth of the root node is 0.
|
||||
* @type {number}
|
||||
*/
|
||||
get depth() {
|
||||
return this.#depth;
|
||||
}
|
||||
|
||||
#depth;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this a hole?
|
||||
* The root node is a hole.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isHole() {
|
||||
return this.#isHole;
|
||||
}
|
||||
|
||||
#isHole;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The Clipper path of this node.
|
||||
* It is empty in case of the root node.
|
||||
* @type {ReadonlyArray<ClipperLib.IntPoint>|null}
|
||||
*/
|
||||
get clipperPath() {
|
||||
return this.#clipperPath;
|
||||
}
|
||||
|
||||
#clipperPath;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The polygon of this node.
|
||||
* It is `null` in case of the root node.
|
||||
* @type {PIXI.Polygon|null}
|
||||
*/
|
||||
get polygon() {
|
||||
let polygon = this.#polygon;
|
||||
if ( polygon === undefined ) polygon = this.#polygon = this.#createPolygon();
|
||||
return polygon;
|
||||
}
|
||||
|
||||
#polygon;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The points of the polygon ([x0, y0, x1, y1, ...]).
|
||||
* They are `null` in case of the root node.
|
||||
* @type {ReadonlyArray<number>|null}
|
||||
*/
|
||||
get points() {
|
||||
const polygon = this.polygon;
|
||||
if ( !polygon ) return null;
|
||||
return polygon.points;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The bounds of the polygon.
|
||||
* They are `null` in case of the root node.
|
||||
* @type {PIXI.Rectangle|null}
|
||||
*/
|
||||
get bounds() {
|
||||
let bounds = this.#bounds;
|
||||
if ( bounds === undefined ) bounds = this.#bounds = this.polygon?.getBounds() ?? null;
|
||||
return bounds;
|
||||
}
|
||||
|
||||
#bounds;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Iterate over recursively over the children in depth-first order.
|
||||
* @yields {RegionPolygonTreeNode}
|
||||
*/
|
||||
*[Symbol.iterator]() {
|
||||
for ( const child of this.children ) {
|
||||
yield child;
|
||||
yield *child;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether given point is contained within this node.
|
||||
* @param {Point} point The point.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
testPoint(point) {
|
||||
return this.#testPoint(point) === 2;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test point containment.
|
||||
* @param {Point} point The point.
|
||||
* @returns {0|1|2} - 0: not contained within the polygon of this node.
|
||||
* - 1: contained within the polygon of this node but also contained
|
||||
* inside the polygon of a sub-node that is a hole.
|
||||
* - 2: contained within the polygon of this node and not contained
|
||||
* inside any polygon of a sub-node that is a hole.
|
||||
*/
|
||||
#testPoint(point) {
|
||||
const {x, y} = point;
|
||||
if ( this.parent ) {
|
||||
if ( !this.bounds.contains(x, y) || !this.polygon.contains(x, y) ) return 0;
|
||||
}
|
||||
const children = this.children;
|
||||
for ( let i = 0, n = children.length; i < n; i++ ) {
|
||||
const result = children[i].#testPoint(point);
|
||||
if ( result !== 0 ) return result;
|
||||
}
|
||||
return this.isHole ? 1 : 2;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test circle containment/intersection with this node.
|
||||
* @param {Point} center The center point of the circle.
|
||||
* @param {number} radius The radius of the circle.
|
||||
* @returns {-1|0|1} - -1: the circle is in the exterior and does not intersect the boundary.
|
||||
* - 0: the circle is intersects the boundary.
|
||||
* - 1: the circle is in the interior and does not intersect the boundary.
|
||||
*/
|
||||
testCircle(center, radius) {
|
||||
switch ( this.#testCircle(center, radius) ) {
|
||||
case 2: return 1;
|
||||
case 3: return 0;
|
||||
default: return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test circle containment/intersection with this node.
|
||||
* @param {Point} center The center point of the circle.
|
||||
* @param {number} radius The radius of the circle.
|
||||
* @returns {0|1|2|3} - 0: does not intersect the boundary or interior of this node.
|
||||
* - 1: contained within the polygon of this node but also contained
|
||||
* inside the polygon of a sub-node that is a hole.
|
||||
* - 2: contained within the polygon of this node and not contained
|
||||
* inside any polygon of a sub-node that is a hole.
|
||||
* - 3: intersects the boundary of this node or any sub-node.
|
||||
*/
|
||||
#testCircle(center, radius) {
|
||||
if ( this.parent ) {
|
||||
const {x, y} = center;
|
||||
|
||||
// Test whether the circle intersects the bounds of this node
|
||||
const {left, right, top, bottom} = this.bounds;
|
||||
if ( (x < left - radius) || (x > right + radius) || (y < top - radius) || (y > bottom + radius) ) return 0;
|
||||
|
||||
// Test whether the circle intersects any edge of the polygon of this node
|
||||
const intersects = foundry.utils.pathCircleIntersects(this.points, true, center, radius);
|
||||
if ( intersects ) return 3;
|
||||
|
||||
// Test whether the circle is completely outside of the polygon
|
||||
const inside = this.polygon.contains(x, y);
|
||||
if ( !inside ) return 0;
|
||||
}
|
||||
|
||||
// Test the children of this node now that we know that the circle is
|
||||
// completely inside of the polygon of this node
|
||||
const children = this.children;
|
||||
for ( let i = 0, n = children.length; i < n; i++ ) {
|
||||
const result = children[i].#testCircle(center, radius);
|
||||
if ( result !== 0 ) return result;
|
||||
}
|
||||
return this.isHole ? 1 : 2;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create the polygon of this node.
|
||||
* @returns {PIXI.Polygon|null}
|
||||
*/
|
||||
#createPolygon() {
|
||||
if ( !this.parent ) return null;
|
||||
const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
|
||||
const polygon = PIXI.Polygon.fromClipperPoints(this.clipperPath, {scalingFactor});
|
||||
polygon._isPositive = !this.isHole;
|
||||
return polygon;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The polygon tree of a {@link Region}.
|
||||
*/
|
||||
export default class RegionPolygonTree extends RegionPolygonTreeNode {
|
||||
|
||||
/**
|
||||
* Create a RegionPolygonTree.
|
||||
* @internal
|
||||
*/
|
||||
constructor() {
|
||||
super(null);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create the tree from a Clipper polygon tree.
|
||||
* @param {ClipperLib.PolyTree} clipperPolyTree
|
||||
* @internal
|
||||
*/
|
||||
static _fromClipperPolyTree(clipperPolyTree) {
|
||||
const visit = (clipperPolyNode, parent) => {
|
||||
const clipperPath = clipperPolyNode.Contour();
|
||||
const node = RegionPolygonTreeNode._fromClipperPath(clipperPath, parent);
|
||||
clipperPolyNode.Childs().forEach(child => visit(child, node));
|
||||
return node;
|
||||
};
|
||||
const tree = new RegionPolygonTree();
|
||||
clipperPolyTree.Childs().forEach(child => visit(child, tree));
|
||||
return tree;
|
||||
}
|
||||
}
|
||||
371
resources/app/client-esm/canvas/regions/shape.mjs
Normal file
371
resources/app/client-esm/canvas/regions/shape.mjs
Normal file
@@ -0,0 +1,371 @@
|
||||
import {CircleShapeData, EllipseShapeData, PolygonShapeData, RectangleShapeData} from "../../data/_module.mjs";
|
||||
|
||||
/**
|
||||
* A shape of a {@link Region}.
|
||||
* @template {data.BaseShapeData} T
|
||||
* @abstract
|
||||
*/
|
||||
export default class RegionShape {
|
||||
|
||||
/**
|
||||
* Create a RegionShape.
|
||||
* @param {T} data The shape data.
|
||||
* @internal
|
||||
*/
|
||||
constructor(data) {
|
||||
this.#data = data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create the RegionShape from the shape data.
|
||||
* @template {data.BaseShapeData} T
|
||||
* @param {T} data The shape data.
|
||||
* @returns {RegionShape<T>}
|
||||
*/
|
||||
static create(data) {
|
||||
switch ( data.type ) {
|
||||
case "circle": return new RegionCircle(data);
|
||||
case "ellipse": return new RegionEllipse(data);
|
||||
case "polygon": return new RegionPolygon(data);
|
||||
case "rectangle": return new RegionRectangle(data);
|
||||
default: throw new Error("Invalid shape type");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The data of this shape.
|
||||
* It is owned by the shape and must not be modified.
|
||||
* @type {T}
|
||||
*/
|
||||
get data() {
|
||||
return this.#data;
|
||||
}
|
||||
|
||||
#data;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this a hole?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isHole() {
|
||||
return this.data.hole;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The Clipper paths of this shape.
|
||||
* The winding numbers are 1 or 0.
|
||||
* @type {ReadonlyArray<ReadonlyArray<ClipperLib.IntPoint>>}
|
||||
*/
|
||||
get clipperPaths() {
|
||||
return this.#clipperPaths ??= ClipperLib.Clipper.PolyTreeToPaths(this.clipperPolyTree);
|
||||
}
|
||||
|
||||
#clipperPaths;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The Clipper polygon tree of this shape.
|
||||
* @type {ClipperLib.PolyTree}
|
||||
*/
|
||||
get clipperPolyTree() {
|
||||
let clipperPolyTree = this.#clipperPolyTree;
|
||||
if ( !clipperPolyTree ) {
|
||||
clipperPolyTree = this._createClipperPolyTree();
|
||||
if ( Array.isArray(clipperPolyTree) ) {
|
||||
const clipperPolyNode = new ClipperLib.PolyNode();
|
||||
clipperPolyNode.m_polygon = clipperPolyTree;
|
||||
clipperPolyTree = new ClipperLib.PolyTree();
|
||||
clipperPolyTree.AddChild(clipperPolyNode);
|
||||
clipperPolyTree.m_AllPolys.push(clipperPolyNode);
|
||||
}
|
||||
this.#clipperPolyTree = clipperPolyTree;
|
||||
}
|
||||
return clipperPolyTree;
|
||||
}
|
||||
|
||||
#clipperPolyTree;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create the Clipper polygon tree of this shape.
|
||||
* This function may return a single positively-orientated and non-selfintersecting Clipper path instead of a tree,
|
||||
* which is automatically converted to a Clipper polygon tree.
|
||||
* This function is called only once. It is not called if the shape is empty.
|
||||
* @returns {ClipperLib.PolyTree|ClipperLib.IntPoint[]}
|
||||
* @protected
|
||||
* @abstract
|
||||
*/
|
||||
_createClipperPolyTree() {
|
||||
throw new Error("A subclass of the RegionShape must implement the _createClipperPolyTree method.");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw shape into the graphics.
|
||||
* @param {PIXI.Graphics} graphics The graphics to draw the shape into.
|
||||
* @protected
|
||||
* @internal
|
||||
*/
|
||||
_drawShape(graphics) {
|
||||
throw new Error("A subclass of the RegionShape must implement the _drawShape method.");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A circle of a {@link Region}.
|
||||
* @extends {RegionShape<data.CircleShapeData>}
|
||||
*
|
||||
* @param {data.CircleShapeData} data The shape data.
|
||||
*/
|
||||
class RegionCircle extends RegionShape {
|
||||
constructor(data) {
|
||||
if ( !(data instanceof CircleShapeData) ) throw new Error("Invalid shape data");
|
||||
super(data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The vertex density epsilon used to create a polygon approximation of the circle.
|
||||
* @type {number}
|
||||
*/
|
||||
static #VERTEX_DENSITY_EPSILON = 1;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_createClipperPolyTree() {
|
||||
const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
|
||||
const data = this.data;
|
||||
const x = data.x * scalingFactor;
|
||||
const y = data.y * scalingFactor;
|
||||
const radius = data.radius * scalingFactor;
|
||||
const epsilon = RegionCircle.#VERTEX_DENSITY_EPSILON * scalingFactor;
|
||||
const density = PIXI.Circle.approximateVertexDensity(radius, epsilon);
|
||||
const path = new Array(density);
|
||||
for ( let i = 0; i < density; i++ ) {
|
||||
const angle = 2 * Math.PI * (i / density);
|
||||
path[i] = new ClipperLib.IntPoint(
|
||||
Math.round(x + (Math.cos(angle) * radius)),
|
||||
Math.round(y + (Math.sin(angle) * radius))
|
||||
);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_drawShape(graphics) {
|
||||
const {x, y, radius} = this.data;
|
||||
graphics.drawCircle(x, y, radius);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An ellipse of a {@link Region}.
|
||||
* @extends {RegionShape<data.EllipseShapeData>}
|
||||
*
|
||||
* @param {data.EllipseShapeData} data The shape data.
|
||||
*/
|
||||
class RegionEllipse extends RegionShape {
|
||||
constructor(data) {
|
||||
if ( !(data instanceof EllipseShapeData) ) throw new Error("Invalid shape data");
|
||||
super(data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The vertex density epsilon used to create a polygon approximation of the circle.
|
||||
* @type {number}
|
||||
*/
|
||||
static #VERTEX_DENSITY_EPSILON = 1;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_createClipperPolyTree() {
|
||||
const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
|
||||
const data = this.data;
|
||||
const x = data.x * scalingFactor;
|
||||
const y = data.y * scalingFactor;
|
||||
const radiusX = data.radiusX * scalingFactor;
|
||||
const radiusY = data.radiusY * scalingFactor;
|
||||
const epsilon = RegionEllipse.#VERTEX_DENSITY_EPSILON * scalingFactor;
|
||||
const density = PIXI.Circle.approximateVertexDensity((radiusX + radiusY) / 2, epsilon);
|
||||
const rotation = Math.toRadians(data.rotation);
|
||||
const cos = Math.cos(rotation);
|
||||
const sin = Math.sin(rotation);
|
||||
const path = new Array(density);
|
||||
for ( let i = 0; i < density; i++ ) {
|
||||
const angle = 2 * Math.PI * (i / density);
|
||||
const dx = Math.cos(angle) * radiusX;
|
||||
const dy = Math.sin(angle) * radiusY;
|
||||
path[i] = new ClipperLib.IntPoint(
|
||||
Math.round(x + ((cos * dx) - (sin * dy))),
|
||||
Math.round(y + ((sin * dx) + (cos * dy)))
|
||||
);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_drawShape(graphics) {
|
||||
const {x, y, radiusX, radiusY, rotation} = this.data;
|
||||
if ( rotation === 0 ) {
|
||||
graphics.drawEllipse(x, y, radiusX, radiusY);
|
||||
} else {
|
||||
graphics.setMatrix(new PIXI.Matrix()
|
||||
.translate(-x, -x)
|
||||
.rotate(Math.toRadians(rotation))
|
||||
.translate(x, y));
|
||||
graphics.drawEllipse(x, y, radiusX, radiusY);
|
||||
graphics.setMatrix(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A polygon of a {@link Region}.
|
||||
* @extends {RegionShape<data.PolygonShapeData>}
|
||||
*
|
||||
* @param {data.PolygonShapeData} data The shape data.
|
||||
*/
|
||||
class RegionPolygon extends RegionShape {
|
||||
constructor(data) {
|
||||
if ( !(data instanceof PolygonShapeData) ) throw new Error("Invalid shape data");
|
||||
super(data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_createClipperPolyTree() {
|
||||
const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
|
||||
const points = this.data.points;
|
||||
const path = new Array(points.length / 2);
|
||||
for ( let i = 0, j = 0; i < path.length; i++ ) {
|
||||
path[i] = new ClipperLib.IntPoint(
|
||||
Math.round(points[j++] * scalingFactor),
|
||||
Math.round(points[j++] * scalingFactor)
|
||||
);
|
||||
}
|
||||
if ( !ClipperLib.Clipper.Orientation(path) ) path.reverse();
|
||||
return path;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_drawShape(graphics) {
|
||||
graphics.drawPolygon(this.data.points);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A rectangle of a {@link Region}.
|
||||
* @extends {RegionShape<data.RectangleShapeData>}
|
||||
*
|
||||
* @param {data.RectangleShapeData} data The shape data.
|
||||
*/
|
||||
class RegionRectangle extends RegionShape {
|
||||
constructor(data) {
|
||||
if ( !(data instanceof RectangleShapeData) ) throw new Error("Invalid shape data");
|
||||
super(data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_createClipperPolyTree() {
|
||||
let p0;
|
||||
let p1;
|
||||
let p2;
|
||||
let p3;
|
||||
const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
|
||||
const {x, y, width, height, rotation} = this.data;
|
||||
let x0 = x * scalingFactor;
|
||||
let y0 = y * scalingFactor;
|
||||
let x1 = (x + width) * scalingFactor;
|
||||
let y1 = (y + height) * scalingFactor;
|
||||
|
||||
// The basic non-rotated case
|
||||
if ( rotation === 0 ) {
|
||||
x0 = Math.round(x0);
|
||||
y0 = Math.round(y0);
|
||||
x1 = Math.round(x1);
|
||||
y1 = Math.round(y1);
|
||||
p0 = new ClipperLib.IntPoint(x0, y0);
|
||||
p1 = new ClipperLib.IntPoint(x1, y0);
|
||||
p2 = new ClipperLib.IntPoint(x1, y1);
|
||||
p3 = new ClipperLib.IntPoint(x0, y1);
|
||||
}
|
||||
|
||||
// The more complex rotated case
|
||||
else {
|
||||
const tx = (x0 + x1) / 2;
|
||||
const ty = (y0 + y1) / 2;
|
||||
x0 -= tx;
|
||||
y0 -= ty;
|
||||
x1 -= tx;
|
||||
y1 -= ty;
|
||||
const angle = Math.toRadians(rotation);
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const x00 = Math.round((cos * x0) - (sin * y0) + tx);
|
||||
const y00 = Math.round((sin * x0) + (cos * y0) + ty);
|
||||
const x10 = Math.round((cos * x1) - (sin * y0) + tx);
|
||||
const y10 = Math.round((sin * x1) + (cos * y0) + ty);
|
||||
const x11 = Math.round((cos * x1) - (sin * y1) + tx);
|
||||
const y11 = Math.round((sin * x1) + (cos * y1) + ty);
|
||||
const x01 = Math.round((cos * x0) - (sin * y1) + tx);
|
||||
const y01 = Math.round((sin * x0) + (cos * y1) + ty);
|
||||
p0 = new ClipperLib.IntPoint(x00, y00);
|
||||
p1 = new ClipperLib.IntPoint(x10, y10);
|
||||
p2 = new ClipperLib.IntPoint(x11, y11);
|
||||
p3 = new ClipperLib.IntPoint(x01, y01);
|
||||
}
|
||||
return [p0, p1, p2, p3];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_drawShape(graphics) {
|
||||
const {x, y, width, height, rotation} = this.data;
|
||||
if ( rotation === 0 ) {
|
||||
graphics.drawRect(x, y, width, height);
|
||||
} else {
|
||||
const centerX = x + (width / 2);
|
||||
const centerY = y + (height / 2);
|
||||
graphics.setMatrix(new PIXI.Matrix()
|
||||
.translate(-centerX, -centerY)
|
||||
.rotate(Math.toRadians(rotation))
|
||||
.translate(centerX, centerY));
|
||||
graphics.drawRect(x, y, width, height);
|
||||
graphics.setMatrix(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
128
resources/app/client-esm/canvas/scene-manager.mjs
Normal file
128
resources/app/client-esm/canvas/scene-manager.mjs
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* A framework for imbuing special scripted behaviors into a single specific Scene.
|
||||
* Managed scenes are registered in CONFIG.Canvas.managedScenes.
|
||||
*
|
||||
* The SceneManager instance is called at various points in the Scene rendering life-cycle.
|
||||
*
|
||||
* This also provides a framework for registering additional hook events which are required only for the life-cycle of
|
||||
* the managed Scene.
|
||||
*
|
||||
* @example Registering a custom SceneManager
|
||||
* ```js
|
||||
* // Define a custom SceneManager subclass
|
||||
* class MyCustomSceneManager extends SceneManager {
|
||||
* async _onInit() {
|
||||
* console.log(`Initializing managed Scene "${this.scene.name}"`);
|
||||
* }
|
||||
*
|
||||
* async _onDraw() {
|
||||
* console.log(`Drawing managed Scene "${this.scene.name}"`);
|
||||
* }
|
||||
*
|
||||
* async _onReady() {
|
||||
* console.log(`Readying managed Scene "${this.scene.name}"`);
|
||||
* }
|
||||
*
|
||||
* async _onTearDown() {
|
||||
* console.log(`Deconstructing managed Scene "${this.scene.name}"`);
|
||||
* }
|
||||
*
|
||||
* _registerHooks() {
|
||||
* this.registerHook("updateToken", this.#onUpdateToken.bind(this));
|
||||
* }
|
||||
*
|
||||
* #onUpdateToken(document, updateData, options, userId) {
|
||||
* console.log("Updating a token within the managed Scene");
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // Register MyCustomSceneManager to be used for a specific Scene
|
||||
* CONFIG.Canvas.sceneManagers = {
|
||||
* [sceneId]: MyCustomSceneManager
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export default class SceneManager {
|
||||
/**
|
||||
* The SceneManager is constructed by passing a reference to the active Scene document.
|
||||
* @param {Scene} scene
|
||||
*/
|
||||
constructor(scene) {
|
||||
this.#scene = scene;
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed Scene.
|
||||
* @type {Scene}
|
||||
*/
|
||||
get scene() {
|
||||
return this.#scene;
|
||||
}
|
||||
#scene;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Scene Life-Cycle Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Additional behaviors to perform when the Canvas is first initialized for the Scene.
|
||||
* @returns {Promise<void>}
|
||||
* @internal
|
||||
*/
|
||||
async _onInit() {}
|
||||
|
||||
/**
|
||||
* Additional behaviors to perform after core groups and layers are drawn to the canvas.
|
||||
* @returns {Promise<void>}
|
||||
* @internal
|
||||
*/
|
||||
async _onDraw() {}
|
||||
|
||||
/**
|
||||
* Additional behaviors to perform after the Canvas is fully initialized for the Scene.
|
||||
* @returns {Promise<void>}
|
||||
* @internal
|
||||
*/
|
||||
async _onReady() {}
|
||||
|
||||
/**
|
||||
* Additional behaviors to perform when the Scene is deactivated.
|
||||
* @returns {Promise<void>}
|
||||
* @internal
|
||||
*/
|
||||
async _onTearDown() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Scene Hooks */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Registered hook functions used within this specific Scene that are automatically deactivated.
|
||||
* @type {Record<string, number>}
|
||||
*/
|
||||
#hooks = {};
|
||||
|
||||
/**
|
||||
* Register additional hook functions are only used while this Scene is active and is automatically deactivated.
|
||||
* Hooks should be registered in this function by calling this._registerHook(hookName, handler)
|
||||
* @internal
|
||||
*/
|
||||
_registerHooks() {}
|
||||
|
||||
/**
|
||||
* Register additional hook functions are only used while this Scene is active and is automatically deactivated.
|
||||
* @param {string} hookName
|
||||
* @param {Function} handler
|
||||
*/
|
||||
registerHook(hookName, handler) {
|
||||
this.#hooks[hookName] = Hooks.on(hookName, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate Hook functions that were added specifically for this Scene.
|
||||
* @internal
|
||||
*/
|
||||
_deactivateHooks() {
|
||||
for ( const [hookName, hookId] of Object.entries(this.#hooks) ) Hooks.off(hookName, hookId);
|
||||
}
|
||||
}
|
||||
25
resources/app/client-esm/canvas/smaa/LICENSE
Normal file
25
resources/app/client-esm/canvas/smaa/LICENSE
Normal file
@@ -0,0 +1,25 @@
|
||||
Copyright (C) 2013 Jorge Jimenez (jorge@iryoku.com)
|
||||
Copyright (C) 2013 Jose I. Echevarria (joseignacioechevarria@gmail.com)
|
||||
Copyright (C) 2013 Belen Masia (bmasia@unizar.es)
|
||||
Copyright (C) 2013 Fernando Navarro (fernandn@microsoft.com)
|
||||
Copyright (C) 2013 Diego Gutierrez (diegog@unizar.es)
|
||||
|
||||
Copyright (C) 2018 Damien Seguin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
109
resources/app/client-esm/canvas/smaa/blend.mjs
Normal file
109
resources/app/client-esm/canvas/smaa/blend.mjs
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* The neighborhood blending filter for {@link foundry.canvas.SMAAFilter}.
|
||||
*/
|
||||
export default class SMAANeighborhoodBlendingFilter extends PIXI.Filter {
|
||||
constructor() {
|
||||
super(VERTEX_SOURCE, FRAGMENT_SOURCE);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The vertex shader source of {@link SMAANeighborhoodBlendingFilter}.
|
||||
* @type {string}
|
||||
*/
|
||||
const VERTEX_SOURCE = `\
|
||||
#define mad(a, b, c) (a * b + c)
|
||||
|
||||
attribute vec2 aVertexPosition;
|
||||
|
||||
uniform mat3 projectionMatrix;
|
||||
uniform vec4 inputSize;
|
||||
uniform vec4 inputPixel;
|
||||
uniform vec4 outputFrame;
|
||||
|
||||
#define resolution (inputPixel.xy)
|
||||
#define SMAA_RT_METRICS (inputPixel.zwxy)
|
||||
|
||||
varying vec2 vTexCoord0;
|
||||
varying vec4 vOffset;
|
||||
|
||||
void main() {
|
||||
vTexCoord0 = aVertexPosition * (outputFrame.zw * inputSize.zw);
|
||||
vOffset = mad(SMAA_RT_METRICS.xyxy, vec4(1.0, 0.0, 0.0, 1.0), vTexCoord0.xyxy);
|
||||
|
||||
vec3 position = vec3(aVertexPosition * max(outputFrame.zw, vec2(0.0)) + outputFrame.xy, 1.0);
|
||||
gl_Position = vec4((projectionMatrix * position).xy, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The fragment shader source of {@link SMAANeighborhoodBlendingFilter}.
|
||||
* @type {string}
|
||||
*/
|
||||
const FRAGMENT_SOURCE = `\
|
||||
precision highp float;
|
||||
|
||||
#define mad(a, b, c) (a * b + c)
|
||||
|
||||
uniform sampler2D blendTex;
|
||||
uniform sampler2D uSampler; // colorTex
|
||||
uniform vec4 inputPixel;
|
||||
|
||||
#define colorTex uSampler
|
||||
#define resolution (inputPixel.xy)
|
||||
#define SMAA_RT_METRICS (inputPixel.zwxy)
|
||||
|
||||
varying vec2 vTexCoord0;
|
||||
varying vec4 vOffset;
|
||||
|
||||
/**
|
||||
* Conditional move:
|
||||
*/
|
||||
void SMAAMovc(bvec2 cond, inout vec2 variable, vec2 value) {
|
||||
if (cond.x) variable.x = value.x;
|
||||
if (cond.y) variable.y = value.y;
|
||||
}
|
||||
|
||||
void SMAAMovc(bvec4 cond, inout vec4 variable, vec4 value) {
|
||||
SMAAMovc(cond.xy, variable.xy, value.xy);
|
||||
SMAAMovc(cond.zw, variable.zw, value.zw);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 color;
|
||||
|
||||
// Fetch the blending weights for current pixel:
|
||||
vec4 a;
|
||||
a.x = texture2D(blendTex, vOffset.xy).a; // Right
|
||||
a.y = texture2D(blendTex, vOffset.zw).g; // Top
|
||||
a.wz = texture2D(blendTex, vTexCoord0).xz; // Bottom / Left
|
||||
|
||||
// Is there any blending weight with a value greater than 0.0?
|
||||
if (dot(a, vec4(1.0, 1.0, 1.0, 1.0)) <= 1e-5) {
|
||||
color = texture2D(colorTex, vTexCoord0); // LinearSampler
|
||||
} else {
|
||||
bool h = max(a.x, a.z) > max(a.y, a.w); // max(horizontal) > max(vertical)
|
||||
|
||||
// Calculate the blending offsets:
|
||||
vec4 blendingOffset = vec4(0.0, a.y, 0.0, a.w);
|
||||
vec2 blendingWeight = a.yw;
|
||||
SMAAMovc(bvec4(h, h, h, h), blendingOffset, vec4(a.x, 0.0, a.z, 0.0));
|
||||
SMAAMovc(bvec2(h, h), blendingWeight, a.xz);
|
||||
blendingWeight /= dot(blendingWeight, vec2(1.0, 1.0));
|
||||
|
||||
// Calculate the texture coordinates:
|
||||
vec4 blendingCoord = mad(blendingOffset, vec4(SMAA_RT_METRICS.xy, -SMAA_RT_METRICS.xy), vTexCoord0.xyxy);
|
||||
|
||||
// We exploit bilinear filtering to mix current pixel with the chosen
|
||||
// neighbor:
|
||||
color = blendingWeight.x * texture2D(colorTex, blendingCoord.xy); // LinearSampler
|
||||
color += blendingWeight.y * texture2D(colorTex, blendingCoord.zw); // LinearSampler
|
||||
}
|
||||
|
||||
gl_FragColor = color;
|
||||
}
|
||||
`;
|
||||
129
resources/app/client-esm/canvas/smaa/edges.mjs
Normal file
129
resources/app/client-esm/canvas/smaa/edges.mjs
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The edge detection filter for {@link foundry.canvas.SMAAFilter}.
|
||||
*/
|
||||
export default class SMAAEdgeDetectionFilter extends PIXI.Filter {
|
||||
/**
|
||||
* @param {SMAAFilterConfig} config
|
||||
*/
|
||||
constructor(config) {
|
||||
super(VERTEX_SOURCE, generateFragmentSource(config));
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The vertex shader source of {@link SMAAEdgeDetectionFilter}.
|
||||
* @type {string}
|
||||
*/
|
||||
const VERTEX_SOURCE = `\
|
||||
#define mad(a, b, c) (a * b + c)
|
||||
|
||||
attribute vec2 aVertexPosition;
|
||||
|
||||
uniform mat3 projectionMatrix;
|
||||
uniform vec4 inputSize;
|
||||
uniform vec4 inputPixel;
|
||||
uniform vec4 outputFrame;
|
||||
|
||||
#define resolution (inputPixel.xy)
|
||||
#define SMAA_RT_METRICS (inputPixel.zwxy)
|
||||
|
||||
varying vec2 vTexCoord0;
|
||||
varying vec4 vOffset[3];
|
||||
|
||||
void main() {
|
||||
vTexCoord0 = aVertexPosition * (outputFrame.zw * inputSize.zw);
|
||||
|
||||
vOffset[0] = mad(SMAA_RT_METRICS.xyxy, vec4(-1.0, 0.0, 0.0, -1.0), vTexCoord0.xyxy);
|
||||
vOffset[1] = mad(SMAA_RT_METRICS.xyxy, vec4( 1.0, 0.0, 0.0, 1.0), vTexCoord0.xyxy);
|
||||
vOffset[2] = mad(SMAA_RT_METRICS.xyxy, vec4(-2.0, 0.0, 0.0, -2.0), vTexCoord0.xyxy);
|
||||
|
||||
vec3 position = vec3(aVertexPosition * max(outputFrame.zw, vec2(0.0)) + outputFrame.xy, 1.0);
|
||||
gl_Position = vec4((projectionMatrix * position).xy, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The fragment shader source of {@link SMAAEdgeDetectionFilter}.
|
||||
* @param {SMAAFilterConfig} config
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateFragmentSource(config) {
|
||||
return `\
|
||||
precision highp float;
|
||||
|
||||
/**
|
||||
* Color Edge Detection
|
||||
*
|
||||
* IMPORTANT NOTICE: color edge detection requires gamma-corrected colors, and
|
||||
* thus 'colorTex' should be a non-sRGB texture.
|
||||
*/
|
||||
|
||||
#define SMAA_THRESHOLD ${config.threshold.toFixed(8)}
|
||||
#define SMAA_LOCAL_CONTRAST_ADAPTATION_FACTOR ${config.localContrastAdaptionFactor.toFixed(8)}
|
||||
|
||||
uniform sampler2D uSampler; // colorTex
|
||||
|
||||
#define colorTex uSampler
|
||||
|
||||
varying vec2 vTexCoord0;
|
||||
varying vec4 vOffset[3];
|
||||
|
||||
void main() {
|
||||
// Calculate the threshold:
|
||||
vec2 threshold = vec2(SMAA_THRESHOLD);
|
||||
|
||||
// Calculate color deltas:
|
||||
vec4 delta;
|
||||
vec3 c = texture2D(colorTex, vTexCoord0).rgb;
|
||||
|
||||
vec3 cLeft = texture2D(colorTex, vOffset[0].xy).rgb;
|
||||
vec3 t = abs(c - cLeft);
|
||||
delta.x = max(max(t.r, t.g), t.b);
|
||||
|
||||
vec3 cTop = texture2D(colorTex, vOffset[0].zw).rgb;
|
||||
t = abs(c - cTop);
|
||||
delta.y = max(max(t.r, t.g), t.b);
|
||||
|
||||
// We do the usual threshold:
|
||||
vec2 edges = step(threshold, delta.xy);
|
||||
|
||||
// Then discard if there is no edge:
|
||||
if (dot(edges, vec2(1.0, 1.0)) == 0.0)
|
||||
discard;
|
||||
|
||||
// Calculate right and bottom deltas:
|
||||
vec3 cRight = texture2D(colorTex, vOffset[1].xy).rgb;
|
||||
t = abs(c - cRight);
|
||||
delta.z = max(max(t.r, t.g), t.b);
|
||||
|
||||
vec3 cBottom = texture2D(colorTex, vOffset[1].zw).rgb;
|
||||
t = abs(c - cBottom);
|
||||
delta.w = max(max(t.r, t.g), t.b);
|
||||
|
||||
// Calculate the maximum delta in the direct neighborhood:
|
||||
vec2 maxDelta = max(delta.xy, delta.zw);
|
||||
|
||||
// Calculate left-left and top-top deltas:
|
||||
vec3 cLeftLeft = texture2D(colorTex, vOffset[2].xy).rgb;
|
||||
t = abs(c - cLeftLeft);
|
||||
delta.z = max(max(t.r, t.g), t.b);
|
||||
|
||||
vec3 cTopTop = texture2D(colorTex, vOffset[2].zw).rgb;
|
||||
t = abs(c - cTopTop);
|
||||
delta.w = max(max(t.r, t.g), t.b);
|
||||
|
||||
// Calculate the final maximum delta:
|
||||
maxDelta = max(maxDelta.xy, delta.zw);
|
||||
float finalDelta = max(maxDelta.x, maxDelta.y);
|
||||
|
||||
// Local contrast adaptation:
|
||||
edges.xy *= step(finalDelta, SMAA_LOCAL_CONTRAST_ADAPTATION_FACTOR * delta.xy);
|
||||
|
||||
gl_FragColor = vec4(edges, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
}
|
||||
116
resources/app/client-esm/canvas/smaa/smaa.mjs
Normal file
116
resources/app/client-esm/canvas/smaa/smaa.mjs
Normal file
@@ -0,0 +1,116 @@
|
||||
|
||||
import {default as SMAAEdgeDetectionFilter} from "./edges.mjs";
|
||||
import {default as SMAABlendingWeightCalculationFilter} from "./weights.mjs";
|
||||
import {default as SMAANeighborhoodBlendingFilter} from "./blend.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {object} SMAAFilterConfig
|
||||
* @property {number} threshold Specifies the threshold or sensitivity to edges. Lowering this value you will be able to detect more edges at the expense of performance. Range: [0, 0.5]. 0.1 is a reasonable value, and allows to catch most visible edges. 0.05 is a rather overkill value, that allows to catch 'em all.
|
||||
* @property {number} localContrastAdaptionFactor If there is an neighbor edge that has SMAA_LOCAL_CONTRAST_FACTOR times bigger contrast than current edge, current edge will be discarded.
|
||||
* This allows to eliminate spurious crossing edges, and is based on the fact that, if there is too much contrast in a direction, that will hide perceptually contrast in the other neighbors.
|
||||
* @property {number} maxSearchSteps Specifies the maximum steps performed in the horizontal/vertical pattern searches, at each side of the pixel. In number of pixels, it's actually the double. So the maximum line length perfectly handled by, for example 16, is 64 (by perfectly, we meant that longer lines won't look as good, but still antialiased. Range: [0, 112].
|
||||
* @property {number} maxSearchStepsDiag Specifies the maximum steps performed in the diagonal pattern searches, at each side of the pixel. In this case we jump one pixel at time, instead of two. Range: [0, 20].
|
||||
* @property {number} cornerRounding Specifies how much sharp corners will be rounded. Range: [0, 100].
|
||||
* @property {boolean} disableDiagDetection Is diagonal detection disabled?
|
||||
* @property {boolean} disableCornerDetection Is corner detection disabled?
|
||||
*/
|
||||
|
||||
export default class SMAAFilter extends PIXI.Filter {
|
||||
/**
|
||||
* @param {Partial<SMAAFilterConfig>} [config] The config (defaults: {@link SMAAFilter.PRESETS.DEFAULT})
|
||||
*/
|
||||
constructor({threshold=0.1, localContrastAdaptionFactor=2.0, maxSearchSteps=16, maxSearchStepsDiag=8, cornerRounding=25, disableDiagDetection=false, disableCornerDetection=false}={}) {
|
||||
super();
|
||||
const config = {threshold, localContrastAdaptionFactor, maxSearchSteps, maxSearchStepsDiag, cornerRounding, disableDiagDetection, disableCornerDetection};
|
||||
this.#edgesFilter = new SMAAEdgeDetectionFilter(config);
|
||||
this.#weightsFilter = new SMAABlendingWeightCalculationFilter(config);
|
||||
this.#blendFilter = new SMAANeighborhoodBlendingFilter();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The presets.
|
||||
* @enum {SMAAFilterConfig}
|
||||
*/
|
||||
static get PRESETS() {
|
||||
return SMAAFilter.#PRESETS;
|
||||
}
|
||||
|
||||
static #PRESETS = {
|
||||
LOW: {
|
||||
threshold: 0.15,
|
||||
localContrastAdaptionFactor: 2.0,
|
||||
maxSearchSteps: 4,
|
||||
maxSearchStepsDiag: 0,
|
||||
cornerRounding: 0,
|
||||
disableDiagDetection: true,
|
||||
disableCornerDetection: true
|
||||
},
|
||||
MEDIUM: {
|
||||
threshold: 0.1,
|
||||
localContrastAdaptionFactor: 2.0,
|
||||
maxSearchSteps: 8,
|
||||
maxSearchStepsDiag: 0,
|
||||
cornerRounding: 0,
|
||||
disableDiagDetection: true,
|
||||
disableCornerDetection: true
|
||||
},
|
||||
HIGH: {
|
||||
threshold: 0.1,
|
||||
localContrastAdaptionFactor: 2.0,
|
||||
maxSearchSteps: 16,
|
||||
maxSearchStepsDiag: 8,
|
||||
cornerRounding: 25,
|
||||
disableDiagDetection: false,
|
||||
disableCornerDetection: false
|
||||
},
|
||||
ULTRA: {
|
||||
threshold: 0.05,
|
||||
localContrastAdaptionFactor: 2.0,
|
||||
maxSearchSteps: 32,
|
||||
maxSearchStepsDiag: 16,
|
||||
cornerRounding: 25,
|
||||
disableDiagDetection: false,
|
||||
disableCornerDetection: false
|
||||
}
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The edge detection filter.
|
||||
* @type {SMAAEdgeDetectionFilter}
|
||||
*/
|
||||
#edgesFilter;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The blending weight calculation filter.
|
||||
* @type {SMAABlendingWeightCalculationFilter}
|
||||
*/
|
||||
#weightsFilter;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The neighborhood blending filter.
|
||||
* @type {SMAANeighborhoodBlendingFilter}
|
||||
*/
|
||||
#blendFilter;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
apply(filterManager, input, output, clearMode, currentState) {
|
||||
const edgesTex = filterManager.getFilterTexture();
|
||||
const blendTex = filterManager.getFilterTexture();
|
||||
this.#edgesFilter.apply(filterManager, input, edgesTex, PIXI.CLEAR_MODES.CLEAR, currentState);
|
||||
this.#weightsFilter.apply(filterManager, edgesTex, blendTex, PIXI.CLEAR_MODES.CLEAR, currentState);
|
||||
this.#blendFilter.uniforms.blendTex = blendTex;
|
||||
this.#blendFilter.apply(filterManager, input, output, clearMode, currentState);
|
||||
filterManager.returnFilterTexture(edgesTex);
|
||||
filterManager.returnFilterTexture(blendTex);
|
||||
}
|
||||
}
|
||||
560
resources/app/client-esm/canvas/smaa/weights.mjs
Normal file
560
resources/app/client-esm/canvas/smaa/weights.mjs
Normal file
File diff suppressed because one or more lines are too long
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;
|
||||
}
|
||||
}
|
||||
3
resources/app/client-esm/canvas/tokens/_module.mjs
Normal file
3
resources/app/client-esm/canvas/tokens/_module.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
export {default as TokenRing} from "./ring.mjs";
|
||||
export {default as TokenRingConfig} from "./ring-config.mjs";
|
||||
export {default as DynamicRingData} from "./ring-data.mjs"
|
||||
379
resources/app/client-esm/canvas/tokens/ring-config.mjs
Normal file
379
resources/app/client-esm/canvas/tokens/ring-config.mjs
Normal file
@@ -0,0 +1,379 @@
|
||||
import DynamicRingData from "./ring-data.mjs";
|
||||
|
||||
/**
|
||||
* The start and end radii of the token ring color band.
|
||||
* @typedef {Object} RingColorBand
|
||||
* @property {number} startRadius The starting normalized radius of the token ring color band.
|
||||
* @property {number} endRadius The ending normalized radius of the token ring color band.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dynamic ring id.
|
||||
* @typedef {string} DynamicRingId
|
||||
*/
|
||||
|
||||
/**
|
||||
* Token Ring configuration Singleton Class.
|
||||
*
|
||||
* @example Add a new custom ring configuration. Allow only ring pulse, ring gradient and background wave effects.
|
||||
* const customConfig = new foundry.canvas.tokens.DynamicRingData({
|
||||
* id: "myCustomRingId",
|
||||
* label: "Custom Ring",
|
||||
* effects: {
|
||||
* RING_PULSE: "TOKEN.RING.EFFECTS.RING_PULSE",
|
||||
* RING_GRADIENT: "TOKEN.RING.EFFECTS.RING_GRADIENT",
|
||||
* BACKGROUND_WAVE: "TOKEN.RING.EFFECTS.BACKGROUND_WAVE"
|
||||
* },
|
||||
* spritesheet: "canvas/tokens/myCustomRings.json",
|
||||
* framework: {
|
||||
* shaderClass: MyCustomTokenRingSamplerShader,
|
||||
* ringClass: TokenRing
|
||||
* }
|
||||
* });
|
||||
* CONFIG.Token.ring.addConfig(customConfig.id, customConfig);
|
||||
*
|
||||
* @example Get a specific ring configuration
|
||||
* const config = CONFIG.Token.ring.getConfig("myCustomRingId");
|
||||
* console.log(config.spritesheet); // Output: canvas/tokens/myCustomRings.json
|
||||
*
|
||||
* @example Use a specific ring configuration
|
||||
* const success = CONFIG.Token.ring.useConfig("myCustomRingId");
|
||||
* console.log(success); // Output: true
|
||||
*
|
||||
* @example Get the labels of all configurations
|
||||
* const configLabels = CONFIG.Token.ring.configLabels;
|
||||
* console.log(configLabels);
|
||||
* // Output:
|
||||
* // {
|
||||
* // "coreSteel": "Foundry VTT Steel Ring",
|
||||
* // "coreBronze": "Foundry VTT Bronze Ring",
|
||||
* // "myCustomRingId" : "My Super Power Ring"
|
||||
* // }
|
||||
*
|
||||
* @example Get the IDs of all configurations
|
||||
* const configIDs = CONFIG.Token.ring.configIDs;
|
||||
* console.log(configIDs); // Output: ["coreSteel", "coreBronze", "myCustomRingId"]
|
||||
*
|
||||
* @example Create a hook to add a custom token ring configuration. This ring configuration will appear in the settings.
|
||||
* Hooks.on("initializeDynamicTokenRingConfig", ringConfig => {
|
||||
* const mySuperPowerRings = new foundry.canvas.tokens.DynamicRingData({
|
||||
* id: "myCustomRingId",
|
||||
* label: "My Super Power Rings",
|
||||
* effects: {
|
||||
* RING_PULSE: "TOKEN.RING.EFFECTS.RING_PULSE",
|
||||
* RING_GRADIENT: "TOKEN.RING.EFFECTS.RING_GRADIENT",
|
||||
* BACKGROUND_WAVE: "TOKEN.RING.EFFECTS.BACKGROUND_WAVE"
|
||||
* },
|
||||
* spritesheet: "canvas/tokens/mySuperPowerRings.json"
|
||||
* });
|
||||
* ringConfig.addConfig("mySuperPowerRings", mySuperPowerRings);
|
||||
* });
|
||||
*
|
||||
* @example Activate color bands debugging visuals to ease configuration
|
||||
* CONFIG.Token.ring.debugColorBands = true;
|
||||
*/
|
||||
export default class TokenRingConfig {
|
||||
constructor() {
|
||||
if ( TokenRingConfig.#instance ) {
|
||||
throw new Error("An instance of TokenRingConfig has already been created. " +
|
||||
"Use `CONFIG.Token.ring` to access it.");
|
||||
}
|
||||
TokenRingConfig.#instance = this;
|
||||
}
|
||||
|
||||
/**
|
||||
* The token ring config instance.
|
||||
* @type {TokenRingConfig}
|
||||
*/
|
||||
static #instance;
|
||||
|
||||
/**
|
||||
* To know if the ring config is initialized.
|
||||
* @type {boolean}
|
||||
*/
|
||||
static #initialized = false;
|
||||
|
||||
/**
|
||||
* To know if a Token Ring registration is possible.
|
||||
* @type {boolean}
|
||||
*/
|
||||
static #closedRegistration = true;
|
||||
|
||||
/**
|
||||
* Core token rings used in Foundry VTT.
|
||||
* Each key is a string identifier for a ring, and the value is an object containing the ring's data.
|
||||
* This object is frozen to prevent any modifications.
|
||||
* @type {Readonly<Record<DynamicRingId, RingData>>}
|
||||
*/
|
||||
static CORE_TOKEN_RINGS = Object.freeze({
|
||||
coreSteel: {
|
||||
id: "coreSteel",
|
||||
label: "TOKEN.RING.SETTINGS.coreSteel",
|
||||
spritesheet: "canvas/tokens/rings-steel.json"
|
||||
},
|
||||
coreBronze: {
|
||||
id: "coreBronze",
|
||||
label: "TOKEN.RING.SETTINGS.coreBronze",
|
||||
spritesheet: "canvas/tokens/rings-bronze.json"
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Core token rings fit modes used in Foundry VTT.
|
||||
* @type {Readonly<object>}
|
||||
*/
|
||||
static CORE_TOKEN_RINGS_FIT_MODES = Object.freeze({
|
||||
subject: {
|
||||
id: "subject",
|
||||
label: "TOKEN.RING.SETTINGS.FIT_MODES.subject"
|
||||
},
|
||||
grid: {
|
||||
id: "grid",
|
||||
label: "TOKEN.RING.SETTINGS.FIT_MODES.grid"
|
||||
}
|
||||
});
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register the token ring config and initialize it
|
||||
*/
|
||||
static initialize() {
|
||||
// If token config is initialized
|
||||
if ( this.#initialized ) {
|
||||
throw new Error("The token configuration class can be initialized only once!")
|
||||
}
|
||||
|
||||
// Open the registration window for the token rings
|
||||
this.#closedRegistration = false;
|
||||
|
||||
// Add default rings
|
||||
for ( const id in this.CORE_TOKEN_RINGS ) {
|
||||
const config = new DynamicRingData(this.CORE_TOKEN_RINGS[id]);
|
||||
CONFIG.Token.ring.addConfig(config.id, config);
|
||||
}
|
||||
|
||||
// Call an explicit hook for token ring configuration
|
||||
Hooks.callAll("initializeDynamicTokenRingConfig", CONFIG.Token.ring);
|
||||
|
||||
// Initialize token rings configuration
|
||||
if ( !CONFIG.Token.ring.useConfig(game.settings.get("core", "dynamicTokenRing")) ) {
|
||||
CONFIG.Token.ring.useConfig(this.CORE_TOKEN_RINGS.coreSteel.id);
|
||||
}
|
||||
|
||||
// Close the registration window for the token rings
|
||||
this.#closedRegistration = true;
|
||||
this.#initialized = true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register game settings used by the Token Ring
|
||||
*/
|
||||
static registerSettings() {
|
||||
game.settings.register("core", "dynamicTokenRing", {
|
||||
name: "TOKEN.RING.SETTINGS.label",
|
||||
hint: "TOKEN.RING.SETTINGS.hint",
|
||||
scope: "world",
|
||||
config: true,
|
||||
type: new foundry.data.fields.StringField({required: true, blank: false,
|
||||
initial: this.CORE_TOKEN_RINGS.coreSteel.id,
|
||||
choices: () => CONFIG.Token.ring.configLabels
|
||||
}),
|
||||
requiresReload: true
|
||||
});
|
||||
|
||||
game.settings.register("core", "dynamicTokenRingFitMode", {
|
||||
name: "TOKEN.RING.SETTINGS.FIT_MODES.label",
|
||||
hint: "TOKEN.RING.SETTINGS.FIT_MODES.hint",
|
||||
scope: "world",
|
||||
config: true,
|
||||
type: new foundry.data.fields.StringField({
|
||||
required: true,
|
||||
blank: false,
|
||||
initial: this.CORE_TOKEN_RINGS_FIT_MODES.subject.id,
|
||||
choices: Object.fromEntries(Object.entries(this.CORE_TOKEN_RINGS_FIT_MODES).map(([key, mode]) => [key, mode.label]))
|
||||
}),
|
||||
requiresReload: true
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Ring configurations.
|
||||
* @type {Map<string, DynamicRingData>}
|
||||
*/
|
||||
#configs = new Map();
|
||||
|
||||
/**
|
||||
* The current ring configuration.
|
||||
* @type {DynamicRingData}
|
||||
*/
|
||||
#currentConfig;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A mapping of token subject paths where modules or systems have configured subject images.
|
||||
* @type {Record<string, string>}
|
||||
*/
|
||||
subjectPaths = {};
|
||||
|
||||
/**
|
||||
* All color bands visual debug flag.
|
||||
* @type {boolean}
|
||||
*/
|
||||
debugColorBands = false;
|
||||
|
||||
/**
|
||||
* Get the current ring class.
|
||||
* @type {typeof TokenRing} The current ring class.
|
||||
*/
|
||||
get ringClass() {
|
||||
return this.#currentConfig.framework.ringClass;
|
||||
}
|
||||
|
||||
set ringClass(value) {
|
||||
this.#currentConfig.framework.ringClass = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current effects.
|
||||
* @type {Record<string, string>} The current effects.
|
||||
*/
|
||||
get effects() {
|
||||
return this.#currentConfig.effects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current spritesheet.
|
||||
* @type {string} The current spritesheet path.
|
||||
*/
|
||||
get spritesheet() {
|
||||
return this.#currentConfig.spritesheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current shader class.
|
||||
* @type {typeof PrimaryBaseSamplerShader} The current shader class.
|
||||
*/
|
||||
get shaderClass() {
|
||||
return this.#currentConfig.framework.shaderClass;
|
||||
}
|
||||
|
||||
set shaderClass(value) {
|
||||
this.#currentConfig.framework.shaderClass = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current localized label.
|
||||
* @returns {string}
|
||||
*/
|
||||
get label() {
|
||||
return this.#currentConfig.label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current id.
|
||||
* @returns {string}
|
||||
*/
|
||||
get id() {
|
||||
return this.#currentConfig.id;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Management */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is a custom fit mode active?
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get isGridFitMode() {
|
||||
return game.settings.get("core","dynamicTokenRingFitMode")
|
||||
=== this.constructor.CORE_TOKEN_RINGS_FIT_MODES.grid.id;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a new ring configuration.
|
||||
* @param {string} id The id of the ring configuration.
|
||||
* @param {RingConfig} config The configuration object for the ring.
|
||||
*/
|
||||
addConfig(id, config) {
|
||||
if ( this.constructor.#closedRegistration ) {
|
||||
throw new Error("Dynamic Rings registration window is closed. You must register a dynamic token ring configuration during" +
|
||||
" the `registerDynamicTokenRing` hook.");
|
||||
}
|
||||
this.#configs.set(id, config);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a ring configuration.
|
||||
* @param {string} id The id of the ring configuration.
|
||||
* @returns {RingConfig} The ring configuration object.
|
||||
*/
|
||||
getConfig(id) {
|
||||
return this.#configs.get(id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Use a ring configuration.
|
||||
* @param {string} id The id of the ring configuration to use.
|
||||
* @returns {boolean} True if the configuration was successfully set, false otherwise.
|
||||
*/
|
||||
useConfig(id) {
|
||||
if ( this.#configs.has(id) ) {
|
||||
this.#currentConfig = this.#configs.get(id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the IDs of all configurations.
|
||||
* @returns {string[]} The names of all configurations.
|
||||
*/
|
||||
get configIDs() {
|
||||
return Array.from(this.#configs.keys());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the labels of all configurations.
|
||||
* @returns {Record<string, string>} An object with configuration names as keys and localized labels as values.
|
||||
*/
|
||||
get configLabels() {
|
||||
const labels = {};
|
||||
for ( const [name, config] of this.#configs.entries() ) {
|
||||
labels[name] = config.label;
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
get configNames() {
|
||||
const msg = "TokenRingConfig#configNames is deprecated and replaced by TokenRingConfig#configIDs";
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
|
||||
return this.configIDs;
|
||||
}
|
||||
}
|
||||
81
resources/app/client-esm/canvas/tokens/ring-data.mjs
Normal file
81
resources/app/client-esm/canvas/tokens/ring-data.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import TokenRing from "./ring.mjs";
|
||||
import DataModel from "../../../common/abstract/data.mjs";
|
||||
import {DataField} from "../../../common/data/fields.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {Object} RingData
|
||||
* @property {string} id The id of this Token Ring configuration.
|
||||
* @property {string} label The label of this Token Ring configuration.
|
||||
* @property {string} spritesheet The spritesheet path which provides token ring frames for various sized creatures.
|
||||
* @property {Record<string, string>} [effects] Registered special effects which can be applied to a token ring.
|
||||
* @property {Object} framework
|
||||
* @property {typeof TokenRing} [framework.ringClass=TokenRing] The manager class responsible for rendering token rings.
|
||||
* @property {typeof PrimaryBaseSamplerShader} [framework.shaderClass=TokenRingSamplerShader] The shader class used to render the TokenRing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A special subclass of DataField used to reference a class definition.
|
||||
*/
|
||||
class ClassReferenceField extends DataField {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.#baseClass = options.baseClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* The base class linked to this data field.
|
||||
* @type {typeof Function}
|
||||
*/
|
||||
#baseClass;
|
||||
|
||||
/** @inheritdoc */
|
||||
static get _defaults() {
|
||||
const defaults = super._defaults;
|
||||
defaults.required = true;
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_cast(value) {
|
||||
if ( !foundry.utils.isSubclass(value, this.#baseClass) ) {
|
||||
throw new Error(`The value provided to a ClassReferenceField must be a ${this.#baseClass.name} subclass.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
getInitialValue(data) {
|
||||
return this.initial;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Dynamic Ring configuration data model.
|
||||
* @extends {foundry.abstract.DataModel}
|
||||
* @implements {RingData}
|
||||
*/
|
||||
export default class DynamicRingData extends DataModel {
|
||||
/** @inheritDoc */
|
||||
static defineSchema() {
|
||||
const fields = foundry.data.fields;
|
||||
|
||||
// Return model schema
|
||||
return {
|
||||
id: new fields.StringField({blank: true}),
|
||||
label: new fields.StringField({blank: false}),
|
||||
spritesheet: new fields.FilePathField({categories: ["TEXT"], required: true}),
|
||||
effects: new fields.ObjectField({initial: {
|
||||
RING_PULSE: "TOKEN.RING.EFFECTS.RING_PULSE",
|
||||
RING_GRADIENT: "TOKEN.RING.EFFECTS.RING_GRADIENT",
|
||||
BKG_WAVE: "TOKEN.RING.EFFECTS.BKG_WAVE",
|
||||
INVISIBILITY: "TOKEN.RING.EFFECTS.INVISIBILITY"
|
||||
}}),
|
||||
framework: new fields.SchemaField({
|
||||
ringClass: new ClassReferenceField({initial: TokenRing, baseClass: TokenRing}),
|
||||
shaderClass: new ClassReferenceField({initial: TokenRingSamplerShader, baseClass: PrimaryBaseSamplerShader})
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
508
resources/app/client-esm/canvas/tokens/ring.mjs
Normal file
508
resources/app/client-esm/canvas/tokens/ring.mjs
Normal file
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* Dynamic Token Ring Manager.
|
||||
*/
|
||||
export default class TokenRing {
|
||||
/**
|
||||
* A TokenRing is constructed by providing a reference to a Token object.
|
||||
* @param {Token} token
|
||||
*/
|
||||
constructor(token) {
|
||||
this.#token = new WeakRef(token);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rings System */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The start and end radii of the token ring color band.
|
||||
* @typedef {Object} RingColorBand
|
||||
* @property {number} startRadius The starting normalized radius of the token ring color band.
|
||||
* @property {number} endRadius The ending normalized radius of the token ring color band.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The effects which could be applied to a token ring (using bitwise operations).
|
||||
* @type {Readonly<Record<string, number>>}
|
||||
*/
|
||||
static effects = Object.freeze({
|
||||
DISABLED: 0x00,
|
||||
ENABLED: 0x01,
|
||||
RING_PULSE: 0x02,
|
||||
RING_GRADIENT: 0x04,
|
||||
BKG_WAVE: 0x08,
|
||||
INVISIBILITY: 0x10 // or spectral pulse effect
|
||||
});
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is the token rings framework enabled? Will be `null` if the system hasn't initialized yet.
|
||||
* @type {boolean|null}
|
||||
*/
|
||||
static get initialized() {
|
||||
return this.#initialized;
|
||||
}
|
||||
|
||||
static #initialized = null;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Token Rings sprite sheet base texture.
|
||||
* @type {PIXI.BaseTexture}
|
||||
*/
|
||||
static baseTexture;
|
||||
|
||||
/**
|
||||
* Rings and background textures UVs and center offset.
|
||||
* @type {Record<string, {UVs: Float32Array, center: {x: number, y: number}}>}
|
||||
*/
|
||||
static texturesData;
|
||||
|
||||
/**
|
||||
* The token ring shader class definition.
|
||||
* @type {typeof TokenRingSamplerShader}
|
||||
*/
|
||||
static tokenRingSamplerShader;
|
||||
|
||||
/**
|
||||
* Ring data with their ring name, background name and their grid dimension target.
|
||||
* @type {{ringName: string, bkgName: string, colorBand: RingColorBand, gridTarget: number,
|
||||
* defaultRingColorLittleEndian: number|null, defaultBackgroundColorLittleEndian: number|null,
|
||||
* subjectScaleAdjustment: number}[]}
|
||||
*/
|
||||
static #ringData;
|
||||
|
||||
/**
|
||||
* Default ring thickness in normalized space.
|
||||
* @type {number}
|
||||
*/
|
||||
static #defaultRingThickness = 0.1269848;
|
||||
|
||||
/**
|
||||
* Default ring subject thickness in normalized space.
|
||||
* @type {number}
|
||||
*/
|
||||
static #defaultSubjectThickness = 0.6666666;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the Token Rings system, registering the batch plugin and patching PrimaryCanvasGroup#addToken.
|
||||
*/
|
||||
static initialize() {
|
||||
if ( TokenRing.#initialized ) return;
|
||||
TokenRing.#initialized = true;
|
||||
// Register batch plugin
|
||||
this.tokenRingSamplerShader = CONFIG.Token.ring.shaderClass;
|
||||
this.tokenRingSamplerShader.registerPlugin();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create texture UVs for each asset into the token rings sprite sheet.
|
||||
*/
|
||||
static createAssetsUVs() {
|
||||
const spritesheet = TextureLoader.loader.getCache(CONFIG.Token.ring.spritesheet);
|
||||
if ( !spritesheet ) throw new Error("TokenRing UV generation failed because no spritesheet was loaded!");
|
||||
|
||||
this.baseTexture = spritesheet.baseTexture;
|
||||
this.texturesData = {};
|
||||
this.#ringData = [];
|
||||
|
||||
const {
|
||||
defaultColorBand={startRadius: 0.59, endRadius: 0.7225},
|
||||
defaultRingColor: drc,
|
||||
defaultBackgroundColor: dbc
|
||||
} = spritesheet.data.config ?? {};
|
||||
const defaultRingColor = Color.from(drc);
|
||||
const defaultBackgroundColor = Color.from(dbc);
|
||||
const validDefaultRingColor = defaultRingColor.valid ? defaultRingColor.littleEndian : null;
|
||||
const validDefaultBackgroundColor = defaultBackgroundColor.valid ? defaultBackgroundColor.littleEndian : null;
|
||||
|
||||
const frames = Object.keys(spritesheet.data.frames || {});
|
||||
|
||||
for ( const asset of frames ) {
|
||||
const assetTexture = PIXI.Assets.cache.get(asset);
|
||||
if ( !assetTexture ) continue;
|
||||
|
||||
// Extracting texture UVs
|
||||
const frame = assetTexture.frame;
|
||||
const textureUvs = new PIXI.TextureUvs();
|
||||
textureUvs.set(frame, assetTexture.baseTexture, assetTexture.rotate);
|
||||
this.texturesData[asset] = {
|
||||
UVs: textureUvs.uvsFloat32,
|
||||
center: {
|
||||
x: frame.center.x / assetTexture.baseTexture.width,
|
||||
y: frame.center.y / assetTexture.baseTexture.height
|
||||
}
|
||||
};
|
||||
|
||||
// Skip background assets
|
||||
if ( asset.includes("-bkg") ) continue;
|
||||
|
||||
// Extracting and determining final colors
|
||||
const { ringColor: rc, backgroundColor: bc, colorBand, gridTarget, ringThickness=this.#defaultRingThickness }
|
||||
= spritesheet.data.frames[asset] || {};
|
||||
|
||||
const ringColor = Color.from(rc);
|
||||
const backgroundColor = Color.from(bc);
|
||||
|
||||
const finalRingColor = ringColor.valid ? ringColor.littleEndian : validDefaultRingColor;
|
||||
const finalBackgroundColor = backgroundColor.valid ? backgroundColor.littleEndian : validDefaultBackgroundColor;
|
||||
const subjectScaleAdjustment = 1 / (ringThickness + this.#defaultSubjectThickness);
|
||||
|
||||
this.#ringData.push({
|
||||
ringName: asset,
|
||||
bkgName: `${asset}-bkg`,
|
||||
colorBand: foundry.utils.deepClone(colorBand ?? defaultColorBand),
|
||||
gridTarget: gridTarget ?? 1,
|
||||
defaultRingColorLittleEndian: finalRingColor,
|
||||
defaultBackgroundColorLittleEndian: finalBackgroundColor,
|
||||
subjectScaleAdjustment
|
||||
});
|
||||
}
|
||||
|
||||
// Sorting the rings data array
|
||||
this.#ringData.sort((a, b) => a.gridTarget - b.gridTarget);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the UVs array for a given texture name and scale correction.
|
||||
* @param {string} name Name of the texture we want to get UVs.
|
||||
* @param {number} [scaleCorrection=1] The scale correction applied to UVs.
|
||||
* @returns {Float32Array}
|
||||
*/
|
||||
static getTextureUVs(name, scaleCorrection=1) {
|
||||
if ( scaleCorrection === 1 ) return this.texturesData[name].UVs;
|
||||
const tUVs = this.texturesData[name].UVs;
|
||||
const c = this.texturesData[name].center;
|
||||
const UVs = new Float32Array(8);
|
||||
for ( let i=0; i<8; i+=2 ) {
|
||||
UVs[i] = ((tUVs[i] - c.x) * scaleCorrection) + c.x;
|
||||
UVs[i+1] = ((tUVs[i+1] - c.y) * scaleCorrection) + c.y;
|
||||
}
|
||||
return UVs;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get ring and background names for a given size.
|
||||
* @param {number} size The size to match (grid size dimension)
|
||||
* @returns {{bkgName: string, ringName: string, colorBand: RingColorBand}}
|
||||
*/
|
||||
static getRingDataBySize(size) {
|
||||
if ( !Number.isFinite(size) || !this.#ringData.length ) {
|
||||
return {
|
||||
ringName: undefined,
|
||||
bkgName: undefined,
|
||||
colorBand: undefined,
|
||||
defaultRingColorLittleEndian: null,
|
||||
defaultBackgroundColorLittleEndian: null,
|
||||
subjectScaleAdjustment: null
|
||||
};
|
||||
}
|
||||
const rings = this.#ringData.map(r => [Math.abs(r.gridTarget - size), r]);
|
||||
|
||||
// Sort rings on proximity to target size
|
||||
rings.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
// Choose the closest ring, access the second element of the first array which is the ring data object
|
||||
const closestRing = rings[0][1];
|
||||
|
||||
return {
|
||||
ringName: closestRing.ringName,
|
||||
bkgName: closestRing.bkgName,
|
||||
colorBand: closestRing.colorBand,
|
||||
defaultRingColorLittleEndian: closestRing.defaultRingColorLittleEndian,
|
||||
defaultBackgroundColorLittleEndian: closestRing.defaultBackgroundColorLittleEndian,
|
||||
subjectScaleAdjustment: closestRing.subjectScaleAdjustment
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Attributes */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @type {string} */
|
||||
ringName;
|
||||
|
||||
/** @type {string} */
|
||||
bkgName;
|
||||
|
||||
/** @type {Float32Array} */
|
||||
ringUVs;
|
||||
|
||||
/** @type {Float32Array} */
|
||||
bkgUVs;
|
||||
|
||||
/** @type {number} */
|
||||
ringColorLittleEndian = 0xFFFFFF; // Little endian format => BBGGRR
|
||||
|
||||
/** @type {number} */
|
||||
bkgColorLittleEndian = 0xFFFFFF; // Little endian format => BBGGRR
|
||||
|
||||
/** @type {number|null} */
|
||||
defaultRingColorLittleEndian = null;
|
||||
|
||||
/** @type {number|null} */
|
||||
defaultBackgroundColorLittleEndian = null;
|
||||
|
||||
/** @type {number} */
|
||||
effects = 0;
|
||||
|
||||
/** @type {number} */
|
||||
scaleCorrection = 1;
|
||||
|
||||
/** @type {number} */
|
||||
scaleAdjustmentX = 1;
|
||||
|
||||
/** @type {number} */
|
||||
scaleAdjustmentY = 1;
|
||||
|
||||
/** @type {number} */
|
||||
subjectScaleAdjustment = 1;
|
||||
|
||||
/** @type {number} */
|
||||
textureScaleAdjustment = 1;
|
||||
|
||||
/** @type {RingColorBand} */
|
||||
colorBand;
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reference to the token that should be animated.
|
||||
* @type {Token|void}
|
||||
*/
|
||||
get token() {
|
||||
return this.#token.deref();
|
||||
}
|
||||
|
||||
/**
|
||||
* Weak reference to the token being animated.
|
||||
* @type {WeakRef<Token>}
|
||||
*/
|
||||
#token;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendering */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Configure the sprite mesh.
|
||||
* @param {PrimarySpriteMesh} [mesh] The mesh to which TokenRing functionality is configured.
|
||||
*/
|
||||
configure(mesh) {
|
||||
this.#configureTexture(mesh);
|
||||
this.configureSize();
|
||||
this.configureVisuals();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clear configuration pertaining to token ring from the mesh.
|
||||
*/
|
||||
clear() {
|
||||
this.ringName = undefined;
|
||||
this.bkgName = undefined;
|
||||
this.ringUVs = undefined;
|
||||
this.bkgUVs = undefined;
|
||||
this.colorBand = undefined;
|
||||
this.ringColorLittleEndian = 0xFFFFFF;
|
||||
this.bkgColorLittleEndian = 0xFFFFFF;
|
||||
this.defaultRingColorLittleEndian = null;
|
||||
this.defaultBackgroundColorLittleEndian = null;
|
||||
this.scaleCorrection = 1;
|
||||
this.scaleAdjustmentX = 1;
|
||||
this.scaleAdjustmentY = 1;
|
||||
this.subjectScaleAdjustment = 1;
|
||||
this.textureScaleAdjustment = 1;
|
||||
const mesh = this.token.mesh;
|
||||
if ( mesh ) mesh.padding = 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Configure token ring size.
|
||||
*/
|
||||
configureSize() {
|
||||
const mesh = this.token.mesh;
|
||||
|
||||
// Ring size
|
||||
const size = Math.min(this.token.document.width ?? 1, this.token.document.height ?? 1);
|
||||
Object.assign(this, this.constructor.getRingDataBySize(size));
|
||||
|
||||
// Subject scale
|
||||
const scale = this.token.document.ring.subject.scale ?? this.scaleCorrection ?? 1;
|
||||
this.scaleCorrection = scale;
|
||||
this.ringUVs = this.constructor.getTextureUVs(this.ringName, scale);
|
||||
this.bkgUVs = this.constructor.getTextureUVs(this.bkgName, scale);
|
||||
|
||||
// Determine the longer and shorter sides of the image
|
||||
const {width: w, height: h} = this.token.mesh.texture ?? this.token.texture;
|
||||
let longSide = Math.max(w, h);
|
||||
let shortSide = Math.min(w, h);
|
||||
|
||||
// Calculate the necessary padding
|
||||
let padding = (longSide - shortSide) / 2;
|
||||
|
||||
// Determine padding for x and y sides
|
||||
let paddingX = (w < h) ? padding : 0;
|
||||
let paddingY = (w > h) ? padding : 0;
|
||||
|
||||
// Apply mesh padding
|
||||
mesh.paddingX = paddingX;
|
||||
mesh.paddingY = paddingY;
|
||||
|
||||
// Apply adjustments
|
||||
const adjustment = shortSide / longSide;
|
||||
this.scaleAdjustmentX = paddingX ? adjustment : 1.0;
|
||||
this.scaleAdjustmentY = paddingY ? adjustment : 1.0;
|
||||
|
||||
// Apply texture scale adjustment for token without a subject texture and in grid fit mode
|
||||
const inferred = (this.token.document.texture.src !== this.token.document._inferRingSubjectTexture());
|
||||
if ( CONFIG.Token.ring.isGridFitMode && !inferred && !this.token.document._source.ring.subject.texture ) {
|
||||
this.textureScaleAdjustment = this.subjectScaleAdjustment;
|
||||
}
|
||||
else this.textureScaleAdjustment = 1;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Configure the token ring visuals properties.
|
||||
*/
|
||||
configureVisuals() {
|
||||
const ring = this.token.document.ring;
|
||||
|
||||
// Configure colors
|
||||
const colors = foundry.utils.mergeObject(ring.colors, this.token.getRingColors(), {inplace: false});
|
||||
const resolveColor = (color, defaultColor) => {
|
||||
const resolvedColor = Color.from(color ?? 0xFFFFFF).littleEndian;
|
||||
return ((resolvedColor === 0xFFFFFF) && (defaultColor !== null)) ? defaultColor : resolvedColor;
|
||||
};
|
||||
this.ringColorLittleEndian = resolveColor(colors?.ring, this.defaultRingColorLittleEndian);
|
||||
this.bkgColorLittleEndian = resolveColor(colors?.background, this.defaultBackgroundColorLittleEndian)
|
||||
|
||||
// Configure effects
|
||||
const effectsToApply = this.token.getRingEffects();
|
||||
this.effects = ((ring.effects >= this.constructor.effects.DISABLED)
|
||||
? ring.effects : this.constructor.effects.ENABLED)
|
||||
| effectsToApply.reduce((acc, e) => acc |= e, 0x0);
|
||||
|
||||
// Mask with enabled effects for the current token ring configuration
|
||||
let mask = this.effects & CONFIG.Token.ring.ringClass.effects.ENABLED;
|
||||
for ( const key in CONFIG.Token.ring.effects ) {
|
||||
const v = CONFIG.Token.ring.ringClass.effects[key];
|
||||
if ( v !== undefined ) {
|
||||
mask |= v;
|
||||
}
|
||||
}
|
||||
this.effects &= mask;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Configure dynamic token ring subject texture.
|
||||
* @param {PrimarySpriteMesh} mesh The mesh being configured
|
||||
*/
|
||||
#configureTexture(mesh) {
|
||||
const src = this.token.document.ring.subject.texture;
|
||||
if ( PIXI.Assets.cache.has(src) ) {
|
||||
const subjectTexture = getTexture(src);
|
||||
if ( subjectTexture?.valid ) mesh.texture = subjectTexture;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Animations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Flash the ring briefly with a certain color.
|
||||
* @param {Color} color Color to flash.
|
||||
* @param {CanvasAnimationOptions} animationOptions Options to customize the animation.
|
||||
* @returns {Promise<boolean|void>}
|
||||
*/
|
||||
async flashColor(color, animationOptions={}) {
|
||||
if ( Number.isNaN(color) ) return;
|
||||
const defaultColorFallback = this.token.ring.defaultRingColorLittleEndian ?? 0xFFFFFF;
|
||||
const configuredColor = Color.from(foundry.utils.mergeObject(
|
||||
this.token.document.ring.colors,
|
||||
this.token.getRingColors(),
|
||||
{inplace: false}
|
||||
).ring);
|
||||
const originalColor = configuredColor.valid ? configuredColor.littleEndian : defaultColorFallback;
|
||||
return await CanvasAnimation.animate([{
|
||||
attribute: "ringColorLittleEndian",
|
||||
parent: this,
|
||||
from: originalColor,
|
||||
to: new Color(color.littleEndian),
|
||||
color: true
|
||||
}], foundry.utils.mergeObject({
|
||||
duration: 1600,
|
||||
priority: PIXI.UPDATE_PRIORITY.HIGH,
|
||||
easing: this.constructor.createSpikeEasing(.15)
|
||||
}, animationOptions));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create an easing function that spikes in the center. Ideal duration is around 1600ms.
|
||||
* @param {number} [spikePct=0.5] Position on [0,1] where the spike occurs.
|
||||
* @returns {Function(number): number}
|
||||
*/
|
||||
static createSpikeEasing(spikePct=0.5) {
|
||||
const scaleStart = 1 / spikePct;
|
||||
const scaleEnd = 1 / (1 - spikePct);
|
||||
return pt => {
|
||||
if ( pt < spikePct ) return CanvasAnimation.easeInCircle(pt * scaleStart);
|
||||
else return 1 - CanvasAnimation.easeOutCircle(((pt - spikePct) * scaleEnd));
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Easing function that produces two peaks before returning to the original value. Ideal duration is around 500ms.
|
||||
* @param {number} pt The proportional animation timing on [0,1].
|
||||
* @returns {number} The eased animation progress on [0,1].
|
||||
*/
|
||||
static easeTwoPeaks(pt) {
|
||||
return (Math.sin((4 * Math.PI * pt) - (Math.PI / 2)) + 1) / 2;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* To avoid breaking dnd5e.
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
configureMesh() {}
|
||||
|
||||
/**
|
||||
* To avoid breaking dnd5e.
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
configureNames() {}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user