From 2979e86201f411003d6b09fa6fa489fab3727bf1 Mon Sep 17 00:00:00 2001 From: Jonathan Calvert <38069151+JDCalvert@users.noreply.github.com> Date: Wed, 9 Mar 2022 17:40:43 +0000 Subject: [PATCH] Improve Pathfinding: Add background caching (#175) --- js/data_structures.js | 58 ++++++- js/hex_support.js | 93 +++++++++++ js/main.js | 17 +- js/pathfinding.js | 350 ++++++++++++++++++++++++++++++++---------- js/util.js | 49 ++++-- 5 files changed, 471 insertions(+), 96 deletions(-) create mode 100644 js/hex_support.js diff --git a/js/data_structures.js b/js/data_structures.js index bc98699..8450913 100644 --- a/js/data_structures.js +++ b/js/data_structures.js @@ -1,7 +1,7 @@ /** * A combination queue/set where the elements are ordered (in ascending order, according to the given priority function) * and unique (according to the given elementMatcher). - * + * * If an element is added to the set and an equivalent element already exists, the lower-priority one is discarded. */ export class PriorityQueueSet { @@ -80,3 +80,59 @@ export class PriorityQueueSet { return first?.value; } } + +/** + * Queue that will only ever accept elements with a given value once. Elements must have a "value" field, the + * JSON representation of which will be used as the key to match + */ +export class ProcessOnceQueue { + constructor() { + this.first = null; + this.last = null; + this.previouslyQueued = new Set(); + } + + /** + * Remove everything from the queue and forget all the previously-queued items + */ + reset() { + this.first = null; + this.last = null; + this.previouslyQueued.clear(); + } + + push(element) { + if (this.previouslyQueued.has(element)) { + return; + } + this.previouslyQueued.add(element); + + const newNode = { + value: element, + next: null, + previous: null + } + + if (!this.first) { + this.first = newNode; + this.last = newNode; + } else { + this.last.next = newNode; + newNode.previous = this.last; + this.last = newNode; + } + } + + pop() { + const node = this.first; + this.first = node?.next; + if (!node?.next) { + this.last = null; + } + return node?.value; + } + + hasNext() { + return !!this.first; + } +} diff --git a/js/hex_support.js b/js/hex_support.js new file mode 100644 index 0000000..8d9f349 --- /dev/null +++ b/js/hex_support.js @@ -0,0 +1,93 @@ +/** + * Functions taken directly from the hex-size-support module (https://github.com/Ourobor/Hex-Size-Support/releases/tag/1.1.0). + * Unless otherwise stated, these functions are taken as-is. + */ + +/** + * Altered version of this function. + * - Instead of taking a token as a parameter to retrieve the altOrientationFlag, receive the flag value directly + * - Instead of taking a grid parameter, get the grid value from the globas canvas + */ +export function findVertexSnapPoint(x, y, altOrientationFlag) { + const grid = canvas.grid.grid; + if (grid.columns) { + return findSnapPointCols(x, y, grid.h, grid.w, altOrientationFlag); + } else { + return findSnapPointRows(x, y, grid.h, grid.w, altOrientationFlag); + } +} + +function findSnapPointRows(x, y, h, w, alt) { + let xOffset = 0.0 + if (canvas.grid.grid.even) { + xOffset = -0.5 + } + + let yOffset1 = 0.75 + let yOffset2 = 0.00 + if (alt) { + yOffset1 = 0.25 + yOffset2 = 1.00 + } + + let row1 = calculateSnapPointsRows(x, y, h, w, 0.5 + xOffset, yOffset1); + let row2 = calculateSnapPointsRows(x, y, h, w, 1.0 + xOffset, yOffset2); + + let dist1 = Math.pow((row1.x - x), 2) + Math.pow((row1.y - y), 2) + let dist2 = Math.pow((row2.x - x), 2) + Math.pow((row2.y - y), 2) + + if (dist1 < dist2) { + return row1 + } + else { + return row2 + } +} + +function calculateSnapPointsRows(x, y, h, w, xOff, yOff) { + let c = Math.floor(((x + ((0.5 - xOff) * w)) / w) + 1) + let r = Math.floor(((y + ((0.75 - yOff) * h)) / (1.5 * h)) + 1) + + let snapX = (c * w) - ((1 - xOff) * w) + let snapY = (r * h * 1.5) - ((1.5 - yOff) * h) + + return {x: snapX, y: snapY} +} + +function findSnapPointCols(x, y, h, w, alt) { + let yOffset = 0.0 + if (canvas.grid.grid.even) { + yOffset = -0.5 + } + + let xOffset1 = 0.25 + let xOffset2 = 1.00 + if (alt) { + xOffset1 = 0.75 + xOffset2 = 0.00 + } + + let row1 = calculateSnapPointsCols(x, y, h, w, xOffset1, 0.5 + yOffset); + let row2 = calculateSnapPointsCols(x, y, h, w, xOffset2, 1.0 + yOffset); + + let dist1 = Math.pow((row1.x - x), 2) + Math.pow((row1.y - y), 2) + let dist2 = Math.pow((row2.x - x), 2) + Math.pow((row2.y - y), 2) + + if (dist1 < dist2) { + return row1 + } + else { + return row2 + } + +} + +function calculateSnapPointsCols(x, y, h, w, xOff, yOff) { + let c = Math.floor(((x + ((0.75 - xOff) * w)) / (1.5 * w)) + 1) + let r = Math.floor(((y + ((0.5 - yOff) * h)) / h) + 1) + + let snapX = (c * w * 1.5) - ((1.5 - xOff) * w) + let snapY = (r * h) - ((1 - yOff) * h) + + return {x: snapX, y: snapY} +} diff --git a/js/main.js b/js/main.js index 0cf1f4e..0e14968 100644 --- a/js/main.js +++ b/js/main.js @@ -7,7 +7,7 @@ import {disableSnap, registerKeybindings} from "./keybindings.js"; import {libWrapper} from "./libwrapper_shim.js"; import {performMigrations} from "./migration.js" import {removeLastHistoryEntryIfAt, resetMovementHistory} from "./movement_tracking.js"; -import {wipePathfindingCache, initializePathfinding} from "./pathfinding.js"; +import {wipePathfindingCache, initializePathfinding, startBackgroundCaching} from "./pathfinding.js"; import {extendRuler} from "./ruler.js"; import {registerSettings, RightClickAction, settingsKey} from "./settings.js" import {recalculate} from "./socket.js"; @@ -28,6 +28,21 @@ initGridlessPathfinding().then(() => { Hooks.on("createWall", wipePathfindingCache); Hooks.on("updateWall", wipePathfindingCache); Hooks.on("deleteWall", wipePathfindingCache); + + // Whenever the current user selects a token, start caching + Hooks.on("controlToken", (token, controlled) => { + if (controlled) { + startBackgroundCaching(token); + } + }); + + // Whenever a token the current user controls updates, start caching + Hooks.on("updateToken", (document) => { + const token = document.object; + if (token._controlled) { + startBackgroundCaching(token); + } + }); }); Hooks.once("init", () => { diff --git a/js/pathfinding.js b/js/pathfinding.js index 395a442..dc006e7 100644 --- a/js/pathfinding.js +++ b/js/pathfinding.js @@ -2,59 +2,220 @@ import {getGridPositionFromPixelsObj, getPixelsFromGridPositionObj} from "./foun import {moveWithoutAnimation, togglePathfinding} from "./keybindings.js"; import {debugGraphics} from "./main.js"; import {settingsKey} from "./settings.js"; -import {getSnapPointForTokenObj, getTokenSize, iterPairs} from "./util.js"; +import {buildSnapPointTokenData, getSnapPointForTokenDataObj, isModuleActive, iterPairs} from "./util.js"; import * as GridlessPathfinding from "../wasm/gridless_pathfinding.js"; -import {PriorityQueueSet} from "./data_structures.js"; +import {PriorityQueueSet, ProcessOnceQueue} from "./data_structures.js"; +class CacheLayer { + constructor(tokenData, cacheId) { + this.tokenData = tokenData; + this.cacheId = cacheId; + this.queue = new ProcessOnceQueue(); + + this.buildNodes(); + this.registerUse(); + } + + buildNodes() { + this.nodes = new Array(gridHeight); + for (let y = 0; y < gridHeight; y++) { + this.nodes[y] = new Array(gridWidth); + for (let x = 0; x < gridWidth; x++) { + this.nodes[y][x] = {x, y}; + } + } + } + + registerUse() { + this.lastUsed = Date.now(); + } +} + +/** + * Class to hold all the cached node data, and functions to deal with caching. + * + * Since pathfinding can depend on several factors, e.g. the token's size, we keep + * several caches, keyed by all the data relevant to pathfinding. If we already have + * the maximum number of caches and we need to create another one, we discard the + * one not used for the longest. + * + * When we select a token, or a token we have selected updates, we start caching + * in the background so, when we do start pathfinding, it's very performant. + * + * Background caching starts by trying to run an idle process (when the browser is + * otherwise not busy), but if it can't do that after an amount of time (e.g. the + * CPU is very slow and is busy) then we instead start caching a few nodes each + * frame. + */ class Cache { - static maxCacheIds = 5; + static maxCacheLayers = 5; + static maxBackgroundCachingMillis = 10; + static maxAnimationCachingMillis = 5; + static backgroundCachingTimeoutMillis = 200; constructor() { - this.nodes = new Map(); - this.lastUsed = new Map(); + this.layers = new Map(); + this.background = { + nextJobId: null, + nextTimeoutId: null, + nextAnimationFrameId: null + } } clear() { - this.nodes.clear(); - this.lastUsed.clear(); + this.layers.clear(); + if (this.background.nextJobId) { + window.cancelIdleCallback(this.background.nextJobId); + this.background.nextJobId = null; + } + this.cancelTimeout(); + this.cancelAnimationFrame(); } /** - * Get the cache associated with the given cache ID, creating a new one - * if we don't already have one + * Retrieve the cache layer for this token, using information that can make a difference to the pathfinding algorithm + * If a layer that suits this token doesn't exist, create one */ - getCachedNodes(cacheId) { - // Track that we've last used this cache right now - this.lastUsed.set(cacheId, Date.now()); - - // Get the nodes for the cacheId. If we don't already have one, create one - let cachedNodes = this.nodes.get(cacheId); - if (!cachedNodes) { - cachedNodes = new Array(gridHeight); - for (let y = 0; y < gridHeight; y++) { - cachedNodes[y] = new Array(gridWidth); - for (let x = 0; x < gridWidth; x++) { - cachedNodes[y][x] = {x, y}; - } + getCacheLayer(token) { + const tokenData = buildTokenData(token); + const cacheId = JSON.stringify(tokenData); + let cacheLayer = this.layers.get(cacheId); + // If we don't already have a cache layer for this cache ID, create one now + if (!cacheLayer) { + // Check if we already have the max number of layers. If we do, + // get rid of the one that hasn't been used for the longest + if (this.layers.size >= Cache.maxCacheLayers) { + const oldestCache = Array.from(this.layers.values()) + .reduce((layer1, layer2) => (layer1?.lastUsed < layer2.lastUsed) ? layer1 : layer2, null); + this.layers.delete(oldestCache.cacheId); } - this.nodes.set(cacheId, cachedNodes); - // Since we're adding a new cache, check if we have too many and, - // if we do, get rid of the one that was last used longest ago - if (this.lastUsed.size > Cache.maxCacheIds) { - let oldest; - for (let entry of this.lastUsed) { - if (!oldest || oldest[1] > entry[1]) { - oldest = entry; - } - } - this.nodes.delete(oldest[0]); - this.lastUsed.delete(oldest[0]); - } + // Create the new cache + cacheLayer = new CacheLayer(tokenData, cacheId); + this.layers.set(cacheId, cacheLayer); + } else { + // Register that we're using this cache right now + cacheLayer.registerUse(); } - return cachedNodes; + return cacheLayer; + } + + /** + * Start background caching from the token's current position + */ + startBackgroundCaching(token) { + const cacheLayer = this.getCacheLayer(token); + const tokenPosition = getGridPositionFromPixelsObj(token.position) + + cacheLayer.queue.push(cacheLayer.nodes[tokenPosition.y][tokenPosition.x]); + + this.scheduleBackgroundCache(); + } + + /** + * Find if any of the caches have more nodes to background cache. If there is, then schedule a background + * caching job for that queue + */ + scheduleBackgroundCache() { + // If we already have a nextJobId, then don't start another one + if (this.background.nextJobId) return; + + // Find the latest-used cache that has nodes left to cache + const latestCache = this.getLatestCacheWithNonEmptyQueue(); + if (latestCache) { + this.background.nextJobId = window.requestIdleCallback( + () => this.runBackgroundCache(latestCache) + ); + this.resetAnimationFrameTimeout(); + } + } + + /** + * Start a timeout which, if we reach the timeout time, will schedule a small amount of caching + * to be performed every frame. This timeout will be reset every time we perform background caching. + */ + resetAnimationFrameTimeout() { + this.cancelTimeout(); + this.cancelAnimationFrame(); + + this.background.nextTimeoutId = window.setTimeout( + () => { + this.scheduleAnimationFrameCache(); + this.background.nextTimeoutId = null; + }, + Cache.backgroundCachingTimeoutMillis + ); + } + + /** + * Schedule a small amount of caching to be done just before the next frame renders + */ + scheduleAnimationFrameCache() { + const latestCache = this.getLatestCacheWithNonEmptyQueue(); + if (latestCache) { + this.background.nextAnimationFrameId = window.requestAnimationFrame( + () => this.runAnimationCache(latestCache) + ); + } + } + + /** + * Find which cache was last used and get its cache ID + */ + getLatestCacheWithNonEmptyQueue() { + return Array.from(this.layers.values()) + .filter(layer => layer.queue.hasNext()) + .reduce((layer1, layer2) => (layer1?.lastUsed > layer2.lastUsed) ? layer1 : layer2, null); + } + + /** + * Cache nodes for a short time, and then schedule another idle job to cache more nodes + */ + runBackgroundCache(cacheLayer) { + const endTime = performance.now() + Cache.maxBackgroundCachingMillis; + while (cacheLayer.queue.hasNext() && performance.now() < endTime) { + this.cacheNextNode(cacheLayer); + } + + this.background.nextJobId = null; + this.scheduleBackgroundCache(); + } + + /** + * Cache nodes for a very short time, then schedule to cache more nodes next frame + */ + runAnimationCache(cacheLayer) { + const endTime = performance.now() + Cache.maxAnimationCachingMillis; + while (cacheLayer.queue.hasNext() && performance.now() < endTime) { + this.cacheNextNode(cacheLayer); + } + + this.background.nextAnimationFrameId = null; + this.scheduleAnimationFrameCache(); + } + + cacheNextNode(cacheLayer) { + let node = cacheLayer.queue.pop(); + getNode(node, cacheLayer); + for (let edge of node.edges) { + cacheLayer.queue.push(edge.target); + } + } + + cancelTimeout() { + if (this.background.nextTimeoutId) { + window.clearTimeout(this.background.nextTimeoutId); + this.background.nextTimeoutId = null; + } + } + + cancelAnimationFrame() { + if (this.background.nextAnimationFrameId) { + window.cancelAnimationFrame(this.background.nextAnimationFrameId); + this.background.nextAnimationFrameId = null; + } } } @@ -85,54 +246,44 @@ export function findPath(from, to, token, previousWaypoints) { paintGridlessPathfindingDebug(pathfinder); return GridlessPathfinding.findPath(pathfinder, from, to); } else { - const cachedNodes = getCachedNodes(token); - const lastNode = calculatePath(from, to, cachedNodes, token, previousWaypoints); - if (!lastNode) + const cacheLayer = cache.getCacheLayer(token); + const firstNode = calculatePath(from, to, cacheLayer, previousWaypoints); + if (!firstNode) return null; - paintGriddedPathfindingDebug(lastNode, token); + paintGriddedPathfindingDebug(firstNode, cacheLayer.tokenData); const path = []; - let currentNode = lastNode; + let currentNode = firstNode; while (currentNode) { // TODO Check if the distance doesn't change - if (path.length >= 2 && !stepCollidesWithWall(path[path.length - 2], currentNode.node, token)) + if (path.length >= 2 && !stepCollidesWithWall(path[path.length - 2], currentNode.node, cacheLayer.tokenData)) { // Replace last waypoint if the current waypoint leads to a valid path path[path.length - 1] = {x: currentNode.node.x, y: currentNode.node.y}; - else + } else { path.push({x: currentNode.node.x, y: currentNode.node.y}); - currentNode = currentNode.previous; + } + currentNode = currentNode.next; } return path; } } -/** - * Build a cache ID based on the current token's data and then retrieve the cache to use from that - */ -function getCachedNodes(token) { - const cacheData = {}; +function buildTokenData(token) { + // Almost all the information we need is for calculating the snap point + const tokenData = buildSnapPointTokenData(token); - // Different-sized tokens snap to different points on the grid, - // so they might follow a different path to other tokens - cacheData.tokenSize = getTokenSize(token); - if (canvas.grid.isHex && game.modules.get("hex-size-support")?.active) { - cacheData.hexConfig = { - altOrientation: CONFIG.hexSizeSupport.getAltOrientationFlag(token), - altSnapping: CONFIG.hexSizeSupport.getAltSnappingFlag(token) - } + // If levels is enabled, which walls matter depends on the token's elevation. + // Depending on the settings in levels, the height we care about is either their + // Foot height (elevation) or eye height (losHeight). + if (isModuleActive("levels")) { + const blockSightMovement = game.settings.get(_levelsModuleName, "blockSightMovement"); + tokenData.elevation = blockSightMovement ? token.data.elevation : token.losHeight; } - // If levels is enabled, the token's elevation can affect which walls - // they need to worry about - if (game.modules.get("levels")?.active) { - cacheData.elevation = token.data.elevation; - } - - const cacheId = JSON.stringify(cacheData); - return cache.getCachedNodes(cacheId); + return tokenData; } -function getNode(pos, cachedNodes, token, initialize = true) { - const node = cachedNodes[pos.y][pos.x]; +function getNode(pos, cacheLayer, initialize = true) { + const node = cacheLayer.nodes[pos.y][pos.x]; if (initialize && !node.edges) { node.edges = []; for (const neighborPos of canvas.grid.grid.getNeighbors(pos.y, pos.x).map(([y, x]) => {return {x, y};})) { @@ -141,9 +292,9 @@ function getNode(pos, cachedNodes, token, initialize = true) { } // TODO Work with pixels instead of grid locations - if (!stepCollidesWithWall(neighborPos, pos, token)) { + if (!stepCollidesWithWall(pos, neighborPos, cacheLayer.tokenData)) { const isDiagonal = node.x !== neighborPos.x && node.y !== neighborPos.y && canvas.grid.type === CONST.GRID_TYPES.SQUARE; - const neighbor = getNode(neighborPos, cachedNodes, token, false); + const neighbor = getNode(neighborPos, cacheLayer, false); // Count 5-10-5 diagonals as 1.5 (so two add up to 3) and 5-5-5 diagonals as 1.0001 (to discourage unnecessary diagonals) // TODO Account for difficult terrain @@ -155,7 +306,7 @@ function getNode(pos, cachedNodes, token, initialize = true) { return node; } -function calculatePath(from, to, cachedNodes, token, previousWaypoints) { +function calculatePath(from, to, cacheLayer, previousWaypoints) { use5105 = game.system.id === "pf2e" || canvas.grid.diagonalRule === "5105"; let startCost = 0; if (use5105 && canvas.grid.type === CONST.GRID_TYPES.SQUARE) { @@ -168,9 +319,9 @@ function calculatePath(from, to, cachedNodes, token, previousWaypoints) { nextNodes.pushWithPriority( { - node: getNode(to, cachedNodes, token), + node: getNode(from, cacheLayer), cost: startCost, - estimated: startCost + estimateCost(to, from), + estimated: startCost + estimateCost(from, to), previous: null } ); @@ -178,12 +329,12 @@ function calculatePath(from, to, cachedNodes, token, previousWaypoints) { while (nextNodes.hasNext()) { // Get node with cheapest estimate const currentNode = nextNodes.pop(); - if (currentNode.node.x === from.x && currentNode.node.y === from.y) { - return currentNode; + if (currentNode.node.x === to.x && currentNode.node.y === to.y) { + return buildPathNodes(currentNode); } previousNodes.add(currentNode.node); for (const edge of currentNode.node.edges) { - const neighborNode = getNode(edge.target, cachedNodes, token); + const neighborNode = getNode(edge.target, cacheLayer); if (previousNodes.has(neighborNode)) { continue; } @@ -191,7 +342,7 @@ function calculatePath(from, to, cachedNodes, token, previousWaypoints) { const neighbor = { node: neighborNode, cost: currentNode.cost + edge.cost, - estimated: currentNode.cost + edge.cost + estimateCost(neighborNode, from), + estimated: currentNode.cost + edge.cost + estimateCost(neighborNode, to), previous: currentNode }; nextNodes.pushWithPriority(neighbor); @@ -199,6 +350,25 @@ function calculatePath(from, to, cachedNodes, token, previousWaypoints) { } } +/** + * Now we've found the path, we know the final node, and each node links to the previous one. + * Reverse this list and return the first node in the path, with each node linking to the next + */ +function buildPathNodes(lastNode) { + let currentNode = lastNode; + let previousNode = null; + while (currentNode) { + const pathNode = { + node: currentNode.node, + cost: currentNode.cost, + next: previousNode + } + previousNode = pathNode; + currentNode = currentNode.previous; + } + return previousNode; +} + function calcNoDiagonals(waypoints) { let diagonals = 0; for (const [p1, p2] of iterPairs(waypoints)) { @@ -217,10 +387,16 @@ function estimateCost(pos, target) { return Math.max(distX, distY) + (use5105 ? Math.min(distX, distY) * 0.5 : 0); } -function stepCollidesWithWall(from, to, token) { - const stepStart = getSnapPointForTokenObj(getPixelsFromGridPositionObj(from), token); - const stepEnd = getSnapPointForTokenObj(getPixelsFromGridPositionObj(to), token); - return canvas.walls.checkCollision(new Ray(stepStart, stepEnd)); +function stepCollidesWithWall(from, to, tokenData) { + const stepStart = getSnapPointForTokenDataObj(getPixelsFromGridPositionObj(from), tokenData); + const stepEnd = getSnapPointForTokenDataObj(getPixelsFromGridPositionObj(to), tokenData); + if (isModuleActive("levels")) { + stepStart.z = tokenData.elevation; + stepEnd.z = tokenData.elevation; + return _levels.testCollision(stepStart, stepEnd, "collision") + } else { + return canvas.walls.checkCollision(new Ray(stepStart, stepEnd)); + } } export function wipePathfindingCache() { @@ -238,20 +414,26 @@ export function initializePathfinding() { gridHeight = Math.ceil(canvas.dimensions.height / canvas.grid.h); } -function paintGriddedPathfindingDebug(lastNode, token) { +export function startBackgroundCaching(token) { + if (game.user.isGM || game.settings.get(settingsKey, "allowPathfinding")) { + cache.startBackgroundCaching(token); + } +} + +function paintGriddedPathfindingDebug(firstNode, tokenData) { if (!CONFIG.debug.dragRuler) return; debugGraphics.removeChildren().forEach(c => c.destroy()); - let currentNode = lastNode; + let currentNode = firstNode; while (currentNode) { let text = new PIXI.Text(currentNode.cost.toFixed(1)); - let pixels = getSnapPointForTokenObj(getPixelsFromGridPositionObj(currentNode.node), token); + let pixels = getSnapPointForTokenDataObj(getPixelsFromGridPositionObj(currentNode.node), tokenData); text.anchor.set(0.5, 1.0); text.x = pixels.x; text.y = pixels.y; debugGraphics.addChild(text); - currentNode = currentNode.previous; + currentNode = currentNode.next; } } diff --git a/js/util.js b/js/util.js index c2406e2..48f48d4 100644 --- a/js/util.js +++ b/js/util.js @@ -1,5 +1,6 @@ import {getPixelsFromGridPosition} from "./foundry_fixes.js" -import { disableSnap } from "./keybindings.js"; +import {findVertexSnapPoint} from "./hex_support.js"; +import {disableSnap} from "./keybindings.js"; export function* zip(it1, it2) { for (let i = 0;i < Math.min(it1.length, it2.length);i++) { @@ -25,14 +26,38 @@ export function sum(arr) { return arr.reduce((a, b) => a + b, 0); } +export function buildSnapPointTokenData(token) { + const tokenData = { + width: token.data.width, + height: token.data.height + }; + + if (isModuleActive("hex-size-support")) { + tokenData.hexSizeSupport = {}; + tokenData.hexSizeSupport.altSnappingFlag = CONFIG.hexSizeSupport.getAltSnappingFlag(token); + tokenData.hexSizeSupport.altOrientationFlag = CONFIG.hexSizeSupport.getAltOrientationFlag(token); + tokenData.hexSizeSupport.borderSize = token.document.getFlag("hex-size-support", "borderSize"); + } + + return tokenData; +} + export function getSnapPointForToken(x, y, token) { + return getSnapPointForTokenData(x, y, buildSnapPointTokenData(token)); +} + +export function getSnapPointForTokenDataObj(pos, tokenData) { + return getSnapPointForTokenData(pos.x, pos.y, tokenData); +} + +function getSnapPointForTokenData(x, y, tokenData) { if (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) { return new PIXI.Point(x, y); } if (canvas.grid.isHex) { - if (game.modules.get("hex-size-support")?.active && CONFIG.hexSizeSupport.getAltSnappingFlag(token)) { - if (token.document.getFlag("hex-size-support", "borderSize") % 2 === 0) { - const snapPoint = CONFIG.hexSizeSupport.findVertexSnapPoint(x, y, token, canvas.grid.grid) + if (tokenData.hexSizeSupport?.altSnappingFlag) { + if (tokenData.hexSizeSupport.borderSize % 2 === 0) { + const snapPoint = findVertexSnapPoint(x, y, tokenData.hexSizeSupport.altOrientationFlag); return new PIXI.Point(snapPoint.x, snapPoint.y) } else { @@ -46,38 +71,38 @@ export function getSnapPointForToken(x, y, token) { const [topLeftX, topLeftY] = canvas.grid.getTopLeft(x, y); let cellX, cellY; - if (token.data.width % 2 === 0) + if (tokenData.width % 2 === 0) cellX = x - canvas.grid.h / 2; else cellX = x; - if (token.data.height % 2 === 0) + if (tokenData.height % 2 === 0) cellY = y - canvas.grid.h / 2; else cellY = y; const [centerX, centerY] = canvas.grid.getCenter(cellX, cellY); let snapX, snapY; // Tiny tokens can snap to the cells corners - if (token.data.width <= 0.5) { + if (tokenData.width <= 0.5) { const offsetX = x - topLeftX; const subGridWidth = Math.floor(canvas.grid.w / 2); const subGridPosX = Math.floor(offsetX / subGridWidth); snapX = topLeftX + (subGridPosX + 0.5) * subGridWidth; } // Tokens with odd multipliers (1x1, 3x3, ...) and tokens smaller than 1x1 but bigger than 0.5x0.5 snap to the center of the grid cell - else if (Math.round(token.data.width) % 2 === 1 || token.data.width < 1) { + else if (Math.round(tokenData.width) % 2 === 1 || tokenData.width < 1) { snapX = centerX; } // All remaining tokens (those with even or fractional multipliers on square grids) snap to the intersection points of the grid else { snapX = centerX + canvas.grid.w / 2; } - if (token.data.height <= 0.5) { + if (tokenData.height <= 0.5) { const offsetY = y - topLeftY; const subGridHeight = Math.floor(canvas.grid.h / 2); const subGridPosY = Math.floor(offsetY / subGridHeight); snapY = topLeftY + (subGridPosY + 0.5) * subGridHeight; } - else if (Math.round(token.data.height) % 2 === 1 || token.data.height < 1) { + else if (Math.round(tokenData.height) % 2 === 1 || tokenData.height < 1) { snapY = centerY; } else { @@ -263,3 +288,7 @@ export function early_isGM() { const gmLevel = CONST.USER_ROLES.ASSISTANT; return level >= gmLevel; } + +export function isModuleActive(moduleName) { + return game.modules.get(moduleName)?.active +}