diff --git a/Changelog.md b/Changelog.md index 2f9c597..d76a1d3 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,9 @@ +# 0.8.1 +Fix toggling pathfinding between waypoints. +Various fixes to display of ruler for other users. +Fixes for display of distance calculations between waypoints. +Possible fix for issue #33 (undefined `token.bounds`). + # 0.8.0 Added pathfinding toggle. Pathfinding works on gridded (both hex and square) and gridless maps. Works when dragging tokens, if Token Ruler is enabled, or when using the Ruler control and you start at a token. diff --git a/scripts/Ruler.js b/scripts/Ruler.js index b41ea50..7d18ebe 100644 --- a/scripts/Ruler.js +++ b/scripts/Ruler.js @@ -6,7 +6,6 @@ duplicate, game, getProperty, PIXI, -Ruler, ui */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ @@ -16,7 +15,7 @@ export const PATCHES = {}; PATCHES.BASIC = {}; PATCHES.SPEED_HIGHLIGHTING = {}; -import { SPEED } from "./const.js"; +import { SPEED, MODULE_ID } from "./const.js"; import { Settings } from "./settings.js"; import { Ray3d } from "./geometry/3d/Ray3d.js"; import { @@ -38,7 +37,8 @@ import { tokenIsSnapped, iterateGridUnderLine, squareGridShape, - hexGridShape } from "./util.js"; + hexGridShape, + log } from "./util.js"; /** * Modified Ruler @@ -84,20 +84,39 @@ UX goals: function clear(wrapper) { // User increments/decrements to the elevation for the current destination this.destination._userElevationIncrements = 0; + this._movementToken = undefined; return wrapper(); } /** * Wrap Ruler.prototype.toJSON * Store the current userElevationIncrements for the destination. + * Store segment information, possibly including pathfinding. */ function toJSON(wrapper) { // If debugging, log will not display on user's console // console.log("constructing ruler json!") const obj = wrapper(); - obj._userElevationIncrements = this._userElevationIncrements; - obj._unsnap = this._unsnap; - obj._unsnappedOrigin = this._unsnappedOrigin; + + const myObj = obj[MODULE_ID] = {}; + + // Segment information + // Simplify the ray. + if ( this.segments ) myObj._segments = this.segments.map(s => { + const newObj = { ...s }; + newObj.ray = { + A: s.ray.A, + B: s.ray.B + }; + newObj.label = Boolean(s.label); + return newObj; + }); + + myObj._userElevationIncrements = this._userElevationIncrements; + myObj._unsnap = this._unsnap; + myObj._unsnappedOrigin = this._unsnappedOrigin; + myObj.totalDistance = this.totalDistance; + myObj.totalMoveDistance = this.totalMoveDistance; return obj; } @@ -107,11 +126,25 @@ function toJSON(wrapper) { * Retrieve the current snap status. */ function update(wrapper, data) { + const myData = data[MODULE_ID]; + if ( !myData ) return wrapper(data); // Just in case. + // Fix for displaying user elevation increments as they happen. - const triggerMeasure = this._userElevationIncrements !== data._userElevationIncrements; - this._userElevationIncrements = data._userElevationIncrements; - this._unsnap = data._unsnap; - this._unsnappedOrigin = data._unsnappedOrigin; + const triggerMeasure = this._userElevationIncrements !== myData._userElevationIncrements; + this._userElevationIncrements = myData._userElevationIncrements; + this._unsnap = myData._unsnap; + this._unsnappedOrigin = myData._unsnappedOrigin; + + // Reconstruct segments. + if ( myData._segments ) this.segments = myData._segments.map(s => { + s.ray = new Ray3d(s.ray.A, s.ray.B); + return s; + }); + + // Add the calculated distance totals. + this.totalDistance = myData.totalDistance; + this.totalMoveDistance = myData.totalMoveDistance; + wrapper(data); if ( triggerMeasure ) { @@ -201,6 +234,9 @@ function _canMove(wrapper, token) { * @param {boolean} gridSpaces Base distance on the number of grid spaces moved? */ function _computeDistance(gridSpaces) { + // If not this ruler's user, use the segments already calculated and passed via socket. + if ( this.user !== game.user ) return; + const gridless = !gridSpaces; const token = this._getMovementToken(); const { measureDistance, modifiedMoveDistance } = this.constructor; @@ -230,7 +266,20 @@ function _computeDistance(gridSpaces) { console.error("Segment is undefined."); } - + // Compute the waypoint distances for labeling. (Distance to immediately previous waypoint.) + const waypointKeys = new Set(this.waypoints.map(w => w.key)); + let waypointDistance = 0; + let waypointMoveDistance = 0; + for ( const segment of this.segments ) { + if ( waypointKeys.has(segment.ray.A.to2d().key) ) { + waypointDistance = 0; + waypointMoveDistance = 0; + } + waypointDistance += segment.distance; + waypointMoveDistance += segment.moveDistance; + segment.waypointDistance = waypointDistance; + segment.waypointMoveDistance = waypointMoveDistance; + } } function _computeTokenSpeed(token, tokenSpeed, gridless = false) { @@ -375,8 +424,12 @@ function splitSegment(segment, splitMoveDistance, token, gridless) { if ( breakPoint.almostEqual(A) ) return []; // Split the segment into two at the break point. - const segment0 = { ray: new Ray3d(A, breakPoint) }; - const segment1 = { ray: new Ray3d(breakPoint, B) }; + const segment0 = {...segment}; + const segment1 = {...segment}; + segment0.ray = new Ray3d(A, breakPoint); + segment1.ray = new Ray3d(breakPoint, B); + segment0.distance = rulerClass.measureDistance(segment0.ray.A, segment0.ray.B, gridless); + segment1.distance = rulerClass.measureDistance(segment1.ray.A, segment1.ray.B, gridless); segment0.moveDistance = rulerClass.modifiedMoveDistance(segment0, token); segment1.moveDistance = rulerClass.modifiedMoveDistance(segment1, token); return [segment0, segment1]; @@ -439,6 +492,21 @@ function _onMouseUp(wrapped, event) { return wrapped(event); } +/** + * Add cached movement token. + * Mixed to avoid error if waypoints have no length. + */ +function _getMovementToken(wrapped) { + if ( !this.waypoints.length ) { + log("Waypoints length 0"); + return undefined; + } + + if ( typeof this._movementToken !== "undefined" ) return this._movementToken; + this._movementToken = wrapped(); + if ( !this._movementToken ) this._movementToken = null; // So we can skip next time. + return this._movementToken; +} PATCHES.BASIC.WRAPS = { clear, @@ -449,7 +517,6 @@ PATCHES.BASIC.WRAPS = { _getMeasurementDestination, // Wraps related to segments - _getMeasurementSegments, _getSegmentLabel, // Move token methods @@ -464,7 +531,7 @@ PATCHES.BASIC.WRAPS = { _canMove }; -PATCHES.BASIC.MIXES = { _animateSegment }; +PATCHES.BASIC.MIXES = { _animateSegment, _getMovementToken, _getMeasurementSegments }; PATCHES.BASIC.OVERRIDES = { _computeDistance }; diff --git a/scripts/Token.js b/scripts/Token.js index 61484d6..7c025fd 100644 --- a/scripts/Token.js +++ b/scripts/Token.js @@ -5,6 +5,7 @@ canvas import { elevationAtWaypoint } from "./segments.js"; import { ConstrainedTokenBorder } from "./ConstrainedTokenBorder.js"; +import { Settings } from "./settings.js"; // Patches for the Token class export const PATCHES = {}; @@ -18,7 +19,8 @@ PATCHES.ConstrainedTokenBorder = {}; function _onDragLeftStart(wrapped, event) { wrapped(event); - // Start a Ruler measurement. + // If Token Ruler, start a ruler measurement. + if ( !Settings.get(Settings.KEYS.TOKEN_RULER.ENABLED) ) return; canvas.controls.ruler._onDragStart(event); } @@ -31,6 +33,7 @@ function _onDragLeftCancel(wrapped, event) { // Cancel a Ruler measurement. // If moving, handled by the drag left drop. + if ( !Settings.get(Settings.KEYS.TOKEN_RULER.ENABLED) ) return; const ruler = canvas.controls.ruler; if ( ruler._state !== Ruler.STATES.MOVING ) canvas.controls.ruler._onMouseUp(event); } @@ -43,6 +46,7 @@ function _onDragLeftMove(wrapped, event) { wrapped(event); // Continue a Ruler measurement. + if ( !Settings.get(Settings.KEYS.TOKEN_RULER.ENABLED) ) return; const ruler = canvas.controls.ruler; if ( ruler._state > 0 ) ruler._onMouseMove(event); } @@ -54,7 +58,7 @@ function _onDragLeftMove(wrapped, event) { async function _onDragLeftDrop(wrapped, event) { // End the ruler measurement const ruler = canvas.controls.ruler; - if ( !ruler.active ) return wrapped(event); + if ( !ruler.active || !Settings.get(Settings.KEYS.TOKEN_RULER.ENABLED) ) return wrapped(event); const destination = event.interactionData.destination; // Ensure the cursor destination is within bounds diff --git a/scripts/module.js b/scripts/module.js index 80a0e66..87ca13d 100644 --- a/scripts/module.js +++ b/scripts/module.js @@ -28,6 +28,11 @@ Hooks.once("init", function() { // Cannot access localization until init. PREFER_TOKEN_CONTROL.title = game.i18n.localize(PREFER_TOKEN_CONTROL.title); registerGeometry(); + + // Configuration + CONFIG[MODULE_ID] = { debug: false }; + + game.modules.get(MODULE_ID).api = { iterateGridUnderLine, PATCHER, @@ -47,7 +52,9 @@ Hooks.once("init", function() { SCENE_GRAPH }, - WallTracer, WallTracerEdge, WallTracerVertex + WallTracer, WallTracerEdge, WallTracerVertex, + + Settings }; }); diff --git a/scripts/patching.js b/scripts/patching.js index fb663fe..616024f 100644 --- a/scripts/patching.js +++ b/scripts/patching.js @@ -38,5 +38,7 @@ export function initializePatching() { PATCHER.registerGroup("BASIC"); PATCHER.registerGroup("ConstrainedTokenBorder"); PATCHER.registerGroup("PATHFINDING"); + PATCHER.registerGroup("TOKEN_RULER"); + PATCHER.registerGroup("SPEED_HIGHLIGHTING"); } diff --git a/scripts/pathfinding/Wall.js b/scripts/pathfinding/Wall.js index bece227..fdd2421 100644 --- a/scripts/pathfinding/Wall.js +++ b/scripts/pathfinding/Wall.js @@ -19,9 +19,6 @@ PATCHES.PATHFINDING = {}; // When canvas is ready, the existing walls are not created, so must re-do here. Hooks.on("canvasReady", async function() { - // console.debug(`outerBounds: ${canvas.walls.outerBounds.length}`); - // console.debug(`innerBounds: ${canvas.walls.innerBounds.length}`); - const t0 = performance.now(); SCENE_GRAPH.clear(); const walls = [ diff --git a/scripts/segments.js b/scripts/segments.js index b64409b..2ab1cfd 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -11,7 +11,7 @@ import { SPEED, MODULES_ACTIVE, MODULE_ID } from "./const.js"; import { Settings } from "./settings.js"; import { Ray3d } from "./geometry/3d/Ray3d.js"; import { Point3d } from "./geometry/3d/Point3d.js"; -import { perpendicularPoints } from "./util.js"; +import { perpendicularPoints, log } from "./util.js"; import { Pathfinder } from "./pathfinding/pathfinding.js"; /** @@ -25,24 +25,61 @@ export function elevationAtWaypoint(waypoint) { } /** - * Wrap Ruler.prototype._getMeasurementSegments - * Add elevation information to the segments + * Mixed wrap of Ruler.prototype._getMeasurementSegments + * Add elevation information to the segments. + * Add pathfinding segments. */ export function _getMeasurementSegments(wrapped) { + // If not the user's ruler, segments calculated by original user and copied via socket. + if ( this.user !== game.user ) { + // Reconstruct labels if necessary. + let labelIndex = 0; + for ( const s of this.segments ) { + if ( !s.label ) continue; // Not every segment has a label. + s.label = this.labels.children[labelIndex++]; + } + return this.segments; + } + + // Elevate the segments const segments = elevateSegments(this, wrapped()); const token = this._getMovementToken(); - if ( !(token && Settings.get(Settings.KEYS.CONTROLS.PATHFINDING)) ) return segments; + // If no movement token, then no pathfinding. + if ( !token ) return segments; - // Test for a collision; if none, no pathfinding. - const lastSegment = segments.at(-1); - if ( !lastSegment ) { - // console.debug("No last segment found", [...segments]); + // If no segments present, clear the path map and return. + // No segments are present if dragging back to the origin point. + const segmentMap = this._pathfindingSegmentMap ??= new Map(); + if ( !segments || !segments.length ) { + segmentMap.clear(); return segments; } - const { A, B } = lastSegment.ray; - if ( !token.checkCollision(B, { origin: A, type: "move", mode: "any" }) ) return segments; + // If currently pathfinding, set path for the last segment, overriding any prior path. + const lastSegment = segments.at(-1); + const pathPoints = Settings.get(Settings.KEYS.CONTROLS.PATHFINDING) + ? calculatePathPointsForSegment(lastSegment, token) + : []; + if ( pathPoints.length > 2 ) segmentMap.set(lastSegment.ray.A.to2d().key, pathPoints); + else segmentMap.delete(lastSegment.ray.A.to2d().key); + + // For each segment, replace with path sub-segment if pathfinding was used for that segment. + const t2 = performance.now(); + const newSegments = constructPathfindingSegments(segments, segmentMap); + const t3 = performance.now(); + log(`${newSegments.length} segments processed in ${t3-t2} ms.`); + return newSegments; +} + +/** + * Calculate a path to get from points A to B on the segment. + * @param {RulerMeasurementSegment} segment + * @returns {PIXI.Point[]} + */ +function calculatePathPointsForSegment(segment, token) { + const { A, B } = segment.ray; + if ( !token.checkCollision(B, { origin: A, type: "move", mode: "any" }) ) return []; // Find path between last waypoint and destination. const t0 = performance.now(); @@ -51,35 +88,21 @@ export function _getMeasurementSegments(wrapped) { const path = pf.runPath(A, B); let pathPoints = Pathfinder.getPathPoints(path); const t1 = performance.now(); - // console.debug(`Found ${pathPoints.length} path points between ${A.x},${A.y} -> ${B.x},${B.y} in ${t1 - t0} ms.`); + log(`Found ${pathPoints.length} path points between ${A.x},${A.y} -> ${B.x},${B.y} in ${t1 - t0} ms.`); - const t4 = performance.now(); + // Clean the path + const t2 = performance.now(); pathPoints = Pathfinder.cleanPath(pathPoints); - const t5 = performance.now(); - if ( !pathPoints ) { - // console.debug("No path points after cleaning"); - return segments; - } + const t3 = performance.now(); + log(`Cleaned to ${pathPoints?.length} path points between ${A.x},${A.y} -> ${B.x},${B.y} in ${t3 - t2} ms.`); - // console.debug(`Cleaned to ${pathPoints?.length} path points between ${A.x},${A.y} -> ${B.x},${B.y} in ${t5 - t4} ms.`); + // If less than 3 points after cleaning, just use the original segment. if ( pathPoints.length < 2 ) { - // console.debug(`Only ${pathPoints.length} path points found.`, [...pathPoints]); - return segments; + log(`Only ${pathPoints.length} path points found.`, [...pathPoints]); + return []; } - - - // Store points in case a waypoint is added. - // Overwrite the last calculated path from this waypoint. - const t2 = performance.now(); - const segmentMap = this._pathfindingSegmentMap ??= new Map(); - segmentMap.set(A.to2d().key, pathPoints); - - // For each segment, replace with path sub-segment if pathfinding was used. - const newSegments = constructPathfindingSegments(segments, segmentMap); - const t3 = performance.now(); - // console.debug(`${newSegments.length} segments processed in ${t3-t2} ms.`); - return newSegments; + return pathPoints; } /** @@ -127,7 +150,11 @@ function constructPathfindingSegments(segments, segmentMap) { * Add elevation information to the label */ export function _getSegmentLabel(wrapped, segment, totalDistance) { + // Force distance to be between waypoints instead of (possibly pathfinding) segments. + const origSegmentDistance = segment.distance; + segment.distance = segment.waypointDistance; const orig_label = wrapped(segment, totalDistance); + segment.distance = origSegmentDistance; let elevation_label = segmentElevationLabel(segment); const level_name = levelNameAtElevation(CONFIG.GeometryLib.utils.pixelsToGridUnits(segment.ray.B.z)); if ( level_name ) elevation_label += `\n${level_name}`; @@ -181,6 +208,9 @@ 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); + const token = this._getMovementToken(); if ( !token ) return wrapped(segment); diff --git a/scripts/settings.js b/scripts/settings.js index 096ff92..acb3bb9 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -7,7 +7,7 @@ canvas import { MODULE_ID, MODULES_ACTIVE, SPEED } from "./const.js"; import { ModuleSettingsAbstract } from "./ModuleSettingsAbstract.js"; -import { PATCHER } from "./patching.js"; +import { log } from "./util.js"; const SETTINGS = { CONTROLS: { @@ -147,8 +147,7 @@ export class Settings extends ModuleSettingsAbstract { config: true, default: false, type: Boolean, - requiresReload: false, - onChange: value => this.toggleTokenRuler(value) + requiresReload: false }); register(KEYS.TOKEN_RULER.SPEED_HIGHLIGHTING, { @@ -158,8 +157,7 @@ export class Settings extends ModuleSettingsAbstract { config: true, default: false, type: Boolean, - requiresReload: false, - onChange: value => this.toggleSpeedHighlighting(value) + requiresReload: false }); register(KEYS.TOKEN_RULER.SPEED_PROPERTY, { @@ -187,8 +185,6 @@ export class Settings extends ModuleSettingsAbstract { }); // Initialize the Token Ruler. - if ( this.get(KEYS.TOKEN_RULER.ENABLED) ) this.toggleTokenRuler(true); - if ( this.get(KEYS.TOKEN_RULER.SPEED_HIGHLIGHTING) ) this.toggleSpeedHighlighting(true); this.setSpeedProperty(this.get(KEYS.TOKEN_RULER.SPEED_PROPERTY)); } @@ -234,18 +230,7 @@ export class Settings extends ModuleSettingsAbstract { }); } - static toggleTokenRuler(value) { - if ( value ) PATCHER.registerGroup("TOKEN_RULER"); - else PATCHER.deregisterGroup("TOKEN_RULER"); - } - - static toggleSpeedHighlighting(value) { - if ( value ) PATCHER.registerGroup("SPEED_HIGHLIGHTING"); - else PATCHER.deregisterGroup("SPEED_HIGHLIGHTING"); - } - static setSpeedProperty(value) { SPEED.ATTRIBUTE = value; } - } /** @@ -268,7 +253,7 @@ function toggleTokenRulerWaypoint(context, add = true) { const position = canvas.mousePosition; const ruler = canvas.controls.ruler; if ( !canvas.tokens.active || !ruler || !ruler.active ) return; - // console.debug(`${add ? "add" : "remove"}TokenRulerWaypoint`); + log(`${add ? "add" : "remove"}TokenRulerWaypoint`); // Keep track of when we last added/deleted a waypoint. const now = Date.now(); @@ -276,7 +261,7 @@ function toggleTokenRulerWaypoint(context, add = true) { if ( delta < 100 ) return true; // Throttle keyboard movement once per 100ms MOVE_TIME = now; - // console.debug(`${add ? "adding" : "removing"}TokenRulerWaypoint`); + log(`${add ? "adding" : "removing"}TokenRulerWaypoint`); if ( add ) ruler._addWaypoint(position); else if ( ruler.waypoints.length > 1 ) ruler._removeWaypoint(position); // Removing the last waypoint throws errors. } diff --git a/scripts/terrain_elevation.js b/scripts/terrain_elevation.js index 835478c..b561df4 100644 --- a/scripts/terrain_elevation.js +++ b/scripts/terrain_elevation.js @@ -93,7 +93,8 @@ function elevationAtLocation(location, measuringToken, startingElevation = Numbe // Prioritize the highest token at the location const max_token_elevation = retrieveVisibleTokens().reduce((e, t) => { // Is the point within the token control area? - if ( !t.bounds.contains(location.x, location.y) ) return e; + // Issue #33: bounds can apparently be undefined in some systems? + if ( !t.bounds || !t.bounds.contains(location.x, location.y) ) return e; return Math.max(tokenElevation(t), e); }, Number.NEGATIVE_INFINITY); if ( isFinite(max_token_elevation) && max_token_elevation >= ignoreBelow ) return max_token_elevation; diff --git a/scripts/util.js b/scripts/util.js index 9f67ca9..907781e 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -10,9 +10,7 @@ import { MODULE_ID } from "./const.js"; export function log(...args) { try { - const isDebugging = game.modules.get("_dev-mode")?.api?.getPackageDebugValue(MODULE_ID); - if (isDebugging) console.log(MODULE_ID, "|", ...args); - + if ( CONFIG[MODULE_ID].debug ) console.debug(MODULE_ID, "|", ...args); } catch(e) { // Empty }