diff --git a/HealthColors/2.1.0/HealthColors.js b/HealthColors/2.1.0/HealthColors.js new file mode 100644 index 000000000..34afc8292 --- /dev/null +++ b/HealthColors/2.1.0/HealthColors.js @@ -0,0 +1,1530 @@ +// =========================== +// === HealthColors v2.1.0 === +// =========================== + +// AUTHORS: +// - DXWarlock: https://app.roll20.net/users/262130/dxwarlock +// - MidNiteShadow7: https://app.roll20.net/users/16506286/midniteshadow7 + +/* global createObj TokenMod spawnFxWithDefinition spawnFx getObj state playerIsGM sendChat findObjs log on */ + +(() => { + "use strict"; + + // ————— CONSTANTS ————— + const VERSION = "2.1.0"; + const SCRIPT_NAME = "HealthColors"; + const SCHEMA_VERSION = "1.1.0"; + const UPDATED = "2026-04-25 07:30 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[]} startColor - Fallback RGBA start color (opaque white). + * @property {number[]} endColor - Fallback RGBA end color (opaque black). + * @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; + } + + /** + * 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; + } + + // ————— 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 = JSON.parse(JSON.stringify(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" }); + const lookupUseColor = makeSmartAttrCache("USECOLOR", { + default: "YES", + validation: (o) => Boolean(o.match(/YES|NO/)), + }); + + // ————— TOKEN HELPERS ————— + /** + * Resets a token to the healthy/default visual state using the palette high color. + * In tint mode it applies tint_color; otherwise it sets aura1 color/radius. + * Roll20 measures aura1_radius from the token edge, so AuraSize maps directly. + * @param {object} obj - Roll20 token graphic object. + */ + function applyDefaultAura(obj) { + const useTint = state.HealthColors.auraTint; + if (useTint) { + obj.set({ tint_color: percentToHex(100) }); + } else { + obj.set({ + tint_color: "transparent", + aura1_color: percentToHex(100), + aura1_radius: state.HealthColors.AuraSize, + showplayers_aura1: true, + }); + } + } + + /** + * 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) { + obj.set({ + tint_color: "transparent", + aura1_color: "transparent", + aura1_radius: 0, + }); + } + + /** + * 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)]; + 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 JSON.parse(JSON.stringify(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, pColor: string }} + */ + 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. + * @param {string} [update] - Pass 'YES' to indicate a forced refresh. + */ + 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 (percReal >= percentOn) { + applyDefaultAura(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. + * @returns {object[]} Array of Roll20 custfx definition objects. + */ + function buildFXList(isHeal, useBlood) { + 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 hurtRgb = hexToRgb(useBlood); + + 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 { + useBlood.split(",").forEach((fxName) => { + const custom = findObjs( + { _type: "custfx", name: fxName.trim() }, + { caseInsensitive: true }, + )[0]; + const customDef = getFxDefinition(custom); + + if (customDef) { + fxArray.push(customDef); + } else { + gmWhisper(`No FX with name ${fxName}`); + } + }); + } + } + + return fxArray; + } + + /** + * Workaround path: update default custfx definitions, then spawn by saved FX id. + * This avoids client-side issues seen in some sandboxes with spawnFxWithDefinition. + * Applies only to DEFAULT heal/hurt colors; custom named FX still use definition spawn. + * Also tightens particle profile settings to keep color visibility consistent. + * @param {object} obj - Roll20 token graphic object. + * @param {boolean} isHeal - True when HP increased. + * @param {string|undefined} useBlood - Per-character blood override. + * @returns {boolean} True when the fallback path handled spawning. + */ + 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); + if (spawnDefaultFxById(obj, isHeal, useBlood)) return; + buildFXList(isHeal, useBlood).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 ————— + /** + * 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("Fixing Tokens", `/w gm Fixing ${workQueue.length} Tokens`); + const drainQueue = () => { + const token = workQueue.shift(); + if (token) { + const prev = JSON.parse(JSON.stringify(token)); + handleToken(token, prev, "YES"); + setTimeout(drainQueue, 0); + } else { + sendChat("Fixing Tokens", "/w gm Finished Fixing 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 = JSON.parse(JSON.stringify(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); + } + + /** + * 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 the current settings panel publicly in game chat. + * Used after setting-changing commands so players/DMs can see active config. + */ + 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 noStyle = "background-color:#A84D4D"; + const offStyle = "background-color:#D6D6D6"; + const pickNameStyle = (value) => { + if (value === "No") return noStyle; + if (value === "Off") return offStyle; + return ""; + }; + const healStyle = `background-color:#${s.HealFX}`; + const hurtStyle = `background-color:#${s.HurtFX}`; + const aura1Style = `background-color:#${s.Aura1Color}`; + const aura2Style = `background-color:#${s.Aura2Color}`; + const html = [ + `
`, + `HealthColors Settings: ${VERSION}
`, + hr, + `Is On: ${makePill(s.auraColorOn ? "Yes" : "No", s.auraColorOn ? "" : "background-color:#A84D4D")}
`, + `Bar: ${makePill(s.auraBar)}
`, + `Use Tint: ${makePill(s.auraTint ? "Yes" : "No", s.auraTint ? "" : "background-color:#A84D4D")}
`, + `Palette: ${makePill(s.colorPalette)}
`, + `Percentage(PC/NPC): ${makePill(percLabel)}
`, + hr, + `Show PC Health: ${makePill(s.PCAura ? "Yes" : "No", s.PCAura ? "" : "background-color:#A84D4D")}
`, + `Show NPC Health: ${makePill(s.NPCAura ? "Yes" : "No", s.NPCAura ? "" : "background-color:#A84D4D")}
`, + `Show Dead PC: ${makePill(s.auraDeadPC ? "Yes" : "No", s.auraDeadPC ? "" : "background-color:#A84D4D")}
`, + `Show Dead NPC: ${makePill(s.auraDead ? "Yes" : "No", s.auraDead ? "" : "background-color:#A84D4D")}
`, + hr, + `GM Sees all PC Names: ${makePill(s.GM_PCNames, pickNameStyle(s.GM_PCNames))}
`, + `GM Sees all NPC Names: ${makePill(s.GM_NPCNames, pickNameStyle(s.GM_NPCNames))}
`, + hr, + `PC Sees all PC Names: ${makePill(s.PCNames, pickNameStyle(s.PCNames))}
`, + `PC Sees all NPC Names: ${makePill(s.NPCNames, pickNameStyle(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: ${makePill(s.OneOff ? "Yes" : "No", s.OneOff ? "" : "background-color:#A84D4D")}
`, + `FX: ${makePill(s.FX ? "Yes" : "No", s.FX ? "" : "background-color:#A84D4D")}
`, + `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, also posts a read-only settings snapshot to game chat. + * Use `!aura settings` to output the current settings snapshot on demand. + * @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(); + let changedSetting = false; + if (option !== "MENU") gmWhisper("UPDATING TOKENS..."); + + switch (option) { + case "MENU": + break; + case "SETTINGS": + showSettingsInGameChat(); + return; + case "ON": + state.HealthColors.auraColorOn = true; + changedSetting = true; + break; + case "OFF": + state.HealthColors.auraColorOn = false; + changedSetting = true; + break; + case "BAR": + if (/^[123]$/.test(parts[2] || "")) { + state.HealthColors.auraBar = `bar${parts[2]}`; + changedSetting = true; + gmWhisper( + `Health bar set to ${state.HealthColors.auraBar}. Forcing sync...`, + ); + menuForceUpdate(); + } else { + gmWhisper( + `Invalid bar "${parts[2] || ""}". Use !aura bar 1, !aura bar 2, or !aura bar 3.`, + ); + } + break; + case "TINT": + state.HealthColors.auraTint = !state.HealthColors.auraTint; + changedSetting = true; + break; + case "PERC": + state.HealthColors.auraPercPC = Number.parseInt(parts[2], 10); + state.HealthColors.auraPerc = Number.parseInt(parts[3], 10); + changedSetting = true; + break; + case "PC": + state.HealthColors.PCAura = !state.HealthColors.PCAura; + changedSetting = true; + break; + case "NPC": + state.HealthColors.NPCAura = !state.HealthColors.NPCAura; + changedSetting = true; + break; + case "GMNPC": + state.HealthColors.GM_NPCNames = parts[2]; + changedSetting = true; + break; + case "GMPC": + state.HealthColors.GM_PCNames = parts[2]; + changedSetting = true; + break; + case "PCNPC": + state.HealthColors.NPCNames = parts[2]; + changedSetting = true; + break; + case "PCPC": + state.HealthColors.PCNames = parts[2]; + changedSetting = true; + break; + case "DEAD": + state.HealthColors.auraDead = !state.HealthColors.auraDead; + changedSetting = true; + break; + case "DEADPC": + state.HealthColors.auraDeadPC = !state.HealthColors.auraDeadPC; + changedSetting = true; + break; + case "DEADFX": + state.HealthColors.auraDeadFX = parts[2]; + changedSetting = true; + break; + case "SIZE": + state.HealthColors.AuraSize = Number.parseFloat(parts[2]); + changedSetting = true; + break; + case "A1SHAPE": + state.HealthColors.Aura1Shape = normalizeShape( + parts[2], + state.HealthColors.Aura1Shape, + ); + changedSetting = true; + break; + case "A1TINT": + state.HealthColors.Aura1Color = normalizeHex6( + parts[2], + state.HealthColors.Aura1Color, + ); + changedSetting = true; + break; + case "A2SIZE": + state.HealthColors.Aura2Size = Number.parseFloat(parts[2]); + changedSetting = true; + break; + case "A2SHAPE": + state.HealthColors.Aura2Shape = normalizeShape( + parts[2], + state.HealthColors.Aura2Shape, + ); + changedSetting = true; + break; + case "A2TINT": + state.HealthColors.Aura2Color = normalizeHex6( + parts[2], + state.HealthColors.Aura2Color, + ); + changedSetting = true; + break; + case "PALETTE": + state.HealthColors.colorPalette = normalizePalette( + parts[2], + state.HealthColors.colorPalette, + ); + menuForceUpdate(); + changedSetting = true; + break; + case "ONEOFF": + state.HealthColors.OneOff = !state.HealthColors.OneOff; + changedSetting = true; + break; + case "FX": + state.HealthColors.FX = !state.HealthColors.FX; + changedSetting = true; + break; + case "HEAL": + state.HealthColors.HealFX = parts[2].toUpperCase(); + syncDefaultFxObjects(); + changedSetting = true; + break; + case "HURT": + state.HealthColors.HurtFX = parts[2].toUpperCase(); + syncDefaultFxObjects(); + changedSetting = true; + break; + case "RESET": + delete state.HealthColors; + gmWhisper("STATE RESET"); + checkInstall(); + changedSetting = true; + break; + case "RESET-FX": + resetDefaultFxObjects(); + break; + case "RESET-ALL": + runResetAllFlow(); + changedSetting = true; + break; + case "FORCEALL": + menuForceUpdate(); + return; + case "UPDATE": + manUpdate(msg); + return; + } + + if (changedSetting) { + showSettingsInGameChat(); + } else { + 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:token → handleToken (live HP changes) + * - 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 = JSON.parse(JSON.stringify(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 27f787826..441b4442d 100644 --- a/HealthColors/CHANGELOG.md +++ b/HealthColors/CHANGELOG.md @@ -5,6 +5,22 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [2.1.0] – 2026-05-01 + +### Added +- Added a palette system for health colors with `default` and `colorblind` options. +- Added `!aura palette ?{Palette|default|colorblind}` command and matching menu control (palette switching forces existing tokens to refresh immediately). + +### Changed +- Switched health color mapping from a fixed red/green calculation to palette-based low/mid/high interpolation. +- Added an explicit `dead` color stop to each palette and mapped exactly 0% HP to black (`#000000`). +- Updated default `AuraSize` from `0.7` to `0.35` and aligned docs/menu wording to describe radius in feet from token edge. +- Updated menu/settings output to include the active palette. + +### Fixed +- Fixed dead-state visuals at 0 HP so dead color behavior remains consistent. +- Fixed missed updates when tokens are resized by treating width/height changes as a visual refresh trigger. + ## [2.0.1] – 2026-04-20 ### Fixed diff --git a/HealthColors/HealthColors.js b/HealthColors/HealthColors.js index d6a9c58b0..34afc8292 100644 --- a/HealthColors/HealthColors.js +++ b/HealthColors/HealthColors.js @@ -1,5 +1,5 @@ // =========================== -// === HealthColors v2.0.1 === +// === HealthColors v2.1.0 === // =========================== // AUTHORS: @@ -12,10 +12,10 @@ "use strict"; // ————— CONSTANTS ————— - const VERSION = "2.0.1"; + const VERSION = "2.1.0"; const SCRIPT_NAME = "HealthColors"; const SCHEMA_VERSION = "1.1.0"; - const UPDATED = "2026-04-20 17:00 UTC"; + const UPDATED = "2026-04-25 07:30 UTC"; // ————— DEFAULTS ————— /** @@ -36,7 +36,7 @@ * @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 - Base aura radius before page-scale is applied. + * @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. @@ -47,6 +47,7 @@ * @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, @@ -62,7 +63,7 @@ PCNames: "Yes", GM_NPCNames: "Yes", NPCNames: "Yes", - AuraSize: 0.7, + AuraSize: 0.35, Aura1Shape: "Circle", Aura1Color: "00FF00", Aura2Size: 5, @@ -73,6 +74,22 @@ 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 + }, }; /** @@ -199,22 +216,33 @@ // ————— UTILITIES ————— /** - * Converts a health percentage (0–100+) to a red-amber-green hex color. - * Values above 100% return blue; 100% is treated as 99% to keep green. + * 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"; - // Cap at 99 so 100% maps to green, not wrapping - const p = normalizedPct === 100 ? 99 : normalizedPct; - const b = 0; - const g = p < 50 ? Math.floor(255 * (p / 50)) : 255; - const r = p < 50 ? 255 : Math.floor(255 * ((50 - (p % 50)) / 50)); - // Bitwise shift used intentionally to build a 6-digit hex color - // eslint-disable-next-line no-bitwise - return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; + 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]); } /** @@ -274,6 +302,17 @@ 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; + } + // ————— WHISPER GM (declared early; used by checkInstall) ————— /** * Sends a styled whisper message to the GM. @@ -355,18 +394,19 @@ // ————— TOKEN HELPERS ————— /** - * Resets a token to the "Default" state requested by the user: - * Aura 1: Green (#00FF00) at 0.7 radius. - * Aura 2: Transparent at 5ft radius. + * Resets a token to the healthy/default visual state using the palette high color. + * In tint mode it applies tint_color; otherwise it sets aura1 color/radius. + * Roll20 measures aura1_radius from the token edge, so AuraSize maps directly. * @param {object} obj - Roll20 token graphic object. */ function applyDefaultAura(obj) { - if (state.HealthColors.auraTint) { - obj.set({ tint_color: "transparent" }); + const useTint = state.HealthColors.auraTint; + if (useTint) { + obj.set({ tint_color: percentToHex(100) }); } else { - // Set Aura 1 directly to sizeSet (expected 0.7) obj.set({ - aura1_color: "#00FF00", + tint_color: "transparent", + aura1_color: percentToHex(100), aura1_radius: state.HealthColors.AuraSize, showplayers_aura1: true, }); @@ -388,21 +428,20 @@ /** * 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 both aura radii and colors. - * On a forced update ('YES'), clears the opposing color first to avoid artifacts. + * 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 - Base aura size from state (e.g. 0.7). + * @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 page = getObj("page", obj.get("pageid")); - const scaleNumber = page.get("scale_number"); - const scale = scaleNumber / 10; - if (state.HealthColors.auraTint) { + const useTint = state.HealthColors.auraTint; + if (useTint) { obj.set({ tint_color: markerColor }); } else { obj.set({ - aura1_radius: sizeSet * scale * 1.8, + tint_color: "transparent", + aura1_radius: sizeSet, aura1_color: markerColor, showplayers_aura1: true, }); @@ -563,6 +602,10 @@ 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); } @@ -766,7 +809,6 @@ if (deadSfx !== "None" && curValue !== Number(prevValue)) playDeath(deadSfx); obj.set("status_dead", true); - clearAuras(obj); } /** @@ -781,15 +823,15 @@ const { curValue, prevValue, percReal, markerColor } = health; const { isTypeOn, percentOn, showDead } = typeConfig; const useAura = oCharacter ? lookupUseColor(oCharacter) : undefined; - const colorType = state.HealthColors.auraTint ? "tint" : "aura1"; + const useTint = state.HealthColors.auraTint; + const colorType = useTint ? "tint" : "aura1"; - if (showDead) { - applyDeadStatus(obj, curValue, prevValue); - if (curValue <= 0) return; - } + if (showDead) applyDeadStatus(obj, curValue, prevValue); if (isTypeOn && useAura !== "NO") { - if (percReal >= percentOn || curValue === 0) { + if (curValue === 0) { + tokenSet(obj, state.HealthColors.AuraSize, markerColor); + } else if (percReal >= percentOn) { applyDefaultAura(obj); } else { tokenSet(obj, state.HealthColors.AuraSize, markerColor); @@ -978,10 +1020,13 @@ if (!health) return; const { maxValue, curValue, prevValue } = health; + const sizeChanged = + prev.width !== obj.get("width") || prev.height !== obj.get("height"); - // NEW in 2.0.5: Only proceed if health actually changed OR it is a forced update. - // This stops the script from "fighting" manual aura/color overrides on movement. - if (curValue === Number(prevValue) && update !== "YES") return; + // 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); @@ -1140,6 +1185,7 @@ `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")}
`, @@ -1153,12 +1199,12 @@ `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.7}")}
`, + `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 (Tint): ${makeBtn(s.Aura1Color, "!aura a1tint ?{Color?|00FF00}", aura1Style)}
`, + `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 (Tint): ${makeBtn(s.Aura2Color, "!aura a2tint ?{Color?|806600}", aura2Style)}
`, + `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)}
`, @@ -1210,6 +1256,7 @@ `Is On: ${makePill(s.auraColorOn ? "Yes" : "No", s.auraColorOn ? "" : "background-color:#A84D4D")}
`, `Bar: ${makePill(s.auraBar)}
`, `Use Tint: ${makePill(s.auraTint ? "Yes" : "No", s.auraTint ? "" : "background-color:#A84D4D")}
`, + `Palette: ${makePill(s.colorPalette)}
`, `Percentage(PC/NPC): ${makePill(percLabel)}
`, hr, `Show PC Health: ${makePill(s.PCAura ? "Yes" : "No", s.PCAura ? "" : "background-color:#A84D4D")}
`, @@ -1225,10 +1272,10 @@ hr, `Aura 1 Radius: ${makePill(String(s.AuraSize))}
`, `Aura 1 Shape: ${makePill(s.Aura1Shape)}
`, - `Aura 1 Tint: ${makePill(s.Aura1Color, aura1Style)}
`, + `Aura 1 Color: ${makePill(s.Aura1Color, aura1Style)}
`, `Aura 2 Radius: ${makePill(String(s.Aura2Size))}
`, `Aura 2 Shape: ${makePill(s.Aura2Shape)}
`, - `Aura 2 Tint: ${makePill(s.Aura2Color, aura2Style)}
`, + `Aura 2 Color: ${makePill(s.Aura2Color, aura2Style)}
`, `One Offs: ${makePill(s.OneOff ? "Yes" : "No", s.OneOff ? "" : "background-color:#A84D4D")}
`, `FX: ${makePill(s.FX ? "Yes" : "No", s.FX ? "" : "background-color:#A84D4D")}
`, `HealFX Color: ${makePill(s.HealFX, healStyle)}
`, @@ -1246,8 +1293,9 @@ * 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. + * 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, also posts a read-only settings snapshot to game chat. * Use `!aura settings` to output the current settings snapshot on demand. * @param {object} msg - Roll20 chat message object. @@ -1287,7 +1335,9 @@ if (/^[123]$/.test(parts[2] || "")) { state.HealthColors.auraBar = `bar${parts[2]}`; changedSetting = true; - gmWhisper(`Health bar set to ${state.HealthColors.auraBar}. Forcing sync...`); + gmWhisper( + `Health bar set to ${state.HealthColors.auraBar}. Forcing sync...`, + ); menuForceUpdate(); } else { gmWhisper( @@ -1376,6 +1426,14 @@ ); changedSetting = true; break; + case "PALETTE": + state.HealthColors.colorPalette = normalizePalette( + parts[2], + state.HealthColors.colorPalette, + ); + menuForceUpdate(); + changedSetting = true; + break; case "ONEOFF": state.HealthColors.OneOff = !state.HealthColors.OneOff; changedSetting = true; @@ -1465,7 +1523,7 @@ }; on("ready", () => { - gmWhisper("API READY"); + gmWhisper(`MOD READY (v${VERSION})`); checkInstall(); registerEventHandlers(); }); diff --git a/HealthColors/README.md b/HealthColors/README.md index 3fe0560e1..1b723d47d 100644 --- a/HealthColors/README.md +++ b/HealthColors/README.md @@ -6,7 +6,8 @@ ## Features -- **Dynamic Color Shifting**: Automatically transitions from Green (Healthy) to Red (Critical) as health drops. +- **Palette-Based Color Shifting**: Automatically transitions across a 3-stop health gradient with a dedicated dead color at 0 HP. +- **Built-In Palettes**: Choose between `default` and `colorblind` palettes for clearer table visibility. - **Aura & Tint Modes**: Choose between a glowing health ring (Aura 1) or a full token color overlay (Tint). - **Aura 1 Exclusive Management**: The script only manages Aura 1. You are free to manually use Aura 2 for range indicators, light sources, or status markers without script interference. - **Aura 1 & Aura 2 Details in Output**: Settings output includes Aura 1 Shape/Tint and Aura 2 Radius/Shape/Tint rows using state-backed defaults for clear reference. @@ -42,7 +43,8 @@ When a command changes a setting, HealthColors posts a single read-only settings | `!aura forceall` | Forces a visual sync for every token on the current map. | | `!aura on/off` | Enables or disables the script globally. | | `!aura tint` | Toggles between **Aura 1** mode and **Tint** mode. | -| `!aura size ` | Sets the default radius for Aura 1 (e.g., `!aura size 0.7`). | +| `!aura palette ` | Sets the health palette (`default` or `colorblind`) and auto-refreshes all tokens. | +| `!aura size ` | Sets Aura 1 radius in feet from token edge (e.g., `!aura size 0.35`). | | `!aura a1shape ` | Sets Aura 1 display shape (`Circle`, `Square`). | | `!aura a1tint ` | Sets Aura 1 display tint color (e.g., `!aura a1tint 00FF00`). | | `!aura a2size ` | Sets Aura 2 display radius (e.g., `!aura a2size 5`). | @@ -59,12 +61,20 @@ When a command changes a setting, HealthColors posts a single read-only settings | `!aura reset-fx` | Rebuilds `-DefaultHeal` and `-DefaultHurt` custom FX objects. | | `!aura reset-all` | Restores all settings to `DEFAULTS`, rebuilds default FX, and force-syncs tokens. | +### Health Palettes + +- `default`: High = Green, Mid = Yellow, Low = Red, Dead = Black. +- `colorblind`: High = Cyan, Mid = Orange, Low = Magenta, Dead = Black. +- At exactly 0 HP, the script uses the palette dead color (`#000000`) for clear knockout state. +- If HP is above 100%, the script still uses blue (`#0000FF`) for overflow/temporary HP visualization. + --- ## Tips & Troubleshooting -- **Aura Visibility**: If you are using a 5ft grid and set the Aura 1 radius to `0.7`, it may be hidden by the token image. Increase the radius (e.g., `3.0`) if you want the health ring to be visible outside the token's edge. +- **Aura Visibility**: Aura 1 radius is measured in feet from the token edge. The default is `0.35`, which can appear subtle on some maps. Increase it (e.g., `3.0`) if you want a more obvious health ring. - **Manual Changes**: If you manually change a token's aura color or radius in the Roll20 dialog, it will stay that way as long as the token's health doesn't change. Once health is updated, the script will re-sync the visuals to the calculated health color. +- **Palette Changes**: Switching palettes from the menu or with `!aura palette ...` immediately runs a full refresh of tokens (equivalent to a force update). - **One-Off Tokens**: You can toggle "One-Offs" in the settings to enable health tracking for tokens that are not linked to a character sheet. - **FX Rendering Variance**: Some Roll20 sandbox/client combinations can render `spawnFxWithDefinition` colors inaccurately. HealthColors uses a fallback that updates default custom FX objects and spawns by FX ID to keep heal/hurt colors consistent. - **Missing Max HP**: If the configured health bar has no `max` value on a token, HealthColors now clears that token's aura/tint until a max value is set. @@ -74,4 +84,4 @@ When a command changes a setting, HealthColors posts a single read-only settings ## Credits Original Author: DXWarlock -Refactored and Modernized for v2.0.0 by MidNiteShadow7 +Refactored and Modernized for v2 by MidNiteShadow7 diff --git a/HealthColors/script.json b/HealthColors/script.json index ab269d0c9..9921aeb41 100644 --- a/HealthColors/script.json +++ b/HealthColors/script.json @@ -1,8 +1,9 @@ { "name": "Aura/Tint HealthColors", "script": "HealthColors.js", - "version": "2.0.1", + "version": "2.1.0", "previousversions": [ + "2.0.1", "2.0.0", "1.6.1", "1.6.0",