904 lines
30 KiB
JavaScript
904 lines
30 KiB
JavaScript
|
|
/**
|
||
|
|
* @typedef {Object} RulerMeasurementSegment
|
||
|
|
* @property {Ray} ray The Ray which represents the point-to-point line segment
|
||
|
|
* @property {PreciseText} label The text object used to display a label for this segment
|
||
|
|
* @property {number} distance The measured distance of the segment
|
||
|
|
* @property {number} cost The measured cost of the segment
|
||
|
|
* @property {number} cumulativeDistance The cumulative measured distance of this segment and the segments before it
|
||
|
|
* @property {number} cumulativeCost The cumulative measured cost of this segment and the segments before it
|
||
|
|
* @property {boolean} history Is this segment part of the measurement history?
|
||
|
|
* @property {boolean} first Is this segment the first one after the measurement history?
|
||
|
|
* @property {boolean} last Is this segment the last one?
|
||
|
|
* @property {object} animation Animation options passed to {@link TokenDocument#update}
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @typedef {object} RulerMeasurementHistoryWaypoint
|
||
|
|
* @property {number} x The x-coordinate of the waypoint
|
||
|
|
* @property {number} y The y-coordinate of the waypoint
|
||
|
|
* @property {boolean} teleport Teleported to from the previous waypoint this waypoint?
|
||
|
|
* @property {number} cost The cost of having moved from the previous waypoint to this waypoint
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @typedef {RulerMeasurementHistoryWaypoint[]} RulerMeasurementHistory
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The Ruler - used to measure distances and trigger movements
|
||
|
|
*/
|
||
|
|
class Ruler extends PIXI.Container {
|
||
|
|
/**
|
||
|
|
* The Ruler constructor.
|
||
|
|
* @param {User} [user=game.user] The User for whom to construct the Ruler instance
|
||
|
|
* @param {object} [options] Additional options
|
||
|
|
* @param {ColorSource} [options.color] The color of the ruler (defaults to the color of the User)
|
||
|
|
*/
|
||
|
|
constructor(user=game.user, {color}={}) {
|
||
|
|
super();
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Record the User which this Ruler references
|
||
|
|
* @type {User}
|
||
|
|
*/
|
||
|
|
this.user = user;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The ruler name - used to differentiate between players
|
||
|
|
* @type {string}
|
||
|
|
*/
|
||
|
|
this.name = `Ruler.${user.id}`;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The ruler color - by default the color of the active user
|
||
|
|
* @type {Color}
|
||
|
|
*/
|
||
|
|
this.color = Color.from(color ?? this.user.color);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The Ruler element is a Graphics instance which draws the line and points of the measured path
|
||
|
|
* @type {PIXI.Graphics}
|
||
|
|
*/
|
||
|
|
this.ruler = this.addChild(new PIXI.Graphics());
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The Labels element is a Container of Text elements which label the measured path
|
||
|
|
* @type {PIXI.Container}
|
||
|
|
*/
|
||
|
|
this.labels = this.addChild(new PIXI.Container());
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The possible Ruler measurement states.
|
||
|
|
* @enum {number}
|
||
|
|
*/
|
||
|
|
static get STATES() {
|
||
|
|
return Ruler.#STATES;
|
||
|
|
}
|
||
|
|
|
||
|
|
static #STATES = Object.freeze({
|
||
|
|
INACTIVE: 0,
|
||
|
|
STARTING: 1,
|
||
|
|
MEASURING: 2,
|
||
|
|
MOVING: 3
|
||
|
|
});
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Is the ruler ready for measure?
|
||
|
|
* @type {boolean}
|
||
|
|
*/
|
||
|
|
static get canMeasure() {
|
||
|
|
return (game.activeTool === "ruler") || game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The current destination point at the end of the measurement
|
||
|
|
* @type {Point|null}
|
||
|
|
*/
|
||
|
|
destination = null;
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The origin point of the measurement, which is the first waypoint.
|
||
|
|
* @type {Point|null}
|
||
|
|
*/
|
||
|
|
get origin() {
|
||
|
|
return this.waypoints.at(0) ?? null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* This Array tracks individual waypoints along the ruler's measured path.
|
||
|
|
* The first waypoint is always the origin of the route.
|
||
|
|
* @type {Point[]}
|
||
|
|
*/
|
||
|
|
waypoints = [];
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The array of most recently computed ruler measurement segments
|
||
|
|
* @type {RulerMeasurementSegment[]}
|
||
|
|
*/
|
||
|
|
segments = [];
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The measurement history.
|
||
|
|
* @type {RulerMeasurementHistory}
|
||
|
|
*/
|
||
|
|
get history() {
|
||
|
|
return this.#history;
|
||
|
|
}
|
||
|
|
|
||
|
|
#history = [];
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The computed total distance of the Ruler.
|
||
|
|
* @type {number}
|
||
|
|
*/
|
||
|
|
totalDistance = 0;
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The computed total cost of the Ruler.
|
||
|
|
* @type {number}
|
||
|
|
*/
|
||
|
|
totalCost = 0;
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The current state of the Ruler (one of {@link Ruler.STATES}).
|
||
|
|
* @type {number}
|
||
|
|
*/
|
||
|
|
get state() {
|
||
|
|
return this._state;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The current state of the Ruler (one of {@link Ruler.STATES}).
|
||
|
|
* @type {number}
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_state = Ruler.STATES.INACTIVE;
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Is the Ruler being actively used to measure distance?
|
||
|
|
* @type {boolean}
|
||
|
|
*/
|
||
|
|
get active() {
|
||
|
|
return this.state !== Ruler.STATES.INACTIVE;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get a GridHighlight layer for this Ruler
|
||
|
|
* @type {GridHighlight}
|
||
|
|
*/
|
||
|
|
get highlightLayer() {
|
||
|
|
return canvas.interface.grid.highlightLayers[this.name] || canvas.interface.grid.addHighlightLayer(this.name);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The Token that is moved by the Ruler.
|
||
|
|
* @type {Token|null}
|
||
|
|
*/
|
||
|
|
get token() {
|
||
|
|
return this.#token;
|
||
|
|
}
|
||
|
|
|
||
|
|
#token = null;
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* Ruler Methods */
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Clear display of the current Ruler
|
||
|
|
*/
|
||
|
|
clear() {
|
||
|
|
this._state = Ruler.STATES.INACTIVE;
|
||
|
|
this.#token = null;
|
||
|
|
this.destination = null;
|
||
|
|
this.waypoints = [];
|
||
|
|
this.segments = [];
|
||
|
|
this.#history = [];
|
||
|
|
this.totalDistance = 0;
|
||
|
|
this.totalCost = 0;
|
||
|
|
this.ruler.clear();
|
||
|
|
this.labels.removeChildren().forEach(c => c.destroy());
|
||
|
|
canvas.interface.grid.clearHighlightLayer(this.name);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Measure the distance between two points and render the ruler UI to illustrate it
|
||
|
|
* @param {Point} destination The destination point to which to measure
|
||
|
|
* @param {object} [options] Additional options
|
||
|
|
* @param {boolean} [options.snap=true] Snap the destination?
|
||
|
|
* @param {boolean} [options.force=false] If not forced and the destination matches the current destination
|
||
|
|
* of this ruler, no measuring is done and nothing is returned
|
||
|
|
* @returns {RulerMeasurementSegment[]|void} The array of measured segments if measured
|
||
|
|
*/
|
||
|
|
measure(destination, {snap=true, force=false}={}) {
|
||
|
|
if ( this.state !== Ruler.STATES.MEASURING ) return;
|
||
|
|
|
||
|
|
// Compute the measurement destination, segments, and distance
|
||
|
|
const d = this._getMeasurementDestination(destination, {snap});
|
||
|
|
if ( this.destination && (d.x === this.destination.x) && (d.y === this.destination.y) && !force ) return;
|
||
|
|
this.destination = d;
|
||
|
|
this.segments = this._getMeasurementSegments();
|
||
|
|
this._computeDistance();
|
||
|
|
this._broadcastMeasurement();
|
||
|
|
|
||
|
|
// Draw the ruler graphic
|
||
|
|
this.ruler.clear();
|
||
|
|
this._drawMeasuredPath();
|
||
|
|
|
||
|
|
// Draw grid highlight
|
||
|
|
this.highlightLayer.clear();
|
||
|
|
for ( const segment of this.segments ) this._highlightMeasurementSegment(segment);
|
||
|
|
return this.segments;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the measurement origin.
|
||
|
|
* @param {Point} point The waypoint
|
||
|
|
* @param {object} [options] Additional options
|
||
|
|
* @param {boolean} [options.snap=true] Snap the waypoint?
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_getMeasurementOrigin(point, {snap=true}={}) {
|
||
|
|
if ( this.token && snap ) {
|
||
|
|
if ( canvas.grid.isGridless ) return this.token.getCenterPoint();
|
||
|
|
const snapped = this.token.getSnappedPosition();
|
||
|
|
const dx = this.token.document.x - Math.round(snapped.x);
|
||
|
|
const dy = this.token.document.y - Math.round(snapped.y);
|
||
|
|
const center = canvas.grid.getCenterPoint({x: point.x - dx, y: point.y - dy});
|
||
|
|
return {x: center.x + dx, y: center.y + dy};
|
||
|
|
}
|
||
|
|
return snap ? canvas.grid.getCenterPoint(point) : {x: point.x, y: point.y};
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the destination point. By default the point is snapped to grid space centers.
|
||
|
|
* @param {Point} point The point coordinates
|
||
|
|
* @param {object} [options] Additional options
|
||
|
|
* @param {boolean} [options.snap=true] Snap the point?
|
||
|
|
* @returns {Point} The snapped destination point
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_getMeasurementDestination(point, {snap=true}={}) {
|
||
|
|
return snap ? canvas.grid.getCenterPoint(point) : {x: point.x, y: point.y};
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Translate the waypoints and destination point of the Ruler into an array of Ray segments.
|
||
|
|
* @returns {RulerMeasurementSegment[]} The segments of the measured path
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_getMeasurementSegments() {
|
||
|
|
const segments = [];
|
||
|
|
const path = this.history.concat(this.waypoints.concat([this.destination]));
|
||
|
|
for ( let i = 1; i < path.length; i++ ) {
|
||
|
|
const label = this.labels.children.at(i - 1) ?? this.labels.addChild(new PreciseText("", CONFIG.canvasTextStyle));
|
||
|
|
const ray = new Ray(path[i - 1], path[i]);
|
||
|
|
segments.push({
|
||
|
|
ray,
|
||
|
|
teleport: (i < this.history.length) ? path[i].teleport : (i === this.history.length) && (ray.distance > 0),
|
||
|
|
label,
|
||
|
|
distance: 0,
|
||
|
|
cost: 0,
|
||
|
|
cumulativeDistance: 0,
|
||
|
|
cumulativeCost: 0,
|
||
|
|
history: i <= this.history.length,
|
||
|
|
first: i === this.history.length + 1,
|
||
|
|
last: i === path.length - 1,
|
||
|
|
animation: {}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if ( this.labels.children.length > segments.length ) {
|
||
|
|
this.labels.removeChildren(segments.length).forEach(c => c.destroy());
|
||
|
|
}
|
||
|
|
return segments;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle the start of a Ruler measurement workflow
|
||
|
|
* @param {Point} origin The origin
|
||
|
|
* @param {object} [options] Additional options
|
||
|
|
* @param {boolean} [options.snap=true] Snap the origin?
|
||
|
|
* @param {Token|null} [options.token] The token that is moved (defaults to {@link Ruler#_getMovementToken})
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_startMeasurement(origin, {snap=true, token}={}) {
|
||
|
|
if ( this.state !== Ruler.STATES.INACTIVE ) return;
|
||
|
|
this.clear();
|
||
|
|
this._state = Ruler.STATES.STARTING;
|
||
|
|
this.#token = token !== undefined ? token : this._getMovementToken(origin);
|
||
|
|
this.#history = this._getMeasurementHistory() ?? [];
|
||
|
|
this._addWaypoint(origin, {snap});
|
||
|
|
canvas.hud.token.clear();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle the conclusion of a Ruler measurement workflow
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_endMeasurement() {
|
||
|
|
if ( this.state !== Ruler.STATES.MEASURING ) return;
|
||
|
|
this.clear();
|
||
|
|
this._broadcastMeasurement();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle the addition of a new waypoint in the Ruler measurement path
|
||
|
|
* @param {Point} point The waypoint
|
||
|
|
* @param {object} [options] Additional options
|
||
|
|
* @param {boolean} [options.snap=true] Snap the waypoint?
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_addWaypoint(point, {snap=true}={}) {
|
||
|
|
if ( (this.state !== Ruler.STATES.STARTING) && (this.state !== Ruler.STATES.MEASURING ) ) return;
|
||
|
|
const waypoint = this.state === Ruler.STATES.STARTING
|
||
|
|
? this._getMeasurementOrigin(point, {snap})
|
||
|
|
: this._getMeasurementDestination(point, {snap});
|
||
|
|
this.waypoints.push(waypoint);
|
||
|
|
this._state = Ruler.STATES.MEASURING;
|
||
|
|
this.measure(this.destination ?? point, {snap, force: true});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle the removal of a waypoint in the Ruler measurement path
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_removeWaypoint() {
|
||
|
|
if ( (this.state !== Ruler.STATES.STARTING) && (this.state !== Ruler.STATES.MEASURING ) ) return;
|
||
|
|
if ( (this.state === Ruler.STATES.MEASURING) && (this.waypoints.length > 1) ) {
|
||
|
|
this.waypoints.pop();
|
||
|
|
this.measure(this.destination, {snap: false, force: true});
|
||
|
|
}
|
||
|
|
else this._endMeasurement();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the cost function to be used for Ruler measurements.
|
||
|
|
* @returns {GridMeasurePathCostFunction|void}
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_getCostFunction() {}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Compute the distance of each segment and the total distance of the measured path.
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_computeDistance() {
|
||
|
|
let path = [];
|
||
|
|
if ( this.segments.length ) path.push(this.segments[0].ray.A);
|
||
|
|
for ( const segment of this.segments ) {
|
||
|
|
const {x, y} = segment.ray.B;
|
||
|
|
path.push({x, y, teleport: segment.teleport});
|
||
|
|
}
|
||
|
|
const measurements = canvas.grid.measurePath(path, {cost: this._getCostFunction()}).segments;
|
||
|
|
this.totalDistance = 0;
|
||
|
|
this.totalCost = 0;
|
||
|
|
for ( let i = 0; i < this.segments.length; i++ ) {
|
||
|
|
const segment = this.segments[i];
|
||
|
|
const distance = measurements[i].distance;
|
||
|
|
const cost = segment.history ? this.history.at(i + 1)?.cost ?? 0 : measurements[i].cost;
|
||
|
|
this.totalDistance += distance;
|
||
|
|
this.totalCost += cost;
|
||
|
|
segment.distance = distance;
|
||
|
|
segment.cost = cost;
|
||
|
|
segment.cumulativeDistance = this.totalDistance;
|
||
|
|
segment.cumulativeCost = this.totalCost;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the text label for a segment of the measured path
|
||
|
|
* @param {RulerMeasurementSegment} segment
|
||
|
|
* @returns {string}
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_getSegmentLabel(segment) {
|
||
|
|
if ( segment.teleport ) return "";
|
||
|
|
const units = canvas.grid.units;
|
||
|
|
let label = `${Math.round(segment.distance * 100) / 100}`;
|
||
|
|
if ( units ) label += ` ${units}`;
|
||
|
|
if ( segment.last ) {
|
||
|
|
label += ` [${Math.round(this.totalDistance * 100) / 100}`;
|
||
|
|
if ( units ) label += ` ${units}`;
|
||
|
|
label += "]";
|
||
|
|
}
|
||
|
|
return label;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Draw each segment of the measured path.
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_drawMeasuredPath() {
|
||
|
|
const paths = [];
|
||
|
|
let path = null;
|
||
|
|
for ( const segment of this.segments ) {
|
||
|
|
const ray = segment.ray;
|
||
|
|
if ( ray.distance !== 0 ) {
|
||
|
|
if ( segment.teleport ) path = null;
|
||
|
|
else {
|
||
|
|
if ( !path || (path.history !== segment.history) ) {
|
||
|
|
path = {points: [ray.A], history: segment.history};
|
||
|
|
paths.push(path);
|
||
|
|
}
|
||
|
|
path.points.push(ray.B);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Draw Label
|
||
|
|
const label = segment.label;
|
||
|
|
if ( label ) {
|
||
|
|
const text = this._getSegmentLabel(segment, /** @deprecated since v12 */ this.totalDistance);
|
||
|
|
label.text = text;
|
||
|
|
label.alpha = segment.last ? 1.0 : 0.5;
|
||
|
|
label.visible = !!text && (ray.distance !== 0);
|
||
|
|
label.anchor.set(0.5, 0.5);
|
||
|
|
let {sizeX, sizeY} = canvas.grid;
|
||
|
|
if ( canvas.grid.isGridless ) sizeX = sizeY = 6; // The radius of the waypoints
|
||
|
|
const pad = 8;
|
||
|
|
const offsetX = (label.width + (2 * pad) + sizeX) / Math.abs(2 * ray.dx);
|
||
|
|
const offsetY = (label.height + (2 * pad) + sizeY) / Math.abs(2 * ray.dy);
|
||
|
|
label.position = ray.project(1 + Math.min(offsetX, offsetY));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const points = paths.map(p => p.points).flat();
|
||
|
|
|
||
|
|
// Draw segments
|
||
|
|
if ( points.length === 1 ) {
|
||
|
|
this.ruler.beginFill(0x000000, 0.5, true).drawCircle(points[0].x, points[0].y, 3).endFill();
|
||
|
|
this.ruler.beginFill(this.color, 0.25, true).drawCircle(points[0].x, points[0].y, 2).endFill();
|
||
|
|
} else {
|
||
|
|
const dashShader = new PIXI.smooth.DashLineShader();
|
||
|
|
for ( const {points, history} of paths ) {
|
||
|
|
this.ruler.lineStyle({width: 6, color: 0x000000, alpha: 0.5, shader: history ? dashShader : null,
|
||
|
|
join: PIXI.LINE_JOIN.ROUND, cap: PIXI.LINE_CAP.ROUND});
|
||
|
|
this.ruler.drawPath(points);
|
||
|
|
this.ruler.lineStyle({width: 4, color: this.color, alpha: 0.25, shader: history ? dashShader : null,
|
||
|
|
join: PIXI.LINE_JOIN.ROUND, cap: PIXI.LINE_CAP.ROUND});
|
||
|
|
this.ruler.drawPath(points);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Draw waypoints
|
||
|
|
this.ruler.beginFill(this.color, 0.25, true).lineStyle(2, 0x000000, 0.5);
|
||
|
|
for ( const {x, y} of points ) this.ruler.drawCircle(x, y, 6);
|
||
|
|
this.ruler.endFill();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Highlight the measurement required to complete the move in the minimum number of discrete spaces
|
||
|
|
* @param {RulerMeasurementSegment} segment
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_highlightMeasurementSegment(segment) {
|
||
|
|
if ( segment.teleport ) return;
|
||
|
|
for ( const offset of canvas.grid.getDirectPath([segment.ray.A, segment.ray.B]) ) {
|
||
|
|
const {x: x1, y: y1} = canvas.grid.getTopLeftPoint(offset);
|
||
|
|
canvas.interface.grid.highlightPosition(this.name, {x: x1, y: y1, color: this.color});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* Token Movement Execution */
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Determine whether a SPACE keypress event entails a legal token movement along a measured ruler
|
||
|
|
* @returns {Promise<boolean>} An indicator for whether a token was successfully moved or not. If True the
|
||
|
|
* event should be prevented from propagating further, if False it should move on
|
||
|
|
* to other handlers.
|
||
|
|
*/
|
||
|
|
async moveToken() {
|
||
|
|
if ( this.state !== Ruler.STATES.MEASURING ) return false;
|
||
|
|
if ( game.paused && !game.user.isGM ) {
|
||
|
|
ui.notifications.warn("GAME.PausedWarning", {localize: true});
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get the Token which should move
|
||
|
|
const token = this.token;
|
||
|
|
if ( !token ) return false;
|
||
|
|
|
||
|
|
// Verify whether the movement is allowed
|
||
|
|
let error;
|
||
|
|
try {
|
||
|
|
if ( !this._canMove(token) ) error = "RULER.MovementNotAllowed";
|
||
|
|
} catch(err) {
|
||
|
|
error = err.message;
|
||
|
|
}
|
||
|
|
if ( error ) {
|
||
|
|
ui.notifications.error(error, {localize: true});
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Animate the movement path defined by each ray segments
|
||
|
|
this._state = Ruler.STATES.MOVING;
|
||
|
|
await this._preMove(token);
|
||
|
|
await this._animateMovement(token);
|
||
|
|
await this._postMove(token);
|
||
|
|
|
||
|
|
// Clear the Ruler
|
||
|
|
this._state = Ruler.STATES.MEASURING;
|
||
|
|
this._endMeasurement();
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Acquire a Token, if any, which is eligible to perform a movement based on the starting point of the Ruler
|
||
|
|
* @param {Point} origin The origin of the Ruler
|
||
|
|
* @returns {Token|null} The Token that is to be moved, if any
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_getMovementToken(origin) {
|
||
|
|
let tokens = canvas.tokens.controlled;
|
||
|
|
if ( !tokens.length && game.user.character ) tokens = game.user.character.getActiveTokens();
|
||
|
|
for ( const token of tokens ) {
|
||
|
|
if ( !token.visible || !token.shape ) continue;
|
||
|
|
const {x, y} = token.document;
|
||
|
|
for ( let dx = -1; dx <= 1; dx++ ) {
|
||
|
|
for ( let dy = -1; dy <= 1; dy++ ) {
|
||
|
|
if ( token.shape.contains(origin.x - x + dx, origin.y - y + dy) ) return token;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the current measurement history.
|
||
|
|
* @returns {RulerMeasurementHistory|void} The current measurement history, if any
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_getMeasurementHistory() {}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create the next measurement history from the current history and current Ruler state.
|
||
|
|
* @returns {RulerMeasurementHistory} The next measurement history
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_createMeasurementHistory() {
|
||
|
|
if ( !this.segments.length ) return [];
|
||
|
|
const origin = this.segments[0].ray.A;
|
||
|
|
return this.segments.reduce((history, s) => {
|
||
|
|
if ( s.ray.distance === 0 ) return history;
|
||
|
|
history.push({x: s.ray.B.x, y: s.ray.B.y, teleport: s.teleport, cost: s.cost});
|
||
|
|
return history;
|
||
|
|
}, [{x: origin.x, y: origin.y, teleport: false, cost: 0}]);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Test whether a Token is allowed to execute a measured movement path.
|
||
|
|
* @param {Token} token The Token being tested
|
||
|
|
* @returns {boolean} Whether the movement is allowed
|
||
|
|
* @throws A specific Error message used instead of returning false
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_canMove(token) {
|
||
|
|
const canUpdate = token.document.canUserModify(game.user, "update");
|
||
|
|
if ( !canUpdate ) throw new Error("RULER.MovementNoPermission");
|
||
|
|
if ( token.document.locked ) throw new Error("RULER.MovementLocked");
|
||
|
|
const hasCollision = this.segments.some(s => {
|
||
|
|
return token.checkCollision(s.ray.B, {origin: s.ray.A, type: "move", mode: "any"});
|
||
|
|
});
|
||
|
|
if ( hasCollision ) throw new Error("RULER.MovementCollision");
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Animate piecewise Token movement along the measured segment path.
|
||
|
|
* @param {Token} token The Token being animated
|
||
|
|
* @returns {Promise<void>} A Promise which resolves once all animation is completed
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
async _animateMovement(token) {
|
||
|
|
const wasPaused = game.paused;
|
||
|
|
|
||
|
|
// Determine offset of the initial origin relative to the snapped Token's top-left.
|
||
|
|
// This is important to position the token relative to the ruler origin for non-1x1 tokens.
|
||
|
|
const origin = this.segments[this.history.length].ray.A;
|
||
|
|
const dx = token.document.x - origin.x;
|
||
|
|
const dy = token.document.y - origin.y;
|
||
|
|
|
||
|
|
// Iterate over each measured segment
|
||
|
|
let priorDest = undefined;
|
||
|
|
for ( const segment of this.segments ) {
|
||
|
|
if ( segment.history || (segment.ray.distance === 0) ) continue;
|
||
|
|
const r = segment.ray;
|
||
|
|
const {x, y} = token.document._source;
|
||
|
|
|
||
|
|
// Break the movement if the game is paused
|
||
|
|
if ( !wasPaused && game.paused ) break;
|
||
|
|
|
||
|
|
// Break the movement if Token is no longer located at the prior destination (some other change override this)
|
||
|
|
if ( priorDest && ((x !== priorDest.x) || (y !== priorDest.y)) ) break;
|
||
|
|
|
||
|
|
// Commit the movement and update the final resolved destination coordinates
|
||
|
|
const adjustedDestination = {x: Math.round(r.B.x + dx), y: Math.round(r.B.y + dy)};
|
||
|
|
await this._animateSegment(token, segment, adjustedDestination);
|
||
|
|
priorDest = adjustedDestination;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update Token position and configure its animation properties for the next leg of its animation.
|
||
|
|
* @param {Token} token The Token being updated
|
||
|
|
* @param {RulerMeasurementSegment} segment The measured segment being moved
|
||
|
|
* @param {Point} destination The adjusted destination coordinate
|
||
|
|
* @param {object} [updateOptions] Additional options to configure the `TokenDocument` update
|
||
|
|
* @returns {Promise<void>} A Promise that resolves once the animation for this segment is done
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
async _animateSegment(token, segment, destination, updateOptions={}) {
|
||
|
|
let name;
|
||
|
|
if ( segment.animation?.name === undefined ) name = token.animationName;
|
||
|
|
else name ||= Symbol(token.animationName);
|
||
|
|
const {x, y} = token.document._source;
|
||
|
|
await token.animate({x, y}, {name, duration: 0});
|
||
|
|
foundry.utils.mergeObject(
|
||
|
|
updateOptions,
|
||
|
|
{teleport: segment.teleport, animation: {...segment.animation, name}},
|
||
|
|
{overwrite: false}
|
||
|
|
);
|
||
|
|
await token.document.update(destination, updateOptions);
|
||
|
|
await CanvasAnimation.getAnimation(name)?.promise;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* An method which can be extended by a subclass of Ruler to define custom behaviors before a confirmed movement.
|
||
|
|
* @param {Token} token The Token that will be moving
|
||
|
|
* @returns {Promise<void>}
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
async _preMove(token) {}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* An event which can be extended by a subclass of Ruler to define custom behaviors before a confirmed movement.
|
||
|
|
* @param {Token} token The Token that finished moving
|
||
|
|
* @returns {Promise<void>}
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
async _postMove(token) {}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* Saving and Loading
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A throttled function that broadcasts the measurement data.
|
||
|
|
* @type {function()}
|
||
|
|
*/
|
||
|
|
#throttleBroadcastMeasurement = foundry.utils.throttle(this.#broadcastMeasurement.bind(this), 100);
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Broadcast Ruler measurement.
|
||
|
|
*/
|
||
|
|
#broadcastMeasurement() {
|
||
|
|
game.user.broadcastActivity({ruler: this.active ? this._getMeasurementData() : null});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Broadcast Ruler measurement if its User is the connected client.
|
||
|
|
* The broadcast is throttled to 100ms.
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_broadcastMeasurement() {
|
||
|
|
if ( !this.user.isSelf || !game.user.hasPermission("SHOW_RULER") ) return;
|
||
|
|
this.#throttleBroadcastMeasurement();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @typedef {object} RulerMeasurementData
|
||
|
|
* @property {number} state The state ({@link Ruler#state})
|
||
|
|
* @property {string|null} token The token ID ({@link Ruler#token})
|
||
|
|
* @property {RulerMeasurementHistory} history The measurement history ({@link Ruler#history})
|
||
|
|
* @property {Point[]} waypoints The waypoints ({@link Ruler#waypoints})
|
||
|
|
* @property {Point|null} destination The destination ({@link Ruler#destination})
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Package Ruler data to an object which can be serialized to a string.
|
||
|
|
* @returns {RulerMeasurementData}
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_getMeasurementData() {
|
||
|
|
return foundry.utils.deepClone({
|
||
|
|
state: this.state,
|
||
|
|
token: this.token?.id ?? null,
|
||
|
|
history: this.history,
|
||
|
|
waypoints: this.waypoints,
|
||
|
|
destination: this.destination
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update a Ruler instance using data provided through the cursor activity socket
|
||
|
|
* @param {RulerMeasurementData|null} data Ruler data with which to update the display
|
||
|
|
*/
|
||
|
|
update(data) {
|
||
|
|
if ( !data || (data.state === Ruler.STATES.INACTIVE) ) return this.clear();
|
||
|
|
this._state = data.state;
|
||
|
|
this.#token = canvas.tokens.get(data.token) ?? null;
|
||
|
|
this.#history = data.history;
|
||
|
|
this.waypoints = data.waypoints;
|
||
|
|
this.measure(data.destination, {snap: false, force: true});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* Event Listeners and Handlers
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle the beginning of a new Ruler measurement workflow
|
||
|
|
* @see {Canvas.#onDragLeftStart}
|
||
|
|
* @param {PIXI.FederatedEvent} event The drag start event
|
||
|
|
* @protected
|
||
|
|
* @internal
|
||
|
|
*/
|
||
|
|
_onDragStart(event) {
|
||
|
|
this._startMeasurement(event.interactionData.origin, {snap: !event.shiftKey});
|
||
|
|
if ( this.token && (this.state === Ruler.STATES.MEASURING) ) this.token.document.locked = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle left-click events on the Canvas during Ruler measurement.
|
||
|
|
* @see {Canvas._onClickLeft}
|
||
|
|
* @param {PIXI.FederatedEvent} event The pointer-down event
|
||
|
|
* @protected
|
||
|
|
* @internal
|
||
|
|
*/
|
||
|
|
_onClickLeft(event) {
|
||
|
|
const isCtrl = event.ctrlKey || event.metaKey;
|
||
|
|
if ( !isCtrl ) return;
|
||
|
|
this._addWaypoint(event.interactionData.origin, {snap: !event.shiftKey});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle right-click events on the Canvas during Ruler measurement.
|
||
|
|
* @see {Canvas._onClickRight}
|
||
|
|
* @param {PIXI.FederatedEvent} event The pointer-down event
|
||
|
|
* @protected
|
||
|
|
* @internal
|
||
|
|
*/
|
||
|
|
_onClickRight(event) {
|
||
|
|
const token = this.token;
|
||
|
|
const isCtrl = event.ctrlKey || event.metaKey;
|
||
|
|
if ( isCtrl ) this._removeWaypoint();
|
||
|
|
else this._endMeasurement();
|
||
|
|
if ( this.active ) canvas.mouseInteractionManager._dragRight = false;
|
||
|
|
else {
|
||
|
|
if ( token ) token.document.locked = token.document._source.locked;
|
||
|
|
canvas.mouseInteractionManager.cancel(event);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Continue a Ruler measurement workflow for left-mouse movements on the Canvas.
|
||
|
|
* @see {Canvas.#onDragLeftMove}
|
||
|
|
* @param {PIXI.FederatedEvent} event The mouse move event
|
||
|
|
* @protected
|
||
|
|
* @internal
|
||
|
|
*/
|
||
|
|
_onMouseMove(event) {
|
||
|
|
const destination = event.interactionData.destination;
|
||
|
|
if ( !canvas.dimensions.rect.contains(destination.x, destination.y) ) return;
|
||
|
|
this.measure(destination, {snap: !event.shiftKey});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Conclude a Ruler measurement workflow by releasing the left-mouse button.
|
||
|
|
* @see {Canvas.#onDragLeftDrop}
|
||
|
|
* @param {PIXI.FederatedEvent} event The pointer-up event
|
||
|
|
* @protected
|
||
|
|
* @internal
|
||
|
|
*/
|
||
|
|
_onMouseUp(event) {
|
||
|
|
if ( !this.active ) return;
|
||
|
|
const isCtrl = event.ctrlKey || event.metaKey;
|
||
|
|
if ( isCtrl || (this.waypoints.length > 1) ) event.preventDefault();
|
||
|
|
else {
|
||
|
|
if ( this.token ) this.token.document.locked = this.token.document._source.locked;
|
||
|
|
this._endMeasurement();
|
||
|
|
canvas.mouseInteractionManager.cancel(event);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Move the Token along the measured path when the move key is pressed.
|
||
|
|
* @param {KeyboardEventContext} context
|
||
|
|
* @protected
|
||
|
|
* @internal
|
||
|
|
*/
|
||
|
|
_onMoveKeyDown(context) {
|
||
|
|
if ( this.token ) this.token.document.locked = this.token.document._source.locked;
|
||
|
|
// noinspection ES6MissingAwait
|
||
|
|
this.moveToken();
|
||
|
|
if ( this.state !== Ruler.STATES.MEASURING ) canvas.mouseInteractionManager.cancel();
|
||
|
|
}
|
||
|
|
}
|