diff --git a/Changelog.md b/Changelog.md index f75c342..acd41b3 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,7 @@ +# 0.7.4 +Add option to enable a Token Ruler when dragging tokens. +Add option to use a token speed attribute to highlight in differing colors when using the ruler (from a token) or dragging tokens. + # 0.7.3 Updated lib-geometry to 0.2.12. Refactor to use Patcher class. diff --git a/languages/en.json b/languages/en.json index 3008c35..faec0e0 100644 --- a/languages/en.json +++ b/languages/en.json @@ -5,6 +5,13 @@ "elevationruler.keybindings.incrementElevation.name": "Increment Ruler Elevation", "elevationruler.keybindings.incrementElevation.hint": "Increase the elevation in grid increments when using the ruler.", + "elevationruler.keybindings.addWaypointTokenRuler.name": "Add Token Ruler Waypoint", + "elevationruler.keybindings.addWaypointTokenRuler.hint": "When token ruler is enabled, add a waypoint while dragging the token.", + + "elevationruler.keybindings.removeWaypointTokenRuler.name": "Remove Token Ruler Waypoint", + "elevationruler.keybindings.removeWaypointTokenRuler.hint": "When token ruler is enabled, remove a waypoint while dragging the token.", + + "elevationruler.settings.no-modules-message.name": "No elevation-related modules found.", "elevationruler.settings.no-modules-message.hint": "Additional settings will be available here if Elevated Vision, Enhanced Terrain Layer, or Levels modules are active.", @@ -26,5 +33,15 @@ "elevationruler.settings.prefer-token-elevation.name": "Add token elevation control", "elevationruler.settings.prefer-token-elevation.hint": "Add a control to the token toolbar to prefer the token elevation when measuring. When toggled on, the control will keep the ruler at the elevation of the measuring token unless another token is encountered or the grid space is at a higher elevation than the token. Otherwise, the ruler will assume the elevation of the grid space or the token encountered.", + "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/ClientKeybindings.js b/scripts/ClientKeybindings.js new file mode 100644 index 0000000..98e839b --- /dev/null +++ b/scripts/ClientKeybindings.js @@ -0,0 +1,32 @@ +/* globals + +*/ +/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ + +import { elevationAtWaypoint } from "./segments.js"; + +// Patches for the ClientKeybindings class +export const PATCHES = {}; +PATCHES.TOKEN_RULER = {}; // Assume this patch is only present if the token ruler setting is enabled. + +/** + * Mixed wrap of ClientKeybindings._onMeasuredRulerMovement + * Called when spacebar is pressed, for ruler. + * If the Token Ruler is active, call that instead. + * @param {KeyboardEventContext} context The context data of the event + */ +async function _onMeasuredRulerMovement(wrapped, context) { + console.log("_onMeasuredRulerMovement"); + // We only care about when tokens are being dragged + const ruler = canvas.controls.ruler; + if ( !ruler.active + || !canvas.tokens.active + || ui.controls.tool !== "select" ) return wrapped(context); + + // For each controlled token, end the drag. + canvas.tokens.clearPreviewContainer(); + await ruler.moveToken(); + ruler._endMeasurement(); +} + +PATCHES.TOKEN_RULER.STATIC_WRAPS = { _onMeasuredRulerMovement } diff --git a/scripts/PlaceableObject.js b/scripts/PlaceableObject.js new file mode 100644 index 0000000..d7782b4 --- /dev/null +++ b/scripts/PlaceableObject.js @@ -0,0 +1,15 @@ +/* globals +canvas +*/ +/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ + +// Patches for the Token class +export const PATCHES = {}; +PATCHES.TOKEN_RULER = {}; // Assume this patch is only present if the token ruler setting is enabled. + + +function _onDragLeftStart(wrapped, event) { + console.log("Placeable.prototype._onDragLeftStart"); +} + +PATCHES.TOKEN_RULER = { _onDragLeftStart }; \ No newline at end of file diff --git a/scripts/Token.js b/scripts/Token.js index 38e41dd..a4ac6fe 100644 --- a/scripts/Token.js +++ b/scripts/Token.js @@ -8,6 +8,57 @@ import { elevationAtWaypoint } from "./segments.js"; // Patches for the Token class export const PATCHES = {}; PATCHES.DRAG_RULER = {}; +PATCHES.TOKEN_RULER = {}; // Assume this patch is only present if the token ruler setting is enabled. + +/** + * Wrap Token.prototype._onDragLeftStart + * Start a ruler measurement. + */ +function _onDragLeftStart(wrapped, event) { + wrapped(event); + + // Start a Ruler measurement. + canvas.controls.ruler._onDragStart(event); +} + +/** + * Wrap Token.prototype._onDragLeftMove + * Continue the ruler measurement + */ +function _onDragLeftMove(wrapped, event) { + wrapped(event); + + // Continue a Ruler measurement. + const ruler = canvas.controls.ruler; + if ( ruler._state > 0 ) ruler._onMouseMove(event); +} + +/** + * Mix Token.prototype._onDragLeftDrop + * End the ruler measurement. + */ +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); +} + + +PATCHES.TOKEN_RULER.WRAPS = { + _onDragLeftStart, + _onDragLeftMove +}; + +PATCHES.TOKEN_RULER.MIXES = { _onDragLeftDrop }; /** * Wrap Token.prototype._onDragLeftDrop @@ -17,7 +68,7 @@ PATCHES.DRAG_RULER = {}; * @param {PIXI.InteractionEvent} event The triggering canvas interaction event * @returns {Promise<*>} */ -async function _onDragLeftDrop(wrapped, event) { +async function _onDragLeftDropDragRuler(wrapped, event) { // Assume the destination elevation is the desired elevation if dragging multiple tokens. // (Likely more useful than having a bunch of tokens move down 10'?) const ruler = canvas.controls.ruler; @@ -40,4 +91,4 @@ async function _onDragLeftDrop(wrapped, event) { return true; } -PATCHES.DRAG_RULER.WRAPS = { _onDragLeftDrop }; +PATCHES.DRAG_RULER.WRAPS = { _onDragLeftDrop: _onDragLeftDropDragRuler }; diff --git a/scripts/const.js b/scripts/const.js index 3f82da1..49634d8 100644 --- a/scripts/const.js +++ b/scripts/const.js @@ -21,3 +21,98 @@ 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 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 ""; +} + +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/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/patching.js b/scripts/patching.js index f546ec8..d405bdb 100644 --- a/scripts/patching.js +++ b/scripts/patching.js @@ -8,14 +8,17 @@ import { MODULES_ACTIVE } from "./const.js"; import { PATCHES as PATCHES_Ruler } from "./Ruler.js"; import { PATCHES as PATCHES_Token } from "./Token.js"; import { PATCHES as PATCHES_GridLayer } from "./GridLayer.js"; +import { PATCHES as PATCHES_PlaceableObject } from "./PlaceableObject.js"; +import { PATCHES as PATCHES_ClientKeybindings } from "./ClientKeybindings.js"; // Settings import { PATCHES as PATCHES_Settings } from "./ModuleSettingsAbstract.js"; - const PATCHES = { + ClientKeybindings: PATCHES_ClientKeybindings, GridLayer: PATCHES_GridLayer, Ruler: PATCHES_Ruler, + PlaceableObject: PATCHES_PlaceableObject, Token: PATCHES_Token, Settings: PATCHES_Settings }; @@ -30,3 +33,4 @@ export function initializePatching() { export function registerDragRuler() { if ( MODULES_ACTIVE.DRAG_RULER ) PATCHER.registerGroup("DRAG_RULER"); } + diff --git a/scripts/ruler.js b/scripts/ruler.js index 9469bc7..0112379 100644 --- a/scripts/ruler.js +++ b/scripts/ruler.js @@ -1,13 +1,20 @@ /* globals canvas, -game +Color, +CONST, +game, +getProperty, +PIXI, +ui */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ // Patches for the Ruler class export const PATCHES = {}; PATCHES.BASIC = {}; +PATCHES.TOKEN_RULER = {}; PATCHES.DRAG_RULER = {}; +PATCHES.SPEED_HIGHLIGHTING = {}; import { elevationAtOrigin, @@ -21,6 +28,10 @@ import { _animateSegment } from "./segments.js"; +import { SPEED } from "./const.js"; +import { Ray3d } from "./geometry/3d/Ray3d.js"; +import { Point3d } from "./geometry/3d/Point3d.js"; + /** * Modified Ruler * Measure elevation change at each waypoint and destination. @@ -116,6 +127,23 @@ function _removeWaypoint(wrapper, point, { snap = true } = {}) { wrapper(point, { snap }); } +/** + * Wrap Ruler.prototype._animateMovement + * Add additional controlled tokens to the move, if permitted. + */ +async function _animateMovement(wrapped, token) { + const promises = [wrapped(token)]; + for ( const controlledToken of canvas.tokens.controlled ) { + if ( controlledToken === token ) continue; + if ( hasSegmentCollision(controlledToken, this.segments) ) { + ui.notifications.error(`${game.i18n.localize("RULER.MovementNotAllowed")} for ${controlledToken.name}`); + continue; + } + promises.push(wrapped(controlledToken)); + } + return Promise.allSettled(promises); +} + /** * Wrap DragRulerRuler.prototype.dragRulerAddWaypoint * Add elevation increments @@ -135,21 +163,218 @@ function dragRulerClearWaypoints(wrapper) { } +// ----- NOTE: Segment highlighting ----- // /** - * Wrap DragRulerRuler.prototype._endMeasurement - * If there is a dragged token, apply the elevation to all selected tokens (assumed part of the move). + * Wrap Ruler.prototype._highlightMeasurementSegment */ -function _endMeasurement(wrapped) { - console.debug("_endMeasurement"); - return wrapped(); +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); + + if ( segment.distance > walkDist ) { + console.debug(`${segment.distance}`); + } + + // 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[1]; // May be 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); + + // 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; } +/** + * 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) { + cutoffDistance -= pastDistance; + if ( cutoffDistance <= 0 ) return []; + if ( cutoffDistance >= segment.distance ) return [segment]; + + // Determine where on the segment ray the cutoff occurs. + // 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 ) { + 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 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]; + segment1.distance = distances[1]; + return segments; +} -function _postMove(wrapped, token) { - console.debug("_animateSegment"); - return wrapped(token); +/* + * 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'?) // const ruler = canvas.controls.ruler; @@ -171,9 +396,6 @@ function _postMove(wrapped, token) { // await t0.scene.updateEmbeddedDocuments(t0.constructor.embeddedName, updates); // return true; - - - PATCHES.BASIC.WRAPS = { clear, toJSON, @@ -186,13 +408,16 @@ PATCHES.BASIC.WRAPS = { _getSegmentLabel, // Move token methods - _animateSegment, - // _postMove + _animateMovement }; +PATCHES.SPEED_HIGHLIGHTING.WRAPS = { _highlightMeasurementSegment }; + +PATCHES.BASIC.MIXES = { _animateSegment }; + PATCHES.DRAG_RULER.WRAPS = { dragRulerAddWaypoint, - dragRulerClearWaypoints, + dragRulerClearWaypoints // _endMeasurement }; @@ -264,3 +489,91 @@ function addWaypointElevationIncrements(ruler, point) { newWaypoint._userElevationIncrements = ruler._userElevationIncrements; } } + +/** + * Check for token collision among the segments. + * Differs from Ruler.prototype._canMove because it adjusts for token position. + * See Ruler.prototype._animateMovement. + * @param {Token} token Token to test for collisions + * @param {object} segments Ruler segments to test + * @returns {boolean} True if a collision is found. + */ +function hasSegmentCollision(token, segments) { + const rulerOrigin = segments[0].ray.A; + const collisionConfig = { type: "move", mode: "any" }; + const s2 = canvas.scene.grid.type === CONST.GRID_TYPES.GRIDLESS ? 1 : (canvas.dimensions.size / 2); + let priorOrigin = { x: token.document.x, y: token.document.y }; + const dx = Math.round((priorOrigin.x - rulerOrigin.x) / s2) * s2; + const dy = Math.round((priorOrigin.y - rulerOrigin.y) / s2) * s2; + for ( const segment of segments ) { + const adjustedDestination = canvas.grid.grid._getRulerDestination(segment.ray, {x: dx, y: dy}, token); + collisionConfig.origin = priorOrigin; + if ( token.checkCollision(adjustedDestination, collisionConfig) ) return true; + priorOrigin = adjustedDestination; + } + 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 [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); + 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) + ]; +} + diff --git a/scripts/segments.js b/scripts/segments.js index 49bf065..93496b7 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -26,11 +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); } @@ -56,7 +51,8 @@ export async function _animateSegment(wrapped, token, segment, destination) { // Update elevation after the token move. if ( segment.ray.A.z !== segment.ray.B.z ) { - await token.document.update({ elevation: CONFIG.GeometryLib.utils.pixelsToGridUnits(segment.ray.B.z) }); + const elevation = CONFIG.GeometryLib.utils.pixelsToGridUnits(segment.ray.B.z); + await token.document.update({ elevation }); } return res; @@ -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 }); } diff --git a/scripts/settings.js b/scripts/settings.js index bf59198..ee683b0 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -5,8 +5,9 @@ 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"; const SETTINGS = { PREFER_TOKEN_ELEVATION: "prefer-token-elevation", @@ -20,12 +21,21 @@ const SETTINGS = { ALWAYS: "levels-labels-always" }, NO_MODS: "no-modules-message", - PREFER_TOKEN_ELEVATION_CURRENT_VALUE: "prefer-token-elevation-current-value" + PREFER_TOKEN_ELEVATION_CURRENT_VALUE: "prefer-token-elevation-current-value", + TOKEN_RULER: { + ENABLED: "enable-token-ruler", + SPEED_HIGHLIGHTING: "token-ruler-highlighting", + SPEED_PROPERTY: "token-speed-property" + } }; const KEYBINDINGS = { INCREMENT: "incrementElevation", - DECREMENT: "decrementElevation" + DECREMENT: "decrementElevation", + TOKEN_RULER: { + ADD_WAYPOINT: "addWaypointTokenRuler", + REMOVE_WAYPOINT: "removeWaypointTokenRuler" + } }; @@ -115,8 +125,45 @@ export class Settings extends ModuleSettingsAbstract { type: Boolean, requiresReload: false }); - } + // ----- NOTE: Token ruler ----- // + register(KEYS.TOKEN_RULER.ENABLED, { + name: localize(`${KEYS.TOKEN_RULER.ENABLED}.name`), + hint: localize(`${KEYS.TOKEN_RULER.ENABLED}.hint`), + scope: "user", + config: true, + default: false, + type: Boolean, + requiresReload: false, + 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() { game.keybindings.register(MODULE_ID, KEYBINDINGS.DECREMENT, { @@ -138,7 +185,40 @@ export class Settings extends ModuleSettingsAbstract { onDown: () => canvas.controls.ruler.incrementElevation(), precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL }); + + game.keybindings.register(MODULE_ID, KEYBINDINGS.TOKEN_RULER.ADD_WAYPOINT, { + name: game.i18n.localize(`${MODULE_ID}.keybindings.${KEYBINDINGS.TOKEN_RULER.ADD_WAYPOINT}.name`), + hint: game.i18n.localize(`${MODULE_ID}.keybindings.${KEYBINDINGS.TOKEN_RULER.ADD_WAYPOINT}.hint`), + editable: [ + { key: "=" } + ], + onDown: context => toggleTokenRulerWaypoint(context, true), + precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL + }); + + game.keybindings.register(MODULE_ID, KEYBINDINGS.TOKEN_RULER.REMOVE_WAYPOINT, { + name: game.i18n.localize(`${MODULE_ID}.keybindings.${KEYBINDINGS.TOKEN_RULER.REMOVE_WAYPOINT}.name`), + hint: game.i18n.localize(`${MODULE_ID}.keybindings.${KEYBINDINGS.TOKEN_RULER.REMOVE_WAYPOINT}.hint`), + editable: [ + { key: "-" } + ], + onDown: context => toggleTokenRulerWaypoint(context, false), + precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL + }); + } + + 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; } + } /** @@ -150,3 +230,27 @@ function reloadTokenControls() { canvas.tokens.deactivate(); canvas.tokens.activate(); } + +/** + * Add or remove a waypoint to the ruler, only if we are using the Token Ruler. + * @param {KeyboardEventContext} context The context data of the event + * @param {boolean} [add=true] Whether to add or remove the waypoint + */ +let MOVE_TIME = 0; +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`); + + // Keep track of when we last added/deleted a waypoint. + const now = Date.now(); + const delta = now - MOVE_TIME; + if ( delta < 100 ) return true; // Throttle keyboard movement once per 100ms + MOVE_TIME = now; + + console.debug(`${add ? "adding" : "removing"}TokenRulerWaypoint`); + if ( add ) ruler._addWaypoint(position); + else if ( ruler.waypoints.length > 1 ) ruler._removeWaypoint(position); // Removing the last waypoint throws errors. +} +