From 846e39d946da455befeace4dba1fcfa3a3764adb Mon Sep 17 00:00:00 2001 From: Jonathan Calvert <38069151+JDCalvert@users.noreply.github.com> Date: Sat, 5 Mar 2022 20:36:56 +0000 Subject: [PATCH 1/7] Pathfinding: Keep Multiple Caches (#177) --- js/pathfinding.js | 120 +++++++++++++++++++++++++++++++++------------- 1 file changed, 87 insertions(+), 33 deletions(-) diff --git a/js/pathfinding.js b/js/pathfinding.js index d361c93..4cc72f1 100644 --- a/js/pathfinding.js +++ b/js/pathfinding.js @@ -2,13 +2,64 @@ import {getGridPositionFromPixelsObj, getPixelsFromGridPositionObj} from "./foun import {moveWithoutAnimation, togglePathfinding} from "./keybindings.js"; import {debugGraphics} from "./main.js"; import {settingsKey} from "./settings.js"; -import {getSnapPointForTokenObj, iterPairs} from "./util.js"; +import {getSnapPointForTokenObj, getTokenSize, iterPairs} from "./util.js"; import * as GridlessPathfinding from "../wasm/gridless_pathfinding.js"; import {PriorityQueueSet} from "./data_structures.js"; -let cachedNodes = undefined; -let cacheElevation; +class Cache { + static maxCacheIds = 5; + + constructor() { + this.nodes = new Map(); + this.lastUsed = new Map(); + } + + clear() { + this.nodes.clear(); + this.lastUsed.clear(); + } + + /** + * Get the cache associated with the given cache ID, creating a new one + * if we don't already have 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}; + } + } + 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]); + } + } + + return cachedNodes; + } +} + +const cache = new Cache(); + let use5105 = false; let gridlessPathfinders = new Map(); let gridWidth, gridHeight; @@ -24,8 +75,6 @@ export function isPathfindingEnabled() { } export function findPath(from, to, token, previousWaypoints) { - checkCacheValid(token); - if (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) { let tokenSize = Math.max(token.data.width, token.data.height) * canvas.dimensions.size; let pathfinder = gridlessPathfinders.get(tokenSize); @@ -36,7 +85,8 @@ export function findPath(from, to, token, previousWaypoints) { paintGridlessPathfindingDebug(pathfinder); return GridlessPathfinding.findPath(pathfinder, from, to); } else { - const lastNode = calculatePath(from, to, token, previousWaypoints); + const cachedNodes = getCachedNodes(token); + const lastNode = calculatePath(from, to, cachedNodes, token, previousWaypoints); if (!lastNode) return null; paintGriddedPathfindingDebug(lastNode, token); @@ -55,27 +105,45 @@ export function findPath(from, to, token, previousWaypoints) { } } -function getNode(pos, token, initialize=true) { - if (!cachedNodes) - cachedNodes = new Array(gridHeight); - if (!cachedNodes[pos.y]) - cachedNodes[pos.y] = new Array(gridWidth); - if (!cachedNodes[pos.y][pos.x]) { - cachedNodes[pos.y][pos.x] = pos; +/** + * 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 = {}; + + // 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, 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); +} + +function getNode(pos, cachedNodes, token, initialize = true) { const node = cachedNodes[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};})) { - if (neighborPos.x < 0 || neighborPos.y < 0 || neighborPos.x > gridWidth || neighborPos.y > gridHeight) { + if (neighborPos.x < 0 || neighborPos.y < 0 || neighborPos.x >= gridWidth || neighborPos.y >= gridHeight) { continue; } // TODO Work with pixels instead of grid locations if (!stepCollidesWithWall(neighborPos, pos, token)) { const isDiagonal = node.x !== neighborPos.x && node.y !== neighborPos.y && canvas.grid.type === CONST.GRID_TYPES.SQUARE; - const neighbor = getNode(neighborPos, token, false); + const neighbor = getNode(neighborPos, cachedNodes, token, 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 @@ -87,7 +155,7 @@ function getNode(pos, token, initialize=true) { return node; } -function calculatePath(from, to, token, previousWaypoints) { +function calculatePath(from, to, cachedNodes, token, previousWaypoints) { use5105 = game.system.id === "pf2e" || canvas.grid.diagonalRule === "5105"; let startCost = 0; if (use5105 && canvas.grid.type === CONST.GRID_TYPES.SQUARE) { @@ -100,7 +168,7 @@ function calculatePath(from, to, token, previousWaypoints) { nextNodes.pushWithPriority( { - node: getNode(to, token), + node: getNode(to, cachedNodes, token), cost: startCost, estimated: startCost + estimateCost(to, from), previous: null @@ -115,7 +183,7 @@ function calculatePath(from, to, token, previousWaypoints) { } previousNodes.add(currentNode.node); for (const edge of currentNode.node.edges) { - const neighborNode = getNode(edge.target, token); + const neighborNode = getNode(edge.target, cachedNodes, token); if (previousNodes.has(neighborNode)) { continue; } @@ -156,7 +224,7 @@ function stepCollidesWithWall(from, to, token) { } export function wipePathfindingCache() { - cachedNodes = undefined; + cache.clear(); for (const pathfinder of gridlessPathfinders.values()) { GridlessPathfinding.free(pathfinder); } @@ -165,20 +233,6 @@ export function wipePathfindingCache() { debugGraphics.removeChildren().forEach(c => c.destroy()); } -/** - * Check if the current cache is still suitable for the path we're about to find. If not, clear the cache - */ - function checkCacheValid(token) { - // If levels is enabled, the cache is invalid if it was made for a - if (game.modules.get("levels")?.active) { - const tokenElevation = token.data.elevation; - if (tokenElevation !== cacheElevation) { - cacheElevation = tokenElevation; - wipePathfindingCache(); - } - } -} - export function initializePathfinding() { gridWidth = Math.ceil(canvas.dimensions.width / canvas.grid.w); gridHeight = Math.ceil(canvas.dimensions.height / canvas.grid.h); From 3f2e9e1a3ef8b932c6964235344db7bbb15365f6 Mon Sep 17 00:00:00 2001 From: Jonathan Calvert <38069151+JDCalvert@users.noreply.github.com> Date: Mon, 28 Feb 2022 18:13:12 +0000 Subject: [PATCH 2/7] Fix one-way wall collision detection (fixes #167) (#172) --- js/pathfinding.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/pathfinding.js b/js/pathfinding.js index e80a291..7e89dad 100644 --- a/js/pathfinding.js +++ b/js/pathfinding.js @@ -41,7 +41,7 @@ export function findPath(from, to, token, previousWaypoints) { let currentNode = lastNode; while (currentNode) { // TODO Check if the distance doesn't change - if (path.length >= 2 && !stepCollidesWithWall(currentNode.node, path[path.length - 2], token)) + if (path.length >= 2 && !stepCollidesWithWall(path[path.length - 2], currentNode.node, token)) // 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 @@ -73,7 +73,7 @@ function getNode(pos, token, initialize=true) { } // TODO Work with pixels instead of grid locations - if (!stepCollidesWithWall(pos, neighborPos, token)) { + if (!stepCollidesWithWall(neighborPos, pos, token)) { const isDiagonal = node.x !== neighborPos.x && node.y !== neighborPos.y && canvas.grid.type === CONST.GRID_TYPES.SQUARE; let targetLayer = pos.layer; if (use5105 && isDiagonal) From 0dfdb23bfb0a97957de61babb3fd5e69b7660b30 Mon Sep 17 00:00:00 2001 From: Jonathan Calvert <38069151+JDCalvert@users.noreply.github.com> Date: Mon, 28 Feb 2022 20:17:54 +0000 Subject: [PATCH 3/7] Levels Compatibility: Clear pathfinding cache after changing elevation (#173) --- js/pathfinding.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/js/pathfinding.js b/js/pathfinding.js index 7e89dad..1f663b9 100644 --- a/js/pathfinding.js +++ b/js/pathfinding.js @@ -7,6 +7,7 @@ import {getSnapPointForTokenObj, iterPairs} from "./util.js"; import * as GridlessPathfinding from "../wasm/gridless_pathfinding.js" let cachedNodes = undefined; +let cacheElevation; let use5105 = false; let gridlessPathfinders = new Map(); let gridWidth, gridHeight; @@ -22,6 +23,8 @@ export function isPathfindingEnabled() { } export function findPath(from, to, token, previousWaypoints) { + checkCacheValid(token); + if (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) { let tokenSize = Math.max(token.data.width, token.data.height) * canvas.dimensions.size; let pathfinder = gridlessPathfinders.get(tokenSize); @@ -161,6 +164,20 @@ export function wipePathfindingCache() { debugGraphics.removeChildren().forEach(c => c.destroy()); } +/** + * Check if the current cache is still suitable for the path we're about to find. If not, clear the cache + */ + function checkCacheValid(token) { + // If levels is enabled, the cache is invalid if it was made for a + if (game.modules.get("levels")?.active) { + const tokenElevation = token.data.elevation; + if (tokenElevation !== cacheElevation) { + cacheElevation = tokenElevation; + wipePathfindingCache(); + } + } +} + export function initializePathfinding() { gridWidth = Math.ceil(canvas.dimensions.width / canvas.grid.w); gridHeight = Math.ceil(canvas.dimensions.height / canvas.grid.h); From ddf89f94994d92a834dd39c39f14d7d612333259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20V=C3=B6gele?= Date: Mon, 28 Feb 2022 22:48:15 +0100 Subject: [PATCH 4/7] Add support for the wall height module in the gridless pathfinder --- js/pathfinding.js | 6 ++-- rust/src/js_api.rs | 64 +++++++++++++++++++++++++++++++++++++++--- rust/src/pathfinder.rs | 5 +++- 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/js/pathfinding.js b/js/pathfinding.js index 1f663b9..fd72069 100644 --- a/js/pathfinding.js +++ b/js/pathfinding.js @@ -24,12 +24,12 @@ export function isPathfindingEnabled() { export function findPath(from, to, token, previousWaypoints) { checkCacheValid(token); - + if (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) { let tokenSize = Math.max(token.data.width, token.data.height) * canvas.dimensions.size; let pathfinder = gridlessPathfinders.get(tokenSize); if (!pathfinder) { - pathfinder = GridlessPathfinding.initialize(canvas.walls.placeables, tokenSize); + pathfinder = GridlessPathfinding.initialize(canvas.walls.placeables, tokenSize, token.data.elevation, Boolean(game.modules.get("levels")?.active)); gridlessPathfinders.set(tokenSize, pathfinder); } paintGridlessPathfindingDebug(pathfinder); @@ -168,7 +168,7 @@ export function wipePathfindingCache() { * Check if the current cache is still suitable for the path we're about to find. If not, clear the cache */ function checkCacheValid(token) { - // If levels is enabled, the cache is invalid if it was made for a + // If levels is enabled, the cache is invalid if it was made for a if (game.modules.get("levels")?.active) { const tokenElevation = token.data.elevation; if (tokenElevation !== cacheElevation) { diff --git a/rust/src/js_api.rs b/rust/src/js_api.rs index 2d29d7c..c90a532 100644 --- a/rust/src/js_api.rs +++ b/rust/src/js_api.rs @@ -23,6 +23,8 @@ extern "C" { extern "C" { pub type JsWall; pub type JsWallData; + pub type JsWallFlags; + pub type JsWallHeight; #[wasm_bindgen(method, getter)] fn data(this: &JsWall) -> JsWallData; @@ -38,6 +40,18 @@ extern "C" { #[wasm_bindgen(method, getter, js_name = "move")] fn move_type(this: &JsWallData) -> WallSenseType; + + #[wasm_bindgen(method, getter)] + fn flags(this: &JsWallData) -> JsWallFlags; + + #[wasm_bindgen(method, getter, js_name = "wallHeight")] + fn wall_height(this: &JsWallFlags) -> Option; + + #[wasm_bindgen(method, getter, js_name = "wallHeightTop")] + fn top(this: &JsWallHeight) -> Option; + + #[wasm_bindgen(method, getter, js_name = "wallHeightBottom")] + fn bottom(this: &JsWallHeight) -> Option; } #[wasm_bindgen] @@ -120,6 +134,38 @@ impl TryFrom for WallSenseType { } } +#[derive(Debug, Copy, Clone)] +pub struct WallHeight { + pub top: f64, + pub bottom: f64, +} + +impl Default for WallHeight { + fn default() -> Self { + Self { + top: f64::INFINITY, + bottom: f64::NEG_INFINITY, + } + } +} + +impl From> for WallHeight { + fn from(height: Option) -> Self { + let height = height + .map(|height| (height.top(), height.bottom())) + .unwrap_or((None, None)); + let top = height.0.unwrap_or(WallHeight::default().top); + let bottom = height.1.unwrap_or(WallHeight::default().bottom); + Self { top, bottom } + } +} + +impl WallHeight { + pub fn contains(&self, height: f64) -> bool { + self.top >= height && self.bottom <= height + } +} + #[derive(Debug, Clone, Copy)] pub struct Wall { pub p1: Point, @@ -127,6 +173,7 @@ pub struct Wall { pub door_type: DoorType, pub door_state: DoorState, pub move_type: WallSenseType, + pub height: WallHeight, } impl Wall { @@ -136,6 +183,7 @@ impl Wall { door_type: DoorType, door_state: DoorState, move_type: WallSenseType, + height: WallHeight, ) -> Self { Self { p1, @@ -143,6 +191,7 @@ impl Wall { door_type, door_state, move_type, + height, } } @@ -156,29 +205,36 @@ impl Wall { } impl Wall { - fn from_js(wall: &JsWall) -> Self { + fn from_js(wall: &JsWall, enable_height: bool) -> Self { let data = wall.data(); let mut c = data.c(); c.iter_mut().for_each(|val| *val = val.round()); + let height = if enable_height { + data.flags().wall_height().into() + } + else { + WallHeight::default() + }; Self::new( Point::new(c[0], c[1]), Point::new(c[2], c[3]), data.door_type(), data.door_state(), data.move_type(), + height, ) } } #[allow(dead_code)] #[wasm_bindgen] -pub fn initialize(js_walls: Vec, token_size: f64) -> Pathfinder { +pub fn initialize(js_walls: Vec, token_size: f64, token_elevation: f64, enable_height: bool) -> Pathfinder { let mut walls = Vec::with_capacity(js_walls.len()); for wall in js_walls { let wall = JsWall::from(wall); - walls.push(Wall::from_js(&wall)); + walls.push(Wall::from_js(&wall, enable_height)); } - Pathfinder::initialize(walls, token_size) + Pathfinder::initialize(walls, token_size, token_elevation) } #[allow(dead_code)] diff --git a/rust/src/pathfinder.rs b/rust/src/pathfinder.rs index 1855b0a..89b37b8 100644 --- a/rust/src/pathfinder.rs +++ b/rust/src/pathfinder.rs @@ -148,7 +148,7 @@ pub struct Pathfinder { } impl Pathfinder { - pub fn initialize(walls: I, token_size: f64) -> Self + pub fn initialize(walls: I, token_size: f64, token_elevation: f64) -> Self where I: IntoIterator, { @@ -162,6 +162,9 @@ impl Pathfinder { if wall.is_door() && wall.is_open() { continue; } + if !wall.height.contains(token_elevation) { + continue; + } let x_diff = wall.p2.x - wall.p1.x; let y_diff = wall.p2.y - wall.p1.y; let p1_angle = y_diff.atan2(x_diff).rem_euclid(2.0 * PI); From 65911c11f08e60c0ae2e4e1f983c1c551b2cb297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20V=C3=B6gele?= Date: Mon, 7 Mar 2022 14:37:15 +0100 Subject: [PATCH 5/7] Only reclaculate the ruler if it's a Drag Ruler (fixes #161) --- js/socket.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/js/socket.js b/js/socket.js index f610fb5..47fc178 100644 --- a/js/socket.js +++ b/js/socket.js @@ -39,5 +39,7 @@ export function recalculate(tokens) { } function _socketRecalculate(tokenIds) { - return canvas.controls.ruler.dragRulerRecalculate(tokenIds); + const ruler = canvas.controls.ruler; + if (ruler.isDragRuler) + ruler.dragRulerRecalculate(tokenIds); } From 02dfd8db0df48436727678bd3bf6c1eb29450982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20V=C3=B6gele?= Date: Mon, 7 Mar 2022 14:37:15 +0100 Subject: [PATCH 6/7] Only reclaculate the ruler if it's a Drag Ruler (fixes #161) --- js/socket.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/js/socket.js b/js/socket.js index f610fb5..47fc178 100644 --- a/js/socket.js +++ b/js/socket.js @@ -39,5 +39,7 @@ export function recalculate(tokens) { } function _socketRecalculate(tokenIds) { - return canvas.controls.ruler.dragRulerRecalculate(tokenIds); + const ruler = canvas.controls.ruler; + if (ruler.isDragRuler) + ruler.dragRulerRecalculate(tokenIds); } From df4c515fcdf94fe7b76c8e820d623f3f38a8a6d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20V=C3=B6gele?= Date: Mon, 7 Mar 2022 16:53:48 +0100 Subject: [PATCH 7/7] Release v1.12.3 --- CHANGELOG.md | 11 +++++++++++ module.json | 4 ++-- rust/Cargo.lock | 2 +- rust/Cargo.toml | 2 +- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac2e436..2c4f0ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 1.12.3 +### Bugfixes +- Fixed a bug that could cause foundry to freeze indefinitely when trying to pathfind to an unreachalbe location (thanks to JDCalvert) +- Fixed a bug that caused the pathfinder to route through one-directional walls from the wrong direction (thanks to JDCalvert) +- Fixed a bug that could cause Drag Ruler to write errors into the JS console during regular usage + +### Compatibility +- Drag Ruler's generic speed provider is now aware of good defaults for DnD 4th Edition +- Drag Ruler's pathfinder should now be compatible with the Wall Height and Levels modules (thanks to JDCalvert) + + ## 1.12.2 ### Bugfixes - Fixed a bug where the pathfinder on gridless scenes sometimes wasn't able to find a way around corners with specific angles diff --git a/module.json b/module.json index d123fda..6df1c76 100644 --- a/module.json +++ b/module.json @@ -2,7 +2,7 @@ "name": "drag-ruler", "title": "Drag Ruler", "description": "When dragging a token displays a ruler showing how far you've moved that token.", - "version": "1.12.2", + "version": "1.12.3", "minimumCoreVersion" : "9.245", "compatibleCoreVersion" : "9", "authors": [ @@ -65,7 +65,7 @@ ], "socket": true, "url": "https://github.com/manuelVo/foundryvtt-drag-ruler", - "download": "https://github.com/manuelVo/foundryvtt-drag-ruler/releases/download/v1.12.2/drag-ruler-1.12.2.zip", + "download": "https://github.com/manuelVo/foundryvtt-drag-ruler/releases/download/v1.12.3/drag-ruler-1.12.3.zip", "manifest": "https://raw.githubusercontent.com/manuelVo/foundryvtt-drag-ruler/master/module.json", "readme": "https://github.com/manuelVo/foundryvtt-drag-ruler/blob/master/README.md", "changelog": "https://github.com/manuelVo/foundryvtt-drag-ruler/blob/master/CHANGELOG.md", diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 2734585..ac1d6e9 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "gridless-pathfinding" -version = "1.12.2" +version = "1.12.3" dependencies = [ "console_error_panic_hook", "js-sys", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 88914a5..cd0c9dd 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gridless-pathfinding" -version = "1.12.2" +version = "1.12.3" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html