Initial
This commit is contained in:
81
resources/app/client/pixi/layers/controls/cursor.js
Normal file
81
resources/app/client/pixi/layers/controls/cursor.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* A single Mouse Cursor
|
||||
* @type {PIXI.Container}
|
||||
*/
|
||||
class Cursor extends PIXI.Container {
|
||||
constructor(user) {
|
||||
super();
|
||||
this.target = {x: 0, y: 0};
|
||||
this.draw(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* To know if this cursor is animated
|
||||
* @type {boolean}
|
||||
*/
|
||||
#animating;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update visibility and animations
|
||||
* @param {User} user The user
|
||||
*/
|
||||
refreshVisibility(user) {
|
||||
const v = this.visible = !user.isSelf && user.hasPermission("SHOW_CURSOR");
|
||||
|
||||
if ( v && !this.#animating ) {
|
||||
canvas.app.ticker.add(this._animate, this);
|
||||
this.#animating = true; // Set flag to true when animation is added
|
||||
} else if ( !v && this.#animating ) {
|
||||
canvas.app.ticker.remove(this._animate, this);
|
||||
this.#animating = false; // Set flag to false when animation is removed
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw the user's cursor as a small dot with their user name attached as text
|
||||
*/
|
||||
draw(user) {
|
||||
|
||||
// Cursor dot
|
||||
const d = this.addChild(new PIXI.Graphics());
|
||||
d.beginFill(user.color, 0.35).lineStyle(1, 0x000000, 0.5).drawCircle(0, 0, 6);
|
||||
|
||||
// Player name
|
||||
const style = CONFIG.canvasTextStyle.clone();
|
||||
style.fontSize = 14;
|
||||
let n = this.addChild(new PreciseText(user.name, style));
|
||||
n.x -= n.width / 2;
|
||||
n.y += 10;
|
||||
|
||||
// Refresh
|
||||
this.refreshVisibility(user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Move an existing cursor to a new position smoothly along the animation loop
|
||||
*/
|
||||
_animate() {
|
||||
const dy = this.target.y - this.y;
|
||||
const dx = this.target.x - this.x;
|
||||
if ( Math.abs( dx ) + Math.abs( dy ) < 10 ) return;
|
||||
this.x += dx / 10;
|
||||
this.y += dy / 10;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
destroy(options) {
|
||||
if ( this.#animating ) {
|
||||
canvas.app.ticker.remove(this._animate, this);
|
||||
this.#animating = false;
|
||||
}
|
||||
super.destroy(options);
|
||||
}
|
||||
}
|
||||
215
resources/app/client/pixi/layers/controls/door.js
Normal file
215
resources/app/client/pixi/layers/controls/door.js
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* An icon representing a Door Control
|
||||
* @extends {PIXI.Container}
|
||||
*/
|
||||
class DoorControl extends PIXI.Container {
|
||||
constructor(wall) {
|
||||
super();
|
||||
this.wall = wall;
|
||||
this.visible = false; // Door controls are not visible by default
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The center of the wall which contains the door.
|
||||
* @type {PIXI.Point}
|
||||
*/
|
||||
get center() {
|
||||
return this.wall.center;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw the DoorControl icon, displaying its icon texture and border
|
||||
* @returns {Promise<DoorControl>}
|
||||
*/
|
||||
async draw() {
|
||||
|
||||
// Background
|
||||
this.bg = this.bg || this.addChild(new PIXI.Graphics());
|
||||
this.bg.clear().beginFill(0x000000, 1.0).drawRoundedRect(-2, -2, 44, 44, 5).endFill();
|
||||
this.bg.alpha = 0;
|
||||
|
||||
// Control Icon
|
||||
this.icon = this.icon || this.addChild(new PIXI.Sprite());
|
||||
this.icon.width = this.icon.height = 40;
|
||||
this.icon.alpha = 0.6;
|
||||
this.icon.texture = this._getTexture();
|
||||
|
||||
// Border
|
||||
this.border = this.border || this.addChild(new PIXI.Graphics());
|
||||
this.border.clear().lineStyle(1, 0xFF5500, 0.8).drawRoundedRect(-2, -2, 44, 44, 5).endFill();
|
||||
this.border.visible = false;
|
||||
|
||||
// Add control interactivity
|
||||
this.eventMode = "static";
|
||||
this.interactiveChildren = false;
|
||||
this.hitArea = new PIXI.Rectangle(-2, -2, 44, 44);
|
||||
this.cursor = "pointer";
|
||||
|
||||
// Set position
|
||||
this.reposition();
|
||||
this.alpha = 1.0;
|
||||
|
||||
// Activate listeners
|
||||
this.removeAllListeners();
|
||||
this.on("pointerover", this._onMouseOver).on("pointerout", this._onMouseOut)
|
||||
.on("pointerdown", this._onMouseDown).on("rightdown", this._onRightDown);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the icon texture to use for the Door Control icon based on the door state
|
||||
* @returns {PIXI.Texture}
|
||||
*/
|
||||
_getTexture() {
|
||||
|
||||
// Determine displayed door state
|
||||
const ds = CONST.WALL_DOOR_STATES;
|
||||
let s = this.wall.document.ds;
|
||||
if ( !game.user.isGM && (s === ds.LOCKED) ) s = ds.CLOSED;
|
||||
|
||||
// Determine texture path
|
||||
const icons = CONFIG.controlIcons;
|
||||
let path = {
|
||||
[ds.LOCKED]: icons.doorLocked,
|
||||
[ds.CLOSED]: icons.doorClosed,
|
||||
[ds.OPEN]: icons.doorOpen
|
||||
}[s] || icons.doorClosed;
|
||||
if ( (s === ds.CLOSED) && (this.wall.document.door === CONST.WALL_DOOR_TYPES.SECRET) ) path = icons.doorSecret;
|
||||
|
||||
// Obtain the icon texture
|
||||
return getTexture(path);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
reposition() {
|
||||
let pos = this.wall.midpoint.map(p => p - 20);
|
||||
this.position.set(...pos);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine whether the DoorControl is visible to the calling user's perspective.
|
||||
* The control is always visible if the user is a GM and no Tokens are controlled.
|
||||
* @see {CanvasVisibility#testVisibility}
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isVisible() {
|
||||
if ( !canvas.visibility.tokenVision ) return true;
|
||||
|
||||
// Hide secret doors from players
|
||||
const w = this.wall;
|
||||
if ( (w.document.door === CONST.WALL_DOOR_TYPES.SECRET) && !game.user.isGM ) return false;
|
||||
|
||||
// Test two points which are perpendicular to the door midpoint
|
||||
const ray = this.wall.toRay();
|
||||
const [x, y] = w.midpoint;
|
||||
const [dx, dy] = [-ray.dy, ray.dx];
|
||||
const t = 3 / (Math.abs(dx) + Math.abs(dy)); // Approximate with Manhattan distance for speed
|
||||
const points = [
|
||||
{x: x + (t * dx), y: y + (t * dy)},
|
||||
{x: x - (t * dx), y: y - (t * dy)}
|
||||
];
|
||||
|
||||
// Test each point for visibility
|
||||
return points.some(p => {
|
||||
return canvas.visibility.testVisibility(p, {object: this, tolerance: 0});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse over events on a door control icon.
|
||||
* @param {PIXI.FederatedEvent} event The originating interaction event
|
||||
* @protected
|
||||
*/
|
||||
_onMouseOver(event) {
|
||||
event.stopPropagation();
|
||||
const canControl = game.user.can("WALL_DOORS");
|
||||
const blockPaused = game.paused && !game.user.isGM;
|
||||
if ( !canControl || blockPaused ) return false;
|
||||
this.border.visible = true;
|
||||
this.icon.alpha = 1.0;
|
||||
this.bg.alpha = 0.25;
|
||||
canvas.walls.hover = this.wall;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse out events on a door control icon.
|
||||
* @param {PIXI.FederatedEvent} event The originating interaction event
|
||||
* @protected
|
||||
*/
|
||||
_onMouseOut(event) {
|
||||
event.stopPropagation();
|
||||
if ( game.paused && !game.user.isGM ) return false;
|
||||
this.border.visible = false;
|
||||
this.icon.alpha = 0.6;
|
||||
this.bg.alpha = 0;
|
||||
canvas.walls.hover = null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle left mouse down events on a door control icon.
|
||||
* This should only toggle between the OPEN and CLOSED states.
|
||||
* @param {PIXI.FederatedEvent} event The originating interaction event
|
||||
* @protected
|
||||
*/
|
||||
_onMouseDown(event) {
|
||||
if ( event.button !== 0 ) return; // Only support standard left-click
|
||||
event.stopPropagation();
|
||||
const { ds } = this.wall.document;
|
||||
const states = CONST.WALL_DOOR_STATES;
|
||||
|
||||
// Determine whether the player can control the door at this time
|
||||
if ( !game.user.can("WALL_DOORS") ) return false;
|
||||
if ( game.paused && !game.user.isGM ) {
|
||||
ui.notifications.warn("GAME.PausedWarning", {localize: true});
|
||||
return false;
|
||||
}
|
||||
|
||||
const sound = !(game.user.isGM && game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.ALT));
|
||||
|
||||
// Play an audio cue for testing locked doors, only for the current client
|
||||
if ( ds === states.LOCKED ) {
|
||||
if ( sound ) this.wall._playDoorSound("test");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Toggle between OPEN and CLOSED states
|
||||
return this.wall.document.update({ds: ds === states.CLOSED ? states.OPEN : states.CLOSED}, {sound});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle right mouse down events on a door control icon.
|
||||
* This should toggle whether the door is LOCKED or CLOSED.
|
||||
* @param {PIXI.FederatedEvent} event The originating interaction event
|
||||
* @protected
|
||||
*/
|
||||
_onRightDown(event) {
|
||||
event.stopPropagation();
|
||||
if ( !game.user.isGM ) return;
|
||||
let state = this.wall.document.ds;
|
||||
const states = CONST.WALL_DOOR_STATES;
|
||||
if ( state === states.OPEN ) return;
|
||||
state = state === states.LOCKED ? states.CLOSED : states.LOCKED;
|
||||
const sound = !(game.user.isGM && game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.ALT));
|
||||
return this.wall.document.update({ds: state}, {sound});
|
||||
}
|
||||
}
|
||||
385
resources/app/client/pixi/layers/controls/layer.js
Normal file
385
resources/app/client/pixi/layers/controls/layer.js
Normal file
@@ -0,0 +1,385 @@
|
||||
|
||||
/**
|
||||
* A CanvasLayer for displaying UI controls which are overlayed on top of other layers.
|
||||
*
|
||||
* We track three types of events:
|
||||
* 1) Cursor movement
|
||||
* 2) Ruler measurement
|
||||
* 3) Map pings
|
||||
*/
|
||||
class ControlsLayer extends InteractionLayer {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Always interactive even if disabled for doors controls
|
||||
this.interactiveChildren = true;
|
||||
|
||||
/**
|
||||
* A container of DoorControl instances
|
||||
* @type {PIXI.Container}
|
||||
*/
|
||||
this.doors = this.addChild(new PIXI.Container());
|
||||
|
||||
/**
|
||||
* A container of cursor interaction elements.
|
||||
* Contains cursors, rulers, interaction rectangles, and pings
|
||||
* @type {PIXI.Container}
|
||||
*/
|
||||
this.cursors = this.addChild(new PIXI.Container());
|
||||
this.cursors.eventMode = "none";
|
||||
this.cursors.mask = canvas.masks.canvas;
|
||||
|
||||
/**
|
||||
* Ruler tools, one per connected user
|
||||
* @type {PIXI.Container}
|
||||
*/
|
||||
this.rulers = this.addChild(new PIXI.Container());
|
||||
this.rulers.eventMode = "none";
|
||||
|
||||
/**
|
||||
* A graphics instance used for drawing debugging visualization
|
||||
* @type {PIXI.Graphics}
|
||||
*/
|
||||
this.debug = this.addChild(new PIXI.Graphics());
|
||||
this.debug.eventMode = "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* The Canvas selection rectangle
|
||||
* @type {PIXI.Graphics}
|
||||
*/
|
||||
select;
|
||||
|
||||
/**
|
||||
* A mapping of user IDs to Cursor instances for quick access
|
||||
* @type {Record<string, Cursor>}
|
||||
*/
|
||||
_cursors = {};
|
||||
|
||||
/**
|
||||
* A mapping of user IDs to Ruler instances for quick access
|
||||
* @type {Record<string, Ruler>}
|
||||
* @private
|
||||
*/
|
||||
_rulers = {};
|
||||
|
||||
/**
|
||||
* The positions of any offscreen pings we are tracking.
|
||||
* @type {Record<string, Point>}
|
||||
* @private
|
||||
*/
|
||||
_offscreenPings = {};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get layerOptions() {
|
||||
return foundry.utils.mergeObject(super.layerOptions, {
|
||||
name: "controls",
|
||||
zIndex: 1000
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties and Public Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience accessor to the Ruler for the active game user
|
||||
* @type {Ruler}
|
||||
*/
|
||||
get ruler() {
|
||||
return this.getRulerForUser(game.user.id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Ruler display for a specific User ID
|
||||
* @param {string} userId
|
||||
* @returns {Ruler|null}
|
||||
*/
|
||||
getRulerForUser(userId) {
|
||||
return this._rulers[userId] || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendering */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _draw(options) {
|
||||
await super._draw(options);
|
||||
|
||||
// Create additional elements
|
||||
this.drawCursors();
|
||||
this.drawRulers();
|
||||
this.drawDoors();
|
||||
this.select = this.cursors.addChild(new PIXI.Graphics());
|
||||
|
||||
// Adjust scale
|
||||
const d = canvas.dimensions;
|
||||
this.hitArea = d.rect;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _tearDown(options) {
|
||||
this._cursors = {};
|
||||
this._rulers = {};
|
||||
this.doors.removeChildren();
|
||||
this.cursors.removeChildren();
|
||||
this.rulers.removeChildren();
|
||||
this.debug.clear();
|
||||
this.debug.debugText?.removeChildren().forEach(c => c.destroy({children: true}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw the cursors container
|
||||
*/
|
||||
drawCursors() {
|
||||
for ( let u of game.users.filter(u => u.active && !u.isSelf ) ) {
|
||||
this.drawCursor(u);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create and add Ruler graphics instances for every game User.
|
||||
*/
|
||||
drawRulers() {
|
||||
const cls = CONFIG.Canvas.rulerClass;
|
||||
for (let u of game.users) {
|
||||
let ruler = this.getRulerForUser(u.id);
|
||||
if ( !ruler ) ruler = this._rulers[u.id] = new cls(u);
|
||||
this.rulers.addChild(ruler);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw door control icons to the doors container.
|
||||
*/
|
||||
drawDoors() {
|
||||
for ( const wall of canvas.walls.placeables ) {
|
||||
if ( wall.isDoor ) wall.createDoorControl();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw the select rectangle given an event originated within the base canvas layer
|
||||
* @param {Object} coords The rectangle coordinates of the form {x, y, width, height}
|
||||
*/
|
||||
drawSelect({x, y, width, height}) {
|
||||
const s = this.select.clear();
|
||||
s.lineStyle(3, 0xFF9829, 0.9).drawRect(x, y, width, height);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_deactivate() {
|
||||
this.interactiveChildren = true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mousemove events on the game canvas to broadcast activity of the user's cursor position
|
||||
*/
|
||||
_onMouseMove() {
|
||||
if ( !game.user.hasPermission("SHOW_CURSOR") ) return;
|
||||
game.user.broadcastActivity({cursor: canvas.mousePosition});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle pinging the canvas.
|
||||
* @param {PIXI.FederatedEvent} event The triggering canvas interaction event.
|
||||
* @param {PIXI.Point} origin The local canvas coordinates of the mousepress.
|
||||
* @protected
|
||||
*/
|
||||
_onLongPress(event, origin) {
|
||||
const isCtrl = game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL);
|
||||
const isTokenLayer = canvas.activeLayer instanceof TokenLayer;
|
||||
if ( !game.user.hasPermission("PING_CANVAS") || isCtrl || !isTokenLayer ) return;
|
||||
canvas.currentMouseManager.cancel(event); // Cancel drag workflow
|
||||
return canvas.ping(origin);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the canvas panning to a new view.
|
||||
* @protected
|
||||
*/
|
||||
_onCanvasPan() {
|
||||
for ( const [name, position] of Object.entries(this._offscreenPings) ) {
|
||||
const { ray, intersection } = this._findViewportIntersection(position);
|
||||
if ( intersection ) {
|
||||
const { x, y } = canvas.canvasCoordinatesFromClient(intersection);
|
||||
const ping = CanvasAnimation.getAnimation(name).context;
|
||||
ping.x = x;
|
||||
ping.y = y;
|
||||
ping.rotation = Math.normalizeRadians(ray.angle + (Math.PI * 1.5));
|
||||
} else CanvasAnimation.terminateAnimation(name);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create and draw the Cursor object for a given User
|
||||
* @param {User} user The User document for whom to draw the cursor Container
|
||||
*/
|
||||
drawCursor(user) {
|
||||
if ( user.id in this._cursors ) {
|
||||
this._cursors[user.id].destroy({children: true});
|
||||
delete this._cursors[user.id];
|
||||
}
|
||||
return this._cursors[user.id] = this.cursors.addChild(new Cursor(user));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the cursor when the user moves to a new position
|
||||
* @param {User} user The User for whom to update the cursor
|
||||
* @param {Point} position The new cursor position
|
||||
*/
|
||||
updateCursor(user, position) {
|
||||
if ( !this.cursors ) return;
|
||||
const cursor = this._cursors[user.id] || this.drawCursor(user);
|
||||
|
||||
// Ignore cursors on other Scenes
|
||||
if ( ( position === null ) || (user.viewedScene !== canvas.scene.id) ) {
|
||||
if ( cursor ) cursor.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the cursor in its currently tracked position
|
||||
cursor.refreshVisibility(user);
|
||||
cursor.target = {x: position.x || 0, y: position.y || 0};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update display of an active Ruler object for a user given provided data
|
||||
* @param {User} user The User for whom to update the ruler
|
||||
* @param {RulerMeasurementData|null} rulerData Data which describes the new ruler measurement to display
|
||||
*/
|
||||
updateRuler(user, rulerData) {
|
||||
|
||||
// Ignore rulers for users who are not permitted to share
|
||||
if ( (user === game.user) || !user.hasPermission("SHOW_RULER") ) return;
|
||||
|
||||
// Update the Ruler display for the user
|
||||
const ruler = this.getRulerForUser(user.id);
|
||||
ruler?.update(rulerData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a broadcast ping.
|
||||
* @see {@link Ping#drawPing}
|
||||
* @param {User} user The user who pinged.
|
||||
* @param {Point} position The position on the canvas that was pinged.
|
||||
* @param {PingData} [data] The broadcast ping data.
|
||||
* @returns {Promise<boolean>} A promise which resolves once the Ping has been drawn and animated
|
||||
*/
|
||||
async handlePing(user, position, {scene, style="pulse", pull=false, zoom=1, ...pingOptions}={}) {
|
||||
if ( !canvas.ready || (canvas.scene?.id !== scene) || !position ) return;
|
||||
if ( pull && (user.isGM || user.isSelf) ) {
|
||||
await canvas.animatePan({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
scale: Math.min(CONFIG.Canvas.maxZoom, zoom),
|
||||
duration: CONFIG.Canvas.pings.pullSpeed
|
||||
});
|
||||
} else if ( canvas.isOffscreen(position) ) this.drawOffscreenPing(position, { style: "arrow", user });
|
||||
if ( game.settings.get("core", "photosensitiveMode") ) style = CONFIG.Canvas.pings.types.PULL;
|
||||
return this.drawPing(position, { style, user, ...pingOptions });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw a ping at the edge of the viewport, pointing to the location of an off-screen ping.
|
||||
* @see {@link Ping#drawPing}
|
||||
* @param {Point} position The coordinates of the off-screen ping.
|
||||
* @param {PingOptions} [options] Additional options to configure how the ping is drawn.
|
||||
* @param {string} [options.style=arrow] The style of ping to draw, from CONFIG.Canvas.pings.
|
||||
* @param {User} [options.user] The user who pinged.
|
||||
* @returns {Promise<boolean>} A promise which resolves once the Ping has been drawn and animated
|
||||
*/
|
||||
async drawOffscreenPing(position, {style="arrow", user, ...pingOptions}={}) {
|
||||
const { ray, intersection } = this._findViewportIntersection(position);
|
||||
if ( !intersection ) return;
|
||||
const name = `Ping.${foundry.utils.randomID()}`;
|
||||
this._offscreenPings[name] = position;
|
||||
position = canvas.canvasCoordinatesFromClient(intersection);
|
||||
if ( game.settings.get("core", "photosensitiveMode") ) pingOptions.rings = 1;
|
||||
const animation = this.drawPing(position, { style, user, name, rotation: ray.angle, ...pingOptions });
|
||||
animation.finally(() => delete this._offscreenPings[name]);
|
||||
return animation;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw a ping on the canvas.
|
||||
* @see {@link Ping#animate}
|
||||
* @param {Point} position The position on the canvas that was pinged.
|
||||
* @param {PingOptions} [options] Additional options to configure how the ping is drawn.
|
||||
* @param {string} [options.style=pulse] The style of ping to draw, from CONFIG.Canvas.pings.
|
||||
* @param {User} [options.user] The user who pinged.
|
||||
* @returns {Promise<boolean>} A promise which resolves once the Ping has been drawn and animated
|
||||
*/
|
||||
async drawPing(position, {style="pulse", user, ...pingOptions}={}) {
|
||||
const cfg = CONFIG.Canvas.pings.styles[style] ?? CONFIG.Canvas.pings.styles.pulse;
|
||||
const options = {
|
||||
duration: cfg.duration,
|
||||
color: cfg.color ?? user?.color,
|
||||
size: canvas.dimensions.size * (cfg.size || 1)
|
||||
};
|
||||
const ping = new cfg.class(position, foundry.utils.mergeObject(options, pingOptions));
|
||||
this.cursors.addChild(ping);
|
||||
return ping.animate();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given off-screen coordinates, determine the closest point at the edge of the viewport to these coordinates.
|
||||
* @param {Point} position The off-screen coordinates.
|
||||
* @returns {{ray: Ray, intersection: LineIntersection|null}} The closest point at the edge of the viewport to these
|
||||
* coordinates and a ray cast from the centre of the
|
||||
* screen towards it.
|
||||
* @private
|
||||
*/
|
||||
_findViewportIntersection(position) {
|
||||
let { clientWidth: w, clientHeight: h } = document.documentElement;
|
||||
// Accommodate the sidebar.
|
||||
if ( !ui.sidebar._collapsed ) w -= ui.sidebar.options.width + 10;
|
||||
const [cx, cy] = [w / 2, h / 2];
|
||||
const ray = new Ray({x: cx, y: cy}, canvas.clientCoordinatesFromCanvas(position));
|
||||
const bounds = [[0, 0, w, 0], [w, 0, w, h], [w, h, 0, h], [0, h, 0, 0]];
|
||||
const intersections = bounds.map(ray.intersectSegment.bind(ray));
|
||||
const intersection = intersections.find(i => i !== null);
|
||||
return { ray, intersection };
|
||||
}
|
||||
}
|
||||
903
resources/app/client/pixi/layers/controls/ruler.js
Normal file
903
resources/app/client/pixi/layers/controls/ruler.js
Normal file
@@ -0,0 +1,903 @@
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user