From f6c882aad0defe82abc5cebd9cd59a08606de21e Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Wed, 7 Feb 2024 10:37:44 -0800 Subject: [PATCH 01/32] Add setter handling to Patcher --- scripts/Patcher.js | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/scripts/Patcher.js b/scripts/Patcher.js index d59868b..6bb3c65 100644 --- a/scripts/Patcher.js +++ b/scripts/Patcher.js @@ -131,6 +131,7 @@ export class Patcher { isStatic: typeName.includes("STATIC") }; switch ( typeName ) { case "HOOKS": patchCl = HookPatch; break; + case "STATIC_OVERRIDES": // eslint-disable-line no-fallthrough case "OVERRIDES": case "STATIC_MIXES": @@ -142,10 +143,20 @@ export class Patcher { ? libWrapper.OVERRIDE : typeName.includes("MIXES") ? libWrapper.MIXED : libWrapper.WRAPPER; break; - case "STATIC_GETTERS": // eslint-disable-line no-fallthrough + + case "STATIC_GETTERS": // eslint-disable-line no-fallthrough case "GETTERS": cfg.isGetter = true; - default: // eslint-disable-line no-fallthrough + patchCl = MethodPatch; + break; + + case "STATIC_SETTERS": // eslint-disable-line no-fallthrough + case "SETTERS": + cfg.isSetter = true; + patchCl = MethodPatch; + break; + + default: patchCl = MethodPatch; } const thePatch = patchCl.create(patchName, patch, cfg); @@ -166,11 +177,20 @@ export class Patcher { * @param {boolean} [opts.optional] True if the getter should not be set if it already exists. * @returns {undefined|object Date: Sun, 11 Feb 2024 14:19:57 -0800 Subject: [PATCH 02/32] Fix for undefined constrainedTokenBounds --- scripts/terrain_elevation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/terrain_elevation.js b/scripts/terrain_elevation.js index 7544203..d93a2e5 100644 --- a/scripts/terrain_elevation.js +++ b/scripts/terrain_elevation.js @@ -120,7 +120,7 @@ export function elevationAtLocation(location, token) { // If normal ruler and not prioritizing the token elevation, use elevation of other tokens at this point. if ( !isTokenRuler && !preferTokenElevation() ) { const maxTokenE = retrieveVisibleTokens() - .filter(t => t.constrainedTokenBounds.contains(location.x, location.y)) + .filter(t => t.constrainedTokenBorder.contains(location.x, location.y)) .reduce((e, t) => Math.max(t.elevationE, e), Number.NEGATIVE_INFINITY); if ( isFinite(maxTokenE) ) return maxTokenE; } From f528b7808462af634426c3143cf34c0d3fc03c12 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sun, 11 Feb 2024 14:20:43 -0800 Subject: [PATCH 03/32] Update changelog --- Changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Changelog.md b/Changelog.md index e5d6f08..7c40158 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,7 @@ +# 0.8.5 +Fix for undefined `constrainedTokenBounds.contains`. + + # 0.8.4 Improve path cleaning algorithm to remove multiple straight-line points. Closes issue #40. Refactor measurement of distances and move distances to better account for 3d distance. Closes issue #41. From 8d4c8dd3914b85b62d512e4c579e1e9ab9e23612 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sun, 11 Feb 2024 14:21:03 -0800 Subject: [PATCH 04/32] Update lib geometry to v0.2.16 --- scripts/geometry | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/geometry b/scripts/geometry index ba408a3..ab4e36b 160000 --- a/scripts/geometry +++ b/scripts/geometry @@ -1 +1 @@ -Subproject commit ba408a3232516ca8c11acc8ffe4639718629cb10 +Subproject commit ab4e36be9b0dd96af20e3c9c6ad5d7a113df1d57 From 9d09ddcf93e1ce26be8653c278035c6433cbaee8 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sun, 11 Feb 2024 14:21:32 -0800 Subject: [PATCH 05/32] Update changelog --- Changelog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Changelog.md b/Changelog.md index 7c40158..3c58f95 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,6 +1,6 @@ # 0.8.5 Fix for undefined `constrainedTokenBounds.contains`. - +Update lib geometry to v0.2.16. # 0.8.4 Improve path cleaning algorithm to remove multiple straight-line points. Closes issue #40. @@ -9,7 +9,7 @@ Remove animation easing for intermediate segments while keeping easing-in for th Add `CONFIG` settings to change the unicode symbol displayed when the ruler is over terrain (or tokens, if tokens count as difficult terrain). - `CONFIG.elevationruler.SPEED.terrainSymbol`: You can use any text string here. Paste in a unicode symbol if you want a different symbol. For Font Awesome icons, use, e.g., "\uf0e7". - `CONFIG.elevationruler.SPEED.useFontAwesome`: Set to true to interpet the `terrainSymbol` as FA unicode. -Update geometry lib to v0.2.15. +Update lib geometry to v0.2.15. ## Breaking changes The added methods `Ruler.measureDistance` and `Ruler.measureMoveDistance` were refactored and now take different parameters. From e732c8025916fb45d0308e9d5ad95b96de712f60 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Tue, 13 Feb 2024 07:00:10 -0800 Subject: [PATCH 06/32] Seemingly working iterateGridMoves --- scripts/measure_distance.js | 209 ++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/scripts/measure_distance.js b/scripts/measure_distance.js index feba1c6..68d6e36 100644 --- a/scripts/measure_distance.js +++ b/scripts/measure_distance.js @@ -27,6 +27,9 @@ const CHANGE = { E: 4 }; +// Store the flipped key/values. +Object.entries(CHANGE).forEach(([key, value]) => CHANGE[value] = key); + /** * Measure physical distance between two points, accounting for grid rules. * @param {Point} a Starting point for the segment @@ -621,3 +624,209 @@ function minMaxOverlap(a0, a1, b0, b1, inclusive = true) { || bMinMax.min.between(aMinMax.min, aMinMax.max, inclusive) || bMinMax.max.between(aMinMax.min, aMinMax.max, inclusive); } + +/** + * From a given origin, move horizontally the total 2d distance between A and B. + * Then move vertically up/down in elevation. + * Return an iterator for that movement. + * @param {Point3d} origin + * @param {Point3d} destination + * @return Iterator, which in turn + * returns [row, col] Array for each grid point under the line. + */ +function iterateGridProjectedElevation(origin, destination) { + const dist2d = PIXI.Point.distanceBetween(origin, destination); + let elev = destination.z - origin.z; + + // Must round up to the next grid step for elevation. + const size = canvas.dimensions.size; + if ( elev % size ) elev = (Math.floor(elev / size) + 1) * size; + + // For hexagonal grids, move in the straight line (row or col) to represent elevation. + const b = isHexRow() + ? new PIXI.Point(origin.x + elev, origin.y + dist2d) + : new PIXI.Point(origin.x + dist2d, origin.y + elev); + return iterateGridUnderLine(origin, b); +} + +/** + * @returns {boolean} True if the grid is a row hex. + */ +function isHexRow() { + return canvas.grid.type === CONST.GRID_TYPES.HEXODDR || canvas.grid.type === CONST.GRID_TYPES.HEXEVENR; +} + +/** + * Type of change between two grid coordinates. + * @param {number[2]} prevGridCoord + * @param {number[2]} nextGridCoord + * @returns {CHANGE} + */ +function gridChangeType(prevGridCoord, nextGridCoord) { + const xChange = prevGridCoord[1] !== nextGridCoord[1]; // Column is x + const yChange = prevGridCoord[0] !== nextGridCoord[0]; // Row is y + return CHANGE[((xChange * 2) + yChange)]; +} + + +function * iterateHexGridMoves(origin, destination) { + const iter2d = iterateGridUnderLine(origin, destination); + const iterElevation = iterateGridProjectedElevation(origin, destination); + // First coordinate is always the origin grid. + let prev2d = iter2d.next().value; + let prevElevation = iterElevation.next().value; + let movementChange = { H: 0, V: 0, D: 0, E: 0 }; + + yield { + movementChange, + gridCoords: prev2d + } + + // Moving along the aligned column/row of the hex grid represents elevation-only change. + // Moves in other direction represents 2d movement. + // Assume no reverse-elevation, so elevation must always go the same direction. + // Hex grid is represented as smaller square grid in Foundry. + + const elevOnlyMoveType = isHexRow() ? "H" : "V"; + const elevOnlyIndex = isHexRow() ? 1 : 0; + const elevSign = Math.sign(destination.z - origin.z); + const elevTest = elevSign ? (a, b) => a > b : (a, b) => a < b; + let currElev = prevElevation[elevOnlyIndex]; + movementChange = { H: 0, V: 0, D: 0, E: 0 }; // Copy; don't keep same object. + + // Use the elevation iteration to tell us when to move to the next 2d step. + // Horizontal or diagonal elevation moves indicate next step. + for ( const nextElevation of iterElevation ) { + const elevChangeType = gridChangeType(prevElevation, nextElevation) + switch ( elevChangeType ) { + case "NONE": console.warn("iterateGridMoves unexpected elevChangeType === NONE"); break; + case elevOnlyMoveType: { + currElev = nextElevation[elevOnlyIndex]; + movementChange.E += 1; + break; + } + default: { + const next2d = iter2d.next().value ?? prev2d; + const moveType = gridChangeType(prev2d, next2d) + prev2d = next2d; + const newElev = nextElevation[elevOnlyIndex]; + if ( elevTest(newElev, currElev) ) { + currElev = newElev; + movementChange.E += 1; + } + movementChange[moveType] += 1; + yield { + movementChange, + gridCoords: next2d + } + movementChange = { H: 0, V: 0, D: 0, E: 0 }; + } + } + prevElevation = nextElevation; + } + + if ( movementChange.E ) { + yield { + movementChange, + gridCoords: prev2d + } + } +} + +/** + * From a given origin to a destination, iterate over each grid coordinate in turn. + * Track data related to the move at each iteration, taking the delta from the previous. + * @param {Point3d} origin + * @param {Point3d} destination + * @returnIterator, which in turn returns {object} + * - @prop {number[2]} gridCoords + * - @prop {number[5]} movementChange + * - @prop {number[5]} totalMovementChange + */ +function * iterateGridMoves(origin, destination) { + const iter2d = iterateGridUnderLine(origin, destination); + const iterElevation = iterateGridProjectedElevation(origin, destination); + // First coordinate is always the origin grid. + let prev2d = iter2d.next().value; + let prevElevation = iterElevation.next().value; + let movementChange = { H: 0, V: 0, D: 0, E: 0 }; + + yield { + movementChange, + gridCoords: prev2d + } + + movementChange = { H: 0, V: 0, D: 0, E: 0 }; // Copy; don't keep same object. + + // Use the elevation iteration to tell us when to move to the next 2d step. + // Horizontal or diagonal elevation moves indicate next step. + for ( const nextElevation of iterElevation ) { + const elevChangeType = gridChangeType(prevElevation, nextElevation) + switch ( elevChangeType ) { + case "NONE": console.warn("iterateGridMoves unexpected elevChangeType === NONE"); break; + case "V": { + movementChange.E += 1; + break; + } + case "D": movementChange.E += 1; + case "H": { + const next2d = iter2d.next().value ?? prev2d; + const moveType = gridChangeType(prev2d, next2d); + prev2d = next2d; + movementChange[moveType] += 1; + yield { + movementChange, + gridCoords: next2d + } + movementChange = { H: 0, V: 0, D: 0, E: 0 }; + } + } + prevElevation = nextElevation; + } + + if ( movementChange.E ) { + yield { + movementChange, + gridCoords: prev2d + } + } +} + + + +/* Testing +Draw = CONFIG.GeometryLib.Draw + + +gridCoords = [...iterateGridUnderLine(origin, destination)] +gridCoords = [...iterateGridProjectedElevation(origin, destination)] + +gridMoves = [...iterateHexGridMoves(origin, destination)] + + +Draw.clearDrawings() +gridMoves = [...iterateGridMoves(origin, destination)] +gridCoords = gridMoves.map(elem => elem.gridCoords) + + +rulerPts = []; +for ( let i = 0; i < gridCoords.length; i += 1 ) { + const [tlx, tly] = canvas.grid.grid.getPixelsFromGridPosition(gridCoords[i][0], gridCoords[i][1]); + const [x, y] = canvas.grid.grid.getCenter(tlx, tly); + rulerPts.push({x, y}) +} +rulerPts.forEach(pt => Draw.point(pt, { color: Draw.COLORS.green })) + +rulerShapes = []; +for ( let i = 0; i < gridCoords.length; i += 1 ) { + rulerShapes.push(gridShapeFromGridCoords([gridCoords[i][0], gridCoords[i][1]])) +} +rulerShapes.forEach(shape => Draw.shape(shape, { color: Draw.COLORS.green })) + +Draw.point(origin); +Draw.point(destination) + +moveArr = gridMoves.map(elem => elem.movementChange); +console.table(moveArr) + +*/ \ No newline at end of file From e405b2fefe8377f8bb2c6603fe66087a501621cf Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Tue, 13 Feb 2024 07:09:08 -0800 Subject: [PATCH 07/32] Add helper to switch between iterating square or hex grids --- scripts/measure_distance.js | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/scripts/measure_distance.js b/scripts/measure_distance.js index 68d6e36..ee9b20f 100644 --- a/scripts/measure_distance.js +++ b/scripts/measure_distance.js @@ -668,7 +668,31 @@ function gridChangeType(prevGridCoord, nextGridCoord) { return CHANGE[((xChange * 2) + yChange)]; } +/** + * From a given origin to a destination, iterate over each grid coordinate in turn. + * Track data related to the move at each iteration, taking the delta from the previous. + * @param {Point3d} origin + * @param {Point3d} destination + * @returnIterator, which in turn returns {object} + * - @prop {number[2]} gridCoords + * - @prop {object} movementChange + */ +function iterateGridMoves(origin, destination) { + if ( canvas.grid.type === CONST.GRID_TYPES.SQUARE + || canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ) return iterateNonHexGridMoves(origin, destination); + return iterateHexGridMoves(origin, destination); +} +/** + * For hex grids. + * From a given origin to a destination, iterate over each grid coordinate in turn. + * Track data related to the move at each iteration, taking the delta from the previous. + * @param {Point3d} origin + * @param {Point3d} destination + * @returnIterator, which in turn returns {object} + * - @prop {number[2]} gridCoords + * - @prop {object} movementChange + */ function * iterateHexGridMoves(origin, destination) { const iter2d = iterateGridUnderLine(origin, destination); const iterElevation = iterateGridProjectedElevation(origin, destination); @@ -734,6 +758,7 @@ function * iterateHexGridMoves(origin, destination) { } /** + * For square grids. * From a given origin to a destination, iterate over each grid coordinate in turn. * Track data related to the move at each iteration, taking the delta from the previous. * @param {Point3d} origin @@ -743,7 +768,7 @@ function * iterateHexGridMoves(origin, destination) { * - @prop {number[5]} movementChange * - @prop {number[5]} totalMovementChange */ -function * iterateGridMoves(origin, destination) { +function * iterateNonHexGridMoves(origin, destination) { const iter2d = iterateGridUnderLine(origin, destination); const iterElevation = iterateGridProjectedElevation(origin, destination); // First coordinate is always the origin grid. From 0e8e4d84411f8a4bc2735b7b454271df0d428c57 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Tue, 13 Feb 2024 07:26:45 -0800 Subject: [PATCH 08/32] Add helper to sum grid moves and adjust for elevation --- scripts/measure_distance.js | 55 +++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/scripts/measure_distance.js b/scripts/measure_distance.js index ee9b20f..fa42e81 100644 --- a/scripts/measure_distance.js +++ b/scripts/measure_distance.js @@ -322,29 +322,48 @@ function griddedMoveDistance(a, b, token, { useAllElevation = true, stopTarget } }; } +/** + * Helper to adjust a single grid move by elevation change. + * If moving horizontal or vertical with elevation, move diagonally instead. + * Sum the remaining elevation and add to diagonal. + * @param {object} gridMove + * @returns {gridMove} For convenience. Modified in place. + */ +function adjustGridMoveForElevation(gridMove) { + let totalE = gridMove.E; + while ( totalE > 0 && gridMove.H > 0 ) { + totalE -= 1; + gridMove.H -= 1; + gridMove.D += 1; + } + while ( totalE > 0 && gridMove.V > 0 ) { + totalE -= 1; + gridMove.V -= 1; + gridMove.D += 1; + } + gridMove.D += totalE; + + return gridMove; +} + + + /** * Count the number of horizontal, vertical, diagonal, elevation grid moves. + * Adjusts vertical and diagonal for elevation. * @param {PIXI.Point|Point3d} a Starting point for the segment * @param {PIXI.Point|Point3d} b Ending point for the segment * @returns {Uint32Array[4]|0} Counts of changes: none, vertical, horizontal, diagonal. */ -function countGridMoves(a, b) { - const iter = iterateGridUnderLine(a, b); - let prev = iter.next().value; - if ( !prev ) return 0; // Should never happen, as passing the same point as a,b returns a single square. - - // No change, vertical change, horizontal change, diagonal change. - const changeCount = new Uint32Array([0, 0, 0, 0]); - if ( prev ) { - for ( const next of iter ) { - const xChange = prev[1] !== next[1]; // Column is x - const yChange = prev[0] !== next[0]; // Row is y - changeCount[((xChange * 2) + yChange)] += 1; - prev = next; - } +function sumGridMoves(a, b) { + const iter = iterateGridMoves(a, b); + const totalChangeCount = { H: 0, V: 0, D: 0, E: 0 } + for ( const move of iter ) { + const movementChange = move.movementChange; + adjustGridMoveForElevation(movementChange); + Object.keys(totalChangeCount).forEach(key => totalChangeCount[key] += movementChange[key]); } - const elevSteps = numElevationGridSteps(Math.abs(b.z - a.z)); - return elevationChangeCount(elevSteps, changeCount); + return totalChangeCount; } /** @@ -636,7 +655,7 @@ function minMaxOverlap(a0, a1, b0, b1, inclusive = true) { */ function iterateGridProjectedElevation(origin, destination) { const dist2d = PIXI.Point.distanceBetween(origin, destination); - let elev = destination.z - origin.z; + let elev = (destination.z ?? 0) - (origin.z ?? 0); // Must round up to the next grid step for elevation. const size = canvas.dimensions.size; @@ -854,4 +873,6 @@ Draw.point(destination) moveArr = gridMoves.map(elem => elem.movementChange); console.table(moveArr) +sumGridMoves(origin, destination) + */ \ No newline at end of file From 3eaa43a01a5a903c8a0713a26cecd8a0ad3959f5 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Tue, 13 Feb 2024 10:13:44 -0800 Subject: [PATCH 09/32] Update lib geometry --- scripts/geometry | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/geometry b/scripts/geometry index ab4e36b..15960f2 160000 --- a/scripts/geometry +++ b/scripts/geometry @@ -1 +1 @@ -Subproject commit ab4e36be9b0dd96af20e3c9c6ad5d7a113df1d57 +Subproject commit 15960f2166b00a15f1c6f3ae58e5dcfd5f02e84d From 72e934fdd4db2a22da4202b2417984363d283797 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Tue, 13 Feb 2024 10:24:23 -0800 Subject: [PATCH 10/32] Add settings to choose how to measure terrain on grids --- languages/en.json | 10 +++++++++- scripts/settings.js | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/languages/en.json b/languages/en.json index 27aac7e..ce17145 100644 --- a/languages/en.json +++ b/languages/en.json @@ -50,7 +50,15 @@ "elevationruler.settings.pathfinding_tokens_block_hostile": "Hostile only", "elevationruler.settings.pathfinding_tokens_block_all": "All", + "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", + "elevationruler.settings.grid-terrain-choice-percent-area": "Percent Area", + "elevationruler.settings.grid-terrain-choice-euclidean": "Euclidean", + + "elevationruler.settings.grid-terrain-area-threshold.name": "Percent Area Threshold", + "elevationruler.settings.grid-terrain-area-threshold.hint": "If 'Percent Area' is selected for Terrain Grid Measurement, this determines the percent overlap required for the terrain/token to count when measuring on grids.", + "elevationruler.controls.prefer-token-elevation.name": "Prefer Token Elevation", "elevationruler.controls.pathfinding-control.name": "Use Pathfinding" - } \ No newline at end of file diff --git a/scripts/settings.js b/scripts/settings.js index f8530ba..8882a16 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -42,7 +42,18 @@ const SETTINGS = { ROUND_TO_MULTIPLE: "round-to-multiple", SPEED_HIGHLIGHTING: "token-ruler-highlighting", TOKEN_MULTIPLIER: "token-terrain-multiplier" + }, + + GRID_TERRAIN: { + ALGORITHM: "grid-terrain-algorithm", + CHOICES: { + CENTER_POINT: "grid-terrain-choice-center-point", + PERCENT_AREA: "grid-terrain-choice-percent-area", + EUCLIDEAN: "grid-terrain-choice-euclidean" + }, + AREA_THRESHOLD: "grid-terrain-area-threshold" } + }; const KEYBINDINGS = { @@ -167,6 +178,34 @@ export class Settings extends ModuleSettingsAbstract { step: 0.1 } }); + + // ----- NOTE: Grid Terrain Measurement ----- // + register(KEYS.GRID_TERRAIN.ALGORITHM, { + name: localize(`${KEYS.GRID_TERRAIN.ALGORITHM}.name`), + hint: localize(`${KEYS.GRID_TERRAIN.ALGORITHM}.hint`), + scope: "world", + config: true, + type: String, + choices: { + [KEYS.GRID_TERRAIN.CHOICES.CENTER_POINT]: localize(`${KEYS.GRID_TERRAIN.CHOICES.CENTER_POINT}`), + [KEYS.GRID_TERRAIN.CHOICES.PERCENT_AREA]: localize(`${KEYS.GRID_TERRAIN.CHOICES.PERCENT_AREA}`), + [KEYS.GRID_TERRAIN.CHOICES.EUCLIDEAN]: localize(`${KEYS.GRID_TERRAIN.CHOICES.EUCLIDEAN}`) + } + }); + + register(KEYS.GRID_TERRAIN.AREA_THRESHOLD, { + name: localize(`${KEYS.GRID_TERRAIN.AREA_THRESHOLD}.name`), + hint: localize(`${KEYS.GRID_TERRAIN.AREA_THRESHOLD}.hint`), + scope: "world", + config: true, + default: 0.5, + type: Number, + range: { + min: 0.1, + max: 1, + step: 0.1 + } + }); } static registerKeybindings() { From f285d294245bf5e29a69fd738c9e604dfc585341 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Tue, 13 Feb 2024 11:54:16 -0800 Subject: [PATCH 11/32] Shorten the GRID_TERRAIN property names --- scripts/settings.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/settings.js b/scripts/settings.js index 8882a16..239aa32 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -47,8 +47,8 @@ const SETTINGS = { GRID_TERRAIN: { ALGORITHM: "grid-terrain-algorithm", CHOICES: { - CENTER_POINT: "grid-terrain-choice-center-point", - PERCENT_AREA: "grid-terrain-choice-percent-area", + CENTER: "grid-terrain-choice-center-point", + PERCENT: "grid-terrain-choice-percent-area", EUCLIDEAN: "grid-terrain-choice-euclidean" }, AREA_THRESHOLD: "grid-terrain-area-threshold" @@ -187,8 +187,8 @@ export class Settings extends ModuleSettingsAbstract { config: true, type: String, choices: { - [KEYS.GRID_TERRAIN.CHOICES.CENTER_POINT]: localize(`${KEYS.GRID_TERRAIN.CHOICES.CENTER_POINT}`), - [KEYS.GRID_TERRAIN.CHOICES.PERCENT_AREA]: localize(`${KEYS.GRID_TERRAIN.CHOICES.PERCENT_AREA}`), + [KEYS.GRID_TERRAIN.CHOICES.CENTER]: localize(`${KEYS.GRID_TERRAIN.CHOICES.CENTER}`), + [KEYS.GRID_TERRAIN.CHOICES.PERCENT]: localize(`${KEYS.GRID_TERRAIN.CHOICES.PERCENT}`), [KEYS.GRID_TERRAIN.CHOICES.EUCLIDEAN]: localize(`${KEYS.GRID_TERRAIN.CHOICES.EUCLIDEAN}`) } }); From 37ce803016279087de56b769ee0e017f80e02d8e Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Tue, 13 Feb 2024 11:54:32 -0800 Subject: [PATCH 12/32] Add methods to get grid shape from grid coordinates --- scripts/util.js | 53 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/scripts/util.js b/scripts/util.js index 8e924a9..aa81991 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -30,31 +30,45 @@ export function gridShape(p) { } } +/** + * Helper to get the grid shape from grid coordinates. + * @param {number[2]} gridCoords + * @returns {null|PIXI.Rectangle|PIXI.Polygon} + */ +export function gridShapeFromGridCoords(gridCoords) { + if ( canvas.grid.isHex ) return hexGridShapeFromGridCoords(gridCoords); + return squareGridShapeFromGridCoords(gridCoords) +} + /** * From ElevatedVision ElevationLayer.js * Return the rectangle corresponding to the grid square at this point. * @param {x: number, y: number} p Location within the square. * @returns {PIXI.Rectangle} */ -export function squareGridShape(p) { - // Get the top left corner - const [tlx, tly] = canvas.grid.grid.getTopLeft(p.x, p.y); +function squareGridShapeFromTopLeft(tlx, tly) { const { w, h } = canvas.grid; return new PIXI.Rectangle(tlx, tly, w, h); } +function squareGridShapeFromGridCoords(gridCoords) { + const [tlx, tly] = canvas.grid.grid.getPixelsFromGridPosition(gridCoords[0], gridCoords[1]); + return squareGridShapeFromTopLeft(tlx, tly) +} + +export function squareGridShape(p) { + const [tlx, tly] = canvas.grid.grid.getTopLeft(p.x, p.y); + return squareGridShapeFromTopLeft(tlx, tly); +} + /** * From ElevatedVision ElevationLayer.js * Return the polygon corresponding to the grid hex at this point. * @param {x: number, y: number} p Location within the square. * @returns {PIXI.Rectangle} */ -export function hexGridShape(p, { width = 1, height = 1 } = {}) { - // Canvas.grid.grid.getBorderPolygon will return null if width !== height. - if ( width !== height ) return null; - - // Get the top left corner - const [tlx, tly] = canvas.grid.grid.getTopLeft(p.x, p.y); +function hexGridShapeFromTopLeft(tlx, tly, { width = 1, height = 1 } = {}) { + if ( width !== height ) return null; // Canvas.grid.grid.getBorderPolygon will return null if width !== height. const points = canvas.grid.grid.getBorderPolygon(width, height, 0); // TO-DO: Should a border be included to improve calc? const pointsTranslated = []; const ln = points.length; @@ -62,6 +76,27 @@ export function hexGridShape(p, { width = 1, height = 1 } = {}) { return new PIXI.Polygon(pointsTranslated); } +function hexGridShapeFromGridCoords(gridCoords, opts) { + const [tlx, tly] = canvas.grid.grid.getPixelsFromGridPosition(gridCoords[0], gridCoords[1]); + return hexGridShapeFromTopLeft(tlx, tly, opts); +} + +export function hexGridShape(p, opts) { + const [tlx, tly] = canvas.grid.grid.getTopLeft(p.x, p.y); + return hexGridShapeFromTopLeft(tlx, tly, opts); +} + +/** + * Find the grid center given grid coordinates. + * @param {number[]} gridCoords + * @returns {PIXI.Point} + */ +export function gridCenterFromGridCoords(gridCoords) { + const [tlx, tly] = canvas.grid.grid.getPixelsFromGridPosition(gridCoords[0], gridCoords[1]); + const [cx, cy] = canvas.grid.grid.getCenter(tlx, tly); + return new PIXI.Point(cx, cy); +} + /** * Get the two points perpendicular to line A --> B at A, a given distance from the line A --> B * @param {PIXI.Point} A From 1037190dd6e2290570ccb45854275496a2e7f37f Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Tue, 13 Feb 2024 11:54:53 -0800 Subject: [PATCH 13/32] Add terrain and token move penalty functions --- scripts/measure_distance.js | 242 +++++++++++++++++++++++++++++++----- 1 file changed, 213 insertions(+), 29 deletions(-) diff --git a/scripts/measure_distance.js b/scripts/measure_distance.js index fa42e81..53f681d 100644 --- a/scripts/measure_distance.js +++ b/scripts/measure_distance.js @@ -12,7 +12,9 @@ import { iterateGridUnderLine, squareGridShape, hexGridShape, - segmentBounds } from "./util.js"; + segmentBounds, + gridShapeFromGridCoords, + gridCenterFromGridCoords } from "./util.js"; import { Settings } from "./settings.js"; import { Point3d } from "./geometry/3d/Point3d.js"; @@ -46,28 +48,28 @@ export function measureDistance(a, b, { gridless = false } = {}) { a = Point3d.fromObject(a); b = Point3d.fromObject(b); - const changeCount = countGridMoves(a, b); - if ( !changeCount ) return 0; + const changeCount = sumGridMoves(a, b); + // Convert each grid step into a distance value. const distance = canvas.dimensions.distance; const diagonalRule = DIAGONAL_RULES[canvas.grid.diagonalRule] ?? DIAGONAL_RULES["555"]; let diagonalDist = distance; - if ( diagonalRule === DIAGONAL_RULES.EUCL ) diagonalDist = Math.hypot(distance, distance); + if ( !canvas.grid.isHex && diagonalRule === DIAGONAL_RULES.EUCL ) diagonalDist = Math.hypot(distance, distance); // Sum the horizontal, vertical, and diagonal grid moves. - let d = (changeCount[CHANGE.V] * distance) - + (changeCount[CHANGE.H] * distance) - + (changeCount[CHANGE.D] * diagonalDist); + let d = (changeCount.V * distance) + + (changeCount.H * distance) + + (changeCount.D * diagonalDist); // If diagonal is 5-10-5, every even move gets an extra 5. - if ( diagonalRule === DIAGONAL_RULES["5105"] ) { - const nEven = ~~(changeCount[CHANGE.D] * 0.5); + if ( !canvas.grid.isHex && diagonalRule === DIAGONAL_RULES["5105"] ) { + const nEven = ~~(changeCount.D * 0.5); d += (nEven * distance); } // For manhattan, every diagonal is done in two steps, so add an additional distance for each diagonal move. - else if ( diagonalRule === DIAGONAL_RULES.MANHATTAN ) { - d += (changeCount[CHANGE.D] * diagonalDist); + else if ( !canvas.grid.isHex && diagonalRule === DIAGONAL_RULES.MANHATTAN ) { + d += (changeCount.D * diagonalDist); } return d; @@ -175,7 +177,6 @@ function findGridlessBreakpoint(a, b, token, splitMoveDistance) { } else { maxLow = t; t += ((maxHigh - t) * 0.5); - } } return testSplitPoint; @@ -277,9 +278,14 @@ function terrainTokenGridlessMoveMultiplier(a, b, token) { * @returns {GriddedMoveDistanceMeasurement} */ function griddedMoveDistance(a, b, token, { useAllElevation = true, stopTarget } = {}) { - const iter = iterateGridUnderLine(a, b); + const iter = iterateGridMoves(a, b); let prev = iter.next().value; - if ( !prev ) return 0; // Should never happen, as passing the same point as a,b returns a single square. + + if ( !prev ) { + // Should never happen, as passing the same point as a,b returns a single square. + console.warn("griddedMoveDistance|iterateGridMoves return undefined first value."); + return 0; + } // Step over each grid shape in turn. let dTotal = 0; @@ -357,7 +363,7 @@ function adjustGridMoveForElevation(gridMove) { */ function sumGridMoves(a, b) { const iter = iterateGridMoves(a, b); - const totalChangeCount = { H: 0, V: 0, D: 0, E: 0 } + const totalChangeCount = { H: 0, V: 0, D: 0, E: 0 }; for ( const move of iter ) { const movementChange = move.movementChange; adjustGridMoveForElevation(movementChange); @@ -470,6 +476,185 @@ function elevationChangeCount(elevSteps, changeCount) { return changeCount; } +/** + * Determine whether this grid space applies a move penalty because one or more tokens occupy it. + * @param {number[2]} currGridCoords + * @param {number[2]} [prevGridCoords] Required for Euclidean setting; otherwise ignored. + * @param {number} currElev Elevation at current grid point, in pixel units + * @param {number} prevElev Elevation at current grid point, in pixel units + * @returns {number} Percent move penalty to apply. Returns 1 if no penalty. + */ +function griddedTokenMovePenalty(currGridCoords, prevGridCoords, currElev = 0, prevElev = 0) { + const mult = Settings.get(Settings.KEYS.TOKEN_RULER.TOKEN_MULTIPLIER); + if ( mult === 1 ) return 1; + + // Locate tokens that overlap this grid space. + const GT = Settings.KEYS.GRID_TERRAIN; + const alg = Settings.get(GT.ALGORITHM); + let collisionTest; + let bounds; + let currCenter; + let prevCenter; + switch ( alg ) { + case GT.CHOICES.CENTER: { + currCenter = gridCenterFromGridCoords(currGridCoords); + collisionTest = o => o.t.constrainedTokenBorder.contains(currCenter.x, currCenter.y); + bounds = gridShape.getBounds(); + break; + } + + case GT.CHOICES.PERCENT: { + const gridShape = gridShapeFromGridCoords(currGridCoords); + const percentThreshold = Settings.get(GT.AREA_THRESHOLD); + const totalArea = gridShape.area(); + collisionTest = o => percentOverlap(o.t.constrainedBorder, gridShape, totalArea) >= percentThreshold; + bounds = gridShape.getBounds(); + break; + } + + case GT.CHOICES.EUCLIDEAN: { + currCenter = gridCenterFromGridCoords(currGridCoords); + prevCenter = gridCenterFromGridCoords(prevGridCoords); + collisionTest = o => o.t.constrainedTokenBorder.lineSegmentIntersects(prevCenter, currCenter, { inside: true }); + bounds = segmentBounds(prevCenter, currCenter); + break; + } + } + + // Check that elevation is within the token height. + const tokens = canvas.tokens.quadtree.getObjects(bounds, { collisionTest }) + .filter(t => currElev.between(t.bottomZ, t.topZ)); + if ( alg !== GT.CHOICES.EUCLIDEAN ) return tokens.size ? mult : 1; + + // For Euclidean, determine the percentage intersect. + prevCenter = Point3d.fromObject(prevCenter); + currCenter = Point3d.fromObject(currCenter); + prevCenter.z = prevElev; + currCenter.z = currElev; + return percentageShapeIntersection(prevCenter, currCenter, tokens.map(t => t.constrainedTokenBorder)); +} + + +/** + * Determine the percentage of the ray that intersects a set of shapes. + * @param {PIXI.Point} a + * @param {PIXI.Point} b + * @param {(PIXI.Polygon|PIXI.Rectangle)[]} shapes + * @returns {number} + */ +percentageShapeIntersection(a, b, shapes = []) { + const tValues = []; + const deltaMag = b.to2d().subtract(a).magnitude(); + + // Determine the percentage of the a|b segment that intersects the shapes. + for ( const shape of shapes ) { + let inside = false; + if ( shape.contains(a) ) { + inside = true; + tValues.push({ t: 0, inside }); + } + + // At each intersection, we switch between inside and outside. + const ixs = shape.segmentIntersections(a, b); // Can we assume the ixs are sorted by t0? + + // See Foundry issue #10336. Don't trust the t0 values. + ixs.forEach(ix => { + // See PIXI.Point.prototype.towardsPoint + const distance = Point3d.distanceBetween(a, ix); + ix.t0 = distance / deltaMag; + }); + ixs.sort((a, b) => a.t0 - b.t0); + + ixs.forEach(ix => { + inside ^= true; + tValues.push({ t: ix.t0, inside }); + }); + } + + // Sort tValues and calculate distance between inside start/end. + // May be multiple inside/outside entries. + tValues.sort((a, b) => a.t0 - b.t0); + let nInside = 0; + let prevT = undefined; + let distInside = 0; + for ( const tValue of tValues ) { + if ( tValue.inside ) { + nInside += 1; + prevT ??= tValue.t; // Store only the first t to take us inside. + } else if ( nInside > 2 ) nInside -= 1; + else if ( nInside === 1 ) { // Inside is false and we are now outside. + const startPt = a.projectToward(b, prevT); + const endPt = a.projectToward(b, tValue.t); + distInside += Point3d.distanceBetween(startPt, endPt); + nInside = 0; + prevT = undefined; + } + } + + // If still inside, we can go all the way to t = 1 + if ( nInside > 0 ) { + const startPt = a.projectToward(b, prevT); + distInside += Point3d.distanceBetween(startPt, b); + } + + if ( !distInside ) return 1; + + const totalDistance = Point3d.distanceBetween(a, b); + return distanceInside / totalDistance; +} + +/** + * Calculate the percent area overlap of one shape on another. + * @param {PIXI.Rectangle|PIXI.Polygon} overlapShape + * @param {PIXI.Rectangle|PIXI.Polygon} areaShape + * @returns {number} Value between 0 and 1. + */ +function percentOverlap(overlapShape, areaShape, totalArea) { + if ( !overlapShape.overlaps(areaShape) ) return 0; + const intersection = overlapShape.intersectPolygon(areaShape.toPolygon()); + const ixArea = intersection.area(); + totalArea ??= areaShape.area(); + return ixArea / totalArea; +} + +/** + * Determine whether this grid space applies a move penalty/bonus because one or more terrains occupy it. + * @param {Token} token + * @param {number[2]} currGridCoords + * @param {number[2]} [prevGridCoords] Required for Euclidean setting; otherwise ignored. + * @param {number} currElev Elevation at current grid point, in pixel units + * @param {number} prevElev Elevation at current grid point, in pixel units + * @returns {number} Percent move penalty to apply. Returns 1 if no penalty. + */ +function griddedTerrainMovePenalty(token, currGridCoords, prevGridCoords, currElev = 0, prevElev = 0) { + if ( !MODULES_ACTIVE.TERRAIN_MAPPER ) return 1; + const Terrain = MODULES_ACTIVE.API.TERRAIN_MAPPER.Terrain; + const speedAttribute = SPEED.ATTRIBUTES[token.movementType] ?? SPEED.ATTRIBUTES.WALK; + const GT = Settings.KEYS.GRID_TERRAIN; + const alg = Settings.get(GT.ALGORITHM); + switch ( alg ) { + case GT.CHOICES.CENTER: { + const currCenter = Point3d.fromObject(gridCenterFromGridCoords(currGridCoords)); + currCenter.z = currElev; + return Terrain.percentMovementChangeForTokenAtPoint(token, currCenter, speedAttribute); + } + + case GT.CHOICES.PERCENT: { + const gridShape = gridShapeFromGridCoords(currGridCoords); + const percentThreshold = Settings.get(GT.AREA_THRESHOLD); + return Terrain.percentMovementChangeForTokenWithinShape(token, gridShape, percentThreshold, speedAttribute, currElev); + } + + case GT.CHOICES.EUCLIDEAN: { + const currCenter = Point3d.fromObject(gridCenterFromGridCoords(currGridCoords)); + const prevCenter = Point3d.fromObject(gridCenterFromGridCoords(prevGridCoords)); + currCenter.z = currElev; + prevCenter.z = prevElev; + return Terrain.percentMovementForTokenAlongPath(token, prevCenter, currCenter, speedAttribute); + } + } +} + /** * Return a function that tracks the grid steps from a previous square/hex to a new square/hex. * The function returns the distance and move distance for a given move. @@ -672,7 +857,8 @@ function iterateGridProjectedElevation(origin, destination) { * @returns {boolean} True if the grid is a row hex. */ function isHexRow() { - return canvas.grid.type === CONST.GRID_TYPES.HEXODDR || canvas.grid.type === CONST.GRID_TYPES.HEXEVENR; + return canvas.grid.type === CONST.GRID_TYPES.HEXODDR + || canvas.grid.type === CONST.GRID_TYPES.HEXEVENR; } /** @@ -723,7 +909,7 @@ function * iterateHexGridMoves(origin, destination) { yield { movementChange, gridCoords: prev2d - } + }; // Moving along the aligned column/row of the hex grid represents elevation-only change. // Moves in other direction represents 2d movement. @@ -740,7 +926,7 @@ function * iterateHexGridMoves(origin, destination) { // Use the elevation iteration to tell us when to move to the next 2d step. // Horizontal or diagonal elevation moves indicate next step. for ( const nextElevation of iterElevation ) { - const elevChangeType = gridChangeType(prevElevation, nextElevation) + const elevChangeType = gridChangeType(prevElevation, nextElevation); switch ( elevChangeType ) { case "NONE": console.warn("iterateGridMoves unexpected elevChangeType === NONE"); break; case elevOnlyMoveType: { @@ -750,7 +936,7 @@ function * iterateHexGridMoves(origin, destination) { } default: { const next2d = iter2d.next().value ?? prev2d; - const moveType = gridChangeType(prev2d, next2d) + const moveType = gridChangeType(prev2d, next2d); prev2d = next2d; const newElev = nextElevation[elevOnlyIndex]; if ( elevTest(newElev, currElev) ) { @@ -761,7 +947,7 @@ function * iterateHexGridMoves(origin, destination) { yield { movementChange, gridCoords: next2d - } + }; movementChange = { H: 0, V: 0, D: 0, E: 0 }; } } @@ -772,7 +958,7 @@ function * iterateHexGridMoves(origin, destination) { yield { movementChange, gridCoords: prev2d - } + }; } } @@ -798,21 +984,21 @@ function * iterateNonHexGridMoves(origin, destination) { yield { movementChange, gridCoords: prev2d - } + }; movementChange = { H: 0, V: 0, D: 0, E: 0 }; // Copy; don't keep same object. // Use the elevation iteration to tell us when to move to the next 2d step. // Horizontal or diagonal elevation moves indicate next step. for ( const nextElevation of iterElevation ) { - const elevChangeType = gridChangeType(prevElevation, nextElevation) + const elevChangeType = gridChangeType(prevElevation, nextElevation); switch ( elevChangeType ) { case "NONE": console.warn("iterateGridMoves unexpected elevChangeType === NONE"); break; case "V": { movementChange.E += 1; break; } - case "D": movementChange.E += 1; + case "D": movementChange.E += 1; // eslint-disable-line no-fallthrough case "H": { const next2d = iter2d.next().value ?? prev2d; const moveType = gridChangeType(prev2d, next2d); @@ -821,7 +1007,7 @@ function * iterateNonHexGridMoves(origin, destination) { yield { movementChange, gridCoords: next2d - } + }; movementChange = { H: 0, V: 0, D: 0, E: 0 }; } } @@ -832,12 +1018,10 @@ function * iterateNonHexGridMoves(origin, destination) { yield { movementChange, gridCoords: prev2d - } + }; } } - - /* Testing Draw = CONFIG.GeometryLib.Draw @@ -875,4 +1059,4 @@ console.table(moveArr) sumGridMoves(origin, destination) -*/ \ No newline at end of file +*/ From 496cc5655c996b61accd9d285878813b6b63b4e2 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Tue, 13 Feb 2024 16:10:40 -0800 Subject: [PATCH 14/32] Tentatively working griddedMoveDistance --- scripts/measure_distance.js | 400 ++++++++++-------------------------- 1 file changed, 103 insertions(+), 297 deletions(-) diff --git a/scripts/measure_distance.js b/scripts/measure_distance.js index 53f681d..03d5e29 100644 --- a/scripts/measure_distance.js +++ b/scripts/measure_distance.js @@ -6,9 +6,8 @@ PIXI */ "use strict"; -import { DIAGONAL_RULES, MODULES_ACTIVE } from "./const.js"; +import { DIAGONAL_RULES, MODULES_ACTIVE, SPEED } from "./const.js"; import { - log, iterateGridUnderLine, squareGridShape, hexGridShape, @@ -51,28 +50,50 @@ export function measureDistance(a, b, { gridless = false } = {}) { const changeCount = sumGridMoves(a, b); // Convert each grid step into a distance value. - const distance = canvas.dimensions.distance; - const diagonalRule = DIAGONAL_RULES[canvas.grid.diagonalRule] ?? DIAGONAL_RULES["555"]; - let diagonalDist = distance; - if ( !canvas.grid.isHex && diagonalRule === DIAGONAL_RULES.EUCL ) diagonalDist = Math.hypot(distance, distance); + // Sum the horizontal and vertical moves. + let d = (changeCount.V + changeCount.H) * canvas.dimensions.distance; - // Sum the horizontal, vertical, and diagonal grid moves. - let d = (changeCount.V * distance) - + (changeCount.H * distance) - + (changeCount.D * diagonalDist); + // Add diagonal distance based on varying diagonal rules. + const diagAdder = diagonalDistanceAdder(); + d += diagAdder(changeCount.D); - // If diagonal is 5-10-5, every even move gets an extra 5. - if ( !canvas.grid.isHex && diagonalRule === DIAGONAL_RULES["5105"] ) { - const nEven = ~~(changeCount.D * 0.5); - d += (nEven * distance); - } + return d; +} - // For manhattan, every diagonal is done in two steps, so add an additional distance for each diagonal move. - else if ( !canvas.grid.isHex && diagonalRule === DIAGONAL_RULES.MANHATTAN ) { - d += (changeCount.D * diagonalDist); +/** + * Additional distance for diagonal moves. + * @returns {function} + * - @param {number} nDiag + * - @returns {number} Diagonal distance, accounting for the diagonal 5105 rule. + */ +function diagonalDistanceAdder() { + const distance = canvas.dimensions.distance; + const diagonalDist = diagonalDistanceMultiplier(); + const diagonalRule = DIAGONAL_RULES[canvas.grid.diagonalRule] ?? DIAGONAL_RULES["555"]; + switch ( diagonalRule ) { + case DIAGONAL_RULES["5105"]: { + let totalDiag = 0; + return nDiag => { + const nEven = ~~(nDiag * 0.5) + (totalDiag % 2); + totalDiag += nDiag; + return (nDiag + nEven) * diagonalDist; + }; + } + case DIAGONAL_RULES.MANHATTAN: return nDiag => (nDiag + nDiag) * distance; + default: return nDiag => nDiag * diagonalDist; } +} - return d; +/** + * Determine the diagonal distance multiplier. + */ +function diagonalDistanceMultiplier() { + const distance = canvas.dimensions.distance; + const diagonalRule = DIAGONAL_RULES[canvas.grid.diagonalRule] ?? DIAGONAL_RULES["555"]; + let diagonalDist = distance; + if ( !canvas.grid.isHex + && diagonalRule === DIAGONAL_RULES.EUCL ) diagonalDist = Math.hypot(distance, distance); + return diagonalDist; } /** @@ -279,52 +300,77 @@ function terrainTokenGridlessMoveMultiplier(a, b, token) { */ function griddedMoveDistance(a, b, token, { useAllElevation = true, stopTarget } = {}) { const iter = iterateGridMoves(a, b); - let prev = iter.next().value; + let prevGridCoords = iter.next().value.gridCoords; - if ( !prev ) { - // Should never happen, as passing the same point as a,b returns a single square. + // Should never happen, as passing the same point as a,b returns a single square. + if ( !prevGridCoords ) { console.warn("griddedMoveDistance|iterateGridMoves return undefined first value."); return 0; } // Step over each grid shape in turn. + const diagAdder = diagonalDistanceAdder(); + const distance = canvas.dimensions.distance; + const size = canvas.scene.dimensions.size; let dTotal = 0; let dMoveTotal = 0; - let currElevSteps = 0; - let prevStep = prev; - let finalElev = 0; - const distanceGridStepFn = distanceForGridStepFunction(prev, a, b, token); - for ( const next of iter ) { - const { distance, movePenalty, elevSteps, currElev } = distanceGridStepFn(next); + let prevElev = a.z; + let elevSteps = 0; + for ( const { movementChange, gridCoords } of iter ) { + adjustGridMoveForElevation(movementChange); + const currElev = prevElev + (movementChange.E * size); // In pixel units. + + // Determine move penalty. + const tokenMovePenalty = griddedTokenMovePenalty(token, gridCoords, prevGridCoords, currElev, prevElev); + const terrainMovePenalty = griddedTerrainMovePenalty(token, gridCoords, prevGridCoords, currElev, prevElev); + if ( !tokenMovePenalty || tokenMovePenalty < 0 ) console.warn("griddedMoveDistance", { tokenMovePenalty }); + if ( !terrainMovePenalty || terrainMovePenalty < 0 ) console.warn("griddedMoveDistance", { terrainMovePenalty }); + + // Calculate the distance for this step. + let d = (movementChange.V + movementChange.H) * distance; + d += diagAdder(movementChange.D); + + // Apply move penalty, if any. + const dMove = d * (tokenMovePenalty * terrainMovePenalty); // Early stop if the stop target is met. - const moveDistance = (distance * movePenalty); - if ( stopTarget && (dMoveTotal + moveDistance) > stopTarget ) break; - - dTotal += distance; - dMoveTotal += (distance * movePenalty); - currElevSteps = elevSteps; - prevStep = next; - finalElev = currElev; + if ( stopTarget && (dMoveTotal + dMove) > stopTarget ) break; + + // Update distance totals. + dTotal += d; + dMoveTotal += dMove; + elevSteps += movementChange.E; + + // Cycle to next. + prevGridCoords = gridCoords; + prevElev = currElev; } // Handle remaining elevation change, if any, by moving directly up/down. - if ( useAllElevation ) { - while ( currElevSteps > 0 ) { - const { distance, movePenalty, elevSteps, currElev } = distanceGridStepFn(prevStep); - dTotal += distance; - dMoveTotal += (distance * movePenalty); - currElevSteps = elevSteps; - finalElev = currElev; - } + const targetElevSteps = numElevationGridSteps(Math.abs(b.z - a.z)); + let elevStepsNeeded = Math.max(targetElevSteps - elevSteps, 0); + if ( useAllElevation && !stopTarget && elevStepsNeeded ) { + // Determine move penalty. + const tokenMovePenalty = griddedTokenMovePenalty(token, prevGridCoords, prevGridCoords, b.z, prevElev); + const terrainMovePenalty = griddedTerrainMovePenalty(token, prevGridCoords, prevGridCoords, b.z, prevElev); + const d = elevStepsNeeded * distance; + dTotal += d; + dMoveTotal += (d * (tokenMovePenalty * terrainMovePenalty)); + elevStepsNeeded = 0; + prevElev = b.z; } + // Force to not go beyond b.z. + if ( b.z > a.z ) prevElev = Math.min(b.z, prevElev); + else if ( b.z < a.z ) prevElev = Math.max(b.z, prevElev); + else prevElev = b.z; + return { distance: dTotal, moveDistance: dMoveTotal, - remainingElevationSteps: currElevSteps, - endElevationZ: finalElev, - endGridCoords: prevStep + remainingElevationSteps: elevStepsNeeded, + endElevationZ: prevElev, + endGridCoords: prevGridCoords }; } @@ -352,8 +398,6 @@ function adjustGridMoveForElevation(gridMove) { return gridMove; } - - /** * Count the number of horizontal, vertical, diagonal, elevation grid moves. * Adjusts vertical and diagonal for elevation. @@ -372,44 +416,6 @@ function sumGridMoves(a, b) { return totalChangeCount; } -/** - * Helper to determine the center of a grid shape given a grid position. - * @param {Array[2]} gridCoords Grid coordinates, [row, col] - * @returns {Point} - */ -function gridCenterFromGridCoordinates(gridCoords) { - const [x, y] = canvas.grid.grid.getPixelsFromGridPosition(gridCoords[0], gridCoords[1]); - const [cx, cy] = canvas.grid.grid.getCenter(x, y); - return new PIXI.Point(cx, cy); -} - -/** - * Helper to get the terrain penalty for a given move from previous point to next point - * across a grid square/hex. - * @param {PIXI.Rectangle|PIXI.Polygon} gridShape - * @param {Point3d} startPt - * @param {Point3d} endPt - * @param {Token} token - * @returns {number} Terrain penalty, averaged across the two portions. - */ -function terrainPenaltyForGridStep(gridShape, startPt, endPt, token) { - const ixs = gridShape - .segmentIntersections(startPt, endPt) - .map(ix => PIXI.Point.fromObject(ix)); - const ix = PIXI.Point.fromObject(ixs[0] ?? PIXI.Point.midPoint(startPt, endPt)); - - // Build 3d points for calculating the terrain intersections - const midPt = Point3d.fromObject(ix); - midPt.z = (startPt.z + endPt.z) * 0.5; - - // Get penalty percentages, which might be 3d. - const terrainPenaltyPrev = terrainMovePenalty(startPt, midPt, token); - const terrainPenaltyCurr = terrainMovePenalty(midPt, endPt, token); - - // TODO: Does it matter that the 3d distance may be different than the 2d distance? - return (terrainPenaltyCurr + terrainPenaltyPrev) * 0.5; -} - /** * Helper to determine the grid shape from grid coordiantes * @param {Array[2]} gridCoords Grid coordinates, [row, col] @@ -421,61 +427,16 @@ export function gridShapeFromGridCoordinates(gridCoords) { return gridShapeFn({x, y}); } -/** - * Determine if at least one token overlaps this grid square/hex. - * @param {PIXI.Rectangle|PIXI.Polygon} gridShape - * @param {number} prevElev top/bottom of the grid - * @param {number} currElev top/bottom of the grid - */ -function doTokensOverlapGridShape(tokens, shape, prevElev = 0, currElev = 0) { - return tokens.some(t => { - // Token must be at the correct elevation to intersect the move. - if ( !minMaxOverlap(prevElev, currElev, t.bottomZ, t.topZ, true) ) return false; - - // Token constrained border, shrunk to avoid false positives from adjacent grid squares. - const border = t.constrainedTokenBorder ?? t.bounds; - border.pad(-2); - return border.overlaps(shape); - }); -} - /** * Count number of grid spaces needed for an elevation change. - * @param {number} e Elevation in pixel units + * @param {number} elevZ Elevation in pixel units * @returns {number} Number of grid steps */ -function numElevationGridSteps(e) { - const gridE = CONFIG.GeometryLib.utils.pixelsToGridUnits(e || 0); +function numElevationGridSteps(elevZ) { + const gridE = CONFIG.GeometryLib.utils.pixelsToGridUnits(elevZ || 0); return Math.ceil(gridE / canvas.dimensions.distance); } -/** - * Modify the change count by elevation moves. - * Assume diagonal can move one elevation. - * If no diagonal available, convert horizontal/vertical to diagonal. - * If no moves available, add horizontal (don't later convert to diagonal). - * @param {number} elevSteps - * @param {Uint32Array[4]} changeCount - * @returns {Uint32Array[4]} The same changeCount array, for convenience. - */ -function elevationChangeCount(elevSteps, changeCount) { - let availableDiags = changeCount[CHANGE.D]; - let addedH = 0; - while ( elevSteps > 0 ) { // Just in case we screw this up and send elevSteps negative. - if ( availableDiags ) availableDiags -= 1; - else if ( changeCount[CHANGE.H] ) { - changeCount[CHANGE.H] -= 1; - changeCount[CHANGE.D] += 1; - } else if ( changeCount[CHANGE.V] ) { - changeCount[CHANGE.V] -= 1; - changeCount[CHANGE.D] += 1; - } else addedH += 1; // Add an additional move "down." - elevSteps -= 1; - } - changeCount[CHANGE.H] += addedH; - return changeCount; -} - /** * Determine whether this grid space applies a move penalty because one or more tokens occupy it. * @param {number[2]} currGridCoords @@ -497,6 +458,7 @@ function griddedTokenMovePenalty(currGridCoords, prevGridCoords, currElev = 0, p let prevCenter; switch ( alg ) { case GT.CHOICES.CENTER: { + const gridShape = gridShapeFromGridCoords(currGridCoords); currCenter = gridCenterFromGridCoords(currGridCoords); collisionTest = o => o.t.constrainedTokenBorder.contains(currCenter.x, currCenter.y); bounds = gridShape.getBounds(); @@ -542,7 +504,7 @@ function griddedTokenMovePenalty(currGridCoords, prevGridCoords, currElev = 0, p * @param {(PIXI.Polygon|PIXI.Rectangle)[]} shapes * @returns {number} */ -percentageShapeIntersection(a, b, shapes = []) { +function percentageShapeIntersection(a, b, shapes = []) { const tValues = []; const deltaMag = b.to2d().subtract(a).magnitude(); @@ -600,7 +562,7 @@ percentageShapeIntersection(a, b, shapes = []) { if ( !distInside ) return 1; const totalDistance = Point3d.distanceBetween(a, b); - return distanceInside / totalDistance; + return distInside / totalDistance; } /** @@ -642,7 +604,8 @@ function griddedTerrainMovePenalty(token, currGridCoords, prevGridCoords, currEl case GT.CHOICES.PERCENT: { const gridShape = gridShapeFromGridCoords(currGridCoords); const percentThreshold = Settings.get(GT.AREA_THRESHOLD); - return Terrain.percentMovementChangeForTokenWithinShape(token, gridShape, percentThreshold, speedAttribute, currElev); + return Terrain.percentMovementChangeForTokenWithinShape(token, gridShape, + percentThreshold, speedAttribute, currElev); } case GT.CHOICES.EUCLIDEAN: { @@ -655,145 +618,6 @@ function griddedTerrainMovePenalty(token, currGridCoords, prevGridCoords, currEl } } -/** - * Return a function that tracks the grid steps from a previous square/hex to a new square/hex. - * The function returns the distance and move distance for a given move. - * @param {number[2]} prev Column, row of the starting grid square - * @param {PIXI.Point|Point3d} a Starting point for the segment - * @param {PIXI.Point|Point3d} b Ending point for the segment - * @param {Token} [token] Token that is moving. - * @returns {function} - * - @param {number[2]} next column, row of the next square - */ -function distanceForGridStepFunction(prev, a, b, token ) { - const zUnitDistance = CONFIG.GeometryLib.utils.gridUnitsToPixels(canvas.scene.dimensions.distance); - const tokenMult = Settings.get(Settings.KEYS.TOKEN_RULER.TOKEN_MULTIPLIER) || 1; - const distance = canvas.dimensions.distance; - - // Rule for measuring diagonal distance. - const diagonalRule = DIAGONAL_RULES[canvas.grid.diagonalRule] ?? DIAGONAL_RULES["555"]; - let diagonalDist = distance; - if ( diagonalRule === DIAGONAL_RULES.EUCL ) diagonalDist = Math.hypot(distance, distance); - let nDiag = 0; - - // Track elevation changes. - let elevSteps = numElevationGridSteps(Math.abs(b.z - a.z)); - const elevDir = Math.sign(b.z - a.z); - let currElev = a.z || 0; - let prevElev = a.z || 0; - - // Find tokens along the ray whose constrained borders intersect the ray. - const bounds = segmentBounds(a, b); - const collisionTest = o => o.t.constrainedTokenBorder.lineSegmentIntersects(a, b, { inside: true }); - const tokens = canvas.tokens.quadtree.getObjects(bounds, { collisionTest }); - tokens.delete(token); - - // Track if token overlaps this space - const gridShape = gridShapeFromGridCoordinates(prev); - let tokenOverlapsPrev = (tokenMult === 1 || !tokens.size) ? false - : doTokensOverlapGridShape(tokens, gridShape, prevElev, currElev); - - // Find the center of this grid shape. - const prevCenter = Point3d.fromObject(gridCenterFromGridCoordinates(prev)); - prevCenter.z = a.z; - - // Function to track movement changes - const gridStepFn = countGridStep(prev, elevSteps); - - // Return a function that calculates distance between previous and next grid spaces. - return next => { - // Track movement changes from previous grid square/hex to next. - const changeCount = gridStepFn(next); - - // Track current elevation. Ensure it is bounded between a.z and b.z. - currElev += (zUnitDistance * changeCount[CHANGE.E] * elevDir); - currElev = elevDir > 0 ? Math.min(b.z, currElev) : Math.max(b.z, currElev); - - // Do one or more token constrained borders overlap this grid space? - const gridShape = gridShapeFromGridCoordinates(next); - const tokenOverlaps = (tokenMult === 1 || !tokens.size) ? false - : doTokensOverlapGridShape(tokens, gridShape, prevElev, currElev); - - // Locate the center of this grid shape. - const currCenter = Point3d.fromObject(gridCenterFromGridCoordinates(next)); - currCenter.z = currElev; - - // Calculate the terrain penalty as an average of the previous grid and current grid shape. - const terrainPenalty = terrainPenaltyForGridStep(gridShape, prevCenter, currCenter, token); - - // Moves this iteration. - let d = (changeCount[CHANGE.V] * distance) - + (changeCount[CHANGE.H] * distance) - + (changeCount[CHANGE.D] * diagonalDist); - - // If diagonal is 5-10-5, every even move gets an extra 5. - nDiag += changeCount[CHANGE.D]; - if ( diagonalRule === DIAGONAL_RULES["5105"] ) { - const nEven = ~~(nDiag * 0.5); - d += (nEven * distance); - } - - // Average - const tokenPenalty = ((tokenOverlaps ? tokenMult : 1) + (tokenOverlapsPrev ? tokenMult : 1)) * 0.5; - log(`griddedMoveDistance|${prevCenter.x},${prevCenter.y},${prevCenter.z} -> ${currCenter.x},${currCenter.y},${currCenter.z}\n\ttokenPenalty: ${tokenPenalty}\n\tterrainPenalty: ${terrainPenalty}`); - if ( !isFinite(currCenter.z) || !isFinite(prevCenter.z) ) { - log("Non-finite z value in distanceForGridStepFunction"); - } - - - // Cycle to next. - tokenOverlapsPrev = tokenOverlaps; - prevCenter.copyFrom(currCenter); - prev = next; - prevElev = currElev; - elevSteps = Math.max(elevSteps - 1, 0); - - return { distance: d, movePenalty: terrainPenalty * tokenPenalty, elevSteps, currElev }; - }; -} - -/** - * Helper to count the moves for a given step. - * @param {number} elevSteps Number of steps of elevation - * @returns {function} Function that will count a step change. - * Function will take: - * @param {Array[2]} prev column, grid of the previous square - * @param {Array[2]} next column, grid of the next square - * @returns {A} - */ -function countGridStep(prev, elevSteps = 0) { - const changeCount = new Uint32Array([0, 0, 0, 0, 0]); - return next => { - changeCount.fill(0); - // Count the move direction. - if ( next ) { - const xChange = prev[1] !== next[1]; // Column is x - const yChange = prev[0] !== next[0]; // Row is y - changeCount[((xChange * 2) + yChange)] += 1; - } - - // Account for an elevation change of maximum 1 grid space. See elevationChangeCount. - if ( elevSteps > 0 ) { - if ( changeCount[CHANGE.D] ) { - // Do nothing. - } else if ( changeCount[CHANGE.H] ) { - changeCount[CHANGE.H] -= 1; - changeCount[CHANGE.D] += 1; - } else if ( changeCount[CHANGE.V] ) { - changeCount[CHANGE.V] -= 1; - changeCount[CHANGE.D] += 1; - } else { - changeCount[CHANGE.H] += 1; // Add an additional move "down." - } - elevSteps -= 1; - changeCount[CHANGE.E] += 1; - } - prev = next; - return changeCount; - }; -} - - // ----- NOTE: Helper methods ----- // /** @@ -811,24 +635,6 @@ function terrainMovePenalty(a, b, token) { return terrainAPI.Terrain.percentMovementForTokenAlongPath(token, a, b) || 1; } -/** - * Does one number range overlap another? - * @param {number} a0 - * @param {number} a1 - * @param {number} b0 - * @param {number} b1 - * @param {boolean} [inclusive=true] - * @returns {boolean} - */ -function minMaxOverlap(a0, a1, b0, b1, inclusive = true) { - const aMinMax = Math.minMax(a0, a1); - const bMinMax = Math.minMax(b0, b1); - return aMinMax.min.between(bMinMax.min, bMinMax.max, inclusive) - || aMinMax.max.between(bMinMax.min, bMinMax.max, inclusive) - || bMinMax.min.between(aMinMax.min, aMinMax.max, inclusive) - || bMinMax.max.between(aMinMax.min, aMinMax.max, inclusive); -} - /** * From a given origin, move horizontally the total 2d distance between A and B. * Then move vertically up/down in elevation. @@ -998,8 +804,8 @@ function * iterateNonHexGridMoves(origin, destination) { movementChange.E += 1; break; } - case "D": movementChange.E += 1; // eslint-disable-line no-fallthrough - case "H": { + case "D": movementChange.E += 1; + case "H": { // eslint-disable-line no-fallthrough const next2d = iter2d.next().value ?? prev2d; const moveType = gridChangeType(prev2d, next2d); prev2d = next2d; From 63c8628961a10f3ea152e9ec4b07b094c6ccfdf4 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Tue, 13 Feb 2024 16:51:27 -0800 Subject: [PATCH 15/32] Pull out segmentGridHalfIntersection function --- scripts/Ruler.js | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/scripts/Ruler.js b/scripts/Ruler.js index 0e4e891..39784b6 100644 --- a/scripts/Ruler.js +++ b/scripts/Ruler.js @@ -421,14 +421,10 @@ function splitSegment(segment, splitMoveDistance, token, gridless) { else { // We can get the end grid. // Use halfway between the intersection points for this grid shape. - const gridShape = gridShapeFromGridCoordinates(res.endGridCoords); - const ixs = gridShape.segmentIntersections(A, B); - if ( !ixs || ixs.length === 0 ) breakPoint = A; - else if ( ixs.length === 1 ) breakPoint = B; - else { - breakPoint = Point3d.midPoint(ixs[0], ixs[1]); - breakPoint.z = res.endElevationZ; - } + breakPoint = Point3d.fromObject(segmentGridHalfIntersection(res.endGridCoords, A, B)); + if ( !breakPoint ) breakPoint = A; + if ( breakPoint === A ) breakPoint.z = A.z; + else breakPoint.z = res.endElevationZ; } if ( breakPoint.almostEqual(B) ) return [segment]; @@ -450,6 +446,24 @@ function splitSegment(segment, splitMoveDistance, token, gridless) { return [s0, s1]; } +/** + * For a given segment, locate its intersection at a grid shape. + * The intersection point is on the segment, halfway between the two intersections for the shape. + * @param {number[]} gridCoords + * @param {PIXI.Point} a + * @param {PIXI.Point} b + * @returns {PIXI.Point|undefined} Undefined if no intersection. If only one intersection, the + * endpoint contained within the shape. + */ +function segmentGridHalfIntersection(gridCoords, a, b) { + const gridShape = gridShapeFromGridCoordinates(gridCoords); + const ixs = gridShape.segmentIntersections(a, b); + if ( !ixs || ixs.length === 0 ) return null; + if ( ixs.length === 1 ) return gridShape.contains(a.x, a.y) ? a : b; + return PIXI.Point.midPoint(ixs[0], ixs[1]); +} + + // ----- NOTE: Event handling ----- // /** From 1d61873ca4fc36dd7eaa3d7eafa0f376e6c3066c Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Tue, 13 Feb 2024 17:29:46 -0800 Subject: [PATCH 16/32] Display elevation change from last user waypoint --- scripts/Ruler.js | 24 ++++++++++++++++++++++++ scripts/segments.js | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/scripts/Ruler.js b/scripts/Ruler.js index 39784b6..512afe7 100644 --- a/scripts/Ruler.js +++ b/scripts/Ruler.js @@ -4,6 +4,7 @@ CONST, duplicate, game, getProperty, +PIXI, Ruler, ui */ @@ -254,6 +255,18 @@ function _computeDistance(gridSpaces) { _computeSegmentDistances.call(this, gridSpaces); if ( Settings.get(Settings.KEYS.TOKEN_RULER.SPEED_HIGHLIGHTING) ) _computeTokenSpeed.call(this, gridSpaces); + switch ( this.segments.length ) { + case 1: break; + case 2: break; + case 3: break; + case 4: break; + case 5: break; + case 6: break; + case 7: break; + case 8: break; + case 9: break; + } + // Debugging if ( this.segments.some(s => !s) ) console.error("Segment is undefined."); @@ -261,15 +274,18 @@ function _computeDistance(gridSpaces) { const waypointKeys = new Set(this.waypoints.map(w => w.key)); let waypointDistance = 0; let waypointMoveDistance = 0; + let waypointStartingElevation = 0; for ( const segment of this.segments ) { if ( waypointKeys.has(segment.ray.A.to2d().key) ) { waypointDistance = 0; waypointMoveDistance = 0; + waypointStartingElevation = segment.ray.A.z; } waypointDistance += segment.distance; waypointMoveDistance += segment.moveDistance; segment.waypointDistance = waypointDistance; segment.waypointMoveDistance = waypointMoveDistance; + segment.waypointElevationIncrement = segment.ray.B.z - waypointStartingElevation; } } @@ -300,6 +316,14 @@ function _computeSegmentDistances(gridSpaces) { totalMoveDistance += segment.moveDistance; } + if ( totalMoveDistance > 30 ) { + log({ totalMoveDistance }); + } + + if ( totalMoveDistance > 60 ) { + log({ totalMoveDistance }); + } + this.totalDistance = totalDistance; this.totalMoveDistance = totalMoveDistance; } diff --git a/scripts/segments.js b/scripts/segments.js index e7cac47..d8d3ccb 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -384,7 +384,7 @@ function levelNameAtElevation(e) { */ function segmentElevationLabel(s) { const units = canvas.scene.grid.units; - const increment = s.ray.dz; + const increment = s.waypointElevationIncrement; const Bz = s.ray.B.z; const segmentArrow = (increment > 0) ? "↑" From 6b5c3ee1bf16954b62cf617e7823a50abd21e0df Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Wed, 14 Feb 2024 14:09:38 -0800 Subject: [PATCH 17/32] Reset distance for each `segment.ray` to 2d for highlighting --- scripts/segments.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/scripts/segments.js b/scripts/segments.js index d8d3ccb..868339d 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -281,16 +281,24 @@ export function hasSegmentCollision(token, segments) { * Wrap Ruler.prototype._highlightMeasurementSegment */ export function _highlightMeasurementSegment(wrapped, segment) { - if ( !(this.user === game.user - && Settings.get(Settings.KEYS.TOKEN_RULER.SPEED_HIGHLIGHTING)) ) return wrapped(segment); + // Temporarily ensure the ray distance is two-dimensional, so highlighting selects correct squares. + // Otherwise the highlighting algorithm can get confused for high-elevation segments. + segment.ray._distance = PIXI.Point.distanceBetween(segment.ray.A, segment.ray.B); + // Adjust the color if this user has selected speed highlighting. + const priorColor = this.color; const token = this._getMovementToken(); - if ( !token ) return wrapped(segment); + const doSpeedHighlighting = token + && this.user === game.user + && Settings.get(Settings.KEYS.TOKEN_RULER.SPEED_HIGHLIGHTING); // Highlight each split in turn, changing highlight color each time. - const priorColor = this.color; - this.color = SPEED.COLORS[segment.speed]; - wrapped(segment); + if ( doSpeedHighlighting ) this.color = SPEED.COLORS[segment.speed]; + + // Call Foundry version and return if not speed highlighting. + const res = wrapped(segment); + segment.ray._distance = undefined; // Reset the distance measurement. + if ( !doSpeedHighlighting ) return res; // If gridless, highlight a rectangular shaped portion of the line. if ( canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ) { From 36bdebc5e18aca9806908249a60dcfb8fab0971b Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Wed, 14 Feb 2024 14:33:07 -0800 Subject: [PATCH 18/32] Tweak grid move counting to handle multiple elevation moves Assume each diagonal can also move 1 elevation. This simplifies things somewhat but allows a bit extra move when going diagonally in elevation. Diagonal 2d: Math.hypot(100, 100) = ~141 Diagonal 3d: Math.hypot(141, 100) = ~173 --- scripts/measure_distance.js | 41 ++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/scripts/measure_distance.js b/scripts/measure_distance.js index 03d5e29..c9738e8 100644 --- a/scripts/measure_distance.js +++ b/scripts/measure_distance.js @@ -377,12 +377,19 @@ function griddedMoveDistance(a, b, token, { useAllElevation = true, stopTarget } /** * Helper to adjust a single grid move by elevation change. * If moving horizontal or vertical with elevation, move diagonally instead. + * If moving diagonally, include elevation move in that diagonal move. * Sum the remaining elevation and add to diagonal. * @param {object} gridMove * @returns {gridMove} For convenience. Modified in place. */ function adjustGridMoveForElevation(gridMove) { let totalE = gridMove.E; + let totalD = gridMove.D; + while ( totalE > 0 && totalD > 0 ) { + totalE -= 1; + totalD -= 1; + } + while ( totalE > 0 && gridMove.H > 0 ) { totalE -= 1; gridMove.H -= 1; @@ -393,7 +400,9 @@ function adjustGridMoveForElevation(gridMove) { gridMove.V -= 1; gridMove.D += 1; } - gridMove.D += totalE; + + // All other elevation moves, if any, can be vertical. + gridMove.V += totalE; return gridMove; } @@ -405,9 +414,9 @@ function adjustGridMoveForElevation(gridMove) { * @param {PIXI.Point|Point3d} b Ending point for the segment * @returns {Uint32Array[4]|0} Counts of changes: none, vertical, horizontal, diagonal. */ -function sumGridMoves(a, b) { +export function sumGridMoves(a, b) { const iter = iterateGridMoves(a, b); - const totalChangeCount = { H: 0, V: 0, D: 0, E: 0 }; + const totalChangeCount = { NONE: 0, H: 0, V: 0, D: 0, E: 0 }; for ( const move of iter ) { const movementChange = move.movementChange; adjustGridMoveForElevation(movementChange); @@ -445,7 +454,7 @@ function numElevationGridSteps(elevZ) { * @param {number} prevElev Elevation at current grid point, in pixel units * @returns {number} Percent move penalty to apply. Returns 1 if no penalty. */ -function griddedTokenMovePenalty(currGridCoords, prevGridCoords, currElev = 0, prevElev = 0) { +function griddedTokenMovePenalty(token, currGridCoords, prevGridCoords, currElev = 0, prevElev = 0) { const mult = Settings.get(Settings.KEYS.TOKEN_RULER.TOKEN_MULTIPLIER); if ( mult === 1 ) return 1; @@ -486,6 +495,7 @@ function griddedTokenMovePenalty(currGridCoords, prevGridCoords, currElev = 0, p // Check that elevation is within the token height. const tokens = canvas.tokens.quadtree.getObjects(bounds, { collisionTest }) .filter(t => currElev.between(t.bottomZ, t.topZ)); + tokens.delete(token); if ( alg !== GT.CHOICES.EUCLIDEAN ) return tokens.size ? mult : 1; // For Euclidean, determine the percentage intersect. @@ -630,6 +640,7 @@ function griddedTerrainMovePenalty(token, currGridCoords, prevGridCoords, currEl * @returns {number} Percent penalty */ function terrainMovePenalty(a, b, token) { + if ( !token ) return 1; const terrainAPI = MODULES_ACTIVE.API.TERRAIN_MAPPER; if ( !terrainAPI || !token ) return 1; return terrainAPI.Terrain.percentMovementForTokenAlongPath(token, a, b) || 1; @@ -644,7 +655,7 @@ function terrainMovePenalty(a, b, token) { * @return Iterator, which in turn * returns [row, col] Array for each grid point under the line. */ -function iterateGridProjectedElevation(origin, destination) { +export function iterateGridProjectedElevation(origin, destination) { const dist2d = PIXI.Point.distanceBetween(origin, destination); let elev = (destination.z ?? 0) - (origin.z ?? 0); @@ -688,7 +699,7 @@ function gridChangeType(prevGridCoord, nextGridCoord) { * - @prop {number[2]} gridCoords * - @prop {object} movementChange */ -function iterateGridMoves(origin, destination) { +export function iterateGridMoves(origin, destination) { if ( canvas.grid.type === CONST.GRID_TYPES.SQUARE || canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ) return iterateNonHexGridMoves(origin, destination); return iterateHexGridMoves(origin, destination); @@ -710,7 +721,7 @@ function * iterateHexGridMoves(origin, destination) { // First coordinate is always the origin grid. let prev2d = iter2d.next().value; let prevElevation = iterElevation.next().value; - let movementChange = { H: 0, V: 0, D: 0, E: 0 }; + let movementChange = { NONE: 0, H: 0, V: 0, D: 0, E: 0 }; yield { movementChange, @@ -727,7 +738,7 @@ function * iterateHexGridMoves(origin, destination) { const elevSign = Math.sign(destination.z - origin.z); const elevTest = elevSign ? (a, b) => a > b : (a, b) => a < b; let currElev = prevElevation[elevOnlyIndex]; - movementChange = { H: 0, V: 0, D: 0, E: 0 }; // Copy; don't keep same object. + movementChange = { NONE: 0, H: 0, V: 0, D: 0, E: 0 }; // Copy; don't keep same object. // Use the elevation iteration to tell us when to move to the next 2d step. // Horizontal or diagonal elevation moves indicate next step. @@ -754,7 +765,7 @@ function * iterateHexGridMoves(origin, destination) { movementChange, gridCoords: next2d }; - movementChange = { H: 0, V: 0, D: 0, E: 0 }; + movementChange = { NONE: 0, H: 0, V: 0, D: 0, E: 0 }; } } prevElevation = nextElevation; @@ -785,14 +796,14 @@ function * iterateNonHexGridMoves(origin, destination) { // First coordinate is always the origin grid. let prev2d = iter2d.next().value; let prevElevation = iterElevation.next().value; - let movementChange = { H: 0, V: 0, D: 0, E: 0 }; + let movementChange = { NONE: 0, H: 0, V: 0, D: 0, E: 0 }; yield { movementChange, gridCoords: prev2d }; - movementChange = { H: 0, V: 0, D: 0, E: 0 }; // Copy; don't keep same object. + movementChange = { NONE: 0, H: 0, V: 0, D: 0, E: 0 }; // Copy; don't keep same object. // Use the elevation iteration to tell us when to move to the next 2d step. // Horizontal or diagonal elevation moves indicate next step. @@ -814,7 +825,7 @@ function * iterateNonHexGridMoves(origin, destination) { movementChange, gridCoords: next2d }; - movementChange = { H: 0, V: 0, D: 0, E: 0 }; + movementChange = { NONE: 0, H: 0, V: 0, D: 0, E: 0 }; } } prevElevation = nextElevation; @@ -829,6 +840,12 @@ function * iterateNonHexGridMoves(origin, destination) { } /* Testing +api = game.modules.get("elevationruler").api +iterateGridUnderLine = api.iterateGridUnderLine +iterateGridProjectedElevation = api.iterateGridProjectedElevation +iterateGridMoves = api.iterateGridMoves +sumGridMoves = api.sumGridMoves + Draw = CONFIG.GeometryLib.Draw From bf135d897fe41f21fc9de875cdade25321a17c78 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Thu, 15 Feb 2024 09:07:09 -0800 Subject: [PATCH 19/32] Track if there was a move penalty for distance measurement --- Changelog.md | 7 +++++++ scripts/Ruler.js | 5 ++--- scripts/measure_distance.js | 13 ++++++++++--- scripts/module.js | 8 +++++++- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/Changelog.md b/Changelog.md index 3c58f95..39ca0ad 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,12 @@ # 0.8.5 + +## New Features +Added settings for selecting how terrain and other tokens are measured for grid squares. GM can choose to count difficult terrain if it covers the grid center, covers a fixed percentage of a grid square/hex, or by the percent for which it overlaps a line between the previous grid shape center to the current grid shape center ("euclidean"). + +## Bug fixes Fix for undefined `constrainedTokenBounds.contains`. +Fix for highlighting incorrect squares with high elevation changes. +Refactor (again!) measurement of distances and move distances. Addresses issues with movement measurement calculating incorrectly when speed highlighting adds temporary waypoints. Update lib geometry to v0.2.16. # 0.8.4 diff --git a/scripts/Ruler.js b/scripts/Ruler.js index 512afe7..4e717bb 100644 --- a/scripts/Ruler.js +++ b/scripts/Ruler.js @@ -316,7 +316,7 @@ function _computeSegmentDistances(gridSpaces) { totalMoveDistance += segment.moveDistance; } - if ( totalMoveDistance > 30 ) { + if ( totalMoveDistance > 40 ) { log({ totalMoveDistance }); } @@ -445,8 +445,7 @@ function splitSegment(segment, splitMoveDistance, token, gridless) { else { // We can get the end grid. // Use halfway between the intersection points for this grid shape. - breakPoint = Point3d.fromObject(segmentGridHalfIntersection(res.endGridCoords, A, B)); - if ( !breakPoint ) breakPoint = A; + breakPoint = Point3d.fromObject(segmentGridHalfIntersection(res.endGridCoords, A, B) ?? A); if ( breakPoint === A ) breakPoint.z = A.z; else breakPoint.z = res.endElevationZ; } diff --git a/scripts/measure_distance.js b/scripts/measure_distance.js index c9738e8..a7d3acd 100644 --- a/scripts/measure_distance.js +++ b/scripts/measure_distance.js @@ -310,12 +310,13 @@ function griddedMoveDistance(a, b, token, { useAllElevation = true, stopTarget } // Step over each grid shape in turn. const diagAdder = diagonalDistanceAdder(); - const distance = canvas.dimensions.distance; - const size = canvas.scene.dimensions.size; + const { distance, size } = canvas.scene.dimensions; let dTotal = 0; let dMoveTotal = 0; let prevElev = a.z; let elevSteps = 0; + let tokenMovePenalty = false; + let terrainMovePenalty = false; for ( const { movementChange, gridCoords } of iter ) { adjustGridMoveForElevation(movementChange); const currElev = prevElev + (movementChange.E * size); // In pixel units. @@ -325,6 +326,8 @@ function griddedMoveDistance(a, b, token, { useAllElevation = true, stopTarget } const terrainMovePenalty = griddedTerrainMovePenalty(token, gridCoords, prevGridCoords, currElev, prevElev); if ( !tokenMovePenalty || tokenMovePenalty < 0 ) console.warn("griddedMoveDistance", { tokenMovePenalty }); if ( !terrainMovePenalty || terrainMovePenalty < 0 ) console.warn("griddedMoveDistance", { terrainMovePenalty }); + tokenMovePenalty ||= (tokenMovePenalty !== 1); + terrainMovePenalty ||= (terrainMovePenalty !== 1); // Calculate the distance for this step. let d = (movementChange.V + movementChange.H) * distance; @@ -353,6 +356,8 @@ function griddedMoveDistance(a, b, token, { useAllElevation = true, stopTarget } // Determine move penalty. const tokenMovePenalty = griddedTokenMovePenalty(token, prevGridCoords, prevGridCoords, b.z, prevElev); const terrainMovePenalty = griddedTerrainMovePenalty(token, prevGridCoords, prevGridCoords, b.z, prevElev); + tokenMovePenalty ||= (tokenMovePenalty !== 1); + terrainMovePenalty ||= (terrainMovePenalty !== 1); const d = elevStepsNeeded * distance; dTotal += d; dMoveTotal += (d * (tokenMovePenalty * terrainMovePenalty)); @@ -370,7 +375,9 @@ function griddedMoveDistance(a, b, token, { useAllElevation = true, stopTarget } moveDistance: dMoveTotal, remainingElevationSteps: elevStepsNeeded, endElevationZ: prevElev, - endGridCoords: prevGridCoords + endGridCoords: prevGridCoords, + tokenMovePenalty, + terrainMovePenalty }; } diff --git a/scripts/module.js b/scripts/module.js index 51bf024..4967f31 100644 --- a/scripts/module.js +++ b/scripts/module.js @@ -11,7 +11,7 @@ ui import { Settings } from "./settings.js"; import { initializePatching, PATCHER } from "./patching.js"; import { MODULE_ID, MOVEMENT_TYPES, SPEED, MOVEMENT_BUTTONS } from "./const.js"; -import { iterateGridUnderLine } from "./util.js"; +import { iterateGridUnderLine, gridShapeFromGridCoords } from "./util.js"; import { registerGeometry } from "./geometry/registration.js"; // Pathfinding @@ -25,6 +25,8 @@ import { benchPathfinding } from "./pathfinding/benchmark.js"; // Wall updates for pathfinding import { SCENE_GRAPH, WallTracer, WallTracerEdge, WallTracerVertex } from "./pathfinding/WallTracer.js"; +import { iterateGridProjectedElevation, iterateGridMoves, sumGridMoves } from "./measure_distance.js"; + Hooks.once("init", function() { // Cannot access localization until init. PREFER_TOKEN_CONTROL.title = game.i18n.localize(PREFER_TOKEN_CONTROL.title); @@ -44,6 +46,10 @@ Hooks.once("init", function() { game.modules.get(MODULE_ID).api = { iterateGridUnderLine, + iterateGridProjectedElevation, + iterateGridMoves, + sumGridMoves, + gridShapeFromGridCoords, PATCHER, MOVEMENT_TYPES, From f1ba7756000599fb021b3619964d5d05ec391b68 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Thu, 15 Feb 2024 11:02:12 -0800 Subject: [PATCH 20/32] Fix for token movement losing track On token update, need to catch if the options parameter is undefined. --- scripts/Ruler.js | 4 ++-- scripts/Token.js | 2 +- scripts/segments.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/Ruler.js b/scripts/Ruler.js index 4e717bb..485f2a6 100644 --- a/scripts/Ruler.js +++ b/scripts/Ruler.js @@ -580,9 +580,9 @@ PATCHES.BASIC.WRAPS = { _canMove }; -PATCHES.BASIC.MIXES = { _animateMovement, _animateSegment, _getMovementToken, _getMeasurementSegments }; +PATCHES.BASIC.MIXES = { _animateMovement, _getMovementToken, _getMeasurementSegments }; -PATCHES.BASIC.OVERRIDES = { _computeDistance }; +PATCHES.BASIC.OVERRIDES = { _computeDistance, _animateSegment }; PATCHES.SPEED_HIGHLIGHTING.WRAPS = { _highlightMeasurementSegment }; diff --git a/scripts/Token.js b/scripts/Token.js index fc77fa1..1282c28 100644 --- a/scripts/Token.js +++ b/scripts/Token.js @@ -107,7 +107,7 @@ function updateToken(document, changes, _options, _userId) { * Wrap Token.prototype._onUpdate to remove easing for pathfinding segments. */ function _onUpdate(wrapped, data, options, userId) { - if ( options.rulerSegment && options.animation.easing ) { + if ( options?.rulerSegment && options?.animation?.easing ) { options.animation.easing = options.firstRulerSegment ? noEndEase(options.animation.easing) : options.lastRulerSegment ? noStartEase(options.animation.easing) : undefined; diff --git a/scripts/segments.js b/scripts/segments.js index 868339d..8c4c42c 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -230,7 +230,7 @@ export function _getDistanceLabels(segmentDistance, moveDistance, totalDistance) * for the given segment. * Mark the token update if pathfinding for this segment. */ -export async function _animateSegment(wrapped, token, segment, destination) { +export async function _animateSegment(token, segment, destination) { // If the token is already at the destination, _animateSegment will throw an error when the animation is undefined. // This can happen when setting artificial segments for highlighting or pathfinding. if ( token.document.x !== destination.x From 409c31ff59931e3e1c2759b4520808462c09ac7c Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Thu, 15 Feb 2024 11:04:09 -0800 Subject: [PATCH 21/32] Update changelog --- Changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Changelog.md b/Changelog.md index 39ca0ad..89c1f16 100644 --- a/Changelog.md +++ b/Changelog.md @@ -4,6 +4,7 @@ Added settings for selecting how terrain and other tokens are measured for grid squares. GM can choose to count difficult terrain if it covers the grid center, covers a fixed percentage of a grid square/hex, or by the percent for which it overlaps a line between the previous grid shape center to the current grid shape center ("euclidean"). ## Bug fixes +Fix for the token apparent position disconnecting from actual token position when dragging or moving token with the ruler. Fix for undefined `constrainedTokenBounds.contains`. Fix for highlighting incorrect squares with high elevation changes. Refactor (again!) measurement of distances and move distances. Addresses issues with movement measurement calculating incorrectly when speed highlighting adds temporary waypoints. From ec5fef93de71214978dd4af3d1ec8900f7292337 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Thu, 15 Feb 2024 11:35:28 -0800 Subject: [PATCH 22/32] Update changelog --- Changelog.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Changelog.md b/Changelog.md index 89c1f16..5bba6e9 100644 --- a/Changelog.md +++ b/Changelog.md @@ -3,9 +3,11 @@ ## New Features Added settings for selecting how terrain and other tokens are measured for grid squares. GM can choose to count difficult terrain if it covers the grid center, covers a fixed percentage of a grid square/hex, or by the percent for which it overlaps a line between the previous grid shape center to the current grid shape center ("euclidean"). +Added selection in Drawings to treat a drawing as imposing a move bonus/penalty. May be changed to accommodate Foundry v12 scene regions in the future. + ## Bug fixes Fix for the token apparent position disconnecting from actual token position when dragging or moving token with the ruler. -Fix for undefined `constrainedTokenBounds.contains`. +Fix for undefined `constrainedTokenBounds.contains`. Closes issue #46. Fix for highlighting incorrect squares with high elevation changes. Refactor (again!) measurement of distances and move distances. Addresses issues with movement measurement calculating incorrectly when speed highlighting adds temporary waypoints. Update lib geometry to v0.2.16. From a929b5c1f2c21b59f84bcecd9bc5875b98a504b0 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Thu, 15 Feb 2024 14:16:28 -0800 Subject: [PATCH 23/32] Add range picker to Drawing config for move penalty --- languages/en.json | 6 +++++- scripts/DrawingConfig.js | 28 ++++++++++++++++++++++++++++ scripts/const.js | 7 ++++++- scripts/patching.js | 2 ++ scripts/util.js | 11 +++++++++++ templates/drawing-config.html | 13 +++++++++++++ 6 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 scripts/DrawingConfig.js create mode 100644 templates/drawing-config.html diff --git a/languages/en.json b/languages/en.json index ce17145..04b67b4 100644 --- a/languages/en.json +++ b/languages/en.json @@ -1,4 +1,5 @@ { + "elevationruler.name": "Elevation Ruler", "elevationruler.keybindings.decrementElevation.name": "Decrement Ruler Elevation", "elevationruler.keybindings.decrementElevation.hint": "Decrease the elevation in grid increments when using the ruler.", @@ -60,5 +61,8 @@ "elevationruler.settings.grid-terrain-area-threshold.hint": "If 'Percent Area' is selected for Terrain Grid Measurement, this determines the percent overlap required for the terrain/token to count when measuring on grids.", "elevationruler.controls.prefer-token-elevation.name": "Prefer Token Elevation", - "elevationruler.controls.pathfinding-control.name": "Use Pathfinding" + "elevationruler.controls.pathfinding-control.name": "Use Pathfinding", + + "elevationruler.drawingconfig.movementPenalty.name": "Movement Bonus/Penalty", + "elevationruler.drawingconfig.movementPenalty.hint": "Set to 1 for no penalty. Values greater than one penalize movement by that percent; values less than one effectively grant a bonus to movement. For example, set to 2 to double movement through this area." } \ No newline at end of file diff --git a/scripts/DrawingConfig.js b/scripts/DrawingConfig.js new file mode 100644 index 0000000..c83a7e9 --- /dev/null +++ b/scripts/DrawingConfig.js @@ -0,0 +1,28 @@ +/* globals +*/ +/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ + +// Patches for Drawing configuration rendering. + +import { MODULE_ID, TEMPLATES, FLAGS } from "./const.js"; +import { injectConfiguration } from "./util.js"; + +export const PATCHES = {}; +PATCHES.BASIC = {}; + +async function renderDrawingConfigHook(app, html, data) { + const template = TEMPLATES.DRAWING_CONFIG; + const findString = "div[data-tab='text']:last"; + addDrawingConfigData(app, data); + await injectConfiguration(app, html, data, template, findString); +} + +function addDrawingConfigData(app, data) { + data.object.flags ??= {}; + data.object.flags[MODULE_ID] ??= {}; + data.object.flags[MODULE_ID][FLAGS.MOVEMENT_PENALTY] ??= 1; +} + +PATCHES.BASIC.HOOKS = { + renderDrawingConfig: renderDrawingConfigHook +}; diff --git a/scripts/const.js b/scripts/const.js index 627c3f6..ae99663 100644 --- a/scripts/const.js +++ b/scripts/const.js @@ -8,8 +8,13 @@ Hooks export const MODULE_ID = "elevationruler"; export const EPSILON = 1e-08; +export const TEMPLATES = { + DRAWING_CONFIG: `modules/${MODULE_ID}/templates/drawing-config.html` +}; + export const FLAGS = { - MOVEMENT_SELECTION: "selectedMovementType" + MOVEMENT_SELECTION: "selectedMovementType", + MOVEMENT_PENALTY: "movementPenalty" }; export const MODULES_ACTIVE = { API: {} }; diff --git a/scripts/patching.js b/scripts/patching.js index d000370..9e60e86 100644 --- a/scripts/patching.js +++ b/scripts/patching.js @@ -12,6 +12,7 @@ import { PATCHES as PATCHES_ClientKeybindings } from "./ClientKeybindings.js"; import { PATCHES as PATCHES_BaseGrid } from "./BaseGrid.js"; import { PATCHES as PATCHES_HexagonalGrid } from "./HexagonalGrid.js"; import { PATCHES as PATCHES_TokenPF } from "./pathfinding/Token.js"; +import { PATCHES as PATCHES_DrawingConfig } from "./DrawingConfig.js"; // Pathfinding import { PATCHES as PATCHES_Wall } from "./pathfinding/Wall.js"; @@ -28,6 +29,7 @@ const PATCHES = { BaseGrid: PATCHES_BaseGrid, ClientKeybindings: PATCHES_ClientKeybindings, ClientSettings: PATCHES_ClientSettings, + DrawingConfig: PATCHES_DrawingConfig, GridLayer: PATCHES_GridLayer, HexagonalGrid: PATCHES_HexagonalGrid, Ruler: PATCHES_Ruler, diff --git a/scripts/util.js b/scripts/util.js index aa81991..3872ec9 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -213,3 +213,14 @@ export function segmentBounds(a, b) { const yMinMax = Math.minMax(a.y, b.y); return new PIXI.Rectangle(xMinMax.min, yMinMax.min, xMinMax.max - xMinMax.min, yMinMax.max - yMinMax.min); } + + +/** + * Helper to inject configuration html into the application config. + */ +export async function injectConfiguration(app, html, data, template, findString) { + const myHTML = await renderTemplate(template, data); + const form = html.find(findString); + form.append(myHTML); + app.setPosition(app.position); +} \ No newline at end of file diff --git a/templates/drawing-config.html b/templates/drawing-config.html new file mode 100644 index 0000000..2eaef75 --- /dev/null +++ b/templates/drawing-config.html @@ -0,0 +1,13 @@ +
+ {{ localize "elevationruler.name" }} + +
+ +
+ {{rangePicker name="flags.elevationruler.movementPenalty" value=object.flags.elevationruler.movementPenalty step=0.1 min=0.1 max=10}} +
+

{{ localize "elevationruler.drawingconfig.movementPenalty.hint" }}

+
+ + +
\ No newline at end of file From 4dc62192f3abb43203a579bb3160b3ed58fd55e0 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Thu, 15 Feb 2024 14:26:51 -0800 Subject: [PATCH 24/32] Update lib geometry with Drawing elevation --- scripts/geometry | 2 +- scripts/module.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/geometry b/scripts/geometry index 15960f2..9b24851 160000 --- a/scripts/geometry +++ b/scripts/geometry @@ -1 +1 @@ -Subproject commit 15960f2166b00a15f1c6f3ae58e5dcfd5f02e84d +Subproject commit 9b24851e097ffe6812820d4f228397ea8cc6a9c7 diff --git a/scripts/module.js b/scripts/module.js index 4967f31..36c002c 100644 --- a/scripts/module.js +++ b/scripts/module.js @@ -13,6 +13,7 @@ import { initializePatching, PATCHER } from "./patching.js"; import { MODULE_ID, MOVEMENT_TYPES, SPEED, MOVEMENT_BUTTONS } from "./const.js"; import { iterateGridUnderLine, gridShapeFromGridCoords } from "./util.js"; import { registerGeometry } from "./geometry/registration.js"; +import { registerElevationConfig } from "./geometry/elevation_configs.js"; // Pathfinding import { BorderTriangle, BorderEdge } from "./pathfinding/BorderTriangle.js"; @@ -81,6 +82,7 @@ Hooks.once("setup", function() { Settings.registerKeybindings(); // Should go before registering settings, so hotkey group is defined Settings.registerAll(); initializePatching(); + registerElevationConfig("DrawingConfig", "Elevation Ruler"); }); // For https://github.com/League-of-Foundry-Developers/foundryvtt-devMode From 4ad65acbfb5c0f4c262612e6fdb8ec0118144960 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Thu, 15 Feb 2024 17:17:36 -0800 Subject: [PATCH 25/32] Update lib geometry --- scripts/geometry | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/geometry b/scripts/geometry index 9b24851..92e8dc2 160000 --- a/scripts/geometry +++ b/scripts/geometry @@ -1 +1 @@ -Subproject commit 9b24851e097ffe6812820d4f228397ea8cc6a9c7 +Subproject commit 92e8dc292fa4b83f669be81438ccebfe5859cbd6 From 947772397d69d4ac1a2c3f2413a5535874c06f36 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Thu, 15 Feb 2024 17:18:17 -0800 Subject: [PATCH 26/32] Use drawing shapes, not boundaries, to measure movement penalty --- scripts/measure_distance.js | 333 ++++++++++++++++++++++++++---------- scripts/settings.js | 1 + 2 files changed, 248 insertions(+), 86 deletions(-) diff --git a/scripts/measure_distance.js b/scripts/measure_distance.js index a7d3acd..7f556c1 100644 --- a/scripts/measure_distance.js +++ b/scripts/measure_distance.js @@ -2,11 +2,12 @@ canvas, CONFIG, CONST, +getProperty, PIXI */ "use strict"; -import { DIAGONAL_RULES, MODULES_ACTIVE, SPEED } from "./const.js"; +import { DIAGONAL_RULES, MODULES_ACTIVE, SPEED, MODULE_ID, FLAGS } from "./const.js"; import { iterateGridUnderLine, squareGridShape, @@ -16,6 +17,9 @@ import { gridCenterFromGridCoords } from "./util.js"; import { Settings } from "./settings.js"; import { Point3d } from "./geometry/3d/Point3d.js"; +import { CenteredRectangle } from "./geometry/CenteredPolygon/CenteredRectangle.js"; +import { CenteredPolygon } from "./geometry/CenteredPolygon/CenteredPolygon.js"; +import { Ellipse } from "./geometry/Ellipse.js"; // Specialized distance measurement methods that can handle grids. @@ -317,24 +321,25 @@ function griddedMoveDistance(a, b, token, { useAllElevation = true, stopTarget } let elevSteps = 0; let tokenMovePenalty = false; let terrainMovePenalty = false; + let drawingMovePenalty = false; for ( const { movementChange, gridCoords } of iter ) { adjustGridMoveForElevation(movementChange); const currElev = prevElev + (movementChange.E * size); // In pixel units. // Determine move penalty. - const tokenMovePenalty = griddedTokenMovePenalty(token, gridCoords, prevGridCoords, currElev, prevElev); - const terrainMovePenalty = griddedTerrainMovePenalty(token, gridCoords, prevGridCoords, currElev, prevElev); - if ( !tokenMovePenalty || tokenMovePenalty < 0 ) console.warn("griddedMoveDistance", { tokenMovePenalty }); - if ( !terrainMovePenalty || terrainMovePenalty < 0 ) console.warn("griddedMoveDistance", { terrainMovePenalty }); - tokenMovePenalty ||= (tokenMovePenalty !== 1); - terrainMovePenalty ||= (terrainMovePenalty !== 1); + const tokenPenalty = griddedTokenMovePenalty(token, gridCoords, prevGridCoords, currElev, prevElev); + const terrainPenalty = griddedTerrainMovePenalty(token, gridCoords, prevGridCoords, currElev, prevElev); + const drawingPenalty = griddedDrawingMovePenalty(gridCoords, prevGridCoords, currElev, prevElev); + if ( !tokenPenalty || tokenPenalty < 0 ) console.warn("griddedMoveDistance", { tokenPenalty }); + if ( !terrainPenalty || terrainPenalty < 0 ) console.warn("griddedMoveDistance", { terrainPenalty }); + if ( !drawingPenalty || drawingPenalty < 0 ) console.warn("griddedMoveDistance", { drawingPenalty }); // Calculate the distance for this step. let d = (movementChange.V + movementChange.H) * distance; d += diagAdder(movementChange.D); // Apply move penalty, if any. - const dMove = d * (tokenMovePenalty * terrainMovePenalty); + const dMove = d * (tokenPenalty * terrainPenalty * drawingPenalty); // Early stop if the stop target is met. if ( stopTarget && (dMoveTotal + dMove) > stopTarget ) break; @@ -347,6 +352,9 @@ function griddedMoveDistance(a, b, token, { useAllElevation = true, stopTarget } // Cycle to next. prevGridCoords = gridCoords; prevElev = currElev; + tokenMovePenalty ||= (tokenPenalty !== 1); + terrainMovePenalty ||= (terrainPenalty !== 1); + drawingMovePenalty ||= (drawingPenalty !== 1); } // Handle remaining elevation change, if any, by moving directly up/down. @@ -354,15 +362,17 @@ function griddedMoveDistance(a, b, token, { useAllElevation = true, stopTarget } let elevStepsNeeded = Math.max(targetElevSteps - elevSteps, 0); if ( useAllElevation && !stopTarget && elevStepsNeeded ) { // Determine move penalty. - const tokenMovePenalty = griddedTokenMovePenalty(token, prevGridCoords, prevGridCoords, b.z, prevElev); - const terrainMovePenalty = griddedTerrainMovePenalty(token, prevGridCoords, prevGridCoords, b.z, prevElev); - tokenMovePenalty ||= (tokenMovePenalty !== 1); - terrainMovePenalty ||= (terrainMovePenalty !== 1); + const tokenPenalty = griddedTokenMovePenalty(token, prevGridCoords, prevGridCoords, b.z, prevElev); + const terrainPenalty = griddedTerrainMovePenalty(token, prevGridCoords, prevGridCoords, b.z, prevElev); + const drawingPenalty = griddedDrawingMovePenalty(prevGridCoords, prevGridCoords, b.z, prevElev); const d = elevStepsNeeded * distance; dTotal += d; - dMoveTotal += (d * (tokenMovePenalty * terrainMovePenalty)); + dMoveTotal += (d * (tokenPenalty * terrainPenalty * drawingPenalty)); elevStepsNeeded = 0; prevElev = b.z; + tokenMovePenalty ||= (tokenPenalty !== 1); + terrainMovePenalty ||= (terrainPenalty !== 1); + drawingMovePenalty ||= (drawingPenalty !== 1); } // Force to not go beyond b.z. @@ -377,7 +387,8 @@ function griddedMoveDistance(a, b, token, { useAllElevation = true, stopTarget } endElevationZ: prevElev, endGridCoords: prevGridCoords, tokenMovePenalty, - terrainMovePenalty + terrainMovePenalty, + drawingMovePenalty }; } @@ -453,6 +464,131 @@ function numElevationGridSteps(elevZ) { return Math.ceil(gridE / canvas.dimensions.distance); } +/** + * Determine the percentage of the ray that intersects a set of shapes. + * @param {PIXI.Point} a + * @param {PIXI.Point} b + * @param {(PIXI.Polygon|PIXI.Rectangle)[]} [shapes=[]] + * @param {number[]} [penalties] + * @returns {number} + */ +function percentageShapeIntersection(a, b, shapes = [], penalties) { // eslint-disable-line default-param-last + const nShapes = shapes.length; + if ( !nShapes ) return 0; + if ( !penalties ) penalties = Array(nShapes).fill(1); + const tValues = []; + const deltaMag = b.to2d().subtract(a).magnitude(); + + // Determine the percentage of the a|b segment that intersects the shapes. + for ( let i = 0; i < nShapes; i += 1 ) { + const shape = shapes[i]; + const penalty = penalties[i] ?? 1; + let inside = false; + if ( shape.contains(a) ) { + inside = true; + tValues.push({ t: 0, inside, penalty }); + } + + // At each intersection, we switch between inside and outside. + const ixs = shape.segmentIntersections(a, b); // Can we assume the ixs are sorted by t0? + + // See Foundry issue #10336. Don't trust the t0 values. + ixs.forEach(ix => { + // See PIXI.Point.prototype.towardsPoint + const distance = Point3d.distanceBetween(a, ix); + ix.t0 = distance / deltaMag; + }); + ixs.sort((a, b) => a.t0 - b.t0); + + ixs.forEach(ix => { + inside ^= true; + tValues.push({ t: ix.t0, inside, penalty }); + }); + } + + // Sort tValues and calculate distance between inside start/end. + // May be multiple inside/outside entries. + tValues.sort((a, b) => a.t0 - b.t0); + let nInside = 0; + let prevT = 0; + let distInside = 0; + let penaltyDistInside = 0; + let currPenalty = 1; + for ( const tValue of tValues ) { + if ( tValue.inside ) { + nInside += 1; + if ( !tValue.t ) continue; // Skip because t is 0 so no distance moved yet. + + // Calculate distance for this segment + const startPt = a.projectToward(b, prevT ?? 0); + const endPt = a.projectToward(b, tValue.t); + const dist = Point3d.distanceBetween(startPt, endPt); + distInside += dist; + penaltyDistInside += (dist * currPenalty); // Penalty before this point. + currPenalty *= tValue.penalty; + + // Cycle to next. + prevT = tValue.t; + + } else if ( nInside > 2 ) { // !tValue.inside + nInside -= 1; + + // Calculate distance for this segment + const startPt = a.projectToward(b, prevT ?? 0); + const endPt = a.projectToward(b, tValue.t); + const dist = Point3d.distanceBetween(startPt, endPt); + distInside += dist; + penaltyDistInside += (dist * currPenalty); // Penalty before this point. + currPenalty *= (1 / tValue.penalty); + + // Cycle to next. + prevT = tValue.t; + } + else if ( nInside === 1 ) { // Inside is false and we are now outside. + nInside = 0; + + // Calculate distance for this segment + const startPt = a.projectToward(b, prevT); + const endPt = a.projectToward(b, tValue.t); + const dist = Point3d.distanceBetween(startPt, endPt); + distInside += dist; + penaltyDistInside += (dist * currPenalty); // Penalty before this point. + currPenalty *= (1 / tValue.penalty); + + // Cycle to next. + prevT = tValue.t; + } + } + + // If still inside, we can go all the way to t = 1 + if ( nInside > 0 ) { + const startPt = a.projectToward(b, prevT); + const dist = Point3d.distanceBetween(startPt, b); + distInside += dist; + penaltyDistInside += (dist * currPenalty); // Penalty before this point. + } + + if ( !distInside ) return 1; + const totalDistance = Point3d.distanceBetween(a, b); + return penaltyDistInside / totalDistance; +} + +/** + * Calculate the percent area overlap of one shape on another. + * @param {PIXI.Rectangle|PIXI.Polygon} overlapShape + * @param {PIXI.Rectangle|PIXI.Polygon} areaShape + * @returns {number} Value between 0 and 1. + */ +function percentOverlap(overlapShape, areaShape, totalArea) { + if ( !overlapShape.overlaps(areaShape) ) return 0; + const intersection = overlapShape.intersectPolygon(areaShape.toPolygon()); + const ixArea = intersection.area(); + totalArea ??= areaShape.area(); + return ixArea / totalArea; +} + +// ----- NOTE: Movement penalty methods ----- // + /** * Determine whether this grid space applies a move penalty because one or more tokens occupy it. * @param {number[2]} currGridCoords @@ -510,90 +646,113 @@ function griddedTokenMovePenalty(token, currGridCoords, prevGridCoords, currElev currCenter = Point3d.fromObject(currCenter); prevCenter.z = prevElev; currCenter.z = currElev; - return percentageShapeIntersection(prevCenter, currCenter, tokens.map(t => t.constrainedTokenBorder)); + return mult * percentageShapeIntersection(prevCenter, currCenter, tokens.map(t => t.constrainedTokenBorder)); } - /** - * Determine the percentage of the ray that intersects a set of shapes. - * @param {PIXI.Point} a - * @param {PIXI.Point} b - * @param {(PIXI.Polygon|PIXI.Rectangle)[]} shapes - * @returns {number} + * Determine whether this grid space applies a move penalty/bonus because one or more drawings occupy it. + * @param {number[2]} currGridCoords + * @param {number[2]} [prevGridCoords] Required for Euclidean setting; otherwise ignored. + * @param {number} currElev Elevation at current grid point, in pixel units + * @param {number} prevElev Elevation at current grid point, in pixel units + * @returns {number} Percent move penalty to apply. Returns 1 if no penalty. */ -function percentageShapeIntersection(a, b, shapes = []) { - const tValues = []; - const deltaMag = b.to2d().subtract(a).magnitude(); - - // Determine the percentage of the a|b segment that intersects the shapes. - for ( const shape of shapes ) { - let inside = false; - if ( shape.contains(a) ) { - inside = true; - tValues.push({ t: 0, inside }); +function griddedDrawingMovePenalty(currGridCoords, prevGridCoords, currElev = 0, prevElev = 0) { + // Locate drawings that overlap this grid space. + const GT = Settings.KEYS.GRID_TERRAIN; + const alg = Settings.get(GT.ALGORITHM); + let collisionTest; + let bounds; + let currCenter; + let prevCenter; + switch ( alg ) { + case GT.CHOICES.CENTER: { + const gridShape = gridShapeFromGridCoords(currGridCoords); + currCenter = gridCenterFromGridCoords(currGridCoords); + bounds = gridShape.getBounds(); + break; } - // At each intersection, we switch between inside and outside. - const ixs = shape.segmentIntersections(a, b); // Can we assume the ixs are sorted by t0? - - // See Foundry issue #10336. Don't trust the t0 values. - ixs.forEach(ix => { - // See PIXI.Point.prototype.towardsPoint - const distance = Point3d.distanceBetween(a, ix); - ix.t0 = distance / deltaMag; - }); - ixs.sort((a, b) => a.t0 - b.t0); - - ixs.forEach(ix => { - inside ^= true; - tValues.push({ t: ix.t0, inside }); - }); - } + case GT.CHOICES.PERCENT: { + const gridShape = gridShapeFromGridCoords(currGridCoords); + const percentThreshold = Settings.get(GT.AREA_THRESHOLD); + const totalArea = gridShape.area(); + collisionTest = o => percentOverlap(shapeForDrawing(o.t), gridShape, totalArea) >= percentThreshold; + bounds = gridShape.getBounds(); + break; + } - // Sort tValues and calculate distance between inside start/end. - // May be multiple inside/outside entries. - tValues.sort((a, b) => a.t0 - b.t0); - let nInside = 0; - let prevT = undefined; - let distInside = 0; - for ( const tValue of tValues ) { - if ( tValue.inside ) { - nInside += 1; - prevT ??= tValue.t; // Store only the first t to take us inside. - } else if ( nInside > 2 ) nInside -= 1; - else if ( nInside === 1 ) { // Inside is false and we are now outside. - const startPt = a.projectToward(b, prevT); - const endPt = a.projectToward(b, tValue.t); - distInside += Point3d.distanceBetween(startPt, endPt); - nInside = 0; - prevT = undefined; + case GT.CHOICES.EUCLIDEAN: { + currCenter = gridCenterFromGridCoords(currGridCoords); + prevCenter = gridCenterFromGridCoords(prevGridCoords); + collisionTest = o => o.t.border.lineSegmentIntersects(prevCenter, currCenter, { inside: true }); + bounds = segmentBounds(prevCenter, currCenter); + break; } } - // If still inside, we can go all the way to t = 1 - if ( nInside > 0 ) { - const startPt = a.projectToward(b, prevT); - distInside += Point3d.distanceBetween(startPt, b); - } + // Check that the drawing has a movement penalty and is within elevation. + // Infinite elevations mean all elevations count + if ( alg !== GT.CHOICES.EUCLIDEAN ) prevElev = undefined; // So hasActiveDrawingTerrain works. + const drawings = canvas.drawings.quadtree.getObjects(bounds, { collisionTest }) + .filter(t => hasActiveDrawingTerrain(t, currElev, prevElev)); + if ( alg !== GT.CHOICES.EUCLIDEAN ) return drawings.size ? calculateDrawingsMovePenalty(drawings) : 1; - if ( !distInside ) return 1; + // For Euclidean, determine the percentage intersect. + prevCenter = Point3d.fromObject(prevCenter); + currCenter = Point3d.fromObject(currCenter); + prevCenter.z = prevElev; + currCenter.z = currElev; - const totalDistance = Point3d.distanceBetween(a, b); - return distInside / totalDistance; + percentageShapeIntersection( + prevCenter, + currCenter, + drawings.map(d => shapeForDrawing(d)), + drawings.map(d => d.document.getFlag(MODULE_ID, FLAGS.MOVEMENT_PENALTY) || 1 ) + ); } /** - * Calculate the percent area overlap of one shape on another. - * @param {PIXI.Rectangle|PIXI.Polygon} overlapShape - * @param {PIXI.Rectangle|PIXI.Polygon} areaShape - * @returns {number} Value between 0 and 1. + * Helper to calculate the percentage penalty for a set of drawings. + * @param {Set} drawings + * @returns {number} */ -function percentOverlap(overlapShape, areaShape, totalArea) { - if ( !overlapShape.overlaps(areaShape) ) return 0; - const intersection = overlapShape.intersectPolygon(areaShape.toPolygon()); - const ixArea = intersection.area(); - totalArea ??= areaShape.area(); - return ixArea / totalArea; +function calculateDrawingsMovePenalty(drawings) { + return drawings.reduce((acc, curr) => { + const penalty = curr.document.getFlag(MODULE_ID, FLAGS.MOVEMENT_PENALTY) || 1; + return acc * penalty; + }, 1); +} + +/** Helper to calculate a shape for a given drawing. + * @param {Drawing} drawing + * @returns {CenteredPolygon|CenteredRectangle|PIXI.Circle} + */ +function shapeForDrawing(drawing) { + switch ( drawing.type ) { + case CONST.DRAWING_TYPES.RECTANGLE: return CenteredRectangle.fromDrawing(drawing); + case CONST.DRAWING_TYPES.POLYGON: return CenteredPolygon.fromDrawing(drawing); + case CONST.DRAWING_TYPES.ELLIPSE: return Ellipse.fromDrawing(drawing); + default: return drawing.bounds; + } +} + +/** + * Helper to test if a drawing has a terrain that is active for this elevation. + * @param {Drawing} drawing Placeable drawing to test + * @param {number} currElev Elevation to test + * @param {number} [prevElev] If defined, drawing must be between prevElev and currElev. + * If not defined, drawing must be at currElev + * @returns {boolean} + */ +function hasActiveDrawingTerrain(drawing, currElev, prevElev) { + if ( !drawing.document.getFlag(MODULE_ID, FLAGS.MOVEMENT_PENALTY) ) return false; + const drawingE = getProperty(drawing.document, "flags.elevatedvision.elevation"); + if ( typeof drawingE === "undefined" ) return true; + + const drawingZ = CONFIG.GeometryLib.utils.gridUnitsToPixels(drawingE); + if ( typeof prevElev === "undefined" ) return currElev.almostEqual(drawingZ); + return drawingZ.between(prevElev, currElev); } /** @@ -635,8 +794,6 @@ function griddedTerrainMovePenalty(token, currGridCoords, prevGridCoords, currEl } } -// ----- NOTE: Helper methods ----- // - /** * Calculate terrain penalty between two points. * Multiply this by distance to get the move distance. @@ -653,6 +810,10 @@ function terrainMovePenalty(a, b, token) { return terrainAPI.Terrain.percentMovementForTokenAlongPath(token, a, b) || 1; } + +// ----- NOTE: Helper methods ----- // + + /** * From a given origin, move horizontally the total 2d distance between A and B. * Then move vertically up/down in elevation. @@ -772,7 +933,7 @@ function * iterateHexGridMoves(origin, destination) { movementChange, gridCoords: next2d }; - movementChange = { NONE: 0, H: 0, V: 0, D: 0, E: 0 }; + movementChange = { NONE: 0, H: 0, V: 0, D: 0, E: 0 }; } } prevElevation = nextElevation; @@ -810,7 +971,7 @@ function * iterateNonHexGridMoves(origin, destination) { gridCoords: prev2d }; - movementChange = { NONE: 0, H: 0, V: 0, D: 0, E: 0 }; // Copy; don't keep same object. + movementChange = { NONE: 0, H: 0, V: 0, D: 0, E: 0 }; // Copy; don't keep same object. // Use the elevation iteration to tell us when to move to the next 2d step. // Horizontal or diagonal elevation moves indicate next step. diff --git a/scripts/settings.js b/scripts/settings.js index 239aa32..1fbcae9 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -185,6 +185,7 @@ export class Settings extends ModuleSettingsAbstract { hint: localize(`${KEYS.GRID_TERRAIN.ALGORITHM}.hint`), scope: "world", config: true, + default: KEYS.GRID_TERRAIN.CHOICES.CENTER, type: String, choices: { [KEYS.GRID_TERRAIN.CHOICES.CENTER]: localize(`${KEYS.GRID_TERRAIN.CHOICES.CENTER}`), From e67691d71c5d2741ef1fab45142bff7ec3e23618 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Fri, 16 Feb 2024 06:58:16 -0800 Subject: [PATCH 27/32] Fixes for errors when measuring penalty distances --- Changelog.md | 4 +- scripts/measure_distance.js | 158 +++++++++++++++++------------------- 2 files changed, 76 insertions(+), 86 deletions(-) diff --git a/Changelog.md b/Changelog.md index 5bba6e9..ffd9d03 100644 --- a/Changelog.md +++ b/Changelog.md @@ -3,14 +3,14 @@ ## New Features Added settings for selecting how terrain and other tokens are measured for grid squares. GM can choose to count difficult terrain if it covers the grid center, covers a fixed percentage of a grid square/hex, or by the percent for which it overlaps a line between the previous grid shape center to the current grid shape center ("euclidean"). -Added selection in Drawings to treat a drawing as imposing a move bonus/penalty. May be changed to accommodate Foundry v12 scene regions in the future. +Added selection in Drawings to treat a drawing as imposing a move bonus/penalty. May be changed or dropped to accommodate Foundry v12 scene regions in the future. ## Bug fixes Fix for the token apparent position disconnecting from actual token position when dragging or moving token with the ruler. Fix for undefined `constrainedTokenBounds.contains`. Closes issue #46. Fix for highlighting incorrect squares with high elevation changes. Refactor (again!) measurement of distances and move distances. Addresses issues with movement measurement calculating incorrectly when speed highlighting adds temporary waypoints. -Update lib geometry to v0.2.16. +Update lib geometry to v0.2.17. # 0.8.4 Improve path cleaning algorithm to remove multiple straight-line points. Closes issue #40. diff --git a/scripts/measure_distance.js b/scripts/measure_distance.js index 7f556c1..5455655 100644 --- a/scripts/measure_distance.js +++ b/scripts/measure_distance.js @@ -158,12 +158,16 @@ function gridlessMoveDistance(a, b, token, { stopTarget } = {}) { // Determine penalty proportion of the a|b segment. const terrainPenalty = terrainMovePenalty(a, b, token); const tokenPenalty = terrainTokenGridlessMoveMultiplier(a, b, token); + const drawingPenalty = terrainDrawingGridlessMoveMultiplier(a, b); const d = CONFIG.GeometryLib.utils.pixelsToGridUnits(Point3d.distanceBetween(a, b)); return { distance: d, - moveDistance: d * terrainPenalty * tokenPenalty, + moveDistance: d * terrainPenalty * tokenPenalty * drawingPenalty, endElevationZ: b.z, - endPoint: b + endPoint: b, + terrainPenalty, + tokenPenalty, + drawingPenalty }; } @@ -227,65 +231,34 @@ function terrainTokenGridlessMoveMultiplier(a, b, token) { if ( !tokens.size ) return 1; // Determine the percentage of the ray that intersects the constrained token shapes. - const tValues = []; - const deltaMag = b.to2d().subtract(a).magnitude(); - for ( const t of tokens ) { - const border = t.constrainedTokenBorder; - let inside = false; - if ( border.contains(a) ) { - inside = true; - tValues.push({ t: 0, inside }); - } - - // At each intersection, we switch between inside and outside. - const ixs = border.segmentIntersections(a, b); // Can we assume the ixs are sorted by t0? - - // See Foundry issue #10336. Don't trust the t0 values. - ixs.forEach(ix => { - // See PIXI.Point.prototype.towardsPoint - const distance = Point3d.distanceBetween(a, ix); - ix.t0 = distance / deltaMag; - }); - ixs.sort((a, b) => a.t0 - b.t0); - - ixs.forEach(ix => { - inside ^= true; - tValues.push({ t: ix.t0, inside }); - }); - } - - // Sort tValues and calculate distance between inside start/end. - // May be multiple inside/outside entries. - tValues.sort((a, b) => a.t0 - b.t0); - let nInside = 0; - let prevT = undefined; - let distInside = 0; - for ( const tValue of tValues ) { - if ( tValue.inside ) { - nInside += 1; - prevT ??= tValue.t; // Store only the first t to take us inside. - } else if ( nInside > 2 ) nInside -= 1; - else if ( nInside === 1 ) { // Inside is false and we are now outside. - const startPt = a.projectToward(b, prevT); - const endPt = a.projectToward(b, tValue.t); - distInside += Point3d.distanceBetween(startPt, endPt); - nInside = 0; - prevT = undefined; - } - } - - // If still inside, we can go all the way to t = 1 - if ( nInside > 0 ) { - const startPt = a.projectToward(b, prevT); - distInside += Point3d.distanceBetween(startPt, b); - } + return percentagePenaltyShapeIntersection(a, b, tokens.map(t => t.constrainedTokenBorder), mult); +} - if ( !distInside ) return 1; +/** + * Get speed multiplier for drawings between two points, assuming gridless. + * Multiplier based on the percentage of the segment that overlaps 1+ drawings. + * @param {Point3d} a Starting point for the segment + * @param {Point3d} b Ending point for the segment + * @returns {number} Percent penalty + */ +function terrainDrawingGridlessMoveMultiplier(a, b) { + // Find drawings along the ray whose borders intersect the ray. + const bounds = segmentBounds(a, b); + const collisionTest = o => o.t.bounds.lineSegmentIntersects(a, b, { inside: true }); + const drawings = canvas.tokens.quadtree.getObjects(bounds, { collisionTest }) + .filter(d => hasActiveDrawingTerrain(d, b.z ?? 0, a.z ?? 0)); + if ( !drawings.size ) return 1; - const totalDistance = Point3d.distanceBetween(a, b); - return ((totalDistance - distInside) + (distInside * mult)) / totalDistance; + // Determine the percentage of the ray that intersects the constrained token shapes. + return percentagePenaltyShapeIntersection( + a, + b, + drawings.map(d => shapeForDrawing(d)), + drawings.map(d => d.document.getFlag(MODULE_ID, FLAGS.MOVEMENT_PENALTY) || 1 ) + ); } + // ----- NOTE: Gridded ----- // /** @@ -468,14 +441,20 @@ function numElevationGridSteps(elevZ) { * Determine the percentage of the ray that intersects a set of shapes. * @param {PIXI.Point} a * @param {PIXI.Point} b - * @param {(PIXI.Polygon|PIXI.Rectangle)[]} [shapes=[]] + * @param {Set|Array} [shapes=[]] * @param {number[]} [penalties] * @returns {number} */ -function percentageShapeIntersection(a, b, shapes = [], penalties) { // eslint-disable-line default-param-last +function percentagePenaltyShapeIntersection(a, b, shapes, penalties) { // eslint-disable-line default-param-last + if ( !shapes ) return 1; + + if ( !Array.isArray(shapes) ) shapes = [...shapes]; const nShapes = shapes.length; - if ( !nShapes ) return 0; - if ( !penalties ) penalties = Array(nShapes).fill(1); + if ( !nShapes ) return 1; + + if ( Number.isNumeric(penalties) ) penalties = Array(nShapes).fill(penalties ?? 1); + if ( !Array.isArray(penalties) ) penalties = [...penalties]; + const tValues = []; const deltaMag = b.to2d().subtract(a).magnitude(); @@ -484,7 +463,7 @@ function percentageShapeIntersection(a, b, shapes = [], penalties) { // eslint-d const shape = shapes[i]; const penalty = penalties[i] ?? 1; let inside = false; - if ( shape.contains(a) ) { + if ( shape.contains(a.x, a.y) ) { inside = true; tValues.push({ t: 0, inside, penalty }); } @@ -512,22 +491,29 @@ function percentageShapeIntersection(a, b, shapes = [], penalties) { // eslint-d let nInside = 0; let prevT = 0; let distInside = 0; + let distOutside = 0; let penaltyDistInside = 0; let currPenalty = 1; for ( const tValue of tValues ) { if ( tValue.inside ) { nInside += 1; - if ( !tValue.t ) continue; // Skip because t is 0 so no distance moved yet. + if ( !tValue.t ) { + currPenalty *= tValue.penalty; + continue; // Skip because t is 0 so no distance moved yet. + } // Calculate distance for this segment const startPt = a.projectToward(b, prevT ?? 0); const endPt = a.projectToward(b, tValue.t); const dist = Point3d.distanceBetween(startPt, endPt); - distInside += dist; - penaltyDistInside += (dist * currPenalty); // Penalty before this point. - currPenalty *= tValue.penalty; + if ( nInside === 1 ) distOutside += dist; + else { + distInside += dist; + penaltyDistInside += (dist * currPenalty); // Penalty before this point. + } // Cycle to next. + currPenalty *= tValue.penalty; prevT = tValue.t; } else if ( nInside > 2 ) { // !tValue.inside @@ -539,9 +525,9 @@ function percentageShapeIntersection(a, b, shapes = [], penalties) { // eslint-d const dist = Point3d.distanceBetween(startPt, endPt); distInside += dist; penaltyDistInside += (dist * currPenalty); // Penalty before this point. - currPenalty *= (1 / tValue.penalty); // Cycle to next. + currPenalty *= (1 / tValue.penalty); prevT = tValue.t; } else if ( nInside === 1 ) { // Inside is false and we are now outside. @@ -553,24 +539,27 @@ function percentageShapeIntersection(a, b, shapes = [], penalties) { // eslint-d const dist = Point3d.distanceBetween(startPt, endPt); distInside += dist; penaltyDistInside += (dist * currPenalty); // Penalty before this point. - currPenalty *= (1 / tValue.penalty); + // Cycle to next. + currPenalty *= (1 / tValue.penalty); prevT = tValue.t; } } // If still inside, we can go all the way to t = 1 + const startPt = a.projectToward(b, prevT); + const dist = Point3d.distanceBetween(startPt, b); if ( nInside > 0 ) { - const startPt = a.projectToward(b, prevT); - const dist = Point3d.distanceBetween(startPt, b); distInside += dist; penaltyDistInside += (dist * currPenalty); // Penalty before this point. - } + } else distOutside += dist; + if ( !distInside ) return 1; + const totalDistance = Point3d.distanceBetween(a, b); - return penaltyDistInside / totalDistance; + return (distOutside + penaltyDistInside) / totalDistance; } /** @@ -582,8 +571,8 @@ function percentageShapeIntersection(a, b, shapes = [], penalties) { // eslint-d function percentOverlap(overlapShape, areaShape, totalArea) { if ( !overlapShape.overlaps(areaShape) ) return 0; const intersection = overlapShape.intersectPolygon(areaShape.toPolygon()); - const ixArea = intersection.area(); - totalArea ??= areaShape.area(); + const ixArea = intersection.area; + totalArea ??= areaShape.area; return ixArea / totalArea; } @@ -620,8 +609,8 @@ function griddedTokenMovePenalty(token, currGridCoords, prevGridCoords, currElev case GT.CHOICES.PERCENT: { const gridShape = gridShapeFromGridCoords(currGridCoords); const percentThreshold = Settings.get(GT.AREA_THRESHOLD); - const totalArea = gridShape.area(); - collisionTest = o => percentOverlap(o.t.constrainedBorder, gridShape, totalArea) >= percentThreshold; + const totalArea = gridShape.area; + collisionTest = o => percentOverlap(o.t.constrainedTokenBorder, gridShape, totalArea) >= percentThreshold; bounds = gridShape.getBounds(); break; } @@ -639,14 +628,15 @@ function griddedTokenMovePenalty(token, currGridCoords, prevGridCoords, currElev const tokens = canvas.tokens.quadtree.getObjects(bounds, { collisionTest }) .filter(t => currElev.between(t.bottomZ, t.topZ)); tokens.delete(token); - if ( alg !== GT.CHOICES.EUCLIDEAN ) return tokens.size ? mult : 1; + if ( !tokens.size ) return 1; + if ( alg !== GT.CHOICES.EUCLIDEAN ) return mult; // For Euclidean, determine the percentage intersect. prevCenter = Point3d.fromObject(prevCenter); currCenter = Point3d.fromObject(currCenter); prevCenter.z = prevElev; currCenter.z = currElev; - return mult * percentageShapeIntersection(prevCenter, currCenter, tokens.map(t => t.constrainedTokenBorder)); + return percentagePenaltyShapeIntersection(prevCenter, currCenter, tokens.map(t => t.constrainedTokenBorder), mult); } /** @@ -676,7 +666,7 @@ function griddedDrawingMovePenalty(currGridCoords, prevGridCoords, currElev = 0, case GT.CHOICES.PERCENT: { const gridShape = gridShapeFromGridCoords(currGridCoords); const percentThreshold = Settings.get(GT.AREA_THRESHOLD); - const totalArea = gridShape.area(); + const totalArea = gridShape.area; collisionTest = o => percentOverlap(shapeForDrawing(o.t), gridShape, totalArea) >= percentThreshold; bounds = gridShape.getBounds(); break; @@ -685,7 +675,7 @@ function griddedDrawingMovePenalty(currGridCoords, prevGridCoords, currElev = 0, case GT.CHOICES.EUCLIDEAN: { currCenter = gridCenterFromGridCoords(currGridCoords); prevCenter = gridCenterFromGridCoords(prevGridCoords); - collisionTest = o => o.t.border.lineSegmentIntersects(prevCenter, currCenter, { inside: true }); + collisionTest = o => o.t.bounds.lineSegmentIntersects(prevCenter, currCenter, { inside: true }); bounds = segmentBounds(prevCenter, currCenter); break; } @@ -695,16 +685,16 @@ function griddedDrawingMovePenalty(currGridCoords, prevGridCoords, currElev = 0, // Infinite elevations mean all elevations count if ( alg !== GT.CHOICES.EUCLIDEAN ) prevElev = undefined; // So hasActiveDrawingTerrain works. const drawings = canvas.drawings.quadtree.getObjects(bounds, { collisionTest }) - .filter(t => hasActiveDrawingTerrain(t, currElev, prevElev)); - if ( alg !== GT.CHOICES.EUCLIDEAN ) return drawings.size ? calculateDrawingsMovePenalty(drawings) : 1; + .filter(d => hasActiveDrawingTerrain(d, currElev, prevElev)); + if ( !drawings.size ) return 1; + if ( alg !== GT.CHOICES.EUCLIDEAN ) return calculateDrawingsMovePenalty(drawings); // For Euclidean, determine the percentage intersect. prevCenter = Point3d.fromObject(prevCenter); currCenter = Point3d.fromObject(currCenter); prevCenter.z = prevElev; currCenter.z = currElev; - - percentageShapeIntersection( + return percentagePenaltyShapeIntersection( prevCenter, currCenter, drawings.map(d => shapeForDrawing(d)), From cb98fefe657f2d14feb774d319dc45ef38eed0fc Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Fri, 16 Feb 2024 07:02:08 -0800 Subject: [PATCH 28/32] Fix for gridless drawings penalty --- scripts/measure_distance.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/measure_distance.js b/scripts/measure_distance.js index 5455655..0f2b822 100644 --- a/scripts/measure_distance.js +++ b/scripts/measure_distance.js @@ -245,7 +245,7 @@ function terrainDrawingGridlessMoveMultiplier(a, b) { // Find drawings along the ray whose borders intersect the ray. const bounds = segmentBounds(a, b); const collisionTest = o => o.t.bounds.lineSegmentIntersects(a, b, { inside: true }); - const drawings = canvas.tokens.quadtree.getObjects(bounds, { collisionTest }) + const drawings = canvas.drawings.quadtree.getObjects(bounds, { collisionTest }) .filter(d => hasActiveDrawingTerrain(d, b.z ?? 0, a.z ?? 0)); if ( !drawings.size ) return 1; From 133234020f868789de1769401aa014160172da60 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Fri, 16 Feb 2024 07:05:17 -0800 Subject: [PATCH 29/32] Simplify euclidean drawings penalty Rely on gridless function --- scripts/measure_distance.js | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/scripts/measure_distance.js b/scripts/measure_distance.js index 0f2b822..2f151ce 100644 --- a/scripts/measure_distance.js +++ b/scripts/measure_distance.js @@ -673,33 +673,21 @@ function griddedDrawingMovePenalty(currGridCoords, prevGridCoords, currElev = 0, } case GT.CHOICES.EUCLIDEAN: { - currCenter = gridCenterFromGridCoords(currGridCoords); - prevCenter = gridCenterFromGridCoords(prevGridCoords); - collisionTest = o => o.t.bounds.lineSegmentIntersects(prevCenter, currCenter, { inside: true }); - bounds = segmentBounds(prevCenter, currCenter); - break; + prevCenter = Point3d.fromObject(prevCenter); + currCenter = Point3d.fromObject(currCenter); + prevCenter.z = prevElev; + currCenter.z = currElev; + return terrainDrawingGridlessMoveMultiplier(prevCenter, currCenter); } } // Check that the drawing has a movement penalty and is within elevation. // Infinite elevations mean all elevations count - if ( alg !== GT.CHOICES.EUCLIDEAN ) prevElev = undefined; // So hasActiveDrawingTerrain works. + prevElev = undefined; // So hasActiveDrawingTerrain works. const drawings = canvas.drawings.quadtree.getObjects(bounds, { collisionTest }) .filter(d => hasActiveDrawingTerrain(d, currElev, prevElev)); if ( !drawings.size ) return 1; - if ( alg !== GT.CHOICES.EUCLIDEAN ) return calculateDrawingsMovePenalty(drawings); - - // For Euclidean, determine the percentage intersect. - prevCenter = Point3d.fromObject(prevCenter); - currCenter = Point3d.fromObject(currCenter); - prevCenter.z = prevElev; - currCenter.z = currElev; - return percentagePenaltyShapeIntersection( - prevCenter, - currCenter, - drawings.map(d => shapeForDrawing(d)), - drawings.map(d => d.document.getFlag(MODULE_ID, FLAGS.MOVEMENT_PENALTY) || 1 ) - ); + return calculateDrawingsMovePenalty(drawings); } /** From aab86fd67e7cdb4a84d3be556597416e71aa9385 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Fri, 16 Feb 2024 07:07:14 -0800 Subject: [PATCH 30/32] Simplify the euclidean token move penalty Rely on the gridless function --- scripts/measure_distance.js | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/scripts/measure_distance.js b/scripts/measure_distance.js index 2f151ce..eb69a19 100644 --- a/scripts/measure_distance.js +++ b/scripts/measure_distance.js @@ -616,11 +616,11 @@ function griddedTokenMovePenalty(token, currGridCoords, prevGridCoords, currElev } case GT.CHOICES.EUCLIDEAN: { - currCenter = gridCenterFromGridCoords(currGridCoords); - prevCenter = gridCenterFromGridCoords(prevGridCoords); - collisionTest = o => o.t.constrainedTokenBorder.lineSegmentIntersects(prevCenter, currCenter, { inside: true }); - bounds = segmentBounds(prevCenter, currCenter); - break; + prevCenter = Point3d.fromObject(prevCenter); + currCenter = Point3d.fromObject(currCenter); + prevCenter.z = prevElev; + currCenter.z = currElev; + return terrainTokenGridlessMoveMultiplier(prevCenter, currCenter, token); } } @@ -629,14 +629,7 @@ function griddedTokenMovePenalty(token, currGridCoords, prevGridCoords, currElev .filter(t => currElev.between(t.bottomZ, t.topZ)); tokens.delete(token); if ( !tokens.size ) return 1; - if ( alg !== GT.CHOICES.EUCLIDEAN ) return mult; - - // For Euclidean, determine the percentage intersect. - prevCenter = Point3d.fromObject(prevCenter); - currCenter = Point3d.fromObject(currCenter); - prevCenter.z = prevElev; - currCenter.z = currElev; - return percentagePenaltyShapeIntersection(prevCenter, currCenter, tokens.map(t => t.constrainedTokenBorder), mult); + return mult; } /** From 7c54ff19815621484787c9175a322277fb101f81 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Fri, 16 Feb 2024 07:34:38 -0800 Subject: [PATCH 31/32] Combine griddedTokenMovePenalty and Drawing into one --- scripts/measure_distance.js | 105 ++++++++++++++++++++++++++++++++---- 1 file changed, 95 insertions(+), 10 deletions(-) diff --git a/scripts/measure_distance.js b/scripts/measure_distance.js index eb69a19..86e16b9 100644 --- a/scripts/measure_distance.js +++ b/scripts/measure_distance.js @@ -300,9 +300,9 @@ function griddedMoveDistance(a, b, token, { useAllElevation = true, stopTarget } const currElev = prevElev + (movementChange.E * size); // In pixel units. // Determine move penalty. - const tokenPenalty = griddedTokenMovePenalty(token, gridCoords, prevGridCoords, currElev, prevElev); const terrainPenalty = griddedTerrainMovePenalty(token, gridCoords, prevGridCoords, currElev, prevElev); - const drawingPenalty = griddedDrawingMovePenalty(gridCoords, prevGridCoords, currElev, prevElev); + const tokenPenalty = griddedMovePenalty(gridCoords, prevGridCoords, { currElev, prevElev, token }); + const drawingPenalty = griddedMovePenalty(gridCoords, prevGridCoords, { currElev, prevElev }); if ( !tokenPenalty || tokenPenalty < 0 ) console.warn("griddedMoveDistance", { tokenPenalty }); if ( !terrainPenalty || terrainPenalty < 0 ) console.warn("griddedMoveDistance", { terrainPenalty }); if ( !drawingPenalty || drawingPenalty < 0 ) console.warn("griddedMoveDistance", { drawingPenalty }); @@ -325,8 +325,8 @@ function griddedMoveDistance(a, b, token, { useAllElevation = true, stopTarget } // Cycle to next. prevGridCoords = gridCoords; prevElev = currElev; - tokenMovePenalty ||= (tokenPenalty !== 1); terrainMovePenalty ||= (terrainPenalty !== 1); + tokenMovePenalty ||= (tokenPenalty !== 1); drawingMovePenalty ||= (drawingPenalty !== 1); } @@ -335,16 +335,16 @@ function griddedMoveDistance(a, b, token, { useAllElevation = true, stopTarget } let elevStepsNeeded = Math.max(targetElevSteps - elevSteps, 0); if ( useAllElevation && !stopTarget && elevStepsNeeded ) { // Determine move penalty. - const tokenPenalty = griddedTokenMovePenalty(token, prevGridCoords, prevGridCoords, b.z, prevElev); const terrainPenalty = griddedTerrainMovePenalty(token, prevGridCoords, prevGridCoords, b.z, prevElev); - const drawingPenalty = griddedDrawingMovePenalty(prevGridCoords, prevGridCoords, b.z, prevElev); + const tokenPenalty = griddedMovePenalty(prevGridCoords, prevGridCoords, { currElev: b.z, prevElev, token }); + const drawingPenalty = griddedMovePenalty(prevGridCoords, prevGridCoords, { currElev: b.z, prevElev }); const d = elevStepsNeeded * distance; dTotal += d; dMoveTotal += (d * (tokenPenalty * terrainPenalty * drawingPenalty)); elevStepsNeeded = 0; prevElev = b.z; - tokenMovePenalty ||= (tokenPenalty !== 1); terrainMovePenalty ||= (terrainPenalty !== 1); + tokenMovePenalty ||= (tokenPenalty !== 1); drawingMovePenalty ||= (drawingPenalty !== 1); } @@ -616,10 +616,14 @@ function griddedTokenMovePenalty(token, currGridCoords, prevGridCoords, currElev } case GT.CHOICES.EUCLIDEAN: { - prevCenter = Point3d.fromObject(prevCenter); + currCenter = gridCenterFromGridCoords(currGridCoords); currCenter = Point3d.fromObject(currCenter); - prevCenter.z = prevElev; currCenter.z = currElev; + + prevCenter = gridCenterFromGridCoords(prevGridCoords); + prevCenter = Point3d.fromObject(prevCenter); + prevCenter.z = prevElev; + return terrainTokenGridlessMoveMultiplier(prevCenter, currCenter, token); } } @@ -666,10 +670,14 @@ function griddedDrawingMovePenalty(currGridCoords, prevGridCoords, currElev = 0, } case GT.CHOICES.EUCLIDEAN: { - prevCenter = Point3d.fromObject(prevCenter); + currCenter = gridCenterFromGridCoords(currGridCoords); currCenter = Point3d.fromObject(currCenter); - prevCenter.z = prevElev; currCenter.z = currElev; + + prevCenter = gridCenterFromGridCoords(prevGridCoords); + prevCenter = Point3d.fromObject(prevCenter); + prevCenter.z = prevElev; + return terrainDrawingGridlessMoveMultiplier(prevCenter, currCenter); } } @@ -683,6 +691,83 @@ function griddedDrawingMovePenalty(currGridCoords, prevGridCoords, currElev = 0, return calculateDrawingsMovePenalty(drawings); } +/** + * Determine whether this grid space applies a move penalty/bonus because one or more drawings or tokens occupy it. + * @param {number[2]} currGridCoords The row,col coordinates for the grid space to test + * @param {number[2]} [prevGridCoords] Previous grid coordinates; required for Euclidean setting; otherwise ignored + * @param {object} [opts] Options needed for some settings + * @param {number} [opts.currElev=0] Elevation at current grid point, in pixel units + * @param {number} [opts.prevElev=0] Elevation at current grid point, in pixel units + * @param {Token} [opts.token] If using token measurement, token to exclude + * @returns {number} Percent move penalty to apply. Returns 1 if no penalty. + */ +function griddedMovePenalty(currGridCoords, prevGridCoords, { currElev = 0, prevElev = 0, token } = {}) { + let mult; + let objectBoundsFn; + let filterFn; + let quadtree + if ( token ) { + mult = Settings.get(Settings.KEYS.TOKEN_RULER.TOKEN_MULTIPLIER); + objectBoundsFn = t => t.constrainedTokenBorder; + filterFn = t => currElev.between(t.bottomZ, t.topZ); + quadtree = canvas.tokens.quadtree; + } else { + objectBoundsFn = shapeForDrawing; + filterFn = hasActiveDrawingTerrain; + quadtree = canvas.drawings.quadtree; + } + if ( mult === 1 ) return 1; + + // Get the bounds and collision test for the algorithm type. + const GT = Settings.KEYS.GRID_TERRAIN; + const alg = Settings.get(GT.ALGORITHM); + let collisionTest; + let bounds; + let currCenter; + let prevCenter; + switch ( alg ) { + case GT.CHOICES.CENTER: { + const gridShape = gridShapeFromGridCoords(currGridCoords); + currCenter = gridCenterFromGridCoords(currGridCoords); + collisionTest = o => objectBoundsFn(o.t).contains(currCenter.x, currCenter.y); + bounds = gridShape.getBounds(); + break; + } + + case GT.CHOICES.PERCENT: { + const gridShape = gridShapeFromGridCoords(currGridCoords); + const percentThreshold = Settings.get(GT.AREA_THRESHOLD); + const totalArea = gridShape.area; + collisionTest = o => percentOverlap(objectBoundsFn(o.t), gridShape, totalArea) >= percentThreshold; + bounds = gridShape.getBounds(); + break; + } + + case GT.CHOICES.EUCLIDEAN: { + currCenter = gridCenterFromGridCoords(currGridCoords); + currCenter = Point3d.fromObject(currCenter); + currCenter.z = currElev; + + prevCenter = gridCenterFromGridCoords(prevGridCoords); + prevCenter = Point3d.fromObject(prevCenter); + prevCenter.z = prevElev; + + const fn = token ? terrainTokenGridlessMoveMultiplier : terrainDrawingGridlessMoveMultiplier; + return fn(prevCenter, currCenter, token); + } + } + + // Check that the drawing has a movement penalty and is within elevation. + // Infinite elevations mean all elevations count + prevElev = undefined; // So hasActiveDrawingTerrain works. + const objects = quadtree.getObjects(bounds, { collisionTest }) + .filter(o => filterFn(o, currElev, prevElev)); + if ( token ) objects.delete(token); + if ( !objects.size ) return 1; + return token ? mult : calculateDrawingsMovePenalty(objects); +} + + /** * Helper to calculate the percentage penalty for a set of drawings. * @param {Set} drawings From 20a3f56973fea0208c5defa7eff65464d3ab76db Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Fri, 16 Feb 2024 07:35:52 -0800 Subject: [PATCH 32/32] Remove unused movementPenalty functions --- scripts/measure_distance.js | 112 ------------------------------------ 1 file changed, 112 deletions(-) diff --git a/scripts/measure_distance.js b/scripts/measure_distance.js index 86e16b9..b4e6551 100644 --- a/scripts/measure_distance.js +++ b/scripts/measure_distance.js @@ -578,118 +578,6 @@ function percentOverlap(overlapShape, areaShape, totalArea) { // ----- NOTE: Movement penalty methods ----- // -/** - * Determine whether this grid space applies a move penalty because one or more tokens occupy it. - * @param {number[2]} currGridCoords - * @param {number[2]} [prevGridCoords] Required for Euclidean setting; otherwise ignored. - * @param {number} currElev Elevation at current grid point, in pixel units - * @param {number} prevElev Elevation at current grid point, in pixel units - * @returns {number} Percent move penalty to apply. Returns 1 if no penalty. - */ -function griddedTokenMovePenalty(token, currGridCoords, prevGridCoords, currElev = 0, prevElev = 0) { - const mult = Settings.get(Settings.KEYS.TOKEN_RULER.TOKEN_MULTIPLIER); - if ( mult === 1 ) return 1; - - // Locate tokens that overlap this grid space. - const GT = Settings.KEYS.GRID_TERRAIN; - const alg = Settings.get(GT.ALGORITHM); - let collisionTest; - let bounds; - let currCenter; - let prevCenter; - switch ( alg ) { - case GT.CHOICES.CENTER: { - const gridShape = gridShapeFromGridCoords(currGridCoords); - currCenter = gridCenterFromGridCoords(currGridCoords); - collisionTest = o => o.t.constrainedTokenBorder.contains(currCenter.x, currCenter.y); - bounds = gridShape.getBounds(); - break; - } - - case GT.CHOICES.PERCENT: { - const gridShape = gridShapeFromGridCoords(currGridCoords); - const percentThreshold = Settings.get(GT.AREA_THRESHOLD); - const totalArea = gridShape.area; - collisionTest = o => percentOverlap(o.t.constrainedTokenBorder, gridShape, totalArea) >= percentThreshold; - bounds = gridShape.getBounds(); - break; - } - - case GT.CHOICES.EUCLIDEAN: { - currCenter = gridCenterFromGridCoords(currGridCoords); - currCenter = Point3d.fromObject(currCenter); - currCenter.z = currElev; - - prevCenter = gridCenterFromGridCoords(prevGridCoords); - prevCenter = Point3d.fromObject(prevCenter); - prevCenter.z = prevElev; - - return terrainTokenGridlessMoveMultiplier(prevCenter, currCenter, token); - } - } - - // Check that elevation is within the token height. - const tokens = canvas.tokens.quadtree.getObjects(bounds, { collisionTest }) - .filter(t => currElev.between(t.bottomZ, t.topZ)); - tokens.delete(token); - if ( !tokens.size ) return 1; - return mult; -} - -/** - * Determine whether this grid space applies a move penalty/bonus because one or more drawings occupy it. - * @param {number[2]} currGridCoords - * @param {number[2]} [prevGridCoords] Required for Euclidean setting; otherwise ignored. - * @param {number} currElev Elevation at current grid point, in pixel units - * @param {number} prevElev Elevation at current grid point, in pixel units - * @returns {number} Percent move penalty to apply. Returns 1 if no penalty. - */ -function griddedDrawingMovePenalty(currGridCoords, prevGridCoords, currElev = 0, prevElev = 0) { - // Locate drawings that overlap this grid space. - const GT = Settings.KEYS.GRID_TERRAIN; - const alg = Settings.get(GT.ALGORITHM); - let collisionTest; - let bounds; - let currCenter; - let prevCenter; - switch ( alg ) { - case GT.CHOICES.CENTER: { - const gridShape = gridShapeFromGridCoords(currGridCoords); - currCenter = gridCenterFromGridCoords(currGridCoords); - bounds = gridShape.getBounds(); - break; - } - - case GT.CHOICES.PERCENT: { - const gridShape = gridShapeFromGridCoords(currGridCoords); - const percentThreshold = Settings.get(GT.AREA_THRESHOLD); - const totalArea = gridShape.area; - collisionTest = o => percentOverlap(shapeForDrawing(o.t), gridShape, totalArea) >= percentThreshold; - bounds = gridShape.getBounds(); - break; - } - - case GT.CHOICES.EUCLIDEAN: { - currCenter = gridCenterFromGridCoords(currGridCoords); - currCenter = Point3d.fromObject(currCenter); - currCenter.z = currElev; - - prevCenter = gridCenterFromGridCoords(prevGridCoords); - prevCenter = Point3d.fromObject(prevCenter); - prevCenter.z = prevElev; - - return terrainDrawingGridlessMoveMultiplier(prevCenter, currCenter); - } - } - - // Check that the drawing has a movement penalty and is within elevation. - // Infinite elevations mean all elevations count - prevElev = undefined; // So hasActiveDrawingTerrain works. - const drawings = canvas.drawings.quadtree.getObjects(bounds, { collisionTest }) - .filter(d => hasActiveDrawingTerrain(d, currElev, prevElev)); - if ( !drawings.size ) return 1; - return calculateDrawingsMovePenalty(drawings); -} /** * Determine whether this grid space applies a move penalty/bonus because one or more drawings or tokens occupy it.