From 92df92f22340baae92b1caf70bdff11609b94983 Mon Sep 17 00:00:00 2001 From: Henry Malinowski Date: Mon, 20 Oct 2025 15:29:17 -0500 Subject: [PATCH] Added better support for dynamic token rings and common dynamic token ring usage to match PF2e and the DnD5e Foundry systems. --- system/src/config.mjs | 6 ++++ system/src/documents/ActorSD.mjs | 55 ++++++++++++++++++++++++++++++++ system/src/hooks/targeting.mjs | 11 +++++++ 3 files changed, 72 insertions(+) diff --git a/system/src/config.mjs b/system/src/config.mjs index 3c4e5e1ff..b4a0f808d 100644 --- a/system/src/config.mjs +++ b/system/src/config.mjs @@ -561,6 +561,12 @@ SHADOWDARK.TALENT_CLASSES = { patronBoon: "SHADOWDARK.talent.class.patronBoon", }; +SHADOWDARK.TOKEN_HP_COLORS = { + damage: 0xFF0000, + healing: 0x00FF00, + defeated: 0x000000, +}; + SHADOWDARK.WEAPON_BASE_DAMAGE = { d2: "1d2", d4: "1d4", diff --git a/system/src/documents/ActorSD.mjs b/system/src/documents/ActorSD.mjs index 3852b16a4..bf2a9d1cc 100644 --- a/system/src/documents/ActorSD.mjs +++ b/system/src/documents/ActorSD.mjs @@ -12,6 +12,42 @@ export default class ActorSD extends Actor { if (abilityScore >= 18) return 4; } + _animateHpChange(delta) { + const isDamage = delta < 0; + const color = isDamage + ? CONFIG.SHADOWDARK.TOKEN_HP_COLORS.damage + : CONFIG.SHADOWDARK.TOKEN_HP_COLORS.healing; + + const tokens = this.isToken ? [this.token] : this.getActiveTokens(true, false); + for (const t of tokens) { + // Suppress further effects if the token is marked as defeated in combat tracker + if (t.document.hasStatusEffect(CONFIG.specialStatusEffects.DEFEATED)) continue; + + // Flash dynamic ring if enabled + if (t.document.ring.enabled) { + const anim = isDamage ? { + duration: 500, + easing: t.ring.constructor.easeTwoPeaks, + } : {}; + t.ring.flashColor(Color.from(color), anim); + } + + // Create scrolling combat text for HP delta + const hpPercent = Math.clamp( + Math.abs(delta) / (this.system.attributes.hp.max || 1), + 0, + 1 + ); + canvas.interface.createScrollingText(t.center, delta.signedString(), { + anchor: CONST.TEXT_ANCHOR_POINTS.TOP, + fontSize: 16 + (32 * hpPercent), + fill: color, + stroke: 0x000000, + strokeThickness: 4, + jitter: 0.25, + }); + } + } async _applyHpRollToMax(value) { const currentHpBase = this.system.attributes.hp.base; @@ -134,6 +170,16 @@ export default class ActorSD extends Actor { }); } + async _onUpdate(data, options, userId) { + super._onUpdate(data, options, userId); + + // If _preUpdate captured a previous HP value, animate the change + const prev = options?.shadowdark?.prevHpValue; + if (prev !== undefined) { + const delta = this.system.attributes.hp.value - prev; + if (delta !== 0) this._animateHpChange(delta); + } + } async _playerRollHP(options={}) { const characterClass = await this.getClass(); @@ -210,6 +256,15 @@ export default class ActorSD extends Actor { this._populatePlayerModifiers(); } + async _preUpdate(data, options, userId) { + await super._preUpdate(data, options, userId); + + // for HP changes, store a transient value to the update options for use in _onUpdate + const hpValuePath = "system.attributes.hp.value"; + if (foundry.utils.hasProperty(data, hpValuePath)) { + (options.shadowdark ??= {}).prevHpValue = this.system.attributes.hp.value; + } + } abilityModifier(ability) { if (this.type === "Player") { diff --git a/system/src/hooks/targeting.mjs b/system/src/hooks/targeting.mjs index 4bf151390..1b14a9a97 100644 --- a/system/src/hooks/targeting.mjs +++ b/system/src/hooks/targeting.mjs @@ -10,6 +10,17 @@ export const TargetingHooks = { if (game.user.targets.size > 1 && !user.isGM) { game.user.updateTokenTargets([token.id]); } + + // if a token has a dynamic ring configured, flash using the user's color + if (token.document.ring.enabled) { + const color = Color.from(user.color); + token.ring.flashColor(color, + { + duration: 500, + easing: token.ring.constructor.easeTwoPeaks, + } + ); + } }); }, };