/* 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: https://app.roll20.net/users/42042/prof
* Github: https://github.com/ProfessorProf
*/
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'
fontSize: 16, // Font size of damage numbers
damageColor: 'red', // Color of damage numbers
healingColor: 'blue', // 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
tokenSizeScaling: true, // Enable font size scaling based on token size
majorDamageScaling: true // Enable font size scaling based on damage dealt (>50% hp = 3x bigger)
};
const barValueKey = `bar${config.healthBar}_value`;
const barMaxKey = `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
const onTokenChange = (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[barValueKey]);
const oldHpMax = parseInt(prev[barMaxKey]);
const newHp = parseInt(obj.get(barValueKey));
const newHpMax = parseInt(obj.get(barMaxKey));
// 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 width = token.get('width');
let height = token.get('height');
let fontSize = scaleFont(height, width, hpChange, newHpMax);
// Create number at random location at the top of the token
let number = createObj('text', {
_pageid: token.get('_pageid'),
layer: token.get('layer'),
left: token.get('left') - width * 0.4 + Math.floor(Math.random() * (width * 0.8)),
top: token.get('top') - height / 2 + Math.floor(Math.random() * 20),
text: Math.abs(hpChange).toString(),
font_family: config.font,
font_size: fontSize,
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);
}
// Scale font size based on token size and damage dealt
function scaleFont(height, width, hpChange, hpMax){
// Roll20's pixels per unit on the map
const pxPerUnit = 70;
let scaledFont = config.fontSize;
// Scale based on token size
if (config.tokenSizeScaling) {
// Get average token dimension for font scaling
let tokenScale = (height + width) / (2 * pxPerUnit);
scaledFont *= Math.max(tokenScale, 1);
}
// Scale based on damage dealt
if (config.majorDamageScaling && Math.abs(hpChange) * 2 > hpMax) {
scaledFont *= 3;
}
return scaledFont;
}
// 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);
}
on('change:graphic', onTokenChange);
if('undefined' !== typeof TokenMod && TokenMod.ObserveTokenChange){
TokenMod.ObserveTokenChange(onTokenChange);
}
if('undefined' !== typeof ApplyDamage && ApplyDamage.registerObserver){
ApplyDamage.registerObserver("change", onTokenChange);
}
});