UniversalSheetWorkers
From Roll20 Wiki
Main Article: Sheet Worker Scripts
Character Sheet Creation
Getting Started
- Building Sheets
- Restrictions
- Common Mistakes
- Templates & Examples
- CSS Wizardry
- Designing Layout
- Image Use
Reference
- Buttons
- Repeating Sections
- Sheet Worker Scripts
- Roll Templates
- Default Settings
- Sheet Translation
- Compendium Integration
- Charactermancer
- All SheetDev Pages
Tools
Contents |
Universal Sheet Workers
In this page, I'll show you how to make one function to handle a bunch of similar sheet workers.
For example, in many games you have a bunch of stats, and each has a stat modifier. You'd normally create a sheet worker for each stat, to create the modifier. That might look something like this:
on("change:strength sheet:opened", function () { getAttrs(['strength'], function (values) { const strength = parseInt(values['strength'], 10)||0; // this extracts the stat, and assumes a stat score of 0 the stat is not recognised as a number. const modifier = Math.floor(strength/2) -5; // this gives a +0 at 10-11, and +/1 for 2 point difference. setAttrs({ strength_modifier: modifier }); }); });
If you have stats of dexterity, constitution, intelligence, wisdom, and charisma, you could easily copy the above sheet worker, and just replace the stat names. But there's a better way: you can rewrite that one function to account for all the stats.
The easiest way is to make sure you have a consistent naming pattern. So for example, if you might have stats named str, dex, con, etc, and the modifiers are always str_mod, dex_mod, con_mod, etc.
So, assuming this is true (don't worry, I'll handle below how to deal with situations where that isnt true), the first step is to create an array of your stat names.
const stats = ['str','dex','con','int','wis','cha'];
Then you create a for each loop like so:
stats.forEach(function (stat) { });
This is going to loop through each stat in the stats array, and send the stat name to the function inside. So you just need to replace each instance of the stat name with the variable stat.
Simple Looped Worker: DnD & Traveller Compatible
That looks like this:
const stats = ['str','dex','con','int','wis','cha']; stats.forEach(function (stat) { on("change:" + stat + " sheet:opened", function () { getAttrs([stat], function (values) { const stat_score = parseInt(values[stat], 10)||0; // this extracts the stat, and assumes a stat score of 0 the stat is not recognised as a number. const stat_modifier = Math.floor(stat_score/2) -5; // this gives a +0 at 10-11, and +/1 for 2 point difference. setAttrs({ [stat + '_mod']: stat_modifier }); }); }); });
The only tricky part is the setAttrs function. Notice the line is
[stat + '_mod']: stat_modifier
since you are building the attribute name, you have to enclose it in brackets to avoid an error.
If you like arrow notation, and string literals, another way to write the function above is:
const stats = ['str','dex','con','int','wis','cha']; stats.forEach(stat => { on(`change:${stat} sheet:opened`, () => { getAttrs([stat], values => { const stat_score = parseInt(values[stat], 10)||0; const stat_modifier = Math.floor(stat_score/2) -5; setAttrs({ [`${stat}_mod`]: stat_modifier }); }); }); });
It doesnt matter which you use. They are identical in all ways that matter.
Handling Tricky Name Combinations
What about when your names dont follow a consistent naming pattern? If your attributes are named str, strength_mod, int, intelligence_mod, etc. (Obviously this is a contrived example, but some situations like this will crop up). The best course of action would be to rename your attributes, it's just neater. But there may be reasons you cant do that. In that case, you can use an object instead of array.
An object is made up of pairs of data, each pair being a key and value. It looks like this:
const stats = { str: strength_modifier, dex: dexterity_modifier, int: intelligence_modifier };
Now you can loop over the keys, using Object.keys(stats) like so:
Object.keys(stats).forEach(function(stat) { console.log(stat, stats[stat]); });
The above function will print to the console the stat key, followed by its value. So to use this, the above function would be written as
const stats = { str: strength_modifier, dex: dexterity_modifier, int: intelligence_modifier }; Object.keys(stats).forEach(stat => { on(`change:${stat} sheet:opened`, function () { getAttrs([stat], values => { const stat_score = parseInt(values[stat], 10)||0; const stat_modifier = Math.floor(stat_score/2) -5; setAttrs({ [stats[stat]]: stat_modifier }); }); }); });
The only real difference is the setAttrs function. You get the name of the modifier attribute by calling the keys value like object[key], or stats[stat] in this case.
Building an Advanced Sheet Worker
Sometimes your sheet worker is more complicated. In BRP games like Runequest, you often have to calculate the bonus from 2 or more attributes, and they each might apply in a different way. I'm going to build a very complex example, the latest edition of Runequest: Glorantha. In this system, you have several skill categories, like Agility, Magic, Manipulation, Perception, etc. Each category can have 2-4 stats. Some stats are primary, giving of +5% at 13-16, +10% at 17-20, etc. Others are less important, where the bonus starts at 17-20. And some stats are negative: they reverse the bonus (so you get a bonus for low scores and a penalty for high scores). For example, in Stealth, a high POW and SIZ makes it harder for you to hide.
So the first thing is to build a function to calculate the bonus a given stat applies.
function calcBonus(score, secondary, negative) { // secondary/negative: 1 = true, 0 = false let bonus = 0; if(score > 12) { bonus = Math.ceil((score-12)/4) *5; if(secondary) bonus -=5; } else if (score < 9) { bonus = Math.ceil((9-score)/4) *5; if(secondary) bonus +=5; } if(negative) bonus = -bonus; return bonus; };
So, given a stat score, whether it is primary or secondary, or positive or negative, this function will return that stats modifier.
So now we need to build the object we can use to build the sheet worker. But first, lets see what the sheet worker would look like for Manipulation.
on("change:str change:dex change:int change:pow sheet:opened", function () { getAttrs(['str','dex','int','pow'], function (values) { const str = parseInt(values.str, 10)||0; const dex = parseInt(values.dex, 10)||0; const int = parseInt(values.int, 10)||0; const pow = parseInt(values.pow, 10)||0; const str_bonus = calcBonus(str,1,0); // str is a secondary bonus, so secondary parameter is 1 const dex_bonus = calcBonus(dex,0,0); // dex is primary, so secondary value is 0. const int_bonus = calcBonus(int,0,0); const pow_bonus = calcBonus(pow,1,0); setAttrs({ manipulation: str_bonus + dex_bonus + int_bonus + pow_bonus }); }); });
Using the function makes that a lot simpler than it would otherwise be, but it also lets us theorycraft what a unified sheetworker looks like. First we need an object to loop over, that for each category, includes 2-4 stats, and for each stat, lists whether it is primary or secondary, and positive or negative.
Brace yourself, this gets complicated:
const skill_categories = { manipulation: { str: { secondary: 1, negative: 0 }, dex: { secondary: 0, negative: 0 }, int: { secondary: 0, negative: 0 }, pow: { secondary: 1, negative: 0 }, }, }
So here we have a skill_categories object. Inside that another object called manipulation. And inside that there are 4 more objects, one for each stat, which contains whether that stat is secondary or negative.
When you need to know the stats inside the manipulation object, you can call them by grabbing the skill_categories[manipulation], then iterating over the keys of that object. Here's an example where we print the names of each of the stats in the manipulation group, and show what their primary rating is (always 0 or 1).
Object.keys(skill_categores[manipulation]).forEach(stat => { console.log(`${stat}: primary: ${skill_categories[category][stat]['secondary']}); }
So with this knowledge we can now build our sheet worker loop. The tricky parts are the on(change) line and the getAttrs line. So the first part of the function, before the on(change) section lay the groundwork for that.
Object.keys(skill_categories).forEach(category => { let stats = []; let change = ''; Object.keys(skill_categories[category]).forEach(stat => { stats.push(stat); // this is the array for the getAttrs line, which needs an array of the stats. change += `change:${stat} `; // here we build the string for the on(change) line. }); on(`${change}sheet:opened`, function () { getAttrs(stats, values => { let bonus = 0; Object.keys(skill_categories[category]).forEach(stat => { // here we loop through all the stats in the current category, and get the bonus for that stat. const score = parseInt(values[stat], 10) ||0; const secondary = skill_categories[category][stat]['secondary']; const negative = skill_categories[category][stat]['negative']; bonus += calcBonus(score, secondary, negative); }); // at this point the bonus for this category is complete so can print it to the sheet. setAttrs({ [category]: bonus }); }); }); });
So, congratulations on following all that. Here are the final, complete sheet worker functions for Runequest: Glorantha stat bonus calculations, with stat names adjusted to match the sheet on Roll20's github site.
Runequest: Glorantha Sheet Worker
const calcBonus = (score, secondary, negative) => { // secondary/negative: 1 = true, 0 = false let bonus = 0; if(score > 12) { bonus = Math.ceil((score-12)/4) *5; if(secondary) bonus -=5; } else if (score < 9) { bonus = Math.ceil((9-score)/4) *5; if(secondary) bonus +=5; } if(negative) bonus = -bonus; return bonus; }, skill_categories = { agility: { curstr: { secondary: 1, negative: 0 }, curdex: { secondary: 0, negative: 0 }, cursiz: { secondary: 1, negative: 1 }, curpow: { secondary: 1, negative: 0 }, }, communication: { curcha: { secondary: 0, negative: 0 }, curint: { secondary: 1, negative: 0 }, curpow: { secondary: 1, negative: 0 }, }, knowledge: { curint: { secondary: 0, negative: 0 }, curpow: { secondary: 1, negative: 0 }, }, magic: { curpow: { secondary: 1, negative: 0 }, curcha: { secondary: 0, negative: 0 }, }, manipulation: { curstr: { secondary: 1, negative: 0 }, curdex: { secondary: 0, negative: 0 }, curint: { secondary: 0, negative: 0 }, curpow: { secondary: 1, negative: 0 }, }, perception: { curint: { secondary: 0, negative: 0 }, curpow: { secondary: 1, negative: 0 }, }, stealth: { cursiz: { secondary: 0, negative: 1 }, curdex: { secondary: 0, negative: 0 }, curint: { secondary: 0, negative: 0 }, curpow: { secondary: 1, negative: 1 }, }, }; Object.keys(skill_categories).forEach(category => { let stats = []; let change = ''; Object.keys(skill_categories[category]).forEach(stat => { stats.push(stat); change += `change:${stat} `; }); on(`${change}sheet:opened`, function () { getAttrs(stats, values => { let bonus = 0; Object.keys(skill_categories[category]).forEach(stat => { const score = parseInt(values[stat], 10) ||0; const secondary = skill_categories[category][stat]['secondary']; const negative = skill_categories[category][stat]['negative']; bonus += calcBonus(score, secondary, negative); }); setAttrs({ [`${category}_mod`]: bonus }); }); }); });
Practical Examples From the Forums
I'll link here to threads where I've already created some examples of universal sheet workers.
- Simplest example: forcing a number to be an integer
- A Simple Example: Calculate modifiers for group of stats
- Replacing a Nested getSectionIDs function: This post and my reply 3 posts below it.
- Replacing EventInfo and a convoluted Switch Case: This post and the one two posts below it.
- Champions/Hero Games Attributes: Attributes
- Limit Structure Part by their Max: Structure Limits
Author: GiGs(G-G-G on github).
See Also
- Sheetworker examples for Non-programmers
- List of all Sheetworker-articles
- Sheet Worker Optimization by Scott C.
- How to integrate table of stats into a sheet - Forum Post
- Multiversal Sheetworker Generator by GiGs
- JavaScript Best Practices - MDN web docs
- Introduction to JavaScript - MDN web docs