Skip to content

Commit

Permalink
Merge branch 'release/0.8.7' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
caewok committed Feb 19, 2024
2 parents ed26398 + 58ea8fb commit 9a896d8
Show file tree
Hide file tree
Showing 8 changed files with 134 additions and 46 deletions.
10 changes: 10 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
# 0.8.7

## New Features
GM can now set whether pathfinding should be limited for users to areas within the fog of war. FYI, testing fog of war in Foundry for canvas positions is a performance hit. Closes issue #47.

## Bug fixes
Take the starting elevation of the path when testing whether tokens or walls block the path. Allows tokens or limited height walls to be ignored if no collision at the given elevation.

Fix for measuring elevation when Elevated Vision module is enabled. Closes issue #45.

# 0.8.6
Fix for pathfinding slipping through small cracks between walls. Unless the wall is a door, the path should be limited to half the token min(width, height).

Expand Down
3 changes: 3 additions & 0 deletions languages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@
"elevationruler.settings.pathfinding_tokens_block_hostile": "Hostile only",
"elevationruler.settings.pathfinding_tokens_block_all": "All",

"elevationruler.settings.pathfinding_limit_token_los.name": "Limit Pathfinding to Explored Areas",
"elevationruler.settings.pathfinding_limit_token_los.hint": "When pathfinding, limit the range of the pathfinding to explored areas unless the user is the GM",

"elevationruler.settings.grid-terrain-algorithm.name": "Terrain Grid Measurement",
"elevationruler.settings.grid-terrain-algorithm.hint": "When on a grid, how to account for movement penalties or bonuses from terrain and tokens? Center: apply if the terrain/token overlaps the grid center; Percent Area: apply if the terrain/token covers at least this much of the grid square/hex; Euclidean: prorate based on percent of line segment within the terrain/token between this grid square/hex and the previous.",
"elevationruler.settings.grid-terrain-choice-center-point": "Center Point",
Expand Down
2 changes: 1 addition & 1 deletion scripts/geometry
Submodule geometry updated 2 files
+1 −0 Changelog.md
+1 −1 elevation.js
26 changes: 15 additions & 11 deletions scripts/pathfinding/BorderTriangle.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,20 +88,22 @@ export class BorderEdge {
* Blocked walls are invalid.
* Typically returns 2 corner destinations plus the median destination.
* If the edge is less than 2 * spacer, no destinations are valid.
* @param {Point} center Test if wall blocks from perspective of this origin point.
* @param {number} [spacer] How much away from the corner to set the corner destinations.
* @param {Point} center Test if wall blocks from perspective of this origin point
* @param {number} elevation Assumed elevation of the move, for testing blocking walls, tokens
* @param {number} [spacer] How much away from the corner to set the corner destinations
* If the edge is less than 2 * spacer, it will be deemed invalid.
* Corner destinations are skipped if not more than spacer away from median.
* @returns {PIXI.Point[]}
*/
getValidDestinations(origin, spacer) {
getValidDestinations(origin, elevation, spacer) {
elevation ??= 0;
spacer ??= canvas.grid.size * 0.5;
const length = this.length;
const destinations = [];

// No destination if edge is smaller than 2x spacer unless it is a door.
// Cheat a little on the spacing so tokens exactly the right size will fit.
if ( this.edgeBlocks(origin)
if ( this.edgeBlocks(origin, elevation)
|| (!this.isOpenDoor && (length < (spacer * 1.9))) ) return destinations;
destinations.push(this.median);

Expand Down Expand Up @@ -135,11 +137,11 @@ export class BorderEdge {
* @param {Point} origin Measure wall blocking from perspective of this origin point.
* @returns {boolean}
*/
edgeBlocks(origin) {
edgeBlocks(origin, elevation = 0) {
const { moveToken, tokenBlockType } = this.constructor;
return this.objects.some(obj => {
if ( obj instanceof Wall ) return WallTracerEdge.wallBlocks(obj, origin);
if ( obj instanceof Token ) return WallTracerEdge.tokenEdgeBlocks(obj, moveToken, tokenBlockType);
if ( obj instanceof Wall ) return WallTracerEdge.wallBlocks(obj, origin, elevation);
if ( obj instanceof Token ) return WallTracerEdge.tokenEdgeBlocks(obj, moveToken, tokenBlockType, elevation);
return false;
});
}
Expand Down Expand Up @@ -301,21 +303,22 @@ export class BorderTriangle {
* Corner destination skipped if median --> corner < spacer
*
* @param {BorderTriangle|null} priorTriangle Triangle that preceded this one along the path
* @param {number} elevation Assumed elevation of the move, for testing edge walls, tokens.
* @param {number} spacer How far from the corner to set the corner destinations
* @returns {PathNode} Each element has properties describing the destination, conforming to pathfinding
* - {number} key
* - {PIXI.Point} entryPoint
* - {BorderTriangle} entryTriangle
* - {BorderTriangle} priorTriangle
*/
getValidDestinations(priorTriangle, spacer) {
getValidDestinations(priorTriangle, elevation, spacer) {
spacer ??= canvas.grid.size * 0.5;
const destinations = [];
const center = this.center;
for ( const edge of Object.values(this.edges) ) {
const entryTriangle = edge.otherTriangle(this); // Neighbor
if ( !entryTriangle || (priorTriangle && priorTriangle === entryTriangle) ) continue;
const pts = edge.getValidDestinations(center, spacer);
const pts = edge.getValidDestinations(center, elevation, spacer);
pts.forEach(entryPoint => {
destinations.push({
entryPoint,
Expand All @@ -331,11 +334,12 @@ export class BorderTriangle {
/**
* Retrieve destinations with cost calculation added.
* @param {BorderTriangle|null} priorTriangle Triangle that preceded this one along the path
* @param {number} elevation Assumed elevation of the move, for testing edge walls, tokens.
* @param {number} spacer How far from the corner to set the corner destinations
* @param {Point} fromPoint Point to measure from, for cost
*/
getValidDestinationsWithCost(priorTriangle, spacer, fromPoint) {
const destinations = this.getValidDestinations(priorTriangle, spacer);
getValidDestinationsWithCost(priorTriangle, elevation, spacer, fromPoint) {
const destinations = this.getValidDestinations(priorTriangle, elevation, spacer);
destinations.forEach(d => {
d.cost = this._calculateMovementCost(fromPoint, d.entryPoint);
d.fromPoint = fromPoint;
Expand Down
23 changes: 15 additions & 8 deletions scripts/pathfinding/WallTracer.js
Original file line number Diff line number Diff line change
Expand Up @@ -332,21 +332,22 @@ export class WallTracerEdge extends GraphEdge {
* @param {Token} [moveToken] Optional token doing the move if token edges should be checked.
* @returns {boolean}
*/
edgeBlocks(origin, moveToken, tokenBlockType) {
edgeBlocks(origin, moveToken, tokenBlockType, elevation = 0) {
return this.objects.some(obj =>
(obj instanceof Wall) ? this.constructor.wallBlocks(obj, origin)
: (obj instanceof Token) ? this.constructor.tokenEdgeBlocks(obj, moveToken, tokenBlockType)
(obj instanceof Wall) ? this.constructor.wallBlocks(obj, origin, elevation)
: (obj instanceof Token) ? this.constructor.tokenEdgeBlocks(obj, moveToken, tokenBlockType, elevation)
: false);
}

/**
* Does this edge wall block from an origin somewhere?
* Tested "live" and not cached so door or wall orientation changes need not be tracked.
* @param {Wall} wall Wall to test
* @param {Point} origin Measure wall blocking from perspective of this origin point.
* @param {Wall} wall Wall to test
* @param {Point} origin Measure wall blocking from perspective of this origin point.
* @param {number} [elevation=0] Elevation of the point or origin to test.
* @returns {boolean}
*/
static wallBlocks(wall, origin) {
static wallBlocks(wall, origin, elevation = 0) {
if ( !wall.document.move || wall.isOpen ) return false;

// Ignore one-directional walls which are facing away from the center
Expand All @@ -361,6 +362,9 @@ export class WallTracerEdge extends GraphEdge {
if ( wall.document.dir
&& side === wall.document.dir ) return false;

// Test for wall height.
if ( !elevation.between(wall.bottomZ, wall.topZ) ) return false;

return true;
}

Expand All @@ -369,11 +373,14 @@ export class WallTracerEdge extends GraphEdge {
* @param {Token} token Token whose edges will be tested
* @param {Token} moveToken Token doing the move
* @param {string} tokenBlockType What test to use for comparing token dispositions for blocking
* @param {number} [elevation=0] Elevation of the point or origin to test.
* @returns {boolean}
*/
static tokenEdgeBlocks(token, moveToken, tokenBlockType) {
static tokenEdgeBlocks(token, moveToken, tokenBlockType, elevation = 0) {
if ( !moveToken || moveToken === token ) return false;

if ( !elevation.between(token.topZ, token.bottomZ) ) return false;

tokenBlockType ??= Settings._tokenBlockType();
const D = CONST.TOKEN_DISPOSITIONS;
const moveTokenD = moveToken.document.disposition;
Expand Down Expand Up @@ -613,7 +620,7 @@ export class WallTracer extends Graph {
.forEach(obj => {
edge.objects.delete(obj);
this._removeEdgeFromObjectSet(id, edge);
});
});
// Works but not clear why edges sometimes exist but are not in the edge set.
// Removing the test for if the edge is in the edges set results in occasional warnings.
if ( !edge.objects.size && this.edges.has(edge.key) ) this.deleteEdge(edge);
Expand Down
68 changes: 59 additions & 9 deletions scripts/pathfinding/pathfinding.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ ClockwiseSweepPolygon,
CONFIG,
CONST,
foundry,
game,
PIXI
*/
/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */
Expand All @@ -15,6 +16,7 @@ import { Draw } from "../geometry/Draw.js";
import { BreadthFirstPathSearch, UniformCostPathSearch, GreedyPathSearch, AStarPathSearch } from "./algorithms.js";
import { SCENE_GRAPH } from "./WallTracer.js";
import { cdt2dConstrainedGraph, cdt2dToBorderTriangles } from "../delaunator/cdt2d_access_functions.js";
import { Settings } from "../settings.js";

/* Testing
Expand Down Expand Up @@ -52,11 +54,11 @@ Pathfinder.drawTriangles();
endPoint = _token.center
token = _token
startPoint = _token.center;
pf = new Pathfinder(token);
pf = token.elevationruler.pathfinder
path = pf.runPath(startPoint, endPoint, "breadth")
pathPoints = Pathfinder.getPathPoints(path);
Expand Down Expand Up @@ -179,6 +181,9 @@ export class Pathfinder {
/** @type {Token} token */
token;

/** @type {number} */
startElevation = 0;

/**
* Optional token to associate with this path.
* Used for path spacing near obstacles.
Expand Down Expand Up @@ -214,6 +219,9 @@ export class Pathfinder {
/** @type {object{BreadthFirstPathSearch}} */
algorithm = {};

/** @type {function|undefined} */
#fogIsExploredFn;

/**
* Find the path between startPoint and endPoint using the chosen algorithm.
* @param {Point} startPoint Start point for the graph
Expand All @@ -224,20 +232,27 @@ export class Pathfinder {
// Set token for token edge blocking.
BorderEdge.moveToken = this.token;

// Set fog exploration testing if that setting is enabled.
if ( !game.user.isGM
&& Settings.get(Settings.KEYS.PATHFINDING.LIMIT_TOKEN_LOS) ) this.#fogIsExploredFn = fogIsExploredFn();

// Initialize the algorithm if not already.
if ( !this.algorithm[type] ) {
const alg = this.algorithm[type] = new this.constructor.ALGORITHMS[type]();
const costMethod = this.constructor.COST_METHOD[type];
alg.getNeighbors = this[costMethod];
alg.getNeighbors = this[costMethod].bind(this);
alg.heuristic = this._heuristic;
}

// Make sure pathfinder triangles are up-to-date.
if ( this.constructor.dirty ) this.constructor.initialize();

// Run the algorithm.
this.startElevation = startPoint.z || 0;
const { start, end } = this._initializeStartEndNodes(startPoint, endPoint);
return this.algorithm[type].run(start, end);
const out = this.algorithm[type].run(start, end);
this.#fogIsExploredFn = undefined;
return out;
}


Expand Down Expand Up @@ -305,14 +320,15 @@ export class Pathfinder {
const newNode = {...goal};
newNode.priorTriangle = pathNode.priorTriangle;
return [newNode];

}
return pathNode.entryTriangle.getValidDestinations(pathNode.priorTriangle, this.spacer);

const destinations = pathNode.entryTriangle.getValidDestinations(pathNode.priorTriangle, this.startElevation, this.spacer);
return this.#filterDestinationsbyExploration(destinations);
}

/**
* Get destinations with cost calculated for a given path node.
* @param {PathNode} pathObject
* @param {PathNode} pathObject
* @returns {PathNode[]} Array of destination nodes
*/
_identifyDestinationsWithCost(pathNode, goal) {
Expand All @@ -325,8 +341,23 @@ export class Pathfinder {
newNode.fromPoint = pathNode.entryPoint;
return [newNode];
}
return pathNode.entryTriangle.getValidDestinationsWithCost(
pathNode.priorTriangle, this.spacer, pathNode.entryPoint);

const destinations = pathNode.entryTriangle.getValidDestinationsWithCost(
pathNode.priorTriangle, this.startElevation, this.spacer, pathNode.entryPoint);
return this.#filterDestinationsbyExploration(destinations);
}

/**
* If not GM and GM has set the limit on pathfinding to token LOS, then filter destinations accordingly.
* @param {PathNode[]} destinations Array of destination nodes
* @returns {PathNode[]} Array of destination nodes, possibly filtered.
*/
#filterDestinationsbyExploration(destinations) {
const fn = this.#fogIsExploredFn;
if ( !fn ) return destinations;

// Each entrypoint must be an explored point.
return destinations.filter(d => fn(d.entryPoint.x, d.entryPoint.y));
}

/**
Expand Down Expand Up @@ -594,3 +625,22 @@ function cleanNonGridPath(pathPoints) {
newPath.push(pathPoints.at(-1));
return newPath;
}

/**
* Function factory to provide a means to test if a given canvas location is explored or unexplored.
* Dependent on the scene having a fog exploration for that user.
* Because fog will change over time, this should be called each time a new path is requested.
* @returns {function} Function that checks whether a canvas position is explored
* - @param {number} x
* - @param {number} y
* - @returns {boolean} True if explored, false if unexplored. If no fog, always true.
*/
export function fogIsExploredFn() {
// log("Checking for new fog texture");
const tex = canvas.fog.exploration?.getTexture();
if ( !tex || !tex.valid ) return undefined;

const { width, height } = canvas.effects.visibility.textureConfiguration;
const cache = CONFIG.GeometryLib.PixelCache.fromTexture(tex, { width, height });
return (x, y) => cache.pixelAtCanvas(x, y) > 128;
}
15 changes: 14 additions & 1 deletion scripts/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ const SETTINGS = {
NO: "pathfinding_tokens_block_no",
HOSTILE: "pathfinding_tokens_block_hostile",
ALL: "pathfinding_tokens_block_all"
}
},
LIMIT_TOKEN_LOS: "pathfinding_limit_token_los"
},

USE_LEVELS_LABEL: "levels-use-floor-label",
Expand Down Expand Up @@ -112,6 +113,8 @@ export class Settings extends ModuleSettingsAbstract {
requiresReload: false
});

// ----- NOTE: Pathfinding ----- //

register(KEYS.CONTROLS.PATHFINDING, {
scope: "user",
config: false,
Expand All @@ -136,6 +139,16 @@ export class Settings extends ModuleSettingsAbstract {
onChange: value => this.toggleTokenBlocksPathfinding(value)
});

register(KEYS.PATHFINDING.LIMIT_TOKEN_LOS, {
name: localize(`${KEYS.PATHFINDING.LIMIT_TOKEN_LOS}.name`),
hint: localize(`${KEYS.PATHFINDING.LIMIT_TOKEN_LOS}.hint`),
scope: "world",
config: true,
default: false,
type: Boolean,
requiresReload: false
});

// ----- NOTE: Token ruler ----- //
register(KEYS.TOKEN_RULER.ENABLED, {
name: localize(`${KEYS.TOKEN_RULER.ENABLED}.name`),
Expand Down
Loading

0 comments on commit 9a896d8

Please sign in to comment.