From 357996569d35e0575fe20914ea962460c65620e6 Mon Sep 17 00:00:00 2001 From: Will Bennion Date: Wed, 29 Jan 2025 23:18:42 +0000 Subject: [PATCH] Update rulers where the start/end points are tokens to track those tokens when they move. Refactor the ruler layer to have `LineOfSightRulerGroup`s, which manage a set of rulers. Each ruler in the group can have a start/end point that is either a Token or a 3D point. If either start/end is a Token, then can choose whether to draw just centre-to-centre or also include edge-to-edge sub-rulers. When a token is refreshed, the rulers are re-drawn. Add a `current` static property to LineOfSightRulerLayer and TerrainHeightLayer. These return the relevant layer from the canvas, simplifying other code and improving intellisense. --- docs/api.md | 2 +- module/api.mjs | 67 ++-- .../token-line-of-sight-config.mjs | 5 - module/config/controls.mjs | 8 +- module/config/keybindings.mjs | 9 +- module/config/settings.mjs | 15 +- module/hooks/token-elevation.mjs | 4 +- module/layers/line-of-sight-ruler-layer.mjs | 312 ++++++++++-------- module/layers/terrain-height-graphics.mjs | 5 +- module/layers/terrain-height-layer.mjs | 5 + module/main.mjs | 8 +- module/utils/misc-utils.mjs | 11 + module/utils/token-utils.mjs | 70 ++++ 13 files changed, 308 insertions(+), 213 deletions(-) diff --git a/docs/api.md b/docs/api.md index b67f5ce..16eef71 100644 --- a/docs/api.md +++ b/docs/api.md @@ -47,7 +47,7 @@ Represents a point in 3D space. ![Available Since v0.3.0](https://img.shields.io/badge/Available%20Since-v0.3.0-blue?style=flat-square) ![Changed in v0.4.0](https://img.shields.io/badge/Changed%20In-v0.4.0-orange?style=flat-square) -Computes a line sight test between two points in 3d space. +Computes a line of sight test between two points in 3d space. Note that this will always return an empty array if the line of sight ray is zero-length, even if the start/end point is within a shape. diff --git a/module/api.mjs b/module/api.mjs index 6af1a37..e85e2ef 100644 --- a/module/api.mjs +++ b/module/api.mjs @@ -4,7 +4,9 @@ import { defaultGroupName, moduleName, settings } from "./consts.mjs"; import { HeightMap } from "./geometry/height-map.mjs"; import { LineOfSightRulerLayer } from "./layers/line-of-sight-ruler-layer.mjs"; +import { TerrainHeightLayer } from "./layers/terrain-height-layer.mjs"; import { getTerrainTypes } from "./utils/terrain-types.mjs"; +import { calculateRaysBetweenTokensOrPoints } from "./utils/token-utils.mjs"; export { getTerrainTypes } from "./utils/terrain-types.mjs"; @@ -30,9 +32,7 @@ export function getTerrainType(terrain) { * @returns {{ terrainTypeId: string; height: number; elevation: number; }[]} */ export function getCell(x, y) { - /** @type {import("./geometry/height-map.mjs").HeightMap} */ - const hm = game.canvas.terrainHeightLayer._heightMap; - return hm.get(y, x); + return TerrainHeightLayer.current?._heightMap.get(y, x); } /** @@ -42,9 +42,7 @@ export function getCell(x, y) { * @param {import("./geometry/height-map.mjs").HeightMapShape | undefined} */ export function getShapes(x, y) { - /** @type {import("./geometry/height-map.mjs").HeightMap} */ - const hm = game.canvas.terrainHeightLayer._heightMap; - return hm.getShapes(y, x); + return TerrainHeightLayer.current?._heightMap.getShapes(y, x); } /** @@ -72,9 +70,7 @@ export function paintCells(cells, terrain, { mode = "totalReplace" } = {}) { if (terrainType.usesHeight && typeof terrain.height !== "number") throw new Error(`Terrain "${terrainType.name}' requires a height, but one was not provided.`); - /** @type {import("./geometry/height-map.mjs").HeightMap} */ - const hm = game.canvas.terrainHeightLayer._heightMap; - return hm.paintCells(cells, terrainType.id, terrain.height ?? 0, terrain.elevation ?? 0, { mode }); + return TerrainHeightLayer.current?._heightMap.paintCells(cells, terrainType.id, terrain.height ?? 0, terrain.elevation ?? 0, { mode }); } /** @@ -87,9 +83,7 @@ export function eraseCells(cells) { throw new Error("Expected `cells` to be an array of arrays."); if (cells.length === 0) return; - /** @type {import("./geometry/height-map.mjs").HeightMap} */ - const hm = game.canvas.terrainHeightLayer._heightMap; - return hm.eraseCells(cells); + return TerrainHeightLayer.current?._heightMap.eraseCells(cells); } /** @@ -117,9 +111,7 @@ export function calculateLineOfSight(p1, p2, options = {}) { * @returns {{ shape: import('./geometry/height-map.mjs').HeightMapShape; regions: import('./geometry/height-map-shape.mjs').LineOfSightIntersectionRegion[]; }[]} */ export function calculateLineOfSightByShape(p1, p2, options = {}) { - /** @type {import("./geometry/height-map.mjs").HeightMap} */ - const hm = game.canvas.terrainHeightLayer._heightMap; - return hm.calculateLineOfSight(p1, p2, options); + return TerrainHeightLayer.current?._heightMap.calculateLineOfSight(p1, p2, options); } /** @@ -137,7 +129,7 @@ export function calculateLineOfSightByShape(p1, p2, options = {}) { */ export function calculateLineOfSightRaysBetweenTokens(token1, token2, { token1RelativeHeight, token2RelativeHeight } = {}) { const defaultRelativeHeight = game.settings.get(moduleName, settings.defaultTokenLosTokenHeight); - const [left, centre, right] = LineOfSightRulerLayer._calculateRaysBetweenTokens(token1, token2, token1RelativeHeight ?? defaultRelativeHeight, token2RelativeHeight ?? defaultRelativeHeight); + const { left, centre, right } = calculateRaysBetweenTokensOrPoints(token1, token2, token1RelativeHeight ?? defaultRelativeHeight, token2RelativeHeight ?? defaultRelativeHeight); return { left: { p1: left[0], p2: left[1] }, centre: { p1: centre[0], p2: centre[1] }, @@ -159,24 +151,30 @@ export function calculateLineOfSightRaysBetweenTokens(token1, token2, { token1Re * @param {} [options.showLabels=true] Whether height labels are shown at the start and end of the ruler. */ export function drawLineOfSightRay(p1, p2, { group = defaultGroupName, drawForOthers = true, includeNoHeightTerrain = false, showLabels = true } = {}) { - if (!LineOfSightRulerLayer._isPoint3d(p1)) throw new Error("`p1` is not a valid Point3D. Expected an object with `x`, `y`, and `h` properties."); - if (!LineOfSightRulerLayer._isPoint3d(p2)) throw new Error("`p2` is not a valid Point3D. Expected an object with `x`, `y`, and `h` properties."); - return drawLineOfSightRays([{ p1, p2, options: { includeNoHeightTerrain, showLabels } }], { group, drawForOthers }); + LineOfSightRulerLayer.current?._drawLineOfSightRays([{ + a: p1, + b: p2, + includeNoHeightTerrain, + showLabels + }], { group, drawForOthers }); } /** * Calculates and draws any number of line of sight rays between the given points. * Note that this will clear all previously drawn lines, INCLUDING those drawn by the tools in the side bar. - * @param {({ p1: import("./layers/line-of-sight-ruler-layer.mjs").Point3D; p2: import("./layers/line-of-sight-ruler-layer.mjs").Point3D; } & import("./layers/line-of-sight-ruler-layer.mjs").RulerOptions)[]} rays + * @param {import("./layers/line-of-sight-ruler-layer.mjs").LineOfSightRulerConfiguration[]} rays * @param {Object} [options={}] Options that change for the lines are drawn. * @param {string} [options.group] The name for this group of rulers. It is strongly recommended to provide a value for * this. Recommended to use something unique, e.g. `"my-module-name"` or `"my-module-name.group1"`. * @param {boolean} [options.drawForOthers=true] Whether to draw these rays for other users connected to the game. */ export function drawLineOfSightRays(rays, { group = defaultGroupName, drawForOthers = true } = {}) { - /** @type {LineOfSightRulerLayer} */ - const ruler = game.canvas.terrainHeightLosRulerLayer; - ruler._drawLineOfSightRays(rays.map(({ p1, p2, ...options }) => [p1, p2, options]), { group, drawForOthers }); + // For legacy reasons, if a and b are not provided, use p1 and p2. + LineOfSightRulerLayer.current?._drawLineOfSightRays(rays.map(ray => ({ + ...ray, + a: ray.a ?? ray.p1, + b: ray.b ?? ray.p2 + })), { group, drawForOthers }); } /** @@ -197,17 +195,16 @@ export function drawLineOfSightRays(rays, { group = defaultGroupName, drawForOth * @param {boolean} [options.includeNoHeightTerrain=false] If true, terrain types that are configured as not using a * height value will be included in the return list. They are treated as having infinite height. * @param {boolean} [options.drawForOthers] Whether to draw these rays for other users connected to the game. + * @param {boolean} [options.includeEdges] Whether to include edge-to-edge rulers between tokens. */ -export function drawLineOfSightRaysBetweenTokens(token1, token2, { group = defaultGroupName, token1RelativeHeight, token2RelativeHeight, includeNoHeightTerrain = false, drawForOthers = true } = {}) { - const { left, centre, right } = calculateLineOfSightRaysBetweenTokens(token1, token2, { token1RelativeHeight, token2RelativeHeight }); - - /** @type {LineOfSightRulerLayer} */ - const ruler = game.canvas.terrainHeightLosRulerLayer; - ruler._drawLineOfSightRays([ - [left.p1, left.p2, { includeNoHeightTerrain, showLabels: false }], - [centre.p1, centre.p2, { includeNoHeightTerrain, showLabels: true }], - [right.p1, right.p2, { includeNoHeightTerrain, showLabels: false }], - ], { group, drawForOthers }); +export function drawLineOfSightRaysBetweenTokens(token1, token2, { group = defaultGroupName, token1RelativeHeight, token2RelativeHeight, includeNoHeightTerrain = false, drawForOthers = true, includeEdges = true } = {}) { + const defaultRelativeHeight = game.settings.get(moduleName, settings.defaultTokenLosTokenHeight); + LineOfSightRulerLayer.current?._drawLineOfSightRays([{ + a: token1, ah: token1RelativeHeight ?? defaultRelativeHeight, + b: token2, bh: token2RelativeHeight ?? defaultRelativeHeight, + includeNoHeightTerrain, + includeEdges + }], { group, drawForOthers }); } /** @@ -217,7 +214,5 @@ export function drawLineOfSightRaysBetweenTokens(token1, token2, { group = defau * this. Recommended to use something unique, e.g. `"my-module-name"` or `"my-module-name.group1"`. */ export function clearLineOfSightRays({ group = defaultGroupName } = {}) { - /** @type {LineOfSightRulerLayer} */ - const ruler = game.canvas.terrainHeightLosRulerLayer; - ruler._clearLineOfSightRays({ group, clearForOthers: true }); + LineOfSightRulerLayer.current?._clearLineOfSightRays({ group, clearForOthers: true }); } diff --git a/module/applications/token-line-of-sight-config.mjs b/module/applications/token-line-of-sight-config.mjs index a98606d..6d2f9d5 100644 --- a/module/applications/token-line-of-sight-config.mjs +++ b/module/applications/token-line-of-sight-config.mjs @@ -22,11 +22,6 @@ export class TokenLineOfSightConfig extends withSubscriptions(Application) { }); } - /** @type {import("../layers/line-of-sight-ruler-layer.mjs").LineOfSightRulerLayer} */ - get #losLayer() { - return canvas.terrainHeightLosRulerLayer; - } - /** Whether or not the user is currently selecting a token. */ get _isSelecting() { return typeof this.#selectingToken$.value === "number"; diff --git a/module/config/controls.mjs b/module/config/controls.mjs index c9f9d7b..ddb2037 100644 --- a/module/config/controls.mjs +++ b/module/config/controls.mjs @@ -4,6 +4,8 @@ import { TerrainErasePalette } from "../applications/terrain-erase-palette.mjs"; import { TerrainPaintPalette } from "../applications/terrain-paint-palette.mjs"; import { TokenLineOfSightConfig } from "../applications/token-line-of-sight-config.mjs"; import { moduleName, settings, tools } from "../consts.mjs"; +import { LineOfSightRulerLayer } from "../layers/line-of-sight-ruler-layer.mjs"; +import { TerrainHeightLayer } from "../layers/terrain-height-layer.mjs"; import { Signal } from "../utils/signal.mjs"; export const sceneControls = { @@ -52,9 +54,7 @@ export function registerSceneControls(controls) { title: game.i18n.localize("CONTROLS.TerrainHeightToolsTokenLineOfSight"), icon: "fas fa-compass-drafting", onClick: () => { - /** @type {import("../layers/line-of-sight-ruler-layer.mjs").LineOfSightRulerLayer} */ - const ruler = game.canvas.terrainHeightLosRulerLayer; - ruler._autoSelectTokenLosTargets(); + LineOfSightRulerLayer.current?._autoSelectTokenLosTargets(); } }, sceneControls.terrainHeightToolsLayerToggleControlButton = { @@ -108,7 +108,7 @@ export function registerSceneControls(controls) { onClick: () => Dialog.confirm({ title: game.i18n.localize("TERRAINHEIGHTTOOLS.ClearConfirmTitle"), content: `

${game.i18n.format("TERRAINHEIGHTTOOLS.ClearConfirmContent")}

`, - yes: () => game.canvas.terrainHeightLayer?.clear() + yes: () => TerrainHeightLayer.current?.clear() }), button: true } diff --git a/module/config/keybindings.mjs b/module/config/keybindings.mjs index 0300fd1..4b1998c 100644 --- a/module/config/keybindings.mjs +++ b/module/config/keybindings.mjs @@ -1,4 +1,5 @@ import { keybindings, moduleName, settings } from "../consts.mjs"; +import { LineOfSightRulerLayer } from "../layers/line-of-sight-ruler-layer.mjs"; import { sceneControls } from "./controls.mjs"; export function registerKeybindings() { @@ -6,9 +7,7 @@ export function registerKeybindings() { name: "KEYBINDINGS.IncreaseLosRulerHeight", editable: [{ key: "Equal" }], onDown: () => { - /** @type {import("../layers/line-of-sight-ruler-layer.mjs").LineOfSightRulerLayer} */ - const ruler = canvas.terrainHeightLosRulerLayer; - ruler._handleHeightChangeKeybinding(1); + LineOfSightRulerLayer.current?._handleHeightChangeKeybinding(1); } }); @@ -16,9 +15,7 @@ export function registerKeybindings() { name: "KEYBINDINGS.DecreaseLosRulerHeight", editable: [{ key: "Minus" }], onDown: () => { - /** @type {import("../layers/line-of-sight-ruler-layer.mjs").LineOfSightRulerLayer} */ - const ruler = canvas.terrainHeightLosRulerLayer; - ruler._handleHeightChangeKeybinding(-1); + LineOfSightRulerLayer.current?._handleHeightChangeKeybinding(-1); } }); diff --git a/module/config/settings.mjs b/module/config/settings.mjs index 9903a3f..f1014c3 100644 --- a/module/config/settings.mjs +++ b/module/config/settings.mjs @@ -1,5 +1,6 @@ import { TerrainTypesConfig } from "../applications/terrain-types-config.mjs"; import { flags, moduleName, settings, terrainStackViewerDisplayModes, tokenRelativeHeights } from "../consts.mjs"; +import { TerrainHeightLayer } from "../layers/terrain-height-layer.mjs"; import { sceneControls } from "./controls.mjs"; export function registerSettings() { @@ -21,7 +22,7 @@ export function registerSettings() { config: false, onChange: () => { sceneControls.terrainPaintPalette?.render(false); - game.canvas.terrainHeightLayer._updateGraphics(); + TerrainHeightLayer.current?._updateGraphics(); globalThis.terrainHeightTools.ui.terrainStackViewer?.render(); } }); @@ -74,9 +75,7 @@ export function registerSettings() { config: false, default: true, onChange: value => { - /** @type {import("../layers/terrain-height-graphics.mjs").TerrainHeightGraphics} */ - const graphicsLayer = game.canvas.terrainHeightLayer._graphics; - graphicsLayer.showOnTokenLayer$.value = value; + TerrainHeightLayer.current._graphics.showOnTokenLayer$.value = value; } }); @@ -108,9 +107,7 @@ export function registerSettings() { config: true, default: 0, onChange: value => { - /** @type {import("../layers/terrain-height-graphics.mjs").TerrainHeightGraphics} */ - const graphicsLayer = game.canvas.terrainHeightLayer._graphics; - graphicsLayer.maskRadius$.value = value; + TerrainHeightLayer.current._graphics.maskRadius$.value = value; } }); @@ -158,7 +155,7 @@ export function registerSettings() { type: Boolean, config: true, default: true, - onChange: () => game.canvas.terrainHeightLayer._updateGraphics() + onChange: () => TerrainHeightLayer.current?._updateGraphics() }); game.settings.register(moduleName, settings.smartLabelPlacement, { @@ -168,7 +165,7 @@ export function registerSettings() { type: Boolean, config: true, default: true, - onChange: () => game.canvas.terrainHeightLayer._updateGraphics() + onChange: () => TerrainHeightLayer.current?._updateGraphics() }); } diff --git a/module/hooks/token-elevation.mjs b/module/hooks/token-elevation.mjs index 52771b3..d6fd7fb 100644 --- a/module/hooks/token-elevation.mjs +++ b/module/hooks/token-elevation.mjs @@ -1,4 +1,5 @@ import { moduleName, settings } from "../consts.mjs"; +import { TerrainHeightLayer } from "../layers/terrain-height-layer.mjs"; import { getCellsUnderTokenPosition, toSceneUnits } from "../utils/grid-utils.mjs"; import { getTerrainType } from "../utils/terrain-types.mjs"; @@ -54,8 +55,7 @@ export function handleTokenPreCreation(tokenDoc, _createData, _options, userId) * @param {boolean} isAltOrientation */ function getHighestTerrainUnderToken(position, isAltOrientation) { - /** @type {import("../geometry/height-map.mjs").HeightMap} */ - const hm = game.canvas.terrainHeightLayer._heightMap; + const hm = TerrainHeightLayer.current?._heightMap; let highest = 0; diff --git a/module/layers/line-of-sight-ruler-layer.mjs b/module/layers/line-of-sight-ruler-layer.mjs index 259d983..31244fa 100644 --- a/module/layers/line-of-sight-ruler-layer.mjs +++ b/module/layers/line-of-sight-ruler-layer.mjs @@ -1,15 +1,14 @@ import { sceneControls } from "../config/controls.mjs"; import { moduleName, settings, socketFuncs, socketName, tools } from "../consts.mjs"; import { HeightMap } from "../geometry/height-map.mjs"; -import { LineSegment } from "../geometry/line-segment.mjs"; -import { Polygon } from "../geometry/polygon.mjs"; import { includeNoHeightTerrain$, lineOfSightRulerConfig$, tokenLineOfSightConfig$ } from "../stores/line-of-sight.mjs"; -import { getGridCellPolygon, getGridCenter, getGridVerticesFromToken, toSceneUnits } from "../utils/grid-utils.mjs"; -import { prettyFraction } from "../utils/misc-utils.mjs"; +import { getGridCellPolygon, getGridCenter, toSceneUnits } from "../utils/grid-utils.mjs"; +import { isPoint3d, prettyFraction } from "../utils/misc-utils.mjs"; import { drawDashedPath } from "../utils/pixi-utils.mjs"; import { fromHook, join } from "../utils/signal.mjs"; import { getTerrainColor, getTerrainTypeMap } from "../utils/terrain-types.mjs"; -import { getTokenHeight } from "../utils/token-utils.mjs"; +import { calculateRaysBetweenTokensOrPoints } from "../utils/token-utils.mjs"; +import { TerrainHeightLayer } from "./terrain-height-layer.mjs"; /** * @typedef {Object} Point3D @@ -19,7 +18,13 @@ import { getTokenHeight } from "../utils/token-utils.mjs"; */ /** - * @typedef {Object} RulerOptions + * @typedef {Object} LineOfSightRulerConfiguration + * @property {Point3D | Token | string} a Either absolute XYH coordinates, a token, or a token ID. + * @property {Point3D | Token | string} b Either absolute XYH coordinates, a token, or a token ID. + * @property {number} [ah] When `a` is a token, the relative height of the ray in respect to that token. + * @property {number} [bh] When `b` is a token, the relative height of the ray in respect to that token. + * @property {boolean} [includeEdges] If at least either first or second are tokens, then this indicates whether to only + * draw centre-to-centre rulers (false) or include both edge-to-edge rulers also (true). Defaults to true. * @property {boolean} [includeNoHeightTerrain] * @property {boolean} [showLabels] */ @@ -31,9 +36,9 @@ export class LineOfSightRulerLayer extends CanvasLayer { /** * Map of users and groups to their rulers. - * @type {Map} + * @type {Map} */ - #rulers = new Map(); + #rulerGroups = new Map(); /** @type {LineOfSightRulerLineCap} */ #lineStartIndicator = undefined; @@ -53,7 +58,7 @@ export class LineOfSightRulerLayer extends CanvasLayer { // When any of the drag values are changed, update the ruler join(({ p1, h1, p2, h2 }, includeNoHeightTerrain) => { if (p1 && p2) - this._drawLineOfSightRays([[{ ...p1, h: h1 }, { ...p2, h: h2 ?? h1 }, { includeNoHeightTerrain }]], { drawForOthers: true }); + this._drawLineOfSightRays([{ a: { ...p1, h: h1 }, b: { ...p2, h: h2 ?? h1 }, includeNoHeightTerrain }], { drawForOthers: true }); else this._clearLineOfSightRays({ clearForOthers: true }); }, @@ -69,11 +74,8 @@ export class LineOfSightRulerLayer extends CanvasLayer { // When either of the selected tokens for the token LOS are changed, update the token LOS rulers. join(({ token1, token2, h1, h2 }, includeNoHeightTerrain, _) => { if (token1 && token2) { - const [leftRay, centreRay, rightRay] = LineOfSightRulerLayer._calculateRaysBetweenTokens(token1, token2, h1, h2); this._drawLineOfSightRays([ - [...leftRay, { includeNoHeightTerrain, showLabels: false }], - [...centreRay, { includeNoHeightTerrain, showLabels: true }], - [...rightRay, { includeNoHeightTerrain, showLabels: false }], + { a: token1, ah: h1, b: token2, bh: h2, includeNoHeightTerrain } ]); } else { this._clearLineOfSightRays(); @@ -95,6 +97,11 @@ export class LineOfSightRulerLayer extends CanvasLayer { }, lineOfSightRulerConfig$.p1$, sceneControls.activeControl$, sceneControls.activeTool$); } + /** @return {LineOfSightRulerLayer | undefined} */ + static get current() { + return canvas.terrainHeightLosRulerLayer; + } + get isToolSelected() { return game.activeTool === tools.lineOfSight; } @@ -134,7 +141,7 @@ export class LineOfSightRulerLayer extends CanvasLayer { /** * Draws one or more line of sight rulers on the map, from the given start and end points and the given intersection * regions. - * @param {[Point3D, Point3D, RulerOptions?][]} rulers The rulers to draw to the canvas. Each pair is the start and + * @param {LineOfSightRulerConfiguration[]} rulers The rulers to draw to the canvas. Each pair is the start and * end points and an optional configuration object. * @param {Object} [options] * @param {string} [options.group] The name of the group to draw these rulers in. @@ -144,15 +151,6 @@ export class LineOfSightRulerLayer extends CanvasLayer { * @param {boolean} [options.drawForOthers] If true, this ruler will be drawn on other user's canvases. */ _drawLineOfSightRays(rulers, { group = "default", userId = undefined, sceneId = undefined, drawForOthers = true } = {}) { - // Validate `ruler` param type - if (!Array.isArray(rulers)) throw new Error("`rulers` was not an array."); - for (let i = 0; i < rulers.length; i++) { - if (!LineOfSightRulerLayer._isPoint3d(rulers[i][0])) - throw new Error(`\`rulers[${i}][0]\` is not a Point3D (object with x, y and h numbers)`); - if (!LineOfSightRulerLayer._isPoint3d(rulers[i][1])) - throw new Error(`\`rulers[${i}][1]\` is not a Point3D (object with x, y and h numbers)`); - } - userId ??= game.userId; sceneId ??= canvas.scene.id; @@ -160,32 +158,30 @@ export class LineOfSightRulerLayer extends CanvasLayer { if (sceneId !== canvas.scene.id) return; // Get the ruler array - const mapKey = this.#getRulerMapKey(userId, group); - let userRulers = this.#rulers.get(mapKey); - if (!userRulers) { - this.#rulers.set(mapKey, userRulers = []); + const mapKey = this.#getRulerGroupMapKey(userId, group); + let rulerGroup = this.#rulerGroups.get(mapKey); + if (!rulerGroup) { + rulerGroup = new LineOfSightRulerGroup(Color.from(game.users.get(userId).color)); + this.addChild(rulerGroup); + this.#rulerGroups.set(mapKey, rulerGroup); } - // Ensure we have as many rulers as needed - while (userRulers.length < rulers.length) - userRulers.push(this.addChild(new LineOfSightRuler(Color.from(game.users.get(userId).color)))); - - while (userRulers.length > rulers.length) - this.removeChild(userRulers.pop()); - - // Update the rulers - for (let i = 0; i < rulers.length; i++) { - const { includeNoHeightTerrain = false, showLabels = true } = rulers[i][2] ?? {}; - userRulers[i].updateRuler(rulers[i][0], rulers[i][1], includeNoHeightTerrain); - userRulers[i].showLabels = showLabels; - userRulers[i].alpha = userId === game.userId ? 1 : game.settings.get(moduleName, settings.otherUserLineOfSightRulerOpacity); - } + // Update the rulers, converting token IDs into tokens + rulerGroup._updateConfig(rulers.map(r => ({ + ...r, + a: typeof r.a === "string" ? canvas.tokens.get(r.a) : r.a, + b: typeof r.b === "string" ? canvas.tokens.get(r.b) : r.b, + }))); // Draw for other players if (drawForOthers && userId === game.userId && this.#shouldShowUsersRuler) { game.socket.emit(socketName, { func: socketFuncs.drawLineOfSightRay, - args: [rulers, { group, userId, sceneId, drawForOthers: false }] + args: [ + // change tokens into token ids to be serialized + rulers.map(r => ({ ...r, a: r.a instanceof Token ? r.a.id : r.a, b: r.b instanceof Token ? r.b.id : r.b })), + { group, userId, sceneId, drawForOthers: false } + ] }); } } @@ -200,11 +196,11 @@ export class LineOfSightRulerLayer extends CanvasLayer { _clearLineOfSightRays({ group = "default", userId = undefined, clearForOthers = true } = {}) { userId ??= game.userId; - const mapKey = this.#getRulerMapKey(userId, group); - const userRulers = this.#rulers.get(mapKey); - if (userRulers) { - userRulers.forEach(ruler => this.removeChild(ruler)); - this.#rulers.delete(mapKey); + const mapKey = this.#getRulerGroupMapKey(userId, group); + const rulerGroup = this.#rulerGroups.get(mapKey); + if (rulerGroup) { + this.removeChild(rulerGroup); + this.#rulerGroups.delete(mapKey); } if (clearForOthers && userId === game.userId && this.#shouldShowUsersRuler) { @@ -215,67 +211,9 @@ export class LineOfSightRulerLayer extends CanvasLayer { } } - /** - * Given two tokens, calculates the centre-to-centre ray, and the two edge-to-edge rays for them. - * @param {Token} token1 - * @param {Token} token2 - * @param {number} token1RelativeHeight A number between 0-1 inclusive that specifies how far vertically relative to - * token1 the ray should spawn from. - * @param {number} token2RelativeHeight A number between 0-1 inclusive that specifies how far vertically relative to - * token2 the ray should end at. - * @returns {[Point3D, Point3D][]} - */ - static _calculateRaysBetweenTokens(token1, token2, token1RelativeHeight = 1, token2RelativeHeight = 1) { - if (!(token1 instanceof Token)) throw new Error("`token1` is not a Foundry Token"); - if (!(token2 instanceof Token)) throw new Error("`token2` is not a Foundry Token"); - if (token1 === token2) throw new Error("Cannot draw line of sight from a token to itself."); - - // Work out the vertices for each token - const token1Vertices = getGridVerticesFromToken(token1); - const token2Vertices = getGridVerticesFromToken(token2); - - // Find the midpoint of each token, and construct a ray between them - const token1Centroid = Polygon.centroid(token1Vertices); - const token2Centroid = Polygon.centroid(token2Vertices); - const centreToCentreRay = new LineSegment(token1Centroid, token2Centroid); - - // For each token, find the vertex that is furtherest away from the c2c ray on either side. These will be our - // two edge to edge rays. - const findOuterMostPoints = (/** @type {{ x: number; y: number; }[]} */ vertices) => { - const vertexCalculations = vertices - .map(({ x, y }) => ({ x, y, ...centreToCentreRay.findClosestPointOnLineTo(x, y) })) - .sort((a, b) => b.distanceSquared - a.distanceSquared); - return [vertexCalculations.find(v => v.side === 1), vertexCalculations.find(v => v.side === -1)]; - }; - const [token1Left, token1Right] = findOuterMostPoints(token1Vertices); - const [token2Left, token2Right] = findOuterMostPoints(token2Vertices); - - // Work out the h value for the tokens. This is how far the token is off the ground + the token's height. - // Note that this uses the assumption that the width and height of the token is it's h value. - const token1Doc = token1 instanceof Token ? token1.document : token1; - const token1Height = token1Doc.elevation + getTokenHeight(token1Doc) * token1RelativeHeight; - const token2Doc = token2 instanceof Token ? token2.document : token2; - const token2Height = token2Doc.elevation + getTokenHeight(token2Doc) * token2RelativeHeight; - - return [ - [ - { x: token1Left.x, y: token1Left.y, h: token1Height }, - { x: token2Left.x, y: token2Left.y, h: token2Height } - ], - [ - { x: token1Centroid.x, y: token1Centroid.y, h: token1Height }, - { x: token2Centroid.x, y: token2Centroid.y, h: token2Height } - ], - [ - { x: token1Right.x, y: token1Right.y, h: token1Height }, - { x: token2Right.x, y: token2Right.y, h: token2Height } - ], - ]; - } - #clearAllCurrentUserRulers() { - this.#rulers.forEach(rulers => rulers.forEach(r => this.removeChild(r))); - this.#rulers.clear(); + this.#rulerGroups.forEach(group => this.removeChild(group)); + this.#rulerGroups.clear(); lineOfSightRulerConfig$.value = { p1: undefined, @@ -288,6 +226,10 @@ export class LineOfSightRulerLayer extends CanvasLayer { }; } + /** + * Whether the current user's ruler should be shown to other users. + * @return {boolean} + */ get #shouldShowUsersRuler() { return game.settings.get(moduleName, game.user.isGM ? settings.displayLosMeasurementGm : settings.displayLosMeasurementPlayer); } @@ -316,14 +258,24 @@ export class LineOfSightRulerLayer extends CanvasLayer { } /** - * Gets the key to use in the `#rulers` map. + * Gets the key to use in the `#rulerGroups` map. * @param {string} userId * @param {string} groupName */ - #getRulerMapKey(userId, groupName) { + #getRulerGroupMapKey(userId, groupName) { return `${userId}|${groupName}`; } + /** + * When a token is refreshed, pass that along to any groups. + * If a token is being tracked for a ruler, that ruler will be re-drawn. + */ + _onTokenRefresh(token) { + for (const group of this.#rulerGroups.values()) { + group._onTokenRefresh(token); + } + } + // ----------------------------- // // Mouse/keyboard event handling // // ----------------------------- // @@ -427,50 +379,89 @@ export class LineOfSightRulerLayer extends CanvasLayer { lineOfSightRulerConfig$.h1$.value = change(lineOfSightRulerConfig$.h1$.value); } } - - // ---- // - // Util // - // ---- // - /** - * @param {*} obj - * @returns {obj is Point3D} - */ - static _isPoint3d(obj) { - return typeof obj === "object" - && typeof obj.x === "number" - && typeof obj.y === "number" - && typeof obj.h === "number"; - } } -class LineOfSightRulerLineCap extends PIXI.Container { +class LineOfSightRulerGroup extends PIXI.Container { - /** @type {PreciseText} */ - #text; + /** @type {{ config: LineOfSightRulerConfiguration; rulers: LineOfSightRuler[]; }[]} */ + #rulers = []; + + #color; /** @param {number} color */ constructor(color = 0xFFFFFF) { super(); - this.#text = this.addChild(new PreciseText("", CONFIG.canvasTextStyle.clone())); - this.#text.anchor.set(0, 0.5); - this.#text.position.set(heightIndicatorXOffset, 0); - this.#text.style.fill = color; + this.#color = color; + } - this.addChild(new PIXI.Graphics()) - .beginFill(color, 0.5) - .lineStyle({ color: 0x000000, alpha: 0.25, width: 2 }) - .drawCircle(0, 0, 6); + /** + * Updates the group's rulers with the new config. + * @param {LineOfSightRulerConfiguration[]} rulers + */ + _updateConfig(rulers) { + // Validate config + if (!Array.isArray(rulers)) + throw new Error("Expected `rulers` to be an array."); + + for (let i = 0; i < rulers.length; i++) { + if (!(rulers[i].a instanceof Token || isPoint3d(rulers[i].a))) + throw new Error(`\`rulers[${i}].a\` is not a Token or a Point3D (object with x, y and h numbers)`); + if (!(rulers[i].b instanceof Token || isPoint3d(rulers[i].b))) + throw new Error(`\`rulers[${i}].b\` is not a Token or a Point3D (object with x, y and h numbers)`); + } + + // Update + while (this.#rulers.length > rulers.length) { + const removed = this.#rulers.pop(); + removed.rulers.forEach(r => this.removeChild(r)); + } + + while (this.#rulers.length < rulers.length) { + this.#rulers.push({ config: {}, rulers: [] }); + } + + for (let i = 0; i < rulers.length; i++) { + this.#rulers[i].config = { ...rulers[i] }; + this.#redrawRulers(this.#rulers[i]); + } } - /** @param {number} value */ - set height(value) { - this.#text.text = `H${prettyFraction(toSceneUnits(value))}`; + /** + * Redraws the ruler(s) at the given index. + * @param {{ config: LineOfSightRulerConfiguration; rulers: LineOfSightRuler[]; }} args + */ + #redrawRulers({ config, rulers }) { + // Work out whether we need to create/destroy any individual rulers or not + const hasEdgeToEdge = (config.a instanceof Token || config.b instanceof Token) && config.includeEdges !== false; + const nRulers = hasEdgeToEdge ? 3 : 1; + + while (rulers.length > nRulers) + this.removeChild(rulers.pop()); + + while (rulers.length < nRulers) + rulers.push(this.addChild(new LineOfSightRuler(this.#color))); + + // Redraw individual rulers + const points = calculateRaysBetweenTokensOrPoints(config.a, config.b, config.ah, config.bh); + + rulers[0].updateRuler(points.centre[0], points.centre[1], config.includeNoHeightTerrain ?? false, true); + if (nRulers === 3) { + rulers[1].updateRuler(points.left[0], points.left[1], config.includeNoHeightTerrain ?? false, false); + rulers[2].updateRuler(points.right[0], points.right[1], config.includeNoHeightTerrain ?? false, false); + } } - /** @param {boolean} value */ - set showLabels(value) { - this.#text.visible = value; + /** + * Indicates that the given token has been refreshed. If any rulers are tracking that token, they will be re-drawn. + * @param {Token} token + */ + _onTokenRefresh(token) { + for (const ruler of this.#rulers) { + if (ruler.config.a === token || ruler.config.b === token) { + this.#redrawRulers(ruler); + } + } } } @@ -515,8 +506,9 @@ class LineOfSightRuler extends PIXI.Container { * @param {Point3D} p1 * @param {Point3D} p2 * @param {boolean} includeNoHeightTerrain + * @param {boolean} [showLabels] */ - updateRuler(p1, p2, includeNoHeightTerrain) { + updateRuler(p1, p2, includeNoHeightTerrain, showLabels = undefined) { // If the points haven't actually changed, don't need to do any recalculations/redraws let hasChanged = false; @@ -539,11 +531,14 @@ class LineOfSightRuler extends PIXI.Container { this._recalculateLos(); this._draw(); } + + if (typeof showLabels === "boolean") { + this.showLabels = showLabels; + } } _recalculateLos() { - /** @type {import("../geometry/height-map.mjs").HeightMap} */ - const hm = game.canvas.terrainHeightLayer._heightMap; + const hm = TerrainHeightLayer.current?._heightMap; const intersectionRegions = hm.calculateLineOfSight(this.#p1, this.#p2, { includeNoHeightTerrain: this.#includeNoHeightTerrain }); this.#intersectionRegions = HeightMap.flattenLineOfSightIntersectionRegions(intersectionRegions); } @@ -609,3 +604,34 @@ class LineOfSightRuler extends PIXI.Container { this.#endCap.position.set(this.#p2.x, this.#p2.y); } } + +class LineOfSightRulerLineCap extends PIXI.Container { + + /** @type {PreciseText} */ + #text; + + /** @param {number} color */ + constructor(color = 0xFFFFFF) { + super(); + + this.#text = this.addChild(new PreciseText("", CONFIG.canvasTextStyle.clone())); + this.#text.anchor.set(0, 0.5); + this.#text.position.set(heightIndicatorXOffset, 0); + this.#text.style.fill = color; + + this.addChild(new PIXI.Graphics()) + .beginFill(color, 0.5) + .lineStyle({ color: 0x000000, alpha: 0.25, width: 2 }) + .drawCircle(0, 0, 6); + } + + /** @param {number} value */ + set height(value) { + this.#text.text = `H${prettyFraction(toSceneUnits(value))}`; + } + + /** @param {boolean} value */ + set showLabels(value) { + this.#text.visible = value; + } +} diff --git a/module/layers/terrain-height-graphics.mjs b/module/layers/terrain-height-graphics.mjs index 4c6bcef..d974356 100644 --- a/module/layers/terrain-height-graphics.mjs +++ b/module/layers/terrain-height-graphics.mjs @@ -7,6 +7,7 @@ import { prettyFraction } from "../utils/misc-utils.mjs"; import { drawDashedPath } from "../utils/pixi-utils.mjs"; import { join, Signal } from "../utils/signal.mjs"; import { getTerrainTypeMap } from '../utils/terrain-types.mjs'; +import { TerrainHeightLayer } from "./terrain-height-layer.mjs"; /** * The positions relative to the shape that the label placement algorithm will test, both horizontal and vertical. @@ -178,7 +179,7 @@ export class TerrainHeightGraphics extends PIXI.Container { // Remove previous mask this.#shapes.forEach(shape => shape._setMask(null)); if (this.cursorRadiusMask) this.removeChild(this.cursorRadiusMask); - game.canvas.terrainHeightLayer._eventListenerObj?.off("globalmousemove", this.#updateCursorMaskPosition); + TerrainHeightLayer.current?._eventListenerObj?.off("globalmousemove", this.#updateCursorMaskPosition); // Stop here if not applying a new mask. We are not applying a mask if: // - The radius is 0, i.e. no mask @@ -213,7 +214,7 @@ export class TerrainHeightGraphics extends PIXI.Container { // Set mask this.#shapes.forEach(shape => shape._setMask(this.cursorRadiusMask)); - game.canvas.terrainHeightLayer._eventListenerObj.on("globalmousemove", this.#updateCursorMaskPosition); + TerrainHeightLayer.current?._eventListenerObj.on("globalmousemove", this.#updateCursorMaskPosition); } #updateCursorMaskPosition = event => { diff --git a/module/layers/terrain-height-layer.mjs b/module/layers/terrain-height-layer.mjs index 18d7c94..b9077c1 100644 --- a/module/layers/terrain-height-layer.mjs +++ b/module/layers/terrain-height-layer.mjs @@ -50,6 +50,11 @@ export class TerrainHeightLayer extends InteractionLayer { Hooks.on("updateScene", this._onSceneUpdate.bind(this)); } + /** @return {TerrainHeightLayer | undefined} */ + static get current() { + return canvas.terrainHeightLayer; + } + /** @override */ static get layerOptions() { return mergeObject(super.layerOptions, { diff --git a/module/main.mjs b/module/main.mjs index 7fd60da..e250cd0 100644 --- a/module/main.mjs +++ b/module/main.mjs @@ -16,6 +16,7 @@ Hooks.on("renderSceneControls", renderToolSpecificApplications); Hooks.on("renderSceneConfig", addAboveTilesToSceneConfig); Hooks.on("preCreateToken", handleTokenPreCreation); Hooks.on("preUpdateToken", handleTokenElevationChange); +Hooks.on("refreshToken", token => LineOfSightRulerLayer.current?._onTokenRefresh(token)); Object.defineProperty(globalThis, "terrainHeightTools", { value: { @@ -98,15 +99,12 @@ function initLibWrapper() { function handleSocketEvent({ func, args }) { switch (func) { case socketFuncs.drawLineOfSightRay: { - /** @type {import("./layers/line-of-sight-ruler-layer.mjs").LineOfSightRulerLayer | undefined} */ - const losRulerLayer = canvas.terrainHeightLosRulerLayer; - losRulerLayer?._drawLineOfSightRays(...args); + LineOfSightRulerLayer.current?._drawLineOfSightRays(...args); break; } case socketFuncs.clearLineOfSightRay: { - const losRulerLayer = canvas.terrainHeightLosRulerLayer; - losRulerLayer?._clearLineOfSightRays(...args); + LineOfSightRulerLayer.current?._clearLineOfSightRays(...args); break; } } diff --git a/module/utils/misc-utils.mjs b/module/utils/misc-utils.mjs index 44a4060..f9d7bb0 100644 --- a/module/utils/misc-utils.mjs +++ b/module/utils/misc-utils.mjs @@ -35,6 +35,17 @@ export function alphaToHex(a) { return Math.round(a * 255).toString(16).padStart(2, "0"); } +/** +* @param {*} obj +* @returns {obj is Point3D} +*/ +export function isPoint3d(obj) { + return typeof obj === "object" + && typeof obj.x === "number" + && typeof obj.y === "number" + && typeof obj.h === "number"; +} + /** * A Set that can be iterated in order the items were added. When iterating, any additional items added to the set while * iterating over it will also be included in the current iterator. diff --git a/module/utils/token-utils.mjs b/module/utils/token-utils.mjs index b378405..a36584d 100644 --- a/module/utils/token-utils.mjs +++ b/module/utils/token-utils.mjs @@ -1,3 +1,9 @@ +/** @import { Point3D } from "../layers/line-of-sight-ruler-layer.mjs" */ +import { LineSegment } from "../geometry/line-segment.mjs"; +import { Polygon } from "../geometry/polygon.mjs"; +import { getGridVerticesFromToken } from "./grid-utils.mjs"; +import { isPoint3d } from "./misc-utils.mjs"; + /** * Gets the vertical height of a token from the given token document. * @param {TokenDocument} tokenDoc @@ -16,3 +22,67 @@ export function getTokenHeight(tokenDoc) { return tokenDoc.width; } } + +/** + * Given two tokens or points, calculates the centre-to-centre ray, and the two edge-to-edge rays for them. + * @param {Token | Point3D} a + * @param {Token | Point3D} b + * @param {number} token1RelativeHeight A number between 0-1 inclusive that specifies how far vertically relative to + * token1 the ray should spawn from. + * @param {number} token2RelativeHeight A number between 0-1 inclusive that specifies how far vertically relative to + * token2 the ray should end at. + * @returns {Record<"left" | "centre" | "right", [Point3D, Point3D]>} + */ +export function calculateRaysBetweenTokensOrPoints(a, b, token1RelativeHeight = 1, token2RelativeHeight = 1) { + if (!(a instanceof Token || isPoint3d(a))) throw new Error("`token1` is not a Token or Point3D"); + if (!(b instanceof Token || isPoint3d(b))) throw new Error("`token2` is not a Token or Point3D"); + if (a === b) throw new Error("Cannot draw line of sight from a token to itself."); + + // If both a and b are points, can skip over the below calculations + if (!(a instanceof Token) && !(b instanceof Token)) { + return { left: [a, b], centre: [a, b], right: [a, b] }; + } + + // Work out the vertices for each token + const aVertices = a instanceof Token ? getGridVerticesFromToken(a) : [a]; + const bVertices = b instanceof Token ? getGridVerticesFromToken(b) : [b]; + + // Find the midpoint of each token, and construct a ray between them + const aCentroid = Polygon.centroid(aVertices); + const bCentroid = Polygon.centroid(bVertices); + const centreToCentreRay = new LineSegment(aCentroid, bCentroid); + + // For each token, find the vertex that is furtherest away from the c2c ray on either side. These will be our + // two edge to edge rays. + const findOuterMostPoints = (/** @type {{ x: number; y: number; }[]} */ vertices) => { + if (vertices.length === 1) // if it was a point, not a token then just use that point as the outermost points + return [vertices[0], vertices[0]]; + + const vertexCalculations = vertices + .map(({ x, y }) => ({ x, y, ...centreToCentreRay.findClosestPointOnLineTo(x, y) })) + .sort((a, b) => b.distanceSquared - a.distanceSquared); + return [vertexCalculations.find(v => v.side === 1), vertexCalculations.find(v => v.side === -1)]; + }; + const [aLeft, aRight] = findOuterMostPoints(aVertices); + const [bLeft, bRight] = findOuterMostPoints(bVertices); + + // Work out the h value for the tokens. This is how far the token is off the ground + the token's height. + // Note that this uses the assumption that the width and height of the token is it's h value. + const aHeight = a instanceof Token ? a.document.elevation + getTokenHeight(a.document) * token1RelativeHeight : a.h; + const bHeight = b instanceof Token ? b.document.elevation + getTokenHeight(b.document) * token2RelativeHeight : b.h; + + return { + left: [ + { x: aLeft.x, y: aLeft.y, h: aHeight }, + { x: bLeft.x, y: bLeft.y, h: bHeight } + ], + centre: [ + { x: aCentroid.x, y: aCentroid.y, h: aHeight }, + { x: bCentroid.x, y: bCentroid.y, h: bHeight } + ], + right: [ + { x: aRight.x, y: aRight.y, h: aHeight }, + { x: bRight.x, y: bRight.y, h: bHeight } + ], + }; +}