From d467fe5bcf53ea9a290d981b2a7138827265df55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20V=C3=B6gele?= Date: Sun, 30 Jan 2022 00:06:48 +0100 Subject: [PATCH] Add pathfinidng support for square and hex grids --- lang/de.json | 8 +++ lang/en.json | 12 ++++ src/foundry_fixes.js | 18 +++++ src/foundry_imports.js | 34 +++++++++- src/keybindings.js | 24 +++++++ src/main.js | 5 ++ src/pathfinding.js | 147 +++++++++++++++++++++++++++++++++++++++++ src/ruler.js | 18 ++++- src/settings.js | 19 ++++++ src/util.js | 6 ++ 10 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 src/pathfinding.js diff --git a/lang/de.json b/lang/de.json index f394d00..424a33b 100644 --- a/lang/de.json +++ b/lang/de.json @@ -40,9 +40,17 @@ "moveWithoutAnimation": { "name": "Token animation deaktivieren", "hint": "Wenn diese Taste gedrückt wird, während ein Token fallen gelassen wird, bewegt es sich ohne Animation zum Zielort." + }, + "togglePathfinding": { + "name": "Wegfindung umschalten", + "hint": "Wenn diese Taste gedrückt gehalten wird, während ein Token gezogen wird, wird die Wegfindung vorübergehend aktiviert/deaktiviert" } }, "settings": { + "allowPathfinding": { + "name": "Wegfindung aktivieren", + "hint": "Aktiviert Drag Ruler's Wegfindungsfunktion. Bitte beachte, dass die Wegfindung Wege durch unerkundeten Nebel des Kriegs und Ätherische Wände berechnen kann. Dies kann deinen Spielern Geheimnisse lüften, von denen sie noch nicht erfahren sollten." + }, "alwaysShowSpeedForPCs": { "name": "Geschwindigkeit von Spielercharakteren für jeden anzeigen", "hint": "Wenn diese Einstellung aktiviert ist wird die Färbung der hervorgehobenen Felder bei Spielercharakteren allen Spielern angezeigt, selbst wenn diese keinen Zugriff auf den Charakterbogen haben." diff --git a/lang/en.json b/lang/en.json index b39dfd2..fae78e3 100644 --- a/lang/en.json +++ b/lang/en.json @@ -40,9 +40,17 @@ "moveWithoutAnimation": { "name": "Disable token animation", "hint": "When being held while dropping a token, the token will move to the target location without animating" + }, + "togglePathfinding": { + "name": "Toggle pathfinding", + "hint": "When being held while dragging a token, the pathfinding functionality will be temporarily enabled/disabled" } }, "settings": { + "allowPathfinding": { + "name": "Enable pathfinding feature", + "hint": "Enables Drag Ruler's pathfinding functionality in this world. Be aware that pathfinding can route through unexplored fog of war and Ethereal Walls, which might reveal secrets to your players ahead of time." + }, "alwaysShowSpeedForPCs": { "name": "Show PC speed to everyone", "hint": "If enabled the coloring based on actor speed for player characters will shown to everyone, even if they don't have observer permission for the character sheet." @@ -51,6 +59,10 @@ "name": "Automatically start measuring", "hint": "If enabled, Drag Ruler will start measuring as soon as the token is being dragged. If disabled, Drag Ruler will remain inactive and will only start measuring once the button to add a waypoint is being pressed." }, + "autoPathfinding": { + "name": "Pathfinding by default", + "hint": "If enabled, dragging a token will automatically use a pathfinding ruler" + }, "enableMovementHistory": { "name": "Enable movement history during combat", "hint": "If enabled, Drag Ruler will remember the path a token took during it's turn in combat and will display it when you pick the token back up." diff --git a/src/foundry_fixes.js b/src/foundry_fixes.js index 7098aeb..9cafa59 100644 --- a/src/foundry_fixes.js +++ b/src/foundry_fixes.js @@ -16,3 +16,21 @@ export function getGridPositionFromPixels(xPixel, yPixel) { return [y, x] return [x, y] } + +export function getGridPositionFromPixelsObj(o) { + const r = {}; + [r.x, r.y] = getGridPositionFromPixels(o.x, o.y); + return r; +} + +export function getPixelsFromGridPositionObj(o) { + const r = {}; + [r.x, r.y] = getPixelsFromGridPosition(o.x, o.y); + return r; +} + +export function getCenterFromGridPositionObj(o) { + const r = getPixelsFromGridPositionObj(o); + [r.x, r.y] = canvas.grid.getCenter(r.x, r.y); + return r; +} diff --git a/src/foundry_imports.js b/src/foundry_imports.js index 8fd1002..4eca1d7 100644 --- a/src/foundry_imports.js +++ b/src/foundry_imports.js @@ -1,8 +1,10 @@ import {highlightMeasurementTerrainRuler, measureDistances} from "./compatibility.js"; -import {getGridPositionFromPixels} from "./foundry_fixes.js"; +import {getCenterFromGridPositionObj, getGridPositionFromPixels, getGridPositionFromPixelsObj} from "./foundry_fixes.js"; import {Line} from "./geometry.js"; import {disableSnap, moveWithoutAnimation} from "./keybindings.js"; import {trackRays} from "./movement_tracking.js" +import {findPath, isPathfindingEnabled} from "./pathfinding.js"; +import {settingsKey} from "./settings.js"; import {recalculate} from "./socket.js"; import {applyTokenSizeOffset, enumeratedZip, getSnapPointForEntity, getSnapPointForToken, getTokenShape, highlightTokenShape, sum} from "./util.js"; @@ -153,14 +155,44 @@ export function cancelScheduledMeasurement() { // This is a modified version of Ruler.measure form foundry 0.7.9 export function measure(destination, options={}) { + const isToken = this.draggedEntity instanceof Token; if (isToken && !this.draggedEntity.isVisible) return [] + options.snap = options.snap ?? !disableSnap; + if (options.snap) { destination = getSnapPointForEntity(destination.x, destination.y, this.draggedEntity); } + this.dragRulerRemovePathfindingWaypoints(); + + if (isToken && isPathfindingEnabled()) { + let path = findPath(getGridPositionFromPixelsObj(this.waypoints[this.waypoints.length - 1]), getGridPositionFromPixelsObj(destination), this.waypoints); + if (path) { + path = path.map(point => getCenterFromGridPositionObj(point)) + + // If the token is snapped to the grid, the first point of the path is already handled by the ruler + if (path[0].x === this.waypoints[this.waypoints.length - 1].x && path[0].y === this.waypoints[this.waypoints.length - 1].y) + path = path.slice(1); + + // If snapping is enabled, the last point of the path is already handled by the ruler + if (options.snap) + path = path.slice(0, path.length - 1); + + for (const point of path) { + point.isPathfinding = true; + this.labels.addChild(new PreciseText("", CONFIG.canvasTextStyle)); + } + this.waypoints = this.waypoints.concat(path); + } + else { + // Don't show a path if the pathfinding yields no result to show the user that the destination is unreachable + destination = this.waypoints[this.waypoints.length - 1]; + } + } + if(options.gridSpaces === undefined) { options.gridSpaces = canvas.grid.type !== CONST.GRID_TYPES.GRIDLESS; } diff --git a/src/keybindings.js b/src/keybindings.js index 3db26c5..cfa5b49 100644 --- a/src/keybindings.js +++ b/src/keybindings.js @@ -3,6 +3,7 @@ import {getMeasurePosition, setSnapParameterOnOptions} from "./util.js"; export let disableSnap = false; export let moveWithoutAnimation = false; +export let togglePathfinding = false; export function registerKeybindings() { game.keybindings.register(settingsKey, "cancelDrag", { @@ -50,6 +51,16 @@ export function registerKeybindings() { }], precedence: -1, }); + + if (game.settings.get(settingsKey, "allowPathfinding")) { + game.keybindings.register(settingsKey, "togglePathfinding", { + name: "drag-ruler.keybindings.togglePathfinding.name", + hint: "drag-ruler.keybindings.togglePathfinding.hint", + onDown: handleTogglePathfinding, + onUp: handleTogglePathfinding, + precedence: -1, + }); + } } function handleDeleteWaypoint() { @@ -103,3 +114,16 @@ function handleDisableSnap(event) { function handleMoveWithoutAnimation(event) { moveWithoutAnimation = !event.up; } + +function handleTogglePathfinding(event) { + togglePathfinding = !event.up; + + const ruler = canvas.controls.ruler; + if (!ruler?.isDragRuler) + return false; + if (ruler._state !== Ruler.STATES.MEASURING) + return false; + + ruler.measure(getMeasurePosition(), {snap: !disableSnap}); + return false; +} diff --git a/src/main.js b/src/main.js index 5b67e6e..0a761aa 100644 --- a/src/main.js +++ b/src/main.js @@ -13,6 +13,9 @@ import {recalculate} from "./socket.js"; import {SpeedProvider} from "./speed_provider.js" import {setSnapParameterOnOptions} from "./util.js"; +CONFIG.debug.dragRuler = false; +export let debugGraphics = undefined; + Hooks.once("init", () => { registerSettings() registerKeybindings() @@ -37,6 +40,8 @@ Hooks.once("ready", () => { performMigrations() checkDependencies(); Hooks.callAll("dragRuler.ready", SpeedProvider) + if (CONFIG.debug.dragRuler) + debugGraphics = canvas.controls.addChild(new PIXI.Container()); }) Hooks.on("canvasReady", () => { diff --git a/src/pathfinding.js b/src/pathfinding.js new file mode 100644 index 0000000..52afdce --- /dev/null +++ b/src/pathfinding.js @@ -0,0 +1,147 @@ +import {getCenterFromGridPositionObj, getGridPositionFromPixelsObj} from "./foundry_fixes.js"; +import {togglePathfinding} from "./keybindings.js"; +import {debugGraphics} from "./main.js"; +import {settingsKey} from "./settings.js"; +import {iterPairs} from "./util.js"; + +let cachedNodes = undefined; +let use5105 = false; + +export function isPathfindingEnabled() { + if (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) + return false; + if (!game.settings.get(settingsKey, "allowPathfinding")) + return false; + return game.settings.get(settingsKey, "autoPathfinding") != togglePathfinding; +} + +export function findPath(from, to, previousWaypoints) { + const lastNode = calculatePath(from, to, previousWaypoints); + if (!lastNode) + return null; + paintPathfindingDebug(lastNode); + const path = []; + let currentNode = lastNode; + while (currentNode) { + // TODO Check if the distance doesn't change + if (path.length >= 2 && !canvas.walls.checkCollision(new Ray(getCenterFromGridPositionObj(currentNode.node), getCenterFromGridPositionObj(path[path.length - 2])))) + // 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}); + currentNode = currentNode.previous; + } + return path; +} + +export function wipePathfindingCache() { + cachedNodes = undefined; +} + +function getNode(pos, initialize=true) { + pos = {layer: 0, ...pos}; // Copy pos and set pos.layer to the default value if it's unset + if (!cachedNodes) + cachedNodes = new Array(2); + if (!cachedNodes[pos.layer]) + cachedNodes[pos.layer] = new Array(Math.ceil(canvas.dimensions.height / canvas.grid.h)); + if (!cachedNodes[pos.layer][pos.y]) + cachedNodes[pos.layer][pos.y] = new Array(Math.ceil(canvas.dimensions.width / canvas.grid.w)); + if (!cachedNodes[pos.layer][pos.y][pos.x]) { + cachedNodes[pos.layer][pos.y][pos.x] = pos; + } + + const node = cachedNodes[pos.layer][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) + continue; + // TODO Work with pixels instead of grid locations + if (!canvas.walls.checkCollision(new Ray(getCenterFromGridPositionObj(pos), getCenterFromGridPositionObj(neighborPos)))) { + const isDiagonal = node.x !== neighborPos.x && node.y !== neighborPos.y && canvas.grid.type === CONST.GRID_TYPES.SQUARE; + let targetLayer = pos.layer; + if (use5105 && isDiagonal) + targetLayer = 1 - targetLayer; + const neighbor = getNode({...neighborPos, layer: targetLayer}, false); + + // TODO We currently assume a cost of one or two for all transitions. Change this for difficult terrain support + let edgeCost = 1; + if (isDiagonal) { + // We charge 0.0001 more for edges to avoid unnecessary diagonal steps + edgeCost = pos.layer === 1 && targetLayer === 0 ? 2 : 1.0001; + } + node.edges.push({target: neighbor, cost: edgeCost}); + } + } + } + return node; +} + +function calculatePath(from, to, previousWaypoints) { + if (game.system.id === "pf2e") + use5105 = true; + if (canvas.grid.diagonalRule === "5105") + use5105 = true; + let startLayer = 0; + if (use5105) { + previousWaypoints = previousWaypoints.map(w => getGridPositionFromPixelsObj(w)); + startLayer = calcNoDiagonals(previousWaypoints) % 2; + } + const nextNodes = [{node: getNode({...to, layer: startLayer}), cost: 0, estimated: estimateCost(to, from), previous: null}]; + const previousNodes = new Set(); + while (nextNodes.length > 0) { + // Sort by estimated cost, high to low + // TODO Re-sorting every iteration is expensive. Think of something better + nextNodes.sort((a, b) => b.estimated - a.estimated); + // Get node with cheapest estimate + const currentNode = nextNodes.pop(); + if (currentNode.node.x === from.x && currentNode.node.y === from.y) + return currentNode; + previousNodes.add(currentNode.node); + for (const edge of currentNode.node.edges) { + const neighborNode = getNode(edge.target); + if (previousNodes.has(neighborNode)) + continue; + const neighbor = {node: neighborNode, cost: currentNode.cost + edge.cost, estimated: currentNode.cost + edge.cost + estimateCost(neighborNode, from), previous: currentNode}; + const neighborIndex = nextNodes.findIndex(node => node.node === neighbor.node); + if (neighborIndex >= 0) { + // If the neighbor is cheaper to reach via the current route than through previously discovered routes, replace it + if (nextNodes[neighborIndex].cost > neighbor.cost) { + nextNodes[neighborIndex] = neighbor; + } + } + else { + nextNodes.push(neighbor); + } + } + } +} + +function calcNoDiagonals(waypoints) { + let diagonals = 0; + for (const [p1, p2] of iterPairs(waypoints)) { + diagonals += Math.min(Math.abs(p1.x - p2.x), Math.abs(p1.y - p2.y)); + } + return diagonals; +} + +function estimateCost(pos, target) { + return Math.max(Math.abs(pos.x - target.x), Math.abs(pos.y - target.y)); +} + +function paintPathfindingDebug(lastNode) { + if (!CONFIG.debug.dragRuler) + return; + + debugGraphics.removeChildren(); + let currentNode = lastNode; + while (currentNode) { + let text = new PIXI.Text(currentNode.cost.toFixed(0)); + let pixels = getCenterFromGridPositionObj(currentNode.node); + text.anchor.set(0.5, 1.0); + text.x = pixels.x; + text.y = pixels.y; + debugGraphics.addChild(text); + currentNode = currentNode.previous; + } +} diff --git a/src/ruler.js b/src/ruler.js index 5163a47..da115cb 100644 --- a/src/ruler.js +++ b/src/ruler.js @@ -2,6 +2,7 @@ import {currentSpeedProvider, getColorForDistanceAndToken, getRangesFromSpeedPro import {getHexSizeSupportTokenGridCenter} from "./compatibility.js"; import {cancelScheduledMeasurement, measure} from "./foundry_imports.js" import {getMovementHistory} from "./movement_tracking.js"; +import {wipePathfindingCache} from "./pathfinding.js"; import {settingsKey} from "./settings.js"; import {getSnapPointForEntity} from "./util.js"; @@ -32,8 +33,13 @@ export function extendRuler() { const json = super.toJSON(); if (this.draggedEntity) { const isToken = this.draggedEntity instanceof Token; - json["draggedEntityIsToken"] = isToken; - json["draggedEntity"] = this.draggedEntity.id; + json.draggedEntityIsToken = isToken; + json.draggedEntity = this.draggedEntity.id; + json.waypoints = json.waypoints.map(old => { + let w = duplicate(old); + w.isPathfinding = undefined; + return w; + }); } return json; } @@ -75,6 +81,7 @@ export function extendRuler() { } this.waypoints.push(new PIXI.Point(point.x, point.y)); this.labels.addChild(new PreciseText("", CONFIG.canvasTextStyle)); + this.waypoints.filter(waypoint => waypoint.isPathfinding).forEach(waypoint => waypoint.isPathfinding = false); } dragRulerAddWaypointHistory(waypoints) { @@ -91,6 +98,7 @@ export function extendRuler() { } dragRulerDeleteWaypoint(event={preventDefault: () => {return}}, options={}) { + this.dragRulerRemovePathfindingWaypoints(); options.snap = options.snap ?? true; if (this.waypoints.filter(w => !w.isPrevious).length > 1) { event.preventDefault(); @@ -107,6 +115,11 @@ export function extendRuler() { } } + dragRulerRemovePathfindingWaypoints() { + this.waypoints.filter(waypoint => waypoint.isPathfinding).forEach(_ => this.labels.removeChild(this.labels.children.pop())); + this.waypoints = this.waypoints.filter(waypoint => !waypoint.isPathfinding); + } + dragRulerAbortDrag(event={preventDefault: () => {return}}) { const token = this.draggedEntity; this._endMeasurement(); @@ -171,6 +184,7 @@ export function extendRuler() { return; const ruler = canvas.controls.ruler; ruler.clear(); + wipePathfindingCache(); ruler._state = Ruler.STATES.STARTING; let entityCenter; if (isToken && canvas.grid.isHex && game.modules.get("hex-size-support")?.active && CONFIG.hexSizeSupport.getAltSnappingFlag(entity)) diff --git a/src/settings.js b/src/settings.js index 5155f5d..7ba23b2 100644 --- a/src/settings.js +++ b/src/settings.js @@ -82,6 +82,25 @@ export function registerSettings() { default: true, }); + game.settings.register(settingsKey, "allowPathfinding", { + name: "drag-ruler.settings.allowPathfinding.name", + hint: "drag-ruler.settings.allowPathfinding.hint", + scope: "world", + config: true, + type: Boolean, + default: false, + onChange: () => location.reload(), + }); + + game.settings.register(settingsKey, "autoPathfinding", { + name: "drag-ruler.settings.autoPathfinding.name", + hint: "drag-ruler.settings.autoPathfinding.hint", + scpoe: "client", + config: true, + type: Boolean, + defualt: false, + }); + game.settings.register(settingsKey, "lastTerrainRulerHintTime", { config: false, type: Number, diff --git a/src/util.js b/src/util.js index bb587b2..dd0eee1 100644 --- a/src/util.js +++ b/src/util.js @@ -15,6 +15,12 @@ export function* enumeratedZip(it1, it2) { } } +export function* iterPairs(l) { + for (let i = 1;i < l.length;i++) { + yield [l[i - 1], l[i]]; + } +} + export function sum(arr) { return arr.reduce((a, b) => a + b, 0); }