diff --git a/HealthColors/.gitignore b/HealthColors/.gitignore new file mode 100644 index 000000000..fa1bf7815 --- /dev/null +++ b/HealthColors/.gitignore @@ -0,0 +1,3 @@ +*.zip +*alpha* +*beta* diff --git a/HealthColors/1.7.1/HealthColors.js b/HealthColors/1.7.1/HealthColors.js new file mode 100644 index 000000000..3df408f57 --- /dev/null +++ b/HealthColors/1.7.1/HealthColors.js @@ -0,0 +1,585 @@ +/* global createObj TokenMod spawnFxWithDefinition getObj state playerIsGM sendChat _ findObjs log on*/ +/* +My Profile link: https://app.roll20.net/users/262130/dxwarlock +GIT link: https://github.com/dxwarlock/Roll20/blob/master/Public/HeathColors +Roll20Link: https://app.roll20.net/forum/post/4630083/script-aura-slash-tint-healthcolor + +Version 1.7.1 - Mar 6 2025 + - Updated FX effects: The damage/healing FX now use the simpler DeathTracker approach. + (Damage FX uses the HurtFX type (default "splatter-blood") and Healing FX uses the HealFX type (default "glow-holy").) + - Added a new command/button ("LISTFX") that displays a graphical menu listing custom FX objects with their IDs. +*/ +/*jshint bitwise: false*/ +var HealthColors = HealthColors || (function () { + 'use strict'; + var version = '1.7.1', + ScriptName = "HealthColors", + schemaVersion = '1.0.3', + Updated = "Feb 16 2025", +/*------------------------ +ON TOKEN CHANGE/CREATE +------------------------*/ + handleToken = function (obj, prev, update) { + //CHECK IF TRIGGERED------------ + if(state.HealthColors.auraColorOn !== true || obj.get("layer") !== "objects") return; + if(obj.get("represents") !== "" || (obj.get("represents") === "" && state.HealthColors.OneOff === true)) { + //**CHECK BARS------------// + var barUsed = state.HealthColors.auraBar; + var maxValue, curValue, prevValue; + if(obj.get(barUsed + "_max") !== "" || obj.get(barUsed + "_value") !== "") { + maxValue = parseInt(obj.get(barUsed + "_max"), 10); + curValue = parseInt(obj.get(barUsed + "_value"), 10); + prevValue = prev[barUsed + "_value"]; + } + if(isNaN(maxValue) || isNaN(curValue) || isNaN(prevValue)) return; + //CALC PERCENTAGE------------ + var percReal = Math.round((curValue / maxValue) * 100); + var markerColor = PercentToHEX(percReal); + //DEFINE VARIABLES--- + var pColor = '#ffffff'; + var GM = '', PC = ''; + var IsTypeOn, PercentOn, ShowDead, UseAura; + //**CHECK MONSTER OR PLAYER------------// + var oCharacter = getObj('character', obj.get("_represents")); + var type = (oCharacter === undefined || oCharacter.get("controlledby") === "") ? 'Monster' : 'Player'; + var colortype = (state.HealthColors.auraTint) ? 'tint' : 'aura1'; + //IF PLAYER------------ + if(type == 'Player') { + GM = state.HealthColors.GM_PCNames; + PC = state.HealthColors.PCNames; + IsTypeOn = state.HealthColors.PCAura; + PercentOn = state.HealthColors.auraPercPC; + ShowDead = state.HealthColors.auraDeadPC; + var cBy = oCharacter.get('controlledby'); + var player = getObj('player', cBy); + pColor = '#000000'; + if(player !== undefined) pColor = player.get('color'); + } + //IF MONSTER------------ + else if(type == 'Monster') { + GM = state.HealthColors.GM_NPCNames; + PC = state.HealthColors.NPCNames; + IsTypeOn = state.HealthColors.NPCAura; + PercentOn = state.HealthColors.auraPerc; + ShowDead = state.HealthColors.auraDead; + } + else return; + //CHECK DISABLED AURA/TINT ATTRIB------------ + if(oCharacter !== undefined) { + UseAura = lookupUseColor(oCharacter); + } + //SET HEALTH COLOR---------- + if(IsTypeOn && UseAura !== "NO") { + percReal = Math.min(percReal, 100); + if(percReal > PercentOn || curValue === 0) SetAuraNone(obj); + else TokenSet(obj, state.HealthColors.AuraSize, markerColor, pColor, update); + //SHOW DEAD---------- + if(ShowDead === true) { + if(curValue > 0) obj.set("status_dead", false); + else if(curValue < 1) { + var DeadSounds = state.HealthColors.auraDeadFX; + if(DeadSounds !== "None" && curValue != prevValue) PlayDeath(DeadSounds); + obj.set("status_dead", true); + SetAuraNone(obj); + } + } + } + else if((!IsTypeOn || UseAura === "NO") && obj.get(colortype + '_color') === markerColor) SetAuraNone(obj); + //SET SHOW NAMES------------ + SetShowNames(GM, PC, obj); + //**FX for Damage/Healing using DeathTracker's approach------------ + if(curValue != prevValue && prevValue != "" && update !== "YES") { + let left = parseInt(obj.get('left')), + top = parseInt(obj.get('top')); + if(state.HealthColors.FX === true) { + if(curValue < prevValue) { + // Damage FX – using HurtFX type (default: splatter-blood) + spawnFxBetweenPoints({ x: left, y: top }, { x: left, y: top }, state.HealthColors.HurtFX, obj.get('_pageid')); + } else if(curValue > prevValue) { + // Healing FX – using HealFX type (default: glow-holy) + spawnFxBetweenPoints({ x: left, y: top }, { x: left, y: top }, state.HealthColors.HealFX, obj.get('_pageid')); + } + } + } + } + }, + /*------------------------ + CHAT MESSAGES + ------------------------*/ + handleInput = function (msg) { + var msgFormula = msg.content.split(/\s+/); + var command = msgFormula[0].toUpperCase(), UPPER =""; + if(msg.type == "api" && command.indexOf("!AURA") !== -1) { + var OPTION = msgFormula[1] || "MENU"; + if(!playerIsGM(msg.playerid)) { + sendChat('HealthColors', "/w " + msg.who + " you must be a GM to use this command!"); + return; + } + else { + if(OPTION !== "MENU") GMW("UPDATING TOKENS..."); + switch(OPTION.toUpperCase()) { + case "MENU": + break; + case "ON": + state.HealthColors.auraColorOn = !state.HealthColors.auraColorOn; + break; + case "BAR": + state.HealthColors.auraBar = "bar" + msgFormula[2]; + break; + case "TINT": + state.HealthColors.auraTint = !state.HealthColors.auraTint; + break; + case "PERC": + state.HealthColors.auraPercPC = parseInt(msgFormula[2], 10); + state.HealthColors.auraPerc = parseInt(msgFormula[3], 10); + break; + case "PC": + state.HealthColors.PCAura = !state.HealthColors.PCAura; + break; + case "NPC": + state.HealthColors.NPCAura = !state.HealthColors.NPCAura; + break; + case "GMNPC": + state.HealthColors.GM_NPCNames = msgFormula[2]; + break; + case "GMPC": + state.HealthColors.GM_PCNames = msgFormula[2]; + break; + case "PCNPC": + state.HealthColors.NPCNames = msgFormula[2]; + break; + case "PCPC": + state.HealthColors.PCNames = msgFormula[2]; + break; + case "DEAD": + state.HealthColors.auraDead = !state.HealthColors.auraDead; + break; + case "DEADPC": + state.HealthColors.auraDeadPC = !state.HealthColors.auraDeadPC; + break; + case "DEADFX": + state.HealthColors.auraDeadFX = msgFormula[2]; + break; + case "SIZE": + state.HealthColors.AuraSize = parseFloat(msgFormula[2]); + break; + case "ONEOFF": + state.HealthColors.OneOff = !state.HealthColors.OneOff; + break; + case "FX": + state.HealthColors.FX = !state.HealthColors.FX; + break; + case "HEAL": + UPPER = msgFormula[2]; + state.HealthColors.HealFX = UPPER; + break; + case "HURT": + UPPER = msgFormula[2]; + state.HealthColors.HurtFX = UPPER; + break; + case "LISTFX": + listCustomFX(); + break; + case "RESET": + delete state.HealthColors; + GMW("STATE RESET"); + checkInstall(); + break; + case "UPDATE": + manUpdate(msg); + return; + } + aurahelp(OPTION); + } + } + }, + // New function: List Custom FX IDs in a formatted menu + listCustomFX = function() { + var allCustFX = findObjs({ _type: "custfx" }); + var output = "
"; + output += "Custom FX IDs:
"; + sendChat('HealthColors', "/w gm " + output); + }, + //SET TOKEN COLORS------------ + TokenSet = function (obj, sizeSet, markerColor, pColor, update) { + var Pageon = getObj("page", obj.get("_pageid")); + var scale = Pageon.get("scale_number") / 10; + if(state.HealthColors.auraTint === true) { + if(obj.get('aura1_color') == markerColor && update === "YES") { + obj.set({'aura1_color': "transparent"}); + } + obj.set({'tint_color': markerColor}); + } + else { + if(obj.get('tint_color') == markerColor && update === "YES") { + obj.set({'tint_color': "transparent"}); + } + obj.set({ + 'aura1_radius': sizeSet * scale * 1.8, + 'aura1_color': markerColor, + 'showplayers_aura1': true + }); + } + }, + //REMOVE ALL------------ + SetAuraNone = function (obj) { + if(state.HealthColors.auraTint === true) obj.set({'tint_color': "transparent"}); + else obj.set({'aura1_color': "transparent"}); + }, + //FORCE ALL TOKEN UPDATE------------ + MenuForceUpdate = function(){ + let i = 0; + const start = new Date().getTime(); + const barUsed = state.HealthColors.auraBar; + const workQueue = findObjs({type: 'graphic', subtype: 'token', layer: 'objects'}) + .filter((o) => o.get(barUsed + "_max") !== "" && o.get(barUsed + "_value") !== ""); + const drainQueue = () => { + let t = workQueue.shift(); + if(t){ + const prev = JSON.parse(JSON.stringify(t)); + handleToken(t, prev, 'YES'); + setTimeout(drainQueue, 0); + } else { + sendChat('Fixing Tokens', `/w gm Finished Fixing Tokens`); + } + }; + sendChat('Fixing Tokens', `/w gm Fixing ${workQueue.length} Tokens`); + drainQueue(); + var end = new Date().getTime(); + return "Tokens Processed: " + workQueue.length + "
Run time in ms: " + (end - start); + }, + SetShowNames = function(GM, PC, obj) { + if(GM != 'Off' && GM != '') { + GM = (GM == "Yes") ? true : false; + obj.set({'showname': GM}); + } + if(PC != 'Off' && PC != '') { + PC = (PC == "Yes") ? true : false; + obj.set({'showplayers_name': PC}); + } + }, + //MANUAL UPDATE------------ + manUpdate = function (msg) { + var selected = msg.selected; + var allNames = ''; + _.each(selected, function (obj) { + var token = getObj('graphic', obj._id); + var tName = token.get("name"); + allNames = allNames.concat(tName + '
'); + var prev = JSON.parse(JSON.stringify(token)); + handleToken(token, prev, "YES"); + }); + GMW(allNames); + }, + //ATTRIBUTE CACHE------------ + makeSmartAttrCache = function (attribute, options) { + let cache = {}, + defaultValue = options.default || 'YES', + validator = options.validation || _.constant(true); + on('change:attribute', function (attr) { + if(attr.get('name') === attribute) { + if(!validator(attr.get('current'))) { + attr.set('current', defaultValue); + } + cache[attr.get('characterid')] = attr.get('current'); + var tokens = findObjs({type: 'graphic'}).filter((o) => o.get('represents') === attr.get("characterid")); + _.each(tokens, function (obj) { + var prev = JSON.parse(JSON.stringify(obj)); + handleToken(obj, prev, "YES"); + }); + } + }); + on('destroy:attribute', function (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]; + }; + }, + lookupUseBlood = makeSmartAttrCache('USEBLOOD', { + default: 'DEFAULT' + }), + lookupUseColor = makeSmartAttrCache('USECOLOR', { + default: 'YES', + validation: (o) => o.match(/YES|NO/) + }), + //DEATH SOUND------------ + PlayDeath = function (trackname) { + var RandTrackName; + if(trackname.indexOf(",") > 0) { + var tracklist = trackname.split(","); + RandTrackName = tracklist[Math.floor(Math.random() * tracklist.length)]; + } + else RandTrackName = trackname; + var track = findObjs({type: 'jukeboxtrack', title: RandTrackName})[0]; + if(track) { + track.set('playing', false); + track.set('softstop', false); + track.set('volume', 50); + track.set('playing', true); + } + else { + log(ScriptName + ": No track found named " + RandTrackName); + } + }, + //PERC TO RGB------------ + PercentToHEX = function (percent) { + var HEX; + if(percent > 100) HEX = "#0000FF"; + else { + if(percent === 100) percent = 99; + var r, g, b = 0; + if(percent < 50) { + g = Math.floor(255 * (percent / 50)); + r = 255; + } + else { + g = 255; + r = Math.floor(255 * ((50 - percent % 50) / 50)); + } + HEX = "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); + } + return HEX; + }, + //HEX TO RGB------------ + HEXtoRGB = function (hex) { + let parts = (hex || '').match(/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/); + if(parts) { + let rgb = _.chain(parts).rest().map((d) => parseInt(d, 16)).value(); + rgb.push(1.0); + return rgb; + } + return [0, 0, 0, 0.0]; + }, + //SPAWN FX------------ + SpawnFX = function (Scale, HitSize, left, top, FX, pageid) { + _.defaults(FX, { + "maxParticles": 100, + "duration": 100, + "size": 100, + "sizeRandom": 100, + "lifeSpan": 100, + "lifeSpanRandom": 100, + "speed": 0, + "speedRandom": 0, + "angle": 0, + "angleRandom": 0, + "emissionRate": 100, + "startColour": [255, 255, 255, 1], + "endColour": [0, 0, 0, 1], + "gravity": {"x": 0, "y": 0.0} + }); + var newFX = { + "maxParticles": FX.maxParticles * HitSize, + "duration": FX.duration * HitSize, + "size": FX.size * Scale / 2, + "sizeRandom": FX.sizeRandom * Scale / 2, + "lifeSpan": FX.lifeSpan, + "lifeSpanRandom": FX.lifeSpanRandom, + "speed": FX.speed * Scale, + "speedRandom": FX.speedRandom * Scale, + "angle": FX.angle, + "angleRandom": FX.angleRandom, + "emissionRate": FX.emissionRate * HitSize * 2, + "startColour": FX.startColour, + "endColour": FX.endColour, + "gravity": {"x": FX.gravity.x * Scale, "y": FX.gravity.y * Scale} + }; + spawnFxWithDefinition(left, top, newFX, pageid); + }, + //HELP MENU------------ + aurahelp = function (OPTION) { + var Update = ''; + if(OPTION !== "MENU") Update = MenuForceUpdate(); + var img = "background-image: -webkit-linear-gradient(left, #76ADD6 0%, #a7c7dc 100%);"; + var tshadow = "-1px -1px #222, 1px -1px #222, -1px 1px #222, 1px 1px #222 , 2px 2px #222;"; + // Base style for buttons with fixed width (for most items) + var style = 'style="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;"'; + // FX type buttons use a wider style + var fxStyle = 'style="padding-top: 1px; text-align:center; font-size: 9pt; width: 80px; height: 14px; border: 1px solid black; margin: 1px; background-color: #6FAEC7; border-radius: 4px; box-shadow: 1px 1px 1px #707070;"'; + // For buttons that should adjust to the text length, we use auto width. + var autoStyle = 'style="padding: 1px 3px; text-align:center; font-size: 9pt; border: 1px solid black; margin: 1px; background-color: #6FAEC7; border-radius: 4px; box-shadow: 1px 1px 1px #707070; display:inline-block; width:auto;"'; + var off = "#A84D4D"; + var disable = "#D6D6D6"; + var HR = "
"; + var FX = state.HealthColors.auraDeadFX.substring(0, 4); + // New button renamed "Custom FX ids" using autoStyle so it adjusts to the text length. + var listFXButton = 'Custom FX ids
'; + sendChat('HealthColors', "/w gm
" + + '
' + + 'HealthColors Version: ' + version + '
' + + HR + + 'Is On: ' + (state.HealthColors.auraColorOn !== true ? "No" : "Yes") + '
' + + 'Bar: ' + state.HealthColors.auraBar + '
' + + 'Use Tint: ' + (state.HealthColors.auraTint !== true ? "No" : "Yes") + '
' + + 'Percentage(PC/NPC): ' + state.HealthColors.auraPercPC + '/' + state.HealthColors.auraPerc + '
' + + HR + + 'Show PC Health: ' + (state.HealthColors.PCAura !== true ? "No" : "Yes") + '
' + + 'Show NPC Health: ' + (state.HealthColors.NPCAura !== true ? "No" : "Yes") + '
' + + 'Show Dead PC: ' + (state.HealthColors.auraDeadPC !== true ? "No" : "Yes") + '
' + + 'Show Dead NPC: ' + (state.HealthColors.auraDead !== true ? "No" : "Yes") + '
' + + HR + + 'GM Sees all PC Names: ' + state.HealthColors.GM_PCNames + '
' + + 'GM Sees all NPC Names: ' + state.HealthColors.GM_NPCNames + '
' + + HR + + 'PC Sees all PC Names: ' + state.HealthColors.PCNames + '
' + + 'PC Sees all NPC Names: ' + state.HealthColors.NPCNames + '
' + + HR + + 'Aura Size: ' + state.HealthColors.AuraSize + '
' + + 'One Offs: ' + (state.HealthColors.OneOff !== true ? "No" : "Yes") + '
' + + 'FX: ' + (state.HealthColors.FX !== true ? "No" : "Yes") + '
' + + 'HealFX Type: ' + state.HealthColors.HealFX + '
' + + 'HurtFX Type: ' + state.HealthColors.HurtFX + '
' + + 'DeathSFX: ' + FX + '
' + + HR + + listFXButton + + Update + + '
'); + }, + //OFF BUTTON COLORS------------ + ButtonColor = function (state, off, disable) { + var color; + if(state == "No") color = off; + if(state == "Off") color = disable; + return color; + }, + //CHECK INSTALL & SET STATE------------ + checkInstall = function () { + log('-=>' + ScriptName + ' v' + version + ' [Updated: ' + Updated + ']<=-'); + if(!_.has(state, 'HealthColors') || state.HealthColors.schemaVersion !== schemaVersion) { + log('<' + ScriptName + ' Updating Schema to v' + schemaVersion + '>'); + state.HealthColors = {schemaVersion: schemaVersion}; + state.HealthColors.version = version; + } + //CHECK STATE VALUES + if(_.isUndefined(state.HealthColors.auraColorOn)) state.HealthColors.auraColorOn = true; //global on or off + if(_.isUndefined(state.HealthColors.auraBar)) state.HealthColors.auraBar = "bar1"; //bar to use + if(_.isUndefined(state.HealthColors.auraTint)) state.HealthColors.auraTint = false; //use tint instead? + if(_.isUndefined(state.HealthColors.auraPercPC)) state.HealthColors.auraPercPC = 100; //precent to start showing PC + if(_.isUndefined(state.HealthColors.auraPerc)) state.HealthColors.auraPerc = 100; //precent to start showing NPC + //----------------- + if(_.isUndefined(state.HealthColors.PCAura)) state.HealthColors.PCAura = true; //show players Health? + if(_.isUndefined(state.HealthColors.NPCAura)) state.HealthColors.NPCAura = true; //show NPC Health? + if(_.isUndefined(state.HealthColors.auraDeadPC)) state.HealthColors.auraDeadPC = true; //show dead X status PC + if(_.isUndefined(state.HealthColors.auraDead)) state.HealthColors.auraDead = true; //show dead X status NPC + //----------------- + if(_.isUndefined(state.HealthColors.GM_PCNames)) state.HealthColors.GM_PCNames = "Yes"; //show GM PC names? + if(_.isUndefined(state.HealthColors.PCNames)) state.HealthColors.PCNames = "Yes"; //show players PC Names? + //----------------- + if(_.isUndefined(state.HealthColors.GM_NPCNames)) state.HealthColors.GM_NPCNames = "Yes"; //show GM NPC names? + if(_.isUndefined(state.HealthColors.NPCNames)) state.HealthColors.NPCNames = "Yes"; //show players NPC Names? + //----------------- + if(_.isUndefined(state.HealthColors.AuraSize)) state.HealthColors.AuraSize = 0.7; //set aura size? + if(_.isUndefined(state.HealthColors.FX)) state.HealthColors.FX = true; //set FX ON/OFF? + // Updated defaults for FX types (using DeathTracker FX names) + if(_.isUndefined(state.HealthColors.HealFX)) state.HealthColors.HealFX = "glow-holy"; //set Heal FX type + if(_.isUndefined(state.HealthColors.HurtFX)) state.HealthColors.HurtFX = "splatter-blood"; //set Hurt FX type + if(_.isUndefined(state.HealthColors.auraDeadFX)) state.HealthColors.auraDeadFX = 'None'; //Sound FX Name + //TokenMod CHECK + if('undefined' !== typeof TokenMod && TokenMod.ObserveTokenChange) TokenMod.ObserveTokenChange(handleToken); + var FXHurt = findObjs({_type: "custfx", name: "-DefaultHurt"}, {caseInsensitive: true})[0]; + var FXHeal = findObjs({_type: "custfx", name: "-DefaultHeal"}, {caseInsensitive: true})[0]; + //DEFAULT FX CHECK + if(!FXHurt) { + GMW("Creating Default Hurt FX"); + var Hurt = { + "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] + }; + createObj('custfx', {name: "-DefaultHurt", definition: Hurt}); + } + if(!FXHeal) { + GMW("Creating Default Heal FX"); + var Heal = { + "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] + }; + createObj('custfx', {name: "-DefaultHeal", definition: Heal}); + } + }, + //WHISPER GM------------ + GMW = function (text) { + var DIV = "
" + text + " { + let token = getObj('graphic', t.id), + prev = JSON.parse(JSON.stringify(token)); + handleToken(token, prev, "YES"); + }, 400); + }); + //register this script to SmartAoE to handle linked bar hp changes + if('undefined' !== typeof SmartAoE && SmartAoE.ObserveTokenChange){ + SmartAoE.ObserveTokenChange(function(obj, prev){ + handleToken(obj, prev, "NO"); + }); + }; + }; + //RETURN OUTSIDE FUNCTIONS------------ + return { + GMW: GMW, + Update: UpdateToken, + CheckInstall: checkInstall, + RegisterEventHandlers: registerEventHandlers + }; +}()); +//On Ready +on('ready', function () { + 'use strict'; + HealthColors.GMW("API READY"); + HealthColors.CheckInstall(); + HealthColors.RegisterEventHandlers(); +}); diff --git a/HealthColors/2.1.1/HealthColors.js b/HealthColors/2.1.1/HealthColors.js new file mode 100644 index 000000000..364a43ced --- /dev/null +++ b/HealthColors/2.1.1/HealthColors.js @@ -0,0 +1,1510 @@ +// =========================== +// === HealthColors v2.1.1 === +// =========================== + +// 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 */ + +const HealthColors = (() => { + 'use strict'; + + // ————— CONSTANTS ————— + const VERSION = '2.1.1'; + const SCRIPT_NAME = 'HealthColors'; + const SCHEMA_VERSION = '1.1.0'; + const UPDATED = '2026-05-07 16:00 UTC'; + + // ————— DEFAULTS ————— + /** + * Default values written into `state.HealthColors` on first install or after a reset. + * Every key maps directly to a property used at runtime — changing a value here changes + * the out-of-the-box behavior for new or reset campaigns. + * + * @property {boolean} auraColorOn - Master on/off switch for the whole script. + * @property {string} auraBar - Which token bar to read HP from ('bar1'|'bar2'|'bar3'). + * @property {boolean} auraTint - When true, colors the token tint instead of the aura rings. + * @property {number} auraPercPC - HP % threshold below which the PC aura activates (0–100). + * @property {number} auraPerc - HP % threshold below which the NPC aura activates (0–100). + * @property {boolean} PCAura - Whether to show a health aura on player-character tokens. + * @property {boolean} NPCAura - Whether to show a health aura on monster/NPC tokens. + * @property {boolean} auraDeadPC - Whether to mark a PC token with the dead status at 0 HP. + * @property {boolean} auraDead - Whether to mark an NPC token with the dead status at 0 HP. + * @property {string} GM_PCNames - GM visibility of PC token names ('Yes'|'No'|'Off'). + * @property {string} PCNames - Player visibility of PC token names ('Yes'|'No'|'Off'). + * @property {string} GM_NPCNames - GM visibility of NPC token names ('Yes'|'No'|'Off'). + * @property {string} NPCNames - Player visibility of NPC token names ('Yes'|'No'|'Off'). + * @property {number} AuraSize - Feet the aura extends beyond the token edge. + * @property {string} Aura1Shape - Display/default Aura 1 shape shown in output. + * @property {string} Aura1Color - Display/default Aura 1 tint shown in output. + * @property {number} Aura2Size - Display/default Aura 2 radius shown in output. + * @property {string} Aura2Shape - Display/default Aura 2 shape shown in output. + * @property {string} Aura2Color - Display/default Aura 2 tint value shown in output. + * @property {boolean} OneOff - When true, tokens without a linked character also get auras. + * @property {boolean} FX - Whether to spawn particle FX on HP changes. + * @property {string} HealFX - Hex color (no '#') used for the healing particle effect. + * @property {string} HurtFX - Hex color (no '#') used for the hurt/damage particle effect. + * @property {string} auraDeadFX - Jukebox track name to play on death, or 'None' to disable. + * @property {string} colorPalette - Health aura colour palette ('default'|'colorblind'). + */ + const DEFAULTS = { + auraColorOn: true, + auraBar: 'bar1', + auraTint: false, + auraPercPC: 100, + auraPerc: 100, + PCAura: true, + NPCAura: true, + auraDeadPC: true, + auraDead: true, + GM_PCNames: 'Yes', + PCNames: 'Yes', + GM_NPCNames: 'Yes', + NPCNames: 'Yes', + AuraSize: 0.35, + Aura1Shape: 'Circle', + Aura1Color: '00FF00', + Aura2Size: 5, + Aura2Shape: 'Square', + Aura2Color: '806600', + OneOff: false, + FX: true, + HealFX: 'FDDC5C', + HurtFX: 'FF0000', + auraDeadFX: 'None', + colorPalette: 'default', + }; + + const COLOR_PALETTES = { + default: { + high: [0, 255, 0], // green + mid: [255, 255, 0], // yellow + low: [255, 0, 0], // red + dead: [0, 0, 0], // black + }, + colorblind: { + high: [51, 187, 238], // cyan + mid: [238, 119, 51], // orange + low: [204, 51, 17], // magenta + dead: [0, 0, 0], // black + }, + }; + + /** + * Seed definition for the '-DefaultHurt' Roll20 custom FX object created at install. + * Models a downward-falling burst (blood/debris) triggered when a token loses HP. + * `startColour` and `endColour` are placeholder zeroes — they are overwritten at + * runtime with the value of `state.HealthColors.HurtFX` (or a per-character override) + * before the FX is spawned, so changing them here has no visible effect. + * + * @property {number} maxParticles - Maximum simultaneous particles in the burst. + * @property {number} duration - How long (in frames) the emitter runs. + * @property {number} size - Base particle diameter before scale is applied. + * @property {number} sizeRandom - Random variance added to each particle's size. + * @property {number} lifeSpan - Frames each particle lives before fading. + * @property {number} lifeSpanRandom - Random variance added to each particle's lifespan. + * @property {number} speed - Base particle speed before scale is applied. + * @property {number} speedRandom - Random variance added to each particle's speed. + * @property {{x:number,y:number}} gravity - Per-frame acceleration applied to all particles. + * @property {number} angle - Emission direction in degrees (270 = straight down). + * @property {number} angleRandom - Cone spread around the emission angle. + * @property {number} emissionRate - Particles emitted per frame while the emitter is active. + * @property {number[]} startColour - RGBA start colour placeholder; overwritten at runtime. + * @property {number[]} endColour - RGBA end colour placeholder; overwritten at runtime. + */ + const DEFAULT_HURT_FX = { + maxParticles: 150, + duration: 50, + size: 10, + sizeRandom: 3, + lifeSpan: 25, + lifeSpanRandom: 5, + speed: 8, + speedRandom: 3, + gravity: { x: 0.01, y: 0.65 }, + angle: 270, + angleRandom: 25, + emissionRate: 100, + startColour: [0, 0, 0, 0], + endColour: [0, 0, 0, 0], + }; + + /** + * Seed definition for the '-DefaultHeal' Roll20 custom FX object created at install. + * Models a soft omnidirectional sparkle/glow triggered when a token regains HP. + * Like DEFAULT_HURT_FX, `startColour` and `endColour` are placeholders overwritten + * at runtime with `state.HealthColors.HealFX` before the FX is spawned. + * + * @property {number} maxParticles - Maximum simultaneous particles in the burst. + * @property {number} duration - How long (in frames) the emitter runs. + * @property {number} size - Base particle diameter before scale is applied. + * @property {number} sizeRandom - Random variance added to each particle's size (larger + * than hurt to produce a softer, more diffuse bloom). + * @property {number} lifeSpan - Frames each particle lives before fading. + * @property {number} lifeSpanRandom - Random variance added to each particle's lifespan. + * @property {number} speed - Base particle speed (slow drift upward). + * @property {number} speedRandom - Random variance added to each particle's speed. + * @property {number} angle - Emission direction in degrees (0 = straight up). + * @property {number} angleRandom - 180° spread produces full omnidirectional emission. + * @property {number} emissionRate - Very high rate creates a dense initial burst. + * @property {number[]} startColour - RGBA start colour placeholder; overwritten at runtime. + * @property {number[]} endColour - RGBA end colour placeholder; overwritten at runtime. + */ + const DEFAULT_HEAL_FX = { + maxParticles: 150, + duration: 50, + size: 10, + sizeRandom: 15, + lifeSpan: 50, + lifeSpanRandom: 30, + speed: 0.5, + speedRandom: 2, + angle: 0, + angleRandom: 180, + emissionRate: 1000, + startColour: [0, 0, 0, 0], + endColour: [0, 0, 0, 0], + }; + + /** + * Fallback baseline merged into every FX definition by `spawnFX` before spawning. + * This is NOT a Roll20 custfx object — it is a local safety net that ensures + * `spawnFX` never passes `undefined` for a required Roll20 FX field when a custom + * or per-character definition omits optional properties. + * Merge order: `{ ...FX_PARAM_DEFAULTS, ...userDefinition }`, so any property + * present in the real definition takes precedence over these fallbacks. + * + * @property {number} maxParticles - Fallback particle count. + * @property {number} duration - Fallback emitter duration (frames). + * @property {number} size - Fallback particle size. + * @property {number} sizeRandom - Fallback size variance. + * @property {number} lifeSpan - Fallback particle lifespan (frames). + * @property {number} lifeSpanRandom - Fallback lifespan variance. + * @property {number} speed - Fallback particle speed (0 = stationary). + * @property {number} speedRandom - Fallback speed variance. + * @property {number} angle - Fallback emission angle in degrees. + * @property {number} angleRandom - Fallback angular spread. + * @property {number} emissionRate - Fallback particles emitted per frame. + * @property {number[]} startColour - Fallback RGBA start colour (British spelling; opaque grey). + * @property {number[]} startColor - Fallback RGBA start color (American spelling; same value). + * @property {number[]} endColour - Fallback RGBA end colour (British spelling; opaque black). + * @property {number[]} endColor - Fallback RGBA end color (American spelling; same value). + * @property {number[]} startColourRandom - Fallback start colour randomization (zeroed). + * @property {number[]} startColorRandom - Fallback start color randomization (zeroed). + * @property {number[]} endColourRandom - Fallback end colour randomization (zeroed). + * @property {number[]} endColorRandom - Fallback end color randomization (zeroed). + * @property {{x:number,y:number}} gravity - Fallback gravity (none). + */ + const FX_PARAM_DEFAULTS = { + maxParticles: 100, + duration: 100, + size: 15, + sizeRandom: 5, + lifeSpan: 50, + lifeSpanRandom: 20, + speed: 1, + speedRandom: 1, + angle: 0, + angleRandom: 0, + emissionRate: 10, + startColour: [128, 128, 128, 1], + startColor: [128, 128, 128, 1], + endColour: [0, 0, 0, 1], + endColor: [0, 0, 0, 1], + startColourRandom: [0, 0, 0, 0], + startColorRandom: [0, 0, 0, 0], + endColourRandom: [0, 0, 0, 0], + endColorRandom: [0, 0, 0, 0], + gravity: { x: 0, y: 0 }, + }; + + // ————— UTILITIES ————— + /** + * Converts a health percentage (0–100+) to a hex color using the active palette. + * Values above 100% return blue; 0% uses dead; 1–100 interpolate low→mid→high. + * + * @param {number} pct - Health percentage. + * @returns {string} A 6-digit hex color string, e.g. '#FF0000'. + */ + function percentToHex(pct) { + const normalizedPct = Math.max(0, Number(pct) || 0); + if (normalizedPct > 100) return '#0000FF'; + const paletteName = state?.HealthColors?.colorPalette || 'default'; + const { high, mid, low, dead } = + COLOR_PALETTES[paletteName] || COLOR_PALETTES.default; + const rgbToHex = (rgb) => + // eslint-disable-next-line no-bitwise + `#${((1 << 24) + (rgb[0] << 16) + (rgb[1] << 8) + rgb[2]).toString(16).slice(1)}`; + + if (normalizedPct === 0) { + return rgbToHex(dead); + } + + const t = + normalizedPct >= 50 ? (normalizedPct - 50) / 50 : normalizedPct / 50; + const from = normalizedPct >= 50 ? mid : low; + const to = normalizedPct >= 50 ? high : mid; + const r = Math.round(from[0] + (to[0] - from[0]) * t); + const g = Math.round(from[1] + (to[1] - from[1]) * t); + const b = Math.round(from[2] + (to[2] - from[2]) * t); + return rgbToHex([r, g, b]); + } + + /** + * Parses a hex color string into an RGBA array suitable for Roll20 FX definitions. + * Returns [0,0,0,0] when the input is invalid. + * + * @param {string} hex - Hex color string with or without leading '#'. + * @returns {number[]} Array of [r, g, b, a] where a is always 1.0 on success. + */ + function hexToRgb(hex) { + const cleanHex = (hex || '').replace('#', '').trim(); + const parts = /^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec( + cleanHex, + ); + if (parts) { + const rgb = parts.slice(1).map((d) => Number.parseInt(d, 16)); + rgb.push(1); + return rgb; + } + // Log invalid hex attempts if they appear non-empty + if (cleanHex) + log(`${SCRIPT_NAME}: hexToRgb received invalid hex: "${hex}"`); + return [0, 0, 0, 0]; + } + + /** + * Returns a random integer between min and max inclusive. + * + * @param {number} min - Lower bound (inclusive). + * @param {number} max - Upper bound (inclusive). + * @returns {number} Random integer in [min, max]. + */ + function randomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; // NOSONAR — cosmetic FX variance, not security-sensitive + } + + /** + * Creates a plain-object snapshot of a Roll20 API object or any serialisable value. + * Uses JSON round-trip rather than structuredClone so that Roll20 proxy objects have + * their toJSON() method called, producing a plain object whose properties are + * accessible directly (e.g. prev.bar1_value) rather than through .get(). + * + * @param {object} obj - Roll20 API object or plain object to snapshot. + * @returns {object} Plain object deep copy. + */ + function deepClone(obj) { + return JSON.parse(JSON.stringify(obj)); // NOSONAR — intentional: triggers Roll20 proxy toJSON() + } + + /** + * Normalizes a 6-digit hex color string (without '#'). + * Returns fallback when input is invalid. + * + * @param {string} value - Candidate hex string. + * @param {string} fallback - Fallback value when invalid. + * @returns {string} Uppercase 6-digit hex. + */ + function normalizeHex6(value, fallback) { + const cleaned = (value || '').replace('#', '').trim().toUpperCase(); + return /^[0-9A-F]{6}$/.test(cleaned) ? cleaned : fallback; + } + + /** + * Normalizes an aura shape label to supported display values. + * + * @param {string} value - Candidate shape value. + * @param {string} fallback - Fallback shape. + * @returns {string} One of Circle|Square. + */ + function normalizeShape(value, fallback) { + const shape = (value || '').trim().toUpperCase(); + if (shape === 'CIRCLE') return 'Circle'; + if (shape === 'SQUARE') return 'Square'; + return fallback; + } + + /** + * Normalizes a palette name to one of the supported keys. + * + * @param {string} value - Candidate palette key. + * @param {string} fallback - Fallback palette key when invalid. + * @returns {string} A valid palette key from COLOR_PALETTES. + */ + function normalizePalette(value, fallback) { + const p = (value || '').trim().toLowerCase(); + return COLOR_PALETTES[p] ? p : fallback; + } + + // ————— WHISPER GM (declared early; used by checkInstall) ————— + /** + * Sends a styled whisper message to the GM. + * + * @param {string} text - Plain text content to display inside the styled div. + */ + function gmWhisper(text) { + const style = [ + 'width:100%', + 'border-radius:4px', + 'box-shadow:1px 1px 1px #707070', + 'text-align:center', + 'vertical-align:middle', + 'padding:3px 0px', + 'margin:0px auto', + 'border:1px solid #000', + 'color:#000', + 'background-image:-webkit-linear-gradient(-45deg,#a7c7dc 0%,#85b2d3 100%)', + ].join(';'); + sendChat(SCRIPT_NAME, `/w GM
${text}
`); + } + + // ————— ATTRIBUTE CACHE ————— + /** + * Creates a cached attribute lookup function that auto-refreshes on attribute + * change or destruction and re-triggers handleToken for affected tokens. + * Creates the attribute with the default value if it does not exist yet. + * + * @param {string} attribute - The Roll20 attribute name to track (e.g. 'USECOLOR'). + * @param {object} [options={}] - Configuration options. + * @param {string} [options.default] - Value to use when the attribute is missing or invalid. + * @param {Function} [options.validation]- Predicate that returns true for valid values. + * @returns {Function} Lookup function accepting a character object and returning the current value. + */ + function makeSmartAttrCache(attribute, options = {}) { + const cache = {}; + const defaultValue = options.default || 'YES'; + const validator = options.validation || (() => true); + + on('change:attribute', (attr) => { + if (attr.get('name') !== attribute) return; + if (!validator(attr.get('current'))) attr.set('current', defaultValue); + cache[attr.get('characterid')] = attr.get('current'); + findObjs({ type: 'graphic' }) + .filter((o) => o.get('represents') === attr.get('characterid')) + .forEach((obj) => { + const prev = deepClone(obj); + handleToken(obj, prev, 'YES'); + }); + }); + + on('destroy:attribute', (attr) => { + if (attr.get('name') === attribute) delete cache[attr.get('characterid')]; + }); + + return function (character) { + let attr = + findObjs( + { type: 'attribute', name: attribute, characterid: character.id }, + { caseInsensitive: true }, + )[0] || + createObj('attribute', { + name: attribute, + characterid: character.id, + current: defaultValue, + }); + + if (!cache[character.id] || cache[character.id] !== attr.get('current')) { + if (!validator(attr.get('current'))) attr.set('current', defaultValue); + cache[character.id] = attr.get('current'); + } + return cache[character.id]; + }; + } + + const lookupUseBlood = makeSmartAttrCache('USEBLOOD', { default: 'DEFAULT' }); + const lookupUseColor = makeSmartAttrCache('USECOLOR', { + default: 'YES', + validation: (o) => Boolean(o.match(/YES|NO/)), + }); + + // ————— TOKEN HELPERS ————— + /** + * Hard-clears all health-indicator visual settings (aura/tint). + * Used for dead tokens or when the script/aura is disabled for a type. + * + * @param {object} obj - Roll20 token graphic object. + */ + function clearAuras(obj) { + 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)]; // NOSONAR — random track selection, not security-sensitive + const track = findObjs({ type: 'jukeboxtrack', title: resolvedName })[0]; + if (track) { + track.set({ playing: false, softstop: false, volume: 50 }); + track.set({ playing: true }); + } else { + log(`${SCRIPT_NAME}: No track found named ${resolvedName}`); + } + } + + /** + * Spawns a scaled particle FX at a token's position using a custom FX definition. + * Merges the provided definition against FX_PARAM_DEFAULTS so partial definitions work. + * + * @param {number} scale - Scaling factor derived from token height (height / 70). + * @param {number} hitSize - Hit-size factor based on damage proportion (0.2–1.0). + * @param {number} left - Horizontal pixel position of the token on the page. + * @param {number} top - Vertical pixel position of the token on the page. + * @param {object} fx - Partial or complete Roll20 custom FX definition object. + * @param {string} pageId - ID of the Roll20 page on which to spawn the FX. + */ + function spawnFX(scale, hitSize, left, top, fx, pageId) { + const m = { ...FX_PARAM_DEFAULTS, ...fx }; + + // Prefer colours from the incoming partial `fx` first (nullish), then merged `m`. + // Order matters: after merge, `m.startColour` can still be FX_PARAM_DEFAULTS grey + // while the real colour only exists on `fx.startColor` (Roll20 / heal seed used + // American keys only). Using `||` on `m` alone would always pick the grey default. + const pick = (obj, keys) => { + if (!obj) return undefined; + for (const key of keys) { + const v = obj[key]; + if (v !== undefined && v !== null) return v; + } + return undefined; + }; + const startKeys = [ + 'startColour', + 'startColor', + 'startcolour', + 'startcolor', + ]; + const endKeys = ['endColour', 'endColor', 'endcolour', 'endcolor']; + const startRndKeys = [ + 'startColourRandom', + 'startColorRandom', + 'startcolourrandom', + 'startcolorrandom', + ]; + const endRndKeys = [ + 'endColourRandom', + 'endColorRandom', + 'endcolourrandom', + 'endcolorrandom', + ]; + const startClr = pick(fx, startKeys) ?? pick(m, startKeys); + const endClr = pick(fx, endKeys) ?? pick(m, endKeys); + const startClrRnd = pick(fx, startRndKeys) ?? pick(m, startRndKeys); + const endClrRnd = pick(fx, endRndKeys) ?? pick(m, endRndKeys); + + spawnFxWithDefinition( + left, + top, + { + maxParticles: m.maxParticles * hitSize, + duration: m.duration * hitSize, + size: (m.size * scale) / 2, + sizeRandom: (m.sizeRandom * scale) / 2, + lifeSpan: m.lifeSpan, + lifeSpanRandom: m.lifeSpanRandom, + speed: m.speed * scale, + speedRandom: m.speedRandom * scale, + angle: m.angle, + angleRandom: m.angleRandom, + emissionRate: m.emissionRate * hitSize * 2, + startColour: startClr, + startColor: startClr, + endColour: endClr, + endColor: endClr, + startColourRandom: startClrRnd, + startColorRandom: startClrRnd, + endColourRandom: endClrRnd, + endColorRandom: endClrRnd, + gravity: { x: m.gravity.x * scale, y: m.gravity.y * scale }, + }, + pageId, + ); + } + + /** + * Safely reads a Roll20 custfx definition and returns a plain mutable object. + * Roll20 may return the definition as either an object or a JSON string. + * + * @param {object} fxObj - Roll20 custfx object. + * @returns {object|null} Parsed FX definition object, or null if unavailable/invalid. + */ + function getFxDefinition(fxObj) { + if (!fxObj) return null; + + const raw = fxObj.get('definition'); + if (!raw) return null; + + if (typeof raw === 'string') { + try { + return JSON.parse(raw); + } catch (err) { + log(`${SCRIPT_NAME}: Failed to parse FX definition: ${err.message}`); + return null; + } + } + + if (typeof raw === 'object') { + return deepClone(raw); + } + + return null; + } + + // ————— STATE / INSTALL ————— + /** + * Initializes or migrates persisted state, applies all default values, registers + * the TokenMod observer if available, and creates the default Hurt/Heal FX objects + * if they do not already exist in the campaign. + * Safe to call multiple times (e.g. after a state reset). + */ + function checkInstall() { + log(`-=> ${SCRIPT_NAME} v${VERSION} [Updated: ${UPDATED}] <=-`); + if (state?.HealthColors?.schemaVersion !== SCHEMA_VERSION) { + log(`<${SCRIPT_NAME} Updating Schema to v${SCHEMA_VERSION}>`); + state.HealthColors = { schemaVersion: SCHEMA_VERSION, version: VERSION }; + } + Object.keys(DEFAULTS).forEach((key) => { + if (state.HealthColors[key] === undefined) + state.HealthColors[key] = DEFAULTS[key]; + }); + state.HealthColors.colorPalette = normalizePalette( + state.HealthColors.colorPalette, + DEFAULTS.colorPalette, + ); + if (typeof TokenMod !== 'undefined' && TokenMod.ObserveTokenChange) { + TokenMod.ObserveTokenChange(handleToken); + } + const fxHurt = findObjs( + { _type: 'custfx', name: '-DefaultHurt' }, + { caseInsensitive: true }, + )[0]; + const fxHeal = findObjs( + { _type: 'custfx', name: '-DefaultHeal' }, + { caseInsensitive: true }, + )[0]; + if (!fxHurt) { + gmWhisper('Creating Default Hurt FX'); + createObj('custfx', { + name: '-DefaultHurt', + definition: DEFAULT_HURT_FX, + }); + } + if (!fxHeal) { + gmWhisper('Creating Default Heal FX'); + createObj('custfx', { + name: '-DefaultHeal', + definition: DEFAULT_HEAL_FX, + }); + } + syncDefaultFxObjects(); + } + + /** + * Builds the normalized default Hurt/Heal definition payload used for + * campaign custom FX objects. + * + * @param {boolean} isHeal - True for Heal profile, false for Hurt profile. + * @param {object} baseDef - Existing definition to merge into. + * @returns {object} Updated definition with normalized color/profile fields. + */ + function buildDefaultFxDefinition(isHeal, baseDef) { + const def = { ...baseDef }; + const rgb = hexToRgb( + isHeal ? state.HealthColors.HealFX : state.HealthColors.HurtFX, + ); + def.startColour = rgb; + def.startColor = rgb; + def.endColour = rgb; + def.endColor = rgb; + def.startColourRandom = [0, 0, 0, 0]; + def.startColorRandom = [0, 0, 0, 0]; + def.endColourRandom = [0, 0, 0, 0]; + def.endColorRandom = [0, 0, 0, 0]; + + // Keep the vivid profile that reads clearly in live play. + if (isHeal) { + def.maxParticles = 220; + def.emissionRate = 260; + def.size = 12; + def.sizeRandom = 4; + def.lifeSpan = 40; + def.lifeSpanRandom = 6; + def.speed = 0.8; + def.speedRandom = 1; + } else { + def.maxParticles = 200; + def.emissionRate = 180; + def.size = 10; + def.sizeRandom = 2; + def.lifeSpan = 22; + def.lifeSpanRandom = 3; + def.speed = 8; + def.speedRandom = 2; + } + return def; + } + + /** + * Applies current Heal/Hurt colors and profile tuning to campaign default + * custom FX objects. This is called on install/reset and when color settings + * change so runtime spawns can use stable pre-synced definitions. + */ + function syncDefaultFxObjects() { + const fxHurt = findObjs( + { _type: 'custfx', name: '-DefaultHurt' }, + { caseInsensitive: true }, + )[0]; + const fxHeal = findObjs( + { _type: 'custfx', name: '-DefaultHeal' }, + { caseInsensitive: true }, + )[0]; + if (fxHeal) { + const base = getFxDefinition(fxHeal) || DEFAULT_HEAL_FX; + fxHeal.set({ definition: buildDefaultFxDefinition(true, base) }); + } + if (fxHurt) { + const base = getFxDefinition(fxHurt) || DEFAULT_HURT_FX; + fxHurt.set({ definition: buildDefaultFxDefinition(false, base) }); + } + } + + /** + * Recreates HealthColors default custom FX objects in the campaign. + * Useful when legacy/stale custfx definitions exist from older script versions. + */ + function resetDefaultFxObjects() { + const existing = findObjs( + { _type: 'custfx' }, + { caseInsensitive: true }, + ).filter((fx) => /-Default(Hurt|Heal)/i.test(fx.get('name') || '')); + existing.forEach((fx) => fx.remove()); + gmWhisper('Recreating Default Hurt/Heal FX'); + checkInstall(); + } + + /** + * Resets all persisted HealthColors settings back to DEFAULTS. + * Keeps schema/version metadata aligned to current script constants. + */ + function resetAllSettingsToDefaults() { + state.HealthColors = { + schemaVersion: SCHEMA_VERSION, + version: VERSION, + ...DEFAULTS, + }; + } + + /** + * Restores all state defaults, rebuilds default FX objects, and force-syncs tokens. + */ + function runResetAllFlow() { + resetAllSettingsToDefaults(); + gmWhisper('RESET ALL: defaults restored + default FX + force update'); + resetDefaultFxObjects(); + menuForceUpdate(); + } + + // ————— TOKEN LOGIC ————— + /** + * Reads the configured health bar from a token and its previous snapshot, + * validates all three values are numeric, and returns a health data object. + * Returns null if any value is missing or non-numeric. + * + * @param {object} obj - Roll20 token graphic object. + * @param {object} prev - Snapshot of the token's previous attribute values. + * @param {string} [update] - Pass 'YES' when called from a forced refresh. + * @returns {{ maxValue: number, curValue: number, prevValue: string|number, + * percReal: number, markerColor: string }|null} + */ + function getBarHealth(obj, prev, update) { + const barUsed = state.HealthColors.auraBar; + if (obj.get(`${barUsed}_max`) === '' && obj.get(`${barUsed}_value`) === '') + return null; + const maxValue = Number.parseInt(obj.get(`${barUsed}_max`), 10); + const curValue = Number.parseInt(obj.get(`${barUsed}_value`), 10); + const prevValue = prev[`${barUsed}_value`]; + if (Number.isNaN(maxValue) || Number.isNaN(curValue)) return null; + if (update !== 'YES' && Number.isNaN(Number.parseInt(prevValue, 10))) + return null; + const percReal = Math.max( + 0, + Math.min(Math.round((curValue / maxValue) * 100), 100), + ); + const markerColor = percentToHex(percReal); + return { maxValue, curValue, prevValue, percReal, markerColor }; + } + + /** + * Determines Player vs Monster and returns all type-specific config in one object. + * + * @param {object|undefined} oCharacter - Roll20 character object (may be undefined). + * @returns {{ gm: string, pc: string, isTypeOn: boolean, percentOn: number, + * showDead: boolean }} + */ + function resolveTypeConfig(oCharacter) { + const isPlayer = oCharacter && oCharacter.get('controlledby') !== ''; + if (isPlayer) { + return { + gm: state.HealthColors.GM_PCNames, + pc: state.HealthColors.PCNames, + isTypeOn: state.HealthColors.PCAura, + percentOn: state.HealthColors.auraPercPC, + showDead: state.HealthColors.auraDeadPC, + }; + } + return { + gm: state.HealthColors.GM_NPCNames, + pc: state.HealthColors.NPCNames, + isTypeOn: state.HealthColors.NPCAura, + percentOn: state.HealthColors.auraPerc, + showDead: state.HealthColors.auraDead, + }; + } + + /** + * Manages the dead-status marker and plays a death sound when a token reaches 0 HP. + * Extracted from applyAuraAndDead to reduce nesting depth. + * + * @param {object} obj - Roll20 token graphic object. + * @param {number} curValue - Current bar value. + * @param {number} prevValue - Previous bar value (may be a string). + */ + function applyDeadStatus(obj, curValue, prevValue) { + if (curValue > 0) { + obj.set('status_dead', false); + return; + } + const deadSfx = state.HealthColors.auraDeadFX; + if (deadSfx !== 'None' && curValue !== Number(prevValue)) + playDeath(deadSfx); + obj.set('status_dead', true); + } + + /** + * Applies or removes the health aura/tint and manages the dead-status marker. + * + * @param {object} obj - Roll20 token graphic object. + * @param {object|undefined} oCharacter - Roll20 character object. + * @param {object} typeConfig - Config returned by resolveTypeConfig. + * @param {object} health - Health data returned by getBarHealth. + */ + function applyAuraAndDead(obj, oCharacter, typeConfig, health) { + const { curValue, prevValue, percReal, markerColor } = health; + const { isTypeOn, percentOn, showDead } = typeConfig; + const useAura = oCharacter ? lookupUseColor(oCharacter) : undefined; + const useTint = state.HealthColors.auraTint; + const colorType = useTint ? 'tint' : 'aura1'; + + if (showDead) applyDeadStatus(obj, curValue, prevValue); + + if (isTypeOn && useAura !== 'NO') { + if (curValue <= 0) { + tokenSet(obj, state.HealthColors.AuraSize, markerColor); + } else if (percentOn <= 0) { + clearAuras(obj); + } else if (percReal > percentOn) { + clearAuras(obj); + } else { + tokenSet(obj, state.HealthColors.AuraSize, markerColor); + } + } else if (obj.get(`${colorType}_color`) === markerColor) { + clearAuras(obj); + } + } + + /** + * Builds the list of FX definition objects to spawn for a heal or hurt event. + * + * @param {boolean} isHeal - True when HP went up. + * @param {string|undefined} useBlood - Per-character blood FX override value. + * @param {string} [label] - Character/token name for error context. + * @returns {object[]} Array of Roll20 custfx definition objects. + */ + function buildFXList(isHeal, useBlood, label) { + const fxArray = []; + + if (isHeal) { + const aFX = findObjs( + { _type: 'custfx', name: '-DefaultHeal' }, + { caseInsensitive: true }, + )[0]; + const def = getFxDefinition(aFX); + + if (def) { + const healRgb = hexToRgb(state.HealthColors.HealFX); + def.startColour = healRgb; + def.startColor = healRgb; + def.endColour = healRgb; + def.endColor = healRgb; + def.startColourRandom = [0, 0, 0, 0]; + def.startColorRandom = [0, 0, 0, 0]; + def.endColourRandom = [0, 0, 0, 0]; + def.endColorRandom = [0, 0, 0, 0]; + fxArray.push(def); + } + + return fxArray; + } + + const aFX = findObjs( + { _type: 'custfx', name: '-DefaultHurt' }, + { caseInsensitive: true }, + )[0]; + const def = getFxDefinition(aFX); + + if (!def) return fxArray; + + if (useBlood === 'DEFAULT' || useBlood === undefined) { + const hurtRgb = hexToRgb(state.HealthColors.HurtFX); + def.startColour = hurtRgb; + def.startColor = hurtRgb; + def.endColour = hurtRgb; + def.endColor = hurtRgb; + def.startColourRandom = [0, 0, 0, 0]; + def.startColorRandom = [0, 0, 0, 0]; + def.endColourRandom = [0, 0, 0, 0]; + def.endColorRandom = [0, 0, 0, 0]; + fxArray.push(def); + } else { + const 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 { + const who = label ? ` (character: "${label}")` : ''; + log(`${SCRIPT_NAME}: Custom FX "${fxName.trim()}"${who} not found — check the USEBLOOD attribute.`); + gmWhisper(`Custom FX "${fxName.trim()}"${who} not found. Fix the USEBLOOD attribute on that character. Falling back to default hurt FX.`); + const fallbackFx = findObjs( + { _type: 'custfx', name: '-DefaultHurt' }, + { caseInsensitive: true }, + )[0]; + const fallbackDef = getFxDefinition(fallbackFx); + if (fallbackDef) fxArray.push(fallbackDef); + } + }); + } + } + + return fxArray; + } + + /** + * Spawns the default heal or hurt FX by their saved custfx ID using spawnFx. + * This avoids client-side color inconsistencies seen in some sandboxes when using + * spawnFxWithDefinition directly. Only handles DEFAULT heal/hurt colors; custom + * named FX (USEBLOOD set to a custfx name) still use the definition-spawn path. + * + * @param {object} obj - Roll20 token graphic object. + * @param {boolean} isHeal - True when HP increased. + * @param {string|undefined} useBlood - Per-character blood override value. + * @returns {boolean} True when spawning was handled; false if the caller should fall back. + */ + function spawnDefaultFxById(obj, isHeal, useBlood) { + if (!(useBlood === 'DEFAULT' || useBlood === undefined)) return false; + const fxName = isHeal ? '-DefaultHeal' : '-DefaultHurt'; + const aFX = findObjs( + { _type: 'custfx', name: fxName }, + { caseInsensitive: true }, + )[0]; + if (!aFX) return false; + + spawnFx(obj.get('left'), obj.get('top'), aFX.id, obj.get('pageid')); + return true; + } + + /** + * Gates and triggers particle FX when HP changes on a non-forced update. + * + * @param {object} obj - Roll20 token graphic object. + * @param {object|undefined} oCharacter - Roll20 character object. + * @param {number} curValue - Current bar value. + * @param {number|string} prevValue - Previous bar value. + * @param {number} maxValue - Maximum bar value. + * @param {string} [update] - Pass 'YES' to suppress FX on forced refreshes. + */ + function maybeSpawnFX( + obj, + oCharacter, + curValue, + prevValue, + maxValue, + update, + ) { + if (curValue === Number(prevValue) || prevValue === '' || update === 'YES') + return; + const useBlood = oCharacter ? lookupUseBlood(oCharacter) : undefined; + if (!state.HealthColors.FX || useBlood === 'OFF' || useBlood === 'NO') + return; + const isHeal = curValue > Number(prevValue); + const amount = Math.abs(curValue - Number(prevValue)); + const scale = obj.get('height') / 70; + const hitSize = + Math.max(Math.min((amount / maxValue) * 4, 1), 0.2) * + (randomInt(60, 100) / 100); + const fxLabel = (oCharacter && oCharacter.get('name')) || obj.get('name') || ''; + if (spawnDefaultFxById(obj, isHeal, useBlood)) return; + buildFXList(isHeal, useBlood, fxLabel).forEach((fx) => + spawnFX( + scale, + hitSize, + obj.get('left'), + obj.get('top'), + fx, + obj.get('pageid'), + ), + ); + } + + /** + * Core token handler — called on token change, token add, and forced updates. + * Delegates to specialized helpers for health reading, type resolution, + * aura management, and FX spawning. + * Clears aura/tint when the selected health bar has no max value. + * + * @param {object} obj - The Roll20 token graphic object. + * @param {object} prev - Snapshot of the token's previous attribute values. + * @param {string} [update] - Pass 'YES' to indicate a forced refresh (suppresses FX). + */ + function handleToken(obj, prev, update) { + if (state.HealthColors === undefined) { + log(`${SCRIPT_NAME} ${VERSION}: state missing, reverting to defaults`); + checkInstall(); + } + if ( + state.HealthColors.auraColorOn !== true || + obj.get('layer') !== 'objects' + ) + return; + if (obj.get('represents') === '' && state.HealthColors.OneOff !== true) + return; + const barUsed = state.HealthColors.auraBar; + if (obj.get(`${barUsed}_max`) === '') { + clearAuras(obj); + return; + } + + const health = getBarHealth(obj, prev, update); + if (!health) return; + + const { maxValue, curValue, prevValue } = health; + const sizeChanged = + prev.width !== obj.get('width') || prev.height !== obj.get('height'); + + // Only proceed if health changed, token was resized, or this is a forced update. + // The size check ensures aura is re-applied when a token is resized, even without an HP change. + if (curValue === Number(prevValue) && update !== 'YES' && !sizeChanged) + return; + + const oCharacter = getObj('character', obj.get('represents')); + const typeConfig = resolveTypeConfig(oCharacter); + + applyAuraAndDead(obj, oCharacter, typeConfig, health); + setShowNames(typeConfig.gm, typeConfig.pc, obj); + maybeSpawnFX(obj, oCharacter, curValue, prevValue, maxValue, update); + } + + // ————— FORCE UPDATE ————— + /** + * Forces a re-evaluation of every token on the objects layer, + * processing them one at a time via a setTimeout drain queue to avoid + * blocking the Roll20 sandbox event loop. + */ + function menuForceUpdate() { + const workQueue = findObjs({ + type: 'graphic', + subtype: 'token', + layer: 'objects', + }); + sendChat(SCRIPT_NAME, `/w gm Refreshing ${workQueue.length} Tokens`); + const drainQueue = () => { + const token = workQueue.shift(); + if (token) { + const prev = deepClone(token); + handleToken(token, prev, 'YES'); + setTimeout(drainQueue, 0); + } else { + sendChat(SCRIPT_NAME, '/w gm Finished Refreshing Tokens'); + } + }; + drainQueue(); + } + + /** + * Forces a health-color update on all currently selected tokens. + * Whispers the list of updated token names to the GM. + * + * @param {object} msg - Roll20 chat message object with a populated `selected` array. + */ + function manUpdate(msg) { + const allNames = msg.selected.reduce((acc, obj) => { + const token = getObj('graphic', obj._id); + const prev = deepClone(token); + handleToken(token, prev, 'YES'); + return `${acc}${token.get('name')}
`; + }, ''); + gmWhisper(allNames); + } + + // ————— MENU ————— + /** + * Builds a styled Roll20 chat button anchor element. + * + * @param {string} label - Button label text. + * @param {string} href - Roll20 API command (e.g. '!aura on'). + * @param {string} [extraStyle=''] - Additional inline CSS to append to the base style. + * @returns {string} An HTML anchor string ready for sendChat. + */ + function makeBtn(label, href, extraStyle = '') { + const base = [ + 'padding-top:1px', + 'text-align:center', + 'font-size:9pt', + 'width:48px', + 'height:14px', + 'border:1px solid black', + 'margin:1px', + 'background-color:#6FAEC7', + 'border-radius:4px', + 'box-shadow:1px 1px 1px #707070', + ].join(';'); + return `${label}`; + } + + /** + * Builds a non-interactive styled value pill for read-only output panels. + * + * @param {string} label - Display text. + * @param {string} [extraStyle=''] - Additional inline CSS to append to base style. + * @returns {string} A styled span element. + */ + function makePill(label, extraStyle = '') { + const base = [ + 'display:inline-block', + 'padding-top:1px', + 'text-align:center', + 'font-size:9pt', + 'min-width:48px', + 'height:14px', + 'border:1px solid black', + 'margin:1px', + 'background-color:#6FAEC7', + 'border-radius:4px', + 'box-shadow:1px 1px 1px #707070', + 'line-height:14px', + 'padding-left:4px', + 'padding-right:4px', + ].join(';'); + return `${label}`; + } + + /** + * Builds a toggle-style button that shows red when the value is false/off. + * + * @param {boolean} value - Current boolean state (true = on/green, false = off/red). + * @param {string} href - Roll20 API command to execute on click. + * @returns {string} An HTML anchor string. + */ + function toggleBtn(value, href) { + const style = value === true ? '' : 'background-color:#A84D4D'; + return makeBtn(value === true ? 'Yes' : 'No', href, style); + } + + /** + * Builds a three-state name-setting button. Red for 'No', grey for 'Off', default for 'Yes'. + * + * @param {string} value - Current value: 'Yes', 'No', or 'Off'. + * @param {string} href - Roll20 API command to execute on click. + * @returns {string} An HTML anchor string. + */ + function nameBtn(value, href) { + let style = ''; + if (value === 'No') style = 'background-color:#A84D4D'; + if (value === 'Off') style = 'background-color:#D6D6D6'; + return makeBtn(value, href, style); + } + + /** + * Read-only pill counterpart to toggleBtn: green background for true, red for false. + * @param {boolean} value - Current boolean state. + * @returns {string} A styled span element. + */ + function boolPill(value) { + return makePill(value ? 'Yes' : 'No', value ? '' : 'background-color:#A84D4D'); + } + + /** + * Read-only pill counterpart to nameBtn: red for 'No', grey for 'Off', default for 'Yes'. + * + * @param {string} value - Current value: 'Yes', 'No', or 'Off'. + * @returns {string} A styled span element. + */ + function namePill(value) { + let style = ''; + if (value === 'No') style = 'background-color:#A84D4D'; + if (value === 'Off') style = 'background-color:#D6D6D6'; + return makePill(value, style); + } + + /** + * Renders and whispers the HealthColors configuration menu to the GM. + * Builds the full HTML panel using makeBtn/toggleBtn/nameBtn helpers and + * reflects all current state values as interactive button labels. + */ + function showMenu() { + const s = state.HealthColors; + const hr = `
`; + const wrapStyle = [ + 'border-radius:8px', + 'padding:5px', + 'font-size:9pt', + 'text-shadow:-1px -1px #222,1px -1px #222,-1px 1px #222,1px 1px #222,2px 2px #222', + 'box-shadow:3px 3px 1px #707070', + 'background-image:-webkit-linear-gradient(left,#76ADD6 0%,#a7c7dc 100%)', + 'color:#FFF', + 'border:2px solid black', + 'text-align:right', + 'vertical-align:middle', + ].join(';'); + + const percLabel = `${s.auraPercPC}/${s.auraPerc}`; + const healBtnStyle = `background-color:#${s.HealFX}`; + const hurtBtnStyle = `background-color:#${s.HurtFX}`; + const aura1Style = `background-color:#${s.Aura1Color}`; + const aura2Style = `background-color:#${s.Aura2Color}`; + const deadFxCmd = `!aura deadfx ?{Sound Name?|${s.auraDeadFX}}`; + const html = [ + `
`, + `HealthColors Version: ${VERSION}
`, + hr, + `Is On: ${toggleBtn(s.auraColorOn, '!aura on')}
`, + `Health Bar: ${makeBtn(s.auraBar, '!aura bar ?{Bar|1|2|3}')}
`, + `Use Tint: ${toggleBtn(s.auraTint, '!aura tint')}
`, + `Palette: ${makeBtn(s.colorPalette, '!aura palette ?{Palette|default|colorblind}', 'width:80px')} (auto refreshes all tokens)
`, + `Percentage(PC/NPC): ${makeBtn(percLabel, '!aura perc ?{PCPercent?|100} ?{NPCPercent?|100}')}
`, + hr, + `Show PC Health: ${toggleBtn(s.PCAura, '!aura pc')}
`, + `Show NPC Health: ${toggleBtn(s.NPCAura, '!aura npc')}
`, + `Show Dead PC: ${toggleBtn(s.auraDeadPC, '!aura deadPC')}
`, + `Show Dead NPC: ${toggleBtn(s.auraDead, '!aura dead')}
`, + hr, + `GM Sees all PC Names: ${nameBtn(s.GM_PCNames, '!aura gmpc ?{Setting|Yes|No|Off}')}
`, + `GM Sees all NPC Names: ${nameBtn(s.GM_NPCNames, '!aura gmnpc ?{Setting|Yes|No|Off}')}
`, + hr, + `PC Sees all PC Names: ${nameBtn(s.PCNames, '!aura pcpc ?{Setting|Yes|No|Off}')}
`, + `PC Sees all NPC Names: ${nameBtn(s.NPCNames, '!aura pcnpc ?{Setting|Yes|No|Off}')}
`, + hr, + `Aura 1 Radius (ft): ${makeBtn(s.AuraSize, '!aura size ?{Size?|0.35}')}
`, + `Aura 1 Shape: ${makeBtn(s.Aura1Shape, '!aura a1shape ?{Shape?|Circle|Square}')}
`, + `Aura 1 Color: ${makeBtn(s.Aura1Color, '!aura a1tint ?{Color?|00FF00}', aura1Style)}
`, + `Aura 2 Radius (ft): ${makeBtn(String(s.Aura2Size), '!aura a2size ?{Size?|5}')}
`, + `Aura 2 Shape: ${makeBtn(s.Aura2Shape, '!aura a2shape ?{Shape?|Square|Circle}')}
`, + `Aura 2 Color: ${makeBtn(s.Aura2Color, '!aura a2tint ?{Color?|806600}', aura2Style)}
`, + `One Offs: ${toggleBtn(s.OneOff, '!aura ONEOFF')}
`, + `FX: ${toggleBtn(s.FX, '!aura FX')}
`, + `HealFX Color: ${makeBtn(s.HealFX, '!aura HEAL ?{Color?|FDDC5C}', healBtnStyle)}
`, + `HurtFX Color: ${makeBtn(s.HurtFX, '!aura HURT ?{Color?|FF0000}', hurtBtnStyle)}
`, + `DeathSFX: ${makeBtn(s.auraDeadFX.substring(0, 4), deadFxCmd)}
`, + hr, + `
`, + ].join(''); + + sendChat(SCRIPT_NAME, `/w GM
${html}`); + } + + /** + * Renders a read-only settings snapshot to public game chat (all players). + * Triggered by `!aura settings` on demand; not called automatically after changes. + */ + function showSettingsInGameChat() { + const s = state.HealthColors; + const hr = `
`; + const wrapStyle = [ + 'border-radius:8px', + 'padding:5px', + 'font-size:9pt', + 'text-shadow:-1px -1px #222,1px -1px #222,-1px 1px #222,1px 1px #222,2px 2px #222', + 'box-shadow:3px 3px 1px #707070', + 'background-image:-webkit-linear-gradient(left,#76ADD6 0%,#a7c7dc 100%)', + 'color:#FFF', + 'border:2px solid black', + 'text-align:right', + 'vertical-align:middle', + ].join(';'); + + const percLabel = `${s.auraPercPC}/${s.auraPerc}`; + const aura1Style = `background-color:#${s.Aura1Color}`; + const aura2Style = `background-color:#${s.Aura2Color}`; + const healStyle = `background-color:#${s.HealFX}`; + const hurtStyle = `background-color:#${s.HurtFX}`; + const html = [ + `
`, + `HealthColors Settings: ${VERSION}
`, + hr, + `Is On: ${boolPill(s.auraColorOn)}
`, + `Bar: ${makePill(s.auraBar)}
`, + `Use Tint: ${boolPill(s.auraTint)}
`, + `Palette: ${makePill(s.colorPalette)}
`, + `Percentage(PC/NPC): ${makePill(percLabel)}
`, + hr, + `Show PC Health: ${boolPill(s.PCAura)}
`, + `Show NPC Health: ${boolPill(s.NPCAura)}
`, + `Show Dead PC: ${boolPill(s.auraDeadPC)}
`, + `Show Dead NPC: ${boolPill(s.auraDead)}
`, + hr, + `GM Sees all PC Names: ${namePill(s.GM_PCNames)}
`, + `GM Sees all NPC Names: ${namePill(s.GM_NPCNames)}
`, + hr, + `PC Sees all PC Names: ${namePill(s.PCNames)}
`, + `PC Sees all NPC Names: ${namePill(s.NPCNames)}
`, + hr, + `Aura 1 Radius: ${makePill(String(s.AuraSize))}
`, + `Aura 1 Shape: ${makePill(s.Aura1Shape)}
`, + `Aura 1 Color: ${makePill(s.Aura1Color, aura1Style)}
`, + `Aura 2 Radius: ${makePill(String(s.Aura2Size))}
`, + `Aura 2 Shape: ${makePill(s.Aura2Shape)}
`, + `Aura 2 Color: ${makePill(s.Aura2Color, aura2Style)}
`, + `One Offs: ${boolPill(s.OneOff)}
`, + `FX: ${boolPill(s.FX)}
`, + `HealFX Color: ${makePill(s.HealFX, healStyle)}
`, + `HurtFX Color: ${makePill(s.HurtFX, hurtStyle)}
`, + `DeathSFX: ${makePill(s.auraDeadFX)}
`, + hr, + `
`, + ].join(''); + + sendChat(SCRIPT_NAME, `
${html}`); + } + + // ————— CHAT HANDLER ————— + /** + * Processes incoming Roll20 chat messages to handle !aura commands. + * GM-only: non-GMs receive an access-denied whisper. + * Routes each subcommand (ON/OFF, BAR, TINT, PERC, PC, NPC, etc.) to the + * appropriate state mutation then refreshes the menu. BAR validates 1/2/3, + * whispers confirmation, and triggers immediate full sync. PALETTE also + * triggers immediate full sync so existing tokens update right away. + * When a setting changes, re-whispers the interactive menu to the GM. + * Use `!aura settings` to post a read-only settings snapshot to public game chat. + * + * @param {object} msg - Roll20 chat message object. + */ + function handleInput(msg) { + const parts = msg.content.split(/\s+/); + const command = parts[0].toUpperCase(); + if (msg.type !== 'api' || !command.includes('!AURA')) return; + + if (!playerIsGM(msg.playerid)) { + sendChat( + SCRIPT_NAME, + `/w ${msg.who} you must be a GM to use this command!`, + ); + return; + } + + const option = (parts[1] || 'MENU').toUpperCase(); + if (option !== 'MENU') gmWhisper('UPDATING TOKENS...'); + + const s = state.HealthColors; + + // Dispatch tables for structurally identical cases + const TOGGLES = { + TINT: 'auraTint', PC: 'PCAura', NPC: 'NPCAura', + DEAD: 'auraDead', DEADPC: 'auraDeadPC', ONEOFF: 'OneOff', FX: 'FX', + }; + const STRINGS = { + GMNPC: 'GM_NPCNames', GMPC: 'GM_PCNames', + PCNPC: 'NPCNames', PCPC: 'PCNames', DEADFX: 'auraDeadFX', + }; + const FLOATS = { SIZE: 'AuraSize', A2SIZE: 'Aura2Size' }; + const SHAPES = { A1SHAPE: 'Aura1Shape', A2SHAPE: 'Aura2Shape' }; + const HEXES = { A1TINT: 'Aura1Color', A2TINT: 'Aura2Color' }; + + if (TOGGLES[option]) { + s[TOGGLES[option]] = !s[TOGGLES[option]]; + } else if (STRINGS[option]) { + s[STRINGS[option]] = parts[2]; + } else if (FLOATS[option]) { + s[FLOATS[option]] = Number.parseFloat(parts[2]); + } else if (SHAPES[option]) { + s[SHAPES[option]] = normalizeShape(parts[2], s[SHAPES[option]]); + } else if (HEXES[option]) { + s[HEXES[option]] = normalizeHex6(parts[2], s[HEXES[option]]); + } else { + switch (option) { + case 'MENU': + break; + case 'SETTINGS': + showSettingsInGameChat(); + return; + case 'ON': + s.auraColorOn = true; + break; + case 'OFF': + s.auraColorOn = false; + break; + case 'BAR': + if (/^[123]$/.test(parts[2] || '')) { + s.auraBar = `bar${parts[2]}`; + gmWhisper(`Health bar set to ${s.auraBar}. Forcing sync...`); + menuForceUpdate(); + } else { + gmWhisper( + `Invalid bar "${parts[2] || ''}". Use !aura bar 1, !aura bar 2, or !aura bar 3.`, + ); + } + break; + case 'PERC': + s.auraPercPC = Number.parseInt(parts[2], 10); + s.auraPerc = Number.parseInt(parts[3], 10); + menuForceUpdate(); + break; + case 'PALETTE': + s.colorPalette = normalizePalette(parts[2], s.colorPalette); + menuForceUpdate(); + break; + case 'HEAL': + s.HealFX = parts[2].toUpperCase(); + syncDefaultFxObjects(); + break; + case 'HURT': + s.HurtFX = parts[2].toUpperCase(); + syncDefaultFxObjects(); + break; + case 'RESET': + delete state.HealthColors; + gmWhisper('STATE RESET'); + checkInstall(); + break; + case 'RESET-FX': + resetDefaultFxObjects(); + break; + case 'RESET-ALL': + runResetAllFlow(); + break; + case 'FORCEALL': + menuForceUpdate(); + return; + case 'UPDATE': + manUpdate(msg); + return; + } + } + + showMenu(); + } + + // ————— OUTSIDE API ————— + /** + * Public entry point for external scripts to request a token color update. + * Validates that the object is a graphic before delegating to handleToken. + * + * @param {object} obj - Roll20 object to update. + * @param {object} prev - Previous attribute snapshot (passed through to handleToken). + */ + function updateToken(obj, prev) { + if (obj.get('type') === 'graphic') { + handleToken(obj, prev); + } else { + gmWhisper('Script sent non-Token to be updated!'); + } + } + + // ————— EVENT HANDLERS ————— + /** + * Registers all Roll20 event listeners for the script. + * - chat:message → handleInput (command processing) + * - change:graphic → handleToken (live HP changes and token resizes) + * - add:token → handleToken (with 400ms delay to allow token data to settle) + */ + function registerEventHandlers() { + on('chat:message', handleInput); + on('change:graphic', handleToken); + on('add:token', (t) => { + setTimeout(() => { + const token = getObj('graphic', t.id); + const prev = deepClone(token); + handleToken(token, prev, 'YES'); + }, 400); + }); + } + + // ————— BOOTSTRAP ————— + globalThis.HealthColors = { + gmWhisper, + update: updateToken, + checkInstall, + registerEventHandlers, + }; + + on('ready', () => { + gmWhisper(`MOD READY (v${VERSION})`); + checkInstall(); + registerEventHandlers(); + }); +})(); diff --git a/HealthColors/CHANGELOG.md b/HealthColors/CHANGELOG.md index 441b4442d..8d15a54f6 100644 --- a/HealthColors/CHANGELOG.md +++ b/HealthColors/CHANGELOG.md @@ -5,6 +5,27 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [2.1.1] – 2026-05-07 · [Milestone](https://github.com/steverobertsuk/roll20-api-scripts/milestone/1) + +### Added +- Added version 1.7.1 (sourced from the Roll20 forums, never previously submitted to the GitHub repository) so it appears as a selectable previous version in the Roll20 Mod Script Console. + +### Fixed +- Fixed aura visibility threshold (`Percentage PC/NPC`): tokens at or above the configured HP threshold were incorrectly shown a green default aura instead of having the aura hidden. Changed comparison from `>=` to `>` and replaced `applyDefaultAura()` with `clearAuras()` in the above-threshold branch so the aura correctly disappears when a token's HP is not below the threshold. Also added a guard so a threshold of `0` clears the aura rather than treating it as "always show". ([#1](https://github.com/steverobertsuk/roll20-api-scripts/issues/1)) +- Fixed `!aura` settings menu after a change: clicking a button and submitting the dialog caused a non-interactive read-only panel (using `` pills) to appear in public chat instead of re-displaying the interactive GM-whispered menu. Root cause was `handleInput` calling `showSettingsInGameChat()` on every setting change instead of `showMenu()`. The interactive GM menu is now always shown after a change; the read-only public snapshot remains available via the explicit `!aura settings` command. ([#3](https://github.com/steverobertsuk/roll20-api-scripts/issues/3), [#4](https://github.com/steverobertsuk/roll20-api-scripts/issues/4)) +- Fixed `!aura perc` not refreshing tokens already on the map: changing the HP threshold now immediately re-evaluates all existing tokens via `menuForceUpdate()`, so tokens that were visible under the old threshold are correctly cleared (or revealed) without requiring a token move. +- Fixed "No FX with name" GM whisper appearing repeatedly when a character's `USEBLOOD` attribute is set to a custom custfx name that no longer exists in the campaign (e.g. after a campaign reset or character import). The error message now identifies the character by name so the GM knows which `USEBLOOD` attribute to correct. The script also falls back to `-DefaultHurt` so a visual effect still plays instead of silently doing nothing. ([#2](https://github.com/steverobertsuk/roll20-api-scripts/issues/2)) + +### Changed +- Removed orphaned `applyDefaultAura` function — it was superseded by `clearAuras()` in the threshold fix but never deleted. +- Removed unused `changedSetting` variable from `handleInput` — it was assigned in ~20 places but never read, as `showMenu()` was always called unconditionally at the end of the function. +- Token refresh progress messages updated: sender changed from a hardcoded string to `SCRIPT_NAME`; text changed from `"Fixing N Tokens"` / `"Finished Fixing Tokens"` to `"Refreshing N Tokens"` / `"Finished Refreshing Tokens"`. +- `handleInput` command dispatch refactored — 27 switch cases reduced to 5 dispatch-table lookups (`TOGGLES`, `STRINGS`, `FLOATS`, `SHAPES`, `HEXES`) plus a 13-case switch for commands with unique behaviour, removing ~100 lines of repetitive boilerplate. +- `showSettingsInGameChat` refactored to reduce cognitive complexity: extracted `boolPill` and `namePill` module-level helpers (read-only counterparts to the existing `toggleBtn`/`nameBtn`), replacing 8 repeated inline ternary expressions and an inline `pickNameStyle` closure. +- JSDoc corrections: removed stale `@param [update]` from `applyAuraAndDead` (parameter was removed in an earlier refactor); corrected `spawnDefaultFxById` description which falsely claimed to tighten particle profile settings; corrected `boolPill` description (background colour, not text colour); corrected `registerEventHandlers` listing `change:token` instead of `change:graphic`; documented the missing British/American colour-key variants on `FX_PARAM_DEFAULTS`; removed stale `pColor` return-type entry from `resolveTypeConfig`. + +--- + ## [2.1.0] – 2026-05-01 ### Added @@ -44,9 +65,8 @@ Major modernization and stability refactor of the entire script, consolidating p - **JSDoc Documentation** — every function is now fully documented with parameter types, return values, and behavioral descriptions to improve maintainability. - **Centralized Configuration** — all default state values, FX definitions, and internal parameters are now managed via unified constants. - **FX Recovery Commands** — added `!aura reset-fx` (rebuild default heal/hurt custom FX objects) and `!aura reset-all` (restore all settings to defaults + rebuild default FX + force update). -- **Public Settings Snapshot** — setting-changing `!aura` commands now post a read-only settings panel to game chat for table visibility. +- **Public Settings Snapshot** — added `!aura settings` to post a read-only settings panel to game chat on demand. - **Aura Detail Commands** — added `!aura a1shape`, `!aura a1tint`, `!aura a2size`, `!aura a2shape`, and `!aura a2tint` to adjust displayed Aura 1/Aura 2 detail values from chat. -- **Settings Output Command** — added `!aura settings` to post the current settings snapshot to game chat on demand. ### Changed - **`_` (Underscore) dependency** — no longer required. @@ -68,11 +88,22 @@ Major modernization and stability refactor of the entire script, consolidating p - `!aura bar` now validates `1|2|3`, whispers confirmation on change, and immediately runs a full sync to apply the new bar selection. - Tokens with no `max` value on the configured bar now have aura/tint cleared, preventing stale health indicators. - Configuration output now explicitly includes Aura 2 detail rows in both the GM menu and public settings snapshot. -- Prevented duplicate settings output: setting-changing commands now publish a single game-chat snapshot instead of both GM and public panels. +- Prevented duplicate settings output: setting-changing commands re-display the GM menu instead of posting to both GM and public chat. - Aura 2 output values are now sourced from state-backed defaults (`Aura2Size`, `Aura2Shape`, `Aura2Color`) instead of hardcoded labels. - Added Aura 1 Shape/Tint rows to settings output and backed them with default state values (`Aura1Shape`, `Aura1Color`). - Default heal/hurt custom FX definitions are now synchronized proactively on install/reset and when FX colors change, preventing delayed/stale visual updates after color edits or token lifecycle events. +--- + +## [1.7.1] – 2025-02-16 + +> **Note:** This version was sourced from a [Roll20 forum post](https://app.roll20.net/forum/permalink/12236299/) by [Surok](https://app.roll20.net/users/335573). It was never submitted to the Roll20 GitHub API scripts repository. + +### Added +- Added `LISTFX` command/button that displays a graphical menu listing custom FX objects with their IDs. + +### Changed +- Updated FX effects: damage/healing FX now use the simpler DeathTracker approach — damage uses the `HurtFX` type (default `splatter-blood`) and healing uses the `HealFX` type (default `glow-holy`). --- diff --git a/HealthColors/HealthColors.js b/HealthColors/HealthColors.js index 34afc8292..364a43ced 100644 --- a/HealthColors/HealthColors.js +++ b/HealthColors/HealthColors.js @@ -1,5 +1,5 @@ // =========================== -// === HealthColors v2.1.0 === +// === HealthColors v2.1.1 === // =========================== // AUTHORS: @@ -8,14 +8,14 @@ /* global createObj TokenMod spawnFxWithDefinition spawnFx getObj state playerIsGM sendChat findObjs log on */ -(() => { - "use strict"; +const HealthColors = (() => { + '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"; + const VERSION = '2.1.1'; + const SCRIPT_NAME = 'HealthColors'; + const SCHEMA_VERSION = '1.1.0'; + const UPDATED = '2026-05-07 16:00 UTC'; // ————— DEFAULTS ————— /** @@ -51,7 +51,7 @@ */ const DEFAULTS = { auraColorOn: true, - auraBar: "bar1", + auraBar: 'bar1', auraTint: false, auraPercPC: 100, auraPerc: 100, @@ -59,22 +59,22 @@ NPCAura: true, auraDeadPC: true, auraDead: true, - GM_PCNames: "Yes", - PCNames: "Yes", - GM_NPCNames: "Yes", - NPCNames: "Yes", + GM_PCNames: 'Yes', + PCNames: 'Yes', + GM_NPCNames: 'Yes', + NPCNames: 'Yes', AuraSize: 0.35, - Aura1Shape: "Circle", - Aura1Color: "00FF00", + Aura1Shape: 'Circle', + Aura1Color: '00FF00', Aura2Size: 5, - Aura2Shape: "Square", - Aura2Color: "806600", + Aura2Shape: 'Square', + Aura2Color: '806600', OneOff: false, FX: true, - HealFX: "FDDC5C", - HurtFX: "FF0000", - auraDeadFX: "None", - colorPalette: "default", + HealFX: 'FDDC5C', + HurtFX: 'FF0000', + auraDeadFX: 'None', + colorPalette: 'default', }; const COLOR_PALETTES = { @@ -187,8 +187,14 @@ * @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 {number[]} startColour - Fallback RGBA start colour (British spelling; opaque grey). + * @property {number[]} startColor - Fallback RGBA start color (American spelling; same value). + * @property {number[]} endColour - Fallback RGBA end colour (British spelling; opaque black). + * @property {number[]} endColor - Fallback RGBA end color (American spelling; same value). + * @property {number[]} startColourRandom - Fallback start colour randomization (zeroed). + * @property {number[]} startColorRandom - Fallback start color randomization (zeroed). + * @property {number[]} endColourRandom - Fallback end colour randomization (zeroed). + * @property {number[]} endColorRandom - Fallback end color randomization (zeroed). * @property {{x:number,y:number}} gravity - Fallback gravity (none). */ const FX_PARAM_DEFAULTS = { @@ -218,13 +224,14 @@ /** * 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"; + 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) => @@ -248,11 +255,12 @@ /** * 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 cleanHex = (hex || '').replace('#', '').trim(); const parts = /^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec( cleanHex, ); @@ -269,68 +277,86 @@ /** * 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; + return Math.floor(Math.random() * (max - min + 1)) + min; // NOSONAR — cosmetic FX variance, not security-sensitive + } + + /** + * Creates a plain-object snapshot of a Roll20 API object or any serialisable value. + * Uses JSON round-trip rather than structuredClone so that Roll20 proxy objects have + * their toJSON() method called, producing a plain object whose properties are + * accessible directly (e.g. prev.bar1_value) rather than through .get(). + * + * @param {object} obj - Roll20 API object or plain object to snapshot. + * @returns {object} Plain object deep copy. + */ + function deepClone(obj) { + return JSON.parse(JSON.stringify(obj)); // NOSONAR — intentional: triggers Roll20 proxy toJSON() } /** * Normalizes a 6-digit hex color string (without '#'). * Returns fallback when input is invalid. + * * @param {string} value - Candidate hex string. * @param {string} fallback - Fallback value when invalid. * @returns {string} Uppercase 6-digit hex. */ function normalizeHex6(value, fallback) { - const cleaned = (value || "").replace("#", "").trim().toUpperCase(); + 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"; + 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(); + 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(";"); + '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}
`); } @@ -339,6 +365,7 @@ * 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. @@ -347,81 +374,62 @@ */ function makeSmartAttrCache(attribute, options = {}) { const cache = {}; - const defaultValue = options.default || "YES"; + 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")) + 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"); + const prev = deepClone(obj); + handleToken(obj, prev, 'YES'); }); }); - on("destroy:attribute", (attr) => { - if (attr.get("name") === attribute) delete cache[attr.get("characterid")]; + 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 }, + { type: 'attribute', name: attribute, characterid: character.id }, { caseInsensitive: true }, )[0] || - createObj("attribute", { + 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"); + 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", + 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", + tint_color: 'transparent', + aura1_color: 'transparent', aura1_radius: 0, }); } @@ -430,6 +438,7 @@ * 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. @@ -440,7 +449,7 @@ obj.set({ tint_color: markerColor }); } else { obj.set({ - tint_color: "transparent", + tint_color: 'transparent', aura1_radius: sizeSet, aura1_color: markerColor, showplayers_aura1: true, @@ -451,26 +460,28 @@ /** * 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" }); + 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]; + trackname.indexOf(',') > 0 ? trackname.split(',') : [trackname]; + const resolvedName = list[Math.floor(Math.random() * list.length)]; // NOSONAR — random track selection, not security-sensitive + const track = findObjs({ type: 'jukeboxtrack', title: resolvedName })[0]; if (track) { track.set({ playing: false, softstop: false, volume: 50 }); track.set({ playing: true }); @@ -482,6 +493,7 @@ /** * 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. @@ -505,23 +517,23 @@ return undefined; }; const startKeys = [ - "startColour", - "startColor", - "startcolour", - "startcolor", + 'startColour', + 'startColor', + 'startcolour', + 'startcolor', ]; - const endKeys = ["endColour", "endColor", "endcolour", "endcolor"]; + const endKeys = ['endColour', 'endColor', 'endcolour', 'endcolor']; const startRndKeys = [ - "startColourRandom", - "startColorRandom", - "startcolourrandom", - "startcolorrandom", + 'startColourRandom', + 'startColorRandom', + 'startcolourrandom', + 'startcolorrandom', ]; const endRndKeys = [ - "endColourRandom", - "endColorRandom", - "endcolourrandom", - "endcolorrandom", + 'endColourRandom', + 'endColorRandom', + 'endcolourrandom', + 'endcolorrandom', ]; const startClr = pick(fx, startKeys) ?? pick(m, startKeys); const endClr = pick(fx, endKeys) ?? pick(m, endKeys); @@ -560,16 +572,17 @@ /** * 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"); + const raw = fxObj.get('definition'); if (!raw) return null; - if (typeof raw === "string") { + if (typeof raw === 'string') { try { return JSON.parse(raw); } catch (err) { @@ -578,8 +591,8 @@ } } - if (typeof raw === "object") { - return JSON.parse(JSON.stringify(raw)); + if (typeof raw === 'object') { + return deepClone(raw); } return null; @@ -606,28 +619,28 @@ state.HealthColors.colorPalette, DEFAULTS.colorPalette, ); - if (typeof TokenMod !== "undefined" && TokenMod.ObserveTokenChange) { + if (typeof TokenMod !== 'undefined' && TokenMod.ObserveTokenChange) { TokenMod.ObserveTokenChange(handleToken); } const fxHurt = findObjs( - { _type: "custfx", name: "-DefaultHurt" }, + { _type: 'custfx', name: '-DefaultHurt' }, { caseInsensitive: true }, )[0]; const fxHeal = findObjs( - { _type: "custfx", name: "-DefaultHeal" }, + { _type: 'custfx', name: '-DefaultHeal' }, { caseInsensitive: true }, )[0]; if (!fxHurt) { - gmWhisper("Creating Default Hurt FX"); - createObj("custfx", { - name: "-DefaultHurt", + gmWhisper('Creating Default Hurt FX'); + createObj('custfx', { + name: '-DefaultHurt', definition: DEFAULT_HURT_FX, }); } if (!fxHeal) { - gmWhisper("Creating Default Heal FX"); - createObj("custfx", { - name: "-DefaultHeal", + gmWhisper('Creating Default Heal FX'); + createObj('custfx', { + name: '-DefaultHeal', definition: DEFAULT_HEAL_FX, }); } @@ -637,6 +650,7 @@ /** * 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. @@ -685,11 +699,11 @@ */ function syncDefaultFxObjects() { const fxHurt = findObjs( - { _type: "custfx", name: "-DefaultHurt" }, + { _type: 'custfx', name: '-DefaultHurt' }, { caseInsensitive: true }, )[0]; const fxHeal = findObjs( - { _type: "custfx", name: "-DefaultHeal" }, + { _type: 'custfx', name: '-DefaultHeal' }, { caseInsensitive: true }, )[0]; if (fxHeal) { @@ -708,11 +722,11 @@ */ function resetDefaultFxObjects() { const existing = findObjs( - { _type: "custfx" }, + { _type: 'custfx' }, { caseInsensitive: true }, - ).filter((fx) => /-Default(Hurt|Heal)/i.test(fx.get("name") || "")); + ).filter((fx) => /-Default(Hurt|Heal)/i.test(fx.get('name') || '')); existing.forEach((fx) => fx.remove()); - gmWhisper("Recreating Default Hurt/Heal FX"); + gmWhisper('Recreating Default Hurt/Heal FX'); checkInstall(); } @@ -733,7 +747,7 @@ */ function runResetAllFlow() { resetAllSettingsToDefaults(); - gmWhisper("RESET ALL: defaults restored + default FX + force update"); + gmWhisper('RESET ALL: defaults restored + default FX + force update'); resetDefaultFxObjects(); menuForceUpdate(); } @@ -743,6 +757,7 @@ * 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. @@ -751,13 +766,13 @@ */ function getBarHealth(obj, prev, update) { const barUsed = state.HealthColors.auraBar; - if (obj.get(`${barUsed}_max`) === "" && obj.get(`${barUsed}_value`) === "") + 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))) + if (update !== 'YES' && Number.isNaN(Number.parseInt(prevValue, 10))) return null; const percReal = Math.max( 0, @@ -769,12 +784,13 @@ /** * 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 }} + * showDead: boolean }} */ function resolveTypeConfig(oCharacter) { - const isPlayer = oCharacter && oCharacter.get("controlledby") !== ""; + const isPlayer = oCharacter && oCharacter.get('controlledby') !== ''; if (isPlayer) { return { gm: state.HealthColors.GM_PCNames, @@ -796,43 +812,46 @@ /** * 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); + obj.set('status_dead', false); return; } const deadSfx = state.HealthColors.auraDeadFX; - if (deadSfx !== "None" && curValue !== Number(prevValue)) + if (deadSfx !== 'None' && curValue !== Number(prevValue)) playDeath(deadSfx); - obj.set("status_dead", true); + 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"; + const colorType = useTint ? 'tint' : 'aura1'; if (showDead) applyDeadStatus(obj, curValue, prevValue); - if (isTypeOn && useAura !== "NO") { - if (curValue === 0) { + if (isTypeOn && useAura !== 'NO') { + if (curValue <= 0) { tokenSet(obj, state.HealthColors.AuraSize, markerColor); - } else if (percReal >= percentOn) { - applyDefaultAura(obj); + } else if (percentOn <= 0) { + clearAuras(obj); + } else if (percReal > percentOn) { + clearAuras(obj); } else { tokenSet(obj, state.HealthColors.AuraSize, markerColor); } @@ -843,16 +862,18 @@ /** * Builds the list of FX definition objects to spawn for a heal or hurt event. + * * @param {boolean} isHeal - True when HP went up. * @param {string|undefined} useBlood - Per-character blood FX override value. + * @param {string} [label] - Character/token name for error context. * @returns {object[]} Array of Roll20 custfx definition objects. */ - function buildFXList(isHeal, useBlood) { + function buildFXList(isHeal, useBlood, label) { const fxArray = []; if (isHeal) { const aFX = findObjs( - { _type: "custfx", name: "-DefaultHeal" }, + { _type: 'custfx', name: '-DefaultHeal' }, { caseInsensitive: true }, )[0]; const def = getFxDefinition(aFX); @@ -874,14 +895,14 @@ } const aFX = findObjs( - { _type: "custfx", name: "-DefaultHurt" }, + { _type: 'custfx', name: '-DefaultHurt' }, { caseInsensitive: true }, )[0]; const def = getFxDefinition(aFX); if (!def) return fxArray; - if (useBlood === "DEFAULT" || useBlood === undefined) { + if (useBlood === 'DEFAULT' || useBlood === undefined) { const hurtRgb = hexToRgb(state.HealthColors.HurtFX); def.startColour = hurtRgb; def.startColor = hurtRgb; @@ -906,9 +927,9 @@ def.endColorRandom = [0, 0, 0, 0]; fxArray.push(def); } else { - useBlood.split(",").forEach((fxName) => { + useBlood.split(',').forEach((fxName) => { const custom = findObjs( - { _type: "custfx", name: fxName.trim() }, + { _type: 'custfx', name: fxName.trim() }, { caseInsensitive: true }, )[0]; const customDef = getFxDefinition(custom); @@ -916,7 +937,15 @@ if (customDef) { fxArray.push(customDef); } else { - gmWhisper(`No FX with name ${fxName}`); + const who = label ? ` (character: "${label}")` : ''; + log(`${SCRIPT_NAME}: Custom FX "${fxName.trim()}"${who} not found — check the USEBLOOD attribute.`); + gmWhisper(`Custom FX "${fxName.trim()}"${who} not found. Fix the USEBLOOD attribute on that character. Falling back to default hurt FX.`); + const fallbackFx = findObjs( + { _type: 'custfx', name: '-DefaultHurt' }, + { caseInsensitive: true }, + )[0]; + const fallbackDef = getFxDefinition(fallbackFx); + if (fallbackDef) fxArray.push(fallbackDef); } }); } @@ -926,30 +955,32 @@ } /** - * 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. + * Spawns the default heal or hurt FX by their saved custfx ID using spawnFx. + * This avoids client-side color inconsistencies seen in some sandboxes when using + * spawnFxWithDefinition directly. Only handles DEFAULT heal/hurt colors; custom + * named FX (USEBLOOD set to a custfx name) still use the definition-spawn path. + * + * @param {object} obj - Roll20 token graphic object. + * @param {boolean} isHeal - True when HP increased. + * @param {string|undefined} useBlood - Per-character blood override value. + * @returns {boolean} True when spawning was handled; false if the caller should fall back. */ function spawnDefaultFxById(obj, isHeal, useBlood) { - if (!(useBlood === "DEFAULT" || useBlood === undefined)) return false; - const fxName = isHeal ? "-DefaultHeal" : "-DefaultHurt"; + if (!(useBlood === 'DEFAULT' || useBlood === undefined)) return false; + const fxName = isHeal ? '-DefaultHeal' : '-DefaultHurt'; const aFX = findObjs( - { _type: "custfx", name: fxName }, + { _type: 'custfx', name: fxName }, { caseInsensitive: true }, )[0]; if (!aFX) return false; - spawnFx(obj.get("left"), obj.get("top"), aFX.id, obj.get("pageid")); + 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. @@ -965,26 +996,27 @@ maxValue, update, ) { - if (curValue === Number(prevValue) || prevValue === "" || update === "YES") + if (curValue === Number(prevValue) || prevValue === '' || update === 'YES') return; const useBlood = oCharacter ? lookupUseBlood(oCharacter) : undefined; - if (!state.HealthColors.FX || useBlood === "OFF" || useBlood === "NO") + 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 scale = obj.get('height') / 70; const hitSize = Math.max(Math.min((amount / maxValue) * 4, 1), 0.2) * (randomInt(60, 100) / 100); + const fxLabel = (oCharacter && oCharacter.get('name')) || obj.get('name') || ''; if (spawnDefaultFxById(obj, isHeal, useBlood)) return; - buildFXList(isHeal, useBlood).forEach((fx) => + buildFXList(isHeal, useBlood, fxLabel).forEach((fx) => spawnFX( scale, hitSize, - obj.get("left"), - obj.get("top"), + obj.get('left'), + obj.get('top'), fx, - obj.get("pageid"), + obj.get('pageid'), ), ); } @@ -994,6 +1026,7 @@ * 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). @@ -1005,13 +1038,13 @@ } if ( state.HealthColors.auraColorOn !== true || - obj.get("layer") !== "objects" + obj.get('layer') !== 'objects' ) return; - if (obj.get("represents") === "" && state.HealthColors.OneOff !== true) + if (obj.get('represents') === '' && state.HealthColors.OneOff !== true) return; const barUsed = state.HealthColors.auraBar; - if (obj.get(`${barUsed}_max`) === "") { + if (obj.get(`${barUsed}_max`) === '') { clearAuras(obj); return; } @@ -1021,14 +1054,14 @@ const { maxValue, curValue, prevValue } = health; const sizeChanged = - prev.width !== obj.get("width") || prev.height !== obj.get("height"); + 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) + if (curValue === Number(prevValue) && update !== 'YES' && !sizeChanged) return; - const oCharacter = getObj("character", obj.get("represents")); + const oCharacter = getObj('character', obj.get('represents')); const typeConfig = resolveTypeConfig(oCharacter); applyAuraAndDead(obj, oCharacter, typeConfig, health); @@ -1044,19 +1077,19 @@ */ function menuForceUpdate() { const workQueue = findObjs({ - type: "graphic", - subtype: "token", - layer: "objects", + type: 'graphic', + subtype: 'token', + layer: 'objects', }); - sendChat("Fixing Tokens", `/w gm Fixing ${workQueue.length} Tokens`); + sendChat(SCRIPT_NAME, `/w gm Refreshing ${workQueue.length} Tokens`); const drainQueue = () => { const token = workQueue.shift(); if (token) { - const prev = JSON.parse(JSON.stringify(token)); - handleToken(token, prev, "YES"); + const prev = deepClone(token); + handleToken(token, prev, 'YES'); setTimeout(drainQueue, 0); } else { - sendChat("Fixing Tokens", "/w gm Finished Fixing Tokens"); + sendChat(SCRIPT_NAME, '/w gm Finished Refreshing Tokens'); } }; drainQueue(); @@ -1065,92 +1098,119 @@ /** * 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")}
`; - }, ""); + const token = getObj('graphic', obj._id); + const prev = deepClone(token); + handleToken(token, prev, 'YES'); + return `${acc}${token.get('name')}
`; + }, ''); gmWhisper(allNames); } // ————— MENU ————— /** * Builds a styled Roll20 chat button anchor element. + * * @param {string} label - Button label text. * @param {string} href - Roll20 API command (e.g. '!aura on'). * @param {string} [extraStyle=''] - Additional inline CSS to append to the base style. * @returns {string} An HTML anchor string ready for sendChat. */ - function makeBtn(label, href, extraStyle = "") { + 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(";"); + '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 = "") { + 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(";"); + '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); + 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"; + let style = ''; + if (value === 'No') style = 'background-color:#A84D4D'; + if (value === 'Off') style = 'background-color:#D6D6D6'; return makeBtn(value, href, style); } + /** + * Read-only pill counterpart to toggleBtn: green background for true, red for false. + * @param {boolean} value - Current boolean state. + * @returns {string} A styled span element. + */ + function boolPill(value) { + return makePill(value ? 'Yes' : 'No', value ? '' : 'background-color:#A84D4D'); + } + + /** + * Read-only pill counterpart to nameBtn: red for 'No', grey for 'Off', default for 'Yes'. + * + * @param {string} value - Current value: 'Yes', 'No', or 'Off'. + * @returns {string} A styled span element. + */ + function namePill(value) { + let style = ''; + if (value === 'No') style = 'background-color:#A84D4D'; + if (value === 'Off') style = 'background-color:#D6D6D6'; + return makePill(value, style); + } + /** * Renders and whispers the HealthColors configuration menu to the GM. * Builds the full HTML panel using makeBtn/toggleBtn/nameBtn helpers and @@ -1160,17 +1220,17 @@ 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(";"); + '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}`; @@ -1182,93 +1242,86 @@ `
`, `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}")}
`, + `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")}
`, + `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}")}
`, + `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}")}
`, + `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)}
`, + `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(""); + ].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. + * Renders a read-only settings snapshot to public game chat (all players). + * Triggered by `!aura settings` on demand; not called automatically after changes. */ function showSettingsInGameChat() { const s = state.HealthColors; const hr = `
`; const wrapStyle = [ - "border-radius:8px", - "padding:5px", - "font-size:9pt", - "text-shadow:-1px -1px #222,1px -1px #222,-1px 1px #222,1px 1px #222,2px 2px #222", - "box-shadow:3px 3px 1px #707070", - "background-image:-webkit-linear-gradient(left,#76ADD6 0%,#a7c7dc 100%)", - "color:#FFF", - "border:2px solid black", - "text-align:right", - "vertical-align:middle", - ].join(";"); + '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 healStyle = `background-color:#${s.HealFX}`; + const hurtStyle = `background-color:#${s.HurtFX}`; const html = [ `
`, `HealthColors Settings: ${VERSION}
`, hr, - `Is On: ${makePill(s.auraColorOn ? "Yes" : "No", s.auraColorOn ? "" : "background-color:#A84D4D")}
`, + `Is On: ${boolPill(s.auraColorOn)}
`, `Bar: ${makePill(s.auraBar)}
`, - `Use Tint: ${makePill(s.auraTint ? "Yes" : "No", s.auraTint ? "" : "background-color:#A84D4D")}
`, + `Use Tint: ${boolPill(s.auraTint)}
`, `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")}
`, + `Show PC Health: ${boolPill(s.PCAura)}
`, + `Show NPC Health: ${boolPill(s.NPCAura)}
`, + `Show Dead PC: ${boolPill(s.auraDeadPC)}
`, + `Show Dead NPC: ${boolPill(s.auraDead)}
`, hr, - `GM Sees all PC Names: ${makePill(s.GM_PCNames, pickNameStyle(s.GM_PCNames))}
`, - `GM Sees all NPC Names: ${makePill(s.GM_NPCNames, pickNameStyle(s.GM_NPCNames))}
`, + `GM Sees all PC Names: ${namePill(s.GM_PCNames)}
`, + `GM Sees all NPC Names: ${namePill(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))}
`, + `PC Sees all PC Names: ${namePill(s.PCNames)}
`, + `PC Sees all NPC Names: ${namePill(s.NPCNames)}
`, hr, `Aura 1 Radius: ${makePill(String(s.AuraSize))}
`, `Aura 1 Shape: ${makePill(s.Aura1Shape)}
`, @@ -1276,14 +1329,14 @@ `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")}
`, + `One Offs: ${boolPill(s.OneOff)}
`, + `FX: ${boolPill(s.FX)}
`, `HealFX Color: ${makePill(s.HealFX, healStyle)}
`, `HurtFX Color: ${makePill(s.HurtFX, hurtStyle)}
`, `DeathSFX: ${makePill(s.auraDeadFX)}
`, hr, `
`, - ].join(""); + ].join(''); sendChat(SCRIPT_NAME, `
${html}`); } @@ -1293,17 +1346,18 @@ * 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. + * appropriate state mutation then refreshes the menu. BAR validates 1/2/3, + * whispers confirmation, and triggers immediate full sync. PALETTE also + * triggers immediate full sync so existing tokens update right away. + * When a setting changes, re-whispers the interactive menu to the GM. + * Use `!aura settings` to post a read-only settings snapshot to public game chat. + * * @param {object} msg - Roll20 chat message object. */ function handleInput(msg) { const parts = msg.content.split(/\s+/); const command = parts[0].toUpperCase(); - if (msg.type !== "api" || !command.includes("!AURA")) return; + if (msg.type !== 'api' || !command.includes('!AURA')) return; if (!playerIsGM(msg.playerid)) { sendChat( @@ -1313,203 +1367,129 @@ 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; - } + const option = (parts[1] || 'MENU').toUpperCase(); + if (option !== 'MENU') gmWhisper('UPDATING TOKENS...'); - if (changedSetting) { - showSettingsInGameChat(); + const s = state.HealthColors; + + // Dispatch tables for structurally identical cases + const TOGGLES = { + TINT: 'auraTint', PC: 'PCAura', NPC: 'NPCAura', + DEAD: 'auraDead', DEADPC: 'auraDeadPC', ONEOFF: 'OneOff', FX: 'FX', + }; + const STRINGS = { + GMNPC: 'GM_NPCNames', GMPC: 'GM_PCNames', + PCNPC: 'NPCNames', PCPC: 'PCNames', DEADFX: 'auraDeadFX', + }; + const FLOATS = { SIZE: 'AuraSize', A2SIZE: 'Aura2Size' }; + const SHAPES = { A1SHAPE: 'Aura1Shape', A2SHAPE: 'Aura2Shape' }; + const HEXES = { A1TINT: 'Aura1Color', A2TINT: 'Aura2Color' }; + + if (TOGGLES[option]) { + s[TOGGLES[option]] = !s[TOGGLES[option]]; + } else if (STRINGS[option]) { + s[STRINGS[option]] = parts[2]; + } else if (FLOATS[option]) { + s[FLOATS[option]] = Number.parseFloat(parts[2]); + } else if (SHAPES[option]) { + s[SHAPES[option]] = normalizeShape(parts[2], s[SHAPES[option]]); + } else if (HEXES[option]) { + s[HEXES[option]] = normalizeHex6(parts[2], s[HEXES[option]]); } else { - showMenu(); + switch (option) { + case 'MENU': + break; + case 'SETTINGS': + showSettingsInGameChat(); + return; + case 'ON': + s.auraColorOn = true; + break; + case 'OFF': + s.auraColorOn = false; + break; + case 'BAR': + if (/^[123]$/.test(parts[2] || '')) { + s.auraBar = `bar${parts[2]}`; + gmWhisper(`Health bar set to ${s.auraBar}. Forcing sync...`); + menuForceUpdate(); + } else { + gmWhisper( + `Invalid bar "${parts[2] || ''}". Use !aura bar 1, !aura bar 2, or !aura bar 3.`, + ); + } + break; + case 'PERC': + s.auraPercPC = Number.parseInt(parts[2], 10); + s.auraPerc = Number.parseInt(parts[3], 10); + menuForceUpdate(); + break; + case 'PALETTE': + s.colorPalette = normalizePalette(parts[2], s.colorPalette); + menuForceUpdate(); + break; + case 'HEAL': + s.HealFX = parts[2].toUpperCase(); + syncDefaultFxObjects(); + break; + case 'HURT': + s.HurtFX = parts[2].toUpperCase(); + syncDefaultFxObjects(); + break; + case 'RESET': + delete state.HealthColors; + gmWhisper('STATE RESET'); + checkInstall(); + break; + case 'RESET-FX': + resetDefaultFxObjects(); + break; + case 'RESET-ALL': + runResetAllFlow(); + break; + case 'FORCEALL': + menuForceUpdate(); + return; + case 'UPDATE': + manUpdate(msg); + return; + } } + + showMenu(); } // ————— OUTSIDE API ————— /** * Public entry point for external scripts to request a token color update. * Validates that the object is a graphic before delegating to handleToken. + * * @param {object} obj - Roll20 object to update. * @param {object} prev - Previous attribute snapshot (passed through to handleToken). */ function updateToken(obj, prev) { - if (obj.get("type") === "graphic") { + if (obj.get('type') === 'graphic') { handleToken(obj, prev); } else { - gmWhisper("Script sent non-Token to be updated!"); + 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) + * - chat:message → handleInput (command processing) + * - change:graphic → handleToken (live HP changes and token resizes) + * - add:token → handleToken (with 400ms delay to allow token data to settle) */ function registerEventHandlers() { - on("chat:message", handleInput); - on("change:graphic", handleToken); - on("add:token", (t) => { + 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"); + const token = getObj('graphic', t.id); + const prev = deepClone(token); + handleToken(token, prev, 'YES'); }, 400); }); } @@ -1522,7 +1502,7 @@ registerEventHandlers, }; - on("ready", () => { + on('ready', () => { gmWhisper(`MOD READY (v${VERSION})`); checkInstall(); registerEventHandlers(); diff --git a/HealthColors/README.md b/HealthColors/README.md index 1b723d47d..1aff98d70 100644 --- a/HealthColors/README.md +++ b/HealthColors/README.md @@ -32,36 +32,43 @@ ### The Configuration Menu Type `!aura` in the chat to open the interactive configuration menu. From here, you can toggle all features and adjust percentages. -When a command changes a setting, HealthColors posts a single read-only settings snapshot to game chat (instead of duplicating menu output). +When a command changes a setting, HealthColors re-whispers the interactive GM menu so you can continue adjusting without re-typing `!aura`. Use `!aura settings` to post a read-only public snapshot to game chat on demand. ### Command Reference -| Command | Description | -| :-------------------------- | :--------------------------------------------------------------------------------- | -| `!aura` | Opens the main configuration menu. | -| `!aura settings` | Outputs the current HealthColors settings snapshot to game chat. | -| `!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 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`). | -| `!aura a2shape ` | Sets Aura 2 display shape (`Square`, `Circle`). | -| `!aura a2tint ` | Sets Aura 2 display tint color (e.g., `!aura a2tint 806600`). | -| `!aura bar <1/2/3>` | Sets which token bar represents health and immediately forces a full token sync. | -| `!aura pc / !aura npc` | Toggles health tracking for PCs or NPCs. | -| `!aura perc ` | Sets the health percentage at which the aura appears (e.g., `!aura perc 100 100`). | -| `!aura dead / !aura deadPC` | Toggles the automatic "Dead" status marker. | -| `!aura fx` | Toggles particle effects for damage and healing. | -| `!aura heal ` | Sets the color of healing particle effects (e.g., `!aura heal FDDC5C`). | -| `!aura hurt ` | Sets the color of damage particle effects (e.g., `!aura hurt FF0000`). | -| `!aura reset` | Resets the script's state to factory defaults. | -| `!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 +| Command | Description | +| :-------------------------- | :---------------------------------------------------------------------------------------------------------------------------- | +| `!aura` | Opens the main configuration menu. | +| `!aura settings` | Outputs the current HealthColors settings snapshot to game chat. | +| `!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 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`). | +| `!aura a2shape ` | Sets Aura 2 display shape (`Square`, `Circle`). | +| `!aura a2tint ` | Sets Aura 2 display tint color (e.g., `!aura a2tint 806600`). | +| `!aura bar <1/2/3>` | Sets which token bar represents health and immediately forces a full token sync. | +| `!aura pc / !aura npc` | Toggles health tracking for PCs or NPCs. | +| `!aura perc ` | Sets aura/tint visibility threshold: `0` disables, `1-99` shows at or below that HP%, `100` always visible for living tokens. | +| `!aura dead / !aura deadPC` | Toggles the automatic "Dead" status marker. | +| `!aura gmpc ` | Sets GM visibility of PC token nameplates (`Yes` = always show, `No` = always hide, `Off` = use Roll20 default). | +| `!aura gmnpc ` | Sets GM visibility of NPC token nameplates (`Yes` = always show, `No` = always hide, `Off` = use Roll20 default). | +| `!aura pcpc ` | Sets player visibility of PC token nameplates (`Yes` = always show, `No` = always hide, `Off` = use Roll20 default). | +| `!aura pcnpc ` | Sets player visibility of NPC token nameplates (`Yes` = always show, `No` = always hide, `Off` = use Roll20 default). | +| `!aura oneoff` | Toggles health tracking for tokens that are not linked to a character sheet. | +| `!aura update` | Forces a health-color update on all currently selected tokens. | +| `!aura fx` | Toggles particle effects for damage and healing. | +| `!aura heal ` | Sets the color of healing particle effects (e.g., `!aura heal FDDC5C`). | +| `!aura hurt ` | Sets the color of damage particle effects (e.g., `!aura hurt FF0000`). | +| `!aura deadfx ` | Sets a jukebox track to play when a token dies (e.g., `!aura deadfx Funeral`), or `None` to disable. | +| `!aura reset` | Resets the script's state to factory defaults. | +| `!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. | + +### HealthColor Palettes - `default`: High = Green, Mid = Yellow, Low = Red, Dead = Black. - `colorblind`: High = Cyan, Mid = Orange, Low = Magenta, Dead = Black. @@ -84,4 +91,5 @@ When a command changes a setting, HealthColors posts a single read-only settings ## Credits Original Author: DXWarlock +Version 1.7.1 edit by Surok Refactored and Modernized for v2 by MidNiteShadow7 diff --git a/HealthColors/script.json b/HealthColors/script.json index 9921aeb41..a5157cfcf 100644 --- a/HealthColors/script.json +++ b/HealthColors/script.json @@ -1,10 +1,12 @@ { "name": "Aura/Tint HealthColors", "script": "HealthColors.js", - "version": "2.1.0", + "version": "2.1.1", "previousversions": [ + "2.1.0", "2.0.1", "2.0.0", + "1.7.1", "1.6.1", "1.6.0", "1.5.1", @@ -15,8 +17,8 @@ "1.2.0" ], "description": "Script to set health indicator on tokens via aura or tint, based on a bars value. Be sure to set the bar your health is tied to in the menu: API menu command !AURA", - "authors": "DXWarlock, MidNiteShadow7", - "roll20userid": "262130, 16506286", + "authors": "DXWarlock, Surok, MidNiteShadow7", + "roll20userid": "262130, 335573, 16506286", "dependencies": [], "modifies": { "custfx": "read,write",