This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
/**
* A mixin which decorates any container with base canvas common properties.
* @category - Mixins
* @param {typeof Container} ContainerClass The parent Container class being mixed.
* @returns {typeof CanvasGroupMixin} A ContainerClass subclass mixed with CanvasGroupMixin features.
*/
const CanvasGroupMixin = ContainerClass => {
return class CanvasGroup extends ContainerClass {
constructor(...args) {
super(...args);
this.sortableChildren = true;
this.layers = this._createLayers();
}
/**
* The name of this canvas group.
* @type {string}
* @abstract
*/
static groupName;
/**
* If this canvas group should teardown non-layers children.
* @type {boolean}
*/
static tearDownChildren = true;
/**
* The canonical name of the canvas group is the name of the constructor that is the immediate child of the
* defined base class.
* @type {string}
*/
get name() {
let cls = Object.getPrototypeOf(this.constructor);
let name = this.constructor.name;
while ( cls ) {
if ( cls !== CanvasGroup ) {
name = cls.name;
cls = Object.getPrototypeOf(cls);
}
else break;
}
return name;
}
/**
* The name used by hooks to construct their hook string.
* Note: You should override this getter if hookName should not return the class constructor name.
* @type {string}
*/
get hookName() {
return this.name;
}
/**
* A mapping of CanvasLayer classes which belong to this group.
* @type {Record<string, CanvasLayer>}
*/
layers;
/* -------------------------------------------- */
/**
* Create CanvasLayer instances which belong to the canvas group.
* @protected
*/
_createLayers() {
const layers = {};
for ( let [name, config] of Object.entries(CONFIG.Canvas.layers) ) {
if ( config.group !== this.constructor.groupName ) continue;
const layer = layers[name] = new config.layerClass();
Object.defineProperty(this, name, {value: layer, writable: false});
if ( !(name in canvas) ) Object.defineProperty(canvas, name, {value: layer, writable: false});
}
return layers;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* An internal reference to a Promise in-progress to draw the canvas group.
* @type {Promise<this>}
*/
#drawing = Promise.resolve(this);
/* -------------------------------------------- */
/**
* Is the group drawn?
* @type {boolean}
*/
#drawn = false;
/* -------------------------------------------- */
/**
* Draw the canvas group and all its components.
* @param {object} [options={}]
* @returns {Promise<this>} A Promise which resolves once the group is fully drawn
*/
async draw(options={}) {
return this.#drawing = this.#drawing.finally(async () => {
console.log(`${vtt} | Drawing the ${this.hookName} canvas group`);
await this.tearDown();
await this._draw(options);
Hooks.callAll(`draw${this.hookName}`, this);
this.#drawn = true;
MouseInteractionManager.emulateMoveEvent();
});
}
/**
* Draw the canvas group and all its component layers.
* @param {object} options
* @protected
*/
async _draw(options) {
// Draw CanvasLayer instances
for ( const layer of Object.values(this.layers) ) {
this.addChild(layer);
await layer.draw();
}
}
/* -------------------------------------------- */
/* Tear-Down */
/* -------------------------------------------- */
/**
* Remove and destroy all layers from the base canvas.
* @param {object} [options={}]
* @returns {Promise<this>}
*/
async tearDown(options={}) {
if ( !this.#drawn ) return this;
this.#drawn = false;
await this._tearDown(options);
Hooks.callAll(`tearDown${this.hookName}`, this);
MouseInteractionManager.emulateMoveEvent();
return this;
}
/**
* Remove and destroy all layers from the base canvas.
* @param {object} options
* @protected
*/
async _tearDown(options) {
// Remove layers
for ( const layer of Object.values(this.layers).reverse() ) {
await layer.tearDown();
this.removeChild(layer);
}
// Check if we need to handle other children
if ( !this.constructor.tearDownChildren ) return;
// Yes? Then proceed with children cleaning
for ( const child of this.removeChildren() ) {
if ( child instanceof CachedContainer ) child.clear();
else child.destroy({children: true});
}
}
};
};
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
Object.defineProperty(globalThis, "BaseCanvasMixin", {
get() {
const msg = "BaseCanvasMixin is deprecated in favor of CanvasGroupMixin";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return CanvasGroupMixin;
}
});

View File

@@ -0,0 +1,333 @@
/**
* A special type of PIXI.Container which draws its contents to a cached RenderTexture.
* This is accomplished by overriding the Container#render method to draw to our own special RenderTexture.
*/
class CachedContainer extends PIXI.Container {
/**
* Construct a CachedContainer.
* @param {PIXI.Sprite|SpriteMesh} [sprite] A specific sprite to bind to this CachedContainer and its renderTexture.
*/
constructor(sprite) {
super();
const renderer = canvas.app?.renderer;
/**
* The RenderTexture that is the render destination for the contents of this Container
* @type {PIXI.RenderTexture}
*/
this.#renderTexture = this.createRenderTexture();
// Bind a sprite to the container
if ( sprite ) this.sprite = sprite;
// Listen for resize events
this.#onResize = this.#resize.bind(this, renderer);
renderer.on("resize", this.#onResize);
}
/**
* The texture configuration to use for this cached container
* @type {{multisample: PIXI.MSAA_QUALITY, scaleMode: PIXI.SCALE_MODES, format: PIXI.FORMATS}}
* @abstract
*/
static textureConfiguration = {};
/**
* A bound resize function which fires on the renderer resize event.
* @type {function(PIXI.Renderer)}
* @private
*/
#onResize;
/**
* A map of render textures, linked to their render function and an optional RGBA clear color.
* @type {Map<PIXI.RenderTexture,{renderFunction: Function, clearColor: number[]}>}
* @protected
*/
_renderPaths = new Map();
/**
* An object which stores a reference to the normal renderer target and source frame.
* We track this so we can restore them after rendering our cached texture.
* @type {{sourceFrame: PIXI.Rectangle, renderTexture: PIXI.RenderTexture}}
* @private
*/
#backup = {
renderTexture: undefined,
sourceFrame: canvas.app.renderer.screen.clone()
};
/**
* An RGBA array used to define the clear color of the RenderTexture
* @type {number[]}
*/
clearColor = [0, 0, 0, 1];
/**
* Should our Container also be displayed on screen, in addition to being drawn to the cached RenderTexture?
* @type {boolean}
*/
displayed = false;
/**
* If true, the Container is rendered every frame.
* If false, the Container is rendered only if {@link CachedContainer#renderDirty} is true.
* @type {boolean}
*/
autoRender = true;
/**
* Does the Container need to be rendered?
* Set to false after the Container is rendered.
* @type {boolean}
*/
renderDirty = true;
/* ---------------------------------------- */
/**
* The primary render texture bound to this cached container.
* @type {PIXI.RenderTexture}
*/
get renderTexture() {
return this.#renderTexture;
}
/** @private */
#renderTexture;
/* ---------------------------------------- */
/**
* Set the alpha mode of the cached container render texture.
* @param {PIXI.ALPHA_MODES} mode
*/
set alphaMode(mode) {
this.#renderTexture.baseTexture.alphaMode = mode;
this.#renderTexture.baseTexture.update();
}
/* ---------------------------------------- */
/**
* A PIXI.Sprite or SpriteMesh which is bound to this CachedContainer.
* The RenderTexture from this Container is associated with the Sprite which is automatically rendered.
* @type {PIXI.Sprite|SpriteMesh}
*/
get sprite() {
return this.#sprite;
}
set sprite(sprite) {
if ( sprite instanceof PIXI.Sprite || sprite instanceof SpriteMesh ) {
sprite.texture = this.renderTexture;
this.#sprite = sprite;
}
else if ( sprite ) {
throw new Error("You may only bind a PIXI.Sprite or a SpriteMesh as the render target for a CachedContainer.");
}
}
/** @private */
#sprite;
/* ---------------------------------------- */
/**
* Create a render texture, provide a render method and an optional clear color.
* @param {object} [options={}] Optional parameters.
* @param {Function} [options.renderFunction] Render function that will be called to render into the RT.
* @param {number[]} [options.clearColor] An optional clear color to clear the RT before rendering into it.
* @returns {PIXI.RenderTexture} A reference to the created render texture.
*/
createRenderTexture({renderFunction, clearColor}={}) {
const renderOptions = {};
const renderer = canvas.app.renderer;
const conf = this.constructor.textureConfiguration;
const pm = canvas.performance.mode;
// Disabling linear filtering by default for low/medium performance mode
const defaultScaleMode = (pm > CONST.CANVAS_PERFORMANCE_MODES.MED)
? PIXI.SCALE_MODES.LINEAR
: PIXI.SCALE_MODES.NEAREST;
// Creating the render texture
const renderTexture = PIXI.RenderTexture.create({
width: renderer.screen.width,
height: renderer.screen.height,
resolution: renderer.resolution,
multisample: conf.multisample ?? renderer.multisample,
scaleMode: conf.scaleMode ?? defaultScaleMode,
format: conf.format ?? PIXI.FORMATS.RGBA
});
renderOptions.renderFunction = renderFunction; // Binding the render function
renderOptions.clearColor = clearColor; // Saving the optional clear color
this._renderPaths.set(renderTexture, renderOptions); // Push into the render paths
this.renderDirty = true;
// Return a reference to the render texture
return renderTexture;
}
/* ---------------------------------------- */
/**
* Remove a previously created render texture.
* @param {PIXI.RenderTexture} renderTexture The render texture to remove.
* @param {boolean} [destroy=true] Should the render texture be destroyed?
*/
removeRenderTexture(renderTexture, destroy=true) {
this._renderPaths.delete(renderTexture);
if ( destroy ) renderTexture?.destroy(true);
this.renderDirty = true;
}
/* ---------------------------------------- */
/**
* Clear the cached container, removing its current contents.
* @param {boolean} [destroy=true] Tell children that we should destroy texture as well.
* @returns {CachedContainer} A reference to the cleared container for chaining.
*/
clear(destroy=true) {
Canvas.clearContainer(this, destroy);
return this;
}
/* ---------------------------------------- */
/** @inheritdoc */
destroy(options) {
if ( this.#onResize ) canvas.app.renderer.off("resize", this.#onResize);
for ( const [rt] of this._renderPaths ) rt?.destroy(true);
this._renderPaths.clear();
super.destroy(options);
}
/* ---------------------------------------- */
/** @inheritdoc */
render(renderer) {
if ( !this.renderable ) return; // Skip updating the cached texture
if ( this.autoRender || this.renderDirty ) {
this.renderDirty = false;
this.#bindPrimaryBuffer(renderer); // Bind the primary buffer (RT)
super.render(renderer); // Draw into the primary buffer
this.#renderSecondary(renderer); // Draw into the secondary buffer(s)
this.#bindOriginalBuffer(renderer); // Restore the original buffer
}
this.#sprite?.render(renderer); // Render the bound sprite
if ( this.displayed ) super.render(renderer); // Optionally draw to the screen
}
/* ---------------------------------------- */
/**
* Custom rendering for secondary render textures
* @param {PIXI.Renderer} renderer The active canvas renderer.
* @protected
*/
#renderSecondary(renderer) {
if ( this._renderPaths.size <= 1 ) return;
// Bind the render texture and call the custom render method for each render path
for ( const [rt, ro] of this._renderPaths ) {
if ( !ro.renderFunction ) continue;
this.#bind(renderer, rt, ro.clearColor);
ro.renderFunction.call(this, renderer);
}
}
/* ---------------------------------------- */
/**
* Bind the primary render texture to the renderer, replacing and saving the original buffer and source frame.
* @param {PIXI.Renderer} renderer The active canvas renderer.
* @private
*/
#bindPrimaryBuffer(renderer) {
// Get the RenderTexture to bind
const tex = this.renderTexture;
const rt = renderer.renderTexture;
// Backup the current render target
this.#backup.renderTexture = rt.current;
this.#backup.sourceFrame.copyFrom(rt.sourceFrame);
// Bind the render texture
this.#bind(renderer, tex);
}
/* ---------------------------------------- */
/**
* Bind a render texture to this renderer.
* Must be called after bindPrimaryBuffer and before bindInitialBuffer.
* @param {PIXI.Renderer} renderer The active canvas renderer.
* @param {PIXI.RenderTexture} tex The texture to bind.
* @param {number[]} [clearColor] A custom clear color.
* @protected
*/
#bind(renderer, tex, clearColor) {
const rt = renderer.renderTexture;
// Bind our texture to the renderer
renderer.batch.flush();
rt.bind(tex, undefined, undefined);
rt.clear(clearColor ?? this.clearColor);
// Enable Filters which are applied to this Container to apply to our cached RenderTexture
const fs = renderer.filter.defaultFilterStack;
if ( fs.length > 1 ) {
fs[fs.length - 1].renderTexture = tex;
}
}
/* ---------------------------------------- */
/**
* Remove the render texture from the Renderer, re-binding the original buffer.
* @param {PIXI.Renderer} renderer The active canvas renderer.
* @private
*/
#bindOriginalBuffer(renderer) {
renderer.batch.flush();
// Restore Filters to apply to the original RenderTexture
const fs = renderer.filter.defaultFilterStack;
if ( fs.length > 1 ) {
fs[fs.length - 1].renderTexture = this.#backup.renderTexture;
}
// Re-bind the original RenderTexture to the renderer
renderer.renderTexture.bind(this.#backup.renderTexture, this.#backup.sourceFrame, undefined);
this.#backup.renderTexture = undefined;
}
/* ---------------------------------------- */
/**
* Resize bound render texture(s) when the dimensions or resolution of the Renderer have changed.
* @param {PIXI.Renderer} renderer The active canvas renderer.
* @private
*/
#resize(renderer) {
for ( const [rt] of this._renderPaths ) CachedContainer.resizeRenderTexture(renderer, rt);
if ( this.#sprite ) this.#sprite._boundsID++; // Inform PIXI that bounds need to be recomputed for this sprite mesh
this.renderDirty = true;
}
/* ---------------------------------------- */
/**
* Resize a render texture passed as a parameter with the renderer.
* @param {PIXI.Renderer} renderer The active canvas renderer.
* @param {PIXI.RenderTexture} rt The render texture to resize.
*/
static resizeRenderTexture(renderer, rt) {
const screen = renderer?.screen;
if ( !rt || !screen ) return;
if ( rt.baseTexture.resolution !== renderer.resolution ) rt.baseTexture.resolution = renderer.resolution;
if ( (rt.width !== screen.width) || (rt.height !== screen.height) ) rt.resize(screen.width, screen.height);
}
}

View File

@@ -0,0 +1,31 @@
/**
* Augment any PIXI.DisplayObject to assume bounds that are always aligned with the full visible screen.
* The bounds of this container do not depend on its children but always fill the entire canvas.
* @param {typeof PIXI.DisplayObject} Base Any PIXI DisplayObject subclass
* @returns {typeof FullCanvasObject} The decorated subclass with full canvas bounds
*/
function FullCanvasObjectMixin(Base) {
return class FullCanvasObject extends Base {
/** @override */
calculateBounds() {
const bounds = this._bounds;
const { x, y, width, height } = canvas.dimensions.rect;
bounds.clear();
bounds.addFrame(this.transform, x, y, x + width, y + height);
bounds.updateID = this._boundsID;
}
};
}
/**
* @deprecated since v11
* @ignore
*/
class FullCanvasContainer extends FullCanvasObjectMixin(PIXI.Container) {
constructor(...args) {
super(...args);
const msg = "You are using the FullCanvasContainer class which has been deprecated in favor of a more flexible "
+ "FullCanvasObjectMixin which can augment any PIXI.DisplayObject subclass.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
}
}

View File

@@ -0,0 +1,118 @@
/**
* Extension of a PIXI.Mesh, with the capabilities to provide a snapshot of the framebuffer.
* @extends PIXI.Mesh
*/
class PointSourceMesh extends PIXI.Mesh {
/**
* To store the previous blend mode of the last renderer PointSourceMesh.
* @type {PIXI.BLEND_MODES}
* @protected
*/
static _priorBlendMode;
/**
* The current texture used by the mesh.
* @type {PIXI.Texture}
* @protected
*/
static _currentTexture;
/**
* The transform world ID of the bounds.
* @type {number}
*/
_worldID = -1;
/**
* The geometry update ID of the bounds.
* @type {number}
*/
_updateID = -1;
/* -------------------------------------------- */
/* PointSourceMesh Properties */
/* -------------------------------------------- */
/** @override */
get geometry() {
return super.geometry;
}
/** @override */
set geometry(value) {
if ( this._geometry !== value ) this._updateID = -1;
super.geometry = value;
}
/* -------------------------------------------- */
/* PointSourceMesh Methods */
/* -------------------------------------------- */
/** @override */
addChild() {
throw new Error("You can't add children to a PointSourceMesh.");
}
/* ---------------------------------------- */
/** @override */
addChildAt() {
throw new Error("You can't add children to a PointSourceMesh.");
}
/* ---------------------------------------- */
/** @override */
_render(renderer) {
if ( this.uniforms.framebufferTexture !== undefined ) {
if ( canvas.blur.enabled ) {
// We need to use the snapshot only if blend mode is changing
const requireUpdate = (this.state.blendMode !== PointSourceMesh._priorBlendMode)
&& (PointSourceMesh._priorBlendMode !== undefined);
if ( requireUpdate ) PointSourceMesh._currentTexture = canvas.snapshot.getFramebufferTexture(renderer);
PointSourceMesh._priorBlendMode = this.state.blendMode;
}
this.uniforms.framebufferTexture = PointSourceMesh._currentTexture;
}
super._render(renderer);
}
/* ---------------------------------------- */
/** @override */
calculateBounds() {
const {transform, geometry} = this;
// Checking bounds id to update only when it is necessary
if ( this._worldID !== transform._worldID
|| this._updateID !== geometry.buffers[0]._updateID ) {
this._worldID = transform._worldID;
this._updateID = geometry.buffers[0]._updateID;
const {x, y, width, height} = this.geometry.bounds;
this._bounds.clear();
this._bounds.addFrame(transform, x, y, x + width, y + height);
}
this._bounds.updateID = this._boundsID;
}
/* ---------------------------------------- */
/** @override */
_calculateBounds() {
this.calculateBounds();
}
/* ---------------------------------------- */
/**
* The local bounds need to be drawn from the underlying geometry.
* @override
*/
getLocalBounds(rect) {
rect ??= this._localBoundsRect ??= new PIXI.Rectangle();
return this.geometry.bounds.copyTo(rect);
}
}

View File

@@ -0,0 +1,125 @@
/**
* A basic rectangular mesh with a shader only. Does not natively handle textures (but a bound shader can).
* Bounds calculations are simplified and the geometry does not need to handle texture coords.
* @param {AbstractBaseShader} shaderClass The shader class to use.
*/
class QuadMesh extends PIXI.Container {
constructor(shaderClass) {
super();
// Assign shader, state and properties
if ( !AbstractBaseShader.isPrototypeOf(shaderClass) ) {
throw new Error("QuadMesh shader class must inherit from AbstractBaseShader.");
}
this.#shader = shaderClass.create();
}
/**
* Geometry bound to this QuadMesh.
* @type {PIXI.Geometry}
*/
#geometry = new PIXI.Geometry()
.addAttribute("aVertexPosition", [0, 0, 1, 0, 1, 1, 0, 1], 2)
.addIndex([0, 1, 2, 0, 2, 3]);
/* ---------------------------------------- */
/**
* The shader bound to this mesh.
* @type {AbstractBaseShader}
*/
get shader() {
return this.#shader;
}
/**
* @type {AbstractBaseShader}
*/
#shader;
/* ---------------------------------------- */
/**
* Assigned blend mode to this mesh.
* @type {PIXI.BLEND_MODES}
*/
get blendMode() {
return this.#state.blendMode;
}
set blendMode(value) {
this.#state.blendMode = value;
}
/**
* State bound to this QuadMesh.
* @type {PIXI.State}
*/
#state = PIXI.State.for2d();
/* ---------------------------------------- */
/**
* Initialize shader based on the shader class type.
* @param {class} shaderClass Shader class used. Must inherit from AbstractBaseShader.
*/
setShaderClass(shaderClass) {
// Escape conditions
if ( !AbstractBaseShader.isPrototypeOf(shaderClass) ) {
throw new Error("QuadMesh shader class must inherit from AbstractBaseShader.");
}
if ( this.#shader.constructor === shaderClass ) return;
// Create shader program
this.#shader = shaderClass.create();
}
/* ---------------------------------------- */
/** @override */
_render(renderer) {
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);
renderer.geometry.bind(this.#geometry, this.#shader);
// Draw the geometry
renderer.geometry.draw(PIXI.DRAW_MODES.TRIANGLES);
}
/* ---------------------------------------- */
/** @override */
_calculateBounds() {
this._bounds.addFrame(this.transform, 0, 0, 1, 1);
}
/* ---------------------------------------- */
/**
* Tests if a point is inside this QuadMesh.
* @param {PIXI.IPointData} point
* @returns {boolean}
*/
containsPoint(point) {
return this.getBounds().contains(point.x, point.y);
}
/* ---------------------------------------- */
/** @override */
destroy(options) {
super.destroy(options);
this.#geometry.dispose();
this.#geometry = null;
this.#shader = null;
this.#state = null;
}
}

View File

@@ -0,0 +1,319 @@
/**
* @typedef {object} QuadtreeObject
* @property {Rectangle} r
* @property {*} t
* @property {Set<Quadtree>} [n]
*/
/**
* A Quadtree implementation that supports collision detection for rectangles.
*
* @param {Rectangle} bounds The outer bounds of the region
* @param {object} [options] Additional options which configure the Quadtree
* @param {number} [options.maxObjects=20] The maximum number of objects per node
* @param {number} [options.maxDepth=4] The maximum number of levels within the root Quadtree
* @param {number} [options._depth=0] The depth level of the sub-tree. For internal use
* @param {number} [options._root] The root of the quadtree. For internal use
*/
class Quadtree {
constructor(bounds, {maxObjects=20, maxDepth=4, _depth=0, _root}={}) {
/**
* The bounding rectangle of the region
* @type {PIXI.Rectangle}
*/
this.bounds = new PIXI.Rectangle(bounds.x, bounds.y, bounds.width, bounds.height);
/**
* The maximum number of objects allowed within this node before it must split
* @type {number}
*/
this.maxObjects = maxObjects;
/**
* The maximum number of levels that the base quadtree is allowed
* @type {number}
*/
this.maxDepth = maxDepth;
/**
* The depth of this node within the root Quadtree
* @type {number}
*/
this.depth = _depth;
/**
* The objects contained at this level of the tree
* @type {QuadtreeObject[]}
*/
this.objects = [];
/**
* Children of this node
* @type {Quadtree[]}
*/
this.nodes = [];
/**
* The root Quadtree
* @type {Quadtree}
*/
this.root = _root || this;
}
/**
* A constant that enumerates the index order of the quadtree nodes from top-left to bottom-right.
* @enum {number}
*/
static INDICES = {tl: 0, tr: 1, bl: 2, br: 3};
/* -------------------------------------------- */
/**
* Return an array of all the objects in the Quadtree (recursive)
* @returns {QuadtreeObject[]}
*/
get all() {
if ( this.nodes.length ) {
return this.nodes.reduce((arr, n) => arr.concat(n.all), []);
}
return this.objects;
}
/* -------------------------------------------- */
/* Tree Management */
/* -------------------------------------------- */
/**
* Split this node into 4 sub-nodes.
* @returns {Quadtree} The split Quadtree
*/
split() {
const b = this.bounds;
const w = b.width / 2;
const h = b.height / 2;
const options = {
maxObjects: this.maxObjects,
maxDepth: this.maxDepth,
_depth: this.depth + 1,
_root: this.root
};
// Create child quadrants
this.nodes[Quadtree.INDICES.tl] = new Quadtree(new PIXI.Rectangle(b.x, b.y, w, h), options);
this.nodes[Quadtree.INDICES.tr] = new Quadtree(new PIXI.Rectangle(b.x+w, b.y, w, h), options);
this.nodes[Quadtree.INDICES.bl] = new Quadtree(new PIXI.Rectangle(b.x, b.y+h, w, h), options);
this.nodes[Quadtree.INDICES.br] = new Quadtree(new PIXI.Rectangle(b.x+w, b.y+h, w, h), options);
// Assign current objects to child nodes
for ( let o of this.objects ) {
o.n.delete(this);
this.insert(o);
}
this.objects = [];
return this;
}
/* -------------------------------------------- */
/* Object Management */
/* -------------------------------------------- */
/**
* Clear the quadtree of all existing contents
* @returns {Quadtree} The cleared Quadtree
*/
clear() {
this.objects = [];
for ( let n of this.nodes ) {
n.clear();
}
this.nodes = [];
return this;
}
/* -------------------------------------------- */
/**
* Add a rectangle object to the tree
* @param {QuadtreeObject} obj The object being inserted
* @returns {Quadtree[]} The Quadtree nodes the object was added to.
*/
insert(obj) {
obj.n = obj.n || new Set();
// If we will exceeded the maximum objects we need to split
if ( (this.objects.length === this.maxObjects - 1) && (this.depth < this.maxDepth) ) {
if ( !this.nodes.length ) this.split();
}
// If this node has children, recursively insert
if ( this.nodes.length ) {
let nodes = this.getChildNodes(obj.r);
return nodes.reduce((arr, n) => arr.concat(n.insert(obj)), []);
}
// Otherwise store the object here
obj.n.add(this);
this.objects.push(obj);
return [this];
}
/* -------------------------------------------- */
/**
* Remove an object from the quadtree
* @param {*} target The quadtree target being removed
* @returns {Quadtree} The Quadtree for method chaining
*/
remove(target) {
this.objects.findSplice(o => o.t === target);
for ( let n of this.nodes ) {
n.remove(target);
}
return this;
}
/* -------------------------------------------- */
/**
* Remove an existing object from the quadtree and re-insert it with a new position
* @param {QuadtreeObject} obj The object being inserted
* @returns {Quadtree[]} The Quadtree nodes the object was added to
*/
update(obj) {
this.remove(obj.t);
return this.insert(obj);
}
/* -------------------------------------------- */
/* Target Identification */
/* -------------------------------------------- */
/**
* Get all the objects which could collide with the provided rectangle
* @param {Rectangle} rect The normalized target rectangle
* @param {object} [options] Options affecting the collision test.
* @param {Function} [options.collisionTest] Function to further refine objects to return
* after a potential collision is found. Parameters are the object and rect, and the
* function should return true if the object should be added to the result set.
* @param {Set} [options._s] The existing result set, for internal use.
* @returns {Set} The objects in the Quadtree which represent potential collisions
*/
getObjects(rect, { collisionTest, _s } = {}) {
const objects = _s || new Set();
// Recursively retrieve objects from child nodes
if ( this.nodes.length ) {
const nodes = this.getChildNodes(rect);
for ( let n of nodes ) {
n.getObjects(rect, {collisionTest, _s: objects});
}
}
// Otherwise, retrieve from this node
else {
for ( let o of this.objects) {
if ( rect.overlaps(o.r) && (!collisionTest || collisionTest(o, rect)) ) objects.add(o.t);
}
}
// Return the result set
return objects;
}
/* -------------------------------------------- */
/**
* Obtain the leaf nodes to which a target rectangle belongs.
* This traverses the quadtree recursively obtaining the final nodes which have no children.
* @param {Rectangle} rect The target rectangle.
* @returns {Quadtree[]} The Quadtree nodes to which the target rectangle belongs
*/
getLeafNodes(rect) {
if ( !this.nodes.length ) return [this];
const nodes = this.getChildNodes(rect);
return nodes.reduce((arr, n) => arr.concat(n.getLeafNodes(rect)), []);
}
/* -------------------------------------------- */
/**
* Obtain the child nodes within the current node which a rectangle belongs to.
* Note that this function is not recursive, it only returns nodes at the current or child level.
* @param {Rectangle} rect The target rectangle.
* @returns {Quadtree[]} The Quadtree nodes to which the target rectangle belongs
*/
getChildNodes(rect) {
// If this node has no children, use it
if ( !this.nodes.length ) return [this];
// Prepare data
const nodes = [];
const hx = this.bounds.x + (this.bounds.width / 2);
const hy = this.bounds.y + (this.bounds.height / 2);
// Determine orientation relative to the node
const startTop = rect.y <= hy;
const startLeft = rect.x <= hx;
const endBottom = (rect.y + rect.height) > hy;
const endRight = (rect.x + rect.width) > hx;
// Top-left
if ( startLeft && startTop ) nodes.push(this.nodes[Quadtree.INDICES.tl]);
// Top-right
if ( endRight && startTop ) nodes.push(this.nodes[Quadtree.INDICES.tr]);
// Bottom-left
if ( startLeft && endBottom ) nodes.push(this.nodes[Quadtree.INDICES.bl]);
// Bottom-right
if ( endRight && endBottom ) nodes.push(this.nodes[Quadtree.INDICES.br]);
return nodes;
}
/* -------------------------------------------- */
/**
* Identify all nodes which are adjacent to this one within the parent Quadtree.
* @returns {Quadtree[]}
*/
getAdjacentNodes() {
const bounds = this.bounds.clone().pad(1);
return this.root.getLeafNodes(bounds);
}
/* -------------------------------------------- */
/**
* Visualize the nodes and objects in the quadtree
* @param {boolean} [objects] Visualize the rectangular bounds of objects in the Quadtree. Default is false.
* @private
*/
visualize({objects=false}={}) {
const debug = canvas.controls.debug;
if ( this.depth === 0 ) debug.clear().endFill();
debug.lineStyle(2, 0x00FF00, 0.5).drawRect(this.bounds.x, this.bounds.y, this.bounds.width, this.bounds.height);
if ( objects ) {
for ( let o of this.objects ) {
debug.lineStyle(2, 0xFF0000, 0.5).drawRect(o.r.x, o.r.y, Math.max(o.r.width, 1), Math.max(o.r.height, 1));
}
}
for ( let n of this.nodes ) {
n.visualize({objects});
}
}
}
/* -------------------------------------------- */
/**
* A subclass of Quadtree specifically intended for classifying the location of objects on the game canvas.
*/
class CanvasQuadtree extends Quadtree {
constructor(options={}) {
super({}, options);
Object.defineProperty(this, "bounds", {get: () => canvas.dimensions.rect});
}
}

View File

@@ -0,0 +1,820 @@
/**
* An extension of PIXI.Mesh which emulate a PIXI.Sprite with a specific shader.
* @param {PIXI.Texture} [texture=PIXI.Texture.EMPTY] Texture bound to this sprite mesh.
* @param {typeof BaseSamplerShader} [shaderClass=BaseSamplerShader] Shader class used by this sprite mesh.
*/
class SpriteMesh extends PIXI.Container {
constructor(texture, shaderClass=BaseSamplerShader) {
super();
// Create shader program
if ( !foundry.utils.isSubclass(shaderClass, BaseSamplerShader) ) {
throw new Error("SpriteMesh shader class must be a subclass of BaseSamplerShader.");
}
this._shader = shaderClass.create();
// Initialize other data to emulate sprite
this.vertexData = this.#geometry.buffers[0].data;
this.uvs = this.#geometry.buffers[1].data;
this.indices = this.#geometry.indexBuffer.data;
this._texture = null;
this._anchor = new PIXI.ObservablePoint(
this._onAnchorUpdate,
this,
(texture ? texture.defaultAnchor.x : 0),
(texture ? texture.defaultAnchor.y : 0)
);
this.texture = texture;
this.isSprite = true;
// Assigning some batch data that will not change during the life of this sprite mesh
this._batchData.vertexData = this.vertexData;
this._batchData.indices = this.indices;
this._batchData.uvs = this.uvs;
this._batchData.object = this;
}
/**
* A temporary reusable rect.
* @type {PIXI.Rectangle}
*/
static #TEMP_RECT = new PIXI.Rectangle();
/**
* A temporary reusable point.
* @type {PIXI.Point}
*/
static #TEMP_POINT = new PIXI.Point();
/**
* Geometry bound to this SpriteMesh.
* @type {PIXI.Geometry}
*/
#geometry = new PIXI.Geometry()
.addAttribute("aVertexPosition", new PIXI.Buffer(new Float32Array(8), false), 2)
.addAttribute("aTextureCoord", new PIXI.Buffer(new Float32Array(8), true), 2)
.addIndex([0, 1, 2, 0, 2, 3]);
/**
* Snapshot of some parameters of this display object to render in batched mode.
* @type {{_tintRGB: number, _texture: PIXI.Texture, indices: number[],
* uvs: number[], blendMode: PIXI.BLEND_MODES, vertexData: number[], worldAlpha: number}}
* @protected
*/
_batchData = {
_texture: undefined,
vertexData: undefined,
indices: undefined,
uvs: undefined,
worldAlpha: undefined,
_tintRGB: undefined,
blendMode: undefined,
object: undefined
};
/**
* The indices of the geometry.
* @type {Uint16Array}
*/
indices;
/**
* The width of the sprite (this is initially set by the texture).
* @type {number}
* @protected
*/
_width = 0;
/**
* The height of the sprite (this is initially set by the texture)
* @type {number}
* @protected
*/
_height = 0;
/**
* The texture that the sprite is using.
* @type {PIXI.Texture}
* @protected
*/
_texture;
/**
* The texture ID.
* @type {number}
* @protected
*/
_textureID = -1;
/**
* Cached tint value so we can tell when the tint is changed.
* @type {[red: number, green: number, blue: number, alpha: number]}
* @protected
* @internal
*/
_cachedTint = [1, 1, 1, 1];
/**
* The texture trimmed ID.
* @type {number}
* @protected
*/
_textureTrimmedID = -1;
/**
* This is used to store the uvs data of the sprite, assigned at the same time
* as the vertexData in calculateVertices().
* @type {Float32Array}
* @protected
*/
uvs;
/**
* The anchor point defines the normalized coordinates
* in the texture that map to the position of this
* sprite.
*
* By default, this is `(0,0)` (or `texture.defaultAnchor`
* if you have modified that), which means the position
* `(x,y)` of this `Sprite` will be the top-left corner.
*
* Note: Updating `texture.defaultAnchor` after
* constructing a `Sprite` does _not_ update its anchor.
*
* {@link https://docs.cocos2d-x.org/cocos2d-x/en/sprites/manipulation.html}
* @type {PIXI.ObservablePoint}
* @protected
*/
_anchor;
/**
* This is used to store the vertex data of the sprite (basically a quad).
* @type {Float32Array}
* @protected
*/
vertexData;
/**
* This is used to calculate the bounds of the object IF it is a trimmed sprite.
* @type {Float32Array|null}
* @protected
*/
vertexTrimmedData = null;
/**
* The transform ID.
* @type {number}
* @private
*/
_transformID = -1;
/**
* The transform ID.
* @type {number}
* @private
*/
_transformTrimmedID = -1;
/**
* The tint applied to the sprite. This is a hex value. A value of 0xFFFFFF will remove any tint effect.
* @type {PIXI.Color}
* @protected
*/
_tintColor = new PIXI.Color(0xFFFFFF);
/**
* The tint applied to the sprite. This is a RGB value. A value of 0xFFFFFF will remove any tint effect.
* @type {number}
* @protected
*/
_tintRGB = 0xFFFFFF;
/**
* An instance of a texture uvs used for padded SpriteMesh.
* Instanced only when padding becomes non-zero.
* @type {PIXI.TextureUvs|null}
* @protected
*/
_textureUvs = null;
/**
* Used to track a tint or alpha change to execute a recomputation of _cachedTint.
* @type {boolean}
* @protected
*/
_tintAlphaDirty = true;
/**
* The PIXI.State of this SpriteMesh.
* @type {PIXI.State}
*/
#state = PIXI.State.for2d();
/* ---------------------------------------- */
/**
* The shader bound to this mesh.
* @type {BaseSamplerShader}
*/
get shader() {
return this._shader;
}
/**
* The shader bound to this mesh.
* @type {BaseSamplerShader}
* @protected
*/
_shader;
/* ---------------------------------------- */
/**
* The x padding in pixels (must be a non-negative value.)
* @type {number}
*/
get paddingX() {
return this._paddingX;
}
set paddingX(value) {
if ( value < 0 ) throw new Error("The padding must be a non-negative value.");
if ( this._paddingX === value ) return;
this._paddingX = value;
this._textureID = -1;
this._textureTrimmedID = -1;
this._textureUvs ??= new PIXI.TextureUvs();
}
/**
* They y padding in pixels (must be a non-negative value.)
* @type {number}
*/
get paddingY() {
return this._paddingY;
}
set paddingY(value) {
if ( value < 0 ) throw new Error("The padding must be a non-negative value.");
if ( this._paddingY === value ) return;
this._paddingY = value;
this._textureID = -1;
this._textureTrimmedID = -1;
this._textureUvs ??= new PIXI.TextureUvs();
}
/**
* The maximum x/y padding in pixels (must be a non-negative value.)
* @type {number}
*/
get padding() {
return Math.max(this._paddingX, this._paddingY);
}
set padding(value) {
if ( value < 0 ) throw new Error("The padding must be a non-negative value.");
this.paddingX = this.paddingY = value;
}
/**
* @type {number}
* @protected
*/
_paddingX = 0;
/**
* @type {number}
* @protected
*/
_paddingY = 0;
/* ---------------------------------------- */
/**
* The blend mode applied to the SpriteMesh.
* @type {PIXI.BLEND_MODES}
* @defaultValue PIXI.BLEND_MODES.NORMAL
*/
set blendMode(value) {
this.#state.blendMode = value;
}
get blendMode() {
return this.#state.blendMode;
}
/* ---------------------------------------- */
/**
* If true PixiJS will Math.round() x/y values when rendering, stopping pixel interpolation.
* Advantages can include sharper image quality (like text) and faster rendering on canvas.
* The main disadvantage is movement of objects may appear less smooth.
* To set the global default, change PIXI.settings.ROUND_PIXELS
* @defaultValue PIXI.settings.ROUND_PIXELS
*/
set roundPixels(value) {
if ( this.#roundPixels !== value ) this._transformID = -1;
this.#roundPixels = value;
}
get roundPixels() {
return this.#roundPixels;
}
#roundPixels = PIXI.settings.ROUND_PIXELS;
/* ---------------------------------------- */
/**
* Used to force an alpha mode on this sprite mesh.
* If this property is non null, this value will replace the texture alphaMode when computing color channels.
* Affects how tint, worldAlpha and alpha are computed each others.
* @type {PIXI.ALPHA_MODES}
*/
get alphaMode() {
return this.#alphaMode ?? this._texture.baseTexture.alphaMode;
}
set alphaMode(mode) {
if ( this.#alphaMode === mode ) return;
this.#alphaMode = mode;
this._tintAlphaDirty = true;
}
#alphaMode = null;
/* ---------------------------------------- */
/**
* Returns the SpriteMesh associated batch plugin. By default the returned plugin is that of the associated shader.
* If a plugin is forced, it will returns the forced plugin.
* @type {string}
*/
get pluginName() {
return this.#pluginName ?? this._shader.pluginName;
}
set pluginName(name) {
this.#pluginName = name;
}
#pluginName = null;
/* ---------------------------------------- */
/** @override */
get width() {
return Math.abs(this.scale.x) * this._texture.orig.width;
}
set width(width) {
const s = Math.sign(this.scale.x) || 1;
this.scale.x = s * width / this._texture.orig.width;
this._width = width;
}
/* ---------------------------------------- */
/** @override */
get height() {
return Math.abs(this.scale.y) * this._texture.orig.height;
}
set height(height) {
const s = Math.sign(this.scale.y) || 1;
this.scale.y = s * height / this._texture.orig.height;
this._height = height;
}
/* ---------------------------------------- */
/**
* The texture that the sprite is using.
* @type {PIXI.Texture}
*/
get texture() {
return this._texture;
}
set texture(texture) {
texture = texture ?? null;
if ( this._texture === texture ) return;
if ( this._texture ) this._texture.off("update", this._onTextureUpdate, this);
this._texture = texture || PIXI.Texture.EMPTY;
this._textureID = this._textureTrimmedID = -1;
this._tintAlphaDirty = true;
if ( texture ) {
if ( this._texture.baseTexture.valid ) this._onTextureUpdate();
else this._texture.once("update", this._onTextureUpdate, this);
}
}
/* ---------------------------------------- */
/**
* The anchor sets the origin point of the sprite. The default value is taken from the {@link PIXI.Texture|Texture}
* and passed to the constructor.
*
* The default is `(0,0)`, this means the sprite's origin is the top left.
*
* Setting the anchor to `(0.5,0.5)` means the sprite's origin is centered.
*
* Setting the anchor to `(1,1)` would mean the sprite's origin point will be the bottom right corner.
*
* If you pass only single parameter, it will set both x and y to the same value as shown in the example below.
* @type {PIXI.ObservablePoint}
*/
get anchor() {
return this._anchor;
}
set anchor(anchor) {
this._anchor.copyFrom(anchor);
}
/* ---------------------------------------- */
/**
* The tint applied to the sprite. 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) {
this._tintColor.setValue(tint);
const tintRGB = this._tintColor.toLittleEndianNumber();
if ( tintRGB === this._tintRGB ) return;
this._tintRGB = tintRGB;
this._tintAlphaDirty = true;
}
/* ---------------------------------------- */
/**
* The HTML source element for this SpriteMesh texture.
* @type {HTMLImageElement|HTMLVideoElement|null}
*/
get sourceElement() {
if ( !this.texture.valid ) return null;
return this.texture?.baseTexture.resource?.source || null;
}
/* ---------------------------------------- */
/**
* Is this SpriteMesh rendering a video texture?
* @type {boolean}
*/
get isVideo() {
const source = this.sourceElement;
return source?.tagName === "VIDEO";
}
/* ---------------------------------------- */
/**
* When the texture is updated, this event will fire to update the scale and frame.
* @protected
*/
_onTextureUpdate() {
this._textureID = this._textureTrimmedID = this._transformID = this._transformTrimmedID = -1;
if ( this._width ) this.scale.x = Math.sign(this.scale.x) * this._width / this._texture.orig.width;
if ( this._height ) this.scale.y = Math.sign(this.scale.y) * this._height / this._texture.orig.height;
// Alpha mode of the texture could have changed
this._tintAlphaDirty = true;
this.updateUvs();
}
/* ---------------------------------------- */
/**
* Called when the anchor position updates.
* @protected
*/
_onAnchorUpdate() {
this._textureID = this._textureTrimmedID = this._transformID = this._transformTrimmedID = -1;
}
/* ---------------------------------------- */
/**
* Update uvs and push vertices and uv buffers on GPU if necessary.
*/
updateUvs() {
if ( this._textureID !== this._texture._updateID ) {
let textureUvs;
if ( (this._paddingX !== 0) || (this._paddingY !== 0) ) {
const texture = this._texture;
const frame = SpriteMesh.#TEMP_RECT.copyFrom(texture.frame).pad(this._paddingX, this._paddingY);
textureUvs = this._textureUvs;
textureUvs.set(frame, texture.baseTexture, texture.rotate);
} else {
textureUvs = this._texture._uvs;
}
this.uvs.set(textureUvs.uvsFloat32);
this.#geometry.buffers[1].update();
}
}
/* ---------------------------------------- */
/**
* Initialize shader based on the shader class type.
* @param {typeof BaseSamplerShader} shaderClass The shader class
*/
setShaderClass(shaderClass) {
if ( !foundry.utils.isSubclass(shaderClass, BaseSamplerShader) ) {
throw new Error("SpriteMesh shader class must inherit from BaseSamplerShader.");
}
if ( this._shader.constructor === shaderClass ) return;
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;
/* ---------------------------------------- */
/**
* Calculates worldTransform * vertices, store it in vertexData.
*/
calculateVertices() {
if ( this._transformID === this.transform._worldID && this._textureID === this._texture._updateID ) return;
// Update uvs if necessary
this.updateUvs();
this._transformID = this.transform._worldID;
this._textureID = this._texture._updateID;
// Set the vertex data
const {a, b, c, d, tx, ty} = this.transform.worldTransform;
const orig = this._texture.orig;
const trim = this._texture.trim;
const padX = this._paddingX;
const padY = this._paddingY;
let w1; let w0; let h1; let h0;
if ( trim ) {
// If the sprite is trimmed and is not a tilingsprite then we need to add the extra
// space before transforming the sprite coords
w1 = trim.x - (this._anchor._x * orig.width) - padX;
w0 = w1 + trim.width + (2 * padX);
h1 = trim.y - (this._anchor._y * orig.height) - padY;
h0 = h1 + trim.height + (2 * padY);
}
else {
w1 = (-this._anchor._x * orig.width) - padX;
w0 = w1 + orig.width + (2 * padX);
h1 = (-this._anchor._y * orig.height) - padY;
h0 = h1 + orig.height + (2 * padY);
}
const vertexData = this.vertexData;
vertexData[0] = (a * w1) + (c * h1) + tx;
vertexData[1] = (d * h1) + (b * w1) + ty;
vertexData[2] = (a * w0) + (c * h1) + tx;
vertexData[3] = (d * h1) + (b * w0) + ty;
vertexData[4] = (a * w0) + (c * h0) + tx;
vertexData[5] = (d * h0) + (b * w0) + ty;
vertexData[6] = (a * w1) + (c * h0) + tx;
vertexData[7] = (d * h0) + (b * w1) + ty;
if ( this.roundPixels ) {
const r = PIXI.settings.RESOLUTION;
for ( let i = 0; i < vertexData.length; ++i ) vertexData[i] = Math.round(vertexData[i] * r) / r;
}
this.#geometry.buffers[0].update();
}
/* ---------------------------------------- */
/**
* Calculates worldTransform * vertices for a non texture with a trim. store it in vertexTrimmedData.
*
* This is used to ensure that the true width and height of a trimmed texture is respected.
*/
calculateTrimmedVertices() {
if ( !this.vertexTrimmedData ) this.vertexTrimmedData = new Float32Array(8);
else if ( (this._transformTrimmedID === this.transform._worldID)
&& (this._textureTrimmedID === this._texture._updateID) ) return;
this._transformTrimmedID = this.transform._worldID;
this._textureTrimmedID = this._texture._updateID;
const texture = this._texture;
const vertexData = this.vertexTrimmedData;
const orig = texture.orig;
const anchor = this._anchor;
const padX = this._paddingX;
const padY = this._paddingY;
// Compute the new untrimmed bounds
const wt = this.transform.worldTransform;
const a = wt.a;
const b = wt.b;
const c = wt.c;
const d = wt.d;
const tx = wt.tx;
const ty = wt.ty;
const w1 = (-anchor._x * orig.width) - padX;
const w0 = w1 + orig.width + (2 * padX);
const h1 = (-anchor._y * orig.height) - padY;
const h0 = h1 + orig.height + (2 * padY);
vertexData[0] = (a * w1) + (c * h1) + tx;
vertexData[1] = (d * h1) + (b * w1) + ty;
vertexData[2] = (a * w0) + (c * h1) + tx;
vertexData[3] = (d * h1) + (b * w0) + ty;
vertexData[4] = (a * w0) + (c * h0) + tx;
vertexData[5] = (d * h0) + (b * w0) + ty;
vertexData[6] = (a * w1) + (c * h0) + tx;
vertexData[7] = (d * h0) + (b * w1) + ty;
if ( this.roundPixels ) {
const r = PIXI.settings.RESOLUTION;
for ( let i = 0; i < vertexData.length; ++i ) vertexData[i] = Math.round(vertexData[i] * r) / r;
}
}
/* ---------------------------------------- */
/** @override */
_render(renderer) {
const pluginName = this.pluginName;
if ( pluginName ) this.#renderBatched(renderer, pluginName);
else this.#renderDirect(renderer, this._shader);
}
/* ---------------------------------------- */
/**
* Render with batching.
* @param {PIXI.Renderer} renderer The renderer
* @param {string} pluginName The batch renderer
*/
#renderBatched(renderer, pluginName) {
this.calculateVertices();
this._updateBatchData();
const batchRenderer = renderer.plugins[pluginName];
renderer.batch.setObjectRenderer(batchRenderer);
batchRenderer.render(this._batchData);
}
/* ---------------------------------------- */
/**
* Render without batching.
* @param {PIXI.Renderer} renderer The renderer
* @param {BaseSamplerShader} shader The shader
*/
#renderDirect(renderer, shader) {
this.calculateVertices();
if ( this._tintAlphaDirty ) {
PIXI.Color.shared.setValue(this._tintColor)
.premultiply(this.worldAlpha, this.alphaMode > 0)
.toArray(this._cachedTint);
this._tintAlphaDirty = false;
}
shader._preRender(this, renderer);
renderer.batch.flush();
renderer.shader.bind(shader);
renderer.state.set(this.#state);
renderer.geometry.bind(this.#geometry, shader);
renderer.geometry.draw(PIXI.DRAW_MODES.TRIANGLES, 6, 0);
}
/* ---------------------------------------- */
/**
* Update the batch data object.
* @protected
*/
_updateBatchData() {
this._batchData._texture = this._texture;
this._batchData.worldAlpha = this.worldAlpha;
this._batchData._tintRGB = this._tintRGB;
this._batchData.blendMode = this.#state.blendMode;
}
/* ---------------------------------------- */
/** @override */
_calculateBounds() {
const trim = this._texture.trim;
const orig = this._texture.orig;
// First lets check to see if the current texture has a trim.
if ( !trim || ((trim.width === orig.width) && (trim.height === orig.height)) ) {
this.calculateVertices();
this._bounds.addQuad(this.vertexData);
}
else {
this.calculateTrimmedVertices();
this._bounds.addQuad(this.vertexTrimmedData);
}
}
/* ---------------------------------------- */
/** @override */
getLocalBounds(rect) {
// Fast local bounds computation if the sprite has no children!
if ( this.children.length === 0 ) {
if ( !this._localBounds ) this._localBounds = new PIXI.Bounds();
const padX = this._paddingX;
const padY = this._paddingY;
const orig = this._texture.orig;
this._localBounds.minX = (orig.width * -this._anchor._x) - padX;
this._localBounds.minY = (orig.height * -this._anchor._y) - padY;
this._localBounds.maxX = (orig.width * (1 - this._anchor._x)) + padX;
this._localBounds.maxY = (orig.height * (1 - this._anchor._y)) + padY;
if ( !rect ) {
if ( !this._localBoundsRect ) this._localBoundsRect = new PIXI.Rectangle();
rect = this._localBoundsRect;
}
return this._localBounds.getRectangle(rect);
}
return super.getLocalBounds(rect);
}
/* ---------------------------------------- */
/** @override */
containsPoint(point) {
const tempPoint = SpriteMesh.#TEMP_POINT;
this.worldTransform.applyInverse(point, tempPoint);
const width = this._texture.orig.width;
const height = this._texture.orig.height;
const x1 = -width * this.anchor.x;
let y1 = 0;
if ( (tempPoint.x >= x1) && (tempPoint.x < (x1 + width)) ) {
y1 = -height * this.anchor.y;
if ( (tempPoint.y >= y1) && (tempPoint.y < (y1 + height)) ) return true;
}
return false;
}
/* ---------------------------------------- */
/** @override */
destroy(options) {
super.destroy(options);
this.#geometry.dispose();
this.#geometry = null;
this._shader = null;
this.#state = null;
this.uvs = null;
this.indices = null;
this.vertexData = null;
this._texture.off("update", this._onTextureUpdate, this);
this._anchor = null;
const destroyTexture = (typeof options === "boolean" ? options : options?.texture);
if ( destroyTexture ) {
const destroyBaseTexture = (typeof options === "boolean" ? options : options?.baseTexture);
this._texture.destroy(!!destroyBaseTexture);
}
this._texture = null;
}
/* ---------------------------------------- */
/**
* Create a SpriteMesh from another source.
* You can specify texture options and a specific shader class derived from BaseSamplerShader.
* @param {string|PIXI.Texture|HTMLCanvasElement|HTMLVideoElement} source Source to create texture from.
* @param {object} [textureOptions] See {@link PIXI.BaseTexture}'s constructor for options.
* @param {BaseSamplerShader} [shaderClass] The shader class to use. BaseSamplerShader by default.
* @returns {SpriteMesh}
*/
static from(source, textureOptions, shaderClass) {
const texture = source instanceof PIXI.Texture ? source : PIXI.Texture.from(source, textureOptions);
return new SpriteMesh(texture, shaderClass);
}
}

View File

@@ -0,0 +1,50 @@
/**
* UnboundContainers behave like PIXI.Containers except that they are not bound to their parent's transforms.
* However, they normally propagate their own transformations to their children.
*/
class UnboundContainer extends PIXI.Container {
constructor(...args) {
super(...args);
// Replacing PIXI.Transform with an UnboundTransform
this.transform = new UnboundTransform();
}
}
/* -------------------------------------------- */
/**
* A custom Transform class which is not bound to the parent worldTransform.
* localTransform are working as usual.
*/
class UnboundTransform extends PIXI.Transform {
/** @override */
static IDENTITY = new UnboundTransform();
/* -------------------------------------------- */
/** @override */
updateTransform(parentTransform) {
const lt = this.localTransform;
if ( this._localID !== this._currentLocalID ) {
// Get the matrix values of the displayobject based on its transform properties..
lt.a = this._cx * this.scale.x;
lt.b = this._sx * this.scale.x;
lt.c = this._cy * this.scale.y;
lt.d = this._sy * this.scale.y;
lt.tx = this.position.x - ((this.pivot.x * lt.a) + (this.pivot.y * lt.c));
lt.ty = this.position.y - ((this.pivot.x * lt.b) + (this.pivot.y * lt.d));
this._currentLocalID = this._localID;
// Force an update
this._parentID = -1;
}
if ( this._parentID !== parentTransform._worldID ) {
// We don't use the values from the parent transform. We're just updating IDs.
this._parentID = parentTransform._worldID;
this._worldID++;
}
}
}

View File

@@ -0,0 +1,314 @@
/**
* @typedef {Object} CanvasAnimationAttribute
* @property {string} attribute The attribute name being animated
* @property {Object} parent The object within which the attribute is stored
* @property {number} to The destination value of the attribute
* @property {number} [from] An initial value of the attribute, otherwise parent[attribute] is used
* @property {number} [delta] The computed delta between to and from
* @property {number} [done] The amount of the total delta which has been animated
* @property {boolean} [color] Is this a color animation that applies to RGB channels
*/
/**
* @typedef {Object} CanvasAnimationOptions
* @property {PIXI.DisplayObject} [context] A DisplayObject which defines context to the PIXI.Ticker function
* @property {string|symbol} [name] A unique name which can be used to reference the in-progress animation
* @property {number} [duration] A duration in milliseconds over which the animation should occur
* @property {number} [priority] A priority in PIXI.UPDATE_PRIORITY which defines when the animation
* should be evaluated related to others
* @property {Function|string} [easing] An easing function used to translate animation time or the string name
* of a static member of the CanvasAnimation class
* @property {function(number, CanvasAnimationData)} [ontick] A callback function which fires after every frame
* @property {Promise} [wait] The animation isn't started until this promise resolves
*/
/**
* @typedef {Object} _CanvasAnimationData
* @property {Function} fn The animation function being executed each frame
* @property {number} time The current time of the animation, in milliseconds
* @property {CanvasAnimationAttribute[]} attributes The attributes being animated
* @property {number} state The current state of the animation (see {@link CanvasAnimation.STATES})
* @property {Promise} promise A Promise which resolves once the animation is complete
* @property {Function} resolve The resolution function, allowing animation to be ended early
* @property {Function} reject The rejection function, allowing animation to be ended early
*/
/**
* @typedef {_CanvasAnimationData & CanvasAnimationOptions} CanvasAnimationData
*/
/**
* A helper class providing utility methods for PIXI Canvas animation
*/
class CanvasAnimation {
/**
* The possible states of an animation.
* @enum {number}
*/
static get STATES() {
return this.#STATES;
}
static #STATES = Object.freeze({
/**
* An error occurred during waiting or running the animation.
*/
FAILED: -2,
/**
* The animation was terminated before it could complete.
*/
TERMINATED: -1,
/**
* Waiting for the wait promise before the animation is started.
*/
WAITING: 0,
/**
* The animation has been started and is running.
*/
RUNNING: 1,
/**
* The animation was completed without errors and without being terminated.
*/
COMPLETED: 2
});
/* -------------------------------------------- */
/**
* The ticker used for animations.
* @type {PIXI.Ticker}
*/
static get ticker() {
return canvas.app.ticker;
}
/* -------------------------------------------- */
/**
* Track an object of active animations by name, context, and function
* This allows a currently playing animation to be referenced and terminated
* @type {Record<string, CanvasAnimationData>}
*/
static animations = {};
/* -------------------------------------------- */
/**
* Apply an animation from the current value of some attribute to a new value
* Resolve a Promise once the animation has concluded and the attributes have reached their new target
*
* @param {CanvasAnimationAttribute[]} attributes An array of attributes to animate
* @param {CanvasAnimationOptions} options Additional options which customize the animation
*
* @returns {Promise<boolean>} A Promise which resolves to true once the animation has concluded
* or false if the animation was prematurely terminated
*
* @example Animate Token Position
* ```js
* let animation = [
* {
* parent: token,
* attribute: "x",
* to: 1000
* },
* {
* parent: token,
* attribute: "y",
* to: 2000
* }
* ];
* CanvasAnimation.animate(attributes, {duration:500});
* ```
*/
static async animate(attributes, {context=canvas.stage, name, duration=1000, easing, ontick, priority, wait}={}) {
priority ??= PIXI.UPDATE_PRIORITY.LOW + 1;
if ( typeof easing === "string" ) easing = this[easing];
// If an animation with this name already exists, terminate it
if ( name ) this.terminateAnimation(name);
// Define the animation and its animation function
attributes = attributes.map(a => {
a.from = a.from ?? a.parent[a.attribute];
a.delta = a.to - a.from;
a.done = 0;
// Special handling for color transitions
if ( a.to instanceof Color ) {
a.color = true;
a.from = Color.from(a.from);
}
return a;
});
if ( attributes.length && attributes.every(a => a.delta === 0) ) return;
const animation = {attributes, context, duration, easing, name, ontick, time: 0, wait,
state: CanvasAnimation.STATES.WAITING};
animation.fn = dt => CanvasAnimation.#animateFrame(dt, animation);
// Create a promise which manages the animation lifecycle
const promise = new Promise(async (resolve, reject) => {
animation.resolve = completed => {
if ( (animation.state === CanvasAnimation.STATES.WAITING)
|| (animation.state === CanvasAnimation.STATES.RUNNING) ) {
animation.state = completed ? CanvasAnimation.STATES.COMPLETED : CanvasAnimation.STATES.TERMINATED;
resolve(completed);
}
};
animation.reject = error => {
if ( (animation.state === CanvasAnimation.STATES.WAITING)
|| (animation.state === CanvasAnimation.STATES.RUNNING) ) {
animation.state = CanvasAnimation.STATES.FAILED;
reject(error);
}
};
try {
if ( wait instanceof Promise ) await wait;
if ( animation.state === CanvasAnimation.STATES.WAITING ) {
animation.state = CanvasAnimation.STATES.RUNNING;
this.ticker.add(animation.fn, context, priority);
}
} catch(err) {
animation.reject(err);
}
})
// Log any errors
.catch(err => console.error(err))
// Remove the animation once completed
.finally(() => {
this.ticker.remove(animation.fn, context);
if ( name && (this.animations[name] === animation) ) delete this.animations[name];
});
// Record the animation and return
if ( name ) {
animation.promise = promise;
this.animations[name] = animation;
}
return promise;
}
/* -------------------------------------------- */
/**
* Retrieve an animation currently in progress by its name
* @param {string} name The animation name to retrieve
* @returns {CanvasAnimationData} The animation data, or undefined
*/
static getAnimation(name) {
return this.animations[name];
}
/* -------------------------------------------- */
/**
* If an animation using a certain name already exists, terminate it
* @param {string} name The animation name to terminate
*/
static terminateAnimation(name) {
let animation = this.animations[name];
if (animation) animation.resolve(false);
}
/* -------------------------------------------- */
/**
* Cosine based easing with smooth in-out.
* @param {number} pt The proportional animation timing on [0,1]
* @returns {number} The eased animation progress on [0,1]
*/
static easeInOutCosine(pt) {
return (1 - Math.cos(Math.PI * pt)) * 0.5;
}
/* -------------------------------------------- */
/**
* Shallow ease out.
* @param {number} pt The proportional animation timing on [0,1]
* @returns {number} The eased animation progress on [0,1]
*/
static easeOutCircle(pt) {
return Math.sqrt(1 - Math.pow(pt - 1, 2));
}
/* -------------------------------------------- */
/**
* Shallow ease in.
* @param {number} pt The proportional animation timing on [0,1]
* @returns {number} The eased animation progress on [0,1]
*/
static easeInCircle(pt) {
return 1 - Math.sqrt(1 - Math.pow(pt, 2));
}
/* -------------------------------------------- */
/**
* Generic ticker function to implement the animation.
* This animation wrapper executes once per frame for the duration of the animation event.
* Once the animated attributes have converged to their targets, it resolves the original Promise.
* The user-provided ontick function runs each frame update to apply additional behaviors.
*
* @param {number} deltaTime The incremental time which has elapsed
* @param {CanvasAnimationData} animation The animation which is being performed
*/
static #animateFrame(deltaTime, animation) {
const {attributes, duration, ontick} = animation;
// Compute animation timing and progress
const dt = this.ticker.elapsedMS; // Delta time in MS
animation.time += dt; // Total time which has elapsed
const complete = animation.time >= duration;
const pt = complete ? 1 : animation.time / duration; // Proportion of total duration
const pa = animation.easing ? animation.easing(pt) : pt;
// Update each attribute
try {
for ( let a of attributes ) CanvasAnimation.#updateAttribute(a, pa);
if ( ontick ) ontick(dt, animation);
}
// Terminate the animation if any errors occur
catch(err) {
animation.reject(err);
}
// Resolve the original promise once the animation is complete
if ( complete ) animation.resolve(true);
}
/* -------------------------------------------- */
/**
* Update a single attribute according to its animation completion percentage
* @param {CanvasAnimationAttribute} attribute The attribute being animated
* @param {number} percentage The animation completion percentage
*/
static #updateAttribute(attribute, percentage) {
attribute.done = attribute.delta * percentage;
// Complete animation
if ( percentage === 1 ) {
attribute.parent[attribute.attribute] = attribute.to;
return;
}
// Color animation
if ( attribute.color ) {
attribute.parent[attribute.attribute] = attribute.from.mix(attribute.to, percentage);
return;
}
// Numeric attribute
attribute.parent[attribute.attribute] = attribute.from + attribute.done;
}
}

View File

@@ -0,0 +1,105 @@
/**
* A generic helper for drawing a standard Control Icon
* @type {PIXI.Container}
*/
class ControlIcon extends PIXI.Container {
constructor({texture, size=40, borderColor=0xFF5500, tint=null, elevation=0}={}, ...args) {
super(...args);
// Define arguments
this.iconSrc = texture;
this.size = size;
this.rect = [-2, -2, size+4, size+4];
this.borderColor = borderColor;
/**
* The color of the icon tint, if any
* @type {number|null}
*/
this.tintColor = tint;
// Define hit area
this.eventMode = "static";
this.interactiveChildren = false;
this.hitArea = new PIXI.Rectangle(...this.rect);
this.cursor = "pointer";
// Background
this.bg = this.addChild(new PIXI.Graphics());
this.bg.clear().beginFill(0x000000, 0.4).lineStyle(2, 0x000000, 1.0).drawRoundedRect(...this.rect, 5).endFill();
// Icon
this.icon = this.addChild(new PIXI.Sprite());
// Border
this.border = this.addChild(new PIXI.Graphics());
this.border.visible = false;
// Elevation
this.tooltip = this.addChild(new PreciseText());
this.tooltip.visible = false;
// Set the initial elevation
this.elevation = elevation;
// Draw asynchronously
this.draw();
}
/* -------------------------------------------- */
/**
* The elevation of the ControlIcon, which is displayed in its tooltip text.
* @type {number}
*/
get elevation() {
return this.#elevation;
}
set elevation(value) {
if ( (typeof value !== "number") || !Number.isFinite(value) ) {
throw new Error("ControlIcon#elevation must be a finite numeric value.");
}
if ( value === this.#elevation ) return;
this.#elevation = value;
this.tooltip.text = `${value > 0 ? "+" : ""}${value} ${canvas.grid.units}`.trim();
this.tooltip.visible = value !== 0;
}
#elevation = 0;
/* -------------------------------------------- */
/**
* Initial drawing of the ControlIcon
* @returns {Promise<ControlIcon>}
*/
async draw() {
if ( this.destroyed ) return this;
this.texture = this.texture ?? await loadTexture(this.iconSrc);
this.icon.texture = this.texture;
this.icon.width = this.icon.height = this.size;
this.tooltip.style = CONFIG.canvasTextStyle;
this.tooltip.anchor.set(0.5, 1);
this.tooltip.position.set(this.size / 2, -12);
return this.refresh();
}
/* -------------------------------------------- */
/**
* Incremental refresh for ControlIcon appearance.
*/
refresh({visible, iconColor, borderColor, borderVisible}={}) {
if ( iconColor !== undefined ) this.tintColor = iconColor;
this.icon.tint = this.tintColor ?? 0xFFFFFF;
if ( borderColor !== undefined ) this.borderColor = borderColor;
this.border.clear().lineStyle(2, this.borderColor, 1.0).drawRoundedRect(...this.rect, 5).endFill();
if ( borderVisible !== undefined ) this.border.visible = borderVisible;
if ( visible !== undefined && (this.visible !== visible) ) {
this.visible = visible;
MouseInteractionManager.emulateMoveEvent();
}
return this;
}
}

View File

@@ -0,0 +1,885 @@
/**
* Handle mouse interaction events for a Canvas object.
* There are three phases of events: hover, click, and drag
*
* Hover Events:
* _handlePointerOver
* action: hoverIn
* _handlePointerOut
* action: hoverOut
*
* Left Click and Double-Click
* _handlePointerDown
* action: clickLeft
* action: clickLeft2
* action: unclickLeft
*
* Right Click and Double-Click
* _handleRightDown
* action: clickRight
* action: clickRight2
* action: unclickRight
*
* Drag and Drop
* _handlePointerMove
* action: dragLeftStart
* action: dragRightStart
* action: dragLeftMove
* action: dragRightMove
* _handlePointerUp
* action: dragLeftDrop
* action: dragRightDrop
* _handleDragCancel
* action: dragLeftCancel
* action: dragRightCancel
*/
class MouseInteractionManager {
constructor(object, layer, permissions={}, callbacks={}, options={}) {
this.object = object;
this.layer = layer;
this.permissions = permissions;
this.callbacks = callbacks;
/**
* Interaction options which configure handling workflows
* @type {{target: PIXI.DisplayObject, dragResistance: number}}
*/
this.options = options;
/**
* The current interaction state
* @type {number}
*/
this.state = this.states.NONE;
/**
* Bound interaction data object to populate with custom data.
* @type {Record<string, any>}
*/
this.interactionData = {};
/**
* The drag handling time
* @type {number}
*/
this.dragTime = 0;
/**
* The time of the last left-click event
* @type {number}
*/
this.lcTime = 0;
/**
* The time of the last right-click event
* @type {number}
*/
this.rcTime = 0;
/**
* A flag for whether we are right-click dragging
* @type {boolean}
*/
this._dragRight = false;
/**
* An optional ControlIcon instance for the object
* @type {ControlIcon|null}
*/
this.controlIcon = this.options.target ? this.object[this.options.target] : null;
/**
* The view id pertaining to the PIXI Application.
* If not provided, default to canvas.app.view.id
* @type {string}
*/
this.viewId = (this.options.application ?? canvas.app).view.id;
}
/**
* The client position of the last left/right-click.
* @type {PIXI.Point}
*/
lastClick = new PIXI.Point();
/**
* Bound handlers which can be added and removed
* @type {Record<string, Function>}
*/
#handlers = {};
/**
* Enumerate the states of a mouse interaction workflow.
* 0: NONE - the object is inactive
* 1: HOVER - the mouse is hovered over the object
* 2: CLICKED - the object is clicked
* 3: GRABBED - the object is grabbed
* 4: DRAG - the object is being dragged
* 5: DROP - the object is being dropped
* @enum {number}
*/
static INTERACTION_STATES = {
NONE: 0,
HOVER: 1,
CLICKED: 2,
GRABBED: 3,
DRAG: 4,
DROP: 5
};
/**
* Enumerate the states of handle outcome.
* -2: SKIPPED - the handler has been skipped by previous logic
* -1: DISALLOWED - the handler has dissallowed further process
* 1: REFUSED - the handler callback has been processed and is refusing further process
* 2: ACCEPTED - the handler callback has been processed and is accepting further process
* @enum {number}
*/
static #HANDLER_OUTCOME = {
SKIPPED: -2,
DISALLOWED: -1,
REFUSED: 1,
ACCEPTED: 2
};
/**
* The maximum number of milliseconds between two clicks to be considered a double-click.
* @type {number}
*/
static DOUBLE_CLICK_TIME_MS = 250;
/**
* The maximum number of pixels between two clicks to be considered a double-click.
* @type {number}
*/
static DOUBLE_CLICK_DISTANCE_PX = 5;
/**
* The number of milliseconds of mouse click depression to consider it a long press.
* @type {number}
*/
static LONG_PRESS_DURATION_MS = 500;
/**
* Global timeout for the long-press event.
* @type {number|null}
*/
static longPressTimeout = null;
/* -------------------------------------------- */
/**
* Emulate a pointermove event. Needs to be called when an object with the static event mode
* or any of its parents is transformed or its visibility is changed.
*/
static emulateMoveEvent() {
MouseInteractionManager.#emulateMoveEvent();
}
static #emulateMoveEvent = foundry.utils.throttle(() => {
const events = canvas.app.renderer.events;
const rootPointerEvent = events.rootPointerEvent;
if ( !events.supportsPointerEvents ) return;
if ( events.supportsTouchEvents && (rootPointerEvent.pointerType === "touch") ) return;
events.domElement.dispatchEvent(new PointerEvent("pointermove", {
pointerId: rootPointerEvent.pointerId,
pointerType: rootPointerEvent.pointerType,
isPrimary: rootPointerEvent.isPrimary,
clientX: rootPointerEvent.clientX,
clientY: rootPointerEvent.clientY,
pageX: rootPointerEvent.pageX,
pageY: rootPointerEvent.pageY,
altKey: rootPointerEvent.altKey,
ctrlKey: rootPointerEvent.ctrlKey,
metaKey: rootPointerEvent.metaKey,
shiftKey: rootPointerEvent.shiftKey
}));
}, 10);
/* -------------------------------------------- */
/**
* Get the target.
* @type {PIXI.DisplayObject}
*/
get target() {
return this.options.target ? this.object[this.options.target] : this.object;
}
/**
* Is this mouse manager in a dragging state?
* @type {boolean}
*/
get isDragging() {
return this.state >= this.states.DRAG;
}
/* -------------------------------------------- */
/**
* Activate interactivity for the handled object
*/
activate() {
// Remove existing listeners
this.state = this.states.NONE;
this.target.removeAllListeners();
// Create bindings for all handler functions
this.#handlers = {
pointerover: this.#handlePointerOver.bind(this),
pointerout: this.#handlePointerOut.bind(this),
pointerdown: this.#handlePointerDown.bind(this),
pointermove: this.#handlePointerMove.bind(this),
pointerup: this.#handlePointerUp.bind(this),
contextmenu: this.#handleDragCancel.bind(this)
};
// Activate hover events to start the workflow
this.#activateHoverEvents();
// Set the target as interactive
this.target.eventMode = "static";
return this;
}
/* -------------------------------------------- */
/**
* Test whether the current user has permission to perform a step of the workflow
* @param {string} action The action being attempted
* @param {Event|PIXI.FederatedEvent} event The event being handled
* @returns {boolean} Can the action be performed?
*/
can(action, event) {
const fn = this.permissions[action];
if ( typeof fn === "boolean" ) return fn;
if ( fn instanceof Function ) return fn.call(this.object, game.user, event);
return true;
}
/* -------------------------------------------- */
/**
* Execute a callback function associated with a certain action in the workflow
* @param {string} action The action being attempted
* @param {Event|PIXI.FederatedEvent} event The event being handled
* @param {...*} args Additional callback arguments.
* @returns {boolean} A boolean which may indicate that the event was handled by the callback.
* Events which do not specify a callback are assumed to have been handled as no-op.
*/
callback(action, event, ...args) {
const fn = this.callbacks[action];
if ( fn instanceof Function ) {
this.#assignInteractionData(event);
return fn.call(this.object, event, ...args) ?? true;
}
return true;
}
/* -------------------------------------------- */
/**
* A reference to the possible interaction states which can be observed
* @returns {Record<string, number>}
*/
get states() {
return this.constructor.INTERACTION_STATES;
}
/* -------------------------------------------- */
/**
* A reference to the possible interaction states which can be observed
* @returns {Record<string, number>}
*/
get handlerOutcomes() {
return MouseInteractionManager.#HANDLER_OUTCOME;
}
/* -------------------------------------------- */
/* Listener Activation and Deactivation */
/* -------------------------------------------- */
/**
* Activate a set of listeners which handle hover events on the target object
*/
#activateHoverEvents() {
// Disable and re-register mouseover and mouseout handlers
this.target.off("pointerover", this.#handlers.pointerover).on("pointerover", this.#handlers.pointerover);
this.target.off("pointerout", this.#handlers.pointerout).on("pointerout", this.#handlers.pointerout);
}
/* -------------------------------------------- */
/**
* Activate a new set of listeners for click events on the target object.
*/
#activateClickEvents() {
this.#deactivateClickEvents();
this.target.on("pointerdown", this.#handlers.pointerdown);
this.target.on("pointerup", this.#handlers.pointerup);
this.target.on("pointerupoutside", this.#handlers.pointerup);
}
/* -------------------------------------------- */
/**
* Deactivate event listeners for click events on the target object.
*/
#deactivateClickEvents() {
this.target.off("pointerdown", this.#handlers.pointerdown);
this.target.off("pointerup", this.#handlers.pointerup);
this.target.off("pointerupoutside", this.#handlers.pointerup);
}
/* -------------------------------------------- */
/**
* Activate events required for handling a drag-and-drop workflow
*/
#activateDragEvents() {
this.#deactivateDragEvents();
this.layer.on("pointermove", this.#handlers.pointermove);
if ( !this._dragRight ) {
canvas.app.view.addEventListener("contextmenu", this.#handlers.contextmenu, {capture: true});
}
}
/* -------------------------------------------- */
/**
* Deactivate events required for handling drag-and-drop workflow.
* @param {boolean} [silent] Set to true to activate the silent mode.
*/
#deactivateDragEvents(silent) {
this.layer.off("pointermove", this.#handlers.pointermove);
canvas.app.view.removeEventListener("contextmenu", this.#handlers.contextmenu, {capture: true});
}
/* -------------------------------------------- */
/* Hover In and Hover Out */
/* -------------------------------------------- */
/**
* Handle mouse-over events which activate downstream listeners and do not stop propagation.
* @param {PIXI.FederatedEvent} event
*/
#handlePointerOver(event) {
const action = "hoverIn";
if ( (this.state !== this.states.NONE) || (event.nativeEvent && (event.nativeEvent.target.id !== this.viewId)) ) {
return this.#debug(action, event, this.handlerOutcomes.SKIPPED);
}
if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
// Invoke the callback function
this.state = this.states.HOVER;
if ( this.callback(action, event) === false ) {
this.state = this.states.NONE;
return this.#debug(action, event, this.handlerOutcomes.REFUSED);
}
// Activate click events
this.#activateClickEvents();
return this.#debug(action, event);
}
/* -------------------------------------------- */
/**
* Handle mouse-out events which terminate hover workflows and do not stop propagation.
* @param {PIXI.FederatedEvent} event
*/
#handlePointerOut(event) {
if ( event.pointerType === "touch" ) return; // Ignore Touch events
const action = "hoverOut";
if ( !this.state.between(this.states.HOVER, this.states.CLICKED)
|| (event.nativeEvent && (event.nativeEvent.target.id !== this.viewId) ) ) {
return this.#debug(action, event, this.handlerOutcomes.SKIPPED);
}
if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
// Was the mouse-out event handled by the callback?
const priorState = this.state;
this.state = this.states.NONE;
if ( this.callback(action, event) === false ) {
this.state = priorState;
return this.#debug(action, event, this.handlerOutcomes.REFUSED);
}
// Deactivate click events
this.#deactivateClickEvents();
return this.#debug(action, event);
}
/* -------------------------------------------- */
/**
* Handle mouse-down events which activate downstream listeners.
* @param {PIXI.FederatedEvent} event
*/
#handlePointerDown(event) {
if ( event.button === 0 ) return this.#handleLeftDown(event);
if ( event.button === 2 ) return this.#handleRightDown(event);
}
/* -------------------------------------------- */
/* Left Click and Double Click */
/* -------------------------------------------- */
/**
* Handle left-click mouse-down events.
* Stop further propagation only if the event is allowed by either single or double-click.
* @param {PIXI.FederatedEvent} event
*/
#handleLeftDown(event) {
if ( !this.state.between(this.states.HOVER, this.states.DRAG) ) return;
// Determine double vs single click
const isDouble = ((event.timeStamp - this.lcTime) <= MouseInteractionManager.DOUBLE_CLICK_TIME_MS)
&& (Math.hypot(event.clientX - this.lastClick.x, event.clientY - this.lastClick.y)
<= MouseInteractionManager.DOUBLE_CLICK_DISTANCE_PX);
this.lcTime = isDouble ? 0 : event.timeStamp;
this.lastClick.set(event.clientX, event.clientY);
// Set the origin point from layer local position
this.interactionData.origin = event.getLocalPosition(this.layer);
// Activate a timeout to detect long presses
if ( !isDouble ) {
clearTimeout(this.constructor.longPressTimeout);
this.constructor.longPressTimeout = setTimeout(() => {
this.#handleLongPress(event, this.interactionData.origin);
}, MouseInteractionManager.LONG_PRESS_DURATION_MS);
}
// Dispatch to double and single-click handlers
if ( isDouble && this.can("clickLeft2", event) ) return this.#handleClickLeft2(event);
else return this.#handleClickLeft(event);
}
/* -------------------------------------------- */
/**
* Handle mouse-down which trigger a single left-click workflow.
* @param {PIXI.FederatedEvent} event
*/
#handleClickLeft(event) {
const action = "clickLeft";
if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
this._dragRight = false;
// Was the left-click event handled by the callback?
const priorState = this.state;
if ( this.state === this.states.HOVER ) this.state = this.states.CLICKED;
canvas.currentMouseManager = this;
if ( this.callback(action, event) === false ) {
this.state = priorState;
canvas.currentMouseManager = null;
return this.#debug(action, event, this.handlerOutcomes.REFUSED);
}
// Activate drag event handlers
if ( (this.state === this.states.CLICKED) && this.can("dragStart", event) ) {
this.state = this.states.GRABBED;
this.#activateDragEvents();
}
return this.#debug(action, event);
}
/* -------------------------------------------- */
/**
* Handle mouse-down which trigger a single left-click workflow.
* @param {PIXI.FederatedEvent} event
*/
#handleClickLeft2(event) {
const action = "clickLeft2";
if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED);
return this.#debug(action, event);
}
/* -------------------------------------------- */
/**
* Handle a long mouse depression to trigger a long-press workflow.
* @param {PIXI.FederatedEvent} event The mousedown event.
* @param {PIXI.Point} origin The original canvas coordinates of the mouse click
*/
#handleLongPress(event, origin) {
const action = "longPress";
if ( this.callback(action, event, origin) === false ) {
return this.#debug(action, event, this.handlerOutcomes.REFUSED);
}
return this.#debug(action, event);
}
/* -------------------------------------------- */
/* Right Click and Double Click */
/* -------------------------------------------- */
/**
* Handle right-click mouse-down events.
* Stop further propagation only if the event is allowed by either single or double-click.
* @param {PIXI.FederatedEvent} event
*/
#handleRightDown(event) {
if ( !this.state.between(this.states.HOVER, this.states.DRAG) ) return;
// Determine double vs single click
const isDouble = ((event.timeStamp - this.rcTime) <= MouseInteractionManager.DOUBLE_CLICK_TIME_MS)
&& (Math.hypot(event.clientX - this.lastClick.x, event.clientY - this.lastClick.y)
<= MouseInteractionManager.DOUBLE_CLICK_DISTANCE_PX);
this.rcTime = isDouble ? 0 : event.timeStamp;
this.lastClick.set(event.clientX, event.clientY);
// Update event data
this.interactionData.origin = event.getLocalPosition(this.layer);
// Dispatch to double and single-click handlers
if ( isDouble && this.can("clickRight2", event) ) return this.#handleClickRight2(event);
else return this.#handleClickRight(event);
}
/* -------------------------------------------- */
/**
* Handle single right-click actions.
* @param {PIXI.FederatedEvent} event
*/
#handleClickRight(event) {
const action = "clickRight";
if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
this._dragRight = true;
// Was the right-click event handled by the callback?
const priorState = this.state;
if ( this.state === this.states.HOVER ) this.state = this.states.CLICKED;
canvas.currentMouseManager = this;
if ( this.callback(action, event) === false ) {
this.state = priorState;
canvas.currentMouseManager = null;
return this.#debug(action, event, this.handlerOutcomes.REFUSED);
}
// Activate drag event handlers
if ( (this.state === this.states.CLICKED) && this.can("dragRight", event) ) {
this.state = this.states.GRABBED;
this.#activateDragEvents();
}
return this.#debug(action, event);
}
/* -------------------------------------------- */
/**
* Handle double right-click actions.
* @param {PIXI.FederatedEvent} event
*/
#handleClickRight2(event) {
const action = "clickRight2";
if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED);
return this.#debug(action, event);
}
/* -------------------------------------------- */
/* Drag and Drop */
/* -------------------------------------------- */
/**
* Handle mouse movement during a drag workflow
* @param {PIXI.FederatedEvent} event
*/
#handlePointerMove(event) {
if ( !this.state.between(this.states.GRABBED, this.states.DRAG) ) return;
// Limit dragging to 60 updates per second
const now = Date.now();
if ( (now - this.dragTime) < canvas.app.ticker.elapsedMS ) return;
this.dragTime = now;
// Update interaction data
const data = this.interactionData;
data.destination = event.getLocalPosition(this.layer, data.destination);
// Handling rare case when origin is not defined
// FIXME: The root cause should be identified and this code removed
if ( data.origin === undefined ) data.origin = new PIXI.Point().copyFrom(data.destination);
// Begin a new drag event
if ( this.state !== this.states.DRAG ) {
const dx = data.destination.x - data.origin.x;
const dy = data.destination.y - data.origin.y;
const dz = Math.hypot(dx, dy);
const r = this.options.dragResistance || (canvas.dimensions.size / 4);
if ( dz >= r ) this.#handleDragStart(event);
}
// Continue a drag event
if ( this.state === this.states.DRAG ) this.#handleDragMove(event);
}
/* -------------------------------------------- */
/**
* Handle the beginning of a new drag start workflow, moving all controlled objects on the layer
* @param {PIXI.FederatedEvent} event
*/
#handleDragStart(event) {
clearTimeout(this.constructor.longPressTimeout);
const action = this._dragRight ? "dragRightStart" : "dragLeftStart";
if ( !this.can(action, event) ) {
this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
this.cancel(event);
return;
}
this.state = this.states.DRAG;
if ( this.callback(action, event) === false ) {
this.state = this.states.GRABBED;
return this.#debug(action, event, this.handlerOutcomes.REFUSED);
}
return this.#debug(action, event, this.handlerOutcomes.ACCEPTED);
}
/* -------------------------------------------- */
/**
* Handle the continuation of a drag workflow, moving all controlled objects on the layer
* @param {PIXI.FederatedEvent} event
*/
#handleDragMove(event) {
clearTimeout(this.constructor.longPressTimeout);
const action = this._dragRight ? "dragRightMove" : "dragLeftMove";
if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
const handled = this.callback(action, event);
return this.#debug(action, event, handled ? this.handlerOutcomes.ACCEPTED : this.handlerOutcomes.REFUSED);
}
/* -------------------------------------------- */
/**
* Handle mouse up events which may optionally conclude a drag workflow
* @param {PIXI.FederatedEvent} event
*/
#handlePointerUp(event) {
clearTimeout(this.constructor.longPressTimeout);
// If this is a touch hover event, treat it as a drag
if ( (this.state === this.states.HOVER) && (event.pointerType === "touch") ) {
this.state = this.states.DRAG;
}
// Save prior state
const priorState = this.state;
// Update event data
this.interactionData.destination = event.getLocalPosition(this.layer, this.interactionData.destination);
if ( this.state >= this.states.DRAG ) {
event.stopPropagation();
if ( event.type.startsWith("right") && !this._dragRight ) return;
if ( this.state === this.states.DRAG ) this.#handleDragDrop(event);
}
// Continue a multi-click drag workflow
if ( event.defaultPrevented ) {
this.state = priorState;
return this.#debug("mouseUp", event, this.handlerOutcomes.SKIPPED);
}
// Handle the unclick event
this.#handleUnclick(event);
// Cancel the drag workflow
this.#handleDragCancel(event);
}
/* -------------------------------------------- */
/**
* Handle the conclusion of a drag workflow, placing all dragged objects back on the layer
* @param {PIXI.FederatedEvent} event
*/
#handleDragDrop(event) {
const action = this._dragRight ? "dragRightDrop" : "dragLeftDrop";
if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
// Was the drag-drop event handled by the callback?
this.state = this.states.DROP;
if ( this.callback(action, event) === false ) {
this.state = this.states.DRAG;
return this.#debug(action, event, this.handlerOutcomes.REFUSED);
}
// Update the workflow state
return this.#debug(action, event);
}
/* -------------------------------------------- */
/**
* Handle the cancellation of a drag workflow, resetting back to the original state
* @param {PIXI.FederatedEvent} event
*/
#handleDragCancel(event) {
this.cancel(event);
}
/* -------------------------------------------- */
/**
* Handle the unclick event
* @param {PIXI.FederatedEvent} event
*/
#handleUnclick(event) {
const action = event.button === 0 ? "unclickLeft" : "unclickRight";
if ( !this.state.between(this.states.CLICKED, this.states.GRABBED) ) {
return this.#debug(action, event, this.handlerOutcomes.SKIPPED);
}
if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED);
return this.#debug(action, event);
}
/* -------------------------------------------- */
/**
* A public method to handle directly an event into this manager, according to its type.
* Note: drag events are not handled.
* @param {PIXI.FederatedEvent} event
* @returns {boolean} Has the event been processed?
*/
handleEvent(event) {
switch ( event.type ) {
case "pointerover":
this.#handlePointerOver(event);
break;
case "pointerout":
this.#handlePointerOut(event);
break;
case "pointerup":
this.#handlePointerUp(event);
break;
case "pointerdown":
this.#handlePointerDown(event);
break;
default:
return false;
}
return true;
}
/* -------------------------------------------- */
/**
* A public method to cancel a current interaction workflow from this manager.
* @param {PIXI.FederatedEvent} [event] The event that initiates the cancellation
*/
cancel(event) {
const eventSystem = canvas.app.renderer.events;
const rootBoundary = eventSystem.rootBoundary;
const createEvent = !event;
if ( createEvent ) {
event = rootBoundary.createPointerEvent(eventSystem.pointer, "pointermove", this.target);
event.defaultPrevented = false;
event.path = null;
}
try {
const action = this._dragRight ? "dragRightCancel" : "dragLeftCancel";
const endState = this.state;
if ( endState <= this.states.HOVER ) return this.#debug(action, event, this.handlerOutcomes.SKIPPED);
// Dispatch a cancellation callback
if ( endState >= this.states.DRAG ) {
if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED);
}
// Continue a multi-click drag workflow if the default event was prevented in the callback
if ( event.defaultPrevented ) {
this.state = this.states.DRAG;
return this.#debug(action, event, this.handlerOutcomes.SKIPPED);
}
// Reset the interaction data and state and deactivate drag events
this.interactionData = {};
this.state = this.states.HOVER;
canvas.currentMouseManager = null;
clearTimeout(this.constructor.longPressTimeout);
this.#deactivateDragEvents();
this.#debug(action, event);
// Check hover state and hover out if necessary
if ( !rootBoundary.trackingData(event.pointerId).overTargets?.includes(this.target) ) {
this.#handlePointerOut(event);
}
} finally {
if ( createEvent ) rootBoundary.freeEvent(event);
}
}
/* -------------------------------------------- */
/**
* Display a debug message in the console (if mouse interaction debug is activated).
* @param {string} action Which action to display?
* @param {Event|PIXI.FederatedEvent} event Which event to display?
* @param {number} [outcome=this.handlerOutcomes.ACCEPTED] The handler outcome.
*/
#debug(action, event, outcome=this.handlerOutcomes.ACCEPTED) {
if ( CONFIG.debug.mouseInteraction ) {
const name = this.object.constructor.name;
const targetName = event.target?.constructor.name;
const {eventPhase, type, button} = event;
const state = Object.keys(this.states)[this.state.toString()];
let msg = `${name} | ${action} | state:${state} | target:${targetName} | phase:${eventPhase} | type:${type} | `
+ `btn:${button} | skipped:${outcome <= -2} | allowed:${outcome > -1} | handled:${outcome > 1}`;
console.debug(msg);
}
}
/* -------------------------------------------- */
/**
* Reset the mouse manager.
* @param {object} [options]
* @param {boolean} [options.interactionData=true] Reset the interaction data?
* @param {boolean} [options.state=true] Reset the state?
*/
reset({interactionData=true, state=true}={}) {
if ( CONFIG.debug.mouseInteraction ) {
console.debug(`${this.object.constructor.name} | Reset | interactionData:${interactionData} | state:${state}`);
}
if ( interactionData ) this.interactionData = {};
if ( state ) this.state = MouseInteractionManager.INTERACTION_STATES.NONE;
}
/* -------------------------------------------- */
/**
* Assign the interaction data to the event.
* @param {PIXI.FederatedEvent} event
*/
#assignInteractionData(event) {
this.interactionData.object = this.object;
event.interactionData = this.interactionData;
// Add deprecated event data references
for ( const k of Object.keys(this.interactionData) ) {
if ( event.hasOwnProperty(k) ) continue;
/**
* @deprecated since v11
* @ignore
*/
Object.defineProperty(event, k, {
get() {
const msg = `event.data.${k} is deprecated in favor of event.interactionData.${k}.`;
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this.interactionData[k];
},
set(value) {
const msg = `event.data.${k} is deprecated in favor of event.interactionData.${k}.`;
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
this.interactionData[k] = value;
}
});
}
}
}

View File

@@ -0,0 +1,59 @@
/**
* @typedef {object} PingOptions
* @property {number} [duration=900] The duration of the animation in milliseconds.
* @property {number} [size=128] The size of the ping graphic.
* @property {string} [color=#ff6400] The color of the ping graphic.
* @property {string} [name] The name for the ping animation to pass to {@link CanvasAnimation.animate}.
*/
/**
* A class to manage a user ping on the canvas.
* @param {Point} origin The canvas coordinates of the origin of the ping.
* @param {PingOptions} [options] Additional options to configure the ping animation.
*/
class Ping extends PIXI.Container {
constructor(origin, options={}) {
super();
this.x = origin.x;
this.y = origin.y;
this.options = foundry.utils.mergeObject({duration: 900, size: 128, color: "#ff6400"}, options);
this._color = Color.from(this.options.color);
}
/* -------------------------------------------- */
/** @inheritdoc */
destroy(options={}) {
options.children = true;
super.destroy(options);
}
/* -------------------------------------------- */
/**
* Start the ping animation.
* @returns {Promise<boolean>} Returns true if the animation ran to completion, false otherwise.
*/
async animate() {
const completed = await CanvasAnimation.animate([], {
context: this,
name: this.options.name,
duration: this.options.duration,
ontick: this._animateFrame.bind(this)
});
this.destroy();
return completed;
}
/* -------------------------------------------- */
/**
* On each tick, advance the animation.
* @param {number} dt The number of ms that elapsed since the previous frame.
* @param {CanvasAnimationData} animation The animation state.
* @protected
*/
_animateFrame(dt, animation) {
throw new Error("Subclasses of Ping must implement the _animateFrame method.");
}
}

View File

@@ -0,0 +1,122 @@
/**
* A type of ping that points to a specific location.
* @param {Point} origin The canvas coordinates of the origin of the ping.
* @param {PingOptions} [options] Additional options to configure the ping animation.
* @extends Ping
*/
class ChevronPing extends Ping {
constructor(origin, options={}) {
super(origin, options);
this._r = (this.options.size / 2) * .75;
// The inner ring is 3/4s the size of the outer.
this._rInner = this._r * .75;
// The animation is split into three stages. First, the chevron fades in and moves downwards, then the rings fade
// in, then everything fades out as the chevron moves back up.
// Store the 1/4 time slice.
this._t14 = this.options.duration * .25;
// Store the 1/2 time slice.
this._t12 = this.options.duration * .5;
// Store the 3/4s time slice.
this._t34 = this._t14 * 3;
}
/**
* The path to the chevron texture.
* @type {string}
* @private
*/
static _CHEVRON_PATH = "icons/pings/chevron.webp";
/* -------------------------------------------- */
/** @inheritdoc */
async animate() {
this.removeChildren();
this.addChild(...this._createRings());
this._chevron = await this._loadChevron();
this.addChild(this._chevron);
return super.animate();
}
/* -------------------------------------------- */
/** @inheritdoc */
_animateFrame(dt, animation) {
const { time } = animation;
if ( time < this._t14 ) {
// Normalise t between 0 and 1.
const t = time / this._t14;
// Apply easing function.
const dy = CanvasAnimation.easeOutCircle(t);
this._chevron.y = this._y + (this._h2 * dy);
this._chevron.alpha = time / this._t14;
} else if ( time < this._t34 ) {
const t = time - this._t14;
const a = t / this._t12;
this._drawRings(a);
} else {
const t = (time - this._t34) / this._t14;
const a = 1 - t;
const dy = CanvasAnimation.easeInCircle(t);
this._chevron.y = this._y + ((1 - dy) * this._h2);
this._chevron.alpha = a;
this._drawRings(a);
}
}
/* -------------------------------------------- */
/**
* Draw the outer and inner rings.
* @param {number} a The alpha.
* @private
*/
_drawRings(a) {
this._outer.clear();
this._inner.clear();
this._outer.lineStyle(6, this._color, a).drawCircle(0, 0, this._r);
this._inner.lineStyle(3, this._color, a).arc(0, 0, this._rInner, 0, Math.PI * 1.5);
}
/* -------------------------------------------- */
/**
* Load the chevron texture.
* @returns {Promise<PIXI.Sprite>}
* @private
*/
async _loadChevron() {
const texture = await TextureLoader.loader.loadTexture(ChevronPing._CHEVRON_PATH);
const chevron = PIXI.Sprite.from(texture);
chevron.tint = this._color;
const w = this.options.size;
const h = (texture.height / texture.width) * w;
chevron.width = w;
chevron.height = h;
// The chevron begins the animation slightly above the pinged point.
this._h2 = h / 2;
chevron.x = -(w / 2);
chevron.y = this._y = -h - this._h2;
return chevron;
}
/* -------------------------------------------- */
/**
* Draw the two rings that are used as part of the ping animation.
* @returns {PIXI.Graphics[]}
* @private
*/
_createRings() {
this._outer = new PIXI.Graphics();
this._inner = new PIXI.Graphics();
return [this._outer, this._inner];
}
}

View File

@@ -0,0 +1,216 @@
/**
* @typedef {PingOptions} PulsePingOptions
* @property {number} [rings=3] The number of rings used in the animation.
* @property {string} [color2=#ffffff] The alternate color that the rings begin at. Use white for a 'flashing' effect.
*/
/**
* A type of ping that produces a pulsing animation.
* @param {Point} origin The canvas coordinates of the origin of the ping.
* @param {PulsePingOptions} [options] Additional options to configure the ping animation.
* @extends Ping
*/
class PulsePing extends Ping {
constructor(origin, {rings=3, color2="#ffffff", ...options}={}) {
super(origin, {rings, color2, ...options});
this._color2 = game.settings.get("core", "photosensitiveMode") ? this._color : Color.from(color2);
// The radius is half the diameter.
this._r = this.options.size / 2;
// This is the radius that the rings initially begin at. It's set to 1/5th of the maximum radius.
this._r0 = this._r / 5;
this._computeTimeSlices();
}
/* -------------------------------------------- */
/**
* Initialize some time slice variables that will be used to control the animation.
*
* The animation for each ring can be separated into two consecutive stages.
* Stage 1: Fade in a white ring with radius r0.
* Stage 2: Expand radius outward. While the radius is expanding outward, we have two additional, consecutive
* animations:
* Stage 2.1: Transition color from white to the configured color.
* Stage 2.2: Fade out.
* 1/5th of the animation time is allocated to Stage 1. 4/5ths are allocated to Stage 2. Of those 4/5ths, 2/5ths
* are allocated to Stage 2.1, and 2/5ths are allocated to Stage 2.2.
* @private
*/
_computeTimeSlices() {
// We divide up the total duration of the animation into rings + 1 time slices. Ring animations are staggered by 1
// slice, and last for a total of 2 slices each. This uses up the full duration and creates the ripple effect.
this._timeSlice = this.options.duration / (this.options.rings + 1);
this._timeSlice2 = this._timeSlice * 2;
// Store the 1/5th time slice for Stage 1.
this._timeSlice15 = this._timeSlice2 / 5;
// Store the 2/5ths time slice for the subdivisions of Stage 2.
this._timeSlice25 = this._timeSlice15 * 2;
// Store the 4/5ths time slice for Stage 2.
this._timeSlice45 = this._timeSlice25 * 2;
}
/* -------------------------------------------- */
/** @inheritdoc */
async animate() {
// Draw rings.
this.removeChildren();
for ( let i = 0; i < this.options.rings; i++ ) {
this.addChild(new PIXI.Graphics());
}
// Add a blur filter to soften the sharp edges of the shape.
const f = new PIXI.BlurFilter(2);
f.padding = this.options.size;
this.filters = [f];
return super.animate();
}
/* -------------------------------------------- */
/** @inheritdoc */
_animateFrame(dt, animation) {
const { time } = animation;
for ( let i = 0; i < this.options.rings; i++ ) {
const ring = this.children[i];
// Offset each ring by 1 time slice.
const tMin = this._timeSlice * i;
// Each ring gets 2 time slices to complete its full animation.
const tMax = tMin + this._timeSlice2;
// If it's not time for this ring to animate, do nothing.
if ( (time < tMin) || (time >= tMax) ) continue;
// Normalise our t.
let t = time - tMin;
ring.clear();
if ( t < this._timeSlice15 ) {
// Stage 1. Fade in a white ring of radius r0.
const a = t / this._timeSlice15;
this._drawShape(ring, this._color2, a, this._r0);
} else {
// Stage 2. Expand radius, transition color, and fade out. Re-normalize t for Stage 2.
t -= this._timeSlice15;
const dr = this._r / this._timeSlice45;
const r = this._r0 + (t * dr);
const c0 = this._color;
const c1 = this._color2;
const c = t <= this._timeSlice25 ? this._colorTransition(c0, c1, this._timeSlice25, t) : c0;
const ta = Math.max(0, t - this._timeSlice25);
const a = 1 - (ta / this._timeSlice25);
this._drawShape(ring, c, a, r);
}
}
}
/* -------------------------------------------- */
/**
* Transition linearly from one color to another.
* @param {Color} from The color to transition from.
* @param {Color} to The color to transition to.
* @param {number} duration The length of the transition in milliseconds.
* @param {number} t The current time along the duration.
* @returns {number} The incremental color between from and to.
* @private
*/
_colorTransition(from, to, duration, t) {
const d = t / duration;
const rgbFrom = from.rgb;
const rgbTo = to.rgb;
return Color.fromRGB(rgbFrom.map((c, i) => {
const diff = rgbTo[i] - c;
return c + (d * diff);
}));
}
/* -------------------------------------------- */
/**
* Draw the shape for this ping.
* @param {PIXI.Graphics} g The graphics object to draw to.
* @param {number} color The color of the shape.
* @param {number} alpha The alpha of the shape.
* @param {number} size The size of the shape to draw.
* @protected
*/
_drawShape(g, color, alpha, size) {
g.lineStyle({color, alpha, width: 6, cap: PIXI.LINE_CAP.ROUND, join: PIXI.LINE_JOIN.BEVEL});
g.drawCircle(0, 0, size);
}
}
/**
* A type of ping that produces an arrow pointing in a given direction.
* @property {PIXI.Point} origin The canvas coordinates of the origin of the ping. This becomes the arrow's
* tip.
* @property {PulsePingOptions} [options] Additional options to configure the ping animation.
* @property {number} [options.rotation=0] The angle of the arrow in radians.
* @extends PulsePing
*/
class ArrowPing extends PulsePing {
constructor(origin, {rotation=0, ...options}={}) {
super(origin, options);
this.rotation = Math.normalizeRadians(rotation + (Math.PI * 1.5));
}
/* -------------------------------------------- */
/** @inheritdoc */
_drawShape(g, color, alpha, size) {
g.lineStyle({color, alpha, width: 6, cap: PIXI.LINE_CAP.ROUND, join: PIXI.LINE_JOIN.BEVEL});
const half = size / 2;
const x = -half;
const y = -size;
g.moveTo(x, y)
.lineTo(0, 0)
.lineTo(half, y)
.lineTo(0, -half)
.lineTo(x, y);
}
}
/**
* A type of ping that produces a pulse warning sign animation.
* @param {PIXI.Point} origin The canvas coordinates of the origin of the ping.
* @param {PulsePingOptions} [options] Additional options to configure the ping animation.
* @extends PulsePing
*/
class AlertPing extends PulsePing {
constructor(origin, {color="#ff0000", ...options}={}) {
super(origin, {color, ...options});
this._r = this.options.size;
}
/* -------------------------------------------- */
/** @inheritdoc */
_drawShape(g, color, alpha, size) {
// Draw a chamfered triangle.
g.lineStyle({color, alpha, width: 6, cap: PIXI.LINE_CAP.ROUND, join: PIXI.LINE_JOIN.BEVEL});
const half = size / 2;
const chamfer = size / 10;
const chamfer2 = chamfer / 2;
const x = -half;
const y = -(size / 3);
g.moveTo(x+chamfer, y)
.lineTo(x+size-chamfer, y)
.lineTo(x+size, y+chamfer)
.lineTo(x+half+chamfer2, y+size-chamfer)
.lineTo(x+half-chamfer2, y+size-chamfer)
.lineTo(x, y+chamfer)
.lineTo(x+chamfer, y);
}
}

View File

@@ -0,0 +1,189 @@
/**
* @typedef {Object} RenderFlag
* @property {string[]} propagate Activating this flag also sets these flags to true
* @property {string[]} reset Activating this flag resets these flags to false
* @property {object} [deprecated] Is this flag deprecated? The deprecation options are passed to
* logCompatibilityWarning. The deprectation message is auto-generated
* unless message is passed with the options.
* By default the message is logged only once.
*/
/**
* A data structure for tracking a set of boolean status flags.
* This is a restricted set which can only accept flag values which are pre-defined.
* @param {Record<string, RenderFlag>} flags An object which defines the flags which are supported for tracking
* @param {object} [config] Optional configuration
* @param {RenderFlagObject} [config.object] The object which owns this RenderFlags instance
* @param {number} [config.priority] The ticker priority at which these render flags are handled
*/
class RenderFlags extends Set {
constructor(flags={}, {object, priority=PIXI.UPDATE_PRIORITY.OBJECTS}={}) {
super([]);
for ( const cfg of Object.values(flags) ) {
cfg.propagate ||= [];
cfg.reset ||= [];
}
Object.defineProperties(this, {
/**
* The flags tracked by this data structure.
* @type {Record<string, RenderFlag>}
*/
flags: {value: Object.freeze(flags), enumerable: false, writable: false},
/**
* The RenderFlagObject instance which owns this set of RenderFlags
* @type {RenderFlagObject}
*/
object: {value: object, enumerable: false, writable: false},
/**
* The update priority when these render flags are applied.
* Valid options are OBJECTS or PERCEPTION.
* @type {string}
*/
priority: {value: priority, enumerable: false, writable: false}
});
}
/* -------------------------------------------- */
/**
* @inheritDoc
* @returns {Record<string, boolean>} The flags which were previously set that have been cleared.
*/
clear() {
// Record which flags were previously active
const flags = {};
for ( const flag of this ) {
flags[flag] = true;
}
// Empty the set
super.clear();
// Remove the object from the pending queue
if ( this.object ) canvas.pendingRenderFlags[this.priority].delete(this.object);
return flags;
}
/* -------------------------------------------- */
/**
* Allow for handling one single flag at a time.
* This function returns whether the flag needs to be handled and removes it from the pending set.
* @param {string} flag
* @returns {boolean}
*/
handle(flag) {
const active = this.has(flag);
this.delete(flag);
return active;
}
/* -------------------------------------------- */
/**
* Activate certain flags, also toggling propagation and reset behaviors
* @param {Record<string, boolean>} changes
*/
set(changes) {
const seen = new Set();
for ( const [flag, value] of Object.entries(changes) ) {
this.#set(flag, value, seen);
}
if ( this.object ) canvas.pendingRenderFlags[this.priority].add(this.object);
}
/* -------------------------------------------- */
/**
* Recursively set a flag.
* This method applies propagation or reset behaviors when flags are assigned.
* @param {string} flag
* @param {boolean} value
* @param {Set<string>} seen
*/
#set(flag, value, seen) {
if ( seen.has(flag) || !value ) return;
seen.add(flag);
const cfg = this.flags[flag];
if ( !cfg ) throw new Error(`"${flag}" is not defined as a supported RenderFlag option.`);
if ( cfg.deprecated ) this.#logDreprecationWarning(flag);
if ( !cfg.alias ) this.add(flag);
for ( const r of cfg.reset ) this.delete(r);
for ( const p of cfg.propagate ) this.#set(p, true, seen);
}
/* -------------------------------------------- */
/**
* Log the deprecation warning of the flag.
* @param {string} flag
*/
#logDreprecationWarning(flag) {
const cfg = this.flags[flag];
if ( !cfg.deprecated ) throw new Error(`The RenderFlag "${flag}" is not deprecated`);
let {message, ...options} = cfg.deprecated;
if ( !message ) {
message = `The RenderFlag "${flag}"`;
if ( this.object ) message += ` of ${this.object.constructor.name}`;
message += " is deprecated";
if ( cfg.propagate.length === 0 ) message += " without replacement.";
else if ( cfg.propagate.length === 1 ) message += ` in favor of ${cfg.propagate[0]}.`;
else message += `. Use ${cfg.propagate.slice(0, -1).join(", ")} and/or ${cfg.propagate.at(-1)} instead.`;
}
options.once ??= true;
foundry.utils.logCompatibilityWarning(message, options);
}
}
/* -------------------------------------------- */
/**
* Add RenderFlags functionality to some other object.
* This mixin standardizes the interface for such functionality.
* @param {typeof PIXI.DisplayObject|typeof Object} Base The base class being mixed. Normally a PIXI.DisplayObject
* @returns {typeof RenderFlagObject} The mixed class definition
*/
function RenderFlagsMixin(Base) {
return class RenderFlagObject extends Base {
constructor(...args) {
super(...args);
this.renderFlags = new RenderFlags(this.constructor.RENDER_FLAGS, {
object: this,
priority: this.constructor.RENDER_FLAG_PRIORITY
});
}
/**
* Configure the render flags used for this class.
* @type {Record<string, RenderFlag>}
*/
static RENDER_FLAGS = {};
/**
* The ticker priority when RenderFlags of this class are handled.
* Valid values are OBJECTS or PERCEPTION.
* @type {string}
*/
static RENDER_FLAG_PRIORITY = "OBJECTS";
/**
* Status flags which are applied at render-time to update the PlaceableObject.
* If an object defines RenderFlags, it should at least include flags for "redraw" and "refresh".
* @type {RenderFlags}
*/
renderFlags;
/**
* Apply any current render flags, clearing the renderFlags set.
* Subclasses should override this method to define behavior.
*/
applyRenderFlags() {
this.renderFlags.clear();
}
};
}
/* -------------------------------------------- */

View File

@@ -0,0 +1,97 @@
class ResizeHandle extends PIXI.Graphics {
constructor(offset, handlers={}) {
super();
this.offset = offset;
this.handlers = handlers;
this.lineStyle(4, 0x000000, 1.0).beginFill(0xFF9829, 1.0).drawCircle(0, 0, 10).endFill();
this.cursor = "pointer";
}
/**
* Track whether the handle is being actively used for a drag workflow
* @type {boolean}
*/
active = false;
/* -------------------------------------------- */
refresh(bounds) {
this.position.set(bounds.x + (bounds.width * this.offset[0]), bounds.y + (bounds.height * this.offset[1]));
this.hitArea = new PIXI.Rectangle(-16, -16, 32, 32); // Make the handle easier to grab
}
/* -------------------------------------------- */
updateDimensions(current, origin, destination, {aspectRatio=null}={}) {
// Identify the change in dimensions
const dx = destination.x - origin.x;
const dy = destination.y - origin.y;
// Determine the new width and the new height
let width = Math.max(origin.width + dx, 24);
let height = Math.max(origin.height + dy, 24);
// Constrain the aspect ratio
if ( aspectRatio ) {
if ( width >= height ) width = height * aspectRatio;
else height = width / aspectRatio;
}
// Adjust the final points
return {
x: current.x,
y: current.y,
width: width * Math.sign(current.width),
height: height * Math.sign(current.height)
};
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
activateListeners() {
this.off("pointerover").off("pointerout").off("pointerdown")
.on("pointerover", this._onHoverIn.bind(this))
.on("pointerout", this._onHoverOut.bind(this))
.on("pointerdown", this._onMouseDown.bind(this));
this.eventMode = "static";
}
/* -------------------------------------------- */
/**
* Handle mouse-over event on a control handle
* @param {PIXI.FederatedEvent} event The mouseover event
* @protected
*/
_onHoverIn(event) {
const handle = event.target;
handle.scale.set(1.5, 1.5);
}
/* -------------------------------------------- */
/**
* Handle mouse-out event on a control handle
* @param {PIXI.FederatedEvent} event The mouseout event
* @protected
*/
_onHoverOut(event) {
const handle = event.target;
handle.scale.set(1.0, 1.0);
}
/* -------------------------------------------- */
/**
* When we start a drag event - create a preview copy of the Tile for re-positioning
* @param {PIXI.FederatedEvent} event The mousedown event
* @protected
*/
_onMouseDown(event) {
if ( this.handlers.canDrag && !this.handlers.canDrag() ) return;
this.active = true;
}
}

View File

@@ -0,0 +1,52 @@
/**
* A subclass of Set which manages the Token ids which the User has targeted.
* @extends {Set}
* @see User#targets
*/
class UserTargets extends Set {
constructor(user) {
super();
if ( user.targets ) throw new Error(`User ${user.id} already has a targets set defined`);
this.user = user;
}
/**
* Return the Token IDs which are user targets
* @type {string[]}
*/
get ids() {
return Array.from(this).map(t => t.id);
}
/** @override */
add(token) {
if ( this.has(token) ) return this;
super.add(token);
this.#hook(token, true);
return this;
}
/** @override */
clear() {
const tokens = Array.from(this);
super.clear();
tokens.forEach(t => this.#hook(t, false));
}
/** @override */
delete(token) {
if ( !this.has(token) ) return false;
super.delete(token);
this.#hook(token, false);
return true;
}
/**
* Dispatch the targetToken hook whenever the user's target set changes.
* @param {Token} token The targeted Token
* @param {boolean} targeted Whether the Token has been targeted or untargeted
*/
#hook(token, targeted) {
Hooks.callAll("targetToken", this.user, token, targeted);
}
}

View File

@@ -0,0 +1,512 @@
/**
* A Loader class which helps with loading video and image textures.
*/
class TextureLoader {
/**
* The duration in milliseconds for which a texture will remain cached
* @type {number}
*/
static CACHE_TTL = 1000 * 60 * 15;
/**
* Record the timestamps when each asset path is retrieved from cache.
* @type {Map<PIXI.BaseTexture|PIXI.Spritesheet,{src:string,time:number}>}
*/
static #cacheTime = new Map();
/**
* A mapping of cached texture data
* @type {WeakMap<PIXI.BaseTexture,Map<string, TextureAlphaData>>}
*/
static #textureDataMap = new WeakMap();
/**
* Create a fixed retry string to use for CORS retries.
* @type {string}
*/
static #retryString = Date.now().toString();
/**
* To know if the basis transcoder has been initialized
* @type {boolean}
*/
static #basisTranscoderInitialized = false;
/* -------------------------------------------- */
/**
* Initialize the basis transcoder for PIXI.Assets
* @returns {Promise<*>}
*/
static async initializeBasisTranscoder() {
if ( this.#basisTranscoderInitialized ) return;
this.#basisTranscoderInitialized = true;
return await PIXI.TranscoderWorker.loadTranscoder(
"scripts/basis_transcoder.js",
"scripts/basis_transcoder.wasm"
);
}
/* -------------------------------------------- */
/**
* Check if a source has a text file extension.
* @param {string} src The source.
* @returns {boolean} If the source has a text extension or not.
*/
static hasTextExtension(src) {
let rgx = new RegExp(`(\\.${Object.keys(CONST.TEXT_FILE_EXTENSIONS).join("|\\.")})(\\?.*)?`, "i");
return rgx.test(src);
}
/* -------------------------------------------- */
/**
* @typedef {Object} TextureAlphaData
* @property {number} width The width of the (downscaled) texture.
* @property {number} height The height of the (downscaled) texture.
* @property {number} minX The minimum x-coordinate with alpha > 0.
* @property {number} minY The minimum y-coordinate with alpha > 0.
* @property {number} maxX The maximum x-coordinate with alpha > 0 plus 1.
* @property {number} maxY The maximum y-coordinate with alpha > 0 plus 1.
* @property {Uint8Array} data The array containing the texture alpha values (0-255)
* with the dimensions (maxX-minX)×(maxY-minY).
*/
/**
* Use the texture to create a cached mapping of pixel alpha and cache it.
* Cache the bounding box of non-transparent pixels for the un-rotated shape.
* @param {PIXI.Texture} texture The provided texture.
* @param {number} [resolution=1] Resolution of the texture data output.
* @returns {TextureAlphaData|undefined} The texture data if the texture is valid, else undefined.
*/
static getTextureAlphaData(texture, resolution=1) {
// If texture is not present
if ( !texture?.valid ) return;
// Get the base tex and the stringified frame + width/height
const width = Math.ceil(Math.round(texture.width * texture.resolution) * resolution);
const height = Math.ceil(Math.round(texture.height * texture.resolution) * resolution);
const baseTex = texture.baseTexture;
const frame = texture.frame;
const sframe = `${frame.x},${frame.y},${frame.width},${frame.height},${width},${height}`;
// Get frameDataMap and textureData if they exist
let textureData;
let frameDataMap = this.#textureDataMap.get(baseTex);
if ( frameDataMap ) textureData = frameDataMap.get(sframe);
// If texture data exists for the baseTex/frame couple, we return it
if ( textureData ) return textureData;
else textureData = {};
// Create a temporary Sprite using the provided texture
const sprite = new PIXI.Sprite(texture);
sprite.width = textureData.width = width;
sprite.height = textureData.height = height;
sprite.anchor.set(0, 0);
// Create or update the alphaMap render texture
const tex = PIXI.RenderTexture.create({width: width, height: height});
canvas.app.renderer.render(sprite, {renderTexture: tex});
sprite.destroy(false);
const pixels = canvas.app.renderer.extract.pixels(tex);
tex.destroy(true);
// Trim pixels with zero alpha
let minX = width;
let minY = height;
let maxX = 0;
let maxY = 0;
for ( let i = 3, y = 0; y < height; y++ ) {
for ( let x = 0; x < width; x++, i += 4 ) {
const alpha = pixels[i];
if ( alpha === 0 ) continue;
if ( x < minX ) minX = x;
if ( x >= maxX ) maxX = x + 1;
if ( y < minY ) minY = y;
if ( y >= maxY ) maxY = y + 1;
}
}
// Special case when the whole texture is alpha 0
if ( minX > maxX ) minX = minY = maxX = maxY = 0;
// Set the bounds of the trimmed region
textureData.minX = minX;
textureData.minY = minY;
textureData.maxX = maxX;
textureData.maxY = maxY;
// Create new buffer for storing the alpha channel only
const data = textureData.data = new Uint8Array((maxX - minX) * (maxY - minY));
for ( let i = 0, y = minY; y < maxY; y++ ) {
for ( let x = minX; x < maxX; x++, i++ ) {
data[i] = pixels[(((width * y) + x) * 4) + 3];
}
}
// Saving the texture data
if ( !frameDataMap ) {
frameDataMap = new Map();
this.#textureDataMap.set(baseTex, frameDataMap);
}
frameDataMap.set(sframe, textureData);
return textureData;
}
/* -------------------------------------------- */
/**
* Load all the textures which are required for a particular Scene
* @param {Scene} scene The Scene to load
* @param {object} [options={}] Additional options that configure texture loading
* @param {boolean} [options.expireCache=true] Destroy other expired textures
* @param {boolean} [options.additionalSources=[]] Additional sources to load during canvas initialize
* @param {number} [options.maxConcurrent] The maximum number of textures that can be loaded concurrently
* @returns {Promise<void[]>}
*/
static loadSceneTextures(scene, {expireCache=true, additionalSources=[], maxConcurrent}={}) {
let toLoad = [];
// Scene background and foreground textures
if ( scene.background.src ) toLoad.push(scene.background.src);
if ( scene.foreground ) toLoad.push(scene.foreground);
if ( scene.fog.overlay ) toLoad.push(scene.fog.overlay);
// Tiles
toLoad = toLoad.concat(scene.tiles.reduce((arr, t) => {
if ( t.texture.src ) arr.push(t.texture.src);
return arr;
}, []));
// Tokens
toLoad.push(CONFIG.Token.ring.spritesheet);
toLoad = toLoad.concat(scene.tokens.reduce((arr, t) => {
if ( t.texture.src ) arr.push(t.texture.src);
if ( t.ring.enabled ) arr.push(t.ring.subject.texture);
return arr;
}, []));
// Control Icons
toLoad = toLoad.concat(Object.values(CONFIG.controlIcons));
// Status Effect textures
toLoad = toLoad.concat(CONFIG.statusEffects.map(e => e.img ?? /** @deprecated since v12 */ e.icon));
// Configured scene textures
toLoad.push(...Object.values(canvas.sceneTextures));
// Additional requested sources
toLoad.push(...additionalSources);
// Load files
const showName = scene.active || scene.visible;
const loadName = showName ? (scene.navName || scene.name) : "...";
return this.loader.load(toLoad, {
message: game.i18n.format("SCENES.Loading", {name: loadName}),
expireCache,
maxConcurrent
});
}
/* -------------------------------------------- */
/**
* Load an Array of provided source URL paths
* @param {string[]} sources The source URLs to load
* @param {object} [options={}] Additional options which modify loading
* @param {string} [options.message] The status message to display in the load bar
* @param {boolean} [options.expireCache=false] Expire other cached textures?
* @param {number} [options.maxConcurrent] The maximum number of textures that can be loaded concurrently.
* @param {boolean} [options.displayProgress] Display loading progress bar
* @returns {Promise<void[]>} A Promise which resolves once all textures are loaded
*/
async load(sources, {message, expireCache=false, maxConcurrent, displayProgress=true}={}) {
sources = new Set(sources);
const progress = {message: message, loaded: 0, failed: 0, total: sources.size, pct: 0};
console.groupCollapsed(`${vtt} | Loading ${sources.size} Assets`);
const loadTexture = async src => {
try {
await this.loadTexture(src);
if ( displayProgress ) TextureLoader.#onProgress(src, progress);
} catch(err) {
TextureLoader.#onError(src, progress, err);
}
};
const promises = [];
if ( maxConcurrent ) {
const semaphore = new foundry.utils.Semaphore(maxConcurrent);
for ( const src of sources ) promises.push(semaphore.add(loadTexture, src));
} else {
for ( const src of sources ) promises.push(loadTexture(src));
}
await Promise.allSettled(promises);
console.groupEnd();
if ( expireCache ) await this.expireCache();
}
/* -------------------------------------------- */
/**
* Load a single texture or spritesheet on-demand from a given source URL path
* @param {string} src The source texture path to load
* @returns {Promise<PIXI.BaseTexture|PIXI.Spritesheet|null>} The loaded texture object
*/
async loadTexture(src) {
const loadAsset = async (src, bustCache=false) => {
if ( bustCache ) src = TextureLoader.getCacheBustURL(src);
if ( !src ) return null;
try {
return await PIXI.Assets.load(src);
} catch ( err ) {
if ( bustCache ) throw err;
return await loadAsset(src, true);
}
};
let asset = await loadAsset(src);
if ( !asset?.baseTexture?.valid ) return null;
if ( asset instanceof PIXI.Texture ) asset = asset.baseTexture;
this.setCache(src, asset);
return asset;
}
/* --------------------------------------------- */
/**
* Use the Fetch API to retrieve a resource and return a Blob instance for it.
* @param {string} src
* @param {object} [options] Options to configure the loading behaviour.
* @param {boolean} [options.bustCache=false] Append a cache-busting query parameter to the request.
* @returns {Promise<Blob>} A Blob containing the loaded data
*/
static async fetchResource(src, {bustCache=false}={}) {
const fail = `Failed to load texture ${src}`;
const req = bustCache ? TextureLoader.getCacheBustURL(src) : src;
if ( !req ) throw new Error(`${fail}: Invalid URL`);
let res;
try {
res = await fetch(req, {mode: "cors", credentials: "same-origin"});
} catch(err) {
// We may have encountered a common CORS limitation: https://bugs.chromium.org/p/chromium/issues/detail?id=409090
if ( !bustCache ) return this.fetchResource(src, {bustCache: true});
throw new Error(`${fail}: CORS failure`);
}
if ( !res.ok ) throw new Error(`${fail}: Server responded with ${res.status}`);
return res.blob();
}
/* -------------------------------------------- */
/**
* Log texture loading progress in the console and in the Scene loading bar
* @param {string} src The source URL being loaded
* @param {object} progress Loading progress
* @private
*/
static #onProgress(src, progress) {
progress.loaded++;
progress.pct = Math.round((progress.loaded + progress.failed) * 100 / progress.total);
SceneNavigation.displayProgressBar({label: progress.message, pct: progress.pct});
console.log(`Loaded ${src} (${progress.pct}%)`);
}
/* -------------------------------------------- */
/**
* Log failed texture loading
* @param {string} src The source URL being loaded
* @param {object} progress Loading progress
* @param {Error} error The error which occurred
* @private
*/
static #onError(src, progress, error) {
progress.failed++;
progress.pct = Math.round((progress.loaded + progress.failed) * 100 / progress.total);
SceneNavigation.displayProgressBar({label: progress.message, pct: progress.pct});
console.warn(`Loading failed for ${src} (${progress.pct}%): ${error.message}`);
}
/* -------------------------------------------- */
/* Cache Controls */
/* -------------------------------------------- */
/**
* Add an image or a sprite sheet url to the assets cache.
* @param {string} src The source URL.
* @param {PIXI.BaseTexture|PIXI.Spritesheet} asset The asset
*/
setCache(src, asset) {
TextureLoader.#cacheTime.set(asset, {src, time: Date.now()});
}
/* -------------------------------------------- */
/**
* Retrieve a texture or a sprite sheet from the assets cache
* @param {string} src The source URL
* @returns {PIXI.BaseTexture|PIXI.Spritesheet|null} The cached texture, a sprite sheet or undefined
*/
getCache(src) {
if ( !src ) return null;
if ( !PIXI.Assets.cache.has(src) ) src = TextureLoader.getCacheBustURL(src) || src;
let asset = PIXI.Assets.get(src);
if ( !asset?.baseTexture?.valid ) return null;
if ( asset instanceof PIXI.Texture ) asset = asset.baseTexture;
this.setCache(src, asset);
return asset;
}
/* -------------------------------------------- */
/**
* Expire and unload assets from the cache which have not been used for more than CACHE_TTL milliseconds.
*/
async expireCache() {
const promises = [];
const t = Date.now();
for ( const [asset, {src, time}] of TextureLoader.#cacheTime.entries() ) {
const baseTexture = asset instanceof PIXI.Spritesheet ? asset.baseTexture : asset;
if ( !baseTexture || baseTexture.destroyed ) {
TextureLoader.#cacheTime.delete(asset);
continue;
}
if ( (t - time) <= TextureLoader.CACHE_TTL ) continue;
console.log(`${vtt} | Expiring cached texture: ${src}`);
promises.push(PIXI.Assets.unload(src));
TextureLoader.#cacheTime.delete(asset);
}
await Promise.allSettled(promises);
}
/* -------------------------------------------- */
/**
* Return a URL with a cache-busting query parameter appended.
* @param {string} src The source URL being attempted
* @returns {string|boolean} The new URL, or false on a failure.
*/
static getCacheBustURL(src) {
const url = URL.parseSafe(src);
if ( !url ) return false;
if ( url.origin === window.location.origin ) return false;
url.searchParams.append("cors-retry", TextureLoader.#retryString);
return url.href;
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
async loadImageTexture(src) {
const warning = "TextureLoader#loadImageTexture is deprecated. Use TextureLoader#loadTexture instead.";
foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
return this.loadTexture(src);
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
async loadVideoTexture(src) {
const warning = "TextureLoader#loadVideoTexture is deprecated. Use TextureLoader#loadTexture instead.";
foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
return this.loadTexture(src);
}
/**
* @deprecated since v12
* @ignore
*/
static get textureBufferDataMap() {
const warning = "TextureLoader.textureBufferDataMap is deprecated without replacement. Use " +
"TextureLoader.getTextureAlphaData to create a texture data map and cache it automatically, or create your own" +
" caching system.";
foundry.utils.logCompatibilityWarning(warning, {since: 12, until: 14});
return this.#textureBufferDataMap;
}
/**
* @deprecated since v12
* @ignore
*/
static #textureBufferDataMap = new Map();
}
/**
* A global reference to the singleton texture loader
* @type {TextureLoader}
*/
TextureLoader.loader = new TextureLoader();
/* -------------------------------------------- */
/**
* Test whether a file source exists by performing a HEAD request against it
* @param {string} src The source URL or path to test
* @returns {Promise<boolean>} Does the file exist at the provided url?
*/
async function srcExists(src) {
return foundry.utils.fetchWithTimeout(src, { method: "HEAD" }).then(resp => {
return resp.status < 400;
}).catch(() => false);
}
/* -------------------------------------------- */
/**
* Get a single texture or sprite sheet from the cache.
* @param {string} src The texture path to load.
* @returns {PIXI.Texture|PIXI.Spritesheet|null} A texture, a sprite sheet or null if not found in cache.
*/
function getTexture(src) {
const asset = TextureLoader.loader.getCache(src);
const baseTexture = asset instanceof PIXI.Spritesheet ? asset.baseTexture : asset;
if ( !baseTexture?.valid ) return null;
return (asset instanceof PIXI.Spritesheet ? asset : new PIXI.Texture(asset));
}
/* -------------------------------------------- */
/**
* Load a single asset and return a Promise which resolves once the asset is ready to use
* @param {string} src The requested asset source
* @param {object} [options] Additional options which modify asset loading
* @param {string} [options.fallback] A fallback texture URL to use if the requested source is unavailable
* @returns {PIXI.Texture|PIXI.Spritesheet|null} The loaded Texture or sprite sheet,
* or null if loading failed with no fallback
*/
async function loadTexture(src, {fallback}={}) {
let asset;
let error;
try {
asset = await TextureLoader.loader.loadTexture(src);
const baseTexture = asset instanceof PIXI.Spritesheet ? asset.baseTexture : asset;
if ( !baseTexture?.valid ) error = new Error(`Invalid Asset ${src}`);
}
catch(err) {
err.message = `The requested asset ${src} could not be loaded: ${err.message}`;
error = err;
}
if ( error ) {
console.error(error);
if ( TextureLoader.hasTextExtension(src) ) return null; // No fallback for spritesheets
return fallback ? loadTexture(fallback) : null;
}
if ( asset instanceof PIXI.Spritesheet ) return asset;
return new PIXI.Texture(asset);
}

View File

@@ -0,0 +1,161 @@
/**
* A special class of Polygon which implements a limited angle of emission for a Point Source.
* The shape is defined by a point origin, radius, angle, and rotation.
* The shape is further customized by a configurable density which informs the approximation.
* An optional secondary externalRadius can be provided which adds supplementary visibility outside the primary angle.
*/
class LimitedAnglePolygon extends PIXI.Polygon {
constructor(origin, {radius, angle=360, rotation=0, density, externalRadius=0} = {}) {
super([]);
/**
* The origin point of the Polygon
* @type {Point}
*/
this.origin = origin;
/**
* The radius of the emitted cone.
* @type {number}
*/
this.radius = radius;
/**
* The angle of the Polygon in degrees.
* @type {number}
*/
this.angle = angle;
/**
* The direction of rotation at the center of the emitted angle in degrees.
* @type {number}
*/
this.rotation = rotation;
/**
* The density of rays which approximate the cone, defined as rays per PI.
* @type {number}
*/
this.density = density ?? PIXI.Circle.approximateVertexDensity(this.radius);
/**
* An optional "external radius" which is included in the polygon for the supplementary area outside the cone.
* @type {number}
*/
this.externalRadius = externalRadius;
/**
* The angle of the left (counter-clockwise) edge of the emitted cone in radians.
* @type {number}
*/
this.aMin = Math.normalizeRadians(Math.toRadians(this.rotation + 90 - (this.angle / 2)));
/**
* The angle of the right (clockwise) edge of the emitted cone in radians.
* @type {number}
*/
this.aMax = this.aMin + Math.toRadians(this.angle);
// Generate polygon points
this.#generatePoints();
}
/**
* The bounding box of the circle defined by the externalRadius, if any
* @type {PIXI.Rectangle}
*/
externalBounds;
/* -------------------------------------------- */
/**
* Generate the points of the LimitedAnglePolygon using the provided configuration parameters.
*/
#generatePoints() {
const {x, y} = this.origin;
// Construct polygon points for the primary angle
const primaryAngle = this.aMax - this.aMin;
const nPrimary = Math.ceil((primaryAngle * this.density) / (2 * Math.PI));
const dPrimary = primaryAngle / nPrimary;
for ( let i=0; i<=nPrimary; i++ ) {
const pad = Ray.fromAngle(x, y, this.aMin + (i * dPrimary), this.radius);
this.points.push(pad.B.x, pad.B.y);
}
// Add secondary angle
if ( this.externalRadius ) {
const secondaryAngle = (2 * Math.PI) - primaryAngle;
const nSecondary = Math.ceil((secondaryAngle * this.density) / (2 * Math.PI));
const dSecondary = secondaryAngle / nSecondary;
for ( let i=0; i<=nSecondary; i++ ) {
const pad = Ray.fromAngle(x, y, this.aMax + (i * dSecondary), this.externalRadius);
this.points.push(pad.B.x, pad.B.y);
}
this.externalBounds = (new PIXI.Circle(x, y, this.externalRadius)).getBounds();
}
// No secondary angle
else {
this.points.unshift(x, y);
this.points.push(x, y);
}
}
/* -------------------------------------------- */
/**
* Restrict the edges which should be included in a PointSourcePolygon based on this specialized shape.
* We use two tests to jointly keep or reject edges.
* 1. If this shape uses an externalRadius, keep edges which collide with the bounding box of that circle.
* 2. Keep edges which are contained within or collide with one of the primary angle boundary rays.
* @param {Point} a The first edge vertex
* @param {Point} b The second edge vertex
* @returns {boolean} Should the edge be included in the PointSourcePolygon computation?
* @internal
*/
_includeEdge(a, b) {
// 1. If this shape uses an externalRadius, keep edges which collide with the bounding box of that circle.
if ( this.externalBounds?.lineSegmentIntersects(a, b, {inside: true}) ) return true;
// 2. Keep edges which are contained within or collide with one of the primary angle boundary rays.
const roundPoint = p => ({x: Math.round(p.x), y: Math.round(p.y)});
const rMin = Ray.fromAngle(this.origin.x, this.origin.y, this.aMin, this.radius);
roundPoint(rMin.B);
const rMax = Ray.fromAngle(this.origin.x, this.origin.y, this.aMax, this.radius);
roundPoint(rMax.B);
// If either vertex is inside, keep the edge
if ( LimitedAnglePolygon.pointBetweenRays(a, rMin, rMax, this.angle) ) return true;
if ( LimitedAnglePolygon.pointBetweenRays(b, rMin, rMax, this.angle) ) return true;
// If both vertices are outside, test whether the edge collides with one (either) of the limiting rays
if ( foundry.utils.lineSegmentIntersects(rMin.A, rMin.B, a, b) ) return true;
if ( foundry.utils.lineSegmentIntersects(rMax.A, rMax.B, a, b) ) return true;
// Otherwise, the edge can be discarded
return false;
}
/* -------------------------------------------- */
/**
* Test whether a vertex lies between two boundary rays.
* If the angle is greater than 180, test for points between rMax and rMin (inverse).
* Otherwise, keep vertices that are between the rays directly.
* @param {Point} point The candidate point
* @param {PolygonRay} rMin The counter-clockwise bounding ray
* @param {PolygonRay} rMax The clockwise bounding ray
* @param {number} angle The angle being tested, in degrees
* @returns {boolean} Is the vertex between the two rays?
*/
static pointBetweenRays(point, rMin, rMax, angle) {
const ccw = foundry.utils.orient2dFast;
if ( angle > 180 ) {
const outside = (ccw(rMax.A, rMax.B, point) <= 0) && (ccw(rMin.A, rMin.B, point) >= 0);
return !outside;
}
return (ccw(rMin.A, rMin.B, point) <= 0) && (ccw(rMax.A, rMax.B, point) >= 0);
}
}

View File

@@ -0,0 +1,446 @@
// noinspection TypeScriptUMDGlobal
/**
* A helper class used to construct triangulated polygon meshes
* Allow to add padding and a specific depth value.
* @param {number[]|PIXI.Polygon} poly Closed polygon to be processed and converted to a mesh
* (array of points or PIXI Polygon)
* @param {object|{}} options Various options : normalizing, offsetting, add depth, ...
*/
class PolygonMesher {
constructor(poly, options = {}) {
this.options = {...this.constructor._defaultOptions, ...options};
const {normalize, x, y, radius, scale, offset} = this.options;
// Creating the scaled values
this.#scaled.sradius = radius * scale;
this.#scaled.sx = x * scale;
this.#scaled.sy = y * scale;
this.#scaled.soffset = offset * scale;
// Computing required number of pass (minimum 1)
this.#nbPass = Math.ceil(Math.abs(offset) / 3);
// Get points from poly param
const points = poly instanceof PIXI.Polygon ? poly.points : poly;
if ( !Array.isArray(points) ) {
throw new Error("You must provide a PIXI.Polygon or an array of vertices to the PolygonMesher constructor");
}
// Correcting normalize option if necessary. We can't normalize with a radius of 0.
if ( normalize && (radius === 0) ) this.options.normalize = false;
// Creating the mesh vertices
this.#computePolygonMesh(points);
}
/**
* Default options values
* @type {Record<string,boolean|number>}
*/
static _defaultOptions = {
offset: 0, // The position value in pixels
normalize: false, // Should the vertices be normalized?
x: 0, // The x origin
y: 0, // The y origin
radius: 0, // The radius
depthOuter: 0, // The depth value on the outer polygon
depthInner: 1, // The depth value on the inner(s) polygon(s)
scale: 10e8, // Constant multiplier to avoid floating point imprecision with ClipperLib
miterLimit: 7, // Distance of the miter limit, when sharp angles are cut during offsetting.
interleaved: false // Should the vertex data be interleaved into one VBO?
};
/* -------------------------------------------- */
/**
* Polygon mesh vertices
* @type {number[]}
*/
vertices = [];
/**
* Polygon mesh indices
* @type {number[]}
*/
indices = [];
/**
* Contains options to apply during the meshing process
* @type {Record<string,boolean|number>}
*/
options = {};
/**
* Contains some options values scaled by the constant factor
* @type {Record<string,number>}
* @private
*/
#scaled = {};
/**
* Polygon mesh geometry
* @type {PIXI.Geometry}
* @private
*/
#geometry = null;
/**
* Contain the polygon tree node object, containing the main forms and its holes and sub-polygons
* @type {{poly: number[], nPoly: number[], children: object[]}}
* @private
*/
#polygonNodeTree = null;
/**
* Contains the the number of offset passes required to compute the polygon
* @type {number}
* @private
*/
#nbPass;
/* -------------------------------------------- */
/* Polygon Mesher static helper methods */
/* -------------------------------------------- */
/**
* Convert a flat points array into a 2 dimensional ClipperLib path
* @param {number[]|PIXI.Polygon} poly PIXI.Polygon or points flat array.
* @param {number} [dimension=2] Dimension.
* @returns {number[]|undefined} The clipper lib path.
*/
static getClipperPathFromPoints(poly, dimension = 2) {
poly = poly instanceof PIXI.Polygon ? poly.points : poly;
// If points is not an array or if its dimension is 1, 0 or negative, it can't be translated to a path.
if ( !Array.isArray(poly) || dimension < 2 ) {
throw new Error("You must provide valid coordinates to create a path.");
}
const path = new ClipperLib.Path();
if ( poly.length <= 1 ) return path; // Returning an empty path if we have zero or one point.
for ( let i = 0; i < poly.length; i += dimension ) {
path.push(new ClipperLib.IntPoint(poly[i], poly[i + 1]));
}
return path;
}
/* -------------------------------------------- */
/* Polygon Mesher Methods */
/* -------------------------------------------- */
/**
* Create the polygon mesh
* @param {number[]} points
* @private
*/
#computePolygonMesh(points) {
if ( !points || points.length < 6 ) return;
this.#updateVertices(points);
this.#updatePolygonNodeTree();
}
/* -------------------------------------------- */
/**
* Update vertices and add depth
* @param {number[]} vertices
* @private
*/
#updateVertices(vertices) {
const {offset, depthOuter, scale} = this.options;
const z = (offset === 0 ? 1.0 : depthOuter);
for ( let i = 0; i < vertices.length; i += 2 ) {
const x = Math.round(vertices[i] * scale);
const y = Math.round(vertices[i + 1] * scale);
this.vertices.push(x, y, z);
}
}
/* -------------------------------------------- */
/**
* Create the polygon by generating the edges and the interior of the polygon if an offset != 0,
* and just activate a fast triangulation if offset = 0
* @private
*/
#updatePolygonNodeTree() {
// Initializing the polygon node tree
this.#polygonNodeTree = {poly: this.vertices, nPoly: this.#normalize(this.vertices), children: []};
// Computing offset only if necessary
if ( this.options.offset === 0 ) return this.#polygonNodeTree.fastTriangulation = true;
// Creating the offsetter ClipperLib object, and adding our polygon path to it.
const offsetter = new ClipperLib.ClipperOffset(this.options.miterLimit);
// Launching the offset computation
return this.#createOffsetPolygon(offsetter, this.#polygonNodeTree);
}
/* -------------------------------------------- */
/**
* Recursively create offset polygons in successive passes
* @param {ClipperLib.ClipperOffset} offsetter ClipperLib offsetter
* @param {object} node A polygon node object to offset
* @param {number} [pass=0] The pass number (initialized with 0 for the first call)
*/
#createOffsetPolygon(offsetter, node, pass = 0) {
// Time to stop recursion on this node branch?
if ( pass >= this.#nbPass ) return;
const path = PolygonMesher.getClipperPathFromPoints(node.poly, 3); // Converting polygon points to ClipperLib path
const passOffset = Math.round(this.#scaled.soffset / this.#nbPass); // Mapping the offset for this path
const depth = Math.mix(this.options.depthOuter, this.options.depthInner, (pass + 1) / this.#nbPass); // Computing depth according to the actual pass and maximum number of pass (linear interpolation)
// Executing the offset
const paths = new ClipperLib.Paths();
offsetter.AddPath(path, ClipperLib.JoinType.jtMiter, ClipperLib.EndType.etClosedPolygon);
offsetter.Execute(paths, passOffset);
offsetter.Clear();
// Verifying if we have pathes. If it's not the case, the area is too small to generate pathes with this offset.
// It's time to stop recursion on this node branch.
if ( !paths.length ) return;
// Incrementing the number of pass to know when recursive offset should stop
pass++;
// Creating offsets for children
for ( const path of paths ) {
const flat = this.#flattenVertices(path, depth);
const child = { poly: flat, nPoly: this.#normalize(flat), children: []};
node.children.push(child);
this.#createOffsetPolygon(offsetter, child, pass);
}
}
/* -------------------------------------------- */
/**
* Flatten a ClipperLib path to array of numbers
* @param {ClipperLib.IntPoint[]} path path to convert
* @param {number} depth depth to add to the flattened vertices
* @returns {number[]} flattened array of points
* @private
*/
#flattenVertices(path, depth) {
const flattened = [];
for ( const point of path ) {
flattened.push(point.X, point.Y, depth);
}
return flattened;
}
/* -------------------------------------------- */
/**
* Normalize polygon coordinates and put result into nPoly property.
* @param {number[]} poly the poly to normalize
* @returns {number[]} the normalized poly array
* @private
*/
#normalize(poly) {
if ( !this.options.normalize ) return [];
// Compute the normalized vertex
const {sx, sy, sradius} = this.#scaled;
const nPoly = [];
for ( let i = 0; i < poly.length; i+=3 ) {
const x = (poly[i] - sx) / sradius;
const y = (poly[i+1] - sy) / sradius;
nPoly.push(x, y, poly[i+2]);
}
return nPoly;
}
/* -------------------------------------------- */
/**
* Execute the triangulation to create indices
* @param {PIXI.Geometry} geometry A geometry to update
* @returns {PIXI.Geometry} The resulting geometry
*/
triangulate(geometry) {
this.#geometry = geometry;
// Can we draw at least one triangle (counting z now)? If not, update or create an empty geometry
if ( this.vertices.length < 9 ) return this.#emptyGeometry();
// Triangulate the mesh and create indices
if ( this.#polygonNodeTree.fastTriangulation ) this.#triangulateFast();
else this.#triangulateTree();
// Update the geometry
return this.#updateGeometry();
}
/* -------------------------------------------- */
/**
* Fast triangulation of the polygon node tree
* @private
*/
#triangulateFast() {
this.indices = PIXI.utils.earcut(this.vertices, null, 3);
if ( this.options.normalize ) {
this.vertices = this.#polygonNodeTree.nPoly;
}
}
/* -------------------------------------------- */
/**
* Recursive triangulation of the polygon node tree
* @private
*/
#triangulateTree() {
this.vertices = [];
this.indices = this.#triangulateNode(this.#polygonNodeTree);
}
/* -------------------------------------------- */
/**
* Triangulate a node and its children recursively to compose a mesh with multiple levels of depth
* @param {object} node The polygon node tree to triangulate
* @param {number[]} [indices=[]] An optional array to receive indices (used for recursivity)
* @returns {number[]} An array of indices, result of the triangulation
*/
#triangulateNode(node, indices = []) {
const {normalize} = this.options;
const vert = [];
const polyLength = node.poly.length / 3;
const hasChildren = !!node.children.length;
vert.push(...node.poly);
// If the node is the outer hull (beginning polygon), it has a position of 0 into the vertices array.
if ( !node.position ) {
node.position = 0;
this.vertices.push(...(normalize ? node.nPoly : node.poly));
}
// If the polygon has no children, it is an interior polygon triangulated in the fast way. Returning here.
if ( !hasChildren ) {
indices.push(...(PIXI.utils.earcut(vert, null, 3).map(v => v + node.position)));
return indices;
}
let holePosition = polyLength;
let holes = [];
let holeGroupPosition = 0;
for ( const nodeChild of node.children ) {
holes.push(holePosition);
nodeChild.position = (this.vertices.length / 3);
if ( !holeGroupPosition ) holeGroupPosition = nodeChild.position; // The position of the holes as a contiguous group.
holePosition += (nodeChild.poly.length / 3);
vert.push(...nodeChild.poly);
this.vertices.push(...(normalize ? nodeChild.nPoly : nodeChild.poly));
}
// We need to shift the result of the indices, to match indices as it is saved in the vertices.
// We are using earcutEdges to enforce links between the outer and inner(s) polygons.
const holeGroupShift = holeGroupPosition - polyLength;
indices.push(...(earcut.earcutEdges(vert, holes).map(v => {
if ( v < polyLength ) return v + node.position;
else return v + holeGroupShift;
})));
// Triangulating children
for ( const nodeChild of node.children ) {
this.#triangulateNode(nodeChild, indices);
}
return indices;
}
/* -------------------------------------------- */
/**
* Updating or creating the PIXI.Geometry that will be used by the mesh
* @private
*/
#updateGeometry() {
const {interleaved, normalize, scale} = this.options;
// Unscale non normalized vertices
if ( !normalize ) {
for ( let i = 0; i < this.vertices.length; i+=3 ) {
this.vertices[i] /= scale;
this.vertices[i+1] /= scale;
}
}
// If VBO shouldn't be interleaved, we create a separate array for vertices and depth
let vertices; let depth;
if ( !interleaved ) {
vertices = [];
depth = [];
for ( let i = 0; i < this.vertices.length; i+=3 ) {
vertices.push(this.vertices[i], this.vertices[i+1]);
depth.push(this.vertices[i+2]);
}
}
else vertices = this.vertices;
if ( this.#geometry ) {
const vertBuffer = this.#geometry.getBuffer("aVertexPosition");
vertBuffer.update(new Float32Array(vertices));
const indicesBuffer = this.#geometry.getIndex();
indicesBuffer.update(new Uint16Array(this.indices));
if ( !interleaved ) {
const depthBuffer = this.#geometry.getBuffer("aDepthValue");
depthBuffer.update(new Float32Array(depth));
}
}
else this.#geometry = this.#createGeometry(vertices, depth);
return this.#geometry;
}
/* -------------------------------------------- */
/**
* Empty the geometry, or if geometry is null, create an empty geometry.
* @private
*/
#emptyGeometry() {
const {interleaved} = this.options;
// Empty the current geometry if it exists
if ( this.#geometry ) {
const vertBuffer = this.#geometry.getBuffer("aVertexPosition");
vertBuffer.update(new Float32Array([0, 0]));
const indicesBuffer = this.#geometry.getIndex();
indicesBuffer.update(new Uint16Array([0, 0]));
if ( !interleaved ) {
const depthBuffer = this.#geometry.getBuffer("aDepthValue");
depthBuffer.update(new Float32Array([0]));
}
}
// Create an empty geometry otherwise
else if ( interleaved ) {
// Interleaved version
return new PIXI.Geometry().addAttribute("aVertexPosition", [0, 0, 0], 3).addIndex([0, 0]);
}
else {
this.#geometry = new PIXI.Geometry().addAttribute("aVertexPosition", [0, 0], 2)
.addAttribute("aTextureCoord", [0, 0, 0, 1, 1, 1, 1, 0], 2)
.addAttribute("aDepthValue", [0], 1)
.addIndex([0, 0]);
}
return this.#geometry;
}
/* -------------------------------------------- */
/**
* Create a new Geometry from provided buffers
* @param {number[]} vertices provided vertices array (interleaved or not)
* @param {number[]} [depth=undefined] provided depth array
* @param {number[]} [indices=this.indices] provided indices array
* @returns {PIXI.Geometry} the new PIXI.Geometry constructed from the provided buffers
*/
#createGeometry(vertices, depth=undefined, indices=this.indices) {
if ( this.options.interleaved ) {
return new PIXI.Geometry().addAttribute("aVertexPosition", vertices, 3).addIndex(indices);
}
if ( !depth ) throw new Error("You must provide a separate depth buffer when the data is not interleaved.");
return new PIXI.Geometry()
.addAttribute("aVertexPosition", vertices, 2)
.addAttribute("aTextureCoord", [0, 0, 1, 0, 1, 1, 0, 1], 2)
.addAttribute("aDepthValue", depth, 1)
.addIndex(indices);
}
}

View File

@@ -0,0 +1,37 @@
/**
* An extension of the default PIXI.Text object which forces double resolution.
* At default resolution Text often looks blurry or fuzzy.
*/
class PreciseText extends PIXI.Text {
constructor(...args) {
super(...args);
this._autoResolution = false;
this._resolution = 2;
}
/**
* Prepare a TextStyle object which merges the canvas defaults with user-provided options
* @param {object} [options={}] Additional options merged with the default TextStyle
* @param {number} [options.anchor] A text anchor point from CONST.TEXT_ANCHOR_POINTS
* @returns {PIXI.TextStyle} The prepared TextStyle
*/
static getTextStyle({anchor, ...options}={}) {
const style = CONFIG.canvasTextStyle.clone();
for ( let [k, v] of Object.entries(options) ) {
if ( v !== undefined ) style[k] = v;
}
// Positioning
if ( !("align" in options) ) {
if ( anchor === CONST.TEXT_ANCHOR_POINTS.LEFT ) style.align = "right";
else if ( anchor === CONST.TEXT_ANCHOR_POINTS.RIGHT ) style.align = "left";
}
// Adaptive Stroke
if ( !("stroke" in options) ) {
const fill = Color.from(style.fill);
style.stroke = fill.hsv[2] > 0.6 ? 0x000000 : 0xFFFFFF;
}
return style;
}
}

View File

@@ -0,0 +1,248 @@
/**
* @typedef {Object} RayIntersection
* @property {number} x The x-coordinate of intersection
* @property {number} y The y-coordinate of intersection
* @property {number} t0 The proximity to the Ray origin, as a ratio of distance
* @property {number} t1 The proximity to the Ray destination, as a ratio of distance
*/
/**
* A ray for the purposes of computing sight and collision
* Given points A[x,y] and B[x,y]
*
* Slope-Intercept form:
* y = a + bx
* y = A.y + ((B.y - A.Y) / (B.x - A.x))x
*
* Parametric form:
* R(t) = (1-t)A + tB
*
* @param {Point} A The origin of the Ray
* @param {Point} B The destination of the Ray
*/
class Ray {
constructor(A, B) {
/**
* The origin point, {x, y}
* @type {Point}
*/
this.A = A;
/**
* The destination point, {x, y}
* @type {Point}
*/
this.B = B;
/**
* The origin y-coordinate
* @type {number}
*/
this.y0 = A.y;
/**
* The origin x-coordinate
* @type {number}
*/
this.x0 = A.x;
/**
* The horizontal distance of the ray, x1 - x0
* @type {number}
*/
this.dx = B.x - A.x;
/**
* The vertical distance of the ray, y1 - y0
* @type {number}
*/
this.dy = B.y - A.y;
/**
* The slope of the ray, dy over dx
* @type {number}
*/
this.slope = this.dy / this.dx;
}
/* -------------------------------------------- */
/* Attributes */
/* -------------------------------------------- */
/**
* The cached angle, computed lazily in Ray#angle
* @type {number}
* @private
*/
_angle = undefined;
/**
* The cached distance, computed lazily in Ray#distance
* @type {number}
* @private
*/
_distance = undefined;
/* -------------------------------------------- */
/**
* The normalized angle of the ray in radians on the range (-PI, PI).
* The angle is computed lazily (only if required) and cached.
* @type {number}
*/
get angle() {
if ( this._angle === undefined ) this._angle = Math.atan2(this.dy, this.dx);
return this._angle;
}
set angle(value) {
this._angle = Number(value);
}
/* -------------------------------------------- */
/**
* A normalized bounding rectangle that encompasses the Ray
* @type {PIXI.Rectangle}
*/
get bounds() {
return new PIXI.Rectangle(this.A.x, this.A.y, this.dx, this.dy).normalize();
}
/* -------------------------------------------- */
/**
* The distance (length) of the Ray in pixels.
* The distance is computed lazily (only if required) and cached.
* @type {number}
*/
get distance() {
if ( this._distance === undefined ) this._distance = Math.hypot(this.dx, this.dy);
return this._distance;
}
set distance(value) {
this._distance = Number(value);
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* A factory method to construct a Ray from an origin point, an angle, and a distance
* @param {number} x The origin x-coordinate
* @param {number} y The origin y-coordinate
* @param {number} radians The ray angle in radians
* @param {number} distance The distance of the ray in pixels
* @returns {Ray} The constructed Ray instance
*/
static fromAngle(x, y, radians, distance) {
const dx = Math.cos(radians);
const dy = Math.sin(radians);
const ray = this.fromArrays([x, y], [x + (dx * distance), y + (dy * distance)]);
ray._angle = Math.normalizeRadians(radians); // Store the angle, cheaper to compute here
ray._distance = distance; // Store the distance, cheaper to compute here
return ray;
}
/* -------------------------------------------- */
/**
* A factory method to construct a Ray from points in array format.
* @param {number[]} A The origin point [x,y]
* @param {number[]} B The destination point [x,y]
* @returns {Ray} The constructed Ray instance
*/
static fromArrays(A, B) {
return new this({x: A[0], y: A[1]}, {x: B[0], y: B[1]});
}
/* -------------------------------------------- */
/**
* Project the Array by some proportion of it's initial distance.
* Return the coordinates of that point along the path.
* @param {number} t The distance along the Ray
* @returns {Object} The coordinates of the projected point
*/
project(t) {
return {
x: this.A.x + (t * this.dx),
y: this.A.y + (t * this.dy)
};
}
/* -------------------------------------------- */
/**
* Create a Ray by projecting a certain distance towards a known point.
* @param {Point} origin The origin of the Ray
* @param {Point} point The point towards which to project
* @param {number} distance The distance of projection
* @returns {Ray}
*/
static towardsPoint(origin, point, distance) {
const dx = point.x - origin.x;
const dy = point.y - origin.y;
const t = distance / Math.hypot(dx, dy);
return new this(origin, {
x: origin.x + (t * dx),
y: origin.y + (t * dy)
});
}
/* -------------------------------------------- */
/**
* Create a Ray by projecting a certain squared-distance towards a known point.
* @param {Point} origin The origin of the Ray
* @param {Point} point The point towards which to project
* @param {number} distance2 The squared distance of projection
* @returns {Ray}
*/
static towardsPointSquared(origin, point, distance2) {
const dx = point.x - origin.x;
const dy = point.y - origin.y;
const t = Math.sqrt(distance2 / (Math.pow(dx, 2) + Math.pow(dy, 2)));
return new this(origin, {
x: origin.x + (t * dx),
y: origin.y + (t * dy)
});
}
/* -------------------------------------------- */
/**
* Reverse the direction of the Ray, returning a second Ray
* @returns {Ray}
*/
reverse() {
const r = new Ray(this.B, this.A);
r._distance = this._distance;
r._angle = Math.PI - this._angle;
return r;
}
/* -------------------------------------------- */
/**
* Create a new ray which uses the same origin point, but a slightly offset angle and distance
* @param {number} offset An offset in radians which modifies the angle of the original Ray
* @param {number} [distance] A distance the new ray should project, otherwise uses the same distance.
* @return {Ray} A new Ray with an offset angle
*/
shiftAngle(offset, distance) {
return this.constructor.fromAngle(this.x0, this.y0, this.angle + offset, distance || this.distance);
}
/* -------------------------------------------- */
/**
* Find the point I[x,y] and distance t* on ray R(t) which intersects another ray
* @see foundry.utils.lineLineIntersection
*/
intersectSegment(coords) {
return foundry.utils.lineSegmentIntersection(this.A, this.B, {x: coords[0], y: coords[1]}, {x: coords[2], y: coords[3]});
}
}

View File

@@ -0,0 +1,531 @@
/**
* @typedef {"light"|"sight"|"sound"|"move"|"universal"} PointSourcePolygonType
*/
/**
* @typedef {Object} PointSourcePolygonConfig
* @property {PointSourcePolygonType} type The type of polygon being computed
* @property {number} [angle=360] The angle of emission, if limited
* @property {number} [density] The desired density of padding rays, a number per PI
* @property {number} [radius] A limited radius of the resulting polygon
* @property {number} [rotation] The direction of facing, required if the angle is limited
* @property {number} [wallDirectionMode] Customize how wall direction of one-way walls is applied
* @property {boolean} [useThreshold=false] Compute the polygon with threshold wall constraints applied
* @property {boolean} [includeDarkness=false] Include edges coming from darkness sources
* @property {number} [priority] Priority when it comes to ignore edges from darkness sources
* @property {boolean} [debug] Display debugging visualization and logging for the polygon
* @property {PointSource} [source] The object (if any) that spawned this polygon.
* @property {Array<PIXI.Rectangle|PIXI.Circle|PIXI.Polygon>} [boundaryShapes] Limiting polygon boundary shapes
* @property {Readonly<boolean>} [useInnerBounds] Does this polygon use the Scene inner or outer bounding rectangle
* @property {Readonly<boolean>} [hasLimitedRadius] Does this polygon have a limited radius?
* @property {Readonly<boolean>} [hasLimitedAngle] Does this polygon have a limited angle?
* @property {Readonly<PIXI.Rectangle>} [boundingBox] The computed bounding box for the polygon
*/
/**
* An extension of the default PIXI.Polygon which is used to represent the line of sight for a point source.
* @extends {PIXI.Polygon}
*/
class PointSourcePolygon extends PIXI.Polygon {
/**
* Customize how wall direction of one-way walls is applied
* @enum {number}
*/
static WALL_DIRECTION_MODES = Object.freeze({
NORMAL: 0,
REVERSED: 1,
BOTH: 2
});
/**
* The rectangular bounds of this polygon
* @type {PIXI.Rectangle}
*/
bounds = new PIXI.Rectangle(0, 0, 0, 0);
/**
* The origin point of the source polygon.
* @type {Point}
*/
origin;
/**
* The configuration of this polygon.
* @type {PointSourcePolygonConfig}
*/
config = {};
/* -------------------------------------------- */
/**
* An indicator for whether this polygon is constrained by some boundary shape?
* @type {boolean}
*/
get isConstrained() {
return this.config.boundaryShapes.length > 0;
}
/* -------------------------------------------- */
/**
* Benchmark the performance of polygon computation for this source
* @param {number} iterations The number of test iterations to perform
* @param {Point} origin The origin point to benchmark
* @param {PointSourcePolygonConfig} config The polygon configuration to benchmark
*/
static benchmark(iterations, origin, config) {
const f = () => this.create(foundry.utils.deepClone(origin), foundry.utils.deepClone(config));
Object.defineProperty(f, "name", {value: `${this.name}.construct`, configurable: true});
return foundry.utils.benchmark(f, iterations);
}
/* -------------------------------------------- */
/**
* Compute the polygon given a point origin and radius
* @param {Point} origin The origin source point
* @param {PointSourcePolygonConfig} [config={}] Configuration options which customize the polygon computation
* @returns {PointSourcePolygon} The computed polygon instance
*/
static create(origin, config={}) {
const poly = new this();
poly.initialize(origin, config);
poly.compute();
return this.applyThresholdAttenuation(poly);
}
/* -------------------------------------------- */
/**
* Create a clone of this polygon.
* This overrides the default PIXI.Polygon#clone behavior.
* @override
* @returns {PointSourcePolygon} A cloned instance
*/
clone() {
const poly = new this.constructor([...this.points]);
poly.config = foundry.utils.deepClone(this.config);
poly.origin = {...this.origin};
poly.bounds = this.bounds.clone();
return poly;
}
/* -------------------------------------------- */
/* Polygon Computation */
/* -------------------------------------------- */
/**
* Compute the polygon using the origin and configuration options.
* @returns {PointSourcePolygon} The computed polygon
*/
compute() {
let t0 = performance.now();
const {x, y} = this.origin;
const {width, height} = canvas.dimensions;
const {angle, debug, radius} = this.config;
if ( !(x >= 0 && x <= width && y >= 0 && y <= height) ) {
console.warn("The polygon cannot be computed because its origin is out of the scene bounds.");
this.points.length = 0;
this.bounds = new PIXI.Rectangle(0, 0, 0, 0);
return this;
}
// Skip zero-angle or zero-radius polygons
if ( (radius === 0) || (angle === 0) ) {
this.points.length = 0;
this.bounds = new PIXI.Rectangle(0, 0, 0, 0);
return this;
}
// Clear the polygon bounds
this.bounds = undefined;
// Delegate computation to the implementation
this._compute();
// Cache the new polygon bounds
this.bounds = this.getBounds();
// Debugging and performance metrics
if ( debug ) {
let t1 = performance.now();
console.log(`Created ${this.constructor.name} in ${Math.round(t1 - t0)}ms`);
this.visualize();
}
return this;
}
/**
* Perform the implementation-specific computation
* @protected
*/
_compute() {
throw new Error("Each subclass of PointSourcePolygon must define its own _compute method");
}
/* -------------------------------------------- */
/**
* Customize the provided configuration object for this polygon type.
* @param {Point} origin The provided polygon origin
* @param {PointSourcePolygonConfig} config The provided configuration object
*/
initialize(origin, config) {
// Polygon origin
const o = this.origin = {x: Math.round(origin.x), y: Math.round(origin.y)};
// Configure radius
const cfg = this.config = config;
const maxR = canvas.dimensions.maxR;
cfg.radius = Math.min(cfg.radius ?? maxR, maxR);
cfg.hasLimitedRadius = (cfg.radius > 0) && (cfg.radius < maxR);
cfg.density = cfg.density ?? PIXI.Circle.approximateVertexDensity(cfg.radius);
// Configure angle
cfg.angle = cfg.angle ?? 360;
cfg.rotation = cfg.rotation ?? 0;
cfg.hasLimitedAngle = cfg.angle !== 360;
// Determine whether to use inner or outer bounds
const sceneRect = canvas.dimensions.sceneRect;
cfg.useInnerBounds ??= (cfg.type === "sight")
&& (o.x >= sceneRect.left && o.x <= sceneRect.right && o.y >= sceneRect.top && o.y <= sceneRect.bottom);
// Customize wall direction
cfg.wallDirectionMode ??= PointSourcePolygon.WALL_DIRECTION_MODES.NORMAL;
// Configure threshold
cfg.useThreshold ??= false;
// Configure darkness inclusion
cfg.includeDarkness ??= false;
// Boundary Shapes
cfg.boundaryShapes ||= [];
if ( cfg.hasLimitedAngle ) this.#configureLimitedAngle();
else if ( cfg.hasLimitedRadius ) this.#configureLimitedRadius();
if ( CONFIG.debug.polygons ) cfg.debug = true;
}
/* -------------------------------------------- */
/**
* Configure a limited angle and rotation into a triangular polygon boundary shape.
*/
#configureLimitedAngle() {
this.config.boundaryShapes.push(new LimitedAnglePolygon(this.origin, this.config));
}
/* -------------------------------------------- */
/**
* Configure a provided limited radius as a circular polygon boundary shape.
*/
#configureLimitedRadius() {
this.config.boundaryShapes.push(new PIXI.Circle(this.origin.x, this.origin.y, this.config.radius));
}
/* -------------------------------------------- */
/**
* Apply a constraining boundary shape to an existing PointSourcePolygon.
* Return a new instance of the polygon with the constraint applied.
* The new instance is only a "shallow clone", as it shares references to component properties with the original.
* @param {PIXI.Circle|PIXI.Rectangle|PIXI.Polygon} constraint The constraining boundary shape
* @param {object} [intersectionOptions] Options passed to the shape intersection method
* @returns {PointSourcePolygon} A new constrained polygon
*/
applyConstraint(constraint, intersectionOptions={}) {
// Enhance polygon configuration data using knowledge of the constraint
const poly = this.clone();
poly.config.boundaryShapes.push(constraint);
if ( (constraint instanceof PIXI.Circle) && (constraint.x === this.origin.x) && (constraint.y === this.origin.y) ) {
if ( poly.config.radius <= constraint.radius ) return poly;
poly.config.radius = constraint.radius;
poly.config.density = intersectionOptions.density ??= PIXI.Circle.approximateVertexDensity(constraint.radius);
if ( constraint.radius === 0 ) {
poly.points.length = 0;
poly.bounds.x = poly.bounds.y = poly.bounds.width = poly.bounds.height = 0;
return poly;
}
}
if ( !poly.points.length ) return poly;
// Apply the constraint and return the constrained polygon
const c = constraint.intersectPolygon(poly, intersectionOptions);
poly.points = c.points;
poly.bounds = poly.getBounds();
return poly;
}
/* -------------------------------------------- */
/** @inheritDoc */
contains(x, y) {
return this.bounds.contains(x, y) && super.contains(x, y);
}
/* -------------------------------------------- */
/* Polygon Boundary Constraints */
/* -------------------------------------------- */
/**
* Constrain polygon points by applying boundary shapes.
* @protected
*/
_constrainBoundaryShapes() {
const {density, boundaryShapes} = this.config;
if ( (this.points.length < 6) || !boundaryShapes.length ) return;
let constrained = this;
const intersectionOptions = {density, scalingFactor: 100};
for ( const c of boundaryShapes ) {
constrained = c.intersectPolygon(constrained, intersectionOptions);
}
this.points = constrained.points;
}
/* -------------------------------------------- */
/* Collision Testing */
/* -------------------------------------------- */
/**
* Test whether a Ray between the origin and destination points would collide with a boundary of this Polygon.
* A valid wall restriction type is compulsory and must be passed into the config options.
* @param {Point} origin An origin point
* @param {Point} destination A destination point
* @param {PointSourcePolygonConfig} config The configuration that defines a certain Polygon type
* @param {"any"|"all"|"closest"} [config.mode] The collision mode to test: "any", "all", or "closest"
* @returns {boolean|PolygonVertex|PolygonVertex[]|null} The collision result depends on the mode of the test:
* * any: returns a boolean for whether any collision occurred
* * all: returns a sorted array of PolygonVertex instances
* * closest: returns a PolygonVertex instance or null
*/
static testCollision(origin, destination, {mode="all", ...config}={}) {
if ( !CONST.WALL_RESTRICTION_TYPES.includes(config.type) ) {
throw new Error("A valid wall restriction type is required for testCollision.");
}
const poly = new this();
const ray = new Ray(origin, destination);
config.boundaryShapes ||= [];
config.boundaryShapes.push(ray.bounds);
poly.initialize(origin, config);
return poly._testCollision(ray, mode);
}
/* -------------------------------------------- */
/**
* Determine the set of collisions which occurs for a Ray.
* @param {Ray} ray The Ray to test
* @param {string} mode The collision mode being tested
* @returns {boolean|PolygonVertex|PolygonVertex[]|null} The collision test result
* @protected
* @abstract
*/
_testCollision(ray, mode) {
throw new Error(`The ${this.constructor.name} class must implement the _testCollision method`);
}
/* -------------------------------------------- */
/* Visualization and Debugging */
/* -------------------------------------------- */
/**
* Visualize the polygon, displaying its computed area and applied boundary shapes.
* @returns {PIXI.Graphics|undefined} The rendered debugging shape
*/
visualize() {
if ( !this.points.length ) return;
let dg = canvas.controls.debug;
dg.clear();
for ( const constraint of this.config.boundaryShapes ) {
dg.lineStyle(2, 0xFFFFFF, 1.0).beginFill(0xAAFF00).drawShape(constraint).endFill();
}
dg.lineStyle(2, 0xFFFFFF, 1.0).beginFill(0xFFAA99, 0.25).drawShape(this).endFill();
return dg;
}
/* -------------------------------------------- */
/**
* Determine if the shape is a complete circle.
* The config object must have an angle and a radius properties.
*/
isCompleteCircle() {
const { radius, angle, density } = this.config;
if ( radius === 0 ) return true;
if ( angle < 360 || (this.points.length !== (density * 2)) ) return false;
const shapeArea = Math.abs(this.signedArea());
const circleArea = (0.5 * density * Math.sin(2 * Math.PI / density)) * (radius ** 2);
return circleArea.almostEqual(shapeArea, 1e-5);
}
/* -------------------------------------------- */
/* Threshold Polygons */
/* -------------------------------------------- */
/**
* Augment a PointSourcePolygon by adding additional coverage for shapes permitted by threshold walls.
* @param {PointSourcePolygon} polygon The computed polygon
* @returns {PointSourcePolygon} The augmented polygon
*/
static applyThresholdAttenuation(polygon) {
const config = polygon.config;
if ( !config.useThreshold ) return polygon;
// Identify threshold walls and confirm whether threshold augmentation is required
const {nAttenuated, edges} = PointSourcePolygon.#getThresholdEdges(polygon.origin, config);
if ( !nAttenuated ) return polygon;
// Create attenuation shapes for all threshold walls
const attenuationShapes = PointSourcePolygon.#createThresholdShapes(polygon, edges);
if ( !attenuationShapes.length ) return polygon;
// Compute a second polygon which does not enforce threshold walls
const noThresholdPolygon = new this();
noThresholdPolygon.initialize(polygon.origin, {...config, useThreshold: false});
noThresholdPolygon.compute();
// Combine the unrestricted polygon with the attenuation shapes
const combined = PointSourcePolygon.#combineThresholdShapes(noThresholdPolygon, attenuationShapes);
polygon.points = combined.points;
polygon.bounds = polygon.getBounds();
return polygon;
}
/* -------------------------------------------- */
/**
* Identify edges in the Scene which include an active threshold.
* @param {Point} origin
* @param {object} config
* @returns {{edges: Edge[], nAttenuated: number}}
*/
static #getThresholdEdges(origin, config) {
let nAttenuated = 0;
const edges = [];
for ( const edge of canvas.edges.values() ) {
if ( edge.applyThreshold(config.type, origin, config.externalRadius) ) {
edges.push(edge);
nAttenuated += edge.threshold.attenuation;
}
}
return {edges, nAttenuated};
}
/* -------------------------------------------- */
/**
* @typedef {ClipperPoint[]} ClipperPoints
*/
/**
* For each threshold wall that this source passes through construct a shape representing the attenuated source.
* The attenuated shape is a circle with a radius modified by origin proximity to the threshold wall.
* Intersect the attenuated shape against the LOS with threshold walls considered.
* The result is the LOS for the attenuated light source.
* @param {PointSourcePolygon} thresholdPolygon The computed polygon with thresholds applied
* @param {Edge[]} edges The identified array of threshold walls
* @returns {ClipperPoints[]} The resulting array of intersected threshold shapes
*/
static #createThresholdShapes(thresholdPolygon, edges) {
const cps = thresholdPolygon.toClipperPoints();
const origin = thresholdPolygon.origin;
const {radius, externalRadius, type} = thresholdPolygon.config;
const shapes = [];
// Iterate over threshold walls
for ( const edge of edges ) {
let thresholdShape;
// Create attenuated shape
if ( edge.threshold.attenuation ) {
const r = PointSourcePolygon.#calculateThresholdAttenuation(edge, origin, radius, externalRadius, type);
if ( !r.outside ) continue;
thresholdShape = new PIXI.Circle(origin.x, origin.y, r.inside + r.outside);
}
// No attenuation, use the full circle
else thresholdShape = new PIXI.Circle(origin.x, origin.y, radius);
// Intersect each shape against the LOS
const ix = thresholdShape.intersectClipper(cps, {convertSolution: false});
if ( ix.length && ix[0].length > 2 ) shapes.push(ix[0]);
}
return shapes;
}
/* -------------------------------------------- */
/**
* Calculate the attenuation of the source as it passes through the threshold wall.
* The distance of perception through the threshold wall depends on proximity of the source from the wall.
* @param {Edge} edge The Edge for which this threshold applies
* @param {Point} origin Origin point on the canvas for this source
* @param {number} radius Radius to use for this source, before considering attenuation
* @param {number} externalRadius The external radius of the source
* @param {string} type Sense type for the source
* @returns {{inside: number, outside: number}} The inside and outside portions of the radius
*/
static #calculateThresholdAttenuation(edge, origin, radius, externalRadius, type) {
const d = edge.threshold?.[type];
if ( !d ) return { inside: radius, outside: radius };
const proximity = edge[type] === CONST.WALL_SENSE_TYPES.PROXIMITY;
// Find the closest point on the threshold wall to the source.
// Calculate the proportion of the source radius that is "inside" and "outside" the threshold wall.
const pt = foundry.utils.closestPointToSegment(origin, edge.a, edge.b);
const inside = Math.hypot(pt.x - origin.x, pt.y - origin.y);
const outside = radius - inside;
if ( (outside < 0) || outside.almostEqual(0) ) return { inside, outside: 0 };
// Attenuate the radius outside the threshold wall based on source proximity to the wall.
const sourceDistance = proximity ? Math.max(inside - externalRadius, 0) : (inside + externalRadius);
const percentDistance = sourceDistance / d;
const pInv = proximity ? 1 - percentDistance : Math.min(1, percentDistance - 1);
const a = (pInv / (2 * (1 - pInv))) * CONFIG.Wall.thresholdAttenuationMultiplier;
return { inside, outside: Math.min(a * d, outside) };
}
/* -------------------------------------------- */
/**
* Union the attenuated shape-LOS intersections with the closed LOS.
* The portion of the light sources "inside" the threshold walls are not modified from their default radius or shape.
* Clipper can union everything at once. Use a positive fill to avoid checkerboard; fill any overlap.
* @param {PointSourcePolygon} los The LOS polygon with threshold walls inactive
* @param {ClipperPoints[]} shapes Attenuation shapes for threshold walls
* @returns {PIXI.Polygon} The combined LOS polygon with threshold shapes
*/
static #combineThresholdShapes(los, shapes) {
const c = new ClipperLib.Clipper();
const combined = [];
const cPaths = [los.toClipperPoints(), ...shapes];
c.AddPaths(cPaths, ClipperLib.PolyType.ptSubject, true);
const p = ClipperLib.PolyFillType.pftPositive;
c.Execute(ClipperLib.ClipType.ctUnion, combined, p, p);
return PIXI.Polygon.fromClipperPoints(combined.length ? combined[0] : []);
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/** @ignore */
get rays() {
foundry.utils.logCompatibilityWarning("You are referencing PointSourcePolygon#rays which is no longer a required "
+ "property of that interface. If your subclass uses the rays property it should be explicitly defined by the "
+ "subclass which requires it.", {since: 11, until: 13});
return this.#rays;
}
set rays(rays) {
this.#rays = rays;
}
/** @deprecated since v11 */
#rays = [];
}