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,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);
}
}