Files
Foundry-VTT-Docker/resources/app/client/pixi/layers/controls/layer.js
2025-01-04 00:34:03 +01:00

386 lines
13 KiB
JavaScript

/**
* 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 };
}
}