Yup, that was one of the things I was going to mention. =D Like GiGs pointed out, you can reduce code duplication by building the property names based on the healthBar configuration. It's a minor thing, but I'd actually build them once outside the function and and save them: const barValue = `bar${config.healthBar}_value`;
const barMax = `bar${config.healthBar}_max`;
then use it in the function: const oldHp = parseInt(prev[barValue]);
const oldHpMax = parseInt(prev[barMax]);
const newHp = parseInt(obj.get(barValue));
const newHpMax = parseInt(obj.get(barMax));
(I've gotten to where I prefer individually declaring the type of variables, hence the const on each line. That's just personal preference, but it makes reordering and moving things simple.) Another thing to be aware of is the 'ready' event. When the API starts up, it sends an add event for every object in the entire game. That's not a big deal for this script, but if you were writing one that took some action on the creation of something, you'd not want to have it activate on all the loading create events. To get around that, you wrap your script in: on('ready',()=>{
/* your script */
}); The 'ready' event happens only once, right after all objects have been loaded in the game. Wrapping in a function scope has an added benefit: All scripts in the API are concatenated into a single file, which means there is a single global namespace. That means any functions you put in the global namespace could collide with other functions. Wrapping in a 'ready' event puts those in a function namespace so you don't have to worry about polluting the global namespace. =D So, I mentioned sharing some functions. I notice you're checking the player page if requirePlayerPage is set. There are technically 3 possible settings for pages. There's the playerpageid, the playerspecificpages, and the lastpage on a player. I wrote a function that builds the right list: const getActivePages = () => [
...new Set([
Campaign().get('playerpageid'),
...Object.values(Campaign().get('playerspecificpages')),
...findObjs({
type: 'player',
online: true
})
.filter((p)=>playerIsGM(p.id))
.map((p)=>p.get('lastpage'))
])
];
This returns a list of pages that have players on them, so you can check if the object is on any of those: let activePages = getActivePages();
if(!activePages.includes(obj.get('_pageid'))) {
return;
}
So, now about the state object. The state object is intended to store values that are persisted across loads of the api. It's ideal for settings, but ONLY if there is a way to set them at some point. To paraphrase The King of Attolia by Megan Whalen Turner (which is a great series) when you constantly overwrite it on load " you thwart its purpose ." In your case, you are better off just having a regular old object for configuration: const config = {
enabled: true, // Set to false to disable script
font: 'Contrail One', // Choices: 'Arial', 'Patrick Hand', 'Contrail One', 'Shadows Into Light', 'Candal'
damageColor: 'black', // Color of damage numbers
healingColor: 'green', // Color of healing numbers
healthBar: 1, // Which bar is the health bar?
displayHealing: true, // Show green numbers when health increases
requireMaxHealth: false, // Only show damage numbers if the token has a maximum health
requirePlayerPage: true // Only show damage numbers on the active player page
};
To truly make use of the state object, you'll want to only selectively initialize it, and provide ways to set it. I wrote a whole section on this in the wiki: <a href="https://wiki.roll20.net/API:Objects#state" rel="nofollow">https://wiki.roll20.net/API:Objects#state</a> The only other comment I have, which is really a minor thing in this case, is that you often want to do your short-circuit tests as early as possible. There's no point in calculating the various hit points if you are not going to be active on that page, so moving the page check earlier than the hp calculations is a minor efficiency gain. Here's the script, reformatted with the above, though I'm gaming right now, so I haven't tested it. =D /* Damage Numbers script
* Generates Final Fantasy-style damage numbers whenever a token's health increases or decreases.
* Works for any system.
*
* Created by Quinn Gordon
* Roll20: <a href="https://app.roll20.net/users/42042/prof" rel="nofollow">https://app.roll20.net/users/42042/prof</a>
* Github: <a href="https://github.com/ProfessorProf" rel="nofollow">https://github.com/ProfessorProf</a>
*/
on('ready', ()=>{
// Config:
const config = {
enabled: true, // Set to false to disable script
font: 'Contrail One', // Choices: 'Arial', 'Patrick Hand', 'Contrail One', 'Shadows Into Light', 'Candal'
damageColor: 'black', // Color of damage numbers
healingColor: 'green', // Color of healing numbers
healthBar: 1, // Which bar is the health bar?
displayHealing: true, // Show green numbers when health increases
requireMaxHealth: false, // Only show damage numbers if the token has a maximum health
requirePlayerPage: true // Only show damage numbers on the active player page
};
const barValue = `bar${config.healthBar}_value`;
const barMax = `bar${config.healthBar}_max`;
const getActivePages = () => [
...new Set([
Campaign().get('playerpageid'),
...Object.values(Campaign().get('playerspecificpages')),
...findObjs({
type: 'player',
online: true
})
.filter((p)=>playerIsGM(p.id))
.map((p)=>p.get('lastpage'))
])
];
// Generate damage/healing numbers
on('change:graphic', function(obj, prev) {
if(config.enabled) {
if(config.requirePlayerPage) {
// Do nothing if it was a token on another page
let activePages = getActivePages();
if(!activePages.includes(obj.get('_pageid'))) {
return;
}
}
const oldHp = parseInt(prev[barValue]);
const oldHpMax = parseInt(prev[barMax]);
const newHp = parseInt(obj.get(barValue));
const newHpMax = parseInt(obj.get(barMax));
// Do nothing if the bar value didn't change
if(oldHp && newHp && oldHp == newHp) {
return;
}
damageNumber(obj, oldHp, newHp, oldHpMax, newHpMax);
}
});
// Display damage number for a token
function damageNumber(token, oldHp, newHp, oldHpMax, newHpMax) {
if(oldHp != oldHp || newHp != newHp ||
(oldHpMax == oldHpMax && newHpMax == newHpMax && oldHpMax != newHpMax) ||
oldHp - newHp == oldHpMax - newHpMax) {
// NaN values or max HP changed, don't show a number
return;
}
let hpChange = newHp - oldHp;
if(!config.displayHealing && hpChange > 0) {
// Do nothing if it's a healing value and healing numbers are disabled
return;
}
if(config.requireMaxHealth && !newHpMax) {
// Do nothing if there's no max HP and require max health is enabled
return;
}
let number = createObj('text', {
_pageid: token.get('_pageid'),
layer: token.get('layer'),
left: token.get('left') - token.get('width') * 0.4 + Math.floor(Math.random() * (token.get('width') * 0.8)),
top: token.get('top') - token.get('height') / 2 + Math.floor(Math.random() * 20),
text: Math.abs(hpChange).toString(),
width: 50,
height: 25.6,
font_family: config.font,
color: hpChange > 0 ? config.healingColor : config.damageColor
});
log(`Created damage number for ${Math.abs(hpChange)} ${hpChange > 0 ? 'healing' : 'damage'} to token ${token.get('name') ? token.get('name') : token.get('_id')}.`);
updateDamageNumber(number, number.get('top') - 50, 20);
}
// Move the number upwards slightly every 50ms, then delete it
function updateDamageNumber(number, targetTop, steps) {
if(steps <= 0) {
number.remove();
return;
}
let top = number.get('top');
top += (targetTop - top) * 0.3;
number.set('top', top);
setTimeout(function () {
updateDamageNumber(number, targetTop, steps - 1);
}, 50);
}
});