From 89655508cc80e0278170a886d4aa0360cb2df653 Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Sun, 25 Oct 2026 15:43:29 +0000 Subject: [PATCH 01/10] feat(HealthColors): Apply the Aura 1 shape, opacity and player lock to tokens Roll20's September 2026 update added Hex, Circle Border, Square Border and Hex Border auras alongside Circle and Square. HealthColors only knew Circle and Square, and its Aura 1 shape setting was display-only: it was shown in the menu but never written to a token. Add AURA_SHAPES with all six Roll20 shapes and their aura1_options API values, plus three HealthColors choices: Auto (Hex on hex-grid pages once the radius fills a grid cell, Circle otherwise, because Roll20 draws hex auras in whole cells and a smaller hex ring would be invisible), Auto Border (the outline versions) and Keep (never change a token's shape). normalizeShape now accepts a label, the API value or a spacing/punctuation variant, so "Circle Border", "circleborder" and "circle-border" all work, including from chat. In aura mode tokenSet now writes the resolved aura1_options, adds an alpha channel for the new opacity setting (!aura opacity <1-100>, default 100 = solid) and, with the new player lock (!aura lock, default on), turns off the token's "players can edit Aura 1" option. Switching to tint mode releases Aura 1 through releaseAura1: the ring is cleared, its shape returns to Circle unless Shape is Keep, and players can edit it again. Changing the radius, shape, opacity or lock repaints every token straight away, and the colour match used when clearing a ring allows for the alpha channel. Upgrading must not reshape auras set by hand: migrateAuraShapeState switches a stored shape that is still the old default Circle to Keep on first load. Any other stored shape was a deliberate choice and is now applied. New installs default to Auto. Add opacity and lock rows to the GM menu and settings snapshot, list the new shapes in the shape prompts, label aura radii in page units, describe Aura 1 colour and the Aura 2 settings as reference values that are never applied, and add the auraOpacity and auraLockPlayers One-Click options. script.json declares the new options, the extended shape lists and the aura1_options, aura1_square, playersedit_aura1 and page.grid_type properties. Signed-off-by: Steve Roberts --- HealthColors/HealthColors.js | 211 +++++++++++++++++++++++++++++------ HealthColors/script.json | 46 ++++++-- 2 files changed, 211 insertions(+), 46 deletions(-) diff --git a/HealthColors/HealthColors.js b/HealthColors/HealthColors.js index e319eb867..aa411a952 100644 --- a/HealthColors/HealthColors.js +++ b/HealthColors/HealthColors.js @@ -39,12 +39,17 @@ var HealthColors = * @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 {number} AuraSize - Page units (usually feet) the aura extends beyond the token edge. + * @property {string} Aura1Shape - Shape applied to the Aura 1 health ring in aura mode: a Roll20 shape + * (Circle, Square, Hex, Circle Border, Square Border, Hex Border), + * Auto / Auto Border (hex on hex-grid pages, circle otherwise), or + * Keep (leave each token's own shape alone). + * @property {number} auraOpacity - Aura 1 health-ring opacity in percent (100 = solid, as before). + * @property {boolean} auraLockPlayers - When true (aura mode), controlling players cannot edit Aura 1. + * @property {string} Aura1Color - Reference value only (aura mode colours Aura 1 by health). + * @property {number} Aura2Size - Reference value only; Aura 2 is never changed by the script. + * @property {string} Aura2Shape - Reference value only; Aura 2 is never changed by the script. + * @property {string} Aura2Color - Reference value only; Aura 2 is never changed by the script. * @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 - Healing effect: a hex color (no '#') that recolours the default particle @@ -76,7 +81,9 @@ var HealthColors = GM_NPCNames: 'Yes', NPCNames: 'Yes', AuraSize: 0.35, - Aura1Shape: 'Circle', + Aura1Shape: 'Auto', + auraOpacity: 100, + auraLockPlayers: true, Aura1Color: '00FF00', Aura2Size: 5, Aura2Shape: 'Square', @@ -465,18 +472,89 @@ var HealthColors = return isHex6(value) ? `background-color:#${value}` : 'width:auto;padding-left:6px;padding-right:6px'; } + // ————— AURA SHAPES ————— /** - * Normalizes an aura shape label to supported display values. + * Roll20 aura shapes (Token Settings → Aura → Shape), keyed by display label, mapped to the + * `aura1_options` / `aura2_options` API value. Roll20 keeps `aura1_square` in sync itself. + */ + const AURA_SHAPES = Object.freeze({ + Circle: 'circle', + Square: 'square', + Hex: 'hex', + 'Circle Border': 'circleborder', + 'Square Border': 'squareborder', + 'Hex Border': 'hexborder', + }); + + /** + * HealthColors-only Aura 1 choices: Auto picks hex on hex-grid pages and circle otherwise + * (Auto Border uses the outline versions); Keep never touches the token's shape. + */ + const AURA1_SPECIAL_SHAPES = Object.freeze(['Auto', 'Auto Border', 'Keep']); + + /** Roll-query option list for the Aura 1 shape prompt. */ + const AURA1_SHAPE_CHOICES = [...AURA1_SPECIAL_SHAPES, ...Object.keys(AURA_SHAPES)].join('|'); + + /** Roll-query option list for the Aura 2 (reference-only) shape prompt. */ + const AURA2_SHAPE_CHOICES = Object.keys(AURA_SHAPES).join('|'); + + /** + * Normalizes an aura shape to its display label. Accepts the label, the API value or any + * spacing/punctuation variant ("Circle Border", "circleborder", "circle-border", "circle_border"). * - * @param {string} value - Candidate shape value. - * @param {string} fallback - Fallback shape. - * @returns {string} One of Circle|Square. + * @param {string} value - Candidate shape value. + * @param {string} fallback - Fallback shape. + * @param {boolean} [allowSpecial] - Also accept Auto, Auto Border and Keep (Aura 1 only). + * @returns {string} A display label from AURA_SHAPES (or AURA1_SPECIAL_SHAPES). */ - function normalizeShape(value, fallback) { - const shape = (value || '').trim().toUpperCase(); - if (shape === 'CIRCLE') return 'Circle'; - if (shape === 'SQUARE') return 'Square'; - return fallback; + function normalizeShape(value, fallback, allowSpecial = true) { + const squash = (v) => + String(v ?? '') + .toLowerCase() + .replace(/[^a-z]/g, ''); + const wanted = squash(value); + if (!wanted) return fallback; + const labels = allowSpecial ? [...AURA1_SPECIAL_SHAPES, ...Object.keys(AURA_SHAPES)] : Object.keys(AURA_SHAPES); + return labels.find((label) => squash(label) === wanted) || fallback; + } + + /** + * Resolves the configured Aura 1 shape for a token into a Roll20 `aura1_options` value. + * Auto / Auto Border look at the token's page grid (`hex` or `hexr` → hex shapes). Roll20 draws + * hex auras in whole grid cells, so a radius under one cell (e.g. the default 0.35) would show + * almost nothing; Auto then uses the circle shapes instead. + * + * @param {object} obj - Roll20 token graphic object. + * @returns {string|null} API shape value, or null when the shape should be left alone (Keep). + */ + function resolveAura1Shape(obj) { + const label = normalizeShape(state.HealthColors.Aura1Shape, DEFAULTS.Aura1Shape); + if (label === 'Keep') return null; + if (label === 'Auto' || label === 'Auto Border') { + const page = getObj('page', obj.get('_pageid')); + const gridType = String(page?.get('grid_type') || '').toLowerCase(); + const cellUnits = (Number(page?.get('scale_number')) || 5) * (Number(page?.get('snapping_increment')) || 1); + const fillsACell = Number(state.HealthColors.AuraSize) >= cellUnits; + const base = gridType.startsWith('hex') && fillsACell ? 'hex' : 'circle'; + return label === 'Auto Border' ? `${base}border` : base; + } + return AURA_SHAPES[label] || 'circle'; + } + + /** + * Applies the configured Aura 1 opacity to a `#rrggbb` colour by appending a two-digit + * alpha channel (Roll20 accepts `#rrggbbaa` for auras). 100% returns the colour unchanged. + * + * @param {string} hex - Colour in `#rrggbb` form. + * @returns {string} `#rrggbb` or `#rrggbbaa`. + */ + function withAuraOpacity(hex) { + const pct = normalizePercent(state.HealthColors.auraOpacity, 100); + if (pct >= 100 || !/^#[0-9a-f]{6}$/i.test(hex)) return hex; + const alpha = Math.round((pct / 100) * 255) + .toString(16) + .padStart(2, '0'); + return `${hex}${alpha}`; } /** @@ -816,9 +894,14 @@ var HealthColors = NPCNames: normalizeYesNoOff, AuraSize: normalizePositiveNumber, Aura1Shape: normalizeShape, + auraOpacity: (value, current) => { + const pct = normalizePercent(value, current); + return pct >= 1 ? pct : current; + }, + auraLockPlayers: parseBooleanOption, Aura1Color: normalizeHex6, Aura2Size: normalizePositiveNumber, - Aura2Shape: normalizeShape, + Aura2Shape: (value, current) => normalizeShape(value, current, false), Aura2Color: normalizeHex6, OneOff: parseBooleanOption, FX: parseBooleanOption, @@ -1015,25 +1098,45 @@ var HealthColors = /** * 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. + * When in tint mode, sets tint_color. When in aura mode, sets the Aura 1 radius, colour + * (with the configured opacity), shape (unless Keep) and player-edit lock. * 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 {number} sizeSet - Page units 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) { + const s = state.HealthColors; + if (s.auraTint) { obj.set({ tint_color: markerColor }); - } else { - obj.set({ - tint_color: 'transparent', - aura1_radius: sizeSet, - aura1_color: markerColor, - showplayers_aura1: true, - }); + return; } + const changes = { + tint_color: 'transparent', + aura1_radius: sizeSet, + aura1_color: withAuraOpacity(markerColor), + showplayers_aura1: true, + playersedit_aura1: s.auraLockPlayers !== true, + }; + const shape = resolveAura1Shape(obj); + if (shape) changes.aura1_options = shape; + obj.set(changes); + } + + /** + * Hands Aura 1 back to the GM when the script stops managing it (switching to tint mode): + * clears the ring, restores Roll20's default circle shape (unless Shape is Keep) and lets + * controlling players edit it again. + * + * @param {object} obj - Roll20 token graphic object. + */ + function releaseAura1(obj) { + const changes = { aura1_color: 'transparent', aura1_radius: 0, playersedit_aura1: true }; + if (normalizeShape(state.HealthColors.Aura1Shape, DEFAULTS.Aura1Shape) !== 'Keep') { + changes.aura1_options = 'circle'; + } + obj.set(changes); } /** @@ -1205,6 +1308,21 @@ var HealthColors = delete s.pcDyingMarker; } + /** + * Before v2.4.0 the Aura 1 shape setting was display-only, so every existing game reports + * the old default "Circle" without that ever having changed a token. On first load of + * v2.4.0 an untouched "Circle" becomes "Keep" so upgrading does not reshape auras GMs set + * by hand; any other stored shape (a deliberate choice) is kept and now applied. + */ + function migrateAuraShapeState() { + const s = state.HealthColors; + if (!s || s.auraShapeApplied) return; + if (s.Aura1Shape === 'Circle') s.Aura1Shape = 'Keep'; + if (s.Aura1Shape !== undefined) s.Aura1Shape = normalizeShape(s.Aura1Shape, DEFAULTS.Aura1Shape); + if (s.Aura2Shape !== undefined) s.Aura2Shape = normalizeShape(s.Aura2Shape, DEFAULTS.Aura2Shape, false); + s.auraShapeApplied = true; + } + /** * Initializes or migrates persisted state, applies all default values, applies any * newer One-Click install options, registers the TokenMod observer if available, and @@ -1218,6 +1336,7 @@ var HealthColors = state.HealthColors = { schemaVersion: SCHEMA_VERSION, version: VERSION }; } migrateDeathSaveState(); + migrateAuraShapeState(); Object.keys(DEFAULTS).forEach((key) => { if (state.HealthColors[key] === undefined) state.HealthColors[key] = DEFAULTS[key]; }); @@ -1525,6 +1644,7 @@ var HealthColors = const useAura = oCharacter ? lookupUseColor(oCharacter) : undefined; const useTint = state.HealthColors.auraTint; const colorType = useTint ? 'tint' : 'aura1'; + const appliedColor = useTint ? markerColor : withAuraOpacity(markerColor); // Death Save Integration (PC-only) owns the dying/stable/dead markers when enabled. const deathSaveHandled = applyDeathSaveStatus(obj, curValue, isPlayer); @@ -1540,7 +1660,7 @@ var HealthColors = } else { tokenSet(obj, state.HealthColors.AuraSize, markerColor); } - } else if (obj.get(`${colorType}_color`) === markerColor) { + } else if (String(obj.get(`${colorType}_color`) || '').toLowerCase() === appliedColor.toLowerCase()) { clearAuras(obj); } } @@ -1766,7 +1886,7 @@ var HealthColors = /** * 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 + * it releases aura1 on each token (see releaseAura1) 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. * @@ -1781,7 +1901,7 @@ var HealthColors = const drainQueue = () => { const token = workQueue.shift(); if (!token) return; - if (toTint) token.set({ aura1_color: 'transparent', aura1_radius: 0 }); + if (toTint) releaseAura1(token); const prev = deepClone(token); handleToken(token, prev, 'YES'); setTimeout(drainQueue, 0); @@ -3455,11 +3575,13 @@ var HealthColors = `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 Radius (units): ${makeBtn(s.AuraSize, '!aura size ?{Size?|0.35}')}
`, + `Aura 1 Shape: ${makeBtn(escapeForChat(s.Aura1Shape), `!aura a1shape ?{Shape?|${AURA1_SHAPE_CHOICES}}`, wide)}
`, + `Aura 1 Opacity %: ${makeBtn(String(s.auraOpacity), `!aura opacity ?{Opacity % (1-100)|${s.auraOpacity}}`)}
`, + `Lock Aura 1 (players): ${toggleBtn(s.auraLockPlayers, '!aura lock')}
`, `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 Radius (units): ${makeBtn(String(s.Aura2Size), '!aura a2size ?{Size?|5}')}
`, + `Aura 2 Shape: ${makeBtn(s.Aura2Shape, `!aura a2shape ?{Shape?|${AURA2_SHAPE_CHOICES}}`)}
`, `Aura 2 Color: ${makeBtn(s.Aura2Color, '!aura a2tint ?{Color?|806600}', aura2Style)}
`, `One Offs: ${toggleBtn(s.OneOff, '!aura ONEOFF')}
`, `FX: ${toggleBtn(s.FX, '!aura FX')}
`, @@ -3547,6 +3669,8 @@ var HealthColors = hr, `Aura 1 Radius: ${makePill(String(s.AuraSize))}
`, `Aura 1 Shape: ${makePill(s.Aura1Shape)}
`, + `Aura 1 Opacity %: ${makePill(String(s.auraOpacity))}
`, + `Lock Aura 1 (players): ${boolPill(s.auraLockPlayers)}
`, `Aura 1 Color: ${makePill(s.Aura1Color, aura1Style)}
`, `Aura 2 Radius: ${makePill(String(s.Aura2Size))}
`, `Aura 2 Shape: ${makePill(s.Aura2Shape)}
`, @@ -3589,6 +3713,7 @@ var HealthColors = DEADPC: 'auraDeadPC', ONEOFF: 'OneOff', FX: 'FX', + LOCK: 'auraLockPlayers', }; const STRINGS = { GMNPC: 'GM_NPCNames', @@ -3618,7 +3743,13 @@ var HealthColors = return true; } if (SHAPES[option]) { - s[SHAPES[option]] = normalizeShape(parts[2], s[SHAPES[option]]); + // Join the remaining words so two-word shapes ("Circle Border") work from chat. + s[SHAPES[option]] = normalizeShape(parts.slice(2).join(' '), s[SHAPES[option]], option === 'A1SHAPE'); + return true; + } + if (option === 'OPACITY') { + const pct = normalizePercent(parts[2], s.auraOpacity); + s.auraOpacity = pct >= 1 ? pct : s.auraOpacity; return true; } if (HEXES[option]) { @@ -3724,6 +3855,9 @@ var HealthColors = return handler ? handler() : { handled: false, earlyReturn: false }; } + /** Mapped options that change how the Aura 1 health ring looks, so tokens need repainting. */ + const AURA_APPEARANCE_OPTIONS = new Set(['SIZE', 'A1SHAPE', 'OPACITY', 'LOCK']); + /** * Processes incoming Roll20 chat messages to handle !aura commands. * GM-only: non-GMs receive an access-denied whisper. @@ -3756,7 +3890,10 @@ var HealthColors = if (option !== 'MENU' && option !== 'LISTFX') gmWhisper('UPDATING TOKENS...'); const s = state.HealthColors; - if (!applyMappedAuraOption(option, parts, s)) { + if (applyMappedAuraOption(option, parts, s)) { + // Aura 1 appearance settings are applied to tokens, so repaint them straight away. + if (AURA_APPEARANCE_OPTIONS.has(option) && !s.auraTint) menuForceUpdate(); + } else { const result = runAuraSpecialOption(option, parts, msg, s); if (result.earlyReturn) return; } diff --git a/HealthColors/script.json b/HealthColors/script.json index 6467284af..39668eadf 100644 --- a/HealthColors/script.json +++ b/HealthColors/script.json @@ -148,45 +148,69 @@ "name": "AuraSize", "type": "number", "default": 0.35, - "description": "Aura 1 health-ring radius in feet beyond the token edge (aura mode). Default: 0.35." + "description": "Aura 1 health-ring radius in page units (usually feet) beyond the token edge (aura mode). Default: 0.35." }, { "name": "Aura1Shape", "type": "select", "options": [ + "Auto", + "Auto Border", + "Keep", "Circle", - "Square" + "Square", + "Hex", + "Circle Border", + "Square Border", + "Hex Border" ], - "default": "Circle", - "description": "Aura 1 shape shown in the settings output. Default: Circle." + "default": "Auto", + "description": "Aura 1 health-ring shape (aura mode). Auto = hex on hex-grid pages when the radius is at least one grid cell, circle otherwise; Auto Border = outline versions; Keep = leave each token’s shape alone. Default: Auto." + }, + { + "name": "auraOpacity", + "type": "number", + "default": 100, + "description": "Aura 1 health-ring opacity in percent, 1-100 (100 = solid). Default: 100." + }, + { + "name": "auraLockPlayers", + "type": "checkbox", + "value": "true", + "checked": "checked", + "description": "Stop controlling players editing Aura 1 while HealthColors manages it (aura mode). Default: enabled." }, { "name": "Aura1Color", "type": "text", "default": "00FF00", - "description": "Aura 1 display colour shown in the settings output, as a 6-digit hex value without #. Default: 00FF00." + "description": "Aura 1 colour reference value, as a 6-digit hex value without # (not applied; aura mode colours by health). Default: 00FF00." }, { "name": "Aura2Size", "type": "number", "default": 5, - "description": "Aura 2 radius shown in the settings output. Default: 5." + "description": "Aura 2 radius reference value (not applied to tokens). Default: 5." }, { "name": "Aura2Shape", "type": "select", "options": [ "Square", - "Circle" + "Circle", + "Hex", + "Circle Border", + "Square Border", + "Hex Border" ], "default": "Square", - "description": "Aura 2 shape shown in the settings output. Default: Square." + "description": "Aura 2 shape reference value (not applied to tokens). Default: Square." }, { "name": "Aura2Color", "type": "text", "default": "806600", - "description": "Aura 2 display colour shown in the settings output, as a 6-digit hex value without #. Default: 806600." + "description": "Aura 2 colour reference value, as a 6-digit hex value without # (not applied to tokens). Default: 806600." }, { "name": "OneOff", @@ -264,7 +288,10 @@ "graphic.bar3_link": "read", "graphic.aura1_color": "read,write", "graphic.aura1_radius": "write", + "graphic.aura1_options": "read,write", + "graphic.aura1_square": "read", "graphic.showplayers_aura1": "write", + "graphic.playersedit_aura1": "write", "graphic.tint_color": "read,write", "graphic.statusmarkers": "read,write", "graphic.showname": "write", @@ -278,6 +305,7 @@ "graphic.left": "read", "graphic.top": "read", "graphic._pageid": "read", + "page.grid_type": "read", "character.name": "read", "character.controlledby": "read", "attribute.name": "read,write", From e495cb76014ac77c07f8df6a05ce1a3baa4dba9b Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Sun, 25 Oct 2026 15:43:45 +0000 Subject: [PATCH 02/10] feat(HealthColors): Allow bar 4 as the health bar Roll20 games can raise "Maximum Token Bubbles/Bars" to 4, but HealthColors only accepted bars 1 to 3 as the bar holding hit points. Accept bar 4 in !aura bar, the GM menu prompt and the auraBar One-Click option, and make the invalid-bar message explain that bar 4 needs the game setting raised to 4. script.json lists bar4 for auraBar and declares the graphic.bar4_value, bar4_max and bar4_link properties. Signed-off-by: Steve Roberts --- HealthColors/HealthColors.js | 17 ++++++++++------- HealthColors/script.json | 8 ++++++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/HealthColors/HealthColors.js b/HealthColors/HealthColors.js index aa411a952..a92613abe 100644 --- a/HealthColors/HealthColors.js +++ b/HealthColors/HealthColors.js @@ -27,7 +27,7 @@ var HealthColors = * 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 {string} auraBar - Which token bar to read HP from ('bar1'|'bar2'|'bar3'|'bar4'). * @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). @@ -860,7 +860,8 @@ var HealthColors = } /** - * Normalizes a One-Click health-bar option ('bar1' | 'bar2' | 'bar3', any case). + * Normalizes a One-Click health-bar option ('bar1' | 'bar2' | 'bar3' | 'bar4', any case). + * bar4 needs the game's "Maximum Token Bubbles/Bars" setting raised to 4. * * @param {string} value - Raw option value. * @param {string} fallback - Current setting. @@ -870,7 +871,7 @@ var HealthColors = const bar = String(value ?? '') .trim() .toLowerCase(); - return /^bar[123]$/.test(bar) ? bar : fallback; + return /^bar[1-4]$/.test(bar) ? bar : fallback; } /** @@ -3559,7 +3560,7 @@ var HealthColors = `
`, hr, `Is On: ${toggleBtn(s.auraColorOn, '!aura on')}
`, - `Health Bar: ${makeBtn(s.auraBar, '!aura bar ?{Bar|1|2|3}')}
`, + `Health Bar: ${makeBtn(s.auraBar, '!aura bar ?{Bar|1|2|3|4}')}
`, `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}')}
`, @@ -3790,12 +3791,14 @@ var HealthColors = return { handled: true, earlyReturn: false }; }, BAR: () => { - if (/^[123]$/.test(parts[2] || '')) { + if (/^[1-4]$/.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.`); + gmWhisper( + `Invalid bar "${parts[2] || ''}". Use !aura bar 1, 2, 3 or 4 (bar 4 needs Maximum Token Bubbles/Bars set to 4).`, + ); } return { handled: true, earlyReturn: false }; }, @@ -3862,7 +3865,7 @@ var HealthColors = * 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, HEAL, HURT, LISTFX, etc.) - * to the appropriate state mutation then refreshes the menu. BAR validates 1/2/3, + * to the appropriate state mutation then refreshes the menu. BAR validates 1–4, * 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. diff --git a/HealthColors/script.json b/HealthColors/script.json index 39668eadf..a3447cd34 100644 --- a/HealthColors/script.json +++ b/HealthColors/script.json @@ -39,10 +39,11 @@ "options": [ "bar1", "bar2", - "bar3" + "bar3", + "bar4" ], "default": "bar1", - "description": "Token bar that holds hit points. Default: bar1." + "description": "Token bar that holds hit points. bar4 needs Maximum Token Bubbles/Bars set to 4. Default: bar1." }, { "name": "auraTint", @@ -280,12 +281,15 @@ "graphic.bar1_value": "read,write", "graphic.bar2_value": "read,write", "graphic.bar3_value": "read,write", + "graphic.bar4_value": "read,write", "graphic.bar1_max": "read", "graphic.bar2_max": "read", "graphic.bar3_max": "read", + "graphic.bar4_max": "read", "graphic.bar1_link": "read", "graphic.bar2_link": "read", "graphic.bar3_link": "read", + "graphic.bar4_link": "read", "graphic.aura1_color": "read,write", "graphic.aura1_radius": "write", "graphic.aura1_options": "read,write", From bb2b924c4b08a673ec112d6d6c0776c3bcc23b42 Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Sun, 25 Oct 2026 15:44:01 +0000 Subject: [PATCH 03/10] fix(HealthColors): Re-apply health colours after the token settings dialog is saved Saving the token settings dialog can overwrite an aura that HealthColors applied while the dialog was open. Because the token's HP, size and bar linkage had not changed, handleToken treated the save as irrelevant and the health aura or tint was lost until the next HP change. Treat a change to the Aura 1 radius, colour, shape (aura1_options or aura1_square) or visibility, or to the tint in tint mode, as relevant, so HealthColors re-applies its colour. Signed-off-by: Steve Roberts --- HealthColors/HealthColors.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/HealthColors/HealthColors.js b/HealthColors/HealthColors.js index a92613abe..f081efcca 100644 --- a/HealthColors/HealthColors.js +++ b/HealthColors/HealthColors.js @@ -1855,6 +1855,14 @@ var HealthColors = const { maxValue, curValue, prevValue, hasPrevValue } = health; const sizeChanged = prev.width !== obj.get('width') || prev.height !== obj.get('height'); const linkageChanged = barLinkageChanged(obj, prev, barUsed); + // Saving token settings can overwrite an aura applied while the dialog was open. + // Reconcile visual changes even when HP, size and bar linkage stayed the same. + const indicatorKeys = state.HealthColors.auraTint + ? ['tint_color'] + : ['aura1_radius', 'aura1_color', 'aura1_options', 'aura1_square', 'showplayers_aura1', 'tint_color']; + const indicatorChanged = indicatorKeys.some( + (key) => String(getPrevBarValue(prev, key) ?? '') !== String(obj.get(key) ?? ''), + ); // Only skip when nothing relevant changed. The `curValue > 0` clause means we never // skip while a token is at/below 0 HP: on linked-attribute sheets (D&D 2024) a drop @@ -1866,7 +1874,8 @@ var HealthColors = curValue > 0 && update !== 'YES' && !sizeChanged && - !linkageChanged + !linkageChanged && + !indicatorChanged ) { return; } From d2ce68b82a54cf6508521e826e48ac8077e12c6c Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Sun, 25 Oct 2026 15:44:17 +0000 Subject: [PATCH 04/10] fix(HealthColors): Restore the SmartAoE integration and respect its 0 HP marker HealthColors 1.7.1 registered with SmartAoE.ObserveTokenChange, but the v2.0.0 rewrite only kept the TokenMod hook. Roll20 does not report changes made by another script as token changes, so area damage or healing applied by SmartAoE left health colours and dead markers stale. Register handleTokenChange with every installed observer script (TokenMod and SmartAoE) through registerTokenObservers, and log which ones are observed at startup. Registration now happens once per sandbox start: checkInstall also runs after !aura reset, and registering again made every TokenMod change process twice, with double FX. When a token at 0 HP carries the zeroHPmarker of a SmartAoE AoE, let that marker stand: HealthColors adds no dead marker (the death sound still plays) and, with Death Save Integration, no dying marker, while stable and dead results still show. HealthColors never removes SmartAoE's marker. The markers are read from SmartAoE.ZeroHPMarkers() where available (SmartAoE 1.0.0, which also covers --instant AoEs that are deleted before their damage lands) and from SmartAoE's saved AoEs otherwise, and are remembered for the session. The rule is off when SmartAoE is not installed. Signed-off-by: Steve Roberts --- HealthColors/HealthColors.js | 139 +++++++++++++++++++++++++++++++---- 1 file changed, 126 insertions(+), 13 deletions(-) diff --git a/HealthColors/HealthColors.js b/HealthColors/HealthColors.js index f081efcca..788628aa6 100644 --- a/HealthColors/HealthColors.js +++ b/HealthColors/HealthColors.js @@ -7,7 +7,7 @@ // - Surok: https://app.roll20.net/users/335573/surok // - MidNiteShadow7: https://app.roll20.net/users/16506286/midniteshadow7 -/* global createObj TokenMod spawnFxWithDefinition spawnFx spawnFxBetweenPoints getObj state globalconfig playerIsGM sendChat findObjs Campaign log on getSheetItem */ +/* global createObj TokenMod SmartAoE spawnFxWithDefinition spawnFx spawnFxBetweenPoints getObj state globalconfig playerIsGM sendChat findObjs Campaign log on getSheetItem */ var HealthColors = HealthColors || @@ -1293,6 +1293,103 @@ var HealthColors = handleToken(obj, prev, recentAttrFires.has(obj.id) ? 'YES' : undefined); } + /** + * Mod scripts whose token changes HealthColors observes. Roll20 does not fire + * change:graphic for changes made by another script, so without these hooks HP set by + * `!token-mod` or SmartAoE damage would not re-colour the token. Both expose + * `ObserveTokenChange(handler)` and call handler(token, prev) after changing a token. + * + * @returns {{name: string, api: object|undefined}[]} Integration name and its API object, if installed. + */ + function getTokenObserverScripts() { + return [ + { name: 'TokenMod', api: typeof TokenMod === 'undefined' ? undefined : TokenMod }, + { name: 'SmartAoE', api: typeof SmartAoE === 'undefined' ? undefined : SmartAoE }, + ]; + } + + /** + * Returns true when the named observer script is installed and offers ObserveTokenChange. + * + * @param {string} name - 'TokenMod' or 'SmartAoE'. + * @returns {boolean} Whether HealthColors can observe it. + */ + function isObserverScriptAvailable(name) { + const entry = getTokenObserverScripts().find((e) => e.name === name); + return typeof entry?.api?.ObserveTokenChange === 'function'; + } + + let tokenObserversRegistered = false; + + /** + * Registers handleTokenChange with every installed observer script. Runs once per + * sandbox start: checkInstall also runs after `!aura reset`, and registering again would + * process (and play FX for) every change twice. + */ + function registerTokenObservers() { + if (tokenObserversRegistered) return; + tokenObserversRegistered = true; + const hooked = getTokenObserverScripts() + .filter(({ name }) => isObserverScriptAvailable(name)) + .map(({ name, api }) => { + api.ObserveTokenChange(handleTokenChange); + return name; + }); + if (hooked.length) log(`${SCRIPT_NAME}: observing token changes from ${hooked.join(', ')}`); + getSmartAoeZeroHpMarkers(); + } + + // SmartAoE 0 HP marker tags seen this session. Remembered after an AoE is deleted, so a + // later "apply damage" click from that AoE's chat card is still recognised. + const knownSmartAoeZeroHpMarkers = new Set(); + + /** + * Collects the 0 HP marker tags configured on SmartAoE AoEs (`zeroHPmarker`, stored per + * AoE in `state.SmartAoE.links`), adding them to the session-wide set. + * + * @returns {Set} Every SmartAoE 0 HP marker tag known this session. + */ + function getSmartAoeZeroHpMarkers() { + const addTags = (markerString) => + String(markerString || '') + .split(',') + .map((tag) => tag.trim().replace(/@\d+$/, '')) + .filter(Boolean) + .forEach((tag) => knownSmartAoeZeroHpMarkers.add(tag)); + // SmartAoE 1.0.0+ reports every 0 HP marker it has used this session, including --instant AoEs, + // which are deleted before their damage is applied. + if (typeof SmartAoE !== 'undefined' && typeof SmartAoE.ZeroHPMarkers === 'function') { + try { + SmartAoE.ZeroHPMarkers().forEach(addTags); + } catch (err) { + log(`${SCRIPT_NAME}: SmartAoE.ZeroHPMarkers() failed: ${err?.message || err}`); + } + } + // Older SmartAoE versions: read the AoEs saved in SmartAoE's state. + const links = state?.SmartAoE?.links; + if (Array.isArray(links)) links.forEach((link) => addTags(link?.zeroHPmarker)); + return knownSmartAoeZeroHpMarkers; + } + + /** + * Returns the SmartAoE 0 HP marker on a token, if any. SmartAoE adds that marker just + * before it lowers the HP bar, so it is already present when HealthColors sees 0 HP. + * When it is there, HealthColors leaves the 0 HP marker to SmartAoE and adds none itself. + * + * @param {object} obj - Roll20 token graphic object. + * @returns {string|null} The matching marker tag, or null. + */ + function getSmartAoeZeroHpMarkerOn(obj) { + if (!isObserverScriptAvailable('SmartAoE')) return null; + const known = getSmartAoeZeroHpMarkers(); + if (!known.size) return null; + const tags = String(obj.get('statusmarkers') || '') + .split(',') + .map((tag) => tag.trim().replace(/@\d+$/, '')) + .filter(Boolean); + return tags.find((tag) => known.has(tag)) || null; + } + // ————— STATE / INSTALL ————— /** * Migrates state from the interim HP-only "PC dying marker" build to Death Save @@ -1326,7 +1423,7 @@ var HealthColors = /** * Initializes or migrates persisted state, applies all default values, applies any - * newer One-Click install options, registers the TokenMod observer if available, and + * newer One-Click install options, registers the TokenMod/SmartAoE observers 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). */ @@ -1343,9 +1440,7 @@ var HealthColors = }); state.HealthColors.colorPalette = normalizePalette(state.HealthColors.colorPalette, DEFAULTS.colorPalette); applyOneClickOptions(); - if (typeof TokenMod !== 'undefined' && TokenMod.ObserveTokenChange) { - TokenMod.ObserveTokenChange(handleTokenChange); - } + registerTokenObservers(); const fxHurt = findObjs({ _type: 'custfx', name: '-DefaultHurt' }, { caseInsensitive: true })[0]; const fxHeal = findObjs({ _type: 'custfx', name: '-DefaultHeal' }, { caseInsensitive: true })[0]; if (!fxHurt) { @@ -1583,15 +1678,16 @@ var HealthColors = * @param {number} curValue - Current bar value. * @param {number} prevValue - Previous bar value. * @param {boolean} hasPrevValue - Whether a valid previous bar value was provided. + * @param {boolean} [soundOnly] - Play the death sound but add no marker (SmartAoE already marked it). */ - function applyDeadStatus(obj, curValue, prevValue, hasPrevValue) { + function applyDeadStatus(obj, curValue, prevValue, hasPrevValue, soundOnly = false) { if (curValue > 0) { obj.set('status_dead', false); return; } const deadSfx = state.HealthColors.auraDeadFX; if (deadSfx !== 'None' && hasPrevValue && curValue !== prevValue) playDeath(deadSfx); - obj.set('status_dead', true); + if (!soundOnly) obj.set('status_dead', true); } /** @@ -1647,9 +1743,17 @@ var HealthColors = const colorType = useTint ? 'tint' : 'aura1'; const appliedColor = useTint ? markerColor : withAuraOpacity(markerColor); - // Death Save Integration (PC-only) owns the dying/stable/dead markers when enabled. - const deathSaveHandled = applyDeathSaveStatus(obj, curValue, isPlayer); - if (!deathSaveHandled && showDead) applyDeadStatus(obj, curValue, prevValue, hasPrevValue); + // SmartAoE added its own 0 HP marker: let that stand and add no HealthColors marker. + const smartAoeMarker = curValue <= 0 ? getSmartAoeZeroHpMarkerOn(obj) : null; + const deathSaveActive = state.HealthColors.deathSavesOn && isPlayer; + + if (smartAoeMarker) { + if (!deathSaveActive && showDead) applyDeadStatus(obj, curValue, prevValue, hasPrevValue, true); + } else { + // Death Save Integration (PC-only) owns the dying/stable/dead markers when enabled. + const deathSaveHandled = applyDeathSaveStatus(obj, curValue, isPlayer); + if (!deathSaveHandled && showDead) applyDeadStatus(obj, curValue, prevValue, hasPrevValue); + } if (isTypeOn && useAura !== 'NO') { if (curValue <= 0) { @@ -2024,6 +2128,8 @@ var HealthColors = * Applies the dying/stable/dead marker to a token from synced save counts. * Above 0 HP all death markers are cleared. Otherwise: 3+ failures → dead (Red X, * plays the death sound once on entering dead); 3+ successes → stable; else dying. + * When the token carries a SmartAoE 0 HP marker, that marker stands in for dying (no dying + * marker is added) and is left in place. * * @param {object} token - Roll20 token graphic object. * @param {number} succ - Number of recorded successes. @@ -2037,16 +2143,23 @@ var HealthColors = token.set(buildDeathSaveMarkerPatch(false, false, false)); return 'alive'; } + // A SmartAoE 0 HP marker stands in for "dying", and is never removed by HealthColors. + const smartAoeMarker = getSmartAoeZeroHpMarkerOn(token); + const patch = (dyingOn, stableOn, deadOn) => { + const changes = buildDeathSaveMarkerPatch(dyingOn && !smartAoeMarker, stableOn, deadOn); + if (smartAoeMarker) delete changes[`status_${smartAoeMarker}`]; + return changes; + }; if (fail >= 3) { if (s.auraDeadFX !== 'None' && !token.get('status_dead')) playDeath(s.auraDeadFX); - token.set(buildDeathSaveMarkerPatch(false, false, true)); + token.set(patch(false, false, true)); return 'dead'; } if (succ >= 3) { - token.set(buildDeathSaveMarkerPatch(false, true, false)); + token.set(patch(false, true, false)); return 'stable'; } - token.set(buildDeathSaveMarkerPatch(true, false, false)); + token.set(patch(true, false, false)); return 'dying'; } From db3cda4bb6eee6b37e7826573eb805f815aa9ac0 Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Sun, 25 Oct 2026 15:44:43 +0000 Subject: [PATCH 05/10] fix(HealthColors): Handle !aura update with nothing selected !aura update threw an error when nothing was selected, or when a drawing rather than a token was selected, because it read msg.selected without checking it. Only update selected graphics that still exist. With none, whisper a reminder to select a token first; otherwise whisper how many tokens were updated and list their names, escaped for chat. The update and settings commands no longer trigger the "UPDATING TOKENS..." whisper. Signed-off-by: Steve Roberts --- HealthColors/HealthColors.js | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/HealthColors/HealthColors.js b/HealthColors/HealthColors.js index 788628aa6..f79062aa0 100644 --- a/HealthColors/HealthColors.js +++ b/HealthColors/HealthColors.js @@ -2055,13 +2055,21 @@ var HealthColors = * @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); + const tokens = (msg.selected || []) + .filter((sel) => sel._type === 'graphic') + .map((sel) => getObj('graphic', sel._id)) + .filter(Boolean); + if (tokens.length === 0) { + gmWhisper( + 'Select one or more tokens first, then press Update Selected (or run !aura update).', + ); + return; + } + const names = tokens.map((token) => { + handleToken(token, deepClone(token), 'YES'); + return escapeForChat(token.get('name') || '(unnamed token)'); + }); + gmWhisper(`Updated ${names.length} token${names.length === 1 ? '' : 's'}:
${names.join('
')}`); } // ————— DEATH SAVE INTEGRATION ————— @@ -4012,7 +4020,7 @@ var HealthColors = return; } - if (option !== 'MENU' && option !== 'LISTFX') gmWhisper('UPDATING TOKENS...'); + if (!['MENU', 'LISTFX', 'UPDATE', 'SETTINGS'].includes(option)) gmWhisper('UPDATING TOKENS...'); const s = state.HealthColors; if (applyMappedAuraOption(option, parts, s)) { From 955f0409278d7ffa86f053a32370887a8af824de Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Sun, 25 Oct 2026 15:44:59 +0000 Subject: [PATCH 06/10] feat(HealthColors): Add a GM-only help handout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GMs had no in-game reference for HealthColors beyond the menu itself. Create a GM-only "HealthColors — Help & Reference" handout in the Journal. It has an overview, the command list (from the same list the script uses), aura shapes and appearance, colour palettes with swatches, an attribute reference (USECOLOR, USEBLOOD and USEHEAL), optional integrations (Death Save Integration, TokenMod and SmartAoE with whether each is detected, attribute scripts, One-Click options and the script API), tips and troubleshooting, and a "Current settings" table. Its body text inherits the journal's colour so it reads in both Roll20 light and dark mode. The handout is rebuilt each time the script loads and after every setting change. !aura handout rebuilds it on demand and whispers a link to it. script.json declares the handout properties used. Signed-off-by: Steve Roberts --- HealthColors/HealthColors.js | 450 ++++++++++++++++++++++++++++++++++- HealthColors/script.json | 4 + 2 files changed, 451 insertions(+), 3 deletions(-) diff --git a/HealthColors/HealthColors.js b/HealthColors/HealthColors.js index f79062aa0..8ccfe2e18 100644 --- a/HealthColors/HealthColors.js +++ b/HealthColors/HealthColors.js @@ -3827,6 +3827,435 @@ var HealthColors = sendChat(SCRIPT_NAME, html); } + // ————— HELP HANDOUT ————— + /** Name of the GM-only help handout HealthColors creates and keeps up to date. */ + const HANDOUT_NAME = `${SCRIPT_NAME} — Help & Reference`; + + /** + * Command reference shown in the help handout, grouped by section. Each row is + * [command, description]. Keep this in step with the chat handler and README. + */ + const HANDOUT_COMMANDS = [ + [ + 'General', + [ + ['!aura', 'Open the interactive GM settings menu.'], + ['!aura settings', 'Post a read-only settings snapshot to game chat.'], + ['!aura handout', 'Rebuild this help handout.'], + ['!aura on / !aura off', 'Turn HealthColors on or off for the whole game.'], + ['!aura forceall', 'Re-colour every token on the Objects layer.'], + ['!aura update', 'Re-colour the selected tokens only.'], + ['!aura reset', 'Reset all settings to factory defaults.'], + ['!aura reset-all', 'Restore defaults, rebuild the default FX and refresh every token.'], + ], + ], + [ + 'Health tracking', + [ + ['!aura bar <1-4>', 'Choose the token bar that holds HP. Bar 4 needs Maximum Token Bubbles/Bars set to 4.'], + ['!aura tint', 'Switch between Aura 1 mode and Tint mode.'], + ['!aura palette ', 'Choose the health colour palette.'], + ['!aura perc ', 'HP % at or below which colour shows: 0 = never, 100 = always.'], + ['!aura pc / !aura npc', 'Track health on player-character / NPC tokens.'], + ['!aura dead / !aura deadPC', 'Add the dead (Red X) marker to NPCs / PCs at 0 HP.'], + ['!aura oneoff', 'Also track tokens that are not linked to a character.'], + ], + ], + [ + 'Aura 1 health ring (aura mode)', + [ + ['!aura size ', 'Ring radius in page units (usually feet) beyond the token edge. Default 0.35.'], + [ + '!aura a1shape ', + 'Ring shape: Auto, Auto Border, Keep, Circle, Square, Hex, Circle Border, Square Border or Hex Border.', + ], + ['!aura opacity <1-100>', 'Ring opacity in percent. 100 = solid.'], + ['!aura lock', 'Stop (or allow) controlling players editing Aura 1.'], + ], + ], + [ + 'Reference values (not applied to tokens)', + [ + ['!aura a1tint ', 'Aura 1 colour reference (aura mode colours the ring by health).'], + ['!aura a2size ', 'Aura 2 radius reference.'], + ['!aura a2shape ', 'Aura 2 shape reference (any of the six Roll20 shapes).'], + ['!aura a2tint ', 'Aura 2 colour reference.'], + ], + ], + [ + 'Nameplates', + [ + ['!aura gmpc ', 'GM sees PC nameplates: Yes = always, No = never, Off = leave Roll20 setting.'], + ['!aura gmnpc ', 'GM sees NPC nameplates.'], + ['!aura pcpc ', 'Players see PC nameplates.'], + ['!aura pcnpc ', 'Players see NPC nameplates.'], + ], + ], + [ + 'Effects and sound', + [ + ['!aura fx', 'Turn heal/hurt particle effects on or off.'], + [ + '!aura heal ', + 'Heal effect: a hex colour, a built-in effect (glow-holy), a custom FX name/id, or default.', + ], + ['!aura hurt ', 'Hurt effect: same formats as heal (e.g. explode-blood).'], + ['!aura listfx', 'List custom FX with Heal/Hurt buttons, plus the built-in effect names.'], + ['!aura reset-fx', 'Rebuild the -DefaultHeal and -DefaultHurt custom FX.'], + ['!aura deadfx ', 'Jukebox track(s) to play when a token dies (comma-separate for random), or None.'], + ], + ], + [ + 'Death Save Integration (optional, PCs only)', + [ + ['!aura deathsaves on / off / toggle', 'Turn Death Save Integration on or off.'], + ['!aura deathsaves success ', 'Success field name(s).'], + ['!aura deathsaves failure ', 'Failure field name(s).'], + ['!aura deathsaves dyingmarker ', 'Marker for a dying PC (default skull).'], + ['!aura deathsaves stablemarker ', 'Marker for a stable PC (default green).'], + ['!aura deathsaves markers', "List the campaign's status-marker tags."], + ['!aura deathsaves sync', 'Re-check death-save markers for the selected tokens now.'], + ['!aura deathsaves debug', 'Diagnostic report for the selected tokens.'], + ['!aura deathsaves watch [status|reset|off]', 'Debug watcher for the configured fields.'], + ['!aura deathsaves watchstatus', 'Debug: show the watched fields and characters.'], + ['!aura deathsaves attrs [filter]', "Debug: list the selected character's legacy attributes."], + ], + ], + ]; + + /** Per-character attributes for the help handout: [name, controls, values]. */ + const HANDOUT_ATTRIBUTES = [ + ['USECOLOR', 'Health colour', 'YES = colour this character by health · NO = never colour its tokens.'], + [ + 'USEBLOOD', + 'Hurt FX', + 'DEFAULT = global hurt effect · OFF / NO = no HealthColors FX at all for this character · a hex colour · one or more effect names (built-in or custom), comma-separated.', + ], + [ + 'USEHEAL', + 'Heal FX', + 'DEFAULT = global heal effect · OFF / NO = no heal FX for this character · a hex colour · one or more effect names, comma-separated.', + ], + ]; + + /** Optional integrations (besides Death Save Integration) for the help handout: [title, text]. */ + const HANDOUT_INTEGRATIONS = [ + [ + 'TokenMod', + 'When TokenMod is installed, HealthColors listens for its token changes, so HP set with !token-mod updates colours, markers and FX like a manual edit.', + ], + [ + 'SmartAoE', + "When SmartAoE is installed, HealthColors listens for its token changes, so area damage and healing it applies re-colour tokens and update dead markers. If an AoE has a zeroHPmarker set, SmartAoE's marker is used instead: HealthColors adds no dead (or dying) marker to a token at 0 HP that already carries a SmartAoE 0 HP marker. The death sound still plays.", + ], + [ + 'AlterBars, ChatSetAttr and other attribute scripts', + 'Scripts that change the HP attribute instead of the token bar are picked up by the attribute listener; no set-up is needed.', + ], + [ + 'One-Click install options', + 'Every setting can be pre-set in the One-Click install dialog. Values are applied on first start and whenever the dialog is saved; in-game !aura changes are kept between saves.', + ], + [ + 'Script API', + "Other scripts can ask HealthColors to re-colour a token with HealthColors.Update(token, prev), where prev is the token's previous values (or {}).", + ], + ]; + + /** Tips & troubleshooting entries for the help handout: [title, text]. Mirrors the README. */ + const HANDOUT_TIPS = [ + [ + 'Aura visibility', + 'The ring radius is in page units from the token edge. The default 0.35 can look subtle; raise it (e.g. 3) or use a border shape for a bolder ring.', + ], + [ + 'Manual changes', + 'In aura mode, manual Aura 1 colour, radius or shape changes are put back when HP changes or the token is saved. Use Shape Keep to manage shapes yourself, or tint mode to keep Aura 1 free.', + ], + [ + 'Auto shape', + 'Auto uses Hex on hex-grid pages (vertical or horizontal) once the radius is at least one grid cell (e.g. 5 ft), and Circle otherwise. Roll20 draws hex and square auras in whole grid cells, so a small radius shows little or nothing with those shapes.', + ], + [ + 'Upgraded from 2.3 or earlier', + 'If Aura 1 Shape shows Keep, the old default Circle was switched so existing shapes were not changed. Pick Auto or another shape to let HealthColors manage it.', + ], + [ + 'Palette changes', + 'Changing the palette, radius, shape, opacity or player lock refreshes every token straight away.', + ], + ['One-off tokens', 'Turn on One Offs to colour tokens that are not linked to a character.'], + ['Missing max HP', 'A token with no max in the health bar is left uncoloured until a max is set.'], + ['Bar 4', 'Bar 4 only works when Game Settings → Maximum Token Bubbles/Bars is set to 4.'], + [ + '"FX … not found" whisper', + "A configured custom FX (global heal/hurt or a character's USEBLOOD/USEHEAL) no longer exists. Run !aura listfx and choose a valid effect; the default burst plays meanwhile.", + ], + [ + 'FX colours look wrong', + 'Some sandbox/browser combinations draw FX colours inaccurately. HealthColors updates its default FX objects and spawns them by id to keep colours consistent; !aura reset-fx rebuilds them.', + ], + [ + 'Death-save markers not updating', + 'On D&D 2024 sheets switch the game to Mod Script Sandbox v1.5. Use !aura deathsaves debug on the token, and !aura deathsaves sync to force a re-check.', + ], + [ + 'This handout', + 'It is rebuilt when the script loads and after each setting change. Run !aura handout if it goes missing.', + ], + ]; + + /** + * Inline styles for the help handout (matches the GM menu's colours). Roll20 strips