diff --git a/Changelog.md b/Changelog.md index 7d61ca6..59d2bc0 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,16 @@ +# 0.9.6 +Added `CONFIG.elevationruler.tokenPathfindingBuffer`. This defaults to -1, allowing movement diagonally when at the corner of a large token and "Tokens Block" is set. For pathfinding, more negative numbers shrink the token border; positive numbers increase it. Note that if you change this, you will need to reload the scene or at least move the tokens before their pathfinding borders will be changed. Closes #88. +Move calculation of speed colors to `Ruler#_highlightMeasurementSegment`. As a result, Ruler segments are not split at movement speed category changes, so there are no longer extra waypoints added. +Remove settings related to determining when a terrain affects a token. Currently, Foundry regions only checks the center point. +For dnd5e, remove the Token HUD control to select movement, because dnd5e now uses status effects to signify when tokens are flying or burrowing. Added a setting to automatically determine token movement for dnd5e. +Updated Polish translation. Thanks @Lioheart! Closes #105. +Added `SCENE.BACKGROUND` to flags (imported from Terrain Mapper). Closes #107. +Don't broadcast the Token Ruler if the token being dragged is secret, invisible, or hidden. Closes #112. +Consolidate speed attributes to a single file. +Add ARS speed definitions. Closes #111. +Add sfrpg speed definitions. +Add a5e speed definitions. + # 0.9.5 Added Brazilian translation. Thanks @Kharmans! Added Russian translation. Thanks @VirusNik21! Closes #96. diff --git a/languages/en.json b/languages/en.json index 5714c6c..eddbd16 100644 --- a/languages/en.json +++ b/languages/en.json @@ -91,6 +91,9 @@ "elevationruler.settings.grid-terrain-area-threshold.name": "Percent Area Threshold", "elevationruler.settings.grid-terrain-area-threshold.hint": "If 'Percent Area' is selected for Terrain Grid Measurement, this determines the percent overlap required for the terrain/token to count when measuring on grids.", + "elevationruler.settings.automatic-movement-type.name": "Autodetect Movement Type", + "elevationruler.settings.automatic-movement-type.hint": "Automatically detect the movement type based on token position. Set the token status effect to 'fly' or 'burrowing' to override.", + "elevationruler.controls.prefer-token-elevation.name": "Prefer Token Elevation", "elevationruler.controls.pathfinding-control.name": "Use Pathfinding", diff --git a/languages/pl.json b/languages/pl.json index 7275b37..986eb7a 100644 --- a/languages/pl.json +++ b/languages/pl.json @@ -6,11 +6,17 @@ "elevationruler.keybindings.incrementElevation.name": "Zwiększenie wysokości linijki", "elevationruler.keybindings.incrementElevation.hint": "Zwiększa wysokość w odstępach siatki podczas korzystania z linijki.", + "elevationruler.keybindings.addWaypoint.name": "Dodanie punktu trasy zwykłej linijki", + "elevationruler.keybindings.addWaypoint.hint": "Podczas korzystania z linijki na płótnie dodaj punkt trasy.", + + "elevationruler.keybindings.removeWaypoint.name": "Usunięcie punktu trasy zwykłej linijki", + "elevationruler.keybindings.removeWaypoint.hint": "Podczas korzystania z linijki na płótnie usuń punkt trasy.", + "elevationruler.keybindings.addWaypointTokenRuler.name": "Dodanie punktu trasy dla tokena", - "elevationruler.keybindings.addWaypointTokenRuler.hint": "Gdy linijka tokena jest włączona, dodaje punkt trasy podczas przeciągania tokena.", + "elevationruler.keybindings.addWaypointTokenRuler.hint": "Gdy linijka tokenów jest włączona i przeciągasz token, dodaj punkt trasy.", "elevationruler.keybindings.removeWaypointTokenRuler.name": "Usunięcie punktu trasy tokena", - "elevationruler.keybindings.removeWaypointTokenRuler.hint": "Gdy linijka tokena jest włączona, usuwa punkt trasy podczas przeciągania tokena.", + "elevationruler.keybindings.removeWaypointTokenRuler.hint": "Gdy linijka tokenów jest włączona i przeciągasz token, usuń punkt trasy.", "elevationruler.keybindings.togglePathfinding.name": "Tymczasowe wyłączenie wyszukiwania ścieżek", "elevationruler.keybindings.togglePathfinding.hint": "Jeśli przycisk wyszukiwania ścieżki jest włączony, przytrzymanie tego przycisku spowoduje tymczasowe wyłączenie wyszukiwania ścieżki. Jeśli przycisk odnajdywania ścieżek nie jest włączony, przytrzymanie tego klawisza tymczasowo włączy odnajdywanie ścieżek.", @@ -33,6 +39,9 @@ "elevationruler.settings.enable-token-ruler.name": "Używanie linijki tokenów", "elevationruler.settings.enable-token-ruler.hint": "Wyświetlanie linijki podczas przeciągania tokenów.", + "elevationruler.settings.hide-gm-ruler.name": "Ukryj linijkę MG", + "elevationruler.settings.hide-gm-ruler.hint": "Nie wyświetlaj linijki tokena MG innym użytkownikom niebędącym MG.", + "elevationruler.settings.speed-highlighting-choice.name": "Używanie podświetlenia prędkości tokenów", "elevationruler.settings.speed-highlighting-choice.hint": "Podczas korzystania z linijki, używaj różnych kolorów dla tokenów chodu/dystansu/maksymalnego dystansu.", diff --git a/module.json b/module.json index 7f57d23..ac4e78c 100644 --- a/module.json +++ b/module.json @@ -8,7 +8,7 @@ "manifestPlusVersion": "1.0.0", "compatibility": { "minimum": "12", - "verified": "12.325" + "verified": "12.328" }, "authors": [ { diff --git a/scripts/MovePenalty.js b/scripts/MovePenalty.js index c148af0..9cf8432 100644 --- a/scripts/MovePenalty.js +++ b/scripts/MovePenalty.js @@ -466,10 +466,10 @@ export class TerrainMovePenaltyGridless extends MovePenaltyGridless { export class MovePenaltyGridded extends MovePenalty { /** @type {Settings.KEYS.GRID_TERRAIN.CHOICES} */ - static get griddedAlgorithm() { return Settings.get(Settings.KEYS.GRID_TERRAIN.ALGORITHM); } + static get griddedAlgorithm() { return "grid-terrain-choice-center-point"; } // return Settings.get(Settings.KEYS.GRID_TERRAIN.ALGORITHM); } /** @type {number} */ - static get percentAreaThreshold() { return Settings.get(Settings.KEYS.GRID_TERRAIN.AREA_THRESHOLD); } + static get percentAreaThreshold() { return 0.5; } // return Settings.get(Settings.KEYS.GRID_TERRAIN.AREA_THRESHOLD); } /** * Returns a penalty function for gridded moves. diff --git a/scripts/Ruler.js b/scripts/Ruler.js index 1b406c8..9777246 100644 --- a/scripts/Ruler.js +++ b/scripts/Ruler.js @@ -1,7 +1,6 @@ /* globals canvas, CONFIG, -CONST, game, PIXI, Ruler, @@ -17,7 +16,6 @@ PATCHES.SPEED_HIGHLIGHTING = {}; import { SPEED, MODULE_ID, FLAGS } from "./const.js"; import { Settings } from "./settings.js"; import { Ray3d } from "./geometry/3d/Ray3d.js"; -import { Point3d } from "./geometry/3d/Point3d.js"; import { elevationFromWaypoint, elevationAtWaypoint, @@ -40,8 +38,6 @@ import { PhysicalDistance } from "./PhysicalDistance.js"; import { MoveDistance } from "./MoveDistance.js"; -import { gridShape, pointFromGridCoordinates, canvasElevationFromCoordinates } from "./grid_coordinates.js"; - /** * Modified Ruler * Measure elevation change at each waypoint and destination. @@ -159,7 +155,7 @@ function _addWaypoint(point, {snap=true}={}) { // Determine the elevation up until this point if ( !this.waypoints.length ) { waypoint._prevElevation = this.token?.elevationE ?? canvas.scene.getFlag("terrainmapper", FLAGS.SCENE.BACKGROUND_ELEVATION) ?? 0; - waypoint._forceToGround ||= this.token.movementType === "WALK" + waypoint._forceToGround ||= this.token ? this.token.movementType === "WALK" : false; } else waypoint._prevElevation = elevationFromWaypoint(this.waypoints.at(-1), waypoint, this.token); this.waypoints.push(waypoint); @@ -274,7 +270,6 @@ function _computeDistance() { // Determine the distance of each segment. _computeSegmentDistances.call(this); - _computeTokenSpeed.call(this); // Always compute speed if there is a token b/c other users may get to see the speed. if ( debug ) { switch ( this.segments.length ) { @@ -334,7 +329,7 @@ function _computeSegmentDistances() { this.segments.at(-1).last = true; } for ( const segment of this.segments ) { - numPrevDiagonal = _measureSegment(segment, token, numPrevDiagonal); + numPrevDiagonal = measureSegment(segment, token, numPrevDiagonal); totalDistance += segment.distance; totalMoveDistance += segment.moveDistance; } @@ -351,7 +346,7 @@ function _computeSegmentDistances() { * @param {number} [numPrevDiagonal=0] Number of previous diagonals for the segment * @returns {number} numPrevDiagonal */ -function _measureSegment(segment, token, numPrevDiagonal = 0) { +export function measureSegment(segment, token, numPrevDiagonal = 0) { segment.numPrevDiagonal = numPrevDiagonal; const res = MoveDistance.measure( segment.ray.A, @@ -370,181 +365,23 @@ function _measureSegment(segment, token, numPrevDiagonal = 0) { // Also need to handle array of speed points. // Need CONFIG function that takes a token and gives array of speeds with colors. -/** - * Incrementally add together all segments. Split segment(s) at SpeedCategory maximum distances. - * Mark each segment with the distance, move distance, and SpeedCategory name. - * Does not assume segments have measurements, and modifies existing measurements. - * Segments modified in place. - */ -function _computeTokenSpeed() { - // Requires a movement token and a defined token speed. - const token = this.token; - if ( !token ) return; - - // Precalculate the token speed. - const tokenSpeed = SPEED.tokenSpeed(token); - if ( !tokenSpeed ) return; - - // Other constants - const gridless = canvas.grid.type === CONST.GRID_TYPES.GRIDLESS; - - // Variables changed in the loop - let totalDistance = 0; - let totalMoveDistance = 0; - let totalCombatMoveDistance = 0; - let minDistance = 0; - let numPrevDiagonal = 0; - let s = 0; - let segment; - - // Debugging - if ( CONFIG[MODULE_ID].debug ) { - if ( this.segments[0].moveDistance > 25 ) log(`${this.segments[0].moveDistance}`); - if ( this.segments[0].moveDistance > 30 ) log(`${this.segments[0].moveDistance}`); - if ( this.segments[0].moveDistance > 50 ) log(`${this.segments[0].moveDistance}`); - if ( this.segments[0].moveDistance > 60 ) log(`${this.segments[0].moveDistance}`); - } - - // Progress through each speed attribute in turn. - const categoryIter = [...SPEED.CATEGORIES].values(); - let speedCategory = categoryIter.next().value; - let maxDistance = SPEED.maximumCategoryDistance(token, speedCategory, tokenSpeed); - - // Determine which speed category we are starting with - // Add in already moved combat distance and determine the starting category - if ( game.combat?.started - && Settings.get(Settings.KEYS.SPEED_HIGHLIGHTING.COMBAT_HISTORY) ) { - - totalCombatMoveDistance = token.lastMoveDistance; - minDistance = totalCombatMoveDistance; - } - - while ( (segment = this.segments[s]) ) { - // Skip speed categories that do not provide a distance larger than the last. - while ( speedCategory && maxDistance <= minDistance ) { - speedCategory = categoryIter.next().value; - maxDistance = SPEED.maximumCategoryDistance(token, speedCategory, tokenSpeed); - } - if ( !speedCategory ) speedCategory = SPEED.CATEGORIES.at(-1); - - segment.speed = speedCategory; - let newPrevDiagonal = _measureSegment(segment, token, numPrevDiagonal); - - // If we have exceeded maxDistance, determine if a split is required. - const newDistance = totalCombatMoveDistance + segment.moveDistance; - - if ( newDistance > maxDistance || newDistance.almostEqual(maxDistance ) ) { - if ( newDistance > maxDistance ) { - // Split the segment, inserting the latter portion in the queue for future iteration. - const splitDistance = maxDistance - totalCombatMoveDistance; - const breakpoint = locateSegmentBreakpoint(segment, splitDistance, token, gridless); - if ( breakpoint ) { - const segments = _splitSegmentAt(segment, breakpoint); - this.segments.splice(s, 1, segments[0]); // Delete the old segment, replace. - this.segments.splice(s + 1, 0, segments[1]); // Add the split. - segment = segments[0]; - newPrevDiagonal = _measureSegment(segment, token, numPrevDiagonal); - } - } - - // Increment to the next speed category. - // Next category will be selected in the while loop above: first category to exceed minDistance. - minDistance = maxDistance; - } - - // Increment totals. - s += 1; - totalDistance += segment.distance; - totalMoveDistance += segment.moveDistance; - totalCombatMoveDistance += segment.moveDistance; - numPrevDiagonal = newPrevDiagonal; - } - this.totalDistance = totalDistance; - this.totalMoveDistance = totalMoveDistance; -} /** - * Determine the specific point at which to cut a ruler segment such that the first subsegment - * measures a specific incremental move distance. - * @param {RulerMeasurementSegment} segment Segment, with ray property, to split - * @param {number} incrementalMoveDistance Distance, in grid units, of the desired first subsegment move distance - * @param {Token} token Token to use when measuring move distance - * @returns {Point3d|null} - * If the incrementalMoveDistance is less than 0, returns null. - * If the incrementalMoveDistance is greater than segment move distance, returns null - * Otherwise returns the point at which to break the segment. + * Mixed wrap Ruler#_broadcastMeasurement + * For token ruler, don't broadcast the ruler if the token is invisible or disposition secret. */ -function locateSegmentBreakpoint(segment, splitMoveDistance, token, gridless) { - if ( splitMoveDistance <= 0 ) return null; - if ( !segment.moveDistance || splitMoveDistance > segment.moveDistance ) return null; - - // Attempt to move the split distance and determine the split location. - const { A, B } = segment.ray; - const res = Ruler.measureMoveDistance(A, B, - { token, gridless, useAllElevation: false, stopTarget: splitMoveDistance }); - - let breakpoint = pointFromGridCoordinates(res.endGridCoords); // We can get the exact split point. - if ( !gridless ) { - // We can get the end grid. - // Use halfway between the intersection points for this grid shape. - breakpoint = Point3d.fromObject(segmentGridHalfIntersection(breakpoint, A, B) ?? A); - if ( breakpoint === A ) breakpoint.z = A.z; - else breakpoint.z = canvasElevationFromCoordinates(res.endGridCoords); - } - - if ( breakpoint.almostEqual(B) || breakpoint.almostEqual(A) ) return null; - return breakpoint; +function _broadcastMeasurement(wrapped) { + // Don't broadcast invisible, hidden, or secret token movement when dragging. + if ( this._isTokenRuler && !this.token ) return; + if ( this._isTokenRuler + && (this.token.document.disposition === CONST.TOKEN_DISPOSITIONS.SECRET + || this.token.document.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE) + || this.token.document.isHidden) ) return; + + wrapped(); } -/** - * Cut a ruler segment at a specified point. Does not remeasure the resulting segments. - * Assumes without testing that the breakpoint lies on the segment between A and B. - * @param {RulerMeasurementSegment} segment Segment, with ray property, to split - * @param {Point3d} breakpoint Point to use when splitting the segments - * @returns [RulerMeasurementSegment, RulerMeasurementSegment] - */ -function _splitSegmentAt(segment, breakpoint) { - const { A, B } = segment.ray; - - // Split the segment into two at the break point. - const s0 = {...segment}; - s0.ray = new Ray3d(A, breakpoint); - s0.distance = null; - s0.moveDistance = null; - s0.numDiagonal = null; - - const s1 = {...segment}; - s1.ray = new Ray3d(breakpoint, B); - s1.distance = null; - s1.moveDistance = null; - s1.numPrevDiagonal = null; - s1.numDiagonal = null; - s1.speed = null; - - if ( segment.first ) { s1.first = false; } - if ( segment.last ) { s0.last = false; } - return [s0, s1]; -} - -/** - * For a given segment, locate its intersection at a grid shape. - * The intersection point is on the segment, halfway between the two intersections for the shape. - * @param {number[]} gridCoords - * @param {PIXI.Point} a - * @param {PIXI.Point} b - * @returns {PIXI.Point|undefined} Undefined if no intersection. If only one intersection, the - * endpoint contained within the shape. - */ -function segmentGridHalfIntersection(gridCoords, a, b) { - const shape = gridShape(gridCoords); - const ixs = shape.segmentIntersections(a, b); - if ( !ixs || ixs.length === 0 ) return null; - if ( ixs.length === 1 ) return shape.contains(a.x, a.y) ? a : b; - return PIXI.Point.midPoint(ixs[0], ixs[1]); -} - - // ----- NOTE: Event handling ----- // /** @@ -588,7 +425,7 @@ PATCHES.BASIC.WRAPS = { _onMoveKeyDown }; -PATCHES.BASIC.MIXES = { _animateMovement, _getMeasurementSegments }; +PATCHES.BASIC.MIXES = { _animateMovement, _getMeasurementSegments, _broadcastMeasurement }; PATCHES.BASIC.OVERRIDES = { _computeDistance, _animateSegment, _addWaypoint }; @@ -656,7 +493,6 @@ async function teleport(_context) { PATCHES.BASIC.METHODS = { incrementElevation, decrementElevation, - _computeTokenSpeed, teleport }; diff --git a/scripts/const.js b/scripts/const.js index 9529657..29f2db1 100644 --- a/scripts/const.js +++ b/scripts/const.js @@ -1,5 +1,4 @@ /* globals -Color, foundry, game, Hooks @@ -15,7 +14,10 @@ export const TEMPLATES = { export const FLAGS = { MOVEMENT_SELECTION: "selectedMovementType", - MOVEMENT_PENALTY: "movementPenalty" + MOVEMENT_PENALTY: "movementPenalty", + SCENE: { + BACKGROUND_ELEVATION: "backgroundElevation" + } }; export const MODULES_ACTIVE = { API: {} }; @@ -46,62 +48,9 @@ export const MOVEMENT_BUTTONS = { }; /** - * 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. -*/ - -/** - * @typedef {object} SpeedCategory - * - * Object that stores the name, multiplier, and color of a given speed category. - * Custom properties are permitted. The SpeedCategory is passed to SPEED.maximumCategoryDistance, - * which in turn can be defined to use custom properties to calculate the maximum distance for the category. - * - * @prop {Color} color Color used with ruler highlighting - * @prop {string} name Unique name of the category (relative to other SpeedCategories) - * @prop {number} [multiplier] This times the token movement equals the distance for this category + * Properties related to token speed measurement + * See system_attributes.js for Speed definitions for different systems. */ - -const WalkSpeedCategory = { - name: "Walk", - color: Color.from(0x00ff00), - multiplier: 1 -}; - -const DashSpeedCategory = { - name: "Dash", - color: Color.from(0xffff00), - multiplier: 2 -}; - -const MaximumSpeedCategory = { - name: "Maximum", - color: Color.from(0xff0000), - multiplier: Number.POSITIVE_INFINITY -} - export const SPEED = { /** * Object of strings indicating where on the actor to locate the given attribute. @@ -115,7 +64,7 @@ export const SPEED = { * in the first category is the next category considered. * @type {SpeedCategory[]} */ - CATEGORIES: [WalkSpeedCategory, DashSpeedCategory, MaximumSpeedCategory], + CATEGORIES: [], // Use Font Awesome font unicode instead of basic unicode for displaying terrain symbol. @@ -133,7 +82,6 @@ export const SPEED = { terrainSymbol: "🥾" }; - /** * Given a token, get the maximum distance the token can travel for a given type. * Distance measured from 0, so types overlap. E.g. @@ -162,116 +110,6 @@ SPEED.tokenSpeed = function(token) { return Number(speed); }; -// Avoid testing for the system id each time. -Hooks.once("init", function() { - SPEED.ATTRIBUTES.WALK = defaultWalkAttribute(); - SPEED.ATTRIBUTES.BURROW = defaultBurrowAttribute(); - SPEED.ATTRIBUTES.FLY = defaultFlyAttribute(); - DashSpeedCategory.multiplier = defaultDashMultiplier(); -}); - -export function defaultHPAttribute() { - switch ( game.system.id ) { - case "dnd5e": return "actor.system.attributes.hp.value"; - case "dragonbane": return "actor.system.hitpoints.value"; - case "twodsix": return "actor.system.hits.value"; - default: return "actor.system.attributes.hp.value"; - } -} - -export function defaultWalkAttribute() { - switch ( game.system.id ) { - case "CoC7": return "actor.system.attribs.mov.value"; - case "dcc": return "actor.system.attributes.speed.value"; - 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"; - case "dragonbane": return "actor.system.movement"; - case "twodsix": return "actor.system.movement.walk"; - default: return ""; - } -} - -export function defaultFlyAttribute() { - switch ( game.system.id ) { - // Missing attribute case "CoC7": - // Missing attribute case "dcc": - case "sfrpg": return "actor.system.attributes.flying.value"; - // Missing attribute case "dnd4e": - case "dnd5e": return "actor.system.attributes.movement.fly"; - // Missing attribute case "lancer": - case "pf1": - case "D35E": return "actor.system.attributes.speed.fly.total"; - // Missing attribute case "shadowrun5e": - // Missing attribute case "swade": - // Missing attribute case "ds4": - // Missing attribute case "splittermond": - // Missing attribute case "wfrp4e": - // Missing attribute case "crucible": - // Missing attribute case "dragonbane": - case "twodsix": return "actor.system.movement.fly"; - default: return ""; - } -} - -export function defaultBurrowAttribute() { - switch ( game.system.id ) { - // Missing attribute case "CoC7": - // Missing attribute case "dcc": - case "sfrpg": return "actor.system.attributes.burrowing.value"; - // Missing attribute case "dnd4e": - case "dnd5e": return "actor.system.attributes.movement.burrow"; - // Missing attribute case "lancer": - case "pf1": - case "D35E": return "actor.system.attributes.speed.burrow.total"; - // Missing attribute case "shadowrun5e": - // Missing attribute case "swade": - // Missing attribute case "ds4": - // Missing attribute case "splittermond": - // Missing attribute case "wfrp4e": - // Missing attribute case "crucible": - // Missing attribute case "dragonbane": - case "twodsix": return "actor.system.movement.burrow"; - default: 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 "dragonbane": - case "twodsix": - case "ds4": return 2; - - case "CoC7": return 5; - case "splittermond": return 3; - case "wfrp4e": return 2; - - case "crucible": - case "swade": return 0; - default: return 0; - } -} - -/* eslint-enable no-multi-spaces */ - /** * From Foundry v12 * The different rules to define and measure diagonal distance/cost in a square grid. diff --git a/scripts/module.js b/scripts/module.js index b2af8b9..1d8d78c 100644 --- a/scripts/module.js +++ b/scripts/module.js @@ -10,7 +10,8 @@ ui import { Settings } from "./settings.js"; import { initializePatching, PATCHER } from "./patching.js"; -import { MODULE_ID, MOVEMENT_TYPES, SPEED, MOVEMENT_BUTTONS, defaultHPAttribute } from "./const.js"; +import { MODULE_ID, MOVEMENT_TYPES, MOVEMENT_BUTTONS, SPEED } from "./const.js"; +import { defaultHPAttribute } from "./system_attributes.js"; import { registerGeometry } from "./geometry/registration.js"; // Grid coordinates @@ -109,6 +110,14 @@ Hooks.once("init", function() { */ gridlessHighlightWidthMultiplier: 0.2, + /** + * Amount, in pixels, to pad the token shape that is used when pathfinding around tokens. + * Negative amounts allow the pathfinding to move through outer border of the token. + * Positive amounts make tokens larger than they appear, creating a buffer. + * @type {number} + */ + tokenPathfindingBuffer: -1, + /** * Enable certain debug console logging and tests. * @type {boolean} diff --git a/scripts/patching.js b/scripts/patching.js index 4147023..89868f4 100644 --- a/scripts/patching.js +++ b/scripts/patching.js @@ -42,6 +42,7 @@ export function initializePatching() { PATCHER.registerGroup("TOKEN_RULER"); PATCHER.registerGroup("SPEED_HIGHLIGHTING"); PATCHER.registerGroup("MOVEMENT_TRACKING"); - PATCHER.registerGroup("MOVEMENT_SELECTION"); + + if ( game.system.id !== "dnd5e" ) PATCHER.registerGroup("MOVEMENT_SELECTION"); } diff --git a/scripts/pathfinding/Token.js b/scripts/pathfinding/Token.js index bbc24fb..aef4a4a 100644 --- a/scripts/pathfinding/Token.js +++ b/scripts/pathfinding/Token.js @@ -24,37 +24,6 @@ function createToken(document, _options, _userId) { Pathfinder.dirty = true; } -/** - * Hook updateToken to update the scene graph and triangulation. - * @param {Document} document The existing Document which was updated - * @param {object} change Differential data that was used to update the document - * @param {DocumentModificationContext} options Additional options which modified the update request - * @param {string} userId The ID of the User who triggered the update workflow - */ -function updateToken(document, changes, _options, _userId) { - // Only update the edges if the coordinates have changed. - if ( !(Object.hasOwn(changes, "x") || Object.hasOwn(changes, "y")) ) return; - - log(`updateToken hook|token moved.`); - -// // Easiest approach is to trash the edges for the wall and re-create them. -// SCENE_GRAPH.removeToken(document.id); -// -// /* Debugging: None of the edges should have this token. -// if ( CONFIG[MODULE_ID].debug ) { -// const token = document.object; -// SCENE_GRAPH.edges.forEach((edge, key) => { -// if ( edge.objects.has(token) ) console.debug(`Edge ${key} has ${token.name} ${token.id} after deletion.`); -// }) -// } -// */ -// -// SCENE_GRAPH.addToken(document.object); -// -// // Need to re-do the triangulation because the change to the wall could have added edges if intersected. -// Pathfinder.dirty = true; -} - /** * Hook refresh token to update the scene graph and triangulation. * Cannot use updateToken hook b/c the token position is not correctly updated by that point. @@ -94,4 +63,4 @@ function deleteToken(document, _options, _userId) { Pathfinder.dirty = true; } -PATCHES.PATHFINDING_TOKENS.HOOKS = { createToken, updateToken, deleteToken, refreshToken }; +PATCHES.PATHFINDING_TOKENS.HOOKS = { createToken, deleteToken, refreshToken }; diff --git a/scripts/pathfinding/WallTracer.js b/scripts/pathfinding/WallTracer.js index 755818d..0217a10 100644 --- a/scripts/pathfinding/WallTracer.js +++ b/scripts/pathfinding/WallTracer.js @@ -658,8 +658,13 @@ export class WallTracer extends Graph { const tokenId = token.id; if ( this.edges.has(tokenId) ) return; + // Pad the constrained token border as necessary. + const borderShape = token.constrainedTokenBorder; + const buffer = CONFIG[MODULE_ID].tokenPathfindingBuffer ?? 0; + if ( buffer ) borderShape.pad(buffer); + // Construct a new token edge set. - const edgeIter = token.constrainedTokenBorder.iterateEdges(); + const edgeIter = borderShape.iterateEdges(); for ( const edge of edgeIter ) this.addObjectEdge(edge.A, edge.B, token); this.tokenIds.add(tokenId); } diff --git a/scripts/segments.js b/scripts/segments.js index 09d62b3..bd22ac1 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -17,6 +17,7 @@ import { perpendicularPoints, log } from "./util.js"; import { Pathfinder, hasCollision } from "./pathfinding/pathfinding.js"; import { userElevationChangeAtWaypoint, elevationFromWaypoint, groundElevationAtWaypoint } from "./terrain_elevation.js"; import { MovePenalty } from "./MovePenalty.js"; +import { tokenSpeedSegmentSplitter } from "./token_speed.js"; /** * Mixed wrap of Ruler.prototype._getMeasurementSegments @@ -263,44 +264,66 @@ export async function _animateSegment(token, segment, destination) { } // ----- NOTE: Segment highlighting ----- // + +const TOKEN_SPEED_SPLITTER = new WeakMap(); + /** * Wrap Ruler.prototype._highlightMeasurementSegment + * @param {RulerMeasurementSegment} segment */ export function _highlightMeasurementSegment(wrapped, segment) { - // Temporarily ensure the ray distance is two-dimensional, so highlighting selects correct squares. + // Temporarily override cached ray.distance such that the ray distance is two-dimensional, + // so highlighting selects correct squares. // Otherwise the highlighting algorithm can get confused for high-elevation segments. segment.ray._distance = PIXI.Point.distanceBetween(segment.ray.A, segment.ray.B); // Adjust the color if this user has selected speed highlighting. - const priorColor = this.color; - const doSpeedHighlighting = segment.speed?.color && Settings.useSpeedHighlighting(this.token); - - // Highlight each split in turn, changing highlight color each time. - if ( doSpeedHighlighting ) this.color = segment.speed.color; + if ( Settings.useSpeedHighlighting(this.token) ) { + if ( segment.first ) TOKEN_SPEED_SPLITTER.set(this.token, tokenSpeedSegmentSplitter(this, this.token)) + const splitterFn = TOKEN_SPEED_SPLITTER.get(this.token); + if ( splitterFn ) { + const priorColor = this.color; + const segments = splitterFn(segment); + if ( segments.length ) { + for ( const segment of segments ) { + this.color = segment.speed.color; + segment.ray._distance = PIXI.Point.distanceBetween(segment.ray.A, segment.ray.B); + wrapped(segment); + + // If gridless, highlight a rectangular shaped portion of the line. + if ( canvas.grid.isGridless ) highlightLineRectangle(segment, this.color, this.name); + } + // Reset to the default color. + this.color = priorColor; + return; + } + } + } - // Call Foundry version and return if not speed highlighting. - const res = wrapped(segment); + wrapped(segment); segment.ray._distance = undefined; // Reset the distance measurement. - if ( !doSpeedHighlighting ) return res; - - // If gridless, highlight a rectangular shaped portion of the line. - if ( canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ) { - const { A, B } = segment.ray; - const width = Math.floor(canvas.scene.dimensions.size * (CONFIG[MODULE_ID].gridlessHighlightWidthMultiplier ?? 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.interface.grid.highlightPosition(this.name, { x: A.x, y: A.y, color: this.color, shape}); - } +} - // Reset to the default color. - this.color = priorColor; +/** + * Highlight a rectangular shaped portion of the line. + * For use on gridless maps where ruler does not highlight. + * @param {RulerMeasurementSegment} segment + * @param {Color} color Color to use + * @param {string} name Name of the ruler for tracking the highlight graphics + */ +function highlightLineRectangle(segment, color, name) { + const { A, B } = segment.ray; + const width = Math.floor(canvas.scene.dimensions.size * (CONFIG[MODULE_ID].gridlessHighlightWidthMultiplier ?? 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.interface.grid.highlightPosition(name, { x: A.x, y: A.y, color, shape}); } /** @@ -324,7 +347,6 @@ function elevateSegments(ruler, segments) { // Add destination as the final way const waypoints = [...ruler.waypoints, destWaypoint]; // Add the waypoint elevations to the corresponding segment endpoints. - let currWaypoint; for ( const segment of segments ) { const ray = segment.ray; const startWaypoint = waypoints.find(w => w.x === ray.A.x && w.y === ray.A.y); @@ -378,7 +400,6 @@ function segmentElevationLabel(s) { const increment = s.waypointElevationIncrement; const multiple = Settings.get(Settings.KEYS.TOKEN_RULER.ROUND_TO_MULTIPLE) || 1; const elevation = CONFIG.GeometryLib.utils.pixelsToGridUnits(s.ray.B.z).toNearest(multiple); - const Bz = s.ray.B.z.toNearest(multiple); const segmentArrow = (increment > 0) ? "↑" : (increment < 0) ? "↓" : "↕"; diff --git a/scripts/settings.js b/scripts/settings.js index 8c1e2cc..aa464fe 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -57,15 +57,17 @@ const SETTINGS = { } }, - GRID_TERRAIN: { - ALGORITHM: "grid-terrain-algorithm", - CHOICES: { - CENTER: "grid-terrain-choice-center-point", - PERCENT: "grid-terrain-choice-percent-area", - EUCLIDEAN: "grid-terrain-choice-euclidean" - }, - AREA_THRESHOLD: "grid-terrain-area-threshold" - } +// GRID_TERRAIN: { +// ALGORITHM: "grid-terrain-algorithm", +// CHOICES: { +// CENTER: "grid-terrain-choice-center-point", +// PERCENT: "grid-terrain-choice-percent-area", +// EUCLIDEAN: "grid-terrain-choice-euclidean" +// }, +// AREA_THRESHOLD: "grid-terrain-area-threshold" +// }, + + AUTO_MOVEMENT_TYPE: "automatic-movement-type" }; const KEYBINDINGS = { @@ -213,6 +215,19 @@ export class Settings extends ModuleSettingsAbstract { requiresReload: false }); + if ( game.system.id === "dnd5e" ) { + register(KEYS.AUTO_MOVEMENT_TYPE, { + name: localize(`${KEYS.AUTO_MOVEMENT_TYPE}.name`), + hint: localize(`${KEYS.AUTO_MOVEMENT_TYPE}.hint`), + scope: "user", + config: true, + default: true, + type: Boolean, + requiresReload: false + }); + } + + register(KEYS.TOKEN_RULER.ROUND_TO_MULTIPLE, { name: localize(`${KEYS.TOKEN_RULER.ROUND_TO_MULTIPLE}.name`), hint: localize(`${KEYS.TOKEN_RULER.ROUND_TO_MULTIPLE}.hint`), @@ -237,33 +252,33 @@ export class Settings extends ModuleSettingsAbstract { }); // ----- NOTE: Grid Terrain Measurement ----- // - register(KEYS.GRID_TERRAIN.ALGORITHM, { - name: localize(`${KEYS.GRID_TERRAIN.ALGORITHM}.name`), - hint: localize(`${KEYS.GRID_TERRAIN.ALGORITHM}.hint`), - scope: "world", - config: true, - default: KEYS.GRID_TERRAIN.CHOICES.CENTER, - type: String, - choices: { - [KEYS.GRID_TERRAIN.CHOICES.CENTER]: localize(`${KEYS.GRID_TERRAIN.CHOICES.CENTER}`), - [KEYS.GRID_TERRAIN.CHOICES.PERCENT]: localize(`${KEYS.GRID_TERRAIN.CHOICES.PERCENT}`), - [KEYS.GRID_TERRAIN.CHOICES.EUCLIDEAN]: localize(`${KEYS.GRID_TERRAIN.CHOICES.EUCLIDEAN}`) - } - }); - - register(KEYS.GRID_TERRAIN.AREA_THRESHOLD, { - name: localize(`${KEYS.GRID_TERRAIN.AREA_THRESHOLD}.name`), - hint: localize(`${KEYS.GRID_TERRAIN.AREA_THRESHOLD}.hint`), - scope: "world", - config: true, - default: 0.5, - type: Number, - range: { - min: 0.1, - max: 1, - step: 0.1 - } - }); +// register(KEYS.GRID_TERRAIN.ALGORITHM, { +// name: localize(`${KEYS.GRID_TERRAIN.ALGORITHM}.name`), +// hint: localize(`${KEYS.GRID_TERRAIN.ALGORITHM}.hint`), +// scope: "world", +// config: true, +// default: KEYS.GRID_TERRAIN.CHOICES.CENTER, +// type: String, +// choices: { +// [KEYS.GRID_TERRAIN.CHOICES.CENTER]: localize(`${KEYS.GRID_TERRAIN.CHOICES.CENTER}`), +// [KEYS.GRID_TERRAIN.CHOICES.PERCENT]: localize(`${KEYS.GRID_TERRAIN.CHOICES.PERCENT}`), +// [KEYS.GRID_TERRAIN.CHOICES.EUCLIDEAN]: localize(`${KEYS.GRID_TERRAIN.CHOICES.EUCLIDEAN}`) +// } +// }); +// +// register(KEYS.GRID_TERRAIN.AREA_THRESHOLD, { +// name: localize(`${KEYS.GRID_TERRAIN.AREA_THRESHOLD}.name`), +// hint: localize(`${KEYS.GRID_TERRAIN.AREA_THRESHOLD}.hint`), +// scope: "world", +// config: true, +// default: 0.5, +// type: Number, +// range: { +// min: 0.1, +// max: 1, +// step: 0.1 +// } +// }); } static registerKeybindings() { diff --git a/scripts/system_attributes.js b/scripts/system_attributes.js new file mode 100644 index 0000000..a9f0f06 --- /dev/null +++ b/scripts/system_attributes.js @@ -0,0 +1,323 @@ +/* globals +Color, +foundry, +game, +Hooks +*/ +"use strict"; + +import { SPEED } from "./const.js"; + +/** + * @typedef {object} SpeedCategory + * + * Object that stores the name, multiplier, and color of a given speed category. + * Custom properties are permitted. The SpeedCategory is passed to SPEED.maximumCategoryDistance, + * which in turn can be defined to use custom properties to calculate the maximum distance for the category. + * + * @prop {Color} color Color used with ruler highlighting + * @prop {string} name Unique name of the category (relative to other SpeedCategories) + * @prop {number} [multiplier] This times the token movement equals the distance for this category + */ + +const WalkSpeedCategory = { + name: "Walk", + color: Color.from(0x00ff00), + multiplier: 1 +}; + +const DashSpeedCategory = { + name: "Dash", + color: Color.from(0xffff00), + multiplier: 2 +}; + +const MaximumSpeedCategory = { + name: "Maximum", + color: Color.from(0xff0000), + multiplier: Number.POSITIVE_INFINITY +} + +Hooks.once("init", function() { + // Set the default speed parameters for the given system. + SPEED.ATTRIBUTES.WALK = defaultWalkAttribute(); + SPEED.ATTRIBUTES.BURROW = defaultBurrowAttribute(); + SPEED.ATTRIBUTES.FLY = defaultFlyAttribute(); + DashSpeedCategory.multiplier = defaultDashMultiplier(); + SPEED.CATEGORIES = [WalkSpeedCategory, DashSpeedCategory, MaximumSpeedCategory]; + + // Add specialized system categories + const moveCategoryFn = SPECIALIZED_MOVE_CATEGORIES[game.system.id]; + if ( moveCategoryFn ) moveCategoryFn(); + + // Add specialized category distance function + const categoryDistanceFn = SPECIALIZED_CATEGORY_DISTANCE[game.system.id]; + if ( categoryDistanceFn ) SPEED.maximumCategoryDistance = categoryDistanceFn; + + // Add specialized token speed function + const tokenSpeedFn = SPECIALIZED_TOKEN_SPEED[game.system.id]; + if ( tokenSpeedFn ) SPEED.tokenSpeed = tokenSpeedFn; +}); + +// ----- NOTE: Attributes ----- // + +/** + * Some of below taken from Drag Ruler + */ + +/** + * Location of the HP attribute for a given system's actor. + * @returns {string} + */ +export function defaultHPAttribute() { + switch ( game.system.id ) { + case "dnd5e": return "actor.system.attributes.hp.value"; + case "dragonbane": return "actor.system.hitpoints.value"; + case "twodsix": return "actor.system.hits.value"; + case "ars": return "actor.system.attributes.hp.value"; + case "a5e": return "actor.system.attributes.hp.value"; + default: return "actor.system.attributes.hp.value"; + } +} + +/** + * Location of the walk attribute for a given system's actor. + * @returns {string} + */ +export function defaultWalkAttribute() { + switch ( game.system.id ) { + case "a5e": return "actor.system.attributes.movement.walk.distance"; + case "ars": return "actor.movement"; + case "CoC7": return "actor.system.attribs.mov.value"; + case "dcc": return "actor.system.attributes.speed.value"; + 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"; + case "dragonbane": return "actor.system.movement"; + case "twodsix": return "actor.system.movement.walk"; + case "worldofdarkness": return "actor.system.movement.walk"; + default: return ""; + } +} + +/** + * Location of the flying attribute for a given system's actor. + * @returns {string} + */ +export function defaultFlyAttribute() { + switch ( game.system.id ) { + case "a5e": return "actor.system.attributes.movement.fly.distance"; + case "sfrpg": return "actor.system.attributes.flying.value"; + case "dnd5e": return "actor.system.attributes.movement.fly"; + case "pf1": + case "D35E": return "actor.system.attributes.speed.fly.total"; + case "twodsix": return "actor.system.movement.fly"; + case "worldofdarkness": return "actor.system.movement.fly"; + default: return ""; + } +} + +/** + * Location of the burrow attribute for a given system's actor. + * @returns {string} + */ +export function defaultBurrowAttribute() { + switch ( game.system.id ) { + case "a5e": return "actor.system.attributes.movement.burrow.distance"; + case "sfrpg": return "actor.system.attributes.burrowing.value"; + case "dnd5e": return "actor.system.attributes.movement.burrow"; + case "pf1": + case "D35E": return "actor.system.attributes.speed.burrow.total"; + case "twodsix": return "actor.system.movement.burrow"; + default: return ""; + } +} + +/** + * How much faster is dashing than walking for a given system? + * @returns {number} + */ +export function defaultDashMultiplier() { + switch ( game.system.id ) { + case "dcc": + case "dnd4e": + case "dnd5e": + case "lancer": + case "pf1": + case "D35E": + case "sfrpg": + case "shadowrun5e": + case "dragonbane": + case "twodsix": + case "a5e": + case "ds4": return 2; + + case "CoC7": return 5; + case "splittermond": return 3; + case "wfrp4e": return 2; + + case "crucible": + case "swade": return 0; + default: return 0; + } +} + +// ----- Specialized move categories by system ----- // +/** + * Dnd5e Level Up (a5e) + */ +function a5eMoveCategories() { + DashSpeedCategory.name = "Action Dash"; + const BonusDashCategory = { + name: "Bonus Dash", + color: Color.from(0xf77926), + multiplier: 4 + } + SPEED.CATEGORIES = [WalkSpeedCategory, DashSpeedCategory, BonusDashCategory, MaximumSpeedCategory]; +} + +/** + * sfrpg + */ +function sfrpgMoveCategories() { + WalkSpeedCategory.name = "sfrpg.speeds.walk"; + DashSpeedCategory.name = "sfrpg.speeds.dash"; + const RunSpeedCategory = { + name: "sfrpg.speeds.run", + color: Color.from(0xff8000), + multiplier: 4 + } + SPEED.CATEGORIES = [WalkSpeedCategory, DashSpeedCategory, RunSpeedCategory, MaximumSpeedCategory]; +} + +const SPECIALIZED_MOVE_CATEGORIES = { + a5e: a5eMoveCategories, + sfrpg: sfrpgMoveCategories +}; + +// ----- Specialized token speed by system ----- // + +/** + * Given a token, retrieve its base speed. + * @param {Token} token Token whose speed is required + * @returns {number|null} Distance, in grid units. Null if no speed provided for that category. + * (Null will disable speed highlighting.) + */ +function sfrpgTokenSpeed(token) { + let speed = foundry.utils.getProperty(token, SPEED.ATTRIBUTES[token.movementType]); + switch ( token.actor?.type ) { + case "starship": speed = foundry.utils.getProperty(token, "actor.system.attributes.speed.value"); break; + case "vehicle": speed = foundry.utils.getProperty(token, "actor.system.attributes.speed.drive"); break; + } + if ( speed == null ) return null; + return Number(speed); +} + +const SPECIALIZED_TOKEN_SPEED = { + sfrpg: sfrpgTokenSpeed +}; + +// ----- Specialized category distances by system ----- // + +/** + * Starfinder (sfrpg) + * Player Characters, Drones, and Non-player Characters: + * There are three speed thresholds: single move (speed * 1), double move (speed *2), and run (speed *4) + * Vehicles: There are three speed thresholds: drive speed, run over speed (drive speed *2), and full speed + * Starships: There are two speed thresholds: normal speed, and full power (speed * 1.5) + + * + * @param {Token} token Token whose speed should be used + * @param {SpeedCategory} speedCategory Category for which the maximum distance is desired + * @param {number} [tokenSpeed] Optional token speed to avoid repeated lookups + * @returns {number} + */ +function sfrpgCategoryDistance(token, speedCategory, tokenSpeed) { + // Set default speed. + tokenSpeed ??= SPEED.tokenSpeed(token); + const type = token.actor?.type; + let speed = speedCategory.multiplier * tokenSpeed; + + // Override default speed for certain vehicles. + switch ( speedCategory.name ) { + case "sfrpg.speeds.dash": { + if ( type === "starship" ) speed = tokenSpeed * 1.5; + break; + } + + case "sfrpg.speeds.run": { + if ( type === "starship" ) speed = 0; + if ( type === "vehicle" ) speed = foundry.utils.getProperty(token, "actor.system.attributes.speed.full"); + break; + } + } + return speed; +} + +const SPECIALIZED_CATEGORY_DISTANCE = { + sfrpg: sfrpgCategoryDistance +}; + + + +// ----- Note: Licenses / Credits ----- // + +/* Drag Ruler +https://github.com/manuelVo/foundryvtt-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. +*/ + +/* sfrpg +https://github.com/J-Dawe/starfinder-drag-ruler/blob/main/scripts/main.js +MIT License + +Copyright (c) 2021 J-Dawe + +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. +*/ diff --git a/scripts/token_hud.js b/scripts/token_hud.js index 8111ade..4712c39 100644 --- a/scripts/token_hud.js +++ b/scripts/token_hud.js @@ -1,5 +1,6 @@ /* globals -canvas +game, +Ruler */ "use strict"; /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ @@ -29,11 +30,12 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { MODULE_ID, FLAGS, MOVEMENT_TYPES, MOVEMENT_BUTTONS, MODULES_ACTIVE } from "./const.js"; -import { LevelsElevationAtPoint } from "./terrain_elevation.js"; +import { MODULE_ID, FLAGS, MOVEMENT_TYPES, MOVEMENT_BUTTONS } from "./const.js"; +import { Settings } from "./settings.js"; import { keyForValue } from "./util.js"; export const PATCHES = {}; +PATCHES.BASIC = {}; PATCHES.MOVEMENT_SELECTION = {}; /** @@ -55,26 +57,29 @@ PATCHES.MOVEMENT_SELECTION.HOOKS = { renderTokenHUD }; * @type {MOVEMENT_TYPE} */ function movementType() { - let selectedMovement = this.document.getFlag(MODULE_ID, FLAGS.MOVEMENT_SELECTION) ?? MOVEMENT_TYPES.AUTO; + // For dnd5e, use status types + if ( game.system.id === "dnd5e" ) { + if ( !this.actor?.statuses ) return "WALK"; + if ( this.actor.statuses.has("flying") ) return "FLY"; + if ( this.actor.statuses.has("burrow") ) return "BURROW"; + if ( Settings.get(Settings.KEYS.AUTO_MOVEMENT_TYPE) ) return determineMovementType(this); + return "WALK"; + } + + // Otherwise, use the Token HUD. + const selectedMovement = this.document.getFlag(MODULE_ID, FLAGS.MOVEMENT_SELECTION) ?? MOVEMENT_TYPES.AUTO; if ( selectedMovement === MOVEMENT_TYPES.AUTO ) return determineMovementType(this); return keyForValue(MOVEMENT_TYPES, selectedMovement); } -PATCHES.MOVEMENT_SELECTION.GETTERS = { movementType }; +PATCHES.BASIC.GETTERS = { movementType }; /** * Determine movement type based on this token's elevation. * @returns {MOVEMENT_TYPE} */ function determineMovementType(token) { - let groundElevation; - if ( MODULES_ACTIVE.ELEVATED_VISION ) { - const calc = new canvas.elevation.TokenElevationCalculator(token); - groundElevation = calc.groundElevation(); - } else if ( MODULES_ACTIVE.LEVELS ) { - groundElevation = LevelsElevationAtPoint(token.center, { startingElevation: token.elevationE }) ?? 0; - } else groundElevation = 0; - + const groundElevation = Ruler.terrainElevationAtLocation(token.center); return keyForValue(MOVEMENT_TYPES, Math.sign(token.elevationE - groundElevation) + 1); } diff --git a/scripts/token_speed.js b/scripts/token_speed.js new file mode 100644 index 0000000..7dc190b --- /dev/null +++ b/scripts/token_speed.js @@ -0,0 +1,185 @@ +/* globals +canvas, +CONST, +game, +PIXI, +Ruler +*/ +"use strict"; + +import { SPEED } from "./const.js"; +import { Settings } from "./settings.js"; +import { measureSegment } from "./Ruler.js"; +import { Point3d } from "./geometry/3d/Point3d.js"; +import { Ray3d } from "./geometry/3d/Ray3d.js"; +import { gridShape, pointFromGridCoordinates, canvasElevationFromCoordinates } from "./grid_coordinates.js"; + +// Functions used to determine token speed colors. + +/** + * Provides a function to track movement speed for a given group of segments. + * The returned function assumes each segment will be passed in order. + * @param {Ruler} ruler Reference to the ruler + * @param {Token} token Movement token + * @returns {function} Call this function to get the colors for this segment + * - @param {RulerMeasurementSegment} segment The next segment for the token move + * - @returns {RulerMeasurementSegment[]} The split segments, with colors identified for each + */ +export function tokenSpeedSegmentSplitter(ruler, token) { + const defaultColor = ruler.color; + + // Other constants + const gridless = canvas.grid.type === CONST.GRID_TYPES.GRIDLESS; + + // Variables changed in the loop + let totalCombatMoveDistance = 0; + let minDistance = 0; + let numPrevDiagonal = 0; + + // Precalculate the token speed. + const tokenSpeed = SPEED.tokenSpeed(token); + + // Progress through each speed attribute in turn. + const categoryIter = [...SPEED.CATEGORIES].values(); + let speedCategory = categoryIter.next().value; + let maxDistance = SPEED.maximumCategoryDistance(token, speedCategory, tokenSpeed); + + // Determine which speed category we are starting with + // Add in already moved combat distance and determine the starting category + if ( game.combat?.started + && Settings.get(Settings.KEYS.SPEED_HIGHLIGHTING.COMBAT_HISTORY) ) { + + totalCombatMoveDistance = token.lastMoveDistance; + minDistance = totalCombatMoveDistance; + } + + return segment => { + if ( !tokenSpeed ) { + segment.speed = defaultColor; + return [segment]; + } + + const processed = []; + const unprocessed = [segment] + while ( (segment = unprocessed.pop()) ) { + // Skip speed categories that do not provide a distance larger than the last. + while ( speedCategory && maxDistance <= minDistance ) { + speedCategory = categoryIter.next().value; + maxDistance = SPEED.maximumCategoryDistance(token, speedCategory, tokenSpeed); + } + if ( !speedCategory ) speedCategory = SPEED.CATEGORIES.at(-1); + + segment.speed = speedCategory; + let newPrevDiagonal = measureSegment(segment, token, numPrevDiagonal); + + // If we have exceeded maxDistance, determine if a split is required. + const newDistance = totalCombatMoveDistance + segment.moveDistance; + + if ( newDistance > maxDistance || newDistance.almostEqual(maxDistance ) ) { + if ( newDistance > maxDistance ) { + // Split the segment, inserting the latter portion in the queue for future iteration. + const splitDistance = maxDistance - totalCombatMoveDistance; + const breakpoint = locateSegmentBreakpoint(segment, splitDistance, token, gridless); + if ( breakpoint ) { + const segments = _splitSegmentAt(segment, breakpoint); + unprocessed.push(segments[1]); + segment = segments[0]; + newPrevDiagonal = measureSegment(segment, token, numPrevDiagonal); + } + } + + // Increment to the next speed category. + // Next category will be selected in the while loop above: first category to exceed minDistance. + minDistance = maxDistance; + } + + // Increment totals. + processed.push(segment); + totalCombatMoveDistance += segment.moveDistance; + numPrevDiagonal = newPrevDiagonal; + } + return processed; + }; +} + +/** + * Determine the specific point at which to cut a ruler segment such that the first subsegment + * measures a specific incremental move distance. + * @param {RulerMeasurementSegment} segment Segment, with ray property, to split + * @param {number} incrementalMoveDistance Distance, in grid units, of the desired first subsegment move distance + * @param {Token} token Token to use when measuring move distance + * @returns {Point3d|null} + * If the incrementalMoveDistance is less than 0, returns null. + * If the incrementalMoveDistance is greater than segment move distance, returns null + * Otherwise returns the point at which to break the segment. + */ +function locateSegmentBreakpoint(segment, splitMoveDistance, token, gridless) { + if ( splitMoveDistance <= 0 ) return null; + if ( !segment.moveDistance || splitMoveDistance > segment.moveDistance ) return null; + + // Attempt to move the split distance and determine the split location. + const { A, B } = segment.ray; + const res = Ruler.measureMoveDistance(A, B, + { token, gridless, useAllElevation: false, stopTarget: splitMoveDistance }); + + let breakpoint = pointFromGridCoordinates(res.endGridCoords); // We can get the exact split point. + if ( !gridless ) { + // We can get the end grid. + // Use halfway between the intersection points for this grid shape. + breakpoint = Point3d.fromObject(segmentGridHalfIntersection(breakpoint, A, B) ?? A); + if ( breakpoint === A ) breakpoint.z = A.z; + else breakpoint.z = canvasElevationFromCoordinates(res.endGridCoords); + } + + if ( breakpoint.almostEqual(B) || breakpoint.almostEqual(A) ) return null; + return breakpoint; +} + +/** + * Cut a ruler segment at a specified point. Does not remeasure the resulting segments. + * Assumes without testing that the breakpoint lies on the segment between A and B. + * @param {RulerMeasurementSegment} segment Segment, with ray property, to split + * @param {Point3d} breakpoint Point to use when splitting the segments + * @returns [RulerMeasurementSegment, RulerMeasurementSegment] + */ +function _splitSegmentAt(segment, breakpoint) { + const { A, B } = segment.ray; + + // Split the segment into two at the break point. + const s0 = {...segment}; + s0.ray = new Ray3d(A, breakpoint); + s0.distance = null; + s0.moveDistance = null; + s0.numDiagonal = null; + + const s1 = {...segment}; + s1.ray = new Ray3d(breakpoint, B); + s1.distance = null; + s1.moveDistance = null; + s1.numPrevDiagonal = null; + s1.numDiagonal = null; + s1.speed = null; + + if ( segment.first ) { s1.first = false; } + if ( segment.last ) { s0.last = false; } + return [s0, s1]; +} + +/** + * For a given segment, locate its intersection at a grid shape. + * The intersection point is on the segment, halfway between the two intersections for the shape. + * @param {number[]} gridCoords + * @param {PIXI.Point} a + * @param {PIXI.Point} b + * @returns {PIXI.Point|undefined} Undefined if no intersection. If only one intersection, the + * endpoint contained within the shape. + */ +function segmentGridHalfIntersection(gridCoords, a, b) { + const shape = gridShape(gridCoords); + const ixs = shape.segmentIntersections(a, b); + if ( !ixs || ixs.length === 0 ) return null; + if ( ixs.length === 1 ) return shape.contains(a.x, a.y) ? a : b; + return PIXI.Point.midPoint(ixs[0], ixs[1]); +} + +