Compare commits

...

65 Commits

Author SHA1 Message Date
Manuel Vögele c08d65ffad Work towards gridless difficult terrain pathfinding 2022-06-10 16:52:44 +02:00
Manuel Vögele 445c03d29a Add difficult terrain support for pathfinding on gridded scenes 2022-05-10 08:39:19 +02:00
Manuel Vögele 5904efbdd5 Remove redundant first waypoint from path generated by pathfinder
This fixes a bug where gridless snapping would snap slightly below the target range
2022-05-07 08:12:30 +02:00
Manuel Vögele d325f8acee Don't attempt to start background caching on gridless scenes, as it's currently unsupported (fixes #196) 2022-05-06 20:22:42 +02:00
Jonathan Calvert 1577435a33 Fix issue caused by client setting moving from Levels to Wall Height (#195) 2022-05-06 20:16:52 +02:00
Cole Schultz 1b8b2328c6 Add the pathfinding radius setting (#194) 2022-04-29 10:41:05 +02:00
Manuel Vögele 0dba39b145 Merge branch 'master' into develop 2022-04-12 00:22:20 +02:00
Manuel Vögele c1b0e9d7fb Release v1.12.8 2022-04-12 00:13:34 +02:00
Manuel Vögele 9d227efcd1 Enusre that measurements to determine the highlight color are always done center to center (fixes #189) 2022-04-12 00:11:30 +02:00
Manuel Vögele a3d942c287 Apply wall height's bounds to the rays before doing colission checks (fixes #190) 2022-04-12 00:11:30 +02:00
Manuel Vögele bdbd4fde18 Merge branch 'master' into develop 2022-04-01 18:07:25 +02:00
Manuel Vögele 3afb1f30b7 Release v1.12.7 2022-04-01 18:06:46 +02:00
Manuel Vögele 654f8e3111 Fix a typo that caused measurement template snapping to be broken (fixes #185) 2022-04-01 18:04:25 +02:00
Jonathan Calvert 2979e86201 Improve Pathfinding: Add background caching (#175) 2022-03-09 18:40:43 +01:00
Manuel Vögele 8f81fd707e Merge branch 'master' into develop 2022-03-08 23:13:36 +01:00
Manuel Vögele 5f6e348bf0 Release v1.12.6 2022-03-08 23:12:17 +01:00
Manuel Vögele 82f2a885fb getSnappedPosition cannot be used on gridless to calculate the snap point for measured templates 2022-03-08 23:09:06 +01:00
Manuel Vögele d536ae8dfb Merge branch 'master' into develop 2022-03-08 22:07:43 +01:00
Manuel Vögele 5add0be393 Release v1.12.5 2022-03-08 22:06:14 +01:00
Manuel Vögele f77990420a Replace getSnapPointForMeasuredTemplate with foundry native function (fixes #181) 2022-03-08 20:17:25 +01:00
Manuel Vögele 44efabc656 Merge branch 'master' into develop 2022-03-08 12:50:16 +01:00
Manuel Vögele e84632c0d7 Release v1.12.4 2022-03-08 12:49:56 +01:00
Manuel Vögele 30bd3d33e0 If no dragged entity is being transmitted, unset it (fixes #179) 2022-03-08 12:48:17 +01:00
Manuel Vögele 6604e322bd Show diagnoal count in debug print 2022-03-08 11:49:27 +01:00
Manuel Vögele b68f3bbe29 Elevation also needs to be respected if only "wall-height" is enabled 2022-03-08 11:45:58 +01:00
Manuel Vögele 793e87bc2f Merge branch 'master' into develop 2022-03-08 09:56:03 +01:00
Manuel Vögele df4c515fcd Release v1.12.3 2022-03-07 16:53:48 +01:00
Manuel Vögele 02dfd8db0d Only reclaculate the ruler if it's a Drag Ruler (fixes #161) 2022-03-07 14:39:31 +01:00
Manuel Vögele 65911c11f0 Only reclaculate the ruler if it's a Drag Ruler (fixes #161) 2022-03-07 14:37:15 +01:00
Manuel Vögele ddf89f9499 Add support for the wall height module in the gridless pathfinder 2022-03-07 12:40:16 +01:00
Jonathan Calvert 0dfdb23bfb Levels Compatibility: Clear pathfinding cache after changing elevation (#173) 2022-03-07 12:39:16 +01:00
Jonathan Calvert 3f2e9e1a3e Fix one-way wall collision detection (fixes #167) (#172) 2022-03-07 12:38:30 +01:00
Jonathan Calvert 846e39d946 Pathfinding: Keep Multiple Caches (#177) 2022-03-05 21:36:56 +01:00
Manuel Vögele 26748ee234 Revert ebd0ee6ccf, which introduced a bug instead of fixing one 2022-03-04 08:40:04 +01:00
Manuel Vögele ebd0ee6ccf Fix the order of colission checks during pathfinding waypoint culling to make one-way-walls work properly 2022-03-02 22:33:42 +01:00
Jonathan Calvert 1d477094b8 Fix bug with diagonal movement (#174) 2022-03-01 14:28:01 +01:00
Jonathan Calvert bbfce2bb77 Pathfinding Improvements: Improve 5-10-5 Diagonal Handling (#170) 2022-02-28 23:08:56 +01:00
Manuel Vögele 54cee52eb2 Add support for the wall height module in the gridless pathfinder 2022-02-28 22:48:15 +01:00
Jonathan Calvert a967a03197 Levels Compatibility: Clear pathfinding cache after changing elevation (#173) 2022-02-28 21:17:54 +01:00
Jonathan Calvert 8d243ce919 Fix one-way wall collision detection (fixes #167) (#172) 2022-02-28 19:13:12 +01:00
Jonathan Calvert b227d5073b Pathfinding Improvements: Replace nextNodes array with PriorityQueueSet (#169) 2022-02-28 18:23:46 +01:00
Jonathan Calvert 576db2dc5a Pathfinding Improvements: Bound Algorithm to Canvas (#168) 2022-02-28 09:52:12 +01:00
EndlesNights b031acaa6e dnd 4e system support (#162) 2022-02-28 09:42:10 +01:00
Manuel Vögele ee956abd78 Update .gitignore 2022-02-17 11:15:40 +01:00
Manuel Vögele 1334703877 Release v1.12.2 2022-02-17 00:58:33 +01:00
Manuel Vögele d6cccf4466 Ensure pathfinding on gridless is possible by always generating a pathfinding point when a virtual wall is being inserted 2022-02-17 00:53:51 +01:00
Manuel Vögele 074d2f5052 Disable pathfinding when the hotkey for moving tokens without pathfinding is enabled (fixes #158) 2022-02-17 00:47:13 +01:00
Manuel Vögele e5b8f896f8 Release v1.12.1 2022-02-16 12:48:59 +01:00
Manuel Vögele 3de350d3e9 Remove wasm snippets from release builds 2022-02-16 12:47:23 +01:00
Manuel Vögele 8f97081236 Release v1.12.0 2022-02-16 11:57:33 +01:00
Manuel Vögele 18253dc42b Delay reload to make sure all settings are being applied 2022-02-16 11:46:46 +01:00
Manuel Vögele fb702cd850 Add support for gridless pathfinding 2022-02-15 17:56:08 +01:00
Manuel Vögele ad3fdf4d18 Release v1.11.5 2022-02-12 18:29:52 +01:00
Manuel Vögele 58946096a6 Remove debugging output 2022-02-12 18:25:50 +01:00
Manuel Vögele 22112ddfd5 Release v1.11.4 2022-02-09 10:58:02 +01:00
Manuel Vögele f59b0f9d2d When changing the measurement mode via a keybinding send the updated ruler state to other players immediately (fixes #152) 2022-02-09 10:51:19 +01:00
Manuel Vögele 443cfd4317 Don't apply snapping to rulers received from other players (fixes #150) 2022-02-09 10:41:01 +01:00
Manuel Vögele fb3665d758 If no measurement has been performed yet before dropping the token, schedule a measurement on drop (fixes #153) 2022-02-09 10:30:17 +01:00
Manuel Vögele 96cb690431 Use foundries new, improved way of wainting for animations when moving tokens (fixes #156) 2022-02-09 08:57:30 +01:00
José Lozano fb7202d5f0 Spanish updated (#155) 2022-02-07 10:07:07 +01:00
Manuel Vögele fa581ddfc3 Release v1.11.3 2022-02-03 18:23:35 +01:00
Manuel Vögele 56e15be4ca Don't perform pathfinding on other player's rulers 2022-02-03 17:59:35 +01:00
Manuel Vögele 1509307313 Show the setting for automatic pathfinding to players if they are allowed to use pathfinding 2022-02-03 17:59:09 +01:00
Manuel Vögele 68885385ea Formatting fix 2022-02-03 17:42:35 +01:00
Manuel Vögele 6748617f6a Add "DnD5e Drag Ruler Integration" and "Shadow of the Demon Lord" to list of Drag Ruler integraion 2022-02-01 20:57:44 +01:00
38 changed files with 2503 additions and 216 deletions
+5 -1
View File
@@ -1 +1,5 @@
foundry-*.js /foundry*.js
artifact/
wasm/
*.lock
.vscode/
+77
View File
@@ -1,3 +1,80 @@
## 1.12.8
### Bugfixes
- Fixed a bug that could cause grid cells to be highlighted in the wrong color
### Compatibility
- Fixed an interaction with the Wall Height module that could allow players to walk through walls
## 1.12.7
### Bugfixes
- Fixed a bug that caused measurement templates to only snap to the corners of the grid (this was a regression introduced in 1.12.5)
## 1.12.6
### Bugfixes
- Measured templates no longer snap to a virtual grid on gridless scenes (this was a regression introduced in 1.12.5)
## 1.12.5
### Compatibility
- Drag Ruler is now compatible with DF Template Enhancements
## 1.12.4
### Bugfixes
- Fixed a bug that could cause Drag Ruler to override the default ruler color on other player's clients
## 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
- Pathfinding will now be disabled when the hotkey to move tokens without animation is being pressed, to allow GMs to move their tokens through walls
## 1.12.1
### Hotfix
- Version 1.12.0 was incorrectly packaged, which caused it to fail to load
## 1.12.0
### New features
- Pathfinding is now supported on gridless scenes
## 1.11.5
### Bugfixes
- Fixed a bug that was causing Drag Ruler to spam useless warnings into the console (this was a regression introduced in 1.11.4)
## 1.11.4
### Bugfixes
- When changing the measurement mode via a keybinding (toggle snaping or toggle pathfinding) the updated ruler will now be sent to other players immediately
- Fixed a bug that incorrectly showed a ruler to be snapped to other players despite the ruler not being snapped
- Fixed a bug that could cause a token to move to an incorrect location if the token was being dragged and dropped very rapidly
- Drag Ruler's token movement animations can now be properly waited for (this improves the interaction with modules like sequencer)
### Translation
- Updated Spanish translation (thanks to Viriato139ac#342)
## 1.11.3
### Bugfixes
- The setting to automatically start pathfinding is now visible to players again (this was a regression introduced in 1.11.2)
- Fixed a bug that would show the measurements of other players as if they were using the pathfinder, even if they were not using it.
## 1.11.2 ## 1.11.2
### Bugfixes ### Bugfixes
- Fixed a memory leak that could cause the rule to slow down after using the pathfinding functionality for a while - Fixed a memory leak that could cause the rule to slow down after using the pathfinding functionality for a while
+2
View File
@@ -37,6 +37,7 @@ Drag Ruler will work with all Foundry VTT game systems. However, some game syste
The game systems that offer Drag Ruler integration are: The game systems that offer Drag Ruler integration are:
- Cypher System (starting with version 1.13.0) - Cypher System (starting with version 1.13.0)
- DnD5e (via the module [DnD5e Drag Ruler Integration](https://foundryvtt.com/packages/elevation-drag-ruler))
- GURPS 4th Edition Game Aid (Unofficial) (starting with version 0.9.1) - GURPS 4th Edition Game Aid (Unofficial) (starting with version 0.9.1)
- Ironclaw Second Edition (starting with version 0.2.2) - Ironclaw Second Edition (starting with version 0.2.2)
- Lancer (via the module [Lancer Speed Provider](https://foundryvtt.com/packages/lancer-speed-provider)) - Lancer (via the module [Lancer Speed Provider](https://foundryvtt.com/packages/lancer-speed-provider))
@@ -44,6 +45,7 @@ The game systems that offer Drag Ruler integration are:
- Pathfinder 1 (starting with version 0.77.3) - Pathfinder 1 (starting with version 0.77.3)
- Pathfinder 2e (via the module [PF2E Drag Ruler Integration](https://foundryvtt.com/packages/pf2e-dragruler/)) - Pathfinder 2e (via the module [PF2E Drag Ruler Integration](https://foundryvtt.com/packages/pf2e-dragruler/))
- Shadowrun 5th Edition (via the module [Drag Ruler Integration for Shadowrun 5E](https://foundryvtt.com/packages/drag-ruler-integration-for-shadowrun-5e)) - Shadowrun 5th Edition (via the module [Drag Ruler Integration for Shadowrun 5E](https://foundryvtt.com/packages/drag-ruler-integration-for-shadowrun-5e))
- Shadow of the Demon Lord (starting with version 1.7.15)
- Starfinder (via the module [Starfinder Drag Ruler Integration](https://foundryvtt.com/packages/starfinder-drag-ruler)) - Starfinder (via the module [Starfinder Drag Ruler Integration](https://foundryvtt.com/packages/starfinder-drag-ruler))
- Stargate RPG (starting with version 1.6.0) - Stargate RPG (starting with version 1.6.0)
- Symbaroum (via the module [Symbaroum drag ruler integration](https://foundryvtt.com/packages/symbaroum-drag-ruler-integration)) - Symbaroum (via the module [Symbaroum drag ruler integration](https://foundryvtt.com/packages/symbaroum-drag-ruler-integration))
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
import json
from pathlib import PurePath, Path
import subprocess
import tempfile
import zipfile
wasm_pack = Path("~/.cargo/bin/wasm-pack").expanduser()
root_files = ["module.json", "README.md", "CHANGELOG.md", "LICENSE"]
wasm_files = ["gridless_pathfinding_bg.wasm", "gridless_pathfinding.js"]
output_dir = Path("artifact")
copy_everything_directories = ["js", "lang", "templates"]
wasm_dir = Path("wasm")
root_dir = Path(".")
rust_dir = Path("rust")
build_dir_tmp = tempfile.TemporaryDirectory()
build_dir = Path(build_dir_tmp.name)
with open("module.json", "r") as file:
manifest = json.load(file)
zip_root = PurePath(f'{manifest["name"]}')
filename = f'{manifest["name"]}-{manifest["version"]}.zip'
result = subprocess.run([wasm_pack, "build", "--target", "web", "--out-dir", build_dir, root_dir / rust_dir])
if result.returncode != 0:
raise Exception("Wasm build failed")
output_dir.mkdir(parents=True, exist_ok=True)
def write_directory(archive, d):
for f in (root_dir / d).iterdir():
if f.is_dir():
write_directory(archive, f)
else:
assert(f.is_file())
archive.write(f, arcname=zip_root / d / f.name)
with zipfile.ZipFile(output_dir / filename, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
for f in root_files:
archive.write(root_dir / f, arcname=zip_root / f)
for d in copy_everything_directories:
write_directory(archive, d)
for f in wasm_files:
archive.write(build_dir / f, arcname=zip_root / wasm_dir / f)
print(f"Successfully built {output_dir / filename}")
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env python3
import sys
import subprocess
from pathlib import Path
root_dir = Path(".")
wasm_dir = root_dir / Path("wasm")
rust_dir = root_dir / Path("rust")
debug = " --debug" if len(sys.argv) >= 2 and sys.argv[1] == "--debug" else ""
result = subprocess.run(["cargo", "watch", "-C" , rust_dir, "-s", f"wasm-pack build --target web --out-dir {wasm_dir.resolve()}{debug}"])
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
cargo install cargo-watch
cargo install wasm-pack
+5 -1
View File
@@ -2,7 +2,7 @@ import {measureDistances} from "./compatibility.js";
import {getMovementHistory} from "./movement_tracking.js"; import {getMovementHistory} from "./movement_tracking.js";
import {GenericSpeedProvider, SpeedProvider} from "./speed_provider.js" import {GenericSpeedProvider, SpeedProvider} from "./speed_provider.js"
import {settingsKey} from "./settings.js" import {settingsKey} from "./settings.js"
import {getTokenShape} from "./util.js"; import {getAreaFromPositionAndShape, getTokenShape} from "./util.js";
export const availableSpeedProviders = {} export const availableSpeedProviders = {}
export let currentSpeedProvider = undefined export let currentSpeedProvider = undefined
@@ -140,6 +140,10 @@ export function getMovedDistanceFromToken(token) {
return distances.reduce((acc, val) => acc + val, 0); 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) { export function registerModule(moduleId, speedProvider) {
// Check if a module with the given id exists and is currently enabled // Check if a module with the given id exists and is currently enabled
const module = game.modules.get(moduleId) const module = game.modules.get(moduleId)
+3 -3
View File
@@ -1,6 +1,6 @@
import {getCostFromSpeedProvider} from "./api.js"; import {buildCostFunction} from "./api.js";
import {settingsKey} from "./settings.js"; import {settingsKey} from "./settings.js";
import {getAreaFromPositionAndShape, highlightTokenShape} from "./util.js"; import {highlightTokenShape} from "./util.js";
export function getHexSizeSupportTokenGridCenter(token) { export function getHexSizeSupportTokenGridCenter(token) {
const tokenCenterOffset = CONFIG.hexSizeSupport.getCenterOffset(token) const tokenCenterOffset = CONFIG.hexSizeSupport.getCenterOffset(token)
@@ -27,7 +27,7 @@ export function measureDistances(segments, entity, shape, options={}) {
const newSegments = segments.slice(firstNewSegmentIndex); const newSegments = segments.slice(firstNewSegmentIndex);
const distances = previousSegments.map(segment => segment.ray.dragRulerVisitedSpaces[segment.ray.dragRulerVisitedSpaces.length - 1].distance); const distances = previousSegments.map(segment => segment.ray.dragRulerVisitedSpaces[segment.ray.dragRulerVisitedSpaces.length - 1].distance);
previousSegments.forEach(segment => segment.ray.terrainRulerVisitedSpaces = duplicate(segment.ray.dragRulerVisitedSpaces)); 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) if (previousSegments.length > 0)
opts.terrainRulerInitialState = previousSegments[previousSegments.length - 1].ray.dragRulerFinalState; opts.terrainRulerInitialState = previousSegments[previousSegments.length - 1].ray.dragRulerFinalState;
return distances.concat(terrainRuler.measureDistances(newSegments, opts)); return distances.concat(terrainRuler.measureDistances(newSegments, opts));
+138
View File
@@ -0,0 +1,138 @@
/**
* 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 {
constructor(elementMatcher, priorityFunction) {
this.first = null;
this.elementMatcher = elementMatcher;
this.priorityFunction = priorityFunction;
}
pushWithPriority(value) {
const newNode = {value, priority: this.priorityFunction(value), next: null};
// If the queue is currently empty, we can just set this new node as the first and we're done
if (!this.first) {
this.first = newNode;
return;
}
let inserted = false;
let previous;
let current = this.first;
// Loop through the existing elements
while (current) {
if (this.elementMatcher(current.value, value)) {
// We've found an equivalent element before one with a lower priority. This one has at least
// the same priority as the new one, so don't bother inserting
return;
} else if (newNode.priority <= current.priority) {
// We've found some element with lower priority than the new one, so insert the new one just before it
newNode.next = current;
if (previous) {
previous.next = newNode;
} else {
this.first = newNode;
}
inserted = true;
previous = current;
current = current.next;
break;
}
previous = current;
current = current.next;
}
if (inserted) {
// Go through the rest of the list and try to find an equivalent element to the new one.
// We know it has higher priority than the new one, so remove it.
while (current) {
if (this.elementMatcher(current.value, value)) {
if (previous) {
previous.next = current.next;
} else {
this.first = current.next;
}
return;
}
previous = current;
current = current.next;
}
} else {
// We reached the end of the queue without finding a lower-priority or existing element, so
// insert the new one at the end
previous.next = newNode;
}
}
hasNext() {
return !!this.first;
}
pop() {
const first = this.first;
this.first = first?.next;
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;
}
}
@@ -28,6 +28,9 @@ export async function moveEntities(draggedEntity, selectedEntities) {
const hasCollision = selectedEntities.some(token => { const hasCollision = selectedEntities.some(token => {
const offset = calculateEntityOffset(token, draggedEntity); const offset = calculateEntityOffset(token, draggedEntity);
const offsetRays = rays.filter(ray => !ray.isPrevious).map(ray => applyOffsetToRay(ray, offset)) const offsetRays = rays.filter(ray => !ray.isPrevious).map(ray => applyOffsetToRay(ray, offset))
if (window.WallHeight) {
window.WallHeight.addBoundsToRays(offsetRays, draggedEntity);
}
return offsetRays.some(r => canvas.walls.checkCollision(r)); return offsetRays.some(r => canvas.walls.checkCollision(r));
}) })
if (hasCollision) { if (hasCollision) {
@@ -91,7 +94,7 @@ async function animateEntities(entities, draggedEntity, draggedRays, wasPaused)
}); });
await draggedEntity.scene.updateEmbeddedDocuments(draggedEntity.constructor.embeddedName, updates, {animate}); await draggedEntity.scene.updateEmbeddedDocuments(draggedEntity.constructor.embeddedName, updates, {animate});
if (animate) if (animate)
await Promise.all(entityPaths.map(({entity, path}) => entity.animateMovement(path))); await Promise.all(entityPaths.map(({entity}) => CanvasAnimation.getAnimation(entity.movementAnimationName)?.promise));
// This is a flag of the "Monk's Active Tile Triggers" module that signals that the movement should be cancelled early // This is a flag of the "Monk's Active Tile Triggers" module that signals that the movement should be cancelled early
if (this.cancelMovement) { if (this.cancelMovement) {
@@ -168,11 +171,13 @@ export function measure(destination, options={}) {
this.dragRulerRemovePathfindingWaypoints(); this.dragRulerRemovePathfindingWaypoints();
if (isToken && isPathfindingEnabled()) { if (isToken && isPathfindingEnabled.call(this)) {
const from = getGridPositionFromPixelsObj(this.waypoints[this.waypoints.length - 1]); const from = getGridPositionFromPixelsObj(this.waypoints[this.waypoints.length - 1]);
const to = getGridPositionFromPixelsObj(destination); const to = getGridPositionFromPixelsObj(destination);
let path = findPath(from, to, this.draggedEntity, this.waypoints); let path = findPath(from, to, this.draggedEntity, this.waypoints);
if (path) { if (path)
path.shift();
if (path && path.length > 0) {
path = path.map(point => getSnapPointForTokenObj(getPixelsFromGridPositionObj(point), this.draggedEntity)); path = path.map(point => getSnapPointForTokenObj(getPixelsFromGridPositionObj(point), this.draggedEntity));
// If the token is snapped to the grid, the first point of the path is already handled by the ruler // If the token is snapped to the grid, the first point of the path is already handled by the ruler
@@ -341,7 +346,8 @@ export function highlightMeasurementNative(ray, previousSegments, tokenShape=[{x
if ( x0 === x1 && y0 === y1 ) continue; if ( x0 === x1 && y0 === y1 ) continue;
// Highlight the grid position // Highlight the grid position
let [xg, yg] = canvas.grid.grid.getPixelsFromGridPosition(x1, y1); let [xgtl, ygtl] = canvas.grid.grid.getPixelsFromGridPosition(x1, y1);
let [xg, yg] = canvas.grid.grid.getCenter(xgtl, ygtl);
const pathUntilSpace = previousSegments.concat([{ray: new Ray(ray.A, {x: xg, y: yg})}]); const pathUntilSpace = previousSegments.concat([{ray: new Ray(ray.A, {x: xg, y: yg})}]);
const distance = sum(canvas.grid.measureDistances(pathUntilSpace, {gridSpaces: true})); const distance = sum(canvas.grid.measureDistances(pathUntilSpace, {gridSpaces: true}));
const color = this.dragRulerGetColorForDistance(distance); const color = this.dragRulerGetColorForDistance(distance);
@@ -355,7 +361,8 @@ export function highlightMeasurementNative(ray, previousSegments, tokenShape=[{x
let th = tMax[i - 1] - (0.5 / nMax); let th = tMax[i - 1] - (0.5 / nMax);
let {x, y} = ray.project(th); let {x, y} = ray.project(th);
let [x1h, y1h] = canvas.grid.grid.getGridPositionFromPixels(x, y); let [x1h, y1h] = canvas.grid.grid.getGridPositionFromPixels(x, y);
let [xgh, ygh] = canvas.grid.grid.getPixelsFromGridPosition(x1h, y1h); let [xghtl, yghtl] = canvas.grid.grid.getPixelsFromGridPosition(x1h, y1h);
let [xgh, ygh] = canvas.grid.grid.getCenter(xghtl, yghtl);
const pathUntilSpace = previousSegments.concat([{ray: new Ray(ray.A, {x: xgh, y: ygh})}]); const pathUntilSpace = previousSegments.concat([{ray: new Ray(ray.A, {x: xgh, y: ygh})}]);
const distance = sum(canvas.grid.measureDistances(pathUntilSpace, {gridSpaces: true})); const distance = sum(canvas.grid.measureDistances(pathUntilSpace, {gridSpaces: true}));
const color = this.dragRulerGetColorForDistance(distance); const color = this.dragRulerGetColorForDistance(distance);
View File
+93
View File
@@ -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}
}
+12
View File
@@ -107,11 +107,22 @@ function handleDisableSnap(event) {
return false; return false;
ruler.measure(getMeasurePosition(), {snap: !disableSnap}); ruler.measure(getMeasurePosition(), {snap: !disableSnap});
ruler.dragRulerSendState();
return false; return false;
} }
function handleMoveWithoutAnimation(event) { function handleMoveWithoutAnimation(event) {
moveWithoutAnimation = !event.up; moveWithoutAnimation = !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});
ruler.dragRulerSendState();
return false;
} }
function handleTogglePathfinding(event) { function handleTogglePathfinding(event) {
@@ -124,5 +135,6 @@ function handleTogglePathfinding(event) {
return false; return false;
ruler.measure(getMeasurePosition(), {snap: !disableSnap}); ruler.measure(getMeasurePosition(), {snap: !disableSnap});
ruler.dragRulerSendState();
return false; return false;
} }
+35
View File
@@ -7,15 +7,44 @@ import {disableSnap, registerKeybindings} from "./keybindings.js";
import {libWrapper} from "./libwrapper_shim.js"; import {libWrapper} from "./libwrapper_shim.js";
import {performMigrations} from "./migration.js" import {performMigrations} from "./migration.js"
import {removeLastHistoryEntryIfAt, resetMovementHistory} from "./movement_tracking.js"; import {removeLastHistoryEntryIfAt, resetMovementHistory} from "./movement_tracking.js";
import {wipePathfindingCache, initializePathfinding, startBackgroundCaching, terrainRulerWrapper} from "./pathfinding.js";
import {extendRuler} from "./ruler.js"; import {extendRuler} from "./ruler.js";
import {registerSettings, RightClickAction, settingsKey} from "./settings.js" import {registerSettings, RightClickAction, settingsKey} from "./settings.js"
import {recalculate} from "./socket.js"; import {recalculate} from "./socket.js";
import {SpeedProvider} from "./speed_provider.js" import {SpeedProvider} from "./speed_provider.js"
import {setSnapParameterOnOptions} from "./util.js"; import {setSnapParameterOnOptions} from "./util.js";
import initGridlessPathfinding, * as GridlessPathfinding from "../wasm/gridless_pathfinding.js"
CONFIG.debug.dragRuler = false; CONFIG.debug.dragRuler = false;
export let debugGraphics = undefined; export let debugGraphics = undefined;
initGridlessPathfinding().then(() => {
Hooks.on("canvasInit", wipePathfindingCache);
Hooks.on("canvasReady", () => {
wipePathfindingCache();
initializePathfinding();
});
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", () => { Hooks.once("init", () => {
registerSettings() registerSettings()
registerKeybindings() registerKeybindings()
@@ -33,6 +62,9 @@ Hooks.once("init", () => {
registerSystem, registerSystem,
recalculate, recalculate,
resetMovementHistory, resetMovementHistory,
private: {
terrainRulerWrapper
}
} }
}) })
@@ -137,6 +169,9 @@ function onEntityDragLeftDrop(event) {
// This can happen if the user presses ESC during drag (maybe there are other ways too) // This can happen if the user presses ESC during drag (maybe there are other ways too)
if (selectedTokens.length === 0) if (selectedTokens.length === 0)
selectedTokens.push(ruler.draggedEntity); selectedTokens.push(ruler.draggedEntity);
// This can happen if the ruler is being dragged so rapidly that the drag move handler hasn't been called before dropping
if (ruler._state === Ruler.STATES.STARTING)
onMouseMove.call(ruler, event);
ruler._state = Ruler.STATES.MOVING ruler._state = Ruler.STATES.MOVING
moveEntities.call(ruler, ruler.draggedEntity, selectedTokens); moveEntities.call(ruler, ruler.draggedEntity, selectedTokens);
return true return true
+514
View File
@@ -0,0 +1,514 @@
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, 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) {
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 maxCacheLayers = 5;
static maxBackgroundCachingMillis = 10;
static maxAnimationCachingMillis = 5;
static backgroundCachingTimeoutMillis = 200;
constructor() {
this.layers = new Map();
this.background = {
nextJobId: null,
nextTimeoutId: null,
nextAnimationFrameId: null
}
}
clear() {
this.layers.clear();
if (this.background.nextJobId) {
window.cancelIdleCallback(this.background.nextJobId);
this.background.nextJobId = null;
}
this.cancelTimeout();
this.cancelAnimationFrame();
}
/**
* 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
*/
getCacheLayer(token) {
const tokenData = buildTokenData(token);
// 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) {
// 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);
}
// 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 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;
}
}
}
const cache = new Cache();
let use5105 = false;
let gridlessPathfinders = new Map();
let gridWidth, gridHeight;
export function isPathfindingEnabled() {
if (this.user !== game.user)
return false;
if (!game.user.isGM && !game.settings.get(settingsKey, "allowPathfinding"))
return false;
if (moveWithoutAnimation)
return false;
return game.settings.get(settingsKey, "autoPathfinding") != togglePathfinding;
}
export function findPath(from, to, token, previousWaypoints) {
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) {
let radiusMultiplier = game.settings.get(settingsKey, "pathfindingRadius");
// TODO Pass proper options to listAllTerrain
// TODO Wipe caches when they become invalid
// TODO Multiple caches, analog to gridded pathfinding
pathfinder = GridlessPathfinding.initialize(canvas.walls.placeables, canvas.terrain.listAllTerrain(), tokenSize * radiusMultiplier, token.data.elevation, Boolean(game.modules.get("wall-height")?.active));
gridlessPathfinders.set(tokenSize, pathfinder);
}
paintGridlessPathfindingDebug(pathfinder);
return GridlessPathfinding.findPath(pathfinder, from, to);
} else {
const cacheLayer = cache.getCacheLayer(token);
const firstNode = calculatePath(from, to, cacheLayer, previousWaypoints);
if (!firstNode)
return null;
paintGriddedPathfindingDebug(firstNode, cacheLayer.tokenData);
const path = [];
let currentNode = firstNode;
while (currentNode) {
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 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;
}
}
export function terrainRulerWrapper(from, to) {
// TODO Send list of terrain to terrain layer
const ray = new Ray(from, to);
return terrainRuler.measureDistances([{ray}])[0] / canvas.dimensions.distance * canvas.dimensions.size;
}
function buildTokenData(token) {
// Almost all the information we need is for calculating the snap point
const tokenData = buildSnapPointTokenData(token);
// If Wall Height is enabled, which walls matter depends on the token's elevation.
// Depending on the settings in Wall Height, the height we care about is either their
// foot height (elevation) or eye height (losHeight).
if (isModuleActive("wall-height")) {
const blockSightMovement = game.settings.get("wall-height", "blockSightMovement");
tokenData.elevation = blockSightMovement ? token.losHeight : token.data.elevation;
}
return tokenData;
}
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};})) {
if (neighborPos.x < 0 || neighborPos.y < 0 || neighborPos.x >= gridWidth || neighborPos.y >= gridHeight) {
continue;
}
// 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);
node.edges.push({target: neighbor, cost: edgeCost});
}
}
}
return node;
}
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) {
previousWaypoints = previousWaypoints.map(w => getGridPositionFromPixelsObj(w));
startCost = (calcNoDiagonals(previousWaypoints) % 2) * 0.5;
}
const nextNodes = new PriorityQueueSet((node1, node2) => node1.node === node2.node, node => node.estimated);
const previousNodes = new Set();
nextNodes.pushWithPriority(
{
node: getNode(from, cacheLayer),
cost: startCost,
estimated: startCost + estimateCost(from, to),
previous: null
}
);
while (nextNodes.hasNext()) {
// Get node with cheapest estimate
const currentNode = nextNodes.pop();
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, cacheLayer);
if (previousNodes.has(neighborNode)) {
continue;
}
const neighbor = {
node: neighborNode,
cost: currentNode.cost + edge.cost,
estimated: currentNode.cost + edge.cost + estimateCost(neighborNode, to),
previous: currentNode
};
nextNodes.pushWithPriority(neighbor);
}
}
}
/**
* 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)) {
diagonals += Math.min(Math.abs(p1.x - p2.x), Math.abs(p1.y - p2.y));
}
return diagonals;
}
/**
* Estimate the travel distance between two points, as the crow flies. Most of the time, this is 1
* per space, but for a square grid using 5-10-5 diagonals, count each diagonal as an extra 0.5
*/
function estimateCost(pos, target) {
const distX = Math.abs(pos.x - target.x);
const distY = Math.abs(pos.y - target.y);
return Math.max(distX, distY) + (use5105 ? Math.min(distX, distY) * 0.5 : 0);
}
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() {
cache.clear();
for (const pathfinder of gridlessPathfinders.values()) {
GridlessPathfinding.free(pathfinder);
}
gridlessPathfinders.clear();
if (debugGraphics)
debugGraphics.removeChildren().forEach(c => c.destroy());
}
export function initializePathfinding() {
gridWidth = Math.ceil(canvas.dimensions.width / canvas.grid.w);
gridHeight = Math.ceil(canvas.dimensions.height / canvas.grid.h);
}
export function startBackgroundCaching(token) {
// Background caching isn't yet supported for gridless scenes
if (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS)
return;
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 = firstNode;
while (currentNode) {
let text = new PIXI.Text(currentNode.cost.toFixed(1));
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.next;
}
}
function paintGridlessPathfindingDebug(pathfinder) {
if (!CONFIG.debug.dragRuler)
return;
debugGraphics.removeChildren().forEach(c => c.destroy());
let graphic = new PIXI.Graphics();
graphic.lineStyle(2, 0x440000);
for (const point of GridlessPathfinding.debugGetPathfindingPoints(pathfinder)) {
graphic.drawCircle(point.x, point.y, 5);
}
debugGraphics.addChild(graphic);
}
+12 -2
View File
@@ -2,7 +2,6 @@ import {currentSpeedProvider, getColorForDistanceAndToken, getRangesFromSpeedPro
import {getHexSizeSupportTokenGridCenter} from "./compatibility.js"; import {getHexSizeSupportTokenGridCenter} from "./compatibility.js";
import {cancelScheduledMeasurement, measure} from "./foundry_imports.js" import {cancelScheduledMeasurement, measure} from "./foundry_imports.js"
import {getMovementHistory} from "./movement_tracking.js"; import {getMovementHistory} from "./movement_tracking.js";
import {wipePathfindingCache} from "./pathfinding.js";
import {settingsKey} from "./settings.js"; import {settingsKey} from "./settings.js";
import {getSnapPointForEntity} from "./util.js"; import {getSnapPointForEntity} from "./util.js";
@@ -55,12 +54,18 @@ export function extendRuler() {
else else
this.draggedEntity = canvas.templates.get(data.draggedEntity); this.draggedEntity = canvas.templates.get(data.draggedEntity);
} }
else {
this.draggedEntity = undefined;
}
super.update(data); super.update(data);
} }
measure(destination, options={}) { measure(destination, options={}) {
if (this.isDragRuler) { if (this.isDragRuler) {
// If this is the ruler of a remote user take the waypoints as they were transmitted and don't apply any additional snapping to them
if (this.user !== game.user)
options.snap = false;
return measure.call(this, destination, options); return measure.call(this, destination, options);
} }
else { else {
@@ -184,7 +189,6 @@ export function extendRuler() {
return; return;
const ruler = canvas.controls.ruler; const ruler = canvas.controls.ruler;
ruler.clear(); ruler.clear();
wipePathfindingCache();
ruler._state = Ruler.STATES.STARTING; ruler._state = Ruler.STATES.STARTING;
let entityCenter; let entityCenter;
if (isToken && canvas.grid.isHex && game.modules.get("hex-size-support")?.active && CONFIG.hexSizeSupport.getAltSnappingFlag(entity)) if (isToken && canvas.grid.isHex && game.modules.get("hex-size-support")?.active && CONFIG.hexSizeSupport.getAltSnappingFlag(entity))
@@ -199,6 +203,12 @@ export function extendRuler() {
if (measureImmediately) if (measureImmediately)
ruler.measure(destination, options); ruler.measure(destination, options);
} }
dragRulerSendState() {
game.user.broadcastActivity({
ruler: this.toJSON()
});
}
} }
Ruler = DragRulerRuler; Ruler = DragRulerRuler;
+17 -4
View File
@@ -1,5 +1,6 @@
import {availableSpeedProviders, currentSpeedProvider, getDefaultSpeedProvider, updateSpeedProvider} from "./api.js"; import {availableSpeedProviders, currentSpeedProvider, getDefaultSpeedProvider, updateSpeedProvider} from "./api.js";
import {SpeedProvider} from "./speed_provider.js" import {SpeedProvider} from "./speed_provider.js"
import {wipePathfindingCache} from "./pathfinding.js"
import { early_isGM } from "./util.js"; import { early_isGM } from "./util.js";
export const settingsKey = "drag-ruler"; export const settingsKey = "drag-ruler";
@@ -10,6 +11,10 @@ export const RightClickAction = Object.freeze({
ABORT_DRAG:2, ABORT_DRAG:2,
}); });
function delayedReload() {
window.setTimeout(() => location.reload(), 500);
}
export function registerSettings() { export function registerSettings() {
game.settings.register(settingsKey, "dataVersion", { game.settings.register(settingsKey, "dataVersion", {
scope: "world", scope: "world",
@@ -90,16 +95,24 @@ export function registerSettings() {
config: true, config: true,
type: Boolean, type: Boolean,
default: false, default: false,
onChange: () => location.reload(), onChange: delayedReload,
}); });
game.settings.register(settingsKey, "autoPathfinding", { game.settings.register(settingsKey, "autoPathfinding", {
name: "drag-ruler.settings.autoPathfinding.name", name: "drag-ruler.settings.autoPathfinding.name",
hint: "drag-ruler.settings.autoPathfinding.hint", hint: "drag-ruler.settings.autoPathfinding.hint",
scpoe: "client", scope: "client",
config: early_isGM(), config: early_isGM() || game.settings.get(settingsKey, "allowPathfinding"),
type: Boolean, type: Boolean,
defualt: false, default: false,
});
game.settings.register(settingsKey, "pathfindingRadius", {
scope: "world",
config: false,
type: Number,
default: 0.9,
onChange: wipePathfindingCache,
}); });
game.settings.register(settingsKey, "lastTerrainRulerHintTime", { game.settings.register(settingsKey, "lastTerrainRulerHintTime", {
+3 -1
View File
@@ -39,5 +39,7 @@ export function recalculate(tokens) {
} }
function _socketRecalculate(tokenIds) { function _socketRecalculate(tokenIds) {
return canvas.controls.ruler.dragRulerRecalculate(tokenIds); const ruler = canvas.controls.ruler;
if (ruler.isDragRuler)
ruler.dragRulerRecalculate(tokenIds);
} }
+8 -5
View File
@@ -4,13 +4,15 @@ export function getDefaultSpeedAttribute() {
return "actor.data.data.attribs.mov.value"; return "actor.data.data.attribs.mov.value";
case "dcc": case "dcc":
return "actor.data.data.attributes.speed.value"; return "actor.data.data.attributes.speed.value";
case "dnd4e":
return "actor.data.data.movement.walk.value";
case "dnd5e": case "dnd5e":
return "actor.data.data.attributes.movement.walk" return "actor.data.data.attributes.movement.walk";
case "lancer": case "lancer":
return "actor.data.data.derived.speed" return "actor.data.data.derived.speed";
case "pf1": case "pf1":
case "D35E": case "D35E":
return "actor.data.data.attributes.speed.land.total" return "actor.data.data.attributes.speed.land.total";
case "sfrpg": case "sfrpg":
return "actor.data.data.attributes.speed.value"; return "actor.data.data.attributes.speed.value";
case "shadowrun5e": case "shadowrun5e":
@@ -28,6 +30,7 @@ export function getDefaultDashMultiplier() {
case "swade": case "swade":
return 0 return 0
case "dcc": case "dcc":
case "dnd4e":
case "dnd5e": case "dnd5e":
case "lancer": case "lancer":
case "pf1": case "pf1":
@@ -35,9 +38,9 @@ export function getDefaultDashMultiplier() {
case "sfrpg": case "sfrpg":
case "shadowrun5e": case "shadowrun5e":
case "ds4": case "ds4":
return 2 return 2;
case "CoC7": case "CoC7":
return 5; return 5;
} }
return 0 return 0;
} }
+52 -35
View File
@@ -1,4 +1,5 @@
import {getPixelsFromGridPosition} from "./foundry_fixes.js" import {getPixelsFromGridPosition} from "./foundry_fixes.js"
import {findVertexSnapPoint} from "./hex_support.js";
import {disableSnap} from "./keybindings.js"; import {disableSnap} from "./keybindings.js";
export function* zip(it1, it2) { export function* zip(it1, it2) {
@@ -25,14 +26,38 @@ export function sum(arr) {
return arr.reduce((a, b) => a + b, 0); 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) { 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) { if (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) {
return new PIXI.Point(x, y); return new PIXI.Point(x, y);
} }
if (canvas.grid.isHex) { if (canvas.grid.isHex) {
if (game.modules.get("hex-size-support")?.active && CONFIG.hexSizeSupport.getAltSnappingFlag(token)) { if (tokenData.hexSizeSupport?.altSnappingFlag) {
if (token.document.getFlag("hex-size-support", "borderSize") % 2 === 0) { if (tokenData.hexSizeSupport.borderSize % 2 === 0) {
const snapPoint = CONFIG.hexSizeSupport.findVertexSnapPoint(x, y, token, canvas.grid.grid) const snapPoint = findVertexSnapPoint(x, y, tokenData.hexSizeSupport.altOrientationFlag);
return new PIXI.Point(snapPoint.x, snapPoint.y) return new PIXI.Point(snapPoint.x, snapPoint.y)
} }
else { else {
@@ -46,38 +71,38 @@ export function getSnapPointForToken(x, y, token) {
const [topLeftX, topLeftY] = canvas.grid.getTopLeft(x, y); const [topLeftX, topLeftY] = canvas.grid.getTopLeft(x, y);
let cellX, cellY; let cellX, cellY;
if (token.data.width % 2 === 0) if (tokenData.width % 2 === 0)
cellX = x - canvas.grid.h / 2; cellX = x - canvas.grid.h / 2;
else else
cellX = x; cellX = x;
if (token.data.height % 2 === 0) if (tokenData.height % 2 === 0)
cellY = y - canvas.grid.h / 2; cellY = y - canvas.grid.h / 2;
else else
cellY = y; cellY = y;
const [centerX, centerY] = canvas.grid.getCenter(cellX, cellY); const [centerX, centerY] = canvas.grid.getCenter(cellX, cellY);
let snapX, snapY; let snapX, snapY;
// Tiny tokens can snap to the cells corners // Tiny tokens can snap to the cells corners
if (token.data.width <= 0.5) { if (tokenData.width <= 0.5) {
const offsetX = x - topLeftX; const offsetX = x - topLeftX;
const subGridWidth = Math.floor(canvas.grid.w / 2); const subGridWidth = Math.floor(canvas.grid.w / 2);
const subGridPosX = Math.floor(offsetX / subGridWidth); const subGridPosX = Math.floor(offsetX / subGridWidth);
snapX = topLeftX + (subGridPosX + 0.5) * 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 // 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; snapX = centerX;
} }
// All remaining tokens (those with even or fractional multipliers on square grids) snap to the intersection points of the grid // All remaining tokens (those with even or fractional multipliers on square grids) snap to the intersection points of the grid
else { else {
snapX = centerX + canvas.grid.w / 2; snapX = centerX + canvas.grid.w / 2;
} }
if (token.data.height <= 0.5) { if (tokenData.height <= 0.5) {
const offsetY = y - topLeftY; const offsetY = y - topLeftY;
const subGridHeight = Math.floor(canvas.grid.h / 2); const subGridHeight = Math.floor(canvas.grid.h / 2);
const subGridPosY = Math.floor(offsetY / subGridHeight); const subGridPosY = Math.floor(offsetY / subGridHeight);
snapY = topLeftY + (subGridPosY + 0.5) * 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; snapY = centerY;
} }
else { else {
@@ -94,23 +119,7 @@ export function getSnapPointForMeasuredTemplate(x, y) {
if (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) { if (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) {
return new PIXI.Point(x, y); return new PIXI.Point(x, y);
} }
let subgridWidth, subgridHeight; return canvas.grid.grid.getSnappedPosition(x, y, canvas.templates.gridPrecision);
if (canvas.grid.type === CONST.GRID_TYPES.SQUARE) {
subgridWidth = subgridHeight = canvas.dimensions.size / 2;
}
else {
if (canvas.grid.grid.columns) {
subgridWidth = canvas.grid.w / 4;
subgridHeight = canvas.grid.h / 2;
}
else {
subgridWidth = canvas.grid.w / 2;
subgridHeight = canvas.grid.h / 4;
}
}
const snappedX = Math.round(x / subgridWidth) * subgridWidth;
const snappedY = Math.round(y / subgridHeight) * subgridHeight;
return new PIXI.Point(snappedX, snappedY);
} }
export function getSnapPointForEntity(x, y, entity) { export function getSnapPointForEntity(x, y, entity) {
@@ -158,15 +167,19 @@ export function getAreaFromPositionAndShape(position, shape) {
} }
export function getTokenShape(token) { 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}] return [{x: 0, y: 0}]
} }
else if (token.scene.data.gridType === CONST.GRID_TYPES.SQUARE) { else if (scene.data.gridType === CONST.GRID_TYPES.SQUARE) {
const topOffset = -Math.floor(token.data.height / 2) const topOffset = -Math.floor(tokenData.height / 2)
const leftOffset = -Math.floor(token.data.width / 2) const leftOffset = -Math.floor(tokenData.width / 2)
const shape = [] const shape = []
for (let y = 0;y < token.data.height;y++) { for (let y = 0;y < tokenData.height;y++) {
for (let x = 0;x < token.data.width;x++) { for (let x = 0;x < tokenData.width;x++) {
shape.push({x: x + leftOffset, y: y + topOffset}) shape.push({x: x + leftOffset, y: y + topOffset})
} }
} }
@@ -174,8 +187,8 @@ export function getTokenShape(token) {
} }
else { else {
// Hex grids // Hex grids
if (game.modules.get("hex-size-support")?.active && CONFIG.hexSizeSupport.getAltSnappingFlag(token)) { if (game.modules.get("hex-size-support")?.active && tokenData.hexSizeSupport.altSnappingFlag) {
const borderSize = token.data.flags["hex-size-support"].borderSize; const borderSize = tokenData.hexSizeSupport.borderSize;
let shape = [{x: 0, y: 0}]; let shape = [{x: 0, y: 0}];
if (borderSize >= 2) if (borderSize >= 2)
shape = shape.concat([{x: 0, y: -1}, {x: -1, y: -1}]); shape = shape.concat([{x: 0, y: -1}, {x: -1, y: -1}]);
@@ -184,7 +197,7 @@ export function getTokenShape(token) {
if (borderSize >= 4) 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}]) 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); shape.forEach(space => space.y *= -1);
if (canvas.grid.grid.options.columns) if (canvas.grid.grid.options.columns)
shape = shape.map(space => {return {x: space.y, y: space.x}}); shape = shape.map(space => {return {x: space.y, y: space.x}});
@@ -279,3 +292,7 @@ export function early_isGM() {
const gmLevel = CONST.USER_ROLES.ASSISTANT; const gmLevel = CONST.USER_ROLES.ASSISTANT;
return level >= gmLevel; return level >= gmLevel;
} }
export function isModuleActive(moduleName) {
return game.modules.get(moduleName)?.active
}
+12
View File
@@ -44,9 +44,17 @@
"moveWithoutAnimation": { "moveWithoutAnimation": {
"name": "Deshabilitar animación de icono", "name": "Deshabilitar animación de icono",
"hint": "Si al soltar un icono se presiona esta tecla, se deshabilitará la animación del icono al moverse al destino" "hint": "Si al soltar un icono se presiona esta tecla, se deshabilitará la animación del icono al moverse al destino"
},
"togglePathfinding": {
"name": "Conmutar búsqueda de camino",
"hint": "Cuando se presione al arrastrar un icono, la funcionalidad de búsqueda de camino será temporalmente habilitada/deshabilitada"
} }
}, },
"settings": { "settings": {
"allowPathfinding": {
"name": "Permitir búsqueda de camino a los jugadores",
"hint": "Permite a los jugadores usar en este mundo la funcionalidad de búsqueda de camino. Tenga cuidado porque la ruta puede transcurrir por lugares con niebla de guerra o muros invisibles, lo que podrá revelar algunos secretos a los jugadores antes de tiempo"
},
"alwaysShowSpeedForPCs": { "alwaysShowSpeedForPCs": {
"name": "Mostrar velocidad de los PJs a todo el mundo", "name": "Mostrar velocidad de los PJs a todo el mundo",
"hint": "Si se habilita, se mostrará a todo el mundo los códigos de colores de las rutas de los PJs, incluso si no tienen permisos de observador para ese personaje" "hint": "Si se habilita, se mostrará a todo el mundo los códigos de colores de las rutas de los PJs, incluso si no tienen permisos de observador para ese personaje"
@@ -55,6 +63,10 @@
"name": "Comenzar a medir automáticamente", "name": "Comenzar a medir automáticamente",
"hint": "Si se habilita, Drag Ruler comenzará a medir en cuanto se comience a arrastrar un icono. Si se deshabilita, Drag Ruler permanecerá inactivo y comenzará a medir únicamente cuando se presione el botón configurado para añadir un nuevo punto de ruta" "hint": "Si se habilita, Drag Ruler comenzará a medir en cuanto se comience a arrastrar un icono. Si se deshabilita, Drag Ruler permanecerá inactivo y comenzará a medir únicamente cuando se presione el botón configurado para añadir un nuevo punto de ruta"
}, },
"autoPathfinding": {
"name": "Búsqueda de camino por defecto",
"hint": "Si se habilita, al arrastrar un icono se usará automáticamente la regla de búsqueda de camino"
},
"enableMovementHistory": { "enableMovementHistory": {
"name": "Habilitar historial de movimiento durante el combate", "name": "Habilitar historial de movimiento durante el combate",
"hint": "Si se habilita, Drag Ruler recordará la ruta que ha seguido un icono en su turno y la mostrará al seleccionarlo de nuevo" "hint": "Si se habilita, Drag Ruler recordará la ruta que ha seguido un icono en su turno y la mostrará al seleccionarlo de nuevo"
+5 -5
View File
@@ -2,7 +2,7 @@
"name": "drag-ruler", "name": "drag-ruler",
"title": "Drag Ruler", "title": "Drag Ruler",
"description": "When dragging a token displays a ruler showing how far you've moved that token.", "description": "When dragging a token displays a ruler showing how far you've moved that token.",
"version": "1.11.2", "version": "1.12.8",
"minimumCoreVersion" : "9.245", "minimumCoreVersion" : "9.245",
"compatibleCoreVersion" : "9", "compatibleCoreVersion" : "9",
"authors": [ "authors": [
@@ -13,9 +13,9 @@
} }
], ],
"esmodules": [ "esmodules": [
"src/libwrapper_shim.js", "js/libwrapper_shim.js",
"src/main.js", "js/main.js",
"src/socket.js" "js/socket.js"
], ],
"templates": [ "templates": [
"speed_provider_settings.html" "speed_provider_settings.html"
@@ -65,7 +65,7 @@
], ],
"socket": true, "socket": true,
"url": "https://github.com/manuelVo/foundryvtt-drag-ruler", "url": "https://github.com/manuelVo/foundryvtt-drag-ruler",
"download": "https://github.com/manuelVo/foundryvtt-drag-ruler/archive/v1.11.2.zip", "download": "https://github.com/manuelVo/foundryvtt-drag-ruler/releases/download/v1.12.8/drag-ruler-1.12.8.zip",
"manifest": "https://raw.githubusercontent.com/manuelVo/foundryvtt-drag-ruler/master/module.json", "manifest": "https://raw.githubusercontent.com/manuelVo/foundryvtt-drag-ruler/master/module.json",
"readme": "https://github.com/manuelVo/foundryvtt-drag-ruler/blob/master/README.md", "readme": "https://github.com/manuelVo/foundryvtt-drag-ruler/blob/master/README.md",
"changelog": "https://github.com/manuelVo/foundryvtt-drag-ruler/blob/master/CHANGELOG.md", "changelog": "https://github.com/manuelVo/foundryvtt-drag-ruler/blob/master/CHANGELOG.md",
+1
View File
@@ -0,0 +1 @@
target/
+232
View File
@@ -0,0 +1,232 @@
# This file is automatically @generated by Cargo.
# 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a45a46ab1f2412e53d3a0ade76ffad2025804294569aae387231a0cd6e0899"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"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"
dependencies = [
"console_error_panic_hook",
"js-sys",
"rustc-hash",
"sha1",
"wasm-bindgen",
]
[[package]]
name = "js-sys"
version = "0.3.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a38fc24e30fd564ce974c02bf1d337caddff65be6cc4735a1f7eab22a7440f04"
dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710"
dependencies = [
"cfg-if",
]
[[package]]
name = "proc-macro2"
version = "1.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029"
dependencies = [
"unicode-xid",
]
[[package]]
name = "quote"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "864d3e96a899863136fc6e99f3d7cae289dafe43bf2c5ac19b70df7210c0a145"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustc-hash"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a65b3f4ffa0092e9887669db0eae07941f023991ab58ea44da8fe8e2d511c6b"
dependencies = [
"proc-macro2",
"quote",
"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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25f1af7423d8588a3d840681122e72e6a24ddbcb3f0ec385cac0d12d24256c06"
dependencies = [
"cfg-if",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b21c0df030f5a177f3cba22e9bc4322695ec43e7257d865302900290bcdedca"
dependencies = [
"bumpalo",
"lazy_static",
"log",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4203d69e40a52ee523b2529a773d5ffc1dc0071801c87b3d270b471b80ed01"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa8a30d46208db204854cadbb5d4baf5fcf8071ba5bf48190c3e59937962ebc"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d958d035c4438e28c70e4321a2911302f10135ce78a9c7834c0cab4123d06a2"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "gridless-pathfinding"
version = "1.12.8"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
crate-type = ["cdylib"]
[profile.release]
lto = true
#debug = true
[dependencies]
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"
+1
View File
@@ -0,0 +1 @@
hard_tabs = true
+253
View File
@@ -0,0 +1,253 @@
use std::hash::{Hash, Hasher};
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
pub type JsPoint;
#[wasm_bindgen(method, getter)]
fn x(this: &JsPoint) -> f64;
#[wasm_bindgen(method, getter)]
fn y(this: &JsPoint) -> f64;
}
#[wasm_bindgen]
#[derive(Debug, Copy, Clone)]
pub struct Point {
pub x: f64,
pub y: f64,
}
impl Point {
pub fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
pub fn from_line_x(line: &Line, x: f64) -> Self {
let y = line.calc_y(x);
Self { x, y }
}
pub fn distance_to(&self, to: Point) -> f64 {
(self.y - to.y).hypot(self.x - to.x)
}
pub fn is_same_as(&self, other: &Self) -> bool {
let e = 0.000001;
(self.x - other.x).abs() < e && (self.y - other.y).abs() < e
}
pub fn is_in_rectangle(&self, rect: &Rectangle) -> bool {
between(self.x, rect.left.p1.x, rect.right.p1.x)
&& between(self.y, rect.top.p1.y, rect.bottom.p1.y)
}
}
impl Eq for Point {}
impl PartialEq for Point {
fn eq(&self, other: &Self) -> bool {
self.x == other.x && self.y == other.y
}
}
impl Hash for Point {
fn hash<H: Hasher>(&self, hasher: &mut H) {
self.x.to_bits().hash(hasher);
self.y.to_bits().hash(hasher);
}
}
impl From<&JsPoint> for Point {
fn from(point: &JsPoint) -> Self {
Self::new(point.x(), point.y())
}
}
#[derive(Debug, Copy, Clone)]
pub struct Line {
pub m: f64,
pub b: f64,
pub p1: Point,
}
impl Line {
pub fn new(m: f64, b: f64, p1: Point) -> Self {
Self { m, b, p1 }
}
pub fn from_points(p1: Point, p2: Point) -> Self {
let m = (p1.y - p2.y) / (p1.x - p2.x);
let b = p1.y - m * p1.x;
Self { m, b, p1 }
}
pub fn from_point_and_angle(p1: Point, angle: f64) -> Self {
let p2 = Point {
x: p1.x - angle.cos(),
y: p1.y - angle.sin(),
};
Line::from_points(p1, p2)
}
pub fn is_vertical(&self) -> bool {
self.m.is_infinite()
}
pub fn is_horizontal(&self) -> bool {
self.m == 0.0
}
pub fn calc_x(&self, y: f64) -> f64 {
(y - self.b) / self.m
}
pub fn calc_y(&self, x: f64) -> f64 {
self.m * x + self.b
}
pub fn intersection(&self, other: &Line) -> Option<Point> {
// Are both lines vertical?
if self.is_vertical() && other.is_vertical() {
return None;
}
// Are the lines paralell?
if (self.m - other.m).abs() < 0.00000005 {
return None;
}
// Is one of the lines vertical?
if self.is_vertical() || other.is_vertical() {
let vertical;
let regular;
if self.is_vertical() {
vertical = self;
regular = other;
} else {
vertical = other;
regular = self;
}
return Some(Point::from_line_x(regular, vertical.p1.x));
}
// Calculate x coordinate of intersection point between both lines
// Find intersection point: x * m1 + b1 = x * m2 + b2
// Solve for x: x = (b1 - b2) / (m2 - m1)
let x = (self.b - other.b) / (other.m - self.m);
if self.m.abs() < other.m.abs() {
Some(Point::from_line_x(self, x))
} else {
Some(Point::from_line_x(other, x))
}
}
pub fn get_perpendicular_through_point(&self, p: Point) -> Self {
let m = -1.0 / self.m;
let b = p.y - m * p.x;
Self { m, b, p1: p }
}
}
#[derive(Debug, Clone, Copy)]
pub struct LineSegment {
pub p1: Point,
pub p2: Point,
pub line: Line,
}
impl LineSegment {
pub fn new(p1: Point, p2: Point) -> Self {
Self {
p1,
p2,
line: Line::from_points(p1, p2),
}
}
pub fn intersection(&self, other: &LineSegment) -> Option<Point> {
let intersection = self.line.intersection(&other.line);
intersection.filter(|intersection| {
self.is_intersection_on_segment(*intersection)
&& other.is_intersection_on_segment(*intersection)
})
}
fn is_intersection_on_segment(&self, intersection: Point) -> bool {
if intersection.is_same_as(&self.p1) || intersection.is_same_as(&self.p2) {
return true;
}
if self.line.is_vertical() || self.line.m.abs() > 1.0 {
return between(intersection.y, self.p1.y, self.p2.y);
}
between(intersection.x, self.p1.x, self.p2.x)
}
pub fn intersects_rect(&self, rect: &Rectangle) -> bool {
if self.p1.is_in_rectangle(rect) {
return true;
}
[rect.left, rect.top, rect.right, rect.bottom]
.iter()
.any(|edge| self.intersection(edge).is_some())
}
}
pub fn between<T: Copy + PartialOrd>(num: T, a: T, b: T) -> bool {
let (min, max) = if a < b { (a, b) } else { (b, a) };
num >= min && num <= max
}
#[wasm_bindgen]
extern "C" {
pub type JsRectangle;
#[wasm_bindgen(method, getter)]
fn left(this: &JsRectangle) -> f64;
#[wasm_bindgen(method, getter)]
fn top(this: &JsRectangle) -> f64;
#[wasm_bindgen(method, getter)]
fn right(this: &JsRectangle) -> f64;
#[wasm_bindgen(method, getter)]
fn bottom(this: &JsRectangle) -> f64;
}
#[derive(Debug, Clone, Copy)]
pub struct Rectangle {
pub left: LineSegment,
pub top: LineSegment,
pub right: LineSegment,
pub bottom: LineSegment,
}
impl Rectangle {
pub fn new(left: f64, top: f64, right: f64, bottom: f64) -> Self {
Self {
left: LineSegment::new(Point::new(left, top), Point::new(left, bottom)),
top: LineSegment::new(Point::new(left, top), Point::new(right, top)),
right: LineSegment::new(Point::new(right, top), Point::new(right, bottom)),
bottom: LineSegment::new(Point::new(left, bottom), Point::new(right, bottom)),
}
}
}
impl From<&JsRectangle> for Rectangle {
fn from(rect: &JsRectangle) -> Self {
let left = rect.left();
let top = rect.top();
let right = rect.right();
let bottom = rect.bottom();
Self::new(left, top, right, bottom)
}
}
#[derive(Debug, Copy, Clone)]
pub struct Circle {
pub center: Point,
pub radius: f64,
}
+418
View File
@@ -0,0 +1,418 @@
use js_sys::Array;
use sha1::{Digest, Sha1};
use wasm_bindgen::prelude::*;
use crate::{
geometry::{Circle, LineSegment, Point, Rectangle},
pathfinder::{DiscoveredNodePtr, Pathfinder},
util::Windows,
};
#[allow(unused)]
macro_rules! log {
( $( $t:tt )* ) => {
log(&format!( $( $t )* ));
};
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console, js_name=warn)]
pub fn log(s: &str);
#[wasm_bindgen(js_namespace = ["dragRuler", "private"], js_name=terrainRulerWrapper)]
pub fn distance_with_terrain(a: Point, b: Point) -> f64;
}
#[wasm_bindgen]
extern "C" {
pub type JsWall;
pub type JsWallData;
pub type JsWallFlags;
pub type JsWallHeight;
#[wasm_bindgen(method, getter)]
fn data(this: &JsWall) -> JsWallData;
#[wasm_bindgen(method, getter)]
fn c(this: &JsWallData) -> Vec<f64>;
#[wasm_bindgen(method, getter, js_name = "door")]
fn door_type(this: &JsWallData) -> DoorType;
#[wasm_bindgen(method, getter, js_name = "ds")]
fn door_state(this: &JsWallData) -> DoorState;
#[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<JsWallHeight>;
#[wasm_bindgen(method, getter, js_name = "wallHeightTop")]
fn top(this: &JsWallHeight) -> Option<f64>;
#[wasm_bindgen(method, getter, js_name = "wallHeightBottom")]
fn bottom(this: &JsWallHeight) -> Option<f64>;
}
#[wasm_bindgen]
extern "C" {
pub type JsPoint;
#[wasm_bindgen(method, getter)]
fn x(this: &JsPoint) -> f64;
#[wasm_bindgen(method, getter)]
fn y(this: &JsPoint) -> f64;
}
impl From<JsPoint> for Point {
fn from(point: JsPoint) -> Self {
Point {
x: point.x(),
y: point.y(),
}
}
}
#[wasm_bindgen]
extern "C" {
pub type JsTerrainInfo;
pub type JsTerrainObject;
pub type JsTerrainShape;
#[wasm_bindgen(method, getter)]
fn object(this: &JsTerrainInfo) -> JsTerrainObject;
#[wasm_bindgen(method, getter)]
fn shape(this: &JsTerrainInfo) -> JsTerrainShape;
#[wasm_bindgen(method, getter)]
fn x(this: &JsTerrainObject) -> f64;
#[wasm_bindgen(method, getter)]
fn y(this: &JsTerrainObject) -> f64;
#[wasm_bindgen(method, getter)]
fn width(this: &JsTerrainObject) -> f64;
#[wasm_bindgen(method, getter)]
fn height(this: &JsTerrainObject) -> f64;
#[wasm_bindgen(method, getter, js_name = "type")]
fn shape_type(this: &JsTerrainShape) -> u32;
#[wasm_bindgen(method, getter)]
fn x(this: &JsTerrainShape) -> f64;
#[wasm_bindgen(method, getter)]
fn y(this: &JsTerrainShape) -> f64;
#[wasm_bindgen(method, getter)]
fn radius(this: &JsTerrainShape) -> f64;
#[wasm_bindgen(method, getter)]
fn points(this: &JsTerrainShape) -> Vec<f64>;
}
impl JsTerrainObject {
fn to_bounding_rect(&self) -> Rectangle {
let left = self.x();
let top = self.y();
let right = left + self.width();
let bottom = top + self.height();
Rectangle::new(left, top, right, bottom)
}
}
impl JsTerrainShape {
fn to_segments(&self, x: f64, y: f64) -> Vec<LineSegment> {
let points = self.points();
assert!(points.len() % 2 == 0);
points
.chunks(2)
.map(|coordinates| Point::new(coordinates[0] + x, coordinates[1] + y))
.windows()
.map(|(p1, p2)| LineSegment::new(p1, p2))
.collect()
}
fn to_circle(&self, x: f64, y: f64) -> Circle {
let center = Point::new(self.x() + x, self.y() + y);
let radius = self.radius();
Circle { center, radius }
}
}
impl From<&JsTerrainInfo> for TerrainShape {
fn from(terrain: &JsTerrainInfo) -> Self {
let shape = terrain.shape();
let object = terrain.object();
let x = object.x();
let y = object.y();
match shape.shape_type() {
0 => TerrainShape::Polygon(shape.to_segments(x, y)),
2 => TerrainShape::Circle(shape.to_circle(x, y)),
_ => unimplemented!(),
}
}
}
#[derive(Debug, Clone)]
pub enum TerrainShape {
Polygon(Vec<LineSegment>),
Circle(Circle),
}
#[derive(Debug, Clone)]
pub struct Terrain {
pub shape: TerrainShape,
pub bounding_box: Rectangle,
}
impl From<&JsTerrainInfo> for Terrain {
fn from(terrain: &JsTerrainInfo) -> Self {
let bounding_box = terrain.object().to_bounding_rect();
let shape = terrain.into();
Self {
bounding_box,
shape,
}
}
}
#[wasm_bindgen]
#[derive(Debug, Copy, Clone, PartialEq)]
pub enum DoorState {
CLOSED = 0,
OPEN = 1,
LOCKED = 2,
}
impl TryFrom<usize> for DoorState {
type Error = ();
fn try_from(value: usize) -> Result<Self, Self::Error> {
match value {
x if x == Self::CLOSED as usize => Ok(Self::CLOSED),
x if x == Self::OPEN as usize => Ok(Self::OPEN),
x if x == Self::LOCKED as usize => Ok(Self::LOCKED),
_ => Err(()),
}
}
}
#[wasm_bindgen]
#[derive(Debug, Copy, Clone, PartialEq)]
pub enum DoorType {
NONE = 0,
DOOR = 1,
SECRET = 2,
}
impl TryFrom<usize> for DoorType {
type Error = ();
fn try_from(value: usize) -> Result<Self, Self::Error> {
match value {
x if x == Self::NONE as usize => Ok(Self::NONE),
x if x == Self::DOOR as usize => Ok(Self::DOOR),
x if x == Self::SECRET as usize => Ok(Self::SECRET),
_ => Err(()),
}
}
}
#[wasm_bindgen]
#[derive(Debug, Copy, Clone, PartialEq)]
pub enum WallSenseType {
NONE = 0,
LIMITED = 10,
NORMAL = 20,
}
impl TryFrom<usize> for WallSenseType {
type Error = ();
fn try_from(value: usize) -> Result<Self, Self::Error> {
match value {
x if x == Self::NONE as usize => Ok(Self::NONE),
x if x == Self::LIMITED as usize => Ok(Self::LIMITED),
x if x == Self::NORMAL as usize => Ok(Self::NORMAL),
_ => Err(()),
}
}
}
#[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<Option<JsWallHeight>> for WallHeight {
fn from(height: Option<JsWallHeight>) -> 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,
pub p2: Point,
pub door_type: DoorType,
pub door_state: DoorState,
pub move_type: WallSenseType,
pub height: WallHeight,
}
impl Wall {
pub fn new(
p1: Point,
p2: Point,
door_type: DoorType,
door_state: DoorState,
move_type: WallSenseType,
height: WallHeight,
) -> Self {
Self {
p1,
p2,
door_type,
door_state,
move_type,
height,
}
}
pub fn is_door(&self) -> bool {
self.door_type != DoorType::NONE
}
pub fn is_open(&self) -> bool {
self.door_state == DoorState::OPEN
}
}
impl Wall {
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<JsValue>,
js_terrain: Vec<JsTerrainInfo>,
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, enable_height));
}
let terrain = js_terrain.iter().map(|terrain| terrain.into()).collect();
Pathfinder::initialize(walls, terrain, token_size, token_elevation)
}
#[allow(dead_code)]
#[wasm_bindgen]
pub fn free(pathfinder: Pathfinder) {
drop(pathfinder);
}
#[allow(dead_code)]
#[wasm_bindgen(js_name=findPath)]
pub fn find_path(pathfinder: &mut Pathfinder, from: JsPoint, to: JsPoint) -> Option<Array> {
pathfinder
.find_path(from.into(), to.into())
.map(|first_node| first_node.iter_path().map(JsValue::from).collect())
}
#[allow(dead_code)]
#[wasm_bindgen(js_name=debugGetPathfindingPoints)]
pub fn debug_get_pathfinding_points(pathfinder: &Pathfinder) -> Array {
pathfinder
.nodes
.iter()
.map(|node| node.borrow().point)
.map(JsValue::from)
.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;
}
impl IteratePath for DiscoveredNodePtr {
fn iter_path(&self) -> PathIterator {
PathIterator {
current_node: Some(self.clone()),
}
}
}
struct PathIterator {
current_node: Option<DiscoveredNodePtr>,
}
impl Iterator for PathIterator {
type Item = Point;
fn next(&mut self) -> Option<Self::Item> {
if let Some(node) = self.current_node.clone() {
let point = node.borrow().node.borrow().point;
self.current_node = node.borrow().previous.clone();
Some(point)
} else {
None
}
}
}
+13
View File
@@ -0,0 +1,13 @@
mod geometry;
#[macro_use]
mod js_api;
mod pathfinder;
mod ptr_indexed_hash_set;
mod util;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn main() {
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
}
+381
View File
@@ -0,0 +1,381 @@
use std::{cell::RefCell, f64::consts::PI, rc::Rc};
use wasm_bindgen::prelude::*;
use rustc_hash::FxHashMap;
use crate::{
geometry::{LineSegment, Point, Rectangle},
js_api::{distance_with_terrain, Terrain, TerrainShape, Wall, WallSenseType, log},
ptr_indexed_hash_set::PtrIndexedHashSet,
};
pub struct Edge {
target: NodePtr,
cost: f64,
}
pub struct Node {
pub point: Point,
edges: Option<Vec<Edge>>,
final_edge: Option<Option<Edge>>,
}
impl Node {
pub fn new(point: Point) -> Self {
Self {
point,
edges: None,
final_edge: None,
}
}
fn iter_edges(
&self,
) -> std::iter::Chain<std::slice::Iter<'_, Edge>, std::option::Iter<'_, Edge>> {
self.edges
.as_ref()
.unwrap()
.iter()
.chain(self.final_edge.as_ref().unwrap().iter())
}
}
type NodePtr = Rc<RefCell<Node>>;
impl From<Node> for NodePtr {
fn from(node: Node) -> Self {
Rc::new(RefCell::new(node))
}
}
pub struct DiscoveredNode {
pub node: NodePtr,
cost: f64,
estimated: f64,
pub previous: Option<DiscoveredNodePtr>,
}
pub type DiscoveredNodePtr = Rc<RefCell<DiscoveredNode>>;
impl From<DiscoveredNode> for DiscoveredNodePtr {
fn from(node: DiscoveredNode) -> Self {
Rc::new(RefCell::new(node))
}
}
#[derive(Default, Clone)]
pub struct NodeStorage {
regular_nodes: Vec<NodePtr>,
final_node: Option<NodePtr>,
}
pub type NodeStorageIterator<'a> = std::iter::Chain<
std::slice::Iter<'a, Rc<RefCell<Node>>>,
std::option::Iter<'a, Rc<RefCell<Node>>>,
>;
impl NodeStorage {
fn new() -> Self {
Self::default()
}
fn push(&mut self, node: NodePtr) {
self.regular_nodes.push(node);
}
fn initialize_edges(&mut self, node: &NodePtr, walls: &[LineSegment], terrain: &[Rectangle]) {
if node.borrow().final_edge.is_none() {
let final_edge = self
.final_node
.as_ref()
.filter(|neighbor| {
!self.collides_with_wall(
&LineSegment::new(node.borrow().point, neighbor.borrow().point),
walls,
)
})
.map(|neighbor| Edge {
target: neighbor.clone(),
cost: Self::measure_distance(
node.borrow().point,
neighbor.borrow().point,
terrain,
),
});
node.borrow_mut().final_edge = Some(final_edge);
}
if node.borrow().edges.is_some() {
return;
}
let point = node.borrow().point;
let mut edges = Vec::new();
for neighbor in &self.regular_nodes {
if Rc::ptr_eq(neighbor, node) {
continue;
}
let neighbor_point = neighbor.borrow().point;
if !self.collides_with_wall(&LineSegment::new(point, neighbor_point), walls) {
let cost =
Self::measure_distance(node.borrow().point, neighbor.borrow().point, terrain);
edges.push(Edge {
target: neighbor.clone(),
cost,
});
}
}
node.borrow_mut().edges = Some(edges);
}
fn collides_with_wall(&self, line: &LineSegment, walls: &[LineSegment]) -> bool {
walls.iter().any(|wall| line.intersection(wall).is_some())
}
fn measure_distance(a: Point, b: Point, terrain: &[Rectangle]) -> f64 {
let segment = LineSegment::new(a, b);
if terrain.iter().any(|rect| segment.intersects_rect(rect)) {
distance_with_terrain(a, b)
} else {
a.distance_to(b)
}
}
pub fn cleanup_final_edges(&mut self) {
for node in &self.regular_nodes {
node.borrow_mut().final_edge = None;
}
}
pub fn iter(&self) -> NodeStorageIterator {
self.regular_nodes.iter().chain(self.final_node.iter())
}
}
#[wasm_bindgen]
pub struct Pathfinder {
#[wasm_bindgen(skip)]
pub nodes: NodeStorage,
#[wasm_bindgen(skip)]
pub walls: Vec<LineSegment>,
#[wasm_bindgen(skip)]
pub terrain: Vec<Rectangle>,
}
impl Pathfinder {
pub fn initialize(
walls: Vec<Wall>,
terrain: Vec<Terrain>,
token_size: f64,
token_elevation: f64,
) -> Self {
log!("{:#?}", terrain);
let distance_from_walls = token_size / 2.0;
// TODO Place pathfinding nodes around terrain edges
let mut polygon_terrain = Vec::new();
let mut circle_terrain = Vec::new();
for terrain in &terrain {
match &terrain.shape {
TerrainShape::Polygon(polygon) => polygon_terrain.append(&mut polygon.clone()),
TerrainShape::Circle(circle) => circle_terrain.push(circle),
}
}
let mut walls = walls
.into_iter()
.filter(|wall| wall.move_type != WallSenseType::NONE)
.filter(|wall| !(wall.is_door() && wall.is_open()))
.filter(|wall| wall.height.contains(token_elevation))
.map(|wall| LineSegment::new(wall.p1, wall.p2))
.collect::<Vec<_>>();
// TODO Generate points around difficult terrain
let mut endpoints = FxHashMap::<Point, Vec<f64>>::default();
for segments in walls.iter().chain(polygon_terrain.iter()) {
let x_diff = segments.p2.x - segments.p1.x;
let y_diff = segments.p2.y - segments.p1.y;
let p1_angle = y_diff.atan2(x_diff).rem_euclid(2.0 * PI);
let p2_angle = (p1_angle + PI).rem_euclid(2.0 * PI);
for (point, angle) in [(segments.p1, p1_angle), (segments.p2, p2_angle)] {
let angles = endpoints.entry(point).or_insert_with(Vec::new);
angles.push(angle);
}
}
endpoints
.values_mut()
.for_each(|angles| angles.sort_by(|a, b| a.partial_cmp(b).unwrap()));
let mut nodes = NodeStorage::new();
for (point, angles) in endpoints {
assert!(!angles.is_empty());
for i in 1..angles.len() {
let angle1 = angles[i - 1];
let angle2 = angles[i];
if angle1 == angle2 {
continue;
}
let angle_diff = angle2 - angle1;
if angle_diff <= PI {
continue;
}
let angle_between = angle_diff / 2.0 + angle1;
nodes.push(calc_pathfinding_node(
point,
angle_between,
distance_from_walls,
&mut walls,
));
nodes.push(calc_pathfinding_node(
point,
angle1 + 0.5 * PI,
distance_from_walls,
&mut walls,
));
nodes.push(calc_pathfinding_node(
point,
angle2 - 0.5 * PI,
distance_from_walls,
&mut walls,
));
}
let angle1 = angles.last().unwrap();
let angle2 = angles.first().unwrap() + 2.0 * PI;
let angle_diff = angle2 - angle1;
if angle_diff <= PI {
continue;
}
let angle_between = angle_diff / 2.0 + angle1;
nodes.push(calc_pathfinding_node(
point,
angle_between,
distance_from_walls,
&mut walls,
));
nodes.push(calc_pathfinding_node(
point,
angle1 + 0.5 * PI,
distance_from_walls,
&mut walls,
));
nodes.push(calc_pathfinding_node(
point,
angle2 - 0.5 * PI,
distance_from_walls,
&mut walls,
));
}
// TODO Check bounding box of circle terrain
for circle in circle_terrain {
let angle_step = f64::asin(token_size / 2.0 / circle.radius);
let mut angle = 0.0;
while angle < 2.0 * PI {
let point = Point {
x: circle.center.x + angle.cos() * circle.radius,
y: circle.center.y + angle.sin() * circle.radius,
};
nodes.push(calc_pathfinding_node(
point,
angle,
distance_from_walls,
&mut walls,
));
angle += angle_step;
}
}
// TODO Eliminating nodes close to each other may improve performance
Self {
nodes,
walls,
terrain: terrain.iter().map(|terrain| terrain.bounding_box).collect(),
}
}
pub fn find_path(&mut self, from: Point, to: Point) -> Option<DiscoveredNodePtr> {
self.nodes.cleanup_final_edges();
let mut nodes = self.nodes.clone();
nodes.final_node = Some(NodePtr::from(Node::new(from)));
let to_node = NodePtr::from(Node::new(to));
nodes.initialize_edges(&to_node, &self.walls, &self.terrain);
let to = DiscoveredNode {
node: to_node,
cost: 0.0,
estimated: to.distance_to(from),
previous: None,
};
// TODO Use a sorted set for next_nodes for better performance
let mut next_nodes = vec![DiscoveredNodePtr::from(to)];
let mut previous_nodes = PtrIndexedHashSet::new();
while !next_nodes.is_empty() {
// Sort by estimated cost, high to low
// TODO Maybe tere's a faster way to do this than re-sorting every iteration?
next_nodes.sort_by(|a, b| {
b.borrow()
.estimated
.partial_cmp(&a.borrow().estimated)
.unwrap()
});
// Get node with cheapest estimate
let current_node = next_nodes.pop().unwrap();
if current_node.borrow().node.borrow().point.x == from.x
&& current_node.borrow().node.borrow().point.y == from.y
{
return Some(current_node);
}
previous_nodes.insert(current_node.borrow().node.clone());
for edge in current_node.borrow().node.borrow().iter_edges() {
let neighbor = &edge.target;
if previous_nodes.contains(neighbor) {
continue;
}
nodes.initialize_edges(neighbor, &self.walls, &self.terrain);
// Add a flat 0.00001 cost per node to discurage creation of unnecessary waypoints
let cost = current_node.borrow().cost + edge.cost + 0.00001;
let discovered_neighbor = DiscoveredNode {
node: neighbor.clone(),
cost,
estimated: cost + neighbor.borrow().point.distance_to(from),
previous: Some(current_node.clone()),
};
let neighbor_entry = next_nodes
.iter()
.find(|node| Rc::ptr_eq(&node.borrow().node, neighbor));
if let Some(entry) = neighbor_entry {
// If the neighbor is cheaper to reach via the current route than through previously discovered routes, replace it
if entry.borrow().cost > cost {
*entry.borrow_mut() = discovered_neighbor;
}
} else {
next_nodes.push(discovered_neighbor.into());
}
}
}
None
}
}
fn calc_pathfinding_node(
p: Point,
angle: f64,
distance_from_walls: f64,
walls: &mut Vec<LineSegment>,
) -> NodePtr {
let offset_x = angle.cos() * distance_from_walls;
let offset_y = angle.sin() * distance_from_walls;
walls.push(LineSegment::new(
p,
Point {
x: p.x + offset_x * 0.99,
y: p.y + offset_y * 0.99,
},
));
NodePtr::from(Node::new(Point {
x: p.x + offset_x,
y: p.y + offset_y,
}))
}
+68
View File
@@ -0,0 +1,68 @@
use rustc_hash::FxHashSet;
use std::collections::hash_set;
use std::hash::{Hash, Hasher};
use std::rc::Rc;
#[derive(Default)]
pub struct PtrIndexedHashSet<T>(FxHashSet<PtrIndexedRc<T>>);
impl<T> PtrIndexedHashSet<T> {
pub fn new() -> Self {
PtrIndexedHashSet(FxHashSet::default())
}
pub fn insert(&mut self, value: Rc<T>) -> bool {
self.0.insert(PtrIndexedRc(value))
}
pub fn remove(&mut self, value: &Rc<T>) -> bool {
self.0.remove(&PtrIndexedRc(Rc::clone(value)))
}
pub fn contains(&mut self, value: &Rc<T>) -> bool {
self.0.contains(&PtrIndexedRc(Rc::clone(value)))
}
}
impl<T: std::fmt::Debug> std::fmt::Debug for PtrIndexedHashSet<T> {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.debug_set().entries(self.0.iter().map(|e| &e.0)).finish()
}
}
struct PtrIndexedRc<T>(Rc<T>);
impl<T> Hash for PtrIndexedRc<T> {
fn hash<H: Hasher>(&self, state: &mut H) {
Rc::as_ptr(&self.0).hash(state)
}
}
impl<T> PartialEq for PtrIndexedRc<T> {
fn eq(&self, other: &Self) -> bool {
Rc::ptr_eq(&self.0, &other.0)
}
}
impl<T> Eq for PtrIndexedRc<T> {}
pub struct PtrIndexedHashSetIterator<'a, T>(hash_set::Iter<'a, PtrIndexedRc<T>>);
impl<'a, T> Iterator for PtrIndexedHashSetIterator<'a, T> {
type Item = &'a Rc<T>;
fn next(&mut self) -> Option<Self::Item> {
match self.0.next() {
Some(item) => Some(&item.0),
None => None,
}
}
}
impl<'a, T> IntoIterator for &'a PtrIndexedHashSet<T> {
type Item = &'a Rc<T>;
type IntoIter = PtrIndexedHashSetIterator<'a, T>;
fn into_iter(self) -> Self::IntoIter {
PtrIndexedHashSetIterator::<T>((&self.0).iter())
}
}
+44
View File
@@ -0,0 +1,44 @@
pub struct WindowIterator<I: Iterator> {
iterator: I,
previous: Option<I::Item>,
}
impl<I: Iterator> Iterator for WindowIterator<I>
where
I::Item: Copy,
{
type Item = (I::Item, I::Item);
fn next(&mut self) -> Option<Self::Item> {
if self.previous.is_none() {
self.previous = self.iterator.next();
}
let current = self.iterator.next();
if current.is_none() {
return None;
}
let current = current.unwrap();
let previous = self.previous.unwrap();
let result = (current, previous);
self.previous = Some(current);
Some(result)
}
}
pub trait Windows<T> {
fn windows(self) -> WindowIterator<Self>
where
Self: Sized + Iterator;
}
impl<I: Iterator> Windows<I> for I {
fn windows(self) -> WindowIterator<Self>
where
Self: Sized + Iterator,
{
WindowIterator {
iterator: self,
previous: None,
}
}
}
-153
View File
@@ -1,153 +0,0 @@
import {getGridPositionFromPixelsObj, getPixelsFromGridPositionObj} from "./foundry_fixes.js";
import {togglePathfinding} from "./keybindings.js";
import {debugGraphics} from "./main.js";
import {settingsKey} from "./settings.js";
import {getSnapPointForTokenObj, 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.user.isGM && !game.settings.get(settingsKey, "allowPathfinding"))
return false;
return game.settings.get(settingsKey, "autoPathfinding") != togglePathfinding;
}
export function findPath(from, to, token, previousWaypoints) {
const lastNode = calculatePath(from, to, token, previousWaypoints);
if (!lastNode)
return null;
paintPathfindingDebug(lastNode, token);
const path = [];
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))
// 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, token, 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 (!stepCollidesWithWall(pos, neighborPos, 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)
targetLayer = 1 - targetLayer;
const neighbor = getNode({...neighborPos, layer: targetLayer}, token, 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, token, previousWaypoints) {
if (game.system.id === "pf2e")
use5105 = true;
if (canvas.grid.diagonalRule === "5105")
use5105 = true;
let startLayer = 0;
if (use5105 && canvas.grid.type === CONST.GRID_TYPES.SQUARE) {
previousWaypoints = previousWaypoints.map(w => getGridPositionFromPixelsObj(w));
startLayer = calcNoDiagonals(previousWaypoints) % 2;
}
const nextNodes = [{node: getNode({...to, layer: startLayer}, token), 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, token);
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 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 paintPathfindingDebug(lastNode, token) {
if (!CONFIG.debug.dragRuler)
return;
debugGraphics.removeChildren();
let currentNode = lastNode;
while (currentNode) {
let text = new PIXI.Text(currentNode.cost.toFixed(0));
let pixels = getSnapPointForTokenObj(getPixelsFromGridPositionObj(currentNode.node), token);
text.anchor.set(0.5, 1.0);
text.x = pixels.x;
text.y = pixels.y;
debugGraphics.addChild(text);
currentNode = currentNode.previous;
}
}