import { currentSpeedProvider, getColorForDistanceAndToken, getRangesFromSpeedProvider, } from "./api.js"; import {highlightMeasurementTerrainRuler, measureDistances} from "./compatibility.js"; import {getGridPositionFromPixelsObj, getPixelsFromGridPositionObj} from "./foundry_fixes.js"; import {cancelScheduledMeasurement, highlightMeasurementNative} from "./foundry_imports.js"; import {disableSnap} from "./keybindings.js"; import {getMovementHistory} from "./movement_tracking.js"; import {settingsKey} from "./settings.js"; import { applyTokenSizeOffset, getSnapPointForEntity, getSnapPointForTokenObj, getEntityCenter, getTokenShape, isPathfindingEnabled, } from "./util.js"; import {getPointer} from "./util.js"; export function extendRuler() { class DragRulerRuler extends CONFIG.Canvas.rulerClass { // Functions below are overridden versions of functions in Ruler constructor(user, {color = null} = {}) { super(user, {color}); this.previousWaypoints = []; this.previousLabels = this.addChild(new PIXI.Container()); } clear() { super.clear(); this.previousWaypoints = []; this.previousLabels.removeChildren().forEach(c => c.destroy()); this.dragRulerRanges = undefined; cancelScheduledMeasurement.call(this); } async moveToken(event) { // Disable moveToken if Drag Ruler is active if (!this.isDragRuler) return await super.moveToken(event); } toJSON() { const json = super.toJSON(); if (this.draggedEntity) { const isToken = this.draggedEntity instanceof Token; json.draggedEntityIsToken = isToken; json.draggedEntity = this.draggedEntity.id; json.waypoints = json.waypoints.map(old => { let w = duplicate(old); w.isPathfinding = undefined; return w; }); } return json; } update(data) { // Don't show a GMs drag ruler to non GM players if ( data.draggedEntity && this.user.isGM && !game.user.isGM && !game.settings.get(settingsKey, "showGMRulerToPlayers") ) return; if (data.draggedEntity) { if (data.draggedEntityIsToken) this.draggedEntity = canvas.tokens.get(data.draggedEntity); else this.draggedEntity = canvas.templates.get(data.draggedEntity); } else { this.draggedEntity = undefined; } super.update(data); } measure(destination, options = {}) { if (!this.isDragRuler) { return super.measure(destination, options); } if (options.gridSpaces === undefined) { options.gridSpaces = canvas.grid.type !== CONST.GRID_TYPES.GRIDLESS; } this.dragRulerGridSpaces = options.gridSpaces; const isToken = this.draggedEntity instanceof Token; if (isToken && !this.draggedEntity.isVisible) { return []; } if (canvas.grid.diagonalRule === "EUCL") { options.gridSpaces = false; options.ignoreGrid = true; } if (options.ignoreGrid === undefined) { options.ignoreGrid = false; } this.dragRulerIgnoreGrid = options.ignoreGrid; // If this is the ruler of a remote user take the waypoints as they were transmitted and don't apply any additional snapping to them if (this.user !== game.user) { options.snap = false; } this.dragRulerSnap = options.snap ?? !disableSnap; this.dragRulerEnableTerrainRuler = isToken && window.terrainRuler; // Compute the measurement destination, segments, and distance const d = this._getMeasurementDestination(destination); if (d.x === this.destination.x && d.y === this.destination.y) return; this.destination = d; // TODO Check if we can reuse the old path this.dragRulerRemovePathfindingWaypoints(); if (this.pathfindingJob) { routinglib.cancelPathfinding(this.pathfindingJob); this.pathfindingJob = undefined; } if (isToken && isPathfindingEnabled.call(this)) { // TODO Show a busy indicator const from = getGridPositionFromPixelsObj(this.waypoints[this.waypoints.length - 1]); const to = getGridPositionFromPixelsObj(destination); const pathfindingJob = routinglib.calculatePath(from, to, {token: this.draggedEntity}); this.pathfindingJob = pathfindingJob; return this.pathfindingJob.then(result => { if (pathfindingJob === this.pathfindingJob) { this.pathfindingJob = undefined; this.addPathToWaypoints(result?.path); return this.performPostPathfindingActions(options); } }); } return this.performPostPathfindingActions(options); } addPathToWaypoints(path) { if (!path) { // TODO Show an indicator informing that there is no path // Don't show a path if the pathfinding yields no result to show the user that the destination is unreachable this.destination = this.waypoints[this.waypoints.length - 1]; return; } path = path.map(point => getSnapPointForTokenObj(getPixelsFromGridPositionObj(point), this.draggedEntity), ); // If the token is snapped to the grid, the first point of the path is already handled by the ruler if ( path[0].x === this.waypoints[this.waypoints.length - 1].x && path[0].y === this.waypoints[this.waypoints.length - 1].y ) { path = path.slice(1); } // If snapping is enabled, the last point of the path is already handled by the ruler if (this.dragRulerSnap) { path = path.slice(0, path.length - 1); } for (const point of path) { point.isPathfinding = true; this.labels.addChild(new PreciseText("", CONFIG.canvasTextStyle)); } this.waypoints = this.waypoints.concat(path); } performPostPathfindingActions(options) { // TODO Clear pathfinding busy indicator this.segments = this._getMeasurementSegments(); this._computeDistance(options.gridSpaces); // Draw the ruler graphic this.ruler.clear(); this._drawMeasuredPath(); // Draw grid highlight this.highlightLayer.clear(); const isToken = this.draggedEntity instanceof Token; if (isToken && canvas.grid.type !== CONST.GRID_TYPES.GRIDLESS && this.dragRulerGridSpaces) { const shape = getTokenShape(this.draggedEntity); if (!this.dragRulerEnableTerrainRuler) { for (const [i, segment] of [...this.segments].reverse().entries()) { const opacityMultiplier = segment.ray.isPrevious ? 0.33 : 1; const previousSegments = this.segments.slice(0, this.segments.length - 1 - i); highlightMeasurementNative.call( this, segment.ray, previousSegments, shape, opacityMultiplier, ); } } else { for (const segment of [...this.segments].reverse()) { const opacityMultiplier = segment.ray.isPrevious ? 0.33 : 1; highlightMeasurementTerrainRuler.call( this, segment.ray, segment.startDistance, shape, opacityMultiplier, ); } } } return this.segments; } _getMeasurementDestination(destination) { if (this.isDragRuler) { if (this.dragRulerSnap) { return getSnapPointForEntity(destination.x, destination.y, this.draggedEntity); } else { return destination; } } else { return super._getMeasurementDestination(destination); } } _getMeasurementSegments() { if (this.isDragRuler) { const unsnappedWaypoints = this.waypoints.concat([this.destination]); const waypoints = this.draggedEntity instanceof Token ? applyTokenSizeOffset(unsnappedWaypoints, this.draggedEntity) : duplicate(unsnappedWaypoints); const unsnappedSegments = []; const segments = []; for (const [i, p1] of waypoints.entries()) { if (i === 0) continue; const unsnappedP1 = unsnappedWaypoints[i]; const p0 = waypoints[i - 1]; const unsnappedP0 = unsnappedWaypoints[i - 1]; const label = this.labels.children[i - 1]; const ray = new Ray(p0, p1); const unsnappedRay = new Ray(unsnappedP0, unsnappedP1); ray.isPrevious = Boolean(unsnappedP0.isPrevious); unsnappedRay.isPrevious = ray.isPrevious; ray.dragRulerVisitedSpaces = unsnappedP0.dragRulerVisitedSpaces; unsnappedRay.dragRulerVisitedSpaces = ray.dragRulerVisitedSpaces; ray.dragRulerFinalState = unsnappedP0.dragRulerFinalState; unsnappedRay.dragRulerFinalState = ray.dragRulerFinalState; if (ray.distance < 10) { if (label) label.visible = false; continue; } segments.push({ray, label}); unsnappedSegments.push({ray: unsnappedRay, label}); } this.dragRulerUnsnappedSegments = unsnappedSegments; return segments; } else { return super._getMeasurementSegments(); } } _computeDistance(gridSpaces) { if (!this.isDragRuler) { return super._computeDistance(gridSpaces); } if (!this.dragRulerEnableTerrainRuler) { if (!this.dragRulerIgnoreGrid) { gridSpaces = true; } super._computeDistance(gridSpaces); } else { const shape = this.draggedEntity ? getTokenShape(this.draggedEntity) : null; const options = { ignoreGrid: this.dragRulerIgnoreGrid, gridSpaces, enableTerrainRuler: this.dragRulerEnableTerrainRuler, }; const distances = measureDistances(this.segments, this.draggedEntity, shape, options); let totalDistance = 0; for (const [i, d] of distances.entries()) { let s = this.segments[i]; s.startDistance = totalDistance; totalDistance += d; s.last = i === this.segments.length - 1; s.distance = d; s.text = this._getSegmentLabel(s, totalDistance); } } for (const [i, segment] of this.segments.entries()) { const unsnappedSegment = this.dragRulerUnsnappedSegments[i]; unsnappedSegment.startDistance = segment.startDistance; unsnappedSegment.last = segment.last; unsnappedSegment.distance = segment.distance; unsnappedSegment.text = segment.text; } } _drawMeasuredPath() { if (!this.isDragRuler) { return super._drawMeasuredPath(); } let rulerColor = this.color; if (!this.dragRulerGridSpaces || canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) { const totalDistance = this.segments.reduce((total, current) => total + current.distance, 0); rulerColor = this.dragRulerGetColorForDistance(totalDistance); } const r = this.ruler.beginFill(rulerColor, 0.25); for (const segment of this.dragRulerUnsnappedSegments) { const opacityMultiplier = segment.ray.isPrevious ? 0.33 : 1; const {ray, distance, label, text, last} = segment; if (distance === 0) continue; // Draw Line r.moveTo(ray.A.x, ray.A.y) .lineStyle(6, 0x000000, 0.5 * opacityMultiplier) .lineTo(ray.B.x, ray.B.y) .lineStyle(4, rulerColor, 0.25 * opacityMultiplier) .moveTo(ray.A.x, ray.A.y) .lineTo(ray.B.x, ray.B.y); // Draw Waypoints r.lineStyle(2, 0x000000, 0.5).drawCircle(ray.A.x, ray.A.y, 8); if (last) r.drawCircle(ray.B.x, ray.B.y, 8); // Draw Label if (label) { label.text = text; label.alpha = last ? 1.0 : 0.5; label.visible = true; let labelPosition = ray.project((ray.distance + 50) / ray.distance); label.position.set(labelPosition.x, labelPosition.y); } } r.endFill(); } _endMeasurement() { super._endMeasurement(); this.draggedEntity = null; if (this.pathfindingJob) { routinglib.cancelPathfinding(this.pathfindingJob); this.pathfindingJob = undefined; } } // The functions below aren't present in the orignal Ruler class and are added by Drag Ruler dragRulerAddWaypoint(point, options = {}) { options.snap = options.snap ?? true; if (options.snap) { point = getSnapPointForEntity(point.x, point.y, this.draggedEntity); } this.waypoints.push(new PIXI.Point(point.x, point.y)); this.labels.addChild(new PreciseText("", CONFIG.canvasTextStyle)); this.waypoints .filter(waypoint => waypoint.isPathfinding) .forEach(waypoint => (waypoint.isPathfinding = false)); } dragRulerAddWaypointHistory(waypoints) { waypoints.forEach(waypoint => (waypoint.isPrevious = true)); this.waypoints = this.waypoints.concat(waypoints); for (const waypoint of waypoints) { this.labels.addChild(new PreciseText("", CONFIG.canvasTextStyle)); } } dragRulerClearWaypoints() { this.waypoints = []; this.labels.removeChildren().forEach(c => c.destroy()); } dragRulerDeleteWaypoint( event = { preventDefault: () => { return; }, }, options = {}, ) { this.dragRulerRemovePathfindingWaypoints(); options.snap = options.snap ?? true; if (this.waypoints.filter(w => !w.isPrevious).length > 1) { event.preventDefault(); const mousePosition = getPointer().getLocalPosition(canvas.tokens); const rulerOffset = this.rulerOffset; // Options are not passed to _removeWaypoint in vanilla Foundry. // Send them in case other modules have overriden that behavior and accept an options parameter (Toggle Snap to Grid) this._removeWaypoint( {x: mousePosition.x + rulerOffset.x, y: mousePosition.y + rulerOffset.y}, options, ); game.user.broadcastActivity({ruler: this}); } else { this.dragRulerAbortDrag(event); } } dragRulerRemovePathfindingWaypoints() { this.waypoints .filter(waypoint => waypoint.isPathfinding) .forEach(_ => this.labels.removeChild(this.labels.children[this.labels.children.length - 1]).destroy(), ); this.waypoints = this.waypoints.filter(waypoint => !waypoint.isPathfinding); } dragRulerAbortDrag( event = { preventDefault: () => { return; }, }, ) { const token = this.draggedEntity; this._endMeasurement(); // Deactivate the drag workflow in mouse token.mouseInteractionManager.cancel(event); token.mouseInteractionManager.state = token.mouseInteractionManager.states.HOVER; // This will cancel the current drag operation // Pass in a fake event that hopefully is enough to allow other modules to function token._onDragLeftCancel(event); } async dragRulerRecalculate(tokenIds) { if (this._state !== Ruler.STATES.MEASURING) return; if (tokenIds && !tokenIds.includes(this.draggedEntity.id)) return; const waypoints = this.waypoints.filter(waypoint => !waypoint.isPrevious); this.dragRulerClearWaypoints(); if (game.settings.get(settingsKey, "enableMovementHistory")) this.dragRulerAddWaypointHistory(getMovementHistory(this.draggedEntity)); for (const waypoint of waypoints) { this.dragRulerAddWaypoint(waypoint, {snap: false}); } this.measure(this.destination); game.user.broadcastActivity({ruler: this}); } static dragRulerGetRaysFromWaypoints(waypoints, destination) { if (destination) waypoints = waypoints.concat([destination]); return waypoints.slice(1).map((wp, i) => { const ray = new Ray(waypoints[i], wp); ray.isPrevious = Boolean(waypoints[i].isPrevious); return ray; }); } dragRulerGetColorForDistance(distance) { if (!this.isDragRuler) return this.color; if (!this.draggedEntity.actor) { return this.color; } // Don't apply colors if the current user doesn't have at least observer permissions if (this.draggedEntity.actor.permission < 2) { // If this is a pc and alwaysShowSpeedForPCs is enabled we show the color anyway if ( !( this.draggedEntity.actor.type === "character" && game.settings.get(settingsKey, "alwaysShowSpeedForPCs") ) ) return this.color; } distance = Math.round(distance * 100) / 100; if (!this.dragRulerRanges) this.dragRulerRanges = getRangesFromSpeedProvider(this.draggedEntity); return ( getColorForDistanceAndToken(distance, this.draggedEntity, this.dragRulerRanges) ?? this.color ); } dragRulerStart(options, measureImmediately = true) { const entity = this.draggedEntity; const isToken = entity instanceof Token; if (isToken && !currentSpeedProvider.usesRuler(entity)) return; const ruler = canvas.controls.ruler; ruler.clear(); ruler._state = Ruler.STATES.STARTING; const entityCenter = getEntityCenter(this.draggedEntity); if (isToken && game.settings.get(settingsKey, "enableMovementHistory")) ruler.dragRulerAddWaypointHistory(getMovementHistory(entity)); ruler.dragRulerAddWaypoint(entityCenter, {snap: false}); const mousePosition = getPointer().getLocalPosition(canvas.tokens); const destination = { x: mousePosition.x + ruler.rulerOffset.x, y: mousePosition.y + ruler.rulerOffset.y, }; if (measureImmediately) ruler.measure(destination, options); } dragRulerSendState() { game.user.broadcastActivity({ ruler: this.toJSON(), }); } } CONFIG.Canvas.rulerClass = DragRulerRuler; }