diff --git a/Changelog.md b/Changelog.md index 1f22d70..ace439b 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,10 @@ +# 0.10.15 +Fix for multiple token dragging resulting in token moving through a wall. Closes #224. +Correct error with Pathfinding when Wall Height 6.1.0 is active. Pathfinding will now assume vaulting is enabled if Wall Height is present. Closes #223. Thanks @drcormier! +Add speed method to handle dnd5e group actor speed (air, land). Closes #221. +Correct ruler measurement in gridless scenes after waypoint is placed. Closes #220. +Fix display of other user's ruler distances. Closes #219. + # 0.10.14 Fix for NaN in the move penalty calculation when moving over regions. diff --git a/scripts/Ruler.js b/scripts/Ruler.js index 3fbc401..bcef0fb 100644 --- a/scripts/Ruler.js +++ b/scripts/Ruler.js @@ -297,7 +297,7 @@ function _getMeasurementSegments(wrapped) { this.segments ??= []; for ( const s of this.segments ) { if ( !s.label ) continue; // Not every segment has a label. - s.label = this.labels.children[labelIndex++]; + s.label = this.labels.children[labelIndex++] ?? this.labels.addChild(new PreciseText("", CONFIG.canvasTextStyle)); } return this.segments; } @@ -596,7 +596,7 @@ async function _animateMovement(wrapped, token) { const promises = [wrapped(token)]; for ( const controlledToken of canvas.tokens.controlled ) { if ( controlledToken === token ) continue; - if ( !(this.user.isGM || this._canMove(controlledToken)) ) { + if ( !(this.user.isGM || testMovement.call(this, controlledToken)) ) { ui.notifications.error(`${game.i18n.localize("RULER.MovementNotAllowed")} for ${controlledToken.name}`); continue; } @@ -610,6 +610,23 @@ async function _animateMovement(wrapped, token) { return Promise.allSettled(promises); } +/** + * Helper to catch movement errors. Must use "call" or "bind" to bind it to the Ruler instance. + */ +function testMovement(token) { + let error; + try { + if ( !this._canMove(token) ) error = "RULER.MovementNotAllowed"; + } catch(err) { + error = err.message; + } + if ( error ) { + ui.notifications.error(error, {localize: true}); + return false; + } + return true; +} + /** * Wrap Ruler.prototype._getMeasurementHistory * Store the history temporarily in the token. @@ -656,6 +673,29 @@ function _createMeasurementHistory(wrapped) { */ function _canMove(wrapper, token) { if ( this.user.isGM ) return true; + if ( !wrapper(token) ) return false; + + // Adjust each segment for the difference from the original token position. + // Important when dragging multiple tokens. + // See Ruler#_animateMovement + const origin = this.segments[this.history.length].ray.A; + const dx = token.document.x - origin.x; + const dy = token.document.y - origin.y; + let {x, y} = token.document._source; + for ( const segment of this.segments ) { + if ( segment.history || (segment.ray.distance === 0) ) continue; + const r = segment.ray; + const adjustedDestination = {x: Math.round(r.B.x + dx), y: Math.round(r.B.y + dy)}; + const a = token.getCenterPoint({x, y}); + const b = token.getCenterPoint(adjustedDestination); + if ( token.checkCollision(b, {origin: a, type: "move", mode: "any"}) ) { + throw new Error("RULER.MovementCollision"); + return false; + } + x = adjustedDestination.x; + y = adjustedDestination.y; + } + return wrapper(token); } diff --git a/scripts/const.js b/scripts/const.js index 9a0008b..0a75a84 100644 --- a/scripts/const.js +++ b/scripts/const.js @@ -25,21 +25,21 @@ export const FLAGS = { // Track certain modules that complement features of this module. export const OTHER_MODULES = { - TERRAIN_MAPPER: { KEY: "terrainmapper" }, + TERRAIN_MAPPER: { KEY: "terrainmapper" }, LEVELS: { KEY: "levels" }, - WALL_HEIGHT: { KEY: "wall-height", FLAGS: { VAULTING: "blockSightMovement" } } -} + WALL_HEIGHT: { KEY: "wall-height" } +}; // Hook init b/c game.modules is not initialized at start. Hooks.once("init", function() { - for ( const obj of Object.values(OTHER_MODULES) ) obj.ACTIVE = game.modules.get(obj.KEY)?.active + for ( const obj of Object.values(OTHER_MODULES) ) obj.ACTIVE = game.modules.get(obj.KEY)?.active; }); // API not necessarily available until ready hook. (Likely added at init.) Hooks.once("ready", function() { const tm = OTHER_MODULES.TERRAIN_MAPPER; if ( tm.ACTIVE ) tm.API = game.modules.get(tm.KEY).api; -}) +}); export const MOVEMENT_TYPES = { diff --git a/scripts/measurement/Grid.js b/scripts/measurement/Grid.js index 072bab2..649cd2a 100644 --- a/scripts/measurement/Grid.js +++ b/scripts/measurement/Grid.js @@ -27,12 +27,13 @@ PATCHES_HexagonalGrid.BASIC = {}; * Wrap GridlessGrid#getDirectPath * Returns the sequence of grid offsets of a shortest, direct path passing through the given waypoints. * @param {RegionMovementWaypoint3d|GridCoordinates3d[]} waypoints The waypoints the path must pass through - * @returns {GridOffset[]} The sequence of grid offsets of a shortest, direct path + * @returns {GridCoordinates|GridCoordinates3d[]} The sequence of grid offsets of a shortest, direct path * @abstract */ function getDirectPathGridless(wrapped, waypoints) { + const GridCoordinates = CONFIG.GeometryLib.GridCoordinates; const offsets2d = wrapped(waypoints); - if ( !(waypoints[0] instanceof CONFIG.GeometryLib.threeD.Point3d) ) return offsets2d; + if ( !(waypoints[0] instanceof CONFIG.GeometryLib.threeD.Point3d) ) return offsets2d.map(o => GridCoordinates.fromOffset(o)); // 1-to-1 relationship between the waypoints and the offsets2d for gridless. const GridCoordinates3d = CONFIG.GeometryLib.threeD.GridCoordinates3d; @@ -48,13 +49,14 @@ function getDirectPathGridless(wrapped, waypoints) { * Wrap HexagonalGrid#getDirectPath and SquareGrid#getDirectPath * Returns the sequence of grid offsets of a shortest, direct path passing through the given waypoints. * @param {Point3d[]} waypoints The waypoints the path must pass through - * @returns {GridOffset[]} The sequence of grid offsets of a shortest, direct path + * @returns {GridCoordinates|GridCoordinates3d[]} The sequence of grid offsets of a shortest, direct path * @abstract */ function getDirectPathGridded(wrapped, waypoints) { - const { HexGridCoordinates3d, GridCoordinates3d } = CONFIG.GeometryLib.threeD; + const { HexGridCoordinates3d, GridCoordinates3d, Point3d } = CONFIG.GeometryLib.threeD; + const GridCoordinates = CONFIG.GeometryLib.GridCoordinates; + if ( !(waypoints[0] instanceof Point3d) ) return wrapped(waypoints).map(o => GridCoordinates.fromObject(o)); - if ( !(waypoints[0] instanceof CONFIG.GeometryLib.threeD.Point3d) ) return wrapped(waypoints); let prevWaypoint = GridCoordinates3d.fromObject(waypoints[0]); const path3d = []; const path3dFn = canvas.grid.isHexagonal ? HexGridCoordinates3d._directPathHex : GridCoordinates3d._directPathSquare; diff --git a/scripts/measurement/MovePenalty.js b/scripts/measurement/MovePenalty.js index 09e1727..ce594bb 100644 --- a/scripts/measurement/MovePenalty.js +++ b/scripts/measurement/MovePenalty.js @@ -283,7 +283,7 @@ export class MovePenalty { if ( this.#penaltyCache.has(key) ) { const res = this.#penaltyCache.get(key); log(`Using key ${key}: ${res}`); - console.groupEnd("movementCostForSegment"); + if ( CONFIG[MODULE_ID].debug ) console.groupEnd("movementCostForSegment"); return res; } @@ -294,7 +294,10 @@ export class MovePenalty { const isOneStep = Math.abs(endCoords.i - startCoords.i) < 2 && Math.abs(endCoords.j - startCoords.j) < 2 && Math.abs(endCoords.k - startCoords.k) < 2; - if ( isOneStep ) return this.movementCostForGridSpace(endCoords, costFreeDistance); + if ( isOneStep ) { + if ( CONFIG[MODULE_ID].debug ) console.groupEnd("movementCostForSegment"); + return this.movementCostForGridSpace(endCoords, costFreeDistance); + } // Unlikely scenario where endCoords are more than 1 step away from startCoords. let totalCost = 0; @@ -316,7 +319,7 @@ export class MovePenalty { this.#penaltyCache.set(key, res); const t1 = performance.now(); log(`Found cost ${res} in ${Math.round(t1 - t0)} ms`); - console.groupEnd("movementCostForSegment"); + if ( CONFIG[MODULE_ID].debug ) console.groupEnd("movementCostForSegment"); return res; } diff --git a/scripts/pathfinding/WallTracer.js b/scripts/pathfinding/WallTracer.js index 4527262..cab9622 100644 --- a/scripts/pathfinding/WallTracer.js +++ b/scripts/pathfinding/WallTracer.js @@ -383,7 +383,7 @@ export class WallTracerEdge extends GraphEdge { // If Wall Height vaulting is enabled, walls less than token vision height do not block. const wh = OTHER_MODULES.WALL_HEIGHT; - if ( wh.ACTIVE && game.settings.get(wh.KEY, wh.FLAGS.VAULTING) && moveToken.visionZ >= wall.topZ ) return false; + if ( wh.ACTIVE && moveToken.visionZ >= wall.topZ ) return false; return true; } @@ -408,7 +408,7 @@ export class WallTracerEdge extends GraphEdge { // Don't block dead tokens (HP <= 0). const { tokenHPAttribute, pathfindingIgnoreStatuses } = CONFIG[MODULE_ID]; let tokenHP = Number(foundry.utils.getProperty(token, tokenHPAttribute)); - + //DemonLord using damage system if ( game.system.id === 'demonlord') { diff --git a/scripts/system_attributes.js b/scripts/system_attributes.js index fa68e14..759779e 100644 --- a/scripts/system_attributes.js +++ b/scripts/system_attributes.js @@ -71,16 +71,18 @@ Hooks.once("init", function() { * @returns {string} */ export function defaultHPAttribute() { + /* eslint-disable no-multi-spaces */ 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"; - case "TheWitcherTRPG": return "actor.system.derivedStats.hp.value"; - case "gurps": return "actor.system.HP.value"; - default: return "actor.system.attributes.hp.value"; + 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"; + case "TheWitcherTRPG": return "actor.system.derivedStats.hp.value"; + case "gurps": return "actor.system.HP.value"; + default: return "actor.system.attributes.hp.value"; } + /* eslint-enable no-multi-spaces */ } /** @@ -88,31 +90,33 @@ export function defaultHPAttribute() { * @returns {string} */ export function defaultWalkAttribute() { + /* eslint-disable no-multi-spaces */ 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.speed"; - case "gurps": return "actor.system.currentmove"; + 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.speed"; + case "gurps": return "actor.system.currentmove"; 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.value"; - case "twodsix": return "actor.system.movement.walk"; + 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.value"; + case "twodsix": return "actor.system.movement.walk"; case "worldofdarkness": return "actor.system.movement.walk"; - case "TheWitcherTRPG": return "actor.system.stats.spd.current"; - case "demonlord": return "actor.system.characteristics.speed"; - default: return ""; + case "TheWitcherTRPG": return "actor.system.stats.spd.current"; + case "demonlord": return "actor.system.characteristics.speed"; + default: return ""; } + /* eslint-enable no-multi-spaces */ } /** @@ -120,17 +124,19 @@ export function defaultWalkAttribute() { * @returns {string} */ export function defaultFlyAttribute() { + /* eslint-disable no-multi-spaces */ 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 "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 "D35E": return "actor.system.attributes.speed.fly.total"; + case "twodsix": return "actor.system.movement.fly"; case "worldofdarkness": return "actor.system.movement.fly"; - case "gurps": return "actor.system.currentflight"; - default: return ""; + case "gurps": return "actor.system.currentflight"; + default: return ""; } + /* eslint-enable no-multi-spaces */ } /** @@ -138,6 +144,7 @@ export function defaultFlyAttribute() { * @returns {string} */ export function defaultBurrowAttribute() { + /* eslint-disable no-multi-spaces */ switch ( game.system.id ) { case "a5e": return "actor.system.attributes.movement.burrow.distance"; case "sfrpg": return "actor.system.attributes.burrowing.value"; @@ -147,6 +154,7 @@ export function defaultBurrowAttribute() { case "twodsix": return "actor.system.movement.burrow"; default: return ""; } + /* eslint-enable no-multi-spaces */ } /** @@ -154,6 +162,7 @@ export function defaultBurrowAttribute() { * @returns {number} */ export function defaultDashMultiplier() { + /* eslint-disable no-multi-spaces */ switch ( game.system.id ) { case "dcc": case "dnd4e": @@ -167,18 +176,18 @@ export function defaultDashMultiplier() { case "twodsix": case "a5e": case "demonlord": - case "ds4": return 2; - - case "CoC7": return 5; - case "splittermond": return 3; - case "wfrp4e": return 2; - case "gurps": return 1.2; + case "ds4": return 2; + case "CoC7": return 5; + case "splittermond": return 3; + case "wfrp4e": return 2; + case "gurps": return 1.2; case "crucible": - case "swade": return 0; - case "TheWitcherTRPG": return 3; - default: return 0; + case "swade": return 0; + case "TheWitcherTRPG": return 3; + default: return 0; } + /* eslint-enable no-multi-spaces */ } // ----- Specialized move categories by system ----- // @@ -191,12 +200,12 @@ function a5eSpeedCategories() { name: "Bonus Dash", color: Color.from(0xf77926), multiplier: 4 - } + }; SPEED.CATEGORIES = [WalkSpeedCategory, DashSpeedCategory, BonusDashCategory, MaximumSpeedCategory]; } /** - * sfrpg + * SFRPG */ function sfrpgSpeedCategories() { WalkSpeedCategory.name = "sfrpg.speeds.walk"; @@ -205,44 +214,44 @@ function sfrpgSpeedCategories() { name: "sfrpg.speeds.run", color: Color.from(0xff8000), multiplier: 4 - } + }; SPEED.CATEGORIES = [WalkSpeedCategory, DashSpeedCategory, RunSpeedCategory, MaximumSpeedCategory]; } /** - * pf2e + * PF2E * See https://github.com/7H3LaughingMan/pf2e-elevation-ruler/blob/main/scripts/module.js */ function pf2eSpeedCategories() { const SingleAction = { - name: "Single Action", - color: Color.from("#3222C7"), - multiplier: 1 - } + name: "Single Action", + color: Color.from("#3222C7"), + multiplier: 1 + }; const DoubleAction = { - name: "Double Action", - color: Color.from("#FFEC07"), - multiplier: 2 - } + name: "Double Action", + color: Color.from("#FFEC07"), + multiplier: 2 + }; const TripleAction = { - name: "Triple Action", - color: Color.from("#C033E0"), - multiplier: 3 - } + name: "Triple Action", + color: Color.from("#C033E0"), + multiplier: 3 + }; const QuadrupleAction = { - name: "Quadruple Action", - color: Color.from("#1BCAD8"), - multiplier: 4 - } + name: "Quadruple Action", + color: Color.from("#1BCAD8"), + multiplier: 4 + }; const Unreachable = { - name: "Unreachable", - color: Color.from("#FF0000"), - multiplier: Number.POSITIVE_INFINITY - } + name: "Unreachable", + color: Color.from("#FF0000"), + multiplier: Number.POSITIVE_INFINITY + }; SPEED.CATEGORIES = [SingleAction, DoubleAction, TripleAction, QuadrupleAction, Unreachable]; } @@ -256,6 +265,29 @@ const SPECIALIZED_SPEED_CATEGORIES = { // ----- Specialized token speed by system ----- // +/** + * dnd5e + * Handles group tokens. + * @param {Token} token Token whose speed is required + * @param {MOVEMENT_TYPES} [movementType] Type of movement; if omitted automatically determined + * @returns {number|null} Distance, in grid units. Null if no speed provided for that category. + * (Null will disable speed highlighting.) + */ +function dnd5eTokenSpeed(token, movementType) { + movementType ??= token.movementType; + let speed = null; + switch ( token.actor?.type ) { + case "group": { + if ( movementType === MOVEMENT_TYPES.WALK ) speed = foundry.utils.getProperty(token, "actor.system.attributes.movement.land"); + else if ( movementType === MOVEMENT_TYPES.FLY ) speed = foundry.utils.getProperty(token, "actor.system.attributes.movement.air"); + break; + } + default: speed = foundry.utils.getProperty(token, SPEED.ATTRIBUTES[keyForValue(MOVEMENT_TYPES, movementType)]); + } + if ( speed == null ) return null; + return Number(speed); +} + /** * sfrpg * Given a token, retrieve its base speed. @@ -276,7 +308,7 @@ function sfrpgTokenSpeed(token, movementType) { } /** - * pf2e + * PF2E * See https://github.com/7H3LaughingMan/pf2e-elevation-ruler/blob/main/scripts/module.js * Finds walk, fly, burrow values. * @param {Token} token Token whose speed is required @@ -291,21 +323,22 @@ function pf2eTokenSpeed(token, movementType) { switch (movementType) { case MOVEMENT_TYPES.WALK: speed = tokenSpeed.total; break; case MOVEMENT_TYPES.FLY: { - const flySpeed = tokenSpeed.otherSpeeds.find(x => x.type == "fly"); + const flySpeed = tokenSpeed.otherSpeeds.find(x => x.type === "fly"); if ( typeof flySpeed !== "undefined" ) speed = flySpeed.total; break; } case MOVEMENT_TYPES.BURROW: { - const burrowSpeed = tokenSpeed.otherSpeeds.find(x => x.type == "burrow"); + const burrowSpeed = tokenSpeed.otherSpeeds.find(x => x.type === "burrow"); if ( typeof burrowSpeed !== "undefined" ) speed = burrowSpeed.total; break; } - }; + } if (speed === null) return null; return Number(speed); } const SPECIALIZED_TOKEN_SPEED = { + dnd5e: dnd5eTokenSpeed, sfrpg: sfrpgTokenSpeed, pf2e: pf2eTokenSpeed }; @@ -410,7 +443,7 @@ function getActionCount(token) { // Check to see if there is an encounter, if that encounter is active, and if the token is in that encounter if ( game.combat == null || !game.combat.active - || (game.combat.turns.find(x => x.tokenId == token.id) == null) ) return maxActions; + || (game.combat.turns.find(x => x.tokenId == token.id) == null) ) return maxActions; // eslint-disable-line eqeqeq // Check to see if the actor is stunned or slowed, and if so the value const stunned = actor.getCondition("stunned")?.value ?? 0; @@ -420,15 +453,15 @@ function getActionCount(token) { // Check to see if PF2e Workbench is active and if Auto Reduce Stunned is enabled let reduction = 0; if ( game.modules.get("xdy-pf2e-workbench")?.active && game.settings.get("xdy-pf2e-workbench", "autoReduceStunned") ) { - const stunReduction = actor.getFlag("xdy-pf2e-workbench", "stunReduction"); - - // Make sure we actually got something and the combat matches. - if ( stunReduction && stunReduction.combat == game.combat.id ) { - // We are going to check to see if the combatant's last round matches the stun reduction round - // Note - A combatant's last round is updated at the start of their turn - const combatant = game.combat.turns.find(x => x.tokenId == token.id); - if ( combatant && combatant.roundOfLastTurn == stunReduction.round ) reduction = stunReduction.reducedBy; - } + const stunReduction = actor.getFlag("xdy-pf2e-workbench", "stunReduction"); + + // Make sure we actually got something and the combat matches. + if ( stunReduction && stunReduction.combat === game.combat.id ) { + // We are going to check to see if the combatant's last round matches the stun reduction round + // Note - A combatant's last round is updated at the start of their turn + const combatant = game.combat.turns.find(x => x.tokenId === token.id); + if ( combatant && combatant.roundOfLastTurn === stunReduction.round ) reduction = stunReduction.reducedBy; + } } // Return the token's maximum number of actions minus the greater of their stunned, slowed, or stun reduction.