From 445c03d29a9a9d9fa80f2bf72164428b463e0f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20V=C3=B6gele?= Date: Tue, 10 May 2022 08:39:19 +0200 Subject: [PATCH] Add difficult terrain support for pathfinding on gridded scenes --- js/api.js | 6 +++- js/compatibility.js | 6 ++-- js/pathfinding.js | 74 +++++++++++++++++++++++++++++++++++------- js/util.js | 22 +++++++------ rust/Cargo.lock | 78 +++++++++++++++++++++++++++++++++++++++++++++ rust/Cargo.toml | 1 + rust/src/js_api.rs | 9 ++++++ 7 files changed, 171 insertions(+), 25 deletions(-) diff --git a/js/api.js b/js/api.js index 0672964..7a371c4 100644 --- a/js/api.js +++ b/js/api.js @@ -2,7 +2,7 @@ import {measureDistances} from "./compatibility.js"; import {getMovementHistory} from "./movement_tracking.js"; import {GenericSpeedProvider, SpeedProvider} from "./speed_provider.js" import {settingsKey} from "./settings.js" -import {getTokenShape} from "./util.js"; +import {getAreaFromPositionAndShape, getTokenShape} from "./util.js"; export const availableSpeedProviders = {} export let currentSpeedProvider = undefined @@ -140,6 +140,10 @@ export function getMovedDistanceFromToken(token) { return distances.reduce((acc, val) => acc + val, 0); } +export function buildCostFunction(token, shape) { + return (x, y, costOptions={}) => getCostFromSpeedProvider(token, getAreaFromPositionAndShape({x, y}, shape), costOptions); +} + export function registerModule(moduleId, speedProvider) { // Check if a module with the given id exists and is currently enabled const module = game.modules.get(moduleId) diff --git a/js/compatibility.js b/js/compatibility.js index 5217038..b97bcf0 100644 --- a/js/compatibility.js +++ b/js/compatibility.js @@ -1,6 +1,6 @@ -import {getCostFromSpeedProvider} from "./api.js"; +import {buildCostFunction} from "./api.js"; import {settingsKey} from "./settings.js"; -import {getAreaFromPositionAndShape, highlightTokenShape} from "./util.js"; +import {highlightTokenShape} from "./util.js"; export function getHexSizeSupportTokenGridCenter(token) { const tokenCenterOffset = CONFIG.hexSizeSupport.getCenterOffset(token) @@ -27,7 +27,7 @@ export function measureDistances(segments, entity, shape, options={}) { const newSegments = segments.slice(firstNewSegmentIndex); const distances = previousSegments.map(segment => segment.ray.dragRulerVisitedSpaces[segment.ray.dragRulerVisitedSpaces.length - 1].distance); previousSegments.forEach(segment => segment.ray.terrainRulerVisitedSpaces = duplicate(segment.ray.dragRulerVisitedSpaces)); - opts.costFunction = (x, y, costOptions={}) => { return getCostFromSpeedProvider(entity, getAreaFromPositionAndShape({x, y}, shape), costOptions); } + opts.costFunction = buildCostFunction(entity, shape); if (previousSegments.length > 0) opts.terrainRulerInitialState = previousSegments[previousSegments.length - 1].ray.dragRulerFinalState; return distances.concat(terrainRuler.measureDistances(newSegments, opts)); diff --git a/js/pathfinding.js b/js/pathfinding.js index befb7ed..46bf512 100644 --- a/js/pathfinding.js +++ b/js/pathfinding.js @@ -1,11 +1,12 @@ -import {getGridPositionFromPixelsObj, getPixelsFromGridPositionObj} from "./foundry_fixes.js"; +import {getCenterFromGridPositionObj, getGridPositionFromPixelsObj, getPixelsFromGridPositionObj} from "./foundry_fixes.js"; import {moveWithoutAnimation, togglePathfinding} from "./keybindings.js"; import {debugGraphics} from "./main.js"; import {settingsKey} from "./settings.js"; -import {buildSnapPointTokenData, getSnapPointForTokenDataObj, isModuleActive, iterPairs} from "./util.js"; +import {buildSnapPointTokenData, getSnapPointForTokenDataObj, getTokenShape, getTokenShapeForTokenData, isModuleActive, iterPairs} from "./util.js"; import * as GridlessPathfinding from "../wasm/gridless_pathfinding.js"; import {PriorityQueueSet, ProcessOnceQueue} from "./data_structures.js"; +import {buildCostFunction} from "./api.js"; class CacheLayer { constructor(tokenData, cacheId) { @@ -79,7 +80,18 @@ class Cache { */ getCacheLayer(token) { const tokenData = buildTokenData(token); - const cacheId = JSON.stringify(tokenData); + // TODO Request this from the speed providers so they can set their own options + let terrainData = canvas.terrain.listAllTerrain({token}); + terrainData = terrainData.map(data => { + return { + x: data.object.x, + y: data.object.y, + cost: data.cost, + shape: data.shape, + }; + }); + const cacheIdData = {tokenData, terrainData}; + const cacheId = GridlessPathfinding.sha1(JSON.stringify(cacheIdData)); let cacheLayer = this.layers.get(cacheId); // If we don't already have a cache layer for this cache ID, create one now if (!cacheLayer) { @@ -255,13 +267,37 @@ export function findPath(from, to, token, previousWaypoints) { const path = []; let currentNode = firstNode; while (currentNode) { - // TODO Check if the distance doesn't change 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 { - path.push({x: currentNode.node.x, y: currentNode.node.y}); + // Replace last waypoint if the current waypoint leads to a valid path that isn't longer than the old path + if (window.terrainRuler) { + let startNode = getCenterFromGridPositionObj(path[path.length - 2]); + let middleNode = getCenterFromGridPositionObj(path[path.length - 1]); + let endNode = getCenterFromGridPositionObj(currentNode.node); + let oldPath = [{ray: new Ray(startNode, middleNode)}, {ray: new Ray(middleNode, endNode)}]; + let newPath = [{ray: new Ray(startNode, endNode)}]; + let costFunction = buildCostFunction(token, getTokenShape(token)); + // TODO Cache the used measurement for use in the next loop to improve performance + let oldDistance = terrainRuler.measureDistances(oldPath, {costFunction}).reduce((a, b) => a + b); + let newDistance = terrainRuler.measureDistances(newPath, {costFunction})[0]; + + // TODO We might need to check if the diagonal count has increased on 5-10-5 + if (newDistance < oldDistance) { + path.pop(); + } + else if (newDistance === oldDistance) { + let oldNoDiagonals = oldPath[1].ray.terrainRulerFinalState?.noDiagonals; + let newNoDiagonals = newPath[0].ray.terrainRulerFinalState?.noDiagonals; + // This uses === && < instead of <= because the variables might be undefined (which shall lead to a true result) + if (oldNoDiagonals === newNoDiagonals || newNoDiagonals < oldNoDiagonals) { + path.pop(); + } + } + } + else { + path.pop(); + } } + path.push({x: currentNode.node.x, y: currentNode.node.y}); currentNode = currentNode.next; } return path; @@ -295,11 +331,25 @@ function getNode(pos, cacheLayer, initialize = true) { // TODO Work with pixels instead of grid locations if (!stepCollidesWithWall(pos, neighborPos, cacheLayer.tokenData)) { const isDiagonal = node.x !== neighborPos.x && node.y !== neighborPos.y && canvas.grid.type === CONST.GRID_TYPES.SQUARE; + let edgeCost; + if (window.terrainRuler) { + let ray = new Ray(getCenterFromGridPositionObj(pos), getCenterFromGridPositionObj(neighborPos)); + let measuredDistance = terrainRuler.measureDistances([{ray}], {costFunction: buildCostFunction(cacheLayer.tokenData, getTokenShapeForTokenData(cacheLayer.tokenData))})[0]; + edgeCost = Math.round(measuredDistance / canvas.dimensions.distance); + if (ray.terrainRulerFinalState?.noDiagonals === 1) { + edgeCost = 1.5; + } + // Charge 1.0001 instead of 1 for diagonals to discourage unnecessary diagonals + if (isDiagonal && edgeCost == 1) { + edgeCost = 1.0001; + } + } + else { + // 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 + edgeCost = isDiagonal ? (use5105 ? 1.5 : 1.0001) : 1; + } 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 - let edgeCost = isDiagonal ? (use5105 ? 1.5 : 1.0001) : 1; node.edges.push({target: neighbor, cost: edgeCost}); } } diff --git a/js/util.js b/js/util.js index 6b73e89..c2c1bfe 100644 --- a/js/util.js +++ b/js/util.js @@ -167,15 +167,19 @@ export function getAreaFromPositionAndShape(position, shape) { } export function getTokenShape(token) { - if (token.scene.data.gridType === CONST.GRID_TYPES.GRIDLESS) { + return getTokenShapeForTokenData(buildSnapPointTokenData(token), token.scene); +} + +export function getTokenShapeForTokenData(tokenData, scene=canvas.scene) { + if (scene.data.gridType === CONST.GRID_TYPES.GRIDLESS) { return [{x: 0, y: 0}] } - else if (token.scene.data.gridType === CONST.GRID_TYPES.SQUARE) { - const topOffset = -Math.floor(token.data.height / 2) - const leftOffset = -Math.floor(token.data.width / 2) + else if (scene.data.gridType === CONST.GRID_TYPES.SQUARE) { + const topOffset = -Math.floor(tokenData.height / 2) + const leftOffset = -Math.floor(tokenData.width / 2) const shape = [] - for (let y = 0;y < token.data.height;y++) { - for (let x = 0;x < token.data.width;x++) { + for (let y = 0;y < tokenData.height;y++) { + for (let x = 0;x < tokenData.width;x++) { shape.push({x: x + leftOffset, y: y + topOffset}) } } @@ -183,8 +187,8 @@ export function getTokenShape(token) { } else { // Hex grids - if (game.modules.get("hex-size-support")?.active && CONFIG.hexSizeSupport.getAltSnappingFlag(token)) { - const borderSize = token.data.flags["hex-size-support"].borderSize; + if (game.modules.get("hex-size-support")?.active && tokenData.hexSizeSupport.altSnappingFlag) { + const borderSize = tokenData.hexSizeSupport.borderSize; let shape = [{x: 0, y: 0}]; if (borderSize >= 2) shape = shape.concat([{x: 0, y: -1}, {x: -1, y: -1}]); @@ -193,7 +197,7 @@ export function getTokenShape(token) { if (borderSize >= 4) shape = shape.concat([{x: -2, y: -1}, {x: 1, y: -1}, {x: -1, y: -2}, {x: 0, y: -2}, {x: 1, y: -2}]) - if (Boolean(CONFIG.hexSizeSupport.getAltOrientationFlag(token)) !== canvas.grid.grid.options.columns) + if (Boolean(tokenData.hexSizeSupport.altOrientationFlag) !== canvas.grid.grid.options.columns) shape.forEach(space => space.y *= -1); if (canvas.grid.grid.options.columns) shape = shape.map(space => {return {x: space.y, y: space.x}}); diff --git a/rust/Cargo.lock b/rust/Cargo.lock index c0676ea..1a86120 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "block-buffer" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.9.1" @@ -24,6 +33,45 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "cpufeatures" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57952ca27b5e3606ff4dd79b0020231aaf9d6aa76dc05fd30137538c50bd3ce8" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gridless-pathfinding" version = "1.12.8" @@ -31,6 +79,7 @@ dependencies = [ "console_error_panic_hook", "js-sys", "rustc-hash", + "sha1", "wasm-bindgen", ] @@ -49,6 +98,12 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "libc" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916d2ae698f6de9bfb891ad7a8d65c09d232dc58cc4ac433c7da3b2fd84bc2b" + [[package]] name = "log" version = "0.4.14" @@ -82,6 +137,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "sha1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77f4e7f65455545c2153c1253d25056825e77ee2533f0e41deb65a93a34852f" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "syn" version = "1.0.86" @@ -93,12 +159,24 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "typenum" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" + [[package]] name = "unicode-xid" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + [[package]] name = "wasm-bindgen" version = "0.2.79" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 226a9b6..02b8521 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -16,4 +16,5 @@ lto = true console_error_panic_hook = "0.1.7" js-sys = "0.3.56" rustc-hash = "1.1.0" +sha1 = "0.10.1" wasm-bindgen = "0.2.79" diff --git a/rust/src/js_api.rs b/rust/src/js_api.rs index c90a532..d2fbc8c 100644 --- a/rust/src/js_api.rs +++ b/rust/src/js_api.rs @@ -1,4 +1,5 @@ use js_sys::Array; +use sha1::{Sha1, Digest}; use wasm_bindgen::prelude::*; use crate::{ @@ -262,6 +263,14 @@ pub fn debug_get_pathfinding_points(pathfinder: &Pathfinder) -> Array { .collect() } +#[allow(dead_code)] +#[wasm_bindgen] +pub fn sha1(input: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(input); + format!("{:x}", hasher.finalize()) +} + trait IteratePath { fn iter_path(&self) -> PathIterator; }