From e80079b1cdfd942f1f41c3682eca44bc7355c41c Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Thu, 4 Jan 2024 16:10:35 -0800 Subject: [PATCH 1/9] Add drag ruler speed attributes --- scripts/const.js | 86 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/scripts/const.js b/scripts/const.js index 3f82da1..dc3ed2e 100644 --- a/scripts/const.js +++ b/scripts/const.js @@ -21,3 +21,89 @@ Hooks.once("init", function() { MODULES_ACTIVE.LEVELS = game.modules.get("levels")?.active; MODULES_ACTIVE.ELEVATED_VISION = game.modules.get("elevatedvision")?.active; }); + + + +/** + * Below Taken from Drag Ruler + */ +/* +MIT License + +Copyright (c) 2021 Manuel Vögele + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +export function defaultSpeedAttribute() { + switch (game.system.id) { + case "CoC7": + return "actor.system.attribs.mov.value"; + case "dcc": + case "sfrpg": + return "actor.system.attributes.speed.value"; + case "dnd4e": + return "actor.system.movement.walk.value"; + case "dnd5e": + return "actor.system.attributes.movement.walk"; + case "lancer": + return "actor.system.derived.speed"; + case "pf1": + case "D35E": + return "actor.system.attributes.speed.land.total"; + case "shadowrun5e": + return "actor.system.movement.walk.value"; + case "swade": + return "actor.system.stats.speed.adjusted"; + case "ds4": + return "actor.system.combatValues.movement.total"; + case "splittermond": + return "actor.derivedValues.speed.value"; + case "wfrp4e": + return "actor.system.details.move.walk"; + case "crucible": + return "actor.system.movement.stride"; + } + return ""; +} + +export function defaultDashMultiplier() { + switch (game.system.id) { + case "dcc": + case "dnd4e": + case "dnd5e": + case "lancer": + case "pf1": + case "D35E": + case "sfrpg": + case "shadowrun5e": + case "ds4": + return 2; + case "CoC7": + return 5; + case "splittermond": + return 3; + case "wfrp4e": + return 2; + case "crucible": + case "swade": + return 0; + } + return 0; +} From 7b62e01b9926c71ffa0e141bfbbdc66c623438e9 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sat, 6 Jan 2024 09:46:44 -0800 Subject: [PATCH 2/9] Add highlighting for token speed --- scripts/const.js | 119 +++++++++++++++++++++++++---------------------- scripts/ruler.js | 106 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 168 insertions(+), 57 deletions(-) diff --git a/scripts/const.js b/scripts/const.js index dc3ed2e..49634d8 100644 --- a/scripts/const.js +++ b/scripts/const.js @@ -22,8 +22,6 @@ Hooks.once("init", function() { MODULES_ACTIVE.ELEVATED_VISION = game.modules.get("elevatedvision")?.active; }); - - /** * Below Taken from Drag Ruler */ @@ -51,59 +49,70 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export function defaultSpeedAttribute() { - switch (game.system.id) { - case "CoC7": - return "actor.system.attribs.mov.value"; - case "dcc": - case "sfrpg": - return "actor.system.attributes.speed.value"; - case "dnd4e": - return "actor.system.movement.walk.value"; - case "dnd5e": - return "actor.system.attributes.movement.walk"; - case "lancer": - return "actor.system.derived.speed"; - case "pf1": - case "D35E": - return "actor.system.attributes.speed.land.total"; - case "shadowrun5e": - return "actor.system.movement.walk.value"; - case "swade": - return "actor.system.stats.speed.adjusted"; - case "ds4": - return "actor.system.combatValues.movement.total"; - case "splittermond": - return "actor.derivedValues.speed.value"; - case "wfrp4e": - return "actor.system.details.move.walk"; - case "crucible": - return "actor.system.movement.stride"; - } - return ""; +export const SPEED = { + ATTRIBUTE: "", + MULTIPLIER: 0 +}; + +// Avoid testing for the system id each time. +Hooks.once("init", function() { + SPEED.ATTRIBUTE = defaultSpeedAttribute(); + SPEED.MULTIPLIER = defaultDashMultiplier(); +}); + +function defaultSpeedAttribute() { + switch (game.system.id) { + case "CoC7": + return "actor.system.attribs.mov.value"; + case "dcc": + case "sfrpg": + return "actor.system.attributes.speed.value"; + case "dnd4e": + return "actor.system.movement.walk.value"; + case "dnd5e": + return "actor.system.attributes.movement.walk"; + case "lancer": + return "actor.system.derived.speed"; + case "pf1": + case "D35E": + return "actor.system.attributes.speed.land.total"; + case "shadowrun5e": + return "actor.system.movement.walk.value"; + case "swade": + return "actor.system.stats.speed.adjusted"; + case "ds4": + return "actor.system.combatValues.movement.total"; + case "splittermond": + return "actor.derivedValues.speed.value"; + case "wfrp4e": + return "actor.system.details.move.walk"; + case "crucible": + return "actor.system.movement.stride"; + } + return ""; } -export function defaultDashMultiplier() { - switch (game.system.id) { - case "dcc": - case "dnd4e": - case "dnd5e": - case "lancer": - case "pf1": - case "D35E": - case "sfrpg": - case "shadowrun5e": - case "ds4": - return 2; - case "CoC7": - return 5; - case "splittermond": - return 3; - case "wfrp4e": - return 2; - case "crucible": - case "swade": - return 0; - } - return 0; +function defaultDashMultiplier() { + switch (game.system.id) { + case "dcc": + case "dnd4e": + case "dnd5e": + case "lancer": + case "pf1": + case "D35E": + case "sfrpg": + case "shadowrun5e": + case "ds4": + return 2; + case "CoC7": + return 5; + case "splittermond": + return 3; + case "wfrp4e": + return 2; + case "crucible": + case "swade": + return 0; + } + return 0; } diff --git a/scripts/ruler.js b/scripts/ruler.js index cbf44cb..a943c23 100644 --- a/scripts/ruler.js +++ b/scripts/ruler.js @@ -1,7 +1,10 @@ /* globals canvas, +Color, CONST, game, +getProperty, +Ray, ui */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ @@ -24,6 +27,8 @@ import { _animateSegment } from "./segments.js"; +import { SPEED } from "./const.js"; + /** * Modified Ruler * Measure elevation change at each waypoint and destination. @@ -170,6 +175,102 @@ function _postMove(wrapped, token) { return wrapped(token); } +// ----- NOTE: Segment highlighting ----- // +/** + * Wrap Ruler.prototype._highlightMeasurementSegment + */ +function _highlightMeasurementSegment(wrapped, segment) { + const token = this._getMovementToken(); + if ( !token ) return wrapped(segment); + const tokenSpeed = Number(getProperty(token, SPEED.ATTRIBUTE)); + if ( !tokenSpeed ) return wrapped(segment); + + // Based on the token being measured. + // Track the distance to this segment. + // Split this segment at the break points for the colors as necessary. + let pastDistance = 0; + for ( const s of this.segments ) { + if ( s === segment ) break; + pastDistance += s.distance; + } + + // Constants + const walkDist = tokenSpeed; + const dashDist = tokenSpeed * SPEED.MULTIPLIER; + const walkColor = Color.from(0x00ff00); + const dashColor = Color.from(0xffff00); + const maxColor = Color.from(0xff0000); + + // Track the splits. + let remainingSegment = segment; + const splitSegments = []; + + // Walk + remainingSegment.color = walkColor; + const walkSegments = splitSegment(remainingSegment, pastDistance, walkDist); + if ( walkSegments.length ) { + const segment0 = walkSegments[0]; + splitSegments.push(segment0); + pastDistance += segment0.distance; + remainingSegment = walkSegments.length > 1 ? walkSegments[1] : undefined; + } + + // Dash + if ( remainingSegment ) { + remainingSegment.color = dashColor; + const dashSegments = splitSegment(remainingSegment, pastDistance, dashDist); + if ( dashSegments.length ) { + const segment0 = dashSegments[0]; + splitSegments.push(segment0); + if ( dashSegments.length > 1 ) { + const remainingSegment = dashSegments[1]; + remainingSegment.color = maxColor; + splitSegments.push(remainingSegment); + } + } + } + + // Highlight each split in turn, changing highlight color each time. + const priorColor = this.color; + for ( const s of splitSegments ) { + this.color = s.color; + wrapped(s); + } + this.color = priorColor; +} + +/** + * Cut a segment, represented as a ray and a distance, at a given point. + * @param {object} segment + * @param {number} pastDistance + * @param {number} cutoffDistance + * @returns {object[]} + * - If cutoffDistance is before the segment start, return []. + * - If cutoffDistance is after the segment end, return [segment]. + * - If cutoffDistance is within the segment, return [segment0, segment1] + */ +function splitSegment(segment, pastDistance, cutoffDistance) { + // Split the segment early so that the second segment has both squares highlighted. + //const spacer = canvas.scene.grid.type === CONST.GRID_TYPES.SQUARE ? 1.41 : 1; + // cutoffDistance -= spacer * 0.5; + + // If the cutoffDistance does not fall within the segment we are done. + cutoffDistance -= pastDistance; + if ( cutoffDistance <= 0 ) return []; + if ( segment.distance <= cutoffDistance ) return [segment]; + + // Split the segment into two. + // TODO: Handle 3d segments. + const t = cutoffDistance / segment.distance; + const segment0 = { ray: new Ray(segment.ray.A, segment.ray.project(t)), color: segment.color }; + const segment1 = { ray: new Ray(segment0.ray.B, segment.ray.B) }; + const segments = [segment0, segment1]; + const distances = canvas.grid.measureDistances(segments, { gridSpaces: false }); + segment0.distance = distances[0]; + segment1.distance = distances[1]; + return segments; +} + // // Assume the destination elevation is the desired elevation if dragging multiple tokens. // // (Likely more useful than having a bunch of tokens move down 10'?) @@ -206,7 +307,8 @@ PATCHES.BASIC.WRAPS = { // Move token methods // _animateSegment, - _animateMovement + _animateMovement, + _highlightMeasurementSegment // _postMove }; @@ -214,7 +316,7 @@ PATCHES.BASIC.MIXES = { _animateSegment }; PATCHES.DRAG_RULER.WRAPS = { dragRulerAddWaypoint, - dragRulerClearWaypoints, + dragRulerClearWaypoints // _endMeasurement }; From 43504a56f8e57b708aadb54077a3382e0db724bb Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sat, 6 Jan 2024 11:55:00 -0800 Subject: [PATCH 3/9] Ensure token drag is within bounds --- scripts/Token.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/Token.js b/scripts/Token.js index cde4c1c..a4ac6fe 100644 --- a/scripts/Token.js +++ b/scripts/Token.js @@ -41,6 +41,13 @@ async function _onDragLeftDrop(wrapped, event) { // End the ruler measurement const ruler = canvas.controls.ruler; if ( !ruler.active ) return wrapped(event); + const destination = event.interactionData.destination; + + // Ensure the cursor destination is within bounds + if ( !canvas.dimensions.rect.contains(destination.x, destination.y) ) { + ruler._onMouseUp(event); + return false; + } await ruler.moveToken(); ruler._onMouseUp(event); } From 7dbd40bdbf1fad7f1fa32772c88f727d7c373878 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sat, 6 Jan 2024 12:10:16 -0800 Subject: [PATCH 4/9] Cleanup elevateSegments --- scripts/segments.js | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/scripts/segments.js b/scripts/segments.js index 7e0cf79..93496b7 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -26,10 +26,6 @@ export function elevationAtWaypoint(waypoint) { */ export function _getMeasurementSegments(wrapped) { const segments = wrapped(); - - // Add destination as the final waypoint - this.destination._terrainElevation = this.terrainElevationAtDestination(); - this.destination._userElevationIncrements = this._userElevationIncrements; return elevateSegments(this, segments); } @@ -68,15 +64,18 @@ export async function _animateSegment(wrapped, token, segment, destination) { * @param {object[]} segments */ function elevateSegments(ruler, segments) { // Add destination as the final waypoint + const gridUnitsToPixels = CONFIG.GeometryLib.utils.gridUnitsToPixels; + + // Add destination as the final waypoint + ruler.destination._terrainElevation = ruler.terrainElevationAtDestination(); + ruler.destination._userElevationIncrements = ruler._userElevationIncrements; const waypoints = ruler.waypoints.concat([ruler.destination]); - const { distance, size } = canvas.dimensions; - const gridUnits = size / distance; - const ln = waypoints.length; + // Add the waypoint elevations to the corresponding segment endpoints. // Skip the first waypoint, which will (likely) end up as p0. + const ln = waypoints.length; for ( let i = 1, j = 0; i < ln; i += 1, j += 1 ) { const segment = segments[j]; - const p0 = waypoints[i - 1]; const p1 = waypoints[i]; const dist2 = PIXI.Point.distanceSquaredBetween(p0, p1); @@ -86,8 +85,8 @@ function elevateSegments(ruler, segments) { // Add destination as the final way } // Convert to 3d Rays - const Az = elevationAtWaypoint(p0) * gridUnits; - const Bz = elevationAtWaypoint(p1) * gridUnits; + const Az = gridUnitsToPixels(elevationAtWaypoint(p0)); + const Bz = gridUnitsToPixels(elevationAtWaypoint(p1)); segment.ray = Ray3d.from2d(segment.ray, { Az, Bz }); } From 8f146b0f20ae9f5ad4b8c026a137e1b1029cdd85 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sat, 6 Jan 2024 21:12:36 -0800 Subject: [PATCH 5/9] WIP to split segment before the grid square change --- scripts/ruler.js | 103 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 92 insertions(+), 11 deletions(-) diff --git a/scripts/ruler.js b/scripts/ruler.js index a943c23..1e3552e 100644 --- a/scripts/ruler.js +++ b/scripts/ruler.js @@ -28,6 +28,8 @@ import { } from "./segments.js"; import { SPEED } from "./const.js"; +import { Ray3d } from "./geometry/3d/Ray3d.js"; +import { Point3d } from "./geometry/3d/Point3d.js"; /** * Modified Ruler @@ -201,6 +203,10 @@ function _highlightMeasurementSegment(wrapped, segment) { const dashColor = Color.from(0xffff00); const maxColor = Color.from(0xff0000); + if ( segment.distance > walkDist ) { + console.debug(`${segment.distance}`); + } + // Track the splits. let remainingSegment = segment; const splitSegments = []; @@ -212,7 +218,7 @@ function _highlightMeasurementSegment(wrapped, segment) { const segment0 = walkSegments[0]; splitSegments.push(segment0); pastDistance += segment0.distance; - remainingSegment = walkSegments.length > 1 ? walkSegments[1] : undefined; + remainingSegment = walkSegments[1]; // May be undefined. } // Dash @@ -250,20 +256,41 @@ function _highlightMeasurementSegment(wrapped, segment) { * - If cutoffDistance is within the segment, return [segment0, segment1] */ function splitSegment(segment, pastDistance, cutoffDistance) { - // Split the segment early so that the second segment has both squares highlighted. - //const spacer = canvas.scene.grid.type === CONST.GRID_TYPES.SQUARE ? 1.41 : 1; - // cutoffDistance -= spacer * 0.5; - - // If the cutoffDistance does not fall within the segment we are done. cutoffDistance -= pastDistance; if ( cutoffDistance <= 0 ) return []; - if ( segment.distance <= cutoffDistance ) return [segment]; + + // Determine where on the segment ray the cutoff occurs. + const deltaZ = segment.ray.B.subtract(segment.ray.A); + const segmentDistanceZ = deltaZ.magnitude(); + const cutoffDistanceZ = CONFIG.GeometryLib.utils.gridUnitsToPixels(cutoffDistance) + let t = cutoffDistanceZ / segmentDistanceZ; + if ( t >= 1 ) return [segment]; + + // If we are using a grid, determine the point at which this segment crosses to the final + // grid square/hex. + // Split the segment early so the last grid square/hex is colored according to the highest color value (dash/max). + // Find where the segment intersects the last grid square/hex. + if ( canvas.grid.type !== CONST.GRID_TYPES.GRIDLESS ) { + // Determine where before t on the ray the ray hits that grid square/hex. + const cutoffPoint = segment.ray.A.projectToward(segment.ray.B, t); + + // Find the last grid space for the cutoff point and determine the intersection point. + const lastGridSpace = gridShape(cutoffPoint); + const ixs = lastGridSpace.segmentIntersections(segment.ray.A, cutoffPoint); + if ( !ixs.length ) return [segment]; + const ix = ixs.at(-1); // Should always have length 1, but... + + // Determine the new t value based on the intersection. + ix.z = segment.ray.A.z; + const distToIx = Point3d.distanceBetween(segment.ray.A, ix); + t = distToIx / segmentDistanceZ; + if ( t > 1 ) return [segment]; + if ( t <= 0 ) return []; + } // Split the segment into two. - // TODO: Handle 3d segments. - const t = cutoffDistance / segment.distance; - const segment0 = { ray: new Ray(segment.ray.A, segment.ray.project(t)), color: segment.color }; - const segment1 = { ray: new Ray(segment0.ray.B, segment.ray.B) }; + const segment0 = { ray: new Ray3d(segment.ray.A, segment.ray.A.projectToward(segment.ray.B, t)), color: segment.color }; + const segment1 = { ray: new Ray3d(segment0.ray.B, segment.ray.B) }; const segments = [segment0, segment1]; const distances = canvas.grid.measureDistances(segments, { gridSpaces: false }); segment0.distance = distances[0]; @@ -293,6 +320,12 @@ function splitSegment(segment, pastDistance, cutoffDistance) { // await t0.scene.updateEmbeddedDocuments(t0.constructor.embeddedName, updates); // return true; +/** + * Wrap Ruler.prototype._getMeasurementDestination + * If the last event held down the shift key, then use the precise location (no snapping). + */ + + PATCHES.BASIC.WRAPS = { clear, @@ -412,3 +445,51 @@ function hasSegmentCollision(token, segments) { } return false; } + + +/** + * Helper to get the grid shape for given grid type. + * @param {x: number, y: number} p Location to use. + * @returns {null|PIXI.Rectangle|PIXI.Polygon} + */ +function gridShape(p) { + const { GRIDLESS, SQUARE } = CONST.GRID_TYPES; + switch ( canvas.grid.type ) { + case GRIDLESS: return null; + case SQUARE: return squareGridShape(p); + default: return hexGridShape(p); + } +} + +/** + * 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} + */ +function squareGridShape(p) { + // Get the top left corner + const [tlx, tly] = canvas.grid.grid.getTopLeft(p.x, p.y); + const { w, h } = canvas.grid; + return new PIXI.Rectangle(tlx, tly, w, h); +} + +/** + * 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} + */ +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 { getTopLeft, getBorderPolygon } = canvas.grid.grid; + const [tlx, tly] = getTopLeft(p.x, p.y); + const points = getBorderPolygon(width, height, 0); // TO-DO: Should a border be included to improve calc? + const pointsTranslated = []; + const ln = points.length; + for ( let i = 0; i < ln; i += 2) pointsTranslated.push(points[i] + tlx, points[i+1] + tly); + return new PIXI.Polygon(pointsTranslated); +} \ No newline at end of file From 109ffb26cf54aa0b1afdb87595c84616a07534bc Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sun, 7 Jan 2024 15:52:24 -0800 Subject: [PATCH 6/9] Fix highlighting at the border between two colors --- scripts/module.js | 14 ++-- scripts/ruler.js | 159 ++++++++++++++++++++++++++++++---------------- 2 files changed, 112 insertions(+), 61 deletions(-) diff --git a/scripts/module.js b/scripts/module.js index 0996588..ff836f9 100644 --- a/scripts/module.js +++ b/scripts/module.js @@ -10,11 +10,18 @@ ui import { Settings } from "./settings.js"; import { initializePatching, PATCHER, registerDragRuler } from "./patching.js"; import { MODULE_ID } from "./const.js"; +import { iterateGridUnderLine } from "./ruler.js"; import { registerGeometry } from "./geometry/registration.js"; Hooks.once("init", function() { + // Cannot access localization until init. + PREFER_TOKEN_CONTROL.title = game.i18n.localize(PREFER_TOKEN_CONTROL.title); registerGeometry(); + game.modules.get(MODULE_ID).api = { + iterateGridUnderLine, + PATCHER + }; }); // Setup is after init; before ready. @@ -38,13 +45,6 @@ const PREFER_TOKEN_CONTROL = { toggle: true }; -Hooks.once("init", function() { - // Cannot access localization until init. - PREFER_TOKEN_CONTROL.title = game.i18n.localize(PREFER_TOKEN_CONTROL.title); - game.modules.get(MODULE_ID).api = { - PATCHER - }; -}); // Render the prefer token control if that setting is enabled Hooks.on("getSceneControlButtons", controls => { diff --git a/scripts/ruler.js b/scripts/ruler.js index 1e3552e..c75e055 100644 --- a/scripts/ruler.js +++ b/scripts/ruler.js @@ -4,7 +4,7 @@ Color, CONST, game, getProperty, -Ray, +PIXI, ui */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ @@ -162,21 +162,6 @@ function dragRulerClearWaypoints(wrapper) { } -/** - * Wrap DragRulerRuler.prototype._endMeasurement - * If there is a dragged token, apply the elevation to all selected tokens (assumed part of the move). - */ -function _endMeasurement(wrapped) { - console.debug("_endMeasurement"); - return wrapped(); -} - - -function _postMove(wrapped, token) { - console.debug("_postMove"); - return wrapped(token); -} - // ----- NOTE: Segment highlighting ----- // /** * Wrap Ruler.prototype._highlightMeasurementSegment @@ -258,39 +243,61 @@ function _highlightMeasurementSegment(wrapped, segment) { function splitSegment(segment, pastDistance, cutoffDistance) { cutoffDistance -= pastDistance; if ( cutoffDistance <= 0 ) return []; + if ( cutoffDistance >= segment.distance ) return [segment]; // Determine where on the segment ray the cutoff occurs. - const deltaZ = segment.ray.B.subtract(segment.ray.A); - const segmentDistanceZ = deltaZ.magnitude(); - const cutoffDistanceZ = CONFIG.GeometryLib.utils.gridUnitsToPixels(cutoffDistance) - let t = cutoffDistanceZ / segmentDistanceZ; - if ( t >= 1 ) return [segment]; - - // If we are using a grid, determine the point at which this segment crosses to the final - // grid square/hex. - // Split the segment early so the last grid square/hex is colored according to the highest color value (dash/max). - // Find where the segment intersects the last grid square/hex. + // Use canvas grid distance measurements to handle 5-5-5, 5-10-5, other measurement configs. + // At this point, the segment is too long for the cutoff. + // If we are using a grid, split the segment a grid/square hex. + // Find where the segment intersects the last grid square/hex before the cutoff. + let breakPoint; + const { A, B } = segment.ray; if ( canvas.grid.type !== CONST.GRID_TYPES.GRIDLESS ) { - // Determine where before t on the ray the ray hits that grid square/hex. - const cutoffPoint = segment.ray.A.projectToward(segment.ray.B, t); - - // Find the last grid space for the cutoff point and determine the intersection point. - const lastGridSpace = gridShape(cutoffPoint); - const ixs = lastGridSpace.segmentIntersections(segment.ray.A, cutoffPoint); - if ( !ixs.length ) return [segment]; - const ix = ixs.at(-1); // Should always have length 1, but... - - // Determine the new t value based on the intersection. - ix.z = segment.ray.A.z; - const distToIx = Point3d.distanceBetween(segment.ray.A, ix); - t = distToIx / segmentDistanceZ; - if ( t > 1 ) return [segment]; - if ( t <= 0 ) return []; + const z = segment.ray.A.z; + const gridShapeFn = canvas.grid.type === CONST.GRID_TYPES.SQUARE ? squareGridShape : hexGridShape; + const segmentDistZ = segment.ray.distance; + + // Cannot just use the t value because segment distance may not be Euclidean. + // Also need to handle that a segment might break on a grid border. + // Determine all the grid positions, and drop each one in turn. + breakPoint = B; + const gridIter = iterateGridUnderLine(A, B, { reverse: true }); + for ( const [r1, c1] of gridIter ) { + const [x, y] = canvas.grid.grid.getPixelsFromGridPosition(r1, c1); + const shape = gridShapeFn({x, y}); + const ixs = shape + .segmentIntersections(A, B) + .map(ix => PIXI.Point.fromObject(ix)); + if ( !ixs.length ) continue; + + // If more than one, split the distance. + // This avoids an issue whereby a segment is too short and so the first square is dropped when highlighting. + if ( ixs.length === 1 ) breakPoint = ixs[0]; + else { + ixs.forEach(ix => { + ix.distance = ix.subtract(A).magnitude(); + ix.t0 = ix.distance / segmentDistZ; + }); + const t = (ixs[0].t0 + ixs[1].t0) * 0.5; + breakPoint = A.projectToward(B, t); + } + + // Construct a shorter segment. + breakPoint.z = z; + const shorterSegment = { ray: new Ray3d(A, breakPoint) }; + shorterSegment.distance = canvas.grid.measureDistances([shorterSegment], { gridSpaces: true })[0]; + if ( shorterSegment.distance <= cutoffDistance ) break; + } + } else { + // Use t values. + const t = cutoffDistance / segment.distance; + breakPoint = A.projectToward(B, t); } + if ( breakPoint === B ) return [segment]; - // Split the segment into two. - const segment0 = { ray: new Ray3d(segment.ray.A, segment.ray.A.projectToward(segment.ray.B, t)), color: segment.color }; - const segment1 = { ray: new Ray3d(segment0.ray.B, segment.ray.B) }; + // Split the segment into two at the break point. + const segment0 = { ray: new Ray3d(A, breakPoint), color: segment.color }; + const segment1 = { ray: new Ray3d(breakPoint, B) }; const segments = [segment0, segment1]; const distances = canvas.grid.measureDistances(segments, { gridSpaces: false }); segment0.distance = distances[0]; @@ -298,6 +305,59 @@ function splitSegment(segment, pastDistance, cutoffDistance) { return segments; } +/* + * Generator to iterate grid points under a line. + * See Ruler.prototype._highlightMeasurementSegment + * @param {x: Number, y: Number} origin Origination point + * @param {x: Number, y: Number} destination Destination point + * @param {object} [opts] Options affecting the result + * @param {boolean} [opts.reverse] Return the points from destination --> origin. + * @return Iterator, which in turn + * returns [row, col] Array for each grid point under the line. + */ +export function * iterateGridUnderLine(origin, destination, { reverse = false } = {}) { + if ( reverse ) [origin, destination] = [destination, origin]; + + const distance = PIXI.Point.distanceBetween(origin, destination); + const spacer = canvas.scene.grid.type === CONST.GRID_TYPES.SQUARE ? 1.41 : 1; + const nMax = Math.max(Math.floor(distance / (spacer * Math.min(canvas.grid.w, canvas.grid.h))), 1); + const tMax = Array.fromRange(nMax+1).map(t => t / nMax); + + // Track prior position + let prior = null; + let tPrior = null; + for ( const t of tMax ) { + const {x, y} = origin.projectToward(destination, t); + + // Get grid position + const [r0, c0] = prior ?? [null, null]; + const [r1, c1] = canvas.grid.grid.getGridPositionFromPixels(x, y); + if ( r0 === r1 && c0 === c1 ) continue; + + // Skip the first one + // If the positions are not neighbors, also highlight their halfway point + if ( prior && !canvas.grid.isNeighbor(r0, c0, r1, c1) ) { + const th = (t + tPrior) * 0.5; + const {x: xh, y: yh} = origin.projectToward(destination, th); + yield canvas.grid.grid.getGridPositionFromPixels(xh, yh); // [rh, ch] + } + + // After so the halfway point is done first. + yield [r1, c1]; + + // Set for next round. + prior = [r1, c1]; + tPrior = t; + } +} + +// iter = iterateGridUnderLine(A, B, { reverse: false }) +// points = [...iter] +// points = points.map(pt => canvas.grid.grid.getPixelsFromGridPosition(pt[0], pt[1])) +// points = points.map(pt => { +// return {x: pt[0], y: pt[1]} +// }) + // // Assume the destination elevation is the desired elevation if dragging multiple tokens. // // (Likely more useful than having a bunch of tokens move down 10'?) @@ -320,13 +380,6 @@ function splitSegment(segment, pastDistance, cutoffDistance) { // await t0.scene.updateEmbeddedDocuments(t0.constructor.embeddedName, updates); // return true; -/** - * Wrap Ruler.prototype._getMeasurementDestination - * If the last event held down the shift key, then use the precise location (no snapping). - */ - - - PATCHES.BASIC.WRAPS = { clear, toJSON, @@ -339,10 +392,8 @@ PATCHES.BASIC.WRAPS = { _getSegmentLabel, // Move token methods - // _animateSegment, _animateMovement, _highlightMeasurementSegment - // _postMove }; PATCHES.BASIC.MIXES = { _animateSegment }; @@ -492,4 +543,4 @@ function hexGridShape(p, { width = 1, height = 1 } = {}) { const ln = points.length; for ( let i = 0; i < ln; i += 2) pointsTranslated.push(points[i] + tlx, points[i+1] + tly); return new PIXI.Polygon(pointsTranslated); -} \ No newline at end of file +} From 55891f5b913337bfb47483deb4ceebf06994ecec Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sun, 7 Jan 2024 15:56:02 -0800 Subject: [PATCH 7/9] Fix for undefined error on hex grids --- scripts/ruler.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/ruler.js b/scripts/ruler.js index c75e055..b4ddc90 100644 --- a/scripts/ruler.js +++ b/scripts/ruler.js @@ -226,6 +226,10 @@ function _highlightMeasurementSegment(wrapped, segment) { for ( const s of splitSegments ) { this.color = s.color; wrapped(s); + + // If gridless, highlight a rectangular shaped portion of the line. + + } this.color = priorColor; } @@ -536,9 +540,8 @@ function hexGridShape(p, { width = 1, height = 1 } = {}) { if ( width !== height ) return null; // Get the top left corner - const { getTopLeft, getBorderPolygon } = canvas.grid.grid; - const [tlx, tly] = getTopLeft(p.x, p.y); - const points = getBorderPolygon(width, height, 0); // TO-DO: Should a border be included to improve calc? + const [tlx, tly] = canvas.grid.grid.getTopLeft(p.x, p.y); + 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; for ( let i = 0; i < ln; i += 2) pointsTranslated.push(points[i] + tlx, points[i+1] + tly); From e6fb8ffadb6547b098e053ab44f711ad4c08cc61 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sun, 7 Jan 2024 17:18:10 -0800 Subject: [PATCH 8/9] Add highlighting for gridless --- scripts/ruler.js | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/scripts/ruler.js b/scripts/ruler.js index b4ddc90..489da6a 100644 --- a/scripts/ruler.js +++ b/scripts/ruler.js @@ -228,8 +228,19 @@ function _highlightMeasurementSegment(wrapped, segment) { wrapped(s); // If gridless, highlight a rectangular shaped portion of the line. - - + if ( canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ) { + const { A, B } = s.ray; + const width = Math.floor(canvas.scene.dimensions.size * 0.2); + const ptsA = perpendicularPoints(A, B, width * 0.5); + const ptsB = perpendicularPoints(B, A, width * 0.5); + const shape = new PIXI.Polygon([ + ptsA[0], + ptsA[1], + ptsB[0], + ptsB[1] + ]); + canvas.grid.highlightPosition(this.name, {color: this.color, shape}); + } } this.color = priorColor; } @@ -547,3 +558,20 @@ function hexGridShape(p, { width = 1, height = 1 } = {}) { for ( let i = 0; i < ln; i += 2) pointsTranslated.push(points[i] + tlx, points[i+1] + tly); return new PIXI.Polygon(pointsTranslated); } + +/** + * Get the two points perpendicular to line A --> B at A, a given distance from the line A --> B + * @param {PIXI.Point} A + * @param {PIXI.Point} B + * @param {number} distance + * @returns {[PIXI.Point, PIXI.Point]} Points on either side of A. + */ +function perpendicularPoints(A, B, distance = 1) { + const delta = B.subtract(A); + const pt0 = new PIXI.Point(A.x - delta.y, A.y + delta.x); + return [ + A.towardsPoint(pt0, distance), + A.towardsPoint(pt0, -distance) + ]; +} + From 668b0772342d9b04f25bf3f1fa13db10e2fb7b91 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sun, 7 Jan 2024 18:05:47 -0800 Subject: [PATCH 9/9] Add settings to toggle speed highlighting and set speed property --- languages/en.json | 7 +++++++ scripts/ruler.js | 6 ++++-- scripts/settings.js | 36 +++++++++++++++++++++++++++++++++--- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/languages/en.json b/languages/en.json index fe0b256..faec0e0 100644 --- a/languages/en.json +++ b/languages/en.json @@ -36,5 +36,12 @@ "elevationruler.settings.enable-token-ruler.name": "Use Token Ruler", "elevationruler.settings.enable-token-ruler.hint": "Display the ruler when dragging tokens.", + "elevationruler.settings.token-ruler-highlighting.name": "Use Token Speed Highlighting", + "elevationruler.settings.token-ruler-highlighting.hint": "When using the ruler, use different colors for token walk/dash/max distance.", + + "elevationruler.settings.token-speed-property.name": "Token Speed Property", + "elevationruler.settings.token-speed-property.hint": "For token speed highlighting, this is the property representing token speed.", + + "elevationruler.controls.prefer-token-elevation.name": "Prefer Token Elevation" } \ No newline at end of file diff --git a/scripts/ruler.js b/scripts/ruler.js index 489da6a..0112379 100644 --- a/scripts/ruler.js +++ b/scripts/ruler.js @@ -14,6 +14,7 @@ export const PATCHES = {}; PATCHES.BASIC = {}; PATCHES.TOKEN_RULER = {}; PATCHES.DRAG_RULER = {}; +PATCHES.SPEED_HIGHLIGHTING = {}; import { elevationAtOrigin, @@ -407,10 +408,11 @@ PATCHES.BASIC.WRAPS = { _getSegmentLabel, // Move token methods - _animateMovement, - _highlightMeasurementSegment + _animateMovement }; +PATCHES.SPEED_HIGHLIGHTING.WRAPS = { _highlightMeasurementSegment }; + PATCHES.BASIC.MIXES = { _animateSegment }; PATCHES.DRAG_RULER.WRAPS = { diff --git a/scripts/settings.js b/scripts/settings.js index c98fe93..ee683b0 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -5,7 +5,7 @@ canvas */ "use strict"; -import { MODULE_ID, MODULES_ACTIVE } from "./const.js"; +import { MODULE_ID, MODULES_ACTIVE, SPEED } from "./const.js"; import { ModuleSettingsAbstract } from "./ModuleSettingsAbstract.js"; import { PATCHER } from "./patching.js"; @@ -24,7 +24,8 @@ const SETTINGS = { PREFER_TOKEN_ELEVATION_CURRENT_VALUE: "prefer-token-elevation-current-value", TOKEN_RULER: { ENABLED: "enable-token-ruler", - RANGE_COLORS: "enable-token-ruler-colors" + SPEED_HIGHLIGHTING: "token-ruler-highlighting", + SPEED_PROPERTY: "token-speed-property" } }; @@ -137,9 +138,31 @@ export class Settings extends ModuleSettingsAbstract { onChange: value => this.toggleTokenRuler(value) }); + register(KEYS.TOKEN_RULER.SPEED_HIGHLIGHTING, { + name: localize(`${KEYS.TOKEN_RULER.SPEED_HIGHLIGHTING}.name`), + hint: localize(`${KEYS.TOKEN_RULER.SPEED_HIGHLIGHTING}.hint`), + scope: "user", + config: true, + default: false, + type: Boolean, + requiresReload: false, + onChange: value => this.toggleSpeedHighlighting(value) + }); + + register(KEYS.TOKEN_RULER.SPEED_PROPERTY, { + name: localize(`${KEYS.TOKEN_RULER.SPEED_PROPERTY}.name`), + hint: localize(`${KEYS.TOKEN_RULER.SPEED_PROPERTY}.hint`), + scope: "world", + config: true, + default: SPEED.ATTRIBUTE, + type: String, + onChange: value => this.setSpeedProperty(value) + }); + // 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)); } static registerKeybindings() { @@ -189,6 +212,13 @@ export class Settings extends ModuleSettingsAbstract { 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; } + } /**