Roll20 uses cookies to improve your experience on our site. Cookies enable you to enjoy certain features, social sharing functionality, and tailor message and display ads to your interests on our site and others. They also help us understand how our site is being used. By continuing to use our site, you consent to our use of cookies. Update your cookie preferences .
×
Create a free account

A Sheet Author's Journey: Roll on!

1648660079

Edited 1648662399
Scott C.
Forum Champion
Sheet Author
API Scripter
Compendium Curator
See the previous post &nbsp;|&nbsp; Check out the Repository &nbsp;|&nbsp;See the Next Post It's finally happening! Our sheet is going to be able to make rolls and be used in an actual game at the end of this week. Today we're talking about&nbsp; roll templates &nbsp;and&nbsp; custom roll parsing &nbsp;to power our sheet's rolls. Here's the output we'll be creating: Oh yeah, and we'll talk a little bit about character sheet dark modes! Series Posts The Beginning Do the Pug Repeating Sections and sheetworkers Style and Layout Finding our Flair! Roll on! Dimming our sheet We'll start making our roll template in a little bit, but let's talk about one of the newest features in Character sheet development; Roll20's dark mode! There are several different&nbsp; methods of creating a dark mode &nbsp;for a site. Roll20 has decided to go with injection of a class to trigger dark mode on the table top. This makes it relatively easy for us to apply dark mode to our sheets by leveraging&nbsp; CSS variables . If you've looked at the CSS for the sheet so far, you'll have noticed that we've been using CSS variables for quite a lot already, even though we haven't talked about them. In fact, in the last post we used them to apply our paper background as a border image. For dark mode, I've gone through and replaced just about every color definition with a CSS variable call instead. This then allows us to recolor our entire sheet with one very simple CSS declaration: .sheet-darkmode .ui-dialog, .sheet-rolltemplate-darkmode{ --backColor:var(--dark-surface1); --fontColor:var(--darkModeText); --borderColor:var(--dark-primarytext); --subHeadBackColor:var(--dark-surface1); --selectedColor:var(--darkModeText); --disableBackground:var(--dark-surface1); //--shadowColor: --disableShadow:0px 0 7px 0px #161616 inset; --buttonColor:var(--dark-surface1); --hoverColor:#2e2e2e; --clickColor:black; //Images --paper-background:url(<a href="https://s3.amazonaws.com/files.d20.io/images/277897578/A-KBtxirzw5z2lhjc6z9Ig/original.png" rel="nofollow">https://s3.amazonaws.com/files.d20.io/images/277897578/A-KBtxirzw5z2lhjc6z9Ig/original.png</a>); --star1Image:url(<a href="https://s3.amazonaws.com/files.d20.io/images/277891541/3QIvaKp_cOc6B8DAAXM6CA/original.png" rel="nofollow">https://s3.amazonaws.com/files.d20.io/images/277891541/3QIvaKp_cOc6B8DAAXM6CA/original.png</a>); --star2Image:url(<a href="https://s3.amazonaws.com/files.d20.io/images/277891540/WAPK5ypyf235723n0GcVMQ/original.png" rel="nofollow">https://s3.amazonaws.com/files.d20.io/images/277891540/WAPK5ypyf235723n0GcVMQ/original.png</a>); --star3Image:url(<a href="https://s3.amazonaws.com/files.d20.io/images/277891539/iR6cOrzXq28bpGfsqSrK0Q/original.png" rel="nofollow">https://s3.amazonaws.com/files.d20.io/images/277891539/iR6cOrzXq28bpGfsqSrK0Q/original.png</a>); .sheetform{ background-color:inherit; } img{ filter: brightness(.8) contrast(1.2); } } And, just like that our sheet becomes dark mode enabled right? Well, not quite. We also needed to move where we define the color variables referenced here so that the dark mode class will be able to override them. This involves the two corner stones of CSS; order and specificity. Originally, our variables were defined like so: .ui-dialog .tab-content .charsheet{ /*Variables defined here*/ } But, the&nbsp; sheet-darkmode &nbsp;class is being applied to the body of the sheet, and ideally we wouldn't need to be overly specific in our declaration of the variables. The solution was to define our variables in the&nbsp; ui-dialog &nbsp;class, and then use them elsewhere as needed. This then allows us to write a concise css declaration to modify our variables for dark mode. Triggering our dark mode off of the&nbsp; sheet-darkmode &nbsp;class makes it very easy for us to apply dark mode styling, but we still need to design the color scheme for our dark mode sheet. Most of the design considerations for what color scheme to use, regardless of dark/light mode, come down to accessibility. We want to make sure that our colors can be differentiated from each other by as large a section of the population as possible. This means that we need to run our colors through some tests to make sure that they can be read easily by anyone. An easy to use tool to compare colors is&nbsp; WebAIM's contrast checker &nbsp;which gives easy to understand pass/fail grades on how two colors work together in a variety of situations. Creating a color scheme that is easy to read and stylish takes a bit of work, but the result at the end is definitely worth it. Roll those math rocks! Our sheet now styles itself to fit into the new dark mode, but let's add the last bit of big functionality to it; the ability to roll! How you design your sheet's roll template and roll functions is going to depend a great deal on what the game system's mechanics are. So, what are the dice mechanics for&nbsp; The Hero's Journey 2e? d20 based system attacks are compared to defense scores damage is reduced by a target's reduction value Critical damage is an optional house rule Saving throws are a flat d20 vs. a character specific target number Saves occassionally can add an attribute bonus to them Advantage/Disadvantage ala D&amp;D 5e That's the system rules that our sheet's rolls would handle in a perfect world. We aren't going to try to automate the attack vs defense score comparison, or the damage reduction as those two things are frequently dynamic in most games. Roll Template PUG The first step in designing our roll system is going to be designing the roll template that our rolls will use. The&nbsp; K-scaffold &nbsp;provides us with several roll template related PUG mixins which we'll use to lay out our template more easily so that instead of needing to write the somewhat confusing html for a roll template: &lt;div class="header"&gt;{{#name}} &lt;h3&gt;{{name}}&lt;/h3&gt;{{/name}}{{#character_name}}{{#character_id}} &lt;h4 class="character_name"&gt;[{{character_name}}](<a href="http://journal.roll20.net/character/{{character_id}})&lt;/h4&gt;{{/character_id}}{{^character_id}}" rel="nofollow">http://journal.roll20.net/character/{{character_id}})&lt;/h4&gt;{{/character_id}}{{^character_id}}</a> &lt;h4 class="character_name"&gt;{{character_name}}&lt;/h4&gt;{{/character_id}}{{/character_name}} &lt;/div&gt; We can write some nice clean PUG: +templateWrapper(templateName) .header +templateConditionalDisplay('name') h3 {{name}} +characterLink The&nbsp; Roll20 wiki has great detail on all of the html that powers roll templates , but what it comes down to is that roll templates use named fields to insert information into the HTML, and you can check the values of these fields using roll template helper functions. The code above defines the header area for our template where the name of the roll and the character's name are going to go. In addition, we'll do conditional checks to see if some predesignated fields like&nbsp; roll ,&nbsp; damage ,&nbsp; description , and several others are present. If they are, we'll create an element to hold their contents and provide headers to easily tell what's what in our output. Here's what the roll section of our template looks like: +templateConditionalDisplay('roll') .template-row +templateConditionalDisplay('roll_name') h5 {{roll_name}} +templateConditionalDisplay('roll_name','invert') h5(data-i18n='roll') span.description {{roll}} So, first we check if the&nbsp; roll &nbsp;field exists, meaning that there is something other than an empty string in the field. If there is we create a template row and either display the provided roll name, or a generic "roll" header. And finally, we display the roll itself in a span. Sidebar - Using Roll templates Roll templates are part of the Roll20 chat system.&nbsp; The wiki explains &nbsp;all the ins and outs of using them, but let's do a quick review of what a message using a roll template looks like. The first part of a roll template invocation is the template call ( &amp;{template:name} ). This tells the Roll20 chat system which template to use for injecting html into the message. Only the templates that are defined in a given game's character sheet and the default template can be used in a game. After the template call, we define fields and the content for the fields by providing a field name and the field's value like so: &amp;{template:thj2e} {{name=Demo message}} The Roll20 backend will then take that message, find the thj2e roll template (the template we're designing right now) and provide the name field as an argument to the template which will then output the appropriate HTML which is then injected into the chat message. Roll Template Style As with everything on a character sheet, the HTML is only one piece of the roll template. It provides what elements will be created in the chat message, but now we need to style it. Just like with our character sheet, we'll use SCSS to generate the CSS for our template. Unlike our character sheet, roll templates operate under a very strict HTML and CSS sanitizer. This means that some of the fancier aspects of web development, like animations, aren't available to us. Additionally, there are&nbsp; some keywords &nbsp;which, if present anywhere in your CSS, will cause your styles to get thrown out and not used in chat. And, finally, every rolltemplate css declaration&nbsp; MUST &nbsp;have&nbsp; .sheet-rolltemplate- &nbsp; templatename &nbsp;(e.g.&nbsp; .sheet-rolltemplate-thj2e ) prepended to the declaration, and all non-Roll20 classes that are referenced in the CSS must be prepended with&nbsp; sheet- .&nbsp;These are all potential problems to keep in mind if your CSS or html doesn't seem to apply correctly. Most of the styling of roll templates is just like styling a character sheet, but there are three elements that we need to think about that we didn't in the sheet proper. One of the effects of this aggressive sanitization is that we can't define a default font-size in the html element like we did for the character sheet. This means, that unless we want to base all our sizes off of the default Roll20 font size of 10px, we'll want to define a font-size in our roll template, and then use the&nbsp; em &nbsp;unit to size our text. Roll templates can have hyperlinks in them, which means we need to style&nbsp; &lt;a&gt; &nbsp;tags. Additionally, ability and API command buttons are also hyperlinks themselves, so we'll want to style these specifically as well. inlinerollresults should be styled as well The SCSS for this template has avoided all those issues though, so let's take a look at some of the unique roll template styling by looking at the inline roll styling. Using SCSS' ability to nest declarations inside of each other allows us to declare the&nbsp; .sheet-rolltemplate-thj2e &nbsp;once and nest everything inside it. Additionally, we're using a wrapper div within the roll template itself to hold our content. This is to ensure that we have plenty of specificity to override the default Roll20 styles, particularly those associated with Dark Mode. The first thing that this styling does is make sure that our inline rolls are slightly larger than normal text to make sure that they pop, while still working reasonably well when used in a line of text. The SCSS declaration for the computed values ensures that when we pass a placeholder value to our template, the roll tooltip won't get displayed. This prevents useless information from showing up. Note that in order for this to work, we have to actually pass our computed rolls as&nbsp; [[0[computed value]]] &nbsp;to provide the data that the CSS needs to recognize them. And then we actually style our rolls. It may seem strange to give the same border, background-color, and padding to the four types of inline rolls, but we need to do this in order to be able to override the default Roll20 styling. And, finally, we assign some colors to our various special rolls. .sheet-rolltemplate-thj2e{ .sheet-template{ .inlinerollresult{ font-size:1.25em; &amp;[title*="[computed value]"], &amp;[original-title*="[computed value]"]{ pointer-events: none; } &amp;, &amp;.fullcrit, &amp;.fullfail, &amp;.importantroll{ border: none; background-color: transparent; padding:0; } &amp;.fullcrit{ color: var(--critColor); } &amp;.fullfail{ color: var(--fumbleColor); } &amp;.importantroll{ color: var(--importantColor); } } } } Roll Parsing Now that we've got a styled roll template, we need to actually set up the sheet to generate some rolls for us. Back when we first created the PUG for our sheet, we defined our roll buttons using a generic roll function as our listener function: +roller({name,role:'heading','aria-level':4,'data-i18n':name,trigger:{listenerFunc:'initiateRoll'}}) We'll be replacing each of those with a custom roll function appropriate to the type of roll. The types of rolls that we'll need to handle are: Saving throw or Attribute roll Attack Rolls Casting Spells The saving throw and attribute roll handler is going to be simple, and will let us take a look at how we can compute values after a roll has been made, so let's look at that code first.&nbsp; We've adjusted our roll button definition for these generic rolls to call the&nbsp; rollGeneric &nbsp;function to start the process of rolling. rollGeneric 's purpose is to assemble the details of the roll and then pass those details, which are contained within the&nbsp; rollObj , to the&nbsp; initiateRoll &nbsp;function. First we determine which of our generic rolls we are supposed to send so that we reference the correct modifier. Then we create our&nbsp; rollObj &nbsp;with keys that represent the fields we want to create, and the values that are the content that should be in those fields. Finally, we call&nbsp; initiateRoll &nbsp;which will actually send the message to chat. So, let's take a look at&nbsp; initiateRoll &nbsp;to see how we're actually using the&nbsp; Custom Roll Parsing &nbsp;feature. const rollGeneric = function(event){ const[section,id,field] = extractRollInfo(event.triggerName); const attributeRef = attributeNames.indexOf(field) &gt; -1 ? `${field}_mod` : undefined; k.getAttrs({ props:rollGet, callback: (attributes)=&gt;{ const rollObj = { name:`^{${field.replace(/_/g,' ')}}`, roll:attributeRef ? `[[@{roll_state} + 0@{${attributeRef}}]]` : `[[@{roll_state}]]` target:`[[@{saving_throw}]]`, result:`[[0[computed value]]]` }; //Send the roll. initiateRoll(attributes,rollObj); } }); }; k.registerFuncs({rollGeneric}); initiateRoll &nbsp;is an asynchronous function as denoted by the&nbsp; async &nbsp;keyword right before the function definition. This then allows us to use the&nbsp; await &nbsp;keyword later so that we can pause the function's execution while startRoll resolves. Note that&nbsp; startRoll &nbsp;is the only sheetworker function that the async/await pattern can be used with. initiateRoll &nbsp;does three things.&nbsp; 1) &nbsp;It checks the roll type that the user has set and, if it's a query, translates the query so that the user will be prompted in their chosen language.&nbsp; 2) &nbsp;The rollObj gets parsed into the full message string and waits for&nbsp; startRoll &nbsp;to parse the constructed message and return roll results. If our roll had a&nbsp; result ,&nbsp; target , and&nbsp; roll &nbsp;field that had inline rolls in them, then we also want to compute the final result of the roll (1=success; 0=failure). Based on the computed value of the result, the word failure or success will be displayed in chat. And&nbsp; 3) &nbsp;we finish the roll, which tells the chat system to actually display the result. const initiateRoll = async function(attributes,rollObj){ if(rollObj.roll){ rollObj.roll = rollObj.roll.replace(/@\{roll_state\}/,(match)=&gt;{ if(/\?\{/.test(attributes.roll_state)){ return translateAdvantageQuery(); }else{ return attributes.roll_state; } }); } const message = Object.entries(rollObj) .reduce((text,[field,content]) =&gt; { return text += `{{${field}=${content}}}`; },`@{template_start}`); const roll = await startRoll(message); const computeObj = {}; if(roll.results.result &amp;&amp; roll.results.target &amp;&amp; roll.results.roll){ computeObj.result = roll.results.roll.result &gt;= roll.results.target.result ? 1 : 0; } finishRoll(roll.rollId,computeObj); }; A Functional Sheet, now what? We've got a functioning sheet that makes rolls and could be used in a game as it is right now. This post has gone a lot longer than others, but it's a complex topic that requires a lot of moving parts.&nbsp; I do want to hit home how&nbsp; Custom Roll Parsing &nbsp;allows us to do many things that used to require an API script or extremely complicated roll template html. We won't take a look at the code in the post, but one of the things that CRP allows us to do is to modify attribute values in reaction to a roll being made. We'll utilize this ability in our spell casting function to automatically decrement the spells available when the "cast" button is clicked. If you want to see how that looks, head on over to the repository to take a look at the full code. Next week we'll be looking at what to think about when setting up an NPC view of a sheet, and then it's just finishing touches and how to release it to the Roll20 community at large. I'm looking ahead to what sorts of blog series would be interesting in the future. If you've got questions about how something on Roll20 works, come join&nbsp; the &nbsp; Kurohyou Studios Patreon &nbsp;to let me know what you'd like to see next! See the previous post &nbsp;|&nbsp; Check out the Repository &nbsp;|&nbsp;See the Next Post
1649856413
Nick Turner
Plus
Marketplace Creator
Hi Scott, quick question: Is there an easy way to have a single control (button, link, etc) set a couple of different attributes at the same time, and then call a function? For example, the user clicks on a thing, that sets attr_char_type to "npc" and sets attr_show_settings to 1 and then calls navigate_page() which runs some logic to determine which elements are shown/hidden (I suppose technically that would have to be async'd to fire after the set calls return) I could do this with custom sheetworkers every time I need this behaviour, but is there something in your scaffold that I'm missing that can easily do this kind of thing all at once without having custom code for each time it's needed?
1649857391

Edited 1649858146
Nick Turner
Plus
Marketplace Creator
Also, whilst I really hate to bring it up again, the docs on github are still kinda broken and janky in places, several sections seem to be overwriting each other (e.g. in _htmlelements.pug, you're setting a block of documentation about the navButton to `varObjects.docs.pug.navButton` on line 409, but then again on line 439 you're overwriting it with the docs for the roller), and the table of arguments for the trigger section is still not formatting very clearly at all, which makes it very tricky to figure out what it's even trying to say. I've noticed that the full text of the scaffold documentation does get bundled up into the javascript of the final character sheet, which makes the sheet file quite a lot bigger than it technically needs to be.
1649873356

Edited 1649873832
Scott C.
Forum Champion
Sheet Author
API Scripter
Compendium Curator
Hey Nick, Thank you for the reminder on the documentation issues. I've been putting out fires in other projects and neglected to get that fixed. So, for your set and navigation question, the scaffold is designed with this in mind. Because of how the Scaffold's attribute object works, you can set an attribute value on the attribute object passed to the callback in&nbsp; k.getAllAttrs &nbsp;or k.getAttrs , and then keep working with that same object to use those newly set attributes in subsequent calculations. As an example, consider this: k.getAttrs({ props:['my_attribute_1','my_attribute_2'], callback:(attributes)=&gt;{ k.log({'1':attributes.my_attribute_1,'2':attributes.my_attribute_2,npc:attributes.npc})//=&gt; 1:1 Initial text | 2:2 Initial Text |npc:undefined attributes.my_attribute_1 = 'Look, we changed things'; k.log({'1':attributes.my_attribute_1,'2':attributes.my_attribute_2,npc:attributes.npc})//=&gt; 1:Look, we changed things | 2:2 Initial Text |npc:undefined attributes.npc = 1;//Set our NPC attribute k.log({'1':attributes.my_attribute_1,'2':attributes.my_attribute_2,npc:attributes.npc})//=&gt; 1:1 Initial text | 2:2 Initial Text |npc:1 } }); The attributes object is a proxy for the regular object that always returns the new value for the attribute. You can also always access the original value by explicitly calling attributes.attribute . This means you can wait to set your attributes till you're actually done with everything instead of trying to deal with organizing the appropriate asynchronous chain. For your explicit goal, I recommend using the basic Scaffold listener and hooking the display changes into the change of the NPC attribute.&nbsp; This would allow you to do something like this: PUG +action({name:'npc preset',trigger:{listenerFunc:'applyPresets'}}) | Activate Normal NPC Presets +input-label({ label:'PC', inputObj:{name:'sheet type',type:'radio',value:'pc',checked:'',triggeredFuncs:['enableSections']} }) +input-label({ label:'NPC', inputObj:{name:'sheet type',type:'radio',value:'npc'} }) +input-label({ label:'Vehicle', inputObj:{name:'sheet type',type:'radio',value:'vehicle'} }) +input-label({ label:'Whisper', inputObj:{name:'whisper',type:'checkbox',value:'/w gm '} }) .hideable-section.npc span Here's some content that is only for NPCs .hideable-section.pc.npc span Here's some content that is for NPCs and PCs .hideable-section.vehicle span Here's some content that is for vehicles .hideable-section.pc span Here's some PC only content JS const applyPresets = function(event){ k.getAllAttrs({ callback:(attributes,sections,casc)=&gt;{ const [section,id,button] = k.parseTriggerName(trigger.name); const target = button.replace(/_preset/,'');//For our npc preset, this becomes 'npc' const targetAffected = { npc:{sheet_type:'npc',whisper:'/w gm '} }; if(targetAffected[target]){ Object.entries(targetAffected[target]).forEach(([key,value])=&gt;{ attributes[key] = value;//Change the value of our npc and whisper value. attributes.queue.push(key);//Add our changed attributes to the attributes object's array of affected attributes to work through }); } attributes.set({trigger,attributes,sections,casc});//Set our values, and pass all the information needed for the scaffold to hook into the standard triggeredFuncs, calculation funcs just as if we had used the default `accessSheet` listener. } }); }; k.registerFuncs({applyPresets}); const enableSections = function({attributes}){ $20('.hideable-section').removeClass('inactive');//Activate all of our sections const target = attributes.sheet_type; $20(`.hideable-section:not(.${target})`).addClass('inactive');//deactivate any section that is not our target }; k.registerFuncs({enableSections}); This way, our functions are only concerned with a single area of responsibility and we can then hook into them as needed from any number of starting points. The applyPresets &nbsp;function only cares about setting the values needed for the desired preset, while&nbsp; enableSections &nbsp;just cares what the value of sheet_type &nbsp;is and makes conditionally visible sections active or not based on that. EDIT &nbsp;It's important to note here, that even though we're calling attributes.set() &nbsp;in applyPresets , the actual set won't happen until after the queue that we've created is resolved. This means that setAttrs &nbsp;is not actually invoked until after enableSections &nbsp;has resolved. End Edit This is similar to how I set up the page navigation in the previous blog post and pretty much what I'm doing to set up the NPC sheet for the Hero's Journey sheet. I've actually got a Scaffold update that I'll be uploading some time in the next couple days that allows buttons to be used as triggeredFuncs, which would allow you to get rid of the k.getAllAttrs &nbsp;call and the attributes.set &nbsp;call in applyPresets , and make that function callable from anywhere as well (not just as a listener to button clicks). EDIT And, should probably mention the CSS that powers the display here as well. It's pretty simple: .hideable-section.inactive{ display: none !important; } This just makes our hideable sections hidden no matter what. You'd combine this with code like that used in the last blog post to navigate between tabs to explicitly show/hide individual sections.