diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b77e17..2091036 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # upcoming -- feature: Support the system's new concentration rolls with sources and messages (system already handles advantage/disadvantage) +- feature: Support the system's new concentration rolls with sources and messages with `flags.adv-reminder.message.ability.concentration` (system already handles advantage/disadvantage) +- feature: Support `flags.midi-qol.grants.critical.range` for conditions like Paralyzed that turn hits into crits if the attacker is adjacent. Does not currently work with Ready Set Roll, will just be ignored. # 3.4.1 diff --git a/src/reminders.js b/src/reminders.js index 2ae2d98..093e266 100644 --- a/src/reminders.js +++ b/src/reminders.js @@ -251,13 +251,28 @@ export class DeathSaveReminder extends AbilityBaseReminder { } export class CriticalReminder extends BaseReminder { - constructor(actor, targetActor, item) { + constructor(actor, targetActor, item, distanceFn) { super(actor); /** @type {object} */ this.targetFlags = this._getFlags(targetActor); /** @type {string} */ this.actionType = item.system.actionType; + + // get the Range directly from the actor's flags + if (targetActor) { + const grantsCriticalRange = + getProperty(targetActor, "flags.midi-qol.grants.critical.range") || -Infinity; + this._adjustRange(distanceFn, grantsCriticalRange); + } + } + + _adjustRange(distanceFn, grantsCriticalRange) { + // adjust the Range flag to look like a boolean like the rest + if ("grants.critical.range" in this.targetFlags) { + const distance = distanceFn(); + this.targetFlags["grants.critical.range"] = distance <= grantsCriticalRange; + } } updateOptions(options, critProp = "critical") { @@ -269,7 +284,11 @@ export class CriticalReminder extends BaseReminder { // build the active effect keys applicable for this roll const critKeys = ["critical.all", `critical.${this.actionType}`]; const normalKeys = ["noCritical.all", `noCritical.${this.actionType}`]; - const grantsCritKeys = ["grants.critical.all", `grants.critical.${this.actionType}`]; + const grantsCritKeys = [ + "grants.critical.all", + `grants.critical.${this.actionType}`, + "grants.critical.range", + ]; const grantsNormalKeys = ["fail.critical.all", `fail.critical.${this.actionType}`]; // find matching keys and update options diff --git a/src/rollers/core.js b/src/rollers/core.js index 6981a03..a44eab4 100644 --- a/src/rollers/core.js +++ b/src/rollers/core.js @@ -26,7 +26,7 @@ import { SkillSource, } from "../sources.js"; import { showSources } from "../settings.js"; -import { debug, getTarget } from "../util.js"; +import { debug, getDistanceToTargetFn, getTarget } from "../util.js"; /** * Setup the dnd5e.preRoll hooks for use with the core roller. @@ -146,10 +146,11 @@ export default class CoreRollerHooks { if (this.isFastForwarding(config)) return; const target = getTarget(); + const distanceFn = getDistanceToTargetFn(config.messageData.speaker); new DamageMessage(item.actor, target, item).addMessage(config); - if (showSources) new CriticalSource(item.actor, target, item).updateOptions(config); - new CriticalReminder(item.actor, target, item).updateOptions(config); + if (showSources) new CriticalSource(item.actor, target, item, distanceFn).updateOptions(config); + new CriticalReminder(item.actor, target, item, distanceFn).updateOptions(config); } /** diff --git a/src/rollers/midi.js b/src/rollers/midi.js index 5afe23c..0150601 100644 --- a/src/rollers/midi.js +++ b/src/rollers/midi.js @@ -99,8 +99,15 @@ export default class MidiRollerHooks extends CoreRollerHooks { if (this.isFastForwarding(config)) return; const target = getTarget(); + // use distance from Midi's Workflow + const distanceFn = () => { + const workflow = MidiQOL.Workflow.getWorkflow(item.uuid); + if (!workflow) return Infinity; + const firstTarget = workflow.hitTargets.values().next().value; + return MidiQOL.computeDistance(firstTarget, workflow.token, false); + } new DamageMessage(item.actor, target, item).addMessage(config); - if (showSources) new CriticalSource(item.actor, target, item).updateOptions(config); + if (showSources) new CriticalSource(item.actor, target, item, distanceFn).updateOptions(config); } } diff --git a/src/rollers/rsr.js b/src/rollers/rsr.js index 648340c..48621a4 100644 --- a/src/rollers/rsr.js +++ b/src/rollers/rsr.js @@ -29,6 +29,10 @@ import { showSources } from "../settings.js"; import { debug, getTarget } from "../util.js"; import CoreRollerHooks from "./core.js"; +// disable the grants.critical.range flag since RSR can't have it's critical flag changed anyways, +// only set by the attack roll +const distanceFn = () => Infinity; + /** * Setup the dnd5e.preRoll hooks for use with Ready Set Roll. */ @@ -137,7 +141,7 @@ export default class ReadySetRollHooks extends CoreRollerHooks { if (this._doMessages(config)) { new DamageMessage(item.actor, target, item).addMessage(config); - if (showSources) new CriticalSource(item.actor, target, item).updateOptions(config); + if (showSources) new CriticalSource(item.actor, target, item, distanceFn).updateOptions(config); } // don't use CriticalReminder here, it's done in another hook } @@ -147,7 +151,7 @@ export default class ReadySetRollHooks extends CoreRollerHooks { // check for critical hits but set the "isCrit" property instead of the default "critical" const target = getTarget(); - new CriticalReminder(item.actor, target, item).updateOptions(config, "isCrit"); + new CriticalReminder(item.actor, target, item, distanceFn).updateOptions(config, "isCrit"); } _doMessages({ fastForward = false }) { diff --git a/src/sources.js b/src/sources.js index 8877433..6c3899a 100644 --- a/src/sources.js +++ b/src/sources.js @@ -105,6 +105,14 @@ export class SkillSource extends SourceMixin(SkillReminder) {} export class DeathSaveSource extends SourceMixin(DeathSaveReminder) {} export class CriticalSource extends SourceMixin(CriticalReminder) { + _adjustRange(distanceFn, grantsCriticalRange) { + // check if the range applies, remove flag if not + if ("grants.critical.range" in this.targetFlags) { + const distance = distanceFn(); + if (distance > grantsCriticalRange) delete this.targetFlags["grants.critical.range"]; + } + } + _accumulator() { const criticalLabels = []; const normalLabels = []; diff --git a/src/util.js b/src/util.js index bb91e20..df6797d 100644 --- a/src/util.js +++ b/src/util.js @@ -37,3 +37,44 @@ export function getTarget() { export function isEmpty(obj) { return !Object.keys(obj).length; } + +export function getDistanceToTargetFn(speaker) { + return () => { + const controlledTokenDoc = game.scenes.get(speaker.scene).tokens.get(speaker.token); + const targetTokenDoc = game.user.targets.first()?.document; + if (!controlledTokenDoc || !targetTokenDoc) return Infinity; + + // make rays from each controlled grid space to targeted grid space + const controlledSpaces = _getAllTokenGridSpaces(controlledTokenDoc); + const targetSpaces = _getAllTokenGridSpaces(targetTokenDoc); + const rays = controlledSpaces.flatMap((c) => targetSpaces.map((t) => ({ ray: new Ray(c, t) }))); + + // measure the horizontal distance: shortest distance between the two tokens' squares + const dist = canvas.scene.grid.distance; + const distances = canvas.grid + .measureDistances(rays, { gridSpaces: true }) + .map((d) => Math.round(d / dist) * dist); + const horizDistance = Math.min(distances); + + // compute vertical distance: diff in elevation + const verticalDistance = Math.abs(controlledTokenDoc.elevation - targetTokenDoc.elevation); + return Math.max(horizDistance, verticalDistance); + }; +} + +function _getAllTokenGridSpaces({ width, height, x, y }) { + // if the token is in a single space, just return that + if (width <= 1 && height <= 1) return [{ x, y }]; + // create a position for each grid square it takes up + const grid = canvas.grid.size; + const centers = []; + for (let w = 0; w < width; w++) { + for (let h = 0; h < height; h++) { + centers.push({ + x: x + w * grid, + y: y + h * grid, + }); + } + } + return centers; +}