diff --git a/HealthColors/2.1.3/HealthColors.js b/HealthColors/2.1.3/HealthColors.js new file mode 100644 index 000000000..f1e86d71a --- /dev/null +++ b/HealthColors/2.1.3/HealthColors.js @@ -0,0 +1,1639 @@ +// =========================== +// === HealthColors v2.1.3 === +// =========================== + +// AUTHORS: +// - DXWarlock: https://app.roll20.net/users/262130/dxwarlock +// - Surok: https://app.roll20.net/users/335573/surok +// - MidNiteShadow7: https://app.roll20.net/users/16506286/midniteshadow7 + +/* global createObj TokenMod spawnFxWithDefinition spawnFx getObj state playerIsGM sendChat findObjs log on */ + +const HealthColors = (() => { + 'use strict'; + + // ————— CONSTANTS ————— + const VERSION = '2.1.3'; + const SCRIPT_NAME = 'HealthColors'; + const SCHEMA_VERSION = '1.1.0'; + const UPDATED = '2026-05-22 16:45 UTC'; + + // ————— DEFAULTS ————— + /** + * Default values written into `state.HealthColors` on first install or after a reset. + * Every key maps directly to a property used at runtime — changing a value here changes + * the out-of-the-box behavior for new or reset campaigns. + * + * @property {boolean} auraColorOn - Master on/off switch for the whole script. + * @property {string} auraBar - Which token bar to read HP from ('bar1'|'bar2'|'bar3'). + * @property {boolean} auraTint - When true, colors the token tint instead of the aura rings. + * @property {number} auraPercPC - HP % threshold below which the PC aura activates (0–100). + * @property {number} auraPerc - HP % threshold below which the NPC aura activates (0–100). + * @property {boolean} PCAura - Whether to show a health aura on player-character tokens. + * @property {boolean} NPCAura - Whether to show a health aura on monster/NPC tokens. + * @property {boolean} auraDeadPC - Whether to mark a PC token with the dead status at 0 HP. + * @property {boolean} auraDead - Whether to mark an NPC token with the dead status at 0 HP. + * @property {string} GM_PCNames - GM visibility of PC token names ('Yes'|'No'|'Off'). + * @property {string} PCNames - Player visibility of PC token names ('Yes'|'No'|'Off'). + * @property {string} GM_NPCNames - GM visibility of NPC token names ('Yes'|'No'|'Off'). + * @property {string} NPCNames - Player visibility of NPC token names ('Yes'|'No'|'Off'). + * @property {number} AuraSize - Feet the aura extends beyond the token edge. + * @property {string} Aura1Shape - Display/default Aura 1 shape shown in output. + * @property {string} Aura1Color - Display/default Aura 1 tint shown in output. + * @property {number} Aura2Size - Display/default Aura 2 radius shown in output. + * @property {string} Aura2Shape - Display/default Aura 2 shape shown in output. + * @property {string} Aura2Color - Display/default Aura 2 tint value shown in output. + * @property {boolean} OneOff - When true, tokens without a linked character also get auras. + * @property {boolean} FX - Whether to spawn particle FX on HP changes. + * @property {string} HealFX - Hex color (no '#') used for the healing particle effect. + * @property {string} HurtFX - Hex color (no '#') used for the hurt/damage particle effect. + * @property {string} auraDeadFX - Jukebox track name to play on death, or 'None' to disable. + * @property {string} colorPalette - Health aura colour palette ('default'|'colorblind'). + */ + const DEFAULTS = { + auraColorOn: true, + auraBar: 'bar1', + auraTint: false, + auraPercPC: 100, + auraPerc: 100, + PCAura: true, + NPCAura: true, + auraDeadPC: true, + auraDead: true, + GM_PCNames: 'Yes', + PCNames: 'Yes', + GM_NPCNames: 'Yes', + NPCNames: 'Yes', + AuraSize: 0.35, + Aura1Shape: 'Circle', + Aura1Color: '00FF00', + Aura2Size: 5, + Aura2Shape: 'Square', + Aura2Color: '806600', + OneOff: false, + FX: true, + HealFX: 'FDDC5C', + HurtFX: 'FF0000', + auraDeadFX: 'None', + colorPalette: 'default', + }; + + const COLOR_PALETTES = { + default: { + high: [0, 255, 0], // green + mid: [255, 255, 0], // yellow + low: [255, 0, 0], // red + dead: [0, 0, 0], // black + }, + colorblind: { + high: [51, 187, 238], // cyan + mid: [238, 119, 51], // orange + low: [204, 51, 17], // magenta + dead: [0, 0, 0], // black + }, + }; + + /** + * Seed definition for the '-DefaultHurt' Roll20 custom FX object created at install. + * Models a downward-falling burst (blood/debris) triggered when a token loses HP. + * `startColour` and `endColour` are placeholder zeroes — they are overwritten at + * runtime with the value of `state.HealthColors.HurtFX` (or a per-character override) + * before the FX is spawned, so changing them here has no visible effect. + * + * @property {number} maxParticles - Maximum simultaneous particles in the burst. + * @property {number} duration - How long (in frames) the emitter runs. + * @property {number} size - Base particle diameter before scale is applied. + * @property {number} sizeRandom - Random variance added to each particle's size. + * @property {number} lifeSpan - Frames each particle lives before fading. + * @property {number} lifeSpanRandom - Random variance added to each particle's lifespan. + * @property {number} speed - Base particle speed before scale is applied. + * @property {number} speedRandom - Random variance added to each particle's speed. + * @property {{x:number,y:number}} gravity - Per-frame acceleration applied to all particles. + * @property {number} angle - Emission direction in degrees (270 = straight down). + * @property {number} angleRandom - Cone spread around the emission angle. + * @property {number} emissionRate - Particles emitted per frame while the emitter is active. + * @property {number[]} startColour - RGBA start colour placeholder; overwritten at runtime. + * @property {number[]} endColour - RGBA end colour placeholder; overwritten at runtime. + */ + const DEFAULT_HURT_FX = { + maxParticles: 150, + duration: 50, + size: 10, + sizeRandom: 3, + lifeSpan: 25, + lifeSpanRandom: 5, + speed: 8, + speedRandom: 3, + gravity: { x: 0.01, y: 0.65 }, + angle: 270, + angleRandom: 25, + emissionRate: 100, + startColour: [0, 0, 0, 0], + endColour: [0, 0, 0, 0], + }; + + /** + * Seed definition for the '-DefaultHeal' Roll20 custom FX object created at install. + * Models a soft omnidirectional sparkle/glow triggered when a token regains HP. + * Like DEFAULT_HURT_FX, `startColour` and `endColour` are placeholders overwritten + * at runtime with `state.HealthColors.HealFX` before the FX is spawned. + * + * @property {number} maxParticles - Maximum simultaneous particles in the burst. + * @property {number} duration - How long (in frames) the emitter runs. + * @property {number} size - Base particle diameter before scale is applied. + * @property {number} sizeRandom - Random variance added to each particle's size (larger + * than hurt to produce a softer, more diffuse bloom). + * @property {number} lifeSpan - Frames each particle lives before fading. + * @property {number} lifeSpanRandom - Random variance added to each particle's lifespan. + * @property {number} speed - Base particle speed (slow drift upward). + * @property {number} speedRandom - Random variance added to each particle's speed. + * @property {number} angle - Emission direction in degrees (0 = straight up). + * @property {number} angleRandom - 180° spread produces full omnidirectional emission. + * @property {number} emissionRate - Very high rate creates a dense initial burst. + * @property {number[]} startColour - RGBA start colour placeholder; overwritten at runtime. + * @property {number[]} endColour - RGBA end colour placeholder; overwritten at runtime. + */ + const DEFAULT_HEAL_FX = { + maxParticles: 150, + duration: 50, + size: 10, + sizeRandom: 15, + lifeSpan: 50, + lifeSpanRandom: 30, + speed: 0.5, + speedRandom: 2, + angle: 0, + angleRandom: 180, + emissionRate: 1000, + startColour: [0, 0, 0, 0], + endColour: [0, 0, 0, 0], + }; + + /** + * Fallback baseline merged into every FX definition by `spawnFX` before spawning. + * This is NOT a Roll20 custfx object — it is a local safety net that ensures + * `spawnFX` never passes `undefined` for a required Roll20 FX field when a custom + * or per-character definition omits optional properties. + * Merge order: `{ ...FX_PARAM_DEFAULTS, ...userDefinition }`, so any property + * present in the real definition takes precedence over these fallbacks. + * + * @property {number} maxParticles - Fallback particle count. + * @property {number} duration - Fallback emitter duration (frames). + * @property {number} size - Fallback particle size. + * @property {number} sizeRandom - Fallback size variance. + * @property {number} lifeSpan - Fallback particle lifespan (frames). + * @property {number} lifeSpanRandom - Fallback lifespan variance. + * @property {number} speed - Fallback particle speed (0 = stationary). + * @property {number} speedRandom - Fallback speed variance. + * @property {number} angle - Fallback emission angle in degrees. + * @property {number} angleRandom - Fallback angular spread. + * @property {number} emissionRate - Fallback particles emitted per frame. + * @property {number[]} startColour - Fallback RGBA start colour (British spelling; opaque grey). + * @property {number[]} startColor - Fallback RGBA start color (American spelling; same value). + * @property {number[]} endColour - Fallback RGBA end colour (British spelling; opaque black). + * @property {number[]} endColor - Fallback RGBA end color (American spelling; same value). + * @property {number[]} startColourRandom - Fallback start colour randomization (zeroed). + * @property {number[]} startColorRandom - Fallback start color randomization (zeroed). + * @property {number[]} endColourRandom - Fallback end colour randomization (zeroed). + * @property {number[]} endColorRandom - Fallback end color randomization (zeroed). + * @property {{x:number,y:number}} gravity - Fallback gravity (none). + */ + const FX_PARAM_DEFAULTS = { + maxParticles: 100, + duration: 100, + size: 15, + sizeRandom: 5, + lifeSpan: 50, + lifeSpanRandom: 20, + speed: 1, + speedRandom: 1, + angle: 0, + angleRandom: 0, + emissionRate: 10, + startColour: [128, 128, 128, 1], + startColor: [128, 128, 128, 1], + endColour: [0, 0, 0, 1], + endColor: [0, 0, 0, 1], + startColourRandom: [0, 0, 0, 0], + startColorRandom: [0, 0, 0, 0], + endColourRandom: [0, 0, 0, 0], + endColorRandom: [0, 0, 0, 0], + gravity: { x: 0, y: 0 }, + }; + + // ————— UTILITIES ————— + /** + * Converts a health percentage (0–100+) to a hex color using the active palette. + * Values above 100% return blue; 0% uses dead; 1–100 interpolate low→mid→high. + * + * @param {number} pct - Health percentage. + * @returns {string} A 6-digit hex color string, e.g. '#FF0000'. + */ + function percentToHex(pct) { + const normalizedPct = Math.max(0, Number(pct) || 0); + if (normalizedPct > 100) return '#0000FF'; + const paletteName = state?.HealthColors?.colorPalette || 'default'; + const { high, mid, low, dead } = + COLOR_PALETTES[paletteName] || COLOR_PALETTES.default; + const rgbToHex = (rgb) => + // eslint-disable-next-line no-bitwise + `#${((1 << 24) + (rgb[0] << 16) + (rgb[1] << 8) + rgb[2]).toString(16).slice(1)}`; + + if (normalizedPct === 0) { + return rgbToHex(dead); + } + + const t = + normalizedPct >= 50 ? (normalizedPct - 50) / 50 : normalizedPct / 50; + const from = normalizedPct >= 50 ? mid : low; + const to = normalizedPct >= 50 ? high : mid; + const r = Math.round(from[0] + (to[0] - from[0]) * t); + const g = Math.round(from[1] + (to[1] - from[1]) * t); + const b = Math.round(from[2] + (to[2] - from[2]) * t); + return rgbToHex([r, g, b]); + } + + /** + * Parses a hex color string into an RGBA array suitable for Roll20 FX definitions. + * Returns [0,0,0,0] when the input is invalid. + * + * @param {string} hex - Hex color string with or without leading '#'. + * @returns {number[]} Array of [r, g, b, a] where a is always 1.0 on success. + */ + function hexToRgb(hex) { + const cleanHex = (hex || '').replace('#', '').trim(); + const parts = /^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec( + cleanHex, + ); + if (parts) { + const rgb = parts.slice(1).map((d) => Number.parseInt(d, 16)); + rgb.push(1); + return rgb; + } + // Log invalid hex attempts if they appear non-empty + if (cleanHex) + log(`${SCRIPT_NAME}: hexToRgb received invalid hex: "${hex}"`); + return [0, 0, 0, 0]; + } + + /** + * Returns a random integer between min and max inclusive. + * + * @param {number} min - Lower bound (inclusive). + * @param {number} max - Upper bound (inclusive). + * @returns {number} Random integer in [min, max]. + */ + function randomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; // NOSONAR — cosmetic FX variance, not security-sensitive + } + + /** + * Creates a plain-object snapshot of a Roll20 API object or any serialisable value. + * Uses JSON round-trip rather than structuredClone so that Roll20 proxy objects have + * their toJSON() method called, producing a plain object whose properties are + * accessible directly (e.g. prev.bar1_value) rather than through .get(). + * + * @param {object} obj - Roll20 API object or plain object to snapshot. + * @returns {object} Plain object deep copy. + */ + function deepClone(obj) { + return JSON.parse(JSON.stringify(obj)); // NOSONAR — intentional: triggers Roll20 proxy toJSON() + } + + /** + * Normalizes a 6-digit hex color string (without '#'). + * Returns fallback when input is invalid. + * + * @param {string} value - Candidate hex string. + * @param {string} fallback - Fallback value when invalid. + * @returns {string} Uppercase 6-digit hex. + */ + function normalizeHex6(value, fallback) { + const cleaned = (value || '').replace('#', '').trim().toUpperCase(); + return /^[0-9A-F]{6}$/.test(cleaned) ? cleaned : fallback; + } + + /** + * Normalizes an aura shape label to supported display values. + * + * @param {string} value - Candidate shape value. + * @param {string} fallback - Fallback shape. + * @returns {string} One of Circle|Square. + */ + function normalizeShape(value, fallback) { + const shape = (value || '').trim().toUpperCase(); + if (shape === 'CIRCLE') return 'Circle'; + if (shape === 'SQUARE') return 'Square'; + return fallback; + } + + /** + * Normalizes a palette name to one of the supported keys. + * + * @param {string} value - Candidate palette key. + * @param {string} fallback - Fallback palette key when invalid. + * @returns {string} A valid palette key from COLOR_PALETTES. + */ + function normalizePalette(value, fallback) { + const p = (value || '').trim().toLowerCase(); + return COLOR_PALETTES[p] ? p : fallback; + } + + /** + * Normalizes a percentage setting to an integer between 0 and 100. + * + * @param {string|number} value - Candidate percentage. + * @param {number} fallback - Fallback percentage when invalid. + * @returns {number} A valid percentage value. + */ + function normalizePercent(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed >= 0 && parsed <= 100 + ? parsed + : fallback; + } + + /** + * Normalizes a positive numeric setting. + * + * @param {string|number} value - Candidate numeric value. + * @param {number} fallback - Fallback value when invalid. + * @returns {number} A valid non-negative number. + */ + function normalizePositiveNumber(value, fallback) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; + } + + /** + * Normalizes a Yes/No/Off style setting. + * + * @param {string} value - Candidate setting value. + * @param {string} fallback - Fallback value when invalid. + * @returns {string} One of Yes, No, or Off. + */ + function normalizeYesNoOff(value, fallback) { + const normalized = (value || '').trim().toUpperCase(); + if (normalized === 'YES') return 'Yes'; + if (normalized === 'NO') return 'No'; + if (normalized === 'OFF') return 'Off'; + return fallback; + } + + /** + * Normalizes a death sound track name. + * + * @param {string} value - Candidate track name. + * @param {string} fallback - Fallback track name when invalid. + * @returns {string} A trimmed track name or None. + */ + function normalizeTrackName(value, fallback) { + const normalized = (value || '').trim(); + if (!normalized) return fallback; + return normalized.toUpperCase() === 'NONE' ? 'None' : normalized; + } + + // ————— WHISPER GM (declared early; used by checkInstall) ————— + /** + * Sends a styled whisper message to the GM. + * + * @param {string} text - Plain text content to display inside the styled div. + */ + function gmWhisper(text) { + const style = [ + 'width:100%', + 'border-radius:4px', + 'box-shadow:1px 1px 1px #707070', + 'text-align:center', + 'vertical-align:middle', + 'padding:3px 0px', + 'margin:0px auto', + 'border:1px solid #000', + 'color:#000', + 'background-image:-webkit-linear-gradient(-45deg,#a7c7dc 0%,#85b2d3 100%)', + ].join(';'); + sendChat(SCRIPT_NAME, `/w GM
${text}
`); + } + + // ————— ATTRIBUTE CACHE ————— + /** + * Creates a cached attribute lookup function that auto-refreshes on attribute + * change or destruction and re-triggers handleToken for affected tokens. + * Creates the attribute with the default value if it does not exist yet. + * + * @param {string} attribute - The Roll20 attribute name to track (e.g. 'USECOLOR'). + * @param {object} [options={}] - Configuration options. + * @param {string} [options.default] - Value to use when the attribute is missing or invalid. + * @param {Function} [options.validation]- Predicate that returns true for valid values. + * @returns {Function} Lookup function accepting a character object and returning the current value. + */ + function makeSmartAttrCache(attribute, options = {}) { + const cache = {}; + const defaultValue = options.default || 'YES'; + const validator = options.validation || (() => true); + + on('change:attribute', (attr) => { + if (attr.get('name') !== attribute) return; + if (!validator(attr.get('current'))) attr.set('current', defaultValue); + cache[attr.get('characterid')] = attr.get('current'); + findObjs({ type: 'graphic' }) + .filter((o) => o.get('represents') === attr.get('characterid')) + .forEach((obj) => { + const prev = deepClone(obj); + handleToken(obj, prev, 'YES'); + }); + }); + + on('destroy:attribute', (attr) => { + if (attr.get('name') === attribute) delete cache[attr.get('characterid')]; + }); + + return function (character) { + let attr = + findObjs( + { type: 'attribute', name: attribute, characterid: character.id }, + { caseInsensitive: true }, + )[0] || + createObj('attribute', { + name: attribute, + characterid: character.id, + current: defaultValue, + }); + + if (!cache[character.id] || cache[character.id] !== attr.get('current')) { + if (!validator(attr.get('current'))) attr.set('current', defaultValue); + cache[character.id] = attr.get('current'); + } + return cache[character.id]; + }; + } + + const lookupUseBlood = makeSmartAttrCache('USEBLOOD', { + default: 'DEFAULT', + validation: (o) => String(o || '').trim() !== '', + }); + const lookupUseColor = makeSmartAttrCache('USECOLOR', { + default: 'YES', + validation: (o) => /^(YES|NO)$/i.test(String(o || '').trim()), + }); + + // ————— TOKEN HELPERS ————— + /** + * Hard-clears all health-indicator visual settings (aura/tint). + * Used for dead tokens or when the script/aura is disabled for a type. + * + * @param {object} obj - Roll20 token graphic object. + */ + function clearAuras(obj) { + const changes = { tint_color: 'transparent' }; + if (!state.HealthColors.auraTint) { + changes.aura1_color = 'transparent'; + changes.aura1_radius = 0; + } + obj.set(changes); + } + + /** + * Applies a health color to a token via aura or tint depending on configuration. + * When in tint mode, sets tint_color. When in aura mode, sets aura radius and color. + * Roll20 measures aura1_radius from the token edge, so sizeSet maps directly. + * + * @param {object} obj - Roll20 token object. + * @param {number} sizeSet - Feet the ring extends beyond the token edge (e.g. 0.35). + * @param {string} markerColor - Hex color string derived from health percentage. + */ + function tokenSet(obj, sizeSet, markerColor) { + const useTint = state.HealthColors.auraTint; + if (useTint) { + obj.set({ tint_color: markerColor }); + } else { + obj.set({ + tint_color: 'transparent', + aura1_radius: sizeSet, + aura1_color: markerColor, + showplayers_aura1: true, + }); + } + } + + /** + * Sets token name-visibility flags for the GM and players. + * 'Yes' → true, 'No' → false, 'Off' → leave unchanged. + * + * @param {string} gm - GM name-display setting: 'Yes', 'No', or 'Off'. + * @param {string} pc - Player name-display setting: 'Yes', 'No', or 'Off'. + * @param {object} obj - Roll20 token object. + */ + function setShowNames(gm, pc, obj) { + if (gm !== 'Off' && gm !== '') obj.set({ showname: gm === 'Yes' }); + if (pc !== 'Off' && pc !== '') obj.set({ showplayers_name: pc === 'Yes' }); + } + + // ————— FX ————— + /** + * Plays a jukebox track when a token dies. + * Accepts a comma-separated list of track names; picks one at random. + * + * @param {string} trackname - Track name or comma-separated list of track names. + */ + function playDeath(trackname) { + const list = + trackname.indexOf(',') > 0 ? trackname.split(',') : [trackname]; + const resolvedName = list[Math.floor(Math.random() * list.length)]; // NOSONAR — random track selection, not security-sensitive + const track = findObjs({ type: 'jukeboxtrack', title: resolvedName })[0]; + if (track) { + track.set({ playing: false, softstop: false, volume: 50 }); + track.set({ playing: true }); + } else { + log(`${SCRIPT_NAME}: No track found named ${resolvedName}`); + } + } + + /** + * Spawns a scaled particle FX at a token's position using a custom FX definition. + * Merges the provided definition against FX_PARAM_DEFAULTS so partial definitions work. + * + * @param {number} scale - Scaling factor derived from token height (height / 70). + * @param {number} hitSize - Hit-size factor based on damage proportion (0.2–1.0). + * @param {number} left - Horizontal pixel position of the token on the page. + * @param {number} top - Vertical pixel position of the token on the page. + * @param {object} fx - Partial or complete Roll20 custom FX definition object. + * @param {string} pageId - ID of the Roll20 page on which to spawn the FX. + */ + function spawnFX(scale, hitSize, left, top, fx, pageId) { + const m = { ...FX_PARAM_DEFAULTS, ...fx }; + + // Prefer colours from the incoming partial `fx` first (nullish), then merged `m`. + // Order matters: after merge, `m.startColour` can still be FX_PARAM_DEFAULTS grey + // while the real colour only exists on `fx.startColor` (Roll20 / heal seed used + // American keys only). Using `||` on `m` alone would always pick the grey default. + const pick = (obj, keys) => { + if (!obj) return undefined; + for (const key of keys) { + const v = obj[key]; + if (v !== undefined && v !== null) return v; + } + return undefined; + }; + const startKeys = [ + 'startColour', + 'startColor', + 'startcolour', + 'startcolor', + ]; + const endKeys = ['endColour', 'endColor', 'endcolour', 'endcolor']; + const startRndKeys = [ + 'startColourRandom', + 'startColorRandom', + 'startcolourrandom', + 'startcolorrandom', + ]; + const endRndKeys = [ + 'endColourRandom', + 'endColorRandom', + 'endcolourrandom', + 'endcolorrandom', + ]; + const startClr = pick(fx, startKeys) ?? pick(m, startKeys); + const endClr = pick(fx, endKeys) ?? pick(m, endKeys); + const startClrRnd = pick(fx, startRndKeys) ?? pick(m, startRndKeys); + const endClrRnd = pick(fx, endRndKeys) ?? pick(m, endRndKeys); + + spawnFxWithDefinition( + left, + top, + { + maxParticles: m.maxParticles * hitSize, + duration: m.duration * hitSize, + size: (m.size * scale) / 2, + sizeRandom: (m.sizeRandom * scale) / 2, + lifeSpan: m.lifeSpan, + lifeSpanRandom: m.lifeSpanRandom, + speed: m.speed * scale, + speedRandom: m.speedRandom * scale, + angle: m.angle, + angleRandom: m.angleRandom, + emissionRate: m.emissionRate * hitSize * 2, + startColour: startClr, + startColor: startClr, + endColour: endClr, + endColor: endClr, + startColourRandom: startClrRnd, + startColorRandom: startClrRnd, + endColourRandom: endClrRnd, + endColorRandom: endClrRnd, + gravity: { x: m.gravity.x * scale, y: m.gravity.y * scale }, + }, + pageId, + ); + } + + /** + * Safely reads a Roll20 custfx definition and returns a plain mutable object. + * Roll20 may return the definition as either an object or a JSON string. + * + * @param {object} fxObj - Roll20 custfx object. + * @returns {object|null} Parsed FX definition object, or null if unavailable/invalid. + */ + function getFxDefinition(fxObj) { + if (!fxObj) return null; + + const raw = fxObj.get('definition'); + if (!raw) return null; + + if (typeof raw === 'string') { + try { + return JSON.parse(raw); + } catch (err) { + log(`${SCRIPT_NAME}: Failed to parse FX definition: ${err.message}`); + return null; + } + } + + if (typeof raw === 'object') { + return deepClone(raw); + } + + return null; + } + + // ————— STATE / INSTALL ————— + /** + * Initializes or migrates persisted state, applies all default values, registers + * the TokenMod observer if available, and creates the default Hurt/Heal FX objects + * if they do not already exist in the campaign. + * Safe to call multiple times (e.g. after a state reset). + */ + function checkInstall() { + log(`-=> ${SCRIPT_NAME} v${VERSION} [Updated: ${UPDATED}] <=-`); + if (state?.HealthColors?.schemaVersion !== SCHEMA_VERSION) { + log(`<${SCRIPT_NAME} Updating Schema to v${SCHEMA_VERSION}>`); + state.HealthColors = { schemaVersion: SCHEMA_VERSION, version: VERSION }; + } + Object.keys(DEFAULTS).forEach((key) => { + if (state.HealthColors[key] === undefined) + state.HealthColors[key] = DEFAULTS[key]; + }); + state.HealthColors.colorPalette = normalizePalette( + state.HealthColors.colorPalette, + DEFAULTS.colorPalette, + ); + if (typeof TokenMod !== 'undefined' && TokenMod.ObserveTokenChange) { + TokenMod.ObserveTokenChange(handleToken); + } + const fxHurt = findObjs( + { _type: 'custfx', name: '-DefaultHurt' }, + { caseInsensitive: true }, + )[0]; + const fxHeal = findObjs( + { _type: 'custfx', name: '-DefaultHeal' }, + { caseInsensitive: true }, + )[0]; + if (!fxHurt) { + gmWhisper('Creating Default Hurt FX'); + createObj('custfx', { + name: '-DefaultHurt', + definition: DEFAULT_HURT_FX, + }); + } + if (!fxHeal) { + gmWhisper('Creating Default Heal FX'); + createObj('custfx', { + name: '-DefaultHeal', + definition: DEFAULT_HEAL_FX, + }); + } + syncDefaultFxObjects(); + } + + /** + * Builds the normalized default Hurt/Heal definition payload used for + * campaign custom FX objects. + * + * @param {boolean} isHeal - True for Heal profile, false for Hurt profile. + * @param {object} baseDef - Existing definition to merge into. + * @returns {object} Updated definition with normalized color/profile fields. + */ + function buildDefaultFxDefinition(isHeal, baseDef) { + const def = { ...baseDef }; + const rgb = hexToRgb( + isHeal ? state.HealthColors.HealFX : state.HealthColors.HurtFX, + ); + def.startColour = rgb; + def.startColor = rgb; + def.endColour = rgb; + def.endColor = rgb; + def.startColourRandom = [0, 0, 0, 0]; + def.startColorRandom = [0, 0, 0, 0]; + def.endColourRandom = [0, 0, 0, 0]; + def.endColorRandom = [0, 0, 0, 0]; + + // Keep the vivid profile that reads clearly in live play. + if (isHeal) { + def.maxParticles = 220; + def.emissionRate = 260; + def.size = 12; + def.sizeRandom = 4; + def.lifeSpan = 40; + def.lifeSpanRandom = 6; + def.speed = 0.8; + def.speedRandom = 1; + } else { + def.maxParticles = 200; + def.emissionRate = 180; + def.size = 10; + def.sizeRandom = 2; + def.lifeSpan = 22; + def.lifeSpanRandom = 3; + def.speed = 8; + def.speedRandom = 2; + } + return def; + } + + /** + * Applies current Heal/Hurt colors and profile tuning to campaign default + * custom FX objects. This is called on install/reset and when color settings + * change so runtime spawns can use stable pre-synced definitions. + */ + function syncDefaultFxObjects() { + const fxHurt = findObjs( + { _type: 'custfx', name: '-DefaultHurt' }, + { caseInsensitive: true }, + )[0]; + const fxHeal = findObjs( + { _type: 'custfx', name: '-DefaultHeal' }, + { caseInsensitive: true }, + )[0]; + if (fxHeal) { + const base = getFxDefinition(fxHeal) || DEFAULT_HEAL_FX; + fxHeal.set({ definition: buildDefaultFxDefinition(true, base) }); + } + if (fxHurt) { + const base = getFxDefinition(fxHurt) || DEFAULT_HURT_FX; + fxHurt.set({ definition: buildDefaultFxDefinition(false, base) }); + } + } + + /** + * Recreates HealthColors default custom FX objects in the campaign. + * Useful when legacy/stale custfx definitions exist from older script versions. + */ + function resetDefaultFxObjects() { + const existing = findObjs( + { _type: 'custfx' }, + { caseInsensitive: true }, + ).filter((fx) => /-Default(Hurt|Heal)/i.test(fx.get('name') || '')); + existing.forEach((fx) => fx.remove()); + gmWhisper('Recreating Default Hurt/Heal FX'); + checkInstall(); + } + + /** + * Resets all persisted HealthColors settings back to DEFAULTS. + * Keeps schema/version metadata aligned to current script constants. + */ + function resetAllSettingsToDefaults() { + state.HealthColors = { + schemaVersion: SCHEMA_VERSION, + version: VERSION, + ...DEFAULTS, + }; + } + + /** + * Restores all state defaults, rebuilds default FX objects, and force-syncs tokens. + */ + function runResetAllFlow() { + resetAllSettingsToDefaults(); + gmWhisper('RESET ALL: defaults restored + default FX + force update'); + resetDefaultFxObjects(); + menuForceUpdate(); + } + + // ————— TOKEN LOGIC ————— + /** + * Reads the configured health bar from a token and its previous snapshot, + * validates all three values are numeric, and returns a health data object. + * Returns null if any value is missing or non-numeric. + * + * @param {object} obj - Roll20 token graphic object. + * @param {object} prev - Snapshot of the token's previous attribute values. + * @param {string} [update] - Pass 'YES' when called from a forced refresh. + * @returns {{ maxValue: number, curValue: number, prevValue: string|number, + * percReal: number, markerColor: string }|null} + */ + function getBarHealth(obj, prev, update) { + const barUsed = state.HealthColors.auraBar; + if (obj.get(`${barUsed}_max`) === '' && obj.get(`${barUsed}_value`) === '') + return null; + const maxValue = Number.parseInt(obj.get(`${barUsed}_max`), 10); + const curValue = Number.parseInt(obj.get(`${barUsed}_value`), 10); + const prevValue = prev[`${barUsed}_value`]; + if (Number.isNaN(maxValue) || Number.isNaN(curValue)) return null; + if (update !== 'YES' && Number.isNaN(Number.parseInt(prevValue, 10))) + return null; + const percReal = Math.max( + 0, + Math.min(Math.round((curValue / maxValue) * 100), 100), + ); + const markerColor = percentToHex(percReal); + return { maxValue, curValue, prevValue, percReal, markerColor }; + } + + /** + * Determines Player vs Monster and returns all type-specific config in one object. + * + * @param {object|undefined} oCharacter - Roll20 character object (may be undefined). + * @returns {{ gm: string, pc: string, isTypeOn: boolean, percentOn: number, + * showDead: boolean }} + */ + function resolveTypeConfig(oCharacter) { + const isPlayer = oCharacter && oCharacter.get('controlledby') !== ''; + if (isPlayer) { + return { + gm: state.HealthColors.GM_PCNames, + pc: state.HealthColors.PCNames, + isTypeOn: state.HealthColors.PCAura, + percentOn: state.HealthColors.auraPercPC, + showDead: state.HealthColors.auraDeadPC, + }; + } + return { + gm: state.HealthColors.GM_NPCNames, + pc: state.HealthColors.NPCNames, + isTypeOn: state.HealthColors.NPCAura, + percentOn: state.HealthColors.auraPerc, + showDead: state.HealthColors.auraDead, + }; + } + + /** + * Manages the dead-status marker and plays a death sound when a token reaches 0 HP. + * Extracted from applyAuraAndDead to reduce nesting depth. + * + * @param {object} obj - Roll20 token graphic object. + * @param {number} curValue - Current bar value. + * @param {number} prevValue - Previous bar value (may be a string). + */ + function applyDeadStatus(obj, curValue, prevValue) { + if (curValue > 0) { + obj.set('status_dead', false); + return; + } + const deadSfx = state.HealthColors.auraDeadFX; + if (deadSfx !== 'None' && curValue !== Number(prevValue)) + playDeath(deadSfx); + obj.set('status_dead', true); + } + + /** + * Applies or removes the health aura/tint and manages the dead-status marker. + * + * @param {object} obj - Roll20 token graphic object. + * @param {object|undefined} oCharacter - Roll20 character object. + * @param {object} typeConfig - Config returned by resolveTypeConfig. + * @param {object} health - Health data returned by getBarHealth. + */ + function applyAuraAndDead(obj, oCharacter, typeConfig, health) { + const { curValue, prevValue, percReal, markerColor } = health; + const { isTypeOn, percentOn, showDead } = typeConfig; + const useAura = oCharacter ? lookupUseColor(oCharacter) : undefined; + const useTint = state.HealthColors.auraTint; + const colorType = useTint ? 'tint' : 'aura1'; + + if (showDead) applyDeadStatus(obj, curValue, prevValue); + + if (isTypeOn && useAura !== 'NO') { + if (curValue <= 0) { + tokenSet(obj, state.HealthColors.AuraSize, markerColor); + } else if (percentOn <= 0) { + clearAuras(obj); + } else if (percReal > percentOn) { + clearAuras(obj); + } else { + tokenSet(obj, state.HealthColors.AuraSize, markerColor); + } + } else if (obj.get(`${colorType}_color`) === markerColor) { + clearAuras(obj); + } + } + + /** + * Builds the list of FX definition objects to spawn for a heal or hurt event. + * + * @param {boolean} isHeal - True when HP went up. + * @param {string|undefined} useBlood - Per-character blood FX override value. + * @param {string} [label] - Character/token name for error context. + * @returns {object[]} Array of Roll20 custfx definition objects. + */ + function buildFXList(isHeal, useBlood, label) { + const fxArray = []; + + if (isHeal) { + const aFX = findObjs( + { _type: 'custfx', name: '-DefaultHeal' }, + { caseInsensitive: true }, + )[0]; + const def = getFxDefinition(aFX); + + if (def) { + const healRgb = hexToRgb(state.HealthColors.HealFX); + def.startColour = healRgb; + def.startColor = healRgb; + def.endColour = healRgb; + def.endColor = healRgb; + def.startColourRandom = [0, 0, 0, 0]; + def.startColorRandom = [0, 0, 0, 0]; + def.endColourRandom = [0, 0, 0, 0]; + def.endColorRandom = [0, 0, 0, 0]; + fxArray.push(def); + } + + return fxArray; + } + + const aFX = findObjs( + { _type: 'custfx', name: '-DefaultHurt' }, + { caseInsensitive: true }, + )[0]; + const def = getFxDefinition(aFX); + + if (!def) return fxArray; + + if (useBlood === 'DEFAULT' || useBlood === undefined) { + const hurtRgb = hexToRgb(state.HealthColors.HurtFX); + def.startColour = hurtRgb; + def.startColor = hurtRgb; + def.endColour = hurtRgb; + def.endColor = hurtRgb; + def.startColourRandom = [0, 0, 0, 0]; + def.startColorRandom = [0, 0, 0, 0]; + def.endColourRandom = [0, 0, 0, 0]; + def.endColorRandom = [0, 0, 0, 0]; + fxArray.push(def); + } else { + const normalizedUseBlood = String(useBlood || '').trim(); + const hurtRgb = hexToRgb(normalizedUseBlood); + + if (hurtRgb.some((v) => v !== 0)) { + def.startColour = hurtRgb; + def.startColor = hurtRgb; + def.endColour = hurtRgb; + def.endColor = hurtRgb; + def.startColourRandom = [0, 0, 0, 0]; + def.startColorRandom = [0, 0, 0, 0]; + def.endColourRandom = [0, 0, 0, 0]; + def.endColorRandom = [0, 0, 0, 0]; + fxArray.push(def); + } else { + const fxNames = normalizedUseBlood + .split(',') + .map((fxName) => fxName.trim()) + .filter((fxName) => fxName !== ''); + + if (fxNames.length === 0) { + const hurtRgb = hexToRgb(state.HealthColors.HurtFX); + def.startColour = hurtRgb; + def.startColor = hurtRgb; + def.endColour = hurtRgb; + def.endColor = hurtRgb; + def.startColourRandom = [0, 0, 0, 0]; + def.startColorRandom = [0, 0, 0, 0]; + def.endColourRandom = [0, 0, 0, 0]; + def.endColorRandom = [0, 0, 0, 0]; + fxArray.push(def); + return fxArray; + } + + fxNames.forEach((fxName) => { + const custom = findObjs( + { _type: 'custfx', name: fxName.trim() }, + { caseInsensitive: true }, + )[0]; + const customDef = getFxDefinition(custom); + + if (customDef) { + fxArray.push(customDef); + } else { + const who = label ? ` (character: "${label}")` : ''; + log( + `${SCRIPT_NAME}: Custom FX "${fxName.trim()}"${who} not found — check the USEBLOOD attribute.`, + ); + gmWhisper( + `Custom FX "${fxName.trim()}"${who} not found. Fix the USEBLOOD attribute on that character. Falling back to default hurt FX.`, + ); + const fallbackFx = findObjs( + { _type: 'custfx', name: '-DefaultHurt' }, + { caseInsensitive: true }, + )[0]; + const fallbackDef = getFxDefinition(fallbackFx); + if (fallbackDef) fxArray.push(fallbackDef); + } + }); + } + } + + return fxArray; + } + + /** + * Spawns the default heal or hurt FX by their saved custfx ID using spawnFx. + * This avoids client-side color inconsistencies seen in some sandboxes when using + * spawnFxWithDefinition directly. Only handles DEFAULT heal/hurt colors; custom + * named FX (USEBLOOD set to a custfx name) still use the definition-spawn path. + * + * @param {object} obj - Roll20 token graphic object. + * @param {boolean} isHeal - True when HP increased. + * @param {string|undefined} useBlood - Per-character blood override value. + * @returns {boolean} True when spawning was handled; false if the caller should fall back. + */ + function spawnDefaultFxById(obj, isHeal, useBlood) { + if (!(useBlood === 'DEFAULT' || useBlood === undefined)) return false; + const fxName = isHeal ? '-DefaultHeal' : '-DefaultHurt'; + const aFX = findObjs( + { _type: 'custfx', name: fxName }, + { caseInsensitive: true }, + )[0]; + if (!aFX) return false; + + spawnFx(obj.get('left'), obj.get('top'), aFX.id, obj.get('pageid')); + return true; + } + + /** + * Gates and triggers particle FX when HP changes on a non-forced update. + * + * @param {object} obj - Roll20 token graphic object. + * @param {object|undefined} oCharacter - Roll20 character object. + * @param {number} curValue - Current bar value. + * @param {number|string} prevValue - Previous bar value. + * @param {number} maxValue - Maximum bar value. + * @param {string} [update] - Pass 'YES' to suppress FX on forced refreshes. + */ + function maybeSpawnFX( + obj, + oCharacter, + curValue, + prevValue, + maxValue, + update, + ) { + if (curValue === Number(prevValue) || prevValue === '' || update === 'YES') + return; + const useBlood = oCharacter ? lookupUseBlood(oCharacter) : undefined; + if (!state.HealthColors.FX || useBlood === 'OFF' || useBlood === 'NO') + return; + const isHeal = curValue > Number(prevValue); + const amount = Math.abs(curValue - Number(prevValue)); + const scale = obj.get('height') / 70; + const hitSize = + Math.max(Math.min((amount / maxValue) * 4, 1), 0.2) * + (randomInt(60, 100) / 100); + const fxLabel = + (oCharacter && oCharacter.get('name')) || obj.get('name') || ''; + if (spawnDefaultFxById(obj, isHeal, useBlood)) return; + buildFXList(isHeal, useBlood, fxLabel).forEach((fx) => + spawnFX( + scale, + hitSize, + obj.get('left'), + obj.get('top'), + fx, + obj.get('pageid'), + ), + ); + } + + /** + * Core token handler — called on token change, token add, and forced updates. + * Delegates to specialized helpers for health reading, type resolution, + * aura management, and FX spawning. + * Clears aura/tint when the selected health bar has no max value. + * + * @param {object} obj - The Roll20 token graphic object. + * @param {object} prev - Snapshot of the token's previous attribute values. + * @param {string} [update] - Pass 'YES' to indicate a forced refresh (suppresses FX). + */ + function handleToken(obj, prev, update) { + if (state.HealthColors === undefined) { + log(`${SCRIPT_NAME} ${VERSION}: state missing, reverting to defaults`); + checkInstall(); + } + if ( + state.HealthColors.auraColorOn !== true || + obj.get('layer') !== 'objects' + ) + return; + if (obj.get('represents') === '' && state.HealthColors.OneOff !== true) + return; + const barUsed = state.HealthColors.auraBar; + if (obj.get(`${barUsed}_max`) === '') { + clearAuras(obj); + return; + } + + const health = getBarHealth(obj, prev, update); + if (!health) return; + + const { maxValue, curValue, prevValue } = health; + const sizeChanged = + prev.width !== obj.get('width') || prev.height !== obj.get('height'); + + // Only proceed if health changed, token was resized, or this is a forced update. + // The size check ensures aura is re-applied when a token is resized, even without an HP change. + if (curValue === Number(prevValue) && update !== 'YES' && !sizeChanged) + return; + + const oCharacter = getObj('character', obj.get('represents')); + const typeConfig = resolveTypeConfig(oCharacter); + + applyAuraAndDead(obj, oCharacter, typeConfig, health); + setShowNames(typeConfig.gm, typeConfig.pc, obj); + maybeSpawnFX(obj, oCharacter, curValue, prevValue, maxValue, update); + } + + // ————— FORCE UPDATE ————— + /** + * Handles the visual transition when switching between aura and tint modes. + * Processes every token in a single drain queue pass: when switching to tint mode + * it clears aura1 on each token before re-evaluating health, ensuring no stale + * HC-set aura rings remain. When switching to aura mode it re-evaluates health + * directly so tokenSet clears the tint and applies the aura ring in one step. + * + * @param {boolean} toTint - True when switching into tint mode, false when switching out. + */ + function modeSwitch(toTint) { + const workQueue = findObjs({ + type: 'graphic', + subtype: 'token', + layer: 'objects', + }); + const drainQueue = () => { + const token = workQueue.shift(); + if (!token) return; + if (toTint) token.set({ aura1_color: 'transparent', aura1_radius: 0 }); + const prev = deepClone(token); + handleToken(token, prev, 'YES'); + setTimeout(drainQueue, 0); + }; + drainQueue(); + } + + /** + * Forces a re-evaluation of every token on the objects layer, + * processing them one at a time via a setTimeout drain queue to avoid + * blocking the Roll20 sandbox event loop. + */ + function menuForceUpdate() { + const workQueue = findObjs({ + type: 'graphic', + subtype: 'token', + layer: 'objects', + }); + sendChat(SCRIPT_NAME, `/w gm Refreshing ${workQueue.length} Tokens`); + const drainQueue = () => { + const token = workQueue.shift(); + if (token) { + const prev = deepClone(token); + handleToken(token, prev, 'YES'); + setTimeout(drainQueue, 0); + } else { + sendChat(SCRIPT_NAME, '/w gm Finished Refreshing Tokens'); + } + }; + drainQueue(); + } + + /** + * Forces a health-color update on all currently selected tokens. + * Whispers the list of updated token names to the GM. + * + * @param {object} msg - Roll20 chat message object with a populated `selected` array. + */ + function manUpdate(msg) { + const allNames = msg.selected.reduce((acc, obj) => { + const token = getObj('graphic', obj._id); + const prev = deepClone(token); + handleToken(token, prev, 'YES'); + return `${acc}${token.get('name')}
`; + }, ''); + gmWhisper(allNames); + } + + // ————— MENU ————— + /** + * Builds a styled Roll20 chat button anchor element. + * + * @param {string} label - Button label text. + * @param {string} href - Roll20 API command (e.g. '!aura on'). + * @param {string} [extraStyle=''] - Additional inline CSS to append to the base style. + * @returns {string} An HTML anchor string ready for sendChat. + */ + function makeBtn(label, href, extraStyle = '') { + const base = [ + 'padding-top:1px', + 'text-align:center', + 'font-size:9pt', + 'width:48px', + 'height:14px', + 'border:1px solid black', + 'margin:1px', + 'background-color:#6FAEC7', + 'border-radius:4px', + 'box-shadow:1px 1px 1px #707070', + ].join(';'); + return `${label}`; + } + + /** + * Builds a non-interactive styled value pill for read-only output panels. + * + * @param {string} label - Display text. + * @param {string} [extraStyle=''] - Additional inline CSS to append to base style. + * @returns {string} A styled span element. + */ + function makePill(label, extraStyle = '') { + const base = [ + 'display:inline-block', + 'padding-top:1px', + 'text-align:center', + 'font-size:9pt', + 'min-width:48px', + 'height:14px', + 'border:1px solid black', + 'margin:1px', + 'background-color:#6FAEC7', + 'border-radius:4px', + 'box-shadow:1px 1px 1px #707070', + 'line-height:14px', + 'padding-left:4px', + 'padding-right:4px', + ].join(';'); + return `${label}`; + } + + /** + * Builds a toggle-style button that shows red when the value is false/off. + * + * @param {boolean} value - Current boolean state (true = on/green, false = off/red). + * @param {string} href - Roll20 API command to execute on click. + * @returns {string} An HTML anchor string. + */ + function toggleBtn(value, href) { + const style = value === true ? '' : 'background-color:#A84D4D'; + return makeBtn(value === true ? 'Yes' : 'No', href, style); + } + + /** + * Builds a three-state name-setting button. Red for 'No', grey for 'Off', default for 'Yes'. + * + * @param {string} value - Current value: 'Yes', 'No', or 'Off'. + * @param {string} href - Roll20 API command to execute on click. + * @returns {string} An HTML anchor string. + */ + function nameBtn(value, href) { + let style = ''; + if (value === 'No') style = 'background-color:#A84D4D'; + if (value === 'Off') style = 'background-color:#D6D6D6'; + return makeBtn(value, href, style); + } + + /** + * Read-only pill counterpart to toggleBtn: green background for true, red for false. + * @param {boolean} value - Current boolean state. + * @returns {string} A styled span element. + */ + function boolPill(value) { + return makePill( + value ? 'Yes' : 'No', + value ? '' : 'background-color:#A84D4D', + ); + } + + /** + * Read-only pill counterpart to nameBtn: red for 'No', grey for 'Off', default for 'Yes'. + * + * @param {string} value - Current value: 'Yes', 'No', or 'Off'. + * @returns {string} A styled span element. + */ + function namePill(value) { + let style = ''; + if (value === 'No') style = 'background-color:#A84D4D'; + if (value === 'Off') style = 'background-color:#D6D6D6'; + return makePill(value, style); + } + + /** + * Renders and whispers the HealthColors configuration menu to the GM. + * Builds the full HTML panel using makeBtn/toggleBtn/nameBtn helpers and + * reflects all current state values as interactive button labels. + */ + function showMenu() { + const s = state.HealthColors; + const hr = `
`; + const wrapStyle = [ + 'border-radius:8px', + 'padding:5px', + 'font-size:9pt', + 'text-shadow:-1px -1px #222,1px -1px #222,-1px 1px #222,1px 1px #222,2px 2px #222', + 'box-shadow:3px 3px 1px #707070', + 'background-image:-webkit-linear-gradient(left,#76ADD6 0%,#a7c7dc 100%)', + 'color:#FFF', + 'border:2px solid black', + 'text-align:right', + 'vertical-align:middle', + ].join(';'); + + const percLabel = `${s.auraPercPC}/${s.auraPerc}`; + const healBtnStyle = `background-color:#${s.HealFX}`; + const hurtBtnStyle = `background-color:#${s.HurtFX}`; + const aura1Style = `background-color:#${s.Aura1Color}`; + const aura2Style = `background-color:#${s.Aura2Color}`; + const deadFxCmd = `!aura deadfx ?{Sound Name?|${s.auraDeadFX}}`; + const html = [ + `
`, + `HealthColors Version: ${VERSION}
`, + hr, + `Is On: ${toggleBtn(s.auraColorOn, '!aura on')}
`, + `Health Bar: ${makeBtn(s.auraBar, '!aura bar ?{Bar|1|2|3}')}
`, + `Use Tint: ${toggleBtn(s.auraTint, '!aura tint')}
`, + `Palette: ${makeBtn(s.colorPalette, '!aura palette ?{Palette|default|colorblind}', 'width:80px')} (auto refreshes all tokens)
`, + `Percentage(PC/NPC): ${makeBtn(percLabel, '!aura perc ?{PCPercent?|100} ?{NPCPercent?|100}')}
`, + hr, + `Show PC Health: ${toggleBtn(s.PCAura, '!aura pc')}
`, + `Show NPC Health: ${toggleBtn(s.NPCAura, '!aura npc')}
`, + `Show Dead PC: ${toggleBtn(s.auraDeadPC, '!aura deadPC')}
`, + `Show Dead NPC: ${toggleBtn(s.auraDead, '!aura dead')}
`, + hr, + `GM Sees all PC Names: ${nameBtn(s.GM_PCNames, '!aura gmpc ?{Setting|Yes|No|Off}')}
`, + `GM Sees all NPC Names: ${nameBtn(s.GM_NPCNames, '!aura gmnpc ?{Setting|Yes|No|Off}')}
`, + hr, + `PC Sees all PC Names: ${nameBtn(s.PCNames, '!aura pcpc ?{Setting|Yes|No|Off}')}
`, + `PC Sees all NPC Names: ${nameBtn(s.NPCNames, '!aura pcnpc ?{Setting|Yes|No|Off}')}
`, + hr, + `Aura 1 Radius (ft): ${makeBtn(s.AuraSize, '!aura size ?{Size?|0.35}')}
`, + `Aura 1 Shape: ${makeBtn(s.Aura1Shape, '!aura a1shape ?{Shape?|Circle|Square}')}
`, + `Aura 1 Color: ${makeBtn(s.Aura1Color, '!aura a1tint ?{Color?|00FF00}', aura1Style)}
`, + `Aura 2 Radius (ft): ${makeBtn(String(s.Aura2Size), '!aura a2size ?{Size?|5}')}
`, + `Aura 2 Shape: ${makeBtn(s.Aura2Shape, '!aura a2shape ?{Shape?|Square|Circle}')}
`, + `Aura 2 Color: ${makeBtn(s.Aura2Color, '!aura a2tint ?{Color?|806600}', aura2Style)}
`, + `One Offs: ${toggleBtn(s.OneOff, '!aura ONEOFF')}
`, + `FX: ${toggleBtn(s.FX, '!aura FX')}
`, + `HealFX Color: ${makeBtn(s.HealFX, '!aura HEAL ?{Color?|FDDC5C}', healBtnStyle)}
`, + `HurtFX Color: ${makeBtn(s.HurtFX, '!aura HURT ?{Color?|FF0000}', hurtBtnStyle)}
`, + `DeathSFX: ${makeBtn(s.auraDeadFX.substring(0, 4), deadFxCmd)}
`, + hr, + `
`, + ].join(''); + + sendChat(SCRIPT_NAME, `/w GM
${html}`); + } + + /** + * Renders a read-only settings snapshot to public game chat (all players). + * Triggered by `!aura settings` on demand; not called automatically after changes. + */ + function showSettingsInGameChat() { + const s = state.HealthColors; + const hr = `
`; + const wrapStyle = [ + 'border-radius:8px', + 'padding:5px', + 'font-size:9pt', + 'text-shadow:-1px -1px #222,1px -1px #222,-1px 1px #222,1px 1px #222,2px 2px #222', + 'box-shadow:3px 3px 1px #707070', + 'background-image:-webkit-linear-gradient(left,#76ADD6 0%,#a7c7dc 100%)', + 'color:#FFF', + 'border:2px solid black', + 'text-align:right', + 'vertical-align:middle', + ].join(';'); + + const percLabel = `${s.auraPercPC}/${s.auraPerc}`; + const aura1Style = `background-color:#${s.Aura1Color}`; + const aura2Style = `background-color:#${s.Aura2Color}`; + const healStyle = `background-color:#${s.HealFX}`; + const hurtStyle = `background-color:#${s.HurtFX}`; + const html = [ + `
`, + `HealthColors Settings: ${VERSION}
`, + hr, + `Is On: ${boolPill(s.auraColorOn)}
`, + `Bar: ${makePill(s.auraBar)}
`, + `Use Tint: ${boolPill(s.auraTint)}
`, + `Palette: ${makePill(s.colorPalette)}
`, + `Percentage(PC/NPC): ${makePill(percLabel)}
`, + hr, + `Show PC Health: ${boolPill(s.PCAura)}
`, + `Show NPC Health: ${boolPill(s.NPCAura)}
`, + `Show Dead PC: ${boolPill(s.auraDeadPC)}
`, + `Show Dead NPC: ${boolPill(s.auraDead)}
`, + hr, + `GM Sees all PC Names: ${namePill(s.GM_PCNames)}
`, + `GM Sees all NPC Names: ${namePill(s.GM_NPCNames)}
`, + hr, + `PC Sees all PC Names: ${namePill(s.PCNames)}
`, + `PC Sees all NPC Names: ${namePill(s.NPCNames)}
`, + hr, + `Aura 1 Radius: ${makePill(String(s.AuraSize))}
`, + `Aura 1 Shape: ${makePill(s.Aura1Shape)}
`, + `Aura 1 Color: ${makePill(s.Aura1Color, aura1Style)}
`, + `Aura 2 Radius: ${makePill(String(s.Aura2Size))}
`, + `Aura 2 Shape: ${makePill(s.Aura2Shape)}
`, + `Aura 2 Color: ${makePill(s.Aura2Color, aura2Style)}
`, + `One Offs: ${boolPill(s.OneOff)}
`, + `FX: ${boolPill(s.FX)}
`, + `HealFX Color: ${makePill(s.HealFX, healStyle)}
`, + `HurtFX Color: ${makePill(s.HurtFX, hurtStyle)}
`, + `DeathSFX: ${makePill(s.auraDeadFX)}
`, + hr, + `
`, + ].join(''); + + sendChat(SCRIPT_NAME, `
${html}`); + } + + // ————— CHAT HANDLER ————— + /** + * Processes incoming Roll20 chat messages to handle !aura commands. + * GM-only: non-GMs receive an access-denied whisper. + * Routes each subcommand (ON/OFF, BAR, TINT, PERC, PC, NPC, etc.) to the + * appropriate state mutation then refreshes the menu. BAR validates 1/2/3, + * whispers confirmation, and triggers immediate full sync. PALETTE also + * triggers immediate full sync so existing tokens update right away. + * When a setting changes, re-whispers the interactive menu to the GM. + * Use `!aura settings` to post a read-only settings snapshot to public game chat. + * + * @param {object} msg - Roll20 chat message object. + */ + function handleInput(msg) { + const parts = msg.content.split(/\s+/); + const command = parts[0].toUpperCase(); + if (msg.type !== 'api' || !command.includes('!AURA')) return; + + if (!playerIsGM(msg.playerid)) { + sendChat( + SCRIPT_NAME, + `/w ${msg.who} you must be a GM to use this command!`, + ); + return; + } + + const option = (parts[1] || 'MENU').toUpperCase(); + if (option !== 'MENU') gmWhisper('UPDATING TOKENS...'); + + const s = state.HealthColors; + + // Dispatch tables for structurally identical cases + const TOGGLES = { + PC: 'PCAura', + NPC: 'NPCAura', + DEAD: 'auraDead', + DEADPC: 'auraDeadPC', + ONEOFF: 'OneOff', + FX: 'FX', + }; + const STRINGS = { + GMNPC: 'GM_NPCNames', + GMPC: 'GM_PCNames', + PCNPC: 'NPCNames', + PCPC: 'PCNames', + DEADFX: 'auraDeadFX', + }; + const FLOATS = { SIZE: 'AuraSize', A2SIZE: 'Aura2Size' }; + const SHAPES = { A1SHAPE: 'Aura1Shape', A2SHAPE: 'Aura2Shape' }; + const HEXES = { A1TINT: 'Aura1Color', A2TINT: 'Aura2Color' }; + + if (TOGGLES[option]) { + s[TOGGLES[option]] = !s[TOGGLES[option]]; + } else if (STRINGS[option]) { + const key = STRINGS[option]; + if (key === 'auraDeadFX') { + s[key] = normalizeTrackName(parts[2], s[key]); + } else { + s[key] = normalizeYesNoOff(parts[2], s[key]); + } + } else if (FLOATS[option]) { + s[FLOATS[option]] = normalizePositiveNumber(parts[2], s[FLOATS[option]]); + } else if (SHAPES[option]) { + s[SHAPES[option]] = normalizeShape(parts[2], s[SHAPES[option]]); + } else if (HEXES[option]) { + s[HEXES[option]] = normalizeHex6(parts[2], s[HEXES[option]]); + } else { + switch (option) { + case 'MENU': + break; + case 'SETTINGS': + showSettingsInGameChat(); + return; + case 'TINT': + s.auraTint = !s.auraTint; + modeSwitch(s.auraTint); + break; + case 'ON': + s.auraColorOn = true; + break; + case 'OFF': + s.auraColorOn = false; + break; + case 'BAR': + if (/^[123]$/.test(parts[2] || '')) { + s.auraBar = `bar${parts[2]}`; + gmWhisper(`Health bar set to ${s.auraBar}. Forcing sync...`); + menuForceUpdate(); + } else { + gmWhisper( + `Invalid bar "${parts[2] || ''}". Use !aura bar 1, !aura bar 2, or !aura bar 3.`, + ); + } + break; + case 'PERC': + s.auraPercPC = normalizePercent(parts[2], s.auraPercPC); + s.auraPerc = normalizePercent(parts[3], s.auraPerc); + menuForceUpdate(); + break; + case 'PALETTE': + s.colorPalette = normalizePalette(parts[2], s.colorPalette); + menuForceUpdate(); + break; + case 'HEAL': + s.HealFX = normalizeHex6(parts[2], s.HealFX); + syncDefaultFxObjects(); + break; + case 'HURT': + s.HurtFX = normalizeHex6(parts[2], s.HurtFX); + syncDefaultFxObjects(); + break; + case 'RESET': + delete state.HealthColors; + gmWhisper('STATE RESET'); + checkInstall(); + break; + case 'RESET-FX': + resetDefaultFxObjects(); + break; + case 'RESET-ALL': + runResetAllFlow(); + break; + case 'FORCEALL': + menuForceUpdate(); + return; + case 'UPDATE': + manUpdate(msg); + return; + } + } + + showMenu(); + } + + // ————— OUTSIDE API ————— + /** + * Public entry point for external scripts to request a token color update. + * Validates that the object is a graphic before delegating to handleToken. + * + * @param {object} obj - Roll20 object to update. + * @param {object} prev - Previous attribute snapshot (passed through to handleToken). + */ + function updateToken(obj, prev) { + if (obj.get('type') === 'graphic') { + handleToken(obj, prev); + } else { + gmWhisper('Script sent non-Token to be updated!'); + } + } + + // ————— EVENT HANDLERS ————— + /** + * Registers all Roll20 event listeners for the script. + * - chat:message → handleInput (command processing) + * - change:graphic → handleToken (live HP changes and token resizes) + * - add:token → handleToken (with 400ms delay to allow token data to settle) + */ + function registerEventHandlers() { + on('chat:message', handleInput); + on('change:graphic', handleToken); + on('add:token', (t) => { + setTimeout(() => { + const token = getObj('graphic', t.id); + const prev = deepClone(token); + handleToken(token, prev, 'YES'); + }, 400); + }); + } + + // ————— BOOTSTRAP ————— + globalThis.HealthColors = { + gmWhisper, + update: updateToken, + checkInstall, + registerEventHandlers, + }; + + on('ready', () => { + gmWhisper(`MOD READY (v${VERSION})`); + checkInstall(); + registerEventHandlers(); + }); +})(); diff --git a/HealthColors/CHANGELOG.md b/HealthColors/CHANGELOG.md index 5dc8e1500..11dbed5e8 100644 --- a/HealthColors/CHANGELOG.md +++ b/HealthColors/CHANGELOG.md @@ -5,6 +5,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [2.1.3] – 2026-05-22 · [Milestone](https://github.com/steverobertsuk/roll20-api-scripts/milestone/6) + +### Fixed + +- Fixed HealthColors treating an empty or whitespace-only `USEBLOOD` value as a custom FX name. The script now trims and validates the attribute before FX lookup, so fetched/imported characters with a blank override fall back to the default hurt FX instead of whispering a missing custom FX warning. +- Hardened `!aura` setting validation for the remaining editable values. Percentage thresholds now reject invalid input, heal/hurt colors require valid hex values, yes/no/off settings are normalized, and blank death sound names no longer overwrite the previous value. + ## [2.1.2] – 2026-05-17 · [Milestone](https://github.com/steverobertsuk/roll20-api-scripts/milestone/5) ### Fixed diff --git a/HealthColors/HealthColors.js b/HealthColors/HealthColors.js index d2f084d3d..f1e86d71a 100644 --- a/HealthColors/HealthColors.js +++ b/HealthColors/HealthColors.js @@ -1,9 +1,10 @@ // =========================== -// === HealthColors v2.1.2 === +// === HealthColors v2.1.3 === // =========================== // AUTHORS: // - DXWarlock: https://app.roll20.net/users/262130/dxwarlock +// - Surok: https://app.roll20.net/users/335573/surok // - MidNiteShadow7: https://app.roll20.net/users/16506286/midniteshadow7 /* global createObj TokenMod spawnFxWithDefinition spawnFx getObj state playerIsGM sendChat findObjs log on */ @@ -12,10 +13,10 @@ const HealthColors = (() => { 'use strict'; // ————— CONSTANTS ————— - const VERSION = '2.1.2'; + const VERSION = '2.1.3'; const SCRIPT_NAME = 'HealthColors'; const SCHEMA_VERSION = '1.1.0'; - const UPDATED = '2026-05-17 12:35 UTC'; + const UPDATED = '2026-05-22 16:45 UTC'; // ————— DEFAULTS ————— /** @@ -338,6 +339,60 @@ const HealthColors = (() => { return COLOR_PALETTES[p] ? p : fallback; } + /** + * Normalizes a percentage setting to an integer between 0 and 100. + * + * @param {string|number} value - Candidate percentage. + * @param {number} fallback - Fallback percentage when invalid. + * @returns {number} A valid percentage value. + */ + function normalizePercent(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed >= 0 && parsed <= 100 + ? parsed + : fallback; + } + + /** + * Normalizes a positive numeric setting. + * + * @param {string|number} value - Candidate numeric value. + * @param {number} fallback - Fallback value when invalid. + * @returns {number} A valid non-negative number. + */ + function normalizePositiveNumber(value, fallback) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; + } + + /** + * Normalizes a Yes/No/Off style setting. + * + * @param {string} value - Candidate setting value. + * @param {string} fallback - Fallback value when invalid. + * @returns {string} One of Yes, No, or Off. + */ + function normalizeYesNoOff(value, fallback) { + const normalized = (value || '').trim().toUpperCase(); + if (normalized === 'YES') return 'Yes'; + if (normalized === 'NO') return 'No'; + if (normalized === 'OFF') return 'Off'; + return fallback; + } + + /** + * Normalizes a death sound track name. + * + * @param {string} value - Candidate track name. + * @param {string} fallback - Fallback track name when invalid. + * @returns {string} A trimmed track name or None. + */ + function normalizeTrackName(value, fallback) { + const normalized = (value || '').trim(); + if (!normalized) return fallback; + return normalized.toUpperCase() === 'NONE' ? 'None' : normalized; + } + // ————— WHISPER GM (declared early; used by checkInstall) ————— /** * Sends a styled whisper message to the GM. @@ -413,10 +468,13 @@ const HealthColors = (() => { }; } - const lookupUseBlood = makeSmartAttrCache('USEBLOOD', { default: 'DEFAULT' }); + const lookupUseBlood = makeSmartAttrCache('USEBLOOD', { + default: 'DEFAULT', + validation: (o) => String(o || '').trim() !== '', + }); const lookupUseColor = makeSmartAttrCache('USECOLOR', { default: 'YES', - validation: (o) => Boolean(o.match(/YES|NO/)), + validation: (o) => /^(YES|NO)$/i.test(String(o || '').trim()), }); // ————— TOKEN HELPERS ————— @@ -915,7 +973,8 @@ const HealthColors = (() => { def.endColorRandom = [0, 0, 0, 0]; fxArray.push(def); } else { - const hurtRgb = hexToRgb(useBlood); + const normalizedUseBlood = String(useBlood || '').trim(); + const hurtRgb = hexToRgb(normalizedUseBlood); if (hurtRgb.some((v) => v !== 0)) { def.startColour = hurtRgb; @@ -928,7 +987,26 @@ const HealthColors = (() => { def.endColorRandom = [0, 0, 0, 0]; fxArray.push(def); } else { - useBlood.split(',').forEach((fxName) => { + const fxNames = normalizedUseBlood + .split(',') + .map((fxName) => fxName.trim()) + .filter((fxName) => fxName !== ''); + + if (fxNames.length === 0) { + const hurtRgb = hexToRgb(state.HealthColors.HurtFX); + def.startColour = hurtRgb; + def.startColor = hurtRgb; + def.endColour = hurtRgb; + def.endColor = hurtRgb; + def.startColourRandom = [0, 0, 0, 0]; + def.startColorRandom = [0, 0, 0, 0]; + def.endColourRandom = [0, 0, 0, 0]; + def.endColorRandom = [0, 0, 0, 0]; + fxArray.push(def); + return fxArray; + } + + fxNames.forEach((fxName) => { const custom = findObjs( { _type: 'custfx', name: fxName.trim() }, { caseInsensitive: true }, @@ -1430,9 +1508,14 @@ const HealthColors = (() => { if (TOGGLES[option]) { s[TOGGLES[option]] = !s[TOGGLES[option]]; } else if (STRINGS[option]) { - s[STRINGS[option]] = parts[2]; + const key = STRINGS[option]; + if (key === 'auraDeadFX') { + s[key] = normalizeTrackName(parts[2], s[key]); + } else { + s[key] = normalizeYesNoOff(parts[2], s[key]); + } } else if (FLOATS[option]) { - s[FLOATS[option]] = Number.parseFloat(parts[2]); + s[FLOATS[option]] = normalizePositiveNumber(parts[2], s[FLOATS[option]]); } else if (SHAPES[option]) { s[SHAPES[option]] = normalizeShape(parts[2], s[SHAPES[option]]); } else if (HEXES[option]) { @@ -1466,8 +1549,8 @@ const HealthColors = (() => { } break; case 'PERC': - s.auraPercPC = Number.parseInt(parts[2], 10); - s.auraPerc = Number.parseInt(parts[3], 10); + s.auraPercPC = normalizePercent(parts[2], s.auraPercPC); + s.auraPerc = normalizePercent(parts[3], s.auraPerc); menuForceUpdate(); break; case 'PALETTE': @@ -1475,11 +1558,11 @@ const HealthColors = (() => { menuForceUpdate(); break; case 'HEAL': - s.HealFX = parts[2].toUpperCase(); + s.HealFX = normalizeHex6(parts[2], s.HealFX); syncDefaultFxObjects(); break; case 'HURT': - s.HurtFX = parts[2].toUpperCase(); + s.HurtFX = normalizeHex6(parts[2], s.HurtFX); syncDefaultFxObjects(); break; case 'RESET': diff --git a/HealthColors/script.json b/HealthColors/script.json index b58a66ecf..f40276c84 100644 --- a/HealthColors/script.json +++ b/HealthColors/script.json @@ -1,8 +1,9 @@ { "name": "Aura/Tint HealthColors", "script": "HealthColors.js", - "version": "2.1.2", + "version": "2.1.3", "previousversions": [ + "2.1.2", "2.1.1", "2.1.0", "2.0.1",